Repository: fish2018/pansou Branch: main Commit: 8da4e6a10ee7 Files: 198 Total size: 1.9 MB Directory structure: gitextract_lptfo9vf/ ├── .dockerignore ├── .github/ │ └── workflows/ │ └── docker_ci.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── api/ │ ├── auth_handler.go │ ├── filter.go │ ├── handler.go │ ├── middleware.go │ └── router.go ├── config/ │ └── config.go ├── docker-compose.yml ├── docs/ │ ├── MCP-SERVICE.md │ ├── 插件开发指南.md │ └── 系统开发设计文档.md ├── go.mod ├── go.sum ├── main.go ├── mcp-config.json ├── model/ │ ├── plugin_result.go │ ├── request.go │ └── response.go ├── package.json ├── plugin/ │ ├── ahhhhfs/ │ │ ├── ahhhhfs.go │ │ └── html结构分析.md │ ├── aikanzy/ │ │ └── aikanzy.go │ ├── alupan/ │ │ ├── alupan.go │ │ └── html结构分析.md │ ├── ash/ │ │ ├── ash.go │ │ └── html结构分析.md │ ├── bixin/ │ │ ├── bixin.go │ │ └── json结构分析.md │ ├── cldi/ │ │ ├── cldi.go │ │ └── html结构分析.md │ ├── clmao/ │ │ ├── clmao.go │ │ └── html结构分析.md │ ├── clxiong/ │ │ ├── clxiong.go │ │ └── html结构分析.md │ ├── cyg/ │ │ └── cyg.go │ ├── daishudj/ │ │ ├── daishudj.go │ │ └── html结构分析.md │ ├── ddys/ │ │ ├── ddys.go │ │ └── html结构分析.md │ ├── discourse/ │ │ ├── discourse.go │ │ └── json结构分析.md │ ├── djgou/ │ │ └── djgou.go │ ├── duoduo/ │ │ ├── duoduo.go │ │ └── html结构分析.md │ ├── dyyj/ │ │ ├── dyyj.go │ │ └── html结构分析.md │ ├── erxiao/ │ │ ├── erxiao.go │ │ └── html结构分析.md │ ├── feikuai/ │ │ ├── feikuai.go │ │ ├── html结构分析.md │ │ └── json结构分析.md │ ├── fox4k/ │ │ ├── fox4k.go │ │ └── html结构分析.md │ ├── gying/ │ │ ├── README.md │ │ ├── gying.go │ │ └── html结构分析.md │ ├── haisou/ │ │ ├── haisou.go │ │ └── json结构分析.md │ ├── hdmoli/ │ │ ├── hdmoli.go │ │ └── html结构分析.md │ ├── hdr4k/ │ │ ├── hdr4k.go │ │ ├── html结构分析.md │ │ └── 设计文档.md │ ├── huban/ │ │ ├── html结构分析.md │ │ ├── huban.go │ │ └── json结构分析.md │ ├── hunhepan/ │ │ └── hunhepan.go │ ├── javdb/ │ │ └── javdb.go │ ├── jikepan/ │ │ └── jikepan.go │ ├── jsnoteclub/ │ │ └── jsnoteclub.go │ ├── jutoushe/ │ │ ├── html结构分析.md │ │ └── jutoushe.go │ ├── kkmao/ │ │ ├── html结构分析.md │ │ └── kkmao.go │ ├── kkv/ │ │ ├── html结构分析.md │ │ └── kkv.go │ ├── labi/ │ │ ├── html结构分析.md │ │ └── labi.go │ ├── leijing/ │ │ ├── html结构分析.md │ │ └── leijing.go │ ├── libvio/ │ │ ├── html结构分析.md │ │ └── libvio.go │ ├── lou1/ │ │ └── lou1.go │ ├── meitizy/ │ │ ├── json结构分析.md │ │ └── meitizy.go │ ├── miaoso/ │ │ ├── json结构分析.md │ │ └── miaoso.go │ ├── mikuclub/ │ │ ├── html结构分析.md │ │ ├── json结构分析.md │ │ └── mikuclub.go │ ├── mizixing/ │ │ └── mizixing.go │ ├── muou/ │ │ ├── html结构分析.md │ │ └── muou.go │ ├── nsgame/ │ │ ├── json结构分析.md │ │ └── nsgame.go │ ├── nyaa/ │ │ ├── html结构分析.md │ │ └── nyaa.go │ ├── ouge/ │ │ ├── json结构分析.md │ │ └── ouge.go │ ├── pan666/ │ │ └── pan666.go │ ├── pansearch/ │ │ └── pansearch.go │ ├── panta/ │ │ ├── panta.go │ │ └── panta插件设计文档.md │ ├── panwiki/ │ │ └── panwiki.go │ ├── panyq/ │ │ └── panyq.go │ ├── pianku/ │ │ ├── html结构分析.md │ │ └── pianku.go │ ├── plugin.go │ ├── qingying/ │ │ ├── html结构分析.md │ │ └── qingying.go │ ├── qqpd/ │ │ ├── README.md │ │ └── qqpd.go │ ├── quark4k/ │ │ ├── json结构分析.md │ │ └── quark4k.go │ ├── quarksoo/ │ │ ├── html结构分析.md │ │ └── quarksoo.go │ ├── qupanshe/ │ │ └── qupanshe.go │ ├── qupansou/ │ │ └── qupansou.go │ ├── sdso/ │ │ └── sdso.go │ ├── shandian/ │ │ ├── html结构分析.md │ │ └── shandian.go │ ├── sousou/ │ │ ├── json结构分析.md │ │ └── sousou.go │ ├── susu/ │ │ ├── html结构分析.md │ │ ├── susu.go │ │ └── susu插件设计文档.md │ ├── thepiratebay/ │ │ ├── html结构分析.md │ │ └── thepiratebay.go │ ├── u3c3/ │ │ └── u3c3.go │ ├── wanou/ │ │ ├── json结构分析.md │ │ └── wanou.go │ ├── weibo/ │ │ ├── README.md │ │ ├── weibo.go │ │ └── 微博用户搜索API文档.md │ ├── wuji/ │ │ ├── html结构分析.md │ │ └── wuji.go │ ├── xb6v/ │ │ └── xb6v.go │ ├── xdpan/ │ │ ├── html结构分析.md │ │ └── xdpan.go │ ├── xdyh/ │ │ ├── json结构分析.md │ │ └── xdyh.go │ ├── xiaoji/ │ │ ├── html结构分析.md │ │ └── xiaoji.go │ ├── xiaozhang/ │ │ ├── html结构分析.md │ │ └── xiaozhang.go │ ├── xinjuc/ │ │ ├── html结构分析.md │ │ └── xinjuc.go │ ├── xuexizhinan/ │ │ ├── html结构分析.md │ │ └── xuexizhinan.go │ ├── xys/ │ │ ├── html结构分析.md │ │ └── xys.go │ ├── yiove/ │ │ └── yiove.go │ ├── ypfxw/ │ │ ├── html结构分析.md │ │ └── ypfxw.go │ ├── yuhuage/ │ │ └── yuhuage.go │ ├── yunsou/ │ │ ├── html结构分析.md │ │ └── yunsou.go │ ├── zhizhen/ │ │ ├── json结构分析.md │ │ └── zhizhen.go │ └── zxzj/ │ ├── html结构分析.md │ └── zxzj.go ├── service/ │ ├── cache_integration.go │ └── search_service.go ├── typescript/ │ ├── package.json │ ├── src/ │ │ ├── index.ts │ │ ├── tools/ │ │ │ ├── health.ts │ │ │ ├── search.ts │ │ │ └── start-backend.ts │ │ └── utils/ │ │ ├── activity-monitor.ts │ │ ├── backend-manager.ts │ │ ├── config.ts │ │ ├── http-client.ts │ │ └── validators.ts │ └── tsconfig.json └── util/ ├── cache/ │ ├── cache_key.go │ ├── delayed_batch_write_manager.go │ ├── disk_cache.go │ ├── enhanced_two_level_cache.go │ ├── global_buffer_manager.go │ ├── memory_cache.go │ ├── serializer.go │ ├── sharded_disk_cache.go │ ├── sharded_memory_cache.go │ └── utils.go ├── compression.go ├── convert.go ├── http_util.go ├── json/ │ └── json.go ├── jwt.go ├── parser_util.go ├── pool/ │ ├── object_pool.go │ └── worker_pool.go └── regex_util.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # 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 ================================================ FILE: .github/workflows/docker_ci.yml ================================================ name: 构建并发布Docker镜像 on: push: branches: - "main" paths-ignore: - "README.md" - "docs/**" pull_request: branches: - "main" 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.DOCKER }} - 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 }} ================================================ FILE: .gitignore ================================================ .DS_Store ================================================ FILE: Dockerfile ================================================ # 构建阶段 # 使用 --platform=$BUILDPLATFORM 确保构建器始终在运行 Actions 的机器的原生架构上运行 (通常是 linux/amd64) # $BUILDPLATFORM 是 buildx 自动提供的变量 FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder # 安装构建依赖 RUN apk add --no-cache git ca-certificates tzdata # 设置工作目录 WORKDIR /app # 复制依赖文件 COPY go.mod go.sum ./ # 下载依赖 RUN go mod download # 复制源代码 COPY . . # 构建参数 ARG VERSION=dev ARG BUILD_DATE=unknown ARG VCS_REF=unknown # 这是 buildx 自动传入的目标平台架构参数,例如 amd64, arm64 ARG TARGETARCH # 构建应用 # Go 语言原生支持交叉编译,这里会根据传入的 TARGETARCH 编译出对应平台的可执行文件 RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w -extldflags '-static'" -o pansou . # 运行阶段 # 这一阶段会根据 buildx 的 --platform 参数选择正确的基础镜像 (例如 linux/arm64 会拉取 arm64/alpine) FROM alpine:3.19 # 添加运行时依赖 RUN apk add --no-cache ca-certificates tzdata # 创建缓存目录 RUN mkdir -p /app/cache # 从构建阶段复制可执行文件 # buildx 会智能地从对应平台的 builder 中复制正确的可执行文件 COPY --from=builder /app/pansou /app/pansou # 设置工作目录 WORKDIR /app # 暴露端口 EXPOSE 8888 # 设置环境变量 # ENABLED_PLUGINS: 必须指定启用的插件,多个插件用逗号分隔 # AUTH_ENABLED: 认证功能默认关闭,可通过环境变量启用 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 \ CHANNELS=tgsearchers4,Aliyun_4K_Movies,bdbdndn11,yunpanx,bsbdbfjfjff,yp123pan,sbsbsnsqq,yunpanxunlei,tianyifc,BaiduCloudDisk,txtyzy,peccxinpd,gotopan,PanjClub,kkxlzy,baicaoZY,MCPH01,MCPH02,MCPH03,bdwpzhpd,ysxb48,jdjdn1111,yggpan,MCPH086,zaihuayun,Q66Share,ucwpzy,shareAliyun,alyp_1,dianyingshare,Quark_Movies,XiangxiuNBB,ydypzyfx,ucquark,xx123pan,yingshifenxiang123,zyfb123,tyypzhpd,tianyirigeng,cloudtianyi,hdhhd21,Lsp115,oneonefivewpfx,qixingzhenren,taoxgzy,Channel_Shares_115,tyysypzypd,vip115hot,wp123zy,yunpan139,yunpan189,yunpanuc,yydf_hzl,leoziyuan,pikpakpan,Q_dongman,yoyokuakeduanju,TG654TG,WFYSFX02,QukanMovie,yeqingjie_GJG666,movielover8888_film3,Baidu_netdisk,D_wusun,FLMdongtianfudi,KaiPanshare,QQZYDAPP,rjyxfx,PikPak_Share_Channel,btzhi,newproductsourcing,cctv1211,duan_ju,QuarkFree,yunpanNB,kkdj001,xxzlzn,pxyunpanxunlei,jxwpzy,kuakedongman,liangxingzhinan,xiangnikanj,solidsexydoll,guoman4K,zdqxm,kduanju,cilidianying,CBduanju,SharePanFilms,dzsgx,BooksRealm,Oscar_4Kmovies,douerpan,baidu_yppan,Q_jilupian,Netdisk_Movies,yunpanquark,ammmziyuan,ciliziyuanku,cili8888,jzmm_123pan \ ENABLED_PLUGINS=labi,zhizhen,shandian,duoduo,muou,wanou,hunhepan,jikepan,panwiki,pansearch,panta,qupansou,hdr4k,pan666,susu,thepiratebay,xuexizhinan,panyq,ouge,huban,cyg,erxiao,miaoso,fox4k,pianku,clmao,wuji,cldi,xiaozhang,libvio,leijing,xb6v,xys,ddys,hdmoli,yuhuage,u3c3,javdb,clxiong,jutoushe,sdso,xiaoji,xdyh,haisou,bixin,djgou,nyaa,xinjuc,aikanzy,qupanshe,xdpan,discourse,yunsou,qqpd,ahhhhfs,nsgame,gying,quark4k,quarksoo,sousou,ash \ AUTH_ENABLED=false \ AUTH_TOKEN_EXPIRY=24 # 构建参数 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"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 fish2018 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # PanSou 网盘搜索API PanSou是一个高性能的网盘资源搜索API服务,支持TG搜索和自定义插件搜索。系统设计以性能和可扩展性为核心,支持并发搜索、结果智能排序和网盘类型分类。 [//]: # (MCP服务文档: [MCP-SERVICE.md](docs/MCP-SERVICE.md)) ## 特性([详见系统设计文档](docs/%E7%B3%BB%E7%BB%9F%E5%BC%80%E5%8F%91%E8%AE%BE%E8%AE%A1%E6%96%87%E6%A1%A3.md)) - **高性能搜索**:并发执行多个TG频道及异步插件搜索,显著提升搜索速度;工作池设计,高效管理并发任务 - **网盘类型分类**:自动识别多种网盘链接,按类型归类展示 - **智能排序**:基于插件等级、时间新鲜度和优先关键词的多维度综合排序算法 - **异步插件系统**:支持通过插件扩展搜索来源,支持"尽快响应,持续处理"的异步搜索模式,解决了某些搜索源响应时间长的问题。详情参考[**插件开发指南**](docs/插件开发指南.md) - **二级缓存**:分片内存+分片磁盘缓存机制,大幅提升重复查询速度和并发性能 ## MCP 服务 PanSou 还提供了一个基于 [Model Context Protocol (MCP)](https://modelcontextprotocol.io) 的服务,可以将搜索功能集成到 Claude Desktop 等支持 MCP 的应用中。详情请参阅 [MCP 服务文档](docs/MCP-SERVICE.md)。 ## 支持的网盘类型 百度网盘 (`baidu`)、阿里云盘 (`aliyun`)、夸克网盘 (`quark`)、天翼云盘 (`tianyi`)、UC网盘 (`uc`)、移动云盘 (`mobile`)、115网盘 (`115`)、PikPak (`pikpak`)、迅雷网盘 (`xunlei`)、123网盘 (`123`)、磁力链接 (`magnet`)、电驴链接 (`ed2k`)、其他 (`others`) ## 快速开始 在 Github 上先[![Fork me on GitHub](https://raw.githubusercontent.com/fishforks/fish2018/refs/heads/main/forkme.png)](https://github.com/fish2018/pansou/fork) 本项目,并点上 Star !!! ### 使用Docker部署 [qqpd搜索插件文档](plugin/qqpd/README.md) [gying搜索插件文档](plugin/gying/README.md) [weibo搜索插件文档](plugin/weibo/README.md) [常见问题总结](https://github.com/fish2018/pansou/issues/46) [TG/QQ频道/插件/微博](https://github.com/fish2018/pansou/issues/4) #### **1、前后端集成版** ##### 直接使用Docker命令 一键启动,开箱即用 ``` docker run -d --name pansou -p 80:80 ghcr.io/fish2018/pansou-web ``` ##### 使用Docker Compose(推荐) ``` # 下载配置文件 curl -o docker-compose.yml https://raw.githubusercontent.com/fish2018/pansou-web/refs/heads/main/docker-compose.yml # 启动服务 docker-compose up -d # 查看日志 docker-compose logs -f ``` #### **2、纯后端API版** ##### 直接使用Docker命令 ```bash docker run -d --name pansou -p 8888:8888 ghcr.io/fish2018/pansou:latest ``` ##### 使用Docker Compose(推荐) ```bash # 下载配置文件 curl -o docker-compose.yml https://raw.githubusercontent.com/fish2018/pansou/refs/heads/main/docker-compose.yml # 启动服务 docker-compose up -d # 访问服务 http://localhost:8888 ``` ### 从源码安装 #### 环境要求 - Go 1.18+ - 可选:SOCKS5代理(用于访问受限地区的Telegram站点) 1. 克隆仓库 ```bash git clone https://github.com/fish2018/pansou.git cd pansou ``` 2. 配置环境变量(可选) #### 基础配置 | 环境变量 | 描述 | 默认值 | 说明 | |----------|------|--------|------| | **PORT** | 服务端口 | `8888` | 修改服务监听端口 | | **PROXY** | SOCKS5代理 | 无 | 如:`PROXY=socks5://127.0.0.1:1080` | | **HTTPS_PROXY/HTTP_PROXY** | HTTPS/HTTP代理 | 无 | 如:`HTTPS_PROXY=http://127.0.0.1:1080`,`HTTP_PROXY=http://127.0.0.1:1080` | | **CHANNELS** | 默认搜索的TG频道 | `tgsearchers3` | 多个频道用逗号分隔 | | **ENABLED_PLUGINS** | 指定启用插件,多个插件用逗号分隔 | 无 | 必须显式指定 | #### 认证配置(可选) PanSou支持可选的安全认证功能,默认关闭。开启后,所有API接口(除登录接口外)都需要提供有效的JWT Token。详见[认证系统设计文档](docs/认证系统设计.md)。 | 环境变量 | 描述 | 默认值 | 说明 | |----------|------|--------|------| | **AUTH_ENABLED** | 是否启用认证 | `false` | 设置为`true`启用认证功能 | | **AUTH_USERS** | 用户账号配置 | 无 | 格式:`user1:pass1,user2:pass2` | | **AUTH_TOKEN_EXPIRY** | Token有效期(小时) | `24` | JWT Token的有效时长 | | **AUTH_JWT_SECRET** | JWT签名密钥 | 自动生成 | 用于签名Token,建议手动设置 | **认证配置示例:** ```bash # 启用认证并配置单个用户 docker run -d --name pansou -p 8888:8888 \ -e AUTH_ENABLED=true \ -e AUTH_USERS=admin:admin123 \ -e AUTH_TOKEN_EXPIRY=24 \ ghcr.io/fish2018/pansou:latest # 配置多个用户 docker run -d --name pansou -p 8888:8888 \ -e AUTH_ENABLED=true \ -e AUTH_USERS=admin:pass123,user1:pass456,user2:pass789 \ ghcr.io/fish2018/pansou:latest ``` **认证API接口:** - `POST /api/auth/login` - 用户登录,获取Token - `POST /api/auth/verify` - 验证Token有效性 - `POST /api/auth/logout` - 退出登录(客户端删除Token) **使用Token调用API:** ```bash # 1. 登录获取Token curl -X POST http://localhost:8888/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"admin123"}' # 响应:{"token":"eyJhbGc...","expires_at":1234567890,"username":"admin"} # 2. 使用Token调用搜索API curl -X POST http://localhost:8888/api/search \ -H "Authorization: Bearer eyJhbGc..." \ -H "Content-Type: application/json" \ -d '{"kw":"速度与激情"}' ``` #### 高级配置(默认值即可)
点击展开高级配置选项(通常不需要修改) | 环境变量 | 描述 | 默认值 | |----------|------|--------| | CONCURRENCY | 并发搜索数 | 自动计算 | | CACHE_TTL | 缓存有效期(分钟) | `60` | | CACHE_MAX_SIZE | 最大缓存大小(MB) | `100` | | PLUGIN_TIMEOUT | 插件超时时间(秒) | `30` | | ASYNC_RESPONSE_TIMEOUT | 快速响应超时(秒) | `4` | | ASYNC_LOG_ENABLED | 异步插件详细日志 | `true` | | CACHE_PATH | 缓存文件路径 | `./cache` | | SHARD_COUNT | 缓存分片数量 | `8` | | CACHE_WRITE_STRATEGY | 缓存写入策略(immediate/hybrid) | `hybrid` | | ENABLE_COMPRESSION | 是否启用压缩 | `false` | | MIN_SIZE_TO_COMPRESS | 最小压缩阈值(字节) | `1024` | | GC_PERCENT | Go GC触发百分比 | `50` | | ASYNC_MAX_BACKGROUND_WORKERS | 最大后台工作者数量 | CPU核心数×5 | | ASYNC_MAX_BACKGROUND_TASKS | 最大后台任务数量 | 工作者数×5 | | ASYNC_CACHE_TTL_HOURS | 异步缓存有效期(小时) | `1` | | ASYNC_PLUGIN_ENABLED | 异步插件是否启用 | `true` | | HTTP_READ_TIMEOUT | HTTP读取超时(秒) | 自动计算 | | HTTP_WRITE_TIMEOUT | HTTP写入超时(秒) | 自动计算 | | HTTP_IDLE_TIMEOUT | HTTP空闲超时(秒) | `120` | | HTTP_MAX_CONNS | HTTP最大连接数 | 自动计算 |
3. 构建 ```linux CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -extldflags '-static'" -o pansou . ``` 4. 运行 ```bash ./pansou ``` ### 其他配置参考
点击展开 supervisor 配置参考 ``` [program:pansou] environment=PORT=8888,CHANNELS="tgsearchers4,Aliyun_4K_Movies,bdbdndn11,yunpanx,bsbdbfjfjff,yp123pan,sbsbsnsqq,yunpanxunlei,tianyifc,BaiduCloudDisk,txtyzy,peccxinpd,gotopan,PanjClub,kkxlzy,baicaoZY,MCPH01,bdwpzhpd,ysxb48,jdjdn1111,yggpan,MCPH086,zaihuayun,Q66Share,Oscar_4Kmovies,ucwpzy,shareAliyun,alyp_1,dianyingshare,Quark_Movies,XiangxiuNBB,ydypzyfx,ucquark,xx123pan,yingshifenxiang123,zyfb123,tyypzhpd,tianyirigeng,cloudtianyi,hdhhd21,Lsp115,oneonefivewpfx,qixingzhenren,taoxgzy,Channel_Shares_115,tyysypzypd,vip115hot,wp123zy,yunpan139,yunpan189,yunpanuc,yydf_hzl,leoziyuan,pikpakpan,Q_dongman,yoyokuakeduanju",ENABLED_PLUGINS="labi,zhizhen,shandian,duoduo,muou" command=/home/work/pansou/pansou directory=/home/work/pansou autostart=true autorestart=true startsecs=5 startretries=3 exitcodes=0 stopwaitsecs=10 stopasgroup=true killasgroup=true ```
点击展开 nginx 配置参考 ``` server { listen 80; server_name pansou.252035.xyz; # 将 HTTP 重定向到 HTTPS return 301 https://$host$request_uri; } limit_req_zone $binary_remote_addr zone=api_limit:10m rate=60r/m; server { listen 443 ssl http2; server_name pansou.252035.xyz; access_log /home/work/logs/pansou.log; # 证书和密钥路径 ssl_certificate /etc/letsencrypt/live/252035.xyz/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/252035.xyz/privkey.pem; # 增强 SSL 安全性 ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH; ssl_prefer_server_ciphers on; # 后端代理,应用限流 location / { # 应用限流规则 limit_req zone=api_limit burst=10 nodelay; # 当超过限制时返回 429 状态码 limit_req_status 429; proxy_pass http://127.0.0.1:8888; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ```
## API文档 ### 认证说明 当启用认证功能(`AUTH_ENABLED=true`)时,除登录和健康检测接口外的所有API接口都需要提供有效的JWT Token。 **请求头格式**: ``` Authorization: Bearer ``` **获取Token**: 1. 调用登录接口获取Token(详见下方[认证API](#认证API)) 2. 在后续所有API请求的Header中添加`Authorization: Bearer ` 3. Token过期后需要重新登录获取新Token **示例**: ```bash # 未启用认证时 curl -X POST http://localhost:8888/api/search \ -H "Content-Type: application/json" \ -d '{"kw":"速度与激情"}' # 启用认证时 curl -X POST http://localhost:8888/api/search \ -H "Content-Type: application/json" \ -H "Authorization: Bearer eyJhbGc..." \ -d '{"kw":"速度与激情"}' ``` ### 认证API #### 用户登录 获取JWT Token用于后续API调用。 **接口地址**:`/api/auth/login` **请求方法**:`POST` **Content-Type**:`application/json` **是否需要认证**:否 **请求参数**: | 参数名 | 类型 | 必填 | 描述 | |--------|------|------|------| | username | string | 是 | 用户名 | | password | string | 是 | 密码 | **请求示例**: ```bash curl -X POST http://localhost:8888/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"admin123"}' ``` **成功响应**: ```json { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expires_at": 1234567890, "username": "admin" } ``` **错误响应**: ```json { "error": "用户名或密码错误" } ``` #### 验证Token 验证当前Token是否有效。 **接口地址**:`/api/auth/verify` **请求方法**:`POST` **是否需要认证**:是 **请求示例**: ```bash curl -X POST http://localhost:8888/api/auth/verify \ -H "Authorization: Bearer eyJhbGc..." ``` **成功响应**: ```json { "valid": true, "username": "admin" } ``` #### 退出登录 退出当前登录(客户端删除Token即可)。 **接口地址**:`/api/auth/logout` **请求方法**:`POST` **是否需要认证**:否 **请求示例**: ```bash curl -X POST http://localhost:8888/api/auth/logout ``` **成功响应**: ```json { "message": "退出成功" } ``` ### 搜索API 搜索网盘资源。 **接口地址**:`/api/search` **请求方法**:`POST` 或 `GET` **Content-Type**:`application/json`(POST方法) **是否需要认证**:取决于`AUTH_ENABLED`配置 **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[] | 否 | 指定搜索的插件列表,不指定则搜索全部插件 | | cloud_types | string[] | 否 | 指定返回的网盘类型列表,支持:baidu、aliyun、quark、tianyi、uc、mobile、115、pikpak、xunlei、123、magnet、ed2k,不指定则返回所有类型 | | ext | object | 否 | 扩展参数,用于传递给插件的自定义参数,如{"title_en":"English Title", "is_all":true} | | filter | object | 否 | 过滤配置,用于过滤返回结果。格式:{"include":["关键词1","关键词2"],"exclude":["排除词1","排除词2"]}。include为包含关键词列表(OR关系),exclude为排除关键词列表(OR关系) | **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 | 否 | 指定搜索的插件列表,使用英文逗号分隔多个插件名,不指定则搜索全部插件 | | cloud_types | string | 否 | 指定返回的网盘类型列表,使用英文逗号分隔多个类型,支持:baidu、aliyun、quark、tianyi、uc、mobile、115、pikpak、xunlei、123、magnet、ed2k,不指定则返回所有类型 | | ext | string | 否 | JSON格式的扩展参数,用于传递给插件的自定义参数,如{"title_en":"English Title", "is_all":true} | | filter | string | 否 | JSON格式的过滤配置,用于过滤返回结果。格式:{"include":["关键词1","关键词2"],"exclude":["排除词1","排除词2"]} | **POST请求示例**: ```bash # 未启用认证 curl -X POST http://localhost:8888/api/search \ -H "Content-Type: application/json" \ -d '{ "kw": "速度与激情", "channels": ["tgsearchers3", "xxx"], "conc": 2, "refresh": true, "res": "merge", "src": "all", "plugins": ["jikepan"], "cloud_types": ["baidu", "quark"], "ext": { "title_en": "Fast and Furious", "is_all": true } }' # 启用认证时(需要添加Authorization头) curl -X POST http://localhost:8888/api/search \ -H "Content-Type: application/json" \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ -d '{ "kw": "速度与激情", "res": "merge" }' # 使用过滤器(只返回包含“合集”或“全集”,且不包含“预告”或“花絮”的结果) curl -X POST http://localhost:8888/api/search \ -H "Content-Type: application/json" \ -d '{ "kw": "唐朝诡事录", "filter": { "include": ["合集", "全集"], "exclude": ["预告", "花絮"] } }' ``` **GET请求示例**: ```bash # 未启用认证 curl "http://localhost:8888/api/search?kw=速度与激情&res=merge&src=tg" # 启用认证时(需要添加Authorization头) curl "http://localhost:8888/api/search?kw=速度与激情&res=merge" \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." # 使用过滤器(GET方式需要URL编码JSON) curl "http://localhost:8888/api/search?kw=唐朝诡事录&filter=%7B%22include%22%3A%5B%22合集%22%2C%22全集%22%5D%2C%22exclude%22%3A%5B%22预告%22%5D%7D" ``` **成功响应**: ```json { "total": 15, "results": [ { "message_id": "12345", "unique_id": "channel-12345", "channel": "tgsearchers3", "datetime": "2023-06-10T14:23:45Z", "title": "速度与激情全集1-10", "content": "速度与激情系列全集,1080P高清...", "links": [ { "type": "baidu", "url": "https://pan.baidu.com/s/1abcdef", "password": "1234", "datetime": "2023-06-10T14:23:45Z", "work_title": "速度与激情全集1-10" } ], "tags": ["电影", "合集"], "images": [ "https://cdn1.cdn-telegram.org/file/xxx.jpg" ] }, // 更多结果... ], "merged_by_type": { "baidu": [ { "url": "https://pan.baidu.com/s/1abcdef", "password": "1234", "note": "速度与激情全集1-10", "datetime": "2023-06-10T14:23:45Z", "source": "tg:频道名称", "images": [ "https://cdn1.cdn-telegram.org/file/xxx.jpg" ] }, // 更多百度网盘链接... ], "quark": [ { "url": "https://pan.quark.cn/s/xxxx", "password": "", "note": "凡人修仙传", "datetime": "2023-06-10T15:30:22Z", "source": "plugin:插件名", "images": [] } ], "aliyun": [ // 阿里云盘链接... ] // 更多网盘类型... } } ``` **字段说明**: **SearchResult对象**: - `message_id`: 消息ID - `unique_id`: 全局唯一标识符 - `channel`: 来源频道名称 - `datetime`: 消息发布时间 - `title`: 消息标题 - `content`: 消息内容 - `links`: 网盘链接数组 - `tags`: 标签数组(可选) - `images`: TG消息中的图片链接数组(可选) **Link对象**: - `type`: 网盘类型(baidu、quark、aliyun等) - `url`: 网盘链接地址 - `password`: 提取码/密码 - `datetime`: 链接更新时间(可选) - `work_title`: 作品标题(可选) - 用于区分同一消息中多个作品的链接 - 当一条消息包含≤4个链接时,所有链接使用相同的work_title - 当一条消息包含>4个链接时,系统会智能识别每个链接对应的作品标题 **MergedLink对象**: - `url`: 网盘链接地址 - `password`: 提取码/密码 - `note`: 资源说明/标题 - `datetime`: 链接更新时间 - `source`: 数据来源标识 - `tg:频道名称`: 来自Telegram频道 - `plugin:插件名`: 来自指定插件 - `unknown`: 未知来源 - `images`: TG消息中的图片链接数组(可选) - 仅在来源为Telegram频道且消息包含图片时出现 **错误响应**: ```json // 参数错误 { "code": 400, "message": "关键词不能为空" } // 未授权(启用认证但未提供Token) { "error": "未授权:缺少认证令牌", "code": "AUTH_TOKEN_MISSING" } // Token无效或过期 { "error": "未授权:令牌无效或已过期", "code": "AUTH_TOKEN_INVALID" } ``` ### 健康检查 检查API服务是否正常运行。 **接口地址**:`/api/health` **请求方法**:`GET` **是否需要认证**:否(公开接口) **请求示例**: ```bash curl http://localhost:8888/api/health ``` **成功响应**: ```json { "status": "ok", "auth_enabled": true, "plugins_enabled": true, "plugin_count": 16, "plugins": [ "pansearch", "panta", "qupansou", "hunhepan", "jikepan", "pan666", "panyq", "susu", "xuexizhinan", "hdr4k", "labi", "shandian", "duoduo", "muou", "wanou", "ouge", "zhizhen", "huban" ], "channels_count": 1, "channels": [ "tgsearchers3" ] } ``` **字段说明**: - `status`: 服务状态,"ok"表示正常 - `auth_enabled`: 是否启用认证功能 - `plugins_enabled`: 是否启用插件 - `plugin_count`: 已启用的插件数量 - `plugins`: 已启用的插件列表 - `channels_count`: 配置的频道数量 - `channels`: 配置的频道列表 ## 📄 许可证 本项目采用 MIT 许可证。详情请见 [LICENSE](LICENSE) 文件。 ## ⭐ Star 历史 [![Star History Chart](https://api.star-history.com/svg?repos=fish2018/pansou&type=Date)](https://star-history.com/#fish2018/pansou&Date) ================================================ FILE: api/auth_handler.go ================================================ package api import ( "time" "github.com/gin-gonic/gin" "pansou/config" "pansou/util" ) // LoginRequest 登录请求结构 type LoginRequest struct { Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required"` } // LoginResponse 登录响应结构 type LoginResponse struct { Token string `json:"token"` ExpiresAt int64 `json:"expires_at"` Username string `json:"username"` } // LoginHandler 处理用户登录 func LoginHandler(c *gin.Context) { var req LoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "参数错误:用户名和密码不能为空"}) return } // 验证认证系统是否启用 if !config.AppConfig.AuthEnabled { c.JSON(403, gin.H{"error": "认证功能未启用"}) return } // 验证用户配置是否存在 if config.AppConfig.AuthUsers == nil || len(config.AppConfig.AuthUsers) == 0 { c.JSON(500, gin.H{"error": "认证系统未正确配置"}) return } // 验证用户名和密码 storedPassword, exists := config.AppConfig.AuthUsers[req.Username] if !exists || storedPassword != req.Password { c.JSON(401, gin.H{"error": "用户名或密码错误"}) return } // 生成JWT token token, err := util.GenerateToken( req.Username, config.AppConfig.AuthJWTSecret, config.AppConfig.AuthTokenExpiry, ) if err != nil { c.JSON(500, gin.H{"error": "生成令牌失败"}) return } // 返回token和过期时间 expiresAt := time.Now().Add(config.AppConfig.AuthTokenExpiry).Unix() c.JSON(200, LoginResponse{ Token: token, ExpiresAt: expiresAt, Username: req.Username, }) } // VerifyHandler 验证token有效性 func VerifyHandler(c *gin.Context) { // 如果未启用认证,直接返回有效 if !config.AppConfig.AuthEnabled { c.JSON(200, gin.H{ "valid": true, "message": "认证功能未启用", }) return } // 如果能到达这里,说明中间件已经验证通过 username, exists := c.Get("username") if !exists { c.JSON(401, gin.H{"error": "未授权"}) return } c.JSON(200, gin.H{ "valid": true, "username": username, }) } // LogoutHandler 退出登录(客户端删除token即可) func LogoutHandler(c *gin.Context) { // JWT是无状态的,服务端不需要处理注销 // 客户端删除存储的token即可 c.JSON(200, gin.H{"message": "退出成功"}) } ================================================ FILE: api/filter.go ================================================ package api import ( "pansou/model" "strings" ) // applyResultFilter 应用过滤器到搜索响应 func applyResultFilter(response model.SearchResponse, filter *model.FilterConfig, resultType string) model.SearchResponse { if filter == nil || (len(filter.Include) == 0 && len(filter.Exclude) == 0) { return response } // 预处理关键词(转小写) includeKeywords := make([]string, len(filter.Include)) for i, kw := range filter.Include { includeKeywords[i] = strings.ToLower(kw) } excludeKeywords := make([]string, len(filter.Exclude)) for i, kw := range filter.Exclude { excludeKeywords[i] = strings.ToLower(kw) } // 根据结果类型决定过滤策略 if resultType == "merged_by_type" || resultType == "" { // 过滤 merged_by_type 的 note 字段 response.MergedByType = filterMergedByType(response.MergedByType, includeKeywords, excludeKeywords) // 重新计算 total total := 0 for _, links := range response.MergedByType { total += len(links) } response.Total = total } else if resultType == "all" || resultType == "results" { // 过滤 results 的 title 和 links 的 work_title response.Results = filterResults(response.Results, includeKeywords, excludeKeywords) response.Total = len(response.Results) // 如果是 all 类型,也需要过滤 merged_by_type if resultType == "all" { response.MergedByType = filterMergedByType(response.MergedByType, includeKeywords, excludeKeywords) } } return response } // filterMergedByType 过滤 merged_by_type 中的链接 func filterMergedByType(mergedLinks model.MergedLinks, includeKeywords, excludeKeywords []string) model.MergedLinks { if mergedLinks == nil { return nil } filtered := make(model.MergedLinks) for linkType, links := range mergedLinks { filteredLinks := make([]model.MergedLink, 0) for _, link := range links { if matchFilter(link.Note, includeKeywords, excludeKeywords) { filteredLinks = append(filteredLinks, link) } } // 只添加非空的类型 if len(filteredLinks) > 0 { filtered[linkType] = filteredLinks } } return filtered } // filterResults 过滤 results 数组 func filterResults(results []model.SearchResult, includeKeywords, excludeKeywords []string) []model.SearchResult { if results == nil { return nil } filtered := make([]model.SearchResult, 0) for _, result := range results { // 先检查 title 是否匹配 if !matchFilter(result.Title, includeKeywords, excludeKeywords) { continue } // title 匹配后,过滤 links 中的 work_title filteredLinks := make([]model.Link, 0) for _, link := range result.Links { // 如果 link 有 work_title,检查它;否则使用 result.Title checkText := link.WorkTitle if checkText == "" { checkText = result.Title } if matchFilter(checkText, includeKeywords, excludeKeywords) { filteredLinks = append(filteredLinks, link) } } // 只有有链接的结果才添加 if len(filteredLinks) > 0 { result.Links = filteredLinks filtered = append(filtered, result) } } return filtered } // matchFilter 检查文本是否匹配过滤条件 func matchFilter(text string, includeKeywords, excludeKeywords []string) bool { lowerText := strings.ToLower(text) // 检查 exclude(任一匹配则排除) for _, kw := range excludeKeywords { if strings.Contains(lowerText, kw) { return false } } // 检查 include(如果有 include 列表,必须至少匹配一个) if len(includeKeywords) > 0 { matched := false for _, kw := range includeKeywords { if strings.Contains(lowerText, kw) { matched = true break } } if !matched { return false } } return true } ================================================ FILE: api/handler.go ================================================ package api import ( // "fmt" "net/http" // "os" "github.com/gin-gonic/gin" "pansou/config" "pansou/model" "pansou/service" jsonutil "pansou/util/json" "pansou/util" "strings" ) // 保存搜索服务的实例 var searchService *service.SearchService // SetSearchService 设置搜索服务实例 func SetSearchService(service *service.SearchService) { searchService = service } // 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 = "merge" // 直接设置为默认值merge } sourceType := c.Query("src") if sourceType == "" || sourceType == " " { sourceType = "all" // 直接设置为默认值all } // 处理plugins参数,支持逗号分隔 var plugins []string // 检查请求中是否存在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 } // 处理cloud_types参数,支持逗号分隔 var cloudTypes []string // 检查请求中是否存在cloud_types参数 if c.Request.URL.Query().Has("cloud_types") { cloudTypesStr := c.Query("cloud_types") // 判断参数是否非空 if cloudTypesStr != "" && cloudTypesStr != " " { parts := strings.Split(cloudTypesStr, ",") for _, part := range parts { trimmed := strings.TrimSpace(part) if trimmed != "" { cloudTypes = append(cloudTypes, trimmed) } } } } else { // 如果请求中不存在cloud_types参数,设置为nil cloudTypes = nil } // 处理ext参数,JSON格式 var ext map[string]interface{} extStr := c.Query("ext") if extStr != "" && extStr != " " { // 处理特殊情况:ext={} if extStr == "{}" { ext = make(map[string]interface{}) } else { if err := jsonutil.Unmarshal([]byte(extStr), &ext); err != nil { c.JSON(http.StatusBadRequest, model.NewErrorResponse(400, "无效的ext参数格式: "+err.Error())) return } } } // 确保ext不为nil if ext == nil { ext = make(map[string]interface{}) } // 处理filter参数,JSON格式 var filter *model.FilterConfig filterStr := c.Query("filter") if filterStr != "" && filterStr != " " { filter = &model.FilterConfig{} if err := jsonutil.Unmarshal([]byte(filterStr), filter); err != nil { c.JSON(http.StatusBadRequest, model.NewErrorResponse(400, "无效的filter参数格式: "+err.Error())) return } } req = model.SearchRequest{ Keyword: keyword, Channels: channels, Concurrency: concurrency, ForceRefresh: forceRefresh, ResultType: resultType, SourceType: sourceType, Plugins: plugins, CloudTypes: cloudTypes, // 添加cloud_types到请求中 Ext: ext, Filter: filter, } } 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并转换为merged_by_type if req.ResultType == "" { req.ResultType = "merged_by_type" } 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参数 } else if req.SourceType == "all" { // 对于all类型,如果plugins为空或不存在,统一设为nil if req.Plugins == nil || len(req.Plugins) == 0 { req.Plugins = nil } } // 可选:启用调试输出(生产环境建议注释掉) // fmt.Printf("🔧 [调试] 搜索参数: keyword=%s, channels=%v, concurrency=%d, refresh=%v, resultType=%s, sourceType=%s, plugins=%v, cloudTypes=%v, ext=%v\n", // req.Keyword, req.Channels, req.Concurrency, req.ForceRefresh, req.ResultType, req.SourceType, req.Plugins, req.CloudTypes, req.Ext) // 执行搜索 result, err := searchService.Search(req.Keyword, req.Channels, req.Concurrency, req.ForceRefresh, req.ResultType, req.SourceType, req.Plugins, req.CloudTypes, req.Ext) if err != nil { response := model.NewErrorResponse(500, "搜索失败: "+err.Error()) jsonData, _ := jsonutil.Marshal(response) c.Data(http.StatusInternalServerError, "application/json", jsonData) return } // 应用过滤器 if req.Filter != nil { result = applyResultFilter(result, req.Filter, req.ResultType) } // 包装SearchResponse到标准响应格式中 response := model.NewSuccessResponse(result) jsonData, _ := jsonutil.Marshal(response) c.Data(http.StatusOK, "application/json", jsonData) } ================================================ FILE: api/middleware.go ================================================ package api import ( "fmt" "net/url" "strings" "time" "github.com/gin-gonic/gin" "pansou/config" "pansou/util" ) // 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 // 对于搜索API,尝试解码关键词以便更好地显示 displayURI := reqURI if strings.Contains(reqURI, "/api/search") && strings.Contains(reqURI, "kw=") { if parsedURL, err := url.Parse(reqURI); err == nil { if keyword := parsedURL.Query().Get("kw"); keyword != "" { if decodedKeyword, err := url.QueryUnescape(keyword); err == nil { // 替换原始URI中的编码关键词为解码后的关键词 displayURI = strings.Replace(reqURI, "kw="+keyword, "kw="+decodedKeyword, 1) } } } } // 状态码 statusCode := c.Writer.Status() // 请求IP clientIP := c.ClientIP() // 日志格式 gin.DefaultWriter.Write([]byte( fmt.Sprintf("| %s | %s | %s | %d | %s\n", clientIP, reqMethod, displayURI, statusCode, latencyTime.String()))) } } // AuthMiddleware JWT认证中间件 func AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { // 如果未启用认证,直接放行 if !config.AppConfig.AuthEnabled { c.Next() return } // 定义公开接口(不需要认证) publicPaths := []string{ "/api/auth/login", "/api/auth/logout", "/api/health", // 健康检查接口可选择是否需要认证 } // 检查当前路径是否是公开接口 path := c.Request.URL.Path for _, p := range publicPaths { if strings.HasPrefix(path, p) { c.Next() return } } // 获取Authorization头 authHeader := c.GetHeader("Authorization") if authHeader == "" { c.JSON(401, gin.H{ "error": "未授权:缺少认证令牌", "code": "AUTH_TOKEN_MISSING", }) c.Abort() return } // 解析Bearer token const bearerPrefix = "Bearer " if !strings.HasPrefix(authHeader, bearerPrefix) { c.JSON(401, gin.H{ "error": "未授权:令牌格式错误", "code": "AUTH_TOKEN_INVALID_FORMAT", }) c.Abort() return } tokenString := strings.TrimPrefix(authHeader, bearerPrefix) // 验证token claims, err := util.ValidateToken(tokenString, config.AppConfig.AuthJWTSecret) if err != nil { c.JSON(401, gin.H{ "error": "未授权:令牌无效或已过期", "code": "AUTH_TOKEN_INVALID", }) c.Abort() return } // 将用户信息存入上下文,供后续处理使用 c.Set("username", claims.Username) c.Next() } } ================================================ FILE: api/router.go ================================================ package api import ( "github.com/gin-gonic/gin" "pansou/config" "pansou/plugin" "pansou/service" "pansou/util" ) // 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()) // 添加压缩中间件 r.Use(AuthMiddleware()) // 添加认证中间件 // 定义API路由组 api := r.Group("/api") { // 认证接口(不需要认证,由中间件公开路径处理) auth := api.Group("/auth") { auth.POST("/login", LoginHandler) auth.POST("/verify", VerifyHandler) auth.POST("/logout", LogoutHandler) } // 搜索接口 - 支持POST和GET两种方式 api.POST("/search", SearchHandler) api.GET("/search", SearchHandler) // 添加GET方式支持 // 健康检查接口 api.GET("/health", func(c *gin.Context) { // 根据配置决定是否返回插件信息 pluginCount := 0 pluginNames := []string{} pluginsEnabled := config.AppConfig.AsyncPluginEnabled if pluginsEnabled && searchService != nil && searchService.GetPluginManager() != nil { plugins := searchService.GetPluginManager().GetPlugins() pluginCount = len(plugins) for _, p := range plugins { pluginNames = append(pluginNames, p.Name()) } } // 获取频道信息 channels := config.AppConfig.DefaultChannels channelsCount := len(channels) response := gin.H{ "status": "ok", "auth_enabled": config.AppConfig.AuthEnabled, // 添加认证状态 "plugins_enabled": pluginsEnabled, "channels": channels, "channels_count": channelsCount, } // 只有当插件启用时才返回插件相关信息 if pluginsEnabled { response["plugin_count"] = pluginCount response["plugins"] = pluginNames } c.JSON(200, response) }) } // 注册插件的Web路由(如果插件实现了PluginWithWebHandler接口) // 只有当插件功能启用且插件在启用列表中时才注册路由 if config.AppConfig.AsyncPluginEnabled && searchService != nil && searchService.GetPluginManager() != nil { enabledPlugins := searchService.GetPluginManager().GetPlugins() for _, p := range enabledPlugins { if webPlugin, ok := p.(plugin.PluginWithWebHandler); ok { webPlugin.RegisterWebRoutes(r.Group("")) } } } return r } ================================================ FILE: config/config.go ================================================ package config import ( "os" "path/filepath" "runtime" "runtime/debug" "strconv" "strings" "time" ) // Config 应用配置结构 type Config struct { DefaultChannels []string DefaultConcurrency int Port string ProxyURL string UseProxy bool HTTPProxyURL string HTTPSProxyURL string // 缓存相关配置 CacheEnabled bool CachePath string CacheMaxSizeMB int CacheTTLMinutes int // 压缩相关配置 EnableCompression bool MinSizeToCompress int // 最小压缩大小(字节) // GC相关配置 GCPercent int // GC触发阈值百分比 OptimizeMemory bool // 是否启用内存优化 // 插件相关配置 PluginTimeoutSeconds int // 插件超时时间(秒) PluginTimeout time.Duration // 插件超时时间(Duration) // 异步插件相关配置 AsyncPluginEnabled bool // 是否启用异步插件 EnabledPlugins []string // 启用的具体插件列表(空表示启用所有) AsyncResponseTimeout int // 响应超时时间(秒) AsyncResponseTimeoutDur time.Duration // 响应超时时间(Duration) AsyncMaxBackgroundWorkers int // 最大后台工作者数量 AsyncMaxBackgroundTasks int // 最大后台任务数量 AsyncCacheTTLHours int // 异步缓存有效期(小时) AsyncLogEnabled bool // 是否启用异步插件详细日志 // HTTP服务器配置 HTTPReadTimeout time.Duration // 读取超时 HTTPWriteTimeout time.Duration // 写入超时 HTTPIdleTimeout time.Duration // 空闲超时 HTTPMaxConns int // 最大连接数 // 认证相关配置 AuthEnabled bool // 是否启用认证 AuthUsers map[string]string // 用户名:密码映射 AuthTokenExpiry time.Duration // Token有效期 AuthJWTSecret string // JWT签名密钥 } // 全局配置实例 var AppConfig *Config // 初始化配置 func Init() { proxyURL := getProxyURL() pluginTimeoutSeconds := getPluginTimeout() asyncResponseTimeoutSeconds := getAsyncResponseTimeout() AppConfig = &Config{ DefaultChannels: getDefaultChannels(), DefaultConcurrency: getDefaultConcurrency(), Port: getPort(), ProxyURL: proxyURL, UseProxy: proxyURL != "", HTTPProxyURL: getHTTPProxyURL(), HTTPSProxyURL: getHTTPSProxyURL(), // 缓存相关配置 CacheEnabled: getCacheEnabled(), CachePath: getCachePath(), CacheMaxSizeMB: getCacheMaxSize(), CacheTTLMinutes: getCacheTTL(), // 压缩相关配置 EnableCompression: getEnableCompression(), MinSizeToCompress: getMinSizeToCompress(), // GC相关配置 GCPercent: getGCPercent(), OptimizeMemory: getOptimizeMemory(), // 插件相关配置 PluginTimeoutSeconds: pluginTimeoutSeconds, PluginTimeout: time.Duration(pluginTimeoutSeconds) * time.Second, // 异步插件相关配置 AsyncPluginEnabled: getAsyncPluginEnabled(), EnabledPlugins: getEnabledPlugins(), AsyncResponseTimeout: asyncResponseTimeoutSeconds, AsyncResponseTimeoutDur: time.Duration(asyncResponseTimeoutSeconds) * time.Second, AsyncMaxBackgroundWorkers: getAsyncMaxBackgroundWorkers(), AsyncMaxBackgroundTasks: getAsyncMaxBackgroundTasks(), AsyncCacheTTLHours: getAsyncCacheTTLHours(), AsyncLogEnabled: getAsyncLogEnabled(), // HTTP服务器配置 HTTPReadTimeout: getHTTPReadTimeout(), HTTPWriteTimeout: getHTTPWriteTimeout(), HTTPIdleTimeout: getHTTPIdleTimeout(), HTTPMaxConns: getHTTPMaxConns(), // 认证相关配置 AuthEnabled: getAuthEnabled(), AuthUsers: getAuthUsers(), AuthTokenExpiry: getAuthTokenExpiry(), AuthJWTSecret: getAuthJWTSecret(), } // 应用GC配置 applyGCSettings() } // 从环境变量获取默认频道列表,如果未设置则使用默认值 func getDefaultChannels() []string { channelsEnv := os.Getenv("CHANNELS") if channelsEnv == "" { return []string{"tgsearchers4"} } return strings.Split(channelsEnv, ",") } // 从环境变量获取默认并发数,如果未设置则使用基于环境变量的简单计算 func getDefaultConcurrency() int { concurrencyEnv := os.Getenv("CONCURRENCY") if concurrencyEnv != "" { concurrency, err := strconv.Atoi(concurrencyEnv) if err == nil && concurrency > 0 { return concurrency } } // 环境变量未设置或无效,使用基于环境变量的简单计算 // 计算频道数 channelCount := len(getDefaultChannels()) // 估计插件数(从环境变量或默认值,实际在应用启动后会根据真实插件数调整) pluginCountEnv := os.Getenv("PLUGIN_COUNT") pluginCount := 0 if pluginCountEnv != "" { count, err := strconv.Atoi(pluginCountEnv) if err == nil && count > 0 { pluginCount = count } } // 如果没有指定插件数,默认使用7个(当前已知的插件数) if pluginCount == 0 { pluginCount = 7 } // 计算并发数 = 频道数 + 插件数 + 10 concurrency := channelCount + pluginCount + 10 if concurrency < 1 { concurrency = 1 // 确保至少为1 } return concurrency } // 更新默认并发数(根据实际插件数或0调用) // pluginCount: 如果插件被禁用则为0,否则为实际插件数 func UpdateDefaultConcurrency(pluginCount int) { if AppConfig == nil { return } // 只有当未通过环境变量指定并发数时才进行调整 concurrencyEnv := os.Getenv("CONCURRENCY") if concurrencyEnv != "" { return } // 计算频道数 channelCount := len(AppConfig.DefaultChannels) // 计算并发数 = 频道数 + 插件数(插件禁用时为0)+ 10 concurrency := channelCount + pluginCount + 10 if concurrency < 1 { concurrency = 1 // 确保至少为1 } // 更新配置 AppConfig.DefaultConcurrency = concurrency } // 从环境变量获取服务端口,如果未设置则使用默认值 func getPort() string { port := os.Getenv("PORT") if port == "" { return "8888" } return port } func getProxyURL() string { return os.Getenv("PROXY") } func getHTTPProxyURL() string { if proxyURL := os.Getenv("HTTP_PROXY"); proxyURL != "" { return proxyURL } return os.Getenv("http_proxy") } func getHTTPSProxyURL() string { if proxyURL := os.Getenv("HTTPS_PROXY"); proxyURL != "" { return proxyURL } return os.Getenv("https_proxy") } // 从环境变量获取是否启用缓存,如果未设置则默认启用 func getCacheEnabled() bool { enabled := os.Getenv("CACHE_ENABLED") if enabled == "" { return true } return enabled != "false" && enabled != "0" } // 从环境变量获取缓存路径,如果未设置则使用默认路径 func getCachePath() string { path := os.Getenv("CACHE_PATH") if path == "" { // 默认在当前目录下创建cache文件夹 defaultPath, err := filepath.Abs("./cache") if err != nil { return "./cache" } return defaultPath } return path } // 从环境变量获取缓存最大大小(MB),如果未设置则使用默认值 func getCacheMaxSize() int { sizeEnv := os.Getenv("CACHE_MAX_SIZE") if sizeEnv == "" { return 100 // 默认100MB } size, err := strconv.Atoi(sizeEnv) if err != nil || size <= 0 { return 100 } return size } // 从环境变量获取缓存TTL(分钟),如果未设置则使用默认值 func getCacheTTL() int { ttlEnv := os.Getenv("CACHE_TTL") if ttlEnv == "" { return 60 // 默认60分钟 } ttl, err := strconv.Atoi(ttlEnv) if err != nil || ttl <= 0 { return 60 } return ttl } // 从环境变量获取是否启用压缩,如果未设置则默认禁用 func getEnableCompression() bool { enabled := os.Getenv("ENABLE_COMPRESSION") if enabled == "" { return false // 默认禁用,因为通常由Nginx等处理 } return enabled == "true" || enabled == "1" } // 从环境变量获取最小压缩大小,如果未设置则使用默认值 func getMinSizeToCompress() int { sizeEnv := os.Getenv("MIN_SIZE_TO_COMPRESS") if sizeEnv == "" { return 1024 // 默认1KB } size, err := strconv.Atoi(sizeEnv) if err != nil || size <= 0 { return 1024 } return size } // 从环境变量获取GC百分比,如果未设置则使用默认值 func getGCPercent() int { percentEnv := os.Getenv("GC_PERCENT") if percentEnv == "" { return 50 // 默认50% - 优化内存管理,更频繁的GC避免内存暴涨 } percent, err := strconv.Atoi(percentEnv) if err != nil || percent <= 0 { return 50 // 错误时也使用优化后的默认值 } return percent } // 从环境变量获取是否优化内存,如果未设置则默认启用 func getOptimizeMemory() bool { enabled := os.Getenv("OPTIMIZE_MEMORY") if enabled == "" { return true // 默认启用 } return enabled != "false" && enabled != "0" } // 从环境变量获取插件超时时间(秒),如果未设置则使用默认值 func getPluginTimeout() int { timeoutEnv := os.Getenv("PLUGIN_TIMEOUT") if timeoutEnv == "" { return 30 // 默认30秒 } timeout, err := strconv.Atoi(timeoutEnv) if err != nil || timeout <= 0 { return 30 } return timeout } // 从环境变量获取是否启用异步插件,如果未设置则默认启用 func getAsyncPluginEnabled() bool { enabled := os.Getenv("ASYNC_PLUGIN_ENABLED") if enabled == "" { return true // 默认启用 } return enabled != "false" && enabled != "0" } // 从环境变量获取启用的插件列表 // 返回nil表示未设置环境变量(不启用任何插件) // 返回[]string{}表示设置为空(不启用任何插件) // 返回具体列表表示启用指定插件 func getEnabledPlugins() []string { plugins, exists := os.LookupEnv("ENABLED_PLUGINS") if !exists { // 未设置环境变量时返回nil,表示不启用任何插件 return nil } if plugins == "" { // 设置为空字符串,也表示不启用任何插件 return []string{} } // 按逗号分割插件名 result := make([]string, 0) for _, plugin := range strings.Split(plugins, ",") { plugin = strings.TrimSpace(plugin) if plugin != "" { result = append(result, plugin) } } return result } // 从环境变量获取异步响应超时时间(秒),如果未设置则使用默认值 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 != "" { size, err := strconv.Atoi(sizeEnv) if err == nil && size > 0 { return size } } // 自动计算:根据CPU核心数计算 // 每个CPU核心分配5个工作者,最小20个 cpuCount := runtime.NumCPU() workers := cpuCount * 5 // 确保至少有20个工作者 if workers < 20 { workers = 20 } return workers } // 从环境变量获取最大后台任务数量,如果未设置则自动计算 func getAsyncMaxBackgroundTasks() int { sizeEnv := os.Getenv("ASYNC_MAX_BACKGROUND_TASKS") if sizeEnv != "" { size, err := strconv.Atoi(sizeEnv) if err == nil && size > 0 { return size } } // 自动计算:工作者数量的5倍,最小100个 workers := getAsyncMaxBackgroundWorkers() tasks := workers * 5 // 确保至少有100个任务 if tasks < 100 { tasks = 100 } return tasks } // 从环境变量获取异步缓存有效期(小时),如果未设置则使用默认值 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 } // 从环境变量获取HTTP读取超时,如果未设置则自动计算 func getHTTPReadTimeout() time.Duration { timeoutEnv := os.Getenv("HTTP_READ_TIMEOUT") if timeoutEnv != "" { timeout, err := strconv.Atoi(timeoutEnv) if err == nil && timeout > 0 { return time.Duration(timeout) * time.Second } } // 自动计算:默认30秒,异步模式下根据异步响应超时调整 timeout := 30 * time.Second // 如果启用了异步插件,确保读取超时足够长 if getAsyncPluginEnabled() { // 读取超时应该至少是异步响应超时的3倍,确保有足够时间完成异步操作 asyncTimeoutSecs := getAsyncResponseTimeout() asyncTimeoutExtended := time.Duration(asyncTimeoutSecs * 3) * time.Second if asyncTimeoutExtended > timeout { timeout = asyncTimeoutExtended } } return timeout } // 从环境变量获取HTTP写入超时,如果未设置则自动计算 func getHTTPWriteTimeout() time.Duration { timeoutEnv := os.Getenv("HTTP_WRITE_TIMEOUT") if timeoutEnv != "" { timeout, err := strconv.Atoi(timeoutEnv) if err == nil && timeout > 0 { return time.Duration(timeout) * time.Second } } // 自动计算:默认60秒,但根据插件超时和异步处理时间调整 timeout := 60 * time.Second // 如果启用了异步插件,确保写入超时足够长 pluginTimeoutSecs := getPluginTimeout() // 计算1.5倍的插件超时时间(使用整数运算:乘以3再除以2) pluginTimeoutExtended := time.Duration(pluginTimeoutSecs * 3 / 2) * time.Second if pluginTimeoutExtended > timeout { timeout = pluginTimeoutExtended } return timeout } // 从环境变量获取HTTP空闲超时,如果未设置则自动计算 func getHTTPIdleTimeout() time.Duration { timeoutEnv := os.Getenv("HTTP_IDLE_TIMEOUT") if timeoutEnv != "" { timeout, err := strconv.Atoi(timeoutEnv) if err == nil && timeout > 0 { return time.Duration(timeout) * time.Second } } // 自动计算:默认120秒,考虑到保持连接的效益 return 120 * time.Second } // 从环境变量获取HTTP最大连接数,如果未设置则自动计算 func getHTTPMaxConns() int { maxConnsEnv := os.Getenv("HTTP_MAX_CONNS") if maxConnsEnv != "" { maxConns, err := strconv.Atoi(maxConnsEnv) if err == nil && maxConns > 0 { return maxConns } } // 自动计算:根据CPU核心数计算 // 每个CPU核心分配200个连接,最小1000个 cpuCount := runtime.NumCPU() maxConns := cpuCount * 200 // 确保至少有1000个连接 if maxConns < 1000 { maxConns = 1000 } return maxConns } // 从环境变量获取异步插件日志开关,如果未设置则使用默认值 func getAsyncLogEnabled() bool { logEnv := os.Getenv("ASYNC_LOG_ENABLED") if logEnv == "" { return true // 默认启用日志 } enabled, err := strconv.ParseBool(logEnv) if err != nil { return true // 解析失败时默认启用 } return enabled } // 从环境变量获取认证开关,如果未设置则默认关闭 func getAuthEnabled() bool { enabled := os.Getenv("AUTH_ENABLED") return enabled == "true" || enabled == "1" } // 从环境变量获取用户配置,格式:user1:pass1,user2:pass2 func getAuthUsers() map[string]string { usersEnv := os.Getenv("AUTH_USERS") if usersEnv == "" { return nil } users := make(map[string]string) pairs := strings.Split(usersEnv, ",") for _, pair := range pairs { parts := strings.SplitN(pair, ":", 2) if len(parts) == 2 { username := strings.TrimSpace(parts[0]) password := strings.TrimSpace(parts[1]) if username != "" && password != "" { users[username] = password } } } return users } // 从环境变量获取Token有效期(小时),如果未设置则使用默认值 func getAuthTokenExpiry() time.Duration { expiryEnv := os.Getenv("AUTH_TOKEN_EXPIRY") if expiryEnv == "" { return 24 * time.Hour // 默认24小时 } expiry, err := strconv.Atoi(expiryEnv) if err != nil || expiry <= 0 { return 24 * time.Hour } return time.Duration(expiry) * time.Hour } // 从环境变量获取JWT密钥,如果未设置则生成随机密钥 func getAuthJWTSecret() string { secret := os.Getenv("AUTH_JWT_SECRET") if secret == "" { // 生成随机密钥(32字节) import_crypto := "crypto/rand" import_encoding := "encoding/base64" _ = import_crypto _ = import_encoding // 注意:实际使用时应该使用crypto/rand生成随机密钥 // 这里为了简化,使用时间戳作为临时密钥 secret = "pansou-default-secret-" + strconv.FormatInt(time.Now().Unix(), 10) } return secret } // 应用GC设置 func applyGCSettings() { // 设置GC百分比 debug.SetGCPercent(AppConfig.GCPercent) // 如果启用内存优化 if AppConfig.OptimizeMemory { // 释放操作系统内存 debug.FreeOSMemory() } } ================================================ FILE: docker-compose.yml ================================================ version: '3.8' services: pansou: image: ghcr.io/fish2018/pansou:latest container_name: pansou restart: unless-stopped ports: - "8888:8888" environment: - PORT=8888 - CHANNELS=tgsearchers4,Aliyun_4K_Movies,bdbdndn11,yunpanx,bsbdbfjfjff,yp123pan,sbsbsnsqq,yunpanxunlei,tianyifc,BaiduCloudDisk,txtyzy,peccxinpd,gotopan,PanjClub,kkxlzy,baicaoZY,MCPH01,MCPH02,MCPH03,bdwpzhpd,ysxb48,jdjdn1111,yggpan,MCPH086,zaihuayun,Q66Share,ucwpzy,shareAliyun,alyp_1,dianyingshare,Quark_Movies,XiangxiuNBB,ydypzyfx,ucquark,xx123pan,yingshifenxiang123,zyfb123,tyypzhpd,tianyirigeng,cloudtianyi,hdhhd21,Lsp115,oneonefivewpfx,qixingzhenren,taoxgzy,Channel_Shares_115,tyysypzypd,vip115hot,wp123zy,yunpan139,yunpan189,yunpanuc,yydf_hzl,leoziyuan,pikpakpan,Q_dongman,yoyokuakeduanju,TG654TG,WFYSFX02,QukanMovie,yeqingjie_GJG666,movielover8888_film3,Baidu_netdisk,D_wusun,FLMdongtianfudi,KaiPanshare,QQZYDAPP,rjyxfx,PikPak_Share_Channel,btzhi,newproductsourcing,cctv1211,duan_ju,QuarkFree,yunpanNB,kkdj001,xxzlzn,pxyunpanxunlei,jxwpzy,kuakedongman,liangxingzhinan,xiangnikanj,solidsexydoll,guoman4K,zdqxm,kduanju,cilidianying,CBduanju,SharePanFilms,dzsgx,BooksRealm,Oscar_4Kmovies,douerpan,baidu_yppan,Q_jilupian,Netdisk_Movies,yunpanquark,ammmziyuan,ciliziyuanku,cili8888,jzmm_123pan # 必须指定启用的插件,多个插件用逗号分隔 - ENABLED_PLUGINS=labi,zhizhen,shandian,duoduo,muou,wanou,hunhepan,jikepan,panwiki,pansearch,panta,qupansou,hdr4k,pan666,susu,thepiratebay,xuexizhinan,panyq,ouge,huban,cyg,erxiao,miaoso,fox4k,pianku,clmao,wuji,cldi,xiaozhang,libvio,leijing,xb6v,xys,ddys,hdmoli,yuhuage,u3c3,javdb,clxiong,jutoushe,sdso,xiaoji,xdyh,haisou,bixin,djgou,nyaa,xinjuc,aikanzy,qupanshe,xdpan,discourse,yunsou,qqpd,ahhhhfs,nsgame,gying,quark4k,quarksoo,sousou,ash - 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 # 认证配置(可选) # - AUTH_ENABLED=true # - AUTH_USERS=admin:admin123,user:pass456 # - AUTH_TOKEN_EXPIRY=24 # - AUTH_JWT_SECRET=your-secret-key-here # 如果需要代理,取消下面的注释并设置代理地址 # - 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 ================================================ FILE: docs/MCP-SERVICE.md ================================================ # PanSou MCP 服务文档 ## 功能介绍 PanSou MCP 服务是一个基于 [Model Context Protocol (MCP)](https://modelcontextprotocol.io) 的工具服务,它将 PanSou 网盘搜索 API 的功能封装为可在支持 MCP 的客户端(如 Cherry Studio)中直接调用的工具。 通过 PanSou MCP 服务,可以直接在 Claude 等 AI 助手中搜索网盘资源,极大地提升了获取网盘资源的便捷性。 ### 核心功能 1. **搜索网盘资源 (`search_netdisk`)**: - 支持通过 `keyword` 参数搜索网盘资源。 - 可通过 `source_type` 参数指定搜索来源:Telegram 频道、插件或两者结合。 - 可通过 `cloud_types` 参数过滤结果,显示特定类型的网盘链接。 - 支持通过 `force_refresh` 参数请求后端刷新缓存。 - 支持通过 `ext_params` 参数向后端插件传递扩展参数。 - 支持通过 `result_type` 参数控制后端返回的结果格式。 - 支持通过 `concurrency` 参数指定并发搜索数量。 2. **检查服务健康状态 (`check_service_health`)**: - 检查所连接的 PanSou 后端服务是否正常运行。 - 获取后端服务的配置信息,如可用的 Telegram 频道列表和插件列表。 3. **启动后端服务 (`start_backend`)**: - 自动启动本地的 PanSou Go 后端服务(如果尚未运行)。 - 等待服务完全启动并可用后才开始处理其他请求。 - 支持参数:`force_restart`(可选,布尔值,是否强制重启后端服务,默认为false)。 4. **获取静态资源信息 (`pansou://` URI scheme)**: - 提供可用插件列表、可用频道列表和支持的网盘类型列表等静态信息资源。 - 支持资源URI:`pansou://plugins`(插件列表)、`pansou://channels`(频道列表)、`pansou://cloud-types`(网盘类型列表)。 ### 架构与部署方式 PanSou MCP 服务设计为与 PanSou Go 后端服务分离,通过 HTTP API 进行通信。支持以下部署方式: - **Node.js 部署 (TypeScript)**: MCP 服务基于 TypeScript 开发,编译后通过 `node` 命令运行编译后的 JavaScript 文件。它会自动连接到指定的 PanSou 后端服务。 - **Docker 部署**: 使用 Docker 容器运行 PanSou 后端服务,MCP 服务通过 HTTP API 连接到容器化的后端。 --- ## 安装与部署 ### 前提条件 1. **Node.js**: 确保您的系统已安装 Node.js (版本 >= 18.0.0)。您可以通过在终端运行 `node -v` 来检查版本。 2. **Go**: 确保您的系统已安装 Go (版本 >= 1.18)。您可以通过在终端运行 `go version` 来检查版本。 ### 部署步骤 PanSou 后端服务通常运行在 `http://localhost:8888` (默认地址)。支持以下两种后端部署方式: ## 后端服务部署 ### 方式一:源码部署后端服务 - 确保系统已安装 Go 1.23.0 或更高版本。 - 克隆或确保已有 PanSou Go 项目源码。 - 在项目根目录下,打开终端并执行以下命令进行构建: ```bash # Windows (PowerShell/CMD) go build -o pansou.exe . ``` - 构建完成后,运行生成的可执行文件以启动后端服务: ```bash # Windows .\pansou.exe ``` 服务默认将在 `http://localhost:8888` 启动。 ### 方式二:Docker 部署后端服务 Docker 部署方式更加简单,无需手动构建 Go 后端服务,直接使用预构建的 Docker 镜像。 **前提条件**:确保您的系统已安装 Docker 和 Docker Compose。 在 PanSou 项目根目录下,使用 Docker Compose 启动后端服务: ```bash # 启动 Docker 容器 docker-compose up -d # 检查容器状态 docker ps # 验证服务是否正常运行 curl http://localhost:8888/api/health ``` ### 验证后端服务 无论使用哪种方式启动后端服务,您都可以通过访问 `http://localhost:8888/api/health` 来检查服务状态,应该能看到类似以下的 JSON 响应: ```json { "status": "ok", "plugins_enabled": true, "channels_count": 1, "channels": ["tgsearchers3"], "plugin_count": 38, "plugins": ["ddys", "erxiao", "..."] } ``` --- ## MCP 服务配置与使用 ### 1. 构建 MCP 服务 - 确保系统已安装 Node.js (版本 >= 18.0.0)。 - 在 `typescript` 目录下,打开终端并执行以下命令来安装依赖并构建项目: ```bash cd typescript npm install npm run build ``` 构建完成后,编译后的 JavaScript 文件将位于 `typescript/dist` 目录下。 ### 2. MCP 服务运行方式 构建完成后,可以通过以下方式之一运行 MCP 服务: - **在MCP调用时自动启动** (推荐): 直接配置MCP客户端,调用时会自动启动后端服务器。 - **使用 `node` 直接运行** (手动启动): 在 PanSou 项目根目录下(包含 `typescript` 文件夹),运行: ```bash # Windows (CMD/PowerShell) node .\typescript\dist\index.js ``` 服务启动后,将默认尝试连接到 `http://localhost:8888` 的 PanSou 后端服务。 如果想要后端服务运行在不同的地址或端口上,需要通过环境变量指定: ```bash # Windows (CMD) set PANSOU_SERVER_URL=http://your-backend-address:port node .\typescript\dist\index.js # Windows (PowerShell) $env:PANSOU_SERVER_URL='http://your-backend-address:port' node .\typescript\dist\index.js ``` ### 3. MCP 客户端配置 #### 示例配置 Cherry Studio(版本1.5.7) 要在 Cherry Studio 中使用 PanSou MCP 服务,需要将其添加到 Cherry Studio MCP 的配置文件中。 - 找到 设置中的MCP。 - 选择 `添加服务器` 、 `从JSON导入` 。 - 加入服务配置(可以直接复制项目根目录下的 `mcp-config.json` 内容): ```json { "mcpServers": { "pansou": { "command": "node", "args": [ "C:\\full\\path\\to\\your\\project\\typescript\\dist\\index.js" ], "env": { "PANSOU_SERVER_URL": "http://localhost:8888", "REQUEST_TIMEOUT": "30", "MAX_RESULTS": "50", "DEFAULT_CLOUD_TYPES": "baidu,aliyun,quark,tianyi,uc,mobile,115,pikpak,xunlei,123,magnet,ed2k,others", "AUTO_START_BACKEND": "true", "DOCKER_MODE": "false", "BACKEND_SHUTDOWN_DELAY": "5000", "BACKEND_STARTUP_TIMEOUT": "30000", "IDLE_TIMEOUT": "300000", "ENABLE_IDLE_SHUTDOWN": "true", "PROJECT_ROOT_PATH": "C:\\full\\path\\to\\your\\project", "ENABLED_PLUGINS": "labi,zhizhen,shandian,duoduo,muou,wanou" } } } } ``` **注意**: - 请将 `C:\\full\\path\\to\\your\\project` 替换为您项目实际的完整路径 - 如需强制指定部署模式,可修改 `DOCKER_MODE` 和 `AUTO_START_BACKEND` 参数 - **重要**:从当前版本开始,必须通过 `ENABLED_PLUGINS` 显式指定要启用的插件,否则不会启用任何插件 ### 4. 启动 MCP 服务并开始使用 配置完成后,在对话界面启用 PanSou MCP 服务,即可开始尝试搜索。 image --- ## 配置说明与高级选项 ### 智能检测机制 当 `DOCKER_MODE` 设置为 `"false"` 或未设置时,MCP 服务将自动检测部署模式: 1. **Docker 容器检测**:检查是否有运行中的 Docker 容器(名称包含 "pansou") 2. **源码部署检测**:检查是否存在 Go 可执行文件(pansou.exe/main.exe) 3. **服务运行检测**:检查后端服务是否已在运行 ### 配置模式 - **自动模式**(推荐):使用默认配置,让服务自动检测部署方式 - **强制 Docker 模式**:设置 `"DOCKER_MODE": "true"` - **强制源码模式**:设置 `"DOCKER_MODE": "false"` 且 `"AUTO_START_BACKEND": "true"` - **仅连接模式**:设置 `"AUTO_START_BACKEND": "false"`(适用于手动启动的后端) ### 统一配置文件 无论使用哪种后端部署方式,都可以使用统一的 `mcp-config.json` 配置文件。MCP 服务会根据配置自动检测和适配不同的部署模式。 ### 常见问题排查 #### 后端服务连接问题 1. **检查服务状态**: ```bash # 检查健康状态 curl http://localhost:8888/api/health # 或使用 PowerShell Invoke-WebRequest -Uri "http://localhost:8888/api/health" ``` 2. **Docker 部署问题**: ```bash # 检查容器状态 docker ps # 查看容器日志 docker-compose logs # 重启容器 docker-compose restart ``` 3. **源码部署问题**: - 确认 Go 版本 >= 1.25.0 - 检查端口 8888 是否被占用 - 确认防火墙设置 4. **MCP 服务问题**: - 确认 Node.js 版本 >= 18.0.0 - 检查 `typescript/dist` 目录是否存在 - 验证配置文件中的路径是否正确 --- ## 支持的参数 MCP 服务通过工具调用接收参数。以下是主要工具及其支持的参数: ### `search_netdisk` 工具 用于搜索网盘资源。 | 参数名 | 类型 | 必填 | 默认值 | 描述 | | :-------------- | :-------------- | :--- | :------------------- | :----------------------------------------------------------- | | `keyword` | string | 是 | - | 搜索关键词,例如 "速度与激情"、"Python教程"。 | | `channels` | array of string | 否 | 配置默认值 | 要搜索的 Telegram 频道列表,例如 `["tgsearchers3", "another_channel"]`。 | | `plugins` | array of string | 否 | 配置默认值或所有插件 | 要使用的搜索插件列表,例如 `["pansearch", "panta"]`。 | | `cloud_types` | array of string | 否 | 无过滤 | 过滤结果,仅返回指定类型的网盘链接。支持的类型有:`baidu`, `aliyun`, `quark`, `tianyi`, `uc`, `mobile`, `115`, `pikpak`, `xunlei`, `123`, `magnet`, `ed2k`, `others`。 | | `source_type` | string | 否 | `"all"` | 数据来源类型。可选值:`"all"` (全部来源), `"tg"` (仅 Telegram), `"plugin"` (仅插件)。 | | `force_refresh` | boolean | 否 | `false` | 是否强制刷新缓存,以获取最新数据。 | | `result_type` | string | 否 | `"merge"` | 返回结果的类型。可选值:`"all"` (返回所有结果), `"results"` (仅返回详细结果), `"merge"` (仅返回按网盘类型分组的结果)。 | | `concurrency` | number | 否 | 自动计算 | 并发搜索的数量,0或不指定则自动计算。 | | `ext_params` | object | 否 | `{}` | 传递给后端插件的自定义扩展参数,例如 `{"title_en": "Fast and Furious", "is_all": true}`。 | --- ### `check_service_health` 工具 用于检查后端服务健康状态。 - **参数**: 无 --- ### `start_backend` 工具 用于启动本地 PanSou 后端服务。 | 参数名 | 类型 | 必填 | 默认值 | 描述 | | :-------------- | :------ | :--- | :------ | :----------------------------------------- | | `force_restart` | boolean | 否 | `false` | 是否强制重启后端服务(即使它已经在运行)。 | --- ### 环境变量配置 您可以通过设置环境变量来配置 MCP 服务的行为: | 环境变量 | 描述 | 默认值 | | :--------------------- | :--------------------------------------------------------- | :------------------------ | | `PANSOU_SERVER_URL` | PanSou 后端服务的 URL 地址。 | `http://localhost:8888` | | `REQUEST_TIMEOUT` | HTTP 请求超时时间(秒)。 | `30` | | `MAX_RESULTS` | (内部使用,限制处理结果数量) | `100` | | `DEFAULT_CHANNELS` | 默认搜索的 Telegram 频道列表(逗号分隔)。 | `""` (使用后端默认) | | `DEFAULT_PLUGINS` | 默认使用的搜索插件列表(逗号分隔)。 | `""` (使用后端默认或所有) | | `ENABLED_PLUGINS` | 指定后端启用的插件列表(逗号分隔),必须显式指定。 | `""` (需要显式设置) | | `DEFAULT_CLOUD_TYPES` | 默认的网盘类型过滤器(逗号分隔)。 | `""` (无过滤) | | `AUTO_START_BACKEND` | 是否在 MCP 服务启动时自动尝试启动后端服务。 | `true` | | `DOCKER_MODE` | 部署模式控制。设置为 `true` 强制使用 Docker 模式;设置为 `false` 或未设置时启用智能检测。智能检测将自动识别 Docker 容器、源码部署或运行中的服务。 | `false` (智能检测) | | `PROJECT_ROOT_PATH` | PanSou 后端可执行文件所在的目录路径(用于自动启动)。 | 无 | | `IDLE_TIMEOUT` | 空闲超时时间(毫秒),超过此时间无活动则可能关闭后端服务。 | `300000` (5分钟) | | `ENABLE_IDLE_SHUTDOWN` | 是否启用空闲超时自动关闭后端服务。 | `true` | ================================================ FILE: docs/插件开发指南.md ================================================ # PanSou 插件开发指南 ## 概述 PanSou 采用异步插件架构,支持通过插件扩展搜索来源。插件系统基于 Go 接口设计,提供高性能的并发搜索能力和智能缓存机制。 ## 系统架构 ### 核心组件 - **插件管理器 (PluginManager)**: 管理所有插件的注册和调度 - **异步插件 (AsyncSearchPlugin)**: 实现异步搜索接口的插件 - **基础插件 (BaseAsyncPlugin)**: 提供通用功能的基础结构 - **工作池**: 管理并发请求和资源限制 - **缓存系统**: 二级缓存提供高性能数据存储 ### 异步处理机制 1. **双级超时控制**: - 短超时 (4秒): 确保快速响应用户 - 长超时 (30秒): 允许完整数据处理 2. **渐进式结果返回**: - `isFinal=false`: 部分结果,继续后台处理 - `isFinal=true`: 完整结果,停止处理 3. **智能缓存更新**: - 实时更新主缓存 (内存+磁盘) - 结果合并去重 - 用户无感知数据更新 ## 插件接口规范 ### AsyncSearchPlugin 接口 ```go type AsyncSearchPlugin interface { // Name 返回插件名称 (必须唯一) Name() string // Priority 返回插件优先级 (1-4,数字越小优先级越高,影响搜索结果排序) Priority() int // AsyncSearch 异步搜索方法 (核心方法) AsyncSearch(keyword string, searchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error), mainCacheKey string, ext map[string]interface{}) ([]model.SearchResult, error) // SetMainCacheKey 设置主缓存键 (由系统调用) SetMainCacheKey(key string) // SetCurrentKeyword 设置当前搜索关键词 (用于日志显示) SetCurrentKeyword(keyword string) // Search 同步搜索方法 (兼容性方法) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) // SkipServiceFilter 返回是否跳过Service层的关键词过滤 (新增功能) // 对于磁力搜索等需要宽泛结果的插件,应返回true SkipServiceFilter() bool } ``` ### 参数说明 - **keyword**: 搜索关键词 - **searchFunc**: HTTP搜索函数,处理实际的网络请求 - **mainCacheKey**: 主缓存键,用于缓存管理 - **ext**: 扩展参数,支持自定义搜索选项 ### Service层过滤控制 (新功能) PanSou支持插件级别的Service层过滤控制,允许插件自主决定是否在Service层进行关键词过滤: #### 过滤机制说明 1. **插件层过滤**: 在插件内部使用 `FilterResultsByKeyword()` 进行精确过滤 2. **Service层过滤**: 在 `search_service.go` 的 `mergeResultsByType()` 中进行二次过滤 3. **双层过滤问题**: 某些插件(如磁力搜索)需要更宽泛的搜索结果,二次过滤会误删有效结果 #### 适用场景 **应该跳过Service层过滤的插件类型**: - ✅ **磁力搜索插件**: 如 thepiratebay,标题格式特殊(点号分隔),需要宽泛匹配 - ✅ **国外资源插件**: 英文资源标题与中文关键词匹配度低 - ✅ **特殊格式插件**: 标题包含大量符号或编码,标准过滤可能失效 - ✅ **聚合搜索插件**: 需要保留所有相关结果供用户筛选 **应该保持Service层过滤的插件类型**: - ⚠️ **网盘搜索插件**: 标准中文资源,过滤有助于提高精确度 - ⚠️ **API接口插件**: 结构化数据,关键词匹配准确 - ⚠️ **论坛爬取插件**: 标题格式标准,过滤效果良好 ## 插件优先级系统 ### 优先级等级 PanSou 采用4级插件优先级系统,直接影响搜索结果的排序权重: | 等级 | 得分 | 适用场景 | 示例插件 | |------|------|----------|----------| | **等级1** | **1000分** | 高质量、稳定可靠的数据源 | panta, zhizhen, labi | | **等级2** | **500分** | 质量良好、响应稳定的数据源 | huban, shandian, duoduo | | **等级3** | **0分** | 普通质量的数据源 | pansearch, hunhepan, pan666 | | **等级4** | **-200分** | 质量较低或不稳定的数据源 | - | ### 排序算法影响 插件优先级在PanSou的多维度排序算法中占据主导地位: ``` 总得分 = 插件得分(1000/500/0/-200) + 时间得分(最高500) + 关键词得分(最高420) ``` **权重分配**: - 🥇 **插件等级**: ~52% (主导因素) - 🥈 **关键词匹配**: ~22% (重要因素) - 🥉 **时间新鲜度**: ~26% (重要因素) **实际效果**: - 等级1插件的结果通常排在前列 - 即使是较旧的等级1插件结果,也会优于新的等级3插件结果 - 包含优先关键词的等级2插件可能超越等级1插件 ### 如何选择优先级 在开发新插件时,应根据以下标准选择合适的优先级: #### 选择等级1的条件 - ✅ 数据源质量极高,很少出现无效链接 - ✅ 服务稳定性好,响应时间短 - ✅ 数据更新频率高,内容新颖 - ✅ 链接有效性高(>90%) #### 选择等级2的条件 - ✅ 数据源质量良好,偶有无效链接 - ✅ 服务相对稳定,响应时间适中 - ✅ 数据更新较为及时 - ✅ 链接有效性中等(70-90%) #### 选择等级3的条件 - ⚠️ 数据源质量一般,存在一定比例无效链接 - ⚠️ 服务稳定性一般,可能偶有超时 - ⚠️ 数据更新不够及时 - ⚠️ 链接有效性较低(50-70%) #### 选择等级4的条件 - ❌ 数据源质量较差,大量无效链接 - ❌ 服务不稳定,经常超时或失败 - ❌ 数据更新缓慢或过时 - ❌ 链接有效性很低(<50%) ### 启动时显示 系统启动时会按优先级排序显示所有已加载的插件: ``` 已加载插件: - panta (优先级: 1) - zhizhen (优先级: 1) - labi (优先级: 1) - huban (优先级: 2) - duoduo (优先级: 2) - pansearch (优先级: 3) - hunhepan (优先级: 3) ``` ## 开发新插件 ### 1. 基础结构 ```go package myplugin import ( "context" "io" "net/http" "time" "pansou/model" "pansou/plugin" "pansou/util/json" // 使用项目统一的高性能JSON工具 ) type MyPlugin struct { *plugin.BaseAsyncPlugin } func init() { p := &MyPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin", 3), // 优先级3 = 普通质量数据源 } plugin.RegisterGlobalPlugin(p) } // 对于需要跳过Service层过滤的插件(如磁力搜索插件) func init() { p := &MyMagnetPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("mymagnet", 4, true), // 跳过Service层过滤 } plugin.RegisterGlobalPlugin(p) } // Search 执行搜索并返回结果(兼容性方法) func (p *MyPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { result, err := p.SearchWithResult(keyword, ext) if err != nil { return nil, err } return result.Results, nil } // SearchWithResult 执行搜索并返回包含IsFinal标记的结果(推荐方法) func (p *MyPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) } ``` ### 2. 实现搜索逻辑(⭐ 推荐实现模式) ```go func (p *MyPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { // 1. 构建请求URL searchURL := fmt.Sprintf("https://api.example.com/search?q=%s", url.QueryEscape(keyword)) // 2. 处理扩展参数 if titleEn, ok := ext["title_en"].(string); ok && titleEn != "" { searchURL += "&title_en=" + url.QueryEscape(titleEn) } // 3. 创建带超时的上下文 ⭐ 重要:避免请求超时 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // 4. 创建请求对象 ⭐ 重要:使用context控制超时 req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err) } // 5. 设置完整请求头 ⭐ 重要:避免反爬虫检测 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") 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("Referer", "https://api.example.com/") // 6. 发送HTTP请求(带重试机制)⭐ 重要:提高稳定性 resp, err := p.doRequestWithRetry(req, client) if err != nil { return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err) } defer resp.Body.Close() // 7. 检查状态码 if resp.StatusCode != 200 { return nil, fmt.Errorf("[%s] 请求返回状态码: %d", p.Name(), resp.StatusCode) } // 8. 解析响应 var apiResp APIResponse if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { return nil, fmt.Errorf("[%s] JSON解析失败: %w", p.Name(), err) } // 9. 转换为标准格式 results := make([]model.SearchResult, 0, len(apiResp.Data)) for _, item := range apiResp.Data { result := model.SearchResult{ UniqueID: fmt.Sprintf("%s-%s", p.Name(), item.ID), Title: item.Title, Content: item.Description, Datetime: item.CreateTime, Tags: item.Tags, Links: convertLinks(item.Links), // 转换链接格式 } results = append(results, result) } // 10. 关键词过滤 return plugin.FilterResultsByKeyword(results, keyword), nil } // doRequestWithRetry 带重试机制的HTTP请求 ⭐ 重要:提高稳定性 func (p *MyPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) { maxRetries := 3 var lastErr error for i := 0; i < maxRetries; i++ { if i > 0 { // 指数退避重试 backoff := time.Duration(1< 4**:系统智能识别每个链接对应的作品标题 ```go // 示例:一条消息包含5个不同作品的链接 // 系统会分析消息文本,为每个链接提取独立的 work_title ``` **插件实现 work_title 的两种方式**: **方式1: 依赖系统自动提取**(适用于TG频道、论坛等) ```go // 直接返回链接,系统会自动调用 extractWorkTitlesForLinks 进行处理 links := []model.Link{ {Type: "quark", URL: "https://pan.quark.cn/s/abc123"}, {Type: "baidu", URL: "https://pan.baidu.com/s/xyz789"}, } // PanSou会根据消息文本自动为每个链接提取work_title ``` **方式2: 插件手动设置**(适用于API插件、磁力搜索等) ```go // 插件直接设置 work_title(如feikuai、thepiratebay等) links := []model.Link{ { Type: "magnet", URL: magnetURL, WorkTitle: buildWorkTitle(keyword, fileName), // 插件自己构建 Datetime: publishedTime, }, } ``` **插件开发建议**: - **网盘API插件**: 如果API直接返回单一作品,可以不设置 work_title(留空) - **磁力搜索插件**: 建议设置 work_title,特别是文件名不含中文时需要拼接关键词 - **爬虫插件**: 如果能从页面提取每个链接的独立标题,建议设置 work_title #### 支持的网盘类型 PanSou系统支持以下网盘类型的自动识别(完整列表): | 网盘类型 | 类型标识 | 域名特征 | 说明 | |---------|---------|----------|------| | **夸克网盘** | `quark` | `pan.quark.cn` | 主流网盘 | | **UC网盘** | `uc` | `drive.uc.cn` | 主流网盘 | | **百度网盘** | `baidu` | `pan.baidu.com` | 主流网盘 | | **阿里云盘** | `aliyun` | `aliyundrive.com`, `alipan.com` | 主流网盘 | | **迅雷网盘** | `xunlei` | `pan.xunlei.com` | 主流网盘 | | **天翼云盘** | `tianyi` | `cloud.189.cn` | 主流网盘 | | **115网盘** | `115` | `115.com`,`115cdn.com`,`anxia.com` | 主流网盘 | | **123网盘** | `123` | `123pan.com`,`123684.com`,`123685.com`,`123912.com`,`123pan.cn`,`123592.com` | 主流网盘 | | **移动云盘** | `mobile` | `caiyun.139.com` | 其他网盘 | | **PikPak** | `pikpak` | `mypikpak.com` | 其他网盘 | | **磁力链接** | `magnet` | `magnet:?xt=urn:btih:` | 磁力链接 | | **ED2K链接** | `ed2k` | `ed2k://` | 磁力链接 | ```go func convertLinks(apiLinks []APILink) []model.Link { links := make([]model.Link, 0, len(apiLinks)) for _, apiLink := range apiLinks { link := model.Link{ Type: determineCloudType(apiLink.URL), // 自动识别网盘类型 URL: apiLink.URL, Password: apiLink.Password, } links = append(links, link) } return links } func determineCloudType(url string) string { switch { case strings.Contains(url, "pan.quark.cn"): return "quark" case strings.Contains(url, "drive.uc.cn"): return "uc" case strings.Contains(url, "pan.baidu.com"): return "baidu" case strings.Contains(url, "aliyundrive.com") || strings.Contains(url, "alipan.com"): return "aliyun" case strings.Contains(url, "pan.xunlei.com"): return "xunlei" case strings.Contains(url, "cloud.189.cn"): return "tianyi" case strings.Contains(url, "115.com") || strings.Contains(url, "115cdn.com") || strings.Contains(url, "anxia.com"): return "115" case strings.Contains(url, "123684.com") || strings.Contains(url, "123685.com") || strings.Contains(url, "123912.com") || strings.Contains(url, "123pan.com") || strings.Contains(url, "123pan.cn") || strings.Contains(url, "123592.com"): return "123" case strings.Contains(url, "caiyun.139.com"): return "mobile" case strings.Contains(url, "mypikpak.com"): return "pikpak" case strings.Contains(url, "magnet:"): return "magnet" case strings.Contains(url, "ed2k://"): return "ed2k" default: return "others" } } // 使用示例 func convertAPILinks(apiLinks []APILink) []model.Link { links := make([]model.Link, 0, len(apiLinks)) for _, apiLink := range apiLinks { // 自动识别网盘类型 cloudType := determineCloudType(apiLink.URL) // 只添加识别成功的链接 if cloudType != "others" || strings.HasPrefix(apiLink.URL, "http") { link := model.Link{ Type: cloudType, URL: apiLink.URL, Password: apiLink.Password, } links = append(links, link) } } return links } ``` ## 高级特性 ### 1. 插件Web路由注册(自定义HTTP接口) #### 概述 PanSou 支持插件注册自定义的 HTTP 路由,用于实现插件专属的管理页面、配置接口或其他Web功能。 **典型应用场景**: - 插件配置管理界面(如 QQPD 的用户登录和频道管理) - 插件数据查询接口 - 插件状态监控页面 - OAuth回调接口 #### 接口定义 ```go // PluginWithWebHandler 支持Web路由的插件接口 // 插件可以选择实现此接口来注册自定义的HTTP路由 type PluginWithWebHandler interface { AsyncSearchPlugin // 继承搜索插件接口 // RegisterWebRoutes 注册Web路由 // router: gin的路由组,插件可以在此注册自己的路由 RegisterWebRoutes(router *gin.RouterGroup) } ``` #### 实现步骤 **步骤1: 插件结构实现接口** ```go package myplugin import ( "github.com/gin-gonic/gin" "pansou/plugin" "pansou/model" ) type MyPlugin struct { *plugin.BaseAsyncPlugin // ... 其他字段 } // 确保插件实现了 PluginWithWebHandler 接口 var _ plugin.PluginWithWebHandler = (*MyPlugin)(nil) ``` **步骤2: 实现 RegisterWebRoutes 方法** ```go // RegisterWebRoutes 注册Web路由 func (p *MyPlugin) RegisterWebRoutes(router *gin.RouterGroup) { // 创建插件专属的路由组 myGroup := router.Group("/myplugin") // 注册GET路由 myGroup.GET("/status", p.handleGetStatus) // 注册POST路由 myGroup.POST("/config", p.handleUpdateConfig) // 支持动态路径参数 myGroup.GET("/:id", p.handleGetByID) myGroup.POST("/:id/action", p.handleAction) } ``` **步骤3: 实现路由处理函数** ```go // handleGetStatus 获取插件状态 func (p *MyPlugin) handleGetStatus(c *gin.Context) { c.JSON(200, gin.H{ "status": "ok", "plugin": p.Name(), "version": "1.0.0", }) } // handleUpdateConfig 更新插件配置 func (p *MyPlugin) handleUpdateConfig(c *gin.Context) { var config map[string]interface{} if err := c.BindJSON(&config); err != nil { c.JSON(400, gin.H{"error": "Invalid JSON"}) return } // 处理配置更新逻辑 // ... c.JSON(200, gin.H{ "success": true, "message": "配置已更新", }) } // handleGetByID 根据ID获取数据 func (p *MyPlugin) handleGetByID(c *gin.Context) { id := c.Param("id") // 根据ID查询数据 // ... c.JSON(200, gin.H{ "id": id, "data": "...", }) } ``` #### 实际案例: QQPD 插件 QQPD 插件实现了完整的用户管理和频道配置功能: ```go // RegisterWebRoutes 注册Web路由 func (p *QQPDPlugin) RegisterWebRoutes(router *gin.RouterGroup) { qqpd := router.Group("/qqpd") // GET /:param - 显示管理页面(HTML) qqpd.GET("/:param", p.handleManagePage) // POST /:param - 处理管理操作(JSON API) qqpd.POST("/:param", p.handleManagePagePOST) } // handleManagePage 渲染管理页面 func (p *QQPDPlugin) handleManagePage(c *gin.Context) { param := c.Param("param") // 生成用户专属的管理页面 html := strings.ReplaceAll(HTMLTemplate, "HASH_PLACEHOLDER", param) c.Header("Content-Type", "text/html; charset=utf-8") c.String(200, html) } // handleManagePagePOST 处理管理操作 func (p *QQPDPlugin) handleManagePagePOST(c *gin.Context) { param := c.Param("param") var req struct { Action string `json:"action"` Channels []string `json:"channels,omitempty"` Keyword string `json:"keyword,omitempty"` } if err := c.BindJSON(&req); err != nil { respondError(c, "无效的请求格式") return } // 根据不同的 action 执行不同的操作 switch req.Action { case "get_status": p.handleGetStatus(c, param) case "set_channels": p.handleSetChannels(c, param, req.Channels) case "test_search": p.handleTestSearch(c, param, req.Keyword) case "logout": p.handleLogout(c, param) default: respondError(c, "未知的操作") } } ``` #### 实际案例: Gying 插件 ```go // RegisterWebRoutes 注册Web路由 func (p *GyingPlugin) RegisterWebRoutes(router *gin.RouterGroup) { gying := router.Group("/gying") gying.GET("/:param", p.handleManagePage) gying.POST("/:param", p.handleManagePagePOST) } ``` #### 路由访问示例 插件注册的路由可以通过以下方式访问: ```bash # QQPD 插件管理页面 GET http://localhost:8888/qqpd/user123 # QQPD 插件配置接口 POST http://localhost:8888/qqpd/user123 Content-Type: application/json { "action": "set_channels", "channels": ["pd97631607", "kuake12345"] } # 自定义插件接口 GET http://localhost:8888/myplugin/status POST http://localhost:8888/myplugin/config GET http://localhost:8888/myplugin/resource123 ``` #### 系统集成 PanSou 在启动时会自动扫描并注册所有实现了 `PluginWithWebHandler` 接口的插件路由: ```go // api/router.go 中的自动注册逻辑 func SetupRouter(searchService *service.SearchService) *gin.Engine { r := gin.Default() // ... 其他路由配置 ... // 注册插件的Web路由(如果插件实现了PluginWithWebHandler接口) allPlugins := plugin.GetRegisteredPlugins() for _, p := range allPlugins { if webPlugin, ok := p.(plugin.PluginWithWebHandler); ok { webPlugin.RegisterWebRoutes(r.Group("")) } } return r } ``` #### 开发建议 1. **路由命名规范**: 使用插件名作为路由前缀,避免与其他插件冲突 ```go // ✅ 推荐 router.Group("/myplugin") // ❌ 避免 router.Group("/config") // 可能与其他插件冲突 ``` 2. **安全考虑**: - 对敏感操作进行身份验证 - 验证用户输入,防止注入攻击 - 使用哈希或加密保护敏感参数 3. **错误处理**: 统一错误响应格式 ```go func respondError(c *gin.Context, message string) { c.JSON(400, gin.H{ "success": false, "message": message, }) } func respondSuccess(c *gin.Context, message string, data interface{}) { c.JSON(200, gin.H{ "success": true, "message": message, "data": data, }) } ``` 4. **HTML模板**: 可以内嵌HTML模板提供管理界面 ```go const HTMLTemplate = ` 插件管理

{{ .PluginName }} 管理界面

` ``` 5. **可选实现**: Web路由是**可选功能**,只有需要自定义HTTP接口的插件才需要实现 ### 2. Service层过滤控制详解 #### 构造函数选择 ```go // 标准插件构造函数(默认启用Service层过滤) func NewStandardPlugin() *StandardPlugin { return &StandardPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("standard", 3), // 默认skipServiceFilter=false } } // 磁力搜索插件构造函数(跳过Service层过滤) func NewMagnetPlugin() *MagnetPlugin { return &MagnetPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("magnet", 4, true), // skipServiceFilter=true } } ``` #### 实际应用示例 **ThePirateBay插件示例**: ```go // thepiratebay插件的实际实现 func NewThePirateBayPlugin() *ThePirateBayPlugin { return &ThePirateBayPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("thepiratebay", 4, true), // 跳过Service层过滤 optimizedClient: createOptimizedHTTPClient(), } } func (p *ThePirateBayPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { // 支持英文搜索优化 searchKeyword := keyword if ext != nil { if titleEn, exists := ext["title_en"]; exists { if titleEnStr, ok := titleEn.(string); ok && titleEnStr != "" { searchKeyword = titleEnStr } } } // 获取搜索结果 allResults := p.fetchAllResults(client, searchKeyword) // 标题格式优化:将'.'替换为空格,提高关键词匹配准确度 for i := range allResults { allResults[i].Title = strings.ReplaceAll(allResults[i].Title, ".", " ") } // 插件层过滤(使用处理后的搜索关键词) filteredResults := plugin.FilterResultsByKeyword(allResults, searchKeyword) return filteredResults, nil // 注意:Service层会通过SkipServiceFilter()方法跳过二次过滤 } ``` #### 过滤策略对比 | 过滤类型 | 标准插件 | 磁力搜索插件 | |----------|----------|--------------| | **插件层过滤** | ✅ 使用原始关键词 | ✅ 使用searchKeyword(支持title_en) | | **Service层过滤** | ✅ 再次过滤 | ❌ 跳过过滤 | | **结果特点** | 精确匹配 | 宽泛搜索 | | **适用场景** | 中文网盘资源 | 英文磁力资源 | #### 动态过滤检测机制 Service层通过以下机制动态判断是否需要过滤: ```go // service/search_service.go 中的实现 func mergeResultsByType(...) { // 检查插件是否需要跳过Service层过滤 var skipKeywordFilter bool = false if result.UniqueID != "" && strings.Contains(result.UniqueID, "-") { parts := strings.SplitN(result.UniqueID, "-", 2) if len(parts) >= 1 { pluginName := parts[0] // 通过插件注册表动态获取过滤设置 if pluginInstance, exists := plugin.GetPluginByName(pluginName); exists { skipKeywordFilter = pluginInstance.SkipServiceFilter() } } } // 根据插件设置决定是否过滤 if !skipKeywordFilter && keyword != "" && !strings.Contains(strings.ToLower(title), lowerKeyword) { continue // 过滤掉不匹配的结果 } } ``` ### 2. 扩展参数处理 ```go // 支持的扩展参数示例 ext := map[string]interface{}{ "title_en": "English Title", // 英文标题 "is_all": true, // 全量搜索标志 "year": 2023, // 年份限制 "type": "movie", // 内容类型 } // 在插件中处理 func (p *MyPlugin) handleExtParams(ext map[string]interface{}) searchOptions { opts := searchOptions{} if titleEn, ok := ext["title_en"].(string); ok { opts.TitleEn = titleEn } if isAll, ok := ext["is_all"].(bool); ok { opts.IsAll = isAll } return opts } ``` ### 2. 缓存策略 ```go // 设置缓存TTL p.SetCacheTTL(2 * time.Hour) // 手动缓存更新 p.UpdateMainCache(cacheKey, results, ttl, true, keyword) ``` ### 3. 错误处理 ```go func (p *MyPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { // 网络错误处理 resp, err := client.Get(url) if err != nil { return nil, fmt.Errorf("[%s] 网络请求失败: %w", p.Name(), err) } // HTTP状态码检查 if resp.StatusCode != 200 { return nil, fmt.Errorf("[%s] HTTP错误: %d", p.Name(), resp.StatusCode) } // JSON解析错误 - 推荐使用项目统一的JSON工具 body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("[%s] 读取响应失败: %w", p.Name(), err) } var apiResp APIResponse if err := json.Unmarshal(body, &apiResp); err != nil { return nil, fmt.Errorf("[%s] JSON解析失败: %w", p.Name(), err) } // 业务逻辑错误 if apiResp.Code != 0 { return nil, fmt.Errorf("[%s] API错误: %s", p.Name(), apiResp.Message) } return results, nil } ``` ## 性能优化 ### 1. HTTP客户端优化 ```go // 使用连接池 client := &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, }, } ``` ### 2. 内存优化 ```go // 预分配切片容量 results := make([]model.SearchResult, 0, expectedCount) // 及时释放大对象 defer func() { apiResp = APIResponse{} }() ``` ### 3. 并发控制 ```go // 使用插件内置的工作池,避免创建过多goroutine // BaseAsyncPlugin 已经提供了工作池管理 ``` ## 测试和调试 ### 1. 单元测试 ```go func TestMyPlugin_Search(t *testing.T) { plugin := &MyPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("test", 3), } results, err := plugin.Search("测试关键词", nil) assert.NoError(t, err) assert.NotEmpty(t, results) } ``` ### 2. 集成测试 ```bash # 使用API测试插件 curl "http://localhost:8888/api/search?kw=测试&plugins=myplugin" ``` ### 3. 性能测试 ```bash # 使用压力测试脚本 python3 stress_test.py ``` ## 部署和配置 ### 1. 插件注册 确保在 `init()` 函数中注册插件: ```go func init() { p := &MyPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin", 3), } plugin.RegisterGlobalPlugin(p) } ``` ### 2. 环境配置 ```bash # 异步插件配置 export ASYNC_PLUGIN_ENABLED=true export ASYNC_RESPONSE_TIMEOUT=4 export ASYNC_MAX_BACKGROUND_WORKERS=40 export ASYNC_MAX_BACKGROUND_TASKS=200 ``` ### 3. 生产部署注意事项 1. **资源限制**: 根据服务器配置调整工作池大小 2. **监控告警**: 监控插件响应时间和错误率 3. **日志管理**: 合理设置日志级别,避免日志过多 4. **缓存配置**: 根据数据更新频率调整缓存TTL ## 现有插件参考 ### 标准网盘搜索插件 - **jikepan** - 标准网盘插件,启用Service层过滤 - **pan666** - 标准网盘插件,启用Service层过滤 - **hunhepan** - 标准网盘插件,启用Service层过滤 - **pansearch** - 标准网盘插件,启用Service层过滤 - **qupansou** - 标准网盘插件,启用Service层过滤 - **panta** - 高质量网盘插件,启用Service层过滤 ### 特殊搜索插件 - **thepiratebay** - 磁力搜索插件,跳过Service层过滤,支持title_en参数,标题格式化处理 ## 插件开发最佳实践 ⭐ ### 核心原则 1. **命名规范**: 插件名使用小写字母和数字 2. **优先级设置**: 1-2为高优先级,3为标准,4-5为低优先级 3. **关键词过滤**: 使用 `FilterResultsByKeyword` 提高结果相关性 4. **缓存友好**: 合理设置缓存TTL,避免频繁请求 5. **资源清理**: 及时关闭连接和释放资源 6. **过滤策略**: 根据插件类型选择合适的Service层过滤策略 ### 必须实现的优化点 #### 1. Service层过滤策略选择 ⭐ 新功能 ```go // ✅ 磁力搜索插件 - 跳过Service层过滤 func NewMagnetSearchPlugin() *MagnetSearchPlugin { return &MagnetSearchPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("magnet", 4, true), // skipServiceFilter=true } } // ✅ 标准网盘插件 - 启用Service层过滤 func NewPanSearchPlugin() *PanSearchPlugin { return &PanSearchPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("pansearch", 3), // 默认skipServiceFilter=false } } ``` **选择指南**: - **跳过过滤** (`true`): 磁力搜索、英文资源、特殊格式标题、聚合搜索 - **启用过滤** (`false`): 网盘搜索、中文资源、API接口、标准格式标题 **注意事项**: - 跳过Service层过滤的插件**必须**在插件内部进行`FilterResultsByKeyword`过滤 - 插件层过滤使用的关键词应与实际搜索关键词一致(支持`title_en`等参数) - 标题格式化处理应在过滤之前进行(如将`"."` 替换为`" "`) #### 2. SearchResult字段设置规范 ⭐ 重要 ```go // ✅ 正确的SearchResult设置 result := model.SearchResult{ UniqueID: fmt.Sprintf("%s-%s", p.Name(), itemID), // 插件名-资源ID Title: title, // 资源标题 Content: description, // 资源描述 Links: downloadLinks, // 下载链接列表 Tags: tags, // 分类标签 Channel: "", // ⭐ 重要:插件搜索结果必须为空字符串 Datetime: time.Now(), // 发布时间 } // ❌ 错误的Channel设置 result.Channel = p.Name() // 不要设置为插件名! ``` **Channel字段使用规则**: - **插件搜索结果**: `Channel` 必须为空字符串 `""` - **Telegram频道**: `Channel` 才设置为频道名称 - **目的**: 区分搜索来源,便于前端展示和后端统计 **Links字段处理规则** ⭐ 重要: - **必须有链接**: 系统会自动过滤掉 `Links` 为空或长度为0的结果 - **链接质量**: 确保返回的链接都是有效的网盘链接,避免返回无效链接 - **链接验证**: 建议使用 `isValidNetworkDriveURL()` 函数预先验证链接有效性 #### 2. HTTP请求最佳实践 ⭐ 重要 ```go // ✅ 正确的请求实现 func (p *MyPlugin) makeRequest(url string, client *http.Client) (*http.Response, error) { // 使用context控制超时 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // 创建请求 req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } // 设置完整的请求头(避免反爬虫) 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") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") req.Header.Set("Connection", "keep-alive") req.Header.Set("Referer", "https://example.com/") // 使用重试机制 return p.doRequestWithRetry(req, client) } // ❌ 错误的简单实现 func (p *MyPlugin) badRequest(url string, client *http.Client) (*http.Response, error) { return client.Get(url) // 没有超时控制、没有请求头、没有重试 } ``` #### 2. 实现高级搜索接口 ⭐ 推荐 ```go // ✅ 推荐:实现两个方法 func (p *MyPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { result, err := p.SearchWithResult(keyword, ext) if err != nil { return nil, err } return result.Results, nil } func (p *MyPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) } ``` #### 3. 错误处理增强 ⭐ 重要 ```go // ✅ 详细的错误信息 if resp.StatusCode != 200 { return nil, fmt.Errorf("[%s] 请求失败,状态码: %d", p.Name(), resp.StatusCode) } // ✅ 包装外部错误 if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, fmt.Errorf("[%s] JSON解析失败: %w", p.Name(), err) } ``` #### 4. 重试机制模板 ⭐ 复制可用 ```go func (p *MyPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) { maxRetries := 3 var lastErr error for i := 0; i < maxRetries; i++ { if i > 0 { backoff := time.Duration(1< B[API Gateway
Gin Handler] B --> C[参数解析与验证
GET/POST处理] C --> D[参数预处理
规范化处理] D --> E[SearchService
主搜索服务] E --> F{源类型判断
sourceType} F -->|TG| G[并行TG搜索] F -->|Plugin| H[并行插件搜索] F -->|All| I[TG+插件并行搜索] I --> G I --> H %% TG搜索分支 G --> G1[生成TG缓存键
GenerateTGCacheKey] G1 --> G2{强制刷新?
forceRefresh} G2 -->|否| G3[检查二级缓存
EnhancedTwoLevelCache] G2 -->|是| G6[跳过缓存检查] G3 --> G4{缓存命中?} G4 -->|是| G5[缓存反序列化
直接返回结果] G4 -->|否| G6[执行TG频道搜索
多频道并行] G6 --> G7[HTML解析
链接提取] G7 --> G8[结果标准化] G8 --> G9[更新缓存
SetBothLevels] %% 插件搜索分支 - 详细的异步处理 H --> H1[生成插件缓存键
GeneratePluginCacheKey] H1 --> H2{强制刷新?
forceRefresh} H2 -->|否| H3[检查二级缓存
EnhancedTwoLevelCache] H2 -->|是| H6[跳过缓存检查] H3 --> H4{缓存命中?} H4 -->|是| H5[缓存反序列化
直接返回结果] H4 -->|否| H6[插件管理器调度
PluginManager] %% 异步插件详细流程 H6 --> H7[异步插件初始化
SetMainCacheKey] H7 --> H8[工作池任务提交
WorkerPool] %% 双级超时机制的并行处理 H8 --> H9{异步并行处理} %% 快速响应分支 (4秒) H9 --> H10[短超时处理
4秒快速响应] H10 --> H11[HTTP请求
短超时模式] H11 --> H12[部分结果解析
快速过滤] H12 --> H13[部分结果缓存
isFinal=false] H13 --> H14[立即返回
部分结果给用户] %% 持续处理分支 (30秒) H9 --> H15[长超时后台处理
最长30秒持续] H15 --> H16[HTTP请求
长超时模式] H16 --> H17[完整结果解析
深度过滤] H17 --> H18[结果去重合并
最终处理] H18 --> H19[完整结果缓存
isFinal=true] H19 --> H20[主缓存异步更新
DelayedBatchWrite] %% 结果合并处理 G5 --> J[结果合并
mergeSearchResults] G9 --> J H5 --> J H14 --> J J --> K[智能排序算法
时间+关键词+插件等级] K --> L[结果过滤
高质量结果筛选] L --> M[网盘类型分组
mergeResultsByType] M --> N{结果类型
resultType} N -->|merged_by_type| O[返回分组结果] N -->|results| P[返回原始结果] N -->|all| Q[返回完整结果] O --> R[JSON响应] P --> R Q --> R R --> S[用户] %% 后台持续更新(不影响用户响应) H20 --> T[后台缓存完善
下次请求更完整] T -.-> U[持续优化
用户体验] %% 缓存系统 subgraph Cache[二级缓存系统] CA[分片内存缓存
LRU + 原子操作] CB[分片磁盘缓存
GOB序列化] CC[智能缓存写入管理器
DelayedBatchWriteManager] CD[全局缓冲区管理器
BufferByPlugin策略] end G3 -.-> CA H3 -.-> CA CA -.-> CB G9 -.-> CC H13 -.-> CC H20 -.-> CC CC -.-> CD %% 样式定义 classDef cacheNode fill:#e1f5fe,stroke:#01579b,stroke-width:2px classDef pluginNode fill:#f3e5f5,stroke:#4a148c,stroke-width:2px classDef searchNode fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px classDef fastResponse fill:#fff3e0,stroke:#e65100,stroke-width:2px classDef slowProcess fill:#fce4ec,stroke:#880e4f,stroke-width:2px classDef processNode fill:#f5f5f5,stroke:#424242,stroke-width:2px class G3,H3,G5,H5,G9,H13,H20,CA,CB,CC,CD cacheNode class H6,H7,H8 pluginNode class G6,G7,G8 searchNode class H10,H11,H12,H13,H14 fastResponse class H15,H16,H17,H18,H19,H20,T slowProcess class D,J,K,L,M processNode ``` ### 2.2 异步插件工作流程 ```mermaid sequenceDiagram participant U as 用户 participant API as API Handler participant S as SearchService participant SP as searchPlugins函数 participant C as 二级缓存系统 participant PM as PluginManager participant P as AsyncPlugin participant WP as WorkerPool participant BWM as BatchWriteManager participant EXT as 外部API %% 请求处理阶段 U->>API: 🔍 搜索请求 (kw=关键词) API->>API: 参数解析与验证 API->>API: 参数预处理规范化 API->>S: Search(req.Keyword, ...) %% 并行搜索启动 Note over S: 🚀 并行启动TG和插件搜索 S->>SP: searchPlugins(keyword, plugins, ...) %% 缓存检查阶段 SP->>SP: 生成插件缓存键 SP->>SP: 检查forceRefresh标志 alt forceRefresh = false SP->>C: 🔍 Get(cacheKey) alt 缓存命中 C-->>SP: ✅ 返回缓存数据 SP->>SP: 反序列化结果 SP-->>S: 🎯 返回缓存结果 (<10ms) S-->>U: ⚡ 极速响应 else 缓存未命中 Note over SP: 🚨 执行异步插件搜索 SP->>PM: 获取可用插件列表 SP->>PM: 过滤指定插件 end else forceRefresh = true Note over SP: 🔄 跳过缓存,强制搜索 SP->>PM: 获取可用插件列表 SP->>PM: 过滤指定插件 end %% 异步搜索初始化 PM->>P: 🎯 设置关键词和缓存键 P->>P: SetMainCacheKey(cacheKey) P->>P: SetCurrentKeyword(keyword) P->>P: 注入缓存更新函数 %% 🚀 异步插件的精髓:双级超时并行机制 Note over P,EXT: 🔥 异步插件精髓:快速响应 + 持续处理 P->>WP: 🚀 提交异步任务到工作池 %% 快速响应路径 (4秒) par 🚀 快速响应路径 (4秒) Note over WP,EXT: ⚡ 第一阶段:快速响应用户 WP->>EXT: HTTP请求 (短超时 4秒) EXT-->>WP: 部分响应数据 WP->>P: 🔍 解析部分结果 P->>P: 快速过滤和标准化 P->>P: 📝 记录日志: 初始缓存创建 %% 部分结果立即缓存和返回 P->>BWM: 🗄️ 异步缓存更新 (isFinal=false) Note over BWM: 部分结果缓存,不等待写入完成 P-->>SP: 📤 部分结果立即返回 SP-->>S: 🎯 部分结果 (isFinal=false) S->>S: 与TG结果合并 S-->>U: ⚡ 快速响应 (~4秒) and 🔄 持续处理路径 (最长30秒) Note over WP,EXT: 🔄 第二阶段:后台持续完善 WP->>EXT: 继续HTTP请求 (长超时 30秒) EXT-->>WP: 完整响应数据 WP->>P: 🔍 解析完整结果 P->>P: 深度过滤和去重 P->>P: 结果质量评估 P->>P: 📝 记录日志: 缓存更新完成 %% 完整结果的主缓存更新 P->>BWM: 🗄️ 主缓存更新 (isFinal=true) Note over BWM: 完整结果写入,高优先级 BWM->>BWM: 🧠 智能缓存写入策略 BWM->>BWM: 🗂️ 全局缓冲区管理 BWM->>C: 📀 批量写入磁盘缓存 Note over C: 🎯 下次同样请求将获得完整结果 end %% 缓存系统内部处理 C->>C: ⚡ 立即更新内存缓存 C->>C: 📀 延迟批量更新磁盘缓存 C->>C: 🧹 自动清理过期缓存 %% 持续优化标注 Note over U,EXT: 💡 异步插件核心价值 Note over U,EXT: ✅ 用户获得快速响应 (4秒内) Note over U,EXT: ✅ 系统持续完善结果 (30秒内) Note over U,EXT: ✅ 下次访问获得完整数据 (<100ms) Note over U,EXT: 🔄 完美平衡:速度 vs 完整性 ``` ### 2.3 核心组件 #### 2.3.1 HTTP服务层 (`api/`) - **router.go**: 路由配置 - **handler.go**: 请求处理逻辑 - **middleware.go**: 中间件(日志、CORS等) #### 2.3.2 搜索服务层 (`service/`) - **search_service.go**: 核心搜索逻辑,结果合并 #### 2.3.3 插件系统层 (`plugin/`) - **plugin.go**: 插件接口定义 - **baseasyncplugin.go**: 异步插件基类 - **各插件目录**: jikepan、pan666、hunhepan等 #### 2.3.4 工具层 (`util/`) - **cache/**: 二级缓存系统实现 - **pool/**: 工作池实现 - **其他工具**: HTTP客户端、解析工具等 --- ## 3. 异步插件系统 ### 3.1 设计理念 异步插件系统解决传统同步搜索响应慢的问题,采用"尽快响应,持续处理"策略: - **4秒短超时**: 快速返回部分结果(`isFinal=false`) - **30秒长超时**: 后台继续处理,获得完整结果(`isFinal=true`) - **主动缓存更新**: 完整结果自动更新主缓存,下次访问更快 ### 3.2 插件接口实现 基于`plugin/plugin.go`的实际接口: ```go type AsyncSearchPlugin interface { Name() string Priority() int AsyncSearch(keyword string, searchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error), mainCacheKey string, ext map[string]interface{}) ([]model.SearchResult, error) SetMainCacheKey(key string) SetCurrentKeyword(keyword string) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) } ``` ### 3.3 基础插件类 `plugin/baseasyncplugin.go`提供通用功能: ```go type BaseAsyncPlugin struct { name string priority int cacheTTL time.Duration mainCacheKey string currentKeyword string // 用于日志显示 httpClient *http.Client mainCacheUpdater func(string, []model.SearchResult, time.Duration, bool, string) error } ``` ### 3.4 已实现插件列表 当前系统包含以下插件(基于`main.go`的导入): - **hdr4k** - **hunhepan** - **jikepan** - **pan666** - **pansearch** - **panta** - **qupansou** - **susu** - **panyq** - **xuexizhinan** ### 3.5 插件注册机制 ```go // 全局插件注册表(plugin/plugin.go) var globalRegistry = make(map[string]AsyncSearchPlugin) // 插件通过init()函数自动注册 func init() { p := &MyPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin", 3), } plugin.RegisterGlobalPlugin(p) } ``` --- ## 4. 二级缓存系统 ### 4.1 实现架构 基于`util/cache/`目录的实际实现: - **enhanced_two_level_cache.go**: 二级缓存主入口 - **sharded_memory_cache.go**: 分片内存缓存(LRU+原子操作) - **sharded_disk_cache.go**: 分片磁盘缓存 - **serializer.go**: GOB序列化器 - **cache_key.go**: 缓存键生成和管理 ### 4.2 分片缓存设计 #### 4.2.1 内存缓存分片 ```go // 基于CPU核心数的动态分片 type ShardedMemoryCache struct { shards []*MemoryCacheShard shardMask uint32 } // 每个分片独立锁,减少竞争 type MemoryCacheShard struct { data map[string]*CacheItem lock sync.RWMutex } ``` #### 4.2.2 磁盘缓存分片 ```go // 磁盘缓存同样采用分片设计 type ShardedDiskCache struct { shards []*DiskCacheShard shardMask uint32 basePath string } ``` ### 4.3 缓存读写策略 #### 4.3.1 读取流程 1. **内存优先**: 先检查分片内存缓存 2. **磁盘回源**: 内存未命中时读取磁盘缓存 3. **异步加载**: 磁盘命中后异步加载到内存 #### 4.3.2 写入流程 1. **智能写入策略**: 立即更新内存缓存,延迟批量写入磁盘 2. **DelayedBatchWriteManager**: 智能缓存写入管理器,支持immediate和hybrid两种策略 3. **原子操作**: 内存缓存使用原子操作 4. **GOB序列化**: 磁盘存储使用GOB格式 5. **数据安全保障**: 程序终止时自动保存所有待写入数据,防止数据丢失 ### 4.4 缓存键策略 `cache_key.go`实现了智能缓存键生成: ```go // TG搜索和插件搜索使用不同的缓存键前缀 func GenerateTGCacheKey(keyword string, channels []string) string func GeneratePluginCacheKey(keyword string, plugins []string) string ``` **优势**: - 独立更新:TG和插件缓存互不影响 - 提高命中率:精确的键匹配 - 并发安全:分片设计减少锁竞争 ### 4.5 序列化性能 使用GOB序列化(`serializer.go`)的实际优势: - **性能**: 比JSON序列化快约30% - **体积**: 比JSON小约20% - **兼容**: Go原生支持,无外部依赖 --- ## 5. 核心组件实现 ### 5.1 工作池系统 (`util/pool/`) #### 5.1.1 worker_pool.go 实现 - **批量任务处理**: `ExecuteBatchWithTimeout`方法 - **超时控制**: 支持任务级别的超时设置 - **并发限制**: 控制最大工作者数量 #### 5.1.2 object_pool.go 实现 - **对象复用**: 减少内存分配和GC压力 - **线程安全**: 支持并发访问 ### 5.2 HTTP服务配置 #### 5.2.1 服务器优化(基于config/config.go) ```go // 自动计算HTTP连接数,防止资源耗尽 func getHTTPMaxConns() int { cpuCount := runtime.NumCPU() maxConns := cpuCount * 25 // 保守配置 if maxConns < 100 { maxConns = 100 } if maxConns > 500 { maxConns = 500 // 限制最大值 } return maxConns } ``` #### 5.2.2 连接池配置(基于util/http_util.go) ```go // HTTP客户端优化配置 transport := &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, } ``` ### 5.3 结果处理系统 #### 5.3.1 智能排序算法(service/search_service.go) PanSou 采用多维度综合评分排序算法,确保高质量结果优先展示: **评分公式**: ``` 总得分 = 插件得分(1000/500/0/-200) + 时间得分(最高500) + 关键词得分(最高420) ``` **权重分配**: - 🥇 **插件等级**: ~52% (主导因素) - 等级1(1000分) > 等级2(500分) > 等级3(0分) - 🥈 **关键词匹配**: ~22% (重要因素) - "合集"(420分) > "系列"(350分) > "全"(280分) - 🥉 **时间新鲜度**: ~26% (重要因素) - 1天内(500分) > 3天内(400分) > 1周内(300分) **关键优化**: - **缓存性能**: 跳过空结果和重复数据的缓存更新,减少70%无效操作 - **排序稳定性**: 修复map遍历随机性问题,确保merged_by_type保持排序 - **插件管理**: 启动时按优先级排序显示已加载插件,便于监控 #### 5.3.2 结果合并(mergeSearchResults函数) - **去重合并**: 基于UniqueID去重 - **完整性选择**: 选择更完整的结果保留 - **增量更新**: 新结果与缓存结果智能合并 ### 5.4 网盘类型识别 支持自动识别的网盘类型(共12种): - 百度网盘、阿里云盘、夸克网盘、天翼云盘 - UC网盘、移动云盘、115网盘、PikPak - 迅雷网盘、123网盘、磁力链接、电驴链接 --- ## 6. 智能排序算法详解 ### 6.1 算法概述 PanSou 搜索引擎采用多维度综合评分排序算法,确保用户能够优先看到最相关、最新、最高质量的搜索结果。 #### 6.1.1 核心设计理念 1. **质量优先**:高等级插件的结果优先展示 2. **时效性重要**:新发布的资源获得更高权重 3. **相关性保证**:关键词匹配度影响排序 4. **用户体验**:最终排序结果保持稳定性 #### 6.1.2 排序流程 ```mermaid graph TD A[搜索请求] --> B[获取搜索结果 allResults] B --> C[sortResultsByTimeAndKeywords] C --> D[为每个结果计算得分] D --> E[时间得分
最高500分] D --> F[关键词得分
最高420分] D --> G[插件得分
等级1=1000分
等级2=500分
等级3=0分] E --> H[总得分 = 时间得分 + 关键词得分 + 插件得分] F --> H G --> H H --> I[按总得分降序排序] I --> J[mergeResultsByType] J --> K[按原始顺序收集唯一链接
保持排序不被破坏] K --> L[按类型分组
生成merged_by_type] L --> M[返回最终结果] ``` ### 6.2 评分算法详解 #### 6.2.1 核心公式 ``` 总得分 = 时间得分 + 关键词得分 + 插件得分 ``` #### 6.2.2 时间得分 (Time Score) 时间得分反映资源的新鲜度,**最高 500 分**: | 时间范围 | 得分 | 说明 | |---------|------|------| | ≤ 1天 | 500 | 最新资源,最高优先级 | | ≤ 3天 | 400 | 非常新的资源 | | ≤ 1周 | 300 | 较新资源 | | ≤ 1月 | 200 | 相对较新 | | ≤ 3月 | 100 | 中等新鲜度 | | ≤ 1年 | 50 | 较旧资源 | | > 1年 | 20 | 旧资源 | | 无日期 | 0 | 未知时间 | #### 6.2.3 关键词得分 (Keyword Score) 关键词得分基于搜索词在标题中的匹配情况,**最高 420 分**: | 优先关键词 | 得分 | 说明 | |-----------|------|------| | "合集" | 420 | 最高优先级 | | "系列" | 350 | 高优先级 | | "全" | 280 | 中高优先级 | | "完" | 210 | 中等优先级 | | "最新" | 140 | 较低优先级 | | "附" | 70 | 低优先级 | | 无匹配 | 0 | 无加分 | #### 6.2.4 插件得分 (Plugin Score) 插件得分基于数据源的质量等级,体现资源可靠性: | 插件等级 | 得分 | 说明 | |---------|------|------| | 等级1 | 1000 | 顶级数据源 | | 等级2 | 500 | 优质数据源 | | 等级3 | 0 | 普通数据源 | | 等级4 | -200 | 低质量数据源 | ### 6.3 权重分析与实际效果 #### 6.3.1 权重分配 | 维度 | 最高分值 | 权重占比 | 影响说明 | |------|---------|---------|----------| | 插件等级 | 1000 | ~52% | **主导因素**,决定基础排序 | | 关键词匹配 | 420 | ~22% | **重要因素**,优先关键词显著加分 | | 时间新鲜度 | 500 | ~26% | **重要因素**,同等级内排序关键 | #### 6.3.2 实际排序示例 | 场景 | 插件等级 | 时间 | 关键词 | 总分 | 排序 | |------|---------|------|--------|------|------| | 等级1 + 1天内 + "合集" | 1000 | 500 | 420 | **1920** | 🥇 第1 | | 等级1 + 1天内 + "系列" | 1000 | 500 | 350 | **1850** | 🥈 第2 | | 等级1 + 1月内 + "合集" | 1000 | 200 | 420 | **1620** | 🥉 第3 | | 等级2 + 1天内 + "合集" | 500 | 500 | 420 | **1420** | 第4 | | 等级1 + 1天内 + 无关键词 | 1000 | 500 | 0 | **1500** | 第5 | --- ## 7. API接口设计 ### 7.1 核心接口实现(基于api/handler.go) #### 7.1.1 搜索接口 ``` POST /api/search GET /api/search ``` **核心参数**: - `kw`: 搜索关键词(必填) - `channels`: TG频道列表 - `plugins`: 插件列表 - `cloud_types`: 网盘类型过滤 - `ext`: 扩展参数(JSON格式) - `refresh`: 强制刷新缓存 - `res`: 返回格式(merge/all/results) - `src`: 数据源(all/tg/plugin) #### 7.1.2 健康检查接口 ``` GET /api/health ``` 返回系统状态和已注册插件信息。 ### 6.2 中间件系统(api/middleware.go) - **日志中间件**: 记录请求响应,支持URL解码显示 - **CORS中间件**: 跨域请求支持 - **错误处理**: 统一错误响应格式 ### 6.3 扩展参数系统 通过`ext`参数支持插件特定选项: ```json { "title_en": "English Title", "is_all": true, "year": 2023 } ``` --- ## 8. 认证系统设计 ### 8.1 系统概述 PanSou认证系统是一个可选的安全访问控制模块,基于JWT(JSON Web Token)标准实现。该系统设计目标是在不影响现有用户的前提下,为需要私有部署的用户提供灵活的认证功能。 #### 8.1.1 核心特性 - **可选性**: 默认关闭,通过环境变量`AUTH_ENABLED`启用 - **无状态**: 基于JWT,无需session存储 - **标准化**: 采用RFC 7519 JWT标准 - **灵活性**: 支持多用户配置 - **安全性**: Token自动过期,防止长期有效性风险 ### 8.2 认证架构 #### 8.2.1 认证流程 ```mermaid sequenceDiagram participant U as 用户 participant F as 前端 participant M as 认证中间件 participant A as 认证接口 participant S as 搜索服务 Note over U,S: 初始访问阶段 U->>F: 访问应用 F->>F: 检查localStorage中的token alt token不存在或无效 F->>U: 显示登录窗口 U->>F: 输入账号密码 F->>A: POST /api/auth/login A->>A: 验证账号密码 A->>A: 生成JWT Token A-->>F: 返回Token F->>F: 存储Token到localStorage F->>U: 关闭登录窗口 end Note over U,S: API调用阶段 U->>F: 发起搜索请求 F->>F: axios拦截器添加Authorization头 F->>M: GET/POST /api/search + Token M->>M: 验证Token有效性 alt Token有效 M->>S: 转发请求 S-->>M: 返回搜索结果 M-->>F: 返回响应 F-->>U: 显示结果 else Token无效/过期 M-->>F: 返回401 Unauthorized F->>F: 响应拦截器捕获401 F->>U: 显示登录窗口 end ``` #### 8.2.2 组件架构 ``` ┌─────────────────────────────────────────────────────────────┐ │ 前端层 (Vue 3) │ ├─────────────────────────────────────────────────────────────┤ │ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ LoginDialog │ │ HTTP拦截器 │ │ Token管理工具 │ │ │ │ 登录组件 │ │ 自动添加Token │ │ LocalStorage │ │ │ └─────────────┘ └──────────────┘ └──────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ↕ HTTP (Authorization: Bearer) ┌─────────────────────────────────────────────────────────────┐ │ 后端层 (Go + Gin) │ ├─────────────────────────────────────────────────────────────┤ │ ┌──────────────────────────────────────────────────────┐ │ │ │ AuthMiddleware 认证中间件 │ │ │ │ • 检查AUTH_ENABLED配置 │ │ │ │ • 排除公开接口(/api/auth/login, /api/health) │ │ │ │ • 验证JWT Token有效性 │ │ │ │ • 提取用户信息到Context │ │ │ └──────────────────────────────────────────────────────┘ │ │ ↓ │ │ ┌─────────────┐ ┌─────────────┐ ┌──────────────────┐ │ │ │ 认证接口 │ │ JWT工具 │ │ 配置管理 │ │ │ │ /auth/login │ │ util/jwt.go │ │ config/config.go │ │ │ │ /auth/verify│ │ GenerateToken│ │ AuthEnabled │ │ │ │ /auth/logout│ │ ValidateToken│ │ AuthUsers │ │ │ └─────────────┘ └─────────────┘ └──────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` ### 8.3 后端实现细节 #### 8.3.1 配置模块 (config/config.go) ```go type Config struct { // ... 现有配置 ... // 认证相关配置 AuthEnabled bool // 是否启用认证 AuthUsers map[string]string // 用户名:密码哈希映射 AuthTokenExpiry time.Duration // Token有效期 AuthJWTSecret string // JWT签名密钥 } // 从环境变量读取认证配置 func getAuthEnabled() bool { enabled := os.Getenv("AUTH_ENABLED") return enabled == "true" || enabled == "1" } func getAuthUsers() map[string]string { usersEnv := os.Getenv("AUTH_USERS") if usersEnv == "" { return nil } users := make(map[string]string) pairs := strings.Split(usersEnv, ",") for _, pair := range pairs { parts := strings.SplitN(pair, ":", 2) if len(parts) == 2 { username := strings.TrimSpace(parts[0]) password := strings.TrimSpace(parts[1]) // 实际使用时应该对密码进行哈希处理 users[username] = password } } return users } ``` #### 8.3.2 JWT工具模块 (util/jwt.go) ```go package util import ( "errors" "github.com/golang-jwt/jwt/v5" "time" ) // Claims JWT载荷结构 type Claims struct { Username string `json:"username"` jwt.RegisteredClaims } // GenerateToken 生成JWT token func GenerateToken(username string, secret string, expiry time.Duration) (string, error) { expirationTime := time.Now().Add(expiry) claims := &Claims{ Username: username, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(expirationTime), IssuedAt: jwt.NewNumericDate(time.Now()), Issuer: "pansou", }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(secret)) } // ValidateToken 验证JWT token func ValidateToken(tokenString string, secret string) (*Claims, error) { claims := &Claims{} token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { return []byte(secret), nil }) if err != nil { return nil, err } if !token.Valid { return nil, errors.New("invalid token") } return claims, nil } ``` #### 8.3.3 认证中间件 (api/middleware.go) ```go // AuthMiddleware JWT认证中间件 func AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { // 如果未启用认证,直接放行 if !config.AppConfig.AuthEnabled { c.Next() return } // 定义公开接口(不需要认证) publicPaths := []string{ "/api/auth/login", "/api/auth/verify", "/api/auth/logout", "/api/health", // 可选:健康检查是否需要认证 } // 检查当前路径是否是公开接口 path := c.Request.URL.Path for _, p := range publicPaths { if strings.HasPrefix(path, p) { c.Next() return } } // 获取Authorization头 authHeader := c.GetHeader("Authorization") if authHeader == "" { c.JSON(401, gin.H{ "error": "未授权:缺少认证令牌", "code": "AUTH_TOKEN_MISSING", }) c.Abort() return } // 解析Bearer token const bearerPrefix = "Bearer " if !strings.HasPrefix(authHeader, bearerPrefix) { c.JSON(401, gin.H{ "error": "未授权:令牌格式错误", "code": "AUTH_TOKEN_INVALID_FORMAT", }) c.Abort() return } tokenString := strings.TrimPrefix(authHeader, bearerPrefix) // 验证token claims, err := util.ValidateToken(tokenString, config.AppConfig.AuthJWTSecret) if err != nil { c.JSON(401, gin.H{ "error": "未授权:令牌无效或已过期", "code": "AUTH_TOKEN_INVALID", }) c.Abort() return } // 将用户信息存入上下文,供后续处理使用 c.Set("username", claims.Username) c.Next() } } ``` #### 8.3.4 认证接口 (api/auth_handler.go) ```go package api import ( "github.com/gin-gonic/gin" "pansou/config" "pansou/util" "time" ) // LoginRequest 登录请求结构 type LoginRequest struct { Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required"` } // LoginResponse 登录响应结构 type LoginResponse struct { Token string `json:"token"` ExpiresAt int64 `json:"expires_at"` Username string `json:"username"` } // LoginHandler 处理用户登录 func LoginHandler(c *gin.Context) { var req LoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "参数错误"}) return } // 验证用户名和密码 if config.AppConfig.AuthUsers == nil { c.JSON(500, gin.H{"error": "认证系统未正确配置"}) return } storedPassword, exists := config.AppConfig.AuthUsers[req.Username] if !exists || storedPassword != req.Password { c.JSON(401, gin.H{"error": "用户名或密码错误"}) return } // 生成JWT token token, err := util.GenerateToken( req.Username, config.AppConfig.AuthJWTSecret, config.AppConfig.AuthTokenExpiry, ) if err != nil { c.JSON(500, gin.H{"error": "生成令牌失败"}) return } // 返回token和过期时间 expiresAt := time.Now().Add(config.AppConfig.AuthTokenExpiry).Unix() c.JSON(200, LoginResponse{ Token: token, ExpiresAt: expiresAt, Username: req.Username, }) } // VerifyHandler 验证token有效性 func VerifyHandler(c *gin.Context) { // 如果能到达这里,说明中间件已经验证通过 username, exists := c.Get("username") if !exists { c.JSON(401, gin.H{"error": "未授权"}) return } c.JSON(200, gin.H{ "valid": true, "username": username, }) } // LogoutHandler 退出登录(客户端删除token即可) func LogoutHandler(c *gin.Context) { c.JSON(200, gin.H{"message": "退出成功"}) } ``` ### 8.4 前端实现细节 #### 8.4.1 API模块扩展 (src/api/index.ts) ```typescript // 登录接口 export interface LoginParams { username: string; password: string; } export interface LoginResponse { token: string; expires_at: number; username: string; } export const login = async (params: LoginParams): Promise => { const response = await api.post('/auth/login', params); return response.data; }; // 验证token export const verifyToken = async (): Promise => { try { await api.post('/auth/verify'); return true; } catch { return false; } }; // 退出登录 export const logout = async (): Promise => { try { await api.post('/auth/logout'); } finally { localStorage.removeItem('auth_token'); localStorage.removeItem('auth_username'); } }; ``` #### 8.4.2 HTTP拦截器配置 ```typescript // 请求拦截器 - 自动添加token api.interceptors.request.use( (config) => { const token = localStorage.getItem('auth_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => Promise.reject(error) ); // 响应拦截器 - 处理401 api.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { // 清除token localStorage.removeItem('auth_token'); localStorage.removeItem('auth_username'); // 触发显示登录窗口 window.dispatchEvent(new CustomEvent('auth:required')); } return Promise.reject(error); } ); ``` ### 8.5 API文档组件集成 在 `ApiDocs.vue` 组件中,需要确保在线调试功能自动携带token: ```typescript // 生成请求预览时包含Authorization头 const generateSearchRequest = () => { const token = localStorage.getItem('auth_token'); let headers = 'Content-Type: application/json\n'; if (token) { headers += `Authorization: Bearer ${token}\n`; } if (searchMethod.value === 'POST') { return `POST /api/search ${headers} ${JSON.stringify(payload, null, 2)}`; } // ... GET请求类似处理 }; ``` ### 8.6 健康检查接口扩展 `/api/health` 接口需要返回认证状态信息: ```go func HealthHandler(c *gin.Context) { // ... 现有逻辑 ... response := gin.H{ "status": "ok", "auth_enabled": config.AppConfig.AuthEnabled, // 新增 "plugins_enabled": pluginsEnabled, "plugin_count": pluginCount, "plugins": pluginNames, "channels": channels, "channels_count": channelsCount, } c.JSON(200, response) } ``` ### 8.7 环境变量配置 | 变量名 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `AUTH_ENABLED` | boolean | `false` | 是否启用认证功能 | | `AUTH_USERS` | string | - | 用户配置,格式:`user1:pass1,user2:pass2` | | `AUTH_TOKEN_EXPIRY` | int | `24` | Token有效期(小时) | | `AUTH_JWT_SECRET` | string | 随机生成 | JWT签名密钥 | ### 8.8 安全考虑 1. **密码存储**: 生产环境应使用bcrypt等算法对密码进行哈希 2. **HTTPS传输**: 生产环境必须使用HTTPS保护token传输 3. **Token过期**: 合理设置token有效期,避免长期有效 4. **限流保护**: 对登录接口实施限流,防止暴力破解 5. **密钥管理**: JWT_SECRET应随机生成并妥善保管 ### 8.9 性能影响 - **未启用认证**: 零性能影响,中间件直接放行 - **启用认证**: 每个请求增加约0.1-0.5ms的token验证时间 - **并发性能**: JWT无状态特性,对高并发无影响 - **缓存友好**: 认证不影响现有缓存机制 --- ## 9. 插件开发框架 ### 9.1 基础开发模板 ```go package myplugin import ( "net/http" "pansou/model" "pansou/plugin" ) type MyPlugin struct { *plugin.BaseAsyncPlugin } func init() { p := &MyPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin", 3), } plugin.RegisterGlobalPlugin(p) } func (p *MyPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { return p.AsyncSearch(keyword, p.searchImpl, p.GetMainCacheKey(), ext) } func (p *MyPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { // 实现具体搜索逻辑 // 1. 构建请求URL // 2. 发送HTTP请求 // 3. 解析响应数据 // 4. 转换为标准格式 // 5. 关键词过滤 return plugin.FilterResultsByKeyword(results, keyword), nil } ``` ### 8.2 插件注册流程 1. **自动注册**: 通过`init()`函数自动注册到全局注册表 2. **管理器加载**: `PluginManager`统一管理所有插件 3. **导入触发**: 在`main.go`中通过空导入触发注册 ### 8.3 开发最佳实践 - **命名规范**: 插件名使用小写字母 - **优先级设置**: 1-5,数字越小优先级越高 - **错误处理**: 详细错误信息,便于调试 - **资源管理**: 及时释放HTTP连接 --- ## 10. 性能优化实现 ### 10.1 环境配置优化 基于实际性能测试结果的配置方案: #### 10.1.1 macOS优化配置 ```bash export HTTP_MAX_CONNS=200 export ASYNC_MAX_BACKGROUND_WORKERS=15 export ASYNC_MAX_BACKGROUND_TASKS=75 export CONCURRENCY=30 ``` #### 9.1.2 服务器优化配置 ```bash export HTTP_MAX_CONNS=500 export ASYNC_MAX_BACKGROUND_WORKERS=40 export ASYNC_MAX_BACKGROUND_TASKS=200 export CONCURRENCY=50 ``` ### 9.2 日志控制系统 基于`config.go`的日志控制: ```bash export ASYNC_LOG_ENABLED=false # 控制异步插件详细日志 ``` 异步插件缓存更新日志可通过环境变量开关,避免生产环境日志过多。 --- ## 11. 技术选型说明 ### 11.1 Go语言优势 - **并发支持**: 原生goroutine,适合高并发场景 - **性能优秀**: 编译型语言,接近C的性能 - **部署简单**: 单一可执行文件,无外部依赖 - **标准库丰富**: HTTP、JSON、并发原语完备 ### 10.2 GIN框架选择 - **高性能**: 路由和中间件处理效率高 - **简洁易用**: API设计简洁,学习成本低 - **中间件生态**: 丰富的中间件支持 - **社区活跃**: 文档完善,问题解决快 ### 10.3 GOB序列化选择 - **性能优势**: 比JSON快约30% - **体积优势**: 比JSON小约20% - **Go原生**: 无需第三方依赖 - **类型安全**: 保持Go类型信息 ### 10.4 Sonic JSON库选择 - **高性能**: 比标准库encoding/json快3-5倍 - **统一处理**: 全局统一JSON序列化/反序列化 - **兼容性好**: 完全兼容标准JSON格式 - **内存优化**: 更高效的内存使用 ### 10.5 无数据库架构 - **简化部署**: 无需数据库安装配置 - **降低复杂度**: 减少组件依赖 - **提升性能**: 避免数据库IO瓶颈 - **易于扩展**: 无状态设计,支持水平扩展 ================================================ FILE: go.mod ================================================ module pansou go 1.24.1 toolchain go1.24.9 require ( github.com/Advik-B/cloudscraper v0.0.0-20250623142001-d5e0e43555db github.com/PuerkitoBio/goquery v1.8.1 github.com/bytedance/sonic v1.14.0 github.com/gin-gonic/gin v1.9.1 github.com/golang-jwt/jwt/v5 v5.2.0 golang.org/x/net v0.41.0 ) require ( github.com/andybalholm/brotli v1.1.1 // indirect github.com/andybalholm/cascadia v1.3.1 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/robertkrimen/otto v0.5.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/crypto v0.39.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.26.0 // indirect google.golang.org/protobuf v1.30.0 // indirect gopkg.in/sourcemap.v1 v1.0.5 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: go.sum ================================================ github.com/Advik-B/cloudscraper v0.0.0-20250623142001-d5e0e43555db h1:r1hesdkYWgm4Bf7abv6UsIUlrCdFxRdKy+DuVypOpw4= github.com/Advik-B/cloudscraper v0.0.0-20250623142001-d5e0e43555db/go.mod h1:X4xeBaRgq6YCNFrPNd/AXnzGLWq2c46oJfIBh0iLOpI= github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0= github.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= ================================================ FILE: main.go ================================================ package main import ( "context" "fmt" "log" "net" "net/http" "os" "os/signal" "runtime" "sort" "strings" "syscall" "time" "golang.org/x/net/netutil" "pansou/api" "pansou/config" "pansou/plugin" "pansou/service" "pansou/util" "pansou/util/cache" // 以下是插件的空导入,用于触发各插件的init函数,实现自动注册 // 添加新插件时,只需在此处添加对应的导入语句即可 _ "pansou/plugin/hdr4k" _ "pansou/plugin/gying" _ "pansou/plugin/pan666" _ "pansou/plugin/hunhepan" _ "pansou/plugin/jikepan" _ "pansou/plugin/panwiki" _ "pansou/plugin/pansearch" _ "pansou/plugin/panta" _ "pansou/plugin/qupansou" _ "pansou/plugin/susu" _ "pansou/plugin/thepiratebay" _ "pansou/plugin/wanou" _ "pansou/plugin/xuexizhinan" _ "pansou/plugin/panyq" _ "pansou/plugin/zhizhen" _ "pansou/plugin/labi" _ "pansou/plugin/muou" _ "pansou/plugin/ouge" _ "pansou/plugin/shandian" _ "pansou/plugin/duoduo" _ "pansou/plugin/huban" _ "pansou/plugin/cyg" _ "pansou/plugin/erxiao" _ "pansou/plugin/miaoso" _ "pansou/plugin/fox4k" _ "pansou/plugin/pianku" _ "pansou/plugin/clmao" _ "pansou/plugin/wuji" _ "pansou/plugin/cldi" _ "pansou/plugin/xiaozhang" _ "pansou/plugin/libvio" _ "pansou/plugin/leijing" _ "pansou/plugin/xb6v" _ "pansou/plugin/xys" _ "pansou/plugin/ddys" _ "pansou/plugin/hdmoli" _ "pansou/plugin/yuhuage" _ "pansou/plugin/u3c3" _ "pansou/plugin/javdb" _ "pansou/plugin/clxiong" _ "pansou/plugin/jutoushe" _ "pansou/plugin/sdso" _ "pansou/plugin/xiaoji" _ "pansou/plugin/xdyh" _ "pansou/plugin/haisou" _ "pansou/plugin/bixin" _ "pansou/plugin/nyaa" _ "pansou/plugin/djgou" _ "pansou/plugin/xinjuc" _ "pansou/plugin/aikanzy" _ "pansou/plugin/qupanshe" _ "pansou/plugin/xdpan" _ "pansou/plugin/discourse" _ "pansou/plugin/yunsou" _ "pansou/plugin/ahhhhfs" _ "pansou/plugin/nsgame" _ "pansou/plugin/quark4k" _ "pansou/plugin/quarksoo" _ "pansou/plugin/sousou" _ "pansou/plugin/ash" _ "pansou/plugin/qqpd" _ "pansou/plugin/weibo" _ "pansou/plugin/feikuai" _ "pansou/plugin/kkmao" _ "pansou/plugin/alupan" _ "pansou/plugin/ypfxw" _ "pansou/plugin/mikuclub" _ "pansou/plugin/daishudj" _ "pansou/plugin/dyyj" _ "pansou/plugin/meitizy" _ "pansou/plugin/jsnoteclub" _ "pansou/plugin/mizixing" _ "pansou/plugin/lou1" _ "pansou/plugin/yiove" _ "pansou/plugin/zxzj" _ "pansou/plugin/qingying" _ "pansou/plugin/kkv" ) // 全局缓存写入管理器 var globalCacheWriteManager *cache.DelayedBatchWriteManager func main() { // 初始化应用 initApp() // 启动服务器 startServer() } // initApp 初始化应用程序 func initApp() { // 初始化配置 config.Init() // 初始化HTTP客户端 util.InitHTTPClient() // 初始化缓存写入管理器 var err error globalCacheWriteManager, err = cache.NewDelayedBatchWriteManager() if err != nil { log.Fatalf("缓存写入管理器创建失败: %v", err) } if err := globalCacheWriteManager.Initialize(); err != nil { log.Fatalf("缓存写入管理器初始化失败: %v", err) } // 将缓存写入管理器注入到service包 service.SetGlobalCacheWriteManager(globalCacheWriteManager) // 延迟设置主缓存更新函数,确保service初始化完成 go func() { // 等待一小段时间确保service包完全初始化 time.Sleep(100 * time.Millisecond) if mainCache := service.GetEnhancedTwoLevelCache(); mainCache != nil { globalCacheWriteManager.SetMainCacheUpdater(func(key string, data []byte, ttl time.Duration) error { return mainCache.SetBothLevels(key, data, ttl) }) } }() // 确保异步插件系统初始化 plugin.InitAsyncPluginSystem() } // startServer 启动Web服务器 func startServer() { // 初始化插件管理器 pluginManager := plugin.NewPluginManager() // 注册全局插件(根据配置过滤) if config.AppConfig.AsyncPluginEnabled { pluginManager.RegisterGlobalPluginsWithFilter(config.AppConfig.EnabledPlugins) } // 更新默认并发数(如果插件被禁用则使用0) pluginCount := 0 if config.AppConfig.AsyncPluginEnabled { pluginCount = len(pluginManager.GetPlugins()) } config.UpdateDefaultConcurrency(pluginCount) // 初始化搜索服务 searchService := service.NewSearchService(pluginManager) // 设置路由 router := api.SetupRouter(searchService) // 获取端口配置 port := config.AppConfig.Port // 输出服务信息 printServiceInfo(port, pluginManager) // 创建HTTP服务器 srv := &http.Server{ Addr: ":" + port, Handler: router, ReadTimeout: config.AppConfig.HTTPReadTimeout, WriteTimeout: config.AppConfig.HTTPWriteTimeout, IdleTimeout: config.AppConfig.HTTPIdleTimeout, } // 创建通道来接收操作系统信号 quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) // 在单独的goroutine中启动服务器 go func() { // 如果设置了最大连接数,使用限制监听器 if config.AppConfig.HTTPMaxConns > 0 { // 创建监听器 listener, err := net.Listen("tcp", srv.Addr) if err != nil { log.Fatalf("创建监听器失败: %v", err) } // 创建限制连接数的监听器 limitListener := netutil.LimitListener(listener, config.AppConfig.HTTPMaxConns) // 使用限制监听器启动服务器 if err := srv.Serve(limitListener); err != nil && err != http.ErrServerClosed { log.Fatalf("启动服务器失败: %v", err) } } else { // 使用默认方式启动服务器(不限制连接数) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("启动服务器失败: %v", err) } } }() // 等待中断信号 <-quit fmt.Println("正在关闭服务器...") // 优先保存缓存数据到磁盘(数据安全第一) // 增加关闭超时时间,确保数据有足够时间保存 shutdownTimeout := 10 * time.Second if globalCacheWriteManager != nil { if err := globalCacheWriteManager.Shutdown(shutdownTimeout); err != nil { log.Printf("缓存数据保存失败: %v", err) } } // 额外确保内存缓存也被保存(双重保障) if mainCache := service.GetEnhancedTwoLevelCache(); mainCache != nil { if err := mainCache.FlushMemoryToDisk(); err != nil { log.Printf("内存缓存同步失败: %v", err) } } // 设置关闭超时时间 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() // 优雅关闭服务器 if err := srv.Shutdown(ctx); err != nil { log.Fatalf("服务器关闭异常: %v", err) } fmt.Println("服务器已安全关闭") } // printServiceInfo 打印服务信息 func printServiceInfo(port string, pluginManager *plugin.PluginManager) { // 启动服务器 fmt.Printf("服务器启动在 http://localhost:%s\n", port) // 输出代理信息 hasProxy := false if config.AppConfig.ProxyURL != "" { proxyType := "代理" if strings.HasPrefix(config.AppConfig.ProxyURL, "socks5://") { proxyType = "SOCKS5代理" } else if strings.HasPrefix(config.AppConfig.ProxyURL, "http://") { proxyType = "HTTP代理" } else if strings.HasPrefix(config.AppConfig.ProxyURL, "https://") { proxyType = "HTTPS代理" } fmt.Printf("使用%s (PROXY): %s\n", proxyType, config.AppConfig.ProxyURL) hasProxy = true } if config.AppConfig.HTTPProxyURL != "" { fmt.Printf("使用HTTP代理 (HTTP_PROXY/http_proxy): %s\n", config.AppConfig.HTTPProxyURL) hasProxy = true } if config.AppConfig.HTTPSProxyURL != "" { fmt.Printf("使用HTTPS代理 (HTTPS_PROXY/https_proxy): %s\n", config.AppConfig.HTTPSProxyURL) hasProxy = true } if !hasProxy { fmt.Println("未使用代理") } // 输出并发信息 if os.Getenv("CONCURRENCY") != "" { fmt.Printf("默认并发数: %d (由环境变量CONCURRENCY指定)\n", config.AppConfig.DefaultConcurrency) } else { channelCount := len(config.AppConfig.DefaultChannels) pluginCount := 0 // 只有插件启用时才计算插件数 if config.AppConfig.AsyncPluginEnabled && pluginManager != nil { pluginCount = len(pluginManager.GetPlugins()) } fmt.Printf("默认并发数: %d (= 频道数%d + 插件数%d + 10)\n", config.AppConfig.DefaultConcurrency, channelCount, pluginCount) } // 输出缓存信息 if config.AppConfig.CacheEnabled { fmt.Printf("缓存已启用: 路径=%s, 最大大小=%dMB, TTL=%d分钟\n", config.AppConfig.CachePath, config.AppConfig.CacheMaxSizeMB, config.AppConfig.CacheTTLMinutes) } else { fmt.Println("缓存已禁用") } // 输出压缩信息 if config.AppConfig.EnableCompression { fmt.Printf("响应压缩已启用: 最小压缩大小=%d字节\n", config.AppConfig.MinSizeToCompress) } // 输出GC配置信息 fmt.Printf("GC配置: 触发阈值=%d%%, 内存优化=%v\n", config.AppConfig.GCPercent, config.AppConfig.OptimizeMemory) // 输出HTTP服务器配置信息 readTimeoutMsg := "" if os.Getenv("HTTP_READ_TIMEOUT") != "" { readTimeoutMsg = "(由环境变量指定)" } else { readTimeoutMsg = "(自动计算)" } writeTimeoutMsg := "" if os.Getenv("HTTP_WRITE_TIMEOUT") != "" { writeTimeoutMsg = "(由环境变量指定)" } else { writeTimeoutMsg = "(自动计算)" } maxConnsMsg := "" if os.Getenv("HTTP_MAX_CONNS") != "" { maxConnsMsg = "(由环境变量指定)" } else { cpuCount := runtime.NumCPU() maxConnsMsg = fmt.Sprintf("(自动计算: CPU核心数%d × 200)", cpuCount) } fmt.Printf("HTTP服务器配置: 读取超时=%v %s, 写入超时=%v %s, 空闲超时=%v, 最大连接数=%d %s\n", config.AppConfig.HTTPReadTimeout, readTimeoutMsg, config.AppConfig.HTTPWriteTimeout, writeTimeoutMsg, config.AppConfig.HTTPIdleTimeout, config.AppConfig.HTTPMaxConns, maxConnsMsg) // 输出异步插件配置信息 if config.AppConfig.AsyncPluginEnabled { // 检查工作者数量是否由环境变量指定 workersMsg := "" if os.Getenv("ASYNC_MAX_BACKGROUND_WORKERS") != "" { workersMsg = "(由环境变量指定)" } else { cpuCount := runtime.NumCPU() workersMsg = fmt.Sprintf("(自动计算: CPU核心数%d × 5)", cpuCount) } // 检查任务数量是否由环境变量指定 tasksMsg := "" if os.Getenv("ASYNC_MAX_BACKGROUND_TASKS") != "" { tasksMsg = "(由环境变量指定)" } else { tasksMsg = "(自动计算: 工作者数量 × 5)" } fmt.Printf("异步插件已启用: 响应超时=%d秒, 最大工作者=%d %s, 最大任务=%d %s, 缓存TTL=%d小时\n", config.AppConfig.AsyncResponseTimeout, config.AppConfig.AsyncMaxBackgroundWorkers, workersMsg, config.AppConfig.AsyncMaxBackgroundTasks, tasksMsg, config.AppConfig.AsyncCacheTTLHours) } else { fmt.Println("异步插件已禁用") } // 只有当插件功能启用时才输出插件信息 if config.AppConfig.AsyncPluginEnabled { plugins := pluginManager.GetPlugins() if len(plugins) > 0 { // 根据新逻辑,只有指定了具体插件才会加载插件 fmt.Printf("已启用指定插件 (%d个):\n", len(plugins)) // 按优先级排序(优先级数字越小越靠前) sort.Slice(plugins, func(i, j int) bool { // 优先级相同时按名称排序 if plugins[i].Priority() == plugins[j].Priority() { return plugins[i].Name() < plugins[j].Name() } return plugins[i].Priority() < plugins[j].Priority() }) for _, p := range plugins { fmt.Printf(" - %s (优先级: %d)\n", p.Name(), p.Priority()) } } else { // 区分不同的情况 if config.AppConfig.EnabledPlugins == nil { fmt.Println("未设置插件列表 (ENABLED_PLUGINS),未加载任何插件") } else if len(config.AppConfig.EnabledPlugins) > 0 { fmt.Printf("未找到指定的插件: %s\n", strings.Join(config.AppConfig.EnabledPlugins, ", ")) } else { fmt.Println("插件列表为空 (ENABLED_PLUGINS=\"\"),未加载任何插件") } } } } ================================================ FILE: mcp-config.json ================================================ { "mcpServers": { "pansou": { "command": "node", "args": [ "C:\\full\\path\\to\\your\\project\\typescript\\dist\\index.js" ], "env": { "PANSOU_SERVER_URL": "http://localhost:8888", "REQUEST_TIMEOUT": "60", "MAX_RESULTS": "50", "DEFAULT_CLOUD_TYPES": "baidu,aliyun,quark,tianyi,uc,mobile,115,pikpak,xunlei,123,magnet,ed2k,others", "AUTO_START_BACKEND": "true", "DOCKER_MODE": "true", "BACKEND_SHUTDOWN_DELAY": "5000", "BACKEND_STARTUP_TIMEOUT": "30000", "IDLE_TIMEOUT": "300000", "ENABLE_IDLE_SHUTDOWN": "true", "PROJECT_ROOT_PATH": "C:\\full\\path\\to\\your\\project", "ENABLED_PLUGINS": "labi,zhizhen,shandian,duoduo,muou,wanou" } } }, "_comments": { "description": "PanSou MCP服务统一配置文件", "version": "2.0", "智能模式说明": { "自动检测": "如果DOCKER_MODE未设置或为false,服务将自动检测部署模式", "检测优先级": [ "1. 检查是否有运行中的Docker容器(名称包含pansou)", "2. 检查是否存在Go可执行文件(pansou.exe/main.exe)", "3. 检查后端服务是否已在运行" ], "环境变量覆盖": "可通过环境变量强制指定模式,如 DOCKER_MODE=true" }, "配置说明": { "PANSOU_SERVER_URL": "后端服务地址,默认http://localhost:8888", "REQUEST_TIMEOUT": "请求超时时间(秒),默认30", "MAX_RESULTS": "最大搜索结果数,默认50", "DEFAULT_CLOUD_TYPES": "默认搜索的网盘类型,逗号分隔", "AUTO_START_BACKEND": "是否自动启动后端服务(源码模式),默认true", "DOCKER_MODE": "是否强制使用Docker模式,默认false(自动检测)", "BACKEND_SHUTDOWN_DELAY": "后端服务关闭延迟(毫秒),默认5000", "BACKEND_STARTUP_TIMEOUT": "后端服务启动超时(毫秒),默认30000", "IDLE_TIMEOUT": "空闲超时时间(毫秒),默认300000(5分钟)", "ENABLE_IDLE_SHUTDOWN": "是否启用空闲自动关闭,默认true", "PROJECT_ROOT_PATH": "项目根目录路径,用于查找Go可执行文件", "ENABLED_PLUGINS": "指定启用的插件列表,多个插件用逗号分隔,必须显式指定" }, "使用示例": { "自动模式": "默认配置,自动检测部署方式", "强制Docker模式": "设置 DOCKER_MODE=true", "强制源码模式": "设置 DOCKER_MODE=false 且 AUTO_START_BACKEND=true", "仅连接模式": "设置 AUTO_START_BACKEND=false(适用于手动启动的后端)" } } } ================================================ FILE: model/plugin_result.go ================================================ package model import ( "time" ) // PluginSearchResult 插件搜索结果 type PluginSearchResult struct { Results []SearchResult `json:"results"` // 搜索结果 IsFinal bool `json:"is_final"` // 是否为最终完整结果 Timestamp time.Time `json:"timestamp"` // 结果时间戳 Source string `json:"source"` // 插件来源 Message string `json:"message"` // 状态描述(可选) } // IsEmpty 检查结果是否为空 func (p *PluginSearchResult) IsEmpty() bool { return len(p.Results) == 0 } // Count 返回结果数量 func (p *PluginSearchResult) Count() int { return len(p.Results) } // GetResults 获取搜索结果列表 func (p *PluginSearchResult) GetResults() []SearchResult { if p.Results == nil { return []SearchResult{} } return p.Results } ================================================ FILE: model/request.go ================================================ package model // FilterConfig 过滤配置 type FilterConfig struct { Include []string `json:"include,omitempty"` // 包含关键词列表(OR关系) Exclude []string `json:"exclude,omitempty"` // 排除关键词列表(AND关系) } // SearchRequest 搜索请求参数 type SearchRequest struct { Keyword string `json:"kw" binding:"required"` // 搜索关键词 Channels []string `json:"channels"` // 搜索的频道列表 Concurrency int `json:"conc"` // 并发搜索数量 ForceRefresh bool `json:"refresh"` // 强制刷新,不使用缓存 ResultType string `json:"res"` // 结果类型:all(返回所有结果)、results(仅返回results)、merge(仅返回merged_by_type) SourceType string `json:"src"` // 数据来源类型:all(默认,全部来源)、tg(仅Telegram)、plugin(仅插件) Plugins []string `json:"plugins"` // 指定搜索的插件列表,不指定则搜索全部插件 Ext map[string]interface{} `json:"ext"` // 扩展参数,用于传递给插件的自定义参数 CloudTypes []string `json:"cloud_types"` // 指定返回的网盘类型列表,不指定则返回所有类型 Filter *FilterConfig `json:"filter,omitempty"` // 过滤配置,用于过滤返回结果 } ================================================ FILE: model/response.go ================================================ package model import "time" // Link 网盘链接 type Link struct { Type string `json:"type" sonic:"type"` URL string `json:"url" sonic:"url"` Password string `json:"password" sonic:"password"` Datetime time.Time `json:"datetime,omitempty" sonic:"datetime,omitempty"` // 链接更新时间(可选) WorkTitle string `json:"work_title,omitempty" sonic:"work_title,omitempty"` // 作品标题(用于区分同一消息中多个作品的链接) } // SearchResult 搜索结果 type SearchResult struct { MessageID string `json:"message_id" sonic:"message_id"` UniqueID string `json:"unique_id" sonic:"unique_id"` // 全局唯一ID Channel string `json:"channel" sonic:"channel"` Datetime time.Time `json:"datetime" sonic:"datetime"` Title string `json:"title" sonic:"title"` Content string `json:"content" sonic:"content"` Links []Link `json:"links" sonic:"links"` Tags []string `json:"tags,omitempty" sonic:"tags,omitempty"` Images []string `json:"images,omitempty" sonic:"images,omitempty"` // TG消息中的图片链接 } // MergedLink 合并后的网盘链接 type MergedLink struct { URL string `json:"url" sonic:"url"` Password string `json:"password" sonic:"password"` Note string `json:"note" sonic:"note"` Datetime time.Time `json:"datetime" sonic:"datetime"` Source string `json:"source,omitempty" sonic:"source,omitempty"` // 数据来源:tg:频道名 或 plugin:插件名 Images []string `json:"images,omitempty" sonic:"images,omitempty"` // TG消息中的图片链接 } // MergedLinks 按网盘类型分组的合并链接 type MergedLinks map[string][]MergedLink // SearchResponse 搜索响应 type SearchResponse struct { Total int `json:"total" sonic:"total"` Results []SearchResult `json:"results,omitempty" sonic:"results,omitempty"` MergedByType MergedLinks `json:"merged_by_type,omitempty" sonic:"merged_by_type,omitempty"` } // Response API通用响应 type Response struct { Code int `json:"code" sonic:"code"` Message string `json:"message" sonic:"message"` Data interface{} `json:"data,omitempty" sonic:"data,omitempty"` } // NewSuccessResponse 创建成功响应 func NewSuccessResponse(data interface{}) Response { return Response{ Code: 0, Message: "success", Data: data, } } // NewErrorResponse 创建错误响应 func NewErrorResponse(code int, message string) Response { return Response{ Code: code, Message: message, } } ================================================ FILE: package.json ================================================ { "dependencies": { "@modelcontextprotocol/sdk": "^1.17.4" } } ================================================ FILE: plugin/ahhhhfs/ahhhhfs.go ================================================ package ahhhhfs import ( "context" "fmt" "net/http" "net/url" "pansou/model" "pansou/plugin" "regexp" "strings" "sync" "sync/atomic" "time" "github.com/PuerkitoBio/goquery" ) // 预编译的正则表达式 var ( // 从详情页URL中提取文章ID的正则表达式 articleIDRegex = regexp.MustCompile(`/(\d+)/?$`) // 常见网盘链接的正则表达式 quarkLinkRegex = regexp.MustCompile(`https?://pan\.quark\.cn/s/[0-9a-zA-Z]+`) baiduLinkRegex = regexp.MustCompile(`https?://pan\.baidu\.com/s/[0-9a-zA-Z_\-]+`) aliyunLinkRegex = regexp.MustCompile(`https?://(www\.)?(aliyundrive\.com|alipan\.com)/s/[0-9a-zA-Z]+`) ucLinkRegex = regexp.MustCompile(`https?://drive\.uc\.cn/s/[0-9a-zA-Z]+`) xunleiLinkRegex = regexp.MustCompile(`https?://pan\.xunlei\.com/s/[0-9a-zA-Z_\-]+`) tianyiLinkRegex = regexp.MustCompile(`https?://cloud\.189\.cn/(t|web)/[0-9a-zA-Z]+`) link115Regex = regexp.MustCompile(`https?://115\.com/s/[0-9a-zA-Z]+`) link123Regex = regexp.MustCompile(`https?://123pan\.com/s/[0-9a-zA-Z]+`) pikpakLinkRegex = regexp.MustCompile(`https?://mypikpak\.com/s/[0-9a-zA-Z]+`) // 提取码匹配模式 pwdPatterns = []*regexp.Regexp{ regexp.MustCompile(`提取码[::]\s*([0-9a-zA-Z]+)`), regexp.MustCompile(`密码[::]\s*([0-9a-zA-Z]+)`), regexp.MustCompile(`pwd[=::]\s*([0-9a-zA-Z]+)`), regexp.MustCompile(`code[=::]\s*([0-9a-zA-Z]+)`), } // 缓存相关 detailCache = sync.Map{} // 缓存详情页解析结果 lastCleanupTime = time.Now() cacheTTL = 1 * time.Hour ) const ( // 插件名称 pluginName = "ahhhhfs" // 优先级 defaultPriority = 2 // 超时时间 DefaultTimeout = 10 * time.Second DetailTimeout = 8 * time.Second // 并发数限制 MaxConcurrency = 15 // HTTP连接池配置 MaxIdleConns = 100 MaxIdleConnsPerHost = 30 MaxConnsPerHost = 50 IdleConnTimeout = 90 * time.Second ) // 性能统计 var ( searchRequests int64 = 0 detailPageRequests int64 = 0 cacheHits int64 = 0 cacheMisses int64 = 0 ) // AhhhhfsAsyncPlugin ahhhhfs异步插件 type AhhhhfsAsyncPlugin struct { *plugin.BaseAsyncPlugin optimizedClient *http.Client } // 在init函数中注册插件 func init() { plugin.RegisterGlobalPlugin(NewAhhhhfsPlugin()) // 启动缓存清理goroutine go startCacheCleaner() } // startCacheCleaner 启动一个定期清理缓存的goroutine func startCacheCleaner() { ticker := time.NewTicker(30 * time.Minute) defer ticker.Stop() for range ticker.C { // 清空所有缓存 detailCache = sync.Map{} lastCleanupTime = time.Now() } } // createOptimizedHTTPClient 创建优化的HTTP客户端 func createOptimizedHTTPClient() *http.Client { transport := &http.Transport{ MaxIdleConns: MaxIdleConns, MaxIdleConnsPerHost: MaxIdleConnsPerHost, MaxConnsPerHost: MaxConnsPerHost, IdleConnTimeout: IdleConnTimeout, DisableKeepAlives: false, } return &http.Client{ Transport: transport, Timeout: DefaultTimeout, } } // NewAhhhhfsPlugin 创建新的ahhhhfs异步插件 func NewAhhhhfsPlugin() *AhhhhfsAsyncPlugin { return &AhhhhfsAsyncPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority), optimizedClient: createOptimizedHTTPClient(), } } // Search 执行搜索并返回结果(兼容性方法) func (p *AhhhhfsAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { result, err := p.SearchWithResult(keyword, ext) if err != nil { return nil, err } return result.Results, nil } // SearchWithResult 执行搜索并返回包含IsFinal标记的结果 func (p *AhhhhfsAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) } // searchImpl 实现具体的搜索逻辑 func (p *AhhhhfsAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { // 性能统计 start := time.Now() atomic.AddInt64(&searchRequests, 1) defer func() { fmt.Printf("[%s] 搜索耗时: %v\n", p.Name(), time.Since(start)) }() // 使用优化的客户端 if p.optimizedClient != nil { client = p.optimizedClient } // 1. 构建搜索URL searchURL := fmt.Sprintf("https://www.ahhhhfs.com/?cat=&s=%s", url.QueryEscape(keyword)) // 2. 创建带超时的上下文 ctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout) defer cancel() // 3. 创建请求 req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err) } // 4. 设置完整的请求头(避免反爬虫) req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") req.Header.Set("Connection", "keep-alive") req.Header.Set("Upgrade-Insecure-Requests", "1") req.Header.Set("Cache-Control", "max-age=0") req.Header.Set("Referer", "https://www.ahhhhfs.com/") // 5. 发送请求(带重试机制) resp, err := p.doRequestWithRetry(req, client) if err != nil { return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err) } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("[%s] 搜索请求返回状态码: %d", p.Name(), resp.StatusCode) } // 6. 解析搜索结果页面 doc, err := goquery.NewDocumentFromReader(resp.Body) if err != nil { return nil, fmt.Errorf("[%s] 解析搜索页面失败: %w", p.Name(), err) } // 7. 提取搜索结果 var results []model.SearchResult var wg sync.WaitGroup var mu sync.Mutex semaphore := make(chan struct{}, MaxConcurrency) doc.Find("article.post-item.item-list").Each(func(i int, s *goquery.Selection) { // 解析基本信息 titleElem := s.Find(".entry-title a") title := strings.TrimSpace(titleElem.Text()) if title == "" { title = strings.TrimSpace(titleElem.AttrOr("title", "")) } detailURL, exists := titleElem.Attr("href") if !exists || detailURL == "" || title == "" { return } // 提取文章ID articleID := p.extractArticleID(detailURL) if articleID == "" { return } // 提取分类标签 var tags []string s.Find(".entry-cat-dot a").Each(func(j int, tag *goquery.Selection) { tagText := strings.TrimSpace(tag.Text()) if tagText != "" { tags = append(tags, tagText) } }) // 提取描述 content := strings.TrimSpace(s.Find(".entry-desc").Text()) // 提取时间 datetime := "" timeElem := s.Find(".entry-meta .meta-date time") if dt, exists := timeElem.Attr("datetime"); exists { datetime = dt } else { datetime = strings.TrimSpace(timeElem.Text()) } // 解析时间 publishTime := p.parseDateTime(datetime) // 异步获取详情页的网盘链接 wg.Add(1) semaphore <- struct{}{} // 获取信号量 go func(title, detailURL, articleID, content string, tags []string, publishTime time.Time) { defer wg.Done() defer func() { <-semaphore }() // 释放信号量 // 获取网盘链接 links := p.fetchDetailLinks(client, detailURL, articleID) if len(links) > 0 { result := model.SearchResult{ UniqueID: fmt.Sprintf("%s-%s", p.Name(), articleID), Title: title, Content: content, Links: links, Tags: tags, Channel: "", // 插件搜索结果 Channel 必须为空 Datetime: publishTime, } mu.Lock() results = append(results, result) mu.Unlock() } }(title, detailURL, articleID, content, tags, publishTime) }) // 等待所有详情页请求完成 wg.Wait() fmt.Printf("[%s] 搜索结果: %d 条\n", p.Name(), len(results)) // 关键词过滤 return plugin.FilterResultsByKeyword(results, keyword), nil } // extractArticleID 从URL中提取文章ID func (p *AhhhhfsAsyncPlugin) extractArticleID(detailURL string) string { matches := articleIDRegex.FindStringSubmatch(detailURL) if len(matches) >= 2 { return matches[1] } return "" } // parseDateTime 解析时间字符串 func (p *AhhhhfsAsyncPlugin) parseDateTime(datetime string) time.Time { datetime = strings.TrimSpace(datetime) // 尝试解析 ISO 格式 if t, err := time.Parse(time.RFC3339, datetime); err == nil { return t } // 尝试解析标准日期格式 layouts := []string{ "2006-01-02", "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02T15:04:05Z07:00", } for _, layout := range layouts { if t, err := time.Parse(layout, datetime); err == nil { return t } } // 处理相对时间(如"1 周前"、"2 天前") now := time.Now() if strings.Contains(datetime, "小时前") || strings.Contains(datetime, "hours ago") { // 简单处理,返回当天 return now } if strings.Contains(datetime, "天前") || strings.Contains(datetime, "days ago") { // 简单处理,返回近期 return now.AddDate(0, 0, -7) } if strings.Contains(datetime, "周前") || strings.Contains(datetime, "weeks ago") { // 简单处理,返回一个月前 return now.AddDate(0, -1, 0) } // 默认返回当前时间 return now } // fetchDetailLinks 获取详情页的网盘链接 func (p *AhhhhfsAsyncPlugin) fetchDetailLinks(client *http.Client, detailURL, articleID string) []model.Link { atomic.AddInt64(&detailPageRequests, 1) // 检查缓存 if cached, ok := detailCache.Load(articleID); ok { atomic.AddInt64(&cacheHits, 1) return cached.([]model.Link) } atomic.AddInt64(&cacheMisses, 1) // 创建带超时的上下文 ctx, cancel := context.WithTimeout(context.Background(), DetailTimeout) defer cancel() // 创建请求 req, err := http.NewRequestWithContext(ctx, "GET", detailURL, nil) if err != nil { fmt.Printf("[%s] 创建详情页请求失败: %v\n", p.Name(), err) return nil } // 设置请求头 req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") req.Header.Set("Referer", "https://www.ahhhhfs.com/") // 发送请求 resp, err := client.Do(req) if err != nil { fmt.Printf("[%s] 详情页请求失败: %v\n", p.Name(), err) return nil } defer resp.Body.Close() if resp.StatusCode != 200 { fmt.Printf("[%s] 详情页返回状态码: %d\n", p.Name(), resp.StatusCode) return nil } // 解析详情页 doc, err := goquery.NewDocumentFromReader(resp.Body) if err != nil { fmt.Printf("[%s] 解析详情页失败: %v\n", p.Name(), err) return nil } // 提取网盘链接 links := p.extractNetDiskLinks(doc) // 缓存结果 if len(links) > 0 { detailCache.Store(articleID, links) } return links } // extractNetDiskLinks 从详情页提取网盘链接 func (p *AhhhhfsAsyncPlugin) extractNetDiskLinks(doc *goquery.Document) []model.Link { var links []model.Link linkMap := make(map[string]model.Link) // 用于去重 // 在文章内容中查找所有链接 doc.Find(".post-content a").Each(func(i int, s *goquery.Selection) { href, exists := s.Attr("href") if !exists || href == "" { return } // 判断是否为网盘链接 cloudType := p.determineCloudType(href) if cloudType == "others" { return } // 提取提取码 password := p.extractPassword(s, href) // 添加到结果(去重) if _, exists := linkMap[href]; !exists { link := model.Link{ Type: cloudType, URL: href, Password: password, } linkMap[href] = link links = append(links, link) } }) return links } // determineCloudType 判断链接类型 func (p *AhhhhfsAsyncPlugin) determineCloudType(url string) string { switch { case strings.Contains(url, "pan.quark.cn"): return "quark" case strings.Contains(url, "drive.uc.cn"): return "uc" case strings.Contains(url, "pan.baidu.com"): return "baidu" case strings.Contains(url, "aliyundrive.com") || strings.Contains(url, "alipan.com"): return "aliyun" case strings.Contains(url, "pan.xunlei.com"): return "xunlei" case strings.Contains(url, "cloud.189.cn"): return "tianyi" case strings.Contains(url, "115.com"): return "115" case strings.Contains(url, "123pan.com"): return "123" case strings.Contains(url, "mypikpak.com"): return "pikpak" default: return "others" } } // extractPassword 提取提取码 func (p *AhhhhfsAsyncPlugin) extractPassword(linkElem *goquery.Selection, url string) string { // 1. 从链接的 title 属性中提取 if title, exists := linkElem.Attr("title"); exists { for _, pattern := range pwdPatterns { if matches := pattern.FindStringSubmatch(title); len(matches) >= 2 { return matches[1] } } } // 2. 从链接文本中提取 linkText := linkElem.Text() for _, pattern := range pwdPatterns { if matches := pattern.FindStringSubmatch(linkText); len(matches) >= 2 { return matches[1] } } // 3. 从链接后面的兄弟节点或父节点的文本中提取 parent := linkElem.Parent() parentText := parent.Text() // 获取链接在父元素文本中的位置 linkIndex := strings.Index(parentText, linkText) if linkIndex >= 0 { // 获取链接后面的文本 afterText := parentText[linkIndex+len(linkText):] for _, pattern := range pwdPatterns { if matches := pattern.FindStringSubmatch(afterText); len(matches) >= 2 { return matches[1] } } } // 4. 从 URL 参数中提取 if strings.Contains(url, "pwd=") { parts := strings.Split(url, "pwd=") if len(parts) >= 2 { pwd := parts[1] // 只取密码部分(去除其他参数) if idx := strings.IndexAny(pwd, "&?#"); idx >= 0 { pwd = pwd[:idx] } return pwd } } return "" } // doRequestWithRetry 带重试机制的HTTP请求 func (p *AhhhhfsAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) { maxRetries := 3 var lastErr error for i := 0; i < maxRetries; i++ { if i > 0 { // 指数退避重试 backoff := time.Duration(1<` (每个搜索结果) ### 3. 单个搜索结果结构 #### 标题区域 (.entry-title) ```html

AI小红书虚拟电商全链路实战课:从选品到变现的AI爆款打法

提取要素: - 标题: a 的文本内容或 title 属性 - 详情页链接: a 的 href 属性 ``` #### 分类标签 (.entry-cat-dot) ```html
短视频 资源
提取要素: - 分类: 所有 a 标签的文本内容 ``` #### 描述区域 (.entry-desc) ```html
AI小红书虚拟电商全链路实战课程概览 《AI小红书虚拟电商5.0实战课》是一门聚焦AI与小红书生态融合的系统课程,围绕AI赋能选品、创作、运营与变现四大环节展开...
提取要素: - 描述: div 的文本内容 ``` #### 元数据栏 (.entry-meta) ```html 提取要素: - 发布时间: time 标签的 datetime 属性或文本内容 ``` ## 详情页面结构 ### 1. 详情页URL模式 ``` https://www.ahhhhfs.com/{文章ID}/ 示例: https://www.ahhhhfs.com/76567/ ``` ### 2. 下载链接位置 下载链接在文章正文内容中 `.post-content` 里面,通常在文章末尾部分。 #### 下载链接格式示例 ```html

学习地址: 夸克

或者

下载地址: 百度网盘 提取码: xxxx

或者多个网盘链接:

阿里云盘:链接
夸克网盘:链接
百度网盘:链接 提取码: xxxx

提取要素: - 网盘链接: .post-content 中包含网盘域名的 a 标签的 href 属性 - 提取码/密码: 链接附近的文本内容,可能包含 "提取码"、"密码"、"pwd" 等关键词 ``` ## CSS选择器总结 | 数据项 | CSS选择器 | 提取方式 | |--------|-----------|----------| | 搜索结果列表 | `article.post-item.item-list` | 遍历所有结果项 | | 标题 | `.entry-title a` | 文本内容或 title 属性 | | 详情页链接 | `.entry-title a` | href 属性 | | 分类标签 | `.entry-cat-dot a` | 所有 a 标签的文本内容 | | 描述 | `.entry-desc` | 文本内容 | | 发布时间 | `.entry-meta .meta-date time` | datetime 属性或文本内容 | | 文章内容 | `.post-content` | HTML 内容 | | 网盘链接 | `.post-content a[href*="pan"]` 或匹配网盘域名 | href 属性 | ## 实现要点 ### 1. 支持的网盘类型 - 夸克网盘: `pan.quark.cn` - 阿里云盘: `aliyundrive.com`, `alipan.com` - 百度网盘: `pan.baidu.com` - UC网盘: `drive.uc.cn` - 迅雷网盘: `pan.xunlei.com` - 天翼云盘: `cloud.189.cn` - 115网盘: `115.com` - 123网盘: `123pan.com` ### 2. 提取码识别 提取码可能出现在以下位置: - 链接后面的文本: `提取码: xxxx` 或 `密码: xxxx` - 链接的 title 属性中 - `
` 标签分隔的下一行 - 括号内: `(提取码: xxxx)` 常见关键词: - 提取码 - 密码 - pwd - code - 取码 ### 3. 链接提取策略 1. 先从搜索结果页获取文章列表 2. 访问每篇文章的详情页 3. 在详情页的 `.post-content` 中查找包含网盘域名的链接 4. 提取链接和相应的提取码 5. 如果文章没有网盘链接,则跳过 ### 4. 时间格式处理 - 相对时间: "1 周前"、"2 天前" 需要转换为具体日期 - 绝对时间: "2025-10-18" 可以直接使用 - datetime 属性: "2025-10-18T13:43:10+08:00" 标准ISO格式 ### 5. 去重标识 - 使用文章ID作为唯一标识: 从详情页URL中提取 `/76567/` ## 注意事项 1. **搜索结果可能为空**: 如果关键词没有匹配结果,页面会显示"没有找到相关内容" 2. **分页**: 搜索结果可能有多页,但通常只抓取第一页即可 3. **网盘链接位置不固定**: 链接可能在文章开头、中间或结尾,需要遍历整个 `.post-content` 4. **广告干扰**: 页面包含广告,需要准确定位到实际内容区域 5. **需要访问详情页**: 搜索结果页不包含下载链接,必须访问详情页才能获取 6. **请求频率**: 需要访问详情页,建议控制请求频率避免被封 ## 示例数据流 ``` 1. 搜索请求: https://www.ahhhhfs.com/search/小红书 ↓ 2. 解析搜索结果页,提取文章列表 - 标题: "AI小红书虚拟电商全链路实战课:从选品到变现的AI爆款打法" - 详情页URL: https://www.ahhhhfs.com/76567/ - 分类: ["短视频", "资源"] - 发布时间: 2025-10-18 ↓ 3. 访问详情页: https://www.ahhhhfs.com/76567/ ↓ 4. 解析详情页 .post-content,提取网盘链接 - 夸克网盘: https://pan.quark.cn/s/c16a5ae18ea0 - 提取码: (如果有) ↓ 5. 构建最终结果 - UniqueID: ahhhhfs-76567 - Title: "AI小红书虚拟电商全链路实战课:从选品到变现的AI爆款打法" - Content: 文章描述 - Links: [{Type: "quark", URL: "...", Password: ""}] - Tags: ["短视频", "资源"] - Datetime: 2025-10-18T13:43:10+08:00 ``` ================================================ FILE: plugin/aikanzy/aikanzy.go ================================================ package aikanzy import ( "context" "fmt" "net/http" "net/url" "pansou/model" "pansou/plugin" "regexp" "strings" "sync" "time" "github.com/PuerkitoBio/goquery" ) // 预编译的正则表达式 var ( // 夸克网盘链接 quarkLinkRegex = regexp.MustCompile(`https?://pan\.quark\.cn/s/[0-9a-zA-Z]+`) // UC网盘链接 ucLinkRegex = regexp.MustCompile(`https?://drive\.uc\.cn/s/[0-9a-zA-Z]+(\?[^"'\s]*)?`) // 百度网盘链接 baiduLinkRegex = regexp.MustCompile(`https?://pan\.baidu\.com/s/[0-9a-zA-Z_-]+`) // 迅雷网盘链接 xunleiLinkRegex = regexp.MustCompile(`https?://pan\.xunlei\.com/s/[0-9a-zA-Z_-]+`) // 从URL中提取文章ID articleIDRegex = regexp.MustCompile(`/([a-z]+)/(\d+)\.html`) // 提取阅读数 viewCountRegex = regexp.MustCompile(`(\d+)\s*阅读`) ) // 常量定义 const ( // 插件名称 pluginName = "aikanzy" // 搜索URL模板 searchURLTemplate = "https://www.aikanzy.com/search?word=%s&molds=article" // 默认优先级 defaultPriority = 3 // 默认超时时间(秒) defaultTimeout = 15 // 详情页超时时间(秒) detailTimeout = 8 // 最大重试次数 maxRetries = 3 // 详情页并发数 detailConcurrency = 15 // 指数退避基数(毫秒) backoffBase = 200 ) // AikanzyAsyncPlugin 是AikanZY网站的异步搜索插件实现 type AikanzyAsyncPlugin struct { *plugin.BaseAsyncPlugin optimizedClient *http.Client } // 确保AikanzyAsyncPlugin实现了AsyncSearchPlugin接口 var _ plugin.AsyncSearchPlugin = (*AikanzyAsyncPlugin)(nil) // 在包初始化时注册插件 func init() { plugin.RegisterGlobalPlugin(NewAikanzyAsyncPlugin()) } // createOptimizedHTTPClient 创建优化的HTTP客户端 func createOptimizedHTTPClient() *http.Client { transport := &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 20, IdleConnTimeout: 90 * time.Second, DisableKeepAlives: false, } return &http.Client{ Transport: transport, Timeout: defaultTimeout * time.Second, } } // NewAikanzyAsyncPlugin 创建一个新的AikanZY异步插件实例 func NewAikanzyAsyncPlugin() *AikanzyAsyncPlugin { return &AikanzyAsyncPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("aikanzy", defaultPriority), optimizedClient: createOptimizedHTTPClient(), } } // Name 返回插件名称 func (p *AikanzyAsyncPlugin) Name() string { return pluginName } // Priority 返回插件优先级 func (p *AikanzyAsyncPlugin) Priority() int { return defaultPriority } // Search 执行搜索并返回结果(兼容性方法) func (p *AikanzyAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { result, err := p.SearchWithResult(keyword, ext) if err != nil { return nil, err } return result.Results, nil } // SearchWithResult 执行搜索并返回包含IsFinal标记的结果 func (p *AikanzyAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { return p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext) } // doSearch 执行具体的搜索逻辑 func (p *AikanzyAsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { // 使用优化的客户端 if p.optimizedClient != nil { client = p.optimizedClient } // 对关键词进行URL编码 encodedKeyword := url.QueryEscape(keyword) // 构建搜索URL searchURL := fmt.Sprintf(searchURLTemplate, encodedKeyword) // 创建一个带有超时的上下文 ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout*time.Second) defer cancel() // 创建请求 req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err) } // 设置完整的请求头(避免反爬虫) 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") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") req.Header.Set("Connection", "keep-alive") req.Header.Set("Referer", "https://www.aikanzy.com/") req.Header.Set("Upgrade-Insecure-Requests", "1") req.Header.Set("Cache-Control", "max-age=0") // 使用带重试的请求方法发送HTTP请求 resp, err := p.doRequestWithRetry(req, client) if err != nil { return nil, fmt.Errorf("[%s] 请求搜索页面失败: %w", p.Name(), err) } defer resp.Body.Close() // 检查状态码 if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("[%s] 请求搜索页面失败,状态码: %d", p.Name(), resp.StatusCode) } // 使用goquery解析HTML doc, err := goquery.NewDocumentFromReader(resp.Body) if err != nil { return nil, fmt.Errorf("[%s] 解析HTML失败: %w", p.Name(), err) } // 解析搜索结果列表 articleItems := p.parseArticleList(doc) if len(articleItems) == 0 { return []model.SearchResult{}, nil } // 并发抓取详情页获取网盘链接 results := p.fetchDetailsWithLinks(articleItems, client, keyword) // 使用过滤功能过滤结果 filteredResults := plugin.FilterResultsByKeyword(results, keyword) return filteredResults, nil } // ArticleItem 文章基本信息 type ArticleItem struct { ID string Title string DetailURL string Category string PublishDate string ViewCount int Summary string ImageURL string } // parseArticleList 解析文章列表 func (p *AikanzyAsyncPlugin) parseArticleList(doc *goquery.Document) []ArticleItem { var items []ArticleItem // 查找所有文章项 doc.Find("article.post-list.contt.blockimg").Each(func(i int, s *goquery.Selection) { // 提取详情页链接 detailLink := s.Find("a[href]").First() detailURL, exists := detailLink.Attr("href") if !exists || detailURL == "" { return } // 提取文章ID articleID := p.extractArticleID(detailURL) if articleID == "" { return } // 提取标题 title := strings.TrimSpace(s.Find("header.entry-header span.entry-title a").Text()) // 移除标题中的HTML标签(如) title = p.cleanHTMLTags(title) if title == "" { return } // 提取分类 category := strings.TrimSpace(s.Find("div.entry-meta > a").First().Text()) // 提取发布日期 publishDate := strings.TrimSpace(s.Find("time").First().Text()) // 提取阅读数 metaText := s.Find("div.entry-meta").Text() viewCount := p.extractViewCount(metaText) // 提取摘要 summary := strings.TrimSpace(s.Find("div.entry-summary.ss p").Text()) summary = p.cleanHTMLTags(summary) // 提取缩略图 imageURL, _ := s.Find("img.block-fea").Attr("data-src") items = append(items, ArticleItem{ ID: articleID, Title: title, DetailURL: detailURL, Category: category, PublishDate: publishDate, ViewCount: viewCount, Summary: summary, ImageURL: imageURL, }) }) return items } // fetchDetailsWithLinks 并发抓取详情页获取网盘链接 func (p *AikanzyAsyncPlugin) fetchDetailsWithLinks(items []ArticleItem, client *http.Client, keyword string) []model.SearchResult { // 创建结果通道和等待组 resultChan := make(chan model.SearchResult, len(items)) var wg sync.WaitGroup // 创建信号量控制并发数 semaphore := make(chan struct{}, detailConcurrency) // 并发处理每个文章项 for _, item := range items { wg.Add(1) go func(item ArticleItem) { defer wg.Done() // 获取信号量 semaphore <- struct{}{} defer func() { <-semaphore }() // 抓取详情页 links := p.fetchDetailPageLinks(item.DetailURL, client) // 只有包含链接的结果才添加 if len(links) > 0 { // 解析发布时间 publishTime := p.parsePublishTime(item.PublishDate) // 组装内容 var contentParts []string if item.Summary != "" { contentParts = append(contentParts, item.Summary) } if item.Category != "" { contentParts = append(contentParts, item.Category) } if item.PublishDate != "" { contentParts = append(contentParts, item.PublishDate) } if item.ViewCount > 0 { contentParts = append(contentParts, fmt.Sprintf("%d阅读", item.ViewCount)) } content := strings.Join(contentParts, " | ") // 组装标签 var tags []string if item.Category != "" { tags = append(tags, item.Category) } result := model.SearchResult{ UniqueID: fmt.Sprintf("aikanzy-%s", item.ID), Title: item.Title, Content: content, Links: links, Tags: tags, Channel: "", // 插件搜索结果Channel为空 Datetime: publishTime, } resultChan <- result } }(item) } // 等待所有goroutine完成 go func() { wg.Wait() close(resultChan) }() // 收集所有结果 var results []model.SearchResult for result := range resultChan { results = append(results, result) } return results } // fetchDetailPageLinks 抓取详情页的网盘链接 func (p *AikanzyAsyncPlugin) fetchDetailPageLinks(detailURL string, client *http.Client) []model.Link { // 创建带超时的上下文 ctx, cancel := context.WithTimeout(context.Background(), detailTimeout*time.Second) defer cancel() // 创建请求 req, err := http.NewRequestWithContext(ctx, "GET", detailURL, nil) if err != nil { return nil } // 设置请求头 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") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") req.Header.Set("Connection", "keep-alive") req.Header.Set("Referer", "https://www.aikanzy.com/") req.Header.Set("Upgrade-Insecure-Requests", "1") // 发送请求(带重试) resp, err := p.doRequestWithRetry(req, client) if err != nil { return nil } defer resp.Body.Close() // 检查状态码 if resp.StatusCode != http.StatusOK { return nil } // 解析HTML doc, err := goquery.NewDocumentFromReader(resp.Body) if err != nil { return nil } // 提取网盘链接 return p.extractNetDiskLinks(doc) } // extractNetDiskLinks 从详情页提取网盘链接 func (p *AikanzyAsyncPlugin) extractNetDiskLinks(doc *goquery.Document) []model.Link { var links []model.Link foundURLs := make(map[string]bool) // 用于去重 // 方法1: 从标签的href属性提取 doc.Find("a[href*='pan.quark.cn'], a[href*='drive.uc.cn'], a[href*='pan.baidu.com'], a[href*='pan.xunlei.com']").Each(func(i int, s *goquery.Selection) { href, exists := s.Attr("href") if !exists || href == "" { return } // 去重 if foundURLs[href] { return } foundURLs[href] = true // 确定链接类型 linkType := p.determineLinkType(href) if linkType == "" { return } links = append(links, model.Link{ Type: linkType, URL: href, Password: p.extractPassword(href), }) }) // 方法2: 从页面HTML文本中提取(正则表达式) if len(links) == 0 { html, _ := doc.Html() // 提取夸克网盘链接 quarkLinks := quarkLinkRegex.FindAllString(html, -1) for _, link := range quarkLinks { if !foundURLs[link] { foundURLs[link] = true links = append(links, model.Link{ Type: "quark", URL: link, Password: p.extractPassword(link), }) } } // 提取UC网盘链接 ucLinks := ucLinkRegex.FindAllString(html, -1) for _, link := range ucLinks { if !foundURLs[link] { foundURLs[link] = true links = append(links, model.Link{ Type: "uc", URL: link, Password: p.extractPassword(link), }) } } // 提取百度网盘链接 baiduLinks := baiduLinkRegex.FindAllString(html, -1) for _, link := range baiduLinks { if !foundURLs[link] { foundURLs[link] = true links = append(links, model.Link{ Type: "baidu", URL: link, Password: p.extractPassword(link), }) } } // 提取迅雷网盘链接 xunleiLinks := xunleiLinkRegex.FindAllString(html, -1) for _, link := range xunleiLinks { if !foundURLs[link] { foundURLs[link] = true links = append(links, model.Link{ Type: "xunlei", URL: link, Password: p.extractPassword(link), }) } } } return links } // determineLinkType 根据URL确定链接类型 func (p *AikanzyAsyncPlugin) determineLinkType(urlStr string) string { lowerURL := strings.ToLower(urlStr) switch { case strings.Contains(lowerURL, "pan.quark.cn"): return "quark" case strings.Contains(lowerURL, "drive.uc.cn"): return "uc" case strings.Contains(lowerURL, "pan.baidu.com"): return "baidu" case strings.Contains(lowerURL, "pan.xunlei.com"): return "xunlei" default: return "" } } // extractArticleID 从URL中提取文章ID func (p *AikanzyAsyncPlugin) extractArticleID(urlStr string) string { matches := articleIDRegex.FindStringSubmatch(urlStr) if len(matches) >= 3 { return matches[2] // 返回数字ID } return "" } // extractViewCount 提取阅读数 func (p *AikanzyAsyncPlugin) extractViewCount(text string) int { matches := viewCountRegex.FindStringSubmatch(text) if len(matches) >= 2 { var count int fmt.Sscanf(matches[1], "%d", &count) return count } return 0 } // cleanHTMLTags 清除HTML标签 func (p *AikanzyAsyncPlugin) cleanHTMLTags(text string) string { // 移除标签 text = regexp.MustCompile(`]*>`).ReplaceAllString(text, "") text = regexp.MustCompile(``).ReplaceAllString(text, "") // 移除其他常见HTML标签 text = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(text, "") return strings.TrimSpace(text) } // parsePublishTime 解析发布时间 func (p *AikanzyAsyncPlugin) parsePublishTime(dateStr string) time.Time { dateStr = strings.TrimSpace(dateStr) if dateStr == "" { return time.Time{} } // 尝试多种日期格式 formats := []string{ "2006-01-02", "2006-01-02 15:04:05", "2006-01-02T15:04:05Z", "2006-01-02T15:04:05+08:00", "2006-01-02T15:04:05-07:00", } for _, format := range formats { if t, err := time.Parse(format, dateStr); err == nil { return t } } // 如果以上格式都不匹配,尝试使用time.RFC3339格式(处理 ``` #### 需要提取的字段 - **标题**: `h2 a` 文本 - **详情链接**: `h2 a[href]` - **分类**: `.label.label-important` 文本(可作为 `Tags` 之一) - **发布日期**: `p > span .icon-time` 所在 ``,格式通常为 `1年前 (2024-07-27)`;取括号内日期 - **摘要**: `p.note` - **封面**: `div.focus img[src]`(仅用于调试,不需要在结果中返回) ### 4. 分页 - 搜索页默认返回全部匹配列表,可根据需要继续解析分页链接(一般抓取第一页即可)。 ## 详情页 ### 1. URL 规则 ``` https://www.aliupan.com/?p={文章ID} ``` - `文章ID` 来自列表页 URL,可直接作为唯一标识。 ### 2. 主体定位 - 标题:`.article-header .article-title a` - 元信息:`.meta`(含分类、作者、时间、阅读) - 正文:`article.article-content` ### 3. 下载链接形态 正文中使用普通段落给出下载地址: ```html

阿里云盘丨遮天:https://www.aliyundrive.com/s/xxxx

夸克网盘丨遮天:https://pan.quark.cn/s/5ad996dc0725

``` - 个别文章会出现“待补”等文字;只返回真正包含链接的 ``。 - 可能同文提供多个链接(夸克 / 阿里云盘 / 其他),需要全部收集。 - 提取码通常写在同一段落文本里,形如 `提取码:xxxx`、`密码:xxxx` 等。 ### 4. 支持的网盘域名 - **阿里云盘**: `https://www.aliyundrive.com/s/`、`https://www.aliyundrive.com/drive/folder/` - **夸克网盘**: `https://pan.quark.cn/s/` - 可根据站点实际扩展(如出现 `pan.baidu.com` 等) ## CSS 选择器速览 | 数据项 | 选择器/规则 | |--------|-------------| | 列表项 | `article.excerpt` | | 标题 & 链接 | `article.excerpt h2 a` | | 分类标签 | `article.excerpt header .label` | | 摘要 | `article.excerpt p.note` | | 发布时间 | `article.excerpt p .icon-time` 所在 ``;取括号中的日期 | | 正文容器 | `article.article-content` | | 网盘链接 | `.article-content a[href*="pan.quark.cn"]`、`a[href*="aliyundrive.com"]` 等 | ## 提取策略 1. **搜索页** - 构建 `https://www.aliupan.com/?s=keyword`,使用浏览器 UA、防爬 Header。 - 解析 `article.excerpt`,抓取基本元信息。 - 由 `?p={id}` 提取 ID,构建唯一键 `alupan-{id}`。 2. **详情页** - 访问正文 `.article-content`。 - 遍历所有 ``,通过域名判断网盘类型。 - 在链接文本或父级文本中搜索提取码关键词(`提取码/密码/pwd/code`)。 - 多个链接去重(同地址只保留一次)。 3. **时间解析** - 优先解析括号内日期(`YYYY-MM-DD`)。 - 若无括号,只能是 `YYYY-MM-DD` 或 `YYYY年MM月DD日`,按常见格式匹配;失败则用当前时间。 4. **性能优化建议** - 统一使用定制 `http.Client`(连接池 + TLS/Expect 超时 + HTTP/2)。 - 搜索与详情请求加入指数退避重试(至少 2~3 次)。 - 对详情解析结果加 TTL 缓存(例如 1 小时),避免重复抓取。 - 使用信号量控制同时抓取的详情页数量,推荐 10~15。 ## 示例数据流 ``` 1. 请求 https://www.aliupan.com/?s=遮天 2. 列表项: - 标题: [阿里云盘][夸克网盘]《遮天》(2023年) - 分类: 中国内地电视剧 - 日期: 1年前 (2024-07-27) - 摘要: 阿里云盘丨遮天:待补 夸克网盘丨遮天:https://pan.quark.cn/... - 详情: https://www.aliupan.com/?p=7078 3. 详情解析: - `https://pan.quark.cn/s/5ad996dc0725` 4. 构建结果: UniqueID: alupan-7078 Title: [阿里云盘][夸克网盘]《遮天》(2023年) Links: [{Type:"quark", URL:"https://pan.quark.cn/s/5ad996dc0725", Password:""}] Tags: ["中国内地电视剧"] Datetime: 2024-07-27T00:00:00+08:00 ``` ## 注意事项 1. **摘要中的裸链**:虽然摘要有时包含 URL,但仍应以详情页数据为准。 2. **缺失链接**:如果正文中没有有效网盘链接(例如“待补”),忽略该文章。 3. **多链接**:同一篇可能同时提供阿里云盘与夸克链接,均需返回。 4. **缓存**:文章更新较频繁,建议缓存加入 TTL,并定时清理。 5. **编码**:站点内容大量中文,解析时确保使用 UTF-8。 ================================================ FILE: plugin/ash/ash.go ================================================ package ash import ( "context" "fmt" "io" "net/http" "net/url" "regexp" "strings" "time" "pansou/model" "pansou/plugin" "pansou/util/json" ) type AshPlugin struct { *plugin.BaseAsyncPlugin } const ( // 错误的夸克域名 wrongQuarkDomain = "pan.qualk.cn" // 正确的夸克域名 correctQuarkDomain = "pan.quark.cn" ) var ( // 提取JSON数据的正则表达式(预编译) jsonDataRegex = regexp.MustCompile(`var jsonData = '(\[.*?\])';`) // 控制字符清理正则(预编译) controlCharRegex = regexp.MustCompile(`[\x00-\x1F\x7F]`) ) // AshResult 表示ASH搜索结果的数据结构 type AshResult struct { ID int `json:"id"` SourceCategoryID int `json:"source_category_id"` Title string `json:"title"` IsType int `json:"is_type"` Code interface{} `json:"code"` // 可能是null或string URL string `json:"url"` IsTime int `json:"is_time"` Name string `json:"name"` Times string `json:"times"` Category interface{} `json:"category"` // 可能是null或string } func init() { p := &AshPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("ash", 2), // 优先级2,质量良好的影视资源 } plugin.RegisterGlobalPlugin(p) } // Search 执行搜索并返回结果 func (p *AshPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { result, err := p.SearchWithResult(keyword, ext) if err != nil { return nil, err } return result.Results, nil } // SearchWithResult 执行搜索并返回包含IsFinal标记的结果 func (p *AshPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) } // searchImpl 实际的搜索实现(优化版本) func (p *AshPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { // 构建搜索URL searchURL := fmt.Sprintf("https://so.allsharehub.com/s/%s.html", url.QueryEscape(keyword)) // 创建带超时的上下文(减少超时时间,提高响应速度) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() // 创建请求 req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err) } // 设置请求头 p.setRequestHeaders(req) // 发送请求(优化重试) resp, err := p.doRequestWithRetry(req, client) if err != nil { return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err) } defer resp.Body.Close() // 检查状态码 if resp.StatusCode != 200 { return nil, fmt.Errorf("[%s] 请求返回状态码: %d", p.Name(), resp.StatusCode) } // 读取响应(使用有限制的读取,避免读取过大内容) // ASH页面通常不会太大,限制在2MB以内 limitReader := io.LimitReader(resp.Body, 2*1024*1024) body, err := io.ReadAll(limitReader) if err != nil { return nil, fmt.Errorf("[%s] 读取响应失败: %w", p.Name(), err) } // 从HTML中提取JSON数据(直接传递字节,避免字符串转换) results, err := p.extractResultsFromBytes(body) if err != nil { return nil, fmt.Errorf("[%s] 提取搜索结果失败: %w", p.Name(), err) } // 关键词过滤 filtered := plugin.FilterResultsByKeyword(results, keyword) return filtered, nil } // extractResultsFromBytes 从字节数组中提取搜索结果(优化版本,避免字符串转换) func (p *AshPlugin) extractResultsFromBytes(data []byte) ([]model.SearchResult, error) { // 直接在字节数组中查找JSON数据(避免转换为字符串) html := string(data) // 只转换一次 // 查找JSON数据 matches := jsonDataRegex.FindStringSubmatch(html) if len(matches) < 2 { return []model.SearchResult{}, nil // 没有找到数据,返回空结果 } // 提取JSON字符串 jsonStr := matches[1] // 清理JSON字符串(批量操作,减少内存分配) if strings.Contains(jsonStr, "\\/") { jsonStr = strings.ReplaceAll(jsonStr, "\\/", "/") } jsonStr = controlCharRegex.ReplaceAllString(jsonStr, "") // 解析JSON - 使用高性能的sonic库 var ashResults []AshResult if err := json.Unmarshal([]byte(jsonStr), &ashResults); err != nil { return nil, fmt.Errorf("JSON解析失败: %w", err) } // 如果没有结果,直接返回 if len(ashResults) == 0 { return []model.SearchResult{}, nil } // 预分配切片容量,避免动态扩容 results := make([]model.SearchResult, 0, len(ashResults)) // 批量处理所有结果 for i := range ashResults { item := &ashResults[i] // 提前检查URL是否有效,避免无效处理 if item.URL == "" { continue } // 处理网盘链接 panURL := p.fixPanURL(item.URL) if panURL == "" { continue } // 确定网盘类型(内联优化) var panType string switch item.IsType { case 0: panType = "quark" case 2: panType = "baidu" case 3: panType = "uc" case 4: panType = "xunlei" default: panType = "quark" } // 处理提取码 var password string if item.Code != nil { if codeStr, ok := item.Code.(string); ok && codeStr != "" { password = codeStr } } // 解析时间 var datetime time.Time if item.Times != "" { if parsedTime, err := time.Parse("2006-01-02", item.Times); err == nil { datetime = parsedTime } else { datetime = time.Now() } } else { datetime = time.Now() } // 获取标签 var tags []string if item.SourceCategoryID > 0 && item.SourceCategoryID <= 6 { categoryNames := [...]string{"短剧", "电影", "电视剧", "动漫", "综艺", "充电视频"} tags = []string{categoryNames[item.SourceCategoryID-1]} } // 构建搜索结果 results = append(results, model.SearchResult{ UniqueID: fmt.Sprintf("%s-%d", p.Name(), item.ID), Title: item.Title, Content: item.Name, Datetime: datetime, Channel: "", Links: []model.Link{{ Type: panType, URL: panURL, Password: password, }}, Tags: tags, }) } return results, nil } // fixPanURL 修复网盘链接 - 关键功能!(优化版本) func (p *AshPlugin) fixPanURL(url string) string { // 快速检查是否为有效的HTTP/HTTPS链接 if len(url) < 8 { // 最短的URL: http://a return "" } // 验证链接协议(使用更快的检查方式) if url[0] != 'h' || (url[4] != ':' && url[5] != ':') { return "" } // 只在包含错误域名时才进行替换,避免不必要的字符串操作 if strings.Contains(url, wrongQuarkDomain) { return strings.Replace(url, wrongQuarkDomain, correctQuarkDomain, 1) } return url } // setRequestHeaders 设置请求头 func (p *AshPlugin) setRequestHeaders(req *http.Request) { 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") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") req.Header.Set("Connection", "keep-alive") req.Header.Set("Upgrade-Insecure-Requests", "1") req.Header.Set("Cache-Control", "max-age=0") req.Header.Set("Referer", "https://so.allsharehub.com/") } // doRequestWithRetry 带重试机制的HTTP请求(优化版本) func (p *AshPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) { maxRetries := 2 // 减少重试次数,提高响应速度 var lastErr error for i := 0; i < maxRetries; i++ { if i > 0 { // 更短的退避时间 backoff := time.Duration(100< 仙逆剧场版神临之战4K完整版
2025-08-31
来源:夸克网盘
``` ## 重要实现要点 ### 1. 网盘链接转换 ⭐ 非常重要 页面返回的链接使用错误的域名,必须进行转换: ``` 原始链接: https://pan.qualk.cn/s/095628b04e6c 正确链接: https://pan.quark.cn/s/095628b04e6c 转换规则: 将 "pan.qualk.cn" 替换为 "pan.quark.cn" ``` ### 2. 数据提取正则表达式 ```go // 提取JSON数据 jsonDataRegex := regexp.MustCompile(`var jsonData = '(\[.*?\])';`) // 清理JSON中的控制字符 jsonData = strings.ReplaceAll(jsonData, "\\/", "/") jsonData = regexp.MustCompile(`[\x00-\x1F\x7F]`).ReplaceAllString(jsonData, "") ``` ### 3. 网盘类型映射 ```go is_type 值映射: 0 -> "quark" (夸克网盘) 2 -> "baidu" (百度网盘) 3 -> "uc" (UC网盘) 4 -> "xunlei" (迅雷网盘) ``` ### 4. 时间格式 - 格式: `YYYY-MM-DD` - 需要转换为标准时间格式: `time.Parse("2006-01-02", timeStr)` ### 5. 分类信息 页面支持按分类筛选: - 0: 全部 - 1: 短剧 - 2: 电影 - 3: 电视剧 - 4: 动漫 - 5: 综艺 - 6: 充电视频 ## CSS选择器总结 | 数据项 | CSS选择器 | 提取方式 | |--------|-----------|----------| | 搜索结果列表 | `.listBox .left .box .list .item` | 遍历所有结果项 | | 标题 | `.item .title` | 文本内容 | | 发布时间 | `.item .type.time` | 文本内容 | | 来源类型 | `.item .type span` | 文本内容 | | 详情页链接 | `.item a[href^="/d/"]` | href 属性 | ## 优先级建议 - **优先级**: 2-3 (质量良好的影视资源搜索) - **跳过Service层过滤**: false (标准中文资源,保持过滤) - **缓存TTL**: 2小时 ## 搜索策略 1. 优先使用JavaScript变量提取数据(更快、更准确) 2. 如果JavaScript解析失败,回退到HTML解析 3. 必须对所有链接进行域名转换(pan.qualk.cn -> pan.quark.cn) 4. 只返回包含有效网盘链接的结果 ================================================ FILE: plugin/bixin/bixin.go ================================================ package bixin import ( "fmt" "io" "math/rand" "net/http" "net/url" "sort" "strings" "sync" "time" "pansou/model" "pansou/plugin" "pansou/util/json" ) // 在init函数中注册插件 func init() { // 注册插件 plugin.RegisterGlobalPlugin(NewBixinAsyncPlugin()) } const ( // API基础URL BaseURL = "https://www.bixbiy.com/api/discussions" // 默认参数 PageSize = 50 // 符合API实际返回数量 MaxRetries = 2 ) // 常用UA列表 var userAgents = []string{ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", } // BixinAsyncPlugin bixin网盘搜索异步插件 type BixinAsyncPlugin struct { *plugin.BaseAsyncPlugin retries int } // NewBixinAsyncPlugin 创建新的bixin异步插件 func NewBixinAsyncPlugin() *BixinAsyncPlugin { return &BixinAsyncPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("bixin", 3, true), // 跳过Service层过滤 retries: MaxRetries, } } // Search 执行搜索并返回结果(兼容性方法) func (p *BixinAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { result, err := p.SearchWithResult(keyword, ext) if err != nil { return nil, err } return result.Results, nil } // SearchWithResult 执行搜索并返回包含IsFinal标记的结果 func (p *BixinAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { return p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext) } // doSearch 实际的搜索实现 func (p *BixinAsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { // 初始化随机数种子 rand.Seed(time.Now().UnixNano()) // 只并发请求2个页面(0-1页) allResults, _, err := p.fetchBatch(client, keyword, 0, 2) if err != nil { return nil, err } // 去重 uniqueResults := p.deduplicateResults(allResults) // 使用过滤功能过滤结果 filteredResults := plugin.FilterResultsByKeyword(uniqueResults, keyword) return filteredResults, nil } // fetchBatch 获取一批页面的数据 func (p *BixinAsyncPlugin) fetchBatch(client *http.Client, keyword string, startOffset, pageCount int) ([]model.SearchResult, bool, error) { var wg sync.WaitGroup resultChan := make(chan struct{ offset int results []model.SearchResult hasMore bool err error }, pageCount) // 并发请求多个页面,但每个请求之间添加随机延迟 for i := 0; i < pageCount; i++ { offset := (startOffset + i) * PageSize wg.Add(1) go func(offset int, index int) { defer wg.Done() // 第一个请求立即执行,后续请求添加随机延迟 if index > 0 { // 随机等待0-1秒 randomDelay := time.Duration(100 + rand.Intn(900)) * time.Millisecond time.Sleep(randomDelay) } // 请求特定页面 results, hasMore, err := p.fetchPage(client, keyword, offset) resultChan <- struct{ offset int results []model.SearchResult hasMore bool err error }{ offset: offset, results: results, hasMore: hasMore, err: err, } }(offset, i) } // 等待所有请求完成 go func() { wg.Wait() close(resultChan) }() // 收集结果 var allResults []model.SearchResult hasMore := false for result := range resultChan { if result.err != nil { return nil, false, result.err } allResults = append(allResults, result.results...) hasMore = hasMore || result.hasMore } return allResults, hasMore, nil } // deduplicateResults 去除重复结果 func (p *BixinAsyncPlugin) deduplicateResults(results []model.SearchResult) []model.SearchResult { seen := make(map[string]bool) unique := make([]model.SearchResult, 0, len(results)) for _, result := range results { if !seen[result.UniqueID] { seen[result.UniqueID] = true unique = append(unique, result) } } // 按时间降序排序 sort.Slice(unique, func(i, j int) bool { return unique[i].Datetime.After(unique[j].Datetime) }) return unique } // fetchPage 获取指定页的搜索结果 func (p *BixinAsyncPlugin) 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) // 创建请求 req, err := http.NewRequest("GET", apiURL, nil) if err != nil { return nil, false, fmt.Errorf("创建请求失败: %w", err) } // 设置请求头 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") var resp *http.Response var responseBody []byte // 重试逻辑 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 BixinResponse if err := json.Unmarshal(responseBody, &apiResp); err != nil { return nil, false, fmt.Errorf("解析响应失败: %w", err) } // 处理结果 results := make([]model.SearchResult, 0, len(apiResp.Data)) postMap := make(map[string]BixinPost) // 创建帖子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 } // 清理HTML内容 cleanedHTML := cleanHTML(post.Attributes.ContentHTML) // 提取链接(只处理移动云盘) links := extractMobileLinksFromText(cleanedHTML) // 如果没有找到链接,跳过该结果 if len(links) == 0 { continue } // 解析时间 createdTime, err := time.Parse(time.RFC3339, discussion.Attributes.CreatedAt) if err != nil { createdTime = time.Now() // 如果解析失败,使用当前时间 } // 创建唯一ID:插件名-帖子ID uniqueID := fmt.Sprintf("bixin-%s", discussion.ID) // 创建搜索结果 result := model.SearchResult{ UniqueID: uniqueID, Title: discussion.Attributes.Title, Content: cleanedHTML, // 使用清理后的HTML作为内容 Datetime: createdTime, Links: links, Channel: "", // 插件搜索结果Channel为空 } results = append(results, result) } // 判断是否有更多结果 hasMore := apiResp.Links.Next != "" return results, hasMore, nil } // 生成随机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))] } // 清理HTML内容(参考pan666的cleanHTML函数) func cleanHTML(html string) string { // 移除
标签 html = strings.ReplaceAll(html, "
", "\n") html = strings.ReplaceAll(html, "
", "\n") html = strings.ReplaceAll(html, "
", "\n") // 移除其他HTML标签 var result strings.Builder inTag := false for _, r := range html { if r == '<' { inTag = true continue } if r == '>' { inTag = false continue } if !inTag { result.WriteRune(r) } } // 处理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, " ", " ") // 处理多行空白 lines := strings.Split(output, "\n") var cleanedLines []string for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed != "" { cleanedLines = append(cleanedLines, trimmed) } } return strings.Join(cleanedLines, "\n") } // 从文本提取移动云盘链接(bixin专用) func extractMobileLinksFromText(content string) []model.Link { var allLinks []model.Link lines := strings.Split(content, "\n") // 收集所有可能的链接信息 var linkInfos []struct { link model.Link position int category string } // 收集所有可能的密码信息 var passwordInfos []struct { keyword string position int password string } // 第一遍:查找所有的链接和密码 for i, line := range lines { line = strings.TrimSpace(line) // 只检查移动云盘(bixin只支持移动云盘) if strings.Contains(line, "caiyun.139.com") { url := extractURLFromText(line) if url != "" { linkInfos = append(linkInfos, struct { link model.Link position int category string }{ link: model.Link{URL: url, Type: "mobile"}, position: i, category: "mobile", }) } } // 检查密码/访问码(移动云盘主要使用访问码) 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 == "mobile" && (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 } // BixinResponse API响应结构 type BixinResponse struct { Links struct { First string `json:"first"` Next string `json:"next,omitempty"` } `json:"links"` Data []BixinDiscussion `json:"data"` Included []BixinPost `json:"included"` } // BixinDiscussion 讨论信息 type BixinDiscussion struct { Type string `json:"type"` ID string `json:"id"` Attributes struct { Title string `json:"title"` Slug string `json:"slug"` CommentCount int `json:"commentCount"` CreatedAt string `json:"createdAt"` LastPostedAt string `json:"lastPostedAt"` LastPostNumber int `json:"lastPostNumber"` IsApproved bool `json:"isApproved"` } `json:"attributes"` Relationships struct { MostRelevantPost struct { Data struct { Type string `json:"type"` ID string `json:"id"` } `json:"data"` } `json:"mostRelevantPost"` } `json:"relationships"` } // BixinPost 帖子内容 type BixinPost struct { Type string `json:"type"` ID string `json:"id"` Attributes struct { Number int `json:"number"` CreatedAt string `json:"createdAt"` ContentType string `json:"contentType"` ContentHTML string `json:"contentHtml"` } `json:"attributes"` } ================================================ FILE: plugin/bixin/json结构分析.md ================================================ # Bixin API 数据结构分析 ## 基本信息 - **数据源类型**: JSON API - **API URL格式**: `https://www.bixbiy.com/api/discussions?filter[q]={关键词}&page[limit]=3&include=mostRelevantPost` - **请求方法**: `GET` - **Content-Type**: `application/json` - **Referer**: `https://www.bixbiy.com/` - **特殊说明**: 该网站**只提供移动云盘(mobile)链接**,域名固定为`caiyun.139.com`,需要从HTML内容中解析网盘链接和密码 ## API响应结构 ### 顶层结构 ```json { "links": { "first": "https://www.bixbiy.com/api/discussions?filter%5Bq%5D=%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0+&page%5Blimit%5D=3&include=mostRelevantPost", "next": "https://www.bixbiy.com/api/discussions?filter%5Bq%5D=%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0+&page%5Blimit%5D=3&page%5Boffset%5D=3&include=mostRelevantPost" }, "data": [ // 讨论帖子数组 ], "included": [ // 相关回复内容数组 ] } ``` ### `data`数组中的讨论帖子结构 ```json { "type": "discussions", "id": "5754", "attributes": { "title": "凡人修仙传(2025)更新至第8集", "slug": "5754", "commentCount": 1, "participantCount": 1, "createdAt": "2025-07-29T15:31:19+00:00", "lastPostedAt": "2025-07-29T15:31:19+00:00", "lastPostNumber": 1, "canReply": false, "canRename": false, "canDelete": false, "canHide": false, "isApproved": true, "canTag": false, "isSticky": false, "canSticky": false, "isStickiest": false, "isTagSticky": false, "canStickiest": false, "canTagSticky": false, "subscription": null, "isLocked": false, "canLock": false }, "relationships": { "mostRelevantPost": { "data": { "type": "posts", "id": "6187" } } } } ``` ### `included`数组中的回复内容结构 ```json { "type": "posts", "id": "6187", "attributes": { "number": 1, "createdAt": "2025-07-29T15:31:19+00:00", "contentType": "comment", "contentHtml": "

凡人修仙传(2025)更新至第8集:https://caiyun.139.com/w/i/2oRhbuZoZbFpi

", "renderFailed": false, "canEdit": false, "canDelete": false, "canHide": false, "mentionedByCount": 0, "canFlag": false, "isApproved": true, "canApprove": false, "canLike": false, "likesCount": 0 } } ``` ## 插件所需字段映射 | 源字段 | 目标字段 | 说明 | |--------|----------|------| | `data[].id` | `UniqueID` | 格式: `bixin-{discussion_id}` | | `data[].attributes.title` | `Title` | 讨论标题 | | `data[].attributes.createdAt` | `Datetime` | 创建时间 | | `included[].attributes.contentHtml` | `Content` | HTML内容,需要解析提取网盘链接 | | `""` | `Channel` | 插件搜索结果Channel为空 | | `[]` | `Tags` | 标签数组(从标题或内容中提取) | | 解析的网盘链接 | `Links` | 从HTML内容中提取的网盘链接 | ## 网盘链接解析 ### HTML内容特点 - **格式**: 包含HTML标签的文本内容,需要清理HTML标签获取纯文本 - **链接**: 以``标签形式存在,但更多是纯文本格式 - **示例**: - HTML格式: `https://caiyun.139.com/w/i/2oRhbuZoZbFpi` - 纯文本格式: `https://caiyun.139.com/w/i/2oRhbuZoZbFpi` ### 支持的网盘类型(bixin专用) | 网盘类型 | 域名特征 | 示例链接 | 密码关键词 | |---------|----------|----------|------------| | **移动云盘** | `caiyun.139.com` | `https://caiyun.139.com/w/i/2oRhbuZoZbFpi` | 访问码、密码 | **重要说明**: bixin插件**只支持移动云盘**,所有链接都是`caiyun.139.com`域名,不需要处理其他网盘类型。 ### 链接解析策略(bixin专用) 1. **HTML清理**: 移除HTML标签,保留纯文本内容 2. **链接提取**: 从纯文本中提取**移动云盘链接**(只处理`caiyun.139.com`) 3. **密码匹配**: 匹配"访问码"或"密码"关键词 4. **位置关联**: 密码通常出现在链接附近的行中 ## 插件开发指导 ### 请求示例 ```go searchURL := fmt.Sprintf("https://www.bixbiy.com/api/discussions?filter[q]=%s&page[limit]=3&include=mostRelevantPost", url.QueryEscape(keyword)) ``` ### 请求头设置(参考pan666实现) ```go req.Header.Set("User-Agent", getRandomUA()) // 使用随机UA避免反爬虫 req.Header.Set("X-Forwarded-For", generateRandomIP()) // 随机IP 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") ``` ### SearchResult构建示例 ```go result := model.SearchResult{ UniqueID: fmt.Sprintf("bixin-%s", discussion.ID), Title: discussion.Attributes.Title, Content: extractTextFromHTML(post.Attributes.ContentHTML), Links: extractLinksFromHTML(post.Attributes.ContentHTML), Tags: extractTagsFromTitle(discussion.Attributes.Title), Channel: "", // 插件搜索结果Channel为空 Datetime: parseTime(discussion.Attributes.CreatedAt), } ``` ### HTML内容解析函数(参考pan666实现) ```go // 清理HTML内容(参考pan666的cleanHTML函数) func (p *BixinAsyncPlugin) cleanHTML(html string) string { // 移除
标签 html = strings.ReplaceAll(html, "
", "\n") html = strings.ReplaceAll(html, "
", "\n") html = strings.ReplaceAll(html, "
", "\n") // 移除其他HTML标签 var result strings.Builder inTag := false for _, r := range html { if r == '<' { inTag = true continue } if r == '>' { inTag = false continue } if !inTag { result.WriteRune(r) } } // 处理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, " ", " ") // 处理多行空白 lines := strings.Split(output, "\n") var cleanedLines []string for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed != "" { cleanedLines = append(cleanedLines, trimmed) } } return strings.Join(cleanedLines, "\n") } // 从文本中提取链接(参考pan666的extractLinksFromText函数) func (p *BixinAsyncPlugin) extractLinksFromText(content string) []model.Link { var allLinks []model.Link lines := strings.Split(content, "\n") // 收集所有可能的链接信息 var linkInfos []struct { link model.Link position int category string } // 收集所有可能的密码信息 var passwordInfos []struct { keyword string position int password string } // 第一遍:查找所有的链接和密码 for i, line := range lines { line = strings.TrimSpace(line) // 只检查移动云盘(bixin只支持移动云盘) if strings.Contains(line, "caiyun.139.com") { url := p.extractURLFromText(line) if url != "" { linkInfos = append(linkInfos, struct { link model.Link position int category string }{ link: model.Link{URL: url, Type: "mobile"}, position: i, category: "mobile", }) } } // 检查密码/访问码(移动云盘主要使用访问码) 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 := p.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 == "mobile" && (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 } ``` ### 辅助函数(参考pan666实现) ```go // 从文本中提取URL func (p *BixinAsyncPlugin) 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 (p *BixinAsyncPlugin) 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 } // 生成随机UA func getRandomUA() string { userAgents := []string{ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", } return userAgents[rand.Intn(len(userAgents))] } // 生成随机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 } ``` ### 时间解析函数 ```go func (p *BixinAsyncPlugin) parseTime(timeStr string) time.Time { // 解析ISO 8601格式时间 t, err := time.Parse("2006-01-02T15:04:05Z07:00", timeStr) if err != nil { return time.Now() } return t } ``` ## 数据结构定义 ### API响应结构体 ```go type BixinAPIResponse struct { Links BixinLinks `json:"links"` Data []BixinDiscussion `json:"data"` Included []BixinPost `json:"included"` } type BixinLinks struct { First string `json:"first"` Next string `json:"next"` } type BixinDiscussion struct { Type string `json:"type"` ID string `json:"id"` Attributes BixinDiscussionAttributes `json:"attributes"` Relationships BixinRelationships `json:"relationships"` } type BixinDiscussionAttributes struct { Title string `json:"title"` Slug string `json:"slug"` CommentCount int `json:"commentCount"` ParticipantCount int `json:"participantCount"` CreatedAt string `json:"createdAt"` LastPostedAt string `json:"lastPostedAt"` LastPostNumber int `json:"lastPostNumber"` IsApproved bool `json:"isApproved"` IsLocked bool `json:"isLocked"` } type BixinRelationships struct { MostRelevantPost BixinPostRef `json:"mostRelevantPost"` } type BixinPostRef struct { Data BixinPostData `json:"data"` } type BixinPostData struct { Type string `json:"type"` ID string `json:"id"` } type BixinPost struct { Type string `json:"type"` ID string `json:"id"` Attributes BixinPostAttributes `json:"attributes"` } type BixinPostAttributes struct { Number int `json:"number"` CreatedAt string `json:"createdAt"` ContentType string `json:"contentType"` ContentHTML string `json:"contentHtml"` RenderFailed bool `json:"renderFailed"` IsApproved bool `json:"isApproved"` LikesCount int `json:"likesCount"` } ``` ## 特殊处理逻辑 ### 1. 讨论与回复关联 - 通过`relationships.mostRelevantPost.data.id`关联讨论和回复 - 需要在`included`数组中查找对应的回复内容 - 一个讨论可能对应多个回复,需要处理所有相关回复 ### 2. HTML内容清理 - 移除HTML标签获取纯文本内容 - 解码HTML实体(如`<`、`>`等) - 提取链接时保留原始URL ### 3. 链接验证 - 验证链接是否为有效的网盘链接 - 过滤掉无效链接(如`javascript:`、`#`等) - 提取链接中的密码信息 ### 4. 标签提取 - 从讨论标题中提取关键词作为标签 - 可以基于内容类型、年份等信息生成标签 - 支持中文和英文标签 ## 与pan666插件的相似性 | 特性 | bixin | pan666 | 说明 | |------|-------|--------|------| | **数据源** | 论坛讨论API | 论坛讨论API | 使用相同的论坛系统 | | **API结构** | 相同 | 相同 | JSON结构完全一致 | | **链接解析** | 文本解析 | 文本解析 | 都需要从HTML清理后的文本中提取 | | **主要网盘** | 移动云盘 | 移动云盘 | 都主要提供移动云盘链接 | | **密码匹配** | 位置关联 | 位置关联 | 使用相同的密码匹配策略 | | **过滤策略** | 跳过Service层过滤 | 跳过Service层过滤 | 都使用`NewBaseAsyncPluginWithFilter` | ## 与其他插件的差异 | 特性 | bixin/pan666 | 其他插件 | 说明 | |------|-------------|----------|------| | **数据源** | 论坛讨论API | 网盘搜索API | 需要解析HTML内容 | | **链接格式** | 纯文本格式 | 直接URL字符串 | 需要从文本中提取 | | **内容结构** | 讨论+回复 | 直接资源信息 | 需要关联处理 | | **链接验证** | 必需 | 可选 | 论坛可能包含无效链接 | | **过滤策略** | 跳过Service层过滤 | 启用Service层过滤 | 论坛内容需要宽泛搜索 | ## 注意事项 1. **HTML解析**: 需要正确处理HTML标签和实体,参考pan666的cleanHTML函数 2. **链接提取**: 主要从纯文本中提取链接,而非HTML标签 3. **内容关联**: 需要将讨论和回复内容正确关联 4. **链接验证**: 论坛内容可能包含无效链接,需要过滤 5. **时间解析**: 使用ISO 8601格式解析时间 6. **错误处理**: API可能返回空数据或格式错误 7. **反爬虫**: 使用随机UA和IP避免反爬虫检测 8. **密码匹配**: 使用位置关联策略匹配密码和链接 ## 开发建议 - **优先级设置**: 建议设置为优先级3,数据质量一般 - **Service层过滤**: 跳过Service层过滤,使用`NewBaseAsyncPluginWithFilter("bixin", 3, true)` - **HTML处理**: 重点处理HTML内容的解析和清理,参考pan666实现 - **链接提取**: 实现robust的链接提取和验证机制,**只处理移动云盘**(caiyun.139.com) - **缓存策略**: 建议使用较短的缓存TTL,论坛内容更新频繁 - **错误日志**: 详细记录HTML解析和链接提取的错误信息 - **基于pan666**: 可以直接基于pan666插件进行修改,主要更改API URL和插件名称 ## API调用示例 ### 搜索请求示例 ```bash curl "https://www.bixbiy.com/api/discussions?filter[q]=凡人修仙传&page[limit]=3&include=mostRelevantPost" \ -H "Referer: https://www.bixbiy.com/" \ -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" ``` ### 完整流程示例 1. **发送搜索请求**: 获取讨论列表和回复内容 2. **解析讨论数据**: 提取标题、时间等基本信息 3. **关联回复内容**: 通过ID关联讨论和回复 4. **清理HTML内容**: 移除HTML标签,获取纯文本 5. **提取网盘链接**: 从纯文本中提取**移动云盘链接**(只处理caiyun.139.com) 6. **匹配密码**: 使用位置关联策略匹配密码和链接 7. **验证链接有效性**: 过滤无效链接 8. **构建搜索结果**: 转换为PanSou标准格式 9. **返回结果**: 包含标题、内容、链接等信息 ### 插件实现建议 ```go // 基于pan666插件进行修改 func NewBixinAsyncPlugin() *BixinAsyncPlugin { return &BixinAsyncPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("bixin", 3, true), // 跳过Service层过滤 retries: MaxRetries, } } // 主要修改点: // 1. 更改API URL: "https://www.bixbiy.com/api/discussions" // 2. 更改插件名称: "bixin" // 3. 简化链接提取:只处理移动云盘(caiyun.139.com) // 4. 简化密码匹配:只匹配"访问码"和"密码"关键词 // 5. 保持相同的HTML解析逻辑 ``` ================================================ FILE: plugin/cldi/cldi.go ================================================ package cldi import ( "context" "fmt" "io" "net/http" "net/url" "regexp" "strings" "sync" "time" "github.com/PuerkitoBio/goquery" "pansou/model" "pansou/plugin" ) type CldiPlugin struct { *plugin.BaseAsyncPlugin } const ( // 并发数限制 MaxConcurrency = 10 // 最大搜索页数 MaxPages = 5 ) var ( // 广告清理正则表达式 adRegex = regexp.MustCompile(`【[^】]*】`) // 文件大小和名称分离正则 fileSizeRegex = regexp.MustCompile(`^(.+?) ([^<]+)$`) // 各种数字提取正则 numberRegex = regexp.MustCompile(`\d+`) ) func init() { p := &CldiPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("cldi", 3, true), // 磁力搜索插件,跳过Service层过滤 } plugin.RegisterGlobalPlugin(p) } // Search 执行搜索并返回结果 func (p *CldiPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { result, err := p.SearchWithResult(keyword, ext) if err != nil { return nil, err } return result.Results, nil } // SearchWithResult 执行搜索并返回包含IsFinal标记的结果 func (p *CldiPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) } // searchImpl 实际的搜索实现 func (p *CldiPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { // 1. 首先搜索第一页 firstPageResults, err := p.searchPage(client, keyword, 1) if err != nil { return nil, fmt.Errorf("[%s] 搜索第一页失败: %w", p.Name(), err) } // 存储所有结果 var allResults []model.SearchResult allResults = append(allResults, firstPageResults...) // 2. 并发搜索其他页面(第2页到第5页) if MaxPages > 1 { var wg sync.WaitGroup var mu sync.Mutex // 使用信号量控制并发数 semaphore := make(chan struct{}, MaxConcurrency) // 存储每页结果 pageResults := make(map[int][]model.SearchResult) for page := 2; page <= MaxPages; page++ { wg.Add(1) go func(pageNum int) { defer wg.Done() // 获取信号量 semaphore <- struct{}{} defer func() { <-semaphore }() // 添加小延迟避免过于频繁的请求 time.Sleep(time.Duration(pageNum%3) * 100 * time.Millisecond) currentPageResults, err := p.searchPage(client, keyword, pageNum) if err == nil && len(currentPageResults) > 0 { mu.Lock() pageResults[pageNum] = currentPageResults mu.Unlock() } }(page) } wg.Wait() // 按页码顺序合并所有页面的结果 for page := 2; page <= MaxPages; page++ { if results, exists := pageResults[page]; exists { allResults = append(allResults, results...) } } } // 3. 关键词过滤 return plugin.FilterResultsByKeyword(allResults, keyword), nil } // searchPage 搜索指定页面 func (p *CldiPlugin) searchPage(client *http.Client, keyword string, page int) ([]model.SearchResult, error) { // 构建搜索URL (分类=0全部, 排序=2按添加时间) searchURL := fmt.Sprintf("https://wvmzbxki.1122132.xyz/search-%s-0-2-%d.html", url.QueryEscape(keyword), page) // 创建带超时的上下文 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // 创建请求 req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err) } // 设置请求头 p.setRequestHeaders(req) // 发送请求 resp, err := p.doRequestWithRetry(req, client) if err != nil { return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err) } defer resp.Body.Close() // 检查状态码 if resp.StatusCode != 200 { return nil, fmt.Errorf("[%s] 请求返回状态码: %d", p.Name(), resp.StatusCode) } // 读取响应 body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("[%s] 读取响应失败: %w", p.Name(), err) } // 解析HTML doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body))) if err != nil { return nil, fmt.Errorf("[%s] HTML解析失败: %w", p.Name(), err) } // 提取搜索结果 return p.extractSearchResults(doc), nil } // setRequestHeaders 设置请求头 func (p *CldiPlugin) setRequestHeaders(req *http.Request) { 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") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") req.Header.Set("Connection", "keep-alive") req.Header.Set("Cache-Control", "no-cache") req.Header.Set("Pragma", "no-cache") req.Header.Set("Referer", "https://wvmzbxki.1122132.xyz/") } // doRequestWithRetry 带重试机制的HTTP请求 func (p *CldiPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) { maxRetries := 3 var lastErr error for i := 0; i < maxRetries; i++ { if i > 0 { // 指数退避重试 backoff := time.Duration(1< 0 { results = append(results, result) } }) return results } // parseSearchResult 解析单个搜索结果 func (p *CldiPlugin) parseSearchResult(s *goquery.Selection) model.SearchResult { result := model.SearchResult{ Channel: "", // 插件搜索结果必须为空字符串 Datetime: time.Now(), } // 提取标题和分类 titleSection := s.Find(".title h3") // 提取分类 category := strings.TrimSpace(titleSection.Find("span").First().Text()) if category != "" { result.Tags = []string{p.mapCategory(category)} } // 提取标题 titleLink := titleSection.Find("a") title := strings.TrimSpace(titleLink.Text()) result.Title = p.cleanTitle(title) // 提取磁力链接和元数据 p.extractMagnetInfo(s, &result) // 提取文件列表作为内容 p.extractFileList(s, &result) // 生成唯一ID result.UniqueID = fmt.Sprintf("%s-%d", p.Name(), time.Now().UnixNano()) return result } // extractMagnetInfo 提取磁力链接和元数据 func (p *CldiPlugin) extractMagnetInfo(s *goquery.Selection, result *model.SearchResult) { sbar := s.Find(".sbar") // 提取磁力链接 magnetLink, exists := sbar.Find("a[href^='magnet:']").Attr("href") if exists && magnetLink != "" { result.Links = []model.Link{{ Type: "magnet", URL: magnetLink, }} } // 提取添加时间 sbar.Find("span").Each(func(i int, span *goquery.Selection) { text := span.Text() if strings.Contains(text, "添加时间:") { timeStr := strings.TrimSpace(span.Find("b").Text()) if timeStr != "" { if parsedTime, err := time.Parse("2006-01-02", timeStr); err == nil { result.Datetime = parsedTime } } } }) } // extractFileList 提取文件列表 func (p *CldiPlugin) extractFileList(s *goquery.Selection, result *model.SearchResult) { var fileList []string s.Find(".slist ul li").Each(func(i int, li *goquery.Selection) { // 获取原始HTML以解析文件名和大小 html, _ := li.Html() // 使用正则表达式分离文件名和大小 if matches := fileSizeRegex.FindStringSubmatch(html); len(matches) == 3 { fileName := strings.TrimSpace(matches[1]) fileSize := strings.TrimSpace(matches[2]) if fileName != "" && fileSize != "" { fileList = append(fileList, fmt.Sprintf("%s (%s)", fileName, fileSize)) } } else { // 回退方案:直接使用文本内容 text := strings.TrimSpace(li.Text()) if text != "" { fileList = append(fileList, text) } } }) if len(fileList) > 0 { result.Content = strings.Join(fileList, "\n") } } // mapCategory 映射分类 func (p *CldiPlugin) mapCategory(category string) string { // 移除方括号 category = strings.Trim(category, "[]") switch category { case "影视": return "影视" case "音乐": return "音乐" case "图像": return "图像" case "文档书籍": return "文档" case "压缩文件": return "压缩包" case "安装包": return "软件" case "其他": return "其他" default: return "其他" } } // cleanTitle 清理标题中的广告内容 func (p *CldiPlugin) cleanTitle(title string) string { // 移除【】内的广告内容 cleaned := adRegex.ReplaceAllString(title, "") // 清理多余的空格 cleaned = strings.TrimSpace(cleaned) cleaned = regexp.MustCompile(`\s+`).ReplaceAllString(cleaned, " ") return cleaned } ================================================ FILE: plugin/cldi/html结构分析.md ================================================ # CLDI (磁力帝) HTML结构分析 ## 网站信息 - **网站名称**: 磁力帝 - **域名**: cldcld.cc (通过动态域名访问) - **类型**: 磁力搜索引擎 - **特点**: 专门搜索BT种子和磁力链接 ## 搜索页面结构 ### 1. 搜索URL模式 ``` https://[域名]/search-[关键词]-[分类]-[排序]-[页码].html 示例: https://wvmzbxki.1122132.xyz/search-%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0-0-2-1.html 参数说明: - 关键词: URL编码的搜索关键词 - 分类: 0=全部, 1=影视, 2=音乐, 3=图像, 4=文档书籍, 5=压缩文件, 6=安装包, 7=其他 - 排序: 0=相关程度, 1=文件大小, 2=添加时间, 3=热度, 4=最近访问 - 页码: 从1开始 ``` ### 2. 搜索结果容器 - **父容器**: `.tbox` - **结果项**: `.ssbox` (每个搜索结果) ### 3. 单个搜索结果结构 #### 标题区域 (.title) ```html 提取要素: - 分类: span 文本内容 (如 "[影视]") - 详情页链接: a 的 href 属性 (用于构造磁力链接) - 标题: a 的文本内容 (需要去掉广告标记) ``` #### 文件列表 (.slist) ```html
  • 凡人修仙传.The.Immortal.Ascension.S01E08.2025.2160p.WEB-DL.H265.60FPS.AAC-DeePTV.mp4 2.7 GB
  • 凡人修仙传.The.Immortal.Ascension.S01E01.2025.2160p.WEB-DL.H265.60FPS.AAC-DeePTV.mp4 2.4 GB
提取要素: - 文件名: li 文本内容 (去掉   后的内容) - 文件大小: span.lightColor 文本内容 ``` #### 元数据栏 (.sbar) ```html
[磁力链接] 添加时间:2025-08-19 大小:54.3 GB 最近下载:2025-08-20 热度:73
提取要素: - 磁力链接: a[href^="magnet:"] 的 href 属性 - 添加时间: "添加时间:" 后的 b 标签文本 - 总大小: "大小:" 后的 b 标签文本 - 最近下载: "最近下载:" 后的 b 标签文本 - 热度: "热度:" 后的 b 标签文本 ``` ## CSS选择器总结 | 数据项 | CSS选择器 | 提取方式 | |--------|-----------|----------| | 搜索结果列表 | `.tbox .ssbox` | 遍历所有结果项 | | 分类标签 | `.title h3 span` | 文本内容,去掉 `[]` | | 标题 | `.title h3 a` | 文本内容,需要清理广告 | | 详情页链接 | `.title h3 a` | href 属性 | | 文件列表 | `.slist ul li` | 文本内容,分割文件名和大小 | | 磁力链接 | `.sbar a[href^="magnet:"]` | href 属性 | | 添加时间 | `.sbar span:contains("添加时间:") b` | 文本内容 | | 总大小 | `.sbar span:contains("大小:") b` | 文本内容 | | 热度 | `.sbar span:contains("热度:") b` | 文本内容 | ## 实现要点 ### 1. 标题清理 - 需要移除 `【xxx】` 格式的广告标记 - 示例: `【不太灵影视 www.2BT0.com】凡人修仙传[...]` → `凡人修仙传[...]` ### 2. 分类映射 ``` [影视] → 影视 [音乐] → 音乐 [图像] → 图像 [文档书籍] → 文档 [压缩文件] → 压缩包 [安装包] → 软件 [其他] → 其他 ``` ### 3. 文件列表解析 - 每个 li 包含: `文件名 大小` - 需要分离文件名和大小信息 ### 4. 时间格式 - 格式: `YYYY-MM-DD` - 需要转换为标准时间格式 ### 5. 磁力链接处理 - 直接从搜索页提取,无需访问详情页 - 链接格式: `magnet:?xt=urn:btih:[HASH]` ## 搜索参数 - 支持中文关键词 (需要URL编码) - 默认使用全部分类 (0) 和按添加时间排序 (2) - 支持分页 (从第1页开始) ================================================ FILE: plugin/clmao/clmao.go ================================================ package clmao import ( "context" "fmt" "io" "net/http" "net/url" "regexp" "strings" "sync" "time" "github.com/PuerkitoBio/goquery" "pansou/model" "pansou/plugin" ) // 常量定义 const ( // 基础URL BaseURL = "https://www.8800492.xyz" // 搜索URL格式:/search-{keyword}-{category}-{sort}-{page}.html SearchURL = BaseURL + "/search-%s-0-2-%d.html" // 默认参数 MaxRetries = 3 TimeoutSeconds = 30 // 并发控制参数 MaxConcurrency = 10 // 最大并发数 MaxPages = 5 // 最大搜索页数 ) // 预编译的正则表达式 var ( // 磁力链接正则 magnetLinkRegex = regexp.MustCompile(`magnet:\?xt=urn:btih:[0-9a-fA-F]{40}[^"'\s]*`) // 文件大小正则 fileSizeRegex = regexp.MustCompile(`(\d+\.?\d*)\s*(B|KB|MB|GB|TB)`) // 数字提取正则 numberRegex = regexp.MustCompile(`\d+`) ) // 常用UA列表 var userAgents = []string{ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0", } // ClmaoPlugin 磁力猫搜索插件 type ClmaoPlugin struct { *plugin.BaseAsyncPlugin } // NewClmaoPlugin 创建新的磁力猫插件实例 func NewClmaoPlugin() *ClmaoPlugin { return &ClmaoPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("clmao", 3, true), } } // Name 返回插件名称 func (p *ClmaoPlugin) Name() string { return "clmao" } // DisplayName 返回插件显示名称 func (p *ClmaoPlugin) DisplayName() string { return "磁力猫" } // Description 返回插件描述 func (p *ClmaoPlugin) Description() string { return "磁力猫 - 磁力链接搜索引擎" } // Search 执行搜索并返回结果(兼容性方法) func (p *ClmaoPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { result, err := p.SearchWithResult(keyword, ext) if err != nil { return nil, err } return result.Results, nil } // SearchWithResult 执行搜索并返回包含IsFinal标记的结果 func (p *ClmaoPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) } // searchImpl 实际的搜索实现 func (p *ClmaoPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { // 1. 首先搜索第一页 firstPageResults, err := p.searchPage(client, keyword, 1) if err != nil { return nil, fmt.Errorf("[%s] 搜索第一页失败: %w", p.Name(), err) } // 存储所有结果 var allResults []model.SearchResult allResults = append(allResults, firstPageResults...) // 2. 并发搜索其他页面(第2页到第5页) if MaxPages > 1 { var wg sync.WaitGroup var mu sync.Mutex // 使用信号量控制并发数 semaphore := make(chan struct{}, MaxConcurrency) // 存储每页结果 pageResults := make(map[int][]model.SearchResult) for page := 2; page <= MaxPages; page++ { wg.Add(1) go func(pageNum int) { defer wg.Done() // 获取信号量 semaphore <- struct{}{} defer func() { <-semaphore }() // 添加小延迟避免过于频繁的请求 time.Sleep(time.Duration(pageNum%3) * 100 * time.Millisecond) currentPageResults, err := p.searchPage(client, keyword, pageNum) if err == nil && len(currentPageResults) > 0 { mu.Lock() pageResults[pageNum] = currentPageResults mu.Unlock() } }(page) } wg.Wait() // 按页码顺序合并所有页面的结果 for page := 2; page <= MaxPages; page++ { if results, exists := pageResults[page]; exists { allResults = append(allResults, results...) } } } // 3. 关键词过滤 searchKeyword := keyword if searchParam, ok := ext["search"]; ok { if searchStr, ok := searchParam.(string); ok && searchStr != "" { searchKeyword = searchStr } } return plugin.FilterResultsByKeyword(allResults, searchKeyword), nil } // searchPage 搜索指定页面 func (p *ClmaoPlugin) searchPage(client *http.Client, keyword string, page int) ([]model.SearchResult, error) { // URL编码关键词 encodedKeyword := url.QueryEscape(keyword) searchURL := fmt.Sprintf(SearchURL, encodedKeyword, page) // 创建带超时的上下文 ctx, cancel := context.WithTimeout(context.Background(), TimeoutSeconds*time.Second) defer cancel() // 创建请求 req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err) } // 设置请求头 p.setRequestHeaders(req) // 发送HTTP请求 resp, err := p.doRequestWithRetry(req, client) if err != nil { return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err) } defer resp.Body.Close() // 检查状态码 if resp.StatusCode != 200 { return nil, fmt.Errorf("[%s] 请求返回状态码: %d", p.Name(), resp.StatusCode) } // 读取响应体内容 body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("[%s] 读取响应失败: %w", p.Name(), err) } // 解析HTML doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body))) if err != nil { return nil, fmt.Errorf("[%s] HTML解析失败: %w", p.Name(), err) } // 提取搜索结果 return p.extractSearchResults(doc), nil } // extractSearchResults 提取搜索结果 func (p *ClmaoPlugin) extractSearchResults(doc *goquery.Document) []model.SearchResult { var results []model.SearchResult // 查找所有搜索结果 doc.Find(".tbox .ssbox").Each(func(i int, s *goquery.Selection) { result := p.parseSearchResult(s) if result.Title != "" && len(result.Links) > 0 { results = append(results, result) } }) return results } // parseSearchResult 解析单个搜索结果 func (p *ClmaoPlugin) parseSearchResult(s *goquery.Selection) model.SearchResult { result := model.SearchResult{ Channel: "", // 插件搜索结果必须为空字符串 Datetime: time.Now(), } // 提取标题 titleSection := s.Find(".title h3") titleLink := titleSection.Find("a") title := strings.TrimSpace(titleLink.Text()) result.Title = p.cleanTitle(title) // 提取分类作为标签 category := strings.TrimSpace(titleSection.Find("span").Text()) if category != "" { result.Tags = []string{p.mapCategory(category)} } // 提取磁力链接和元数据 p.extractMagnetInfo(s, &result) // 提取文件列表作为内容 p.extractFileList(s, &result) // 生成唯一ID result.UniqueID = fmt.Sprintf("%s-%d", p.Name(), time.Now().UnixNano()) return result } // extractMagnetInfo 提取磁力链接和元数据 func (p *ClmaoPlugin) extractMagnetInfo(s *goquery.Selection, result *model.SearchResult) { sbar := s.Find(".sbar") // 提取磁力链接 magnetLink, _ := sbar.Find("a[href^='magnet:']").Attr("href") if magnetLink != "" { link := model.Link{ Type: "magnet", URL: magnetLink, } result.Links = []model.Link{link} } // 提取元数据并添加到内容中 var metadata []string sbar.Find("span").Each(func(i int, span *goquery.Selection) { text := strings.TrimSpace(span.Text()) if strings.Contains(text, "添加时间:") || strings.Contains(text, "大小:") || strings.Contains(text, "热度:") { metadata = append(metadata, text) } }) if len(metadata) > 0 { if result.Content != "" { result.Content += "\n\n" } result.Content += strings.Join(metadata, " | ") } } // extractFileList 提取文件列表 func (p *ClmaoPlugin) extractFileList(s *goquery.Selection, result *model.SearchResult) { var files []string s.Find(".slist ul li").Each(func(i int, li *goquery.Selection) { text := strings.TrimSpace(li.Text()) if text != "" { files = append(files, text) } }) if len(files) > 0 { if result.Content != "" { result.Content += "\n\n文件列表:\n" } else { result.Content = "文件列表:\n" } result.Content += strings.Join(files, "\n") } } // mapCategory 映射分类 func (p *ClmaoPlugin) mapCategory(category string) string { switch category { case "[影视]": return "video" case "[音乐]": return "music" case "[图像]": return "image" case "[文档书籍]": return "document" case "[压缩文件]": return "archive" case "[安装包]": return "software" case "[其他]": return "others" default: return "others" } } // cleanTitle 清理标题 func (p *ClmaoPlugin) cleanTitle(title string) string { // 移除【】之间的广告内容 title = regexp.MustCompile(`【[^】]*】`).ReplaceAllString(title, "") // 移除[]之间的内容(如有需要) title = regexp.MustCompile(`\[[^\]]*\]`).ReplaceAllString(title, "") // 移除多余的空格 title = regexp.MustCompile(`\s+`).ReplaceAllString(title, " ") return strings.TrimSpace(title) } // setRequestHeaders 设置请求头 func (p *ClmaoPlugin) setRequestHeaders(req *http.Request) { // 使用第一个稳定的UA ua := userAgents[0] req.Header.Set("User-Agent", ua) req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") // 暂时不使用压缩编码,避免解压问题 // req.Header.Set("Accept-Encoding", "gzip, deflate") req.Header.Set("Connection", "keep-alive") req.Header.Set("Upgrade-Insecure-Requests", "1") req.Header.Set("Cache-Control", "no-cache") req.Header.Set("Pragma", "no-cache") } // doRequestWithRetry 带重试的HTTP请求 func (p *ClmaoPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) { var lastErr error for i := 0; i < MaxRetries; i++ { resp, err := client.Do(req) if err == nil { return resp, nil } lastErr = err if i < MaxRetries-1 { time.Sleep(time.Duration(i+1) * time.Second) } } return nil, fmt.Errorf("请求失败,已重试%d次: %w", MaxRetries, lastErr) } // init 注册插件 func init() { plugin.RegisterGlobalPlugin(NewClmaoPlugin()) } ================================================ FILE: plugin/clmao/html结构分析.md ================================================ # Clmao (磁力猫) HTML结构分析 ## 网站信息 - **网站名称**: 磁力猫 - 磁力搜索引擎 - **基础URL**: https://www.8800492.xyz/ - **功能**: BT种子磁力链接搜索 - **搜索URL格式**: `/search-{keyword}-{category}-{sort}-{page}.html` ## 搜索页面结构 ### 1. 搜索URL参数说明 ``` https://www.8800492.xyz/search-%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0-0-0-1.html ^关键词(URL编码) ^分类 ^排序 ^页码 ``` **参数说明**: - `keyword`: URL编码的搜索关键词 - `category`: 分类筛选 (0=全部, 1=影视, 2=音乐, 3=图像, 4=文档书籍, 5=压缩文件, 6=安装包, 7=其他) - `sort`: 排序方式 (0=相关程度, 1=文件大小, 2=添加时间, 3=热度, 4=最近访问) - `page`: 页码 (从1开始) ### 2. 搜索结果容器 ```html
``` ### 3. 单个搜索结果结构 #### 标题区域 ```html ``` #### 文件列表区域 ```html
  • rw.mp4 145.5 MB
``` #### 信息栏区域 ```html
[磁力链接] 添加时间:2022-06-28 大小:145.5 MB 最近下载:2025-08-19 热度:2348
``` ### 4. 分页区域 ```html
共61页 上一页 1 2 下一页
``` ## 数据提取要点 ### 需要提取的信息 1. **搜索结果基本信息**: - 标题: `.title h3 a` 的文本内容 - 分类: `.title h3 span` 的文本内容 - 详情页链接: `.title h3 a` 的 `href` 属性 2. **磁力链接信息**: - 磁力链接: `.sbar a[href^="magnet:"]` 的 `href` 属性 - 文件大小: `.sbar .cpill` 的文本内容 - 添加时间: `.sbar` 中 "添加时间:" 后的 `` 标签内容 - 热度: `.sbar` 中 "热度:" 后的 `` 标签内容 3. **文件列表**: - 文件名和大小: `.slist ul li` 的文本内容 ### CSS选择器 ```css /* 搜索结果容器 */ .tbox .ssbox /* 标题和分类 */ .title h3 span /* 分类 */ .title h3 a /* 标题和详情链接 */ /* 磁力链接 */ .sbar a[href^="magnet:"] /* 文件信息 */ .slist ul li /* 元数据 */ .sbar span b /* 时间、大小、热度等 */ ``` ## 特殊处理 ### 1. 关键词高亮 搜索关键词在结果中用 `` 标签高亮显示 ### 2. 文件大小格式 文件大小格式多样: `145.5 MB`、`854.2 MB`、`41.5 GB` 等 ### 3. 磁力链接格式 标准磁力链接格式: `magnet:?xt=urn:btih:{40位哈希值}` ### 4. 分类映射 - [影视] → movie/video - [音乐] → music - [图像] → image - [文档书籍] → document - [压缩文件] → archive - [安装包] → software - [其他] → others ## 请求头要求 建议设置常见的浏览器请求头: - User-Agent: 现代浏览器UA - Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 - Accept-Language: zh-CN,zh;q=0.9,en;q=0.8 ## 注意事项 1. 网站可能有反爬虫机制,需要适当的请求间隔 2. 搜索关键词需要进行URL编码 3. 磁力链接是直接可用的,无需额外处理 4. 部分结果可能包含大量无关文件,需要进行过滤 5. 网站域名可能会变更,需要支持域名更新 ================================================ FILE: plugin/clxiong/clxiong.go ================================================ package clxiong import ( "fmt" "io" "log" "net/http" "net/url" "regexp" "strings" "sync" "time" "github.com/PuerkitoBio/goquery" "pansou/model" "pansou/plugin" ) const ( BaseURL = "https://www.cilixiong.org" SearchURL = "https://www.cilixiong.org/e/search/index.php" UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" MaxRetries = 3 RetryDelay = 2 * time.Second MaxResults = 30 ) // DetailPageInfo 详情页信息结构体 type DetailPageInfo struct { MagnetLinks []model.Link UpdateTime time.Time Title string FileNames []string // 所有文件的名称,与磁力链接对应 } // ClxiongPlugin 磁力熊插件 type ClxiongPlugin struct { *plugin.BaseAsyncPlugin debugMode bool } func init() { p := &ClxiongPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("clxiong", 2, true), debugMode: false, // 开启调试模式检查磁力链接提取问题 } plugin.RegisterGlobalPlugin(p) } // Search 搜索接口实现 func (p *ClxiongPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { result, err := p.SearchWithResult(keyword, ext) if err != nil { return nil, err } return result.Results, nil } // SearchWithResult 搜索并返回详细结果 func (p *ClxiongPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (*model.PluginSearchResult, error) { if p.debugMode { log.Printf("[CLXIONG] 开始搜索: %s", keyword) } // 第一步:POST搜索获取searchid searchID, err := p.getSearchID(keyword) if err != nil { if p.debugMode { log.Printf("[CLXIONG] 获取searchid失败: %v", err) } return nil, fmt.Errorf("获取searchid失败: %v", err) } // 第二步:GET搜索结果 results, err := p.getSearchResults(searchID, keyword) if err != nil { if p.debugMode { log.Printf("[CLXIONG] 获取搜索结果失败: %v", err) } return nil, err } // 第三步:同步获取详情页磁力链接 results = p.fetchDetailLinksSync(results) if p.debugMode { log.Printf("[CLXIONG] 搜索完成,获得 %d 个结果", len(results)) } // 应用关键词过滤 filteredResults := plugin.FilterResultsByKeyword(results, keyword) return &model.PluginSearchResult{ Results: filteredResults, IsFinal: true, Timestamp: time.Now(), Source: p.Name(), Message: fmt.Sprintf("找到 %d 个结果", len(filteredResults)), }, nil } // getSearchID 第一步:POST搜索获取searchid func (p *ClxiongPlugin) getSearchID(keyword string) (string, error) { if p.debugMode { log.Printf("[CLXIONG] 正在获取searchid...") } client := &http.Client{ Timeout: 30 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { // 不自动跟随重定向,我们需要手动处理 return http.ErrUseLastResponse }, } // 准备POST数据 formData := url.Values{} formData.Set("classid", "1,2") // 1=电影,2=剧集 formData.Set("show", "title") // 搜索字段 formData.Set("tempid", "1") // 模板ID formData.Set("keyboard", keyword) // 搜索关键词 req, err := http.NewRequest("POST", SearchURL, strings.NewReader(formData.Encode())) if err != nil { return "", err } req.Header.Set("User-Agent", UserAgent) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Referer", BaseURL+"/") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") var resp *http.Response var lastErr error // 重试机制 for i := 0; i < MaxRetries; i++ { resp, lastErr = client.Do(req) if lastErr == nil && (resp.StatusCode == 302 || resp.StatusCode == 301) { break } if resp != nil { resp.Body.Close() } if i < MaxRetries-1 { time.Sleep(RetryDelay) } } if lastErr != nil { return "", lastErr } defer resp.Body.Close() // 检查重定向响应 if resp.StatusCode != 302 && resp.StatusCode != 301 { return "", fmt.Errorf("期望302重定向,但得到状态码: %d", resp.StatusCode) } // 从Location头部提取searchid location := resp.Header.Get("Location") if location == "" { return "", fmt.Errorf("重定向响应中没有Location头部") } // 解析searchid searchID := p.extractSearchIDFromLocation(location) if searchID == "" { return "", fmt.Errorf("无法从Location中提取searchid: %s", location) } if p.debugMode { log.Printf("[CLXIONG] 获取到searchid: %s", searchID) } return searchID, nil } // extractSearchIDFromLocation 从Location头部提取searchid func (p *ClxiongPlugin) extractSearchIDFromLocation(location string) string { // location格式: "result/?searchid=7549" re := regexp.MustCompile(`searchid=(\d+)`) matches := re.FindStringSubmatch(location) if len(matches) > 1 { return matches[1] } return "" } // getSearchResults 第二步:GET搜索结果 func (p *ClxiongPlugin) getSearchResults(searchID, keyword string) ([]model.SearchResult, error) { if p.debugMode { log.Printf("[CLXIONG] 正在获取搜索结果,searchid: %s", searchID) } // 构建结果页URL resultURL := fmt.Sprintf("%s/e/search/result/?searchid=%s", BaseURL, searchID) client := &http.Client{Timeout: 30 * time.Second} req, err := http.NewRequest("GET", resultURL, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", UserAgent) req.Header.Set("Referer", BaseURL+"/") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") var resp *http.Response var lastErr error // 重试机制 for i := 0; i < MaxRetries; i++ { resp, lastErr = client.Do(req) if lastErr == nil && resp.StatusCode == 200 { break } if resp != nil { resp.Body.Close() } if i < MaxRetries-1 { time.Sleep(RetryDelay) } } if lastErr != nil { return nil, lastErr } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("搜索结果请求失败,状态码: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return p.parseSearchResults(string(body)) } // parseSearchResults 解析搜索结果页面 func (p *ClxiongPlugin) parseSearchResults(html string) ([]model.SearchResult, error) { doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) if err != nil { return nil, err } var results []model.SearchResult // 查找搜索结果项 doc.Find(".row.row-cols-2.row-cols-lg-4 .col").Each(func(i int, s *goquery.Selection) { if i >= MaxResults { return // 限制结果数量 } // 提取详情页链接 linkEl := s.Find("a[href*='/drama/'], a[href*='/movie/']") if linkEl.Length() == 0 { return // 跳过无链接的项 } detailPath, exists := linkEl.Attr("href") if !exists || detailPath == "" { return } // 构建完整的详情页URL detailURL := BaseURL + detailPath // 提取标题 title := strings.TrimSpace(linkEl.Find("h2.h4").Text()) if title == "" { return // 跳过无标题的项 } // 提取评分 rating := strings.TrimSpace(s.Find(".rank").Text()) // 提取年份 year := strings.TrimSpace(s.Find(".small").Last().Text()) // 提取海报图片 poster := "" cardImg := s.Find(".card-img") if cardImg.Length() > 0 { if style, exists := cardImg.Attr("style"); exists { poster = p.extractImageFromStyle(style) } } // 构建内容信息 var contentParts []string if rating != "" { contentParts = append(contentParts, "评分: "+rating) } if year != "" { contentParts = append(contentParts, "年份: "+year) } if poster != "" { contentParts = append(contentParts, "海报: "+poster) } // 添加详情页链接到content中,供后续提取磁力链接使用 contentParts = append(contentParts, "详情页: "+detailURL) content := strings.Join(contentParts, " | ") // 生成唯一ID uniqueID := p.generateUniqueID(detailPath) result := model.SearchResult{ Title: title, Content: content, Channel: "", // 插件搜索结果必须为空 Tags: []string{"磁力链接", "影视"}, Datetime: time.Now(), // 搜索时间 Links: []model.Link{}, // 初始为空,后续异步获取 UniqueID: uniqueID, } results = append(results, result) }) if p.debugMode { log.Printf("[CLXIONG] 解析到 %d 个搜索结果", len(results)) } return results, nil } // extractImageFromStyle 从style属性中提取背景图片URL func (p *ClxiongPlugin) extractImageFromStyle(style string) string { // style格式: "background-image: url('https://i.nacloud.cc/2024/12154.webp');" re := regexp.MustCompile(`url\(['"]?([^'"]+)['"]?\)`) matches := re.FindStringSubmatch(style) if len(matches) > 1 { return matches[1] } return "" } // fetchDetailLinksSync 同步获取详情页磁力链接 func (p *ClxiongPlugin) fetchDetailLinksSync(results []model.SearchResult) []model.SearchResult { if len(results) == 0 { return results } if p.debugMode { log.Printf("[CLXIONG] 开始同步获取 %d 个详情页的磁力链接", len(results)) } // 使用WaitGroup确保所有请求完成后再返回 var wg sync.WaitGroup var mu sync.Mutex // 保护results切片的互斥锁 var additionalResults []model.SearchResult // 存储额外创建的搜索结果 // 限制并发数,避免过多请求 semaphore := make(chan struct{}, 5) // 最多5个并发请求 for i := range results { wg.Add(1) go func(index int) { defer wg.Done() // 获取信号量 semaphore <- struct{}{} defer func() { <-semaphore }() detailURL := p.extractDetailURLFromContent(results[index].Content) if detailURL != "" { detailInfo := p.fetchDetailPageInfo(detailURL, results[index].Title) if detailInfo != nil && len(detailInfo.MagnetLinks) > 0 { // 为每个磁力链接创建独立的搜索结果,这样每个链接都有自己的note baseResult := results[index] // 第一个链接更新原结果 if len(detailInfo.FileNames) > 0 { results[index].Title = fmt.Sprintf("%s-%s", baseResult.Title, detailInfo.FileNames[0]) } results[index].Links = []model.Link{detailInfo.MagnetLinks[0]} if !detailInfo.UpdateTime.IsZero() { results[index].Datetime = detailInfo.UpdateTime } // 其他链接创建新的搜索结果 var newResults []model.SearchResult for i := 1; i < len(detailInfo.MagnetLinks); i++ { newResult := model.SearchResult{ MessageID: fmt.Sprintf("%s-%d", baseResult.MessageID, i+1), UniqueID: fmt.Sprintf("%s-%d", baseResult.UniqueID, i+1), Channel: baseResult.Channel, Content: baseResult.Content, Tags: baseResult.Tags, Images: baseResult.Images, Links: []model.Link{detailInfo.MagnetLinks[i]}, } // 设置独特的标题和时间 if i < len(detailInfo.FileNames) { newResult.Title = fmt.Sprintf("%s-%s", baseResult.Title, detailInfo.FileNames[i]) } else { newResult.Title = baseResult.Title } if !detailInfo.UpdateTime.IsZero() { newResult.Datetime = detailInfo.UpdateTime } else { newResult.Datetime = baseResult.Datetime } newResults = append(newResults, newResult) } // 使用锁保护切片的修改 if len(newResults) > 0 { mu.Lock() additionalResults = append(additionalResults, newResults...) mu.Unlock() } if p.debugMode { log.Printf("[CLXIONG] 为结果 %d 获取到 %d 个磁力链接,创建了 %d 个搜索结果", index+1, len(detailInfo.MagnetLinks), len(detailInfo.MagnetLinks)) } } } }(i) } // 等待所有goroutine完成 wg.Wait() // 合并额外创建的搜索结果 results = append(results, additionalResults...) if p.debugMode { totalLinks := 0 for _, result := range results { totalLinks += len(result.Links) } log.Printf("[CLXIONG] 所有磁力链接获取完成,共获得 %d 个磁力链接,总搜索结果 %d 个", totalLinks, len(results)) } return results } // extractDetailURLFromContent 从content中提取详情页URL func (p *ClxiongPlugin) extractDetailURLFromContent(content string) string { // 查找"详情页: URL"模式 re := regexp.MustCompile(`详情页: (https?://[^\s|]+)`) matches := re.FindStringSubmatch(content) if len(matches) > 1 { return matches[1] } return "" } // fetchDetailPageInfo 获取详情页的完整信息 func (p *ClxiongPlugin) fetchDetailPageInfo(detailURL string, movieTitle string) *DetailPageInfo { if p.debugMode { log.Printf("[CLXIONG] 正在获取详情页信息: %s", detailURL) } client := &http.Client{Timeout: 20 * time.Second} req, err := http.NewRequest("GET", detailURL, nil) if err != nil { if p.debugMode { log.Printf("[CLXIONG] 创建详情页请求失败: %v", err) } return nil } req.Header.Set("User-Agent", UserAgent) req.Header.Set("Referer", BaseURL+"/") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") resp, err := client.Do(req) if err != nil { if p.debugMode { log.Printf("[CLXIONG] 详情页请求失败: %v", err) } return nil } defer resp.Body.Close() if resp.StatusCode != 200 { if p.debugMode { log.Printf("[CLXIONG] 详情页HTTP状态错误: %d", resp.StatusCode) } return nil } body, err := io.ReadAll(resp.Body) if err != nil { if p.debugMode { log.Printf("[CLXIONG] 读取详情页响应失败: %v", err) } return nil } return p.parseDetailPageInfo(string(body), movieTitle) } // parseDetailPageInfo 从详情页HTML中解析完整信息 func (p *ClxiongPlugin) parseDetailPageInfo(html string, movieTitle string) *DetailPageInfo { doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) if err != nil { if p.debugMode { log.Printf("[CLXIONG] 解析详情页HTML失败: %v", err) } return nil } detailInfo := &DetailPageInfo{ Title: movieTitle, } // 解析更新时间 detailInfo.UpdateTime = p.parseUpdateTimeFromDetail(doc) // 解析磁力链接 magnetLinks, fileNames := p.parseMagnetLinksFromDetailDoc(doc, movieTitle) detailInfo.MagnetLinks = magnetLinks detailInfo.FileNames = fileNames if p.debugMode { log.Printf("[CLXIONG] 详情页解析完成: 磁力链接 %d 个,更新时间: %v", len(detailInfo.MagnetLinks), detailInfo.UpdateTime) } return detailInfo } // parseUpdateTimeFromDetail 从详情页解析更新时间 func (p *ClxiongPlugin) parseUpdateTimeFromDetail(doc *goquery.Document) time.Time { // 查找"最后更新于:2025-08-16"这样的文本 var updateTime time.Time doc.Find(".mv_detail p").Each(func(i int, s *goquery.Selection) { text := strings.TrimSpace(s.Text()) if strings.Contains(text, "最后更新于:") { // 提取日期部分 dateStr := strings.Replace(text, "最后更新于:", "", 1) dateStr = strings.TrimSpace(dateStr) // 解析日期,支持多种格式 layouts := []string{ "2006-01-02", "2006-1-2", "2006/01/02", "2006/1/2", } for _, layout := range layouts { if t, err := time.Parse(layout, dateStr); err == nil { updateTime = t if p.debugMode { log.Printf("[CLXIONG] 解析到更新时间: %s -> %v", dateStr, updateTime) } return } } if p.debugMode { log.Printf("[CLXIONG] 无法解析更新时间: %s", dateStr) } } }) return updateTime } // parseMagnetLinksFromDetailDoc 从详情页DOM解析磁力链接 func (p *ClxiongPlugin) parseMagnetLinksFromDetailDoc(doc *goquery.Document, movieTitle string) ([]model.Link, []string) { var links []model.Link var fileNames []string if p.debugMode { // 调试:检查是否找到磁力下载区域 mvDown := doc.Find(".mv_down") log.Printf("[CLXIONG] 找到 .mv_down 区域数量: %d", mvDown.Length()) // 调试:检查磁力链接数量 magnetLinks := doc.Find(".mv_down a[href^='magnet:']") log.Printf("[CLXIONG] 找到磁力链接数量: %d", magnetLinks.Length()) // 如果没找到,尝试其他可能的选择器 if magnetLinks.Length() == 0 { allMagnetLinks := doc.Find("a[href^='magnet:']") log.Printf("[CLXIONG] 页面总磁力链接数量: %d", allMagnetLinks.Length()) } } // 查找磁力链接 doc.Find(".mv_down a[href^='magnet:']").Each(func(i int, s *goquery.Selection) { href, exists := s.Attr("href") if exists && href != "" { // 获取文件名(链接文本) fileName := strings.TrimSpace(s.Text()) link := model.Link{ URL: href, Type: "magnet", } // 磁力链接密码字段设置为空(按用户要求) link.Password = "" links = append(links, link) fileNames = append(fileNames, fileName) if p.debugMode { log.Printf("[CLXIONG] 找到磁力链接: %s", fileName) } } }) if p.debugMode { log.Printf("[CLXIONG] 详情页共找到 %d 个磁力链接", len(links)) } return links, fileNames } // generateUniqueID 生成唯一ID func (p *ClxiongPlugin) generateUniqueID(detailPath string) string { // 从路径中提取ID,如 "/drama/4466.html" -> "4466" re := regexp.MustCompile(`/(?:drama|movie)/(\d+)\.html`) matches := re.FindStringSubmatch(detailPath) if len(matches) > 1 { return fmt.Sprintf("clxiong-%s", matches[1]) } // 备用方案:使用完整路径生成哈希 hash := 0 for _, char := range detailPath { hash = hash*31 + int(char) } if hash < 0 { hash = -hash } return fmt.Sprintf("clxiong-%d", hash) } ================================================ FILE: plugin/clxiong/html结构分析.md ================================================ # 磁力熊(CiLiXiong) HTML结构分析文档 ## 网站信息 - **域名**: `www.cilixiong.org` - **名称**: 磁力熊 - **类型**: 影视磁力链接搜索网站 - **特点**: 两步式搜索流程,需要先POST获取searchid,再GET搜索结果 ## 搜索流程分析 ### 第一步:提交搜索请求 #### 请求信息 - **URL**: `https://www.cilixiong.org/e/search/index.php` - **方法**: POST - **Content-Type**: `application/x-www-form-urlencoded` - **Referer**: `https://www.cilixiong.org/` #### POST参数 ``` classid=1%2C2&show=title&tempid=1&keyboard={URL编码的关键词} ``` 参数说明: - `classid=1,2` - 搜索分类(1=电影,2=剧集) - `show=title` - 搜索字段 - `tempid=1` - 模板ID - `keyboard` - 搜索关键词(需URL编码) #### 响应处理 - **状态码**: 302重定向 - **关键信息**: 从响应头`Location`字段获取searchid - **格式**: `result/?searchid=7549` ### 第二步:获取搜索结果 #### 请求信息 - **URL**: `https://www.cilixiong.org/e/search/result/?searchid={searchid}` - **方法**: GET - **Referer**: `https://www.cilixiong.org/` ## 搜索结果页面结构 ### 页面布局 - **容器**: `.container` - **结果提示**: `.text-white.py-3` - 显示"找到 X 条符合搜索条件" - **结果网格**: `.row.row-cols-2.row-cols-lg-4.align-items-stretch.g-4.py-2` ### 单个结果项结构 ```html ``` ### 数据提取选择器 #### 结果列表 - **选择器**: `.row.row-cols-2.row-cols-lg-4 .col` - **排除**: 空白或无效的卡片 #### 单项数据提取 1. **详情链接**: `.col a[href*="/drama/"]` 或 `.col a[href*="/movie/"]` 2. **标题**: `.col h2.h4` 3. **评分**: `.col .rank` 4. **年份**: `.col .small`(最后一个li元素) 5. **海报**: `.col .card-img[style*="background-image"]` - 从style属性提取url #### 链接格式 - 电影:`/movie/ID.html` - 剧集:`/drama/ID.html` - 需补全为绝对URL:`https://www.cilixiong.org/drama/ID.html` ## 详情页面结构 ### 基本信息区域 ```html

影片标题

豆瓣评分: 8.9

又名:英文名称

上映日期:2025-05-25(美国)

类型:|喜剧|冒险|科幻|动画|

单集片长:22分钟

上映地区:美国

主演:演员列表

``` ### 磁力链接区域 ```html

影片名磁力下载地址

``` ### 磁力链接提取 - **容器**: `.mv_down .container` - **链接项**: `.border-bottom` - **磁力链接**: `a[href^="magnet:"]` - **文件名**: 链接的文本内容 - **大小信息**: 通常包含在文件名的方括号中 ## 错误处理 ### 常见问题 1. **搜索无结果**: 页面会显示"找到 0 条符合搜索条件" 2. **searchid失效**: 可能需要重新发起搜索请求 3. **详情页无磁力链接**: 某些内容可能暂时无下载资源 ### 限流检测 - **状态码**: 检测429或403状态码 - **页面内容**: 检测是否包含"访问频繁"等提示 ## 实现要点 ### 请求头设置 ```http User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Content-Type: application/x-www-form-urlencoded (POST请求) Referer: https://www.cilixiong.org/ ``` ### Cookie处理 - 网站可能需要维持会话状态 - 建议在客户端中启用Cookie存储 ### 搜索策略 1. **首次搜索**: POST提交 → 解析Location → GET结果页 2. **结果解析**: 提取基本信息,构建搜索结果 3. **详情获取**: 可选,异步获取磁力链接 ### 数据字段映射 - **Title**: 影片中文标题 - **Content**: 评分、年份、类型等信息组合 - **UniqueID**: 使用详情页URL的ID部分 - **Links**: 磁力链接数组 - **Tags**: 影片类型标签 ## 技术注意事项 ### URL编码 - 搜索关键词必须进行URL编码 - 中文字符使用UTF-8编码 ### 重定向处理 - POST请求会返回302重定向 - 需要从响应头提取Location信息 - 不要自动跟随重定向,需要手动解析 ### 异步处理 - 搜索结果可以先返回基本信息 - 磁力链接通过异步请求详情页获取 - 设置合理的并发限制和超时时间 ================================================ FILE: plugin/cyg/cyg.go ================================================ package cyg import ( "context" "fmt" "html" "io" "net/http" "net/url" "regexp" "strings" "sync" "time" "pansou/model" "pansou/plugin" "pansou/util/json" ) // 预编译的正则表达式(性能优化) var ( // 常见网盘链接的正则表达式(支持15+种类型) quarkLinkRegex = regexp.MustCompile(`https?://pan\.quark\.cn/s/[0-9a-zA-Z]+`) ucLinkRegex = regexp.MustCompile(`https?://drive\.uc\.cn/s/[0-9a-zA-Z]+`) baiduLinkRegex = regexp.MustCompile(`https?://pan\.baidu\.com/s/[0-9a-zA-Z_\-]+`) aliyunLinkRegex = regexp.MustCompile(`https?://(www\.)?(aliyundrive\.com|alipan\.com)/s/[0-9a-zA-Z]+`) xunleiLinkRegex = regexp.MustCompile(`https?://pan\.xunlei\.com/s/[0-9a-zA-Z_\-]+`) tianyiLinkRegex = regexp.MustCompile(`https?://cloud\.189\.cn/t/[0-9a-zA-Z]+`) link115Regex = regexp.MustCompile(`https?://115\.com/s/[0-9a-zA-Z]+`) mobileLinkRegex = regexp.MustCompile(`https?://(caiyun\.feixin\.10086\.cn|caiyun\.139\.com|yun\.139\.com|cloud\.139\.com|pan\.139\.com)/.*`) link123Regex = regexp.MustCompile(`https?://123pan\.com/s/[0-9a-zA-Z]+`) pikpakLinkRegex = regexp.MustCompile(`https?://mypikpak\.com/s/[0-9a-zA-Z]+`) magnetLinkRegex = regexp.MustCompile(`magnet:\?xt=urn:btih:[0-9a-fA-F]{40}`) ed2kLinkRegex = regexp.MustCompile(`ed2k://\|file\|.+\|\d+\|[0-9a-fA-F]{32}\|/`) // HTML标签清理 htmlTagRegex = regexp.MustCompile(`<[^>]*>`) ) // CygPlugin CYG插件结构体 type CygPlugin struct { *plugin.BaseAsyncPlugin } // CygPost 搜索结果结构体 type CygPost struct { ID int `json:"id"` Date string `json:"date"` Title struct { Rendered string `json:"rendered"` } `json:"title"` Excerpt struct { Rendered string `json:"rendered"` } `json:"excerpt"` Link string `json:"link"` CategoryName string `json:"category_name"` AuthorName string `json:"author_name"` Pageviews int `json:"pageviews"` LikeCount int `json:"like_count"` } // CygDownload 下载链接结构体 type CygDownload struct { Name string `json:"name"` // 网盘类型名称 URL string `json:"url"` // 网盘链接 DownloadPwd string `json:"downloadPwd"` // 提取密码 ExtractPwd string `json:"extractPwd"` // 解压密码 ID string `json:"id"` // 链接ID } // CygSearchOptions 搜索选项 type CygSearchOptions struct { PerPage int // 每页结果数 (默认: 20) Page int // 页码 (默认: 1) OrderBy string // 排序字段 (默认: date) Order string // 排序方向 (默认: desc) } // init 注册插件 func init() { p := &CygPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("cyg", 3), // 优先级3,标准质量数据源 } plugin.RegisterGlobalPlugin(p) } // Search 执行搜索并返回结果(兼容性方法) func (p *CygPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { result, err := p.SearchWithResult(keyword, ext) if err != nil { return nil, err } return result.Results, nil } // SearchWithResult 执行搜索并返回包含IsFinal标记的结果(推荐方法) func (p *CygPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) } // searchImpl 搜索实现逻辑 func (p *CygPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { // 解析扩展参数 opts := p.parseExtOptions(ext) // 1. 构建搜索URL searchURL := fmt.Sprintf("https://cyg.app/wp-json/wp/v2/posts?per_page=%d&orderby=%s&order=%s&page=%d&search=%s", opts.PerPage, opts.OrderBy, opts.Order, opts.Page, url.QueryEscape(keyword)) // 2. 发送搜索请求 posts, err := p.fetchSearchResults(client, searchURL) if err != nil { return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err) } if len(posts) == 0 { return []model.SearchResult{}, nil } // 3. 并发获取每个帖子的下载链接 results := p.fetchDownloadLinksAsync(client, posts, keyword) // 4. 关键词过滤 filteredResults := plugin.FilterResultsByKeyword(results, keyword) return filteredResults, nil } // fetchSearchResults 获取搜索结果列表 func (p *CygPlugin) fetchSearchResults(client *http.Client, searchURL string) ([]CygPost, error) { // 创建带超时的上下文 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // 创建请求对象 req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { return nil, fmt.Errorf("创建请求失败: %w", err) } // 设置请求头 p.setRequestHeaders(req) // 发送请求 resp, err := p.doRequestWithRetry(req, client) if err != nil { return nil, fmt.Errorf("HTTP请求失败: %w", err) } defer resp.Body.Close() // 检查状态码 if resp.StatusCode != 200 { return nil, fmt.Errorf("HTTP错误状态码: %d", resp.StatusCode) } // 解析响应 body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取响应失败: %w", err) } var posts []CygPost if err := json.Unmarshal(body, &posts); err != nil { return nil, fmt.Errorf("JSON解析失败: %w", err) } return posts, nil } // fetchDownloadLinksAsync 并发获取下载链接 func (p *CygPlugin) fetchDownloadLinksAsync(client *http.Client, posts []CygPost, keyword string) []model.SearchResult { var wg sync.WaitGroup resultChan := make(chan model.SearchResult, len(posts)) // 限制并发数量 semaphore := make(chan struct{}, 10) // 最多10个并发 for _, post := range posts { wg.Add(1) go func(p *CygPlugin, post CygPost) { defer wg.Done() // 获取信号量 semaphore <- struct{}{} defer func() { <-semaphore }() // 获取下载链接 links, err := p.getDownloadLinks(client, post.ID) if err != nil { // 记录错误但不影响其他结果 return } // 只返回有效链接的结果 if len(links) > 0 { result := p.convertToSearchResult(post, links) resultChan <- result } }(p, post) } // 等待所有goroutine完成 go func() { wg.Wait() close(resultChan) }() // 收集结果 var results []model.SearchResult for result := range resultChan { results = append(results, result) } return results } // getDownloadLinks 获取指定帖子的下载链接 func (p *CygPlugin) getDownloadLinks(client *http.Client, postID int) ([]model.Link, error) { // 构建下载链接获取URL downloadURL := fmt.Sprintf("https://cyg.app/wp-json/acg-studio/v1/download?id=%d", postID) // 创建带超时的上下文 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // 创建请求对象 req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) if err != nil { return nil, fmt.Errorf("创建下载链接请求失败: %w", err) } // 设置请求头 p.setRequestHeaders(req) // 发送请求 resp, err := p.doRequestWithRetry(req, client) if err != nil { return nil, fmt.Errorf("下载链接请求失败: %w", err) } defer resp.Body.Close() // 检查状态码 if resp.StatusCode != 200 { return nil, fmt.Errorf("下载链接请求状态码: %d", resp.StatusCode) } // 解析响应 body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取下载链接响应失败: %w", err) } var downloadData []CygDownload if err := json.Unmarshal(body, &downloadData); err != nil { return nil, fmt.Errorf("下载链接JSON解析失败: %w", err) } // 转换为model.Link格式 return p.convertToLinks(downloadData), nil } // convertToSearchResult 转换为标准搜索结果格式 func (p *CygPlugin) convertToSearchResult(post CygPost, links []model.Link) model.SearchResult { return model.SearchResult{ UniqueID: fmt.Sprintf("cyg-%d", post.ID), Title: p.cleanHTML(post.Title.Rendered), Content: p.cleanHTML(post.Excerpt.Rendered), Datetime: p.parseDateTime(post.Date), Tags: []string{post.CategoryName}, Links: links, Channel: "", // 插件搜索结果必须为空字符串 } } // convertToLinks 转换下载链接数据 func (p *CygPlugin) convertToLinks(downloadData []CygDownload) []model.Link { links := make([]model.Link, 0, len(downloadData)) for _, item := range downloadData { // 优先使用URL模式匹配,fallback到名称映射 linkType := p.determineCloudTypeByURL(item.URL) if linkType == "others" { linkType = p.determineCloudType(item.Name) } link := model.Link{ Type: linkType, URL: item.URL, Password: item.DownloadPwd, // 提取密码 } links = append(links, link) } return links } // determineCloudTypeByURL 根据URL确定网盘类型(支持15+种类型) func (p *CygPlugin) determineCloudTypeByURL(url string) string { switch { case quarkLinkRegex.MatchString(url): return "quark" case ucLinkRegex.MatchString(url): return "uc" case baiduLinkRegex.MatchString(url): return "baidu" case aliyunLinkRegex.MatchString(url): return "aliyun" case xunleiLinkRegex.MatchString(url): return "xunlei" case tianyiLinkRegex.MatchString(url): return "tianyi" case link115Regex.MatchString(url): return "115" case mobileLinkRegex.MatchString(url): return "mobile" case link123Regex.MatchString(url): return "123" case pikpakLinkRegex.MatchString(url): return "pikpak" case magnetLinkRegex.MatchString(url): return "magnet" case ed2kLinkRegex.MatchString(url): return "ed2k" default: return "others" } } // determineCloudType 根据名称确定网盘类型(支持15+种网盘类型的名称映射) func (p *CygPlugin) determineCloudType(name string) string { switch strings.ToLower(strings.TrimSpace(name)) { case "夸克", "夸克网盘": return "quark" case "uc", "uc网盘": return "uc" case "百度网盘", "百度", "baidu": return "baidu" case "阿里云盘", "阿里", "aliyun", "阿里网盘": return "aliyun" case "迅雷", "迅雷网盘", "xunlei": return "xunlei" case "天翼", "天翼云盘", "189", "189云盘": return "tianyi" case "115", "115网盘": return "115" case "移动云盘", "移动", "mobile", "和彩云", "139云盘", "139", "中国移动云盘": return "mobile" case "123网盘", "123pan", "123": return "123" case "pikpak", "pikpak网盘": return "pikpak" case "磁力链接", "magnet": return "magnet" case "ed2k": return "ed2k" default: return "others" } } // setRequestHeaders 设置请求头 func (p *CygPlugin) setRequestHeaders(req *http.Request) { req.Header.Set("Referer", "https://h5.acgn.my/") req.Header.Set("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1") 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") } // doRequestWithRetry 带重试机制的HTTP请求 func (p *CygPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) { maxRetries := 3 var lastErr error for i := 0; i < maxRetries; i++ { if i > 0 { // 指数退避重试 backoff := time.Duration(1< 0 { opts.PerPage = perPage } if page, ok := ext["page"].(int); ok && page > 0 { opts.Page = page } if orderBy, ok := ext["order_by"].(string); ok && orderBy != "" { opts.OrderBy = orderBy } if order, ok := ext["order"].(string); ok && order != "" { opts.Order = order } return opts } // cleanHTML 清理HTML标签和实体编码 func (p *CygPlugin) cleanHTML(htmlContent string) string { // 移除HTML标签 text := htmlTagRegex.ReplaceAllString(htmlContent, "") // 解码HTML实体 text = html.UnescapeString(text) // 清理多余空白 text = strings.TrimSpace(text) // 替换多个空白字符为单个空格 text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ") return text } // parseDateTime 解析时间字符串 func (p *CygPlugin) parseDateTime(dateStr string) time.Time { // 尝试解析ISO 8601格式 if t, err := time.Parse(time.RFC3339, dateStr); err == nil { return t } // 尝试解析其他常见格式 formats := []string{ "2006-01-02T15:04:05", "2006-01-02 15:04:05", "2006-01-02", } for _, format := range formats { if t, err := time.Parse(format, dateStr); err == nil { return t } } // 解析失败时返回当前时间 return time.Now() } ================================================ FILE: plugin/daishudj/daishudj.go ================================================ package daishudj import ( "context" "fmt" "net/http" "net/url" "regexp" "strings" "sync" "time" "github.com/PuerkitoBio/goquery" "pansou/model" "pansou/plugin" ) var ( idRegex = regexp.MustCompile(`/(\d+)/`) textURLReg = regexp.MustCompile(`https?://[^\s<>"']+`) linkPatterns = []struct { reg *regexp.Regexp typ string }{ {regexp.MustCompile(`https?://pan\.quark\.cn/(s|g)/[0-9A-Za-z]+`), "quark"}, {regexp.MustCompile(`https?://(?:www\.)?(aliyundrive\.com|alipan\.com)/s/[0-9A-Za-z]+`), "aliyun"}, {regexp.MustCompile(`https?://pan\.baidu\.com/s/[0-9A-Za-z\-_]+`), "baidu"}, {regexp.MustCompile(`https?://pan\.xunlei\.com/s/[0-9A-Za-z\-_]+`), "xunlei"}, {regexp.MustCompile(`https?://drive\.uc\.cn/s/[0-9A-Za-z]+`), "uc"}, {regexp.MustCompile(`https?://(?:www\.)?mypikpak\.com/s/[0-9A-Za-z]+`), "pikpak"}, {regexp.MustCompile(`https?://caiyun\.139\.com/[^\s]+`), "mobile"}, {regexp.MustCompile(`magnet:\?xt=urn:btih:[0-9A-Za-z]+`), "magnet"}, {regexp.MustCompile(`https?://(?:www\.)?(123pan\.com|123pan\.cn|123684\.com|123685\.com|123912\.com|123592\.com)/s/[0-9A-Za-z]+`), "123"}, } passwordPatterns = []*regexp.Regexp{ regexp.MustCompile(`提取码[::]?\s*([0-9A-Za-z]+)`), regexp.MustCompile(`密码[::]?\s*([0-9A-Za-z]+)`), regexp.MustCompile(`pwd\s*[=::]\s*([0-9A-Za-z]+)`), regexp.MustCompile(`code\s*[=::]\s*([0-9A-Za-z]+)`), } detailCache = sync.Map{} cacheTTL = 1 * time.Hour cacheCleanupInterval = 30 * time.Minute ) type cacheEntry struct { links []model.Link expiresAt time.Time } const ( pluginName = "daishudj" defaultPriority = 3 searchTimeout = 10 * time.Second detailTimeout = 8 * time.Second maxConcurrency = 10 maxIdleConns = 64 maxIdlePerHost = 16 maxConnsPerHost = 32 idleConnLifetime = 90 * time.Second tlsHandshakeTimeout = 10 * time.Second expectContinueTimeout = 1 * time.Second maxRetries = 3 retryBaseDelay = 200 * time.Millisecond ) // DaishuPlugin 袋鼠短剧插件 type DaishuPlugin struct { *plugin.BaseAsyncPlugin client *http.Client } func init() { plugin.RegisterGlobalPlugin(NewDaishuPlugin()) go startCacheCleaner() } // NewDaishuPlugin 构造函数 func NewDaishuPlugin() *DaishuPlugin { return &DaishuPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority), client: newHTTPClient(), } } // Search 兼容方法 func (p *DaishuPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { result, err := p.SearchWithResult(keyword, ext) if err != nil { return nil, err } return result.Results, nil } // SearchWithResult 主流程 func (p *DaishuPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) } func newHTTPClient() *http.Client { transport := &http.Transport{ MaxIdleConns: maxIdleConns, MaxIdleConnsPerHost: maxIdlePerHost, MaxConnsPerHost: maxConnsPerHost, IdleConnTimeout: idleConnLifetime, TLSHandshakeTimeout: tlsHandshakeTimeout, ExpectContinueTimeout: expectContinueTimeout, ForceAttemptHTTP2: true, } return &http.Client{ Transport: transport, Timeout: searchTimeout, } } func (p *DaishuPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { if p.client != nil { client = p.client } searchURL := fmt.Sprintf("https://www.daishuduanju.com/?s=%s", url.QueryEscape(keyword)) ctx, cancel := context.WithTimeout(context.Background(), searchTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil) if err != nil { return nil, fmt.Errorf("[%s] 创建搜索请求失败: %w", p.Name(), err) } setCommonHeaders(req, "https://www.daishuduanju.com/") resp, err := p.doRequestWithRetry(req, client) if err != nil { return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("[%s] 搜索返回状态码: %d", p.Name(), resp.StatusCode) } doc, err := goquery.NewDocumentFromReader(resp.Body) if err != nil { return nil, fmt.Errorf("[%s] 解析搜索页面失败: %w", p.Name(), err) } var ( results []model.SearchResult wg sync.WaitGroup mu sync.Mutex sem = make(chan struct{}, maxConcurrency) ) doc.Find(".item-jx.item-blog").Each(func(_ int, item *goquery.Selection) { titleSel := item.Find(".subtitle h5 a") title := strings.TrimSpace(titleSel.Text()) detailURL, ok := titleSel.Attr("href") if !ok || title == "" || detailURL == "" { return } postID := extractPostID(detailURL) if postID == "" { return } summary := strings.TrimSpace(item.Find(".subtitle p.pdesc").Text()) var tags []string if cat := strings.TrimSpace(item.Find(".sortbox a.sort").Text()); cat != "" { tags = append(tags, cat) } dateText := strings.TrimSpace(item.Find(".pmbox .time").Text()) publishTime := parseChineseDate(dateText) wg.Add(1) sem <- struct{}{} go func(title, detailURL, summary, postID string, tags []string, publish time.Time) { defer wg.Done() defer func() { <-sem }() links := p.fetchDetailLinks(client, detailURL, postID) if len(links) == 0 { return } result := model.SearchResult{ UniqueID: fmt.Sprintf("%s-%s", p.Name(), postID), Title: title, Content: summary, Links: links, Tags: tags, Channel: "", Datetime: publish, } mu.Lock() results = append(results, result) mu.Unlock() }(title, detailURL, summary, postID, append([]string{}, tags...), publishTime) }) wg.Wait() if len(results) == 0 { return nil, fmt.Errorf("[%s] 未找到相关资源", p.Name()) } return plugin.FilterResultsByKeyword(results, keyword), nil } func (p *DaishuPlugin) fetchDetailLinks(client *http.Client, detailURL, postID string) []model.Link { if cached, ok := detailCache.Load(postID); ok { if entry, valid := cached.(cacheEntry); valid { if time.Now().Before(entry.expiresAt) && len(entry.links) > 0 { return entry.links } detailCache.Delete(postID) } } ctx, cancel := context.WithTimeout(context.Background(), detailTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil) if err != nil { return nil } setCommonHeaders(req, "https://www.daishuduanju.com/") resp, err := p.doRequestWithRetry(req, client) if err != nil { return nil } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil } doc, err := goquery.NewDocumentFromReader(resp.Body) if err != nil { return nil } container := doc.Find(".article-body") if container.Length() == 0 { container = doc.Find("article.post") } if container.Length() == 0 { container = doc.Selection } links := extractLinks(container) if len(links) > 0 { detailCache.Store(postID, cacheEntry{ links: links, expiresAt: time.Now().Add(cacheTTL), }) } return links } func extractLinks(selection *goquery.Selection) []model.Link { var ( results []model.Link seen = make(map[string]struct{}) ) selection.Find("a[href]").Each(func(_ int, node *goquery.Selection) { href, ok := node.Attr("href") if !ok { return } href = strings.TrimSpace(href) if href == "" { return } linkType, normalized := classifyLink(href) if linkType == "" { return } if _, exists := seen[normalized]; exists { return } password := extractPassword(node) results = append(results, model.Link{ Type: linkType, URL: normalized, Password: password, }) seen[normalized] = struct{}{} }) text := selection.Text() for _, idx := range textURLReg.FindAllStringIndex(text, -1) { raw := text[idx[0]:idx[1]] linkType, normalized := classifyLink(raw) if linkType == "" { continue } if _, exists := seen[normalized]; exists { continue } context := substring(text, idx[0]-80, idx[1]+80) password := matchPassword(context) results = append(results, model.Link{ Type: linkType, URL: normalized, Password: password, }) seen[normalized] = struct{}{} } return results } func classifyLink(raw string) (string, string) { for _, pattern := range linkPatterns { if loc := pattern.reg.FindString(raw); loc != "" { return pattern.typ, loc } } return "", "" } func extractPassword(node *goquery.Selection) string { candidates := []string{node.Text()} if title, ok := node.Attr("title"); ok { candidates = append(candidates, title) } if parent := node.Parent(); parent != nil && parent.Length() > 0 { candidates = append(candidates, parent.Text()) if next := parent.Next(); next.Length() > 0 { candidates = append(candidates, next.Text()) } } if sibling := node.Next(); sibling.Length() > 0 { candidates = append(candidates, sibling.Text()) } for _, text := range candidates { if pwd := matchPassword(text); pwd != "" { return pwd } } return "" } func matchPassword(text string) string { text = strings.TrimSpace(text) if text == "" { return "" } for _, pattern := range passwordPatterns { if matches := pattern.FindStringSubmatch(text); len(matches) >= 2 { return strings.TrimSpace(matches[1]) } } return "" } func substring(text string, start, end int) string { if start < 0 { start = 0 } if end > len(text) { end = len(text) } return text[start:end] } func extractPostID(detailURL string) string { if matches := idRegex.FindStringSubmatch(detailURL); len(matches) >= 2 { return matches[1] } return "" } func parseChineseDate(value string) time.Time { value = strings.TrimSpace(value) if value == "" { return time.Now() } value = strings.ReplaceAll(value, "年", "-") value = strings.ReplaceAll(value, "月", "-") value = strings.ReplaceAll(value, "日", "") layouts := []string{ "2006-01-02 15:04", "2006-01-02", } for _, layout := range layouts { if t, err := time.Parse(layout, value); err == nil { return t } } return time.Now() } func setCommonHeaders(req *http.Request, referer string) { req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") req.Header.Set("Connection", "keep-alive") req.Header.Set("Referer", referer) } func (p *DaishuPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) { var lastErr error for attempt := 0; attempt < maxRetries; attempt++ { resp, err := client.Do(req.Clone(req.Context())) if err == nil && resp.StatusCode == http.StatusOK { return resp, nil } if resp != nil { resp.Body.Close() } lastErr = err if attempt < maxRetries-1 { backoff := retryBaseDelay * time.Duration(1<
短剧《将军回乡》高清完整版全集免费在线观看

📺 ... 夸克网盘:... https://pan.quark.cn/...

袋鼠短剧网 2025年11月16日
``` ### 提取要点 - **标题**:`h5 a` 文本 - **详情 URL**:`h5 a` 的 `href` - **摘要**:`p.pdesc` 文本(通常包含观看地址和部分链接) - **分类标签**:`.sortbox a` - **发布时间**:`.pmbox .time`(格式含中文,如 `2025年11月16日`) - **作者**:`.pmbox .author` - **附带链接**:摘要可能直接包含下载链接,可做快速过滤,但最终以详情页为准 ## 详情页面 - **URL 规则**:`https://www.daishuduanju.com/{post_id}/` - **主体容器**:`.article-body` - **常见内容顺序**: 1. 顶部信息框(观看地址、夸克链接按钮) 2. 介绍/剧情文案(多段 `

`) 3. 相关文章、评论等 ### 下载信息块 ```html

📺 观看地址
夸克网盘:将军回乡
https://pan.quark.cn/s/703f4c422d24
...
``` ### 其他形式 - 纯文本段落:`

夸克:https://pan.quark.cn/s/...

` - 多链接场景:介绍底部 `p.pdesc` 或相关内容中附带多个 `https://pan.quark.cn/s/...` 链接 - 可能含有二维码、外部提示、群组链接,可忽略 ### DOM 选择建议 - 优先在 `.article-body` 内查找 ``,筛选包含网盘域名的链接 - 若 `.article-body` 缺失,则回退到整篇文章 `article.post` - 提取码通常写在同段文本或链接 `href` 参数中,使用关键字匹配 `提取码/密码/pwd` ## 支持的网盘域名 - 夸克:`pan.quark.cn` - (可扩展)百度、阿里、迅雷等,如出现可重用通用判断逻辑 ## 实现要点 1. **搜索**:直接请求 HTML 列表,解析 `.item-jx`,提取基础信息。 2. **详情**:抓取 `.article-body`,搜集 `` 链接,并结合文本解析裸露 URL。 3. **提取码**:在链接周围文本、父节点、相邻节点中搜 `提取码/密码/pwd/code`。 4. **时间格式**:中文日期需转换成 `YYYY-MM-DD`,可替换 `年/月/日`。 5. **去重**:使用文章 ID (`/1047/`) 作为 `UniqueID` 的一部分。 ================================================ FILE: plugin/ddys/ddys.go ================================================ package ddys import ( "context" "fmt" "io" "log" "net/http" "net/url" "regexp" "strings" "sync" "time" "github.com/PuerkitoBio/goquery" "pansou/model" "pansou/plugin" ) const ( PluginName = "ddys" DisplayName = "低端影视" Description = "低端影视 - 影视资源网盘链接搜索" BaseURL = "https://ddys.pro" SearchPath = "/?s=%s&post_type=post" UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" MaxResults = 50 MaxConcurrency = 20 ) // DdysPlugin 低端影视插件 type DdysPlugin struct { *plugin.BaseAsyncPlugin debugMode bool detailCache sync.Map // 缓存详情页结果 cacheTTL time.Duration } // init 注册插件 func init() { plugin.RegisterGlobalPlugin(NewDdysPlugin()) } // NewDdysPlugin 创建新的低端影视插件实例 func NewDdysPlugin() *DdysPlugin { debugMode := false // 生产环境关闭调试 p := &DdysPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(PluginName, 1), // 标准网盘插件,启用Service层过滤 debugMode: debugMode, cacheTTL: 30 * time.Minute, // 详情页缓存30分钟 } return p } // Name 插件名称 func (p *DdysPlugin) Name() string { return PluginName } // DisplayName 插件显示名称 func (p *DdysPlugin) DisplayName() string { return DisplayName } // Description 插件描述 func (p *DdysPlugin) Description() string { return Description } // Search 搜索接口 func (p *DdysPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { return p.searchImpl(&http.Client{Timeout: 30 * time.Second}, keyword, ext) } // searchImpl 搜索实现 func (p *DdysPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { if p.debugMode { log.Printf("[DDYS] 开始搜索: %s", keyword) } // 第一步:执行搜索获取结果列表 searchResults, err := p.executeSearch(client, keyword) if err != nil { return nil, fmt.Errorf("[%s] 执行搜索失败: %w", p.Name(), err) } if p.debugMode { log.Printf("[DDYS] 搜索获取到 %d 个结果", len(searchResults)) } // 第二步:并发获取详情页链接 finalResults := p.fetchDetailLinks(client, searchResults, keyword) if p.debugMode { log.Printf("[DDYS] 最终获取到 %d 个有效结果", len(finalResults)) } // 第三步:关键词过滤(标准网盘插件需要过滤) filteredResults := plugin.FilterResultsByKeyword(finalResults, keyword) if p.debugMode { log.Printf("[DDYS] 关键词过滤后剩余 %d 个结果", len(filteredResults)) } return filteredResults, nil } // executeSearch 执行搜索请求 func (p *DdysPlugin) executeSearch(client *http.Client, keyword string) ([]model.SearchResult, error) { // 构建搜索URL searchURL := fmt.Sprintf("%s%s", BaseURL, fmt.Sprintf(SearchPath, url.QueryEscape(keyword))) // 创建带超时的上下文 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { return nil, fmt.Errorf("[%s] 创建搜索请求失败: %w", p.Name(), err) } // 设置完整的请求头 req.Header.Set("User-Agent", UserAgent) req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") req.Header.Set("Connection", "keep-alive") req.Header.Set("Upgrade-Insecure-Requests", "1") req.Header.Set("Cache-Control", "max-age=0") req.Header.Set("Referer", BaseURL+"/") resp, err := p.doRequestWithRetry(req, client) if err != nil { return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err) } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("[%s] 搜索请求HTTP状态错误: %d", p.Name(), resp.StatusCode) } // 解析HTML提取搜索结果 doc, err := goquery.NewDocumentFromReader(resp.Body) if err != nil { return nil, fmt.Errorf("[%s] 解析搜索结果HTML失败: %w", p.Name(), err) } return p.parseSearchResults(doc) } // doRequestWithRetry 带重试机制的HTTP请求 func (p *DdysPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) { maxRetries := 3 var lastErr error for i := 0; i < maxRetries; i++ { if i > 0 { // 指数退避重试 backoff := time.Duration(1<= MaxResults { return } result := p.parseResultItem(s, i+1) if result != nil { results = append(results, *result) } }) if p.debugMode { log.Printf("[DDYS] 解析到 %d 个原始结果", len(results)) } return results, nil } // parseResultItem 解析单个搜索结果项 func (p *DdysPlugin) parseResultItem(s *goquery.Selection, index int) *model.SearchResult { // 提取文章ID articleClass, _ := s.Attr("class") postID := p.extractPostID(articleClass) if postID == "" { postID = fmt.Sprintf("unknown-%d", index) } // 提取标题和链接 linkEl := s.Find(".post-title a") if linkEl.Length() == 0 { if p.debugMode { log.Printf("[DDYS] 跳过无标题链接的结果") } return nil } // 提取标题 title := strings.TrimSpace(linkEl.Text()) if title == "" { return nil } // 提取详情页链接 detailURL, _ := linkEl.Attr("href") if detailURL == "" { if p.debugMode { log.Printf("[DDYS] 跳过无链接的结果: %s", title) } return nil } // 提取发布时间 publishTime := p.extractPublishTime(s) // 提取分类 category := p.extractCategory(s) // 提取简介 content := p.extractContent(s) // 构建初始结果对象(详情页链接稍后获取) result := model.SearchResult{ Title: title, Content: fmt.Sprintf("分类:%s\n%s", category, content), Channel: "", // 插件搜索结果必须为空字符串(按开发指南要求) MessageID: fmt.Sprintf("%s-%s-%d", p.Name(), postID, index), UniqueID: fmt.Sprintf("%s-%s-%d", p.Name(), postID, index), Datetime: publishTime, Links: []model.Link{}, // 先为空,详情页处理后添加 Tags: []string{category}, } // 添加详情页URL到临时字段(用于后续处理) result.Content += fmt.Sprintf("\n详情页: %s", detailURL) if p.debugMode { log.Printf("[DDYS] 解析结果: %s (%s)", title, category) } return &result } // extractPostID 从文章class中提取文章ID func (p *DdysPlugin) extractPostID(articleClass string) string { // 匹配 post-{数字} 格式 re := regexp.MustCompile(`post-(\d+)`) matches := re.FindStringSubmatch(articleClass) if len(matches) > 1 { return matches[1] } return "" } // extractPublishTime 提取发布时间 func (p *DdysPlugin) extractPublishTime(s *goquery.Selection) time.Time { timeEl := s.Find(".meta_date time.entry-date") if timeEl.Length() == 0 { return time.Now() } datetime, exists := timeEl.Attr("datetime") if !exists { return time.Now() } // 解析ISO 8601格式时间 if t, err := time.Parse(time.RFC3339, datetime); err == nil { return t } return time.Now() } // extractCategory 提取分类 func (p *DdysPlugin) extractCategory(s *goquery.Selection) string { categoryEl := s.Find(".meta_categories .cat-links a") if categoryEl.Length() > 0 { return strings.TrimSpace(categoryEl.Text()) } return "未分类" } // extractContent 提取内容简介 func (p *DdysPlugin) extractContent(s *goquery.Selection) string { contentEl := s.Find(".entry-content") if contentEl.Length() > 0 { content := strings.TrimSpace(contentEl.Text()) // 限制长度 if len(content) > 200 { content = content[:200] + "..." } return content } return "" } // fetchDetailLinks 并发获取详情页链接 func (p *DdysPlugin) fetchDetailLinks(client *http.Client, searchResults []model.SearchResult, keyword string) []model.SearchResult { if len(searchResults) == 0 { return []model.SearchResult{} } // 使用通道控制并发数 semaphore := make(chan struct{}, MaxConcurrency) var wg sync.WaitGroup resultsChan := make(chan model.SearchResult, len(searchResults)) for _, result := range searchResults { wg.Add(1) go func(r model.SearchResult) { defer wg.Done() semaphore <- struct{}{} // 获取信号量 defer func() { <-semaphore }() // 释放信号量 // 从Content中提取详情页URL detailURL := p.extractDetailURLFromContent(r.Content) if detailURL == "" { if p.debugMode { log.Printf("[DDYS] 跳过无详情页URL的结果: %s", r.Title) } return } // 获取详情页链接 links := p.fetchDetailPageLinks(client, detailURL) if len(links) > 0 { r.Links = links // 清理Content中的详情页URL r.Content = p.cleanContent(r.Content) resultsChan <- r } else if p.debugMode { log.Printf("[DDYS] 详情页无有效链接: %s", r.Title) } }(result) } // 等待所有goroutine完成 go func() { wg.Wait() close(resultsChan) }() // 收集结果 var finalResults []model.SearchResult for result := range resultsChan { finalResults = append(finalResults, result) } return finalResults } // extractDetailURLFromContent 从Content中提取详情页URL func (p *DdysPlugin) extractDetailURLFromContent(content string) string { lines := strings.Split(content, "\n") for _, line := range lines { if strings.HasPrefix(line, "详情页: ") { return strings.TrimPrefix(line, "详情页: ") } } return "" } // cleanContent 清理Content,移除详情页URL行 func (p *DdysPlugin) cleanContent(content string) string { lines := strings.Split(content, "\n") var cleanedLines []string for _, line := range lines { if !strings.HasPrefix(line, "详情页: ") { cleanedLines = append(cleanedLines, line) } } return strings.Join(cleanedLines, "\n") } // fetchDetailPageLinks 获取详情页的网盘链接 func (p *DdysPlugin) fetchDetailPageLinks(client *http.Client, detailURL string) []model.Link { // 检查缓存 if cached, found := p.detailCache.Load(detailURL); found { if links, ok := cached.([]model.Link); ok { if p.debugMode { log.Printf("[DDYS] 使用缓存的详情页链接: %s", detailURL) } return links } } // 创建带超时的上下文 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "GET", detailURL, nil) if err != nil { if p.debugMode { log.Printf("[DDYS] 创建详情页请求失败: %v", err) } return []model.Link{} } // 设置请求头 req.Header.Set("User-Agent", UserAgent) req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") req.Header.Set("Referer", BaseURL+"/") resp, err := client.Do(req) if err != nil { if p.debugMode { log.Printf("[DDYS] 详情页请求失败: %v", err) } return []model.Link{} } defer resp.Body.Close() if resp.StatusCode != 200 { if p.debugMode { log.Printf("[DDYS] 详情页HTTP状态错误: %d", resp.StatusCode) } return []model.Link{} } // 读取响应体 body, err := io.ReadAll(resp.Body) if err != nil { if p.debugMode { log.Printf("[DDYS] 读取详情页响应失败: %v", err) } return []model.Link{} } // 解析网盘链接 links := p.parseNetworkDiskLinks(string(body)) // 缓存结果 if len(links) > 0 { p.detailCache.Store(detailURL, links) } if p.debugMode { log.Printf("[DDYS] 从详情页提取到 %d 个链接: %s", len(links), detailURL) } return links } // parseNetworkDiskLinks 解析网盘链接 func (p *DdysPlugin) parseNetworkDiskLinks(htmlContent string) []model.Link { var links []model.Link // 定义网盘链接匹配模式 patterns := []struct { name string pattern string urlType string }{ {"夸克网盘", `\(夸克[^)]*\)[::]\s*]*href\s*=\s*["']([^"']+)["'][^>]*>([^<]+)`, "quark"}, {"百度网盘", `\(百度[^)]*\)[::]\s*]*href\s*=\s*["']([^"']+)["'][^>]*>([^<]+)`, "baidu"}, {"阿里云盘", `\(阿里[^)]*\)[::]\s*]*href\s*=\s*["']([^"']+)["'][^>]*>([^<]+)`, "aliyun"}, {"天翼云盘", `\(天翼[^)]*\)[::]\s*]*href\s*=\s*["']([^"']+)["'][^>]*>([^<]+)`, "tianyi"}, {"迅雷网盘", `\(迅雷[^)]*\)[::]\s*]*href\s*=\s*["']([^"']+)["'][^>]*>([^<]+)`, "xunlei"}, // 通用模式 {"通用网盘", `]*href\s*=\s*["'](https?://[^"']*(?:pan|drive|cloud)[^"']*)["'][^>]*>([^<]+)`, "others"}, } // 去重用的map seen := make(map[string]bool) for _, pattern := range patterns { re := regexp.MustCompile(pattern.pattern) matches := re.FindAllStringSubmatch(htmlContent, -1) for _, match := range matches { if len(match) >= 3 { url := match[1] // 去重 if seen[url] { continue } seen[url] = true // 确定网盘类型 urlType := p.determineCloudType(url) if urlType == "others" { urlType = pattern.urlType } // 提取可能的提取码 password := p.extractPassword(htmlContent, url) link := model.Link{ Type: urlType, URL: url, Password: password, } links = append(links, link) if p.debugMode { log.Printf("[DDYS] 找到链接: %s (%s)", url, urlType) } } } } return links } // extractPassword 提取网盘提取码 func (p *DdysPlugin) extractPassword(content string, panURL string) string { // 常见提取码模式 patterns := []string{ `提取[码密][::]?\s*([A-Za-z0-9]{4,8})`, `密码[::]?\s*([A-Za-z0-9]{4,8})`, `[码密][::]?\s*([A-Za-z0-9]{4,8})`, `([A-Za-z0-9]{4,8})\s*[是为]?提取[码密]`, } // 在网盘链接附近搜索提取码 urlIndex := strings.Index(content, panURL) if urlIndex == -1 { return "" } // 搜索范围:链接前后200个字符 start := urlIndex - 200 if start < 0 { start = 0 } end := urlIndex + len(panURL) + 200 if end > len(content) { end = len(content) } searchArea := content[start:end] for _, pattern := range patterns { re := regexp.MustCompile(pattern) matches := re.FindStringSubmatch(searchArea) if len(matches) > 1 { return matches[1] } } return "" } // determineCloudType 根据URL自动识别网盘类型(按开发指南完整列表) func (p *DdysPlugin) determineCloudType(url string) string { switch { case strings.Contains(url, "pan.quark.cn"): return "quark" case strings.Contains(url, "drive.uc.cn"): return "uc" case strings.Contains(url, "pan.baidu.com"): return "baidu" case strings.Contains(url, "aliyundrive.com") || strings.Contains(url, "alipan.com"): return "aliyun" case strings.Contains(url, "pan.xunlei.com"): return "xunlei" case strings.Contains(url, "cloud.189.cn"): return "tianyi" case strings.Contains(url, "caiyun.139.com"): return "mobile" case strings.Contains(url, "115.com"): return "115" case strings.Contains(url, "123pan.com"): return "123" case strings.Contains(url, "mypikpak.com"): return "pikpak" case strings.Contains(url, "lanzou"): return "lanzou" default: return "others" } } ================================================ FILE: plugin/ddys/html结构分析.md ================================================ # DDYS(低端影视)插件HTML结构分析 ## 网站概述 - **网站名称**: 低端影视 - **域名**: https://ddys.pro/ - **类型**: 影视资源网站,提供在线播放和网盘下载链接 ## API流程概述 ### 搜索页面 - **请求URL**: `https://ddys.pro/?s={keyword}&post_type=post` - **方法**: GET - **Headers**: 标准浏览器请求头 - **特点**: WordPress网站,使用标准搜索功能 ## 搜索结果结构 ### 搜索结果页面HTML结构 ```html
``` ### 详情页面HTML结构 ```html
``` ## 数据提取要点 ### 搜索结果页面 1. **结果列表**: `article[class^="post-"]` - 每个搜索结果 2. **文章ID**: 从article的class或id属性提取,如 `post-1404` 3. **标题**: `.post-title > a` - 获取文本和href属性 4. **链接**: `.post-title > a[href]` - 详情页链接 5. **发布时间**: `.meta_date > time.entry-date[datetime]` - ISO格式时间 6. **分类**: `.meta_categories > .cat-links > a` - 分类信息 7. **简介**: `.entry-content` - 内容简介(可能为空) ### 详情页面 1. **标题**: `h1.post-title` - 影片标题 2. **发布时间**: `.meta_date > time.entry-date[datetime]` - 发布时间 3. **分类标签**: `.meta_categories`和`.meta_tags`中的链接 4. **网盘链接提取**: - 模式1: `(网盘名):链接文本` - 模式2: `(网盘名) 链接文本` - 常见网盘: 夸克网盘、百度网盘、阿里云盘、天翼云盘等 5. **豆瓣信息**: `.doulist-item`区块(可选) ## 网盘链接识别规则 ### 支持的网盘类型 - **夸克网盘**: `pan.quark.cn` - **百度网盘**: `pan.baidu.com` - **阿里云盘**: `aliyundrive.com` / `alipan.com` - **天翼云盘**: `cloud.189.cn` - **迅雷网盘**: `pan.xunlei.com` - **115网盘**: `115.com` - **蓝奏云**: `lanzou`相关域名 ### 链接提取策略 1. 在详情页的`.entry`内容区域搜索 2. 使用正则表达式匹配网盘链接模式 3. 提取网盘类型、链接和可能的提取码 4. 链接去重和验证 ## 特殊处理 ### 时间解析 - 格式: ISO 8601格式 `2023-07-13T14:27:08+08:00` - 显示: `2023年7月13日` ### 内容清理 - 移除HTML标签 - 处理特殊字符和编码 - 清理多余空格和换行 ### 错误处理 - 网络超时重试 - 解析失败的降级处理 - 空结果的处理 ## 注意事项 1. **反爬虫**: 网站可能有基础的反爬虫措施,需要设置合理的请求头 2. **限频**: 避免请求过于频繁 3. **编码**: 处理中文关键词的URL编码 4. **更新**: 网站结构可能会变化,需要定期维护选择器 ================================================ FILE: plugin/discourse/discourse.go ================================================ package discourse import ( "fmt" "io" "net/http" "net/url" "pansou/model" "pansou/plugin" "pansou/util/json" "regexp" "strings" "time" cloudscraper "github.com/Advik-B/cloudscraper/lib" ) // 预编译的正则表达式 - 用于从blurb中提取网盘链接 var ( // 网盘链接正则表达式 quarkRegex = regexp.MustCompile(`https://pan\.quark\.cn/s/[0-9a-zA-Z]+`) baiduRegex = regexp.MustCompile(`https://pan\.baidu\.com/s/[0-9a-zA-Z_\-]+(?:\?pwd=([0-9a-zA-Z]+))?`) aliyunRegex = regexp.MustCompile(`https://(?:www\.)?aliyundrive\.com/s/[0-9a-zA-Z]+`) xunleiRegex = regexp.MustCompile(`https://pan\.xunlei\.com/s/[0-9a-zA-Z_\-]+(?:\?pwd=([0-9a-zA-Z]+))?`) tianyiRegex = regexp.MustCompile(`https://cloud\.189\.cn/t/[0-9a-zA-Z]+`) ucRegex = regexp.MustCompile(`https://drive\.uc\.cn/s/[0-9a-zA-Z]+`) pan115Regex = regexp.MustCompile(`https://115\.com/s/[0-9a-zA-Z]+`) // 百度网盘提取码 (出现在文本中) baiduPwdRegex = regexp.MustCompile(`(?:提取码|密码|pwd)[::]\s*([0-9a-zA-Z]{4})`) ) // 常量定义 const ( pluginName = "discourse" // searchURLTemplate = "https://linux.do/search.json?q=%s%%20%%23resource%%3Acloud-asset%%20in%%3Atitle&page=%d" searchURLTemplate = "https://linux.do/search.json?q=%s%%20in%%3Atitle%%20%%23resource&page=%d" detailURLTemplate = "https://linux.do/t/%d.json?track_visit=true&forceLoad=true" defaultPriority = 2 defaultTimeout = 30 * time.Second // 多页获取配置 defaultMaxPages = 1 // 默认最多获取1页 maxAllowedPages = 10 // 最多允许获取10页 pageRequestDelay = 500 * time.Millisecond // 每页请求间隔 ) // DiscourseAsyncPlugin 是 Discourse 论坛的异步搜索插件实现 type DiscourseAsyncPlugin struct { *plugin.BaseAsyncPlugin scraper *cloudscraper.Scraper } // SearchResponse 搜索API响应结构 type SearchResponse struct { Posts []Post `json:"posts"` Topics []Topic `json:"topics"` GroupedSearchResult GroupedSearchResult `json:"grouped_search_result"` } // Post 帖子信息 type Post struct { ID int `json:"id"` Name string `json:"name"` Username string `json:"username"` CreatedAt string `json:"created_at"` LikeCount int `json:"like_count"` Blurb string `json:"blurb"` TopicID int `json:"topic_id"` } // Topic 主题信息 type Topic struct { ID int `json:"id"` Title string `json:"title"` FancyTitle string `json:"fancy_title"` Tags []string `json:"tags"` PostsCount int `json:"posts_count"` CreatedAt string `json:"created_at"` CategoryID int `json:"category_id"` } // GroupedSearchResult 搜索元数据 type GroupedSearchResult struct { Term string `json:"term"` PostIDs []int `json:"post_ids"` MoreResults bool `json:"more_full_page_results"` } // DetailResponse 详情API响应结构 type DetailResponse struct { PostStream PostStream `json:"post_stream"` ID int `json:"id"` Title string `json:"title"` Tags []string `json:"tags"` } // PostStream 帖子流 type PostStream struct { Posts []DetailPost `json:"posts"` } // DetailPost 详情帖子 type DetailPost struct { ID int `json:"id"` Username string `json:"username"` CreatedAt string `json:"created_at"` Cooked string `json:"cooked"` TopicID int `json:"topic_id"` LinkCounts []LinkCount `json:"link_counts"` } // LinkCount 链接统计 type LinkCount struct { URL string `json:"url"` Internal bool `json:"internal"` Reflection bool `json:"reflection"` Clicks int `json:"clicks"` } // 确保 DiscourseAsyncPlugin 实现了 AsyncSearchPlugin 接口 var _ plugin.AsyncSearchPlugin = (*DiscourseAsyncPlugin)(nil) // init 在包初始化时注册插件 func init() { plugin.RegisterGlobalPlugin(NewDiscourseAsyncPlugin()) } // NewDiscourseAsyncPlugin 创建一个新的 Discourse 异步插件实例 func NewDiscourseAsyncPlugin() *DiscourseAsyncPlugin { // 创建 cloudscraper 实例 scraper, err := cloudscraper.New() if err != nil { // 如果创建失败,记录错误但不阻止插件注册 fmt.Printf("[%s] Failed to create cloudscraper: %v\n", pluginName, err) return &DiscourseAsyncPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority), } } return &DiscourseAsyncPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority), scraper: scraper, } } // Search 执行搜索并返回结果(兼容性方法) func (p *DiscourseAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { result, err := p.SearchWithResult(keyword, ext) if err != nil { return nil, err } return result.Results, nil } // SearchWithResult 执行搜索并返回包含IsFinal标记的结果 func (p *DiscourseAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { // 使用BaseAsyncPlugin的异步搜索能力 return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) } // searchImpl 实现具体的搜索逻辑 func (p *DiscourseAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { // 检查 cloudscraper 是否初始化成功 if p.scraper == nil { return nil, fmt.Errorf("cloudscraper not initialized") } // 提取 max_pages 参数(最多获取多少页) maxPages := defaultMaxPages if maxPagesVal, ok := ext["max_pages"]; ok { if maxPagesInt, ok := maxPagesVal.(int); ok { maxPages = maxPagesInt } else if maxPagesFloat, ok := maxPagesVal.(float64); ok { maxPages = int(maxPagesFloat) } } // 限制最大页数 if maxPages > maxAllowedPages { maxPages = maxAllowedPages } if maxPages < 1 { maxPages = 1 } // 提取起始page参数(默认为1) startPage := 1 if pageVal, ok := ext["page"]; ok { if pageInt, ok := pageVal.(int); ok { startPage = pageInt } } // URL编码关键词 encodedKeyword := url.QueryEscape(keyword) // 存储所有结果 var allResults []model.SearchResult seenPostIDs := make(map[int]bool) // 用于去重 fetchedPages := 0 // 实际获取的页数 // 循环获取多页 for currentPage := startPage; currentPage < startPage+maxPages; currentPage++ { fetchedPages++ // 如果不是第一页,添加延迟避免请求过快 if currentPage > startPage { time.Sleep(pageRequestDelay) } searchURL := fmt.Sprintf(searchURLTemplate, encodedKeyword, currentPage) // 发送搜索请求 resp, err := p.scraper.Get(searchURL) if err != nil { // 如果已经获取到一些结果,返回已有结果而不是报错 if len(allResults) > 0 { fmt.Printf("[%s] Warning: failed to fetch page %d: %v\n", p.Name(), currentPage, err) break } return nil, fmt.Errorf("[%s] search request failed on page %d: %w", p.Name(), currentPage, err) } // 检查HTTP状态码 if resp.StatusCode != 200 { resp.Body.Close() // 如果已经获取到一些结果,返回已有结果 if len(allResults) > 0 { fmt.Printf("[%s] Warning: unexpected status code %d on page %d\n", p.Name(), resp.StatusCode, currentPage) break } return nil, fmt.Errorf("[%s] unexpected status code: %d on page %d", p.Name(), resp.StatusCode, currentPage) } // 读取响应体 body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { if len(allResults) > 0 { fmt.Printf("[%s] Warning: failed to read page %d: %v\n", p.Name(), currentPage, err) break } return nil, fmt.Errorf("[%s] read response failed on page %d: %w", p.Name(), currentPage, err) } // 解析JSON响应 var searchResp SearchResponse if err := json.Unmarshal(body, &searchResp); err != nil { if len(allResults) > 0 { fmt.Printf("[%s] Warning: failed to parse page %d: %v\n", p.Name(), currentPage, err) break } return nil, fmt.Errorf("[%s] parse json failed on page %d: %w", p.Name(), currentPage, err) } // 如果没有帖子了,停止获取 if len(searchResp.Posts) == 0 { break } // 转换为SearchResult并去重 pageResults := p.convertToSearchResults(searchResp) // 添加结果(去重) for _, result := range pageResults { // 从 UniqueID 中提取帖子ID var postID int fmt.Sscanf(result.UniqueID, "discourse-%d", &postID) if !seenPostIDs[postID] { seenPostIDs[postID] = true allResults = append(allResults, result) } } // 如果 API 返回没有更多结果了,停止获取 if !searchResp.GroupedSearchResult.MoreResults { break } // 如果这一页没有新的结果,也停止 if len(pageResults) == 0 { break } } // 如果启用了多页获取,在日志中显示获取的总结果数 if maxPages > 1 && len(allResults) > 0 { fmt.Printf("[%s] Fetched %d unique results from %d pages for keyword: %s\n", p.Name(), len(allResults), fetchedPages, keyword) } return allResults, nil } // max 返回两个整数中的较大值 func max(a, b int) int { if a > b { return a } return b } // convertToSearchResults 将搜索响应转换为SearchResult列表 func (p *DiscourseAsyncPlugin) convertToSearchResults(resp SearchResponse) []model.SearchResult { var results []model.SearchResult // 创建 topic 映射,方便快速查找 topicMap := make(map[int]Topic) for _, topic := range resp.Topics { topicMap[topic.ID] = topic } // 遍历所有帖子 for _, post := range resp.Posts { // 获取对应的主题 topic, found := topicMap[post.TopicID] if !found { // 如果找不到主题,使用默认值 topic = Topic{ ID: post.TopicID, Title: "未知标题", Tags: []string{}, } } // 从blurb中提取网盘链接 links := p.extractNetDiskLinksFromBlurb(post.Blurb) // 如果没有提取到链接,跳过这个结果 if len(links) == 0 { continue } // 解析时间 createdAt, _ := time.Parse(time.RFC3339, post.CreatedAt) // 构建 SearchResult result := model.SearchResult{ UniqueID: fmt.Sprintf("%s-%d", pluginName, post.ID), Title: topic.Title, Content: p.cleanContent(post.Blurb), Links: links, Tags: topic.Tags, Channel: "", // 插件搜索结果必须为空 Datetime: createdAt, } results = append(results, result) } return results } // extractNetDiskLinksFromBlurb 从blurb文本中提取网盘链接 func (p *DiscourseAsyncPlugin) extractNetDiskLinksFromBlurb(blurb string) []model.Link { var links []model.Link // 提取夸克网盘 quarkLinks := quarkRegex.FindAllString(blurb, -1) for _, linkURL := range quarkLinks { links = append(links, model.Link{ Type: "quark", URL: linkURL, }) } // 提取百度网盘(带提取码) baiduMatches := baiduRegex.FindAllStringSubmatch(blurb, -1) for _, match := range baiduMatches { link := model.Link{ Type: "baidu", URL: match[0], } // 如果URL中包含pwd参数 if len(match) > 1 && match[1] != "" { link.Password = match[1] } else { // 尝试从文本中查找提取码 pwdMatch := baiduPwdRegex.FindStringSubmatch(blurb) if len(pwdMatch) > 1 { link.Password = pwdMatch[1] } } links = append(links, link) } // 提取阿里云盘 aliyunLinks := aliyunRegex.FindAllString(blurb, -1) for _, linkURL := range aliyunLinks { links = append(links, model.Link{ Type: "aliyun", URL: linkURL, }) } // 提取迅雷网盘(带提取码) xunleiMatches := xunleiRegex.FindAllStringSubmatch(blurb, -1) for _, match := range xunleiMatches { link := model.Link{ Type: "xunlei", URL: match[0], } if len(match) > 1 && match[1] != "" { link.Password = match[1] } links = append(links, link) } // 提取天翼云盘 tianyiLinks := tianyiRegex.FindAllString(blurb, -1) for _, linkURL := range tianyiLinks { links = append(links, model.Link{ Type: "tianyi", URL: linkURL, }) } // 提取UC网盘 ucLinks := ucRegex.FindAllString(blurb, -1) for _, linkURL := range ucLinks { links = append(links, model.Link{ Type: "uc", URL: linkURL, }) } // 提取115网盘 pan115Links := pan115Regex.FindAllString(blurb, -1) for _, linkURL := range pan115Links { links = append(links, model.Link{ Type: "115", URL: linkURL, }) } return links } // cleanContent 清理内容,移除HTML标签 func (p *DiscourseAsyncPlugin) cleanContent(content string) string { // 移除HTML标签 content = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(content, "") // 解码HTML实体 content = strings.ReplaceAll(content, "&", "&") content = strings.ReplaceAll(content, "<", "<") content = strings.ReplaceAll(content, ">", ">") content = strings.ReplaceAll(content, """, "\"") content = strings.ReplaceAll(content, "'", "'") // 移除多余空白 content = regexp.MustCompile(`\s+`).ReplaceAllString(content, " ") content = strings.TrimSpace(content) // 限制长度 if len(content) > 200 { content = content[:200] + "..." } return content } // GetTopicDetail 获取主题详情(可选实现,用于获取完整链接) func (p *DiscourseAsyncPlugin) GetTopicDetail(topicID int) ([]model.Link, error) { // 检查 cloudscraper 是否初始化成功 if p.scraper == nil { return nil, fmt.Errorf("cloudscraper not initialized") } // 构建详情URL detailURL := fmt.Sprintf(detailURLTemplate, topicID) // 发送详情请求 resp, err := p.scraper.Get(detailURL) if err != nil { return nil, fmt.Errorf("detail request failed: %w", err) } defer resp.Body.Close() // 检查HTTP状态码 if resp.StatusCode != 200 { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } // 读取响应体 body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response failed: %w", err) } // 解析JSON响应 var detailResp DetailResponse if err := json.Unmarshal(body, &detailResp); err != nil { return nil, fmt.Errorf("parse json failed: %w", err) } // 提取第一个帖子的链接 if len(detailResp.PostStream.Posts) == 0 { return nil, fmt.Errorf("no posts found") } mainPost := detailResp.PostStream.Posts[0] // 从 link_counts 中提取网盘链接 var links []model.Link for _, linkCount := range mainPost.LinkCounts { // 跳过内部链接 if linkCount.Internal { continue } // 判断是否为网盘链接并解析 link := p.parseNetDiskLink(linkCount.URL) if link != nil { links = append(links, *link) } } return links, nil } // parseNetDiskLink 解析网盘链接 func (p *DiscourseAsyncPlugin) parseNetDiskLink(linkURL string) *model.Link { // 夸克网盘 if quarkRegex.MatchString(linkURL) { return &model.Link{ Type: "quark", URL: linkURL, } } // 百度网盘 if baiduRegex.MatchString(linkURL) { link := &model.Link{ Type: "baidu", URL: linkURL, } // 提取pwd参数 if matches := baiduRegex.FindStringSubmatch(linkURL); len(matches) > 1 && matches[1] != "" { link.Password = matches[1] } return link } // 阿里云盘 if aliyunRegex.MatchString(linkURL) { return &model.Link{ Type: "aliyun", URL: linkURL, } } // 迅雷网盘 if xunleiRegex.MatchString(linkURL) { link := &model.Link{ Type: "xunlei", URL: linkURL, } // 提取pwd参数 if matches := xunleiRegex.FindStringSubmatch(linkURL); len(matches) > 1 && matches[1] != "" { link.Password = matches[1] } return link } // 天翼云盘 if tianyiRegex.MatchString(linkURL) { return &model.Link{ Type: "tianyi", URL: linkURL, } } // UC网盘 if ucRegex.MatchString(linkURL) { return &model.Link{ Type: "uc", URL: linkURL, } } // 115网盘 if pan115Regex.MatchString(linkURL) { return &model.Link{ Type: "115", URL: linkURL, } } // 不是网盘链接 return nil } ================================================ FILE: plugin/discourse/json结构分析.md ================================================ # Linux.do 搜索API JSON结构分析 ## 接口信息 - **接口名称**: Linux.do 论坛搜索API (Discourse) - **接口地址**: `https://linux.do/search.json` - **请求方法**: `GET` - **Content-Type**: `application/json` - **主要特点**: 基于Discourse论坛系统,搜索网盘资源分享帖子,需要绕过Cloudflare防护 ## 请求结构 ### 搜索API请求格式 ``` GET https://linux.do/search.json?q={keyword}%20%23resource%3Acloud-asset%20in%3Atitle&page={page} ``` ### 请求参数说明 | 参数名 | 类型 | 必需 | 默认值 | 说明 | |--------|------|------|--------|------| | `q` | string | 是 | - | 搜索查询,包含关键词和过滤条件,需要URL编码 | | `page` | int | 否 | 1 | 页码,从1开始 | ### 查询字符串格式 ``` {keyword} #resource:cloud-asset in:title ``` 说明: - `{keyword}`: 搜索关键词(如:遮天) - `#resource:cloud-asset`: 过滤标签,只搜索云盘资源类别 - `in:title`: 只在标题中搜索 ## 响应结构 ### 完整响应格式 ```json { "posts": [...], "topics": [...], "users": [], "categories": [], "tags": [], "groups": [], "grouped_search_result": { "more_posts": null, "more_users": null, "more_categories": null, "term": "遮天 #resource:cloud-asset in:title", "search_log_id": 16604511, "more_full_page_results": true, "can_create_topic": true, "error": null, "extra": {}, "post_ids": [...], "user_ids": [], "category_ids": [], "tag_ids": [], "group_ids": [] } } ``` ### 响应字段详解 #### 1. posts 数组(帖子列表) 包含搜索到的帖子信息,每个帖子包含网盘链接: ```json { "id": 9619992, "name": "lxwh", "username": "lxwh", "avatar_template": "/user_avatar/linux.do/lxwh/{size}/387453_2.png", "created_at": "2025-10-21T10:29:05.613Z", "like_count": 2, "blurb": "紫川更新 遮天... 夸克网盘: https://pan.quark.cn/s/99758a147076 点击进入 百度网盘: https://pan.baidu.com/s/1wF1YzQ14Vo8us_k9UfFNJQ?pwd=hccn 点击进入...", "post_number": 1, "topic_id": 1067663 } ``` | 字段名 | 类型 | 说明 | |--------|------|------| | `id` | int | 帖子ID | | `name` | string | 发帖人姓名 | | `username` | string | 发帖人用户名 | | `avatar_template` | string | 头像模板URL | | `created_at` | string | 发布时间(ISO 8601格式) | | `like_count` | int | 点赞数 | | `blurb` | string | **帖子内容摘要(包含网盘链接)** | | `post_number` | int | 帖子楼层号 | | `topic_id` | int | 主题ID | #### 2. topics 数组(主题列表) 包含搜索到的主题信息: ```json { "fancy_title": "遮天 第132集&紫川2更15集 【4K高码】", "id": 1067663, "title": "遮天 第132集&紫川2更15集 【4K高码】", "slug": "topic", "posts_count": 8, "reply_count": 2, "highest_post_number": 8, "created_at": "2025-10-21T10:29:05.493Z", "last_posted_at": "2025-10-22T00:28:29.185Z", "bumped": true, "bumped_at": "2025-10-22T00:28:29.185Z", "archetype": "regular", "unseen": false, "pinned": false, "unpinned": null, "visible": true, "closed": false, "archived": false, "bookmarked": null, "liked": null, "tags": [ "夸克网盘", "影视", "百度网盘", "动漫" ], "tags_descriptions": {}, "category_id": 94, "has_accepted_answer": false, "can_have_answer": false } ``` | 字段名 | 类型 | 说明 | |--------|------|------| | `id` | int | 主题ID | | `title` | string | 主题标题 | | `fancy_title` | string | 格式化标题(HTML实体) | | `tags` | array | **标签列表(包含网盘类型)** | | `posts_count` | int | 回复数 | | `created_at` | string | 创建时间 | | `last_posted_at` | string | 最后回复时间 | | `category_id` | int | 分类ID(94=云盘资源) | #### 3. grouped_search_result(搜索元数据) ```json { "term": "遮天 #resource:cloud-asset in:title", "search_log_id": 16604511, "more_full_page_results": true, "can_create_topic": true, "error": null, "post_ids": [9619992, 9620329, ...], "user_ids": [], "category_ids": [], "tag_ids": [], "group_ids": [] } ``` | 字段名 | 类型 | 说明 | |--------|------|------| | `term` | string | 搜索词 | | `post_ids` | array | 所有帖子ID列表 | | `more_full_page_results` | bool | 是否有更多结果 | ## 数据提取逻辑 ### 1. 从 blurb 中提取网盘链接 `blurb` 字段包含帖子的文本摘要,其中包含网盘链接。需要使用正则表达式提取: #### 网盘链接格式 | 网盘类型 | URL 格式 | 提取码格式 | |----------|----------|-----------| | **夸克网盘** | `https://pan.quark.cn/s/{code}` | 无需提取码 | | **百度网盘** | `https://pan.baidu.com/s/{code}?pwd={password}` | `?pwd={password}` | | **阿里云盘** | `https://www.aliyundrive.com/s/{code}` | 无需提取码 | | **迅雷网盘** | `https://pan.xunlei.com/s/{code}?pwd={password}` | `?pwd={password}` | | **天翼云盘** | `https://cloud.189.cn/t/{code}` | 访问码: {code} | | **UC网盘** | `https://drive.uc.cn/s/{code}` | 无需提取码 | #### 正则表达式模式 ```go // 夸克网盘 quarkPattern := regexp.MustCompile(`https://pan\.quark\.cn/s/[0-9a-zA-Z]+`) // 百度网盘(带提取码) baiduPattern := regexp.MustCompile(`https://pan\.baidu\.com/s/[0-9a-zA-Z_\-]+(?:\?pwd=([0-9a-zA-Z]+))?`) // 阿里云盘 aliyunPattern := regexp.MustCompile(`https://(?:www\.)?aliyundrive\.com/s/[0-9a-zA-Z]+`) // 迅雷网盘 xunleiPattern := regexp.MustCompile(`https://pan\.xunlei\.com/s/[0-9a-zA-Z_\-]+(?:\?pwd=([0-9a-zA-Z]+))?`) // 天翼云盘 tianyiPattern := regexp.MustCompile(`https://cloud\.189\.cn/t/[0-9a-zA-Z]+`) // UC网盘 ucPattern := regexp.MustCompile(`https://drive\.uc\.cn/s/[0-9a-zA-Z]+`) ``` ### 2. 从 tags 中获取网盘类型 `tags` 数组包含网盘类型标签,可以用于过滤和分类: ```go tags := []string{"夸克网盘", "百度网盘", "动漫"} ``` ### 3. 网盘类型映射 | 标签名 | 英文标识 | |--------|---------| | 夸克网盘 | quark | | 百度网盘 | baidu | | 阿里云盘 | aliyun | | 迅雷网盘 | xunlei | | UC网盘 | uc | | 天翼云盘 | tianyi | | 115网盘 | 115 | | 123网盘 | 123 | ### 4. 时间格式转换 ```go // 输入格式: "2025-10-21T10:29:05.613Z" (ISO 8601) // 解析为 time.Time parsedTime, err := time.Parse(time.RFC3339, "2025-10-21T10:29:05.613Z") ``` ## 实现要点 ### 1. Cloudflare 绕过 Linux.do 使用 Cloudflare 防护,必须使用 cloudscraper 库绕过: ```go import "github.com/Advik-B/cloudscraper/lib" // 创建 cloudscraper 客户端 sc, err := cloudscraper.New() // 发送请求 resp, err := sc.Get(searchURL) ``` ### 2. URL 构建 ```go // 搜索关键词需要 URL 编码 keyword := "遮天" query := fmt.Sprintf("%s #resource:cloud-asset in:title", keyword) searchURL := fmt.Sprintf("https://linux.do/search.json?q=%s&page=%d", url.QueryEscape(query), page) ``` ### 3. 链接提取逻辑 ```go // 从 blurb 中提取所有网盘链接 func extractNetDiskLinks(blurb string) []model.Link { var links []model.Link // 提取夸克网盘 quarkLinks := quarkPattern.FindAllString(blurb, -1) for _, linkURL := range quarkLinks { links = append(links, model.Link{ Type: "quark", URL: linkURL, }) } // 提取百度网盘(带提取码) baiduMatches := baiduPattern.FindAllStringSubmatch(blurb, -1) for _, match := range baiduMatches { link := model.Link{ Type: "baidu", URL: match[0], } if len(match) > 1 && match[1] != "" { link.Password = match[1] } links = append(links, link) } // ... 其他网盘类型 return links } ``` ### 4. SearchResult 构建 ```go func convertToSearchResult(post Post, topic Topic) model.SearchResult { // 提取网盘链接 links := extractNetDiskLinks(post.Blurb) // 解析时间 createdAt, _ := time.Parse(time.RFC3339, post.CreatedAt) return model.SearchResult{ UniqueID: fmt.Sprintf("linuxdo-%d", post.ID), Title: topic.Title, Content: post.Blurb, Links: links, Tags: topic.Tags, Channel: "", // 插件搜索结果必须为空 Datetime: createdAt, } } ``` ## 注意事项 1. **Cloudflare 防护**: 必须使用 cloudscraper 库绕过 2. **查询格式**: 必须包含 `#resource:cloud-asset in:title` 过滤条件 3. **链接提取**: blurb 是截断的文本,可能包含不完整的链接 4. **去重**: 同一个资源可能在多个帖子中出现,需要去重 5. **网盘类型**: 从 tags 和 链接URL 双重判断网盘类型 6. **提取码**: 百度网盘和迅雷网盘的提取码在 URL 参数中 7. **分页**: 支持 page 参数进行分页搜索 ## 优先级建议 根据 Linux.do 的特点,建议设置插件优先级为 **2**: - ✅ 数据源质量良好,社区活跃 - ✅ 资源更新及时,内容新鲜 - ✅ 支持多种网盘类型 - ⚠️ 需要绕过 Cloudflare 防护 - ⚠️ 链接提取依赖文本解析,可能有误差 ## 详情页API ### 接口说明 当搜索结果中的 `blurb` 字段无法提供完整的网盘链接时,可以通过详情页API获取完整内容。 ### 请求格式 ``` GET https://linux.do/t/{topic_id}.json?track_visit=true&forceLoad=true ``` | 参数名 | 类型 | 必需 | 说明 | |--------|------|------|------| | `topic_id` | int | 是 | 主题ID,从搜索结果的 `topic_id` 字段获取 | | `track_visit` | bool | 否 | 是否跟踪访问 | | `forceLoad` | bool | 否 | 是否强制加载 | ### 响应结构 ```json { "post_stream": { "posts": [ { "id": 9619992, "username": "lxwh", "created_at": "2025-10-21T10:29:05.613Z", "cooked": "

HTML格式的完整帖子内容...

", "post_number": 1, "topic_id": 1067663, "link_counts": [ { "url": "https://pan.quark.cn/s/d6b8b0908959", "internal": false, "reflection": false, "clicks": 29 }, { "url": "https://pan.baidu.com/s/1KJylsrBbKbMhi9e-i9YMVA?pwd=tn44", "internal": false, "reflection": false, "clicks": 16 } ] } ] }, "id": 1067663, "title": "遮天 第132集&紫川2更15集 【4K高码】", "fancy_title": "遮天 第132集&紫川2更15集 【4K高码】", "tags": ["夸克网盘", "影视", "百度网盘", "动漫"], "category_id": 94 } ``` ### 关键字段说明 #### post_stream.posts[0](主帖内容) | 字段名 | 类型 | 说明 | |--------|------|------| | `cooked` | string | **HTML格式的完整帖子内容** | | `link_counts` | array | **所有外部链接列表(含网盘链接)** | #### link_counts 数组 这是最可靠的链接提取来源,包含了帖子中所有外部链接: ```json { "url": "https://pan.quark.cn/s/d6b8b0908959", "internal": false, "reflection": false, "clicks": 29 } ``` | 字段名 | 类型 | 说明 | |--------|------|------| | `url` | string | **完整的链接URL** | | `internal` | bool | 是否为内部链接(false表示外部链接) | | `clicks` | int | 点击次数 | ### 数据提取策略 **推荐方式**:优先使用 `link_counts` 数组 1. ✅ **最可靠**:由服务器解析,不会遗漏或截断 2. ✅ **最完整**:包含所有外部链接 3. ✅ **易解析**:结构化数据,无需正则表达式 ```go // 从 link_counts 提取网盘链接 for _, linkCount := range post.LinkCounts { // 跳过内部链接 if linkCount.Internal { continue } // 判断是否为网盘链接 if isNetDiskURL(linkCount.URL) { link := parseNetDiskLink(linkCount.URL) links = append(links, link) } } ``` **备用方式**:从 `cooked` HTML 中提取 当 `link_counts` 为空或不完整时,可以从 HTML 中提取: ```go // 使用 goquery 解析 HTML doc, _ := goquery.NewDocumentFromReader(strings.NewReader(post.Cooked)) // 提取所有 标签 doc.Find("a").Each(func(i int, s *goquery.Selection) { href, exists := s.Attr("href") if exists && isNetDiskURL(href) { link := parseNetDiskLink(href) links = append(links, link) } }) ``` ## 实现策略 ### 两步法获取完整数据 1. **第一步:搜索API** - 获取帖子列表和基本信息 - 从 `blurb` 快速提取部分链接 - 获取 `topic_id` 用于详情请求 2. **第二步:详情API**(按需) - 当 `blurb` 中链接不完整时 - 或需要获取完整帖子内容时 - 使用 `link_counts` 获取所有链接 ### 性能优化建议 - ✅ **批量获取**:使用协程并发请求多个详情页 - ✅ **智能跳过**:如果搜索结果已有完整链接,跳过详情请求 - ✅ **缓存结果**:相同 `topic_id` 的详情可缓存 - ⚠️ **速率限制**:避免请求过快被限流 ## 示例 ### 1. 搜索API 示例 #### 请求 ``` GET https://linux.do/search.json?q=%E9%81%AE%E5%A4%A9%20%23resource%3Acloud-asset%20in%3Atitle&page=1 ``` #### 响应(简化) ```json { "posts": [ { "id": 9619992, "username": "lxwh", "created_at": "2025-10-21T10:29:05.613Z", "blurb": "夸克网盘: https://pan.quark.cn/s/99758a147076", "topic_id": 1067663 } ], "topics": [ { "id": 1067663, "title": "遮天 第132集&紫川2更15集 【4K高码】", "tags": ["夸克网盘", "影视", "动漫"] } ] } ``` ### 2. 详情API 示例 #### 请求 ``` GET https://linux.do/t/1067663.json?track_visit=true&forceLoad=true ``` #### 响应(简化) ```json { "post_stream": { "posts": [ { "id": 9619992, "link_counts": [ { "url": "https://pan.quark.cn/s/d6b8b0908959", "internal": false, "clicks": 29 }, { "url": "https://pan.baidu.com/s/1KJylsrBbKbMhi9e-i9YMVA?pwd=tn44", "internal": false, "clicks": 16 } ] } ] }, "title": "遮天 第132集&紫川2更15集 【4K高码】" } ``` ================================================ FILE: plugin/djgou/djgou.go ================================================ package djgou import ( "context" "fmt" "net/http" "net/url" "pansou/model" "pansou/plugin" "regexp" "strings" "sync" "time" "github.com/PuerkitoBio/goquery" ) // 预编译的正则表达式 var ( // 夸克网盘链接正则表达式(网站只有夸克网盘) // 注意:夸克链接可能包含字母、数字、下划线、连字符等字符 quarkLinkRegex = regexp.MustCompile(`https?://pan\.quark\.cn/s/[0-9a-zA-Z_\-]+`) // 提取码正则表达式 pwdRegex = regexp.MustCompile(`提取码[::]\s*([a-zA-Z0-9]{4})`) // 缓存相关 detailCache = sync.Map{} // 缓存详情页解析结果 lastCleanupTime = time.Now() cacheTTL = 1 * time.Hour ) const ( // 超时时间 DefaultTimeout = 8 * time.Second DetailTimeout = 6 * time.Second // 并发数(精简后的代码使用较低的并发即可) MaxConcurrency = 15 // HTTP连接池配置 MaxIdleConns = 50 MaxIdleConnsPerHost = 20 MaxConnsPerHost = 30 IdleConnTimeout = 90 * time.Second // 网站URL SiteURL = "https://duanjugou.top" ) // 在init函数中注册插件 func init() { plugin.RegisterGlobalPlugin(NewDjgouPlugin()) // 启动缓存清理goroutine go startCacheCleaner() } // startCacheCleaner 启动一个定期清理缓存的goroutine func startCacheCleaner() { ticker := time.NewTicker(30 * time.Minute) defer ticker.Stop() for range ticker.C { // 清空所有缓存 detailCache = sync.Map{} lastCleanupTime = time.Now() } } // DjgouPlugin 短剧狗插件 type DjgouPlugin struct { *plugin.BaseAsyncPlugin optimizedClient *http.Client } // createOptimizedHTTPClient 创建优化的HTTP客户端 func createOptimizedHTTPClient() *http.Client { transport := &http.Transport{ MaxIdleConns: MaxIdleConns, MaxIdleConnsPerHost: MaxIdleConnsPerHost, MaxConnsPerHost: MaxConnsPerHost, IdleConnTimeout: IdleConnTimeout, DisableKeepAlives: false, } return &http.Client{Transport: transport, Timeout: DefaultTimeout} } // NewDjgouPlugin 创建新的短剧狗插件 func NewDjgouPlugin() *DjgouPlugin { return &DjgouPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("djgou", 2), // 优先级2:质量良好的数据源 optimizedClient: createOptimizedHTTPClient(), } } // Search 执行搜索并返回结果(兼容性方法) func (p *DjgouPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { result, err := p.SearchWithResult(keyword, ext) if err != nil { return nil, err } return result.Results, nil } // SearchWithResult 执行搜索并返回包含IsFinal标记的结果 func (p *DjgouPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) } // searchImpl 实现具体的搜索逻辑 func (p *DjgouPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { // 1. 构建搜索URL searchURL := fmt.Sprintf("%s/search.php?q=%s&page=1", SiteURL, url.QueryEscape(keyword)) // 2. 创建带超时的上下文 ctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout) defer cancel() // 3. 创建请求 req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err) } // 4. 设置完整的请求头(避免反爬虫) req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") req.Header.Set("Connection", "keep-alive") req.Header.Set("Upgrade-Insecure-Requests", "1") req.Header.Set("Cache-Control", "max-age=0") req.Header.Set("Referer", SiteURL) // 5. 发送请求(带重试机制) resp, err := p.doRequestWithRetry(req, client) if err != nil { return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err) } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("[%s] 搜索请求返回状态码: %d", p.Name(), resp.StatusCode) } // 6. 解析搜索结果页面 doc, err := goquery.NewDocumentFromReader(resp.Body) if err != nil { return nil, fmt.Errorf("[%s] 解析搜索页面失败: %w", p.Name(), err) } // 7. 提取搜索结果 var results []model.SearchResult // 查找主列表容器 mainListSection := doc.Find("div.erx-list-box") if mainListSection.Length() == 0 { return nil, fmt.Errorf("[%s] 未找到erx-list-box容器", p.Name()) } // 查找列表项 items := mainListSection.Find("ul.erx-list li.item") if items.Length() == 0 { return []model.SearchResult{}, nil // 没有搜索结果 } // 8. 解析每个搜索结果项 items.Each(func(i int, s *goquery.Selection) { result := p.parseSearchItem(s, keyword) if result.UniqueID != "" { results = append(results, result) } }) // 9. 异步获取详情页信息 enhancedResults := p.enhanceWithDetails(client, results) // 10. 关键词过滤 return plugin.FilterResultsByKeyword(enhancedResults, keyword), nil } // parseSearchItem 解析单个搜索结果项 func (p *DjgouPlugin) parseSearchItem(s *goquery.Selection, keyword string) model.SearchResult { result := model.SearchResult{} // 提取标题区域 aDiv := s.Find("div.a") if aDiv.Length() == 0 { return result } // 提取链接和标题 linkElem := aDiv.Find("a.main") if linkElem.Length() == 0 { return result } title := strings.TrimSpace(linkElem.Text()) link, exists := linkElem.Attr("href") if !exists || link == "" { return result } // 处理相对路径 if !strings.HasPrefix(link, "http") { if strings.HasPrefix(link, "/") { link = SiteURL + link } else { link = SiteURL + "/" + link } } // 提取时间 timeText := "" iDiv := s.Find("div.i") if iDiv.Length() > 0 { timeSpan := iDiv.Find("span.time") if timeSpan.Length() > 0 { timeText = strings.TrimSpace(timeSpan.Text()) } } // 生成唯一ID(使用链接的路径部分) itemID := strings.TrimPrefix(link, SiteURL) itemID = strings.Trim(itemID, "/") result.UniqueID = fmt.Sprintf("%s-%s", p.Name(), url.QueryEscape(itemID)) result.Title = title result.Datetime = p.parseTime(timeText) result.Tags = []string{"短剧"} result.Channel = "" // 插件搜索结果必须为空字符串 // 将详情页链接存储在Content中,后续获取详情 result.Content = link return result } // parseTime 解析时间字符串 func (p *DjgouPlugin) parseTime(timeStr string) time.Time { if timeStr == "" { return time.Now() } // 尝试多种时间格式 formats := []string{ "2006-01-02 15:04:05", "2006-01-02 15:04", "2006-01-02", "2006/01/02 15:04:05", "2006/01/02 15:04", "2006/01/02", } for _, format := range formats { if t, err := time.Parse(format, timeStr); err == nil { return t } } return time.Now() } // enhanceWithDetails 异步获取详情页信息 func (p *DjgouPlugin) enhanceWithDetails(client *http.Client, results []model.SearchResult) []model.SearchResult { var wg sync.WaitGroup var mu sync.Mutex // 使用信号量控制并发数 semaphore := make(chan struct{}, MaxConcurrency) enhancedResults := make([]model.SearchResult, 0, len(results)) for _, result := range results { wg.Add(1) go func(r model.SearchResult) { defer wg.Done() // 获取信号量 semaphore <- struct{}{} defer func() { <-semaphore }() // 从缓存或详情页获取链接 links, content := p.getDetailInfo(client, r.Content) // 更新结果 r.Links = links r.Content = content // 只添加有链接的结果 if len(links) > 0 { mu.Lock() enhancedResults = append(enhancedResults, r) mu.Unlock() } }(result) } wg.Wait() return enhancedResults } // getDetailInfo 获取详情页信息(带缓存) func (p *DjgouPlugin) getDetailInfo(client *http.Client, detailURL string) ([]model.Link, string) { // 检查缓存 if cached, ok := detailCache.Load(detailURL); ok { cachedData := cached.(DetailCacheData) if time.Since(cachedData.Timestamp) < cacheTTL { return cachedData.Links, cachedData.Content } } // 获取详情页 links, content := p.fetchDetailPage(client, detailURL) // 存入缓存 if len(links) > 0 { detailCache.Store(detailURL, DetailCacheData{ Links: links, Content: content, Timestamp: time.Now(), }) } return links, content } // DetailCacheData 详情页缓存数据 type DetailCacheData struct { Links []model.Link Content string Timestamp time.Time } // fetchDetailPage 获取详情页信息 func (p *DjgouPlugin) fetchDetailPage(client *http.Client, detailURL string) ([]model.Link, string) { // 创建带超时的上下文 ctx, cancel := context.WithTimeout(context.Background(), DetailTimeout) defer cancel() // 创建请求 req, err := http.NewRequestWithContext(ctx, "GET", detailURL, nil) if err != nil { return nil, "" } // 设置请求头 req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") req.Header.Set("Referer", SiteURL) // 发送请求 resp, err := client.Do(req) if err != nil { return nil, "" } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, "" } // 解析页面 doc, err := goquery.NewDocumentFromReader(resp.Body) if err != nil { return nil, "" } // 查找主内容区域(用于提取简介) mainContent := doc.Find("div.erx-wrap") if mainContent.Length() == 0 { return nil, "" } // 提取网盘链接(从整个页面HTML中提取,不仅仅是mainContent) links := p.extractLinksFromDoc(doc) // 提取简介(从mainContent提取) content := p.extractContent(mainContent) return links, content } // extractLinksFromDoc 从整个文档中提取夸克网盘链接(重要:从整个页面HTML中提取,不限于某个div) func (p *DjgouPlugin) extractLinksFromDoc(doc *goquery.Document) []model.Link { var links []model.Link linkMap := make(map[string]bool) // 去重 // 获取整个页面的HTML内容(这是关键!) htmlContent, _ := doc.Html() // 提取提取码 password := "" if match := pwdRegex.FindStringSubmatch(htmlContent); len(match) > 1 { password = match[1] } // 方法1:使用专用正则表达式提取夸克网盘链接 quarkLinks := quarkLinkRegex.FindAllString(htmlContent, -1) for _, quarkURL := range quarkLinks { // 去重 if !linkMap[quarkURL] { linkMap[quarkURL] = true links = append(links, model.Link{ Type: "quark", URL: quarkURL, Password: password, }) } } // 方法2:从所有标签中查找夸克链接(作为补充) doc.Find("a").Each(func(i int, s *goquery.Selection) { href, exists := s.Attr("href") if !exists || href == "" { return } // 检查是否是夸克网盘链接 if strings.Contains(href, "pan.quark.cn") { // 去重 if !linkMap[href] { linkMap[href] = true links = append(links, model.Link{ Type: "quark", URL: href, Password: password, }) } } }) return links } // extractContent 提取简介 func (p *DjgouPlugin) extractContent(mainContent *goquery.Selection) string { content := strings.TrimSpace(mainContent.Text()) // 清理空白字符 content = regexp.MustCompile(`\s+`).ReplaceAllString(content, " ") // 限制长度 if len(content) > 300 { content = content[:300] + "..." } return content } // doRequestWithRetry 带重试机制的HTTP请求 func (p *DjgouPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) { maxRetries := 3 var lastErr error for i := 0; i < maxRetries; i++ { if i > 0 { // 指数退避重试 backoff := time.Duration(1< img").Attr("data-src"); exists && picURL != "" { images = append(images, picURL) } result.Images = images // 构建内容描述 var contentParts []string if quality != "" { contentParts = append(contentParts, "【"+quality+"】") } if director != "" { contentParts = append(contentParts, "导演:"+director) } if len(actors) > 0 { actorStr := strings.Join(actors[:min(3, len(actors))], "、") // 只显示前3个演员 if len(actors) > 3 { actorStr += "等" } contentParts = append(contentParts, "主演:"+actorStr) } if plot != "" { contentParts = append(contentParts, plot) } result.Content = strings.Join(contentParts, "\n") result.Channel = "" // 插件搜索结果不设置频道名,只有Telegram频道结果才设置 result.Datetime = time.Time{} // 使用零值而不是nil,参考jikepan插件标准 return result } // enhanceWithDetails 异步获取详情页信息以获取下载链接 func (p *DuoduoAsyncPlugin) enhanceWithDetails(client *http.Client, results []model.SearchResult) []model.SearchResult { var enhancedResults []model.SearchResult var mu sync.Mutex var wg sync.WaitGroup // 限制并发数 semaphore := make(chan struct{}, MaxConcurrency) for _, result := range results { wg.Add(1) go func(r model.SearchResult) { defer wg.Done() // 获取信号量 semaphore <- struct{}{} defer func() { <-semaphore }() // 从UniqueID提取ID parts := strings.Split(r.UniqueID, "-") if len(parts) < 2 { mu.Lock() enhancedResults = append(enhancedResults, r) mu.Unlock() return } itemID := parts[1] // 检查缓存 if cached, ok := detailCache.Load(itemID); ok { if cachedResult, ok := cached.(model.SearchResult); ok { atomic.AddInt64(&cacheHits, 1) mu.Lock() enhancedResults = append(enhancedResults, cachedResult) mu.Unlock() return } } atomic.AddInt64(&cacheMisses, 1) // 获取详情页链接和图片 detailLinks, detailImages := p.fetchDetailLinksAndImages(client, itemID) r.Links = detailLinks // 合并图片:优先使用详情页的海报,如果没有则使用搜索结果的图片 if len(detailImages) > 0 { r.Images = detailImages } // 缓存结果 detailCache.Store(itemID, r) mu.Lock() enhancedResults = append(enhancedResults, r) mu.Unlock() }(result) } wg.Wait() return enhancedResults } // doRequestWithRetry 带重试机制的HTTP请求 func (p *DuoduoAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) { maxRetries := 3 var lastErr error for i := 0; i < maxRetries; i++ { if i > 0 { // 指数退避 backoff := time.Duration(1< 0 { avgSearchTime = float64(totalSearchTime) / float64(totalSearchRequests) / 1e6 // 转换为毫秒 } if totalDetailRequests > 0 { avgDetailTime = float64(totalDetailTime) / float64(totalDetailRequests) / 1e6 // 转换为毫秒 } if totalCacheHits+totalCacheMisses > 0 { cacheHitRate = float64(totalCacheHits) / float64(totalCacheHits+totalCacheMisses) * 100 } return map[string]interface{}{ "search_requests": totalSearchRequests, "detail_page_requests": totalDetailRequests, "cache_hits": totalCacheHits, "cache_misses": totalCacheMisses, "cache_hit_rate": cacheHitRate, "avg_search_time_ms": avgSearchTime, "avg_detail_time_ms": avgDetailTime, "total_search_time_ns": totalSearchTime, "total_detail_time_ns": totalDetailTime, } } ================================================ FILE: plugin/duoduo/html结构分析.md ================================================ # Duoduo网站 (多多) 搜索结果HTML结构分析 ## 网站信息 - **网站名称**: 多多 - **域名**: `tv.yydsys.top` - **搜索URL格式**: `https://tv.yydsys.top/index.php/vod/search/wd/{关键词}.html` - **详情页URL格式**: `https://tv.yydsys.top/index.php/vod/detail/id/{ID}.html` - **主要特点**: 多多系列网盘资源站,提供高清影视资源 ## HTML结构 基于DYXS2模板系统,与 Labi、Shandian、Muou 网站使用**完全相同的HTML结构**: ### 搜索结果页面结构 ```html
``` ### 单个搜索结果结构 - **封面图片**: `.video-cover .module-item-cover .module-item-pic` - **详情页链接**: `href="/index.php/vod/detail/id/{ID}.html"` - **标题**: `.video-info-header h3 a` - **资源状态**: `.video-serial`(如"更新至11集") - **分类信息**: `.video-info-aux .tag-link` - **导演主演**: `.video-info-main .video-info-items` - **操作按钮**: `.video-info-footer` ### 详情页面结构 - **下载链接区域**: `#download-list` - **网盘类型**: `.module-tab-item span[data-dropdown-value]` - **下载链接**: `data-clipboard-text` 属性或 `.module-row-title p` ## 与其他网站对比 | 项目 | Duoduo (多多) | Labi | Shandian | Muou | |------|---------------|------|----------|------| | 域名 | tv.yydsys.top | xiaocge.fun | 1.95.79.193 | 123.666291.xyz | | 协议 | HTTPS | HTTP | HTTP | HTTP | | HTML结构 | **完全一致** | **完全一致** | **完全一致** | **完全一致** | | 模板系统 | DYXS2 | DYXS2 | DYXS2 | DYXS2 | ## 提取逻辑 与 Labi、Shandian、Muou 插件使用相同的提取逻辑: 1. **搜索结果页面**: 查找 `.module-search-item` 元素 2. **详情页面**: 查找 `#download-list .module-row-one` 获取下载链接 3. **网盘类型**: 根据链接URL自动识别(可能是夸克、UC、百度等) ## 重要发现和修正 ### 1. 详情页链接提取 ⚠️ 重要修正 **错误的提取方式:** ```html
``` **正确的提取方式:** ```html ``` ### 2. 反爬虫机制 🚫 网站具有时间限制的反爬虫遮罩层: - **限制时间**: 05:00-18:00 显示遮罩 - **可用时间**: 18:00-05:00 不显示遮罩 - **绕过方式**: 设置适当的请求头,模拟正常浏览器行为 ### 3. 网盘类型支持 💾 该网站支持四种主要网盘: - **夸克网盘**: `https://pan.quark.cn/s/5c258bac77e9` - **百度网盘**: `https://pan.baidu.com/s/1-3T82ScmmHORlxNCzBiDxQ?pwd=yyds` - **UC网盘**: `https://drive.uc.cn/s/985330f160cd4` - **迅雷网盘**: `https://pan.xunlei.com/s/VOW914w3izuHrOBPtJlwFYkuA1?pwd=nxv9` ### 4. 下载链接提取 在详情页中,下载链接位于: ```html ``` ## 注意事项 1. **协议**: 使用HTTPS,安全性更高 2. **反爬虫**: 注意时间限制,可能需要在特定时间段访问 3. **多网盘**: 支持夸克、百度、UC、迅雷四种网盘 4. **链接提取**: 必须从 `.video-info-header h3 a` 提取详情页链接 5. **域名稳定性**: 注意域名可能变化,需要支持域名切换 ================================================ FILE: plugin/dyyj/dyyj.go ================================================ package dyyj import ( "context" "fmt" "io" "log" "net/http" "net/url" "os" "regexp" "strings" "sync" "time" "github.com/PuerkitoBio/goquery" "pansou/model" "pansou/plugin" ) const ( PluginName = "dyyj" DisplayName = "电影云集" Description = "电影云集 - 影视资源网盘链接搜索" BaseURL = "https://bbs.dyyjmax.org" SearchPath = "/?q=%s" UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" MaxResults = 100 MaxConcurrency = 100 RequestTimeout = 30 * time.Second // HTTP连接池配置(性能优化) MaxIdleConns = 100 // 最大空闲连接数 MaxIdleConnsPerHost = 100 // 每个主机的最大空闲连接数 MaxConnsPerHost = 100 // 每个主机的最大连接数 IdleConnTimeout = 90 * time.Second // 空闲连接超时 TLSHandshakeTimeout = 10 * time.Second // TLS握手超时 ExpectContinueTimeout = 1 * time.Second // Expect: 100-continue超时 ) // 预编译的正则表达式(性能优化:避免重复编译) var ( // 提取文章ID的正则 postIDRegex = regexp.MustCompile(`/d/(\d+)`) // 提取noscript标签的正则 noscriptRegex = regexp.MustCompile(`]*id=["']flarum-content["'][^>]*>([\s\S]*?)`) // 提取li标签内链接的正则 liLinkRegex = regexp.MustCompile(`]*>\s*]*href=["']([^"']*\/d\/[^"']*)["'][^>]*>([\s\S]*?)\s*`) // 清理HTML标签的正则 htmlTagRegex = regexp.MustCompile(`<[^>]+>`) // 提取链接的正则 linkHrefRegex = regexp.MustCompile(`href=["']([^"']*\/d\/[^"']*)["']`) // 提取发布时间meta标签的正则 publishTimeRegexes = []*regexp.Regexp{ regexp.MustCompile(`]*datetime=["']([^"']+)["']`), } // 网盘链接匹配模式(预编译,性能优化) networkDiskPatterns = []struct { name string regex *regexp.Regexp urlType string }{ {"夸克网盘", regexp.MustCompile(`

夸克[^<]*

\s*

]*href\s*=\s*["']([^"']+)["'][^>]*>`), "quark"}, {"百度网盘", regexp.MustCompile(`

百度[^<]*

\s*

]*href\s*=\s*["']([^"']+)["'][^>]*>`), "baidu"}, {"阿里云盘", regexp.MustCompile(`

阿里[^<]*

\s*

]*href\s*=\s*["']([^"']+)["'][^>]*>`), "aliyun"}, {"天翼云盘", regexp.MustCompile(`

天翼[^<]*

\s*

]*href\s*=\s*["']([^"']+)["'][^>]*>`), "tianyi"}, {"迅雷网盘", regexp.MustCompile(`

迅雷[^<]*

\s*

]*href\s*=\s*["']([^"']+)["'][^>]*>`), "xunlei"}, {"通用网盘", regexp.MustCompile(`]*href\s*=\s*["'](https?://[^"']*(?:pan|drive|cloud)[^"']*)["'][^>]*>`), "others"}, } ) // DyyjPlugin 电影云集插件 type DyyjPlugin struct { *plugin.BaseAsyncPlugin debugMode bool detailCache sync.Map // 缓存详情页结果 cacheTTL time.Duration optimizedClient *http.Client // 优化的HTTP客户端(连接池) } // init 注册插件 func init() { plugin.RegisterGlobalPlugin(NewDyyjPlugin()) } // NewDyyjPlugin 创建新的电影云集插件实例 func NewDyyjPlugin() *DyyjPlugin { debugMode := false // 生产环境关闭调试 p := &DyyjPlugin{ BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(PluginName, 2), // 质量良好,优先级2 debugMode: debugMode, cacheTTL: 30 * time.Minute, // 详情页缓存30分钟 optimizedClient: createOptimizedHTTPClient(), // 创建优化的HTTP客户端 } return p } // createOptimizedHTTPClient 创建优化的HTTP客户端(连接池配置) func createOptimizedHTTPClient() *http.Client { transport := &http.Transport{ MaxIdleConns: MaxIdleConns, MaxIdleConnsPerHost: MaxIdleConnsPerHost, MaxConnsPerHost: MaxConnsPerHost, IdleConnTimeout: IdleConnTimeout, TLSHandshakeTimeout: TLSHandshakeTimeout, ExpectContinueTimeout: ExpectContinueTimeout, ForceAttemptHTTP2: true, // 启用HTTP/2支持 DisableKeepAlives: false, // 启用Keep-Alive连接复用 } return &http.Client{ Transport: transport, Timeout: RequestTimeout, } } // Name 插件名称 func (p *DyyjPlugin) Name() string { return PluginName } // DisplayName 插件显示名称 func (p *DyyjPlugin) DisplayName() string { return DisplayName } // Description 插件描述 func (p *DyyjPlugin) Description() string { return Description } // Search 搜索接口 func (p *DyyjPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { result, err := p.SearchWithResult(keyword, ext) if err != nil { return nil, err } return result.Results, nil } // SearchWithResult 执行搜索并返回包含IsFinal标记的结果 func (p *DyyjPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) { return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext) } // searchImpl 搜索实现 func (p *DyyjPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) { if p.debugMode { log.Printf("[DYYJ] 开始搜索: %s", keyword) } // 第一步:执行搜索获取结果列表 // 使用优化的客户端(连接池)而不是传入的client searchResults, err := p.executeSearch(p.optimizedClient, keyword) if err != nil { if p.debugMode { log.Printf("[DYYJ] 执行搜索失败: %v", err) } return nil, fmt.Errorf("[%s] 执行搜索失败: %w", p.Name(), err) } if p.debugMode { log.Printf("[DYYJ] 搜索获取到 %d 个结果", len(searchResults)) } // 第二步:先对标题进行关键词过滤,只处理包含关键词的结果(避免不必要的详情页请求) titleFilteredResults := p.filterByTitleKeyword(searchResults, keyword) if p.debugMode { log.Printf("[DYYJ] 标题关键词过滤后剩余 %d 个结果(将只对这些结果获取详情页)", len(titleFilteredResults)) } // 第三步:并发获取详情页链接(只对标题包含关键词的结果) // 使用优化的客户端(连接池)而不是传入的client finalResults := p.fetchDetailLinks(p.optimizedClient, titleFilteredResults, keyword) if p.debugMode { log.Printf("[DYYJ] 最终获取到 %d 个有效结果", len(finalResults)) } // 第四步:最终关键词过滤(对标题和内容都进行过滤,标准网盘插件需要过滤) filteredResults := plugin.FilterResultsByKeyword(finalResults, keyword) if p.debugMode { log.Printf("[DYYJ] 最终关键词过滤后剩余 %d 个结果", len(filteredResults)) } return filteredResults, nil } // executeSearch 执行搜索请求 func (p *DyyjPlugin) executeSearch(client *http.Client, keyword string) ([]model.SearchResult, error) { // 构建搜索URL searchURL := fmt.Sprintf("%s%s", BaseURL, fmt.Sprintf(SearchPath, url.QueryEscape(keyword))) if p.debugMode { log.Printf("[DYYJ] 搜索URL: %s", searchURL) } // 创建带超时的上下文 ctx, cancel := context.WithTimeout(context.Background(), RequestTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { if p.debugMode { log.Printf("[DYYJ] 创建搜索请求失败: %v", err) } return nil, fmt.Errorf("[%s] 创建搜索请求失败: %w", p.Name(), err) } // 设置完整的请求头 req.Header.Set("User-Agent", UserAgent) req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") req.Header.Set("Connection", "keep-alive") req.Header.Set("Upgrade-Insecure-Requests", "1") req.Header.Set("Cache-Control", "max-age=0") req.Header.Set("Referer", BaseURL+"/") resp, err := p.doRequestWithRetry(req, client) if err != nil { if p.debugMode { log.Printf("[DYYJ] 搜索请求失败: %v", err) } return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err) } defer resp.Body.Close() if p.debugMode { log.Printf("[DYYJ] 搜索请求响应状态码: %d", resp.StatusCode) } if resp.StatusCode != 200 { if p.debugMode { log.Printf("[DYYJ] 搜索请求HTTP状态错误: %d", resp.StatusCode) } return nil, fmt.Errorf("[%s] 搜索请求HTTP状态错误: %d", p.Name(), resp.StatusCode) } // 读取响应体用于调试 bodyBytes, err := io.ReadAll(resp.Body) if err != nil { if p.debugMode { log.Printf("[DYYJ] 读取响应体失败: %v", err) } return nil, fmt.Errorf("[%s] 读取响应体失败: %w", p.Name(), err) } bodyString := string(bodyBytes) if p.debugMode { log.Printf("[DYYJ] 响应体大小: %d 字节", len(bodyString)) // 保存完整HTML到文件用于分析 filename := fmt.Sprintf("./dyyj_search_%s_%d.html", url.QueryEscape(keyword), time.Now().Unix()) if err := os.WriteFile(filename, bodyBytes, 0644); err == nil { log.Printf("[DYYJ] 完整HTML已保存到: %s", filename) } else { log.Printf("[DYYJ] 保存HTML文件失败: %v", err) } // 输出HTML的前2000个字符用于调试 previewLen := 2000 if len(bodyString) < previewLen { previewLen = len(bodyString) } log.Printf("[DYYJ] HTML内容预览(前%d字符):\n%s", previewLen, bodyString[:previewLen]) // 检查关键元素是否存在 hasNoscript := strings.Contains(bodyString, "") hasLI := strings.Contains(bodyString, "

  • ") hasIDFlarumContent := strings.Contains(bodyString, "id=\"flarum-content\"") || strings.Contains(bodyString, "id='flarum-content'") log.Printf("[DYYJ] HTML结构检查: noscript=%v, flarum-content=%v, id=flarum-content=%v, container=%v, ul=%v, li=%v", hasNoscript, hasFlarumContent, hasIDFlarumContent, hasContainer, hasUL, hasLI) // 查找所有noscript标签 noscriptCount := strings.Count(bodyString, "") if noscriptEnd > 0 { noscriptContent := bodyString[noscriptStart : noscriptStart+noscriptEnd+10] noscriptIndex++ hasFlarumInNoscript := strings.Contains(noscriptContent, "flarum-content") hasULInNoscript := strings.Contains(noscriptContent, "