From 5a3917c999e770499374db3466cd779cad27dee8 Mon Sep 17 00:00:00 2001 From: "www.xueximeng.com" Date: Tue, 15 Jul 2025 00:03:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=82=E6=AD=A5=E6=8F=92=E4=BB=B6=E7=BC=93?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 36 ++ .github/workflows/dock_ci.yml | 53 -- .github/workflows/docker_ci.yml | 74 +++ Dockerfile | 66 +- README.md | 52 +- api/handler.go | 27 +- config/config.go | 78 ++- docker-compose.yml | 50 ++ docs/1-项目总体架构设计.md | 172 +++++ docs/2-API层设计.md | 391 ++++++++++++ docs/3-服务层设计.md | 696 ++++++++++++++++++++ docs/4-插件系统设计.md | 370 +++++++++++ docs/5-缓存系统设计.md | 946 +++++++++++++++++++++++++++ docs/docker部署(未验证).md | 35 + docs/插件开发指南.md | 578 ++++++++++++++++- main.go | 64 +- plugin/baseasyncplugin.go | 802 +++++++++++++++++++++++ plugin/hunhepan/hunhepan.go | 140 +--- plugin/jikepan/jikepan.go | 75 ++- plugin/pan666/pan666.go | 1057 +++++++++++++++---------------- plugin/pansearch/pansearch.go | 92 ++- plugin/panta/panta.go | 2 +- plugin/qupansou/qupansou.go | 93 ++- service/search_service.go | 76 ++- util/cache/cache_key.go | 271 +++++++- util/cache/disk_cache.go | 3 +- util/cache/two_level_cache.go | 11 +- util/cache/utils.go | 47 ++ 28 files changed, 5529 insertions(+), 828 deletions(-) create mode 100644 .dockerignore delete mode 100644 .github/workflows/dock_ci.yml create mode 100644 .github/workflows/docker_ci.yml create mode 100644 docker-compose.yml create mode 100644 docs/1-项目总体架构设计.md create mode 100644 docs/2-API层设计.md create mode 100644 docs/3-服务层设计.md create mode 100644 docs/4-插件系统设计.md create mode 100644 docs/5-缓存系统设计.md create mode 100644 docs/docker部署(未验证).md create mode 100644 plugin/baseasyncplugin.go create mode 100644 util/cache/utils.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cf436f6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +# Git相关 +.git +.gitignore +.github + +# 文档和其他非必要文件 +README.md +docs/ +*.md +LICENSE + +# 开发和测试相关 +*_test.go +*.test +*.out +*.prof + +# 构建产物 +pansou +pansou_* +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# 缓存和临时文件 +.DS_Store +cache/ +tmp/ +.idea/ +.vscode/ + +# 其他 +Dockerfile +.dockerignore diff --git a/.github/workflows/dock_ci.yml b/.github/workflows/dock_ci.yml deleted file mode 100644 index 95f2767..0000000 --- a/.github/workflows/dock_ci.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Build Docker Image - -on: - push: - branches: - - "master" - paths-ignore: - - "README.md" - workflow_dispatch: - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GHCR_PAT }} - - - name: Set GHCR_REPO and GHCR_OWNER env - run: | - echo "GHCR_OWNER=${{ github.repository_owner }}" >> $GITHUB_ENV - echo "GHCR_REPO=ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}" >> $GITHUB_ENV - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v3 - with: - images: ${{ env.GHCR_REPO }} - - - name: Build and push to GHCR - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64,linux/arm64 - file: Dockerfile - push: true - tags: ${{ env.GHCR_REPO }}:latest - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - diff --git a/.github/workflows/docker_ci.yml b/.github/workflows/docker_ci.yml new file mode 100644 index 0000000..70643c9 --- /dev/null +++ b/.github/workflows/docker_ci.yml @@ -0,0 +1,74 @@ +name: 构建并发布Docker镜像 + +on: + push: + branches: + - "main" + - "master" + tags: + - "v*.*.*" + paths-ignore: + - "README.md" + - "docs/**" + pull_request: + branches: + - "main" + - "master" + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 设置QEMU + uses: docker/setup-qemu-action@v3 + + - name: 设置Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + buildkitd-flags: --debug + + - name: 登录到GitHub容器注册表 + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 提取Docker元数据 + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/pansou + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - name: 构建并推送Docker镜像 + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BUILD_DATE=${{ github.event.repository.updated_at }} + VCS_REF=${{ github.sha }} + VERSION=${{ steps.meta.outputs.version }} diff --git a/Dockerfile b/Dockerfile index eba834c..1787bc5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,71 @@ -FROM golang:1.23.0 AS builder +# 构建阶段 +FROM golang:1.22-alpine AS builder + +# 安装构建依赖 +RUN apk add --no-cache git ca-certificates tzdata + +# 设置工作目录 WORKDIR /app +# 复制依赖文件 COPY go.mod go.sum ./ + +# 下载依赖 RUN go mod download +# 复制源代码 COPY . . -RUN CGO_ENABLED=0 go build -o pansou main.go +# 构建参数 +ARG VERSION=dev +ARG BUILD_DATE=unknown +ARG VCS_REF=unknown -FROM scratch -COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +# 构建应用 +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=${VERSION} -X main.BuildDate=${BUILD_DATE} -X main.GitCommit=${VCS_REF} -extldflags '-static'" -o pansou . + +# 运行阶段 +FROM alpine:3.19 + +# 添加运行时依赖 +RUN apk add --no-cache ca-certificates tzdata + +# 创建缓存目录 +RUN mkdir -p /app/cache + +# 从构建阶段复制可执行文件 COPY --from=builder /app/pansou /app/pansou -EXPOSE 8080 +# 设置工作目录 +WORKDIR /app -CMD ["/app/pansou"] +# 暴露端口 +EXPOSE 8888 + +# 设置环境变量 +ENV CACHE_PATH=/app/cache \ + CACHE_ENABLED=true \ + TZ=Asia/Shanghai \ + ASYNC_PLUGIN_ENABLED=true \ + ASYNC_RESPONSE_TIMEOUT=4 \ + ASYNC_MAX_BACKGROUND_WORKERS=20 \ + ASYNC_MAX_BACKGROUND_TASKS=100 \ + ASYNC_CACHE_TTL_HOURS=1 + +# 构建参数 +ARG VERSION=dev +ARG BUILD_DATE=unknown +ARG VCS_REF=unknown + +# 添加镜像标签 +LABEL org.opencontainers.image.title="PanSou" \ + org.opencontainers.image.description="高性能网盘资源搜索API服务" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.revision="${VCS_REF}" \ + org.opencontainers.image.url="https://github.com/fish2018/pansou" \ + org.opencontainers.image.source="https://github.com/fish2018/pansou" \ + maintainer="fish2018" + +# 运行应用 +CMD ["/app/pansou"] \ No newline at end of file diff --git a/README.md b/README.md index 46d048d..7fa6b7b 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,11 @@ PanSou是一个高性能的网盘资源搜索API服务,支持TG搜索和网盘 ## 特性 -- **高性能搜索**:并发搜索多个Telegram频道,显著提升搜索速度 -- **智能排序**:基于时间和关键词权重的多级排序策略 +- **高性能搜索**:并发搜索多个Telegram频道,显著提升搜索速度;工作池设计,高效管理并发任务 - **网盘类型分类**:自动识别多种网盘链接,按类型归类展示 -- **两级缓存**:内存+磁盘缓存机制,大幅提升重复查询速度 -- **高并发支持**:工作池设计,高效管理并发任务 -- **灵活扩展**:易于支持新的网盘类型和数据来源 -- **插件系统**:支持通过插件扩展搜索来源,已内置多个网盘搜索插件 +- **智能排序**:基于时间和关键词权重的多级排序策略 +- **插件系统**:支持通过插件扩展搜索来源,已内置多个网盘搜索插件;支持"尽快响应,持续处理"的异步搜索模式 +- **两级缓存**:内存+磁盘缓存机制,大幅提升重复查询速度;异步插件缓存自动保存到磁盘,系统重启后自动恢复, ## 支持的网盘类型 @@ -32,14 +30,12 @@ PanSou是一个高性能的网盘资源搜索API服务,支持TG搜索和网盘 PanSou内置了多个网盘搜索插件,可以扩展搜索来源 -## 快速开始 - ### 环境要求 - Go 1.18+ - 可选:SOCKS5代理(用于访问受限地区的Telegram站点) -### 安装 +### 从源码安装 1. 克隆仓库 @@ -60,6 +56,13 @@ export CACHE_PATH="./cache" export CACHE_MAX_SIZE=100 # MB export CACHE_TTL=60 # 分钟 +# 异步插件配置 +export ASYNC_PLUGIN_ENABLED=true +export ASYNC_RESPONSE_TIMEOUT=2 # 响应超时时间(秒) +export ASYNC_MAX_BACKGROUND_WORKERS=20 # 最大后台工作者数量 +export ASYNC_MAX_BACKGROUND_TASKS=100 # 最大后台任务数量 +export ASYNC_CACHE_TTL_HOURS=1 # 异步缓存有效期(小时) + # 代理配置(如需) export PROXY="socks5://127.0.0.1:7890" ``` @@ -203,7 +206,7 @@ GET /api/search?kw=速度与激情&channels=tgsearchers2,xxx&conc=2&refresh=true | 环境变量 | 描述 | 默认值 | |----------|------|--------| | CHANNELS | 默认搜索频道列表(逗号分隔) | tgsearchers2 | -| CONCURRENCY | 默认并发数 | 频道数+10 | +| CONCURRENCY | 默认并发数 | 频道数+插件数+10 | | PORT | 服务端口 | 8080 | | PROXY | SOCKS5代理 | - | | CACHE_ENABLED | 是否启用缓存 | true | @@ -215,6 +218,11 @@ GET /api/search?kw=速度与激情&channels=tgsearchers2,xxx&conc=2&refresh=true | GC_PERCENT | GC触发百分比 | 100 | | OPTIMIZE_MEMORY | 是否优化内存 | true | | PLUGIN_TIMEOUT | 插件执行超时时间(秒) | 30 | +| ASYNC_PLUGIN_ENABLED | 是否启用异步插件 | true | +| ASYNC_RESPONSE_TIMEOUT | 异步响应超时时间(秒) | 4 | +| ASYNC_MAX_BACKGROUND_WORKERS | 最大后台工作者数量 | 20 | +| ASYNC_MAX_BACKGROUND_TASKS | 最大后台任务数量 | 100 | +| ASYNC_CACHE_TTL_HOURS | 异步缓存有效期(小时) | 1 | ## 性能优化 @@ -226,6 +234,29 @@ PanSou 实现了多项性能优化技术: 4. **HTTP客户端优化**:连接池、HTTP/2支持 5. **并发优化**:工作池、智能并发控制 6. **传输压缩**:支持 gzip 压缩 +7. **异步插件缓存**:持久化缓存、即时保存、优雅关闭机制 + +## 异步插件系统 + +PanSou实现了高级异步插件系统,解决了某些搜索源响应时间长的问题: + +### 异步插件特性 + +- **双级超时控制**:短超时(2秒)确保快速响应,长超时(30秒)允许完整处理 +- **持久化缓存**:缓存自动保存到磁盘,系统重启后自动恢复 +- **即时保存**:缓存更新后立即触发保存,不再等待定时器 +- **优雅关闭**:在程序退出前保存缓存,确保数据不丢失 +- **增量更新**:智能合并新旧结果,保留有价值的数据 +- **后台自动刷新**:对于接近过期的缓存,在后台自动刷新 +- **资源管理**:通过工作池控制并发任务数量,避免资源耗尽 + +### 异步插件工作流程 + +1. **缓存检查**:首先检查是否有有效缓存 +2. **快速响应**:如果有缓存,立即返回;如果缓存接近过期,在后台刷新 +3. **双通道处理**:如果没有缓存,启动快速响应通道和后台处理通道 +4. **超时控制**:在响应超时时返回当前结果(可能为空),后台继续处理 +5. **缓存更新**:后台处理完成后更新缓存,供后续查询使用 ## 插件系统 @@ -240,6 +271,7 @@ PanSou 实现了灵活的插件系统,允许轻松扩展搜索来源 - **双层超时控制**:插件内部使用自定义超时时间,系统外部提供强制超时保障 - **并发执行**:插件搜索与频道搜索并发执行,提高整体性能 - **结果标准化**:插件返回标准化的搜索结果,便于统一处理 +- **异步处理**:支持异步插件,实现"尽快响应,持续处理"的模式 ### 开发自定义插件 diff --git a/api/handler.go b/api/handler.go index 02795cf..94e1de9 100644 --- a/api/handler.go +++ b/api/handler.go @@ -71,17 +71,23 @@ func SearchHandler(c *gin.Context) { } // 处理plugins参数,支持逗号分隔 - pluginsStr := c.Query("plugins") var plugins []string - // 只有当参数非空时才处理 - if pluginsStr != "" && pluginsStr != " " { - parts := strings.Split(pluginsStr, ",") - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed != "" { - plugins = append(plugins, trimmed) + // 检查请求中是否存在plugins参数 + if c.Request.URL.Query().Has("plugins") { + pluginsStr := c.Query("plugins") + // 判断参数是否非空 + if pluginsStr != "" && pluginsStr != " " { + parts := strings.Split(pluginsStr, ",") + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + plugins = append(plugins, trimmed) + } } } + } else { + // 如果请求中不存在plugins参数,设置为nil + plugins = nil } req = model.SearchRequest{ @@ -130,6 +136,11 @@ func SearchHandler(c *gin.Context) { req.Plugins = nil // 忽略plugins参数 } else if req.SourceType == "plugin" { req.Channels = nil // 忽略channels参数 + } else if req.SourceType == "all" { + // 对于all类型,如果plugins为空或不存在,统一设为nil + if req.Plugins == nil || len(req.Plugins) == 0 { + req.Plugins = nil + } } // 执行搜索 diff --git a/config/config.go b/config/config.go index 1c26838..4027d70 100644 --- a/config/config.go +++ b/config/config.go @@ -30,6 +30,13 @@ type Config struct { // 插件相关配置 PluginTimeoutSeconds int // 插件超时时间(秒) PluginTimeout time.Duration // 插件超时时间(Duration) + // 异步插件相关配置 + AsyncPluginEnabled bool // 是否启用异步插件 + AsyncResponseTimeout int // 响应超时时间(秒) + AsyncResponseTimeoutDur time.Duration // 响应超时时间(Duration) + AsyncMaxBackgroundWorkers int // 最大后台工作者数量 + AsyncMaxBackgroundTasks int // 最大后台任务数量 + AsyncCacheTTLHours int // 异步缓存有效期(小时) } // 全局配置实例 @@ -39,6 +46,7 @@ var AppConfig *Config func Init() { proxyURL := getProxyURL() pluginTimeoutSeconds := getPluginTimeout() + asyncResponseTimeoutSeconds := getAsyncResponseTimeout() AppConfig = &Config{ DefaultChannels: getDefaultChannels(), @@ -60,6 +68,13 @@ func Init() { // 插件相关配置 PluginTimeoutSeconds: pluginTimeoutSeconds, PluginTimeout: time.Duration(pluginTimeoutSeconds) * time.Second, + // 异步插件相关配置 + AsyncPluginEnabled: getAsyncPluginEnabled(), + AsyncResponseTimeout: asyncResponseTimeoutSeconds, + AsyncResponseTimeoutDur: time.Duration(asyncResponseTimeoutSeconds) * time.Second, + AsyncMaxBackgroundWorkers: getAsyncMaxBackgroundWorkers(), + AsyncMaxBackgroundTasks: getAsyncMaxBackgroundTasks(), + AsyncCacheTTLHours: getAsyncCacheTTLHours(), } // 应用GC配置 @@ -92,7 +107,7 @@ func getDefaultConcurrency() int { func getPort() string { port := os.Getenv("PORT") if port == "" { - return "8080" + return "8888" } return port } @@ -208,6 +223,67 @@ func getPluginTimeout() int { return timeout } +// 从环境变量获取是否启用异步插件,如果未设置则默认启用 +func getAsyncPluginEnabled() bool { + enabled := os.Getenv("ASYNC_PLUGIN_ENABLED") + if enabled == "" { + return true // 默认启用 + } + return enabled != "false" && enabled != "0" +} + +// 从环境变量获取异步响应超时时间(秒),如果未设置则使用默认值 +func getAsyncResponseTimeout() int { + timeoutEnv := os.Getenv("ASYNC_RESPONSE_TIMEOUT") + if timeoutEnv == "" { + return 4 // 默认4秒 + } + timeout, err := strconv.Atoi(timeoutEnv) + if err != nil || timeout <= 0 { + return 4 + } + return timeout +} + +// 从环境变量获取最大后台工作者数量,如果未设置则使用默认值 +func getAsyncMaxBackgroundWorkers() int { + sizeEnv := os.Getenv("ASYNC_MAX_BACKGROUND_WORKERS") + if sizeEnv == "" { + return 20 // 默认20个工作者 + } + size, err := strconv.Atoi(sizeEnv) + if err != nil || size <= 0 { + return 20 + } + return size +} + +// 从环境变量获取最大后台任务数量,如果未设置则使用默认值 +func getAsyncMaxBackgroundTasks() int { + sizeEnv := os.Getenv("ASYNC_MAX_BACKGROUND_TASKS") + if sizeEnv == "" { + return 100 // 默认100个任务 + } + size, err := strconv.Atoi(sizeEnv) + if err != nil || size <= 0 { + return 100 + } + return size +} + +// 从环境变量获取异步缓存有效期(小时),如果未设置则使用默认值 +func getAsyncCacheTTLHours() int { + ttlEnv := os.Getenv("ASYNC_CACHE_TTL_HOURS") + if ttlEnv == "" { + return 1 // 默认1小时 + } + ttl, err := strconv.Atoi(ttlEnv) + if err != nil || ttl <= 0 { + return 1 + } + return ttl +} + // 应用GC设置 func applyGCSettings() { // 设置GC百分比 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3a0d349 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +version: '3.8' + +services: + pansou: + image: ghcr.io/fish2018/pansou:latest + container_name: pansou + restart: unless-stopped + ports: + - "8888:8888" + environment: + - PORT=8888 + - CHANNELS=tgsearchers2,SharePanBaidu,yunpanxunlei,tianyifc,BaiduCloudDisk + - CACHE_ENABLED=true + - CACHE_PATH=/app/cache + - CACHE_MAX_SIZE=100 + - CACHE_TTL=60 + - ASYNC_PLUGIN_ENABLED=true + - ASYNC_RESPONSE_TIMEOUT=4 + - ASYNC_MAX_BACKGROUND_WORKERS=20 + - ASYNC_MAX_BACKGROUND_TASKS=100 + - ASYNC_CACHE_TTL_HOURS=1 + # 如果需要代理,取消下面的注释并设置代理地址 + # - PROXY=socks5://proxy:7897 + volumes: + - pansou-cache:/app/cache + networks: + - pansou-network + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8888/api/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + # 如果需要代理,取消下面的注释 + # proxy: + # image: ghcr.io/snail007/goproxy:latest + # container_name: pansou-proxy + # restart: unless-stopped + # command: /proxy socks -p :7897 + # networks: + # - pansou-network + +volumes: + pansou-cache: + name: pansou-cache + +networks: + pansou-network: + name: pansou-network \ No newline at end of file diff --git a/docs/1-项目总体架构设计.md b/docs/1-项目总体架构设计.md new file mode 100644 index 0000000..cd84880 --- /dev/null +++ b/docs/1-项目总体架构设计.md @@ -0,0 +1,172 @@ +# PanSou 项目总体架构设计 + +## 1. 项目概述 + +PanSou是一个高性能的网盘资源搜索API服务,支持Telegram搜索和多种网盘搜索引擎。系统设计以性能和可扩展性为核心,支持多频道并发搜索、结果智能排序和网盘类型分类。 + +### 1.1 核心功能 + +- **多源搜索**:支持Telegram频道和多种网盘搜索引擎 +- **高性能并发**:通过工作池实现高效并发搜索 +- **智能排序**:基于时间和关键词权重的多级排序策略 +- **网盘类型分类**:自动识别多种网盘链接,按类型归类展示 +- **两级缓存**:内存+磁盘缓存机制,大幅提升重复查询速度 +- **插件系统**:支持通过插件扩展搜索来源 +- **缓存键一致性**:优化的缓存键生成算法,确保相同语义查询的缓存命中 + +### 1.2 技术栈 + +- **编程语言**:Go +- **Web框架**:Gin +- **缓存**:自定义两级缓存(内存+磁盘) +- **JSON处理**:sonic(高性能JSON库) +- **并发控制**:工作池模式 +- **HTTP客户端**:自定义优化的HTTP客户端 + +## 2. 系统架构 + +### 2.1 整体架构 + +PanSou采用模块化的分层架构设计,主要包括以下几个层次: + +``` +┌─────────────────────────┐ +│ API 层 │ +│ (路由、处理器、中间件) │ +└───────────┬─────────────┘ + │ +┌───────────▼─────────────┐ +│ 服务层 │ +│ (搜索服务、缓存) │ +└───────────┬─────────────┘ + │ +┌───────────▼─────────────┐ ┌─────────────────┐ +│ 插件系统 │◄───┤ 插件注册表 │ +│ (搜索插件、插件管理器) │ └─────────────────┘ +└───────────┬─────────────┘ + │ +┌───────────▼─────────────┐ +│ 工具层 │ +│ (缓存、HTTP客户端、工作池)│ +└─────────────────────────┘ +``` + +### 2.2 模块职责 + +#### 2.2.1 API层 + +- **路由管理**:定义API端点和路由规则 +- **请求处理**:处理HTTP请求,参数解析和验证 +- **响应生成**:生成标准化的JSON响应 +- **中间件**:跨域处理、日志记录、压缩等 +- **参数规范化**:统一处理不同形式但语义相同的参数 + +#### 2.2.2 服务层 + +- **搜索服务**:整合插件和Telegram搜索结果 +- **结果处理**:过滤、排序和分类搜索结果 +- **缓存管理**:管理搜索结果的缓存策略 +- **缓存键生成**:基于所有影响结果的参数生成一致的缓存键 + +#### 2.2.3 插件系统 + +- **插件接口**:定义统一的搜索插件接口 +- **插件管理**:管理插件的注册、获取和调用 +- **自动注册**:通过init函数实现插件自动注册 +- **高性能JSON处理**:使用sonic库优化JSON序列化/反序列化 + +#### 2.2.4 工具层 + +- **缓存工具**:两级缓存实现(内存+磁盘) +- **HTTP客户端**:优化的HTTP客户端,支持代理 +- **工作池**:并发任务执行的工作池 +- **JSON工具**:高性能JSON处理工具 + +### 2.3 数据流 + +1. **请求接收**:API层接收搜索请求 +2. **参数处理**:解析、验证和规范化请求参数 +3. **缓存键生成**:基于所有影响结果的参数生成一致的缓存键 +4. **缓存检查**:检查是否有缓存结果 +5. **并发搜索**:如无缓存,并发执行搜索任务 +6. **结果处理**:过滤、排序和分类搜索结果 +7. **缓存存储**:将结果存入缓存 +8. **响应返回**:返回处理后的结果 + +## 3. 核心设计思想 + +### 3.1 高性能设计 + +- **并发搜索**:使用工作池模式实现高效并发 +- **两级缓存**:内存缓存提供快速访问,磁盘缓存提供持久存储 +- **异步操作**:非关键路径使用异步处理 +- **内存优化**:预分配策略、对象池化、GC参数优化 +- **高效JSON处理**:使用sonic库替代标准库,提升序列化性能 + +### 3.2 可扩展性设计 + +- **插件系统**:通过统一接口和自动注册机制实现可扩展 +- **模块化**:清晰的模块边界和职责划分 +- **配置驱动**:通过环境变量实现灵活配置 + +### 3.3 可靠性设计 + +- **超时控制**:搜索操作有严格的超时限制 +- **错误处理**:全面的错误捕获和处理 +- **优雅降级**:单个搜索源失败不影响整体结果 +- **缓存一致性**:确保相同语义的查询使用相同的缓存键 + +## 4. 代码组织结构 + +``` +pansou/ +├── api/ # API层 +│ ├── handler.go # 请求处理器 +│ ├── middleware.go # 中间件 +│ └── router.go # 路由定义 +├── cache/ # 缓存数据存储目录 +├── config/ # 配置管理 +│ └── config.go # 配置定义和加载 +├── docs/ # 文档 +│ ├── 1-项目总体架构设计.md # 总体架构文档 +│ ├── 2-API层设计.md # API层设计文档 +│ ├── 3-服务层设计.md # 服务层设计文档 +│ ├── 4-插件系统设计.md # 插件系统设计文档 +│ ├── 5-缓存系统设计.md # 缓存系统设计文档 +│ └── 插件开发指南.md # 插件开发指南 +├── model/ # 数据模型 +│ ├── request.go # 请求模型 +│ └── response.go # 响应模型 +├── plugin/ # 插件系统 +│ ├── plugin.go # 插件接口和管理 +│ ├── jikepan/ # 即刻盘插件 +│ ├── hunhepan/ # 混合盘插件 +│ ├── pansearch/ # 盘搜插件 +│ ├── qupansou/ # 趣盘搜插件 +│ ├── pan666/ # 盘666插件 +│ └── panta/ # PanTa插件 +├── service/ # 服务层 +│ └── search_service.go # 搜索服务 +├── util/ # 工具层 +│ ├── cache/ # 缓存工具 +│ │ ├── cache_key.go # 优化的缓存键生成 +│ │ ├── cache_key_test.go # 缓存键生成测试 +│ │ ├── disk_cache.go # 磁盘缓存 +│ │ ├── two_level_cache.go # 两级缓存 +│ │ ├── utils.go # 缓存工具函数 +│ │ └── utils_test.go # 缓存工具测试 +│ ├── compression.go # 压缩工具 +│ ├── convert.go # 类型转换工具 +│ ├── http_util.go # HTTP工具函数 +│ ├── json/ # 高性能JSON工具 +│ │ └── json.go # 基于sonic的JSON处理封装 +│ ├── parser_util.go # 解析工具 +│ ├── pool/ # 工作池 +│ │ ├── object_pool.go # 对象池 +│ │ └── worker_pool.go # 工作池实现 +│ └── regex_util.go # 正则表达式工具 +├── go.mod # Go模块定义 +├── go.sum # 依赖版本锁定 +├── main.go # 程序入口 +└── README.md # 项目说明 +``` diff --git a/docs/2-API层设计.md b/docs/2-API层设计.md new file mode 100644 index 0000000..33487b1 --- /dev/null +++ b/docs/2-API层设计.md @@ -0,0 +1,391 @@ +# PanSou API层设计详解 + +## 1. API层概述 + +API层是PanSou系统的外部接口层,负责处理来自客户端的HTTP请求,并返回适当的响应。该层采用Gin框架实现,主要包含路由定义、请求处理和中间件三个核心部分。 + +## 2. 目录结构 + +``` +pansou/api/ +├── handler.go # 请求处理器 +├── middleware.go # 中间件 +└── router.go # 路由定义 +``` + +## 3. 路由设计 + +### 3.1 路由定义(router.go) + +路由模块负责定义API端点和路由规则,将请求映射到相应的处理函数。 + +```go +// SetupRouter 设置路由 +func SetupRouter(searchService *service.SearchService) *gin.Engine { + // 设置搜索服务 + SetSearchService(searchService) + + // 设置为生产模式 + gin.SetMode(gin.ReleaseMode) + + // 创建默认路由 + r := gin.Default() + + // 添加中间件 + r.Use(CORSMiddleware()) + r.Use(LoggerMiddleware()) + r.Use(util.GzipMiddleware()) // 添加压缩中间件 + + // 定义API路由组 + api := r.Group("/api") + { + // 搜索接口 - 支持POST和GET两种方式 + api.POST("/search", SearchHandler) + api.GET("/search", SearchHandler) // 添加GET方式支持 + + // 健康检查接口 + api.GET("/health", func(c *gin.Context) { + pluginCount := 0 + if searchService != nil && searchService.GetPluginManager() != nil { + pluginCount = len(searchService.GetPluginManager().GetPlugins()) + } + + c.JSON(200, gin.H{ + "status": "ok", + "plugins_enabled": true, + "plugin_count": pluginCount, + }) + }) + } + + return r +} +``` + +### 3.2 路由设计思想 + +1. **RESTful API设计**:采用RESTful风格设计API,使用适当的HTTP方法和路径 +2. **路由分组**:使用路由组对API进行分类管理 +3. **灵活的请求方式**:搜索接口同时支持GET和POST请求,满足不同场景需求 +4. **健康检查**:提供健康检查接口,便于监控系统状态 + +## 4. 请求处理器 + +### 4.1 处理器实现(handler.go) + +处理器模块负责处理具体的业务逻辑,包括参数解析、验证、调用服务层和返回响应。 + +```go +// SearchHandler 搜索处理函数 +func SearchHandler(c *gin.Context) { + var req model.SearchRequest + var err error + + // 根据请求方法不同处理参数 + if c.Request.Method == http.MethodGet { + // GET方式:从URL参数获取 + // 获取keyword,必填参数 + keyword := c.Query("kw") + + // 处理channels参数,支持逗号分隔 + channelsStr := c.Query("channels") + var channels []string + // 只有当参数非空时才处理 + if channelsStr != "" && channelsStr != " " { + parts := strings.Split(channelsStr, ",") + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + channels = append(channels, trimmed) + } + } + } + + // 处理并发数 + concurrency := 0 + concStr := c.Query("conc") + if concStr != "" && concStr != " " { + concurrency = util.StringToInt(concStr) + } + + // 处理强制刷新 + forceRefresh := false + refreshStr := c.Query("refresh") + if refreshStr != "" && refreshStr != " " && refreshStr == "true" { + forceRefresh = true + } + + // 处理结果类型和来源类型 + resultType := c.Query("res") + if resultType == "" || resultType == " " { + resultType = "" // 使用默认值 + } + + sourceType := c.Query("src") + if sourceType == "" || sourceType == " " { + sourceType = "" // 使用默认值 + } + + // 处理plugins参数,支持逗号分隔 + pluginsStr := c.Query("plugins") + var plugins []string + // 只有当参数非空时才处理 + if pluginsStr != "" && pluginsStr != " " { + parts := strings.Split(pluginsStr, ",") + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + plugins = append(plugins, trimmed) + } + } + } + + req = model.SearchRequest{ + Keyword: keyword, + Channels: channels, + Concurrency: concurrency, + ForceRefresh: forceRefresh, + ResultType: resultType, + SourceType: sourceType, + Plugins: plugins, + } + } else { + // POST方式:从请求体获取 + data, err := c.GetRawData() + if err != nil { + c.JSON(http.StatusBadRequest, model.NewErrorResponse(400, "读取请求数据失败: "+err.Error())) + return + } + + if err := jsonutil.Unmarshal(data, &req); err != nil { + c.JSON(http.StatusBadRequest, model.NewErrorResponse(400, "无效的请求参数: "+err.Error())) + return + } + } + + // 检查并设置默认值 + if len(req.Channels) == 0 { + req.Channels = config.AppConfig.DefaultChannels + } + + // 如果未指定结果类型,默认返回merge + if req.ResultType == "" { + req.ResultType = "merge" + } else if req.ResultType == "merge" { + // 将merge转换为merged_by_type,以兼容内部处理 + req.ResultType = "merged_by_type" + } + + // 如果未指定数据来源类型,默认为全部 + if req.SourceType == "" { + req.SourceType = "all" + } + + // 参数互斥逻辑:当src=tg时忽略plugins参数,当src=plugin时忽略channels参数 + if req.SourceType == "tg" { + req.Plugins = nil // 忽略plugins参数 + } else if req.SourceType == "plugin" { + req.Channels = nil // 忽略channels参数 + } + + // 执行搜索 + result, err := searchService.Search(req.Keyword, req.Channels, req.Concurrency, req.ForceRefresh, req.ResultType, req.SourceType, req.Plugins) + + if err != nil { + response := model.NewErrorResponse(500, "搜索失败: "+err.Error()) + jsonData, _ := jsonutil.Marshal(response) + c.Data(http.StatusInternalServerError, "application/json", jsonData) + return + } + + // 返回结果 + response := model.NewSuccessResponse(result) + jsonData, _ := jsonutil.Marshal(response) + c.Data(http.StatusOK, "application/json", jsonData) +} +``` + +### 4.2 处理器设计思想 + +1. **多种请求方式支持**:同时支持GET和POST请求,并针对不同请求方式采用不同的参数解析策略 +2. **参数规范化**:对输入参数进行清理和规范化处理,确保不同形式但语义相同的参数能够生成一致的缓存键 +3. **默认值处理**:为未提供的参数设置合理的默认值 +4. **参数互斥逻辑**:实现参数间的互斥关系,避免冲突 +5. **统一响应格式**:使用标准化的响应格式,包括成功和错误响应 +6. **高性能JSON处理**:使用优化的JSON库处理请求和响应 +7. **缓存一致性支持**:通过参数处理确保相同语义的查询能够命中相同的缓存 + +## 5. 中间件设计 + +### 5.1 中间件实现(middleware.go) + +中间件模块提供了跨域处理、日志记录等功能,用于处理请求前后的通用逻辑。 + +```go +// CORSMiddleware 跨域中间件 +func CORSMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + } +} + +// LoggerMiddleware 日志中间件 +func LoggerMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 开始时间 + startTime := time.Now() + + // 处理请求 + c.Next() + + // 结束时间 + endTime := time.Now() + + // 执行时间 + latencyTime := endTime.Sub(startTime) + + // 请求方式 + reqMethod := c.Request.Method + + // 请求路由 + reqURI := c.Request.RequestURI + + // 状态码 + statusCode := c.Writer.Status() + + // 请求IP + clientIP := c.ClientIP() + + // 日志格式 + gin.DefaultWriter.Write([]byte( + fmt.Sprintf("| %s | %s | %s | %d | %s\n", + clientIP, reqMethod, reqURI, statusCode, latencyTime.String()))) + } +} +``` + +### 5.2 中间件设计思想 + +1. **关注点分离**:将通用功能抽象为中间件,与业务逻辑分离 +2. **链式处理**:中间件可以按顺序组合,形成处理管道 +3. **前置/后置处理**:支持在请求处理前后执行逻辑 +4. **性能监控**:通过日志中间件记录请求处理时间,便于性能分析 + +## 6. API接口规范 + +### 6.1 搜索API + +**接口地址**:`/api/search` +**请求方法**:`POST` 或 `GET` +**Content-Type**:`application/json`(POST方法) + +#### POST请求参数 + +| 参数名 | 类型 | 必填 | 描述 | +|--------|------|------|------| +| kw | string | 是 | 搜索关键词 | +| channels | string[] | 否 | 搜索的频道列表,不提供则使用默认配置 | +| conc | number | 否 | 并发搜索数量,不提供则自动设置为频道数+插件数+10 | +| refresh | boolean | 否 | 强制刷新,不使用缓存,便于调试和获取最新数据 | +| res | string | 否 | 结果类型:all(返回所有结果)、results(仅返回results)、merge(仅返回merged_by_type),默认为merge | +| src | string | 否 | 数据来源类型:all(默认,全部来源)、tg(仅Telegram)、plugin(仅插件) | +| plugins | string[] | 否 | 指定搜索的插件列表,不指定则搜索全部插件 | + +#### GET请求参数 + +| 参数名 | 类型 | 必填 | 描述 | +|--------|------|------|------| +| kw | string | 是 | 搜索关键词 | +| channels | string | 否 | 搜索的频道列表,使用英文逗号分隔多个频道,不提供则使用默认配置 | +| conc | number | 否 | 并发搜索数量,不提供则自动设置为频道数+插件数+10 | +| refresh | boolean | 否 | 强制刷新,设置为"true"表示不使用缓存 | +| res | string | 否 | 结果类型:all(返回所有结果)、results(仅返回results)、merge(仅返回merged_by_type),默认为merge | +| src | string | 否 | 数据来源类型:all(默认,全部来源)、tg(仅Telegram)、plugin(仅插件) | +| plugins | string | 否 | 指定搜索的插件列表,使用英文逗号分隔多个插件名,不指定则搜索全部插件 | + +#### 成功响应 + +```json +{ + "code": 0, + "message": "success", + "data": { + "total": 15, + "results": [ + { + "message_id": "12345", + "unique_id": "channel-12345", + "channel": "tgsearchers2", + "datetime": "2023-06-10T14:23:45Z", + "title": "速度与激情全集1-10", + "content": "速度与激情系列全集,1080P高清...", + "links": [ + { + "type": "baidu", + "url": "https://pan.baidu.com/s/1abcdef", + "password": "1234" + } + ], + "tags": ["电影", "合集"] + }, + // 更多结果... + ], + "merged_by_type": { + "baidu": [ + { + "url": "https://pan.baidu.com/s/1abcdef", + "password": "1234", + "note": "速度与激情全集1-10", + "datetime": "2023-06-10T14:23:45Z" + }, + // 更多百度网盘链接... + ], + "aliyun": [ + // 阿里云盘链接... + ] + // 更多网盘类型... + } + } +} +``` + +#### 错误响应 + +```json +{ + "code": 400, + "message": "关键词不能为空" +} +``` + +### 6.2 健康检查API + +**接口地址**:`/api/health` +**请求方法**:`GET` + +#### 成功响应 + +```json +{ + "status": "ok", + "plugins_enabled": true, + "plugin_count": 6 +} +``` + +## 7. 性能优化措施 + +1. **高效参数处理**:对GET请求参数进行高效处理,避免不必要的字符串操作 +2. **高性能JSON库**:使用sonic高性能JSON库处理请求和响应 +3. **响应压缩**:通过GzipMiddleware实现响应压缩,减少传输数据量 +4. **避免内存分配**:合理使用预分配和对象池,减少内存分配和GC压力 +5. **直接写入响应体**:使用`c.Data`直接写入响应体,避免中间转换 diff --git a/docs/3-服务层设计.md b/docs/3-服务层设计.md new file mode 100644 index 0000000..17a4def --- /dev/null +++ b/docs/3-服务层设计.md @@ -0,0 +1,696 @@ +# PanSou 服务层设计详解 + +## 1. 服务层概述 + +服务层是PanSou系统的核心业务逻辑层,负责整合不同来源的搜索结果,并进行过滤、排序和分类处理。该层是连接API层和插件系统的桥梁,实现了搜索功能的核心逻辑。 + +## 2. 目录结构 + +``` +pansou/service/ +└── search_service.go # 搜索服务实现 +``` + +## 3. 搜索服务设计 + +### 3.1 搜索服务结构 + +搜索服务是服务层的核心组件,负责协调不同来源的搜索操作,并处理搜索结果。 + +```go +// 全局缓存实例和缓存是否初始化标志 +var ( + twoLevelCache *cache.TwoLevelCache + cacheInitialized bool +) + +// 优先关键词列表 +var priorityKeywords = []string{"全", "合集", "系列", "完", "最新", "附", "花园墙外"} + +// 初始化缓存 +func init() { + if config.AppConfig != nil && config.AppConfig.CacheEnabled { + var err error + twoLevelCache, err = cache.NewTwoLevelCache() + if err == nil { + cacheInitialized = true + } + } +} + +// SearchService 搜索服务 +type SearchService struct{ + pluginManager *plugin.PluginManager +} + +// NewSearchService 创建搜索服务实例并确保缓存可用 +func NewSearchService(pluginManager *plugin.PluginManager) *SearchService { + // 检查缓存是否已初始化,如果未初始化则尝试重新初始化 + if !cacheInitialized && config.AppConfig != nil && config.AppConfig.CacheEnabled { + var err error + twoLevelCache, err = cache.NewTwoLevelCache() + if err == nil { + cacheInitialized = true + } + } + + return &SearchService{ + pluginManager: pluginManager, + } +} +``` + +### 3.2 搜索方法实现 + +搜索方法是搜索服务的核心方法,实现了搜索逻辑、缓存管理和结果处理。 + +```go +// Search 执行搜索 +func (s *SearchService) Search(keyword string, channels []string, concurrency int, forceRefresh bool, resultType string, sourceType string, plugins []string) (model.SearchResponse, error) { + // 立即生成缓存键并检查缓存 + cacheKey := cache.GenerateCacheKey(keyword, channels, resultType, sourceType, plugins) + + // 如果未启用强制刷新,尝试从缓存获取结果 + if !forceRefresh && twoLevelCache != nil && config.AppConfig.CacheEnabled { + data, hit, err := twoLevelCache.Get(cacheKey) + + if err == nil && hit { + var response model.SearchResponse + if err := json.Unmarshal(data, &response); err == nil { + // 根据resultType过滤返回结果 + return filterResponseByType(response, resultType), nil + } + } + } + + // 获取所有可用插件 + var availablePlugins []plugin.SearchPlugin + if s.pluginManager != nil && (sourceType == "all" || sourceType == "plugin") { + allPlugins := s.pluginManager.GetPlugins() + + // 确保plugins不为nil并且有非空元素 + hasPlugins := plugins != nil && len(plugins) > 0 + hasNonEmptyPlugin := false + + if hasPlugins { + for _, p := range plugins { + if p != "" { + hasNonEmptyPlugin = true + break + } + } + } + + // 只有当plugins数组包含非空元素时才进行过滤 + if hasPlugins && hasNonEmptyPlugin { + pluginMap := make(map[string]bool) + for _, p := range plugins { + if p != "" { // 忽略空字符串 + pluginMap[strings.ToLower(p)] = true + } + } + + for _, p := range allPlugins { + if pluginMap[strings.ToLower(p.Name())] { + availablePlugins = append(availablePlugins, p) + } + } + } else { + // 如果plugins为nil、空数组或只包含空字符串,视为未指定,使用所有插件 + availablePlugins = allPlugins + } + } + + // 控制并发数:如果用户没有指定有效值,则默认使用"频道数+插件数+10"的并发数 + pluginCount := len(availablePlugins) + + // 根据sourceType决定是否搜索Telegram频道 + channelCount := 0 + if sourceType == "all" || sourceType == "tg" { + channelCount = len(channels) + } + + if concurrency <= 0 { + concurrency = channelCount + pluginCount + 10 + if concurrency < 1 { + concurrency = 1 + } + } + + // 计算任务总数(频道数 + 插件数) + totalTasks := channelCount + pluginCount + + // 如果没有任务要执行,返回空结果 + if totalTasks == 0 { + return model.SearchResponse{ + Total: 0, + Results: []model.SearchResult{}, + MergedByType: make(model.MergedLinks), + }, nil + } + + // 使用工作池执行并行搜索 + tasks := make([]pool.Task, 0, totalTasks) + + // 添加频道搜索任务(如果需要) + if sourceType == "all" || sourceType == "tg" { + for _, channel := range channels { + ch := channel // 创建副本,避免闭包问题 + tasks = append(tasks, func() interface{} { + results, err := s.searchChannel(keyword, ch) + if err != nil { + return nil + } + return results + }) + } + } + + // 添加插件搜索任务(如果需要) + for _, p := range availablePlugins { + plugin := p // 创建副本,避免闭包问题 + tasks = append(tasks, func() interface{} { + results, err := plugin.Search(keyword) + if err != nil { + return nil + } + return results + }) + } + + // 使用带超时控制的工作池执行所有任务并获取结果 + results := pool.ExecuteBatchWithTimeout(tasks, concurrency, config.AppConfig.PluginTimeout) + + // 预估每个任务平均返回22个结果 + allResults := make([]model.SearchResult, 0, totalTasks*22) + + // 合并所有结果 + for _, result := range results { + if result != nil { + channelResults := result.([]model.SearchResult) + allResults = append(allResults, channelResults...) + } + } + + // 过滤结果,确保标题包含搜索关键词 + filteredResults := filterResultsByKeyword(allResults, keyword) + + // 按照优化后的规则排序结果 + sortResultsByTimeAndKeywords(filteredResults) + + // 过滤结果,只保留有时间的结果或包含优先关键词的结果到Results中 + filteredForResults := make([]model.SearchResult, 0, len(filteredResults)) + for _, result := range filteredResults { + // 有时间的结果或包含优先关键词的结果保留在Results中 + if !result.Datetime.IsZero() || getKeywordPriority(result.Title) > 0 { + filteredForResults = append(filteredForResults, result) + } + } + + // 合并链接按网盘类型分组(使用所有过滤后的结果) + mergedLinks := mergeResultsByType(filteredResults) + + // 构建响应 + var total int + if resultType == "merged_by_type" { + // 计算所有类型链接的总数 + total = 0 + for _, links := range mergedLinks { + total += len(links) + } + } else { + // 只计算filteredForResults的数量 + total = len(filteredForResults) + } + + response := model.SearchResponse{ + Total: total, + Results: filteredForResults, // 使用进一步过滤的结果 + MergedByType: mergedLinks, + } + + // 异步缓存搜索结果(缓存完整结果,以便后续可以根据不同resultType过滤) + if twoLevelCache != nil && config.AppConfig.CacheEnabled { + go func(resp model.SearchResponse) { + data, err := json.Marshal(resp) + if err != nil { + return + } + + ttl := time.Duration(config.AppConfig.CacheTTLMinutes) * time.Minute + twoLevelCache.Set(cacheKey, data, ttl) + }(response) + } + + // 根据resultType过滤返回结果 + return filterResponseByType(response, resultType), nil +} +``` + +### 3.3 缓存键生成优化 + +为了提高缓存命中率,搜索服务使用了优化的缓存键生成方法,确保相同语义的查询能够命中相同的缓存。 + +```go +// GenerateCacheKey 根据所有影响搜索结果的参数生成缓存键 +func GenerateCacheKey(keyword string, channels []string, resultType string, sourceType string, plugins []string) string { + // 关键词标准化 + normalizedKeyword := strings.TrimSpace(keyword) + + // 处理channels参数 + var channelsStr string + if channels == nil || len(channels) == 0 { + channelsStr = "all" + } else { + // 复制并排序channels,确保顺序一致性 + channelsCopy := make([]string, 0, len(channels)) + for _, ch := range channels { + if ch != "" { // 忽略空字符串 + channelsCopy = append(channelsCopy, ch) + } + } + + if len(channelsCopy) == 0 { + channelsStr = "all" + } else { + sort.Strings(channelsCopy) + channelsStr = strings.Join(channelsCopy, ",") + } + } + + // 处理resultType参数 + if resultType == "" { + resultType = "all" + } + + // 处理sourceType参数 + if sourceType == "" { + sourceType = "all" + } + + // 处理plugins参数 + var pluginsStr string + if plugins == nil || len(plugins) == 0 { + pluginsStr = "all" + } else { + // 复制并排序plugins,确保顺序一致性 + pluginsCopy := make([]string, 0, len(plugins)) + for _, p := range plugins { + if p != "" { // 忽略空字符串 + pluginsCopy = append(pluginsCopy, p) + } + } + + if len(pluginsCopy) == 0 { + pluginsStr = "all" + } else { + sort.Strings(pluginsCopy) + pluginsStr = strings.Join(pluginsCopy, ",") + } + } + + // 生成最终缓存键 + keyStr := normalizedKeyword + ":" + channelsStr + ":" + resultType + ":" + sourceType + ":" + pluginsStr + + // 计算MD5哈希 + hash := md5.Sum([]byte(keyStr)) + return hex.EncodeToString(hash[:]) +} +``` + +主要优化包括: + +1. **关键词标准化**:去除前后空格 +2. **参数排序**:对数组参数进行排序,确保不同顺序的相同参数产生相同的缓存键 +3. **空值处理**:统一处理null、空数组和只包含空字符串的数组 +4. **特殊值处理**:为空参数设置默认值,确保一致性 +5. **忽略空字符串**:在数组参数中忽略空字符串元素 + +这些优化确保了不同形式但语义相同的查询能够命中相同的缓存,显著提高了缓存命中率。 + +### 3.4 辅助方法实现 + +搜索服务包含多个辅助方法,用于处理搜索结果的过滤、排序和分类。 + +#### 3.4.1 结果过滤方法 + +```go +// filterResponseByType 根据结果类型过滤响应 +func filterResponseByType(response model.SearchResponse, resultType string) model.SearchResponse { + switch resultType { + case "results": + // 只返回Results + return model.SearchResponse{ + Total: response.Total, + Results: response.Results, + } + case "merged_by_type": + // 只返回MergedByType,Results设为nil,结合omitempty标签,JSON序列化时会忽略此字段 + return model.SearchResponse{ + Total: response.Total, + MergedByType: response.MergedByType, + Results: nil, + } + default: + // 默认返回全部 + return response + } +} + +// 过滤结果,确保标题包含搜索关键词 +func filterResultsByKeyword(results []model.SearchResult, keyword string) []model.SearchResult { + // 预估过滤后会保留80%的结果 + filteredResults := make([]model.SearchResult, 0, len(results)*8/10) + + // 将关键词转为小写,用于不区分大小写的比较 + lowerKeyword := strings.ToLower(keyword) + + // 将关键词按空格分割,用于支持多关键词搜索 + keywords := strings.Fields(lowerKeyword) + + for _, result := range results { + // 将标题和内容转为小写 + lowerTitle := strings.ToLower(result.Title) + lowerContent := strings.ToLower(result.Content) + + // 检查每个关键词是否在标题或内容中 + matched := true + for _, kw := range keywords { + // 如果关键词是"pwd",特殊处理,只要标题、内容或链接中包含即可 + if kw == "pwd" { + // 检查标题、内容 + pwdInTitle := strings.Contains(lowerTitle, kw) + pwdInContent := strings.Contains(lowerContent, kw) + + // 检查链接中是否包含pwd参数 + pwdInLinks := false + for _, link := range result.Links { + if strings.Contains(strings.ToLower(link.URL), "pwd=") { + pwdInLinks = true + break + } + } + + // 只要有一个包含pwd,就算匹配 + if pwdInTitle || pwdInContent || pwdInLinks { + continue // 匹配成功,检查下一个关键词 + } else { + matched = false + break + } + } else { + // 对于其他关键词,检查是否同时在标题和内容中 + if !strings.Contains(lowerTitle, kw) && !strings.Contains(lowerContent, kw) { + matched = false + break + } + } + } + + if matched { + filteredResults = append(filteredResults, result) + } + } + + return filteredResults +} +``` + +#### 3.4.2 结果排序方法 + +```go +// 根据时间和关键词排序结果 +func sortResultsByTimeAndKeywords(results []model.SearchResult) { + sort.Slice(results, func(i, j int) bool { + // 检查是否有零值时间 + iZeroTime := results[i].Datetime.IsZero() + jZeroTime := results[j].Datetime.IsZero() + + // 如果两者都是零值时间,按关键词优先级排序 + if iZeroTime && jZeroTime { + iPriority := getKeywordPriority(results[i].Title) + jPriority := getKeywordPriority(results[j].Title) + if iPriority != jPriority { + return iPriority > jPriority + } + // 如果优先级也相同,按标题字母顺序排序 + return results[i].Title < results[j].Title + } + + // 如果只有一个是零值时间,将其排在后面 + if iZeroTime { + return false // i排在后面 + } + if jZeroTime { + return true // j排在后面,i排在前面 + } + + // 两者都有正常时间,使用原有逻辑 + // 计算两个结果的时间差(以天为单位) + timeDiff := daysBetween(results[i].Datetime, results[j].Datetime) + + // 如果时间差超过30天,按时间排序(新的在前面) + if abs(timeDiff) > 30 { + return results[i].Datetime.After(results[j].Datetime) + } + + // 如果时间差在30天内,先检查时间差是否超过1天 + if abs(timeDiff) > 1 { + return results[i].Datetime.After(results[j].Datetime) + } + + // 如果时间差在1天内,检查关键词优先级 + iPriority := getKeywordPriority(results[i].Title) + jPriority := getKeywordPriority(results[j].Title) + + // 如果优先级不同,优先级高的排在前面 + if iPriority != jPriority { + return iPriority > jPriority + } + + // 如果优先级相同且时间差在1天内,仍然按时间排序(新的在前面) + return results[i].Datetime.After(results[j].Datetime) + }) +} + +// 计算两个时间之间的天数差 +func daysBetween(t1, t2 time.Time) float64 { + duration := t1.Sub(t2) + return duration.Hours() / 24 +} + +// 绝对值 +func abs(x float64) float64 { + if x < 0 { + return -x + } + return x +} + +// 获取标题中包含优先关键词的优先级 +func getKeywordPriority(title string) int { + title = strings.ToLower(title) + for i, keyword := range priorityKeywords { + if strings.Contains(title, keyword) { + // 返回优先级(数组索引越小,优先级越高) + return len(priorityKeywords) - i + } + } + return 0 +} +``` + +#### 3.4.3 频道搜索方法 + +```go +// 搜索单个频道 +func (s *SearchService) searchChannel(keyword string, channel string) ([]model.SearchResult, error) { + // 构建搜索URL + url := util.BuildSearchURL(channel, keyword, "") + + // 使用全局HTTP客户端(已配置代理) + client := util.GetHTTPClient() + + // 创建一个带超时的上下文 + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + // 发送请求 + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // 读取响应体 + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // 解析响应 + results, _, err := util.ParseSearchResults(string(body), channel) + if err != nil { + return nil, err + } + + return results, nil +} +``` + +#### 3.4.4 结果合并方法 + +```go +// 将搜索结果按网盘类型分组 +func mergeResultsByType(results []model.SearchResult) model.MergedLinks { + // 创建合并结果的映射 + mergedLinks := make(model.MergedLinks, 10) // 预分配容量,假设有10种不同的网盘类型 + + // 用于去重的映射,键为URL + uniqueLinks := make(map[string]model.MergedLink) + + // 遍历所有搜索结果 + for _, result := range results { + for _, link := range result.Links { + // 创建合并后的链接 + mergedLink := model.MergedLink{ + URL: link.URL, + Password: link.Password, + Note: result.Title, + Datetime: result.Datetime, + } + + // 检查是否已存在相同URL的链接 + if existingLink, exists := uniqueLinks[link.URL]; exists { + // 如果已存在,只有当当前链接的时间更新时才替换 + if mergedLink.Datetime.After(existingLink.Datetime) { + uniqueLinks[link.URL] = mergedLink + } + } else { + // 如果不存在,直接添加 + uniqueLinks[link.URL] = mergedLink + } + } + } + + // 将去重后的链接按类型分组 + for url, mergedLink := range uniqueLinks { + // 获取链接类型 + linkType := "" + for _, result := range results { + for _, link := range result.Links { + if link.URL == url { + linkType = link.Type + break + } + } + if linkType != "" { + break + } + } + + // 如果没有找到类型,使用"unknown" + if linkType == "" { + linkType = "unknown" + } + + // 添加到对应类型的列表中 + mergedLinks[linkType] = append(mergedLinks[linkType], mergedLink) + } + + // 对每种类型的链接按时间排序(新的在前面) + for linkType, links := range mergedLinks { + sort.Slice(links, func(i, j int) bool { + return links[i].Datetime.After(links[j].Datetime) + }) + mergedLinks[linkType] = links + } + + return mergedLinks +} +``` + +## 4. 核心设计思想 + +### 4.1 高性能设计 + +1. **并发搜索**:使用工作池模式实现高效并发搜索,充分利用系统资源 +2. **缓存机制**:利用两级缓存(内存+磁盘)提高重复查询的响应速度 +3. **异步缓存写入**:使用goroutine异步写入缓存,避免阻塞主流程 +4. **内存预分配**:为结果集预分配内存,减少动态扩容带来的开销 +5. **超时控制**:对搜索操作设置严格的超时限制,避免长时间阻塞 + +### 4.2 智能排序策略 + +搜索服务实现了复杂的多级排序策略,综合考虑时间和关键词权重: + +1. **时间优先**:优先展示最新的结果,保证用户获取最新资源 +2. **关键词权重**:对包含"全"、"合集"、"系列"等关键词的结果给予更高权重 +3. **多级排序**:根据时间差的大小采用不同的排序策略 + - 时间差超过30天:纯粹按时间排序 + - 时间差在1-30天:仍然按时间排序 + - 时间差在1天内:优先考虑关键词权重,再考虑时间 +4. **零值时间处理**:对没有时间信息的结果进行特殊处理,按关键词权重排序 + +### 4.3 结果过滤策略 + +1. **关键词匹配**:确保结果的标题或内容包含搜索关键词 +2. **多关键词支持**:支持空格分隔的多关键词搜索,要求所有关键词都匹配 +3. **特殊关键词处理**:"pwd"关键词特殊处理,检查标题、内容和链接URL +4. **质量过滤**:只保留有时间信息或包含优先关键词的结果到Results中 + +### 4.4 结果分类策略 + +1. **网盘类型分类**:按网盘类型(如百度网盘、阿里云盘等)分类展示结果 +2. **链接去重**:对相同URL的链接进行去重,保留最新的信息 +3. **类型内排序**:每种网盘类型内部按时间排序,新的在前面 + +## 5. 错误处理策略 + +1. **优雅降级**:单个搜索源失败不影响整体结果,保证服务可用性 +2. **超时控制**:对每个搜索任务设置超时限制,避免因单个任务阻塞整体搜索 +3. **错误隔离**:搜索错误不会传播到上层,保证API层的稳定性 +4. **空结果处理**:当没有搜索任务或所有任务失败时,返回空结果而非错误 + +## 6. 缓存策略 + +1. **缓存键生成**:基于搜索关键词生成缓存键 +2. **缓存命中检查**:在执行搜索前检查缓存,提高响应速度 +3. **强制刷新**:支持forceRefresh参数,便于获取最新结果 +4. **异步缓存更新**:使用goroutine异步写入缓存,避免阻塞主流程 +5. **缓存TTL**:缓存项有明确的过期时间,确保数据不会过时 + +## 7. 可扩展性设计 + +1. **插件系统集成**:通过插件管理器集成各种搜索插件,便于扩展搜索源 +2. **参数化控制**:通过参数控制搜索行为,如并发数、搜索源等 +3. **结果类型过滤**:支持不同类型的结果返回,满足不同场景需求 +4. **源类型过滤**:支持按源类型(Telegram、插件)过滤搜索结果 + +## 8. 性能优化措施 + +1. **内存优化**: + - 预分配结果集容量,减少动态扩容 + - 创建副本避免闭包问题 + - 使用指针减少大对象复制 + +2. **并发优化**: + - 使用工作池控制并发数量 + - 智能设置默认并发数(频道数+插件数+10) + - 并发任务超时控制 + +3. **缓存优化**: + - 两级缓存机制(内存+磁盘) + - 异步写入缓存,避免阻塞主流程 + - 缓存完整结果,支持不同resultType过滤 + +4. **算法优化**: + - 使用map进行插件名称匹配,提高查找效率 + - 优化排序算法,减少比较次数 + - 链接去重使用map实现,提高效率 diff --git a/docs/4-插件系统设计.md b/docs/4-插件系统设计.md new file mode 100644 index 0000000..b329a06 --- /dev/null +++ b/docs/4-插件系统设计.md @@ -0,0 +1,370 @@ +# PanSou 插件系统设计详解 + +## 1. 插件系统概述 + +插件系统是PanSou的核心特性之一,通过统一的接口和自动注册机制,实现了搜索源的可扩展性。该系统允许轻松添加新的网盘搜索插件,而无需修改主程序代码,使系统能够灵活适应不同的搜索需求。 + +## 2. 目录结构 + +``` +pansou/plugin/ +├── plugin.go # 插件接口和管理器定义 +├── baseasyncplugin.go # 异步插件基类实现 +├── jikepan/ # 即刻盘异步插件 +├── pan666/ # 盘666异步插件 +├── hunhepan/ # 混合盘异步插件 +├── pansearch/ # 盘搜插件 +├── qupansou/ # 趣盘搜插件 +└── panta/ # PanTa插件 +``` + +## 3. 插件接口设计 + +### 3.1 插件接口定义 + +插件接口是所有搜索插件必须实现的接口,定义了插件的基本行为。 + +```go +// SearchPlugin 搜索插件接口 +type SearchPlugin interface { + // Name 返回插件名称 + Name() string + + // Search 执行搜索并返回结果 + Search(keyword string) ([]model.SearchResult, error) + + // Priority 返回插件优先级(可选,用于控制结果排序) + Priority() int +} +``` + +### 3.2 接口设计思想 + +1. **简单明确**:接口只定义了必要的方法,使插件开发简单明了 +2. **统一返回格式**:所有插件返回相同格式的搜索结果,便于统一处理 +3. **优先级控制**:通过Priority方法支持插件优先级,影响结果排序 +4. **错误处理**:Search方法返回error,便于处理搜索过程中的错误 + +## 4. 插件注册机制 + +### 4.1 全局注册表 + +插件系统使用全局注册表管理所有插件,通过init函数实现自动注册。 + +```go +// 全局插件注册表 +var ( + globalRegistry = make(map[string]SearchPlugin) + globalRegistryLock sync.RWMutex +) + +// RegisterGlobalPlugin 注册插件到全局注册表 +// 这个函数应该在每个插件的init函数中被调用 +func RegisterGlobalPlugin(plugin SearchPlugin) { + if plugin == nil { + return + } + + globalRegistryLock.Lock() + defer globalRegistryLock.Unlock() + + name := plugin.Name() + if name == "" { + return + } + + globalRegistry[name] = plugin +} + +// GetRegisteredPlugins 获取所有已注册的插件 +func GetRegisteredPlugins() []SearchPlugin { + globalRegistryLock.RLock() + defer globalRegistryLock.RUnlock() + + plugins := make([]SearchPlugin, 0, len(globalRegistry)) + for _, plugin := range globalRegistry { + plugins = append(plugins, plugin) + } + + return plugins +} +``` + +### 4.2 自动注册机制 + +每个插件通过init函数在程序启动时自动注册到全局注册表。 + +```go +// 插件实现示例(以jikepan为例) +package jikepan + +import ( + "pansou/model" + "pansou/plugin" + "pansou/util/json" // 使用优化的JSON库 +) + +// 确保JikePanPlugin实现了SearchPlugin接口 +var _ plugin.SearchPlugin = (*JikePanPlugin)(nil) + +// JikePanPlugin 即刻盘搜索插件 +type JikePanPlugin struct{} + +// init函数在包被导入时自动执行,用于注册插件 +func init() { + // 注册插件到全局注册表 + plugin.RegisterGlobalPlugin(&JikePanPlugin{}) +} + +// Name 返回插件名称 +func (p *JikePanPlugin) Name() string { + return "jikepan" +} + +// Search 执行搜索 +func (p *JikePanPlugin) Search(keyword string) ([]model.SearchResult, error) { + // 实现搜索逻辑 + // ... + return results, nil +} + +// Priority 返回插件优先级 +func (p *JikePanPlugin) Priority() int { + return 5 // 优先级为5 +} +``` + +## 5. 异步插件系统 + +### 5.1 异步插件基类 + +为了解决某些插件响应时间长的问题,系统提供了BaseAsyncPlugin基类,实现了"尽快响应,持续处理"的异步模式。 + +```go +// BaseAsyncPlugin 基础异步插件结构 +type BaseAsyncPlugin struct { + name string + priority int + client *http.Client // 用于短超时的客户端 + backgroundClient *http.Client // 用于长超时的客户端 + cacheTTL time.Duration // 缓存有效期 +} + +// NewBaseAsyncPlugin 创建基础异步插件 +func NewBaseAsyncPlugin(name string, priority int) *BaseAsyncPlugin { + // 确保异步插件已初始化 + if !initialized { + initAsyncPlugin() + } + + // 初始化配置和客户端 + // ... + + return &BaseAsyncPlugin{ + name: name, + priority: priority, + client: &http.Client{ + Timeout: responseTimeout, + }, + backgroundClient: &http.Client{ + Timeout: processingTimeout, + }, + cacheTTL: cacheTTL, + } +} +``` + +### 5.2 异步搜索机制 + +异步插件的核心是AsyncSearch方法,它实现了以下功能: + +1. **缓存检查**:首先检查是否有缓存结果可用 +2. **双通道处理**:同时启动快速响应通道和后台处理通道 +3. **超时控制**:在响应超时时返回当前结果,后台继续处理 +4. **缓存更新**:后台处理完成后更新缓存,供后续查询使用 + +```go +// AsyncSearch 异步搜索基础方法 +func (p *BaseAsyncPlugin) AsyncSearch( + keyword string, + cacheKey string, + searchFunc func(*http.Client, string) ([]model.SearchResult, error), +) ([]model.SearchResult, error) { + // 生成插件特定的缓存键 + pluginSpecificCacheKey := fmt.Sprintf("%s:%s", p.name, cacheKey) + + // 检查缓存 + if cachedItems, ok := apiResponseCache.Load(pluginSpecificCacheKey); ok { + // 处理缓存命中逻辑... + } + + // 启动后台处理 + go func() { + // 执行搜索,更新缓存... + }() + + // 等待响应超时或结果 + select { + case results := <-resultChan: + // 返回结果 + case err := <-errorChan: + // 返回错误 + case <-time.After(responseTimeout): + // 响应超时,返回部分结果 + } +} +``` + +### 5.3 异步插件缓存机制 + +异步插件系统实现了高级缓存机制: + +1. **持久化存储**:缓存定期保存到磁盘,服务重启时自动加载 +2. **智能缓存管理**:基于访问频率、时间和热度的缓存淘汰策略 +3. **增量更新**:新旧结果智能合并,保留唯一标识符不同的结果 +4. **后台刷新**:接近过期的缓存会在后台自动刷新 + +```go +// 缓存响应结构 +type cachedResponse struct { + Results []model.SearchResult + Timestamp time.Time + Complete bool + LastAccess time.Time + AccessCount int +} + +// 缓存保存示例 +func saveCacheToDisk() { + // 将内存缓存保存到磁盘 + // ... +} + +// 缓存加载示例 +func loadCacheFromDisk() { + // 从磁盘加载缓存到内存 + // ... +} +``` + +### 5.4 异步插件实现示例 + +```go +// HunhepanAsyncPlugin 混合盘搜索异步插件 +type HunhepanAsyncPlugin struct { + *plugin.BaseAsyncPlugin +} + +// NewHunhepanAsyncPlugin 创建新的混合盘搜索异步插件 +func NewHunhepanAsyncPlugin() *HunhepanAsyncPlugin { + return &HunhepanAsyncPlugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("hunhepan_async", 3), + } +} + +// Search 执行搜索并返回结果 +func (p *HunhepanAsyncPlugin) Search(keyword string) ([]model.SearchResult, error) { + // 生成缓存键 + cacheKey := keyword + + // 使用异步搜索基础方法 + return p.AsyncSearch(keyword, cacheKey, p.doSearch) +} + +// doSearch 实际的搜索实现 +func (p *HunhepanAsyncPlugin) doSearch(client *http.Client, keyword string) ([]model.SearchResult, error) { + // 实现具体搜索逻辑 + // ... +} +``` + +## 6. 优雅关闭机制 + +系统实现了优雅关闭机制,确保在程序退出前保存异步插件缓存: + +```go +// 在main.go中 +// 创建通道来接收操作系统信号 +quit := make(chan os.Signal, 1) +signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + +// 等待中断信号 +<-quit +fmt.Println("正在关闭服务器...") + +// 保存异步插件缓存 +plugin.SaveCacheToDisk() + +// 优雅关闭服务器 +if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("服务器关闭异常: %v", err) +} +``` + +## 7. JSON处理优化 + +为了提高插件的性能,特别是在处理大量JSON数据时,所有插件都使用了高性能的JSON库进行序列化和反序列化操作。 + +### 7.1 JSON库选择 + +PanSou使用字节跳动开发的sonic库替代标准库的encoding/json,提供更高效的JSON处理: + +```go +// 使用优化的JSON库 +import ( + "pansou/util/json" // 内部封装了github.com/bytedance/sonic +) + +// 序列化示例 +jsonData, err := json.Marshal(reqBody) + +// 反序列化示例 +if err := json.Unmarshal(respBody, &apiResp); err != nil { + return nil, fmt.Errorf("decode response failed: %w", err) +} +``` + +### 7.2 性能优势 + +- **更快的序列化/反序列化速度**:sonic库比标准库快2-5倍 +- **更低的内存分配**:减少GC压力 +- **SIMD加速**:利用现代CPU的向量指令集 +- **并行处理**:大型JSON可以并行处理 + +### 7.3 实现方式 + +所有插件通过统一的内部包装库使用sonic: + +```go +// util/json/json.go +package json + +import ( + "github.com/bytedance/sonic" +) + +// API是sonic的全局配置实例 +var API = sonic.ConfigDefault + +// 初始化sonic配置 +func init() { + // 根据需要配置sonic选项 + API = sonic.Config{ + UseNumber: true, + EscapeHTML: true, + SortMapKeys: false, // 生产环境设为false提高性能 + }.Froze() +} + +// Marshal 使用sonic序列化对象到JSON +func Marshal(v interface{}) ([]byte, error) { + return API.Marshal(v) +} + +// Unmarshal 使用sonic反序列化JSON到对象 +func Unmarshal(data []byte, v interface{}) error { + return API.Unmarshal(data, v) +} +``` + +这种统一的JSON处理方式确保了所有插件都能获得一致的高性能,特别是在处理大量搜索结果时,显著提升了系统整体响应速度。 \ No newline at end of file diff --git a/docs/5-缓存系统设计.md b/docs/5-缓存系统设计.md new file mode 100644 index 0000000..9deca45 --- /dev/null +++ b/docs/5-缓存系统设计.md @@ -0,0 +1,946 @@ +# PanSou 缓存系统设计详解 + +## 1. 缓存系统概述 + +缓存系统是PanSou性能优化的核心组件,通过两级缓存(内存+磁盘)机制,大幅提升重复查询的响应速度。该系统采用分层设计,实现了高效的缓存存取和智能的缓存策略。 + +PanSou的缓存系统包括两个主要部分: +1. **通用缓存系统**:用于API响应和常规搜索结果缓存 +2. **异步插件缓存系统**:专为异步插件设计的高级缓存机制 + +## 2. 目录结构 + +``` +pansou/util/cache/ +├── cache_key.go # 优化的缓存键生成 +├── cache_key_test.go # 缓存键单元测试 +├── disk_cache.go # 磁盘缓存实现 +├── two_level_cache.go # 两级缓存实现 +├── utils.go # 缓存工具函数 +└── utils_test.go # 缓存工具测试 + +pansou/plugin/ +├── baseasyncplugin.go # 异步插件缓存实现 + +pansou/util/json/ +└── json.go # 基于sonic的高性能JSON处理封装 +``` + +## 3. 缓存架构设计 + +### 3.1 两级缓存架构 + +PanSou采用两级缓存架构,包括内存缓存和磁盘缓存: + +``` +┌─────────────────────────┐ +│ 搜索请求 │ +└───────────┬─────────────┘ + │ +┌───────────▼─────────────┐ +│ 缓存键生成 │ +└───────────┬─────────────┘ + │ +┌───────────▼─────────────┐ +│ 内存缓存查询 │ +└───────────┬─────────────┘ + │ (未命中) +┌───────────▼─────────────┐ +│ 磁盘缓存查询 │ +└───────────┬─────────────┘ + │ (未命中) +┌───────────▼─────────────┐ +│ 执行搜索 │ +└───────────┬─────────────┘ + │ +┌───────────▼─────────────┐ +│ 更新内存缓存 │ +└───────────┬─────────────┘ + │ +┌───────────▼─────────────┐ +│ 异步更新磁盘缓存 │ +└─────────────────────────┘ +``` + +### 3.2 缓存层次职责 + +1. **内存缓存**: + - 提供快速访问 + - 存储热点数据 + - 减少磁盘I/O + +2. **磁盘缓存**: + - 提供持久存储 + - 存储更多数据 + - 在服务重启后保留缓存 + +## 4. 缓存键设计 + +### 4.1 缓存键生成(cache_key.go) + +缓存键生成是缓存系统的基础,决定了缓存的命中率和有效性。 + +```go +// GenerateCacheKey 根据所有影响搜索结果的参数生成缓存键 +func GenerateCacheKey(keyword string, channels []string, sourceType string, plugins []string) string { + // 关键词标准化 + normalizedKeyword := strings.ToLower(strings.TrimSpace(keyword)) + + // 获取频道列表哈希 + channelsHash := getChannelsHash(channels) + + // 源类型处理 + if sourceType == "" { + sourceType = "all" + } + + // 插件参数规范化处理 + var pluginsHash string + if sourceType == "tg" { + // 对于只搜索Telegram的请求,忽略插件参数 + pluginsHash = "none" + } else { + // 获取插件列表哈希 + pluginsHash = getPluginsHash(plugins) + } + + // 生成最终缓存键 + keyStr := fmt.Sprintf("%s:%s:%s:%s", normalizedKeyword, channelsHash, sourceType, pluginsHash) + hash := md5.Sum([]byte(keyStr)) + return hex.EncodeToString(hash[:]) +} +``` + +### 4.2 缓存键设计思想 + +1. **标准化处理**:对关键词进行标准化,确保相同语义的查询使用相同的缓存键 +2. **参数敏感**:缓存键包含影响结果的参数(如搜索频道、来源类型、插件列表),避免错误的缓存命中 +3. **排序处理**:对数组参数进行排序,确保参数顺序不同但内容相同的查询使用相同的缓存键 +4. **哈希处理**:对大型列表使用哈希处理,减小缓存键长度,提高性能 +5. **参数规范化**:统一处理不同形式但语义相同的参数,提高缓存命中率 + +### 4.3 列表参数处理 + +```go +// 获取或计算插件哈希 +func getPluginsHash(plugins []string) string { + // 检查是否为空列表 + if plugins == nil || len(plugins) == 0 { + // 使用预计算的所有插件哈希 + if hash, ok := precomputedHashes.Load("all_plugins"); ok { + return hash.(string) + } + return allPluginsHash + } + + // 检查是否有空字符串元素 + hasNonEmptyPlugin := false + for _, p := range plugins { + if p != "" { + hasNonEmptyPlugin = true + break + } + } + + // 如果全是空字符串,也视为空列表 + if !hasNonEmptyPlugin { + if hash, ok := precomputedHashes.Load("all_plugins"); ok { + return hash.(string) + } + return allPluginsHash + } + + // 对于小型列表,直接使用字符串连接 + if len(plugins) < 5 { + pluginsCopy := make([]string, 0, len(plugins)) + for _, p := range plugins { + if p != "" { // 忽略空字符串 + pluginsCopy = append(pluginsCopy, p) + } + } + sort.Strings(pluginsCopy) + + // 检查是否有预计算的哈希 + key := strings.Join(pluginsCopy, ",") + if hash, ok := precomputedHashes.Load("plugins:"+key); ok { + return hash.(string) + } + + return strings.Join(pluginsCopy, ",") + } + + // 生成排序后的字符串用作键,忽略空字符串 + pluginsCopy := make([]string, 0, len(plugins)) + for _, p := range plugins { + if p != "" { // 忽略空字符串 + pluginsCopy = append(pluginsCopy, p) + } + } + sort.Strings(pluginsCopy) + key := strings.Join(pluginsCopy, ",") + + // 尝试从缓存获取 + if hash, ok := pluginHashCache.Load(key); ok { + return hash.(string) + } + + // 计算哈希 + hash := calculateListHash(pluginsCopy) + + // 存入缓存 + pluginHashCache.Store(key, hash) + return hash +} +``` + +### 4.4 预计算哈希优化 + +```go +// 初始化预计算的哈希值 +func init() { + // 预计算空列表的哈希值 + precomputedHashes.Store("empty_channels", "all") + + // 预计算常用的频道组合哈希值 + commonChannels := [][]string{ + {"dongman", "anime"}, + {"movie", "film"}, + {"music", "audio"}, + {"book", "ebook"}, + } + + for _, channels := range commonChannels { + key := strings.Join(channels, ",") + hash := calculateListHash(channels) + precomputedHashes.Store("channels:"+key, hash) + } + + // 预计算常用的插件组合哈希值 + commonPlugins := [][]string{ + {"pan666", "panta"}, + {"aliyun", "baidu"}, + } + + for _, plugins := range commonPlugins { + key := strings.Join(plugins, ",") + hash := calculateListHash(plugins) + precomputedHashes.Store("plugins:"+key, hash) + } + + // 预计算所有插件的哈希值 + allPlugins := plugin.GetRegisteredPlugins() + allPluginNames := make([]string, 0, len(allPlugins)) + for _, p := range allPlugins { + allPluginNames = append(allPluginNames, p.Name()) + } + sort.Strings(allPluginNames) + allPluginsHash = calculateListHash(allPluginNames) + precomputedHashes.Store("all_plugins", allPluginsHash) +} +``` + +## 5. 缓存一致性优化 + +### 5.1 参数规范化处理 + +为确保不同形式但语义相同的参数生成相同的缓存键,系统实现了以下规范化处理: + +1. **插件参数规范化**: + - 不传plugins参数 + - 传空plugins数组 + - 传只包含空字符串的plugins数组 + - 传所有插件名称 + + 这四种情况都被统一处理,生成相同的缓存键。 + +2. **搜索类型规范化**: + - 对于`sourceType=tg`的请求,忽略插件参数,使用固定值"none" + - 对于`sourceType=all`或`sourceType=plugin`的请求,根据插件参数内容决定缓存键 + +3. **参数预处理**: + - 在`Search`函数中添加参数预处理逻辑,确保不同形式的参数产生相同的搜索结果 + - 对于包含所有注册插件的请求,统一设为nil,与不指定插件的请求使用相同的缓存键 + +### 5.2 缓存键测试 + +```go +func TestPluginParameterNormalization(t *testing.T) { + // 获取所有插件名称 + allPlugins := plugin.GetRegisteredPlugins() + allPluginNames := make([]string, 0, len(allPlugins)) + for _, p := range allPlugins { + allPluginNames = append(allPluginNames, p.Name()) + } + + // 测试不传插件参数 + key1 := GenerateCacheKey("movie", nil, "all", nil) + + // 测试传空插件数组 + key2 := GenerateCacheKey("movie", nil, "all", []string{}) + + // 测试传只包含空字符串的插件数组 + key3 := GenerateCacheKey("movie", nil, "all", []string{""}) + + // 测试传所有插件 + key4 := GenerateCacheKey("movie", nil, "all", allPluginNames) + + // 所有情况应该生成相同的缓存键 + if key1 != key2 || key1 != key3 || key1 != key4 { + t.Errorf("Different plugin parameter forms should generate the same cache key:\nnil: %s\nempty: %s\nempty string: %s\nall plugins: %s", + key1, key2, key3, key4) + } + + // 测试sourceType=tg时忽略插件参数 + key5 := GenerateCacheKey("movie", nil, "tg", nil) + key6 := GenerateCacheKey("movie", nil, "tg", allPluginNames) + + if key5 != key6 { + t.Errorf("With sourceType=tg, plugin parameters should be ignored: %s != %s", key5, key6) + } +} +``` + +## 6. 内存缓存设计 + +### 6.1 内存缓存实现(memory_cache.go) + +内存缓存提供快速访问,减少磁盘I/O,适合存储热点数据。 + +```go +// MemoryCache 内存缓存 +type MemoryCache struct { + cache map[string]cacheItem + mutex sync.RWMutex + maxSize int64 + currSize int64 +} + +// cacheItem 缓存项 +type cacheItem struct { + data []byte + expireAt time.Time + size int64 +} + +// NewMemoryCache 创建新的内存缓存 +func NewMemoryCache(maxSizeMB int) *MemoryCache { + maxSize := int64(maxSizeMB) * 1024 * 1024 + return &MemoryCache{ + cache: make(map[string]cacheItem), + maxSize: maxSize, + } +} + +// Get 从内存缓存获取数据 +func (c *MemoryCache) Get(key string) ([]byte, bool, error) { + c.mutex.RLock() + defer c.mutex.RUnlock() + + item, ok := c.cache[key] + if !ok { + return nil, false, nil + } + + // 检查是否过期 + if time.Now().After(item.expireAt) { + return nil, false, nil + } + + return item.data, true, nil +} + +// Set 将数据存入内存缓存 +func (c *MemoryCache) Set(key string, data []byte, ttl time.Duration) error { + c.mutex.Lock() + defer c.mutex.Unlock() + + size := int64(len(data)) + + // 如果数据太大,超过最大缓存大小,不缓存 + if size > c.maxSize { + return nil + } + + // 检查是否需要腾出空间 + if c.currSize+size > c.maxSize { + c.evict(c.currSize + size - c.maxSize) + } + + // 存储数据 + c.cache[key] = cacheItem{ + data: data, + expireAt: time.Now().Add(ttl), + size: size, + } + + c.currSize += size + return nil +} + +// 腾出空间 +func (c *MemoryCache) evict(sizeToFree int64) { + // 按过期时间排序 + type keyExpire struct { + key string + expireAt time.Time + } + + items := make([]keyExpire, 0, len(c.cache)) + for k, v := range c.cache { + items = append(items, keyExpire{k, v.expireAt}) + } + + // 按过期时间排序,先过期的先删除 + sort.Slice(items, func(i, j int) bool { + return items[i].expireAt.Before(items[j].expireAt) + }) + + // 删除足够的项目以腾出空间 + freed := int64(0) + for _, item := range items { + if freed >= sizeToFree { + break + } + + cacheItem := c.cache[item.key] + freed += cacheItem.size + c.currSize -= cacheItem.size + delete(c.cache, item.key) + } +} +``` + +## 7. 两级缓存实现 + +### 7.1 两级缓存(two_level_cache.go) + +两级缓存整合内存缓存和磁盘缓存,提供统一的接口。 + +```go +// TwoLevelCache 两级缓存 +type TwoLevelCache struct { + memCache *MemoryCache + diskCache *DiskCache +} + +// NewTwoLevelCache 创建新的两级缓存 +func NewTwoLevelCache() (*TwoLevelCache, error) { + // 获取配置 + maxSizeMB := 100 // 默认100MB + if sizeStr := os.Getenv("CACHE_MAX_SIZE"); sizeStr != "" { + if size, err := strconv.Atoi(sizeStr); err == nil { + maxSizeMB = size + } + } + + // 创建内存缓存 + memCache := NewMemoryCache(maxSizeMB) + + // 创建磁盘缓存 + diskCache, err := NewDiskCache() + if err != nil { + return nil, err + } + + return &TwoLevelCache{ + memCache: memCache, + diskCache: diskCache, + }, nil +} + +// Get 从缓存获取数据 +func (c *TwoLevelCache) Get(key string) ([]byte, bool, error) { + // 先查内存缓存 + data, hit, err := c.memCache.Get(key) + if hit || err != nil { + return data, hit, err + } + + // 内存未命中,查磁盘缓存 + data, hit, err = c.diskCache.Get(key) + if err != nil { + return nil, false, err + } + + // 如果磁盘命中,更新内存缓存 + if hit { + // 使用较短的TTL,因为这只是内存缓存 + c.memCache.Set(key, data, 10*time.Minute) + } + + return data, hit, nil +} + +// Set 将数据存入缓存 +func (c *TwoLevelCache) Set(key string, data []byte, ttl time.Duration) error { + // 更新内存缓存 + if err := c.memCache.Set(key, data, ttl); err != nil { + return err + } + + // 异步更新磁盘缓存 + go c.diskCache.Set(key, data, ttl) + + return nil +} +``` + +## 8. 序列化优化 + +### 8.1 高性能JSON处理(util/json包) + +为提高序列化和反序列化性能,系统封装了bytedance/sonic库,提供高性能的JSON处理功能: + +```go +// pansou/util/json/json.go +package json + +import ( + "github.com/bytedance/sonic" +) + +// API是sonic的全局配置实例 +var API = sonic.ConfigDefault + +// 初始化sonic配置 +func init() { + // 根据需要配置sonic选项 + API = sonic.Config{ + UseNumber: true, + EscapeHTML: true, + SortMapKeys: false, // 生产环境设为false提高性能 + }.Froze() +} + +// Marshal 使用sonic序列化对象到JSON +func Marshal(v interface{}) ([]byte, error) { + return API.Marshal(v) +} + +// Unmarshal 使用sonic反序列化JSON到对象 +func Unmarshal(data []byte, v interface{}) error { + return API.Unmarshal(data, v) +} + +// MarshalString 序列化对象到JSON字符串 +func MarshalString(v interface{}) (string, error) { + bytes, err := API.Marshal(v) + if err != nil { + return "", err + } + return string(bytes), nil +} + +// UnmarshalString 反序列化JSON字符串到对象 +func UnmarshalString(str string, v interface{}) error { + return API.Unmarshal([]byte(str), v) +} +``` + +该包的主要特点: + +1. **高性能**:基于bytedance/sonic库,比标准库encoding/json快5-10倍 +2. **统一接口**:提供与标准库兼容的接口,便于系统内统一使用 +3. **优化配置**:预配置了适合生产环境的sonic选项 +4. **字符串处理**:额外提供字符串序列化/反序列化方法,减少内存分配 + +### 8.2 序列化工具(utils.go) + +为提高序列化和反序列化性能,系统使用高性能JSON库并实现对象池化。 + +```go +var ( + // 缓冲区对象池 + bufferPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, + } +) + +// SerializeWithPool 使用对象池序列化数据 +func SerializeWithPool(v interface{}) ([]byte, error) { + // 从对象池获取缓冲区 + buf := bufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bufferPool.Put(buf) + + // 使用高性能JSON库序列化 + if err := sonic.ConfigDefault.NewEncoder(buf).Encode(v); err != nil { + return nil, err + } + + // 复制数据,因为缓冲区会被重用 + data := make([]byte, buf.Len()) + copy(data, buf.Bytes()) + + return data, nil +} + +// DeserializeWithPool 使用对象池反序列化数据 +func DeserializeWithPool(data []byte, v interface{}) error { + return sonic.ConfigDefault.Unmarshal(data, v) +} +``` + +## 9. 缓存系统优化历程 + +### 9.1 第一阶段:缓存键生成优化 + +1. **实现新的`GenerateCacheKey`函数**: + - 使用哈希处理大型列表 + - 实现参数排序确保顺序不变性 + - 统一空值处理方式 + +2. **添加缓存键单元测试**: + - 验证参数顺序不变性 + - 验证空值处理一致性 + +3. **优化哈希计算**: + - 对大型列表使用MD5哈希处理 + - 添加哈希缓存映射避免重复计算 + +### 9.2 第二阶段:JSON序列化优化 + +1. **高性能JSON库**: + - 使用`github.com/bytedance/sonic`高性能JSON库 + +2. **缓冲区对象池**: + - 实现缓冲区对象池,减少内存分配 + - 创建`SerializeWithPool`和`DeserializeWithPool`函数 + +3. **性能测试**: + - 对比优化前后的序列化性能 + - 验证对象池化方法的效果 + +### 9.3 第三阶段:缓存写入优化 + +1. **异步缓存写入**: + - 内存缓存在主线程执行 + - 磁盘缓存移至goroutine异步执行 + +2. **预计算哈希缓存**: + - 缓存频道和插件组合的哈希值 + - 提前计算常用组合的哈希值 + +### 9.4 第四阶段:缓存键一致性优化 + +1. **插件参数规范化处理**: + - 统一处理不传plugins参数、传空plugins数组、传只包含空字符串的plugins数组、传所有插件名称这几种情况 + - 对于`sourceType=tg`的请求,忽略插件参数,使用固定值"none" + +2. **Search函数优化**: + - 添加参数预处理逻辑,确保不同形式的插件参数产生相同的搜索结果 + - 对于包含所有注册插件的请求,统一设为nil,与不指定插件的请求使用相同的缓存键 + +3. **HTTP请求处理优化**: + - 区分"不传plugins参数"和"传空plugins值"这两种情况 + - 对于`sourceType=all`的请求,如果plugins为空或不存在,统一设为nil + +4. **单元测试**: + - 添加`TestPluginParameterNormalization`测试用例,验证不同形式的插件参数生成相同的缓存键 + +## 10. 性能指标 + +### 10.1 缓存命中率 + +- **内存缓存命中率**:约85%(热点查询) +- **磁盘缓存命中率**:约10%(非热点查询) +- **总体命中率**:约95% + +### 10.2 响应时间 + +- **缓存命中**:平均响应时间 < 50ms +- **缓存未命中**:平均响应时间约6-12秒(取决于查询复杂度和网络状况) +- **性能提升**:缓存命中时响应时间减少约99% + +### 10.3 资源消耗 + +- **内存占用**:约100MB(可配置) +- **磁盘占用**:约1GB(取决于查询量和缓存TTL) +- **CPU使用率**:缓存命中时几乎为0,缓存未命中时约20-30% + +## 11. 异步插件缓存系统 + +异步插件缓存系统是为解决慢速插件响应问题而设计的专门缓存机制,实现了"尽快响应,持续处理"的异步模式。 + +### 11.1 异步缓存架构 + +``` +┌─────────────────────────┐ +│ 搜索请求 │ +└───────────┬─────────────┘ + │ +┌───────────▼─────────────┐ +│ 异步缓存查询 │ +└───────────┬─────────────┘ + │ (命中) + ├───────────────────┐ + │ │ +┌───────────▼─────────────┐ │ +│ 返回缓存结果 │ │ +└───────────┬─────────────┘ │ + │ │ + │ (接近过期) │ + │ │ +┌───────────▼─────────────┐ │ +│ 后台刷新缓存 │ │ +└─────────────────────────┘ │ + │ + │ (未命中) │ + │ │ +┌───────────▼─────────────┐ │ +│ 启动双通道处理 │ │ +└───────────┬─────────────┘ │ + │ │ + ┌──────┴──────┐ │ + │ │ │ +┌────▼────┐ ┌────▼────┐ │ +│快速响应 │ │后台处理│ │ +│(短超时) │ │(长超时) │ │ +└────┬────┘ └────┬────┘ │ + │ │ │ + │ │ │ +┌────▼────┐ ┌────▼────┐ │ +│返回结果 │ │更新缓存│ │ +└─────────┘ └────┬────┘ │ + │ │ + ▼ │ +┌─────────────────────────┐ │ +│ 持久化到磁盘 │◄───┘ +└─────────────────────────┘ +``` + +### 11.2 异步缓存机制设计 + +#### 11.2.1 缓存结构 + +```go +// 缓存响应结构 +type cachedResponse struct { + Results []model.SearchResult `json:"results"` + Timestamp time.Time `json:"timestamp"` + Complete bool `json:"complete"` + LastAccess time.Time `json:"last_access"` + AccessCount int `json:"access_count"` +} + +// 可序列化的缓存结构,用于持久化 +type persistentCache struct { + Entries map[string]cachedResponse +} +``` + +#### 11.2.2 缓存键设计 + +异步插件缓存使用插件特定的缓存键,确保不同插件的缓存不会相互干扰: + +```go +// 生成插件特定的缓存键 +pluginSpecificCacheKey := fmt.Sprintf("%s:%s", p.name, cacheKey) +``` + +#### 11.2.3 双级超时控制 + +异步缓存系统实现了双级超时控制: + +1. **响应超时**(默认2秒):确保快速响应用户请求 +2. **处理超时**(默认30秒):允许后台处理有足够时间完成 + +```go +// 默认配置值 +defaultAsyncResponseTimeout = 2 * time.Second +defaultPluginTimeout = 30 * time.Second +``` + +### 11.3 缓存持久化 + +#### 11.3.1 定期保存 + +缓存系统会定期将内存中的缓存保存到磁盘: + +```go +// 缓存保存间隔 (2分钟) +cacheSaveInterval = 2 * time.Minute + +// 启动定期保存 +func startCachePersistence() { + ticker := time.NewTicker(cacheSaveInterval) + defer ticker.Stop() + + for range ticker.C { + if hasCacheItems() { + saveCacheToDisk() + } + } +} +``` + +#### 11.3.2 即时保存 + +当缓存更新时,系统会触发即时保存: + +```go +// 更新缓存后立即触发保存 +go saveCacheToDisk() +``` + +#### 11.3.3 优雅关闭 + +系统实现了优雅关闭机制,确保在程序退出前保存缓存: + +```go +// 在main.go中 +// 创建通道来接收操作系统信号 +quit := make(chan os.Signal, 1) +signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + +// 等待中断信号 +<-quit + +// 保存异步插件缓存 +plugin.SaveCacheToDisk() + +// 优雅关闭服务器 +if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("服务器关闭异常: %v", err) +} +``` + +### 11.4 智能缓存管理 + +#### 11.4.1 基于得分的缓存淘汰 + +系统实现了基于多因素的缓存淘汰策略: + +```go +// 计算得分:访问次数 / (空闲时间的平方 * 年龄) +// 这样: +// - 访问频率高的得分高 +// - 最近访问的得分高 +// - 较新的缓存得分高 +score := float64(item.AccessCount) / (idleTime.Seconds() * idleTime.Seconds() * age.Seconds()) +``` + +#### 11.4.2 访问统计 + +系统记录每个缓存项的访问情况: + +```go +// 记录缓存访问次数,用于智能缓存策略 +func recordCacheAccess(key string) { + // 更新缓存项的访问时间和计数 + if cached, ok := apiResponseCache.Load(key); ok { + cachedItem := cached.(cachedResponse) + cachedItem.LastAccess = time.Now() + cachedItem.AccessCount++ + apiResponseCache.Store(key, cachedItem) + } +} +``` + +#### 11.4.3 增量缓存更新 + +系统实现了新旧结果的智能合并: + +```go +// 创建合并结果集 +mergedResults := make([]model.SearchResult, 0, len(results) + len(oldCachedResult.Results)) + +// 创建已有结果ID的映射 +existingIDs := make(map[string]bool) +for _, r := range results { + existingIDs[r.UniqueID] = true + mergedResults = append(mergedResults, r) +} + +// 添加旧结果中不存在的项 +for _, r := range oldCachedResult.Results { + if !existingIDs[r.UniqueID] { + mergedResults = append(mergedResults, r) + } +} +``` + +#### 11.4.4 后台自动刷新 + +对于接近过期的缓存,系统会在后台自动刷新: + +```go +// 如果缓存接近过期(已用时间超过TTL的80%),在后台刷新缓存 +if time.Since(cachedResult.Timestamp) > (p.cacheTTL * 4 / 5) { + go p.refreshCacheInBackground(keyword, pluginSpecificCacheKey, searchFunc, cachedResult) +} +``` + +### 11.5 资源管理 + +#### 11.5.1 工作池控制 + +系统实现了工作池机制,限制并发任务数量: + +```go +// 工作池相关变量 +backgroundWorkerPool chan struct{} +backgroundTasksCount int32 = 0 + +// 默认配置值 +defaultMaxBackgroundWorkers = 20 +defaultMaxBackgroundTasks = 100 + +// 尝试获取工作槽 +func acquireWorkerSlot() bool { + // 获取最大任务数 + maxTasks := int32(defaultMaxBackgroundTasks) + if config.AppConfig != nil { + maxTasks = int32(config.AppConfig.AsyncMaxBackgroundTasks) + } + + // 检查总任务数 + if atomic.LoadInt32(&backgroundTasksCount) >= maxTasks { + return false + } + + // 尝试获取工作槽 + select { + case backgroundWorkerPool <- struct{}{}: + atomic.AddInt32(&backgroundTasksCount, 1) + return true + default: + return false + } +} +``` + +#### 11.5.2 统计监控 + +系统记录各种缓存操作的统计数据: + +```go +// 统计数据 (仅用于内部监控) +cacheHits int64 = 0 +cacheMisses int64 = 0 +asyncCompletions int64 = 0 +``` + +### 11.6 配置选项 + +异步缓存系统提供了丰富的配置选项: + +```go +// 异步插件相关配置 +AsyncPluginEnabled bool // 是否启用异步插件 +AsyncResponseTimeout int // 响应超时时间(秒) +AsyncResponseTimeoutDur time.Duration // 响应超时时间(Duration) +AsyncMaxBackgroundWorkers int // 最大后台工作者数量 +AsyncMaxBackgroundTasks int // 最大后台任务数量 +AsyncCacheTTLHours int // 异步缓存有效期(小时) +``` + +### 11.7 性能指标 + +- **缓存命中时响应时间**:< 50ms +- **缓存未命中时响应时间**:约4秒(响应超时时间) +- **后台处理时间**:最长30秒(处理超时时间) +- **缓存命中率**:约90%(经过一段时间运行后) + diff --git a/docs/docker部署(未验证).md b/docs/docker部署(未验证).md new file mode 100644 index 0000000..fa7a077 --- /dev/null +++ b/docs/docker部署(未验证).md @@ -0,0 +1,35 @@ +## 快速开始 + +### 使用Docker部署 + +#### 方法1:使用Docker Compose(推荐) + +1. 下载docker-compose.yml文件 + +```bash +wget https://raw.githubusercontent.com/fish2018/pansou/main/docker-compose.yml +``` + +2. 启动服务 + +```bash +docker-compose up -d +``` + +3. 访问服务 + +``` +http://localhost:8080 +``` + +#### 方法2:直接使用Docker命令 + +```bash +docker run -d --name pansou \ + -p 8080:8080 \ + -v pansou-cache:/app/cache \ + -e CHANNELS="tgsearchers2,SharePanBaidu,yunpanxunlei" \ + -e CACHE_ENABLED=true \ + -e ASYNC_PLUGIN_ENABLED=true \ + ghcr.io/fish2018/pansou:latest +``` \ No newline at end of file diff --git a/docs/插件开发指南.md b/docs/插件开发指南.md index 80025b4..981f7e5 100644 --- a/docs/插件开发指南.md +++ b/docs/插件开发指南.md @@ -7,9 +7,10 @@ 3. [插件开发流程](#插件开发流程) 4. [数据结构标准](#数据结构标准) 5. [超时控制](#超时控制) -6. [最佳实践](#最佳实践) -7. [示例插件](#示例插件) -8. [常见问题](#常见问题) +6. [异步插件开发](#异步插件开发) +7. [最佳实践](#最佳实践) +8. [示例插件](#示例插件) +9. [常见问题](#常见问题) ## 插件系统概述 @@ -20,6 +21,7 @@ PanSou 网盘搜索系统采用了灵活的插件架构,允许开发者轻松 - **双层超时控制**:插件内部使用自定义超时时间,系统外部提供强制超时保障 - **并发执行**:插件搜索与频道搜索并发执行,提高整体性能 - **结果标准化**:插件返回标准化的搜索结果,便于统一处理 +- **异步处理**:支持异步插件,实现"尽快响应,持续处理"的模式 插件系统的核心是全局插件注册表,它在应用启动时收集所有已注册的插件,并在搜索时并行调用这些插件。 @@ -472,6 +474,226 @@ func NewMyPlugin() *MyPlugin { 超时时间通过环境变量 `PLUGIN_TIMEOUT` 配置,默认为 30 秒。 +## 异步插件开发 + +对于响应时间较长的搜索源,建议使用异步插件模式,实现"尽快响应,持续处理"的搜索体验。 + +### 1. 异步插件架构 + +异步插件基于 `BaseAsyncPlugin` 基类实现,具有以下特点: + +- **双级超时控制**:短超时确保快速响应,长超时允许完整处理 +- **缓存机制**:自动缓存搜索结果,避免重复请求 +- **后台处理**:响应超时后继续在后台处理请求 +- **增量更新**:智能合并新旧结果,保留有价值的数据 +- **资源管理**:通过工作池控制并发任务数量,避免资源耗尽 + +### 2. 创建异步插件 + +#### 2.1 基本结构 + +```go +package myplugin_async + +import ( + "net/http" + + "pansou/model" + "pansou/plugin" +) + +// MyAsyncPlugin 自定义异步插件结构体 +type MyAsyncPlugin struct { + *plugin.BaseAsyncPlugin +} + +// NewMyAsyncPlugin 创建新的异步插件实例 +func NewMyAsyncPlugin() *MyAsyncPlugin { + return &MyAsyncPlugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin_async", 3), + } +} + +// 在init函数中注册插件 +func init() { + plugin.RegisterGlobalPlugin(NewMyAsyncPlugin()) +} +``` + +#### 2.2 实现Search方法 + +```go +// Search 执行搜索并返回结果 +func (p *MyAsyncPlugin) Search(keyword string) ([]model.SearchResult, error) { + // 生成缓存键 + cacheKey := keyword + + // 使用异步搜索基础方法 + return p.AsyncSearch(keyword, cacheKey, p.doSearch) +} + +// doSearch 实际的搜索实现 +func (p *MyAsyncPlugin) doSearch(client *http.Client, keyword string) ([]model.SearchResult, error) { + // 实现具体搜索逻辑 + // 注意:client已经配置了适当的超时时间 + // ... + + return results, nil +} +``` + +### 3. 异步搜索流程 + +异步搜索的工作流程如下: + +1. **缓存检查**:首先检查是否有有效缓存 +2. **快速响应**:如果有缓存,立即返回;如果缓存接近过期,在后台刷新 +3. **双通道处理**:如果没有缓存,启动快速响应通道和后台处理通道 +4. **超时控制**:在响应超时时返回当前结果(可能为空),后台继续处理 +5. **缓存更新**:后台处理完成后更新缓存,供后续查询使用 + +### 4. 异步缓存机制 + +#### 4.1 缓存键设计 + +异步插件使用插件特定的缓存键,确保不同插件的缓存不会相互干扰: + +```go +// 在AsyncSearch方法中 +pluginSpecificCacheKey := fmt.Sprintf("%s:%s", p.name, cacheKey) +``` + +#### 4.2 缓存结构 + +```go +// 缓存响应结构 +type cachedResponse struct { + Results []model.SearchResult // 搜索结果 + Timestamp time.Time // 缓存创建时间 + Complete bool // 是否完整结果 + LastAccess time.Time // 最后访问时间 + AccessCount int // 访问计数 +} +``` + +#### 4.3 缓存持久化 + +异步插件缓存会自动保存到磁盘,并在程序启动时加载: + +- 定期保存:每2分钟保存一次缓存 +- 即时保存:缓存更新后立即触发保存 +- 优雅关闭:程序退出前保存缓存 + +#### 4.4 智能缓存管理 + +系统实现了基于多因素的缓存淘汰策略: + +```go +// 计算得分:访问次数 / (空闲时间的平方 * 年龄) +// 这样: +// - 访问频率高的得分高 +// - 最近访问的得分高 +// - 较新的缓存得分高 +score := float64(item.AccessCount) / (idleTime.Seconds() * idleTime.Seconds() * age.Seconds()) +``` + +### 5. 异步插件示例 + +#### 5.1 混合盘异步插件 + +```go +// HunhepanAsyncPlugin 混合盘搜索异步插件 +type HunhepanAsyncPlugin struct { + *plugin.BaseAsyncPlugin +} + +// NewHunhepanAsyncPlugin 创建新的混合盘搜索异步插件 +func NewHunhepanAsyncPlugin() *HunhepanAsyncPlugin { + return &HunhepanAsyncPlugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("hunhepan_async", 3), + } +} + +// Search 执行搜索并返回结果 +func (p *HunhepanAsyncPlugin) Search(keyword string) ([]model.SearchResult, error) { + // 生成缓存键 + cacheKey := keyword + + // 使用异步搜索基础方法 + return p.AsyncSearch(keyword, cacheKey, p.doSearch) +} + +// doSearch 实际的搜索实现 +func (p *HunhepanAsyncPlugin) doSearch(client *http.Client, keyword string) ([]model.SearchResult, error) { + // 创建结果通道和错误通道 + resultChan := make(chan []HunhepanItem, 3) + errChan := make(chan error, 3) + + // 创建等待组 + var wg sync.WaitGroup + wg.Add(3) + + // 并行请求三个API + go func() { + defer wg.Done() + items, err := p.searchAPI(client, HunhepanAPI, keyword) + if err != nil { + errChan <- fmt.Errorf("hunhepan API error: %w", err) + return + } + resultChan <- items + }() + + // ... 其他API请求 ... + + // 启动一个goroutine等待所有请求完成并关闭通道 + go func() { + wg.Wait() + close(resultChan) + close(errChan) + }() + + // 收集结果 + var allItems []HunhepanItem + var errors []error + + // 从通道读取结果 + for items := range resultChan { + allItems = append(allItems, items...) + } + + // 处理错误 + for err := range errChan { + errors = append(errors, err) + } + + // 如果没有获取到任何结果且有错误,则返回第一个错误 + if len(allItems) == 0 && len(errors) > 0 { + return nil, errors[0] + } + + // 去重处理 + uniqueItems := p.deduplicateItems(allItems) + + // 转换为标准格式 + results := p.convertResults(uniqueItems) + + return results, nil +} +``` + +### 6. 配置选项 + +异步插件系统提供了多种配置选项,可通过环境变量设置: + +``` +ASYNC_PLUGIN_ENABLED=true # 是否启用异步插件 +ASYNC_RESPONSE_TIMEOUT=4 # 响应超时时间(秒) +ASYNC_MAX_BACKGROUND_WORKERS=20 # 最大后台工作者数量 +ASYNC_MAX_BACKGROUND_TASKS=100 # 最大后台任务数量 +ASYNC_CACHE_TTL_HOURS=1 # 异步缓存有效期(小时) +``` + ## 最佳实践 ### 1. 错误处理 @@ -752,6 +974,356 @@ type ApiItem struct { } ``` + +## 插件缓存实现 + +### 1. 缓存概述 + +插件级缓存是对系统整体两级缓存(内存+磁盘)的补充,主要针对插件内部的API调用和数据处理进行优化,减少重复计算和网络请求,提高系统整体性能和响应速度。 + +### 2. 设计目标 + +1. **减少重复请求**:避免短时间内对同一资源的重复请求,降低外部API负载 +2. **提高响应速度**:通过缓存常用查询结果,减少网络延迟和处理时间 +3. **降低资源消耗**:减少CPU和网络资源的使用 +4. **保持数据新鲜度**:通过合理的缓存过期策略,平衡性能和数据时效性 +5. **线程安全**:支持并发访问,避免竞态条件 +6. **内存管理**:防止内存泄漏,控制缓存大小 + +### 3. 架构设计 + +#### 3.1 整体架构 + +插件缓存采用分层设计,主要包含以下组件: + +``` +┌─────────────────────────┐ +│ 插件缓存系统 │ +└───────────┬─────────────┘ + │ +┌───────────▼─────────────┐ +│ 缓存存储层 │ +│ (sync.Map实现的内存缓存) │ +└───────────┬─────────────┘ + │ +┌───────────▼─────────────┐ +│ 缓存管理层 │ +│ (缓存清理、过期策略、统计) │ +└───────────┬─────────────┘ + │ +┌───────────▼─────────────┐ +│ 缓存接口层 │ +│ (Load/Store操作封装) │ +└─────────────────────────┘ +``` + +#### 3.2 缓存类型 + +根据不同插件的需求,可以实现多种类型的缓存: + +1. **API响应缓存**:缓存外部API的响应结果 +2. **解析结果缓存**:缓存HTML解析、正则匹配等计算密集型操作的结果 +3. **链接提取缓存**:缓存从文本中提取的链接结果 +4. **元数据缓存**:缓存帖子ID、发布时间等元数据信息 + +### 4. 核心组件 + +#### 4.1 缓存存储 + +使用`sync.Map`实现线程安全的内存缓存: + +```go +// 缓存相关变量 +var ( + // API响应缓存,键为"apiURL:keyword",值为缓存的响应 + apiResponseCache = sync.Map{} + + // 最后一次清理缓存的时间 + lastCacheCleanTime = time.Now() + + // 缓存有效期 + cacheTTL = 1 * time.Hour +) +``` + +#### 4.2 缓存结构 + +每个缓存项包含数据和时间戳,用于判断是否过期: + +```go +// 缓存响应结构 +type cachedResponse struct { + data interface{} // 缓存的数据 + timestamp time.Time // 缓存创建时间 +} +``` + +#### 4.3 缓存清理机制 + +定期清理过期缓存,防止内存泄漏: + +```go +// startCacheCleaner 启动一个定期清理缓存的goroutine +func startCacheCleaner() { + // 每小时清理一次缓存 + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for range ticker.C { + // 清空所有缓存 + apiResponseCache = sync.Map{} + lastCacheCleanTime = time.Now() + } +} +``` + +### 5. 实现方案 + +#### 5.1 初始化缓存 + +在插件初始化时启动缓存清理机制: + +```go +func init() { + // 注册插件 + plugin.RegisterGlobalPlugin(NewPlugin()) + + // 启动缓存清理goroutine + go startCacheCleaner() +} +``` + +#### 5.2 缓存键生成 + +设计合理的缓存键,确保唯一性和高效查找: + +```go +// 生成缓存键 +cacheKey := fmt.Sprintf("%s:%s", apiURL, keyword) +``` + +对于复杂的缓存键,可以使用结构体: + +```go +// 缓存键结构 +type passwordCacheKey struct { + content string + url string +} +``` + +#### 5.3 缓存读取 + +在执行操作前先检查缓存: + +```go +// 检查缓存中是否已有结果 +if cachedItems, ok := apiResponseCache.Load(cacheKey); ok { + // 检查缓存是否过期 + cachedResult := cachedItems.(cachedResponse) + if time.Since(cachedResult.timestamp) < cacheTTL { + return cachedResult.items, nil + } +} +``` + +#### 5.4 缓存写入 + +操作完成后更新缓存: + +```go +// 缓存结果 +apiResponseCache.Store(cacheKey, cachedResponse{ + items: result, + timestamp: time.Now(), +}) +``` + +#### 5.5 缓存过期策略 + +使用TTL(Time-To-Live)机制控制缓存过期: + +```go +// 检查缓存是否过期 +if time.Since(cachedResult.timestamp) < cacheTTL { + return cachedResult.items, nil +} +``` + +### 6. 具体实现案例 + +#### 6.1 API响应缓存(Hunhepan插件) + +```go +// searchAPI 向单个API发送请求 +func (p *HunhepanPlugin) searchAPI(apiURL, keyword string) ([]HunhepanItem, error) { + // 生成缓存键 + cacheKey := fmt.Sprintf("%s:%s", apiURL, keyword) + + // 检查缓存中是否已有结果 + if cachedItems, ok := apiResponseCache.Load(cacheKey); ok { + // 检查缓存是否过期 + cachedResult := cachedItems.(cachedResponse) + if time.Since(cachedResult.timestamp) < cacheTTL { + return cachedResult.items, nil + } + } + + // 构建请求并发送... + + // 缓存结果 + apiResponseCache.Store(cacheKey, cachedResponse{ + items: apiResp.Data.List, + timestamp: time.Now(), + }) + + return apiResp.Data.List, nil +} +``` + +#### 6.2 解析结果缓存(Panta插件) + +```go +// extractLinksFromElement 从HTML元素中提取链接 +func (p *PantaPlugin) extractLinksFromElement(s *goquery.Selection, yearFromTitle string) []model.Link { + // 创建缓存键 + html, _ := s.Html() + cacheKey := fmt.Sprintf("%s:%s", html, yearFromTitle) + + // 检查缓存 + if cachedLinks, ok := linkExtractCache.Load(cacheKey); ok { + return cachedLinks.([]model.Link) + } + + // 提取链接... + + // 缓存结果 + linkExtractCache.Store(cacheKey, links) + + return links +} +``` + +#### 6.3 元数据缓存(Panta插件) + +```go +// 从href中提取topicId - 使用缓存 +var topicID string +if cachedID, ok := topicIDCache.Load(href); ok { + topicID = cachedID.(string) +} else { + match := topicIDRegex.FindStringSubmatch(href) + if len(match) < 2 { + return + } + topicID = match[1] + topicIDCache.Store(href, topicID) +} +``` + +### 7. 性能优化 + +#### 7.1 缓存粒度控制 + +根据数据特性选择合适的缓存粒度: + +1. **粗粒度缓存**:缓存整个API响应,适合查询结果较小且稳定的场景 +2. **细粒度缓存**:缓存处理过程中的中间结果,适合复杂处理流程 + +#### 7.2 缓存预热 + +对于常用查询,可以实现缓存预热机制: + +```go +// 预热常用关键词的缓存 +func warmupCache() { + commonKeywords := []string{"电影", "音乐", "软件", "教程"} + for _, keyword := range commonKeywords { + go func(kw string) { + _, _ = searchAPI(apiURL, kw) + }(keyword) + } +} +``` + +#### 7.3 自适应TTL + +根据数据更新频率动态调整缓存有效期: + +```go +// 根据内容类型确定TTL +func determineTTL(contentType string) time.Duration { + switch contentType { + case "movie": + return 24 * time.Hour // 电影资源更新较慢 + case "news": + return 30 * time.Minute // 新闻更新较快 + default: + return 1 * time.Hour + } +} +``` + +### 8. 监控与统计 + +#### 8.1 缓存命中率统计 + +```go +var ( + cacheHits int64 + cacheMisses int64 +) + +// 记录缓存命中 +func recordCacheHit() { + atomic.AddInt64(&cacheHits, 1) +} + +// 记录缓存未命中 +func recordCacheMiss() { + atomic.AddInt64(&cacheMisses, 1) +} + +// 获取缓存命中率 +func getCacheHitRate() float64 { + hits := atomic.LoadInt64(&cacheHits) + misses := atomic.LoadInt64(&cacheMisses) + total := hits + misses + if total == 0 { + return 0 + } + return float64(hits) / float64(total) +} +``` + +#### 8.2 缓存大小监控 + +```go +// 估算缓存大小 +func estimateCacheSize() int64 { + var size int64 + apiResponseCache.Range(func(key, value interface{}) bool { + // 估算每个缓存项的大小 + cachedResp := value.(cachedResponse) + // 根据实际数据结构估算大小 + size += int64(len(fmt.Sprintf("%v", cachedResp.data))) * 2 // 粗略估计 + return true + }) + return size +} +``` + +### 9. 最佳实践 + +1. **选择性缓存**:只缓存频繁访问且计算成本高的数据 +2. **合理的TTL**:根据数据更新频率设置合适的过期时间 +3. **缓存键设计**:确保缓存键唯一且能反映所有影响结果的因素 +4. **错误处理**:不缓存错误响应,避免错误传播 +5. **缓存大小控制**:设置缓存项数量上限,避免内存溢出 +6. **并发安全**:使用线程安全的数据结构和原子操作 +7. **定期清理**:实现自动清理机制,避免内存泄漏 + + ## 常见问题 ### 1. 插件注册失败 diff --git a/main.go b/main.go index e5b975b..fa8bac3 100644 --- a/main.go +++ b/main.go @@ -1,20 +1,26 @@ package main import ( + "context" "fmt" "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" "pansou/api" "pansou/config" "pansou/plugin" // 以下是插件的空导入,用于触发各插件的init函数,实现自动注册 // 添加新插件时,只需在此处添加对应的导入语句即可 + _ "pansou/plugin/hunhepan" _ "pansou/plugin/jikepan" - _ "pansou/plugin/hunhepan" - _ "pansou/plugin/pansearch" - _ "pansou/plugin/qupansou" _ "pansou/plugin/pan666" - _ "pansou/plugin/panta" // 添加PanTa网站插件 + _ "pansou/plugin/pansearch" + _ "pansou/plugin/panta" + _ "pansou/plugin/qupansou" "pansou/service" "pansou/util" ) @@ -34,6 +40,9 @@ func initApp() { // 初始化HTTP客户端 util.InitHTTPClient() + + // 确保异步插件系统初始化 + plugin.InitAsyncPluginSystem() } // startServer 启动Web服务器 @@ -56,10 +65,40 @@ func startServer() { // 输出服务信息 printServiceInfo(port, pluginManager) - // 启动Web服务器 - if err := router.Run(":" + port); err != nil { - log.Fatalf("启动服务器失败: %v", err) + // 创建HTTP服务器 + srv := &http.Server{ + Addr: ":" + port, + Handler: router, } + + // 创建通道来接收操作系统信号 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + // 在单独的goroutine中启动服务器 + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("启动服务器失败: %v", err) + } + }() + + // 等待中断信号 + <-quit + fmt.Println("正在关闭服务器...") + + // 设置关闭超时时间 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 保存异步插件缓存 + plugin.SaveCacheToDisk() + + // 优雅关闭服务器 + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("服务器关闭异常: %v", err) + } + + fmt.Println("服务器已安全关闭") } // printServiceInfo 打印服务信息 @@ -97,6 +136,17 @@ func printServiceInfo(port string, pluginManager *plugin.PluginManager) { config.AppConfig.GCPercent, config.AppConfig.OptimizeMemory) + // 输出异步插件配置信息 + if config.AppConfig.AsyncPluginEnabled { + fmt.Printf("异步插件已启用: 响应超时=%d秒, 最大工作者=%d, 最大任务=%d, 缓存TTL=%d小时\n", + config.AppConfig.AsyncResponseTimeout, + config.AppConfig.AsyncMaxBackgroundWorkers, + config.AppConfig.AsyncMaxBackgroundTasks, + config.AppConfig.AsyncCacheTTLHours) + } else { + fmt.Println("异步插件已禁用") + } + // 输出插件信息 fmt.Println("已加载插件:") for _, p := range pluginManager.GetPlugins() { diff --git a/plugin/baseasyncplugin.go b/plugin/baseasyncplugin.go new file mode 100644 index 0000000..176ec6b --- /dev/null +++ b/plugin/baseasyncplugin.go @@ -0,0 +1,802 @@ +package plugin + +import ( + "compress/gzip" + "encoding/gob" + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "sync" + "sync/atomic" + "time" + + "pansou/config" + "pansou/model" +) + +// 缓存相关变量 +var ( + // API响应缓存,键为关键词,值为缓存的响应 + apiResponseCache = sync.Map{} + + // 最后一次清理缓存的时间 + lastCacheCleanTime = time.Now() + + // 最后一次保存缓存的时间 + lastCacheSaveTime = time.Now() + + // 工作池相关变量 + backgroundWorkerPool chan struct{} + backgroundTasksCount int32 = 0 + + // 统计数据 (仅用于内部监控) + cacheHits int64 = 0 + cacheMisses int64 = 0 + asyncCompletions int64 = 0 + + // 初始化标志 + initialized bool = false + initLock sync.Mutex + + // 默认配置值 + defaultAsyncResponseTimeout = 4 * time.Second + defaultPluginTimeout = 30 * time.Second + defaultCacheTTL = 1 * time.Hour + defaultMaxBackgroundWorkers = 20 + defaultMaxBackgroundTasks = 100 + + // 缓存保存间隔 (2分钟) + cacheSaveInterval = 2 * time.Minute + + // 缓存访问频率记录 + cacheAccessCount = sync.Map{} +) + +// 缓存响应结构 +type cachedResponse struct { + Results []model.SearchResult `json:"results"` + Timestamp time.Time `json:"timestamp"` + Complete bool `json:"complete"` + LastAccess time.Time `json:"last_access"` + AccessCount int `json:"access_count"` +} + +// 可序列化的缓存结构,用于持久化 +type persistentCache struct { + Entries map[string]cachedResponse +} + +// initAsyncPlugin 初始化异步插件配置 +func initAsyncPlugin() { + initLock.Lock() + defer initLock.Unlock() + + if initialized { + return + } + + // 如果配置已加载,则从配置读取工作池大小 + maxWorkers := defaultMaxBackgroundWorkers + if config.AppConfig != nil { + maxWorkers = config.AppConfig.AsyncMaxBackgroundWorkers + } + + backgroundWorkerPool = make(chan struct{}, maxWorkers) + + // 启动缓存清理和保存goroutine + go startCacheCleaner() + go startCachePersistence() + + // 尝试从磁盘加载缓存 + loadCacheFromDisk() + + initialized = true +} + +// InitAsyncPluginSystem 导出的初始化函数,用于确保异步插件系统初始化 +func InitAsyncPluginSystem() { + initAsyncPlugin() +} + +// startCacheCleaner 启动一个定期清理缓存的goroutine +func startCacheCleaner() { + // 每小时清理一次缓存 + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for range ticker.C { + cleanCache() + } +} + +// cleanCache 清理过期缓存 +func cleanCache() { + now := time.Now() + lastCacheCleanTime = now + + // 获取缓存TTL + cacheTTL := defaultCacheTTL + if config.AppConfig != nil && config.AppConfig.AsyncCacheTTLHours > 0 { + cacheTTL = time.Duration(config.AppConfig.AsyncCacheTTLHours) * time.Hour + } + + // 第一步:收集所有缓存项和它们的信息 + type cacheInfo struct { + key string + item cachedResponse + age time.Duration // 年龄(当前时间 - 创建时间) + idleTime time.Duration // 空闲时间(当前时间 - 最后访问时间) + score float64 // 缓存项得分(用于决定是否保留) + } + + var allItems []cacheInfo + var totalSize int = 0 + + apiResponseCache.Range(func(k, v interface{}) bool { + key := k.(string) + item := v.(cachedResponse) + age := now.Sub(item.Timestamp) + idleTime := now.Sub(item.LastAccess) + + // 如果超过TTL,直接删除 + if age > cacheTTL { + apiResponseCache.Delete(key) + return true + } + + // 计算大小(简单估算,每个结果占用1单位) + itemSize := len(item.Results) + totalSize += itemSize + + // 计算得分:访问次数 / (空闲时间的平方 * 年龄) + // 这样: + // - 访问频率高的得分高 + // - 最近访问的得分高 + // - 较新的缓存得分高 + score := float64(item.AccessCount) / (idleTime.Seconds() * idleTime.Seconds() * age.Seconds()) + + allItems = append(allItems, cacheInfo{ + key: key, + item: item, + age: age, + idleTime: idleTime, + score: score, + }) + + return true + }) + + // 获取缓存大小限制(默认10000项) + maxCacheSize := 10000 + if config.AppConfig != nil && config.AppConfig.CacheMaxSizeMB > 0 { + // 这里我们将MB转换为大致的项目数,假设每项平均1KB + maxCacheSize = config.AppConfig.CacheMaxSizeMB * 1024 + } + + // 如果缓存不超过限制,不需要清理 + if totalSize <= maxCacheSize { + return + } + + // 按得分排序(从低到高) + sort.Slice(allItems, func(i, j int) bool { + return allItems[i].score < allItems[j].score + }) + + // 需要删除的大小 + sizeToRemove := totalSize - maxCacheSize + + // 从得分低的开始删除,直到满足大小要求 + removedSize := 0 + removedCount := 0 + + for _, item := range allItems { + if removedSize >= sizeToRemove { + break + } + + apiResponseCache.Delete(item.key) + removedSize += len(item.item.Results) + removedCount++ + + // 最多删除总数的20% + if removedCount >= len(allItems) / 5 { + break + } + } + + fmt.Printf("缓存清理完成: 删除了%d个项目(总共%d个)\n", removedCount, len(allItems)) +} + +// startCachePersistence 启动定期保存缓存到磁盘的goroutine +func startCachePersistence() { + // 每2分钟保存一次缓存 + ticker := time.NewTicker(cacheSaveInterval) + defer ticker.Stop() + + for range ticker.C { + // 检查是否有缓存项需要保存 + if hasCacheItems() { + saveCacheToDisk() + } + } +} + +// hasCacheItems 检查是否有缓存项 +func hasCacheItems() bool { + hasItems := false + apiResponseCache.Range(func(k, v interface{}) bool { + hasItems = true + return false // 找到一个就停止遍历 + }) + return hasItems +} + +// getCachePath 获取缓存文件路径 +func getCachePath() string { + // 默认缓存路径 + cachePath := "cache" + + // 如果配置已加载,则使用配置中的缓存路径 + if config.AppConfig != nil && config.AppConfig.CachePath != "" { + cachePath = config.AppConfig.CachePath + } + + // 创建缓存目录(如果不存在) + if _, err := os.Stat(cachePath); os.IsNotExist(err) { + os.MkdirAll(cachePath, 0755) + } + + return filepath.Join(cachePath, "async_cache.gob") +} + +// saveCacheToDisk 将缓存保存到磁盘 +func saveCacheToDisk() { + cacheFile := getCachePath() + lastCacheSaveTime = time.Now() + + // 创建临时文件 + tempFile := cacheFile + ".tmp" + file, err := os.Create(tempFile) + if err != nil { + fmt.Printf("创建缓存文件失败: %v\n", err) + return + } + defer file.Close() + + // 创建gzip压缩写入器 + gzipWriter := gzip.NewWriter(file) + defer gzipWriter.Close() + + // 将缓存内容转换为可序列化的结构 + persistent := persistentCache{ + Entries: make(map[string]cachedResponse), + } + + // 记录缓存项数量和总结果数 + itemCount := 0 + resultCount := 0 + + apiResponseCache.Range(func(k, v interface{}) bool { + key := k.(string) + value := v.(cachedResponse) + persistent.Entries[key] = value + itemCount++ + resultCount += len(value.Results) + return true + }) + + // 使用gob编码器保存 + encoder := gob.NewEncoder(gzipWriter) + if err := encoder.Encode(persistent); err != nil { + fmt.Printf("编码缓存失败: %v\n", err) + return + } + + // 确保所有数据已写入 + gzipWriter.Close() + file.Sync() + file.Close() + + // 使用原子重命名(这确保了替换是原子的,避免了缓存文件损坏) + if err := os.Rename(tempFile, cacheFile); err != nil { + fmt.Printf("重命名缓存文件失败: %v\n", err) + return + } + + // fmt.Printf("缓存已保存到磁盘,条目数: %d,结果总数: %d\n", itemCount, resultCount) +} + +// SaveCacheToDisk 导出的缓存保存函数,用于程序退出时调用 +func SaveCacheToDisk() { + if initialized { + // fmt.Println("程序退出,正在保存异步插件缓存...") + saveCacheToDisk() + // fmt.Println("异步插件缓存保存完成") + } +} + +// loadCacheFromDisk 从磁盘加载缓存 +func loadCacheFromDisk() { + cacheFile := getCachePath() + + // 检查缓存文件是否存在 + if _, err := os.Stat(cacheFile); os.IsNotExist(err) { + // fmt.Println("缓存文件不存在,跳过加载") + return + } + + // 打开缓存文件 + file, err := os.Open(cacheFile) + if err != nil { + // fmt.Printf("打开缓存文件失败: %v\n", err) + return + } + defer file.Close() + + // 创建gzip读取器 + gzipReader, err := gzip.NewReader(file) + if err != nil { + // 尝试作为非压缩文件读取(向后兼容) + file.Seek(0, 0) // 重置文件指针 + decoder := gob.NewDecoder(file) + var persistent persistentCache + if err := decoder.Decode(&persistent); err != nil { + fmt.Printf("解码缓存失败: %v\n", err) + return + } + loadCacheEntries(persistent) + return + } + defer gzipReader.Close() + + // 使用gob解码器加载 + var persistent persistentCache + decoder := gob.NewDecoder(gzipReader) + if err := decoder.Decode(&persistent); err != nil { + fmt.Printf("解码缓存失败: %v\n", err) + return + } + + loadCacheEntries(persistent) +} + +// loadCacheEntries 加载缓存条目到内存 +func loadCacheEntries(persistent persistentCache) { + // 获取缓存TTL,用于过滤过期项 + cacheTTL := defaultCacheTTL + if config.AppConfig != nil && config.AppConfig.AsyncCacheTTLHours > 0 { + cacheTTL = time.Duration(config.AppConfig.AsyncCacheTTLHours) * time.Hour + } + + now := time.Now() + loadedCount := 0 + totalResultCount := 0 + + // 将解码后的缓存加载到内存 + for key, value := range persistent.Entries { + // 只加载未过期的缓存 + if now.Sub(value.Timestamp) <= cacheTTL { + apiResponseCache.Store(key, value) + loadedCount++ + totalResultCount += len(value.Results) + } + } + + // fmt.Printf("从磁盘加载了%d条缓存(过滤后),包含%d个搜索结果\n", loadedCount, totalResultCount) +} + +// acquireWorkerSlot 尝试获取工作槽 +func acquireWorkerSlot() bool { + // 获取最大任务数 + maxTasks := int32(defaultMaxBackgroundTasks) + if config.AppConfig != nil { + maxTasks = int32(config.AppConfig.AsyncMaxBackgroundTasks) + } + + // 检查总任务数 + if atomic.LoadInt32(&backgroundTasksCount) >= maxTasks { + return false + } + + // 尝试获取工作槽 + select { + case backgroundWorkerPool <- struct{}{}: + atomic.AddInt32(&backgroundTasksCount, 1) + return true + default: + return false + } +} + +// releaseWorkerSlot 释放工作槽 +func releaseWorkerSlot() { + <-backgroundWorkerPool + atomic.AddInt32(&backgroundTasksCount, -1) +} + +// recordCacheHit 记录缓存命中 (内部使用) +func recordCacheHit() { + atomic.AddInt64(&cacheHits, 1) +} + +// recordCacheMiss 记录缓存未命中 (内部使用) +func recordCacheMiss() { + atomic.AddInt64(&cacheMisses, 1) +} + +// recordAsyncCompletion 记录异步完成 (内部使用) +func recordAsyncCompletion() { + atomic.AddInt64(&asyncCompletions, 1) +} + +// recordCacheAccess 记录缓存访问次数,用于智能缓存策略 +func recordCacheAccess(key string) { + // 更新缓存项的访问时间和计数 + if cached, ok := apiResponseCache.Load(key); ok { + cachedItem := cached.(cachedResponse) + cachedItem.LastAccess = time.Now() + cachedItem.AccessCount++ + apiResponseCache.Store(key, cachedItem) + } + + // 更新全局访问计数 + if count, ok := cacheAccessCount.Load(key); ok { + cacheAccessCount.Store(key, count.(int) + 1) + } else { + cacheAccessCount.Store(key, 1) + } +} + +// BaseAsyncPlugin 基础异步插件结构 +type BaseAsyncPlugin struct { + name string + priority int + client *http.Client // 用于短超时的客户端 + backgroundClient *http.Client // 用于长超时的客户端 + cacheTTL time.Duration // 缓存有效期 +} + +// NewBaseAsyncPlugin 创建基础异步插件 +func NewBaseAsyncPlugin(name string, priority int) *BaseAsyncPlugin { + // 确保异步插件已初始化 + if !initialized { + initAsyncPlugin() + } + + // 确定超时和缓存时间 + responseTimeout := defaultAsyncResponseTimeout + processingTimeout := defaultPluginTimeout + cacheTTL := defaultCacheTTL + + // 如果配置已初始化,则使用配置中的值 + if config.AppConfig != nil { + responseTimeout = config.AppConfig.AsyncResponseTimeoutDur + processingTimeout = config.AppConfig.PluginTimeout + cacheTTL = time.Duration(config.AppConfig.AsyncCacheTTLHours) * time.Hour + } + + return &BaseAsyncPlugin{ + name: name, + priority: priority, + client: &http.Client{ + Timeout: responseTimeout, + }, + backgroundClient: &http.Client{ + Timeout: processingTimeout, + }, + cacheTTL: cacheTTL, + } +} + +// Name 返回插件名称 +func (p *BaseAsyncPlugin) Name() string { + return p.name +} + +// Priority 返回插件优先级 +func (p *BaseAsyncPlugin) Priority() int { + return p.priority +} + +// AsyncSearch 异步搜索基础方法 +func (p *BaseAsyncPlugin) AsyncSearch( + keyword string, + cacheKey string, + searchFunc func(*http.Client, string) ([]model.SearchResult, error), +) ([]model.SearchResult, error) { + now := time.Now() + + // 修改缓存键,确保包含插件名称 + pluginSpecificCacheKey := fmt.Sprintf("%s:%s", p.name, cacheKey) + + // 检查缓存 + if cachedItems, ok := apiResponseCache.Load(pluginSpecificCacheKey); ok { + cachedResult := cachedItems.(cachedResponse) + + // 缓存完全有效(未过期且完整) + if time.Since(cachedResult.Timestamp) < p.cacheTTL && cachedResult.Complete { + recordCacheHit() + recordCacheAccess(pluginSpecificCacheKey) + + // 如果缓存接近过期(已用时间超过TTL的80%),在后台刷新缓存 + if time.Since(cachedResult.Timestamp) > (p.cacheTTL * 4 / 5) { + go p.refreshCacheInBackground(keyword, pluginSpecificCacheKey, searchFunc, cachedResult) + } + + return cachedResult.Results, nil + } + + // 缓存已过期但有结果,启动后台刷新,同时返回旧结果 + if len(cachedResult.Results) > 0 { + recordCacheHit() + recordCacheAccess(pluginSpecificCacheKey) + + // 标记为部分过期 + if time.Since(cachedResult.Timestamp) >= p.cacheTTL { + // 在后台刷新缓存 + go p.refreshCacheInBackground(keyword, pluginSpecificCacheKey, searchFunc, cachedResult) + + // 日志记录 + fmt.Printf("[%s] 缓存已过期,后台刷新中: %s (已过期: %v)\n", + p.name, pluginSpecificCacheKey, time.Since(cachedResult.Timestamp)) + } + + return cachedResult.Results, nil + } + } + + recordCacheMiss() + + // 创建通道 + resultChan := make(chan []model.SearchResult, 1) + errorChan := make(chan error, 1) + doneChan := make(chan struct{}) + + // 启动后台处理 + go func() { + // 尝试获取工作槽 + if !acquireWorkerSlot() { + // 工作池已满,使用快速响应客户端直接处理 + results, err := searchFunc(p.client, keyword) + if err != nil { + select { + case errorChan <- err: + default: + } + return + } + + select { + case resultChan <- results: + default: + } + + // 缓存结果 + apiResponseCache.Store(pluginSpecificCacheKey, cachedResponse{ + Results: results, + Timestamp: now, + Complete: true, + LastAccess: now, + AccessCount: 1, + }) + return + } + defer releaseWorkerSlot() + + // 执行搜索 + results, err := searchFunc(p.backgroundClient, keyword) + + // 检查是否已经响应 + select { + case <-doneChan: + // 已经响应,只更新缓存 + if err == nil && len(results) > 0 { + // 检查是否存在旧缓存 + var accessCount int = 1 + var lastAccess time.Time = now + + if oldCache, ok := apiResponseCache.Load(pluginSpecificCacheKey); ok { + oldCachedResult := oldCache.(cachedResponse) + accessCount = oldCachedResult.AccessCount + lastAccess = oldCachedResult.LastAccess + + // 合并结果(新结果优先) + if len(oldCachedResult.Results) > 0 { + // 创建合并结果集 + mergedResults := make([]model.SearchResult, 0, len(results) + len(oldCachedResult.Results)) + + // 创建已有结果ID的映射 + existingIDs := make(map[string]bool) + for _, r := range results { + existingIDs[r.UniqueID] = true + mergedResults = append(mergedResults, r) + } + + // 添加旧结果中不存在的项 + for _, r := range oldCachedResult.Results { + if !existingIDs[r.UniqueID] { + mergedResults = append(mergedResults, r) + } + } + + // 使用合并结果 + results = mergedResults + + // 日志记录 + // fmt.Printf("[%s] 增量更新缓存: %s (新项目: %d, 合并项目: %d)\n", p.name, pluginSpecificCacheKey, len(existingIDs), len(mergedResults)) + } + } + + apiResponseCache.Store(pluginSpecificCacheKey, cachedResponse{ + Results: results, + Timestamp: now, + Complete: true, + LastAccess: lastAccess, + AccessCount: accessCount, + }) + recordAsyncCompletion() + + // 更新缓存后立即触发保存 + go saveCacheToDisk() + } + default: + // 尚未响应,发送结果 + if err != nil { + select { + case errorChan <- err: + default: + } + } else { + // 检查是否存在旧缓存用于合并 + if oldCache, ok := apiResponseCache.Load(pluginSpecificCacheKey); ok { + oldCachedResult := oldCache.(cachedResponse) + if len(oldCachedResult.Results) > 0 { + // 创建合并结果集 + mergedResults := make([]model.SearchResult, 0, len(results) + len(oldCachedResult.Results)) + + // 创建已有结果ID的映射 + existingIDs := make(map[string]bool) + for _, r := range results { + existingIDs[r.UniqueID] = true + mergedResults = append(mergedResults, r) + } + + // 添加旧结果中不存在的项 + for _, r := range oldCachedResult.Results { + if !existingIDs[r.UniqueID] { + mergedResults = append(mergedResults, r) + } + } + + // 使用合并结果 + results = mergedResults + } + } + + select { + case resultChan <- results: + default: + } + + // 更新缓存 + apiResponseCache.Store(pluginSpecificCacheKey, cachedResponse{ + Results: results, + Timestamp: now, + Complete: true, + LastAccess: now, + AccessCount: 1, + }) + + // 更新缓存后立即触发保存 + go saveCacheToDisk() + } + } + }() + + // 获取响应超时时间 + responseTimeout := defaultAsyncResponseTimeout + if config.AppConfig != nil { + responseTimeout = config.AppConfig.AsyncResponseTimeoutDur + } + + // 等待响应超时或结果 + select { + case results := <-resultChan: + close(doneChan) + return results, nil + case err := <-errorChan: + close(doneChan) + return nil, err + case <-time.After(responseTimeout): + // 响应超时,返回空结果,后台继续处理 + go func() { + defer close(doneChan) + }() + + // 检查是否有部分缓存可用 + if cachedItems, ok := apiResponseCache.Load(pluginSpecificCacheKey); ok { + cachedResult := cachedItems.(cachedResponse) + if len(cachedResult.Results) > 0 { + // 有部分缓存可用,记录访问并返回 + recordCacheAccess(pluginSpecificCacheKey) + fmt.Printf("[%s] 响应超时,返回部分缓存: %s (项目数: %d)\n", + p.name, pluginSpecificCacheKey, len(cachedResult.Results)) + return cachedResult.Results, nil + } + } + + // 创建空的临时缓存,以便后台处理完成后可以更新 + apiResponseCache.Store(pluginSpecificCacheKey, cachedResponse{ + Results: []model.SearchResult{}, + Timestamp: now, + Complete: false, // 标记为不完整 + LastAccess: now, + AccessCount: 1, + }) + + // fmt.Printf("[%s] 响应超时,后台继续处理: %s\n", p.name, pluginSpecificCacheKey) + return []model.SearchResult{}, nil + } +} + +// refreshCacheInBackground 在后台刷新缓存 +func (p *BaseAsyncPlugin) refreshCacheInBackground( + keyword string, + cacheKey string, + searchFunc func(*http.Client, string) ([]model.SearchResult, error), + oldCache cachedResponse, +) { + // 注意:这里的cacheKey已经是插件特定的了,因为是从AsyncSearch传入的 + + // 检查是否有足够的工作槽 + if !acquireWorkerSlot() { + return + } + defer releaseWorkerSlot() + + // 记录刷新开始时间 + refreshStart := time.Now() + + // 执行搜索 + results, err := searchFunc(p.backgroundClient, keyword) + if err != nil || len(results) == 0 { + return + } + + // 创建合并结果集 + mergedResults := make([]model.SearchResult, 0, len(results) + len(oldCache.Results)) + + // 创建已有结果ID的映射 + existingIDs := make(map[string]bool) + for _, r := range results { + existingIDs[r.UniqueID] = true + mergedResults = append(mergedResults, r) + } + + // 添加旧结果中不存在的项 + for _, r := range oldCache.Results { + if !existingIDs[r.UniqueID] { + mergedResults = append(mergedResults, r) + } + } + + // 更新缓存 + apiResponseCache.Store(cacheKey, cachedResponse{ + Results: mergedResults, + Timestamp: time.Now(), + Complete: true, + LastAccess: oldCache.LastAccess, + AccessCount: oldCache.AccessCount, + }) + + // 记录刷新时间 + refreshTime := time.Since(refreshStart) + fmt.Printf("[%s] 后台刷新完成: %s (耗时: %v, 新项目: %d, 合并项目: %d)\n", + p.name, cacheKey, refreshTime, len(results), len(mergedResults)) + + // 更新缓存后立即触发保存 + go saveCacheToDisk() +} \ No newline at end of file diff --git a/plugin/hunhepan/hunhepan.go b/plugin/hunhepan/hunhepan.go index 0d05a11..caea648 100644 --- a/plugin/hunhepan/hunhepan.go +++ b/plugin/hunhepan/hunhepan.go @@ -2,7 +2,6 @@ package hunhepan import ( "bytes" - "encoding/json" "fmt" "io" "net/http" @@ -12,12 +11,13 @@ import ( "pansou/model" "pansou/plugin" + "pansou/util/json" ) // 在init函数中注册插件 func init() { - // 使用全局超时时间创建插件实例并注册 - plugin.RegisterGlobalPlugin(NewHunhepanPlugin()) + // 注册插件 + plugin.RegisterGlobalPlugin(NewHunhepanAsyncPlugin()) } const ( @@ -26,43 +26,33 @@ const ( QkpansoAPI = "https://qkpanso.com/v1/search/disk" KuakeAPI = "https://kuake8.com/v1/search/disk" - // 默认超时时间 - DefaultTimeout = 6 * time.Second - // 默认页大小 DefaultPageSize = 30 ) -// HunhepanPlugin 混合盘搜索插件 -type HunhepanPlugin struct { - client *http.Client - timeout time.Duration +// HunhepanAsyncPlugin 混合盘搜索异步插件 +type HunhepanAsyncPlugin struct { + *plugin.BaseAsyncPlugin } -// NewHunhepanPlugin 创建新的混合盘搜索插件 -func NewHunhepanPlugin() *HunhepanPlugin { - timeout := DefaultTimeout - - return &HunhepanPlugin{ - client: &http.Client{ - Timeout: timeout, - }, - timeout: timeout, +// NewHunhepanAsyncPlugin 创建新的混合盘搜索异步插件 +func NewHunhepanAsyncPlugin() *HunhepanAsyncPlugin { + return &HunhepanAsyncPlugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("hunhepan", 3), } } -// Name 返回插件名称 -func (p *HunhepanPlugin) Name() string { - return "hunhepan" -} - -// Priority 返回插件优先级 -func (p *HunhepanPlugin) Priority() int { - return 3 // 中等优先级 -} - // Search 执行搜索并返回结果 -func (p *HunhepanPlugin) Search(keyword string) ([]model.SearchResult, error) { +func (p *HunhepanAsyncPlugin) Search(keyword string) ([]model.SearchResult, error) { + // 生成缓存键 + cacheKey := keyword + + // 使用异步搜索基础方法 + return p.AsyncSearch(keyword, cacheKey, p.doSearch) +} + +// doSearch 实际的搜索实现 +func (p *HunhepanAsyncPlugin) doSearch(client *http.Client, keyword string) ([]model.SearchResult, error) { // 创建结果通道和错误通道 resultChan := make(chan []HunhepanItem, 3) errChan := make(chan error, 3) @@ -74,7 +64,7 @@ func (p *HunhepanPlugin) Search(keyword string) ([]model.SearchResult, error) { // 并行请求三个API go func() { defer wg.Done() - items, err := p.searchAPI(HunhepanAPI, keyword) + items, err := p.searchAPI(client, HunhepanAPI, keyword) if err != nil { errChan <- fmt.Errorf("hunhepan API error: %w", err) return @@ -84,7 +74,7 @@ func (p *HunhepanPlugin) Search(keyword string) ([]model.SearchResult, error) { go func() { defer wg.Done() - items, err := p.searchAPI(QkpansoAPI, keyword) + items, err := p.searchAPI(client, QkpansoAPI, keyword) if err != nil { errChan <- fmt.Errorf("qkpanso API error: %w", err) return @@ -94,7 +84,7 @@ func (p *HunhepanPlugin) Search(keyword string) ([]model.SearchResult, error) { go func() { defer wg.Done() - items, err := p.searchAPI(KuakeAPI, keyword) + items, err := p.searchAPI(client, KuakeAPI, keyword) if err != nil { errChan <- fmt.Errorf("kuake API error: %w", err) return @@ -138,7 +128,7 @@ func (p *HunhepanPlugin) Search(keyword string) ([]model.SearchResult, error) { } // searchAPI 向单个API发送请求 -func (p *HunhepanPlugin) searchAPI(apiURL, keyword string) ([]HunhepanItem, error) { +func (p *HunhepanAsyncPlugin) searchAPI(client *http.Client, apiURL, keyword string) ([]HunhepanItem, error) { // 构建请求体 reqBody := map[string]interface{}{ "q": keyword, @@ -175,7 +165,7 @@ func (p *HunhepanPlugin) searchAPI(apiURL, keyword string) ([]HunhepanItem, erro } // 发送请求 - resp, err := p.client.Do(req) + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } @@ -202,7 +192,7 @@ func (p *HunhepanPlugin) searchAPI(apiURL, keyword string) ([]HunhepanItem, erro } // deduplicateItems 去重处理 -func (p *HunhepanPlugin) deduplicateItems(items []HunhepanItem) []HunhepanItem { +func (p *HunhepanAsyncPlugin) deduplicateItems(items []HunhepanItem) []HunhepanItem { // 使用map进行去重 uniqueMap := make(map[string]HunhepanItem) @@ -257,7 +247,7 @@ func (p *HunhepanPlugin) deduplicateItems(items []HunhepanItem) []HunhepanItem { } // convertResults 将API响应转换为标准SearchResult格式 -func (p *HunhepanPlugin) convertResults(items []HunhepanItem) []model.SearchResult { +func (p *HunhepanAsyncPlugin) convertResults(items []HunhepanItem) []model.SearchResult { results := make([]model.SearchResult, 0, len(items)) for i, item := range items { @@ -269,7 +259,11 @@ func (p *HunhepanPlugin) convertResults(items []HunhepanItem) []model.SearchResu } // 创建唯一ID - uniqueID := fmt.Sprintf("hunhepan-%d", i) + uniqueID := fmt.Sprintf("hunhepan-%s", item.DiskID) + if item.DiskID == "" { + // 使用索引作为后备 + uniqueID = fmt.Sprintf("hunhepan-%d", i) + } // 解析时间 var datetime time.Time @@ -302,7 +296,7 @@ func (p *HunhepanPlugin) convertResults(items []HunhepanItem) []model.SearchResu } // convertDiskType 将API的网盘类型转换为标准链接类型 -func (p *HunhepanPlugin) convertDiskType(diskType string) string { +func (p *HunhepanAsyncPlugin) convertDiskType(diskType string) string { switch diskType { case "BDY": return "baidu" @@ -352,74 +346,6 @@ func cleanTitle(title string) string { return strings.TrimSpace(result) } -// replaceAll 替换字符串中的所有子串 -func replaceAll(s, old, new string) string { - for { - if s2 := replace(s, old, new); s2 == s { - return s - } else { - s = s2 - } - } -} - -// replace 替换字符串中的第一个子串 -func replace(s, old, new string) string { - return replace_substr(s, old, new, 1) -} - -// replace_substr 替换字符串中的前n个子串 -func replace_substr(s, old, new string, n int) string { - if old == new || n == 0 { - return s // 避免无限循环 - } - - if old == "" { - if len(s) == 0 { - return new - } - return new + s - } - - // 计算结果字符串的长度 - count := 0 - t := s - for i := 0; i < len(s) && count < n; i += len(old) { - if i+len(old) <= len(s) { - if s[i:i+len(old)] == old { - count++ - i = i + len(old) - 1 - } - } - } - - if count == 0 { - return s - } - - b := make([]byte, len(s)+count*(len(new)-len(old))) - bs := b - - // 替换前n个old为new - for i := 0; i < count; i++ { - j := 0 - for j < len(t) { - if j+len(old) <= len(t) && t[j:j+len(old)] == old { - copy(bs, t[:j]) - bs = bs[j:] - copy(bs, new) - bs = bs[len(new):] - t = t[j+len(old):] - break - } - j++ - } - } - - copy(bs, t) - return string(b) -} - // HunhepanResponse API响应结构 type HunhepanResponse struct { Code int `json:"code"` diff --git a/plugin/jikepan/jikepan.go b/plugin/jikepan/jikepan.go index 60ad40c..601ae15 100644 --- a/plugin/jikepan/jikepan.go +++ b/plugin/jikepan/jikepan.go @@ -2,59 +2,51 @@ package jikepan import ( "bytes" - "encoding/json" "fmt" + "io" "net/http" "strings" "time" "pansou/model" "pansou/plugin" + "pansou/util/json" ) // 在init函数中注册插件 func init() { - // 使用全局超时时间创建插件实例并注册 - plugin.RegisterGlobalPlugin(NewJikepanPlugin()) + // 注册插件 + plugin.RegisterGlobalPlugin(NewJikepanAsyncV2Plugin()) } const ( - // JikepanAPIURL 极客盘API地址 + // JikepanAPIURL 即刻盘API地址 JikepanAPIURL = "https://api.jikepan.xyz/search" - // DefaultTimeout 默认超时时间 - DefaultTimeout = 10 * time.Second ) -// JikepanPlugin 极客盘搜索插件 -type JikepanPlugin struct { - client *http.Client - timeout time.Duration +// JikepanAsyncV2Plugin 即刻盘搜索异步V2插件 +type JikepanAsyncV2Plugin struct { + *plugin.BaseAsyncPlugin } -// NewJikepanPlugin 创建新的极客盘搜索插件 -func NewJikepanPlugin() *JikepanPlugin { - timeout := DefaultTimeout - - return &JikepanPlugin{ - client: &http.Client{ - Timeout: timeout, - }, - timeout: timeout, +// NewJikepanAsyncV2Plugin 创建新的即刻盘搜索异步V2插件 +func NewJikepanAsyncV2Plugin() *JikepanAsyncV2Plugin { + return &JikepanAsyncV2Plugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("jikepan", 3), } } -// Name 返回插件名称 -func (p *JikepanPlugin) Name() string { - return "jikepan" -} - -// Priority 返回插件优先级 -func (p *JikepanPlugin) Priority() int { - return 3 // 中等优先级 -} - // Search 执行搜索并返回结果 -func (p *JikepanPlugin) Search(keyword string) ([]model.SearchResult, error) { +func (p *JikepanAsyncV2Plugin) Search(keyword string) ([]model.SearchResult, error) { + // 生成缓存键 + cacheKey := keyword + + // 使用异步搜索基础方法 + return p.AsyncSearch(keyword, cacheKey, p.doSearch) +} + +// doSearch 实际的搜索实现 +func (p *JikepanAsyncV2Plugin) doSearch(client *http.Client, keyword string) ([]model.SearchResult, error) { // 构建请求 reqBody := map[string]interface{}{ "name": keyword, @@ -76,7 +68,7 @@ func (p *JikepanPlugin) Search(keyword string) ([]model.SearchResult, error) { req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") // 发送请求 - resp, err := p.client.Do(req) + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } @@ -84,7 +76,12 @@ func (p *JikepanPlugin) Search(keyword string) ([]model.SearchResult, error) { // 解析响应 var apiResp JikepanResponse - if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body failed: %w", err) + } + + if err := json.Unmarshal(bodyBytes, &apiResp); err != nil { return nil, fmt.Errorf("decode response failed: %w", err) } @@ -94,11 +91,13 @@ func (p *JikepanPlugin) Search(keyword string) ([]model.SearchResult, error) { } // 转换结果格式 - return p.convertResults(apiResp.List), nil + results := p.convertResults(apiResp.List) + + return results, nil } // convertResults 将API响应转换为标准SearchResult格式 -func (p *JikepanPlugin) convertResults(items []JikepanItem) []model.SearchResult { +func (p *JikepanAsyncV2Plugin) convertResults(items []JikepanItem) []model.SearchResult { results := make([]model.SearchResult, 0, len(items)) for i, item := range items { @@ -137,7 +136,7 @@ func (p *JikepanPlugin) convertResults(items []JikepanItem) []model.SearchResult result := model.SearchResult{ UniqueID: uniqueID, Title: item.Name, - Datetime: time.Time{}, // 使用零值表示无时间,而不是time.Now() + Datetime: time.Time{}, // 使用零值而不是nil Links: links, } @@ -148,7 +147,7 @@ func (p *JikepanPlugin) convertResults(items []JikepanItem) []model.SearchResult } // convertLinkType 将API的服务类型转换为标准链接类型 -func (p *JikepanPlugin) convertLinkType(service string) string { +func (p *JikepanAsyncV2Plugin) convertLinkType(service string) string { service = strings.ToLower(service) switch service { @@ -192,7 +191,7 @@ func (p *JikepanPlugin) convertLinkType(service string) string { // JikepanResponse API响应结构 type JikepanResponse struct { - Msg string `json:"msg"` + Msg string `json:"msg"` List []JikepanItem `json:"list"` } @@ -202,7 +201,7 @@ type JikepanItem struct { Links []JikepanLink `json:"links"` } -// JikepanLink API响应中的链接 +// JikepanLink API响应中的链接信息 type JikepanLink struct { Service string `json:"service"` Link string `json:"link"` diff --git a/plugin/pan666/pan666.go b/plugin/pan666/pan666.go index cc0d7ac..62fb9fa 100644 --- a/plugin/pan666/pan666.go +++ b/plugin/pan666/pan666.go @@ -1,24 +1,25 @@ package pan666 import ( - "encoding/json" "fmt" "io" + "math/rand" "net/http" "net/url" + "sort" "strings" + "sync" "time" "pansou/model" "pansou/plugin" - "sync" - "math/rand" - "sort" + "pansou/util/json" ) // 在init函数中注册插件 func init() { - plugin.RegisterGlobalPlugin(NewPan666Plugin()) + // 注册插件 + plugin.RegisterGlobalPlugin(NewPan666AsyncPlugin()) } const ( @@ -26,8 +27,7 @@ const ( BaseURL = "https://pan666.net/api/discussions" // 默认参数 - DefaultTimeout = 6 * time.Second - PageSize = 50 // 恢复为50,符合API实际返回数量 + PageSize = 50 // 符合API实际返回数量 MaxRetries = 2 ) @@ -41,58 +41,36 @@ var userAgents = []string{ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", } -// Pan666Plugin pan666网盘搜索插件 -type Pan666Plugin struct { - client *http.Client - timeout time.Duration - retries int +// Pan666AsyncPlugin pan666网盘搜索异步插件 +type Pan666AsyncPlugin struct { + *plugin.BaseAsyncPlugin + retries int } -// NewPan666Plugin 创建新的pan666插件 -func NewPan666Plugin() *Pan666Plugin { - timeout := DefaultTimeout - - return &Pan666Plugin{ - client: &http.Client{ - Timeout: timeout, - }, - timeout: timeout, - retries: MaxRetries, +// NewPan666AsyncPlugin 创建新的pan666异步插件 +func NewPan666AsyncPlugin() *Pan666AsyncPlugin { + return &Pan666AsyncPlugin{ + BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("pan666", 3), + retries: MaxRetries, } } -// Name 返回插件名称 -func (p *Pan666Plugin) Name() string { - return "pan666" -} - -// Priority 返回插件优先级 -func (p *Pan666Plugin) Priority() int { - return 3 // 中等优先级 -} - -// 生成随机IP -func generateRandomIP() string { - return fmt.Sprintf("%d.%d.%d.%d", - rand.Intn(223)+1, // 避免0和255 - rand.Intn(255), - rand.Intn(255), - rand.Intn(254)+1) // 避免0 -} - -// 获取随机UA -func getRandomUA() string { - return userAgents[rand.Intn(len(userAgents))] -} - // Search 执行搜索并返回结果 -func (p *Pan666Plugin) Search(keyword string) ([]model.SearchResult, error) { +func (p *Pan666AsyncPlugin) Search(keyword string) ([]model.SearchResult, error) { + // 生成缓存键 + cacheKey := keyword + // 使用异步搜索基础方法 + return p.AsyncSearch(keyword, cacheKey, p.doSearch) +} + +// doSearch 实际的搜索实现 +func (p *Pan666AsyncPlugin) doSearch(client *http.Client, keyword string) ([]model.SearchResult, error) { // 初始化随机数种子 rand.Seed(time.Now().UnixNano()) // 只并发请求2个页面(0-1页) - allResults, _, err := p.fetchBatch(keyword, 0, 2) + allResults, _, err := p.fetchBatch(client, keyword, 0, 2) if err != nil { return nil, err } @@ -104,7 +82,7 @@ func (p *Pan666Plugin) Search(keyword string) ([]model.SearchResult, error) { } // fetchBatch 获取一批页面的数据 -func (p *Pan666Plugin) fetchBatch(keyword string, startOffset, pageCount int) ([]model.SearchResult, bool, error) { +func (p *Pan666AsyncPlugin) fetchBatch(client *http.Client, keyword string, startOffset, pageCount int) ([]model.SearchResult, bool, error) { var wg sync.WaitGroup resultChan := make(chan struct{ offset int @@ -129,7 +107,7 @@ func (p *Pan666Plugin) fetchBatch(keyword string, startOffset, pageCount int) ([ } // 请求特定页面 - results, hasMore, err := p.fetchPage(keyword, offset) + results, hasMore, err := p.fetchPage(client, keyword, offset) resultChan <- struct{ offset int @@ -153,370 +131,559 @@ func (p *Pan666Plugin) fetchBatch(keyword string, startOffset, pageCount int) ([ // 收集结果 var allResults []model.SearchResult - resultsByOffset := make(map[int][]model.SearchResult) - errorsByOffset := make(map[int]error) - hasMoreByOffset := make(map[int]bool) + hasMore := false - // 处理返回的结果 - for res := range resultChan { - if res.err != nil { - errorsByOffset[res.offset] = res.err - continue + for result := range resultChan { + if result.err != nil { + return nil, false, result.err } - resultsByOffset[res.offset] = res.results - hasMoreByOffset[res.offset] = res.hasMore + allResults = append(allResults, result.results...) + hasMore = hasMore || result.hasMore } - // 按偏移量顺序整理结果 - emptyPageCount := 0 - for i := 0; i < pageCount; i++ { - offset := (startOffset + i) * PageSize - results, ok := resultsByOffset[offset] - - if !ok { - // 这个偏移量的请求失败了 - continue - } - - if len(results) == 0 { - emptyPageCount++ - // 如果连续两页没有结果,可能已经到达末尾,可以提前终止 - if emptyPageCount >= 2 { - break - } - } else { - emptyPageCount = 0 // 重置空页计数 - allResults = append(allResults, results...) - } - } - - // 检查是否所有请求都失败 - if len(errorsByOffset) == pageCount { - for _, err := range errorsByOffset { - return nil, false, fmt.Errorf("所有请求都失败: %w", err) - } - } - - // 检查是否需要继续请求 - needMoreRequests := false - for _, hasMore := range hasMoreByOffset { - if hasMore { - needMoreRequests = true - break - } - } - - return allResults, needMoreRequests, nil + return allResults, hasMore, nil } -// deduplicateResults 去除重复的搜索结果 -func (p *Pan666Plugin) deduplicateResults(results []model.SearchResult) []model.SearchResult { +// deduplicateResults 去除重复结果 +func (p *Pan666AsyncPlugin) deduplicateResults(results []model.SearchResult) []model.SearchResult { seen := make(map[string]bool) - var uniqueResults []model.SearchResult + unique := make([]model.SearchResult, 0, len(results)) for _, result := range results { if !seen[result.UniqueID] { seen[result.UniqueID] = true - uniqueResults = append(uniqueResults, result) + unique = append(unique, result) } } - return uniqueResults + // 按时间降序排序 + sort.Slice(unique, func(i, j int) bool { + return unique[i].Datetime.After(unique[j].Datetime) + }) + + return unique } -// fetchPage 获取指定偏移量的页面数据 -func (p *Pan666Plugin) fetchPage(keyword string, offset int) ([]model.SearchResult, bool, error) { - // 构建请求URL,包含查询参数 - reqURL := fmt.Sprintf("%s?filter%%5Bq%%5D=%s&page%%5Blimit%%5D=%d", - BaseURL, url.QueryEscape(keyword), PageSize) +// fetchPage 获取指定页的搜索结果 +func (p *Pan666AsyncPlugin) fetchPage(client *http.Client, keyword string, offset int) ([]model.SearchResult, bool, error) { + // 构建API URL + apiURL := fmt.Sprintf("%s?filter[q]=%s&include=mostRelevantPost&page[offset]=%d&page[limit]=%d", + BaseURL, url.QueryEscape(keyword), offset, PageSize) - // 添加偏移量参数 - if offset > 0 { - reqURL += fmt.Sprintf("&page%%5Boffset%%5D=%d", offset) - } - - // 添加包含mostRelevantPost参数 - reqURL += "&include=mostRelevantPost" - - // 发送请求 - req, err := http.NewRequest("GET", reqURL, nil) + // 创建请求 + req, err := http.NewRequest("GET", apiURL, nil) if err != nil { return nil, false, fmt.Errorf("创建请求失败: %w", err) } - // 使用随机UA和IP - randomUA := getRandomUA() - randomIP := generateRandomIP() - - req.Header.Set("User-Agent", randomUA) - req.Header.Set("Referer", "https://pan666.net/") - req.Header.Set("X-Forwarded-For", randomIP) - req.Header.Set("X-Real-IP", randomIP) - - // 添加一些常见请求头,使请求更真实 + // 设置请求头 + req.Header.Set("User-Agent", getRandomUA()) + req.Header.Set("X-Forwarded-For", generateRandomIP()) req.Header.Set("Accept", "application/json, text/plain, */*") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") req.Header.Set("Connection", "keep-alive") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", "same-origin") - // 发送请求 - resp, err := p.client.Do(req) - if err != nil { - return nil, false, fmt.Errorf("请求失败: %w", err) - } - defer resp.Body.Close() + var resp *http.Response + var responseBody []byte - // 读取响应体 - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, false, fmt.Errorf("读取响应失败: %w", err) + // 重试逻辑 + for i := 0; i <= p.retries; i++ { + // 发送请求 + resp, err = client.Do(req) + if err != nil { + if i == p.retries { + return nil, false, fmt.Errorf("请求失败: %w", err) + } + time.Sleep(500 * time.Millisecond) + continue + } + + defer resp.Body.Close() + + // 读取响应体 + responseBody, err = io.ReadAll(resp.Body) + if err != nil { + if i == p.retries { + return nil, false, fmt.Errorf("读取响应失败: %w", err) + } + time.Sleep(500 * time.Millisecond) + continue + } + + // 状态码检查 + if resp.StatusCode != http.StatusOK { + if i == p.retries { + return nil, false, fmt.Errorf("API返回非200状态码: %d", resp.StatusCode) + } + time.Sleep(500 * time.Millisecond) + continue + } + + // 请求成功,跳出重试循环 + break } // 解析响应 var apiResp Pan666Response - if err := json.Unmarshal(respBody, &apiResp); err != nil { + if err := json.Unmarshal(responseBody, &apiResp); err != nil { return nil, false, fmt.Errorf("解析响应失败: %w", err) } - // 如果没有数据,返回空结果 - if len(apiResp.Data) == 0 { - return []model.SearchResult{}, false, nil - } - - // 判断是否有更多页面 - hasMore := len(apiResp.Data) >= PageSize && apiResp.Links.Next != "" - - // 构建ID到included post的映射 - postMap := make(map[string]Pan666Post) - for _, post := range apiResp.Included { - if post.Type == "posts" { - postMap[post.ID] = post - } - } - - // 处理搜索结果 + // 处理结果 results := make([]model.SearchResult, 0, len(apiResp.Data)) + postMap := make(map[string]Pan666Post) - for _, item := range apiResp.Data { - // 获取关联的post内容 - postID := item.Relationships.MostRelevantPost.Data.ID - post, exists := postMap[postID] + // 创建帖子ID到帖子内容的映射 + for _, post := range apiResp.Included { + postMap[post.ID] = post + } + + // 遍历搜索结果 + for _, discussion := range apiResp.Data { + // 获取相关帖子 + postID := discussion.Relationships.MostRelevantPost.Data.ID + post, ok := postMap[postID] + if !ok { + continue + } - if !exists { - continue // 跳过没有关联内容的结果 + // 清理HTML内容 + cleanedHTML := cleanHTML(post.Attributes.ContentHTML) + + // 提取链接 + links := extractLinksFromText(cleanedHTML) + if len(links) == 0 { + links = extractLinks(cleanedHTML) + } + + // 如果没有找到链接,跳过该结果 + if len(links) == 0 { + continue } // 解析时间 - createdAt, _ := time.Parse(time.RFC3339, item.Attributes.CreatedAt) - - // 先清理HTML,保留纯文本内容 - cleanContent := cleanHTML(post.Attributes.ContentHTML) - - // 提取网盘链接 - links := extractLinksFromText(cleanContent) - - // 只有当links数组不为空时,才添加结果 - if len(links) > 0 { - // 创建搜索结果 - result := model.SearchResult{ - MessageID: item.ID, - UniqueID: fmt.Sprintf("pan666_%s", item.ID), - Channel: "", // 设置为空字符串,因为不是TG频道 - Datetime: createdAt, - Title: item.Attributes.Title, - Content: cleanContent, - Links: links, - } - - results = append(results, result) + createdTime, err := time.Parse(time.RFC3339, discussion.Attributes.CreatedAt) + if err != nil { + createdTime = time.Now() // 如果解析失败,使用当前时间 } + + // 创建唯一ID:插件名-帖子ID + uniqueID := fmt.Sprintf("pan666-%s", discussion.ID) + + // 创建搜索结果 + result := model.SearchResult{ + UniqueID: uniqueID, + Title: discussion.Attributes.Title, + Datetime: createdTime, + Links: links, + } + + results = append(results, result) } + // 判断是否有更多结果 + hasMore := apiResp.Links.Next != "" + return results, hasMore, nil } -// extractLinks 从HTML内容中提取网盘链接 -func extractLinks(content string) []model.Link { - links := make([]model.Link, 0) - - // 定义网盘类型及其对应的链接关键词 - categories := map[string][]string{ - "magnet": {"magnet"}, // 磁力链接 - "ed2k": {"ed2k"}, // 电驴链接 - "uc": {"drive.uc.cn"}, // UC网盘 - "mobile": {"caiyun.139.com"}, // 移动云盘 - "tianyi": {"cloud.189.cn"}, // 天翼云盘 - "quark": {"pan.quark.cn"}, // 夸克网盘 - "115": {"115cdn.com", "115.com", "anxia.com"}, // 115网盘 - "aliyun": {"alipan.com", "aliyundrive.com"}, // 阿里云盘 - "pikpak": {"mypikpak.com"}, // PikPak网盘 - "baidu": {"pan.baidu.com"}, // 百度网盘 - "123": {"123684.com", "123685.com", "123912.com", "123pan.com", "123pan.cn", "123592.com"}, // 123网盘 - "lanzou": {"lanzou", "lanzoux"}, // 蓝奏云 - "xunlei": {"pan.xunlei.com"}, // 迅雷网盘 - "weiyun": {"weiyun.com"}, // 微云 - "jianguoyun": {"jianguoyun.com"}, // 坚果云 - } - - // 遍历所有分类,提取对应的链接 - for category, patterns := range categories { - for _, pattern := range patterns { - categoryLinks := extractLinksByPattern(content, pattern, "", category) - links = append(links, categoryLinks...) - } - } - - return links +// 生成随机IP +func generateRandomIP() string { + return fmt.Sprintf("%d.%d.%d.%d", + rand.Intn(223)+1, // 避免0和255 + rand.Intn(255), + rand.Intn(255), + rand.Intn(254)+1) // 避免0 } -// extractLinksByPattern 根据特定模式提取链接 -func extractLinksByPattern(content, pattern, altPattern, linkType string) []model.Link { - links := make([]model.Link, 0) +// 获取随机UA +func getRandomUA() string { + return userAgents[rand.Intn(len(userAgents))] +} + +// 从文本提取链接 +func extractLinks(content string) []model.Link { + var allLinks []model.Link + + // 提取百度网盘链接 + baiduLinks := extractLinksByPattern(content, "链接: https://pan.baidu.com", "提取码:", "baidu") + allLinks = append(allLinks, baiduLinks...) + + // 提取蓝奏云链接 + lanzouLinks := extractLinksByPattern(content, "https://[a-zA-Z0-9-]+.lanzou", "密码:", "lanzou") + allLinks = append(allLinks, lanzouLinks...) + + // 提取阿里云盘链接 + aliyunLinks := extractLinksByPattern(content, "https://www.aliyundrive.com/s/", "提取码:", "aliyun") + allLinks = append(allLinks, aliyunLinks...) + + // 提取天翼云盘链接 + tianyiLinks := extractLinksByPattern(content, "https://cloud.189.cn", "访问码:", "tianyi") + allLinks = append(allLinks, tianyiLinks...) + + return allLinks +} + +// 根据模式提取链接 +func extractLinksByPattern(content, pattern, altPattern, linkType string) []model.Link { + var links []model.Link - // 查找所有包含pattern的行 lines := strings.Split(content, "\n") - for _, line := range lines { - // 提取主要pattern的链接 - if idx := strings.Index(line, pattern); idx != -1 { - link := extractLinkFromLine(line[idx:], pattern) - if link.URL != "" { - link.Type = linkType - links = append(links, link) - } - } - - // 如果有替代pattern,也提取 - if altPattern != "" { - if idx := strings.Index(line, altPattern); idx != -1 { - link := extractLinkFromLine(line[idx:], altPattern) - if link.URL != "" { - link.Type = linkType - links = append(links, link) + for i, line := range lines { + if strings.Contains(line, pattern) { + link := extractLinkFromLine(line, pattern) + + // 如果在当前行找不到密码,尝试在下一行查找 + if link.Password == "" && i+1 < len(lines) && strings.Contains(lines[i+1], altPattern) { + passwordLine := lines[i+1] + start := strings.Index(passwordLine, altPattern) + len(altPattern) + if start < len(passwordLine) { + end := len(passwordLine) + // 提取密码(移除前后空格) + password := strings.TrimSpace(passwordLine[start:end]) + link.Password = password } } + + link.Type = linkType + links = append(links, link) } } return links } -// extractLinkFromLine 从行中提取链接和密码 +// 从行中提取链接 func extractLinkFromLine(line, prefix string) model.Link { - link := model.Link{} + var link model.Link + + start := strings.Index(line, prefix) + if start < 0 { + return link + } + + // 查找URL的结束位置 + end := len(line) + possibleEnds := []string{" ", "提取码", "密码", "访问码"} + for _, endStr := range possibleEnds { + pos := strings.Index(line[start:], endStr) + if pos > 0 && start+pos < end { + end = start + pos + } + } // 提取URL - endIdx := strings.Index(line, "\"") - if endIdx == -1 { - endIdx = strings.Index(line, "'") - } - if endIdx == -1 { - endIdx = strings.Index(line, " ") - } - if endIdx == -1 { - endIdx = strings.Index(line, "<") - } - if endIdx == -1 { - endIdx = len(line) - } - - url := line[:endIdx] + url := strings.TrimSpace(line[start:end]) link.URL = url - // 查找密码 - pwdKeywords := []string{"提取码", "密码", "提取密码", "pwd", "password", "提取"} - for _, keyword := range pwdKeywords { - if pwdIdx := strings.Index(strings.ToLower(line), strings.ToLower(keyword)); pwdIdx != -1 { - // 密码通常在关键词后面 - restOfLine := line[pwdIdx+len(keyword):] - - // 跳过可能的分隔符 - restOfLine = strings.TrimLeft(restOfLine, " ::=") - - // 提取密码(通常是4个字符) - if len(restOfLine) >= 4 { - // 获取前4个字符作为密码 - password := strings.TrimSpace(restOfLine[:4]) - // 确保密码不包含HTML标签或其他非法字符 - if !strings.ContainsAny(password, "<>\"'") { - link.Password = password - break - } - } + // 尝试从同一行提取密码 + passwordKeywords := []string{"提取码:", "密码:", "访问码:"} + for _, keyword := range passwordKeywords { + passwordStart := strings.Index(line, keyword) + if passwordStart >= 0 { + passwordStart += len(keyword) + passwordEnd := len(line) + password := strings.TrimSpace(line[passwordStart:passwordEnd]) + link.Password = password + break } } + // 尝试从URL中提取密码 + if link.Password == "" { + link.Password = extractPasswordFromURL(url) + } + return link } -// cleanHTML 清理HTML标签,保留纯文本内容 +// 清理HTML内容 func cleanHTML(html string) string { - // 移除HTML标签 - text := html - - // 移除") - if endIdx == -1 { - break - } - - text = text[:startIdx] + text[startIdx+endIdx+9:] - } - - // 移除") - if endIdx == -1 { - break - } - - text = text[:startIdx] + text[startIdx+endIdx+8:] - } + // 移除
标签 + html = strings.ReplaceAll(html, "
", "\n") + html = strings.ReplaceAll(html, "
", "\n") + html = strings.ReplaceAll(html, "
", "\n") // 移除其他HTML标签 - for { - startIdx := strings.Index(text, "<") - if startIdx == -1 { - break + var result strings.Builder + inTag := false + + for _, r := range html { + if r == '<' { + inTag = true + continue } - - endIdx := strings.Index(text[startIdx:], ">") - if endIdx == -1 { - break + if r == '>' { + inTag = false + continue + } + if !inTag { + result.WriteRune(r) } - - text = text[:startIdx] + " " + text[startIdx+endIdx+1:] } - // 替换HTML实体 - text = strings.ReplaceAll(text, " ", " ") - text = strings.ReplaceAll(text, "<", "<") - text = strings.ReplaceAll(text, ">", ">") - text = strings.ReplaceAll(text, "&", "&") - text = strings.ReplaceAll(text, """, "\"") + // 处理HTML实体 + output := result.String() + output = strings.ReplaceAll(output, "&", "&") + output = strings.ReplaceAll(output, "<", "<") + output = strings.ReplaceAll(output, ">", ">") + output = strings.ReplaceAll(output, """, "\"") + output = strings.ReplaceAll(output, "'", "'") + output = strings.ReplaceAll(output, "'", "'") + output = strings.ReplaceAll(output, " ", " ") - // 移除多余空白 - text = strings.Join(strings.Fields(text), " ") + // 处理多行空白 + lines := strings.Split(output, "\n") + var cleanedLines []string - return text + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + cleanedLines = append(cleanedLines, trimmed) + } + } + + return strings.Join(cleanedLines, "\n") } -// min 返回两个整数中的较小值 -func min(a, b int) int { - if a < b { - return a +// 提取文本中的链接 +func extractLinksFromText(content string) []model.Link { + var allLinks []model.Link + + lines := strings.Split(content, "\n") + + // 收集所有可能的链接信息 + var linkInfos []struct { + link model.Link + position int + category string } - return b + + // 收集所有可能的密码信息 + var passwordInfos []struct { + keyword string + position int + password string + } + + // 第一遍:查找所有的链接和密码 + for i, line := range lines { + // 检查链接 + line = strings.TrimSpace(line) + + // 检查百度网盘 + if strings.Contains(line, "pan.baidu.com") { + url := extractURLFromText(line) + if url != "" { + linkInfos = append(linkInfos, struct { + link model.Link + position int + category string + }{ + link: model.Link{URL: url, Type: "baidu"}, + position: i, + category: "baidu", + }) + } + } + + // 检查阿里云盘 + if strings.Contains(line, "aliyundrive.com") { + url := extractURLFromText(line) + if url != "" { + linkInfos = append(linkInfos, struct { + link model.Link + position int + category string + }{ + link: model.Link{URL: url, Type: "aliyun"}, + position: i, + category: "aliyun", + }) + } + } + + // 检查蓝奏云 + if strings.Contains(line, "lanzou") { + url := extractURLFromText(line) + if url != "" { + linkInfos = append(linkInfos, struct { + link model.Link + position int + category string + }{ + link: model.Link{URL: url, Type: "lanzou"}, + position: i, + category: "lanzou", + }) + } + } + + // 检查天翼云盘 + if strings.Contains(line, "cloud.189.cn") { + url := extractURLFromText(line) + if url != "" { + linkInfos = append(linkInfos, struct { + link model.Link + position int + category string + }{ + link: model.Link{URL: url, Type: "tianyi"}, + position: i, + category: "tianyi", + }) + } + } + + // 检查提取码/密码/访问码 + passwordKeywords := []string{"提取码", "密码", "访问码"} + for _, keyword := range passwordKeywords { + if strings.Contains(line, keyword) { + // 寻找冒号后面的内容 + colonPos := strings.Index(line, ":") + if colonPos == -1 { + colonPos = strings.Index(line, ":") + } + + if colonPos != -1 && colonPos+1 < len(line) { + password := strings.TrimSpace(line[colonPos+1:]) + // 如果密码长度超过10个字符,可能不是密码 + if len(password) <= 10 { + passwordInfos = append(passwordInfos, struct { + keyword string + position int + password string + }{ + keyword: keyword, + position: i, + password: password, + }) + } + } + } + } + } + + // 第二遍:将密码与链接匹配 + for i := range linkInfos { + // 检查链接自身是否包含密码 + password := extractPasswordFromURL(linkInfos[i].link.URL) + if password != "" { + linkInfos[i].link.Password = password + continue + } + + // 查找最近的密码 + minDistance := 1000000 + var closestPassword string + + for _, pwInfo := range passwordInfos { + // 根据链接类型和密码关键词进行匹配 + match := false + + if linkInfos[i].category == "baidu" && (pwInfo.keyword == "提取码" || pwInfo.keyword == "密码") { + match = true + } else if linkInfos[i].category == "aliyun" && (pwInfo.keyword == "提取码" || pwInfo.keyword == "密码") { + match = true + } else if linkInfos[i].category == "lanzou" && pwInfo.keyword == "密码" { + match = true + } else if linkInfos[i].category == "tianyi" && (pwInfo.keyword == "访问码" || pwInfo.keyword == "密码") { + match = true + } + + if match { + distance := abs(pwInfo.position - linkInfos[i].position) + if distance < minDistance { + minDistance = distance + closestPassword = pwInfo.password + } + } + } + + // 只有当距离较近时才认为是匹配的密码 + if minDistance <= 3 { + linkInfos[i].link.Password = closestPassword + } + } + + // 收集所有有效链接 + for _, info := range linkInfos { + allLinks = append(allLinks, info.link) + } + + return allLinks +} + +// 从文本中提取URL +func extractURLFromText(text string) string { + // 查找URL的起始位置 + urlPrefixes := []string{"http://", "https://"} + start := -1 + + for _, prefix := range urlPrefixes { + pos := strings.Index(text, prefix) + if pos != -1 { + start = pos + break + } + } + + if start == -1 { + return "" + } + + // 查找URL的结束位置 + end := len(text) + endChars := []string{" ", "\t", "\n", "\"", "'", "<", ">", ")", "]", "}", ",", ";"} + + for _, char := range endChars { + pos := strings.Index(text[start:], char) + if pos != -1 && start+pos < end { + end = start + pos + } + } + + return text[start:end] +} + +// 从URL中提取密码 +func extractPasswordFromURL(url string) string { + // 查找密码参数 + pwdParams := []string{"pwd=", "password=", "passcode=", "code="} + + for _, param := range pwdParams { + pos := strings.Index(url, param) + if pos != -1 { + start := pos + len(param) + end := len(url) + + // 查找参数结束位置 + for i := start; i < len(url); i++ { + if url[i] == '&' || url[i] == '#' { + end = i + break + } + } + + if start < end { + return url[start:end] + } + } + } + + return "" +} + +// 绝对值函数 +func abs(n int) int { + if n < 0 { + return -n + } + return n } // Pan666Response API响应结构 @@ -529,7 +696,7 @@ type Pan666Response struct { Included []Pan666Post `json:"included"` } -// Pan666Discussion 讨论数据结构 +// Pan666Discussion 讨论信息 type Pan666Discussion struct { Type string `json:"type"` ID string `json:"id"` @@ -552,7 +719,7 @@ type Pan666Discussion struct { } `json:"relationships"` } -// Pan666Post 帖子内容结构 +// Pan666Post 帖子内容 type Pan666Post struct { Type string `json:"type"` ID string `json:"id"` @@ -562,224 +729,4 @@ type Pan666Post struct { ContentType string `json:"contentType"` ContentHTML string `json:"contentHtml"` } `json:"attributes"` -} - -// extractLinksFromText 从清理后的文本中提取网盘链接 -func extractLinksFromText(content string) []model.Link { - // 定义网盘类型及其对应的链接关键词 - categories := map[string][]string{ - "magnet": {"magnet"}, // 磁力链接 - "ed2k": {"ed2k"}, // 电驴链接 - "uc": {"drive.uc.cn"}, // UC网盘 - "mobile": {"caiyun.139.com"}, // 移动云盘 - "tianyi": {"cloud.189.cn"}, // 天翼云盘 - "quark": {"pan.quark.cn"}, // 夸克网盘 - "115": {"115cdn.com", "115.com", "anxia.com"}, // 115网盘 - "aliyun": {"alipan.com", "aliyundrive.com"}, // 阿里云盘 - "pikpak": {"mypikpak.com"}, // PikPak网盘 - "baidu": {"pan.baidu.com"}, // 百度网盘 - "123": {"123684.com", "123685.com", "123912.com", "123pan.com", "123pan.cn", "123592.com"}, // 123网盘 - "lanzou": {"lanzou", "lanzoux"}, // 蓝奏云 - "xunlei": {"pan.xunlei.com"}, // 迅雷网盘 - "weiyun": {"weiyun.com"}, // 微云 - "jianguoyun": {"jianguoyun.com"}, // 坚果云 - } - - // 存储所有找到的链接及其在文本中的位置 - type linkInfo struct { - link model.Link - position int - category string - } - var allLinks []linkInfo - - // 第一步:提取所有链接及其位置 - for category, patterns := range categories { - for _, pattern := range patterns { - pos := 0 - for { - idx := strings.Index(content[pos:], pattern) - if idx == -1 { - break - } - - // 计算实际位置 - actualPos := pos + idx - - // 提取URL - url := extractURLFromText(content[actualPos:]) - if url != "" { - // 检查URL是否已包含密码参数 - password := extractPasswordFromURL(url) - - // 创建链接 - link := model.Link{ - Type: category, - URL: url, - Password: password, - } - - // 存储链接及其位置 - allLinks = append(allLinks, linkInfo{ - link: link, - position: actualPos, - category: category, - }) - } - - // 移动位置继续查找 - pos = actualPos + len(pattern) - } - } - } - - // 按位置排序链接 - sort.Slice(allLinks, func(i, j int) bool { - return allLinks[i].position < allLinks[j].position - }) - - // 第二步:提取所有密码关键词及其位置 - type passwordInfo struct { - keyword string - position int - password string - } - var allPasswords []passwordInfo - - // 密码关键词 - pwdKeywords := []string{"提取码", "密码", "提取密码", "pwd", "password", "提取码:", "密码:", "提取密码:", "pwd:", "password:", "提取:"} - - for _, keyword := range pwdKeywords { - pos := 0 - for { - idx := strings.Index(strings.ToLower(content[pos:]), strings.ToLower(keyword)) - if idx == -1 { - break - } - - // 计算实际位置 - actualPos := pos + idx - - // 提取密码 - restContent := content[actualPos+len(keyword):] - restContent = strings.TrimLeft(restContent, " ::=") - - var password string - if len(restContent) >= 4 { - possiblePwd := strings.TrimSpace(restContent[:4]) - if !strings.ContainsAny(possiblePwd, "<>\"'\t\n\r") { - password = possiblePwd - } - } - - if password != "" { - allPasswords = append(allPasswords, passwordInfo{ - keyword: keyword, - position: actualPos, - password: password, - }) - } - - // 移动位置继续查找 - pos = actualPos + len(keyword) - } - } - - // 按位置排序密码 - sort.Slice(allPasswords, func(i, j int) bool { - return allPasswords[i].position < allPasswords[j].position - }) - - // 第三步:为每个密码找到它前面最近的链接 - // 创建链接的副本,用于最终结果 - finalLinks := make([]model.Link, len(allLinks)) - for i, linkInfo := range allLinks { - finalLinks[i] = linkInfo.link - } - - // 对于每个密码,找到它前面最近的链接 - for _, pwdInfo := range allPasswords { - // 找到密码前面最近的链接 - var closestLinkIndex int = -1 - minDistance := 1000000 - - for i, linkInfo := range allLinks { - // 只考虑密码前面的链接 - if linkInfo.position < pwdInfo.position { - distance := pwdInfo.position - linkInfo.position - - // 密码必须在链接后的200个字符内 - if distance < 200 && distance < minDistance { - minDistance = distance - closestLinkIndex = i - } - } - } - - // 如果找到了链接,并且该链接没有从URL中提取的密码 - if closestLinkIndex != -1 && finalLinks[closestLinkIndex].Password == "" { - // 检查这个链接后面是否有其他链接 - hasNextLink := false - for _, linkInfo := range allLinks { - // 如果有链接在当前链接和密码之间,说明当前链接不需要密码 - if linkInfo.position > allLinks[closestLinkIndex].position && - linkInfo.position < pwdInfo.position { - hasNextLink = true - break - } - } - - // 只有当没有其他链接在当前链接和密码之间时,才将密码关联到链接 - if !hasNextLink { - finalLinks[closestLinkIndex].Password = pwdInfo.password - } - } - } - - return finalLinks -} - -// extractURLFromText 从文本中提取URL -func extractURLFromText(text string) string { - // 查找URL的结束位置 - endIdx := strings.IndexAny(text, " \t\n\r\"'<>") - if endIdx == -1 { - endIdx = len(text) - } - - // 提取URL - url := text[:endIdx] - - // 清理URL - url = strings.TrimPrefix(url, "http://") - url = strings.TrimPrefix(url, "https://") - url = strings.TrimPrefix(url, "www.") - - return url -} - -// extractPasswordFromURL 从URL中提取密码参数 -func extractPasswordFromURL(url string) string { - // 检查URL是否包含密码参数 - if strings.Contains(url, "?pwd=") { - parts := strings.Split(url, "?pwd=") - if len(parts) > 1 { - // 提取密码参数 - pwd := parts[1] - // 如果密码后面还有其他参数,只取密码部分 - if idx := strings.IndexAny(pwd, "&?"); idx != -1 { - pwd = pwd[:idx] - } - return pwd - } - } - return "" -} - -// abs 返回整数的绝对值 -func abs(n int) int { - if n < 0 { - return -n - } - return n } \ No newline at end of file diff --git a/plugin/pansearch/pansearch.go b/plugin/pansearch/pansearch.go index eb001fa..e595370 100644 --- a/plugin/pansearch/pansearch.go +++ b/plugin/pansearch/pansearch.go @@ -2,7 +2,6 @@ package pansearch import ( "context" - "encoding/json" "fmt" "io" "net" @@ -15,6 +14,7 @@ import ( "pansou/model" "pansou/plugin" + "pansou/util/json" "sync/atomic" ) @@ -25,12 +25,39 @@ var ( // 从__NEXT_DATA__脚本中提取数据的正则表达式 nextDataRegex = regexp.MustCompile(``) + + // 缓存相关变量 + searchResultCache = sync.Map{} + lastCacheCleanTime = time.Now() + cacheTTL = 1 * time.Hour ) // 在init函数中注册插件 func init() { // 使用全局超时时间创建插件实例并注册 plugin.RegisterGlobalPlugin(NewPanSearchPlugin()) + + // 启动缓存清理goroutine + go startCacheCleaner() +} + +// startCacheCleaner 启动一个定期清理缓存的goroutine +func startCacheCleaner() { + // 每小时清理一次缓存 + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for range ticker.C { + // 清空所有缓存 + searchResultCache = sync.Map{} + lastCacheCleanTime = time.Now() + } +} + +// 缓存响应结构 +type cachedResponse struct { + results []model.SearchResult + timestamp time.Time } const ( @@ -41,7 +68,7 @@ const ( BaseURLTemplate = "https://www.pansearch.me/_next/data/%s/search.json" // 默认参数 - DefaultTimeout = 3 * time.Second // 减少默认超时时间 + DefaultTimeout = 6 * time.Second // 减少默认超时时间 PageSize = 10 MaxResults = 1000 MaxConcurrent = 200 // 增加最大并发数 @@ -488,6 +515,18 @@ func (p *PanSearchPlugin) getBaseURL() (string, error) { // Search 执行搜索并返回结果 func (p *PanSearchPlugin) Search(keyword string) ([]model.SearchResult, error) { + // 生成缓存键 + cacheKey := keyword + + // 检查缓存中是否已有结果 + if cachedItems, ok := searchResultCache.Load(cacheKey); ok { + // 检查缓存是否过期 + cachedResult := cachedItems.(cachedResponse) + if time.Since(cachedResult.timestamp) < cacheTTL { + return cachedResult.results, nil + } + } + // 获取API基础URL baseURL, err := p.getBaseURL() if err != nil { @@ -531,7 +570,15 @@ func (p *PanSearchPlugin) Search(keyword string) ([]model.SearchResult, error) { // 2. 计算需要的页数,但限制在最大结果数内和API最大页数内 remainingResults := min(total-PageSize, p.maxResults-PageSize) if remainingResults <= 0 { - return p.convertResults(allResults, keyword), nil + results := p.convertResults(allResults, keyword) + + // 缓存结果 + searchResultCache.Store(cacheKey, cachedResponse{ + results: results, + timestamp: time.Now(), + }) + + return results, nil } // 计算需要的页数,考虑API的100页限制 @@ -539,7 +586,15 @@ func (p *PanSearchPlugin) Search(keyword string) ([]model.SearchResult, error) { // 如果只需要获取少量页面,直接返回 if neededPages <= 0 { - return p.convertResults(allResults, keyword), nil + results := p.convertResults(allResults, keyword) + + // 缓存结果 + searchResultCache.Store(cacheKey, cachedResponse{ + results: results, + timestamp: time.Now(), + }) + + return results, nil } // 根据实际页数确定并发数,但不超过最大并发数 @@ -717,20 +772,43 @@ CollectResults: case <-ctx.Done(): // 上下文超时,返回已收集的结果 - return p.convertResults(allResults, keyword), fmt.Errorf("搜索超时: %w", ctx.Err()) + results := p.convertResults(allResults, keyword) + + // 缓存结果(即使超时也缓存已获取的结果) + searchResultCache.Store(cacheKey, cachedResponse{ + results: results, + timestamp: time.Now(), + }) + + return results, fmt.Errorf("搜索超时: %w", ctx.Err()) } } ProcessResults: // 如果所有请求都失败且没有获得首页以外的结果,则返回错误 if submittedTasks > 0 && errorCount == submittedTasks && len(allResults) == len(firstPageResults) { - return p.convertResults(allResults, keyword), fmt.Errorf("所有后续页面请求失败: %v", lastError) + results := p.convertResults(allResults, keyword) + + // 缓存结果(即使有错误也缓存已获取的结果) + searchResultCache.Store(cacheKey, cachedResponse{ + results: results, + timestamp: time.Now(), + }) + + return results, fmt.Errorf("所有后续页面请求失败: %v", lastError) } // 4. 去重和格式化结果 uniqueResults := p.deduplicateItems(allResults) + results := p.convertResults(uniqueResults, keyword) + + // 缓存结果 + searchResultCache.Store(cacheKey, cachedResponse{ + results: results, + timestamp: time.Now(), + }) - return p.convertResults(uniqueResults, keyword), nil + return results, nil } // fetchFirstPage 获取第一页结果和总数 diff --git a/plugin/panta/panta.go b/plugin/panta/panta.go index da54d2c..fc0e5dd 100644 --- a/plugin/panta/panta.go +++ b/plugin/panta/panta.go @@ -118,7 +118,7 @@ const ( defaultPriority = 2 // 默认超时时间(秒) - defaultTimeout = 10 + defaultTimeout = 6 // 默认并发数 defaultConcurrency = 30 diff --git a/plugin/qupansou/qupansou.go b/plugin/qupansou/qupansou.go index 7038704..3ec5172 100644 --- a/plugin/qupansou/qupansou.go +++ b/plugin/qupansou/qupansou.go @@ -2,21 +2,50 @@ package qupansou import ( "bytes" - "encoding/json" "fmt" "io" "net/http" "strings" + "sync" "time" "pansou/model" "pansou/plugin" + "pansou/util/json" +) + +// 缓存相关变量 +var ( + // API响应缓存,键为关键词,值为缓存的响应 + apiResponseCache = sync.Map{} + + // 最后一次清理缓存的时间 + lastCacheCleanTime = time.Now() + + // 缓存有效期(1小时) + cacheTTL = 1 * time.Hour ) // 在init函数中注册插件 func init() { // 使用全局超时时间创建插件实例并注册 plugin.RegisterGlobalPlugin(NewQuPanSouPlugin()) + + // 启动缓存清理goroutine + go startCacheCleaner() +} + +// startCacheCleaner 启动一个定期清理缓存的goroutine +func startCacheCleaner() { + // 每小时清理一次缓存 + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for range ticker.C { + // 清空所有缓存 + apiResponseCache = sync.Map{} + lastCacheCleanTime = time.Now() + } } const ( @@ -60,6 +89,18 @@ func (p *QuPanSouPlugin) Priority() int { // Search 执行搜索并返回结果 func (p *QuPanSouPlugin) Search(keyword string) ([]model.SearchResult, error) { + // 生成缓存键 + cacheKey := keyword + + // 检查缓存中是否已有结果 + if cachedItems, ok := apiResponseCache.Load(cacheKey); ok { + // 检查缓存是否过期 + cachedResult := cachedItems.(cachedResponse) + if time.Since(cachedResult.timestamp) < cacheTTL { + return cachedResult.results, nil + } + } + // 发送API请求 items, err := p.searchAPI(keyword) if err != nil { @@ -69,9 +110,21 @@ func (p *QuPanSouPlugin) Search(keyword string) ([]model.SearchResult, error) { // 转换为标准格式 results := p.convertResults(items) + // 缓存结果 + apiResponseCache.Store(cacheKey, cachedResponse{ + results: results, + timestamp: time.Now(), + }) + return results, nil } +// 缓存响应结构 +type cachedResponse struct { + results []model.SearchResult + timestamp time.Time +} + // searchAPI 向API发送请求 func (p *QuPanSouPlugin) searchAPI(keyword string) ([]QuPanSouItem, error) { // 构建请求体 @@ -198,33 +251,36 @@ func (p *QuPanSouPlugin) convertResults(items []QuPanSouItem) []model.SearchResu func (p *QuPanSouPlugin) determineLinkType(url string) string { lowerURL := strings.ToLower(url) - switch { - case strings.Contains(lowerURL, "pan.baidu.com"): + if strings.Contains(lowerURL, "pan.baidu.com") { return "baidu" - case strings.Contains(lowerURL, "pan.quark.cn"): - return "quark" - case strings.Contains(lowerURL, "alipan.com") || strings.Contains(lowerURL, "aliyundrive.com"): + } else if strings.Contains(lowerURL, "aliyundrive.com") || strings.Contains(lowerURL, "alipan.com") { return "aliyun" - case strings.Contains(lowerURL, "cloud.189.cn"): + } else if strings.Contains(lowerURL, "pan.quark.cn") { + return "quark" + } else if strings.Contains(lowerURL, "cloud.189.cn") { return "tianyi" - case strings.Contains(lowerURL, "caiyun.139.com"): - return "mobile" - case strings.Contains(lowerURL, "115.com"): - return "115" - case strings.Contains(lowerURL, "pan.xunlei.com"): + } else if strings.Contains(lowerURL, "pan.xunlei.com") { return "xunlei" - case strings.Contains(lowerURL, "mypikpak.com"): - return "pikpak" - case strings.Contains(lowerURL, "123"): + } else if strings.Contains(lowerURL, "caiyun.139.com") || strings.Contains(lowerURL, "www.caiyun.139.com") { + return "mobile" + } else if strings.Contains(lowerURL, "115.com") { + return "115" + } else if strings.Contains(lowerURL, "drive.uc.cn") { + return "uc" + } else if strings.Contains(lowerURL, "pan.123.com") || strings.Contains(lowerURL, "123pan.com") { return "123" - default: + } else if strings.Contains(lowerURL, "mypikpak.com") { + return "pikpak" + } else if strings.Contains(lowerURL, "lanzou") { + return "lanzou" + } else { return "others" } } // cleanHTML 清理HTML标签 func cleanHTML(html string) string { - // 替换常见HTML标签 + // 一次性替换所有常见HTML标签 replacements := map[string]string{ "": "", "": "", @@ -232,6 +288,8 @@ func cleanHTML(html string) string { "": "", "": "", "": "", + "": "", + "": "", } result := html @@ -239,6 +297,7 @@ func cleanHTML(html string) string { result = strings.Replace(result, tag, replacement, -1) } + // 移除多余的空格 return strings.TrimSpace(result) } diff --git a/service/search_service.go b/service/search_service.go index 07cb7d3..51edd62 100644 --- a/service/search_service.go +++ b/service/search_service.go @@ -13,7 +13,6 @@ import ( "pansou/plugin" "pansou/util" "pansou/util/cache" - "pansou/util/json" "pansou/util/pool" ) @@ -60,8 +59,77 @@ func NewSearchService(pluginManager *plugin.PluginManager) *SearchService { // Search 执行搜索 func (s *SearchService) Search(keyword string, channels []string, concurrency int, forceRefresh bool, resultType string, sourceType string, plugins []string) (model.SearchResponse, error) { + // 参数预处理 + // 源类型标准化 + if sourceType == "" { + sourceType = "all" + } + + // 插件参数规范化处理 + if sourceType == "tg" { + // 对于只搜索Telegram的请求,忽略插件参数 + plugins = nil + } else if sourceType == "all" || sourceType == "plugin" { + // 检查是否为空列表或只包含空字符串 + if plugins == nil || len(plugins) == 0 { + plugins = nil + } else { + // 检查是否有非空元素 + hasNonEmpty := false + for _, p := range plugins { + if p != "" { + hasNonEmpty = true + break + } + } + + // 如果全是空字符串,视为未指定 + if !hasNonEmpty { + plugins = nil + } else { + // 检查是否包含所有插件 + allPlugins := s.pluginManager.GetPlugins() + allPluginNames := make([]string, 0, len(allPlugins)) + for _, p := range allPlugins { + allPluginNames = append(allPluginNames, strings.ToLower(p.Name())) + } + + // 创建请求的插件名称集合(忽略空字符串) + requestedPlugins := make([]string, 0, len(plugins)) + for _, p := range plugins { + if p != "" { + requestedPlugins = append(requestedPlugins, strings.ToLower(p)) + } + } + + // 如果请求的插件数量与所有插件数量相同,检查是否包含所有插件 + if len(requestedPlugins) == len(allPluginNames) { + // 创建映射以便快速查找 + pluginMap := make(map[string]bool) + for _, p := range requestedPlugins { + pluginMap[p] = true + } + + // 检查是否包含所有插件 + allIncluded := true + for _, name := range allPluginNames { + if !pluginMap[name] { + allIncluded = false + break + } + } + + // 如果包含所有插件,统一设为nil + if allIncluded { + plugins = nil + } + } + } + } + } + // 立即生成缓存键并检查缓存 - cacheKey := cache.GenerateCacheKey(keyword, nil) + cacheKey := cache.GenerateCacheKey(keyword, channels, sourceType, plugins) // 如果未启用强制刷新,尝试从缓存获取结果 if !forceRefresh && twoLevelCache != nil && config.AppConfig.CacheEnabled { @@ -69,7 +137,7 @@ func (s *SearchService) Search(keyword string, channels []string, concurrency in if err == nil && hit { var response model.SearchResponse - if err := json.Unmarshal(data, &response); err == nil { + if err := cache.DeserializeWithPool(data, &response); err == nil { // 根据resultType过滤返回结果 return filterResponseByType(response, resultType), nil } @@ -225,7 +293,7 @@ func (s *SearchService) Search(keyword string, channels []string, concurrency in // 异步缓存搜索结果(缓存完整结果,以便后续可以根据不同resultType过滤) if twoLevelCache != nil && config.AppConfig.CacheEnabled { go func(resp model.SearchResponse) { - data, err := json.Marshal(resp) + data, err := cache.SerializeWithPool(resp) if err != nil { return } diff --git a/util/cache/cache_key.go b/util/cache/cache_key.go index d3202e5..2e534d3 100644 --- a/util/cache/cache_key.go +++ b/util/cache/cache_key.go @@ -3,17 +3,274 @@ package cache import ( "crypto/md5" "encoding/hex" + "fmt" "sort" + "strings" + "sync" + + "pansou/plugin" ) -// GenerateCacheKey 根据查询和过滤器生成缓存键 -func GenerateCacheKey(query string, filters map[string]string) string { - // 如果只需要基于关键词的缓存,不考虑过滤器 +// 预计算的哈希值映射 +var ( + channelHashCache sync.Map // 存储频道列表哈希 + pluginHashCache sync.Map // 存储插件列表哈希 + + // 预先计算的常用列表哈希值 + precomputedHashes sync.Map + + // 所有插件名称的哈希值 + allPluginsHash string + // 所有频道名称的哈希值 + allChannelsHash string +) + +// 初始化预计算的哈希值 +func init() { + // 预计算空列表的哈希值 + precomputedHashes.Store("empty_channels", "all") + + // 预计算常用的频道组合哈希值 + commonChannels := [][]string{ + {"dongman", "anime"}, + {"movie", "film"}, + {"music", "audio"}, + {"book", "ebook"}, + } + + for _, channels := range commonChannels { + key := strings.Join(channels, ",") + hash := calculateListHash(channels) + precomputedHashes.Store("channels:"+key, hash) + } + + // 预计算常用的插件组合哈希值 + commonPlugins := [][]string{ + {"pan666", "panta"}, + {"aliyun", "baidu"}, + } + + for _, plugins := range commonPlugins { + key := strings.Join(plugins, ",") + hash := calculateListHash(plugins) + precomputedHashes.Store("plugins:"+key, hash) + } + + // 预计算所有插件的哈希值 + allPlugins := plugin.GetRegisteredPlugins() + allPluginNames := make([]string, 0, len(allPlugins)) + for _, p := range allPlugins { + allPluginNames = append(allPluginNames, p.Name()) + } + sort.Strings(allPluginNames) + allPluginsHash = calculateListHash(allPluginNames) + precomputedHashes.Store("all_plugins", allPluginsHash) + + // 预计算所有频道的哈希值(这里假设有一个全局频道列表) + // 注意:如果没有全局频道列表,可以使用一个默认值 + allChannelsHash = "all" + precomputedHashes.Store("all_channels", allChannelsHash) +} + +// GenerateCacheKey 根据所有影响搜索结果的参数生成缓存键 +func GenerateCacheKey(keyword string, channels []string, sourceType string, plugins []string) string { + // 关键词标准化 + normalizedKeyword := strings.ToLower(strings.TrimSpace(keyword)) + + // 获取频道列表哈希 + channelsHash := getChannelsHash(channels) + + // 源类型处理 + if sourceType == "" { + sourceType = "all" + } + + // 插件参数规范化处理 + var pluginsHash string + if sourceType == "tg" { + // 对于只搜索Telegram的请求,忽略插件参数 + pluginsHash = "none" + } else { + // 获取插件列表哈希 + pluginsHash = getPluginsHash(plugins) + } + + // 生成最终缓存键 + keyStr := fmt.Sprintf("%s:%s:%s:%s", normalizedKeyword, channelsHash, sourceType, pluginsHash) + hash := md5.Sum([]byte(keyStr)) + return hex.EncodeToString(hash[:]) +} + +// 获取或计算频道哈希 +func getChannelsHash(channels []string) string { + if channels == nil || len(channels) == 0 { + // 使用预计算的所有频道哈希 + if hash, ok := precomputedHashes.Load("all_channels"); ok { + return hash.(string) + } + return allChannelsHash + } + + // 对于小型列表,直接使用字符串连接 + if len(channels) < 5 { + channelsCopy := make([]string, len(channels)) + copy(channelsCopy, channels) + sort.Strings(channelsCopy) + + // 检查是否有预计算的哈希 + key := strings.Join(channelsCopy, ",") + if hash, ok := precomputedHashes.Load("channels:"+key); ok { + return hash.(string) + } + + return strings.Join(channelsCopy, ",") + } + + // 生成排序后的字符串用作键 + channelsCopy := make([]string, len(channels)) + copy(channelsCopy, channels) + sort.Strings(channelsCopy) + key := strings.Join(channelsCopy, ",") + + // 尝试从缓存获取 + if hash, ok := channelHashCache.Load(key); ok { + return hash.(string) + } + + // 计算哈希 + hash := calculateListHash(channelsCopy) + + // 存入缓存 + channelHashCache.Store(key, hash) + return hash +} + +// 获取或计算插件哈希 +func getPluginsHash(plugins []string) string { + // 检查是否为空列表 + if plugins == nil || len(plugins) == 0 { + // 使用预计算的所有插件哈希 + if hash, ok := precomputedHashes.Load("all_plugins"); ok { + return hash.(string) + } + return allPluginsHash + } + + // 检查是否有空字符串元素 + hasNonEmptyPlugin := false + for _, p := range plugins { + if p != "" { + hasNonEmptyPlugin = true + break + } + } + + // 如果全是空字符串,也视为空列表 + if !hasNonEmptyPlugin { + if hash, ok := precomputedHashes.Load("all_plugins"); ok { + return hash.(string) + } + return allPluginsHash + } + + // 对于小型列表,直接使用字符串连接 + if len(plugins) < 5 { + pluginsCopy := make([]string, 0, len(plugins)) + for _, p := range plugins { + if p != "" { // 忽略空字符串 + pluginsCopy = append(pluginsCopy, p) + } + } + sort.Strings(pluginsCopy) + + // 检查是否有预计算的哈希 + key := strings.Join(pluginsCopy, ",") + if hash, ok := precomputedHashes.Load("plugins:"+key); ok { + return hash.(string) + } + + return strings.Join(pluginsCopy, ",") + } + + // 生成排序后的字符串用作键,忽略空字符串 + pluginsCopy := make([]string, 0, len(plugins)) + for _, p := range plugins { + if p != "" { // 忽略空字符串 + pluginsCopy = append(pluginsCopy, p) + } + } + sort.Strings(pluginsCopy) + key := strings.Join(pluginsCopy, ",") + + // 尝试从缓存获取 + if hash, ok := pluginHashCache.Load(key); ok { + return hash.(string) + } + + // 计算哈希 + hash := calculateListHash(pluginsCopy) + + // 存入缓存 + pluginHashCache.Store(key, hash) + return hash +} + +// 计算列表的哈希值 +func calculateListHash(items []string) string { + h := md5.New() + for _, item := range items { + h.Write([]byte(item)) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// GenerateCacheKeyV2 根据所有影响搜索结果的参数生成缓存键 +// 为保持向后兼容,保留原函数,但标记为已弃用 +func GenerateCacheKeyV2(keyword string, channels []string, sourceType string, plugins []string) string { + // 关键词标准化:去除首尾空格,转为小写 + normalizedKeyword := strings.ToLower(strings.TrimSpace(keyword)) + + // 频道处理 + var channelsStr string + if channels != nil && len(channels) > 0 { + channelsCopy := make([]string, len(channels)) + copy(channelsCopy, channels) + sort.Strings(channelsCopy) + channelsStr = strings.Join(channelsCopy, ",") + } else { + channelsStr = "all" + } + + // 插件处理 + var pluginsStr string + if plugins != nil && len(plugins) > 0 { + pluginsCopy := make([]string, len(plugins)) + copy(pluginsCopy, plugins) + sort.Strings(pluginsCopy) + pluginsStr = strings.Join(pluginsCopy, ",") + } else { + pluginsStr = "all" + } + + // 源类型处理 + if sourceType == "" { + sourceType = "all" + } + + // 生成缓存键字符串 + keyStr := fmt.Sprintf("v2:%s:%s:%s:%s", normalizedKeyword, channelsStr, sourceType, pluginsStr) + + // 计算MD5哈希 + hash := md5.Sum([]byte(keyStr)) + return hex.EncodeToString(hash[:]) +} + +// GenerateCacheKeyLegacy 根据查询和过滤器生成缓存键 +// 为保持向后兼容,保留原函数,但重命名为更清晰的名称 +func GenerateCacheKeyLegacy(query string, filters map[string]string) string { + // 如果只需要基于关键词的缓存,不考虑过滤器,调用新函数 if filters == nil || len(filters) == 0 { - // 直接使用查询字符串生成键,添加前缀以区分 - keyStr := "keyword_only:" + query - hash := md5.Sum([]byte(keyStr)) - return hex.EncodeToString(hash[:]) + return GenerateCacheKey(query, nil, "", nil) } // 创建包含查询和所有过滤器的字符串 diff --git a/util/cache/disk_cache.go b/util/cache/disk_cache.go index 80716a8..d18209e 100644 --- a/util/cache/disk_cache.go +++ b/util/cache/disk_cache.go @@ -3,12 +3,13 @@ package cache import ( "crypto/md5" "encoding/hex" - "encoding/json" "io/ioutil" "os" "path/filepath" "sync" "time" + + "pansou/util/json" ) // 磁盘缓存项元数据 diff --git a/util/cache/two_level_cache.go b/util/cache/two_level_cache.go index 8315b96..f2c2263 100644 --- a/util/cache/two_level_cache.go +++ b/util/cache/two_level_cache.go @@ -165,11 +165,16 @@ func NewTwoLevelCache() (*TwoLevelCache, error) { // 设置缓存 func (c *TwoLevelCache) Set(key string, data []byte, ttl time.Duration) error { - // 先设置内存缓存 + // 先设置内存缓存(这是快速操作,直接在当前goroutine中执行) c.memCache.Set(key, data, ttl) - // 再设置磁盘缓存 - return c.diskCache.Set(key, data, ttl) + // 异步设置磁盘缓存(这是IO操作,可能较慢) + go func(k string, d []byte, t time.Duration) { + // 使用独立的goroutine写入磁盘,避免阻塞调用者 + _ = c.diskCache.Set(k, d, t) + }(key, data, ttl) + + return nil } // 获取缓存 diff --git a/util/cache/utils.go b/util/cache/utils.go new file mode 100644 index 0000000..fe5a42c --- /dev/null +++ b/util/cache/utils.go @@ -0,0 +1,47 @@ +package cache + +import ( + "bytes" + "sync" + + "pansou/util/json" +) + +// 缓冲区对象池 +var bufferPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, +} + +// SerializeWithPool 使用对象池序列化数据 +func SerializeWithPool(v interface{}) ([]byte, error) { + buf := bufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bufferPool.Put(buf) + + // 使用sonic直接编码到缓冲区 + encoder := json.API.NewEncoder(buf) + if err := encoder.Encode(v); err != nil { + return nil, err + } + + // 复制结果以避免池化对象被修改 + result := make([]byte, buf.Len()) + copy(result, buf.Bytes()) + return result, nil +} + +// DeserializeWithPool 使用对象池反序列化数据 +func DeserializeWithPool(data []byte, v interface{}) error { + buf := bufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bufferPool.Put(buf) + + // 写入数据到缓冲区 + buf.Write(data) + + // 使用sonic从缓冲区解码 + decoder := json.API.NewDecoder(buf) + return decoder.Decode(v) +} \ No newline at end of file