[
  {
    "path": ".dockerignore",
    "content": "# Git相关\n.git\n.gitignore\n.github\n\n# 文档和其他非必要文件\nREADME.md\ndocs/\n*.md\nLICENSE\n\n# 开发和测试相关\n*_test.go\n*.test\n*.out\n*.prof\n\n# 构建产物\npansou\npansou_*\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n# 缓存和临时文件\n.DS_Store\ncache/\ntmp/\n.idea/\n.vscode/\n\n# 其他\nDockerfile\n.dockerignore \n"
  },
  {
    "path": ".github/workflows/docker_ci.yml",
    "content": "name: 构建并发布Docker镜像\n\non:\n  push:\n    branches:\n      - \"main\"\n    paths-ignore:\n      - \"README.md\"\n      - \"docs/**\"\n  pull_request:\n    branches:\n      - \"main\"\n  workflow_dispatch:\n\njobs:\n  build-and-push:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n    steps:\n      - name: 检出代码\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: 设置QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: 设置Docker Buildx\n        uses: docker/setup-buildx-action@v3\n        with:\n          buildkitd-flags: --debug\n\n      - name: 登录到GitHub容器注册表\n        if: github.event_name != 'pull_request'\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.DOCKER }} \n\n      - name: 提取Docker元数据\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/pansou\n          tags: |\n            type=ref,event=branch\n            type=ref,event=pr\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}.{{minor}}\n            type=sha,format=short\n            type=raw,value=latest,enable={{is_default_branch}}\n\n      - name: 构建并推送Docker镜像\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          # 这是关键修改点\n          platforms: linux/amd64,linux/arm64\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          build-args: |\n            BUILD_DATE=${{ github.event.repository.updated_at }}\n            VCS_REF=${{ github.sha }}\n            VERSION=${{ steps.meta.outputs.version }}\n"
  },
  {
    "path": ".gitignore",
    "content": ".DS_Store\n"
  },
  {
    "path": "Dockerfile",
    "content": "# 构建阶段\n# 使用 --platform=$BUILDPLATFORM 确保构建器始终在运行 Actions 的机器的原生架构上运行 (通常是 linux/amd64)\n# $BUILDPLATFORM 是 buildx 自动提供的变量\nFROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder\n\n# 安装构建依赖\nRUN apk add --no-cache git ca-certificates tzdata\n\n# 设置工作目录\nWORKDIR /app\n\n# 复制依赖文件\nCOPY go.mod go.sum ./\n\n# 下载依赖\nRUN go mod download\n\n# 复制源代码\nCOPY . .\n\n# 构建参数\nARG VERSION=dev\nARG BUILD_DATE=unknown\nARG VCS_REF=unknown\n\n# 这是 buildx 自动传入的目标平台架构参数，例如 amd64, arm64\nARG TARGETARCH\n\n# 构建应用\n# Go 语言原生支持交叉编译，这里会根据传入的 TARGETARCH 编译出对应平台的可执行文件\nRUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags=\"-s -w -extldflags '-static'\" -o pansou .\n\n# 运行阶段\n# 这一阶段会根据 buildx 的 --platform 参数选择正确的基础镜像 (例如 linux/arm64 会拉取 arm64/alpine)\nFROM alpine:3.19\n\n# 添加运行时依赖\nRUN apk add --no-cache ca-certificates tzdata\n\n# 创建缓存目录\nRUN mkdir -p /app/cache\n\n# 从构建阶段复制可执行文件\n# buildx 会智能地从对应平台的 builder 中复制正确的可执行文件\nCOPY --from=builder /app/pansou /app/pansou\n\n# 设置工作目录\nWORKDIR /app\n\n# 暴露端口\nEXPOSE 8888\n\n# 设置环境变量\n# ENABLED_PLUGINS: 必须指定启用的插件，多个插件用逗号分隔\n# AUTH_ENABLED: 认证功能默认关闭，可通过环境变量启用\nENV CACHE_PATH=/app/cache \\\n    CACHE_ENABLED=true \\\n    TZ=Asia/Shanghai \\\n    ASYNC_PLUGIN_ENABLED=true \\\n    ASYNC_RESPONSE_TIMEOUT=4 \\\n    ASYNC_MAX_BACKGROUND_WORKERS=20 \\\n    ASYNC_MAX_BACKGROUND_TASKS=100 \\\n    ASYNC_CACHE_TTL_HOURS=1 \\\n    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 \\\n    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 \\\n    AUTH_ENABLED=false \\\n    AUTH_TOKEN_EXPIRY=24\n\n# 构建参数\nARG VERSION=dev\nARG BUILD_DATE=unknown\nARG VCS_REF=unknown\n\n# 添加镜像标签\nLABEL org.opencontainers.image.title=\"PanSou\" \\\n      org.opencontainers.image.description=\"高性能网盘资源搜索API服务\" \\\n      org.opencontainers.image.version=\"${VERSION}\" \\\n      org.opencontainers.image.created=\"${BUILD_DATE}\" \\\n      org.opencontainers.image.revision=\"${VCS_REF}\" \\\n      org.opencontainers.image.url=\"https://github.com/fish2018/pansou\" \\\n      org.opencontainers.image.source=\"https://github.com/fish2018/pansou\" \\\n      maintainer=\"fish2018\"\n\n# 运行应用\nCMD [\"/app/pansou\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 fish2018\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# PanSou 网盘搜索API\n\nPanSou是一个高性能的网盘资源搜索API服务，支持TG搜索和自定义插件搜索。系统设计以性能和可扩展性为核心，支持并发搜索、结果智能排序和网盘类型分类。\n\n[//]: # (MCP服务文档: [MCP-SERVICE.md]&#40;docs/MCP-SERVICE.md&#41;)\n\n\n## 特性（[详见系统设计文档](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)）\n\n- **高性能搜索**：并发执行多个TG频道及异步插件搜索，显著提升搜索速度；工作池设计，高效管理并发任务\n- **网盘类型分类**：自动识别多种网盘链接，按类型归类展示\n- **智能排序**：基于插件等级、时间新鲜度和优先关键词的多维度综合排序算法\n- **异步插件系统**：支持通过插件扩展搜索来源，支持\"尽快响应，持续处理\"的异步搜索模式，解决了某些搜索源响应时间长的问题。详情参考[**插件开发指南**](docs/插件开发指南.md)\n- **二级缓存**：分片内存+分片磁盘缓存机制，大幅提升重复查询速度和并发性能  \n\n## MCP 服务\n\nPanSou 还提供了一个基于 [Model Context Protocol (MCP)](https://modelcontextprotocol.io) 的服务，可以将搜索功能集成到 Claude Desktop 等支持 MCP 的应用中。详情请参阅 [MCP 服务文档](docs/MCP-SERVICE.md)。\n\n## 支持的网盘类型\n\n百度网盘 (`baidu`)、阿里云盘 (`aliyun`)、夸克网盘 (`quark`)、天翼云盘 (`tianyi`)、UC网盘 (`uc`)、移动云盘 (`mobile`)、115网盘 (`115`)、PikPak (`pikpak`)、迅雷网盘 (`xunlei`)、123网盘 (`123`)、磁力链接 (`magnet`)、电驴链接 (`ed2k`)、其他 (`others`)\n\n## 快速开始\n\n在 Github 上先[![Fork me on GitHub](https://raw.githubusercontent.com/fishforks/fish2018/refs/heads/main/forkme.png)](https://github.com/fish2018/pansou/fork)\n本项目，并点上 Star !!!\n\n### 使用Docker部署\n[qqpd搜索插件文档](plugin/qqpd/README.md)  \n[gying搜索插件文档](plugin/gying/README.md)   \n[weibo搜索插件文档](plugin/weibo/README.md)   \n[常见问题总结](https://github.com/fish2018/pansou/issues/46)  \n[TG/QQ频道/插件/微博](https://github.com/fish2018/pansou/issues/4)\n\n#### **1、前后端集成版**\n\n##### 直接使用Docker命令\n\n一键启动，开箱即用\n\n```\ndocker run -d --name pansou -p 80:80 ghcr.io/fish2018/pansou-web\n```\n\n##### 使用Docker Compose（推荐）\n```\n# 下载配置文件\ncurl -o docker-compose.yml https://raw.githubusercontent.com/fish2018/pansou-web/refs/heads/main/docker-compose.yml\n\n# 启动服务\ndocker-compose up -d\n\n# 查看日志\ndocker-compose logs -f\n```\n\n#### **2、纯后端API版**\n\n##### 直接使用Docker命令\n\n```bash\ndocker run -d --name pansou -p 8888:8888 ghcr.io/fish2018/pansou:latest\n```\n\n##### 使用Docker Compose（推荐）\n\n```bash\n# 下载配置文件\ncurl -o docker-compose.yml  https://raw.githubusercontent.com/fish2018/pansou/refs/heads/main/docker-compose.yml\n\n# 启动服务\ndocker-compose up -d\n\n# 访问服务\nhttp://localhost:8888\n```\n\n### 从源码安装\n\n#### 环境要求\n\n- Go 1.18+\n- 可选：SOCKS5代理（用于访问受限地区的Telegram站点）\n\n1. 克隆仓库\n\n```bash\ngit clone https://github.com/fish2018/pansou.git\ncd pansou\n```\n\n2. 配置环境变量（可选）\n\n#### 基础配置\n\n| 环境变量 | 描述 | 默认值 | 说明 |\n|----------|------|--------|------|\n| **PORT** | 服务端口 | `8888` | 修改服务监听端口 |\n| **PROXY** | SOCKS5代理 | 无 | 如：`PROXY=socks5://127.0.0.1:1080` |\n| **HTTPS_PROXY/HTTP_PROXY** | HTTPS/HTTP代理 | 无 | 如：`HTTPS_PROXY=http://127.0.0.1:1080`,`HTTP_PROXY=http://127.0.0.1:1080` |\n| **CHANNELS** | 默认搜索的TG频道 | `tgsearchers3` | 多个频道用逗号分隔 |\n| **ENABLED_PLUGINS** | 指定启用插件，多个插件用逗号分隔 | 无 | 必须显式指定 |\n\n#### 认证配置（可选）\n\nPanSou支持可选的安全认证功能，默认关闭。开启后，所有API接口（除登录接口外）都需要提供有效的JWT Token。详见[认证系统设计文档](docs/认证系统设计.md)。\n\n| 环境变量 | 描述 | 默认值 | 说明 |\n|----------|------|--------|------|\n| **AUTH_ENABLED** | 是否启用认证 | `false` | 设置为`true`启用认证功能 |\n| **AUTH_USERS** | 用户账号配置 | 无 | 格式：`user1:pass1,user2:pass2` |\n| **AUTH_TOKEN_EXPIRY** | Token有效期（小时） | `24` | JWT Token的有效时长 |\n| **AUTH_JWT_SECRET** | JWT签名密钥 | 自动生成 | 用于签名Token，建议手动设置 |\n\n**认证配置示例：**\n\n```bash\n# 启用认证并配置单个用户\ndocker run -d --name pansou -p 8888:8888 \\\n  -e AUTH_ENABLED=true \\\n  -e AUTH_USERS=admin:admin123 \\\n  -e AUTH_TOKEN_EXPIRY=24 \\\n  ghcr.io/fish2018/pansou:latest\n\n# 配置多个用户\ndocker run -d --name pansou -p 8888:8888 \\\n  -e AUTH_ENABLED=true \\\n  -e AUTH_USERS=admin:pass123,user1:pass456,user2:pass789 \\\n  ghcr.io/fish2018/pansou:latest\n```\n\n**认证API接口：**\n\n- `POST /api/auth/login` - 用户登录，获取Token\n- `POST /api/auth/verify` - 验证Token有效性\n- `POST /api/auth/logout` - 退出登录（客户端删除Token）\n\n**使用Token调用API：**\n\n```bash\n# 1. 登录获取Token\ncurl -X POST http://localhost:8888/api/auth/login \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"username\":\"admin\",\"password\":\"admin123\"}'\n\n# 响应：{\"token\":\"eyJhbGc...\",\"expires_at\":1234567890,\"username\":\"admin\"}\n\n# 2. 使用Token调用搜索API\ncurl -X POST http://localhost:8888/api/search \\\n  -H \"Authorization: Bearer eyJhbGc...\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"kw\":\"速度与激情\"}'\n```\n\n#### 高级配置（默认值即可）\n\n<details>\n<summary>点击展开高级配置选项（通常不需要修改）</summary>\n\n| 环境变量 | 描述 | 默认值 |\n|----------|------|--------|\n| CONCURRENCY | 并发搜索数 | 自动计算 |\n| CACHE_TTL | 缓存有效期（分钟） | `60` |\n| CACHE_MAX_SIZE | 最大缓存大小(MB) | `100` |\n| PLUGIN_TIMEOUT | 插件超时时间(秒) | `30` |\n| ASYNC_RESPONSE_TIMEOUT | 快速响应超时(秒) | `4` |\n| ASYNC_LOG_ENABLED | 异步插件详细日志 | `true` | \n| CACHE_PATH | 缓存文件路径 | `./cache` |\n| SHARD_COUNT | 缓存分片数量 | `8` |\n| CACHE_WRITE_STRATEGY | 缓存写入策略(immediate/hybrid) | `hybrid` |\n| ENABLE_COMPRESSION | 是否启用压缩 | `false` |\n| MIN_SIZE_TO_COMPRESS | 最小压缩阈值(字节) | `1024` |\n| GC_PERCENT | Go GC触发百分比 | `50` |\n| ASYNC_MAX_BACKGROUND_WORKERS | 最大后台工作者数量 | CPU核心数×5 |\n| ASYNC_MAX_BACKGROUND_TASKS | 最大后台任务数量 | 工作者数×5 |\n| ASYNC_CACHE_TTL_HOURS | 异步缓存有效期(小时) | `1` |\n| ASYNC_PLUGIN_ENABLED | 异步插件是否启用 | `true` |\n| HTTP_READ_TIMEOUT | HTTP读取超时(秒) | 自动计算 |\n| HTTP_WRITE_TIMEOUT | HTTP写入超时(秒) | 自动计算 |\n| HTTP_IDLE_TIMEOUT | HTTP空闲超时(秒) | `120` |\n| HTTP_MAX_CONNS | HTTP最大连接数 | 自动计算 |\n\n</details>\n\n3. 构建\n\n```linux\nCGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=\"-s -w -extldflags '-static'\" -o pansou .\n```\n\n4. 运行\n\n```bash\n./pansou\n```\n\n### 其他配置参考\n\n<details>\n<summary>点击展开 supervisor 配置参考</summary>\n\n```\n[program:pansou]\nenvironment=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\"\ncommand=/home/work/pansou/pansou\ndirectory=/home/work/pansou\nautostart=true\nautorestart=true\nstartsecs=5\nstartretries=3\nexitcodes=0\nstopwaitsecs=10\nstopasgroup=true\nkillasgroup=true\n```\n\n</details>\n\n<details>\n<summary>点击展开 nginx 配置参考</summary>\n\n```\nserver {\n    listen 80;\n    server_name pansou.252035.xyz;\n\n    # 将 HTTP 重定向到 HTTPS\n    return 301 https://$host$request_uri;\n}\n\nlimit_req_zone $binary_remote_addr zone=api_limit:10m rate=60r/m;\n\nserver {\n    listen 443 ssl http2;\n    server_name pansou.252035.xyz;\n\n    access_log /home/work/logs/pansou.log;\n\n    # 证书和密钥路径\n    ssl_certificate /etc/letsencrypt/live/252035.xyz/fullchain.pem;\n    ssl_certificate_key /etc/letsencrypt/live/252035.xyz/privkey.pem;\n\n    # 增强 SSL 安全性\n    ssl_protocols TLSv1.2 TLSv1.3;\n    ssl_ciphers EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH;\n    ssl_prefer_server_ciphers on;\n\n    # 后端代理，应用限流\n    location / {\n        # 应用限流规则\n        limit_req zone=api_limit burst=10 nodelay;\n        # 当超过限制时返回 429 状态码\n        limit_req_status 429;\n\n        proxy_pass http://127.0.0.1:8888;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n```\n\n</details>\n\n## API文档\n\n### 认证说明\n\n当启用认证功能（`AUTH_ENABLED=true`）时，除登录和健康检测接口外的所有API接口都需要提供有效的JWT Token。\n\n**请求头格式**：\n```\nAuthorization: Bearer <your-jwt-token>\n```\n\n**获取Token**：\n\n1. 调用登录接口获取Token（详见下方[认证API](#认证API)）\n2. 在后续所有API请求的Header中添加`Authorization: Bearer <token>`\n3. Token过期后需要重新登录获取新Token\n\n**示例**：\n```bash\n# 未启用认证时\ncurl -X POST http://localhost:8888/api/search \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"kw\":\"速度与激情\"}'\n\n# 启用认证时\ncurl -X POST http://localhost:8888/api/search \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer eyJhbGc...\" \\\n  -d '{\"kw\":\"速度与激情\"}'\n```\n\n### 认证API\n\n#### 用户登录\n\n获取JWT Token用于后续API调用。\n\n**接口地址**：`/api/auth/login`  \n**请求方法**：`POST`  \n**Content-Type**：`application/json`  \n**是否需要认证**：否\n\n**请求参数**：\n\n| 参数名 | 类型 | 必填 | 描述 |\n|--------|------|------|------|\n| username | string | 是 | 用户名 |\n| password | string | 是 | 密码 |\n\n**请求示例**：\n```bash\ncurl -X POST http://localhost:8888/api/auth/login \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"username\":\"admin\",\"password\":\"admin123\"}'\n```\n\n**成功响应**：\n```json\n{\n  \"token\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n  \"expires_at\": 1234567890,\n  \"username\": \"admin\"\n}\n```\n\n**错误响应**：\n```json\n{\n  \"error\": \"用户名或密码错误\"\n}\n```\n\n#### 验证Token\n\n验证当前Token是否有效。\n\n**接口地址**：`/api/auth/verify`  \n**请求方法**：`POST`  \n**是否需要认证**：是\n\n**请求示例**：\n```bash\ncurl -X POST http://localhost:8888/api/auth/verify \\\n  -H \"Authorization: Bearer eyJhbGc...\"\n```\n\n**成功响应**：\n```json\n{\n  \"valid\": true,\n  \"username\": \"admin\"\n}\n```\n\n#### 退出登录\n\n退出当前登录（客户端删除Token即可）。\n\n**接口地址**：`/api/auth/logout`  \n**请求方法**：`POST`  \n**是否需要认证**：否\n\n**请求示例**：\n```bash\ncurl -X POST http://localhost:8888/api/auth/logout\n```\n\n**成功响应**：\n```json\n{\n  \"message\": \"退出成功\"\n}\n```\n\n### 搜索API\n\n搜索网盘资源。\n\n**接口地址**：`/api/search`  \n**请求方法**：`POST` 或 `GET`  \n**Content-Type**：`application/json`（POST方法）  \n**是否需要认证**：取决于`AUTH_ENABLED`配置\n\n**POST请求参数**：\n\n| 参数名 | 类型 | 必填 | 描述 |\n|--------|------|------|------|\n| kw | string | 是 | 搜索关键词 |\n| channels | string[] | 否 | 搜索的频道列表，不提供则使用默认配置 |\n| conc | number | 否 | 并发搜索数量，不提供则自动设置为频道数+插件数+10 |\n| refresh | boolean | 否 | 强制刷新，不使用缓存，便于调试和获取最新数据 |\n| res | string | 否 | 结果类型：all(返回所有结果)、results(仅返回results)、merge(仅返回merged_by_type)，默认为merge |\n| src | string | 否 | 数据来源类型：all(默认，全部来源)、tg(仅Telegram)、plugin(仅插件) |\n| plugins | string[] | 否 | 指定搜索的插件列表，不指定则搜索全部插件 |\n| cloud_types | string[] | 否 | 指定返回的网盘类型列表，支持：baidu、aliyun、quark、tianyi、uc、mobile、115、pikpak、xunlei、123、magnet、ed2k，不指定则返回所有类型 |\n| ext | object | 否 | 扩展参数，用于传递给插件的自定义参数，如{\"title_en\":\"English Title\", \"is_all\":true} |\n| filter | object | 否 | 过滤配置，用于过滤返回结果。格式：{\"include\":[\"关键词1\",\"关键词2\"],\"exclude\":[\"排除词1\",\"排除词2\"]}。include为包含关键词列表（OR关系），exclude为排除关键词列表（OR关系） |\n\n**GET请求参数**：\n\n| 参数名 | 类型 | 必填 | 描述 |\n|--------|------|------|------|\n| kw | string | 是 | 搜索关键词 |\n| channels | string | 否 | 搜索的频道列表，使用英文逗号分隔多个频道，不提供则使用默认配置 |\n| conc | number | 否 | 并发搜索数量，不提供则自动设置为频道数+插件数+10 |\n| refresh | boolean | 否 | 强制刷新，设置为\"true\"表示不使用缓存 |\n| res | string | 否 | 结果类型：all(返回所有结果)、results(仅返回results)、merge(仅返回merged_by_type)，默认为merge |\n| src | string | 否 | 数据来源类型：all(默认，全部来源)、tg(仅Telegram)、plugin(仅插件) |\n| plugins | string | 否 | 指定搜索的插件列表，使用英文逗号分隔多个插件名，不指定则搜索全部插件 |\n| cloud_types | string | 否 | 指定返回的网盘类型列表，使用英文逗号分隔多个类型，支持：baidu、aliyun、quark、tianyi、uc、mobile、115、pikpak、xunlei、123、magnet、ed2k，不指定则返回所有类型 |\n| ext | string | 否 | JSON格式的扩展参数，用于传递给插件的自定义参数，如{\"title_en\":\"English Title\", \"is_all\":true} |\n| filter | string | 否 | JSON格式的过滤配置，用于过滤返回结果。格式：{\"include\":[\"关键词1\",\"关键词2\"],\"exclude\":[\"排除词1\",\"排除词2\"]} |\n\n**POST请求示例**：\n\n```bash\n# 未启用认证\ncurl -X POST http://localhost:8888/api/search \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"kw\": \"速度与激情\",\n    \"channels\": [\"tgsearchers3\", \"xxx\"],\n    \"conc\": 2,\n    \"refresh\": true,\n    \"res\": \"merge\",\n    \"src\": \"all\",\n    \"plugins\": [\"jikepan\"],\n    \"cloud_types\": [\"baidu\", \"quark\"],\n    \"ext\": {\n      \"title_en\": \"Fast and Furious\",\n      \"is_all\": true\n    }\n  }'\n\n# 启用认证时（需要添加Authorization头）\ncurl -X POST http://localhost:8888/api/search \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\" \\\n  -d '{\n    \"kw\": \"速度与激情\",\n    \"res\": \"merge\"\n  }'\n\n# 使用过滤器（只返回包含“合集”或“全集”，且不包含“预告”或“花絮”的结果）\ncurl -X POST http://localhost:8888/api/search \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"kw\": \"唐朝诡事录\",\n    \"filter\": {\n      \"include\": [\"合集\", \"全集\"],\n      \"exclude\": [\"预告\", \"花絮\"]\n    }\n  }'\n```\n\n**GET请求示例**：\n\n```bash\n# 未启用认证\ncurl \"http://localhost:8888/api/search?kw=速度与激情&res=merge&src=tg\"\n\n# 启用认证时（需要添加Authorization头）\ncurl \"http://localhost:8888/api/search?kw=速度与激情&res=merge\" \\\n  -H \"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n\n# 使用过滤器（GET方式需要URL编码JSON）\ncurl \"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\"\n```\n\n**成功响应**：\n\n```json\n{\n  \"total\": 15,\n  \"results\": [\n    {\n      \"message_id\": \"12345\",\n      \"unique_id\": \"channel-12345\",\n      \"channel\": \"tgsearchers3\",\n      \"datetime\": \"2023-06-10T14:23:45Z\",\n      \"title\": \"速度与激情全集1-10\",\n      \"content\": \"速度与激情系列全集，1080P高清...\",\n      \"links\": [\n        {\n          \"type\": \"baidu\",\n          \"url\": \"https://pan.baidu.com/s/1abcdef\",\n          \"password\": \"1234\",\n          \"datetime\": \"2023-06-10T14:23:45Z\",\n          \"work_title\": \"速度与激情全集1-10\"\n        }\n      ],\n      \"tags\": [\"电影\", \"合集\"],\n      \"images\": [\n        \"https://cdn1.cdn-telegram.org/file/xxx.jpg\"\n      ]\n    },\n    // 更多结果...\n  ],\n  \"merged_by_type\": {\n    \"baidu\": [\n      {\n        \"url\": \"https://pan.baidu.com/s/1abcdef\",\n        \"password\": \"1234\",\n        \"note\": \"速度与激情全集1-10\",\n        \"datetime\": \"2023-06-10T14:23:45Z\",\n        \"source\": \"tg:频道名称\",\n        \"images\": [\n          \"https://cdn1.cdn-telegram.org/file/xxx.jpg\"\n        ]\n      },\n      // 更多百度网盘链接...\n    ],\n    \"quark\": [\n      {\n        \"url\": \"https://pan.quark.cn/s/xxxx\",\n        \"password\": \"\",\n        \"note\": \"凡人修仙传\",\n        \"datetime\": \"2023-06-10T15:30:22Z\",\n        \"source\": \"plugin:插件名\",\n        \"images\": []\n      }\n    ],\n    \"aliyun\": [\n      // 阿里云盘链接...\n    ]\n    // 更多网盘类型...\n  }\n}\n```\n\n**字段说明**：\n\n**SearchResult对象**：\n- `message_id`: 消息ID\n- `unique_id`: 全局唯一标识符\n- `channel`: 来源频道名称\n- `datetime`: 消息发布时间\n- `title`: 消息标题\n- `content`: 消息内容\n- `links`: 网盘链接数组\n- `tags`: 标签数组（可选）\n- `images`: TG消息中的图片链接数组（可选）\n\n**Link对象**：\n- `type`: 网盘类型（baidu、quark、aliyun等）\n- `url`: 网盘链接地址\n- `password`: 提取码/密码\n- `datetime`: 链接更新时间（可选）\n- `work_title`: 作品标题（可选）\n  - 用于区分同一消息中多个作品的链接\n  - 当一条消息包含≤4个链接时，所有链接使用相同的work_title\n  - 当一条消息包含>4个链接时，系统会智能识别每个链接对应的作品标题\n\n**MergedLink对象**：\n- `url`: 网盘链接地址\n- `password`: 提取码/密码\n- `note`: 资源说明/标题\n- `datetime`: 链接更新时间\n- `source`: 数据来源标识\n  - `tg:频道名称`: 来自Telegram频道\n  - `plugin:插件名`: 来自指定插件\n  - `unknown`: 未知来源\n- `images`: TG消息中的图片链接数组（可选）\n  - 仅在来源为Telegram频道且消息包含图片时出现\n\n\n**错误响应**：\n\n```json\n// 参数错误\n{\n  \"code\": 400,\n  \"message\": \"关键词不能为空\"\n}\n\n// 未授权（启用认证但未提供Token）\n{\n  \"error\": \"未授权：缺少认证令牌\",\n  \"code\": \"AUTH_TOKEN_MISSING\"\n}\n\n// Token无效或过期\n{\n  \"error\": \"未授权：令牌无效或已过期\",\n  \"code\": \"AUTH_TOKEN_INVALID\"\n}\n```\n\n### 健康检查\n\n检查API服务是否正常运行。\n\n**接口地址**：`/api/health`  \n**请求方法**：`GET`  \n**是否需要认证**：否（公开接口）\n\n**请求示例**：\n```bash\ncurl http://localhost:8888/api/health\n```\n\n**成功响应**：\n\n```json\n{\n  \"status\": \"ok\",\n  \"auth_enabled\": true,\n  \"plugins_enabled\": true,\n  \"plugin_count\": 16,\n  \"plugins\": [\n    \"pansearch\",\n    \"panta\", \n    \"qupansou\",\n    \"hunhepan\",\n    \"jikepan\",\n    \"pan666\",\n    \"panyq\",\n    \"susu\",\n    \"xuexizhinan\",\n    \"hdr4k\",\n    \"labi\",\n    \"shandian\",\n    \"duoduo\",\n    \"muou\",\n    \"wanou\",\n    \"ouge\",\n    \"zhizhen\",\n    \"huban\"\n  ],\n  \"channels_count\": 1,\n  \"channels\": [\n    \"tgsearchers3\"\n  ]\n}\n```\n\n**字段说明**：\n- `status`: 服务状态，\"ok\"表示正常\n- `auth_enabled`: 是否启用认证功能\n- `plugins_enabled`: 是否启用插件\n- `plugin_count`: 已启用的插件数量\n- `plugins`: 已启用的插件列表\n- `channels_count`: 配置的频道数量\n- `channels`: 配置的频道列表\n\n## 📄 许可证\n\n本项目采用 MIT 许可证。详情请见 [LICENSE](LICENSE) 文件。\n\n## ⭐ Star 历史\n\n[![Star History Chart](https://api.star-history.com/svg?repos=fish2018/pansou&type=Date)](https://star-history.com/#fish2018/pansou&Date)\n"
  },
  {
    "path": "api/auth_handler.go",
    "content": "package api\n\nimport (\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"pansou/config\"\n\t\"pansou/util\"\n)\n\n// LoginRequest 登录请求结构\ntype LoginRequest struct {\n\tUsername string `json:\"username\" binding:\"required\"`\n\tPassword string `json:\"password\" binding:\"required\"`\n}\n\n// LoginResponse 登录响应结构\ntype LoginResponse struct {\n\tToken     string `json:\"token\"`\n\tExpiresAt int64  `json:\"expires_at\"`\n\tUsername  string `json:\"username\"`\n}\n\n// LoginHandler 处理用户登录\nfunc LoginHandler(c *gin.Context) {\n\tvar req LoginRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"参数错误：用户名和密码不能为空\"})\n\t\treturn\n\t}\n\n\t// 验证认证系统是否启用\n\tif !config.AppConfig.AuthEnabled {\n\t\tc.JSON(403, gin.H{\"error\": \"认证功能未启用\"})\n\t\treturn\n\t}\n\n\t// 验证用户配置是否存在\n\tif config.AppConfig.AuthUsers == nil || len(config.AppConfig.AuthUsers) == 0 {\n\t\tc.JSON(500, gin.H{\"error\": \"认证系统未正确配置\"})\n\t\treturn\n\t}\n\n\t// 验证用户名和密码\n\tstoredPassword, exists := config.AppConfig.AuthUsers[req.Username]\n\tif !exists || storedPassword != req.Password {\n\t\tc.JSON(401, gin.H{\"error\": \"用户名或密码错误\"})\n\t\treturn\n\t}\n\n\t// 生成JWT token\n\ttoken, err := util.GenerateToken(\n\t\treq.Username,\n\t\tconfig.AppConfig.AuthJWTSecret,\n\t\tconfig.AppConfig.AuthTokenExpiry,\n\t)\n\tif err != nil {\n\t\tc.JSON(500, gin.H{\"error\": \"生成令牌失败\"})\n\t\treturn\n\t}\n\n\t// 返回token和过期时间\n\texpiresAt := time.Now().Add(config.AppConfig.AuthTokenExpiry).Unix()\n\tc.JSON(200, LoginResponse{\n\t\tToken:     token,\n\t\tExpiresAt: expiresAt,\n\t\tUsername:  req.Username,\n\t})\n}\n\n// VerifyHandler 验证token有效性\nfunc VerifyHandler(c *gin.Context) {\n\t// 如果未启用认证，直接返回有效\n\tif !config.AppConfig.AuthEnabled {\n\t\tc.JSON(200, gin.H{\n\t\t\t\"valid\": true,\n\t\t\t\"message\": \"认证功能未启用\",\n\t\t})\n\t\treturn\n\t}\n\n\t// 如果能到达这里，说明中间件已经验证通过\n\tusername, exists := c.Get(\"username\")\n\tif !exists {\n\t\tc.JSON(401, gin.H{\"error\": \"未授权\"})\n\t\treturn\n\t}\n\n\tc.JSON(200, gin.H{\n\t\t\"valid\":    true,\n\t\t\"username\": username,\n\t})\n}\n\n// LogoutHandler 退出登录（客户端删除token即可）\nfunc LogoutHandler(c *gin.Context) {\n\t// JWT是无状态的，服务端不需要处理注销\n\t// 客户端删除存储的token即可\n\tc.JSON(200, gin.H{\"message\": \"退出成功\"})\n}\n"
  },
  {
    "path": "api/filter.go",
    "content": "package api\n\nimport (\n\t\"pansou/model\"\n\t\"strings\"\n)\n\n// applyResultFilter 应用过滤器到搜索响应\nfunc applyResultFilter(response model.SearchResponse, filter *model.FilterConfig, resultType string) model.SearchResponse {\n\tif filter == nil || (len(filter.Include) == 0 && len(filter.Exclude) == 0) {\n\t\treturn response\n\t}\n\n\t// 预处理关键词（转小写）\n\tincludeKeywords := make([]string, len(filter.Include))\n\tfor i, kw := range filter.Include {\n\t\tincludeKeywords[i] = strings.ToLower(kw)\n\t}\n\t\n\texcludeKeywords := make([]string, len(filter.Exclude))\n\tfor i, kw := range filter.Exclude {\n\t\texcludeKeywords[i] = strings.ToLower(kw)\n\t}\n\n\t// 根据结果类型决定过滤策略\n\tif resultType == \"merged_by_type\" || resultType == \"\" {\n\t\t// 过滤 merged_by_type 的 note 字段\n\t\tresponse.MergedByType = filterMergedByType(response.MergedByType, includeKeywords, excludeKeywords)\n\t\t\n\t\t// 重新计算 total\n\t\ttotal := 0\n\t\tfor _, links := range response.MergedByType {\n\t\t\ttotal += len(links)\n\t\t}\n\t\tresponse.Total = total\n\t} else if resultType == \"all\" || resultType == \"results\" {\n\t\t// 过滤 results 的 title 和 links 的 work_title\n\t\tresponse.Results = filterResults(response.Results, includeKeywords, excludeKeywords)\n\t\tresponse.Total = len(response.Results)\n\t\t\n\t\t// 如果是 all 类型，也需要过滤 merged_by_type\n\t\tif resultType == \"all\" {\n\t\t\tresponse.MergedByType = filterMergedByType(response.MergedByType, includeKeywords, excludeKeywords)\n\t\t}\n\t}\n\n\treturn response\n}\n\n// filterMergedByType 过滤 merged_by_type 中的链接\nfunc filterMergedByType(mergedLinks model.MergedLinks, includeKeywords, excludeKeywords []string) model.MergedLinks {\n\tif mergedLinks == nil {\n\t\treturn nil\n\t}\n\n\tfiltered := make(model.MergedLinks)\n\t\n\tfor linkType, links := range mergedLinks {\n\t\tfilteredLinks := make([]model.MergedLink, 0)\n\t\t\n\t\tfor _, link := range links {\n\t\t\tif matchFilter(link.Note, includeKeywords, excludeKeywords) {\n\t\t\t\tfilteredLinks = append(filteredLinks, link)\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 只添加非空的类型\n\t\tif len(filteredLinks) > 0 {\n\t\t\tfiltered[linkType] = filteredLinks\n\t\t}\n\t}\n\t\n\treturn filtered\n}\n\n// filterResults 过滤 results 数组\nfunc filterResults(results []model.SearchResult, includeKeywords, excludeKeywords []string) []model.SearchResult {\n\tif results == nil {\n\t\treturn nil\n\t}\n\n\tfiltered := make([]model.SearchResult, 0)\n\t\n\tfor _, result := range results {\n\t\t// 先检查 title 是否匹配\n\t\tif !matchFilter(result.Title, includeKeywords, excludeKeywords) {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// title 匹配后，过滤 links 中的 work_title\n\t\tfilteredLinks := make([]model.Link, 0)\n\t\tfor _, link := range result.Links {\n\t\t\t// 如果 link 有 work_title，检查它；否则使用 result.Title\n\t\t\tcheckText := link.WorkTitle\n\t\t\tif checkText == \"\" {\n\t\t\t\tcheckText = result.Title\n\t\t\t}\n\t\t\t\n\t\t\tif matchFilter(checkText, includeKeywords, excludeKeywords) {\n\t\t\t\tfilteredLinks = append(filteredLinks, link)\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 只有有链接的结果才添加\n\t\tif len(filteredLinks) > 0 {\n\t\t\tresult.Links = filteredLinks\n\t\t\tfiltered = append(filtered, result)\n\t\t}\n\t}\n\t\n\treturn filtered\n}\n\n// matchFilter 检查文本是否匹配过滤条件\nfunc matchFilter(text string, includeKeywords, excludeKeywords []string) bool {\n\tlowerText := strings.ToLower(text)\n\t\n\t// 检查 exclude（任一匹配则排除）\n\tfor _, kw := range excludeKeywords {\n\t\tif strings.Contains(lowerText, kw) {\n\t\t\treturn false\n\t\t}\n\t}\n\t\n\t// 检查 include（如果有 include 列表，必须至少匹配一个）\n\tif len(includeKeywords) > 0 {\n\t\tmatched := false\n\t\tfor _, kw := range includeKeywords {\n\t\t\tif strings.Contains(lowerText, kw) {\n\t\t\t\tmatched = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !matched {\n\t\t\treturn false\n\t\t}\n\t}\n\t\n\treturn true\n}\n"
  },
  {
    "path": "api/handler.go",
    "content": "package api\n\nimport (\n\t// \"fmt\"\n\t\"net/http\"\n\t// \"os\"\n\t\n\t\"github.com/gin-gonic/gin\"\n\t\"pansou/config\"\n\t\"pansou/model\"\n\t\"pansou/service\"\n\tjsonutil \"pansou/util/json\"\n\t\"pansou/util\"\n\t\"strings\"\n)\n\n// 保存搜索服务的实例\nvar searchService *service.SearchService\n\n// SetSearchService 设置搜索服务实例\nfunc SetSearchService(service *service.SearchService) {\n\tsearchService = service\n}\n\n// SearchHandler 搜索处理函数\nfunc SearchHandler(c *gin.Context) {\n\tvar req model.SearchRequest\n\tvar err error\n\n\t// 根据请求方法不同处理参数\n\tif c.Request.Method == http.MethodGet {\n\t\t// GET方式：从URL参数获取\n\t\t// 获取keyword，必填参数\n\t\tkeyword := c.Query(\"kw\")\n\t\t\n\t\t// 处理channels参数，支持逗号分隔\n\t\tchannelsStr := c.Query(\"channels\")\n\t\tvar channels []string\n\t\t// 只有当参数非空时才处理\n\t\tif channelsStr != \"\" && channelsStr != \" \" {\n\t\t\tparts := strings.Split(channelsStr, \",\")\n\t\t\tfor _, part := range parts {\n\t\t\t\ttrimmed := strings.TrimSpace(part)\n\t\t\t\tif trimmed != \"\" {\n\t\t\t\t\tchannels = append(channels, trimmed)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 处理并发数\n\t\tconcurrency := 0\n\t\tconcStr := c.Query(\"conc\")\n\t\tif concStr != \"\" && concStr != \" \" {\n\t\t\tconcurrency = util.StringToInt(concStr)\n\t\t}\n\t\t\n\t\t// 处理强制刷新\n\t\tforceRefresh := false\n\t\trefreshStr := c.Query(\"refresh\")\n\t\tif refreshStr != \"\" && refreshStr != \" \" && refreshStr == \"true\" {\n\t\t\tforceRefresh = true\n\t\t}\n\t\t\n\t\t// 处理结果类型和来源类型\n\t\tresultType := c.Query(\"res\")\n\t\tif resultType == \"\" || resultType == \" \" {\n\t\t\tresultType = \"merge\" // 直接设置为默认值merge\n\t\t}\n\t\t\n\t\tsourceType := c.Query(\"src\")\n\t\tif sourceType == \"\" || sourceType == \" \" {\n\t\t\tsourceType = \"all\" // 直接设置为默认值all\n\t\t}\n\t\t\n\t\t// 处理plugins参数，支持逗号分隔\n\t\tvar plugins []string\n\t\t// 检查请求中是否存在plugins参数\n\t\tif c.Request.URL.Query().Has(\"plugins\") {\n\t\t\tpluginsStr := c.Query(\"plugins\")\n\t\t\t// 判断参数是否非空\n\t\t\tif pluginsStr != \"\" && pluginsStr != \" \" {\n\t\t\t\tparts := strings.Split(pluginsStr, \",\")\n\t\t\t\tfor _, part := range parts {\n\t\t\t\t\ttrimmed := strings.TrimSpace(part)\n\t\t\t\t\tif trimmed != \"\" {\n\t\t\t\t\t\tplugins = append(plugins, trimmed)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// 如果请求中不存在plugins参数，设置为nil\n\t\t\tplugins = nil\n\t\t}\n\t\t\n\t\t// 处理cloud_types参数，支持逗号分隔\n\t\tvar cloudTypes []string\n\t\t// 检查请求中是否存在cloud_types参数\n\t\tif c.Request.URL.Query().Has(\"cloud_types\") {\n\t\t\tcloudTypesStr := c.Query(\"cloud_types\")\n\t\t\t// 判断参数是否非空\n\t\t\tif cloudTypesStr != \"\" && cloudTypesStr != \" \" {\n\t\t\t\tparts := strings.Split(cloudTypesStr, \",\")\n\t\t\t\tfor _, part := range parts {\n\t\t\t\t\ttrimmed := strings.TrimSpace(part)\n\t\t\t\t\tif trimmed != \"\" {\n\t\t\t\t\t\tcloudTypes = append(cloudTypes, trimmed)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// 如果请求中不存在cloud_types参数，设置为nil\n\t\t\tcloudTypes = nil\n\t\t}\n\t\t\n\t\t// 处理ext参数，JSON格式\n\t\tvar ext map[string]interface{}\n\t\textStr := c.Query(\"ext\")\n\t\tif extStr != \"\" && extStr != \" \" {\n\t\t\t// 处理特殊情况：ext={}\n\t\t\tif extStr == \"{}\" {\n\t\t\t\text = make(map[string]interface{})\n\t\t\t} else {\n\t\t\t\tif err := jsonutil.Unmarshal([]byte(extStr), &ext); err != nil {\n\t\t\t\t\tc.JSON(http.StatusBadRequest, model.NewErrorResponse(400, \"无效的ext参数格式: \"+err.Error()))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// 确保ext不为nil\n\t\tif ext == nil {\n\t\t\text = make(map[string]interface{})\n\t\t}\n\t\t\n\t\t// 处理filter参数，JSON格式\n\t\tvar filter *model.FilterConfig\n\t\tfilterStr := c.Query(\"filter\")\n\t\tif filterStr != \"\" && filterStr != \" \" {\n\t\t\tfilter = &model.FilterConfig{}\n\t\t\tif err := jsonutil.Unmarshal([]byte(filterStr), filter); err != nil {\n\t\t\t\tc.JSON(http.StatusBadRequest, model.NewErrorResponse(400, \"无效的filter参数格式: \"+err.Error()))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\treq = model.SearchRequest{\n\t\t\tKeyword:      keyword,\n\t\t\tChannels:     channels,\n\t\t\tConcurrency:  concurrency,\n\t\t\tForceRefresh: forceRefresh,\n\t\t\tResultType:   resultType,\n\t\t\tSourceType:   sourceType,\n\t\t\tPlugins:      plugins,\n\t\t\tCloudTypes:   cloudTypes, // 添加cloud_types到请求中\n\t\t\tExt:          ext,\n\t\t\tFilter:       filter,\n\t\t}\n\t} else {\n\t\t// POST方式：从请求体获取\n\t\tdata, err := c.GetRawData()\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, model.NewErrorResponse(400, \"读取请求数据失败: \"+err.Error()))\n\t\t\treturn\n\t\t}\n\n\t\tif err := jsonutil.Unmarshal(data, &req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, model.NewErrorResponse(400, \"无效的请求参数: \"+err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\t\n\t// 检查并设置默认值\n\tif len(req.Channels) == 0 {\n\t\treq.Channels = config.AppConfig.DefaultChannels\n\t}\n\t\n\t// 如果未指定结果类型，默认返回merge并转换为merged_by_type\n\tif req.ResultType == \"\" {\n\t\treq.ResultType = \"merged_by_type\"\n\t} else if req.ResultType == \"merge\" {\n\t\t// 将merge转换为merged_by_type，以兼容内部处理\n\t\treq.ResultType = \"merged_by_type\"\n\t}\n\t\n\t// 如果未指定数据来源类型，默认为全部\n\tif req.SourceType == \"\" {\n\t\treq.SourceType = \"all\"\n\t}\n\t\n\t// 参数互斥逻辑：当src=tg时忽略plugins参数，当src=plugin时忽略channels参数\n\tif req.SourceType == \"tg\" {\n\t\treq.Plugins = nil // 忽略plugins参数\n\t} else if req.SourceType == \"plugin\" {\n\t\treq.Channels = nil // 忽略channels参数\n\t} else if req.SourceType == \"all\" {\n\t\t// 对于all类型，如果plugins为空或不存在，统一设为nil\n\t\tif req.Plugins == nil || len(req.Plugins) == 0 {\n\t\t\treq.Plugins = nil\n\t\t}\n\t}\n\t\n\t// 可选：启用调试输出（生产环境建议注释掉）\n\t// fmt.Printf(\"🔧 [调试] 搜索参数: keyword=%s, channels=%v, concurrency=%d, refresh=%v, resultType=%s, sourceType=%s, plugins=%v, cloudTypes=%v, ext=%v\\n\", \n\t//\treq.Keyword, req.Channels, req.Concurrency, req.ForceRefresh, req.ResultType, req.SourceType, req.Plugins, req.CloudTypes, req.Ext)\n\t\n\t// 执行搜索\n\tresult, err := searchService.Search(req.Keyword, req.Channels, req.Concurrency, req.ForceRefresh, req.ResultType, req.SourceType, req.Plugins, req.CloudTypes, req.Ext)\n\t\n\tif err != nil {\n\t\tresponse := model.NewErrorResponse(500, \"搜索失败: \"+err.Error())\n\t\tjsonData, _ := jsonutil.Marshal(response)\n\t\tc.Data(http.StatusInternalServerError, \"application/json\", jsonData)\n\t\treturn\n\t}\n\n\t// 应用过滤器\n\tif req.Filter != nil {\n\t\tresult = applyResultFilter(result, req.Filter, req.ResultType)\n\t}\n\n\t// 包装SearchResponse到标准响应格式中\n\tresponse := model.NewSuccessResponse(result)\n\tjsonData, _ := jsonutil.Marshal(response)\n\tc.Data(http.StatusOK, \"application/json\", jsonData)\n} "
  },
  {
    "path": "api/middleware.go",
    "content": "package api\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"pansou/config\"\n\t\"pansou/util\"\n)\n\n// CORSMiddleware 跨域中间件\nfunc CORSMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tc.Writer.Header().Set(\"Access-Control-Allow-Origin\", \"*\")\n\t\tc.Writer.Header().Set(\"Access-Control-Allow-Methods\", \"GET, POST, OPTIONS\")\n\t\tc.Writer.Header().Set(\"Access-Control-Allow-Headers\", \"Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization\")\n\t\t\n\t\tif c.Request.Method == \"OPTIONS\" {\n\t\t\tc.AbortWithStatus(204)\n\t\t\treturn\n\t\t}\n\t\t\n\t\tc.Next()\n\t}\n}\n\n// LoggerMiddleware 日志中间件\nfunc LoggerMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// 开始时间\n\t\tstartTime := time.Now()\n\t\t\n\t\t// 处理请求\n\t\tc.Next()\n\t\t\n\t\t// 结束时间\n\t\tendTime := time.Now()\n\t\t\n\t\t// 执行时间\n\t\tlatencyTime := endTime.Sub(startTime)\n\t\t\n\t\t// 请求方式\n\t\treqMethod := c.Request.Method\n\t\t\n\t\t// 请求路由\n\t\treqURI := c.Request.RequestURI\n\t\t\n\t\t// 对于搜索API，尝试解码关键词以便更好地显示\n\t\tdisplayURI := reqURI\n\t\tif strings.Contains(reqURI, \"/api/search\") && strings.Contains(reqURI, \"kw=\") {\n\t\t\tif parsedURL, err := url.Parse(reqURI); err == nil {\n\t\t\t\tif keyword := parsedURL.Query().Get(\"kw\"); keyword != \"\" {\n\t\t\t\t\tif decodedKeyword, err := url.QueryUnescape(keyword); err == nil {\n\t\t\t\t\t\t// 替换原始URI中的编码关键词为解码后的关键词\n\t\t\t\t\t\tdisplayURI = strings.Replace(reqURI, \"kw=\"+keyword, \"kw=\"+decodedKeyword, 1)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 状态码\n\t\tstatusCode := c.Writer.Status()\n\t\t\n\t\t// 请求IP\n\t\tclientIP := c.ClientIP()\n\t\t\n\t\t// 日志格式\n\t\tgin.DefaultWriter.Write([]byte(\n\t\t\tfmt.Sprintf(\"| %s | %s | %s | %d | %s\\n\", \n\t\t\t\tclientIP, reqMethod, displayURI, statusCode, latencyTime.String())))\n\t}\n}\n\n// AuthMiddleware JWT认证中间件\nfunc AuthMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// 如果未启用认证，直接放行\n\t\tif !config.AppConfig.AuthEnabled {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// 定义公开接口（不需要认证）\n\t\tpublicPaths := []string{\n\t\t\t\"/api/auth/login\",\n\t\t\t\"/api/auth/logout\",\n\t\t\t\"/api/health\", // 健康检查接口可选择是否需要认证\n\t\t}\n\n\t\t// 检查当前路径是否是公开接口\n\t\tpath := c.Request.URL.Path\n\t\tfor _, p := range publicPaths {\n\t\t\tif strings.HasPrefix(path, p) {\n\t\t\t\tc.Next()\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// 获取Authorization头\n\t\tauthHeader := c.GetHeader(\"Authorization\")\n\t\tif authHeader == \"\" {\n\t\t\tc.JSON(401, gin.H{\n\t\t\t\t\"error\": \"未授权：缺少认证令牌\",\n\t\t\t\t\"code\":  \"AUTH_TOKEN_MISSING\",\n\t\t\t})\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\t// 解析Bearer token\n\t\tconst bearerPrefix = \"Bearer \"\n\t\tif !strings.HasPrefix(authHeader, bearerPrefix) {\n\t\t\tc.JSON(401, gin.H{\n\t\t\t\t\"error\": \"未授权：令牌格式错误\",\n\t\t\t\t\"code\":  \"AUTH_TOKEN_INVALID_FORMAT\",\n\t\t\t})\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\ttokenString := strings.TrimPrefix(authHeader, bearerPrefix)\n\n\t\t// 验证token\n\t\tclaims, err := util.ValidateToken(tokenString, config.AppConfig.AuthJWTSecret)\n\t\tif err != nil {\n\t\t\tc.JSON(401, gin.H{\n\t\t\t\t\"error\": \"未授权：令牌无效或已过期\",\n\t\t\t\t\"code\":  \"AUTH_TOKEN_INVALID\",\n\t\t\t})\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\t// 将用户信息存入上下文，供后续处理使用\n\t\tc.Set(\"username\", claims.Username)\n\t\tc.Next()\n\t}\n} "
  },
  {
    "path": "api/router.go",
    "content": "package api\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"pansou/config\"\n\t\"pansou/plugin\"\n\t\"pansou/service\"\n\t\"pansou/util\"\n)\n\n// SetupRouter 设置路由\nfunc SetupRouter(searchService *service.SearchService) *gin.Engine {\n\t// 设置搜索服务\n\tSetSearchService(searchService)\n\t\n\t// 设置为生产模式\n\tgin.SetMode(gin.ReleaseMode)\n\t\n\t// 创建默认路由\n\tr := gin.Default()\n\t\n\t// 添加中间件\n\tr.Use(CORSMiddleware())\n\tr.Use(LoggerMiddleware())\n\tr.Use(util.GzipMiddleware()) // 添加压缩中间件\n\tr.Use(AuthMiddleware())      // 添加认证中间件\n\t\n\t// 定义API路由组\n\tapi := r.Group(\"/api\")\n\t{\n\t\t// 认证接口（不需要认证，由中间件公开路径处理）\n\t\tauth := api.Group(\"/auth\")\n\t\t{\n\t\t\tauth.POST(\"/login\", LoginHandler)\n\t\t\tauth.POST(\"/verify\", VerifyHandler)\n\t\t\tauth.POST(\"/logout\", LogoutHandler)\n\t\t}\n\t\t\n\t\t// 搜索接口 - 支持POST和GET两种方式\n\t\tapi.POST(\"/search\", SearchHandler)\n\t\tapi.GET(\"/search\", SearchHandler) // 添加GET方式支持\n\t\t\n\t\t// 健康检查接口\n\t\tapi.GET(\"/health\", func(c *gin.Context) {\n\t\t\t// 根据配置决定是否返回插件信息\n\t\t\tpluginCount := 0\n\t\t\tpluginNames := []string{}\n\t\t\tpluginsEnabled := config.AppConfig.AsyncPluginEnabled\n\t\t\t\n\t\t\tif pluginsEnabled && searchService != nil && searchService.GetPluginManager() != nil {\n\t\t\t\tplugins := searchService.GetPluginManager().GetPlugins()\n\t\t\t\tpluginCount = len(plugins)\n\t\t\t\tfor _, p := range plugins {\n\t\t\t\t\tpluginNames = append(pluginNames, p.Name())\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 获取频道信息\n\t\t\tchannels := config.AppConfig.DefaultChannels\n\t\t\tchannelsCount := len(channels)\n\t\t\t\n\t\t\tresponse := gin.H{\n\t\t\t\t\"status\":         \"ok\",\n\t\t\t\t\"auth_enabled\":   config.AppConfig.AuthEnabled, // 添加认证状态\n\t\t\t\t\"plugins_enabled\": pluginsEnabled,\n\t\t\t\t\"channels\":        channels,\n\t\t\t\t\"channels_count\":  channelsCount,\n\t\t\t}\n\t\t\t\n\t\t\t// 只有当插件启用时才返回插件相关信息\n\t\t\tif pluginsEnabled {\n\t\t\t\tresponse[\"plugin_count\"] = pluginCount\n\t\t\t\tresponse[\"plugins\"] = pluginNames\n\t\t\t}\n\t\t\t\n\t\t\tc.JSON(200, response)\n\t\t})\n\t}\n\t\n\t// 注册插件的Web路由（如果插件实现了PluginWithWebHandler接口）\n\t// 只有当插件功能启用且插件在启用列表中时才注册路由\n\tif config.AppConfig.AsyncPluginEnabled && searchService != nil && searchService.GetPluginManager() != nil {\n\t\tenabledPlugins := searchService.GetPluginManager().GetPlugins()\n\t\tfor _, p := range enabledPlugins {\n\t\t\tif webPlugin, ok := p.(plugin.PluginWithWebHandler); ok {\n\t\t\t\twebPlugin.RegisterWebRoutes(r.Group(\"\"))\n\t\t\t}\n\t\t}\n\t}\n\t\n\treturn r\n} "
  },
  {
    "path": "config/config.go",
    "content": "package config\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"runtime/debug\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\n// Config 应用配置结构\ntype Config struct {\n\tDefaultChannels    []string\n\tDefaultConcurrency int\n\tPort               string\n\tProxyURL           string\n\tUseProxy           bool\n\tHTTPProxyURL       string\n\tHTTPSProxyURL      string\n\t// 缓存相关配置\n\tCacheEnabled    bool\n\tCachePath       string\n\tCacheMaxSizeMB  int\n\tCacheTTLMinutes int\n\t// 压缩相关配置\n\tEnableCompression bool\n\tMinSizeToCompress int // 最小压缩大小（字节）\n\t// GC相关配置\n\tGCPercent      int  // GC触发阈值百分比\n\tOptimizeMemory bool // 是否启用内存优化\n\t// 插件相关配置\n\tPluginTimeoutSeconds int           // 插件超时时间（秒）\n\tPluginTimeout        time.Duration // 插件超时时间（Duration）\n\t// 异步插件相关配置\n\tAsyncPluginEnabled        bool          // 是否启用异步插件\n\tEnabledPlugins            []string      // 启用的具体插件列表（空表示启用所有）\n\tAsyncResponseTimeout      int           // 响应超时时间（秒）\n\tAsyncResponseTimeoutDur   time.Duration // 响应超时时间（Duration）\n\tAsyncMaxBackgroundWorkers int           // 最大后台工作者数量\n\tAsyncMaxBackgroundTasks   int           // 最大后台任务数量\n\tAsyncCacheTTLHours        int           // 异步缓存有效期（小时）\n\tAsyncLogEnabled           bool          // 是否启用异步插件详细日志\n\t// HTTP服务器配置\n\tHTTPReadTimeout  time.Duration // 读取超时\n\tHTTPWriteTimeout time.Duration // 写入超时\n\tHTTPIdleTimeout  time.Duration // 空闲超时\n\tHTTPMaxConns     int           // 最大连接数\n\t// 认证相关配置\n\tAuthEnabled     bool              // 是否启用认证\n\tAuthUsers       map[string]string // 用户名:密码映射\n\tAuthTokenExpiry time.Duration     // Token有效期\n\tAuthJWTSecret   string            // JWT签名密钥\n\n}\n\n// 全局配置实例\nvar AppConfig *Config\n\n// 初始化配置\nfunc Init() {\n\tproxyURL := getProxyURL()\n\tpluginTimeoutSeconds := getPluginTimeout()\n\tasyncResponseTimeoutSeconds := getAsyncResponseTimeout()\n\t\n\tAppConfig = &Config{\n\t\tDefaultChannels:    getDefaultChannels(),\n\t\tDefaultConcurrency: getDefaultConcurrency(),\n\t\tPort:               getPort(),\n\t\tProxyURL:           proxyURL,\n\t\tUseProxy:           proxyURL != \"\",\n\t\tHTTPProxyURL:       getHTTPProxyURL(),\n\t\tHTTPSProxyURL:      getHTTPSProxyURL(),\n\t\t// 缓存相关配置\n\t\tCacheEnabled:    getCacheEnabled(),\n\t\tCachePath:       getCachePath(),\n\t\tCacheMaxSizeMB:  getCacheMaxSize(),\n\t\tCacheTTLMinutes: getCacheTTL(),\n\t\t// 压缩相关配置\n\t\tEnableCompression: getEnableCompression(),\n\t\tMinSizeToCompress: getMinSizeToCompress(),\n\t\t// GC相关配置\n\t\tGCPercent:      getGCPercent(),\n\t\tOptimizeMemory: getOptimizeMemory(),\n\t\t// 插件相关配置\n\t\tPluginTimeoutSeconds: pluginTimeoutSeconds,\n\t\tPluginTimeout:        time.Duration(pluginTimeoutSeconds) * time.Second,\n\t\t// 异步插件相关配置\n\t\tAsyncPluginEnabled:        getAsyncPluginEnabled(),\n\t\tEnabledPlugins:            getEnabledPlugins(),\n\t\tAsyncResponseTimeout:      asyncResponseTimeoutSeconds,\n\t\tAsyncResponseTimeoutDur:   time.Duration(asyncResponseTimeoutSeconds) * time.Second,\n\t\tAsyncMaxBackgroundWorkers: getAsyncMaxBackgroundWorkers(),\n\t\tAsyncMaxBackgroundTasks:   getAsyncMaxBackgroundTasks(),\n\t\tAsyncCacheTTLHours:        getAsyncCacheTTLHours(),\n\t\tAsyncLogEnabled:           getAsyncLogEnabled(),\n\t\t// HTTP服务器配置\n\t\tHTTPReadTimeout:  getHTTPReadTimeout(),\n\t\tHTTPWriteTimeout: getHTTPWriteTimeout(),\n\t\tHTTPIdleTimeout:  getHTTPIdleTimeout(),\n\t\tHTTPMaxConns:     getHTTPMaxConns(),\n\t\t// 认证相关配置\n\t\tAuthEnabled:     getAuthEnabled(),\n\t\tAuthUsers:       getAuthUsers(),\n\t\tAuthTokenExpiry: getAuthTokenExpiry(),\n\t\tAuthJWTSecret:   getAuthJWTSecret(),\n\n\t}\n\t\n\t// 应用GC配置\n\tapplyGCSettings()\n}\n\n// 从环境变量获取默认频道列表，如果未设置则使用默认值\nfunc getDefaultChannels() []string {\n\tchannelsEnv := os.Getenv(\"CHANNELS\")\n\tif channelsEnv == \"\" {\n\t\treturn []string{\"tgsearchers4\"}\n\t}\n\treturn strings.Split(channelsEnv, \",\")\n}\n\n// 从环境变量获取默认并发数，如果未设置则使用基于环境变量的简单计算\nfunc getDefaultConcurrency() int {\n\tconcurrencyEnv := os.Getenv(\"CONCURRENCY\")\n\tif concurrencyEnv != \"\" {\n\t\tconcurrency, err := strconv.Atoi(concurrencyEnv)\n\t\tif err == nil && concurrency > 0 {\n\t\t\treturn concurrency\n\t\t}\n\t}\n\t\n\t// 环境变量未设置或无效，使用基于环境变量的简单计算\n\t// 计算频道数\n\tchannelCount := len(getDefaultChannels())\n\t\n\t// 估计插件数（从环境变量或默认值，实际在应用启动后会根据真实插件数调整）\n\tpluginCountEnv := os.Getenv(\"PLUGIN_COUNT\")\n\tpluginCount := 0\n\tif pluginCountEnv != \"\" {\n\t\tcount, err := strconv.Atoi(pluginCountEnv)\n\t\tif err == nil && count > 0 {\n\t\t\tpluginCount = count\n\t\t}\n\t}\n\t\n\t// 如果没有指定插件数，默认使用7个（当前已知的插件数）\n\tif pluginCount == 0 {\n\t\tpluginCount = 7\n\t}\n\t\n\t// 计算并发数 = 频道数 + 插件数 + 10\n\tconcurrency := channelCount + pluginCount + 10\n\tif concurrency < 1 {\n\t\tconcurrency = 1 // 确保至少为1\n\t}\n\t\n\treturn concurrency\n}\n\n// 更新默认并发数（根据实际插件数或0调用）\n// pluginCount: 如果插件被禁用则为0，否则为实际插件数\nfunc UpdateDefaultConcurrency(pluginCount int) {\n\tif AppConfig == nil {\n\t\treturn\n\t}\n\t\n\t// 只有当未通过环境变量指定并发数时才进行调整\n\tconcurrencyEnv := os.Getenv(\"CONCURRENCY\")\n\tif concurrencyEnv != \"\" {\n\t\treturn\n\t}\n\t\n\t// 计算频道数\n\tchannelCount := len(AppConfig.DefaultChannels)\n\t\n\t// 计算并发数 = 频道数 + 插件数（插件禁用时为0）+ 10\n\tconcurrency := channelCount + pluginCount + 10\n\tif concurrency < 1 {\n\t\tconcurrency = 1 // 确保至少为1\n\t}\n\t\n\t// 更新配置\n\tAppConfig.DefaultConcurrency = concurrency\n}\n\n// 从环境变量获取服务端口，如果未设置则使用默认值\nfunc getPort() string {\n\tport := os.Getenv(\"PORT\")\n\tif port == \"\" {\n\t\treturn \"8888\"\n\t}\n\treturn port\n}\n\nfunc getProxyURL() string {\n\treturn os.Getenv(\"PROXY\")\n}\n\nfunc getHTTPProxyURL() string {\n\tif proxyURL := os.Getenv(\"HTTP_PROXY\"); proxyURL != \"\" {\n\t\treturn proxyURL\n\t}\n\treturn os.Getenv(\"http_proxy\")\n}\n\nfunc getHTTPSProxyURL() string {\n\tif proxyURL := os.Getenv(\"HTTPS_PROXY\"); proxyURL != \"\" {\n\t\treturn proxyURL\n\t}\n\treturn os.Getenv(\"https_proxy\")\n}\n\n// 从环境变量获取是否启用缓存，如果未设置则默认启用\nfunc getCacheEnabled() bool {\n\tenabled := os.Getenv(\"CACHE_ENABLED\")\n\tif enabled == \"\" {\n\t\treturn true\n\t}\n\treturn enabled != \"false\" && enabled != \"0\"\n}\n\n// 从环境变量获取缓存路径，如果未设置则使用默认路径\nfunc getCachePath() string {\n\tpath := os.Getenv(\"CACHE_PATH\")\n\tif path == \"\" {\n\t\t// 默认在当前目录下创建cache文件夹\n\t\tdefaultPath, err := filepath.Abs(\"./cache\")\n\t\tif err != nil {\n\t\t\treturn \"./cache\"\n\t\t}\n\t\treturn defaultPath\n\t}\n\treturn path\n}\n\n// 从环境变量获取缓存最大大小(MB)，如果未设置则使用默认值\nfunc getCacheMaxSize() int {\n\tsizeEnv := os.Getenv(\"CACHE_MAX_SIZE\")\n\tif sizeEnv == \"\" {\n\t\treturn 100 // 默认100MB\n\t}\n\tsize, err := strconv.Atoi(sizeEnv)\n\tif err != nil || size <= 0 {\n\t\treturn 100\n\t}\n\treturn size\n}\n\n// 从环境变量获取缓存TTL(分钟)，如果未设置则使用默认值\nfunc getCacheTTL() int {\n\tttlEnv := os.Getenv(\"CACHE_TTL\")\n\tif ttlEnv == \"\" {\n\t\treturn 60 // 默认60分钟\n\t}\n\tttl, err := strconv.Atoi(ttlEnv)\n\tif err != nil || ttl <= 0 {\n\t\treturn 60\n\t}\n\treturn ttl\n}\n\n// 从环境变量获取是否启用压缩，如果未设置则默认禁用\nfunc getEnableCompression() bool {\n\tenabled := os.Getenv(\"ENABLE_COMPRESSION\")\n\tif enabled == \"\" {\n\t\treturn false // 默认禁用，因为通常由Nginx等处理\n\t}\n\treturn enabled == \"true\" || enabled == \"1\"\n}\n\n// 从环境变量获取最小压缩大小，如果未设置则使用默认值\nfunc getMinSizeToCompress() int {\n\tsizeEnv := os.Getenv(\"MIN_SIZE_TO_COMPRESS\")\n\tif sizeEnv == \"\" {\n\t\treturn 1024 // 默认1KB\n\t}\n\tsize, err := strconv.Atoi(sizeEnv)\n\tif err != nil || size <= 0 {\n\t\treturn 1024\n\t}\n\treturn size\n}\n\n// 从环境变量获取GC百分比，如果未设置则使用默认值\nfunc getGCPercent() int {\n\tpercentEnv := os.Getenv(\"GC_PERCENT\")\n\tif percentEnv == \"\" {\n\t\treturn 50 // 默认50% - 优化内存管理，更频繁的GC避免内存暴涨\n\t}\n\tpercent, err := strconv.Atoi(percentEnv)\n\tif err != nil || percent <= 0 {\n\t\treturn 50 // 错误时也使用优化后的默认值\n\t}\n\treturn percent\n}\n\n// 从环境变量获取是否优化内存，如果未设置则默认启用\nfunc getOptimizeMemory() bool {\n\tenabled := os.Getenv(\"OPTIMIZE_MEMORY\")\n\tif enabled == \"\" {\n\t\treturn true // 默认启用\n\t}\n\treturn enabled != \"false\" && enabled != \"0\"\n}\n\n// 从环境变量获取插件超时时间（秒），如果未设置则使用默认值\nfunc getPluginTimeout() int {\n\ttimeoutEnv := os.Getenv(\"PLUGIN_TIMEOUT\")\n\tif timeoutEnv == \"\" {\n\t\treturn 30 // 默认30秒\n\t}\n\ttimeout, err := strconv.Atoi(timeoutEnv)\n\tif err != nil || timeout <= 0 {\n\t\treturn 30\n\t}\n\treturn timeout\n}\n\n// 从环境变量获取是否启用异步插件，如果未设置则默认启用\nfunc getAsyncPluginEnabled() bool {\n\tenabled := os.Getenv(\"ASYNC_PLUGIN_ENABLED\")\n\tif enabled == \"\" {\n\t\treturn true // 默认启用\n\t}\n\treturn enabled != \"false\" && enabled != \"0\"\n}\n\n// 从环境变量获取启用的插件列表\n// 返回nil表示未设置环境变量（不启用任何插件）\n// 返回[]string{}表示设置为空（不启用任何插件）\n// 返回具体列表表示启用指定插件\nfunc getEnabledPlugins() []string {\n\tplugins, exists := os.LookupEnv(\"ENABLED_PLUGINS\")\n\tif !exists {\n\t\t// 未设置环境变量时返回nil，表示不启用任何插件\n\t\treturn nil\n\t}\n\t\n\tif plugins == \"\" {\n\t\t// 设置为空字符串，也表示不启用任何插件\n\t\treturn []string{}\n\t}\n\t\n\t// 按逗号分割插件名\n\tresult := make([]string, 0)\n\tfor _, plugin := range strings.Split(plugins, \",\") {\n\t\tplugin = strings.TrimSpace(plugin)\n\t\tif plugin != \"\" {\n\t\t\tresult = append(result, plugin)\n\t\t}\n\t}\n\t\n\treturn result\n}\n\n// 从环境变量获取异步响应超时时间（秒），如果未设置则使用默认值\nfunc getAsyncResponseTimeout() int {\n\ttimeoutEnv := os.Getenv(\"ASYNC_RESPONSE_TIMEOUT\")\n\tif timeoutEnv == \"\" {\n\t\treturn 4 // 默认4秒\n\t}\n\ttimeout, err := strconv.Atoi(timeoutEnv)\n\tif err != nil || timeout <= 0 {\n\t\treturn 4\n\t}\n\treturn timeout\n}\n\n// 从环境变量获取最大后台工作者数量，如果未设置则自动计算\nfunc getAsyncMaxBackgroundWorkers() int {\n\tsizeEnv := os.Getenv(\"ASYNC_MAX_BACKGROUND_WORKERS\")\n\tif sizeEnv != \"\" {\n\t\tsize, err := strconv.Atoi(sizeEnv)\n\t\tif err == nil && size > 0 {\n\t\t\treturn size\n\t\t}\n\t}\n\t\n\t// 自动计算：根据CPU核心数计算\n\t// 每个CPU核心分配5个工作者，最小20个\n\tcpuCount := runtime.NumCPU()\n\tworkers := cpuCount * 5\n\t\n\t// 确保至少有20个工作者\n\tif workers < 20 {\n\t\tworkers = 20\n\t}\n\t\n\treturn workers\n}\n\n// 从环境变量获取最大后台任务数量，如果未设置则自动计算\nfunc getAsyncMaxBackgroundTasks() int {\n\tsizeEnv := os.Getenv(\"ASYNC_MAX_BACKGROUND_TASKS\")\n\tif sizeEnv != \"\" {\n\t\tsize, err := strconv.Atoi(sizeEnv)\n\t\tif err == nil && size > 0 {\n\t\t\treturn size\n\t\t}\n\t}\n\t\n\t// 自动计算：工作者数量的5倍，最小100个\n\tworkers := getAsyncMaxBackgroundWorkers()\n\ttasks := workers * 5\n\t\n\t// 确保至少有100个任务\n\tif tasks < 100 {\n\t\ttasks = 100\n\t}\n\t\n\treturn tasks\n}\n\n// 从环境变量获取异步缓存有效期（小时），如果未设置则使用默认值\nfunc getAsyncCacheTTLHours() int {\n\tttlEnv := os.Getenv(\"ASYNC_CACHE_TTL_HOURS\")\n\tif ttlEnv == \"\" {\n\t\treturn 1 // 默认1小时\n\t}\n\tttl, err := strconv.Atoi(ttlEnv)\n\tif err != nil || ttl <= 0 {\n\t\treturn 1\n\t}\n\treturn ttl\n}\n\n// 从环境变量获取HTTP读取超时，如果未设置则自动计算\nfunc getHTTPReadTimeout() time.Duration {\n\ttimeoutEnv := os.Getenv(\"HTTP_READ_TIMEOUT\")\n\tif timeoutEnv != \"\" {\n\t\ttimeout, err := strconv.Atoi(timeoutEnv)\n\t\tif err == nil && timeout > 0 {\n\t\t\treturn time.Duration(timeout) * time.Second\n\t\t}\n\t}\n\t\n\t// 自动计算：默认30秒，异步模式下根据异步响应超时调整\n\ttimeout := 30 * time.Second\n\t\n\t// 如果启用了异步插件，确保读取超时足够长\n\tif getAsyncPluginEnabled() {\n\t\t// 读取超时应该至少是异步响应超时的3倍，确保有足够时间完成异步操作\n\t\tasyncTimeoutSecs := getAsyncResponseTimeout()\n\t\tasyncTimeoutExtended := time.Duration(asyncTimeoutSecs * 3) * time.Second\n\t\tif asyncTimeoutExtended > timeout {\n\t\t\ttimeout = asyncTimeoutExtended\n\t\t}\n\t}\n\t\n\treturn timeout\n}\n\n// 从环境变量获取HTTP写入超时，如果未设置则自动计算\nfunc getHTTPWriteTimeout() time.Duration {\n\ttimeoutEnv := os.Getenv(\"HTTP_WRITE_TIMEOUT\")\n\tif timeoutEnv != \"\" {\n\t\ttimeout, err := strconv.Atoi(timeoutEnv)\n\t\tif err == nil && timeout > 0 {\n\t\t\treturn time.Duration(timeout) * time.Second\n\t\t}\n\t}\n\t\n\t// 自动计算：默认60秒，但根据插件超时和异步处理时间调整\n\ttimeout := 60 * time.Second\n\t\n\t// 如果启用了异步插件，确保写入超时足够长\n\tpluginTimeoutSecs := getPluginTimeout()\n\t\n\t// 计算1.5倍的插件超时时间（使用整数运算：乘以3再除以2）\n\tpluginTimeoutExtended := time.Duration(pluginTimeoutSecs * 3 / 2) * time.Second\n\t\n\tif pluginTimeoutExtended > timeout {\n\t\ttimeout = pluginTimeoutExtended\n\t}\n\t\n\treturn timeout\n}\n\n// 从环境变量获取HTTP空闲超时，如果未设置则自动计算\nfunc getHTTPIdleTimeout() time.Duration {\n\ttimeoutEnv := os.Getenv(\"HTTP_IDLE_TIMEOUT\")\n\tif timeoutEnv != \"\" {\n\t\ttimeout, err := strconv.Atoi(timeoutEnv)\n\t\tif err == nil && timeout > 0 {\n\t\t\treturn time.Duration(timeout) * time.Second\n\t\t}\n\t}\n\t\n\t// 自动计算：默认120秒，考虑到保持连接的效益\n\treturn 120 * time.Second\n}\n\n// 从环境变量获取HTTP最大连接数，如果未设置则自动计算\nfunc getHTTPMaxConns() int {\n\tmaxConnsEnv := os.Getenv(\"HTTP_MAX_CONNS\")\n\tif maxConnsEnv != \"\" {\n\t\tmaxConns, err := strconv.Atoi(maxConnsEnv)\n\t\tif err == nil && maxConns > 0 {\n\t\t\treturn maxConns\n\t\t}\n\t}\n\t\n\t// 自动计算：根据CPU核心数计算\n\t// 每个CPU核心分配200个连接，最小1000个\n\tcpuCount := runtime.NumCPU()\n\tmaxConns := cpuCount * 200\n\t\n\t// 确保至少有1000个连接\n\tif maxConns < 1000 {\n\t\tmaxConns = 1000\n\t}\n\t\n\treturn maxConns\n}\n\n// 从环境变量获取异步插件日志开关，如果未设置则使用默认值\nfunc getAsyncLogEnabled() bool {\n\tlogEnv := os.Getenv(\"ASYNC_LOG_ENABLED\")\n\tif logEnv == \"\" {\n\t\treturn true // 默认启用日志\n\t}\n\tenabled, err := strconv.ParseBool(logEnv)\n\tif err != nil {\n\t\treturn true // 解析失败时默认启用\n\t}\n\treturn enabled\n}\n\n// 从环境变量获取认证开关，如果未设置则默认关闭\nfunc getAuthEnabled() bool {\n\tenabled := os.Getenv(\"AUTH_ENABLED\")\n\treturn enabled == \"true\" || enabled == \"1\"\n}\n\n// 从环境变量获取用户配置，格式：user1:pass1,user2:pass2\nfunc getAuthUsers() map[string]string {\n\tusersEnv := os.Getenv(\"AUTH_USERS\")\n\tif usersEnv == \"\" {\n\t\treturn nil\n\t}\n\t\n\tusers := make(map[string]string)\n\tpairs := strings.Split(usersEnv, \",\")\n\tfor _, pair := range pairs {\n\t\tparts := strings.SplitN(pair, \":\", 2)\n\t\tif len(parts) == 2 {\n\t\t\tusername := strings.TrimSpace(parts[0])\n\t\t\tpassword := strings.TrimSpace(parts[1])\n\t\t\tif username != \"\" && password != \"\" {\n\t\t\t\tusers[username] = password\n\t\t\t}\n\t\t}\n\t}\n\treturn users\n}\n\n// 从环境变量获取Token有效期（小时），如果未设置则使用默认值\nfunc getAuthTokenExpiry() time.Duration {\n\texpiryEnv := os.Getenv(\"AUTH_TOKEN_EXPIRY\")\n\tif expiryEnv == \"\" {\n\t\treturn 24 * time.Hour // 默认24小时\n\t}\n\texpiry, err := strconv.Atoi(expiryEnv)\n\tif err != nil || expiry <= 0 {\n\t\treturn 24 * time.Hour\n\t}\n\treturn time.Duration(expiry) * time.Hour\n}\n\n// 从环境变量获取JWT密钥，如果未设置则生成随机密钥\nfunc getAuthJWTSecret() string {\n\tsecret := os.Getenv(\"AUTH_JWT_SECRET\")\n\tif secret == \"\" {\n\t\t// 生成随机密钥（32字节）\n\t\timport_crypto := \"crypto/rand\"\n\t\timport_encoding := \"encoding/base64\"\n\t\t_ = import_crypto\n\t\t_ = import_encoding\n\t\t// 注意：实际使用时应该使用crypto/rand生成随机密钥\n\t\t// 这里为了简化，使用时间戳作为临时密钥\n\t\tsecret = \"pansou-default-secret-\" + strconv.FormatInt(time.Now().Unix(), 10)\n\t}\n\treturn secret\n}\n\n// 应用GC设置\nfunc applyGCSettings() {\n\t// 设置GC百分比\n\tdebug.SetGCPercent(AppConfig.GCPercent)\n\t\n\t// 如果启用内存优化\n\tif AppConfig.OptimizeMemory {\n\t\t// 释放操作系统内存\n\t\tdebug.FreeOSMemory()\n\t}\n}\n\n "
  },
  {
    "path": "docker-compose.yml",
    "content": "version: '3.8'\n\nservices:\n  pansou:\n    image: ghcr.io/fish2018/pansou:latest\n    container_name: pansou\n    restart: unless-stopped\n    ports:\n      - \"8888:8888\"\n    environment:\n      - PORT=8888\n      - 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\n      # 必须指定启用的插件，多个插件用逗号分隔\n      - 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\n      - CACHE_ENABLED=true\n      - CACHE_PATH=/app/cache\n      - CACHE_MAX_SIZE=100\n      - CACHE_TTL=60\n      - ASYNC_PLUGIN_ENABLED=true\n      - ASYNC_RESPONSE_TIMEOUT=4\n      - ASYNC_MAX_BACKGROUND_WORKERS=20\n      - ASYNC_MAX_BACKGROUND_TASKS=100\n      - ASYNC_CACHE_TTL_HOURS=1\n      # 认证配置（可选）\n      # - AUTH_ENABLED=true\n      # - AUTH_USERS=admin:admin123,user:pass456\n      # - AUTH_TOKEN_EXPIRY=24\n      # - AUTH_JWT_SECRET=your-secret-key-here\n      # 如果需要代理，取消下面的注释并设置代理地址\n      # - PROXY=socks5://proxy:7897\n    volumes:\n      - pansou-cache:/app/cache\n    networks:\n      - pansou-network\n    healthcheck:\n      test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:8888/api/health\"]\n      interval: 30s\n      timeout: 5s\n      retries: 3\n      start_period: 10s\n\n  # 如果需要代理，取消下面的注释\n  # proxy:\n  #   image: ghcr.io/snail007/goproxy:latest\n  #   container_name: pansou-proxy\n  #   restart: unless-stopped\n  #   command: /proxy socks -p :7897\n  #   networks:\n  #     - pansou-network\n\nvolumes:\n  pansou-cache:\n    name: pansou-cache\n\nnetworks:\n  pansou-network:\n    name: pansou-network "
  },
  {
    "path": "docs/MCP-SERVICE.md",
    "content": "# PanSou MCP 服务文档\n\n## 功能介绍\n\nPanSou MCP 服务是一个基于 [Model Context Protocol (MCP)](https://modelcontextprotocol.io) 的工具服务，它将 PanSou 网盘搜索 API 的功能封装为可在支持 MCP 的客户端（如 Cherry Studio）中直接调用的工具。\n\n通过 PanSou MCP 服务，可以直接在 Claude 等 AI 助手中搜索网盘资源，极大地提升了获取网盘资源的便捷性。\n\n### 核心功能\n\n1. **搜索网盘资源 (`search_netdisk`)**:\n   - 支持通过 `keyword` 参数搜索网盘资源。\n   - 可通过 `source_type` 参数指定搜索来源：Telegram 频道、插件或两者结合。\n   - 可通过 `cloud_types` 参数过滤结果，显示特定类型的网盘链接。\n   - 支持通过 `force_refresh` 参数请求后端刷新缓存。\n   - 支持通过 `ext_params` 参数向后端插件传递扩展参数。\n   - 支持通过 `result_type` 参数控制后端返回的结果格式。\n   - 支持通过 `concurrency` 参数指定并发搜索数量。\n\n2. **检查服务健康状态 (`check_service_health`)**:\n   - 检查所连接的 PanSou 后端服务是否正常运行。\n   - 获取后端服务的配置信息，如可用的 Telegram 频道列表和插件列表。\n\n3. **启动后端服务 (`start_backend`)**:\n   - 自动启动本地的 PanSou Go 后端服务（如果尚未运行）。\n   - 等待服务完全启动并可用后才开始处理其他请求。\n   - 支持参数：`force_restart`（可选，布尔值，是否强制重启后端服务，默认为false）。\n\n4. **获取静态资源信息 (`pansou://` URI scheme)**:\n   - 提供可用插件列表、可用频道列表和支持的网盘类型列表等静态信息资源。\n   - 支持资源URI：`pansou://plugins`（插件列表）、`pansou://channels`（频道列表）、`pansou://cloud-types`（网盘类型列表）。\n\n### 架构与部署方式\n\nPanSou MCP 服务设计为与 PanSou Go 后端服务分离，通过 HTTP API 进行通信。支持以下部署方式：\n\n- **Node.js 部署 (TypeScript)**: MCP 服务基于 TypeScript 开发，编译后通过 `node` 命令运行编译后的 JavaScript 文件。它会自动连接到指定的 PanSou 后端服务。\n- **Docker 部署**: 使用 Docker 容器运行 PanSou 后端服务，MCP 服务通过 HTTP API 连接到容器化的后端。\n\n---\n\n## 安装与部署\n\n### 前提条件\n\n1. **Node.js**: 确保您的系统已安装 Node.js (版本 >= 18.0.0)。您可以通过在终端运行 `node -v` 来检查版本。\n2. **Go**: 确保您的系统已安装 Go (版本 >= 1.18)。您可以通过在终端运行 `go version` 来检查版本。\n\n### 部署步骤\n\nPanSou 后端服务通常运行在 `http://localhost:8888` (默认地址)。支持以下两种后端部署方式：\n\n## 后端服务部署\n\n### 方式一：源码部署后端服务\n\n- 确保系统已安装 Go 1.23.0 或更高版本。\n- 克隆或确保已有 PanSou Go 项目源码。\n- 在项目根目录下，打开终端并执行以下命令进行构建：\n\n```bash\n# Windows (PowerShell/CMD)\ngo build -o pansou.exe .\n```\n\n- 构建完成后，运行生成的可执行文件以启动后端服务：\n\n```bash\n# Windows\n.\\pansou.exe\n```\n\n服务默认将在 `http://localhost:8888` 启动。\n\n### 方式二：Docker 部署后端服务\n\nDocker 部署方式更加简单，无需手动构建 Go 后端服务，直接使用预构建的 Docker 镜像。\n\n**前提条件**：确保您的系统已安装 Docker 和 Docker Compose。\n\n在 PanSou 项目根目录下，使用 Docker Compose 启动后端服务：\n\n```bash\n# 启动 Docker 容器\ndocker-compose up -d\n\n# 检查容器状态\ndocker ps\n\n# 验证服务是否正常运行\ncurl http://localhost:8888/api/health\n```\n\n### 验证后端服务\n\n无论使用哪种方式启动后端服务，您都可以通过访问 `http://localhost:8888/api/health` 来检查服务状态，应该能看到类似以下的 JSON 响应：\n\n```json\n{\n  \"status\": \"ok\",\n  \"plugins_enabled\": true,\n  \"channels_count\": 1,\n  \"channels\": [\"tgsearchers3\"],\n  \"plugin_count\": 38,\n  \"plugins\": [\"ddys\", \"erxiao\", \"...\"]\n}\n```\n\n---\n\n## MCP 服务配置与使用\n\n### 1. 构建 MCP 服务\n\n- 确保系统已安装 Node.js (版本 >= 18.0.0)。\n- 在 `typescript` 目录下，打开终端并执行以下命令来安装依赖并构建项目：\n\n```bash\ncd typescript\nnpm install\nnpm run build\n```\n\n构建完成后，编译后的 JavaScript 文件将位于 `typescript/dist` 目录下。\n\n### 2. MCP 服务运行方式\n\n构建完成后，可以通过以下方式之一运行 MCP 服务：\n\n- **在MCP调用时自动启动** (推荐):\n  直接配置MCP客户端，调用时会自动启动后端服务器。\n\n- **使用 `node` 直接运行** (手动启动):\n  在 PanSou 项目根目录下（包含 `typescript` 文件夹），运行：\n\n  ```bash\n  # Windows (CMD/PowerShell)\n  node .\\typescript\\dist\\index.js\n  ```\n\n服务启动后，将默认尝试连接到 `http://localhost:8888` 的 PanSou 后端服务。\n\n如果想要后端服务运行在不同的地址或端口上，需要通过环境变量指定：\n\n```bash\n# Windows (CMD)\nset PANSOU_SERVER_URL=http://your-backend-address:port\nnode .\\typescript\\dist\\index.js\n\n# Windows (PowerShell)\n$env:PANSOU_SERVER_URL='http://your-backend-address:port'\nnode .\\typescript\\dist\\index.js\n```\n\n### 3. MCP 客户端配置\n\n#### 示例配置 Cherry Studio(版本1.5.7)\n\n要在 Cherry Studio 中使用 PanSou MCP 服务，需要将其添加到 Cherry Studio MCP 的配置文件中。\n\n- 找到 设置中的MCP。\n- 选择 `添加服务器` 、 `从JSON导入` 。\n- 加入服务配置(可以直接复制项目根目录下的 `mcp-config.json` 内容)：\n\n```json\n{\n  \"mcpServers\": {\n    \"pansou\": {\n      \"command\": \"node\",\n      \"args\": [\n        \"C:\\\\full\\\\path\\\\to\\\\your\\\\project\\\\typescript\\\\dist\\\\index.js\"\n      ],\n      \"env\": {\n        \"PANSOU_SERVER_URL\": \"http://localhost:8888\",\n        \"REQUEST_TIMEOUT\": \"30\",\n        \"MAX_RESULTS\": \"50\",\n        \"DEFAULT_CLOUD_TYPES\": \"baidu,aliyun,quark,tianyi,uc,mobile,115,pikpak,xunlei,123,magnet,ed2k,others\",\n        \"AUTO_START_BACKEND\": \"true\",\n        \"DOCKER_MODE\": \"false\",\n        \"BACKEND_SHUTDOWN_DELAY\": \"5000\",\n        \"BACKEND_STARTUP_TIMEOUT\": \"30000\",\n        \"IDLE_TIMEOUT\": \"300000\",\n        \"ENABLE_IDLE_SHUTDOWN\": \"true\",\n        \"PROJECT_ROOT_PATH\": \"C:\\\\full\\\\path\\\\to\\\\your\\\\project\",\n        \"ENABLED_PLUGINS\": \"labi,zhizhen,shandian,duoduo,muou,wanou\"\n      }\n    }\n  }\n}\n```\n\n**注意**：\n- 请将 `C:\\\\full\\\\path\\\\to\\\\your\\\\project` 替换为您项目实际的完整路径\n- 如需强制指定部署模式，可修改 `DOCKER_MODE` 和 `AUTO_START_BACKEND` 参数\n- **重要**：从当前版本开始，必须通过 `ENABLED_PLUGINS` 显式指定要启用的插件，否则不会启用任何插件\n\n### 4. 启动 MCP 服务并开始使用\n\n配置完成后，在对话界面启用 PanSou MCP 服务，即可开始尝试搜索。\n\n<img width=\"495\" height=\"649\" alt=\"image\" src=\"https://github.com/user-attachments/assets/b8c72649-03e8-4f52-86ba-aa16c4cc3b7e\" />\n\n---\n\n## 配置说明与高级选项\n\n### 智能检测机制\n\n当 `DOCKER_MODE` 设置为 `\"false\"` 或未设置时，MCP 服务将自动检测部署模式：\n\n1. **Docker 容器检测**：检查是否有运行中的 Docker 容器（名称包含 \"pansou\"）\n2. **源码部署检测**：检查是否存在 Go 可执行文件（pansou.exe/main.exe）\n3. **服务运行检测**：检查后端服务是否已在运行\n\n### 配置模式\n\n- **自动模式**（推荐）：使用默认配置，让服务自动检测部署方式\n- **强制 Docker 模式**：设置 `\"DOCKER_MODE\": \"true\"`\n- **强制源码模式**：设置 `\"DOCKER_MODE\": \"false\"` 且 `\"AUTO_START_BACKEND\": \"true\"`\n- **仅连接模式**：设置 `\"AUTO_START_BACKEND\": \"false\"`（适用于手动启动的后端）\n\n### 统一配置文件\n\n无论使用哪种后端部署方式，都可以使用统一的 `mcp-config.json` 配置文件。MCP 服务会根据配置自动检测和适配不同的部署模式。\n\n### 常见问题排查\n\n#### 后端服务连接问题\n\n1. **检查服务状态**：\n   ```bash\n   # 检查健康状态\n   curl http://localhost:8888/api/health\n   \n   # 或使用 PowerShell\n   Invoke-WebRequest -Uri \"http://localhost:8888/api/health\"\n   ```\n\n2. **Docker 部署问题**：\n   ```bash\n   # 检查容器状态\n   docker ps\n   \n   # 查看容器日志\n   docker-compose logs\n   \n   # 重启容器\n   docker-compose restart\n   ```\n\n3. **源码部署问题**：\n   - 确认 Go 版本 >= 1.25.0\n   - 检查端口 8888 是否被占用\n   - 确认防火墙设置\n\n4. **MCP 服务问题**：\n   - 确认 Node.js 版本 >= 18.0.0\n   - 检查 `typescript/dist` 目录是否存在\n   - 验证配置文件中的路径是否正确\n\n---\n\n## 支持的参数\n\nMCP 服务通过工具调用接收参数。以下是主要工具及其支持的参数：\n\n### `search_netdisk` 工具\n\n用于搜索网盘资源。\n\n| 参数名          | 类型            | 必填 | 默认值               | 描述                                                         |\n| :-------------- | :-------------- | :--- | :------------------- | :----------------------------------------------------------- |\n| `keyword`       | string          | 是   | -                    | 搜索关键词，例如 \"速度与激情\"、\"Python教程\"。                |\n| `channels`      | array of string | 否   | 配置默认值           | 要搜索的 Telegram 频道列表，例如 `[\"tgsearchers3\", \"another_channel\"]`。 |\n| `plugins`       | array of string | 否   | 配置默认值或所有插件 | 要使用的搜索插件列表，例如 `[\"pansearch\", \"panta\"]`。        |\n| `cloud_types`   | array of string | 否   | 无过滤               | 过滤结果，仅返回指定类型的网盘链接。支持的类型有：`baidu`, `aliyun`, `quark`, `tianyi`, `uc`, `mobile`, `115`, `pikpak`, `xunlei`, `123`, `magnet`, `ed2k`, `others`。 |\n| `source_type`   | string          | 否   | `\"all\"`              | 数据来源类型。可选值：`\"all\"` (全部来源), `\"tg\"` (仅 Telegram), `\"plugin\"` (仅插件)。 |\n| `force_refresh` | boolean         | 否   | `false`              | 是否强制刷新缓存，以获取最新数据。                           |\n| `result_type`   | string          | 否   | `\"merge\"`            | 返回结果的类型。可选值：`\"all\"` (返回所有结果), `\"results\"` (仅返回详细结果), `\"merge\"` (仅返回按网盘类型分组的结果)。 |\n| `concurrency`   | number          | 否   | 自动计算             | 并发搜索的数量，0或不指定则自动计算。                         |\n| `ext_params`    | object          | 否   | `{}`                 | 传递给后端插件的自定义扩展参数，例如 `{\"title_en\": \"Fast and Furious\", \"is_all\": true}`。 |\n\n---\n\n### `check_service_health` 工具\n\n用于检查后端服务健康状态。\n\n- **参数**: 无\n\n---\n\n### `start_backend` 工具\n\n用于启动本地 PanSou 后端服务。\n\n| 参数名          | 类型    | 必填 | 默认值  | 描述                                       |\n| :-------------- | :------ | :--- | :------ | :----------------------------------------- |\n| `force_restart` | boolean | 否   | `false` | 是否强制重启后端服务（即使它已经在运行）。 |\n\n---\n\n### 环境变量配置\n\n您可以通过设置环境变量来配置 MCP 服务的行为：\n\n| 环境变量               | 描述                                                       | 默认值                    |\n| :--------------------- | :--------------------------------------------------------- | :------------------------ |\n| `PANSOU_SERVER_URL`    | PanSou 后端服务的 URL 地址。                               | `http://localhost:8888`   |\n| `REQUEST_TIMEOUT`      | HTTP 请求超时时间（秒）。                                  | `30`                      |\n| `MAX_RESULTS`          | （内部使用，限制处理结果数量）                             | `100`                     |\n| `DEFAULT_CHANNELS`     | 默认搜索的 Telegram 频道列表（逗号分隔）。                 | `\"\"` (使用后端默认)       |\n| `DEFAULT_PLUGINS`      | 默认使用的搜索插件列表（逗号分隔）。                       | `\"\"` (使用后端默认或所有) |\n| `ENABLED_PLUGINS`      | 指定后端启用的插件列表（逗号分隔），必须显式指定。         | `\"\"` (需要显式设置)       |\n| `DEFAULT_CLOUD_TYPES`  | 默认的网盘类型过滤器（逗号分隔）。                         | `\"\"` (无过滤)             |\n| `AUTO_START_BACKEND`   | 是否在 MCP 服务启动时自动尝试启动后端服务。                | `true`                    |\n| `DOCKER_MODE`          | 部署模式控制。设置为 `true` 强制使用 Docker 模式；设置为 `false` 或未设置时启用智能检测。智能检测将自动识别 Docker 容器、源码部署或运行中的服务。 | `false` (智能检测)        |\n| `PROJECT_ROOT_PATH`    | PanSou 后端可执行文件所在的目录路径（用于自动启动）。      | 无                        |\n| `IDLE_TIMEOUT`         | 空闲超时时间（毫秒），超过此时间无活动则可能关闭后端服务。 | `300000` (5分钟)          |\n| `ENABLE_IDLE_SHUTDOWN` | 是否启用空闲超时自动关闭后端服务。                         | `true`                    |\n"
  },
  {
    "path": "docs/插件开发指南.md",
    "content": "# PanSou 插件开发指南\n\n## 概述\n\nPanSou 采用异步插件架构，支持通过插件扩展搜索来源。插件系统基于 Go 接口设计，提供高性能的并发搜索能力和智能缓存机制。\n\n## 系统架构\n\n### 核心组件\n\n- **插件管理器 (PluginManager)**: 管理所有插件的注册和调度\n- **异步插件 (AsyncSearchPlugin)**: 实现异步搜索接口的插件\n- **基础插件 (BaseAsyncPlugin)**: 提供通用功能的基础结构\n- **工作池**: 管理并发请求和资源限制\n- **缓存系统**: 二级缓存提供高性能数据存储\n\n### 异步处理机制\n\n1. **双级超时控制**:\n   - 短超时 (4秒): 确保快速响应用户\n   - 长超时 (30秒): 允许完整数据处理\n\n2. **渐进式结果返回**:\n   - `isFinal=false`: 部分结果，继续后台处理\n   - `isFinal=true`: 完整结果，停止处理\n\n3. **智能缓存更新**:\n   - 实时更新主缓存 (内存+磁盘)\n   - 结果合并去重\n   - 用户无感知数据更新\n\n## 插件接口规范\n\n### AsyncSearchPlugin 接口\n\n```go\ntype AsyncSearchPlugin interface {\n    // Name 返回插件名称 (必须唯一)\n    Name() string\n    \n    // Priority 返回插件优先级 (1-4，数字越小优先级越高，影响搜索结果排序)\n    Priority() int\n    \n    // AsyncSearch 异步搜索方法 (核心方法)\n    AsyncSearch(keyword string, searchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error), mainCacheKey string, ext map[string]interface{}) ([]model.SearchResult, error)\n    \n    // SetMainCacheKey 设置主缓存键 (由系统调用)\n    SetMainCacheKey(key string)\n    \n    // SetCurrentKeyword 设置当前搜索关键词 (用于日志显示)\n    SetCurrentKeyword(keyword string)\n    \n    // Search 同步搜索方法 (兼容性方法)\n    Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error)\n    \n    // SkipServiceFilter 返回是否跳过Service层的关键词过滤 (新增功能)\n    // 对于磁力搜索等需要宽泛结果的插件，应返回true\n    SkipServiceFilter() bool\n}\n```\n\n### 参数说明\n\n- **keyword**: 搜索关键词\n- **searchFunc**: HTTP搜索函数，处理实际的网络请求\n- **mainCacheKey**: 主缓存键，用于缓存管理\n- **ext**: 扩展参数，支持自定义搜索选项\n\n### Service层过滤控制 (新功能)\n\nPanSou支持插件级别的Service层过滤控制，允许插件自主决定是否在Service层进行关键词过滤：\n\n#### 过滤机制说明\n\n1. **插件层过滤**: 在插件内部使用 `FilterResultsByKeyword()` 进行精确过滤\n2. **Service层过滤**: 在 `search_service.go` 的 `mergeResultsByType()` 中进行二次过滤\n3. **双层过滤问题**: 某些插件（如磁力搜索）需要更宽泛的搜索结果，二次过滤会误删有效结果\n\n#### 适用场景\n\n**应该跳过Service层过滤的插件类型**:\n- ✅ **磁力搜索插件**: 如 thepiratebay，标题格式特殊（点号分隔），需要宽泛匹配\n- ✅ **国外资源插件**: 英文资源标题与中文关键词匹配度低\n- ✅ **特殊格式插件**: 标题包含大量符号或编码，标准过滤可能失效\n- ✅ **聚合搜索插件**: 需要保留所有相关结果供用户筛选\n\n**应该保持Service层过滤的插件类型**:\n- ⚠️ **网盘搜索插件**: 标准中文资源，过滤有助于提高精确度\n- ⚠️ **API接口插件**: 结构化数据，关键词匹配准确\n- ⚠️ **论坛爬取插件**: 标题格式标准，过滤效果良好\n\n## 插件优先级系统\n\n### 优先级等级\n\nPanSou 采用4级插件优先级系统，直接影响搜索结果的排序权重：\n\n| 等级 | 得分 | 适用场景 | 示例插件 |\n|------|------|----------|----------|\n| **等级1** | **1000分** | 高质量、稳定可靠的数据源 | panta, zhizhen, labi |\n| **等级2** | **500分** | 质量良好、响应稳定的数据源 | huban, shandian, duoduo |\n| **等级3** | **0分** | 普通质量的数据源 | pansearch, hunhepan, pan666 |\n| **等级4** | **-200分** | 质量较低或不稳定的数据源 | - |\n\n### 排序算法影响\n\n插件优先级在PanSou的多维度排序算法中占据主导地位：\n\n```\n总得分 = 插件得分(1000/500/0/-200) + 时间得分(最高500) + 关键词得分(最高420)\n```\n\n**权重分配**：\n- 🥇 **插件等级**: ~52% (主导因素)\n- 🥈 **关键词匹配**: ~22% (重要因素)  \n- 🥉 **时间新鲜度**: ~26% (重要因素)\n\n**实际效果**：\n- 等级1插件的结果通常排在前列\n- 即使是较旧的等级1插件结果，也会优于新的等级3插件结果\n- 包含优先关键词的等级2插件可能超越等级1插件\n\n### 如何选择优先级\n\n在开发新插件时，应根据以下标准选择合适的优先级：\n\n#### 选择等级1的条件\n- ✅ 数据源质量极高，很少出现无效链接\n- ✅ 服务稳定性好，响应时间短\n- ✅ 数据更新频率高，内容新颖\n- ✅ 链接有效性高（>90%）\n\n#### 选择等级2的条件  \n- ✅ 数据源质量良好，偶有无效链接\n- ✅ 服务相对稳定，响应时间适中\n- ✅ 数据更新较为及时\n- ✅ 链接有效性中等（70-90%）\n\n#### 选择等级3的条件\n- ⚠️ 数据源质量一般，存在一定比例无效链接\n- ⚠️ 服务稳定性一般，可能偶有超时\n- ⚠️ 数据更新不够及时\n- ⚠️ 链接有效性较低（50-70%）\n\n#### 选择等级4的条件\n- ❌ 数据源质量较差，大量无效链接\n- ❌ 服务不稳定，经常超时或失败\n- ❌ 数据更新缓慢或过时\n- ❌ 链接有效性很低（<50%）\n\n### 启动时显示\n\n系统启动时会按优先级排序显示所有已加载的插件：\n\n```\n已加载插件:\n  - panta (优先级: 1)\n  - zhizhen (优先级: 1)  \n  - labi (优先级: 1)\n  - huban (优先级: 2)\n  - duoduo (优先级: 2)\n  - pansearch (优先级: 3)\n  - hunhepan (优先级: 3)\n```\n\n## 开发新插件\n\n### 1. 基础结构\n\n```go\npackage myplugin\n\nimport (\n    \"context\"\n    \"io\"\n    \"net/http\"\n    \"time\"\n    \"pansou/model\"\n    \"pansou/plugin\"\n    \"pansou/util/json\"  // 使用项目统一的高性能JSON工具\n)\n\ntype MyPlugin struct {\n    *plugin.BaseAsyncPlugin\n}\n\nfunc init() {\n    p := &MyPlugin{\n        BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"myplugin\", 3), // 优先级3 = 普通质量数据源\n    }\n    plugin.RegisterGlobalPlugin(p)\n}\n\n// 对于需要跳过Service层过滤的插件（如磁力搜索插件）\nfunc init() {\n    p := &MyMagnetPlugin{\n        BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"mymagnet\", 4, true), // 跳过Service层过滤\n    }\n    plugin.RegisterGlobalPlugin(p)\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *MyPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n    result, err := p.SearchWithResult(keyword, ext)\n    if err != nil {\n        return nil, err\n    }\n    return result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果（推荐方法）\nfunc (p *MyPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n    return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n```\n\n### 2. 实现搜索逻辑（⭐ 推荐实现模式）\n\n```go\nfunc (p *MyPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n    // 1. 构建请求URL\n    searchURL := fmt.Sprintf(\"https://api.example.com/search?q=%s\", url.QueryEscape(keyword))\n    \n    // 2. 处理扩展参数\n    if titleEn, ok := ext[\"title_en\"].(string); ok && titleEn != \"\" {\n        searchURL += \"&title_en=\" + url.QueryEscape(titleEn)\n    }\n    \n    // 3. 创建带超时的上下文 ⭐ 重要：避免请求超时\n    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n    defer cancel()\n    \n    // 4. 创建请求对象 ⭐ 重要：使用context控制超时\n    req, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n    if err != nil {\n        return nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n    }\n    \n    // 5. 设置完整请求头 ⭐ 重要：避免反爬虫检测\n    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\")\n    req.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n    req.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n    req.Header.Set(\"Connection\", \"keep-alive\")\n    req.Header.Set(\"Referer\", \"https://api.example.com/\")\n    \n    // 6. 发送HTTP请求（带重试机制）⭐ 重要：提高稳定性\n    resp, err := p.doRequestWithRetry(req, client)\n    if err != nil {\n        return nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n    }\n    defer resp.Body.Close()\n    \n    // 7. 检查状态码\n    if resp.StatusCode != 200 {\n        return nil, fmt.Errorf(\"[%s] 请求返回状态码: %d\", p.Name(), resp.StatusCode)\n    }\n    \n    // 8. 解析响应\n    var apiResp APIResponse\n    if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {\n        return nil, fmt.Errorf(\"[%s] JSON解析失败: %w\", p.Name(), err)\n    }\n    \n    // 9. 转换为标准格式\n    results := make([]model.SearchResult, 0, len(apiResp.Data))\n    for _, item := range apiResp.Data {\n        result := model.SearchResult{\n            UniqueID:  fmt.Sprintf(\"%s-%s\", p.Name(), item.ID),\n            Title:     item.Title,\n            Content:   item.Description,\n            Datetime:  item.CreateTime,\n            Tags:      item.Tags,\n            Links:     convertLinks(item.Links), // 转换链接格式\n        }\n        results = append(results, result)\n    }\n    \n    // 10. 关键词过滤\n    return plugin.FilterResultsByKeyword(results, keyword), nil\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求 ⭐ 重要：提高稳定性\nfunc (p *MyPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n    maxRetries := 3\n    var lastErr error\n    \n    for i := 0; i < maxRetries; i++ {\n        if i > 0 {\n            // 指数退避重试\n            backoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n            time.Sleep(backoff)\n        }\n        \n        // 克隆请求避免并发问题\n        reqClone := req.Clone(req.Context())\n        \n        resp, err := client.Do(reqClone)\n        if err == nil && resp.StatusCode == 200 {\n            return resp, nil\n        }\n        \n        if resp != nil {\n            resp.Body.Close()\n        }\n        lastErr = err\n    }\n    \n    return nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n```\n\n### 3. 链接转换与 work_title 字段\n\n#### Link 结构定义\n\n```go\ntype Link struct {\n    Type      string    `json:\"type\"`                                    // 网盘类型\n    URL       string    `json:\"url\"`                                     // 链接地址\n    Password  string    `json:\"password\"`                                // 提取码/密码\n    Datetime  time.Time `json:\"datetime,omitempty\"`                      // 链接更新时间（可选）\n    WorkTitle string    `json:\"work_title,omitempty\"`                    // 作品标题（重要：用于区分同一消息中多个作品的链接）\n}\n```\n\n#### work_title 字段详解\n\n**字段作用**:\n- 用于区分**同一条消息/结果中包含的多个不同作品**的链接\n- 特别适用于论坛帖子、TG频道消息等一次性发布多部影视资源的场景\n\n**使用场景示例**:\n\n```\n📺 TG频道消息示例：\n【今日更新】多部热门剧集\n1. 凡人修仙传 第30集\n   夸克：https://pan.quark.cn/s/abc123\n2. 唐朝诡事录 第20集\n   夸克：https://pan.quark.cn/s/def456\n3. 庆余年2 全集\n   百度：https://pan.baidu.com/s/xyz789?pwd=abcd\n```\n\n**不使用 work_title 的问题**:\n- 所有链接的标题都是 \"【今日更新】多部热门剧集\"\n- 用户无法区分哪个链接对应哪部剧集\n\n**使用 work_title 后的效果**:\n```go\nlinks := []model.Link{\n    {\n        Type:      \"quark\",\n        URL:       \"https://pan.quark.cn/s/abc123\",\n        WorkTitle: \"凡人修仙传 第30集\",  // 独立作品标题\n    },\n    {\n        Type:      \"quark\", \n        URL:       \"https://pan.quark.cn/s/def456\",\n        WorkTitle: \"唐朝诡事录 第20集\",  // 独立作品标题\n    },\n    {\n        Type:      \"baidu\",\n        URL:       \"https://pan.baidu.com/s/xyz789?pwd=abcd\",\n        Password:  \"abcd\",\n        WorkTitle: \"庆余年2 全集\",       // 独立作品标题\n    },\n}\n```\n\n**PanSou系统的智能处理**:\n\nPanSou 会根据消息中的链接数量自动决定是否提取 work_title：\n\n1. **链接数量 ≤ 4**：所有链接使用相同的 work_title（即消息标题）\n   ```go\n   // 示例：一条消息只包含同一部剧的不同网盘链接\n   // 消息标题：\"凡人修仙传 第30集\"\n   // 链接1(夸克)、链接2(百度) → work_title 都是 \"凡人修仙传 第30集\"\n   ```\n\n2. **链接数量 > 4**：系统智能识别每个链接对应的作品标题\n   ```go\n   // 示例：一条消息包含5个不同作品的链接\n   // 系统会分析消息文本，为每个链接提取独立的 work_title\n   ```\n\n**插件实现 work_title 的两种方式**:\n\n**方式1: 依赖系统自动提取**（适用于TG频道、论坛等）\n```go\n// 直接返回链接，系统会自动调用 extractWorkTitlesForLinks 进行处理\nlinks := []model.Link{\n    {Type: \"quark\", URL: \"https://pan.quark.cn/s/abc123\"},\n    {Type: \"baidu\", URL: \"https://pan.baidu.com/s/xyz789\"},\n}\n// PanSou会根据消息文本自动为每个链接提取work_title\n```\n\n**方式2: 插件手动设置**（适用于API插件、磁力搜索等）\n```go\n// 插件直接设置 work_title（如feikuai、thepiratebay等）\nlinks := []model.Link{\n    {\n        Type:      \"magnet\",\n        URL:       magnetURL,\n        WorkTitle: buildWorkTitle(keyword, fileName), // 插件自己构建\n        Datetime:  publishedTime,\n    },\n}\n```\n\n**插件开发建议**:\n- **网盘API插件**: 如果API直接返回单一作品，可以不设置 work_title（留空）\n- **磁力搜索插件**: 建议设置 work_title，特别是文件名不含中文时需要拼接关键词\n- **爬虫插件**: 如果能从页面提取每个链接的独立标题，建议设置 work_title\n\n#### 支持的网盘类型\n\nPanSou系统支持以下网盘类型的自动识别（完整列表）：\n\n| 网盘类型 | 类型标识 | 域名特征 | 说明 |\n|---------|---------|----------|------|\n| **夸克网盘** | `quark` | `pan.quark.cn` | 主流网盘 |\n| **UC网盘** | `uc` | `drive.uc.cn` | 主流网盘 |\n| **百度网盘** | `baidu` | `pan.baidu.com` | 主流网盘 |\n| **阿里云盘** | `aliyun` | `aliyundrive.com`, `alipan.com` | 主流网盘 |\n| **迅雷网盘** | `xunlei` | `pan.xunlei.com` | 主流网盘 |\n| **天翼云盘** | `tianyi` | `cloud.189.cn` | 主流网盘 |\n| **115网盘** | `115` | `115.com`,`115cdn.com`,`anxia.com` | 主流网盘 |\n| **123网盘** | `123` | `123pan.com`,`123684.com`,`123685.com`,`123912.com`,`123pan.cn`,`123592.com` | 主流网盘 |\n| **移动云盘** | `mobile` | `caiyun.139.com` | 其他网盘 |\n| **PikPak** | `pikpak` | `mypikpak.com` | 其他网盘 |\n| **磁力链接** | `magnet` | `magnet:?xt=urn:btih:` | 磁力链接 |\n| **ED2K链接** | `ed2k` | `ed2k://` | 磁力链接 |\n\n```go\nfunc convertLinks(apiLinks []APILink) []model.Link {\n    links := make([]model.Link, 0, len(apiLinks))\n    for _, apiLink := range apiLinks {\n        link := model.Link{\n            Type:     determineCloudType(apiLink.URL), // 自动识别网盘类型\n            URL:      apiLink.URL,\n            Password: apiLink.Password,\n        }\n        links = append(links, link)\n    }\n    return links\n}\n\nfunc determineCloudType(url string) string {\n    switch {\n    case strings.Contains(url, \"pan.quark.cn\"):\n        return \"quark\"\n    case strings.Contains(url, \"drive.uc.cn\"):\n        return \"uc\"\n    case strings.Contains(url, \"pan.baidu.com\"):\n        return \"baidu\"\n    case strings.Contains(url, \"aliyundrive.com\") || strings.Contains(url, \"alipan.com\"):\n        return \"aliyun\"\n    case strings.Contains(url, \"pan.xunlei.com\"):\n        return \"xunlei\"\n    case strings.Contains(url, \"cloud.189.cn\"):\n        return \"tianyi\"\n    case strings.Contains(url, \"115.com\") || strings.Contains(url, \"115cdn.com\") || strings.Contains(url, \"anxia.com\"):\n        return \"115\"\n    case strings.Contains(url, \"123684.com\") || strings.Contains(url, \"123685.com\") || \n\t\tstrings.Contains(url, \"123912.com\") || strings.Contains(url, \"123pan.com\") || \n\t\tstrings.Contains(url, \"123pan.cn\") || strings.Contains(url, \"123592.com\"): \n\t\treturn \"123\"\n    case strings.Contains(url, \"caiyun.139.com\"):\n        return \"mobile\"\n    case strings.Contains(url, \"mypikpak.com\"):\n        return \"pikpak\"\n    case strings.Contains(url, \"magnet:\"):\n        return \"magnet\"\n    case strings.Contains(url, \"ed2k://\"):\n        return \"ed2k\"\n    default:\n        return \"others\"\n    }\n}\n\n// 使用示例\nfunc convertAPILinks(apiLinks []APILink) []model.Link {\n    links := make([]model.Link, 0, len(apiLinks))\n    for _, apiLink := range apiLinks {\n        // 自动识别网盘类型\n        cloudType := determineCloudType(apiLink.URL)\n        \n        // 只添加识别成功的链接\n        if cloudType != \"others\" || strings.HasPrefix(apiLink.URL, \"http\") {\n            link := model.Link{\n                Type:     cloudType,\n                URL:      apiLink.URL,\n                Password: apiLink.Password,\n            }\n            links = append(links, link)\n        }\n    }\n    return links\n}\n```\n\n## 高级特性\n\n### 1. 插件Web路由注册（自定义HTTP接口）\n\n#### 概述\n\nPanSou 支持插件注册自定义的 HTTP 路由，用于实现插件专属的管理页面、配置接口或其他Web功能。\n\n**典型应用场景**:\n- 插件配置管理界面（如 QQPD 的用户登录和频道管理）\n- 插件数据查询接口\n- 插件状态监控页面\n- OAuth回调接口\n\n#### 接口定义\n\n```go\n// PluginWithWebHandler 支持Web路由的插件接口\n// 插件可以选择实现此接口来注册自定义的HTTP路由\ntype PluginWithWebHandler interface {\n    AsyncSearchPlugin // 继承搜索插件接口\n    \n    // RegisterWebRoutes 注册Web路由\n    // router: gin的路由组，插件可以在此注册自己的路由\n    RegisterWebRoutes(router *gin.RouterGroup)\n}\n```\n\n#### 实现步骤\n\n**步骤1: 插件结构实现接口**\n\n```go\npackage myplugin\n\nimport (\n    \"github.com/gin-gonic/gin\"\n    \"pansou/plugin\"\n    \"pansou/model\"\n)\n\ntype MyPlugin struct {\n    *plugin.BaseAsyncPlugin\n    // ... 其他字段\n}\n\n// 确保插件实现了 PluginWithWebHandler 接口\nvar _ plugin.PluginWithWebHandler = (*MyPlugin)(nil)\n```\n\n**步骤2: 实现 RegisterWebRoutes 方法**\n\n```go\n// RegisterWebRoutes 注册Web路由\nfunc (p *MyPlugin) RegisterWebRoutes(router *gin.RouterGroup) {\n    // 创建插件专属的路由组\n    myGroup := router.Group(\"/myplugin\")\n    \n    // 注册GET路由\n    myGroup.GET(\"/status\", p.handleGetStatus)\n    \n    // 注册POST路由\n    myGroup.POST(\"/config\", p.handleUpdateConfig)\n    \n    // 支持动态路径参数\n    myGroup.GET(\"/:id\", p.handleGetByID)\n    myGroup.POST(\"/:id/action\", p.handleAction)\n}\n```\n\n**步骤3: 实现路由处理函数**\n\n```go\n// handleGetStatus 获取插件状态\nfunc (p *MyPlugin) handleGetStatus(c *gin.Context) {\n    c.JSON(200, gin.H{\n        \"status\": \"ok\",\n        \"plugin\": p.Name(),\n        \"version\": \"1.0.0\",\n    })\n}\n\n// handleUpdateConfig 更新插件配置\nfunc (p *MyPlugin) handleUpdateConfig(c *gin.Context) {\n    var config map[string]interface{}\n    \n    if err := c.BindJSON(&config); err != nil {\n        c.JSON(400, gin.H{\"error\": \"Invalid JSON\"})\n        return\n    }\n    \n    // 处理配置更新逻辑\n    // ...\n    \n    c.JSON(200, gin.H{\n        \"success\": true,\n        \"message\": \"配置已更新\",\n    })\n}\n\n// handleGetByID 根据ID获取数据\nfunc (p *MyPlugin) handleGetByID(c *gin.Context) {\n    id := c.Param(\"id\")\n    \n    // 根据ID查询数据\n    // ...\n    \n    c.JSON(200, gin.H{\n        \"id\": id,\n        \"data\": \"...\",\n    })\n}\n```\n\n#### 实际案例: QQPD 插件\n\nQQPD 插件实现了完整的用户管理和频道配置功能：\n\n```go\n// RegisterWebRoutes 注册Web路由\nfunc (p *QQPDPlugin) RegisterWebRoutes(router *gin.RouterGroup) {\n    qqpd := router.Group(\"/qqpd\")\n    \n    // GET /:param - 显示管理页面（HTML）\n    qqpd.GET(\"/:param\", p.handleManagePage)\n    \n    // POST /:param - 处理管理操作（JSON API）\n    qqpd.POST(\"/:param\", p.handleManagePagePOST)\n}\n\n// handleManagePage 渲染管理页面\nfunc (p *QQPDPlugin) handleManagePage(c *gin.Context) {\n    param := c.Param(\"param\")\n    \n    // 生成用户专属的管理页面\n    html := strings.ReplaceAll(HTMLTemplate, \"HASH_PLACEHOLDER\", param)\n    \n    c.Header(\"Content-Type\", \"text/html; charset=utf-8\")\n    c.String(200, html)\n}\n\n// handleManagePagePOST 处理管理操作\nfunc (p *QQPDPlugin) handleManagePagePOST(c *gin.Context) {\n    param := c.Param(\"param\")\n    \n    var req struct {\n        Action   string   `json:\"action\"`\n        Channels []string `json:\"channels,omitempty\"`\n        Keyword  string   `json:\"keyword,omitempty\"`\n    }\n    \n    if err := c.BindJSON(&req); err != nil {\n        respondError(c, \"无效的请求格式\")\n        return\n    }\n    \n    // 根据不同的 action 执行不同的操作\n    switch req.Action {\n    case \"get_status\":\n        p.handleGetStatus(c, param)\n    case \"set_channels\":\n        p.handleSetChannels(c, param, req.Channels)\n    case \"test_search\":\n        p.handleTestSearch(c, param, req.Keyword)\n    case \"logout\":\n        p.handleLogout(c, param)\n    default:\n        respondError(c, \"未知的操作\")\n    }\n}\n```\n\n#### 实际案例: Gying 插件\n\n```go\n// RegisterWebRoutes 注册Web路由\nfunc (p *GyingPlugin) RegisterWebRoutes(router *gin.RouterGroup) {\n    gying := router.Group(\"/gying\")\n    gying.GET(\"/:param\", p.handleManagePage)\n    gying.POST(\"/:param\", p.handleManagePagePOST)\n}\n```\n\n#### 路由访问示例\n\n插件注册的路由可以通过以下方式访问：\n\n```bash\n# QQPD 插件管理页面\nGET  http://localhost:8888/qqpd/user123\n\n# QQPD 插件配置接口\nPOST http://localhost:8888/qqpd/user123\nContent-Type: application/json\n{\n  \"action\": \"set_channels\",\n  \"channels\": [\"pd97631607\", \"kuake12345\"]\n}\n\n# 自定义插件接口\nGET  http://localhost:8888/myplugin/status\nPOST http://localhost:8888/myplugin/config\nGET  http://localhost:8888/myplugin/resource123\n```\n\n#### 系统集成\n\nPanSou 在启动时会自动扫描并注册所有实现了 `PluginWithWebHandler` 接口的插件路由：\n\n```go\n// api/router.go 中的自动注册逻辑\nfunc SetupRouter(searchService *service.SearchService) *gin.Engine {\n    r := gin.Default()\n    \n    // ... 其他路由配置 ...\n    \n    // 注册插件的Web路由（如果插件实现了PluginWithWebHandler接口）\n    allPlugins := plugin.GetRegisteredPlugins()\n    for _, p := range allPlugins {\n        if webPlugin, ok := p.(plugin.PluginWithWebHandler); ok {\n            webPlugin.RegisterWebRoutes(r.Group(\"\"))\n        }\n    }\n    \n    return r\n}\n```\n\n#### 开发建议\n\n1. **路由命名规范**: 使用插件名作为路由前缀，避免与其他插件冲突\n   ```go\n   // ✅ 推荐\n   router.Group(\"/myplugin\")\n   \n   // ❌ 避免\n   router.Group(\"/config\")  // 可能与其他插件冲突\n   ```\n\n2. **安全考虑**: \n   - 对敏感操作进行身份验证\n   - 验证用户输入，防止注入攻击\n   - 使用哈希或加密保护敏感参数\n\n3. **错误处理**: 统一错误响应格式\n   ```go\n   func respondError(c *gin.Context, message string) {\n       c.JSON(400, gin.H{\n           \"success\": false,\n           \"message\": message,\n       })\n   }\n   \n   func respondSuccess(c *gin.Context, message string, data interface{}) {\n       c.JSON(200, gin.H{\n           \"success\": true,\n           \"message\": message,\n           \"data\":    data,\n       })\n   }\n   ```\n\n4. **HTML模板**: 可以内嵌HTML模板提供管理界面\n   ```go\n   const HTMLTemplate = `<!DOCTYPE html>\n   <html>\n   <head>\n       <title>插件管理</title>\n   </head>\n   <body>\n       <h1>{{ .PluginName }} 管理界面</h1>\n       <!-- ... -->\n   </body>\n   </html>`\n   ```\n\n5. **可选实现**: Web路由是**可选功能**，只有需要自定义HTTP接口的插件才需要实现\n\n### 2. Service层过滤控制详解\n\n#### 构造函数选择\n\n```go\n// 标准插件构造函数（默认启用Service层过滤）\nfunc NewStandardPlugin() *StandardPlugin {\n    return &StandardPlugin{\n        BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"standard\", 3), // 默认skipServiceFilter=false\n    }\n}\n\n// 磁力搜索插件构造函数（跳过Service层过滤）\nfunc NewMagnetPlugin() *MagnetPlugin {\n    return &MagnetPlugin{\n        BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"magnet\", 4, true), // skipServiceFilter=true\n    }\n}\n```\n\n#### 实际应用示例\n\n**ThePirateBay插件示例**:\n```go\n// thepiratebay插件的实际实现\nfunc NewThePirateBayPlugin() *ThePirateBayPlugin {\n    return &ThePirateBayPlugin{\n        BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"thepiratebay\", 4, true), // 跳过Service层过滤\n        optimizedClient: createOptimizedHTTPClient(),\n    }\n}\n\nfunc (p *ThePirateBayPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n    // 支持英文搜索优化\n    searchKeyword := keyword\n    if ext != nil {\n        if titleEn, exists := ext[\"title_en\"]; exists {\n            if titleEnStr, ok := titleEn.(string); ok && titleEnStr != \"\" {\n                searchKeyword = titleEnStr\n            }\n        }\n    }\n    \n    // 获取搜索结果\n    allResults := p.fetchAllResults(client, searchKeyword)\n    \n    // 标题格式优化：将'.'替换为空格，提高关键词匹配准确度\n    for i := range allResults {\n        allResults[i].Title = strings.ReplaceAll(allResults[i].Title, \".\", \" \")\n    }\n    \n    // 插件层过滤（使用处理后的搜索关键词）\n    filteredResults := plugin.FilterResultsByKeyword(allResults, searchKeyword)\n    \n    return filteredResults, nil\n    // 注意：Service层会通过SkipServiceFilter()方法跳过二次过滤\n}\n```\n\n#### 过滤策略对比\n\n| 过滤类型 | 标准插件 | 磁力搜索插件 |\n|----------|----------|--------------|\n| **插件层过滤** | ✅ 使用原始关键词 | ✅ 使用searchKeyword（支持title_en） |\n| **Service层过滤** | ✅ 再次过滤 | ❌ 跳过过滤 |\n| **结果特点** | 精确匹配 | 宽泛搜索 |\n| **适用场景** | 中文网盘资源 | 英文磁力资源 |\n\n#### 动态过滤检测机制\n\nService层通过以下机制动态判断是否需要过滤：\n\n```go\n// service/search_service.go 中的实现\nfunc mergeResultsByType(...) {\n    // 检查插件是否需要跳过Service层过滤\n    var skipKeywordFilter bool = false\n    if result.UniqueID != \"\" && strings.Contains(result.UniqueID, \"-\") {\n        parts := strings.SplitN(result.UniqueID, \"-\", 2)\n        if len(parts) >= 1 {\n            pluginName := parts[0]\n            // 通过插件注册表动态获取过滤设置\n            if pluginInstance, exists := plugin.GetPluginByName(pluginName); exists {\n                skipKeywordFilter = pluginInstance.SkipServiceFilter()\n            }\n        }\n    }\n    \n    // 根据插件设置决定是否过滤\n    if !skipKeywordFilter && keyword != \"\" && !strings.Contains(strings.ToLower(title), lowerKeyword) {\n        continue // 过滤掉不匹配的结果\n    }\n}\n```\n\n### 2. 扩展参数处理\n\n```go\n// 支持的扩展参数示例\next := map[string]interface{}{\n    \"title_en\": \"English Title\",     // 英文标题\n    \"is_all\":   true,               // 全量搜索标志\n    \"year\":     2023,               // 年份限制\n    \"type\":     \"movie\",            // 内容类型\n}\n\n// 在插件中处理\nfunc (p *MyPlugin) handleExtParams(ext map[string]interface{}) searchOptions {\n    opts := searchOptions{}\n    \n    if titleEn, ok := ext[\"title_en\"].(string); ok {\n        opts.TitleEn = titleEn\n    }\n    \n    if isAll, ok := ext[\"is_all\"].(bool); ok {\n        opts.IsAll = isAll\n    }\n    \n    return opts\n}\n```\n\n### 2. 缓存策略\n\n```go\n// 设置缓存TTL\np.SetCacheTTL(2 * time.Hour)\n\n// 手动缓存更新\np.UpdateMainCache(cacheKey, results, ttl, true, keyword)\n```\n\n### 3. 错误处理\n\n```go\nfunc (p *MyPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n    // 网络错误处理\n    resp, err := client.Get(url)\n    if err != nil {\n        return nil, fmt.Errorf(\"[%s] 网络请求失败: %w\", p.Name(), err)\n    }\n    \n    // HTTP状态码检查\n    if resp.StatusCode != 200 {\n        return nil, fmt.Errorf(\"[%s] HTTP错误: %d\", p.Name(), resp.StatusCode)\n    }\n    \n    // JSON解析错误 - 推荐使用项目统一的JSON工具\n    body, err := io.ReadAll(resp.Body)\n    if err != nil {\n        return nil, fmt.Errorf(\"[%s] 读取响应失败: %w\", p.Name(), err)\n    }\n    \n    var apiResp APIResponse\n    if err := json.Unmarshal(body, &apiResp); err != nil {\n        return nil, fmt.Errorf(\"[%s] JSON解析失败: %w\", p.Name(), err)\n    }\n    \n    // 业务逻辑错误\n    if apiResp.Code != 0 {\n        return nil, fmt.Errorf(\"[%s] API错误: %s\", p.Name(), apiResp.Message)\n    }\n    \n    return results, nil\n}\n```\n\n## 性能优化\n\n### 1. HTTP客户端优化\n\n```go\n// 使用连接池\nclient := &http.Client{\n    Timeout: 30 * time.Second,\n    Transport: &http.Transport{\n        MaxIdleConns:        100,\n        MaxIdleConnsPerHost: 10,\n        IdleConnTimeout:     90 * time.Second,\n    },\n}\n```\n\n### 2. 内存优化\n\n```go\n// 预分配切片容量\nresults := make([]model.SearchResult, 0, expectedCount)\n\n// 及时释放大对象\ndefer func() {\n    apiResp = APIResponse{}\n}()\n```\n\n### 3. 并发控制\n\n```go\n// 使用插件内置的工作池，避免创建过多goroutine\n// BaseAsyncPlugin 已经提供了工作池管理\n```\n\n## 测试和调试\n\n### 1. 单元测试\n\n```go\nfunc TestMyPlugin_Search(t *testing.T) {\n    plugin := &MyPlugin{\n        BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"test\", 3),\n    }\n    \n    results, err := plugin.Search(\"测试关键词\", nil)\n    assert.NoError(t, err)\n    assert.NotEmpty(t, results)\n}\n```\n\n### 2. 集成测试\n\n```bash\n# 使用API测试插件\ncurl \"http://localhost:8888/api/search?kw=测试&plugins=myplugin\"\n```\n\n### 3. 性能测试\n\n```bash\n# 使用压力测试脚本\npython3 stress_test.py\n```\n\n## 部署和配置\n\n### 1. 插件注册\n\n确保在 `init()` 函数中注册插件：\n\n```go\nfunc init() {\n    p := &MyPlugin{\n        BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"myplugin\", 3),\n    }\n    plugin.RegisterGlobalPlugin(p)\n}\n```\n\n### 2. 环境配置\n\n```bash\n# 异步插件配置\nexport ASYNC_PLUGIN_ENABLED=true\nexport ASYNC_RESPONSE_TIMEOUT=4\nexport ASYNC_MAX_BACKGROUND_WORKERS=40\nexport ASYNC_MAX_BACKGROUND_TASKS=200\n```\n\n### 3. 生产部署注意事项\n\n1. **资源限制**: 根据服务器配置调整工作池大小\n2. **监控告警**: 监控插件响应时间和错误率\n3. **日志管理**: 合理设置日志级别，避免日志过多\n4. **缓存配置**: 根据数据更新频率调整缓存TTL\n\n## 现有插件参考\n\n### 标准网盘搜索插件\n- **jikepan** - 标准网盘插件，启用Service层过滤\n- **pan666** - 标准网盘插件，启用Service层过滤\n- **hunhepan** - 标准网盘插件，启用Service层过滤\n- **pansearch** - 标准网盘插件，启用Service层过滤\n- **qupansou** - 标准网盘插件，启用Service层过滤\n- **panta** - 高质量网盘插件，启用Service层过滤\n\n### 特殊搜索插件\n- **thepiratebay** - 磁力搜索插件，跳过Service层过滤，支持title_en参数，标题格式化处理\n\n## 插件开发最佳实践 ⭐\n\n### 核心原则\n\n1. **命名规范**: 插件名使用小写字母和数字\n2. **优先级设置**: 1-2为高优先级，3为标准，4-5为低优先级\n3. **关键词过滤**: 使用 `FilterResultsByKeyword` 提高结果相关性\n4. **缓存友好**: 合理设置缓存TTL，避免频繁请求\n5. **资源清理**: 及时关闭连接和释放资源\n6. **过滤策略**: 根据插件类型选择合适的Service层过滤策略\n\n### 必须实现的优化点\n\n#### 1. Service层过滤策略选择 ⭐ 新功能\n\n```go\n// ✅ 磁力搜索插件 - 跳过Service层过滤\nfunc NewMagnetSearchPlugin() *MagnetSearchPlugin {\n    return &MagnetSearchPlugin{\n        BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"magnet\", 4, true), // skipServiceFilter=true\n    }\n}\n\n// ✅ 标准网盘插件 - 启用Service层过滤  \nfunc NewPanSearchPlugin() *PanSearchPlugin {\n    return &PanSearchPlugin{\n        BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"pansearch\", 3), // 默认skipServiceFilter=false\n    }\n}\n```\n\n**选择指南**:\n- **跳过过滤** (`true`): 磁力搜索、英文资源、特殊格式标题、聚合搜索\n- **启用过滤** (`false`): 网盘搜索、中文资源、API接口、标准格式标题\n\n**注意事项**:\n- 跳过Service层过滤的插件**必须**在插件内部进行`FilterResultsByKeyword`过滤\n- 插件层过滤使用的关键词应与实际搜索关键词一致（支持`title_en`等参数）\n- 标题格式化处理应在过滤之前进行（如将`\".\"` 替换为`\" \"`）\n\n#### 2. SearchResult字段设置规范 ⭐ 重要\n\n```go\n// ✅ 正确的SearchResult设置\nresult := model.SearchResult{\n    UniqueID: fmt.Sprintf(\"%s-%s\", p.Name(), itemID),  // 插件名-资源ID\n    Title:    title,                                   // 资源标题\n    Content:  description,                             // 资源描述\n    Links:    downloadLinks,                           // 下载链接列表\n    Tags:     tags,                                    // 分类标签\n    Channel:  \"\",                                      // ⭐ 重要：插件搜索结果必须为空字符串\n    Datetime: time.Now(),                              // 发布时间\n}\n\n// ❌ 错误的Channel设置\nresult.Channel = p.Name()  // 不要设置为插件名！\n```\n\n**Channel字段使用规则**:\n- **插件搜索结果**: `Channel` 必须为空字符串 `\"\"`\n- **Telegram频道**: `Channel` 才设置为频道名称\n- **目的**: 区分搜索来源，便于前端展示和后端统计\n\n**Links字段处理规则** ⭐ 重要:\n- **必须有链接**: 系统会自动过滤掉 `Links` 为空或长度为0的结果\n- **链接质量**: 确保返回的链接都是有效的网盘链接，避免返回无效链接\n- **链接验证**: 建议使用 `isValidNetworkDriveURL()` 函数预先验证链接有效性\n\n#### 2. HTTP请求最佳实践 ⭐ 重要\n\n```go\n// ✅ 正确的请求实现\nfunc (p *MyPlugin) makeRequest(url string, client *http.Client) (*http.Response, error) {\n    // 使用context控制超时\n    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n    defer cancel()\n    \n    // 创建请求\n    req, err := http.NewRequestWithContext(ctx, \"GET\", url, nil)\n    if err != nil {\n        return nil, err\n    }\n    \n    // 设置完整的请求头（避免反爬虫）\n    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\")\n    req.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n    req.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n    req.Header.Set(\"Connection\", \"keep-alive\")\n    req.Header.Set(\"Referer\", \"https://example.com/\")\n    \n    // 使用重试机制\n    return p.doRequestWithRetry(req, client)\n}\n\n// ❌ 错误的简单实现\nfunc (p *MyPlugin) badRequest(url string, client *http.Client) (*http.Response, error) {\n    return client.Get(url) // 没有超时控制、没有请求头、没有重试\n}\n```\n\n#### 2. 实现高级搜索接口 ⭐ 推荐\n\n```go\n// ✅ 推荐：实现两个方法\nfunc (p *MyPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n    result, err := p.SearchWithResult(keyword, ext)\n    if err != nil {\n        return nil, err\n    }\n    return result.Results, nil\n}\n\nfunc (p *MyPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n    return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n```\n\n#### 3. 错误处理增强 ⭐ 重要\n\n```go\n// ✅ 详细的错误信息\nif resp.StatusCode != 200 {\n    return nil, fmt.Errorf(\"[%s] 请求失败，状态码: %d\", p.Name(), resp.StatusCode)\n}\n\n// ✅ 包装外部错误\nif err := json.NewDecoder(resp.Body).Decode(&data); err != nil {\n    return nil, fmt.Errorf(\"[%s] JSON解析失败: %w\", p.Name(), err)\n}\n```\n\n#### 4. 重试机制模板 ⭐ 复制可用\n\n```go\nfunc (p *MyPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n    maxRetries := 3\n    var lastErr error\n    \n    for i := 0; i < maxRetries; i++ {\n        if i > 0 {\n            backoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n            time.Sleep(backoff)\n        }\n        \n        reqClone := req.Clone(req.Context())\n        resp, err := client.Do(reqClone)\n        if err == nil && resp.StatusCode == 200 {\n            return resp, nil\n        }\n        \n        if resp != nil {\n            resp.Body.Close()\n        }\n        lastErr = err\n    }\n    \n    return nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n```\n\n#### 5. 请求头模板 ⭐ 复制可用\n\n```go\n// HTML页面请求头\nreq.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\")\nreq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\nreq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\nreq.Header.Set(\"Connection\", \"keep-alive\")\nreq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\nreq.Header.Set(\"Cache-Control\", \"max-age=0\")\nreq.Header.Set(\"Referer\", \"https://example.com/\")\n\n// JSON API请求头\nreq.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\")\nreq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\nreq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\nreq.Header.Set(\"Connection\", \"keep-alive\")\nreq.Header.Set(\"Content-Type\", \"application/json\")\nreq.Header.Set(\"Referer\", \"https://example.com/\")\n```\n\n### 常见问题避免\n\n1. **不要使用 `client.Get(url)`** - 缺少超时控制和请求头\n2. **不要忘记设置 User-Agent** - 很多网站会阻止空UA请求\n3. **不要忘记错误上下文** - 使用 `fmt.Errorf(\"[%s] 错误描述: %w\", p.Name(), err)`\n4. **不要忘记关闭响应体** - `defer resp.Body.Close()`\n5. **不要在循环中创建大量goroutine** - 使用信号量控制并发数\n6. **Service层过滤常见问题**:\n   - ❌ **跳过Service层过滤但不在插件内过滤** - 会返回大量无关结果\n   - ❌ **磁力搜索插件使用默认构造函数** - 会被Service层误过滤\n   - ❌ **过滤关键词不一致** - 插件用`title_en`搜索但用原`keyword`过滤\n   - ❌ **标题格式化在过滤之后** - 格式化不会改善过滤效果"
  },
  {
    "path": "docs/系统开发设计文档.md",
    "content": "# PanSou 网盘搜索系统开发设计文档\n\n## 📋 文档目录\n\n- [1. 项目概述](#1-项目概述)\n- [2. 系统架构设计](#2-系统架构设计)\n- [3. 异步插件系统](#3-异步插件系统)\n- [4. 二级缓存系统](#4-二级缓存系统)  \n- [5. 核心组件实现](#5-核心组件实现)\n- [6. 智能排序算法详解](#6-智能排序算法详解)\n- [7. API接口设计](#7-api接口设计)\n- [8. 认证系统设计](#8-认证系统设计)\n- [9. 插件开发框架](#9-插件开发框架)\n- [10. 性能优化实现](#10-性能优化实现)\n- [11. 技术选型说明](#11-技术选型说明)\n\n---\n\n## 1. 项目概述\n\n### 1.1 项目定位\n\nPanSou是一个高性能的网盘资源搜索API服务，支持TG搜索和自定义插件搜索。系统采用异步插件架构，具备二级缓存机制和并发控制能力，在MacBook Pro 8GB上能够支持500用户并发访问。\n\n### 1.2 核心特性\n\n- **异步插件系统**: 双级超时控制（4秒/30秒），渐进式结果返回\n- **二级缓存系统**: 分片内存缓存+分片磁盘缓存，GOB序列化\n- **工作池管理**: 基于`util/pool`的并发控制\n- **智能结果合并**: `mergeSearchResults`函数实现去重合并\n- **多维度排序**: 插件等级+时间新鲜度+优先关键词综合评分\n- **多网盘类型支持**: 自动识别12种网盘类型\n\n---\n\n## 2. 系统架构设计\n\n### 2.1 整体架构流程\n\n```mermaid\ngraph TB\n    A[用户请求] --> B[API Gateway<br/>Gin Handler]\n    B --> C[参数解析与验证<br/>GET/POST处理]\n    C --> D[参数预处理<br/>规范化处理]\n    \n    D --> E[SearchService<br/>主搜索服务]\n    E --> F{源类型判断<br/>sourceType}\n    \n    F -->|TG| G[并行TG搜索]\n    F -->|Plugin| H[并行插件搜索]\n    F -->|All| I[TG+插件并行搜索]\n    \n    I --> G\n    I --> H\n    \n    %% TG搜索分支\n    G --> G1[生成TG缓存键<br/>GenerateTGCacheKey]\n    G1 --> G2{强制刷新?<br/>forceRefresh}\n    G2 -->|否| G3[检查二级缓存<br/>EnhancedTwoLevelCache]\n    G2 -->|是| G6[跳过缓存检查]\n    \n    G3 --> G4{缓存命中?}\n    G4 -->|是| G5[缓存反序列化<br/>直接返回结果]\n    G4 -->|否| G6[执行TG频道搜索<br/>多频道并行]\n    G6 --> G7[HTML解析<br/>链接提取]\n    G7 --> G8[结果标准化]\n    G8 --> G9[更新缓存<br/>SetBothLevels]\n    \n    %% 插件搜索分支 - 详细的异步处理\n    H --> H1[生成插件缓存键<br/>GeneratePluginCacheKey]\n    H1 --> H2{强制刷新?<br/>forceRefresh}\n    H2 -->|否| H3[检查二级缓存<br/>EnhancedTwoLevelCache]\n    H2 -->|是| H6[跳过缓存检查]\n    \n    H3 --> H4{缓存命中?}\n    H4 -->|是| H5[缓存反序列化<br/>直接返回结果]\n    H4 -->|否| H6[插件管理器调度<br/>PluginManager]\n    \n    %% 异步插件详细流程\n    H6 --> H7[异步插件初始化<br/>SetMainCacheKey]\n    H7 --> H8[工作池任务提交<br/>WorkerPool]\n    \n    %% 双级超时机制的并行处理\n    H8 --> H9{异步并行处理}\n    \n    %% 快速响应分支 (4秒)\n    H9 --> H10[短超时处理<br/>4秒快速响应]\n    H10 --> H11[HTTP请求<br/>短超时模式]\n    H11 --> H12[部分结果解析<br/>快速过滤]\n    H12 --> H13[部分结果缓存<br/>isFinal=false]\n    H13 --> H14[立即返回<br/>部分结果给用户]\n    \n    %% 持续处理分支 (30秒)\n    H9 --> H15[长超时后台处理<br/>最长30秒持续]\n    H15 --> H16[HTTP请求<br/>长超时模式]\n    H16 --> H17[完整结果解析<br/>深度过滤]\n    H17 --> H18[结果去重合并<br/>最终处理]\n    H18 --> H19[完整结果缓存<br/>isFinal=true]\n    H19 --> H20[主缓存异步更新<br/>DelayedBatchWrite]\n    \n    %% 结果合并处理\n    G5 --> J[结果合并<br/>mergeSearchResults]\n    G9 --> J\n    H5 --> J\n    H14 --> J\n    \n    J --> K[智能排序算法<br/>时间+关键词+插件等级]\n    K --> L[结果过滤<br/>高质量结果筛选]\n    L --> M[网盘类型分组<br/>mergeResultsByType]\n    M --> N{结果类型<br/>resultType}\n    \n    N -->|merged_by_type| O[返回分组结果]\n    N -->|results| P[返回原始结果]\n    N -->|all| Q[返回完整结果]\n    \n    O --> R[JSON响应]\n    P --> R\n    Q --> R\n    R --> S[用户]\n    \n    %% 后台持续更新（不影响用户响应）\n    H20 --> T[后台缓存完善<br/>下次请求更完整]\n    T -.-> U[持续优化<br/>用户体验]\n    \n    %% 缓存系统\n    subgraph Cache[二级缓存系统]\n        CA[分片内存缓存<br/>LRU + 原子操作]\n        CB[分片磁盘缓存<br/>GOB序列化]\n        CC[智能缓存写入管理器<br/>DelayedBatchWriteManager]\n        CD[全局缓冲区管理器<br/>BufferByPlugin策略]\n    end\n    \n    G3 -.-> CA\n    H3 -.-> CA\n    CA -.-> CB\n    G9 -.-> CC\n    H13 -.-> CC\n    H20 -.-> CC\n    CC -.-> CD\n    \n    %% 样式定义\n    classDef cacheNode fill:#e1f5fe,stroke:#01579b,stroke-width:2px\n    classDef pluginNode fill:#f3e5f5,stroke:#4a148c,stroke-width:2px\n    classDef searchNode fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px\n    classDef fastResponse fill:#fff3e0,stroke:#e65100,stroke-width:2px\n    classDef slowProcess fill:#fce4ec,stroke:#880e4f,stroke-width:2px\n    classDef processNode fill:#f5f5f5,stroke:#424242,stroke-width:2px\n    \n    class G3,H3,G5,H5,G9,H13,H20,CA,CB,CC,CD cacheNode\n    class H6,H7,H8 pluginNode\n    class G6,G7,G8 searchNode\n    class H10,H11,H12,H13,H14 fastResponse\n    class H15,H16,H17,H18,H19,H20,T slowProcess\n    class D,J,K,L,M processNode\n```\n\n### 2.2 异步插件工作流程\n\n```mermaid\nsequenceDiagram\n    participant U as 用户\n    participant API as API Handler\n    participant S as SearchService\n    participant SP as searchPlugins函数\n    participant C as 二级缓存系统\n    participant PM as PluginManager\n    participant P as AsyncPlugin\n    participant WP as WorkerPool\n    participant BWM as BatchWriteManager\n    participant EXT as 外部API\n\n    %% 请求处理阶段\n    U->>API: 🔍 搜索请求 (kw=关键词)\n    API->>API: 参数解析与验证\n    API->>API: 参数预处理规范化\n    API->>S: Search(req.Keyword, ...)\n    \n    %% 并行搜索启动\n    Note over S: 🚀 并行启动TG和插件搜索\n    S->>SP: searchPlugins(keyword, plugins, ...)\n    \n    %% 缓存检查阶段\n    SP->>SP: 生成插件缓存键\n    SP->>SP: 检查forceRefresh标志\n    \n    alt forceRefresh = false\n        SP->>C: 🔍 Get(cacheKey)\n        alt 缓存命中\n            C-->>SP: ✅ 返回缓存数据\n            SP->>SP: 反序列化结果\n            SP-->>S: 🎯 返回缓存结果 (<10ms)\n            S-->>U: ⚡ 极速响应\n        else 缓存未命中\n            Note over SP: 🚨 执行异步插件搜索\n            SP->>PM: 获取可用插件列表\n            SP->>PM: 过滤指定插件\n        end\n    else forceRefresh = true\n        Note over SP: 🔄 跳过缓存，强制搜索\n        SP->>PM: 获取可用插件列表\n        SP->>PM: 过滤指定插件\n    end\n    \n    %% 异步搜索初始化\n    PM->>P: 🎯 设置关键词和缓存键\n    P->>P: SetMainCacheKey(cacheKey)\n    P->>P: SetCurrentKeyword(keyword)\n    P->>P: 注入缓存更新函数\n    \n    %% 🚀 异步插件的精髓：双级超时并行机制\n    Note over P,EXT: 🔥 异步插件精髓：快速响应 + 持续处理\n    \n    P->>WP: 🚀 提交异步任务到工作池\n    \n    %% 快速响应路径 (4秒)\n    par 🚀 快速响应路径 (4秒)\n        Note over WP,EXT: ⚡ 第一阶段：快速响应用户\n        WP->>EXT: HTTP请求 (短超时 4秒)\n        EXT-->>WP: 部分响应数据\n        WP->>P: 🔍 解析部分结果\n        P->>P: 快速过滤和标准化\n        P->>P: 📝 记录日志: 初始缓存创建\n        \n        %% 部分结果立即缓存和返回\n        P->>BWM: 🗄️ 异步缓存更新 (isFinal=false)\n        Note over BWM: 部分结果缓存，不等待写入完成\n        P-->>SP: 📤 部分结果立即返回\n        SP-->>S: 🎯 部分结果 (isFinal=false)\n        S->>S: 与TG结果合并\n        S-->>U: ⚡ 快速响应 (~4秒)\n        \n    and 🔄 持续处理路径 (最长30秒)\n        Note over WP,EXT: 🔄 第二阶段：后台持续完善\n        WP->>EXT: 继续HTTP请求 (长超时 30秒)\n        EXT-->>WP: 完整响应数据\n        WP->>P: 🔍 解析完整结果\n        P->>P: 深度过滤和去重\n        P->>P: 结果质量评估\n        P->>P: 📝 记录日志: 缓存更新完成\n        \n        %% 完整结果的主缓存更新\n        P->>BWM: 🗄️ 主缓存更新 (isFinal=true)\n        Note over BWM: 完整结果写入，高优先级\n        BWM->>BWM: 🧠 智能缓存写入策略\n        BWM->>BWM: 🗂️ 全局缓冲区管理\n        BWM->>C: 📀 批量写入磁盘缓存\n        \n        Note over C: 🎯 下次同样请求将获得完整结果\n    end\n    \n    %% 缓存系统内部处理\n    C->>C: ⚡ 立即更新内存缓存\n    C->>C: 📀 延迟批量更新磁盘缓存\n    C->>C: 🧹 自动清理过期缓存\n    \n    %% 持续优化标注\n    Note over U,EXT: 💡 异步插件核心价值\n    Note over U,EXT: ✅ 用户获得快速响应 (4秒内)\n    Note over U,EXT: ✅ 系统持续完善结果 (30秒内)  \n    Note over U,EXT: ✅ 下次访问获得完整数据 (<100ms)\n    Note over U,EXT: 🔄 完美平衡：速度 vs 完整性\n```\n\n### 2.3 核心组件\n\n#### 2.3.1 HTTP服务层 (`api/`)\n- **router.go**: 路由配置\n- **handler.go**: 请求处理逻辑\n- **middleware.go**: 中间件（日志、CORS等）\n\n#### 2.3.2 搜索服务层 (`service/`)\n- **search_service.go**: 核心搜索逻辑，结果合并\n\n#### 2.3.3 插件系统层 (`plugin/`)\n- **plugin.go**: 插件接口定义\n- **baseasyncplugin.go**: 异步插件基类\n- **各插件目录**: jikepan、pan666、hunhepan等\n\n#### 2.3.4 工具层 (`util/`)\n- **cache/**: 二级缓存系统实现\n- **pool/**: 工作池实现\n- **其他工具**: HTTP客户端、解析工具等\n\n---\n\n## 3. 异步插件系统\n\n### 3.1 设计理念\n\n异步插件系统解决传统同步搜索响应慢的问题，采用\"尽快响应，持续处理\"策略：\n- **4秒短超时**: 快速返回部分结果（`isFinal=false`）\n- **30秒长超时**: 后台继续处理，获得完整结果（`isFinal=true`）\n- **主动缓存更新**: 完整结果自动更新主缓存，下次访问更快\n\n### 3.2 插件接口实现\n\n基于`plugin/plugin.go`的实际接口：\n\n```go\ntype AsyncSearchPlugin interface {\n    Name() string\n    Priority() int\n    \n    AsyncSearch(keyword string, searchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error), \n               mainCacheKey string, ext map[string]interface{}) ([]model.SearchResult, error)\n    \n    SetMainCacheKey(key string)\n    SetCurrentKeyword(keyword string)\n    Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error)\n}\n```\n\n### 3.3 基础插件类\n\n`plugin/baseasyncplugin.go`提供通用功能：\n\n```go\ntype BaseAsyncPlugin struct {\n    name              string\n    priority          int\n    cacheTTL          time.Duration\n    mainCacheKey      string\n    currentKeyword    string        // 用于日志显示\n    httpClient        *http.Client\n    mainCacheUpdater  func(string, []model.SearchResult, time.Duration, bool, string) error\n}\n```\n\n### 3.4 已实现插件列表\n\n当前系统包含以下插件（基于`main.go`的导入）：\n- **hdr4k**\n- **hunhepan**\n- **jikepan**\n- **pan666**\n- **pansearch**\n- **panta**\n- **qupansou**\n- **susu**\n- **panyq**\n- **xuexizhinan**\n\n### 3.5 插件注册机制\n\n```go\n// 全局插件注册表（plugin/plugin.go）\nvar globalRegistry = make(map[string]AsyncSearchPlugin)\n\n// 插件通过init()函数自动注册\nfunc init() {\n    p := &MyPlugin{\n        BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"myplugin\", 3),\n    }\n    plugin.RegisterGlobalPlugin(p)\n}\n```\n\n---\n\n## 4. 二级缓存系统\n\n### 4.1 实现架构\n\n基于`util/cache/`目录的实际实现：\n\n- **enhanced_two_level_cache.go**: 二级缓存主入口\n- **sharded_memory_cache.go**: 分片内存缓存（LRU+原子操作）\n- **sharded_disk_cache.go**: 分片磁盘缓存\n- **serializer.go**: GOB序列化器\n- **cache_key.go**: 缓存键生成和管理\n\n### 4.2 分片缓存设计\n\n#### 4.2.1 内存缓存分片\n```go\n// 基于CPU核心数的动态分片\ntype ShardedMemoryCache struct {\n    shards    []*MemoryCacheShard\n    shardMask uint32\n}\n\n// 每个分片独立锁，减少竞争\ntype MemoryCacheShard struct {\n    data map[string]*CacheItem\n    lock sync.RWMutex\n}\n```\n\n#### 4.2.2 磁盘缓存分片\n```go\n// 磁盘缓存同样采用分片设计\ntype ShardedDiskCache struct {\n    shards    []*DiskCacheShard  \n    shardMask uint32\n    basePath  string\n}\n```\n\n### 4.3 缓存读写策略\n\n#### 4.3.1 读取流程\n1. **内存优先**: 先检查分片内存缓存\n2. **磁盘回源**: 内存未命中时读取磁盘缓存\n3. **异步加载**: 磁盘命中后异步加载到内存\n\n#### 4.3.2 写入流程  \n1. **智能写入策略**: 立即更新内存缓存，延迟批量写入磁盘\n2. **DelayedBatchWriteManager**: 智能缓存写入管理器，支持immediate和hybrid两种策略\n3. **原子操作**: 内存缓存使用原子操作\n4. **GOB序列化**: 磁盘存储使用GOB格式\n5. **数据安全保障**: 程序终止时自动保存所有待写入数据，防止数据丢失\n\n### 4.4 缓存键策略\n\n`cache_key.go`实现了智能缓存键生成：\n\n```go\n// TG搜索和插件搜索使用不同的缓存键前缀\nfunc GenerateTGCacheKey(keyword string, channels []string) string\nfunc GeneratePluginCacheKey(keyword string, plugins []string) string\n```\n\n**优势**:\n- 独立更新：TG和插件缓存互不影响\n- 提高命中率：精确的键匹配\n- 并发安全：分片设计减少锁竞争\n\n### 4.5 序列化性能\n\n使用GOB序列化（`serializer.go`）的实际优势：\n- **性能**: 比JSON序列化快约30%\n- **体积**: 比JSON小约20%\n- **兼容**: Go原生支持，无外部依赖\n\n---\n\n## 5. 核心组件实现\n\n### 5.1 工作池系统 (`util/pool/`)\n\n#### 5.1.1 worker_pool.go 实现\n- **批量任务处理**: `ExecuteBatchWithTimeout`方法\n- **超时控制**: 支持任务级别的超时设置\n- **并发限制**: 控制最大工作者数量\n\n#### 5.1.2 object_pool.go 实现  \n- **对象复用**: 减少内存分配和GC压力\n- **线程安全**: 支持并发访问\n\n### 5.2 HTTP服务配置\n\n#### 5.2.1 服务器优化（基于config/config.go）\n```go\n// 自动计算HTTP连接数，防止资源耗尽\nfunc getHTTPMaxConns() int {\n    cpuCount := runtime.NumCPU()\n    maxConns := cpuCount * 25  // 保守配置\n    \n    if maxConns < 100 {\n        maxConns = 100\n    }\n    if maxConns > 500 {\n        maxConns = 500  // 限制最大值\n    }\n    \n    return maxConns\n}\n```\n\n#### 5.2.2 连接池配置（基于util/http_util.go）\n```go\n// HTTP客户端优化配置\ntransport := &http.Transport{\n    MaxIdleConns:        100,\n    MaxIdleConnsPerHost: 10,\n    IdleConnTimeout:     90 * time.Second,\n}\n```\n\n### 5.3 结果处理系统\n\n#### 5.3.1 智能排序算法（service/search_service.go）\n\nPanSou 采用多维度综合评分排序算法，确保高质量结果优先展示：\n\n**评分公式**:\n```\n总得分 = 插件得分(1000/500/0/-200) + 时间得分(最高500) + 关键词得分(最高420)\n```\n\n**权重分配**:\n- 🥇 **插件等级**: ~52% (主导因素) - 等级1(1000分) > 等级2(500分) > 等级3(0分)\n- 🥈 **关键词匹配**: ~22% (重要因素) - \"合集\"(420分) > \"系列\"(350分) > \"全\"(280分)\n- 🥉 **时间新鲜度**: ~26% (重要因素) - 1天内(500分) > 3天内(400分) > 1周内(300分)\n\n**关键优化**:\n- **缓存性能**: 跳过空结果和重复数据的缓存更新，减少70%无效操作\n- **排序稳定性**: 修复map遍历随机性问题，确保merged_by_type保持排序\n- **插件管理**: 启动时按优先级排序显示已加载插件，便于监控\n\n#### 5.3.2 结果合并（mergeSearchResults函数）\n- **去重合并**: 基于UniqueID去重\n- **完整性选择**: 选择更完整的结果保留\n- **增量更新**: 新结果与缓存结果智能合并\n\n### 5.4 网盘类型识别\n\n支持自动识别的网盘类型（共12种）：\n- 百度网盘、阿里云盘、夸克网盘、天翼云盘\n- UC网盘、移动云盘、115网盘、PikPak\n- 迅雷网盘、123网盘、磁力链接、电驴链接\n\n---\n\n## 6. 智能排序算法详解\n\n### 6.1 算法概述\n\nPanSou 搜索引擎采用多维度综合评分排序算法，确保用户能够优先看到最相关、最新、最高质量的搜索结果。\n\n#### 6.1.1 核心设计理念\n\n1. **质量优先**：高等级插件的结果优先展示\n2. **时效性重要**：新发布的资源获得更高权重\n3. **相关性保证**：关键词匹配度影响排序\n4. **用户体验**：最终排序结果保持稳定性\n\n#### 6.1.2 排序流程\n\n```mermaid\ngraph TD\n    A[搜索请求] --> B[获取搜索结果 allResults]\n    B --> C[sortResultsByTimeAndKeywords]\n    \n    C --> D[为每个结果计算得分]\n    D --> E[时间得分<br/>最高500分]\n    D --> F[关键词得分<br/>最高420分]\n    D --> G[插件得分<br/>等级1=1000分<br/>等级2=500分<br/>等级3=0分]\n    \n    E --> H[总得分 = 时间得分 + 关键词得分 + 插件得分]\n    F --> H\n    G --> H\n    \n    H --> I[按总得分降序排序]\n    I --> J[mergeResultsByType]\n    \n    J --> K[按原始顺序收集唯一链接<br/>保持排序不被破坏]\n    K --> L[按类型分组<br/>生成merged_by_type]\n    \n    L --> M[返回最终结果]\n```\n\n### 6.2 评分算法详解\n\n#### 6.2.1 核心公式\n```\n总得分 = 时间得分 + 关键词得分 + 插件得分\n```\n\n#### 6.2.2 时间得分 (Time Score)\n\n时间得分反映资源的新鲜度，**最高 500 分**：\n\n| 时间范围 | 得分 | 说明 |\n|---------|------|------|\n| ≤ 1天   | 500  | 最新资源，最高优先级 |\n| ≤ 3天   | 400  | 非常新的资源 |\n| ≤ 1周   | 300  | 较新资源 |\n| ≤ 1月   | 200  | 相对较新 |\n| ≤ 3月   | 100  | 中等新鲜度 |\n| ≤ 1年   | 50   | 较旧资源 |\n| > 1年   | 20   | 旧资源 |\n| 无日期   | 0    | 未知时间 |\n\n#### 6.2.3 关键词得分 (Keyword Score)\n\n关键词得分基于搜索词在标题中的匹配情况，**最高 420 分**：\n\n| 优先关键词 | 得分 | 说明 |\n|-----------|------|------|\n| \"合集\" | 420 | 最高优先级 |\n| \"系列\" | 350 | 高优先级 |\n| \"全\" | 280 | 中高优先级 |\n| \"完\" | 210 | 中等优先级 |\n| \"最新\" | 140 | 较低优先级 |\n| \"附\" | 70 | 低优先级 |\n| 无匹配 | 0 | 无加分 |\n\n#### 6.2.4 插件得分 (Plugin Score)\n\n插件得分基于数据源的质量等级，体现资源可靠性：\n\n| 插件等级 | 得分 | 说明 |\n|---------|------|------|\n| 等级1   | 1000 | 顶级数据源 |\n| 等级2   | 500  | 优质数据源 |\n| 等级3   | 0    | 普通数据源 |\n| 等级4   | -200 | 低质量数据源 |\n\n### 6.3 权重分析与实际效果\n\n#### 6.3.1 权重分配\n\n| 维度 | 最高分值 | 权重占比 | 影响说明 |\n|------|---------|---------|----------|\n| 插件等级 | 1000 | ~52% | **主导因素**，决定基础排序 |\n| 关键词匹配 | 420 | ~22% | **重要因素**，优先关键词显著加分 |\n| 时间新鲜度 | 500 | ~26% | **重要因素**，同等级内排序关键 |\n\n#### 6.3.2 实际排序示例\n\n| 场景 | 插件等级 | 时间 | 关键词 | 总分 | 排序 |\n|------|---------|------|--------|------|------|\n| 等级1 + 1天内 + \"合集\" | 1000 | 500 | 420 | **1920** | 🥇 第1 |\n| 等级1 + 1天内 + \"系列\" | 1000 | 500 | 350 | **1850** | 🥈 第2 |\n| 等级1 + 1月内 + \"合集\" | 1000 | 200 | 420 | **1620** | 🥉 第3 |\n| 等级2 + 1天内 + \"合集\" | 500 | 500 | 420 | **1420** | 第4 |\n| 等级1 + 1天内 + 无关键词 | 1000 | 500 | 0 | **1500** | 第5 |\n\n---\n\n## 7. API接口设计\n\n### 7.1 核心接口实现（基于api/handler.go）\n\n#### 7.1.1 搜索接口\n```\nPOST /api/search\nGET  /api/search\n```\n\n**核心参数**:\n- `kw`: 搜索关键词（必填）\n- `channels`: TG频道列表\n- `plugins`: 插件列表  \n- `cloud_types`: 网盘类型过滤\n- `ext`: 扩展参数（JSON格式）\n- `refresh`: 强制刷新缓存\n- `res`: 返回格式（merge/all/results）\n- `src`: 数据源（all/tg/plugin）\n\n#### 7.1.2 健康检查接口\n```\nGET /api/health\n```\n\n返回系统状态和已注册插件信息。\n\n### 6.2 中间件系统（api/middleware.go）\n\n- **日志中间件**: 记录请求响应，支持URL解码显示\n- **CORS中间件**: 跨域请求支持\n- **错误处理**: 统一错误响应格式\n\n### 6.3 扩展参数系统\n\n通过`ext`参数支持插件特定选项：\n```json\n{\n  \"title_en\": \"English Title\",\n  \"is_all\": true,\n  \"year\": 2023\n}\n```\n\n---\n\n## 8. 认证系统设计\n\n### 8.1 系统概述\n\nPanSou认证系统是一个可选的安全访问控制模块，基于JWT（JSON Web Token）标准实现。该系统设计目标是在不影响现有用户的前提下，为需要私有部署的用户提供灵活的认证功能。\n\n#### 8.1.1 核心特性\n\n- **可选性**: 默认关闭，通过环境变量`AUTH_ENABLED`启用\n- **无状态**: 基于JWT，无需session存储\n- **标准化**: 采用RFC 7519 JWT标准\n- **灵活性**: 支持多用户配置\n- **安全性**: Token自动过期，防止长期有效性风险\n\n### 8.2 认证架构\n\n#### 8.2.1 认证流程\n\n```mermaid\nsequenceDiagram\n    participant U as 用户\n    participant F as 前端\n    participant M as 认证中间件\n    participant A as 认证接口\n    participant S as 搜索服务\n    \n    Note over U,S: 初始访问阶段\n    U->>F: 访问应用\n    F->>F: 检查localStorage中的token\n    alt token不存在或无效\n        F->>U: 显示登录窗口\n        U->>F: 输入账号密码\n        F->>A: POST /api/auth/login\n        A->>A: 验证账号密码\n        A->>A: 生成JWT Token\n        A-->>F: 返回Token\n        F->>F: 存储Token到localStorage\n        F->>U: 关闭登录窗口\n    end\n    \n    Note over U,S: API调用阶段\n    U->>F: 发起搜索请求\n    F->>F: axios拦截器添加Authorization头\n    F->>M: GET/POST /api/search + Token\n    M->>M: 验证Token有效性\n    alt Token有效\n        M->>S: 转发请求\n        S-->>M: 返回搜索结果\n        M-->>F: 返回响应\n        F-->>U: 显示结果\n    else Token无效/过期\n        M-->>F: 返回401 Unauthorized\n        F->>F: 响应拦截器捕获401\n        F->>U: 显示登录窗口\n    end\n```\n\n#### 8.2.2 组件架构\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                         前端层 (Vue 3)                        │\n├─────────────────────────────────────────────────────────────┤\n│  ┌─────────────┐  ┌──────────────┐  ┌──────────────────┐  │\n│  │ LoginDialog │  │ HTTP拦截器    │  │ Token管理工具     │  │\n│  │ 登录组件     │  │ 自动添加Token │  │ LocalStorage     │  │\n│  └─────────────┘  └──────────────┘  └──────────────────┘  │\n└─────────────────────────────────────────────────────────────┘\n                            ↕ HTTP (Authorization: Bearer)\n┌─────────────────────────────────────────────────────────────┐\n│                        后端层 (Go + Gin)                      │\n├─────────────────────────────────────────────────────────────┤\n│  ┌──────────────────────────────────────────────────────┐  │\n│  │              AuthMiddleware 认证中间件                 │  │\n│  │  • 检查AUTH_ENABLED配置                              │  │\n│  │  • 排除公开接口（/api/auth/login, /api/health）      │  │\n│  │  • 验证JWT Token有效性                               │  │\n│  │  • 提取用户信息到Context                             │  │\n│  └──────────────────────────────────────────────────────┘  │\n│                            ↓                                │\n│  ┌─────────────┐  ┌─────────────┐  ┌──────────────────┐  │\n│  │ 认证接口     │  │ JWT工具      │  │ 配置管理          │  │\n│  │ /auth/login │  │ util/jwt.go │  │ config/config.go │  │\n│  │ /auth/verify│  │ GenerateToken│  │ AuthEnabled     │  │\n│  │ /auth/logout│  │ ValidateToken│  │ AuthUsers       │  │\n│  └─────────────┘  └─────────────┘  └──────────────────┘  │\n└─────────────────────────────────────────────────────────────┘\n```\n\n### 8.3 后端实现细节\n\n#### 8.3.1 配置模块 (config/config.go)\n\n```go\ntype Config struct {\n    // ... 现有配置 ...\n    \n    // 认证相关配置\n    AuthEnabled      bool              // 是否启用认证\n    AuthUsers        map[string]string // 用户名:密码哈希映射\n    AuthTokenExpiry  time.Duration     // Token有效期\n    AuthJWTSecret    string            // JWT签名密钥\n}\n\n// 从环境变量读取认证配置\nfunc getAuthEnabled() bool {\n    enabled := os.Getenv(\"AUTH_ENABLED\")\n    return enabled == \"true\" || enabled == \"1\"\n}\n\nfunc getAuthUsers() map[string]string {\n    usersEnv := os.Getenv(\"AUTH_USERS\")\n    if usersEnv == \"\" {\n        return nil\n    }\n    \n    users := make(map[string]string)\n    pairs := strings.Split(usersEnv, \",\")\n    for _, pair := range pairs {\n        parts := strings.SplitN(pair, \":\", 2)\n        if len(parts) == 2 {\n            username := strings.TrimSpace(parts[0])\n            password := strings.TrimSpace(parts[1])\n            // 实际使用时应该对密码进行哈希处理\n            users[username] = password\n        }\n    }\n    return users\n}\n```\n\n#### 8.3.2 JWT工具模块 (util/jwt.go)\n\n```go\npackage util\n\nimport (\n    \"errors\"\n    \"github.com/golang-jwt/jwt/v5\"\n    \"time\"\n)\n\n// Claims JWT载荷结构\ntype Claims struct {\n    Username string `json:\"username\"`\n    jwt.RegisteredClaims\n}\n\n// GenerateToken 生成JWT token\nfunc GenerateToken(username string, secret string, expiry time.Duration) (string, error) {\n    expirationTime := time.Now().Add(expiry)\n    claims := &Claims{\n        Username: username,\n        RegisteredClaims: jwt.RegisteredClaims{\n            ExpiresAt: jwt.NewNumericDate(expirationTime),\n            IssuedAt:  jwt.NewNumericDate(time.Now()),\n            Issuer:    \"pansou\",\n        },\n    }\n    \n    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)\n    return token.SignedString([]byte(secret))\n}\n\n// ValidateToken 验证JWT token\nfunc ValidateToken(tokenString string, secret string) (*Claims, error) {\n    claims := &Claims{}\n    \n    token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {\n        return []byte(secret), nil\n    })\n    \n    if err != nil {\n        return nil, err\n    }\n    \n    if !token.Valid {\n        return nil, errors.New(\"invalid token\")\n    }\n    \n    return claims, nil\n}\n```\n\n#### 8.3.3 认证中间件 (api/middleware.go)\n\n```go\n// AuthMiddleware JWT认证中间件\nfunc AuthMiddleware() gin.HandlerFunc {\n    return func(c *gin.Context) {\n        // 如果未启用认证，直接放行\n        if !config.AppConfig.AuthEnabled {\n            c.Next()\n            return\n        }\n        \n        // 定义公开接口（不需要认证）\n        publicPaths := []string{\n            \"/api/auth/login\",\n            \"/api/auth/verify\",\n            \"/api/auth/logout\",\n            \"/api/health\",  // 可选：健康检查是否需要认证\n        }\n        \n        // 检查当前路径是否是公开接口\n        path := c.Request.URL.Path\n        for _, p := range publicPaths {\n            if strings.HasPrefix(path, p) {\n                c.Next()\n                return\n            }\n        }\n        \n        // 获取Authorization头\n        authHeader := c.GetHeader(\"Authorization\")\n        if authHeader == \"\" {\n            c.JSON(401, gin.H{\n                \"error\": \"未授权：缺少认证令牌\",\n                \"code\": \"AUTH_TOKEN_MISSING\",\n            })\n            c.Abort()\n            return\n        }\n        \n        // 解析Bearer token\n        const bearerPrefix = \"Bearer \"\n        if !strings.HasPrefix(authHeader, bearerPrefix) {\n            c.JSON(401, gin.H{\n                \"error\": \"未授权：令牌格式错误\",\n                \"code\": \"AUTH_TOKEN_INVALID_FORMAT\",\n            })\n            c.Abort()\n            return\n        }\n        \n        tokenString := strings.TrimPrefix(authHeader, bearerPrefix)\n        \n        // 验证token\n        claims, err := util.ValidateToken(tokenString, config.AppConfig.AuthJWTSecret)\n        if err != nil {\n            c.JSON(401, gin.H{\n                \"error\": \"未授权：令牌无效或已过期\",\n                \"code\": \"AUTH_TOKEN_INVALID\",\n            })\n            c.Abort()\n            return\n        }\n        \n        // 将用户信息存入上下文，供后续处理使用\n        c.Set(\"username\", claims.Username)\n        c.Next()\n    }\n}\n```\n\n#### 8.3.4 认证接口 (api/auth_handler.go)\n\n```go\npackage api\n\nimport (\n    \"github.com/gin-gonic/gin\"\n    \"pansou/config\"\n    \"pansou/util\"\n    \"time\"\n)\n\n// LoginRequest 登录请求结构\ntype LoginRequest struct {\n    Username string `json:\"username\" binding:\"required\"`\n    Password string `json:\"password\" binding:\"required\"`\n}\n\n// LoginResponse 登录响应结构\ntype LoginResponse struct {\n    Token     string `json:\"token\"`\n    ExpiresAt int64  `json:\"expires_at\"`\n    Username  string `json:\"username\"`\n}\n\n// LoginHandler 处理用户登录\nfunc LoginHandler(c *gin.Context) {\n    var req LoginRequest\n    if err := c.ShouldBindJSON(&req); err != nil {\n        c.JSON(400, gin.H{\"error\": \"参数错误\"})\n        return\n    }\n    \n    // 验证用户名和密码\n    if config.AppConfig.AuthUsers == nil {\n        c.JSON(500, gin.H{\"error\": \"认证系统未正确配置\"})\n        return\n    }\n    \n    storedPassword, exists := config.AppConfig.AuthUsers[req.Username]\n    if !exists || storedPassword != req.Password {\n        c.JSON(401, gin.H{\"error\": \"用户名或密码错误\"})\n        return\n    }\n    \n    // 生成JWT token\n    token, err := util.GenerateToken(\n        req.Username,\n        config.AppConfig.AuthJWTSecret,\n        config.AppConfig.AuthTokenExpiry,\n    )\n    if err != nil {\n        c.JSON(500, gin.H{\"error\": \"生成令牌失败\"})\n        return\n    }\n    \n    // 返回token和过期时间\n    expiresAt := time.Now().Add(config.AppConfig.AuthTokenExpiry).Unix()\n    c.JSON(200, LoginResponse{\n        Token:     token,\n        ExpiresAt: expiresAt,\n        Username:  req.Username,\n    })\n}\n\n// VerifyHandler 验证token有效性\nfunc VerifyHandler(c *gin.Context) {\n    // 如果能到达这里，说明中间件已经验证通过\n    username, exists := c.Get(\"username\")\n    if !exists {\n        c.JSON(401, gin.H{\"error\": \"未授权\"})\n        return\n    }\n    \n    c.JSON(200, gin.H{\n        \"valid\":    true,\n        \"username\": username,\n    })\n}\n\n// LogoutHandler 退出登录（客户端删除token即可）\nfunc LogoutHandler(c *gin.Context) {\n    c.JSON(200, gin.H{\"message\": \"退出成功\"})\n}\n```\n\n### 8.4 前端实现细节\n\n#### 8.4.1 API模块扩展 (src/api/index.ts)\n\n```typescript\n// 登录接口\nexport interface LoginParams {\n  username: string;\n  password: string;\n}\n\nexport interface LoginResponse {\n  token: string;\n  expires_at: number;\n  username: string;\n}\n\nexport const login = async (params: LoginParams): Promise<LoginResponse> => {\n  const response = await api.post<LoginResponse>('/auth/login', params);\n  return response.data;\n};\n\n// 验证token\nexport const verifyToken = async (): Promise<boolean> => {\n  try {\n    await api.post('/auth/verify');\n    return true;\n  } catch {\n    return false;\n  }\n};\n\n// 退出登录\nexport const logout = async (): Promise<void> => {\n  try {\n    await api.post('/auth/logout');\n  } finally {\n    localStorage.removeItem('auth_token');\n    localStorage.removeItem('auth_username');\n  }\n};\n```\n\n#### 8.4.2 HTTP拦截器配置\n\n```typescript\n// 请求拦截器 - 自动添加token\napi.interceptors.request.use(\n  (config) => {\n    const token = localStorage.getItem('auth_token');\n    if (token) {\n      config.headers.Authorization = `Bearer ${token}`;\n    }\n    return config;\n  },\n  (error) => Promise.reject(error)\n);\n\n// 响应拦截器 - 处理401\napi.interceptors.response.use(\n  (response) => response,\n  (error) => {\n    if (error.response?.status === 401) {\n      // 清除token\n      localStorage.removeItem('auth_token');\n      localStorage.removeItem('auth_username');\n      \n      // 触发显示登录窗口\n      window.dispatchEvent(new CustomEvent('auth:required'));\n    }\n    return Promise.reject(error);\n  }\n);\n```\n\n### 8.5 API文档组件集成\n\n在 `ApiDocs.vue` 组件中，需要确保在线调试功能自动携带token：\n\n```typescript\n// 生成请求预览时包含Authorization头\nconst generateSearchRequest = () => {\n  const token = localStorage.getItem('auth_token');\n  let headers = 'Content-Type: application/json\\n';\n  \n  if (token) {\n    headers += `Authorization: Bearer ${token}\\n`;\n  }\n  \n  if (searchMethod.value === 'POST') {\n    return `POST /api/search\n${headers}\n${JSON.stringify(payload, null, 2)}`;\n  }\n  // ... GET请求类似处理\n};\n```\n\n### 8.6 健康检查接口扩展\n\n`/api/health` 接口需要返回认证状态信息：\n\n```go\nfunc HealthHandler(c *gin.Context) {\n    // ... 现有逻辑 ...\n    \n    response := gin.H{\n        \"status\":          \"ok\",\n        \"auth_enabled\":    config.AppConfig.AuthEnabled,  // 新增\n        \"plugins_enabled\": pluginsEnabled,\n        \"plugin_count\":    pluginCount,\n        \"plugins\":         pluginNames,\n        \"channels\":        channels,\n        \"channels_count\":  channelsCount,\n    }\n    \n    c.JSON(200, response)\n}\n```\n\n### 8.7 环境变量配置\n\n| 变量名 | 类型 | 默认值 | 说明 |\n|--------|------|--------|------|\n| `AUTH_ENABLED` | boolean | `false` | 是否启用认证功能 |\n| `AUTH_USERS` | string | - | 用户配置，格式：`user1:pass1,user2:pass2` |\n| `AUTH_TOKEN_EXPIRY` | int | `24` | Token有效期（小时） |\n| `AUTH_JWT_SECRET` | string | 随机生成 | JWT签名密钥 |\n\n### 8.8 安全考虑\n\n1. **密码存储**: 生产环境应使用bcrypt等算法对密码进行哈希\n2. **HTTPS传输**: 生产环境必须使用HTTPS保护token传输\n3. **Token过期**: 合理设置token有效期，避免长期有效\n4. **限流保护**: 对登录接口实施限流，防止暴力破解\n5. **密钥管理**: JWT_SECRET应随机生成并妥善保管\n\n### 8.9 性能影响\n\n- **未启用认证**: 零性能影响，中间件直接放行\n- **启用认证**: 每个请求增加约0.1-0.5ms的token验证时间\n- **并发性能**: JWT无状态特性，对高并发无影响\n- **缓存友好**: 认证不影响现有缓存机制\n\n---\n\n## 9. 插件开发框架\n\n### 9.1 基础开发模板\n\n```go\npackage myplugin\n\nimport (\n    \"net/http\"\n    \"pansou/model\"\n    \"pansou/plugin\"\n)\n\ntype MyPlugin struct {\n    *plugin.BaseAsyncPlugin\n}\n\nfunc init() {\n    p := &MyPlugin{\n        BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"myplugin\", 3),\n    }\n    plugin.RegisterGlobalPlugin(p)\n}\n\nfunc (p *MyPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n    return p.AsyncSearch(keyword, p.searchImpl, p.GetMainCacheKey(), ext)\n}\n\nfunc (p *MyPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n    // 实现具体搜索逻辑\n    // 1. 构建请求URL\n    // 2. 发送HTTP请求  \n    // 3. 解析响应数据\n    // 4. 转换为标准格式\n    // 5. 关键词过滤\n    return plugin.FilterResultsByKeyword(results, keyword), nil\n}\n```\n\n### 8.2 插件注册流程\n\n1. **自动注册**: 通过`init()`函数自动注册到全局注册表\n2. **管理器加载**: `PluginManager`统一管理所有插件\n3. **导入触发**: 在`main.go`中通过空导入触发注册\n\n### 8.3 开发最佳实践\n\n- **命名规范**: 插件名使用小写字母\n- **优先级设置**: 1-5，数字越小优先级越高\n- **错误处理**: 详细错误信息，便于调试\n- **资源管理**: 及时释放HTTP连接\n\n---\n\n## 10. 性能优化实现\n\n### 10.1 环境配置优化\n\n基于实际性能测试结果的配置方案：\n\n#### 10.1.1 macOS优化配置\n```bash\nexport HTTP_MAX_CONNS=200\nexport ASYNC_MAX_BACKGROUND_WORKERS=15\nexport ASYNC_MAX_BACKGROUND_TASKS=75\nexport CONCURRENCY=30\n```\n\n#### 9.1.2 服务器优化配置  \n```bash\nexport HTTP_MAX_CONNS=500\nexport ASYNC_MAX_BACKGROUND_WORKERS=40\nexport ASYNC_MAX_BACKGROUND_TASKS=200\nexport CONCURRENCY=50\n```\n\n### 9.2 日志控制系统\n\n基于`config.go`的日志控制：\n```bash\nexport ASYNC_LOG_ENABLED=false  # 控制异步插件详细日志\n```\n\n异步插件缓存更新日志可通过环境变量开关，避免生产环境日志过多。\n\n---\n\n## 11. 技术选型说明\n\n### 11.1 Go语言优势\n- **并发支持**: 原生goroutine，适合高并发场景\n- **性能优秀**: 编译型语言，接近C的性能\n- **部署简单**: 单一可执行文件，无外部依赖\n- **标准库丰富**: HTTP、JSON、并发原语完备\n\n### 10.2 GIN框架选择\n- **高性能**: 路由和中间件处理效率高\n- **简洁易用**: API设计简洁，学习成本低  \n- **中间件生态**: 丰富的中间件支持\n- **社区活跃**: 文档完善，问题解决快\n\n### 10.3 GOB序列化选择\n- **性能优势**: 比JSON快约30%\n- **体积优势**: 比JSON小约20%\n- **Go原生**: 无需第三方依赖\n- **类型安全**: 保持Go类型信息\n\n### 10.4 Sonic JSON库选择\n- **高性能**: 比标准库encoding/json快3-5倍\n- **统一处理**: 全局统一JSON序列化/反序列化\n- **兼容性好**: 完全兼容标准JSON格式\n- **内存优化**: 更高效的内存使用\n\n### 10.5 无数据库架构\n- **简化部署**: 无需数据库安装配置\n- **降低复杂度**: 减少组件依赖\n- **提升性能**: 避免数据库IO瓶颈\n- **易于扩展**: 无状态设计，支持水平扩展"
  },
  {
    "path": "go.mod",
    "content": "module pansou\n\ngo 1.24.1\n\ntoolchain go1.24.9\n\nrequire (\n\tgithub.com/Advik-B/cloudscraper v0.0.0-20250623142001-d5e0e43555db\n\tgithub.com/PuerkitoBio/goquery v1.8.1\n\tgithub.com/bytedance/sonic v1.14.0\n\tgithub.com/gin-gonic/gin v1.9.1\n\tgithub.com/golang-jwt/jwt/v5 v5.2.0\n\tgolang.org/x/net v0.41.0\n)\n\nrequire (\n\tgithub.com/andybalholm/brotli v1.1.1 // indirect\n\tgithub.com/andybalholm/cascadia v1.3.1 // indirect\n\tgithub.com/bytedance/sonic/loader v0.3.0 // indirect\n\tgithub.com/cloudwego/base64x v0.1.5 // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.2 // indirect\n\tgithub.com/gin-contrib/sse v0.1.0 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/go-playground/validator/v10 v10.14.0 // indirect\n\tgithub.com/goccy/go-json v0.10.2 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.2.4 // indirect\n\tgithub.com/leodido/go-urn v1.2.4 // indirect\n\tgithub.com/mattn/go-isatty v0.0.19 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.0.8 // indirect\n\tgithub.com/robertkrimen/otto v0.5.1 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.2.11 // indirect\n\tgolang.org/x/arch v0.3.0 // indirect\n\tgolang.org/x/crypto v0.39.0 // indirect\n\tgolang.org/x/sys v0.33.0 // indirect\n\tgolang.org/x/text v0.26.0 // indirect\n\tgoogle.golang.org/protobuf v1.30.0 // indirect\n\tgopkg.in/sourcemap.v1 v1.0.5 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/Advik-B/cloudscraper v0.0.0-20250623142001-d5e0e43555db h1:r1hesdkYWgm4Bf7abv6UsIUlrCdFxRdKy+DuVypOpw4=\ngithub.com/Advik-B/cloudscraper v0.0.0-20250623142001-d5e0e43555db/go.mod h1:X4xeBaRgq6YCNFrPNd/AXnzGLWq2c46oJfIBh0iLOpI=\ngithub.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=\ngithub.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=\ngithub.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=\ngithub.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=\ngithub.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=\ngithub.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=\ngithub.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=\ngithub.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=\ngithub.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=\ngithub.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=\ngithub.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=\ngithub.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=\ngithub.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=\ngithub.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=\ngithub.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=\ngithub.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=\ngithub.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=\ngithub.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=\ngithub.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=\ngithub.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=\ngithub.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=\ngithub.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=\ngithub.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=\ngithub.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=\ngithub.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=\ngithub.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=\ngithub.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=\ngithub.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=\ngithub.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=\ngithub.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=\ngithub.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=\ngithub.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=\ngithub.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=\ngithub.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=\ngithub.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=\ngithub.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=\ngithub.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=\ngithub.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0=\ngithub.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=\ngithub.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=\ngithub.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=\ngithub.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=\ngithub.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngolang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=\ngolang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=\ngolang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=\ngolang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=\ngolang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=\ngolang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=\ngoogle.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=\ngoogle.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=\ngopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nnullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=\n"
  },
  {
    "path": "main.go",
    "content": "package main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"runtime\"\n\t\"sort\"\n\t\"strings\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"golang.org/x/net/netutil\"\n\n\t\"pansou/api\"\n\t\"pansou/config\"\n\t\"pansou/plugin\"\n\t\"pansou/service\"\n\t\"pansou/util\"\n\t\"pansou/util/cache\"\n\n\t// 以下是插件的空导入，用于触发各插件的init函数，实现自动注册\n\t// 添加新插件时，只需在此处添加对应的导入语句即可\n\t_ \"pansou/plugin/hdr4k\"\n\t_ \"pansou/plugin/gying\"\n\t_ \"pansou/plugin/pan666\"\n\t_ \"pansou/plugin/hunhepan\"\n\t_ \"pansou/plugin/jikepan\"\n\t_ \"pansou/plugin/panwiki\"\n\t_ \"pansou/plugin/pansearch\"\n\t_ \"pansou/plugin/panta\"\n\t_ \"pansou/plugin/qupansou\"\n\t_ \"pansou/plugin/susu\"\n\t_ \"pansou/plugin/thepiratebay\"\n\t_ \"pansou/plugin/wanou\"\n\t_ \"pansou/plugin/xuexizhinan\"\n\t_ \"pansou/plugin/panyq\"\n\t_ \"pansou/plugin/zhizhen\"\n\t_ \"pansou/plugin/labi\"\n\t_ \"pansou/plugin/muou\"\n\t_ \"pansou/plugin/ouge\"\n\t_ \"pansou/plugin/shandian\"\n\t_ \"pansou/plugin/duoduo\"\n\t_ \"pansou/plugin/huban\"\n\t_ \"pansou/plugin/cyg\"\n\t_ \"pansou/plugin/erxiao\"\n\t_ \"pansou/plugin/miaoso\"\n\t_ \"pansou/plugin/fox4k\"\n\t_ \"pansou/plugin/pianku\"\n\t_ \"pansou/plugin/clmao\"\n\t_ \"pansou/plugin/wuji\"\n\t_ \"pansou/plugin/cldi\"\n\t_ \"pansou/plugin/xiaozhang\"\n\t_ \"pansou/plugin/libvio\"\n\t_ \"pansou/plugin/leijing\"\n\t_ \"pansou/plugin/xb6v\"\n\t_ \"pansou/plugin/xys\"\n\t_ \"pansou/plugin/ddys\"\n\t_ \"pansou/plugin/hdmoli\"\n\t_ \"pansou/plugin/yuhuage\"\n\t_ \"pansou/plugin/u3c3\"\n\t_ \"pansou/plugin/javdb\"\n\t_ \"pansou/plugin/clxiong\"\n\t_ \"pansou/plugin/jutoushe\"\n\t_ \"pansou/plugin/sdso\"\n\t_ \"pansou/plugin/xiaoji\"\n\t_ \"pansou/plugin/xdyh\"\n\t_ \"pansou/plugin/haisou\"\n\t_ \"pansou/plugin/bixin\"\n\t_ \"pansou/plugin/nyaa\"\n\t_ \"pansou/plugin/djgou\"\n\t_ \"pansou/plugin/xinjuc\"\n\t_ \"pansou/plugin/aikanzy\"\n\t_ \"pansou/plugin/qupanshe\"\n\t_ \"pansou/plugin/xdpan\"\n\t_ \"pansou/plugin/discourse\"\n\t_ \"pansou/plugin/yunsou\"\n\t_ \"pansou/plugin/ahhhhfs\"\n\t_ \"pansou/plugin/nsgame\"\n\t_ \"pansou/plugin/quark4k\"\n\t_ \"pansou/plugin/quarksoo\"\n\t_ \"pansou/plugin/sousou\"\n\t_ \"pansou/plugin/ash\"\n\t_ \"pansou/plugin/qqpd\"\n\t_ \"pansou/plugin/weibo\"\n\t_ \"pansou/plugin/feikuai\"\n\t_ \"pansou/plugin/kkmao\"\n\t_ \"pansou/plugin/alupan\"\n\t_ \"pansou/plugin/ypfxw\"\n\t_ \"pansou/plugin/mikuclub\"\n\t_ \"pansou/plugin/daishudj\"\n\t_ \"pansou/plugin/dyyj\"\n\t_ \"pansou/plugin/meitizy\"\n\t_ \"pansou/plugin/jsnoteclub\"\n\t_ \"pansou/plugin/mizixing\"\n\t_ \"pansou/plugin/lou1\"\n\t_ \"pansou/plugin/yiove\"\n\t_ \"pansou/plugin/zxzj\"\n\t_ \"pansou/plugin/qingying\"\n\t_ \"pansou/plugin/kkv\"\n)\n\n// 全局缓存写入管理器\nvar globalCacheWriteManager *cache.DelayedBatchWriteManager\n\nfunc main() {\n\t// 初始化应用\n\tinitApp()\n\n\t// 启动服务器\n\tstartServer()\n}\n\n// initApp 初始化应用程序\nfunc initApp() {\n\t// 初始化配置\n\tconfig.Init()\n\n\t// 初始化HTTP客户端\n\tutil.InitHTTPClient()\n\n\t// 初始化缓存写入管理器\n\tvar err error\n\tglobalCacheWriteManager, err = cache.NewDelayedBatchWriteManager()\n\tif err != nil {\n\t\tlog.Fatalf(\"缓存写入管理器创建失败: %v\", err)\n\t}\n\tif err := globalCacheWriteManager.Initialize(); err != nil {\n\t\tlog.Fatalf(\"缓存写入管理器初始化失败: %v\", err)\n\t}\n\t// 将缓存写入管理器注入到service包\n\tservice.SetGlobalCacheWriteManager(globalCacheWriteManager)\n\n\t// 延迟设置主缓存更新函数，确保service初始化完成\n\tgo func() {\n\t\t// 等待一小段时间确保service包完全初始化\n\t\ttime.Sleep(100 * time.Millisecond)\n\t\tif mainCache := service.GetEnhancedTwoLevelCache(); mainCache != nil {\n\t\t\tglobalCacheWriteManager.SetMainCacheUpdater(func(key string, data []byte, ttl time.Duration) error {\n\t\t\t\treturn mainCache.SetBothLevels(key, data, ttl)\n\t\t\t})\n\t\t}\n\t}()\n\n\t// 确保异步插件系统初始化\n\tplugin.InitAsyncPluginSystem()\n}\n\n// startServer 启动Web服务器\nfunc startServer() {\n\t// 初始化插件管理器\n\tpluginManager := plugin.NewPluginManager()\n\n\t// 注册全局插件（根据配置过滤）\n\tif config.AppConfig.AsyncPluginEnabled {\n\t\tpluginManager.RegisterGlobalPluginsWithFilter(config.AppConfig.EnabledPlugins)\n\t}\n\n\t// 更新默认并发数（如果插件被禁用则使用0）\n\tpluginCount := 0\n\tif config.AppConfig.AsyncPluginEnabled {\n\t\tpluginCount = len(pluginManager.GetPlugins())\n\t}\n\tconfig.UpdateDefaultConcurrency(pluginCount)\n\n\t// 初始化搜索服务\n\tsearchService := service.NewSearchService(pluginManager)\n\n\t// 设置路由\n\trouter := api.SetupRouter(searchService)\n\n\t// 获取端口配置\n\tport := config.AppConfig.Port\n\n\t// 输出服务信息\n\tprintServiceInfo(port, pluginManager)\n\n\t// 创建HTTP服务器\n\tsrv := &http.Server{\n\t\tAddr:         \":\" + port,\n\t\tHandler:      router,\n\t\tReadTimeout:  config.AppConfig.HTTPReadTimeout,\n\t\tWriteTimeout: config.AppConfig.HTTPWriteTimeout,\n\t\tIdleTimeout:  config.AppConfig.HTTPIdleTimeout,\n\t}\n\n\t// 创建通道来接收操作系统信号\n\tquit := make(chan os.Signal, 1)\n\tsignal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)\n\n\t// 在单独的goroutine中启动服务器\n\tgo func() {\n\t\t// 如果设置了最大连接数，使用限制监听器\n\t\tif config.AppConfig.HTTPMaxConns > 0 {\n\t\t\t// 创建监听器\n\t\t\tlistener, err := net.Listen(\"tcp\", srv.Addr)\n\t\t\tif err != nil {\n\t\t\t\tlog.Fatalf(\"创建监听器失败: %v\", err)\n\t\t\t}\n\n\t\t\t// 创建限制连接数的监听器\n\t\t\tlimitListener := netutil.LimitListener(listener, config.AppConfig.HTTPMaxConns)\n\n\t\t\t// 使用限制监听器启动服务器\n\t\t\tif err := srv.Serve(limitListener); err != nil && err != http.ErrServerClosed {\n\t\t\t\tlog.Fatalf(\"启动服务器失败: %v\", err)\n\t\t\t}\n\t\t} else {\n\t\t\t// 使用默认方式启动服务器（不限制连接数）\n\t\t\tif err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {\n\t\t\t\tlog.Fatalf(\"启动服务器失败: %v\", err)\n\t\t\t}\n\t\t}\n\t}()\n\n\t// 等待中断信号\n\t<-quit\n\tfmt.Println(\"正在关闭服务器...\")\n\n\t// 优先保存缓存数据到磁盘（数据安全第一）\n\t// 增加关闭超时时间，确保数据有足够时间保存\n\tshutdownTimeout := 10 * time.Second\n\t\n\tif globalCacheWriteManager != nil {\n\t\tif err := globalCacheWriteManager.Shutdown(shutdownTimeout); err != nil {\n\t\t\tlog.Printf(\"缓存数据保存失败: %v\", err)\n\t\t}\n\t}\n\t\n\t// 额外确保内存缓存也被保存（双重保障）\n\tif mainCache := service.GetEnhancedTwoLevelCache(); mainCache != nil {\n\t\tif err := mainCache.FlushMemoryToDisk(); err != nil {\n\t\t\tlog.Printf(\"内存缓存同步失败: %v\", err)\n\t\t} \n\t}\n\n\t// 设置关闭超时时间\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\t// 优雅关闭服务器\n\tif err := srv.Shutdown(ctx); err != nil {\n\t\tlog.Fatalf(\"服务器关闭异常: %v\", err)\n\t}\n\n\tfmt.Println(\"服务器已安全关闭\")\n}\n\n// printServiceInfo 打印服务信息\nfunc printServiceInfo(port string, pluginManager *plugin.PluginManager) {\n\t// 启动服务器\n\tfmt.Printf(\"服务器启动在 http://localhost:%s\\n\", port)\n\n\t// 输出代理信息\n\thasProxy := false\n\tif config.AppConfig.ProxyURL != \"\" {\n\t\tproxyType := \"代理\"\n\t\tif strings.HasPrefix(config.AppConfig.ProxyURL, \"socks5://\") {\n\t\t\tproxyType = \"SOCKS5代理\"\n\t\t} else if strings.HasPrefix(config.AppConfig.ProxyURL, \"http://\") {\n\t\t\tproxyType = \"HTTP代理\"\n\t\t} else if strings.HasPrefix(config.AppConfig.ProxyURL, \"https://\") {\n\t\t\tproxyType = \"HTTPS代理\"\n\t\t}\n\t\tfmt.Printf(\"使用%s (PROXY): %s\\n\", proxyType, config.AppConfig.ProxyURL)\n\t\thasProxy = true\n\t}\n\tif config.AppConfig.HTTPProxyURL != \"\" {\n\t\tfmt.Printf(\"使用HTTP代理 (HTTP_PROXY/http_proxy): %s\\n\", config.AppConfig.HTTPProxyURL)\n\t\thasProxy = true\n\t}\n\tif config.AppConfig.HTTPSProxyURL != \"\" {\n\t\tfmt.Printf(\"使用HTTPS代理 (HTTPS_PROXY/https_proxy): %s\\n\", config.AppConfig.HTTPSProxyURL)\n\t\thasProxy = true\n\t}\n\tif !hasProxy {\n\t\tfmt.Println(\"未使用代理\")\n\t}\n\n\t// 输出并发信息\n\tif os.Getenv(\"CONCURRENCY\") != \"\" {\n\t\tfmt.Printf(\"默认并发数: %d (由环境变量CONCURRENCY指定)\\n\", config.AppConfig.DefaultConcurrency)\n\t} else {\n\t\tchannelCount := len(config.AppConfig.DefaultChannels)\n\t\tpluginCount := 0\n\t\t// 只有插件启用时才计算插件数\n\t\tif config.AppConfig.AsyncPluginEnabled && pluginManager != nil {\n\t\t\tpluginCount = len(pluginManager.GetPlugins())\n\t\t}\n\t\tfmt.Printf(\"默认并发数: %d (= 频道数%d + 插件数%d + 10)\\n\",\n\t\t\tconfig.AppConfig.DefaultConcurrency, channelCount, pluginCount)\n\t}\n\n\t// 输出缓存信息\n\tif config.AppConfig.CacheEnabled {\n\t\tfmt.Printf(\"缓存已启用: 路径=%s, 最大大小=%dMB, TTL=%d分钟\\n\",\n\t\t\tconfig.AppConfig.CachePath,\n\t\t\tconfig.AppConfig.CacheMaxSizeMB,\n\t\t\tconfig.AppConfig.CacheTTLMinutes)\n\t} else {\n\t\tfmt.Println(\"缓存已禁用\")\n\t}\n\n\t// 输出压缩信息\n\tif config.AppConfig.EnableCompression {\n\t\tfmt.Printf(\"响应压缩已启用: 最小压缩大小=%d字节\\n\",\n\t\t\tconfig.AppConfig.MinSizeToCompress)\n\t} \n\n\t// 输出GC配置信息\n\tfmt.Printf(\"GC配置: 触发阈值=%d%%, 内存优化=%v\\n\",\n\t\tconfig.AppConfig.GCPercent,\n\t\tconfig.AppConfig.OptimizeMemory)\n\n\t// 输出HTTP服务器配置信息\n\treadTimeoutMsg := \"\"\n\tif os.Getenv(\"HTTP_READ_TIMEOUT\") != \"\" {\n\t\treadTimeoutMsg = \"(由环境变量指定)\"\n\t} else {\n\t\treadTimeoutMsg = \"(自动计算)\"\n\t}\n\n\twriteTimeoutMsg := \"\"\n\tif os.Getenv(\"HTTP_WRITE_TIMEOUT\") != \"\" {\n\t\twriteTimeoutMsg = \"(由环境变量指定)\"\n\t} else {\n\t\twriteTimeoutMsg = \"(自动计算)\"\n\t}\n\n\tmaxConnsMsg := \"\"\n\tif os.Getenv(\"HTTP_MAX_CONNS\") != \"\" {\n\t\tmaxConnsMsg = \"(由环境变量指定)\"\n\t} else {\n\t\tcpuCount := runtime.NumCPU()\n\t\tmaxConnsMsg = fmt.Sprintf(\"(自动计算: CPU核心数%d × 200)\", cpuCount)\n\t}\n\n\tfmt.Printf(\"HTTP服务器配置: 读取超时=%v %s, 写入超时=%v %s, 空闲超时=%v, 最大连接数=%d %s\\n\",\n\t\tconfig.AppConfig.HTTPReadTimeout, readTimeoutMsg,\n\t\tconfig.AppConfig.HTTPWriteTimeout, writeTimeoutMsg,\n\t\tconfig.AppConfig.HTTPIdleTimeout,\n\t\tconfig.AppConfig.HTTPMaxConns, maxConnsMsg)\n\n\t// 输出异步插件配置信息\n\tif config.AppConfig.AsyncPluginEnabled {\n\t\t// 检查工作者数量是否由环境变量指定\n\t\tworkersMsg := \"\"\n\t\tif os.Getenv(\"ASYNC_MAX_BACKGROUND_WORKERS\") != \"\" {\n\t\t\tworkersMsg = \"(由环境变量指定)\"\n\t\t} else {\n\t\t\tcpuCount := runtime.NumCPU()\n\t\t\tworkersMsg = fmt.Sprintf(\"(自动计算: CPU核心数%d × 5)\", cpuCount)\n\t\t}\n\n\t\t// 检查任务数量是否由环境变量指定\n\t\ttasksMsg := \"\"\n\t\tif os.Getenv(\"ASYNC_MAX_BACKGROUND_TASKS\") != \"\" {\n\t\t\ttasksMsg = \"(由环境变量指定)\"\n\t\t} else {\n\t\t\ttasksMsg = \"(自动计算: 工作者数量 × 5)\"\n\t\t}\n\n\t\tfmt.Printf(\"异步插件已启用: 响应超时=%d秒, 最大工作者=%d %s, 最大任务=%d %s, 缓存TTL=%d小时\\n\",\n\t\t\tconfig.AppConfig.AsyncResponseTimeout,\n\t\t\tconfig.AppConfig.AsyncMaxBackgroundWorkers, workersMsg,\n\t\t\tconfig.AppConfig.AsyncMaxBackgroundTasks, tasksMsg,\n\t\t\tconfig.AppConfig.AsyncCacheTTLHours)\n\t} else {\n\t\tfmt.Println(\"异步插件已禁用\")\n\t}\n\n\t// 只有当插件功能启用时才输出插件信息\n\tif config.AppConfig.AsyncPluginEnabled {\n\t\tplugins := pluginManager.GetPlugins()\n\t\tif len(plugins) > 0 {\n\t\t\t// 根据新逻辑，只有指定了具体插件才会加载插件\n\t\t\tfmt.Printf(\"已启用指定插件 (%d个):\\n\", len(plugins))\n\n\t\t\t// 按优先级排序（优先级数字越小越靠前）\n\t\t\tsort.Slice(plugins, func(i, j int) bool {\n\t\t\t\t// 优先级相同时按名称排序\n\t\t\t\tif plugins[i].Priority() == plugins[j].Priority() {\n\t\t\t\t\treturn plugins[i].Name() < plugins[j].Name()\n\t\t\t\t}\n\t\t\t\treturn plugins[i].Priority() < plugins[j].Priority()\n\t\t\t})\n\n\t\t\tfor _, p := range plugins {\n\t\t\t\tfmt.Printf(\"  - %s (优先级: %d)\\n\", p.Name(), p.Priority())\n\t\t\t}\n\t\t} else {\n\t\t\t// 区分不同的情况\n\t\t\tif config.AppConfig.EnabledPlugins == nil {\n\t\t\t\tfmt.Println(\"未设置插件列表 (ENABLED_PLUGINS)，未加载任何插件\")\n\t\t\t} else if len(config.AppConfig.EnabledPlugins) > 0 {\n\t\t\t\tfmt.Printf(\"未找到指定的插件: %s\\n\", strings.Join(config.AppConfig.EnabledPlugins, \", \"))\n\t\t\t} else {\n\t\t\t\tfmt.Println(\"插件列表为空 (ENABLED_PLUGINS=\\\"\\\")，未加载任何插件\")\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "mcp-config.json",
    "content": "{\n  \"mcpServers\": {\n    \"pansou\": {\n      \"command\": \"node\",\n      \"args\": [\n        \"C:\\\\full\\\\path\\\\to\\\\your\\\\project\\\\typescript\\\\dist\\\\index.js\"\n      ],\n      \"env\": {\n        \"PANSOU_SERVER_URL\": \"http://localhost:8888\",\n        \"REQUEST_TIMEOUT\": \"60\",\n        \"MAX_RESULTS\": \"50\",\n        \"DEFAULT_CLOUD_TYPES\": \"baidu,aliyun,quark,tianyi,uc,mobile,115,pikpak,xunlei,123,magnet,ed2k,others\",\n        \"AUTO_START_BACKEND\": \"true\",\n        \"DOCKER_MODE\": \"true\",\n        \"BACKEND_SHUTDOWN_DELAY\": \"5000\",\n        \"BACKEND_STARTUP_TIMEOUT\": \"30000\",\n        \"IDLE_TIMEOUT\": \"300000\",\n        \"ENABLE_IDLE_SHUTDOWN\": \"true\",\n        \"PROJECT_ROOT_PATH\": \"C:\\\\full\\\\path\\\\to\\\\your\\\\project\",\n        \"ENABLED_PLUGINS\": \"labi,zhizhen,shandian,duoduo,muou,wanou\"\n      }\n    }\n  },\n  \"_comments\": {\n    \"description\": \"PanSou MCP服务统一配置文件\",\n    \"version\": \"2.0\",\n    \"智能模式说明\": {\n      \"自动检测\": \"如果DOCKER_MODE未设置或为false，服务将自动检测部署模式\",\n      \"检测优先级\": [\n        \"1. 检查是否有运行中的Docker容器（名称包含pansou）\",\n        \"2. 检查是否存在Go可执行文件（pansou.exe/main.exe）\",\n        \"3. 检查后端服务是否已在运行\"\n      ],\n      \"环境变量覆盖\": \"可通过环境变量强制指定模式，如 DOCKER_MODE=true\"\n    },\n    \"配置说明\": {\n      \"PANSOU_SERVER_URL\": \"后端服务地址，默认http://localhost:8888\",\n      \"REQUEST_TIMEOUT\": \"请求超时时间（秒），默认30\",\n      \"MAX_RESULTS\": \"最大搜索结果数，默认50\",\n      \"DEFAULT_CLOUD_TYPES\": \"默认搜索的网盘类型，逗号分隔\",\n      \"AUTO_START_BACKEND\": \"是否自动启动后端服务（源码模式），默认true\",\n      \"DOCKER_MODE\": \"是否强制使用Docker模式，默认false（自动检测）\",\n      \"BACKEND_SHUTDOWN_DELAY\": \"后端服务关闭延迟（毫秒），默认5000\",\n      \"BACKEND_STARTUP_TIMEOUT\": \"后端服务启动超时（毫秒），默认30000\",\n      \"IDLE_TIMEOUT\": \"空闲超时时间（毫秒），默认300000（5分钟）\",\n      \"ENABLE_IDLE_SHUTDOWN\": \"是否启用空闲自动关闭，默认true\",\n      \"PROJECT_ROOT_PATH\": \"项目根目录路径，用于查找Go可执行文件\",\n      \"ENABLED_PLUGINS\": \"指定启用的插件列表，多个插件用逗号分隔，必须显式指定\"\n    },\n    \"使用示例\": {\n      \"自动模式\": \"默认配置，自动检测部署方式\",\n      \"强制Docker模式\": \"设置 DOCKER_MODE=true\",\n      \"强制源码模式\": \"设置 DOCKER_MODE=false 且 AUTO_START_BACKEND=true\",\n      \"仅连接模式\": \"设置 AUTO_START_BACKEND=false（适用于手动启动的后端）\"\n    }\n  }\n}"
  },
  {
    "path": "model/plugin_result.go",
    "content": "package model\n\nimport (\n\t\"time\"\n)\n\n// PluginSearchResult 插件搜索结果\ntype PluginSearchResult struct {\n\tResults   []SearchResult `json:\"results\"`     // 搜索结果\n\tIsFinal   bool           `json:\"is_final\"`    // 是否为最终完整结果\n\tTimestamp time.Time      `json:\"timestamp\"`   // 结果时间戳\n\tSource    string         `json:\"source\"`      // 插件来源\n\tMessage   string         `json:\"message\"`     // 状态描述（可选）\n}\n\n// IsEmpty 检查结果是否为空\nfunc (p *PluginSearchResult) IsEmpty() bool {\n\treturn len(p.Results) == 0\n}\n\n// Count 返回结果数量\nfunc (p *PluginSearchResult) Count() int {\n\treturn len(p.Results)\n}\n\n// GetResults 获取搜索结果列表\nfunc (p *PluginSearchResult) GetResults() []SearchResult {\n\tif p.Results == nil {\n\t\treturn []SearchResult{}\n\t}\n\treturn p.Results\n} "
  },
  {
    "path": "model/request.go",
    "content": "package model\n\n// FilterConfig 过滤配置\ntype FilterConfig struct {\n\tInclude []string `json:\"include,omitempty\"` // 包含关键词列表（OR关系）\n\tExclude []string `json:\"exclude,omitempty\"` // 排除关键词列表（AND关系）\n}\n\n// SearchRequest 搜索请求参数\ntype SearchRequest struct {\n\tKeyword      string                 `json:\"kw\" binding:\"required\"`       // 搜索关键词\n\tChannels     []string               `json:\"channels\"`                    // 搜索的频道列表\n\tConcurrency  int                    `json:\"conc\"`                        // 并发搜索数量\n\tForceRefresh bool                   `json:\"refresh\"`                     // 强制刷新，不使用缓存\n\tResultType   string                 `json:\"res\"`                         // 结果类型：all(返回所有结果)、results(仅返回results)、merge(仅返回merged_by_type)\n\tSourceType   string                 `json:\"src\"`                         // 数据来源类型：all(默认，全部来源)、tg(仅Telegram)、plugin(仅插件)\n\tPlugins      []string               `json:\"plugins\"`                     // 指定搜索的插件列表，不指定则搜索全部插件\n\tExt          map[string]interface{} `json:\"ext\"`                         // 扩展参数，用于传递给插件的自定义参数\n\tCloudTypes   []string               `json:\"cloud_types\"`                 // 指定返回的网盘类型列表，不指定则返回所有类型\n\tFilter       *FilterConfig          `json:\"filter,omitempty\"`            // 过滤配置，用于过滤返回结果\n} "
  },
  {
    "path": "model/response.go",
    "content": "package model\n\nimport \"time\"\n\n// Link 网盘链接\ntype Link struct {\n\tType      string    `json:\"type\" sonic:\"type\"`\n\tURL       string    `json:\"url\" sonic:\"url\"`\n\tPassword  string    `json:\"password\" sonic:\"password\"`\n\tDatetime  time.Time `json:\"datetime,omitempty\" sonic:\"datetime,omitempty\"` // 链接更新时间（可选）\n\tWorkTitle string    `json:\"work_title,omitempty\" sonic:\"work_title,omitempty\"` // 作品标题（用于区分同一消息中多个作品的链接）\n}\n\n// SearchResult 搜索结果\ntype SearchResult struct {\n\tMessageID string    `json:\"message_id\" sonic:\"message_id\"`\n\tUniqueID  string    `json:\"unique_id\" sonic:\"unique_id\"`     // 全局唯一ID\n\tChannel   string    `json:\"channel\" sonic:\"channel\"`\n\tDatetime  time.Time `json:\"datetime\" sonic:\"datetime\"`\n\tTitle     string    `json:\"title\" sonic:\"title\"`\n\tContent   string    `json:\"content\" sonic:\"content\"`\n\tLinks     []Link    `json:\"links\" sonic:\"links\"`\n\tTags      []string  `json:\"tags,omitempty\" sonic:\"tags,omitempty\"`\n\tImages    []string  `json:\"images,omitempty\" sonic:\"images,omitempty\"` // TG消息中的图片链接\n}\n\n// MergedLink 合并后的网盘链接\ntype MergedLink struct {\n\tURL      string    `json:\"url\" sonic:\"url\"`\n\tPassword string    `json:\"password\" sonic:\"password\"`\n\tNote     string    `json:\"note\" sonic:\"note\"`\n\tDatetime time.Time `json:\"datetime\" sonic:\"datetime\"`\n\tSource   string    `json:\"source,omitempty\" sonic:\"source,omitempty\"` // 数据来源：tg:频道名 或 plugin:插件名\n\tImages   []string  `json:\"images,omitempty\" sonic:\"images,omitempty\"`   // TG消息中的图片链接\n}\n\n// MergedLinks 按网盘类型分组的合并链接\ntype MergedLinks map[string][]MergedLink\n\n// SearchResponse 搜索响应\ntype SearchResponse struct {\n\tTotal        int           `json:\"total\" sonic:\"total\"`\n\tResults      []SearchResult `json:\"results,omitempty\" sonic:\"results,omitempty\"`\n\tMergedByType MergedLinks   `json:\"merged_by_type,omitempty\" sonic:\"merged_by_type,omitempty\"`\n}\n\n// Response API通用响应\ntype Response struct {\n\tCode    int         `json:\"code\" sonic:\"code\"`\n\tMessage string      `json:\"message\" sonic:\"message\"`\n\tData    interface{} `json:\"data,omitempty\" sonic:\"data,omitempty\"`\n}\n\n// NewSuccessResponse 创建成功响应\nfunc NewSuccessResponse(data interface{}) Response {\n\treturn Response{\n\t\tCode:    0,\n\t\tMessage: \"success\",\n\t\tData:    data,\n\t}\n}\n\n// NewErrorResponse 创建错误响应\nfunc NewErrorResponse(code int, message string) Response {\n\treturn Response{\n\t\tCode:    code,\n\t\tMessage: message,\n\t}\n}"
  },
  {
    "path": "package.json",
    "content": "{\n  \"dependencies\": {\n    \"@modelcontextprotocol/sdk\": \"^1.17.4\"\n  }\n}\n"
  },
  {
    "path": "plugin/ahhhhfs/ahhhhfs.go",
    "content": "package ahhhhfs\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\n// 预编译的正则表达式\nvar (\n\t// 从详情页URL中提取文章ID的正则表达式\n\tarticleIDRegex = regexp.MustCompile(`/(\\d+)/?$`)\n\t\n\t// 常见网盘链接的正则表达式\n\tquarkLinkRegex  = regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9a-zA-Z]+`)\n\tbaiduLinkRegex  = regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9a-zA-Z_\\-]+`)\n\taliyunLinkRegex = regexp.MustCompile(`https?://(www\\.)?(aliyundrive\\.com|alipan\\.com)/s/[0-9a-zA-Z]+`)\n\tucLinkRegex     = regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9a-zA-Z]+`)\n\txunleiLinkRegex = regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9a-zA-Z_\\-]+`)\n\ttianyiLinkRegex = regexp.MustCompile(`https?://cloud\\.189\\.cn/(t|web)/[0-9a-zA-Z]+`)\n\tlink115Regex    = regexp.MustCompile(`https?://115\\.com/s/[0-9a-zA-Z]+`)\n\tlink123Regex    = regexp.MustCompile(`https?://123pan\\.com/s/[0-9a-zA-Z]+`)\n\tpikpakLinkRegex = regexp.MustCompile(`https?://mypikpak\\.com/s/[0-9a-zA-Z]+`)\n\t\n\t// 提取码匹配模式\n\tpwdPatterns = []*regexp.Regexp{\n\t\tregexp.MustCompile(`提取码[：:]\\s*([0-9a-zA-Z]+)`),\n\t\tregexp.MustCompile(`密码[：:]\\s*([0-9a-zA-Z]+)`),\n\t\tregexp.MustCompile(`pwd[=:：]\\s*([0-9a-zA-Z]+)`),\n\t\tregexp.MustCompile(`code[=:：]\\s*([0-9a-zA-Z]+)`),\n\t}\n\t\n\t// 缓存相关\n\tdetailCache     = sync.Map{} // 缓存详情页解析结果\n\tlastCleanupTime = time.Now()\n\tcacheTTL        = 1 * time.Hour\n)\n\nconst (\n\t// 插件名称\n\tpluginName = \"ahhhhfs\"\n\t\n\t// 优先级\n\tdefaultPriority = 2\n\t\n\t// 超时时间\n\tDefaultTimeout = 10 * time.Second\n\tDetailTimeout  = 8 * time.Second\n\t\n\t// 并发数限制\n\tMaxConcurrency = 15\n\t\n\t// HTTP连接池配置\n\tMaxIdleConns        = 100\n\tMaxIdleConnsPerHost = 30\n\tMaxConnsPerHost     = 50\n\tIdleConnTimeout     = 90 * time.Second\n)\n\n// 性能统计\nvar (\n\tsearchRequests     int64 = 0\n\tdetailPageRequests int64 = 0\n\tcacheHits          int64 = 0\n\tcacheMisses        int64 = 0\n)\n\n// AhhhhfsAsyncPlugin ahhhhfs异步插件\ntype AhhhhfsAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// 在init函数中注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewAhhhhfsPlugin())\n\t\n\t// 启动缓存清理goroutine\n\tgo startCacheCleaner()\n}\n\n// startCacheCleaner 启动一个定期清理缓存的goroutine\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(30 * time.Minute)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\t// 清空所有缓存\n\t\tdetailCache = sync.Map{}\n\t\tlastCleanupTime = time.Now()\n\t}\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t}\n\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   DefaultTimeout,\n\t}\n}\n\n// NewAhhhhfsPlugin 创建新的ahhhhfs异步插件\nfunc NewAhhhhfsPlugin() *AhhhhfsAsyncPlugin {\n\treturn &AhhhhfsAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *AhhhhfsAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *AhhhhfsAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实现具体的搜索逻辑\nfunc (p *AhhhhfsAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 性能统计\n\tstart := time.Now()\n\tatomic.AddInt64(&searchRequests, 1)\n\tdefer func() {\n\t\tfmt.Printf(\"[%s] 搜索耗时: %v\\n\", p.Name(), time.Since(start))\n\t}()\n\n\t// 使用优化的客户端\n\tif p.optimizedClient != nil {\n\t\tclient = p.optimizedClient\n\t}\n\n\t// 1. 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"https://www.ahhhhfs.com/?cat=&s=%s\", url.QueryEscape(keyword))\n\t\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\t\n\t// 3. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 4. 设置完整的请求头（避免反爬虫）\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Referer\", \"https://www.ahhhhfs.com/\")\n\t\n\t// 5. 发送请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 6. 解析搜索结果页面\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 7. 提取搜索结果\n\tvar results []model.SearchResult\n\tvar wg sync.WaitGroup\n\tvar mu sync.Mutex\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\n\tdoc.Find(\"article.post-item.item-list\").Each(func(i int, s *goquery.Selection) {\n\t\t// 解析基本信息\n\t\ttitleElem := s.Find(\".entry-title a\")\n\t\ttitle := strings.TrimSpace(titleElem.Text())\n\t\tif title == \"\" {\n\t\t\ttitle = strings.TrimSpace(titleElem.AttrOr(\"title\", \"\"))\n\t\t}\n\t\t\n\t\tdetailURL, exists := titleElem.Attr(\"href\")\n\t\tif !exists || detailURL == \"\" || title == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 提取文章ID\n\t\tarticleID := p.extractArticleID(detailURL)\n\t\tif articleID == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 提取分类标签\n\t\tvar tags []string\n\t\ts.Find(\".entry-cat-dot a\").Each(func(j int, tag *goquery.Selection) {\n\t\t\ttagText := strings.TrimSpace(tag.Text())\n\t\t\tif tagText != \"\" {\n\t\t\t\ttags = append(tags, tagText)\n\t\t\t}\n\t\t})\n\t\t\n\t\t// 提取描述\n\t\tcontent := strings.TrimSpace(s.Find(\".entry-desc\").Text())\n\t\t\n\t\t// 提取时间\n\t\tdatetime := \"\"\n\t\ttimeElem := s.Find(\".entry-meta .meta-date time\")\n\t\tif dt, exists := timeElem.Attr(\"datetime\"); exists {\n\t\t\tdatetime = dt\n\t\t} else {\n\t\t\tdatetime = strings.TrimSpace(timeElem.Text())\n\t\t}\n\t\t\n\t\t// 解析时间\n\t\tpublishTime := p.parseDateTime(datetime)\n\t\t\n\t\t// 异步获取详情页的网盘链接\n\t\twg.Add(1)\n\t\tsemaphore <- struct{}{} // 获取信号量\n\t\t\n\t\tgo func(title, detailURL, articleID, content string, tags []string, publishTime time.Time) {\n\t\t\tdefer wg.Done()\n\t\t\tdefer func() { <-semaphore }() // 释放信号量\n\t\t\t\n\t\t\t// 获取网盘链接\n\t\t\tlinks := p.fetchDetailLinks(client, detailURL, articleID)\n\t\t\t\n\t\t\tif len(links) > 0 {\n\t\t\t\tresult := model.SearchResult{\n\t\t\t\t\tUniqueID: fmt.Sprintf(\"%s-%s\", p.Name(), articleID),\n\t\t\t\t\tTitle:    title,\n\t\t\t\t\tContent:  content,\n\t\t\t\t\tLinks:    links,\n\t\t\t\t\tTags:     tags,\n\t\t\t\t\tChannel:  \"\", // 插件搜索结果 Channel 必须为空\n\t\t\t\t\tDatetime: publishTime,\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tmu.Lock()\n\t\t\t\tresults = append(results, result)\n\t\t\t\tmu.Unlock()\n\t\t\t}\n\t\t}(title, detailURL, articleID, content, tags, publishTime)\n\t})\n\t\n\t// 等待所有详情页请求完成\n\twg.Wait()\n\t\n\tfmt.Printf(\"[%s] 搜索结果: %d 条\\n\", p.Name(), len(results))\n\t\n\t// 关键词过滤\n\treturn plugin.FilterResultsByKeyword(results, keyword), nil\n}\n\n// extractArticleID 从URL中提取文章ID\nfunc (p *AhhhhfsAsyncPlugin) extractArticleID(detailURL string) string {\n\tmatches := articleIDRegex.FindStringSubmatch(detailURL)\n\tif len(matches) >= 2 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// parseDateTime 解析时间字符串\nfunc (p *AhhhhfsAsyncPlugin) parseDateTime(datetime string) time.Time {\n\tdatetime = strings.TrimSpace(datetime)\n\t\n\t// 尝试解析 ISO 格式\n\tif t, err := time.Parse(time.RFC3339, datetime); err == nil {\n\t\treturn t\n\t}\n\t\n\t// 尝试解析标准日期格式\n\tlayouts := []string{\n\t\t\"2006-01-02\",\n\t\t\"2006-01-02 15:04:05\",\n\t\t\"2006-01-02T15:04:05\",\n\t\t\"2006-01-02T15:04:05Z07:00\",\n\t}\n\t\n\tfor _, layout := range layouts {\n\t\tif t, err := time.Parse(layout, datetime); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\t\n\t// 处理相对时间（如\"1 周前\"、\"2 天前\"）\n\tnow := time.Now()\n\t\n\tif strings.Contains(datetime, \"小时前\") || strings.Contains(datetime, \"hours ago\") {\n\t\t// 简单处理，返回当天\n\t\treturn now\n\t}\n\t\n\tif strings.Contains(datetime, \"天前\") || strings.Contains(datetime, \"days ago\") {\n\t\t// 简单处理，返回近期\n\t\treturn now.AddDate(0, 0, -7)\n\t}\n\t\n\tif strings.Contains(datetime, \"周前\") || strings.Contains(datetime, \"weeks ago\") {\n\t\t// 简单处理，返回一个月前\n\t\treturn now.AddDate(0, -1, 0)\n\t}\n\t\n\t// 默认返回当前时间\n\treturn now\n}\n\n// fetchDetailLinks 获取详情页的网盘链接\nfunc (p *AhhhhfsAsyncPlugin) fetchDetailLinks(client *http.Client, detailURL, articleID string) []model.Link {\n\tatomic.AddInt64(&detailPageRequests, 1)\n\t\n\t// 检查缓存\n\tif cached, ok := detailCache.Load(articleID); ok {\n\t\tatomic.AddInt64(&cacheHits, 1)\n\t\treturn cached.([]model.Link)\n\t}\n\t\n\tatomic.AddInt64(&cacheMisses, 1)\n\t\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DetailTimeout)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\tfmt.Printf(\"[%s] 创建详情页请求失败: %v\\n\", p.Name(), err)\n\t\treturn nil\n\t}\n\t\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Referer\", \"https://www.ahhhhfs.com/\")\n\t\n\t// 发送请求\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tfmt.Printf(\"[%s] 详情页请求失败: %v\\n\", p.Name(), err)\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\tfmt.Printf(\"[%s] 详情页返回状态码: %d\\n\", p.Name(), resp.StatusCode)\n\t\treturn nil\n\t}\n\t\n\t// 解析详情页\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\tfmt.Printf(\"[%s] 解析详情页失败: %v\\n\", p.Name(), err)\n\t\treturn nil\n\t}\n\t\n\t// 提取网盘链接\n\tlinks := p.extractNetDiskLinks(doc)\n\t\n\t// 缓存结果\n\tif len(links) > 0 {\n\t\tdetailCache.Store(articleID, links)\n\t}\n\t\n\treturn links\n}\n\n// extractNetDiskLinks 从详情页提取网盘链接\nfunc (p *AhhhhfsAsyncPlugin) extractNetDiskLinks(doc *goquery.Document) []model.Link {\n\tvar links []model.Link\n\tlinkMap := make(map[string]model.Link) // 用于去重\n\t\n\t// 在文章内容中查找所有链接\n\tdoc.Find(\".post-content a\").Each(func(i int, s *goquery.Selection) {\n\t\thref, exists := s.Attr(\"href\")\n\t\tif !exists || href == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 判断是否为网盘链接\n\t\tcloudType := p.determineCloudType(href)\n\t\tif cloudType == \"others\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 提取提取码\n\t\tpassword := p.extractPassword(s, href)\n\t\t\n\t\t// 添加到结果（去重）\n\t\tif _, exists := linkMap[href]; !exists {\n\t\t\tlink := model.Link{\n\t\t\t\tType:     cloudType,\n\t\t\t\tURL:      href,\n\t\t\t\tPassword: password,\n\t\t\t}\n\t\t\tlinkMap[href] = link\n\t\t\tlinks = append(links, link)\n\t\t}\n\t})\n\t\n\treturn links\n}\n\n// determineCloudType 判断链接类型\nfunc (p *AhhhhfsAsyncPlugin) determineCloudType(url string) string {\n\tswitch {\n\tcase strings.Contains(url, \"pan.quark.cn\"):\n\t\treturn \"quark\"\n\tcase strings.Contains(url, \"drive.uc.cn\"):\n\t\treturn \"uc\"\n\tcase strings.Contains(url, \"pan.baidu.com\"):\n\t\treturn \"baidu\"\n\tcase strings.Contains(url, \"aliyundrive.com\") || strings.Contains(url, \"alipan.com\"):\n\t\treturn \"aliyun\"\n\tcase strings.Contains(url, \"pan.xunlei.com\"):\n\t\treturn \"xunlei\"\n\tcase strings.Contains(url, \"cloud.189.cn\"):\n\t\treturn \"tianyi\"\n\tcase strings.Contains(url, \"115.com\"):\n\t\treturn \"115\"\n\tcase strings.Contains(url, \"123pan.com\"):\n\t\treturn \"123\"\n\tcase strings.Contains(url, \"mypikpak.com\"):\n\t\treturn \"pikpak\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n// extractPassword 提取提取码\nfunc (p *AhhhhfsAsyncPlugin) extractPassword(linkElem *goquery.Selection, url string) string {\n\t// 1. 从链接的 title 属性中提取\n\tif title, exists := linkElem.Attr(\"title\"); exists {\n\t\tfor _, pattern := range pwdPatterns {\n\t\t\tif matches := pattern.FindStringSubmatch(title); len(matches) >= 2 {\n\t\t\t\treturn matches[1]\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 2. 从链接文本中提取\n\tlinkText := linkElem.Text()\n\tfor _, pattern := range pwdPatterns {\n\t\tif matches := pattern.FindStringSubmatch(linkText); len(matches) >= 2 {\n\t\t\treturn matches[1]\n\t\t}\n\t}\n\t\n\t// 3. 从链接后面的兄弟节点或父节点的文本中提取\n\tparent := linkElem.Parent()\n\tparentText := parent.Text()\n\t\n\t// 获取链接在父元素文本中的位置\n\tlinkIndex := strings.Index(parentText, linkText)\n\tif linkIndex >= 0 {\n\t\t// 获取链接后面的文本\n\t\tafterText := parentText[linkIndex+len(linkText):]\n\t\tfor _, pattern := range pwdPatterns {\n\t\t\tif matches := pattern.FindStringSubmatch(afterText); len(matches) >= 2 {\n\t\t\t\treturn matches[1]\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 4. 从 URL 参数中提取\n\tif strings.Contains(url, \"pwd=\") {\n\t\tparts := strings.Split(url, \"pwd=\")\n\t\tif len(parts) >= 2 {\n\t\t\tpwd := parts[1]\n\t\t\t// 只取密码部分（去除其他参数）\n\t\t\tif idx := strings.IndexAny(pwd, \"&?#\"); idx >= 0 {\n\t\t\t\tpwd = pwd[:idx]\n\t\t\t}\n\t\t\treturn pwd\n\t\t}\n\t}\n\t\n\treturn \"\"\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *AhhhhfsAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n\n"
  },
  {
    "path": "plugin/ahhhhfs/html结构分析.md",
    "content": "# ahhhhfs (A姐分享) HTML结构分析\n\n## 网站信息\n- **网站名称**: ahhhhfs (A姐分享)\n- **域名**: www.ahhhhfs.com\n- **类型**: 资源分享网站（WordPress 站点）\n- **特点**: 分享各类学习资源、软件、教程等\n\n## 搜索页面结构\n\n### 1. 搜索URL模式\n```\nhttps://www.ahhhhfs.com/search/{关键词}\n或\nhttps://www.ahhhhfs.com/?s={关键词}\n\n示例:\nhttps://www.ahhhhfs.com/search/小红书\nhttps://www.ahhhhfs.com/?s=小红书\n\n参数说明:\n- 关键词: 直接使用中文或URL编码都可以\n```\n\n### 2. 搜索结果容器\n- **父容器**: `.row` (结果列表容器)\n- **结果项**: `<article class=\"post-item item-list\">` (每个搜索结果)\n\n### 3. 单个搜索结果结构\n\n#### 标题区域 (.entry-title)\n```html\n<h2 class=\"entry-title\">\n    <a target=\"_blank\" href=\"https://www.ahhhhfs.com/76567/\" \n       title=\"AI小红书虚拟电商全链路实战课：从选品到变现的AI爆款打法\">\n        AI小红书虚拟电商全链路实战课：从选品到变现的AI爆款打法\n    </a>\n</h2>\n\n提取要素:\n- 标题: a 的文本内容或 title 属性\n- 详情页链接: a 的 href 属性\n```\n\n#### 分类标签 (.entry-cat-dot)\n```html\n<div class=\"entry-cat-dot\">\n    <a href=\"https://www.ahhhhfs.com/recourse/%e7%9f%ad%e8%a7%86%e9%a2%91/\">短视频</a>\n    <a href=\"https://www.ahhhhfs.com/recourse/\">资源</a>\n</div>\n\n提取要素:\n- 分类: 所有 a 标签的文本内容\n```\n\n#### 描述区域 (.entry-desc)\n```html\n<div class=\"entry-desc\">\n    AI小红书虚拟电商全链路实战课程概览 《AI小红书虚拟电商5.0实战课》是一门聚焦AI与小红书生态融合的系统课程，围绕AI赋能选品、创作、运营与变现四大环节展开...\n</div>\n\n提取要素:\n- 描述: div 的文本内容\n```\n\n#### 元数据栏 (.entry-meta)\n```html\n<div class=\"entry-meta\">\n    <span class=\"meta-date\">\n        <i class=\"far fa-clock me-1\"></i>\n        <time class=\"pub-date\" datetime=\"2025-10-18T13:43:10+08:00\">1 周前</time>\n    </span>\n    <span class=\"meta-likes d-none d-md-inline-block\"><i class=\"far fa-heart me-1\"></i>0</span>\n    <span class=\"meta-fav d-none d-md-inline-block\"><i class=\"far fa-star me-1\"></i>1</span>\n</div>\n\n提取要素:\n- 发布时间: time 标签的 datetime 属性或文本内容\n```\n\n## 详情页面结构\n\n### 1. 详情页URL模式\n```\nhttps://www.ahhhhfs.com/{文章ID}/\n\n示例:\nhttps://www.ahhhhfs.com/76567/\n```\n\n### 2. 下载链接位置\n下载链接在文章正文内容中 `.post-content` 里面，通常在文章末尾部分。\n\n#### 下载链接格式示例\n```html\n<p>\n    学习地址：\n    <a title=\"...\" \n       href=\"https://pan.quark.cn/s/c16a5ae18ea0\" \n       target=\"_blank\" \n       rel=\"nofollow noopener noreferrer\">夸克</a>\n</p>\n\n或者\n\n<p>\n    下载地址：\n    <a href=\"https://pan.baidu.com/s/xxxxx\" \n       target=\"_blank\" \n       rel=\"nofollow noopener noreferrer\">百度网盘</a>\n    提取码: xxxx\n</p>\n\n或者多个网盘链接：\n<p>\n    阿里云盘：<a href=\"...\">链接</a><br>\n    夸克网盘：<a href=\"...\">链接</a><br>\n    百度网盘：<a href=\"...\">链接</a> 提取码: xxxx\n</p>\n\n提取要素:\n- 网盘链接: .post-content 中包含网盘域名的 a 标签的 href 属性\n- 提取码/密码: 链接附近的文本内容，可能包含 \"提取码\"、\"密码\"、\"pwd\" 等关键词\n```\n\n## CSS选择器总结\n\n| 数据项 | CSS选择器 | 提取方式 |\n|--------|-----------|----------|\n| 搜索结果列表 | `article.post-item.item-list` | 遍历所有结果项 |\n| 标题 | `.entry-title a` | 文本内容或 title 属性 |\n| 详情页链接 | `.entry-title a` | href 属性 |\n| 分类标签 | `.entry-cat-dot a` | 所有 a 标签的文本内容 |\n| 描述 | `.entry-desc` | 文本内容 |\n| 发布时间 | `.entry-meta .meta-date time` | datetime 属性或文本内容 |\n| 文章内容 | `.post-content` | HTML 内容 |\n| 网盘链接 | `.post-content a[href*=\"pan\"]` 或匹配网盘域名 | href 属性 |\n\n## 实现要点\n\n### 1. 支持的网盘类型\n- 夸克网盘: `pan.quark.cn`\n- 阿里云盘: `aliyundrive.com`, `alipan.com`\n- 百度网盘: `pan.baidu.com`\n- UC网盘: `drive.uc.cn`\n- 迅雷网盘: `pan.xunlei.com`\n- 天翼云盘: `cloud.189.cn`\n- 115网盘: `115.com`\n- 123网盘: `123pan.com`\n\n### 2. 提取码识别\n提取码可能出现在以下位置：\n- 链接后面的文本: `提取码: xxxx` 或 `密码: xxxx`\n- 链接的 title 属性中\n- `<br>` 标签分隔的下一行\n- 括号内: `(提取码: xxxx)`\n\n常见关键词：\n- 提取码\n- 密码\n- pwd\n- code\n- 取码\n\n### 3. 链接提取策略\n1. 先从搜索结果页获取文章列表\n2. 访问每篇文章的详情页\n3. 在详情页的 `.post-content` 中查找包含网盘域名的链接\n4. 提取链接和相应的提取码\n5. 如果文章没有网盘链接，则跳过\n\n### 4. 时间格式处理\n- 相对时间: \"1 周前\"、\"2 天前\" 需要转换为具体日期\n- 绝对时间: \"2025-10-18\" 可以直接使用\n- datetime 属性: \"2025-10-18T13:43:10+08:00\" 标准ISO格式\n\n### 5. 去重标识\n- 使用文章ID作为唯一标识: 从详情页URL中提取 `/76567/`\n\n## 注意事项\n\n1. **搜索结果可能为空**: 如果关键词没有匹配结果，页面会显示\"没有找到相关内容\"\n2. **分页**: 搜索结果可能有多页，但通常只抓取第一页即可\n3. **网盘链接位置不固定**: 链接可能在文章开头、中间或结尾，需要遍历整个 `.post-content`\n4. **广告干扰**: 页面包含广告，需要准确定位到实际内容区域\n5. **需要访问详情页**: 搜索结果页不包含下载链接，必须访问详情页才能获取\n6. **请求频率**: 需要访问详情页，建议控制请求频率避免被封\n\n## 示例数据流\n\n```\n1. 搜索请求: https://www.ahhhhfs.com/search/小红书\n   ↓\n2. 解析搜索结果页，提取文章列表\n   - 标题: \"AI小红书虚拟电商全链路实战课：从选品到变现的AI爆款打法\"\n   - 详情页URL: https://www.ahhhhfs.com/76567/\n   - 分类: [\"短视频\", \"资源\"]\n   - 发布时间: 2025-10-18\n   ↓\n3. 访问详情页: https://www.ahhhhfs.com/76567/\n   ↓\n4. 解析详情页 .post-content，提取网盘链接\n   - 夸克网盘: https://pan.quark.cn/s/c16a5ae18ea0\n   - 提取码: (如果有)\n   ↓\n5. 构建最终结果\n   - UniqueID: ahhhhfs-76567\n   - Title: \"AI小红书虚拟电商全链路实战课：从选品到变现的AI爆款打法\"\n   - Content: 文章描述\n   - Links: [{Type: \"quark\", URL: \"...\", Password: \"\"}]\n   - Tags: [\"短视频\", \"资源\"]\n   - Datetime: 2025-10-18T13:43:10+08:00\n```\n\n"
  },
  {
    "path": "plugin/aikanzy/aikanzy.go",
    "content": "package aikanzy\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\n// 预编译的正则表达式\nvar (\n\t// 夸克网盘链接\n\tquarkLinkRegex = regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9a-zA-Z]+`)\n\t\n\t// UC网盘链接\n\tucLinkRegex = regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9a-zA-Z]+(\\?[^\"'\\s]*)?`)\n\t\n\t// 百度网盘链接\n\tbaiduLinkRegex = regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9a-zA-Z_-]+`)\n\t\n\t// 迅雷网盘链接\n\txunleiLinkRegex = regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9a-zA-Z_-]+`)\n\t\n\t// 从URL中提取文章ID\n\tarticleIDRegex = regexp.MustCompile(`/([a-z]+)/(\\d+)\\.html`)\n\t\n\t// 提取阅读数\n\tviewCountRegex = regexp.MustCompile(`(\\d+)\\s*阅读`)\n)\n\n// 常量定义\nconst (\n\t// 插件名称\n\tpluginName = \"aikanzy\"\n\t\n\t// 搜索URL模板\n\tsearchURLTemplate = \"https://www.aikanzy.com/search?word=%s&molds=article\"\n\t\n\t// 默认优先级\n\tdefaultPriority = 3\n\t\n\t// 默认超时时间（秒）\n\tdefaultTimeout = 15\n\t\n\t// 详情页超时时间（秒）\n\tdetailTimeout = 8\n\t\n\t// 最大重试次数\n\tmaxRetries = 3\n\t\n\t// 详情页并发数\n\tdetailConcurrency = 15\n\t\n\t// 指数退避基数（毫秒）\n\tbackoffBase = 200\n)\n\n// AikanzyAsyncPlugin 是AikanZY网站的异步搜索插件实现\ntype AikanzyAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// 确保AikanzyAsyncPlugin实现了AsyncSearchPlugin接口\nvar _ plugin.AsyncSearchPlugin = (*AikanzyAsyncPlugin)(nil)\n\n// 在包初始化时注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewAikanzyAsyncPlugin())\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        100,\n\t\tMaxIdleConnsPerHost: 20,\n\t\tIdleConnTimeout:     90 * time.Second,\n\t\tDisableKeepAlives:   false,\n\t}\n\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   defaultTimeout * time.Second,\n\t}\n}\n\n// NewAikanzyAsyncPlugin 创建一个新的AikanZY异步插件实例\nfunc NewAikanzyAsyncPlugin() *AikanzyAsyncPlugin {\n\treturn &AikanzyAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"aikanzy\", defaultPriority),\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Name 返回插件名称\nfunc (p *AikanzyAsyncPlugin) Name() string {\n\treturn pluginName\n}\n\n// Priority 返回插件优先级\nfunc (p *AikanzyAsyncPlugin) Priority() int {\n\treturn defaultPriority\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *AikanzyAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *AikanzyAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)\n}\n\n// doSearch 执行具体的搜索逻辑\nfunc (p *AikanzyAsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 使用优化的客户端\n\tif p.optimizedClient != nil {\n\t\tclient = p.optimizedClient\n\t}\n\t\n\t// 对关键词进行URL编码\n\tencodedKeyword := url.QueryEscape(keyword)\n\t\n\t// 构建搜索URL\n\tsearchURL := fmt.Sprintf(searchURLTemplate, encodedKeyword)\n\t\n\t// 创建一个带有超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), defaultTimeout*time.Second)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 设置完整的请求头（避免反爬虫）\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"https://www.aikanzy.com/\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\t\n\t// 使用带重试的请求方法发送HTTP请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 请求搜索页面失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查状态码\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"[%s] 请求搜索页面失败，状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 使用goquery解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析HTML失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 解析搜索结果列表\n\tarticleItems := p.parseArticleList(doc)\n\tif len(articleItems) == 0 {\n\t\treturn []model.SearchResult{}, nil\n\t}\n\t\n\t// 并发抓取详情页获取网盘链接\n\tresults := p.fetchDetailsWithLinks(articleItems, client, keyword)\n\t\n\t// 使用过滤功能过滤结果\n\tfilteredResults := plugin.FilterResultsByKeyword(results, keyword)\n\t\n\treturn filteredResults, nil\n}\n\n// ArticleItem 文章基本信息\ntype ArticleItem struct {\n\tID          string\n\tTitle       string\n\tDetailURL   string\n\tCategory    string\n\tPublishDate string\n\tViewCount   int\n\tSummary     string\n\tImageURL    string\n}\n\n// parseArticleList 解析文章列表\nfunc (p *AikanzyAsyncPlugin) parseArticleList(doc *goquery.Document) []ArticleItem {\n\tvar items []ArticleItem\n\t\n\t// 查找所有文章项\n\tdoc.Find(\"article.post-list.contt.blockimg\").Each(func(i int, s *goquery.Selection) {\n\t\t// 提取详情页链接\n\t\tdetailLink := s.Find(\"a[href]\").First()\n\t\tdetailURL, exists := detailLink.Attr(\"href\")\n\t\tif !exists || detailURL == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 提取文章ID\n\t\tarticleID := p.extractArticleID(detailURL)\n\t\tif articleID == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 提取标题\n\t\ttitle := strings.TrimSpace(s.Find(\"header.entry-header span.entry-title a\").Text())\n\t\t// 移除标题中的HTML标签（如<b>）\n\t\ttitle = p.cleanHTMLTags(title)\n\t\tif title == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 提取分类\n\t\tcategory := strings.TrimSpace(s.Find(\"div.entry-meta > a\").First().Text())\n\t\t\n\t\t// 提取发布日期\n\t\tpublishDate := strings.TrimSpace(s.Find(\"time\").First().Text())\n\t\t\n\t\t// 提取阅读数\n\t\tmetaText := s.Find(\"div.entry-meta\").Text()\n\t\tviewCount := p.extractViewCount(metaText)\n\t\t\n\t\t// 提取摘要\n\t\tsummary := strings.TrimSpace(s.Find(\"div.entry-summary.ss p\").Text())\n\t\tsummary = p.cleanHTMLTags(summary)\n\t\t\n\t\t// 提取缩略图\n\t\timageURL, _ := s.Find(\"img.block-fea\").Attr(\"data-src\")\n\t\t\n\t\titems = append(items, ArticleItem{\n\t\t\tID:          articleID,\n\t\t\tTitle:       title,\n\t\t\tDetailURL:   detailURL,\n\t\t\tCategory:    category,\n\t\t\tPublishDate: publishDate,\n\t\t\tViewCount:   viewCount,\n\t\t\tSummary:     summary,\n\t\t\tImageURL:    imageURL,\n\t\t})\n\t})\n\t\n\treturn items\n}\n\n// fetchDetailsWithLinks 并发抓取详情页获取网盘链接\nfunc (p *AikanzyAsyncPlugin) fetchDetailsWithLinks(items []ArticleItem, client *http.Client, keyword string) []model.SearchResult {\n\t// 创建结果通道和等待组\n\tresultChan := make(chan model.SearchResult, len(items))\n\tvar wg sync.WaitGroup\n\t\n\t// 创建信号量控制并发数\n\tsemaphore := make(chan struct{}, detailConcurrency)\n\t\n\t// 并发处理每个文章项\n\tfor _, item := range items {\n\t\twg.Add(1)\n\t\t\n\t\tgo func(item ArticleItem) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 抓取详情页\n\t\t\tlinks := p.fetchDetailPageLinks(item.DetailURL, client)\n\t\t\t\n\t\t\t// 只有包含链接的结果才添加\n\t\t\tif len(links) > 0 {\n\t\t\t\t// 解析发布时间\n\t\t\t\tpublishTime := p.parsePublishTime(item.PublishDate)\n\t\t\t\t\n\t\t\t\t// 组装内容\n\t\t\t\tvar contentParts []string\n\t\t\t\tif item.Summary != \"\" {\n\t\t\t\t\tcontentParts = append(contentParts, item.Summary)\n\t\t\t\t}\n\t\t\t\tif item.Category != \"\" {\n\t\t\t\t\tcontentParts = append(contentParts, item.Category)\n\t\t\t\t}\n\t\t\t\tif item.PublishDate != \"\" {\n\t\t\t\t\tcontentParts = append(contentParts, item.PublishDate)\n\t\t\t\t}\n\t\t\t\tif item.ViewCount > 0 {\n\t\t\t\t\tcontentParts = append(contentParts, fmt.Sprintf(\"%d阅读\", item.ViewCount))\n\t\t\t\t}\n\t\t\t\tcontent := strings.Join(contentParts, \" | \")\n\t\t\t\t\n\t\t\t\t// 组装标签\n\t\t\t\tvar tags []string\n\t\t\t\tif item.Category != \"\" {\n\t\t\t\t\ttags = append(tags, item.Category)\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tresult := model.SearchResult{\n\t\t\t\t\tUniqueID: fmt.Sprintf(\"aikanzy-%s\", item.ID),\n\t\t\t\t\tTitle:    item.Title,\n\t\t\t\t\tContent:  content,\n\t\t\t\t\tLinks:    links,\n\t\t\t\t\tTags:     tags,\n\t\t\t\t\tChannel:  \"\", // 插件搜索结果Channel为空\n\t\t\t\t\tDatetime: publishTime,\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tresultChan <- result\n\t\t\t}\n\t\t}(item)\n\t}\n\t\n\t// 等待所有goroutine完成\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultChan)\n\t}()\n\t\n\t// 收集所有结果\n\tvar results []model.SearchResult\n\tfor result := range resultChan {\n\t\tresults = append(results, result)\n\t}\n\t\n\treturn results\n}\n\n// fetchDetailPageLinks 抓取详情页的网盘链接\nfunc (p *AikanzyAsyncPlugin) fetchDetailPageLinks(detailURL string, client *http.Client) []model.Link {\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), detailTimeout*time.Second)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"https://www.aikanzy.com/\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\t\n\t// 发送请求（带重试）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查状态码\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil\n\t}\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\t// 提取网盘链接\n\treturn p.extractNetDiskLinks(doc)\n}\n\n// extractNetDiskLinks 从详情页提取网盘链接\nfunc (p *AikanzyAsyncPlugin) extractNetDiskLinks(doc *goquery.Document) []model.Link {\n\tvar links []model.Link\n\tfoundURLs := make(map[string]bool) // 用于去重\n\t\n\t// 方法1: 从<a>标签的href属性提取\n\tdoc.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) {\n\t\thref, exists := s.Attr(\"href\")\n\t\tif !exists || href == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 去重\n\t\tif foundURLs[href] {\n\t\t\treturn\n\t\t}\n\t\tfoundURLs[href] = true\n\t\t\n\t\t// 确定链接类型\n\t\tlinkType := p.determineLinkType(href)\n\t\tif linkType == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\tlinks = append(links, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      href,\n\t\t\tPassword: p.extractPassword(href),\n\t\t})\n\t})\n\t\n\t// 方法2: 从页面HTML文本中提取（正则表达式）\n\tif len(links) == 0 {\n\t\thtml, _ := doc.Html()\n\t\t\n\t\t// 提取夸克网盘链接\n\t\tquarkLinks := quarkLinkRegex.FindAllString(html, -1)\n\t\tfor _, link := range quarkLinks {\n\t\t\tif !foundURLs[link] {\n\t\t\t\tfoundURLs[link] = true\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tType:     \"quark\",\n\t\t\t\t\tURL:      link,\n\t\t\t\t\tPassword: p.extractPassword(link),\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 提取UC网盘链接\n\t\tucLinks := ucLinkRegex.FindAllString(html, -1)\n\t\tfor _, link := range ucLinks {\n\t\t\tif !foundURLs[link] {\n\t\t\t\tfoundURLs[link] = true\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tType:     \"uc\",\n\t\t\t\t\tURL:      link,\n\t\t\t\t\tPassword: p.extractPassword(link),\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 提取百度网盘链接\n\t\tbaiduLinks := baiduLinkRegex.FindAllString(html, -1)\n\t\tfor _, link := range baiduLinks {\n\t\t\tif !foundURLs[link] {\n\t\t\t\tfoundURLs[link] = true\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tType:     \"baidu\",\n\t\t\t\t\tURL:      link,\n\t\t\t\t\tPassword: p.extractPassword(link),\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 提取迅雷网盘链接\n\t\txunleiLinks := xunleiLinkRegex.FindAllString(html, -1)\n\t\tfor _, link := range xunleiLinks {\n\t\t\tif !foundURLs[link] {\n\t\t\t\tfoundURLs[link] = true\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tType:     \"xunlei\",\n\t\t\t\t\tURL:      link,\n\t\t\t\t\tPassword: p.extractPassword(link),\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\t\n\treturn links\n}\n\n// determineLinkType 根据URL确定链接类型\nfunc (p *AikanzyAsyncPlugin) determineLinkType(urlStr string) string {\n\tlowerURL := strings.ToLower(urlStr)\n\t\n\tswitch {\n\tcase strings.Contains(lowerURL, \"pan.quark.cn\"):\n\t\treturn \"quark\"\n\tcase strings.Contains(lowerURL, \"drive.uc.cn\"):\n\t\treturn \"uc\"\n\tcase strings.Contains(lowerURL, \"pan.baidu.com\"):\n\t\treturn \"baidu\"\n\tcase strings.Contains(lowerURL, \"pan.xunlei.com\"):\n\t\treturn \"xunlei\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// extractArticleID 从URL中提取文章ID\nfunc (p *AikanzyAsyncPlugin) extractArticleID(urlStr string) string {\n\tmatches := articleIDRegex.FindStringSubmatch(urlStr)\n\tif len(matches) >= 3 {\n\t\treturn matches[2] // 返回数字ID\n\t}\n\treturn \"\"\n}\n\n// extractViewCount 提取阅读数\nfunc (p *AikanzyAsyncPlugin) extractViewCount(text string) int {\n\tmatches := viewCountRegex.FindStringSubmatch(text)\n\tif len(matches) >= 2 {\n\t\tvar count int\n\t\tfmt.Sscanf(matches[1], \"%d\", &count)\n\t\treturn count\n\t}\n\treturn 0\n}\n\n// cleanHTMLTags 清除HTML标签\nfunc (p *AikanzyAsyncPlugin) cleanHTMLTags(text string) string {\n\t// 移除<b>标签\n\ttext = regexp.MustCompile(`<b[^>]*>`).ReplaceAllString(text, \"\")\n\ttext = regexp.MustCompile(`</b>`).ReplaceAllString(text, \"\")\n\t\n\t// 移除其他常见HTML标签\n\ttext = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(text, \"\")\n\t\n\treturn strings.TrimSpace(text)\n}\n\n// parsePublishTime 解析发布时间\nfunc (p *AikanzyAsyncPlugin) parsePublishTime(dateStr string) time.Time {\n\tdateStr = strings.TrimSpace(dateStr)\n\tif dateStr == \"\" {\n\t\treturn time.Time{}\n\t}\n\t\n\t// 尝试多种日期格式\n\tformats := []string{\n\t\t\"2006-01-02\",\n\t\t\"2006-01-02 15:04:05\",\n\t\t\"2006-01-02T15:04:05Z\",\n\t\t\"2006-01-02T15:04:05+08:00\",\n\t\t\"2006-01-02T15:04:05-07:00\",\n\t}\n\t\n\tfor _, format := range formats {\n\t\tif t, err := time.Parse(format, dateStr); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\t\n\t// 如果以上格式都不匹配，尝试使用time.RFC3339格式（处理<time>标签的datetime属性）\n\tif t, err := time.Parse(time.RFC3339, dateStr); err == nil {\n\t\treturn t\n\t}\n\t\n\treturn time.Time{}\n}\n\n// extractPassword 从网盘链接中提取密码\nfunc (p *AikanzyAsyncPlugin) extractPassword(urlStr string) string {\n\t// 从URL中提取pwd=后面的四位密码(不包含#)\n\tpwdRegex := regexp.MustCompile(`pwd=([^#&]{4})`)\n\tmatches := pwdRegex.FindStringSubmatch(urlStr)\n\tif len(matches) >= 2 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// doRequestWithRetry 发送HTTP请求，带重试机制\nfunc (p *AikanzyAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tvar resp *http.Response\n\tvar err error\n\t\n\tfor retry := 0; retry <= maxRetries; retry++ {\n\t\tif retry > 0 {\n\t\t\t// 指数退避\n\t\t\tbackoffTime := time.Duration(1<<uint(retry-1)) * backoffBase * time.Millisecond\n\t\t\ttime.Sleep(backoffTime)\n\t\t\t\n\t\t\t// 克隆请求\n\t\t\treq = req.Clone(req.Context())\n\t\t}\n\t\t\n\t\tresp, err = client.Do(req)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, err)\n}\n"
  },
  {
    "path": "plugin/alupan/alupan.go",
    "content": "package alupan\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nvar (\n\tarticleIDRegex = regexp.MustCompile(`\\?p=(\\d+)`)\n\n\tlinkPatterns = []struct {\n\t\treg *regexp.Regexp\n\t\ttyp string\n\t}{\n\t\t{regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9A-Za-z]+`), \"quark\"},\n\t\t{regexp.MustCompile(`https?://www\\.aliyundrive\\.com/s/[0-9A-Za-z]+`), \"aliyun\"},\n\t\t{regexp.MustCompile(`https?://www\\.aliyundrive\\.com/drive/folder/[0-9A-Za-z]+`), \"aliyun\"},\n\t}\n\n\tpwdPatterns = []*regexp.Regexp{\n\t\tregexp.MustCompile(`提取码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`密码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`pwd\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`code\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t}\n\n\tdetailCache          = sync.Map{}\n\tcacheTTL             = 1 * time.Hour\n\tcacheCleanupInterval = 30 * time.Minute\n)\n\ntype cacheEntry struct {\n\tlinks     []model.Link\n\texpiresAt time.Time\n}\n\nconst (\n\tpluginName            = \"alupan\"\n\tdefaultPriority       = 2\n\tsearchTimeout         = 12 * time.Second\n\tdetailTimeout         = 10 * time.Second\n\tmaxConcurrency        = 12\n\tmaxIdleConns          = 64\n\tmaxIdlePerHost        = 16\n\tmaxConnsPerHost       = 32\n\tidleConnLifetime      = 90 * time.Second\n\ttlsHandshakeTimeout   = 10 * time.Second\n\texpectContinueTimeout = 1 * time.Second\n\n\tsearchMaxRetries = 3\n\tdetailMaxRetries = 2\n\tretryBaseDelay   = 200 * time.Millisecond\n)\n\n// AlupanPlugin 搜索插件\ntype AlupanPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tclient *http.Client\n}\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewAlupanPlugin())\n\tgo startCacheCleaner()\n}\n\n// NewAlupanPlugin 创建插件\nfunc NewAlupanPlugin() *AlupanPlugin {\n\treturn &AlupanPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),\n\t\tclient:          newHTTPClient(),\n\t}\n}\n\n// Search 兼容方法\nfunc (p *AlupanPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 主搜索方法\nfunc (p *AlupanPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\nfunc newHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:          maxIdleConns,\n\t\tMaxIdleConnsPerHost:   maxIdlePerHost,\n\t\tMaxConnsPerHost:       maxConnsPerHost,\n\t\tIdleConnTimeout:       idleConnLifetime,\n\t\tTLSHandshakeTimeout:   tlsHandshakeTimeout,\n\t\tExpectContinueTimeout: expectContinueTimeout,\n\t\tForceAttemptHTTP2:     true,\n\t}\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   searchTimeout,\n\t}\n}\n\nfunc (p *AlupanPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif p.client != nil {\n\t\tclient = p.client\n\t}\n\n\tsearchURL := fmt.Sprintf(\"https://www.aliupan.com/?s=%s\", url.QueryEscape(keyword))\n\tctx, cancel := context.WithTimeout(context.Background(), searchTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\n\tsetCommonHeaders(req, \"https://www.aliupan.com/\")\n\n\tresp, err := p.doRequestWithRetry(req, client, searchMaxRetries)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\n\tvar (\n\t\tresults []model.SearchResult\n\t\twg      sync.WaitGroup\n\t\tmu      sync.Mutex\n\t\tsem     = make(chan struct{}, maxConcurrency)\n\t)\n\n\tdoc.Find(\"article.excerpt\").Each(func(_ int, item *goquery.Selection) {\n\t\ttitleSel := item.Find(\"header h2 a\")\n\t\ttitle := strings.TrimSpace(titleSel.Text())\n\t\tdetailURL, ok := titleSel.Attr(\"href\")\n\t\tif !ok || title == \"\" || detailURL == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tarticleID := extractArticleID(detailURL)\n\t\tif articleID == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tcategory := strings.TrimSpace(item.Find(\"header .label\").First().Text())\n\t\tvar tags []string\n\t\tif category != \"\" {\n\t\t\ttags = append(tags, category)\n\t\t}\n\n\t\tsummary := strings.TrimSpace(item.Find(\"p.note\").Text())\n\t\ttimeText := strings.TrimSpace(item.Find(\"p .icon-time\").Parent().Text())\n\t\tpublishTime := parsePublishTime(timeText)\n\n\t\twg.Add(1)\n\t\tsem <- struct{}{}\n\t\tgo func(title, detailURL, summary string, tags []string, publish time.Time, articleID string) {\n\t\t\tdefer wg.Done()\n\t\t\tdefer func() { <-sem }()\n\n\t\t\tlinks := p.fetchDetailLinks(client, detailURL, articleID)\n\t\t\tif len(links) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tresult := model.SearchResult{\n\t\t\t\tUniqueID: fmt.Sprintf(\"%s-%s\", p.Name(), articleID),\n\t\t\t\tTitle:    title,\n\t\t\t\tContent:  summary,\n\t\t\t\tLinks:    links,\n\t\t\t\tTags:     tags,\n\t\t\t\tChannel:  \"\",\n\t\t\t\tDatetime: publish,\n\t\t\t}\n\n\t\t\tmu.Lock()\n\t\t\tresults = append(results, result)\n\t\t\tmu.Unlock()\n\t\t}(title, detailURL, summary, tags, publishTime, articleID)\n\t})\n\n\twg.Wait()\n\n\treturn plugin.FilterResultsByKeyword(results, keyword), nil\n}\n\nfunc extractArticleID(detailURL string) string {\n\tif matches := articleIDRegex.FindStringSubmatch(detailURL); len(matches) >= 2 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\nfunc parsePublishTime(value string) time.Time {\n\tvalue = strings.TrimSpace(value)\n\tif value == \"\" {\n\t\treturn time.Now()\n\t}\n\n\tif idx := strings.Index(value, \"(\"); idx >= 0 && strings.HasSuffix(value, \")\") {\n\t\tvalue = value[idx+1 : len(value)-1]\n\t\tvalue = strings.TrimSpace(value)\n\t}\n\n\tlayouts := []string{\n\t\t\"2006-01-02\",\n\t\t\"2006/01/02\",\n\t\t\"2006年01月02日\",\n\t\ttime.RFC3339,\n\t}\n\n\tfor _, layout := range layouts {\n\t\tif t, err := time.Parse(layout, value); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\n\treturn time.Now()\n}\n\nfunc (p *AlupanPlugin) fetchDetailLinks(client *http.Client, detailURL, articleID string) []model.Link {\n\tif cached, ok := detailCache.Load(articleID); ok {\n\t\tif entry, valid := cached.(cacheEntry); valid {\n\t\t\tif time.Now().Before(entry.expiresAt) && len(entry.links) > 0 {\n\t\t\t\treturn entry.links\n\t\t\t}\n\t\t\tdetailCache.Delete(articleID)\n\t\t}\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), detailTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tsetCommonHeaders(req, detailURL)\n\n\tresp, err := p.doRequestWithRetry(req, client, detailMaxRetries)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\tlinks := extractNetDiskLinks(doc)\n\tif len(links) > 0 {\n\t\tdetailCache.Store(articleID, cacheEntry{\n\t\t\tlinks:     links,\n\t\t\texpiresAt: time.Now().Add(cacheTTL),\n\t\t})\n\t}\n\treturn links\n}\n\nfunc extractNetDiskLinks(doc *goquery.Document) []model.Link {\n\tvar (\n\t\tresults []model.Link\n\t\tseen    = make(map[string]struct{})\n\t)\n\n\tdoc.Find(\".article-content a[href]\").Each(func(_ int, node *goquery.Selection) {\n\t\thref, exists := node.Attr(\"href\")\n\t\tif !exists {\n\t\t\treturn\n\t\t}\n\t\thref = strings.TrimSpace(href)\n\t\tif href == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tlinkType, normalized := classifyLink(href)\n\t\tif linkType == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tif _, found := seen[normalized]; found {\n\t\t\treturn\n\t\t}\n\n\t\tpassword := extractPassword(node)\n\n\t\tresults = append(results, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      normalized,\n\t\t\tPassword: password,\n\t\t})\n\t\tseen[normalized] = struct{}{}\n\t})\n\n\treturn results\n}\n\nfunc classifyLink(raw string) (string, string) {\n\tfor _, pattern := range linkPatterns {\n\t\tif loc := pattern.reg.FindString(raw); loc != \"\" {\n\t\t\treturn pattern.typ, loc\n\t\t}\n\t}\n\treturn \"\", \"\"\n}\n\nfunc extractPassword(link *goquery.Selection) string {\n\tcandidates := []string{\n\t\tlink.Text(),\n\t}\n\n\tif title, ok := link.Attr(\"title\"); ok {\n\t\tcandidates = append(candidates, title)\n\t}\n\n\tif parent := link.Parent(); parent != nil && parent.Length() > 0 {\n\t\tcandidates = append(candidates, parent.Text())\n\t\tif next := parent.Next(); next.Length() > 0 {\n\t\t\tcandidates = append(candidates, next.Text())\n\t\t}\n\t}\n\n\tif next := link.Next(); next.Length() > 0 {\n\t\tcandidates = append(candidates, next.Text())\n\t}\n\n\tfor _, text := range candidates {\n\t\tif pwd := matchPassword(text); pwd != \"\" {\n\t\t\treturn pwd\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc matchPassword(text string) string {\n\ttext = strings.TrimSpace(text)\n\tif text == \"\" {\n\t\treturn \"\"\n\t}\n\n\tfor _, pattern := range pwdPatterns {\n\t\tif matches := pattern.FindStringSubmatch(text); len(matches) >= 2 {\n\t\t\treturn strings.TrimSpace(matches[1])\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc setCommonHeaders(req *http.Request, referer string) {\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", referer)\n}\n\nfunc (p *AlupanPlugin) doRequestWithRetry(req *http.Request, client *http.Client, maxRetries int) (*http.Response, error) {\n\tvar lastErr error\n\n\tfor attempt := 0; attempt < maxRetries; attempt++ {\n\t\tresp, err := client.Do(req.Clone(req.Context()))\n\t\tif err == nil && resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t\tif attempt < maxRetries-1 {\n\t\t\tbackoff := retryBaseDelay * time.Duration(1<<attempt)\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"重试 %d 次后失败: %w\", maxRetries, lastErr)\n}\n\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(cacheCleanupInterval)\n\tdefer ticker.Stop()\n\n\tfor range ticker.C {\n\t\tnow := time.Now()\n\t\tdetailCache.Range(func(key, value interface{}) bool {\n\t\t\tentry, ok := value.(cacheEntry)\n\t\t\tif !ok || now.After(entry.expiresAt) {\n\t\t\t\tdetailCache.Delete(key)\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "plugin/alupan/html结构分析.md",
    "content": "# alupan (阿里U盘) HTML结构分析\n\n## 网站信息\n- **站点名称**: 阿里U盘\n- **域名**: `www.aliupan.com`\n- **类型**: 影视/图书等资源聚合站（WordPress D8 主题）\n- **特点**: 搜索结果页按文章列表展示，详情页正文直接给出阿里云盘/夸克网盘链接，文章数量大、分类细\n\n## 搜索/列表页\n\n### 1. 请求入口\n```\nhttps://www.aliupan.com/?s={关键词}\n```\n- 关键词直接 UTF-8；无需额外参数\n- 返回 WordPress 搜索结果页（带 `archive-header`）\n\n### 2. 结果容器\n- 外层：`section.container > .content-wrap > .content`\n- 列表项：`article.excerpt`（常见类名 `excerpt-titletype`）\n\n### 3. 单条记录\n```html\n<article class=\"excerpt excerpt-titletype\">\n  <div class=\"focus\">\n    <a href=\"https://www.aliupan.com/?p=7078\" class=\"thumbnail\">\n      <img src=\"...\" alt=\"[阿里云盘][夸克网盘]《遮天》（2023年）\" />\n    </a>\n  </div>\n  <header>\n    <a class=\"label label-important\" href=\"https://www.aliupan.com/?cat=19\">中国内地电视剧<i class=\"label-arrow\"></i></a>\n    <h2>\n      <a href=\"https://www.aliupan.com/?p=7078\" title=\"...\">[阿里云盘][夸克网盘]《遮天》（2023年）</a>\n    </h2>\n  </header>\n  <p>\n    <span class=\"muted\"><i class=\"icon-user\"></i><a href=\"...\">阿里U盘</a></span>\n    <span class=\"muted\"><i class=\"icon-time\"></i> 1年前 (2024-07-27)</span>\n    <span class=\"muted\"><i class=\"icon-eye-open\"></i> 745浏览</span>\n    <span class=\"muted\"><i class=\"icon-comment\"></i><a href=\"...\">0评论</a></span>\n  </p>\n  <p class=\"note\">……摘要文本……</p>\n</article>\n```\n\n#### 需要提取的字段\n- **标题**: `h2 a` 文本\n- **详情链接**: `h2 a[href]`\n- **分类**: `.label.label-important` 文本（可作为 `Tags` 之一）\n- **发布日期**: `p > span .icon-time` 所在 `<span>`，格式通常为 `1年前 (2024-07-27)`；取括号内日期\n- **摘要**: `p.note`\n- **封面**: `div.focus img[src]`（仅用于调试，不需要在结果中返回）\n\n### 4. 分页\n- 搜索页默认返回全部匹配列表，可根据需要继续解析分页链接（一般抓取第一页即可）。\n\n## 详情页\n\n### 1. URL 规则\n```\nhttps://www.aliupan.com/?p={文章ID}\n```\n- `文章ID` 来自列表页 URL，可直接作为唯一标识。\n\n### 2. 主体定位\n- 标题：`.article-header .article-title a`\n- 元信息：`.meta`（含分类、作者、时间、阅读）\n- 正文：`article.article-content`\n\n### 3. 下载链接形态\n正文中使用普通段落给出下载地址：\n```html\n<p>阿里云盘丨遮天：<a href=\"https://www.aliyundrive.com/s/xxxx\" target=\"_blank\" rel=\"nofollow\">https://www.aliyundrive.com/s/xxxx</a></p>\n<p>夸克网盘丨遮天：<a href=\"https://pan.quark.cn/s/5ad996dc0725\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">https://pan.quark.cn/s/5ad996dc0725</a></p>\n```\n- 个别文章会出现“待补”等文字；只返回真正包含链接的 `<a>`。\n- 可能同文提供多个链接（夸克 / 阿里云盘 / 其他），需要全部收集。\n- 提取码通常写在同一段落文本里，形如 `提取码：xxxx`、`密码：xxxx` 等。\n\n### 4. 支持的网盘域名\n- **阿里云盘**: `https://www.aliyundrive.com/s/`、`https://www.aliyundrive.com/drive/folder/`\n- **夸克网盘**: `https://pan.quark.cn/s/`\n- 可根据站点实际扩展（如出现 `pan.baidu.com` 等）\n\n## CSS 选择器速览\n\n| 数据项 | 选择器/规则 |\n|--------|-------------|\n| 列表项 | `article.excerpt` |\n| 标题 & 链接 | `article.excerpt h2 a` |\n| 分类标签 | `article.excerpt header .label` |\n| 摘要 | `article.excerpt p.note` |\n| 发布时间 | `article.excerpt p .icon-time` 所在 `<span>`；取括号中的日期 |\n| 正文容器 | `article.article-content` |\n| 网盘链接 | `.article-content a[href*=\"pan.quark.cn\"]`、`a[href*=\"aliyundrive.com\"]` 等 |\n\n## 提取策略\n1. **搜索页**  \n   - 构建 `https://www.aliupan.com/?s=keyword`，使用浏览器 UA、防爬 Header。\n   - 解析 `article.excerpt`，抓取基本元信息。\n   - 由 `?p={id}` 提取 ID，构建唯一键 `alupan-{id}`。\n\n2. **详情页**  \n   - 访问正文 `.article-content`。\n   - 遍历所有 `<a>`，通过域名判断网盘类型。\n   - 在链接文本或父级文本中搜索提取码关键词（`提取码/密码/pwd/code`）。\n   - 多个链接去重（同地址只保留一次）。\n\n3. **时间解析**  \n   - 优先解析括号内日期（`YYYY-MM-DD`）。  \n   - 若无括号，只能是 `YYYY-MM-DD` 或 `YYYY年MM月DD日`，按常见格式匹配；失败则用当前时间。\n\n4. **性能优化建议**\n   - 统一使用定制 `http.Client`（连接池 + TLS/Expect 超时 + HTTP/2）。\n   - 搜索与详情请求加入指数退避重试（至少 2~3 次）。\n   - 对详情解析结果加 TTL 缓存（例如 1 小时），避免重复抓取。\n   - 使用信号量控制同时抓取的详情页数量，推荐 10~15。\n\n## 示例数据流\n```\n1. 请求 https://www.aliupan.com/?s=遮天\n2. 列表项：\n   - 标题: [阿里云盘][夸克网盘]《遮天》（2023年）\n   - 分类: 中国内地电视剧\n   - 日期: 1年前 (2024-07-27)\n   - 摘要: 阿里云盘丨遮天：待补 夸克网盘丨遮天：https://pan.quark.cn/...\n   - 详情: https://www.aliupan.com/?p=7078\n3. 详情解析：\n   - `https://pan.quark.cn/s/5ad996dc0725`\n4. 构建结果：\n   UniqueID: alupan-7078  \n   Title: [阿里云盘][夸克网盘]《遮天》（2023年）  \n   Links: [{Type:\"quark\", URL:\"https://pan.quark.cn/s/5ad996dc0725\", Password:\"\"}]  \n   Tags: [\"中国内地电视剧\"]  \n   Datetime: 2024-07-27T00:00:00+08:00\n```\n\n## 注意事项\n1. **摘要中的裸链**：虽然摘要有时包含 URL，但仍应以详情页数据为准。\n2. **缺失链接**：如果正文中没有有效网盘链接（例如“待补”），忽略该文章。\n3. **多链接**：同一篇可能同时提供阿里云盘与夸克链接，均需返回。\n4. **缓存**：文章更新较频繁，建议缓存加入 TTL，并定时清理。\n5. **编码**：站点内容大量中文，解析时确保使用 UTF-8。\n\n"
  },
  {
    "path": "plugin/ash/ash.go",
    "content": "package ash\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\ntype AshPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\nconst (\n\t// 错误的夸克域名\n\twrongQuarkDomain = \"pan.qualk.cn\"\n\t// 正确的夸克域名\n\tcorrectQuarkDomain = \"pan.quark.cn\"\n)\n\nvar (\n\t// 提取JSON数据的正则表达式（预编译）\n\tjsonDataRegex = regexp.MustCompile(`var jsonData = '(\\[.*?\\])';`)\n\t\n\t// 控制字符清理正则（预编译）\n\tcontrolCharRegex = regexp.MustCompile(`[\\x00-\\x1F\\x7F]`)\n)\n\n// AshResult 表示ASH搜索结果的数据结构\ntype AshResult struct {\n\tID               int         `json:\"id\"`\n\tSourceCategoryID int         `json:\"source_category_id\"`\n\tTitle            string      `json:\"title\"`\n\tIsType           int         `json:\"is_type\"`\n\tCode             interface{} `json:\"code\"` // 可能是null或string\n\tURL              string      `json:\"url\"`\n\tIsTime           int         `json:\"is_time\"`\n\tName             string      `json:\"name\"`\n\tTimes            string      `json:\"times\"`\n\tCategory         interface{} `json:\"category\"` // 可能是null或string\n}\n\nfunc init() {\n\tp := &AshPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"ash\", 2), // 优先级2，质量良好的影视资源\n\t}\n\tplugin.RegisterGlobalPlugin(p)\n}\n\n// Search 执行搜索并返回结果\nfunc (p *AshPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *AshPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实际的搜索实现（优化版本）\nfunc (p *AshPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"https://so.allsharehub.com/s/%s.html\", url.QueryEscape(keyword))\n\t\n\t// 创建带超时的上下文（减少超时时间，提高响应速度）\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 设置请求头\n\tp.setRequestHeaders(req)\n\t\n\t// 发送请求（优化重试）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 读取响应（使用有限制的读取，避免读取过大内容）\n\t// ASH页面通常不会太大，限制在2MB以内\n\tlimitReader := io.LimitReader(resp.Body, 2*1024*1024)\n\tbody, err := io.ReadAll(limitReader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 读取响应失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 从HTML中提取JSON数据（直接传递字节，避免字符串转换）\n\tresults, err := p.extractResultsFromBytes(body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 提取搜索结果失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 关键词过滤\n\tfiltered := plugin.FilterResultsByKeyword(results, keyword)\n\t\n\treturn filtered, nil\n}\n\n// extractResultsFromBytes 从字节数组中提取搜索结果（优化版本，避免字符串转换）\nfunc (p *AshPlugin) extractResultsFromBytes(data []byte) ([]model.SearchResult, error) {\n\t// 直接在字节数组中查找JSON数据（避免转换为字符串）\n\thtml := string(data) // 只转换一次\n\t\n\t// 查找JSON数据\n\tmatches := jsonDataRegex.FindStringSubmatch(html)\n\tif len(matches) < 2 {\n\t\treturn []model.SearchResult{}, nil // 没有找到数据，返回空结果\n\t}\n\t\n\t// 提取JSON字符串\n\tjsonStr := matches[1]\n\t\n\t// 清理JSON字符串（批量操作，减少内存分配）\n\tif strings.Contains(jsonStr, \"\\\\/\") {\n\t\tjsonStr = strings.ReplaceAll(jsonStr, \"\\\\/\", \"/\")\n\t}\n\tjsonStr = controlCharRegex.ReplaceAllString(jsonStr, \"\")\n\t\n\t// 解析JSON - 使用高性能的sonic库\n\tvar ashResults []AshResult\n\tif err := json.Unmarshal([]byte(jsonStr), &ashResults); err != nil {\n\t\treturn nil, fmt.Errorf(\"JSON解析失败: %w\", err)\n\t}\n\t\n\t// 如果没有结果，直接返回\n\tif len(ashResults) == 0 {\n\t\treturn []model.SearchResult{}, nil\n\t}\n\t\n\t// 预分配切片容量，避免动态扩容\n\tresults := make([]model.SearchResult, 0, len(ashResults))\n\t\n\t// 批量处理所有结果\n\tfor i := range ashResults {\n\t\titem := &ashResults[i]\n\t\t\n\t\t// 提前检查URL是否有效，避免无效处理\n\t\tif item.URL == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 处理网盘链接\n\t\tpanURL := p.fixPanURL(item.URL)\n\t\tif panURL == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 确定网盘类型（内联优化）\n\t\tvar panType string\n\t\tswitch item.IsType {\n\t\tcase 0:\n\t\t\tpanType = \"quark\"\n\t\tcase 2:\n\t\t\tpanType = \"baidu\"\n\t\tcase 3:\n\t\t\tpanType = \"uc\"\n\t\tcase 4:\n\t\t\tpanType = \"xunlei\"\n\t\tdefault:\n\t\t\tpanType = \"quark\"\n\t\t}\n\t\t\n\t\t// 处理提取码\n\t\tvar password string\n\t\tif item.Code != nil {\n\t\t\tif codeStr, ok := item.Code.(string); ok && codeStr != \"\" {\n\t\t\t\tpassword = codeStr\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 解析时间\n\t\tvar datetime time.Time\n\t\tif item.Times != \"\" {\n\t\t\tif parsedTime, err := time.Parse(\"2006-01-02\", item.Times); err == nil {\n\t\t\t\tdatetime = parsedTime\n\t\t\t} else {\n\t\t\t\tdatetime = time.Now()\n\t\t\t}\n\t\t} else {\n\t\t\tdatetime = time.Now()\n\t\t}\n\t\t\n\t\t// 获取标签\n\t\tvar tags []string\n\t\tif item.SourceCategoryID > 0 && item.SourceCategoryID <= 6 {\n\t\t\tcategoryNames := [...]string{\"短剧\", \"电影\", \"电视剧\", \"动漫\", \"综艺\", \"充电视频\"}\n\t\t\ttags = []string{categoryNames[item.SourceCategoryID-1]}\n\t\t}\n\t\t\n\t\t// 构建搜索结果\n\t\tresults = append(results, model.SearchResult{\n\t\t\tUniqueID: fmt.Sprintf(\"%s-%d\", p.Name(), item.ID),\n\t\t\tTitle:    item.Title,\n\t\t\tContent:  item.Name,\n\t\t\tDatetime: datetime,\n\t\t\tChannel:  \"\",\n\t\t\tLinks: []model.Link{{\n\t\t\t\tType:     panType,\n\t\t\t\tURL:      panURL,\n\t\t\t\tPassword: password,\n\t\t\t}},\n\t\t\tTags: tags,\n\t\t})\n\t}\n\t\n\treturn results, nil\n}\n\n// fixPanURL 修复网盘链接 - 关键功能！（优化版本）\nfunc (p *AshPlugin) fixPanURL(url string) string {\n\t// 快速检查是否为有效的HTTP/HTTPS链接\n\tif len(url) < 8 { // 最短的URL: http://a\n\t\treturn \"\"\n\t}\n\t\n\t// 验证链接协议（使用更快的检查方式）\n\tif url[0] != 'h' || (url[4] != ':' && url[5] != ':') {\n\t\treturn \"\"\n\t}\n\t\n\t// 只在包含错误域名时才进行替换，避免不必要的字符串操作\n\tif strings.Contains(url, wrongQuarkDomain) {\n\t\treturn strings.Replace(url, wrongQuarkDomain, correctQuarkDomain, 1)\n\t}\n\t\n\treturn url\n}\n\n// setRequestHeaders 设置请求头\nfunc (p *AshPlugin) setRequestHeaders(req *http.Request) {\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Referer\", \"https://so.allsharehub.com/\")\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求（优化版本）\nfunc (p *AshPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 2 // 减少重试次数，提高响应速度\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 更短的退避时间\n\t\t\tbackoff := time.Duration(100<<uint(i-1)) * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求（只在重试时克隆）\n\t\tvar reqToUse *http.Request\n\t\tif i == 0 {\n\t\t\treqToUse = req\n\t\t} else {\n\t\t\treqToUse = req.Clone(req.Context())\n\t\t}\n\t\t\n\t\tresp, err := client.Do(reqToUse)\n\t\t\n\t\t// 成功返回\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\t// 清理响应\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\t\n\t\tlastErr = err\n\t\t\n\t\t// 如果是上下文取消或超时，不再重试\n\t\tif req.Context().Err() != nil {\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\tif lastErr != nil {\n\t\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败\", maxRetries)\n}\n\n"
  },
  {
    "path": "plugin/ash/html结构分析.md",
    "content": "# ASH搜剧助手 HTML结构分析\n\n## 网站信息\n- **网站名称**: ASH搜剧助手\n- **域名**: so.allsharehub.com\n- **类型**: 影视资源搜索引擎\n- **特点**: 专门搜索影视剧资源，主要提供夸克网盘链接\n- **搜索模式**: 本地搜索（从网站数据库查询，不使用全网搜）\n\n## 搜索页面结构\n\n### 1. 搜索URL模式\n```\nhttps://so.allsharehub.com/s/[关键词].html\n\n示例:\nhttps://so.allsharehub.com/s/%E4%BB%99%E9%80%86.html\n\n参数说明:\n- 关键词: URL编码的搜索关键词\n- 支持分页: /s/[关键词]-[页码].html\n- 支持分类: /s/[关键词]-[页码]-[分类ID].html\n```\n\n### 2. 数据提取方式\n\n#### JavaScript数据源（唯一方式）\n搜索结果嵌入在页面JavaScript变量中（本地搜索数据）：\n```javascript\nvar jsonData = '[{\"id\":987,\"source_category_id\":0,\"title\":\"仙逆剧场版神临之战4K完整版\",\"is_type\":0,\"code\":null,\"url\":\"https://pan.qualk.cn/s/095628b04e6c\",\"is_time\":0,\"name\":\"仙逆剧场版神临之战4K完整版\",\"times\":\"2025-08-31\",\"category\":null}]';\n```\n\n**注意**: \n- 只使用本地搜索数据（currentSource === 0）\n- 不需要处理全网搜的SSE流式数据（currentSource === 1）\n\n### 3. 数据字段说明\n\n| 字段 | 类型 | 说明 | 示例 |\n|------|------|------|------|\n| `id` | number | 资源ID | 987 |\n| `source_category_id` | number | 分类ID | 0 |\n| `title` | string | 资源标题 | \"仙逆剧场版神临之战4K完整版\" |\n| `is_type` | number | 网盘类型 (0=夸克) | 0 |\n| `code` | string/null | 提取码 | null 或 \"1234\" |\n| `url` | string | 网盘链接 | \"https://pan.qualk.cn/s/095628b04e6c\" |\n| `is_time` | number | 时间标记 | 0 |\n| `name` | string | 资源名称 | \"仙逆剧场版神临之战4K完整版\" |\n| `times` | string | 发布时间 | \"2025-08-31\" |\n| `category` | string/null | 分类 | null |\n\n### 4. HTML结构（备用方式）\n\n#### 搜索结果容器\n- **父容器**: `.listBox .left .box .list`\n- **结果项**: `.item` (每个搜索结果)\n\n#### 单个搜索结果结构\n```html\n<div class=\"item\">\n    <!-- 标题 -->\n    <a href=\"javascript:;\" onclick=\"linkBtn(this)\" data-index=\"0\" class=\"title\">\n        仙逆剧场版神临之战4K完整版\n    </a>\n    \n    <!-- 发布时间 -->\n    <div class=\"type time\">2025-08-31</div>\n    \n    <!-- 来源 -->\n    <div class=\"type\">\n        <span>来源：夸克网盘</span>\n    </div>\n    \n    <!-- 操作按钮 -->\n    <div class=\"btns\">\n        <div class=\"btn\" @click.stop=\"copyText(...)\">\n            <i class=\"iconfont icon-fenxiang1\"></i>复制分享\n        </div>\n        <a href=\"/d/987.html\" class=\"btn\">\n            <i class=\"iconfont icon-fangwen\"></i>查看详情\n        </a>\n        <a href=\"javascript:;\" onclick=\"linkBtn(this)\" data-index=\"0\" class=\"btn\">\n            立即访问\n        </a>\n    </div>\n</div>\n```\n\n## 重要实现要点\n\n### 1. 网盘链接转换 ⭐ 非常重要\n页面返回的链接使用错误的域名，必须进行转换：\n```\n原始链接: https://pan.qualk.cn/s/095628b04e6c\n正确链接: https://pan.quark.cn/s/095628b04e6c\n\n转换规则: 将 \"pan.qualk.cn\" 替换为 \"pan.quark.cn\"\n```\n\n### 2. 数据提取正则表达式\n```go\n// 提取JSON数据\njsonDataRegex := regexp.MustCompile(`var jsonData = '(\\[.*?\\])';`)\n\n// 清理JSON中的控制字符\njsonData = strings.ReplaceAll(jsonData, \"\\\\/\", \"/\")\njsonData = regexp.MustCompile(`[\\x00-\\x1F\\x7F]`).ReplaceAllString(jsonData, \"\")\n```\n\n### 3. 网盘类型映射\n```go\nis_type 值映射:\n0 -> \"quark\" (夸克网盘)\n2 -> \"baidu\" (百度网盘) \n3 -> \"uc\" (UC网盘)\n4 -> \"xunlei\" (迅雷网盘)\n```\n\n### 4. 时间格式\n- 格式: `YYYY-MM-DD`\n- 需要转换为标准时间格式: `time.Parse(\"2006-01-02\", timeStr)`\n\n### 5. 分类信息\n页面支持按分类筛选：\n- 0: 全部\n- 1: 短剧\n- 2: 电影\n- 3: 电视剧\n- 4: 动漫\n- 5: 综艺\n- 6: 充电视频\n\n## CSS选择器总结\n\n| 数据项 | CSS选择器 | 提取方式 |\n|--------|-----------|----------|\n| 搜索结果列表 | `.listBox .left .box .list .item` | 遍历所有结果项 |\n| 标题 | `.item .title` | 文本内容 |\n| 发布时间 | `.item .type.time` | 文本内容 |\n| 来源类型 | `.item .type span` | 文本内容 |\n| 详情页链接 | `.item a[href^=\"/d/\"]` | href 属性 |\n\n## 优先级建议\n- **优先级**: 2-3 (质量良好的影视资源搜索)\n- **跳过Service层过滤**: false (标准中文资源，保持过滤)\n- **缓存TTL**: 2小时\n\n## 搜索策略\n1. 优先使用JavaScript变量提取数据（更快、更准确）\n2. 如果JavaScript解析失败，回退到HTML解析\n3. 必须对所有链接进行域名转换（pan.qualk.cn -> pan.quark.cn）\n4. 只返回包含有效网盘链接的结果\n\n"
  },
  {
    "path": "plugin/bixin/bixin.go",
    "content": "package bixin\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\n// 在init函数中注册插件\nfunc init() {\n\t// 注册插件\n\tplugin.RegisterGlobalPlugin(NewBixinAsyncPlugin())\n}\n\nconst (\n\t// API基础URL\n\tBaseURL = \"https://www.bixbiy.com/api/discussions\"\n\t\n\t// 默认参数\n\tPageSize = 50 // 符合API实际返回数量\n\tMaxRetries = 2\n)\n\n// 常用UA列表\nvar userAgents = []string{\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36\",\n\t\"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\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0\",\n\t\"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\",\n\t\"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36\",\n}\n\n// BixinAsyncPlugin bixin网盘搜索异步插件\ntype BixinAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tretries int\n}\n\n// NewBixinAsyncPlugin 创建新的bixin异步插件\nfunc NewBixinAsyncPlugin() *BixinAsyncPlugin {\n\treturn &BixinAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"bixin\", 3, true), // 跳过Service层过滤\n\t\tretries:         MaxRetries,\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *BixinAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *BixinAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)\n}\n\n// doSearch 实际的搜索实现\nfunc (p *BixinAsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 初始化随机数种子\n\trand.Seed(time.Now().UnixNano())\n\t\n\t// 只并发请求2个页面（0-1页）\n\tallResults, _, err := p.fetchBatch(client, keyword, 0, 2)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\t// 去重\n\tuniqueResults := p.deduplicateResults(allResults)\n\t\n\t// 使用过滤功能过滤结果\n\tfilteredResults := plugin.FilterResultsByKeyword(uniqueResults, keyword)\n\t\n\treturn filteredResults, nil\n}\n\n// fetchBatch 获取一批页面的数据\nfunc (p *BixinAsyncPlugin) fetchBatch(client *http.Client, keyword string, startOffset, pageCount int) ([]model.SearchResult, bool, error) {\n\tvar wg sync.WaitGroup\n\tresultChan := make(chan struct{\n\t\toffset  int\n\t\tresults []model.SearchResult\n\t\thasMore bool\n\t\terr     error\n\t}, pageCount)\n\t\n\t// 并发请求多个页面，但每个请求之间添加随机延迟\n\tfor i := 0; i < pageCount; i++ {\n\t\toffset := (startOffset + i) * PageSize\n\t\twg.Add(1)\n\t\t\n\t\tgo func(offset int, index int) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 第一个请求立即执行，后续请求添加随机延迟\n\t\t\tif index > 0 {\n\t\t\t\t// 随机等待0-1秒\n\t\t\t\trandomDelay := time.Duration(100 + rand.Intn(900)) * time.Millisecond\n\t\t\t\ttime.Sleep(randomDelay)\n\t\t\t}\n\t\t\t\n\t\t\t// 请求特定页面\n\t\t\tresults, hasMore, err := p.fetchPage(client, keyword, offset)\n\t\t\t\n\t\t\tresultChan <- struct{\n\t\t\t\toffset  int\n\t\t\t\tresults []model.SearchResult\n\t\t\t\thasMore bool\n\t\t\t\terr     error\n\t\t\t}{\n\t\t\t\toffset:  offset,\n\t\t\t\tresults: results,\n\t\t\t\thasMore: hasMore,\n\t\t\t\terr:     err,\n\t\t\t}\n\t\t}(offset, i)\n\t}\n\t\n\t// 等待所有请求完成\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultChan)\n\t}()\n\t\n\t// 收集结果\n\tvar allResults []model.SearchResult\n\thasMore := false\n\t\n\tfor result := range resultChan {\n\t\tif result.err != nil {\n\t\t\treturn nil, false, result.err\n\t\t}\n\t\t\n\t\tallResults = append(allResults, result.results...)\n\t\thasMore = hasMore || result.hasMore\n\t}\n\t\n\treturn allResults, hasMore, nil\n}\n\n// deduplicateResults 去除重复结果\nfunc (p *BixinAsyncPlugin) deduplicateResults(results []model.SearchResult) []model.SearchResult {\n\tseen := make(map[string]bool)\n\tunique := make([]model.SearchResult, 0, len(results))\n\t\n\tfor _, result := range results {\n\t\tif !seen[result.UniqueID] {\n\t\t\tseen[result.UniqueID] = true\n\t\t\tunique = append(unique, result)\n\t\t}\n\t}\n\t\n\t// 按时间降序排序\n\tsort.Slice(unique, func(i, j int) bool {\n\t\treturn unique[i].Datetime.After(unique[j].Datetime)\n\t})\n\t\n\treturn unique\n}\n\n// fetchPage 获取指定页的搜索结果\nfunc (p *BixinAsyncPlugin) fetchPage(client *http.Client, keyword string, offset int) ([]model.SearchResult, bool, error) {\n\t// 构建API URL\n\tapiURL := fmt.Sprintf(\"%s?filter[q]=%s&include=mostRelevantPost&page[offset]=%d&page[limit]=%d\",\n\t\tBaseURL, url.QueryEscape(keyword), offset, PageSize)\n\t\n\t// 创建请求\n\treq, err := http.NewRequest(\"GET\", apiURL, nil)\n\tif err != nil {\n\t\treturn nil, false, fmt.Errorf(\"创建请求失败: %w\", err)\n\t}\n\t\n\t// 设置请求头\n\treq.Header.Set(\"User-Agent\", getRandomUA())\n\treq.Header.Set(\"X-Forwarded-For\", generateRandomIP())\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Sec-Fetch-Dest\", \"empty\")\n\treq.Header.Set(\"Sec-Fetch-Mode\", \"cors\")\n\treq.Header.Set(\"Sec-Fetch-Site\", \"same-origin\")\n\t\n\tvar resp *http.Response\n\tvar responseBody []byte\n\t\n\t// 重试逻辑\n\tfor i := 0; i <= p.retries; i++ {\n\t\t// 发送请求\n\t\tresp, err = client.Do(req)\n\t\tif err != nil {\n\t\t\tif i == p.retries {\n\t\t\t\treturn nil, false, fmt.Errorf(\"请求失败: %w\", err)\n\t\t\t}\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\tdefer resp.Body.Close()\n\t\t\n\t\t// 读取响应体\n\t\tresponseBody, err = io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tif i == p.retries {\n\t\t\t\treturn nil, false, fmt.Errorf(\"读取响应失败: %w\", err)\n\t\t\t}\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 状态码检查\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tif i == p.retries {\n\t\t\t\treturn nil, false, fmt.Errorf(\"API返回非200状态码: %d\", resp.StatusCode)\n\t\t\t}\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 请求成功，跳出重试循环\n\t\tbreak\n\t}\n\t\n\t// 解析响应\n\tvar apiResp BixinResponse\n\tif err := json.Unmarshal(responseBody, &apiResp); err != nil {\n\t\treturn nil, false, fmt.Errorf(\"解析响应失败: %w\", err)\n\t}\n\t\n\t// 处理结果\n\tresults := make([]model.SearchResult, 0, len(apiResp.Data))\n\tpostMap := make(map[string]BixinPost)\n\t\n\t// 创建帖子ID到帖子内容的映射\n\tfor _, post := range apiResp.Included {\n\t\tpostMap[post.ID] = post\n\t}\n\t\n\t// 遍历搜索结果\n\tfor _, discussion := range apiResp.Data {\n\t\t// 获取相关帖子\n\t\tpostID := discussion.Relationships.MostRelevantPost.Data.ID\n\t\tpost, ok := postMap[postID]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 清理HTML内容\n\t\tcleanedHTML := cleanHTML(post.Attributes.ContentHTML)\n\t\t\n\t\t// 提取链接（只处理移动云盘）\n\t\tlinks := extractMobileLinksFromText(cleanedHTML)\n\t\t\n\t\t// 如果没有找到链接，跳过该结果\n\t\tif len(links) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 解析时间\n\t\tcreatedTime, err := time.Parse(time.RFC3339, discussion.Attributes.CreatedAt)\n\t\tif err != nil {\n\t\t\tcreatedTime = time.Now() // 如果解析失败，使用当前时间\n\t\t}\n\t\t\n\t\t// 创建唯一ID：插件名-帖子ID\n\t\tuniqueID := fmt.Sprintf(\"bixin-%s\", discussion.ID)\n\t\t\n\t\t// 创建搜索结果\n\t\tresult := model.SearchResult{\n\t\t\tUniqueID:  uniqueID,\n\t\t\tTitle:     discussion.Attributes.Title,\n\t\t\tContent:   cleanedHTML, // 使用清理后的HTML作为内容\n\t\t\tDatetime:  createdTime,\n\t\t\tLinks:     links,\n\t\t\tChannel:   \"\", // 插件搜索结果Channel为空\n\t\t}\n\t\t\n\t\tresults = append(results, result)\n\t}\n\t\n\t// 判断是否有更多结果\n\thasMore := apiResp.Links.Next != \"\"\n\t\n\treturn results, hasMore, nil\n}\n\n// 生成随机IP\nfunc generateRandomIP() string {\n\treturn fmt.Sprintf(\"%d.%d.%d.%d\", \n\t\trand.Intn(223)+1,  // 避免0和255\n\t\trand.Intn(255),\n\t\trand.Intn(255),\n\t\trand.Intn(254)+1)  // 避免0\n}\n\n// 获取随机UA\nfunc getRandomUA() string {\n\treturn userAgents[rand.Intn(len(userAgents))]\n}\n\n// 清理HTML内容（参考pan666的cleanHTML函数）\nfunc cleanHTML(html string) string {\n\t// 移除<br>标签\n\thtml = strings.ReplaceAll(html, \"<br>\", \"\\n\")\n\thtml = strings.ReplaceAll(html, \"<br/>\", \"\\n\")\n\thtml = strings.ReplaceAll(html, \"<br />\", \"\\n\")\n\t\n\t// 移除其他HTML标签\n\tvar result strings.Builder\n\tinTag := false\n\t\n\tfor _, r := range html {\n\t\tif r == '<' {\n\t\t\tinTag = true\n\t\t\tcontinue\n\t\t}\n\t\tif r == '>' {\n\t\t\tinTag = false\n\t\t\tcontinue\n\t\t}\n\t\tif !inTag {\n\t\t\tresult.WriteRune(r)\n\t\t}\n\t}\n\t\n\t// 处理HTML实体\n\toutput := result.String()\n\toutput = strings.ReplaceAll(output, \"&amp;\", \"&\")\n\toutput = strings.ReplaceAll(output, \"&lt;\", \"<\")\n\toutput = strings.ReplaceAll(output, \"&gt;\", \">\")\n\toutput = strings.ReplaceAll(output, \"&quot;\", \"\\\"\")\n\toutput = strings.ReplaceAll(output, \"&apos;\", \"'\")\n\toutput = strings.ReplaceAll(output, \"&#39;\", \"'\")\n\toutput = strings.ReplaceAll(output, \"&nbsp;\", \" \")\n\t\n\t// 处理多行空白\n\tlines := strings.Split(output, \"\\n\")\n\tvar cleanedLines []string\n\t\n\tfor _, line := range lines {\n\t\ttrimmed := strings.TrimSpace(line)\n\t\tif trimmed != \"\" {\n\t\t\tcleanedLines = append(cleanedLines, trimmed)\n\t\t}\n\t}\n\t\n\treturn strings.Join(cleanedLines, \"\\n\")\n}\n\n// 从文本提取移动云盘链接（bixin专用）\nfunc extractMobileLinksFromText(content string) []model.Link {\n\tvar allLinks []model.Link\n\t\n\tlines := strings.Split(content, \"\\n\")\n\t\n\t// 收集所有可能的链接信息\n\tvar linkInfos []struct {\n\t\tlink     model.Link\n\t\tposition int\n\t\tcategory string\n\t}\n\t\n\t// 收集所有可能的密码信息\n\tvar passwordInfos []struct {\n\t\tkeyword   string\n\t\tposition  int\n\t\tpassword  string\n\t}\n\t\n\t// 第一遍：查找所有的链接和密码\n\tfor i, line := range lines {\n\t\tline = strings.TrimSpace(line)\n\t\t\n\t\t// 只检查移动云盘（bixin只支持移动云盘）\n\t\tif strings.Contains(line, \"caiyun.139.com\") {\n\t\t\turl := extractURLFromText(line)\n\t\t\tif url != \"\" {\n\t\t\t\tlinkInfos = append(linkInfos, struct {\n\t\t\t\t\tlink     model.Link\n\t\t\t\t\tposition int\n\t\t\t\t\tcategory string\n\t\t\t\t}{\n\t\t\t\t\tlink:     model.Link{URL: url, Type: \"mobile\"},\n\t\t\t\t\tposition: i,\n\t\t\t\t\tcategory: \"mobile\",\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 检查密码/访问码（移动云盘主要使用访问码）\n\t\tpasswordKeywords := []string{\"访问码\", \"密码\"}\n\t\tfor _, keyword := range passwordKeywords {\n\t\t\tif strings.Contains(line, keyword) {\n\t\t\t\t// 寻找冒号后面的内容\n\t\t\t\tcolonPos := strings.Index(line, \":\")\n\t\t\t\tif colonPos == -1 {\n\t\t\t\t\tcolonPos = strings.Index(line, \"：\")\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tif colonPos != -1 && colonPos+1 < len(line) {\n\t\t\t\t\tpassword := strings.TrimSpace(line[colonPos+1:])\n\t\t\t\t\t// 如果密码长度超过10个字符，可能不是密码\n\t\t\t\t\tif len(password) <= 10 {\n\t\t\t\t\t\tpasswordInfos = append(passwordInfos, struct {\n\t\t\t\t\t\t\tkeyword   string\n\t\t\t\t\t\t\tposition  int\n\t\t\t\t\t\t\tpassword  string\n\t\t\t\t\t\t}{\n\t\t\t\t\t\t\tkeyword:   keyword,\n\t\t\t\t\t\t\tposition:  i,\n\t\t\t\t\t\t\tpassword:  password,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 第二遍：将密码与链接匹配\n\tfor i := range linkInfos {\n\t\t// 检查链接自身是否包含密码\n\t\tpassword := extractPasswordFromURL(linkInfos[i].link.URL)\n\t\tif password != \"\" {\n\t\t\tlinkInfos[i].link.Password = password\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 查找最近的密码\n\t\tminDistance := 1000000\n\t\tvar closestPassword string\n\t\t\n\t\tfor _, pwInfo := range passwordInfos {\n\t\t\t// 移动云盘匹配访问码或密码\n\t\t\tmatch := false\n\t\t\t\n\t\t\tif linkInfos[i].category == \"mobile\" && (pwInfo.keyword == \"访问码\" || pwInfo.keyword == \"密码\") {\n\t\t\t\tmatch = true\n\t\t\t}\n\t\t\t\n\t\t\tif match {\n\t\t\t\tdistance := abs(pwInfo.position - linkInfos[i].position)\n\t\t\t\tif distance < minDistance {\n\t\t\t\t\tminDistance = distance\n\t\t\t\t\tclosestPassword = pwInfo.password\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 只有当距离较近时才认为是匹配的密码\n\t\tif minDistance <= 3 {\n\t\t\tlinkInfos[i].link.Password = closestPassword\n\t\t}\n\t}\n\t\n\t// 收集所有有效链接\n\tfor _, info := range linkInfos {\n\t\tallLinks = append(allLinks, info.link)\n\t}\n\t\n\treturn allLinks\n}\n\n// 从文本中提取URL\nfunc extractURLFromText(text string) string {\n\t// 查找URL的起始位置\n\turlPrefixes := []string{\"http://\", \"https://\"}\n\tstart := -1\n\t\n\tfor _, prefix := range urlPrefixes {\n\t\tpos := strings.Index(text, prefix)\n\t\tif pos != -1 {\n\t\t\tstart = pos\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\tif start == -1 {\n\t\treturn \"\"\n\t}\n\t\n\t// 查找URL的结束位置\n\tend := len(text)\n\tendChars := []string{\" \", \"\\t\", \"\\n\", \"\\\"\", \"'\", \"<\", \">\", \")\", \"]\", \"}\", \",\", \";\"}\n\t\n\tfor _, char := range endChars {\n\t\tpos := strings.Index(text[start:], char)\n\t\tif pos != -1 && start+pos < end {\n\t\t\tend = start + pos\n\t\t}\n\t}\n\t\n\treturn text[start:end]\n}\n\n// 从URL中提取密码\nfunc extractPasswordFromURL(url string) string {\n\t// 查找密码参数\n\tpwdParams := []string{\"pwd=\", \"password=\", \"passcode=\", \"code=\"}\n\t\n\tfor _, param := range pwdParams {\n\t\tpos := strings.Index(url, param)\n\t\tif pos != -1 {\n\t\t\tstart := pos + len(param)\n\t\t\tend := len(url)\n\t\t\t\n\t\t\t// 查找参数结束位置\n\t\t\tfor i := start; i < len(url); i++ {\n\t\t\t\tif url[i] == '&' || url[i] == '#' {\n\t\t\t\t\tend = i\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tif start < end {\n\t\t\t\treturn url[start:end]\n\t\t\t}\n\t\t}\n\t}\n\t\n\treturn \"\"\n}\n\n// 绝对值函数\nfunc abs(n int) int {\n\tif n < 0 {\n\t\treturn -n\n\t}\n\treturn n\n}\n\n// BixinResponse API响应结构\ntype BixinResponse struct {\n\tLinks struct {\n\t\tFirst string `json:\"first\"`\n\t\tNext  string `json:\"next,omitempty\"`\n\t} `json:\"links\"`\n\tData     []BixinDiscussion `json:\"data\"`\n\tIncluded []BixinPost       `json:\"included\"`\n}\n\n// BixinDiscussion 讨论信息\ntype BixinDiscussion struct {\n\tType       string `json:\"type\"`\n\tID         string `json:\"id\"`\n\tAttributes struct {\n\t\tTitle          string    `json:\"title\"`\n\t\tSlug           string    `json:\"slug\"`\n\t\tCommentCount   int       `json:\"commentCount\"`\n\t\tCreatedAt      string    `json:\"createdAt\"`\n\t\tLastPostedAt   string    `json:\"lastPostedAt\"`\n\t\tLastPostNumber int       `json:\"lastPostNumber\"`\n\t\tIsApproved     bool      `json:\"isApproved\"`\n\t} `json:\"attributes\"`\n\tRelationships struct {\n\t\tMostRelevantPost struct {\n\t\t\tData struct {\n\t\t\t\tType string `json:\"type\"`\n\t\t\t\tID   string `json:\"id\"`\n\t\t\t} `json:\"data\"`\n\t\t} `json:\"mostRelevantPost\"`\n\t} `json:\"relationships\"`\n}\n\n// BixinPost 帖子内容\ntype BixinPost struct {\n\tType       string `json:\"type\"`\n\tID         string `json:\"id\"`\n\tAttributes struct {\n\t\tNumber      int    `json:\"number\"`\n\t\tCreatedAt   string `json:\"createdAt\"`\n\t\tContentType string `json:\"contentType\"`\n\t\tContentHTML string `json:\"contentHtml\"`\n\t} `json:\"attributes\"`\n}\n"
  },
  {
    "path": "plugin/bixin/json结构分析.md",
    "content": "# Bixin API 数据结构分析\n\n## 基本信息\n- **数据源类型**: JSON API  \n- **API URL格式**: `https://www.bixbiy.com/api/discussions?filter[q]={关键词}&page[limit]=3&include=mostRelevantPost`\n- **请求方法**: `GET`\n- **Content-Type**: `application/json`\n- **Referer**: `https://www.bixbiy.com/`\n- **特殊说明**: 该网站**只提供移动云盘(mobile)链接**，域名固定为`caiyun.139.com`，需要从HTML内容中解析网盘链接和密码\n\n## API响应结构\n\n### 顶层结构\n```json\n{\n    \"links\": {\n        \"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\",\n        \"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\"\n    },\n    \"data\": [\n        // 讨论帖子数组\n    ],\n    \"included\": [\n        // 相关回复内容数组\n    ]\n}\n```\n\n### `data`数组中的讨论帖子结构\n```json\n{\n    \"type\": \"discussions\",\n    \"id\": \"5754\",\n    \"attributes\": {\n        \"title\": \"凡人修仙传（2025）更新至第8集\",\n        \"slug\": \"5754\",\n        \"commentCount\": 1,\n        \"participantCount\": 1,\n        \"createdAt\": \"2025-07-29T15:31:19+00:00\",\n        \"lastPostedAt\": \"2025-07-29T15:31:19+00:00\",\n        \"lastPostNumber\": 1,\n        \"canReply\": false,\n        \"canRename\": false,\n        \"canDelete\": false,\n        \"canHide\": false,\n        \"isApproved\": true,\n        \"canTag\": false,\n        \"isSticky\": false,\n        \"canSticky\": false,\n        \"isStickiest\": false,\n        \"isTagSticky\": false,\n        \"canStickiest\": false,\n        \"canTagSticky\": false,\n        \"subscription\": null,\n        \"isLocked\": false,\n        \"canLock\": false\n    },\n    \"relationships\": {\n        \"mostRelevantPost\": {\n            \"data\": {\n                \"type\": \"posts\",\n                \"id\": \"6187\"\n            }\n        }\n    }\n}\n```\n\n### `included`数组中的回复内容结构\n```json\n{\n    \"type\": \"posts\",\n    \"id\": \"6187\",\n    \"attributes\": {\n        \"number\": 1,\n        \"createdAt\": \"2025-07-29T15:31:19+00:00\",\n        \"contentType\": \"comment\",\n        \"contentHtml\": \"<p>凡人修仙传（2025）更新至第8集：<a href=\\\"https://caiyun.139.com/w/i/2oRhbuZoZbFpi\\\" rel=\\\"ugc nofollow\\\">https://caiyun.139.com/w/i/2oRhbuZoZbFpi</a></p>\",\n        \"renderFailed\": false,\n        \"canEdit\": false,\n        \"canDelete\": false,\n        \"canHide\": false,\n        \"mentionedByCount\": 0,\n        \"canFlag\": false,\n        \"isApproved\": true,\n        \"canApprove\": false,\n        \"canLike\": false,\n        \"likesCount\": 0\n    }\n}\n```\n\n## 插件所需字段映射\n\n| 源字段 | 目标字段 | 说明 |\n|--------|----------|------|\n| `data[].id` | `UniqueID` | 格式: `bixin-{discussion_id}` |\n| `data[].attributes.title` | `Title` | 讨论标题 |\n| `data[].attributes.createdAt` | `Datetime` | 创建时间 |\n| `included[].attributes.contentHtml` | `Content` | HTML内容，需要解析提取网盘链接 |\n| `\"\"` | `Channel` | 插件搜索结果Channel为空 |\n| `[]` | `Tags` | 标签数组（从标题或内容中提取） |\n| 解析的网盘链接 | `Links` | 从HTML内容中提取的网盘链接 |\n\n## 网盘链接解析\n\n### HTML内容特点\n- **格式**: 包含HTML标签的文本内容，需要清理HTML标签获取纯文本\n- **链接**: 以`<a href=\"...\">`标签形式存在，但更多是纯文本格式\n- **示例**: \n  - HTML格式: `<a href=\"https://caiyun.139.com/w/i/2oRhbuZoZbFpi\" rel=\"ugc nofollow\">https://caiyun.139.com/w/i/2oRhbuZoZbFpi</a>`\n  - 纯文本格式: `https://caiyun.139.com/w/i/2oRhbuZoZbFpi`\n\n### 支持的网盘类型（bixin专用）\n\n| 网盘类型 | 域名特征 | 示例链接 | 密码关键词 |\n|---------|----------|----------|------------|\n| **移动云盘** | `caiyun.139.com` | `https://caiyun.139.com/w/i/2oRhbuZoZbFpi` | 访问码、密码 |\n\n**重要说明**: bixin插件**只支持移动云盘**，所有链接都是`caiyun.139.com`域名，不需要处理其他网盘类型。\n\n### 链接解析策略（bixin专用）\n1. **HTML清理**: 移除HTML标签，保留纯文本内容\n2. **链接提取**: 从纯文本中提取**移动云盘链接**（只处理`caiyun.139.com`）\n3. **密码匹配**: 匹配\"访问码\"或\"密码\"关键词\n4. **位置关联**: 密码通常出现在链接附近的行中\n\n## 插件开发指导\n\n### 请求示例\n```go\nsearchURL := fmt.Sprintf(\"https://www.bixbiy.com/api/discussions?filter[q]=%s&page[limit]=3&include=mostRelevantPost\", url.QueryEscape(keyword))\n```\n\n### 请求头设置（参考pan666实现）\n```go\nreq.Header.Set(\"User-Agent\", getRandomUA()) // 使用随机UA避免反爬虫\nreq.Header.Set(\"X-Forwarded-For\", generateRandomIP()) // 随机IP\nreq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\nreq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\nreq.Header.Set(\"Connection\", \"keep-alive\")\nreq.Header.Set(\"Sec-Fetch-Dest\", \"empty\")\nreq.Header.Set(\"Sec-Fetch-Mode\", \"cors\")\nreq.Header.Set(\"Sec-Fetch-Site\", \"same-origin\")\n```\n\n### SearchResult构建示例\n```go\nresult := model.SearchResult{\n    UniqueID: fmt.Sprintf(\"bixin-%s\", discussion.ID),\n    Title:    discussion.Attributes.Title,\n    Content:  extractTextFromHTML(post.Attributes.ContentHTML),\n    Links:    extractLinksFromHTML(post.Attributes.ContentHTML),\n    Tags:     extractTagsFromTitle(discussion.Attributes.Title),\n    Channel:  \"\", // 插件搜索结果Channel为空\n    Datetime: parseTime(discussion.Attributes.CreatedAt),\n}\n```\n\n### HTML内容解析函数（参考pan666实现）\n```go\n// 清理HTML内容（参考pan666的cleanHTML函数）\nfunc (p *BixinAsyncPlugin) cleanHTML(html string) string {\n    // 移除<br>标签\n    html = strings.ReplaceAll(html, \"<br>\", \"\\n\")\n    html = strings.ReplaceAll(html, \"<br/>\", \"\\n\")\n    html = strings.ReplaceAll(html, \"<br />\", \"\\n\")\n    \n    // 移除其他HTML标签\n    var result strings.Builder\n    inTag := false\n    \n    for _, r := range html {\n        if r == '<' {\n            inTag = true\n            continue\n        }\n        if r == '>' {\n            inTag = false\n            continue\n        }\n        if !inTag {\n            result.WriteRune(r)\n        }\n    }\n    \n    // 处理HTML实体\n    output := result.String()\n    output = strings.ReplaceAll(output, \"&amp;\", \"&\")\n    output = strings.ReplaceAll(output, \"&lt;\", \"<\")\n    output = strings.ReplaceAll(output, \"&gt;\", \">\")\n    output = strings.ReplaceAll(output, \"&quot;\", \"\\\"\")\n    output = strings.ReplaceAll(output, \"&apos;\", \"'\")\n    output = strings.ReplaceAll(output, \"&#39;\", \"'\")\n    output = strings.ReplaceAll(output, \"&nbsp;\", \" \")\n    \n    // 处理多行空白\n    lines := strings.Split(output, \"\\n\")\n    var cleanedLines []string\n    \n    for _, line := range lines {\n        trimmed := strings.TrimSpace(line)\n        if trimmed != \"\" {\n            cleanedLines = append(cleanedLines, trimmed)\n        }\n    }\n    \n    return strings.Join(cleanedLines, \"\\n\")\n}\n\n// 从文本中提取链接（参考pan666的extractLinksFromText函数）\nfunc (p *BixinAsyncPlugin) extractLinksFromText(content string) []model.Link {\n    var allLinks []model.Link\n    \n    lines := strings.Split(content, \"\\n\")\n    \n    // 收集所有可能的链接信息\n    var linkInfos []struct {\n        link     model.Link\n        position int\n        category string\n    }\n    \n    // 收集所有可能的密码信息\n    var passwordInfos []struct {\n        keyword   string\n        position  int\n        password  string\n    }\n    \n    // 第一遍：查找所有的链接和密码\n    for i, line := range lines {\n        line = strings.TrimSpace(line)\n        \n        // 只检查移动云盘（bixin只支持移动云盘）\n        if strings.Contains(line, \"caiyun.139.com\") {\n            url := p.extractURLFromText(line)\n            if url != \"\" {\n                linkInfos = append(linkInfos, struct {\n                    link     model.Link\n                    position int\n                    category string\n                }{\n                    link:     model.Link{URL: url, Type: \"mobile\"},\n                    position: i,\n                    category: \"mobile\",\n                })\n            }\n        }\n        \n        // 检查密码/访问码（移动云盘主要使用访问码）\n        passwordKeywords := []string{\"访问码\", \"密码\"}\n        for _, keyword := range passwordKeywords {\n            if strings.Contains(line, keyword) {\n                // 寻找冒号后面的内容\n                colonPos := strings.Index(line, \":\")\n                if colonPos == -1 {\n                    colonPos = strings.Index(line, \"：\")\n                }\n                \n                if colonPos != -1 && colonPos+1 < len(line) {\n                    password := strings.TrimSpace(line[colonPos+1:])\n                    // 如果密码长度超过10个字符，可能不是密码\n                    if len(password) <= 10 {\n                        passwordInfos = append(passwordInfos, struct {\n                            keyword   string\n                            position  int\n                            password  string\n                        }{\n                            keyword:   keyword,\n                            position:  i,\n                            password:  password,\n                        })\n                    }\n                }\n            }\n        }\n    }\n    \n    // 第二遍：将密码与链接匹配\n    for i := range linkInfos {\n        // 检查链接自身是否包含密码\n        password := p.extractPasswordFromURL(linkInfos[i].link.URL)\n        if password != \"\" {\n            linkInfos[i].link.Password = password\n            continue\n        }\n        \n        // 查找最近的密码\n        minDistance := 1000000\n        var closestPassword string\n        \n        for _, pwInfo := range passwordInfos {\n            // 移动云盘匹配访问码或密码\n            match := false\n            \n            if linkInfos[i].category == \"mobile\" && (pwInfo.keyword == \"访问码\" || pwInfo.keyword == \"密码\") {\n                match = true\n            }\n            \n            if match {\n                distance := abs(pwInfo.position - linkInfos[i].position)\n                if distance < minDistance {\n                    minDistance = distance\n                    closestPassword = pwInfo.password\n                }\n            }\n        }\n        \n        // 只有当距离较近时才认为是匹配的密码\n        if minDistance <= 3 {\n            linkInfos[i].link.Password = closestPassword\n        }\n    }\n    \n    // 收集所有有效链接\n    for _, info := range linkInfos {\n        allLinks = append(allLinks, info.link)\n    }\n    \n    return allLinks\n}\n```\n\n### 辅助函数（参考pan666实现）\n```go\n// 从文本中提取URL\nfunc (p *BixinAsyncPlugin) extractURLFromText(text string) string {\n    // 查找URL的起始位置\n    urlPrefixes := []string{\"http://\", \"https://\"}\n    start := -1\n    \n    for _, prefix := range urlPrefixes {\n        pos := strings.Index(text, prefix)\n        if pos != -1 {\n            start = pos\n            break\n        }\n    }\n    \n    if start == -1 {\n        return \"\"\n    }\n    \n    // 查找URL的结束位置\n    end := len(text)\n    endChars := []string{\" \", \"\\t\", \"\\n\", \"\\\"\", \"'\", \"<\", \">\", \")\", \"]\", \"}\", \",\", \";\"}\n    \n    for _, char := range endChars {\n        pos := strings.Index(text[start:], char)\n        if pos != -1 && start+pos < end {\n            end = start + pos\n        }\n    }\n    \n    return text[start:end]\n}\n\n// 从URL中提取密码\nfunc (p *BixinAsyncPlugin) extractPasswordFromURL(url string) string {\n    // 查找密码参数\n    pwdParams := []string{\"pwd=\", \"password=\", \"passcode=\", \"code=\"}\n    \n    for _, param := range pwdParams {\n        pos := strings.Index(url, param)\n        if pos != -1 {\n            start := pos + len(param)\n            end := len(url)\n            \n            // 查找参数结束位置\n            for i := start; i < len(url); i++ {\n                if url[i] == '&' || url[i] == '#' {\n                    end = i\n                    break\n                }\n            }\n            \n            if start < end {\n                return url[start:end]\n            }\n        }\n    }\n    \n    return \"\"\n}\n\n// 绝对值函数\nfunc abs(n int) int {\n    if n < 0 {\n        return -n\n    }\n    return n\n}\n\n// 生成随机UA\nfunc getRandomUA() string {\n    userAgents := []string{\n        \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\",\n        \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36\",\n        \"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\",\n        \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0\",\n        \"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\",\n        \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36\",\n    }\n    return userAgents[rand.Intn(len(userAgents))]\n}\n\n// 生成随机IP\nfunc generateRandomIP() string {\n    return fmt.Sprintf(\"%d.%d.%d.%d\", \n        rand.Intn(223)+1,  // 避免0和255\n        rand.Intn(255),\n        rand.Intn(255),\n        rand.Intn(254)+1)  // 避免0\n}\n```\n\n### 时间解析函数\n```go\nfunc (p *BixinAsyncPlugin) parseTime(timeStr string) time.Time {\n    // 解析ISO 8601格式时间\n    t, err := time.Parse(\"2006-01-02T15:04:05Z07:00\", timeStr)\n    if err != nil {\n        return time.Now()\n    }\n    return t\n}\n```\n\n## 数据结构定义\n\n### API响应结构体\n```go\ntype BixinAPIResponse struct {\n    Links    BixinLinks `json:\"links\"`\n    Data     []BixinDiscussion `json:\"data\"`\n    Included []BixinPost `json:\"included\"`\n}\n\ntype BixinLinks struct {\n    First string `json:\"first\"`\n    Next  string `json:\"next\"`\n}\n\ntype BixinDiscussion struct {\n    Type         string `json:\"type\"`\n    ID           string `json:\"id\"`\n    Attributes   BixinDiscussionAttributes `json:\"attributes\"`\n    Relationships BixinRelationships `json:\"relationships\"`\n}\n\ntype BixinDiscussionAttributes struct {\n    Title           string    `json:\"title\"`\n    Slug            string    `json:\"slug\"`\n    CommentCount    int       `json:\"commentCount\"`\n    ParticipantCount int      `json:\"participantCount\"`\n    CreatedAt       string    `json:\"createdAt\"`\n    LastPostedAt    string    `json:\"lastPostedAt\"`\n    LastPostNumber  int       `json:\"lastPostNumber\"`\n    IsApproved      bool      `json:\"isApproved\"`\n    IsLocked        bool      `json:\"isLocked\"`\n}\n\ntype BixinRelationships struct {\n    MostRelevantPost BixinPostRef `json:\"mostRelevantPost\"`\n}\n\ntype BixinPostRef struct {\n    Data BixinPostData `json:\"data\"`\n}\n\ntype BixinPostData struct {\n    Type string `json:\"type\"`\n    ID   string `json:\"id\"`\n}\n\ntype BixinPost struct {\n    Type       string `json:\"type\"`\n    ID         string `json:\"id\"`\n    Attributes BixinPostAttributes `json:\"attributes\"`\n}\n\ntype BixinPostAttributes struct {\n    Number           int    `json:\"number\"`\n    CreatedAt        string `json:\"createdAt\"`\n    ContentType      string `json:\"contentType\"`\n    ContentHTML      string `json:\"contentHtml\"`\n    RenderFailed     bool   `json:\"renderFailed\"`\n    IsApproved       bool   `json:\"isApproved\"`\n    LikesCount       int    `json:\"likesCount\"`\n}\n```\n\n## 特殊处理逻辑\n\n### 1. 讨论与回复关联\n- 通过`relationships.mostRelevantPost.data.id`关联讨论和回复\n- 需要在`included`数组中查找对应的回复内容\n- 一个讨论可能对应多个回复，需要处理所有相关回复\n\n### 2. HTML内容清理\n- 移除HTML标签获取纯文本内容\n- 解码HTML实体（如`&lt;`、`&gt;`等）\n- 提取链接时保留原始URL\n\n### 3. 链接验证\n- 验证链接是否为有效的网盘链接\n- 过滤掉无效链接（如`javascript:`、`#`等）\n- 提取链接中的密码信息\n\n### 4. 标签提取\n- 从讨论标题中提取关键词作为标签\n- 可以基于内容类型、年份等信息生成标签\n- 支持中文和英文标签\n\n## 与pan666插件的相似性\n\n| 特性 | bixin | pan666 | 说明 |\n|------|-------|--------|------|\n| **数据源** | 论坛讨论API | 论坛讨论API | 使用相同的论坛系统 |\n| **API结构** | 相同 | 相同 | JSON结构完全一致 |\n| **链接解析** | 文本解析 | 文本解析 | 都需要从HTML清理后的文本中提取 |\n| **主要网盘** | 移动云盘 | 移动云盘 | 都主要提供移动云盘链接 |\n| **密码匹配** | 位置关联 | 位置关联 | 使用相同的密码匹配策略 |\n| **过滤策略** | 跳过Service层过滤 | 跳过Service层过滤 | 都使用`NewBaseAsyncPluginWithFilter` |\n\n## 与其他插件的差异\n\n| 特性 | bixin/pan666 | 其他插件 | 说明 |\n|------|-------------|----------|------|\n| **数据源** | 论坛讨论API | 网盘搜索API | 需要解析HTML内容 |\n| **链接格式** | 纯文本格式 | 直接URL字符串 | 需要从文本中提取 |\n| **内容结构** | 讨论+回复 | 直接资源信息 | 需要关联处理 |\n| **链接验证** | 必需 | 可选 | 论坛可能包含无效链接 |\n| **过滤策略** | 跳过Service层过滤 | 启用Service层过滤 | 论坛内容需要宽泛搜索 |\n\n## 注意事项\n\n1. **HTML解析**: 需要正确处理HTML标签和实体，参考pan666的cleanHTML函数\n2. **链接提取**: 主要从纯文本中提取链接，而非HTML标签\n3. **内容关联**: 需要将讨论和回复内容正确关联\n4. **链接验证**: 论坛内容可能包含无效链接，需要过滤\n5. **时间解析**: 使用ISO 8601格式解析时间\n6. **错误处理**: API可能返回空数据或格式错误\n7. **反爬虫**: 使用随机UA和IP避免反爬虫检测\n8. **密码匹配**: 使用位置关联策略匹配密码和链接\n\n## 开发建议\n\n- **优先级设置**: 建议设置为优先级3，数据质量一般\n- **Service层过滤**: 跳过Service层过滤，使用`NewBaseAsyncPluginWithFilter(\"bixin\", 3, true)`\n- **HTML处理**: 重点处理HTML内容的解析和清理，参考pan666实现\n- **链接提取**: 实现robust的链接提取和验证机制，**只处理移动云盘**（caiyun.139.com）\n- **缓存策略**: 建议使用较短的缓存TTL，论坛内容更新频繁\n- **错误日志**: 详细记录HTML解析和链接提取的错误信息\n- **基于pan666**: 可以直接基于pan666插件进行修改，主要更改API URL和插件名称\n\n## API调用示例\n\n### 搜索请求示例\n```bash\ncurl \"https://www.bixbiy.com/api/discussions?filter[q]=凡人修仙传&page[limit]=3&include=mostRelevantPost\" \\\n  -H \"Referer: https://www.bixbiy.com/\" \\\n  -H \"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\"\n```\n\n### 完整流程示例\n1. **发送搜索请求**: 获取讨论列表和回复内容\n2. **解析讨论数据**: 提取标题、时间等基本信息\n3. **关联回复内容**: 通过ID关联讨论和回复\n4. **清理HTML内容**: 移除HTML标签，获取纯文本\n5. **提取网盘链接**: 从纯文本中提取**移动云盘链接**（只处理caiyun.139.com）\n6. **匹配密码**: 使用位置关联策略匹配密码和链接\n7. **验证链接有效性**: 过滤无效链接\n8. **构建搜索结果**: 转换为PanSou标准格式\n9. **返回结果**: 包含标题、内容、链接等信息\n\n### 插件实现建议\n```go\n// 基于pan666插件进行修改\nfunc NewBixinAsyncPlugin() *BixinAsyncPlugin {\n    return &BixinAsyncPlugin{\n        BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"bixin\", 3, true), // 跳过Service层过滤\n        retries:         MaxRetries,\n    }\n}\n\n// 主要修改点：\n// 1. 更改API URL: \"https://www.bixbiy.com/api/discussions\"\n// 2. 更改插件名称: \"bixin\"\n// 3. 简化链接提取：只处理移动云盘（caiyun.139.com）\n// 4. 简化密码匹配：只匹配\"访问码\"和\"密码\"关键词\n// 5. 保持相同的HTML解析逻辑\n```\n"
  },
  {
    "path": "plugin/cldi/cldi.go",
    "content": "package cldi\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\ntype CldiPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\nconst (\n\t// 并发数限制\n\tMaxConcurrency = 10\n\t\n\t// 最大搜索页数\n\tMaxPages = 5\n)\n\nvar (\n\t// 广告清理正则表达式\n\tadRegex = regexp.MustCompile(`【[^】]*】`)\n\t\n\t// 文件大小和名称分离正则\n\tfileSizeRegex = regexp.MustCompile(`^(.+?)&nbsp;<span class=\"lightColor\">([^<]+)</span>$`)\n\t\n\t// 各种数字提取正则\n\tnumberRegex = regexp.MustCompile(`\\d+`)\n)\n\nfunc init() {\n\tp := &CldiPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"cldi\", 3, true), // 磁力搜索插件，跳过Service层过滤\n\t}\n\tplugin.RegisterGlobalPlugin(p)\n}\n\n// Search 执行搜索并返回结果\nfunc (p *CldiPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *CldiPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实际的搜索实现\nfunc (p *CldiPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 1. 首先搜索第一页\n\tfirstPageResults, err := p.searchPage(client, keyword, 1)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索第一页失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 存储所有结果\n\tvar allResults []model.SearchResult\n\tallResults = append(allResults, firstPageResults...)\n\t\n\t// 2. 并发搜索其他页面（第2页到第5页）\n\tif MaxPages > 1 {\n\t\tvar wg sync.WaitGroup\n\t\tvar mu sync.Mutex\n\t\t\n\t\t// 使用信号量控制并发数\n\t\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\t\n\t\t// 存储每页结果\n\t\tpageResults := make(map[int][]model.SearchResult)\n\t\t\n\t\tfor page := 2; page <= MaxPages; page++ {\n\t\t\twg.Add(1)\n\t\t\tgo func(pageNum int) {\n\t\t\t\tdefer wg.Done()\n\t\t\t\t\n\t\t\t\t// 获取信号量\n\t\t\t\tsemaphore <- struct{}{}\n\t\t\t\tdefer func() { <-semaphore }()\n\t\t\t\t\n\t\t\t\t// 添加小延迟避免过于频繁的请求\n\t\t\t\ttime.Sleep(time.Duration(pageNum%3) * 100 * time.Millisecond)\n\t\t\t\t\n\t\t\t\tcurrentPageResults, err := p.searchPage(client, keyword, pageNum)\n\t\t\t\tif err == nil && len(currentPageResults) > 0 {\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\tpageResults[pageNum] = currentPageResults\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t}\n\t\t\t}(page)\n\t\t}\n\t\t\n\t\twg.Wait()\n\t\t\n\t\t// 按页码顺序合并所有页面的结果\n\t\tfor page := 2; page <= MaxPages; page++ {\n\t\t\tif results, exists := pageResults[page]; exists {\n\t\t\t\tallResults = append(allResults, results...)\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 3. 关键词过滤\n\treturn plugin.FilterResultsByKeyword(allResults, keyword), nil\n}\n\n// searchPage 搜索指定页面\nfunc (p *CldiPlugin) searchPage(client *http.Client, keyword string, page int) ([]model.SearchResult, error) {\n\t// 构建搜索URL (分类=0全部, 排序=2按添加时间)\n\tsearchURL := fmt.Sprintf(\"https://wvmzbxki.1122132.xyz/search-%s-0-2-%d.html\", url.QueryEscape(keyword), page)\n\t\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 设置请求头\n\tp.setRequestHeaders(req)\n\t\n\t// 发送请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 读取响应\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 读取响应失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body)))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] HTML解析失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 提取搜索结果\n\treturn p.extractSearchResults(doc), nil\n}\n\n// setRequestHeaders 设置请求头\nfunc (p *CldiPlugin) setRequestHeaders(req *http.Request) {\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Cache-Control\", \"no-cache\")\n\treq.Header.Set(\"Pragma\", \"no-cache\")\n\treq.Header.Set(\"Referer\", \"https://wvmzbxki.1122132.xyz/\")\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *CldiPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n\n// extractSearchResults 提取搜索结果\nfunc (p *CldiPlugin) extractSearchResults(doc *goquery.Document) []model.SearchResult {\n\tvar results []model.SearchResult\n\t\n\t// 查找所有搜索结果\n\tdoc.Find(\".tbox .ssbox\").Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchResult(s)\n\t\tif result.Title != \"\" && len(result.Links) > 0 {\n\t\t\tresults = append(results, result)\n\t\t}\n\t})\n\t\n\treturn results\n}\n\n// parseSearchResult 解析单个搜索结果\nfunc (p *CldiPlugin) parseSearchResult(s *goquery.Selection) model.SearchResult {\n\tresult := model.SearchResult{\n\t\tChannel:  \"\", // 插件搜索结果必须为空字符串\n\t\tDatetime: time.Now(),\n\t}\n\t\n\t// 提取标题和分类\n\ttitleSection := s.Find(\".title h3\")\n\t\n\t// 提取分类\n\tcategory := strings.TrimSpace(titleSection.Find(\"span\").First().Text())\n\tif category != \"\" {\n\t\tresult.Tags = []string{p.mapCategory(category)}\n\t}\n\t\n\t// 提取标题\n\ttitleLink := titleSection.Find(\"a\")\n\ttitle := strings.TrimSpace(titleLink.Text())\n\tresult.Title = p.cleanTitle(title)\n\t\n\t// 提取磁力链接和元数据\n\tp.extractMagnetInfo(s, &result)\n\t\n\t// 提取文件列表作为内容\n\tp.extractFileList(s, &result)\n\t\n\t// 生成唯一ID\n\tresult.UniqueID = fmt.Sprintf(\"%s-%d\", p.Name(), time.Now().UnixNano())\n\t\n\treturn result\n}\n\n// extractMagnetInfo 提取磁力链接和元数据\nfunc (p *CldiPlugin) extractMagnetInfo(s *goquery.Selection, result *model.SearchResult) {\n\tsbar := s.Find(\".sbar\")\n\t\n\t// 提取磁力链接\n\tmagnetLink, exists := sbar.Find(\"a[href^='magnet:']\").Attr(\"href\")\n\tif exists && magnetLink != \"\" {\n\t\tresult.Links = []model.Link{{\n\t\t\tType: \"magnet\",\n\t\t\tURL:  magnetLink,\n\t\t}}\n\t}\n\t\n\t// 提取添加时间\n\tsbar.Find(\"span\").Each(func(i int, span *goquery.Selection) {\n\t\ttext := span.Text()\n\t\tif strings.Contains(text, \"添加时间:\") {\n\t\t\ttimeStr := strings.TrimSpace(span.Find(\"b\").Text())\n\t\t\tif timeStr != \"\" {\n\t\t\t\tif parsedTime, err := time.Parse(\"2006-01-02\", timeStr); err == nil {\n\t\t\t\t\tresult.Datetime = parsedTime\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n}\n\n// extractFileList 提取文件列表\nfunc (p *CldiPlugin) extractFileList(s *goquery.Selection, result *model.SearchResult) {\n\tvar fileList []string\n\t\n\ts.Find(\".slist ul li\").Each(func(i int, li *goquery.Selection) {\n\t\t// 获取原始HTML以解析文件名和大小\n\t\thtml, _ := li.Html()\n\t\t\n\t\t// 使用正则表达式分离文件名和大小\n\t\tif matches := fileSizeRegex.FindStringSubmatch(html); len(matches) == 3 {\n\t\t\tfileName := strings.TrimSpace(matches[1])\n\t\t\tfileSize := strings.TrimSpace(matches[2])\n\t\t\tif fileName != \"\" && fileSize != \"\" {\n\t\t\t\tfileList = append(fileList, fmt.Sprintf(\"%s (%s)\", fileName, fileSize))\n\t\t\t}\n\t\t} else {\n\t\t\t// 回退方案：直接使用文本内容\n\t\t\ttext := strings.TrimSpace(li.Text())\n\t\t\tif text != \"\" {\n\t\t\t\tfileList = append(fileList, text)\n\t\t\t}\n\t\t}\n\t})\n\t\n\tif len(fileList) > 0 {\n\t\tresult.Content = strings.Join(fileList, \"\\n\")\n\t}\n}\n\n// mapCategory 映射分类\nfunc (p *CldiPlugin) mapCategory(category string) string {\n\t// 移除方括号\n\tcategory = strings.Trim(category, \"[]\")\n\t\n\tswitch category {\n\tcase \"影视\":\n\t\treturn \"影视\"\n\tcase \"音乐\":\n\t\treturn \"音乐\"\n\tcase \"图像\":\n\t\treturn \"图像\"\n\tcase \"文档书籍\":\n\t\treturn \"文档\"\n\tcase \"压缩文件\":\n\t\treturn \"压缩包\"\n\tcase \"安装包\":\n\t\treturn \"软件\"\n\tcase \"其他\":\n\t\treturn \"其他\"\n\tdefault:\n\t\treturn \"其他\"\n\t}\n}\n\n// cleanTitle 清理标题中的广告内容\nfunc (p *CldiPlugin) cleanTitle(title string) string {\n\t// 移除【】内的广告内容\n\tcleaned := adRegex.ReplaceAllString(title, \"\")\n\t\n\t// 清理多余的空格\n\tcleaned = strings.TrimSpace(cleaned)\n\tcleaned = regexp.MustCompile(`\\s+`).ReplaceAllString(cleaned, \" \")\n\t\n\treturn cleaned\n}"
  },
  {
    "path": "plugin/cldi/html结构分析.md",
    "content": "# CLDI (磁力帝) HTML结构分析\n\n## 网站信息\n- **网站名称**: 磁力帝\n- **域名**: cldcld.cc (通过动态域名访问)\n- **类型**: 磁力搜索引擎\n- **特点**: 专门搜索BT种子和磁力链接\n\n## 搜索页面结构\n\n### 1. 搜索URL模式\n```\nhttps://[域名]/search-[关键词]-[分类]-[排序]-[页码].html\n\n示例:\nhttps://wvmzbxki.1122132.xyz/search-%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0-0-2-1.html\n\n参数说明:\n- 关键词: URL编码的搜索关键词\n- 分类: 0=全部, 1=影视, 2=音乐, 3=图像, 4=文档书籍, 5=压缩文件, 6=安装包, 7=其他\n- 排序: 0=相关程度, 1=文件大小, 2=添加时间, 3=热度, 4=最近访问\n- 页码: 从1开始\n```\n\n### 2. 搜索结果容器\n- **父容器**: `.tbox`\n- **结果项**: `.ssbox` (每个搜索结果)\n\n### 3. 单个搜索结果结构\n\n#### 标题区域 (.title)\n```html\n<div class=\"title\">\n    <h3>\n        <span>[影视]</span>\n        <a href=\"/hash/186e709110410a995f1a4bece816d70c5986a5d5.html\" target=\"_blank\">\n            【不太灵影视 www.2BT0.com】<span class=\"red\">凡人修仙传</span>[60帧率版本][全30集][国语配音+中文字幕].2025.2160p.WEB-DL.H265.60FPS.AAC-DeePTV\n        </a>\n    </h3>\n</div>\n\n提取要素:\n- 分类: span 文本内容 (如 \"[影视]\")\n- 详情页链接: a 的 href 属性 (用于构造磁力链接)\n- 标题: a 的文本内容 (需要去掉广告标记)\n```\n\n#### 文件列表 (.slist)\n```html\n<div class=\"slist\">\n    <ul>\n        <li>凡人修仙传.The.Immortal.Ascension.S01E08.2025.2160p.WEB-DL.H265.60FPS.AAC-DeePTV.mp4&nbsp;<span class=\"lightColor\">2.7 GB</span></li>\n        <li>凡人修仙传.The.Immortal.Ascension.S01E01.2025.2160p.WEB-DL.H265.60FPS.AAC-DeePTV.mp4&nbsp;<span class=\"lightColor\">2.4 GB</span></li>\n        <!-- 更多文件... -->\n    </ul>\n</div>\n\n提取要素:\n- 文件名: li 文本内容 (去掉 &nbsp; 后的内容)\n- 文件大小: span.lightColor 文本内容\n```\n\n#### 元数据栏 (.sbar)\n```html\n<div class=\"sbar\">\n    <span><a href=\"magnet:?xt=urn:btih:186E709110410A995F1A4BECE816D70C5986A5D5\" target=\"_blank\">[磁力链接]</a></span>\n    <span>添加时间:<b>2025-08-19</b></span>\n    <span>大小:<b class=\"cpill yellow-pill\">54.3 GB</b></span>\n    <span>最近下载:<b>2025-08-20</b></span>\n    <span>热度:<b>73</b></span>\n</div>\n\n提取要素:\n- 磁力链接: a[href^=\"magnet:\"] 的 href 属性\n- 添加时间: \"添加时间:\" 后的 b 标签文本\n- 总大小: \"大小:\" 后的 b 标签文本\n- 最近下载: \"最近下载:\" 后的 b 标签文本  \n- 热度: \"热度:\" 后的 b 标签文本\n```\n\n## CSS选择器总结\n\n| 数据项 | CSS选择器 | 提取方式 |\n|--------|-----------|----------|\n| 搜索结果列表 | `.tbox .ssbox` | 遍历所有结果项 |\n| 分类标签 | `.title h3 span` | 文本内容，去掉 `[]` |\n| 标题 | `.title h3 a` | 文本内容，需要清理广告 |\n| 详情页链接 | `.title h3 a` | href 属性 |\n| 文件列表 | `.slist ul li` | 文本内容，分割文件名和大小 |\n| 磁力链接 | `.sbar a[href^=\"magnet:\"]` | href 属性 |\n| 添加时间 | `.sbar span:contains(\"添加时间:\") b` | 文本内容 |\n| 总大小 | `.sbar span:contains(\"大小:\") b` | 文本内容 |\n| 热度 | `.sbar span:contains(\"热度:\") b` | 文本内容 |\n\n## 实现要点\n\n### 1. 标题清理\n- 需要移除 `【xxx】` 格式的广告标记\n- 示例: `【不太灵影视 www.2BT0.com】凡人修仙传[...]` → `凡人修仙传[...]`\n\n### 2. 分类映射\n```\n[影视] → 影视\n[音乐] → 音乐  \n[图像] → 图像\n[文档书籍] → 文档\n[压缩文件] → 压缩包\n[安装包] → 软件\n[其他] → 其他\n```\n\n### 3. 文件列表解析\n- 每个 li 包含: `文件名&nbsp;<span class=\"lightColor\">大小</span>`\n- 需要分离文件名和大小信息\n\n### 4. 时间格式\n- 格式: `YYYY-MM-DD`\n- 需要转换为标准时间格式\n\n### 5. 磁力链接处理\n- 直接从搜索页提取，无需访问详情页\n- 链接格式: `magnet:?xt=urn:btih:[HASH]`\n\n## 搜索参数\n- 支持中文关键词 (需要URL编码)\n- 默认使用全部分类 (0) 和按添加时间排序 (2)\n- 支持分页 (从第1页开始)"
  },
  {
    "path": "plugin/clmao/clmao.go",
    "content": "package clmao\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\n// 常量定义\nconst (\n\t// 基础URL\n\tBaseURL = \"https://www.8800492.xyz\"\n\t\n\t// 搜索URL格式：/search-{keyword}-{category}-{sort}-{page}.html\n\tSearchURL = BaseURL + \"/search-%s-0-2-%d.html\"\n\t\n\t// 默认参数\n\tMaxRetries = 3\n\tTimeoutSeconds = 30\n\t\n\t// 并发控制参数\n\tMaxConcurrency = 10 // 最大并发数\n\tMaxPages = 5        // 最大搜索页数\n)\n\n// 预编译的正则表达式\nvar (\n\t// 磁力链接正则\n\tmagnetLinkRegex = regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9a-fA-F]{40}[^\"'\\s]*`)\n\t\n\t// 文件大小正则\n\tfileSizeRegex = regexp.MustCompile(`(\\d+\\.?\\d*)\\s*(B|KB|MB|GB|TB)`)\n\t\n\t// 数字提取正则\n\tnumberRegex = regexp.MustCompile(`\\d+`)\n)\n\n// 常用UA列表\nvar userAgents = []string{\n\t\"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\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0\",\n}\n\n// ClmaoPlugin 磁力猫搜索插件\ntype ClmaoPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\n// NewClmaoPlugin 创建新的磁力猫插件实例\nfunc NewClmaoPlugin() *ClmaoPlugin {\n\treturn &ClmaoPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"clmao\", 3, true),\n\t}\n}\n\n// Name 返回插件名称\nfunc (p *ClmaoPlugin) Name() string {\n\treturn \"clmao\"\n}\n\n// DisplayName 返回插件显示名称\nfunc (p *ClmaoPlugin) DisplayName() string {\n\treturn \"磁力猫\"\n}\n\n// Description 返回插件描述\nfunc (p *ClmaoPlugin) Description() string {\n\treturn \"磁力猫 - 磁力链接搜索引擎\"\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *ClmaoPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *ClmaoPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实际的搜索实现\nfunc (p *ClmaoPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 1. 首先搜索第一页\n\tfirstPageResults, err := p.searchPage(client, keyword, 1)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索第一页失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 存储所有结果\n\tvar allResults []model.SearchResult\n\tallResults = append(allResults, firstPageResults...)\n\t\n\t// 2. 并发搜索其他页面（第2页到第5页）\n\tif MaxPages > 1 {\n\t\tvar wg sync.WaitGroup\n\t\tvar mu sync.Mutex\n\t\t\n\t\t// 使用信号量控制并发数\n\t\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\t\n\t\t// 存储每页结果\n\t\tpageResults := make(map[int][]model.SearchResult)\n\t\t\n\t\tfor page := 2; page <= MaxPages; page++ {\n\t\t\twg.Add(1)\n\t\t\tgo func(pageNum int) {\n\t\t\t\tdefer wg.Done()\n\t\t\t\t\n\t\t\t\t// 获取信号量\n\t\t\t\tsemaphore <- struct{}{}\n\t\t\t\tdefer func() { <-semaphore }()\n\t\t\t\t\n\t\t\t\t// 添加小延迟避免过于频繁的请求\n\t\t\t\ttime.Sleep(time.Duration(pageNum%3) * 100 * time.Millisecond)\n\t\t\t\t\n\t\t\t\tcurrentPageResults, err := p.searchPage(client, keyword, pageNum)\n\t\t\t\tif err == nil && len(currentPageResults) > 0 {\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\tpageResults[pageNum] = currentPageResults\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t}\n\t\t\t}(page)\n\t\t}\n\t\t\n\t\twg.Wait()\n\t\t\n\t\t// 按页码顺序合并所有页面的结果\n\t\tfor page := 2; page <= MaxPages; page++ {\n\t\t\tif results, exists := pageResults[page]; exists {\n\t\t\t\tallResults = append(allResults, results...)\n\t\t\t}\n\t\t}\n\t}\n\t\n\t\n\t// 3. 关键词过滤\n\tsearchKeyword := keyword\n\tif searchParam, ok := ext[\"search\"]; ok {\n\t\tif searchStr, ok := searchParam.(string); ok && searchStr != \"\" {\n\t\t\tsearchKeyword = searchStr\n\t\t}\n\t}\n\treturn plugin.FilterResultsByKeyword(allResults, searchKeyword), nil\n}\n\n// searchPage 搜索指定页面\nfunc (p *ClmaoPlugin) searchPage(client *http.Client, keyword string, page int) ([]model.SearchResult, error) {\n\t// URL编码关键词\n\tencodedKeyword := url.QueryEscape(keyword)\n\tsearchURL := fmt.Sprintf(SearchURL, encodedKeyword, page)\n\t\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), TimeoutSeconds*time.Second)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 设置请求头\n\tp.setRequestHeaders(req)\n\t\n\t// 发送HTTP请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 读取响应体内容\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 读取响应失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body)))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] HTML解析失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 提取搜索结果\n\treturn p.extractSearchResults(doc), nil\n}\n\n// extractSearchResults 提取搜索结果\nfunc (p *ClmaoPlugin) extractSearchResults(doc *goquery.Document) []model.SearchResult {\n\tvar results []model.SearchResult\n\t\n\t// 查找所有搜索结果\n\tdoc.Find(\".tbox .ssbox\").Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchResult(s)\n\t\tif result.Title != \"\" && len(result.Links) > 0 {\n\t\t\tresults = append(results, result)\n\t\t}\n\t})\n\t\n\treturn results\n}\n\n// parseSearchResult 解析单个搜索结果\nfunc (p *ClmaoPlugin) parseSearchResult(s *goquery.Selection) model.SearchResult {\n\tresult := model.SearchResult{\n\t\tChannel:  \"\", // 插件搜索结果必须为空字符串\n\t\tDatetime: time.Now(),\n\t}\n\t\n\t// 提取标题\n\ttitleSection := s.Find(\".title h3\")\n\ttitleLink := titleSection.Find(\"a\")\n\ttitle := strings.TrimSpace(titleLink.Text())\n\tresult.Title = p.cleanTitle(title)\n\t\n\t// 提取分类作为标签\n\tcategory := strings.TrimSpace(titleSection.Find(\"span\").Text())\n\tif category != \"\" {\n\t\tresult.Tags = []string{p.mapCategory(category)}\n\t}\n\t\n\t// 提取磁力链接和元数据\n\tp.extractMagnetInfo(s, &result)\n\t\n\t// 提取文件列表作为内容\n\tp.extractFileList(s, &result)\n\t\n\t// 生成唯一ID\n\tresult.UniqueID = fmt.Sprintf(\"%s-%d\", p.Name(), time.Now().UnixNano())\n\t\n\treturn result\n}\n\n// extractMagnetInfo 提取磁力链接和元数据\nfunc (p *ClmaoPlugin) extractMagnetInfo(s *goquery.Selection, result *model.SearchResult) {\n\tsbar := s.Find(\".sbar\")\n\t\n\t// 提取磁力链接\n\tmagnetLink, _ := sbar.Find(\"a[href^='magnet:']\").Attr(\"href\")\n\tif magnetLink != \"\" {\n\t\tlink := model.Link{\n\t\t\tType: \"magnet\",\n\t\t\tURL:  magnetLink,\n\t\t}\n\t\tresult.Links = []model.Link{link}\n\t}\n\t\n\t// 提取元数据并添加到内容中\n\tvar metadata []string\n\tsbar.Find(\"span\").Each(func(i int, span *goquery.Selection) {\n\t\ttext := strings.TrimSpace(span.Text())\n\t\t\n\t\tif strings.Contains(text, \"添加时间:\") || \n\t\t   strings.Contains(text, \"大小:\") || \n\t\t   strings.Contains(text, \"热度:\") {\n\t\t\tmetadata = append(metadata, text)\n\t\t}\n\t})\n\t\n\tif len(metadata) > 0 {\n\t\tif result.Content != \"\" {\n\t\t\tresult.Content += \"\\n\\n\"\n\t\t}\n\t\tresult.Content += strings.Join(metadata, \" | \")\n\t}\n}\n\n// extractFileList 提取文件列表\nfunc (p *ClmaoPlugin) extractFileList(s *goquery.Selection, result *model.SearchResult) {\n\tvar files []string\n\t\n\ts.Find(\".slist ul li\").Each(func(i int, li *goquery.Selection) {\n\t\ttext := strings.TrimSpace(li.Text())\n\t\tif text != \"\" {\n\t\t\tfiles = append(files, text)\n\t\t}\n\t})\n\t\n\tif len(files) > 0 {\n\t\tif result.Content != \"\" {\n\t\t\tresult.Content += \"\\n\\n文件列表:\\n\"\n\t\t} else {\n\t\t\tresult.Content = \"文件列表:\\n\"\n\t\t}\n\t\tresult.Content += strings.Join(files, \"\\n\")\n\t}\n}\n\n// mapCategory 映射分类\nfunc (p *ClmaoPlugin) mapCategory(category string) string {\n\tswitch category {\n\tcase \"[影视]\":\n\t\treturn \"video\"\n\tcase \"[音乐]\":\n\t\treturn \"music\"\n\tcase \"[图像]\":\n\t\treturn \"image\"\n\tcase \"[文档书籍]\":\n\t\treturn \"document\"\n\tcase \"[压缩文件]\":\n\t\treturn \"archive\"\n\tcase \"[安装包]\":\n\t\treturn \"software\"\n\tcase \"[其他]\":\n\t\treturn \"others\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n// cleanTitle 清理标题\nfunc (p *ClmaoPlugin) cleanTitle(title string) string {\n\t// 移除【】之间的广告内容\n\ttitle = regexp.MustCompile(`【[^】]*】`).ReplaceAllString(title, \"\")\n\t// 移除[]之间的内容（如有需要）\n\ttitle = regexp.MustCompile(`\\[[^\\]]*\\]`).ReplaceAllString(title, \"\")\n\t// 移除多余的空格\n\ttitle = regexp.MustCompile(`\\s+`).ReplaceAllString(title, \" \")\n\treturn strings.TrimSpace(title)\n}\n\n// setRequestHeaders 设置请求头\nfunc (p *ClmaoPlugin) setRequestHeaders(req *http.Request) {\n\t// 使用第一个稳定的UA\n\tua := userAgents[0]\n\treq.Header.Set(\"User-Agent\", ua)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\t// 暂时不使用压缩编码，避免解压问题\n\t// req.Header.Set(\"Accept-Encoding\", \"gzip, deflate\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"no-cache\")\n\treq.Header.Set(\"Pragma\", \"no-cache\")\n}\n\n// doRequestWithRetry 带重试的HTTP请求\nfunc (p *ClmaoPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tvar lastErr error\n\t\n\tfor i := 0; i < MaxRetries; i++ {\n\t\tresp, err := client.Do(req)\n\t\tif err == nil {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tlastErr = err\n\t\tif i < MaxRetries-1 {\n\t\t\ttime.Sleep(time.Duration(i+1) * time.Second)\n\t\t}\n\t}\n\t\n\treturn nil, fmt.Errorf(\"请求失败，已重试%d次: %w\", MaxRetries, lastErr)\n}\n\n\n\n// init 注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewClmaoPlugin())\n}"
  },
  {
    "path": "plugin/clmao/html结构分析.md",
    "content": "# Clmao (磁力猫) HTML结构分析\n\n## 网站信息\n\n- **网站名称**: 磁力猫 - 磁力搜索引擎\n- **基础URL**: https://www.8800492.xyz/\n- **功能**: BT种子磁力链接搜索\n- **搜索URL格式**: `/search-{keyword}-{category}-{sort}-{page}.html`\n\n## 搜索页面结构\n\n### 1. 搜索URL参数说明\n\n```\nhttps://www.8800492.xyz/search-%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0-0-0-1.html\n                                    ^关键词(URL编码)   ^分类 ^排序 ^页码\n```\n\n**参数说明**:\n- `keyword`: URL编码的搜索关键词\n- `category`: 分类筛选 (0=全部, 1=影视, 2=音乐, 3=图像, 4=文档书籍, 5=压缩文件, 6=安装包, 7=其他)\n- `sort`: 排序方式 (0=相关程度, 1=文件大小, 2=添加时间, 3=热度, 4=最近访问)\n- `page`: 页码 (从1开始)\n\n### 2. 搜索结果容器\n\n```html\n<div class=\"tbox\">\n    <div class=\"ssbox\">\n        <!-- 单个搜索结果 -->\n    </div>\n    <!-- 更多结果... -->\n</div>\n```\n\n### 3. 单个搜索结果结构\n\n#### 标题区域\n```html\n<div class=\"title\">\n    <h3>\n        <span>[影视]</span>  <!-- 分类标签 -->\n        <a href=\"/hash/a6cfa78f3c36e78c7f6342ff12de9590a25db441.html\" target=\"_blank\">\n            19<span class=\"red\">凡人修仙传</span>20<span class=\"red\">凡人修仙传</span>21天龙八部...\n        </a>\n    </h3>\n</div>\n```\n\n#### 文件列表区域\n```html\n<div class=\"slist\">\n    <ul>\n        <li>rw.mp4&nbsp;<span class=\"lightColor\">145.5 MB</span></li>\n        <!-- 更多文件... -->\n    </ul>\n</div>\n```\n\n#### 信息栏区域\n```html\n<div class=\"sbar\">\n    <span><a href=\"magnet:?xt=urn:btih:A6CFA78F3C36E78C7F6342FF12DE9590A25DB441\" target=\"_blank\">[磁力链接]</a></span>\n    <span>添加时间:<b>2022-06-28</b></span>\n    <span>大小:<b class=\"cpill yellow-pill\">145.5 MB</b></span>\n    <span>最近下载:<b>2025-08-19</b></span>\n    <span>热度:<b>2348</b></span>\n</div>\n```\n\n### 4. 分页区域\n\n```html\n<div class=\"pager\">\n    <span>共61页</span>\n    <a href=\"#\">上一页</a>\n    <span>1</span>  <!-- 当前页 -->\n    <a href=\"/search-%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0-0-0-2.html\">2</a>\n    <!-- 更多页码... -->\n    <a href=\"/search-%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0-0-0-2.html\">下一页</a>\n</div>\n```\n\n## 数据提取要点\n\n### 需要提取的信息\n\n1. **搜索结果基本信息**:\n   - 标题: `.title h3 a` 的文本内容\n   - 分类: `.title h3 span` 的文本内容\n   - 详情页链接: `.title h3 a` 的 `href` 属性\n\n2. **磁力链接信息**:\n   - 磁力链接: `.sbar a[href^=\"magnet:\"]` 的 `href` 属性\n   - 文件大小: `.sbar .cpill` 的文本内容\n   - 添加时间: `.sbar` 中 \"添加时间:\" 后的 `<b>` 标签内容\n   - 热度: `.sbar` 中 \"热度:\" 后的 `<b>` 标签内容\n\n3. **文件列表**:\n   - 文件名和大小: `.slist ul li` 的文本内容\n\n### CSS选择器\n\n```css\n/* 搜索结果容器 */\n.tbox .ssbox\n\n/* 标题和分类 */\n.title h3 span    /* 分类 */\n.title h3 a       /* 标题和详情链接 */\n\n/* 磁力链接 */\n.sbar a[href^=\"magnet:\"]\n\n/* 文件信息 */\n.slist ul li\n\n/* 元数据 */\n.sbar span b      /* 时间、大小、热度等 */\n```\n\n## 特殊处理\n\n### 1. 关键词高亮\n搜索关键词在结果中用 `<span class=\"red\">` 标签高亮显示\n\n### 2. 文件大小格式\n文件大小格式多样: `145.5 MB`、`854.2 MB`、`41.5 GB` 等\n\n### 3. 磁力链接格式\n标准磁力链接格式: `magnet:?xt=urn:btih:{40位哈希值}`\n\n### 4. 分类映射\n- [影视] → movie/video\n- [音乐] → music\n- [图像] → image\n- [文档书籍] → document\n- [压缩文件] → archive\n- [安装包] → software\n- [其他] → others\n\n## 请求头要求\n\n建议设置常见的浏览器请求头:\n- User-Agent: 现代浏览器UA\n- Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\n- Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\n\n## 注意事项\n\n1. 网站可能有反爬虫机制，需要适当的请求间隔\n2. 搜索关键词需要进行URL编码\n3. 磁力链接是直接可用的，无需额外处理\n4. 部分结果可能包含大量无关文件，需要进行过滤\n5. 网站域名可能会变更，需要支持域名更新"
  },
  {
    "path": "plugin/clxiong/clxiong.go",
    "content": "package clxiong\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tBaseURL       = \"https://www.cilixiong.org\"\n\tSearchURL     = \"https://www.cilixiong.org/e/search/index.php\"\n\tUserAgent     = \"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\"\n\tMaxRetries    = 3\n\tRetryDelay    = 2 * time.Second\n\tMaxResults    = 30\n)\n\n// DetailPageInfo 详情页信息结构体\ntype DetailPageInfo struct {\n\tMagnetLinks []model.Link\n\tUpdateTime  time.Time\n\tTitle       string\n\tFileNames   []string // 所有文件的名称，与磁力链接对应\n}\n\n// ClxiongPlugin 磁力熊插件\ntype ClxiongPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tdebugMode bool\n}\n\nfunc init() {\n\tp := &ClxiongPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"clxiong\", 2, true), \n\t\tdebugMode:       false, // 开启调试模式检查磁力链接提取问题\n\t}\n\tplugin.RegisterGlobalPlugin(p)\n}\n\n// Search 搜索接口实现\nfunc (p *ClxiongPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 搜索并返回详细结果\nfunc (p *ClxiongPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (*model.PluginSearchResult, error) {\n\tif p.debugMode {\n\t\tlog.Printf(\"[CLXIONG] 开始搜索: %s\", keyword)\n\t}\n\n\t// 第一步：POST搜索获取searchid\n\tsearchID, err := p.getSearchID(keyword)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[CLXIONG] 获取searchid失败: %v\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"获取searchid失败: %v\", err)\n\t}\n\n\t// 第二步：GET搜索结果\n\tresults, err := p.getSearchResults(searchID, keyword)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[CLXIONG] 获取搜索结果失败: %v\", err)\n\t\t}\n\t\treturn nil, err\n\t}\n\n\t// 第三步：同步获取详情页磁力链接\n\tresults = p.fetchDetailLinksSync(results)\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[CLXIONG] 搜索完成，获得 %d 个结果\", len(results))\n\t}\n\n\t// 应用关键词过滤\n\tfilteredResults := plugin.FilterResultsByKeyword(results, keyword)\n\n\treturn &model.PluginSearchResult{\n\t\tResults:   filteredResults,\n\t\tIsFinal:   true,\n\t\tTimestamp: time.Now(),\n\t\tSource:    p.Name(),\n\t\tMessage:   fmt.Sprintf(\"找到 %d 个结果\", len(filteredResults)),\n\t}, nil\n}\n\n// getSearchID 第一步：POST搜索获取searchid\nfunc (p *ClxiongPlugin) getSearchID(keyword string) (string, error) {\n\tif p.debugMode {\n\t\tlog.Printf(\"[CLXIONG] 正在获取searchid...\")\n\t}\n\n\tclient := &http.Client{\n\t\tTimeout: 30 * time.Second,\n\t\tCheckRedirect: func(req *http.Request, via []*http.Request) error {\n\t\t\t// 不自动跟随重定向，我们需要手动处理\n\t\t\treturn http.ErrUseLastResponse\n\t\t},\n\t}\n\n\t// 准备POST数据\n\tformData := url.Values{}\n\tformData.Set(\"classid\", \"1,2\")      // 1=电影，2=剧集\n\tformData.Set(\"show\", \"title\")       // 搜索字段\n\tformData.Set(\"tempid\", \"1\")         // 模板ID\n\tformData.Set(\"keyboard\", keyword)   // 搜索关键词\n\n\treq, err := http.NewRequest(\"POST\", SearchURL, strings.NewReader(formData.Encode()))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\n\tvar resp *http.Response\n\tvar lastErr error\n\n\t// 重试机制\n\tfor i := 0; i < MaxRetries; i++ {\n\t\tresp, lastErr = client.Do(req)\n\t\tif lastErr == nil && (resp.StatusCode == 302 || resp.StatusCode == 301) {\n\t\t\tbreak\n\t\t}\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tif i < MaxRetries-1 {\n\t\t\ttime.Sleep(RetryDelay)\n\t\t}\n\t}\n\n\tif lastErr != nil {\n\t\treturn \"\", lastErr\n\t}\n\tdefer resp.Body.Close()\n\n\t// 检查重定向响应\n\tif resp.StatusCode != 302 && resp.StatusCode != 301 {\n\t\treturn \"\", fmt.Errorf(\"期望302重定向，但得到状态码: %d\", resp.StatusCode)\n\t}\n\n\t// 从Location头部提取searchid\n\tlocation := resp.Header.Get(\"Location\")\n\tif location == \"\" {\n\t\treturn \"\", fmt.Errorf(\"重定向响应中没有Location头部\")\n\t}\n\n\t// 解析searchid\n\tsearchID := p.extractSearchIDFromLocation(location)\n\tif searchID == \"\" {\n\t\treturn \"\", fmt.Errorf(\"无法从Location中提取searchid: %s\", location)\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[CLXIONG] 获取到searchid: %s\", searchID)\n\t}\n\n\treturn searchID, nil\n}\n\n// extractSearchIDFromLocation 从Location头部提取searchid\nfunc (p *ClxiongPlugin) extractSearchIDFromLocation(location string) string {\n\t// location格式: \"result/?searchid=7549\"\n\tre := regexp.MustCompile(`searchid=(\\d+)`)\n\tmatches := re.FindStringSubmatch(location)\n\tif len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// getSearchResults 第二步：GET搜索结果\nfunc (p *ClxiongPlugin) getSearchResults(searchID, keyword string) ([]model.SearchResult, error) {\n\tif p.debugMode {\n\t\tlog.Printf(\"[CLXIONG] 正在获取搜索结果，searchid: %s\", searchID)\n\t}\n\n\t// 构建结果页URL\n\tresultURL := fmt.Sprintf(\"%s/e/search/result/?searchid=%s\", BaseURL, searchID)\n\n\tclient := &http.Client{Timeout: 30 * time.Second}\n\n\treq, err := http.NewRequest(\"GET\", resultURL, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\n\tvar resp *http.Response\n\tvar lastErr error\n\n\t// 重试机制\n\tfor i := 0; i < MaxRetries; i++ {\n\t\tresp, lastErr = client.Do(req)\n\t\tif lastErr == nil && resp.StatusCode == 200 {\n\t\t\tbreak\n\t\t}\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tif i < MaxRetries-1 {\n\t\t\ttime.Sleep(RetryDelay)\n\t\t}\n\t}\n\n\tif lastErr != nil {\n\t\treturn nil, lastErr\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"搜索结果请求失败，状态码: %d\", resp.StatusCode)\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn p.parseSearchResults(string(body))\n}\n\n// parseSearchResults 解析搜索结果页面\nfunc (p *ClxiongPlugin) parseSearchResults(html string) ([]model.SearchResult, error) {\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar results []model.SearchResult\n\n\t// 查找搜索结果项\n\tdoc.Find(\".row.row-cols-2.row-cols-lg-4 .col\").Each(func(i int, s *goquery.Selection) {\n\t\tif i >= MaxResults {\n\t\t\treturn // 限制结果数量\n\t\t}\n\n\t\t// 提取详情页链接\n\t\tlinkEl := s.Find(\"a[href*='/drama/'], a[href*='/movie/']\")\n\t\tif linkEl.Length() == 0 {\n\t\t\treturn // 跳过无链接的项\n\t\t}\n\n\t\tdetailPath, exists := linkEl.Attr(\"href\")\n\t\tif !exists || detailPath == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\t// 构建完整的详情页URL\n\t\tdetailURL := BaseURL + detailPath\n\n\t\t// 提取标题\n\t\ttitle := strings.TrimSpace(linkEl.Find(\"h2.h4\").Text())\n\t\tif title == \"\" {\n\t\t\treturn // 跳过无标题的项\n\t\t}\n\n\t\t// 提取评分\n\t\trating := strings.TrimSpace(s.Find(\".rank\").Text())\n\n\t\t// 提取年份\n\t\tyear := strings.TrimSpace(s.Find(\".small\").Last().Text())\n\n\t\t// 提取海报图片\n\t\tposter := \"\"\n\t\tcardImg := s.Find(\".card-img\")\n\t\tif cardImg.Length() > 0 {\n\t\t\tif style, exists := cardImg.Attr(\"style\"); exists {\n\t\t\t\tposter = p.extractImageFromStyle(style)\n\t\t\t}\n\t\t}\n\n\t\t// 构建内容信息\n\t\tvar contentParts []string\n\t\tif rating != \"\" {\n\t\t\tcontentParts = append(contentParts, \"评分: \"+rating)\n\t\t}\n\t\tif year != \"\" {\n\t\t\tcontentParts = append(contentParts, \"年份: \"+year)\n\t\t}\n\t\tif poster != \"\" {\n\t\t\tcontentParts = append(contentParts, \"海报: \"+poster)\n\t\t}\n\t\t// 添加详情页链接到content中，供后续提取磁力链接使用\n\t\tcontentParts = append(contentParts, \"详情页: \"+detailURL)\n\n\t\tcontent := strings.Join(contentParts, \" | \")\n\n\t\t// 生成唯一ID\n\t\tuniqueID := p.generateUniqueID(detailPath)\n\n\t\tresult := model.SearchResult{\n\t\t\tTitle:    title,\n\t\t\tContent:  content,\n\t\t\tChannel:  \"\", // 插件搜索结果必须为空\n\t\t\tTags:     []string{\"磁力链接\", \"影视\"},\n\t\t\tDatetime: time.Now(), // 搜索时间\n\t\t\tLinks:    []model.Link{}, // 初始为空，后续异步获取\n\t\t\tUniqueID: uniqueID,\n\t\t}\n\n\t\tresults = append(results, result)\n\t})\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[CLXIONG] 解析到 %d 个搜索结果\", len(results))\n\t}\n\n\treturn results, nil\n}\n\n// extractImageFromStyle 从style属性中提取背景图片URL\nfunc (p *ClxiongPlugin) extractImageFromStyle(style string) string {\n\t// style格式: \"background-image: url('https://i.nacloud.cc/2024/12154.webp');\"\n\tre := regexp.MustCompile(`url\\(['\"]?([^'\"]+)['\"]?\\)`)\n\tmatches := re.FindStringSubmatch(style)\n\tif len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// fetchDetailLinksSync 同步获取详情页磁力链接\nfunc (p *ClxiongPlugin) fetchDetailLinksSync(results []model.SearchResult) []model.SearchResult {\n\tif len(results) == 0 {\n\t\treturn results\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[CLXIONG] 开始同步获取 %d 个详情页的磁力链接\", len(results))\n\t}\n\n\t// 使用WaitGroup确保所有请求完成后再返回\n\tvar wg sync.WaitGroup\n\tvar mu sync.Mutex // 保护results切片的互斥锁\n\tvar additionalResults []model.SearchResult // 存储额外创建的搜索结果\n\t\n\t// 限制并发数，避免过多请求\n\tsemaphore := make(chan struct{}, 5) // 最多5个并发请求\n\n\tfor i := range results {\n\t\twg.Add(1)\n\t\tgo func(index int) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\tdetailURL := p.extractDetailURLFromContent(results[index].Content)\n\t\t\tif detailURL != \"\" {\n\t\t\t\tdetailInfo := p.fetchDetailPageInfo(detailURL, results[index].Title)\n\t\t\t\tif detailInfo != nil && len(detailInfo.MagnetLinks) > 0 {\n\t\t\t\t\t// 为每个磁力链接创建独立的搜索结果，这样每个链接都有自己的note\n\t\t\t\t\tbaseResult := results[index]\n\t\t\t\t\t\n\t\t\t\t\t// 第一个链接更新原结果\n\t\t\t\t\tif len(detailInfo.FileNames) > 0 {\n\t\t\t\t\t\tresults[index].Title = fmt.Sprintf(\"%s-%s\", baseResult.Title, detailInfo.FileNames[0])\n\t\t\t\t\t}\n\t\t\t\t\tresults[index].Links = []model.Link{detailInfo.MagnetLinks[0]}\n\t\t\t\t\tif !detailInfo.UpdateTime.IsZero() {\n\t\t\t\t\t\tresults[index].Datetime = detailInfo.UpdateTime\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\t// 其他链接创建新的搜索结果\n\t\t\t\t\tvar newResults []model.SearchResult\n\t\t\t\t\tfor i := 1; i < len(detailInfo.MagnetLinks); i++ {\n\t\t\t\t\t\tnewResult := model.SearchResult{\n\t\t\t\t\t\t\tMessageID: fmt.Sprintf(\"%s-%d\", baseResult.MessageID, i+1),\n\t\t\t\t\t\t\tUniqueID:  fmt.Sprintf(\"%s-%d\", baseResult.UniqueID, i+1),\n\t\t\t\t\t\t\tChannel:   baseResult.Channel,\n\t\t\t\t\t\t\tContent:   baseResult.Content,\n\t\t\t\t\t\t\tTags:      baseResult.Tags,\n\t\t\t\t\t\t\tImages:    baseResult.Images,\n\t\t\t\t\t\t\tLinks:     []model.Link{detailInfo.MagnetLinks[i]},\n\t\t\t\t\t\t}\n\t\t\t\t\t\t\n\t\t\t\t\t\t// 设置独特的标题和时间\n\t\t\t\t\t\tif i < len(detailInfo.FileNames) {\n\t\t\t\t\t\t\tnewResult.Title = fmt.Sprintf(\"%s-%s\", baseResult.Title, detailInfo.FileNames[i])\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tnewResult.Title = baseResult.Title\n\t\t\t\t\t\t}\n\t\t\t\t\t\t\n\t\t\t\t\t\tif !detailInfo.UpdateTime.IsZero() {\n\t\t\t\t\t\t\tnewResult.Datetime = detailInfo.UpdateTime\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tnewResult.Datetime = baseResult.Datetime\n\t\t\t\t\t\t}\n\t\t\t\t\t\t\n\t\t\t\t\t\tnewResults = append(newResults, newResult)\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\t// 使用锁保护切片的修改\n\t\t\t\t\tif len(newResults) > 0 {\n\t\t\t\t\t\tmu.Lock()\n\t\t\t\t\t\tadditionalResults = append(additionalResults, newResults...)\n\t\t\t\t\t\tmu.Unlock()\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\tif p.debugMode {\n\t\t\t\t\t\tlog.Printf(\"[CLXIONG] 为结果 %d 获取到 %d 个磁力链接，创建了 %d 个搜索结果\", index+1, len(detailInfo.MagnetLinks), len(detailInfo.MagnetLinks))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}(i)\n\t}\n\t\n\t// 等待所有goroutine完成\n\twg.Wait()\n\t\n\t// 合并额外创建的搜索结果\n\tresults = append(results, additionalResults...)\n\t\n\tif p.debugMode {\n\t\ttotalLinks := 0\n\t\tfor _, result := range results {\n\t\t\ttotalLinks += len(result.Links)\n\t\t}\n\t\tlog.Printf(\"[CLXIONG] 所有磁力链接获取完成，共获得 %d 个磁力链接，总搜索结果 %d 个\", totalLinks, len(results))\n\t}\n\t\n\treturn results\n}\n\n// extractDetailURLFromContent 从content中提取详情页URL\nfunc (p *ClxiongPlugin) extractDetailURLFromContent(content string) string {\n\t// 查找\"详情页: URL\"模式\n\tre := regexp.MustCompile(`详情页: (https?://[^\\s|]+)`)\n\tmatches := re.FindStringSubmatch(content)\n\tif len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// fetchDetailPageInfo 获取详情页的完整信息\nfunc (p *ClxiongPlugin) fetchDetailPageInfo(detailURL string, movieTitle string) *DetailPageInfo {\n\tif p.debugMode {\n\t\tlog.Printf(\"[CLXIONG] 正在获取详情页信息: %s\", detailURL)\n\t}\n\n\tclient := &http.Client{Timeout: 20 * time.Second}\n\n\treq, err := http.NewRequest(\"GET\", detailURL, nil)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[CLXIONG] 创建详情页请求失败: %v\", err)\n\t\t}\n\t\treturn nil\n\t}\n\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[CLXIONG] 详情页请求失败: %v\", err)\n\t\t}\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[CLXIONG] 详情页HTTP状态错误: %d\", resp.StatusCode)\n\t\t}\n\t\treturn nil\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[CLXIONG] 读取详情页响应失败: %v\", err)\n\t\t}\n\t\treturn nil\n\t}\n\n\treturn p.parseDetailPageInfo(string(body), movieTitle)\n}\n\n// parseDetailPageInfo 从详情页HTML中解析完整信息\nfunc (p *ClxiongPlugin) parseDetailPageInfo(html string, movieTitle string) *DetailPageInfo {\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[CLXIONG] 解析详情页HTML失败: %v\", err)\n\t\t}\n\t\treturn nil\n\t}\n\n\tdetailInfo := &DetailPageInfo{\n\t\tTitle: movieTitle,\n\t}\n\n\t// 解析更新时间\n\tdetailInfo.UpdateTime = p.parseUpdateTimeFromDetail(doc)\n\n\t// 解析磁力链接\n\tmagnetLinks, fileNames := p.parseMagnetLinksFromDetailDoc(doc, movieTitle)\n\tdetailInfo.MagnetLinks = magnetLinks\n\tdetailInfo.FileNames = fileNames\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[CLXIONG] 详情页解析完成: 磁力链接 %d 个，更新时间: %v\", \n\t\t\tlen(detailInfo.MagnetLinks), detailInfo.UpdateTime)\n\t}\n\n\treturn detailInfo\n}\n\n// parseUpdateTimeFromDetail 从详情页解析更新时间\nfunc (p *ClxiongPlugin) parseUpdateTimeFromDetail(doc *goquery.Document) time.Time {\n\t// 查找\"最后更新于：2025-08-16\"这样的文本\n\tvar updateTime time.Time\n\t\n\tdoc.Find(\".mv_detail p\").Each(func(i int, s *goquery.Selection) {\n\t\ttext := strings.TrimSpace(s.Text())\n\t\tif strings.Contains(text, \"最后更新于：\") {\n\t\t\t// 提取日期部分\n\t\t\tdateStr := strings.Replace(text, \"最后更新于：\", \"\", 1)\n\t\t\tdateStr = strings.TrimSpace(dateStr)\n\t\t\t\n\t\t\t// 解析日期，支持多种格式\n\t\t\tlayouts := []string{\n\t\t\t\t\"2006-01-02\",\n\t\t\t\t\"2006-1-2\",\n\t\t\t\t\"2006/01/02\",\n\t\t\t\t\"2006/1/2\",\n\t\t\t}\n\t\t\t\n\t\t\tfor _, layout := range layouts {\n\t\t\t\tif t, err := time.Parse(layout, dateStr); err == nil {\n\t\t\t\t\tupdateTime = t\n\t\t\t\t\tif p.debugMode {\n\t\t\t\t\t\tlog.Printf(\"[CLXIONG] 解析到更新时间: %s -> %v\", dateStr, updateTime)\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[CLXIONG] 无法解析更新时间: %s\", dateStr)\n\t\t\t}\n\t\t}\n\t})\n\t\n\treturn updateTime\n}\n\n// parseMagnetLinksFromDetailDoc 从详情页DOM解析磁力链接\nfunc (p *ClxiongPlugin) parseMagnetLinksFromDetailDoc(doc *goquery.Document, movieTitle string) ([]model.Link, []string) {\n\tvar links []model.Link\n\tvar fileNames []string\n\n\tif p.debugMode {\n\t\t// 调试：检查是否找到磁力下载区域\n\t\tmvDown := doc.Find(\".mv_down\")\n\t\tlog.Printf(\"[CLXIONG] 找到 .mv_down 区域数量: %d\", mvDown.Length())\n\t\t\n\t\t// 调试：检查磁力链接数量\n\t\tmagnetLinks := doc.Find(\".mv_down a[href^='magnet:']\")\n\t\tlog.Printf(\"[CLXIONG] 找到磁力链接数量: %d\", magnetLinks.Length())\n\t\t\n\t\t// 如果没找到，尝试其他可能的选择器\n\t\tif magnetLinks.Length() == 0 {\n\t\t\tallMagnetLinks := doc.Find(\"a[href^='magnet:']\")\n\t\t\tlog.Printf(\"[CLXIONG] 页面总磁力链接数量: %d\", allMagnetLinks.Length())\n\t\t}\n\t}\n\n\t// 查找磁力链接\n\tdoc.Find(\".mv_down a[href^='magnet:']\").Each(func(i int, s *goquery.Selection) {\n\t\thref, exists := s.Attr(\"href\")\n\t\tif exists && href != \"\" {\n\t\t\t// 获取文件名（链接文本）\n\t\t\tfileName := strings.TrimSpace(s.Text())\n\t\t\t\n\t\t\tlink := model.Link{\n\t\t\t\tURL:  href,\n\t\t\t\tType: \"magnet\",\n\t\t\t}\n\n\t\t\t// 磁力链接密码字段设置为空（按用户要求）\n\t\t\tlink.Password = \"\"\n\n\t\t\tlinks = append(links, link)\n\t\t\tfileNames = append(fileNames, fileName)\n\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[CLXIONG] 找到磁力链接: %s\", fileName)\n\t\t\t}\n\t\t}\n\t})\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[CLXIONG] 详情页共找到 %d 个磁力链接\", len(links))\n\t}\n\n\treturn links, fileNames\n}\n\n// generateUniqueID 生成唯一ID\nfunc (p *ClxiongPlugin) generateUniqueID(detailPath string) string {\n\t// 从路径中提取ID，如 \"/drama/4466.html\" -> \"4466\"\n\tre := regexp.MustCompile(`/(?:drama|movie)/(\\d+)\\.html`)\n\tmatches := re.FindStringSubmatch(detailPath)\n\tif len(matches) > 1 {\n\t\treturn fmt.Sprintf(\"clxiong-%s\", matches[1])\n\t}\n\t\n\t// 备用方案：使用完整路径生成哈希\n\thash := 0\n\tfor _, char := range detailPath {\n\t\thash = hash*31 + int(char)\n\t}\n\tif hash < 0 {\n\t\thash = -hash\n\t}\n\treturn fmt.Sprintf(\"clxiong-%d\", hash)\n}"
  },
  {
    "path": "plugin/clxiong/html结构分析.md",
    "content": "# 磁力熊(CiLiXiong) HTML结构分析文档\n\n## 网站信息\n- **域名**: `www.cilixiong.org`  \n- **名称**: 磁力熊\n- **类型**: 影视磁力链接搜索网站\n- **特点**: 两步式搜索流程，需要先POST获取searchid，再GET搜索结果\n\n## 搜索流程分析\n\n### 第一步：提交搜索请求\n#### 请求信息\n- **URL**: `https://www.cilixiong.org/e/search/index.php`\n- **方法**: POST\n- **Content-Type**: `application/x-www-form-urlencoded`\n- **Referer**: `https://www.cilixiong.org/`\n\n#### POST参数\n```\nclassid=1%2C2&show=title&tempid=1&keyboard={URL编码的关键词}\n```\n参数说明：\n- `classid=1,2` - 搜索分类（1=电影，2=剧集）\n- `show=title` - 搜索字段\n- `tempid=1` - 模板ID\n- `keyboard` - 搜索关键词（需URL编码）\n\n#### 响应处理\n- **状态码**: 302重定向\n- **关键信息**: 从响应头`Location`字段获取searchid\n- **格式**: `result/?searchid=7549`\n\n### 第二步：获取搜索结果\n#### 请求信息  \n- **URL**: `https://www.cilixiong.org/e/search/result/?searchid={searchid}`\n- **方法**: GET\n- **Referer**: `https://www.cilixiong.org/`\n\n## 搜索结果页面结构\n\n### 页面布局\n- **容器**: `.container`\n- **结果提示**: `.text-white.py-3` - 显示\"找到 X 条符合搜索条件\"\n- **结果网格**: `.row.row-cols-2.row-cols-lg-4.align-items-stretch.g-4.py-2`\n\n### 单个结果项结构\n```html\n<div class=\"col\">\n    <div class=\"card card-cover h-100 overflow-hidden text-bg-dark rounded-4 shadow-lg position-relative\">\n        <a href=\"/drama/4466.html\">\n            <div class=\"card-img\" style=\"background-image: url('海报图片URL');\"><span></span></div>\n            <div class=\"card-body position-absolute d-flex w-100 flex-column text-white\">\n                <h2 class=\"pt-5 lh-1 pb-2 h4\">影片标题</h2>\n                <ul class=\"d-flex list-unstyled mb-0\">\n                    <li class=\"me-auto\"><span class=\"rank bg-success p-1\">8.9</span></li>\n                    <li class=\"d-flex align-items-center small\">2025</li>\n                </ul>\n            </div>\n        </a>\n    </div>\n</div>\n```\n\n### 数据提取选择器\n\n#### 结果列表\n- **选择器**: `.row.row-cols-2.row-cols-lg-4 .col`\n- **排除**: 空白或无效的卡片\n\n#### 单项数据提取\n1. **详情链接**: `.col a[href*=\"/drama/\"]` 或 `.col a[href*=\"/movie/\"]`\n2. **标题**: `.col h2.h4`\n3. **评分**: `.col .rank`\n4. **年份**: `.col .small`（最后一个li元素）\n5. **海报**: `.col .card-img[style*=\"background-image\"]` - 从style属性提取url\n\n#### 链接格式\n- 电影：`/movie/ID.html`\n- 剧集：`/drama/ID.html`\n- 需补全为绝对URL：`https://www.cilixiong.org/drama/ID.html`\n\n## 详情页面结构\n\n### 基本信息区域\n```html\n<div class=\"mv_detail lh-2 px-3\">\n    <p class=\"mb-2\"><h1>影片标题</h1></p>\n    <p class=\"mb-2\">豆瓣评分: <span class=\"db_rank\">8.9</span></p>\n    <p class=\"mb-2\">又名：英文名称</p>\n    <p class=\"mb-2\">上映日期：2025-05-25(美国)</p>\n    <p class=\"mb-2\">类型：|喜剧|冒险|科幻|动画|</p>\n    <p class=\"mb-2\">单集片长：22分钟</p>\n    <p class=\"mb-2\">上映地区：美国</p>\n    <p class=\"mb-2\">主演：演员列表</p>\n</div>\n```\n\n### 磁力链接区域\n```html\n<div class=\"mv_down p-5 pb-3 rounded-4 text-center\">\n    <h2 class=\"h6 pb-3\">影片名磁力下载地址</h2>\n    <div class=\"container\">\n        <div class=\"border-bottom pt-2 pb-4 mb-3\">\n            <a href=\"magnet:?xt=urn:btih:HASH\">文件名.mkv[文件大小]</a>\n            <a class=\"ms-3 text-muted small\" href=\"/magnet.php?url=...\" target=\"_blank\">详情</a>\n        </div>\n    </div>\n</div>\n```\n\n### 磁力链接提取\n- **容器**: `.mv_down .container`\n- **链接项**: `.border-bottom`\n- **磁力链接**: `a[href^=\"magnet:\"]`\n- **文件名**: 链接的文本内容\n- **大小信息**: 通常包含在文件名的方括号中\n\n## 错误处理\n\n### 常见问题\n1. **搜索无结果**: 页面会显示\"找到 0 条符合搜索条件\"\n2. **searchid失效**: 可能需要重新发起搜索请求\n3. **详情页无磁力链接**: 某些内容可能暂时无下载资源\n\n### 限流检测\n- **状态码**: 检测429或403状态码\n- **页面内容**: 检测是否包含\"访问频繁\"等提示\n\n## 实现要点\n\n### 请求头设置\n```http\nUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\nContent-Type: application/x-www-form-urlencoded (POST请求)\nReferer: https://www.cilixiong.org/\n```\n\n### Cookie处理\n- 网站可能需要维持会话状态\n- 建议在客户端中启用Cookie存储\n\n### 搜索策略\n1. **首次搜索**: POST提交 → 解析Location → GET结果页\n2. **结果解析**: 提取基本信息，构建搜索结果\n3. **详情获取**: 可选，异步获取磁力链接\n\n### 数据字段映射\n- **Title**: 影片中文标题\n- **Content**: 评分、年份、类型等信息组合\n- **UniqueID**: 使用详情页URL的ID部分\n- **Links**: 磁力链接数组\n- **Tags**: 影片类型标签\n\n## 技术注意事项\n\n### URL编码\n- 搜索关键词必须进行URL编码\n- 中文字符使用UTF-8编码\n\n### 重定向处理\n- POST请求会返回302重定向\n- 需要从响应头提取Location信息\n- 不要自动跟随重定向，需要手动解析\n\n### 异步处理\n- 搜索结果可以先返回基本信息\n- 磁力链接通过异步请求详情页获取\n- 设置合理的并发限制和超时时间"
  },
  {
    "path": "plugin/cyg/cyg.go",
    "content": "package cyg\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"html\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\n// 预编译的正则表达式（性能优化）\nvar (\n\t// 常见网盘链接的正则表达式（支持15+种类型）\n\tquarkLinkRegex      = regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9a-zA-Z]+`)\n\tucLinkRegex         = regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9a-zA-Z]+`)\n\tbaiduLinkRegex      = regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9a-zA-Z_\\-]+`)\n\taliyunLinkRegex     = regexp.MustCompile(`https?://(www\\.)?(aliyundrive\\.com|alipan\\.com)/s/[0-9a-zA-Z]+`)\n\txunleiLinkRegex     = regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9a-zA-Z_\\-]+`)\n\ttianyiLinkRegex     = regexp.MustCompile(`https?://cloud\\.189\\.cn/t/[0-9a-zA-Z]+`)\n\tlink115Regex        = regexp.MustCompile(`https?://115\\.com/s/[0-9a-zA-Z]+`)\n\tmobileLinkRegex     = regexp.MustCompile(`https?://(caiyun\\.feixin\\.10086\\.cn|caiyun\\.139\\.com|yun\\.139\\.com|cloud\\.139\\.com|pan\\.139\\.com)/.*`)\n\tlink123Regex        = regexp.MustCompile(`https?://123pan\\.com/s/[0-9a-zA-Z]+`)\n\tpikpakLinkRegex     = regexp.MustCompile(`https?://mypikpak\\.com/s/[0-9a-zA-Z]+`)\n\tmagnetLinkRegex     = regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9a-fA-F]{40}`)\n\ted2kLinkRegex       = regexp.MustCompile(`ed2k://\\|file\\|.+\\|\\d+\\|[0-9a-fA-F]{32}\\|/`)\n\n\t// HTML标签清理\n\thtmlTagRegex = regexp.MustCompile(`<[^>]*>`)\n)\n\n// CygPlugin CYG插件结构体\ntype CygPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\n// CygPost 搜索结果结构体\ntype CygPost struct {\n\tID       int    `json:\"id\"`\n\tDate     string `json:\"date\"`\n\tTitle    struct {\n\t\tRendered string `json:\"rendered\"`\n\t} `json:\"title\"`\n\tExcerpt struct {\n\t\tRendered string `json:\"rendered\"`\n\t} `json:\"excerpt\"`\n\tLink         string `json:\"link\"`\n\tCategoryName string `json:\"category_name\"`\n\tAuthorName   string `json:\"author_name\"`\n\tPageviews    int    `json:\"pageviews\"`\n\tLikeCount    int    `json:\"like_count\"`\n}\n\n// CygDownload 下载链接结构体\ntype CygDownload struct {\n\tName        string `json:\"name\"`        // 网盘类型名称\n\tURL         string `json:\"url\"`         // 网盘链接\n\tDownloadPwd string `json:\"downloadPwd\"` // 提取密码\n\tExtractPwd  string `json:\"extractPwd\"`  // 解压密码\n\tID          string `json:\"id\"`          // 链接ID\n}\n\n// CygSearchOptions 搜索选项\ntype CygSearchOptions struct {\n\tPerPage int    // 每页结果数 (默认: 20)\n\tPage    int    // 页码 (默认: 1)\n\tOrderBy string // 排序字段 (默认: date)\n\tOrder   string // 排序方向 (默认: desc)\n}\n\n// init 注册插件\nfunc init() {\n\tp := &CygPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"cyg\", 3), // 优先级3，标准质量数据源\n\t}\n\tplugin.RegisterGlobalPlugin(p)\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *CygPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果（推荐方法）\nfunc (p *CygPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 搜索实现逻辑\nfunc (p *CygPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 解析扩展参数\n\topts := p.parseExtOptions(ext)\n\n\t// 1. 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"https://cyg.app/wp-json/wp/v2/posts?per_page=%d&orderby=%s&order=%s&page=%d&search=%s\",\n\t\topts.PerPage, opts.OrderBy, opts.Order, opts.Page, url.QueryEscape(keyword))\n\n\t// 2. 发送搜索请求\n\tposts, err := p.fetchSearchResults(client, searchURL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\n\tif len(posts) == 0 {\n\t\treturn []model.SearchResult{}, nil\n\t}\n\n\t// 3. 并发获取每个帖子的下载链接\n\tresults := p.fetchDownloadLinksAsync(client, posts, keyword)\n\n\t// 4. 关键词过滤\n\tfilteredResults := plugin.FilterResultsByKeyword(results, keyword)\n\n\treturn filteredResults, nil\n}\n\n// fetchSearchResults 获取搜索结果列表\nfunc (p *CygPlugin) fetchSearchResults(client *http.Client, searchURL string) ([]CygPost, error) {\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\t// 创建请求对象\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建请求失败: %w\", err)\n\t}\n\n\t// 设置请求头\n\tp.setRequestHeaders(req)\n\n\t// 发送请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"HTTP请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"HTTP错误状态码: %d\", resp.StatusCode)\n\t}\n\n\t// 解析响应\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"读取响应失败: %w\", err)\n\t}\n\n\tvar posts []CygPost\n\tif err := json.Unmarshal(body, &posts); err != nil {\n\t\treturn nil, fmt.Errorf(\"JSON解析失败: %w\", err)\n\t}\n\n\treturn posts, nil\n}\n\n// fetchDownloadLinksAsync 并发获取下载链接\nfunc (p *CygPlugin) fetchDownloadLinksAsync(client *http.Client, posts []CygPost, keyword string) []model.SearchResult {\n\tvar wg sync.WaitGroup\n\tresultChan := make(chan model.SearchResult, len(posts))\n\n\t// 限制并发数量\n\tsemaphore := make(chan struct{}, 10) // 最多10个并发\n\n\tfor _, post := range posts {\n\t\twg.Add(1)\n\t\tgo func(p *CygPlugin, post CygPost) {\n\t\t\tdefer wg.Done()\n\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\n\t\t\t// 获取下载链接\n\t\t\tlinks, err := p.getDownloadLinks(client, post.ID)\n\t\t\tif err != nil {\n\t\t\t\t// 记录错误但不影响其他结果\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 只返回有效链接的结果\n\t\t\tif len(links) > 0 {\n\t\t\t\tresult := p.convertToSearchResult(post, links)\n\t\t\t\tresultChan <- result\n\t\t\t}\n\t\t}(p, post)\n\t}\n\n\t// 等待所有goroutine完成\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultChan)\n\t}()\n\n\t// 收集结果\n\tvar results []model.SearchResult\n\tfor result := range resultChan {\n\t\tresults = append(results, result)\n\t}\n\n\treturn results\n}\n\n// getDownloadLinks 获取指定帖子的下载链接\nfunc (p *CygPlugin) getDownloadLinks(client *http.Client, postID int) ([]model.Link, error) {\n\t// 构建下载链接获取URL\n\tdownloadURL := fmt.Sprintf(\"https://cyg.app/wp-json/acg-studio/v1/download?id=%d\", postID)\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\t// 创建请求对象\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", downloadURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建下载链接请求失败: %w\", err)\n\t}\n\n\t// 设置请求头\n\tp.setRequestHeaders(req)\n\n\t// 发送请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"下载链接请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"下载链接请求状态码: %d\", resp.StatusCode)\n\t}\n\n\t// 解析响应\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"读取下载链接响应失败: %w\", err)\n\t}\n\n\tvar downloadData []CygDownload\n\tif err := json.Unmarshal(body, &downloadData); err != nil {\n\t\treturn nil, fmt.Errorf(\"下载链接JSON解析失败: %w\", err)\n\t}\n\n\t// 转换为model.Link格式\n\treturn p.convertToLinks(downloadData), nil\n}\n\n// convertToSearchResult 转换为标准搜索结果格式\nfunc (p *CygPlugin) convertToSearchResult(post CygPost, links []model.Link) model.SearchResult {\n\treturn model.SearchResult{\n\t\tUniqueID:  fmt.Sprintf(\"cyg-%d\", post.ID),\n\t\tTitle:     p.cleanHTML(post.Title.Rendered),\n\t\tContent:   p.cleanHTML(post.Excerpt.Rendered),\n\t\tDatetime:  p.parseDateTime(post.Date),\n\t\tTags:      []string{post.CategoryName},\n\t\tLinks:     links,\n\t\tChannel:   \"\", // 插件搜索结果必须为空字符串\n\t}\n}\n\n// convertToLinks 转换下载链接数据\nfunc (p *CygPlugin) convertToLinks(downloadData []CygDownload) []model.Link {\n\tlinks := make([]model.Link, 0, len(downloadData))\n\tfor _, item := range downloadData {\n\t\t// 优先使用URL模式匹配，fallback到名称映射\n\t\tlinkType := p.determineCloudTypeByURL(item.URL)\n\t\tif linkType == \"others\" {\n\t\t\tlinkType = p.determineCloudType(item.Name)\n\t\t}\n\n\t\tlink := model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      item.URL,\n\t\t\tPassword: item.DownloadPwd, // 提取密码\n\t\t}\n\t\tlinks = append(links, link)\n\t}\n\treturn links\n}\n\n// determineCloudTypeByURL 根据URL确定网盘类型（支持15+种类型）\nfunc (p *CygPlugin) determineCloudTypeByURL(url string) string {\n\tswitch {\n\tcase quarkLinkRegex.MatchString(url):\n\t\treturn \"quark\"\n\tcase ucLinkRegex.MatchString(url):\n\t\treturn \"uc\"\n\tcase baiduLinkRegex.MatchString(url):\n\t\treturn \"baidu\"\n\tcase aliyunLinkRegex.MatchString(url):\n\t\treturn \"aliyun\"\n\tcase xunleiLinkRegex.MatchString(url):\n\t\treturn \"xunlei\"\n\tcase tianyiLinkRegex.MatchString(url):\n\t\treturn \"tianyi\"\n\tcase link115Regex.MatchString(url):\n\t\treturn \"115\"\n\tcase mobileLinkRegex.MatchString(url):\n\t\treturn \"mobile\"\n\tcase link123Regex.MatchString(url):\n\t\treturn \"123\"\n\tcase pikpakLinkRegex.MatchString(url):\n\t\treturn \"pikpak\"\n\tcase magnetLinkRegex.MatchString(url):\n\t\treturn \"magnet\"\n\tcase ed2kLinkRegex.MatchString(url):\n\t\treturn \"ed2k\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n// determineCloudType 根据名称确定网盘类型（支持15+种网盘类型的名称映射）\nfunc (p *CygPlugin) determineCloudType(name string) string {\n\tswitch strings.ToLower(strings.TrimSpace(name)) {\n\tcase \"夸克\", \"夸克网盘\":\n\t\treturn \"quark\"\n\tcase \"uc\", \"uc网盘\":\n\t\treturn \"uc\"\n\tcase \"百度网盘\", \"百度\", \"baidu\":\n\t\treturn \"baidu\"\n\tcase \"阿里云盘\", \"阿里\", \"aliyun\", \"阿里网盘\":\n\t\treturn \"aliyun\"\n\tcase \"迅雷\", \"迅雷网盘\", \"xunlei\":\n\t\treturn \"xunlei\"\n\tcase \"天翼\", \"天翼云盘\", \"189\", \"189云盘\":\n\t\treturn \"tianyi\"\n\tcase \"115\", \"115网盘\":\n\t\treturn \"115\"\n\tcase \"移动云盘\", \"移动\", \"mobile\", \"和彩云\", \"139云盘\", \"139\", \"中国移动云盘\":\n\t\treturn \"mobile\"\n\tcase \"123网盘\", \"123pan\", \"123\":\n\t\treturn \"123\"\n\tcase \"pikpak\", \"pikpak网盘\":\n\t\treturn \"pikpak\"\n\tcase \"磁力链接\", \"magnet\":\n\t\treturn \"magnet\"\n\tcase \"ed2k\":\n\t\treturn \"ed2k\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n// setRequestHeaders 设置请求头\nfunc (p *CygPlugin) setRequestHeaders(req *http.Request) {\n\treq.Header.Set(\"Referer\", \"https://h5.acgn.my/\")\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *CygPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n\n// parseExtOptions 从ext参数中解析搜索选项\nfunc (p *CygPlugin) parseExtOptions(ext map[string]interface{}) CygSearchOptions {\n\topts := CygSearchOptions{\n\t\tPerPage: 20,\n\t\tPage:    1,\n\t\tOrderBy: \"date\",\n\t\tOrder:   \"desc\",\n\t}\n\n\tif ext == nil {\n\t\treturn opts\n\t}\n\n\tif perPage, ok := ext[\"per_page\"].(int); ok && perPage > 0 {\n\t\topts.PerPage = perPage\n\t}\n\n\tif page, ok := ext[\"page\"].(int); ok && page > 0 {\n\t\topts.Page = page\n\t}\n\n\tif orderBy, ok := ext[\"order_by\"].(string); ok && orderBy != \"\" {\n\t\topts.OrderBy = orderBy\n\t}\n\n\tif order, ok := ext[\"order\"].(string); ok && order != \"\" {\n\t\topts.Order = order\n\t}\n\n\treturn opts\n}\n\n// cleanHTML 清理HTML标签和实体编码\nfunc (p *CygPlugin) cleanHTML(htmlContent string) string {\n\t// 移除HTML标签\n\ttext := htmlTagRegex.ReplaceAllString(htmlContent, \"\")\n\n\t// 解码HTML实体\n\ttext = html.UnescapeString(text)\n\n\t// 清理多余空白\n\ttext = strings.TrimSpace(text)\n\n\t// 替换多个空白字符为单个空格\n\ttext = regexp.MustCompile(`\\s+`).ReplaceAllString(text, \" \")\n\n\treturn text\n}\n\n// parseDateTime 解析时间字符串\nfunc (p *CygPlugin) parseDateTime(dateStr string) time.Time {\n\t// 尝试解析ISO 8601格式\n\tif t, err := time.Parse(time.RFC3339, dateStr); err == nil {\n\t\treturn t\n\t}\n\n\t// 尝试解析其他常见格式\n\tformats := []string{\n\t\t\"2006-01-02T15:04:05\",\n\t\t\"2006-01-02 15:04:05\",\n\t\t\"2006-01-02\",\n\t}\n\n\tfor _, format := range formats {\n\t\tif t, err := time.Parse(format, dateStr); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\n\t// 解析失败时返回当前时间\n\treturn time.Now()\n}"
  },
  {
    "path": "plugin/daishudj/daishudj.go",
    "content": "package daishudj\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nvar (\n\tidRegex    = regexp.MustCompile(`/(\\d+)/`)\n\ttextURLReg = regexp.MustCompile(`https?://[^\\s<>\"']+`)\n\n\tlinkPatterns = []struct {\n\t\treg *regexp.Regexp\n\t\ttyp string\n\t}{\n\t\t{regexp.MustCompile(`https?://pan\\.quark\\.cn/(s|g)/[0-9A-Za-z]+`), \"quark\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?(aliyundrive\\.com|alipan\\.com)/s/[0-9A-Za-z]+`), \"aliyun\"},\n\t\t{regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9A-Za-z\\-_]+`), \"baidu\"},\n\t\t{regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9A-Za-z\\-_]+`), \"xunlei\"},\n\t\t{regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9A-Za-z]+`), \"uc\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?mypikpak\\.com/s/[0-9A-Za-z]+`), \"pikpak\"},\n\t\t{regexp.MustCompile(`https?://caiyun\\.139\\.com/[^\\s]+`), \"mobile\"},\n\t\t{regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9A-Za-z]+`), \"magnet\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?(123pan\\.com|123pan\\.cn|123684\\.com|123685\\.com|123912\\.com|123592\\.com)/s/[0-9A-Za-z]+`), \"123\"},\n\t}\n\n\tpasswordPatterns = []*regexp.Regexp{\n\t\tregexp.MustCompile(`提取码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`密码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`pwd\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`code\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t}\n\n\tdetailCache          = sync.Map{}\n\tcacheTTL             = 1 * time.Hour\n\tcacheCleanupInterval = 30 * time.Minute\n)\n\ntype cacheEntry struct {\n\tlinks     []model.Link\n\texpiresAt time.Time\n}\n\nconst (\n\tpluginName            = \"daishudj\"\n\tdefaultPriority       = 3\n\tsearchTimeout         = 10 * time.Second\n\tdetailTimeout         = 8 * time.Second\n\tmaxConcurrency        = 10\n\tmaxIdleConns          = 64\n\tmaxIdlePerHost        = 16\n\tmaxConnsPerHost       = 32\n\tidleConnLifetime      = 90 * time.Second\n\ttlsHandshakeTimeout   = 10 * time.Second\n\texpectContinueTimeout = 1 * time.Second\n\tmaxRetries            = 3\n\tretryBaseDelay        = 200 * time.Millisecond\n)\n\n// DaishuPlugin 袋鼠短剧插件\ntype DaishuPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tclient *http.Client\n}\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewDaishuPlugin())\n\tgo startCacheCleaner()\n}\n\n// NewDaishuPlugin 构造函数\nfunc NewDaishuPlugin() *DaishuPlugin {\n\treturn &DaishuPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),\n\t\tclient:          newHTTPClient(),\n\t}\n}\n\n// Search 兼容方法\nfunc (p *DaishuPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 主流程\nfunc (p *DaishuPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\nfunc newHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:          maxIdleConns,\n\t\tMaxIdleConnsPerHost:   maxIdlePerHost,\n\t\tMaxConnsPerHost:       maxConnsPerHost,\n\t\tIdleConnTimeout:       idleConnLifetime,\n\t\tTLSHandshakeTimeout:   tlsHandshakeTimeout,\n\t\tExpectContinueTimeout: expectContinueTimeout,\n\t\tForceAttemptHTTP2:     true,\n\t}\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   searchTimeout,\n\t}\n}\n\nfunc (p *DaishuPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif p.client != nil {\n\t\tclient = p.client\n\t}\n\n\tsearchURL := fmt.Sprintf(\"https://www.daishuduanju.com/?s=%s\", url.QueryEscape(keyword))\n\tctx, cancel := context.WithTimeout(context.Background(), searchTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建搜索请求失败: %w\", p.Name(), err)\n\t}\n\tsetCommonHeaders(req, \"https://www.daishuduanju.com/\")\n\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\n\tvar (\n\t\tresults []model.SearchResult\n\t\twg      sync.WaitGroup\n\t\tmu      sync.Mutex\n\t\tsem     = make(chan struct{}, maxConcurrency)\n\t)\n\n\tdoc.Find(\".item-jx.item-blog\").Each(func(_ int, item *goquery.Selection) {\n\t\ttitleSel := item.Find(\".subtitle h5 a\")\n\t\ttitle := strings.TrimSpace(titleSel.Text())\n\t\tdetailURL, ok := titleSel.Attr(\"href\")\n\t\tif !ok || title == \"\" || detailURL == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tpostID := extractPostID(detailURL)\n\t\tif postID == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tsummary := strings.TrimSpace(item.Find(\".subtitle p.pdesc\").Text())\n\n\t\tvar tags []string\n\t\tif cat := strings.TrimSpace(item.Find(\".sortbox a.sort\").Text()); cat != \"\" {\n\t\t\ttags = append(tags, cat)\n\t\t}\n\n\t\tdateText := strings.TrimSpace(item.Find(\".pmbox .time\").Text())\n\t\tpublishTime := parseChineseDate(dateText)\n\n\t\twg.Add(1)\n\t\tsem <- struct{}{}\n\t\tgo func(title, detailURL, summary, postID string, tags []string, publish time.Time) {\n\t\t\tdefer wg.Done()\n\t\t\tdefer func() { <-sem }()\n\n\t\t\tlinks := p.fetchDetailLinks(client, detailURL, postID)\n\t\t\tif len(links) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tresult := model.SearchResult{\n\t\t\t\tUniqueID: fmt.Sprintf(\"%s-%s\", p.Name(), postID),\n\t\t\t\tTitle:    title,\n\t\t\t\tContent:  summary,\n\t\t\t\tLinks:    links,\n\t\t\t\tTags:     tags,\n\t\t\t\tChannel:  \"\",\n\t\t\t\tDatetime: publish,\n\t\t\t}\n\n\t\t\tmu.Lock()\n\t\t\tresults = append(results, result)\n\t\t\tmu.Unlock()\n\t\t}(title, detailURL, summary, postID, append([]string{}, tags...), publishTime)\n\t})\n\n\twg.Wait()\n\n\tif len(results) == 0 {\n\t\treturn nil, fmt.Errorf(\"[%s] 未找到相关资源\", p.Name())\n\t}\n\n\treturn plugin.FilterResultsByKeyword(results, keyword), nil\n}\n\nfunc (p *DaishuPlugin) fetchDetailLinks(client *http.Client, detailURL, postID string) []model.Link {\n\tif cached, ok := detailCache.Load(postID); ok {\n\t\tif entry, valid := cached.(cacheEntry); valid {\n\t\t\tif time.Now().Before(entry.expiresAt) && len(entry.links) > 0 {\n\t\t\t\treturn entry.links\n\t\t\t}\n\t\t\tdetailCache.Delete(postID)\n\t\t}\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), detailTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tsetCommonHeaders(req, \"https://www.daishuduanju.com/\")\n\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\tcontainer := doc.Find(\".article-body\")\n\tif container.Length() == 0 {\n\t\tcontainer = doc.Find(\"article.post\")\n\t}\n\tif container.Length() == 0 {\n\t\tcontainer = doc.Selection\n\t}\n\n\tlinks := extractLinks(container)\n\tif len(links) > 0 {\n\t\tdetailCache.Store(postID, cacheEntry{\n\t\t\tlinks:     links,\n\t\t\texpiresAt: time.Now().Add(cacheTTL),\n\t\t})\n\t}\n\treturn links\n}\n\nfunc extractLinks(selection *goquery.Selection) []model.Link {\n\tvar (\n\t\tresults []model.Link\n\t\tseen    = make(map[string]struct{})\n\t)\n\n\tselection.Find(\"a[href]\").Each(func(_ int, node *goquery.Selection) {\n\t\thref, ok := node.Attr(\"href\")\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\t\thref = strings.TrimSpace(href)\n\t\tif href == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tlinkType, normalized := classifyLink(href)\n\t\tif linkType == \"\" {\n\t\t\treturn\n\t\t}\n\t\tif _, exists := seen[normalized]; exists {\n\t\t\treturn\n\t\t}\n\n\t\tpassword := extractPassword(node)\n\n\t\tresults = append(results, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      normalized,\n\t\t\tPassword: password,\n\t\t})\n\t\tseen[normalized] = struct{}{}\n\t})\n\n\ttext := selection.Text()\n\tfor _, idx := range textURLReg.FindAllStringIndex(text, -1) {\n\t\traw := text[idx[0]:idx[1]]\n\t\tlinkType, normalized := classifyLink(raw)\n\t\tif linkType == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := seen[normalized]; exists {\n\t\t\tcontinue\n\t\t}\n\n\t\tcontext := substring(text, idx[0]-80, idx[1]+80)\n\t\tpassword := matchPassword(context)\n\n\t\tresults = append(results, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      normalized,\n\t\t\tPassword: password,\n\t\t})\n\t\tseen[normalized] = struct{}{}\n\t}\n\n\treturn results\n}\n\nfunc classifyLink(raw string) (string, string) {\n\tfor _, pattern := range linkPatterns {\n\t\tif loc := pattern.reg.FindString(raw); loc != \"\" {\n\t\t\treturn pattern.typ, loc\n\t\t}\n\t}\n\treturn \"\", \"\"\n}\n\nfunc extractPassword(node *goquery.Selection) string {\n\tcandidates := []string{node.Text()}\n\n\tif title, ok := node.Attr(\"title\"); ok {\n\t\tcandidates = append(candidates, title)\n\t}\n\n\tif parent := node.Parent(); parent != nil && parent.Length() > 0 {\n\t\tcandidates = append(candidates, parent.Text())\n\t\tif next := parent.Next(); next.Length() > 0 {\n\t\t\tcandidates = append(candidates, next.Text())\n\t\t}\n\t}\n\n\tif sibling := node.Next(); sibling.Length() > 0 {\n\t\tcandidates = append(candidates, sibling.Text())\n\t}\n\n\tfor _, text := range candidates {\n\t\tif pwd := matchPassword(text); pwd != \"\" {\n\t\t\treturn pwd\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc matchPassword(text string) string {\n\ttext = strings.TrimSpace(text)\n\tif text == \"\" {\n\t\treturn \"\"\n\t}\n\tfor _, pattern := range passwordPatterns {\n\t\tif matches := pattern.FindStringSubmatch(text); len(matches) >= 2 {\n\t\t\treturn strings.TrimSpace(matches[1])\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc substring(text string, start, end int) string {\n\tif start < 0 {\n\t\tstart = 0\n\t}\n\tif end > len(text) {\n\t\tend = len(text)\n\t}\n\treturn text[start:end]\n}\n\nfunc extractPostID(detailURL string) string {\n\tif matches := idRegex.FindStringSubmatch(detailURL); len(matches) >= 2 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\nfunc parseChineseDate(value string) time.Time {\n\tvalue = strings.TrimSpace(value)\n\tif value == \"\" {\n\t\treturn time.Now()\n\t}\n\tvalue = strings.ReplaceAll(value, \"年\", \"-\")\n\tvalue = strings.ReplaceAll(value, \"月\", \"-\")\n\tvalue = strings.ReplaceAll(value, \"日\", \"\")\n\tlayouts := []string{\n\t\t\"2006-01-02 15:04\",\n\t\t\"2006-01-02\",\n\t}\n\tfor _, layout := range layouts {\n\t\tif t, err := time.Parse(layout, value); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\treturn time.Now()\n}\n\nfunc setCommonHeaders(req *http.Request, referer string) {\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", referer)\n}\n\nfunc (p *DaishuPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxRetries; attempt++ {\n\t\tresp, err := client.Do(req.Clone(req.Context()))\n\t\tif err == nil && resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t\tif attempt < maxRetries-1 {\n\t\t\tbackoff := retryBaseDelay * time.Duration(1<<attempt)\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t}\n\treturn nil, fmt.Errorf(\"重试 %d 次后失败: %w\", maxRetries, lastErr)\n}\n\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(cacheCleanupInterval)\n\tdefer ticker.Stop()\n\n\tfor range ticker.C {\n\t\tnow := time.Now()\n\t\tdetailCache.Range(func(key, value interface{}) bool {\n\t\t\tentry, ok := value.(cacheEntry)\n\t\t\tif !ok || now.After(entry.expiresAt) {\n\t\t\t\tdetailCache.Delete(key)\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "plugin/daishudj/html结构分析.md",
    "content": "# daishudj (袋鼠短剧网) HTML结构分析\n\n## 搜索页面\n\n- **URL**: `https://www.daishuduanju.com/?s={关键词}`\n- **页面结构**:\n  - 列表容器：`div.item-jx.item-blog`\n  - 缩略图：`.thumb img`\n  - 标题：`.subtitle h5 a`\n  - 摘要：`.subtitle p.pdesc`\n  - 分类：`.sortbox a.sort`\n  - 作者/时间：`.pmbox .l` 内的 `.author`、`.time`\n\n### 单条结果结构\n```html\n<div class=\"item-jx item-blog\">\n  <div class=\"jxbox\">\n    <div class=\"thumb\">\n      <a href=\"https://www.daishuduanju.com/1047/\">\n        <div class=\"thumb-inner\">\n          <img src=\"...\" alt=\"短剧《将军回乡》...\">\n        </div>\n      </a>\n      <div class=\"sortbox\"><a href=\"https://www.daishuduanju.com/duanju/\" class=\"sort sort-1\">短剧</a></div>\n    </div>\n    <div class=\"subtitle\">\n      <h5 class=\"line-tow\"><a href=\"https://www.daishuduanju.com/1047/\">短剧《将军回乡》高清完整版全集免费在线观看</a></h5>\n      <p class=\"pdesc\">📺 ... 夸克网盘：... https://pan.quark.cn/...</p>\n      <div class=\"pmbox\">\n        <div class=\"l\">\n          <a class=\"author\" href=\"...\">袋鼠短剧网</a>\n          <span class=\"time\">2025年11月16日</span>\n        </div>\n      </div>\n    </div>\n  </div>\n</div>\n```\n\n### 提取要点\n- **标题**：`h5 a` 文本\n- **详情 URL**：`h5 a` 的 `href`\n- **摘要**：`p.pdesc` 文本（通常包含观看地址和部分链接）\n- **分类标签**：`.sortbox a`\n- **发布时间**：`.pmbox .time`（格式含中文，如 `2025年11月16日`）\n- **作者**：`.pmbox .author`\n- **附带链接**：摘要可能直接包含下载链接，可做快速过滤，但最终以详情页为准\n\n## 详情页面\n\n- **URL 规则**：`https://www.daishuduanju.com/{post_id}/`\n- **主体容器**：`.article-body`\n- **常见内容顺序**：\n  1. 顶部信息框（观看地址、夸克链接按钮）\n  2. 介绍/剧情文案（多段 `<p>`）\n  3. 相关文章、评论等\n\n### 下载信息块\n```html\n<div style=\"...\">\n  <div style=\"font-size:18px;font-weight:bold;\">📺 观看地址</div>\n  <div>\n    <span>夸克网盘：将军回乡</span><br/>\n    <a href=\"https://pan.quark.cn/s/703f4c422d24\" target=\"_blank\" rel=\"noopener nofollow\">https://pan.quark.cn/s/703f4c422d24</a>\n  </div>\n  <div style=\"...\">\n    <a href=\"https://pan.quark.cn/s/703f4c422d24\" ...>观看全集</a>\n  </div>\n  ...\n</div>\n```\n\n### 其他形式\n- 纯文本段落：`<p>夸克：https://pan.quark.cn/s/... </p>`\n- 多链接场景：介绍底部 `p.pdesc` 或相关内容中附带多个 `https://pan.quark.cn/s/...` 链接\n- 可能含有二维码、外部提示、群组链接，可忽略\n\n### DOM 选择建议\n- 优先在 `.article-body` 内查找 `<a href>`，筛选包含网盘域名的链接\n- 若 `.article-body` 缺失，则回退到整篇文章 `article.post`\n- 提取码通常写在同段文本或链接 `href` 参数中，使用关键字匹配 `提取码/密码/pwd`\n\n## 支持的网盘域名\n- 夸克：`pan.quark.cn`\n- （可扩展）百度、阿里、迅雷等，如出现可重用通用判断逻辑\n\n## 实现要点\n1. **搜索**：直接请求 HTML 列表，解析 `.item-jx`，提取基础信息。\n2. **详情**：抓取 `.article-body`，搜集 `<a>` 链接，并结合文本解析裸露 URL。\n3. **提取码**：在链接周围文本、父节点、相邻节点中搜 `提取码/密码/pwd/code`。\n4. **时间格式**：中文日期需转换成 `YYYY-MM-DD`，可替换 `年/月/日`。\n5. **去重**：使用文章 ID (`/1047/`) 作为 `UniqueID` 的一部分。 \n\n"
  },
  {
    "path": "plugin/ddys/ddys.go",
    "content": "package ddys\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tPluginName    = \"ddys\"\n\tDisplayName   = \"低端影视\"\n\tDescription   = \"低端影视 - 影视资源网盘链接搜索\"\n\tBaseURL       = \"https://ddys.pro\"\n\tSearchPath    = \"/?s=%s&post_type=post\"\n\tUserAgent     = \"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\"\n\tMaxResults    = 50\n\tMaxConcurrency = 20\n)\n\n// DdysPlugin 低端影视插件\ntype DdysPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tdebugMode    bool\n\tdetailCache  sync.Map // 缓存详情页结果\n\tcacheTTL     time.Duration\n}\n\n// init 注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewDdysPlugin())\n}\n\n// NewDdysPlugin 创建新的低端影视插件实例\nfunc NewDdysPlugin() *DdysPlugin {\n\tdebugMode := false // 生产环境关闭调试\n\n\tp := &DdysPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(PluginName, 1), // 标准网盘插件，启用Service层过滤\n\t\tdebugMode:       debugMode,\n\t\tcacheTTL:        30 * time.Minute, // 详情页缓存30分钟\n\t}\n\n\treturn p\n}\n\n// Name 插件名称\nfunc (p *DdysPlugin) Name() string {\n\treturn PluginName\n}\n\n// DisplayName 插件显示名称\nfunc (p *DdysPlugin) DisplayName() string {\n\treturn DisplayName\n}\n\n// Description 插件描述\nfunc (p *DdysPlugin) Description() string {\n\treturn Description\n}\n\n// Search 搜索接口\nfunc (p *DdysPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\treturn p.searchImpl(&http.Client{Timeout: 30 * time.Second}, keyword, ext)\n}\n\n// searchImpl 搜索实现\nfunc (p *DdysPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif p.debugMode {\n\t\tlog.Printf(\"[DDYS] 开始搜索: %s\", keyword)\n\t}\n\n\t// 第一步：执行搜索获取结果列表\n\tsearchResults, err := p.executeSearch(client, keyword)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 执行搜索失败: %w\", p.Name(), err)\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DDYS] 搜索获取到 %d 个结果\", len(searchResults))\n\t}\n\n\t// 第二步：并发获取详情页链接\n\tfinalResults := p.fetchDetailLinks(client, searchResults, keyword)\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DDYS] 最终获取到 %d 个有效结果\", len(finalResults))\n\t}\n\n\t// 第三步：关键词过滤（标准网盘插件需要过滤）\n\tfilteredResults := plugin.FilterResultsByKeyword(finalResults, keyword)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[DDYS] 关键词过滤后剩余 %d 个结果\", len(filteredResults))\n\t}\n\n\treturn filteredResults, nil\n}\n\n// executeSearch 执行搜索请求\nfunc (p *DdysPlugin) executeSearch(client *http.Client, keyword string) ([]model.SearchResult, error) {\n\t// 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"%s%s\", BaseURL, fmt.Sprintf(SearchPath, url.QueryEscape(keyword)))\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建搜索请求失败: %w\", p.Name(), err)\n\t}\n\n\t// 设置完整的请求头\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求HTTP状态错误: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\t// 解析HTML提取搜索结果\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索结果HTML失败: %w\", p.Name(), err)\n\t}\n\n\treturn p.parseSearchResults(doc)\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *DdysPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"[%s] 重试 %d 次后仍然失败: %w\", p.Name(), maxRetries, lastErr)\n}\n\n// parseSearchResults 解析搜索结果HTML\nfunc (p *DdysPlugin) parseSearchResults(doc *goquery.Document) ([]model.SearchResult, error) {\n\tvar results []model.SearchResult\n\n\t// 查找搜索结果项: article[class^=\"post-\"]\n\tdoc.Find(\"article[class*='post-']\").Each(func(i int, s *goquery.Selection) {\n\t\tif len(results) >= MaxResults {\n\t\t\treturn\n\t\t}\n\n\t\tresult := p.parseResultItem(s, i+1)\n\t\tif result != nil {\n\t\t\tresults = append(results, *result)\n\t\t}\n\t})\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DDYS] 解析到 %d 个原始结果\", len(results))\n\t}\n\n\treturn results, nil\n}\n\n// parseResultItem 解析单个搜索结果项\nfunc (p *DdysPlugin) parseResultItem(s *goquery.Selection, index int) *model.SearchResult {\n\t// 提取文章ID\n\tarticleClass, _ := s.Attr(\"class\")\n\tpostID := p.extractPostID(articleClass)\n\tif postID == \"\" {\n\t\tpostID = fmt.Sprintf(\"unknown-%d\", index)\n\t}\n\n\t// 提取标题和链接\n\tlinkEl := s.Find(\".post-title a\")\n\tif linkEl.Length() == 0 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DDYS] 跳过无标题链接的结果\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t// 提取标题\n\ttitle := strings.TrimSpace(linkEl.Text())\n\tif title == \"\" {\n\t\treturn nil\n\t}\n\n\t// 提取详情页链接\n\tdetailURL, _ := linkEl.Attr(\"href\")\n\tif detailURL == \"\" {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DDYS] 跳过无链接的结果: %s\", title)\n\t\t}\n\t\treturn nil\n\t}\n\n\t// 提取发布时间\n\tpublishTime := p.extractPublishTime(s)\n\n\t// 提取分类\n\tcategory := p.extractCategory(s)\n\n\t// 提取简介\n\tcontent := p.extractContent(s)\n\n\t// 构建初始结果对象（详情页链接稍后获取）\n\tresult := model.SearchResult{\n\t\tTitle:     title,\n\t\tContent:   fmt.Sprintf(\"分类：%s\\n%s\", category, content),\n\t\tChannel:   \"\", // 插件搜索结果必须为空字符串（按开发指南要求）\n\t\tMessageID: fmt.Sprintf(\"%s-%s-%d\", p.Name(), postID, index),\n\t\tUniqueID:  fmt.Sprintf(\"%s-%s-%d\", p.Name(), postID, index),\n\t\tDatetime:  publishTime,\n\t\tLinks:     []model.Link{}, // 先为空，详情页处理后添加\n\t\tTags:      []string{category},\n\t}\n\n\t// 添加详情页URL到临时字段（用于后续处理）\n\tresult.Content += fmt.Sprintf(\"\\n详情页: %s\", detailURL)\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DDYS] 解析结果: %s (%s)\", title, category)\n\t}\n\n\treturn &result\n}\n\n// extractPostID 从文章class中提取文章ID\nfunc (p *DdysPlugin) extractPostID(articleClass string) string {\n\t// 匹配 post-{数字} 格式\n\tre := regexp.MustCompile(`post-(\\d+)`)\n\tmatches := re.FindStringSubmatch(articleClass)\n\tif len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// extractPublishTime 提取发布时间\nfunc (p *DdysPlugin) extractPublishTime(s *goquery.Selection) time.Time {\n\ttimeEl := s.Find(\".meta_date time.entry-date\")\n\tif timeEl.Length() == 0 {\n\t\treturn time.Now()\n\t}\n\n\tdatetime, exists := timeEl.Attr(\"datetime\")\n\tif !exists {\n\t\treturn time.Now()\n\t}\n\n\t// 解析ISO 8601格式时间\n\tif t, err := time.Parse(time.RFC3339, datetime); err == nil {\n\t\treturn t\n\t}\n\n\treturn time.Now()\n}\n\n// extractCategory 提取分类\nfunc (p *DdysPlugin) extractCategory(s *goquery.Selection) string {\n\tcategoryEl := s.Find(\".meta_categories .cat-links a\")\n\tif categoryEl.Length() > 0 {\n\t\treturn strings.TrimSpace(categoryEl.Text())\n\t}\n\treturn \"未分类\"\n}\n\n// extractContent 提取内容简介\nfunc (p *DdysPlugin) extractContent(s *goquery.Selection) string {\n\tcontentEl := s.Find(\".entry-content\")\n\tif contentEl.Length() > 0 {\n\t\tcontent := strings.TrimSpace(contentEl.Text())\n\t\t// 限制长度\n\t\tif len(content) > 200 {\n\t\t\tcontent = content[:200] + \"...\"\n\t\t}\n\t\treturn content\n\t}\n\treturn \"\"\n}\n\n// fetchDetailLinks 并发获取详情页链接\nfunc (p *DdysPlugin) fetchDetailLinks(client *http.Client, searchResults []model.SearchResult, keyword string) []model.SearchResult {\n\tif len(searchResults) == 0 {\n\t\treturn []model.SearchResult{}\n\t}\n\n\t// 使用通道控制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\tvar wg sync.WaitGroup\n\tresultsChan := make(chan model.SearchResult, len(searchResults))\n\n\tfor _, result := range searchResults {\n\t\twg.Add(1)\n\t\tgo func(r model.SearchResult) {\n\t\t\tdefer wg.Done()\n\t\t\tsemaphore <- struct{}{} // 获取信号量\n\t\t\tdefer func() { <-semaphore }() // 释放信号量\n\n\t\t\t// 从Content中提取详情页URL\n\t\t\tdetailURL := p.extractDetailURLFromContent(r.Content)\n\t\t\tif detailURL == \"\" {\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[DDYS] 跳过无详情页URL的结果: %s\", r.Title)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 获取详情页链接\n\t\t\tlinks := p.fetchDetailPageLinks(client, detailURL)\n\t\t\tif len(links) > 0 {\n\t\t\t\tr.Links = links\n\t\t\t\t// 清理Content中的详情页URL\n\t\t\t\tr.Content = p.cleanContent(r.Content)\n\t\t\t\tresultsChan <- r\n\t\t\t} else if p.debugMode {\n\t\t\t\tlog.Printf(\"[DDYS] 详情页无有效链接: %s\", r.Title)\n\t\t\t}\n\t\t}(result)\n\t}\n\n\t// 等待所有goroutine完成\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultsChan)\n\t}()\n\n\t// 收集结果\n\tvar finalResults []model.SearchResult\n\tfor result := range resultsChan {\n\t\tfinalResults = append(finalResults, result)\n\t}\n\n\treturn finalResults\n}\n\n// extractDetailURLFromContent 从Content中提取详情页URL\nfunc (p *DdysPlugin) extractDetailURLFromContent(content string) string {\n\tlines := strings.Split(content, \"\\n\")\n\tfor _, line := range lines {\n\t\tif strings.HasPrefix(line, \"详情页: \") {\n\t\t\treturn strings.TrimPrefix(line, \"详情页: \")\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// cleanContent 清理Content，移除详情页URL行\nfunc (p *DdysPlugin) cleanContent(content string) string {\n\tlines := strings.Split(content, \"\\n\")\n\tvar cleanedLines []string\n\tfor _, line := range lines {\n\t\tif !strings.HasPrefix(line, \"详情页: \") {\n\t\t\tcleanedLines = append(cleanedLines, line)\n\t\t}\n\t}\n\treturn strings.Join(cleanedLines, \"\\n\")\n}\n\n// fetchDetailPageLinks 获取详情页的网盘链接\nfunc (p *DdysPlugin) fetchDetailPageLinks(client *http.Client, detailURL string) []model.Link {\n\t// 检查缓存\n\tif cached, found := p.detailCache.Load(detailURL); found {\n\t\tif links, ok := cached.([]model.Link); ok {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[DDYS] 使用缓存的详情页链接: %s\", detailURL)\n\t\t\t}\n\t\t\treturn links\n\t\t}\n\t}\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DDYS] 创建详情页请求失败: %v\", err)\n\t\t}\n\t\treturn []model.Link{}\n\t}\n\n\t// 设置请求头\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DDYS] 详情页请求失败: %v\", err)\n\t\t}\n\t\treturn []model.Link{}\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DDYS] 详情页HTTP状态错误: %d\", resp.StatusCode)\n\t\t}\n\t\treturn []model.Link{}\n\t}\n\n\t// 读取响应体\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DDYS] 读取详情页响应失败: %v\", err)\n\t\t}\n\t\treturn []model.Link{}\n\t}\n\n\t// 解析网盘链接\n\tlinks := p.parseNetworkDiskLinks(string(body))\n\n\t// 缓存结果\n\tif len(links) > 0 {\n\t\tp.detailCache.Store(detailURL, links)\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DDYS] 从详情页提取到 %d 个链接: %s\", len(links), detailURL)\n\t}\n\n\treturn links\n}\n\n// parseNetworkDiskLinks 解析网盘链接\nfunc (p *DdysPlugin) parseNetworkDiskLinks(htmlContent string) []model.Link {\n\tvar links []model.Link\n\n\t// 定义网盘链接匹配模式\n\tpatterns := []struct {\n\t\tname    string\n\t\tpattern string\n\t\turlType string\n\t}{\n\t\t{\"夸克网盘\", `\\(夸克[^)]*\\)[：:]\\s*<a[^>]*href\\s*=\\s*[\"']([^\"']+)[\"'][^>]*>([^<]+)</a>`, \"quark\"},\n\t\t{\"百度网盘\", `\\(百度[^)]*\\)[：:]\\s*<a[^>]*href\\s*=\\s*[\"']([^\"']+)[\"'][^>]*>([^<]+)</a>`, \"baidu\"},\n\t\t{\"阿里云盘\", `\\(阿里[^)]*\\)[：:]\\s*<a[^>]*href\\s*=\\s*[\"']([^\"']+)[\"'][^>]*>([^<]+)</a>`, \"aliyun\"},\n\t\t{\"天翼云盘\", `\\(天翼[^)]*\\)[：:]\\s*<a[^>]*href\\s*=\\s*[\"']([^\"']+)[\"'][^>]*>([^<]+)</a>`, \"tianyi\"},\n\t\t{\"迅雷网盘\", `\\(迅雷[^)]*\\)[：:]\\s*<a[^>]*href\\s*=\\s*[\"']([^\"']+)[\"'][^>]*>([^<]+)</a>`, \"xunlei\"},\n\t\t// 通用模式\n\t\t{\"通用网盘\", `<a[^>]*href\\s*=\\s*[\"'](https?://[^\"']*(?:pan|drive|cloud)[^\"']*)[\"'][^>]*>([^<]+)</a>`, \"others\"},\n\t}\n\n\t// 去重用的map\n\tseen := make(map[string]bool)\n\n\tfor _, pattern := range patterns {\n\t\tre := regexp.MustCompile(pattern.pattern)\n\t\tmatches := re.FindAllStringSubmatch(htmlContent, -1)\n\n\t\tfor _, match := range matches {\n\t\t\tif len(match) >= 3 {\n\t\t\t\turl := match[1]\n\t\t\t\t\n\t\t\t\t// 去重\n\t\t\t\tif seen[url] {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tseen[url] = true\n\n\t\t\t\t// 确定网盘类型\n\t\t\t\turlType := p.determineCloudType(url)\n\t\t\t\tif urlType == \"others\" {\n\t\t\t\t\turlType = pattern.urlType\n\t\t\t\t}\n\n\t\t\t\t// 提取可能的提取码\n\t\t\t\tpassword := p.extractPassword(htmlContent, url)\n\n\t\t\t\tlink := model.Link{\n\t\t\t\t\tType:     urlType,\n\t\t\t\t\tURL:      url,\n\t\t\t\t\tPassword: password,\n\t\t\t\t}\n\n\t\t\t\tlinks = append(links, link)\n\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[DDYS] 找到链接: %s (%s)\", url, urlType)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn links\n}\n\n// extractPassword 提取网盘提取码\nfunc (p *DdysPlugin) extractPassword(content string, panURL string) string {\n\t// 常见提取码模式\n\tpatterns := []string{\n\t\t`提取[码密][：:]?\\s*([A-Za-z0-9]{4,8})`,\n\t\t`密码[：:]?\\s*([A-Za-z0-9]{4,8})`,\n\t\t`[码密][：:]?\\s*([A-Za-z0-9]{4,8})`,\n\t\t`([A-Za-z0-9]{4,8})\\s*[是为]?提取[码密]`,\n\t}\n\n\t// 在网盘链接附近搜索提取码\n\turlIndex := strings.Index(content, panURL)\n\tif urlIndex == -1 {\n\t\treturn \"\"\n\t}\n\n\t// 搜索范围：链接前后200个字符\n\tstart := urlIndex - 200\n\tif start < 0 {\n\t\tstart = 0\n\t}\n\tend := urlIndex + len(panURL) + 200\n\tif end > len(content) {\n\t\tend = len(content)\n\t}\n\t\n\tsearchArea := content[start:end]\n\n\tfor _, pattern := range patterns {\n\t\tre := regexp.MustCompile(pattern)\n\t\tmatches := re.FindStringSubmatch(searchArea)\n\t\tif len(matches) > 1 {\n\t\t\treturn matches[1]\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\n// determineCloudType 根据URL自动识别网盘类型（按开发指南完整列表）\nfunc (p *DdysPlugin) determineCloudType(url string) string {\n\tswitch {\n\tcase strings.Contains(url, \"pan.quark.cn\"):\n\t\treturn \"quark\"\n\tcase strings.Contains(url, \"drive.uc.cn\"):\n\t\treturn \"uc\"\n\tcase strings.Contains(url, \"pan.baidu.com\"):\n\t\treturn \"baidu\"\n\tcase strings.Contains(url, \"aliyundrive.com\") || strings.Contains(url, \"alipan.com\"):\n\t\treturn \"aliyun\"\n\tcase strings.Contains(url, \"pan.xunlei.com\"):\n\t\treturn \"xunlei\"\n\tcase strings.Contains(url, \"cloud.189.cn\"):\n\t\treturn \"tianyi\"\n\tcase strings.Contains(url, \"caiyun.139.com\"):\n\t\treturn \"mobile\"\n\tcase strings.Contains(url, \"115.com\"):\n\t\treturn \"115\"\n\tcase strings.Contains(url, \"123pan.com\"):\n\t\treturn \"123\"\n\tcase strings.Contains(url, \"mypikpak.com\"):\n\t\treturn \"pikpak\"\n\tcase strings.Contains(url, \"lanzou\"):\n\t\treturn \"lanzou\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}"
  },
  {
    "path": "plugin/ddys/html结构分析.md",
    "content": "# DDYS（低端影视）插件HTML结构分析\n\n## 网站概述\n- **网站名称**: 低端影视\n- **域名**: https://ddys.pro/\n- **类型**: 影视资源网站，提供在线播放和网盘下载链接\n\n## API流程概述\n\n### 搜索页面\n- **请求URL**: `https://ddys.pro/?s={keyword}&post_type=post`\n- **方法**: GET\n- **Headers**: 标准浏览器请求头\n- **特点**: WordPress网站，使用标准搜索功能\n\n## 搜索结果结构\n\n### 搜索结果页面HTML结构\n```html\n<main id=\"main\" class=\"site-main col-md-8\" role=\"main\">\n    <article id=\"post-1404\" class=\"post-1404 post type-post status-publish ...\">\n        <div class=\"row\">\n            <div class=\"post-content col-md-12\">\n                <header class=\"entry-header\">\n                    <h2 class=\"post-title\">\n                        <a href=\"https://ddys.pro/deadpool/\" rel=\"bookmark\">死侍 1-3</a>\n                    </h2>\n                </header>\n                \n                <div class=\"entry-content\">\n                    <p>注：本片不适合公共场合观看</p>\n                </div>\n                \n                <footer class=\"entry-footer\">\n                    <div class=\"metadata\">\n                        <ul>\n                            <li class=\"meta_date\">\n                                <time class=\"entry-date published\" datetime=\"2018-08-08T01:41:40+08:00\">\n                                    2018年8月8日\n                                </time>\n                            </li>\n                            <li class=\"meta_categories\">\n                                <span class=\"cat-links\">\n                                    <a href=\"...\" rel=\"category tag\">欧美电影</a>\n                                </span>\n                            </li>\n                        </ul>\n                    </div>\n                </footer>\n            </div>\n        </div>\n    </article>\n</main>\n```\n\n### 详情页面HTML结构\n```html\n<main id=\"main\" class=\"site-main\" role=\"main\">\n    <article id=\"post-19840\" class=\"...\">\n        <div class=\"post-content\">\n            <h1 class=\"post-title\">变形金刚 超能勇士崛起</h1>\n            \n            <div class=\"metadata\">\n                <ul>\n                    <li class=\"meta_date\">\n                        <time class=\"entry-date published updated\" \n                              datetime=\"2023-07-13T14:27:08+08:00\">\n                            2023年7月13日\n                        </time>\n                    </li>\n                    <li class=\"meta_categories\">\n                        <span class=\"cat-links\">\n                            <a href=\"...\" rel=\"category tag\">欧美电影</a>\n                        </span>\n                    </li>\n                    <li class=\"meta_tags\">\n                        <span class=\"tags-links\">\n                            标签：<a href=\"...\" rel=\"tag\">动作</a>\n                        </span>\n                    </li>\n                </ul>\n            </div>\n            \n            <div class=\"entry\">\n                <!-- 播放器相关内容 -->\n                \n                <!-- 网盘下载链接 -->\n                <p>视频下载 (夸克网盘)： \n                    <a href=\"https://pan.quark.cn/s/a372a91a0296\" \n                       rel=\"noopener nofollow\" target=\"_blank\">\n                        https://pan.quark.cn/s/a372a91a0296\n                    </a>\n                </p>\n                \n                <!-- 豆瓣信息区块 -->\n                <div class=\"doulist-item\">\n                    <div class=\"mod\">\n                        <div class=\"v-overflowHidden doulist-subject\">\n                            <div class=\"post\">\n                                <img src=\"douban_cache/xxx.jpg\">\n                            </div>\n                            <div class=\"title\">\n                                <a href=\"https://movie.douban.com/subject/...\" \n                                   class=\"cute\" target=\"_blank\">\n                                    影片名称 英文名\n                                </a>\n                            </div>\n                            <div class=\"rating\">\n                                <span class=\"rating_nums\">5.8</span>\n                            </div>\n                            <div class=\"abstract\">\n                                <!-- 详细信息：又名、导演、演员、类型等 -->\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </article>\n</main>\n```\n\n## 数据提取要点\n\n### 搜索结果页面\n1. **结果列表**: `article[class^=\"post-\"]` - 每个搜索结果\n2. **文章ID**: 从article的class或id属性提取，如 `post-1404`\n3. **标题**: `.post-title > a` - 获取文本和href属性\n4. **链接**: `.post-title > a[href]` - 详情页链接\n5. **发布时间**: `.meta_date > time.entry-date[datetime]` - ISO格式时间\n6. **分类**: `.meta_categories > .cat-links > a` - 分类信息\n7. **简介**: `.entry-content` - 内容简介（可能为空）\n\n### 详情页面  \n1. **标题**: `h1.post-title` - 影片标题\n2. **发布时间**: `.meta_date > time.entry-date[datetime]` - 发布时间\n3. **分类标签**: `.meta_categories`和`.meta_tags`中的链接\n4. **网盘链接提取**: \n   - 模式1: `(网盘名)：<a href=\"链接\">链接文本</a>`\n   - 模式2: `(网盘名) <a href=\"链接\">链接文本</a>`\n   - 常见网盘: 夸克网盘、百度网盘、阿里云盘、天翼云盘等\n5. **豆瓣信息**: `.doulist-item`区块（可选）\n\n## 网盘链接识别规则\n\n### 支持的网盘类型\n- **夸克网盘**: `pan.quark.cn`\n- **百度网盘**: `pan.baidu.com`  \n- **阿里云盘**: `aliyundrive.com` / `alipan.com`\n- **天翼云盘**: `cloud.189.cn`\n- **迅雷网盘**: `pan.xunlei.com`\n- **115网盘**: `115.com`\n- **蓝奏云**: `lanzou`相关域名\n\n### 链接提取策略\n1. 在详情页的`.entry`内容区域搜索\n2. 使用正则表达式匹配网盘链接模式\n3. 提取网盘类型、链接和可能的提取码\n4. 链接去重和验证\n\n## 特殊处理\n\n### 时间解析\n- 格式: ISO 8601格式 `2023-07-13T14:27:08+08:00`\n- 显示: `2023年7月13日`\n\n### 内容清理\n- 移除HTML标签\n- 处理特殊字符和编码\n- 清理多余空格和换行\n\n### 错误处理\n- 网络超时重试\n- 解析失败的降级处理\n- 空结果的处理\n\n## 注意事项\n\n1. **反爬虫**: 网站可能有基础的反爬虫措施，需要设置合理的请求头\n2. **限频**: 避免请求过于频繁\n3. **编码**: 处理中文关键词的URL编码\n4. **更新**: 网站结构可能会变化，需要定期维护选择器"
  },
  {
    "path": "plugin/discourse/discourse.go",
    "content": "package discourse\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\tcloudscraper \"github.com/Advik-B/cloudscraper/lib\"\n)\n\n// 预编译的正则表达式 - 用于从blurb中提取网盘链接\nvar (\n\t// 网盘链接正则表达式\n\tquarkRegex    = regexp.MustCompile(`https://pan\\.quark\\.cn/s/[0-9a-zA-Z]+`)\n\tbaiduRegex    = regexp.MustCompile(`https://pan\\.baidu\\.com/s/[0-9a-zA-Z_\\-]+(?:\\?pwd=([0-9a-zA-Z]+))?`)\n\taliyunRegex   = regexp.MustCompile(`https://(?:www\\.)?aliyundrive\\.com/s/[0-9a-zA-Z]+`)\n\txunleiRegex   = regexp.MustCompile(`https://pan\\.xunlei\\.com/s/[0-9a-zA-Z_\\-]+(?:\\?pwd=([0-9a-zA-Z]+))?`)\n\ttianyiRegex   = regexp.MustCompile(`https://cloud\\.189\\.cn/t/[0-9a-zA-Z]+`)\n\tucRegex       = regexp.MustCompile(`https://drive\\.uc\\.cn/s/[0-9a-zA-Z]+`)\n\tpan115Regex   = regexp.MustCompile(`https://115\\.com/s/[0-9a-zA-Z]+`)\n\t\n\t// 百度网盘提取码 (出现在文本中)\n\tbaiduPwdRegex = regexp.MustCompile(`(?:提取码|密码|pwd)[：:]\\s*([0-9a-zA-Z]{4})`)\n)\n\n// 常量定义\nconst (\n\tpluginName        = \"discourse\"\n\t// searchURLTemplate = \"https://linux.do/search.json?q=%s%%20%%23resource%%3Acloud-asset%%20in%%3Atitle&page=%d\"\n\tsearchURLTemplate = \"https://linux.do/search.json?q=%s%%20in%%3Atitle%%20%%23resource&page=%d\"\n\tdetailURLTemplate = \"https://linux.do/t/%d.json?track_visit=true&forceLoad=true\"\n\tdefaultPriority   = 2\n\tdefaultTimeout    = 30 * time.Second\n\t\n\t// 多页获取配置\n\tdefaultMaxPages  = 1   // 默认最多获取1页\n\tmaxAllowedPages  = 10  // 最多允许获取10页\n\tpageRequestDelay = 500 * time.Millisecond // 每页请求间隔\n)\n\n// DiscourseAsyncPlugin 是 Discourse 论坛的异步搜索插件实现\ntype DiscourseAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tscraper *cloudscraper.Scraper\n}\n\n// SearchResponse 搜索API响应结构\ntype SearchResponse struct {\n\tPosts              []Post              `json:\"posts\"`\n\tTopics             []Topic             `json:\"topics\"`\n\tGroupedSearchResult GroupedSearchResult `json:\"grouped_search_result\"`\n}\n\n// Post 帖子信息\ntype Post struct {\n\tID            int       `json:\"id\"`\n\tName          string    `json:\"name\"`\n\tUsername      string    `json:\"username\"`\n\tCreatedAt     string    `json:\"created_at\"`\n\tLikeCount     int       `json:\"like_count\"`\n\tBlurb         string    `json:\"blurb\"`\n\tTopicID       int       `json:\"topic_id\"`\n}\n\n// Topic 主题信息\ntype Topic struct {\n\tID          int      `json:\"id\"`\n\tTitle       string   `json:\"title\"`\n\tFancyTitle  string   `json:\"fancy_title\"`\n\tTags        []string `json:\"tags\"`\n\tPostsCount  int      `json:\"posts_count\"`\n\tCreatedAt   string   `json:\"created_at\"`\n\tCategoryID  int      `json:\"category_id\"`\n}\n\n// GroupedSearchResult 搜索元数据\ntype GroupedSearchResult struct {\n\tTerm         string `json:\"term\"`\n\tPostIDs      []int  `json:\"post_ids\"`\n\tMoreResults  bool   `json:\"more_full_page_results\"`\n}\n\n// DetailResponse 详情API响应结构\ntype DetailResponse struct {\n\tPostStream PostStream `json:\"post_stream\"`\n\tID         int        `json:\"id\"`\n\tTitle      string     `json:\"title\"`\n\tTags       []string   `json:\"tags\"`\n}\n\n// PostStream 帖子流\ntype PostStream struct {\n\tPosts []DetailPost `json:\"posts\"`\n}\n\n// DetailPost 详情帖子\ntype DetailPost struct {\n\tID         int         `json:\"id\"`\n\tUsername   string      `json:\"username\"`\n\tCreatedAt  string      `json:\"created_at\"`\n\tCooked     string      `json:\"cooked\"`\n\tTopicID    int         `json:\"topic_id\"`\n\tLinkCounts []LinkCount `json:\"link_counts\"`\n}\n\n// LinkCount 链接统计\ntype LinkCount struct {\n\tURL        string `json:\"url\"`\n\tInternal   bool   `json:\"internal\"`\n\tReflection bool   `json:\"reflection\"`\n\tClicks     int    `json:\"clicks\"`\n}\n\n// 确保 DiscourseAsyncPlugin 实现了 AsyncSearchPlugin 接口\nvar _ plugin.AsyncSearchPlugin = (*DiscourseAsyncPlugin)(nil)\n\n// init 在包初始化时注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewDiscourseAsyncPlugin())\n}\n\n// NewDiscourseAsyncPlugin 创建一个新的 Discourse 异步插件实例\nfunc NewDiscourseAsyncPlugin() *DiscourseAsyncPlugin {\n\t// 创建 cloudscraper 实例\n\tscraper, err := cloudscraper.New()\n\tif err != nil {\n\t\t// 如果创建失败，记录错误但不阻止插件注册\n\t\tfmt.Printf(\"[%s] Failed to create cloudscraper: %v\\n\", pluginName, err)\n\t\treturn &DiscourseAsyncPlugin{\n\t\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),\n\t\t}\n\t}\n\n\treturn &DiscourseAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),\n\t\tscraper:         scraper,\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *DiscourseAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *DiscourseAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\t// 使用BaseAsyncPlugin的异步搜索能力\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实现具体的搜索逻辑\nfunc (p *DiscourseAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 检查 cloudscraper 是否初始化成功\n\tif p.scraper == nil {\n\t\treturn nil, fmt.Errorf(\"cloudscraper not initialized\")\n\t}\n\n\t// 提取 max_pages 参数（最多获取多少页）\n\tmaxPages := defaultMaxPages\n\tif maxPagesVal, ok := ext[\"max_pages\"]; ok {\n\t\tif maxPagesInt, ok := maxPagesVal.(int); ok {\n\t\t\tmaxPages = maxPagesInt\n\t\t} else if maxPagesFloat, ok := maxPagesVal.(float64); ok {\n\t\t\tmaxPages = int(maxPagesFloat)\n\t\t}\n\t}\n\t\n\t// 限制最大页数\n\tif maxPages > maxAllowedPages {\n\t\tmaxPages = maxAllowedPages\n\t}\n\tif maxPages < 1 {\n\t\tmaxPages = 1\n\t}\n\n\t// 提取起始page参数（默认为1）\n\tstartPage := 1\n\tif pageVal, ok := ext[\"page\"]; ok {\n\t\tif pageInt, ok := pageVal.(int); ok {\n\t\t\tstartPage = pageInt\n\t\t}\n\t}\n\n\t// URL编码关键词\n\tencodedKeyword := url.QueryEscape(keyword)\n\t\n\t// 存储所有结果\n\tvar allResults []model.SearchResult\n\tseenPostIDs := make(map[int]bool) // 用于去重\n\tfetchedPages := 0 // 实际获取的页数\n\t\n\t// 循环获取多页\n\tfor currentPage := startPage; currentPage < startPage+maxPages; currentPage++ {\n\t\tfetchedPages++\n\t\t// 如果不是第一页，添加延迟避免请求过快\n\t\tif currentPage > startPage {\n\t\t\ttime.Sleep(pageRequestDelay)\n\t\t}\n\t\t\n\t\tsearchURL := fmt.Sprintf(searchURLTemplate, encodedKeyword, currentPage)\n\t\t\n\t\t// 发送搜索请求\n\t\tresp, err := p.scraper.Get(searchURL)\n\t\tif err != nil {\n\t\t\t// 如果已经获取到一些结果，返回已有结果而不是报错\n\t\t\tif len(allResults) > 0 {\n\t\t\t\tfmt.Printf(\"[%s] Warning: failed to fetch page %d: %v\\n\", p.Name(), currentPage, err)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil, fmt.Errorf(\"[%s] search request failed on page %d: %w\", p.Name(), currentPage, err)\n\t\t}\n\n\t\t// 检查HTTP状态码\n\t\tif resp.StatusCode != 200 {\n\t\t\tresp.Body.Close()\n\t\t\t// 如果已经获取到一些结果，返回已有结果\n\t\t\tif len(allResults) > 0 {\n\t\t\t\tfmt.Printf(\"[%s] Warning: unexpected status code %d on page %d\\n\", p.Name(), resp.StatusCode, currentPage)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil, fmt.Errorf(\"[%s] unexpected status code: %d on page %d\", p.Name(), resp.StatusCode, currentPage)\n\t\t}\n\n\t\t// 读取响应体\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tresp.Body.Close()\n\t\tif err != nil {\n\t\t\tif len(allResults) > 0 {\n\t\t\t\tfmt.Printf(\"[%s] Warning: failed to read page %d: %v\\n\", p.Name(), currentPage, err)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil, fmt.Errorf(\"[%s] read response failed on page %d: %w\", p.Name(), currentPage, err)\n\t\t}\n\n\t\t// 解析JSON响应\n\t\tvar searchResp SearchResponse\n\t\tif err := json.Unmarshal(body, &searchResp); err != nil {\n\t\t\tif len(allResults) > 0 {\n\t\t\t\tfmt.Printf(\"[%s] Warning: failed to parse page %d: %v\\n\", p.Name(), currentPage, err)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil, fmt.Errorf(\"[%s] parse json failed on page %d: %w\", p.Name(), currentPage, err)\n\t\t}\n\n\t\t// 如果没有帖子了，停止获取\n\t\tif len(searchResp.Posts) == 0 {\n\t\t\tbreak\n\t\t}\n\t\t\n\t\t// 转换为SearchResult并去重\n\t\tpageResults := p.convertToSearchResults(searchResp)\n\t\t\n\t\t// 添加结果（去重）\n\t\tfor _, result := range pageResults {\n\t\t\t// 从 UniqueID 中提取帖子ID\n\t\t\tvar postID int\n\t\t\tfmt.Sscanf(result.UniqueID, \"discourse-%d\", &postID)\n\t\t\t\n\t\t\tif !seenPostIDs[postID] {\n\t\t\t\tseenPostIDs[postID] = true\n\t\t\t\tallResults = append(allResults, result)\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 如果 API 返回没有更多结果了，停止获取\n\t\tif !searchResp.GroupedSearchResult.MoreResults {\n\t\t\tbreak\n\t\t}\n\t\t\n\t\t// 如果这一页没有新的结果，也停止\n\t\tif len(pageResults) == 0 {\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\t// 如果启用了多页获取，在日志中显示获取的总结果数\n\tif maxPages > 1 && len(allResults) > 0 {\n\t\tfmt.Printf(\"[%s] Fetched %d unique results from %d pages for keyword: %s\\n\", \n\t\t\tp.Name(), len(allResults), fetchedPages, keyword)\n\t}\n\n\treturn allResults, nil\n}\n\n// max 返回两个整数中的较大值\nfunc max(a, b int) int {\n\tif a > b {\n\t\treturn a\n\t}\n\treturn b\n}\n\n// convertToSearchResults 将搜索响应转换为SearchResult列表\nfunc (p *DiscourseAsyncPlugin) convertToSearchResults(resp SearchResponse) []model.SearchResult {\n\tvar results []model.SearchResult\n\n\t// 创建 topic 映射，方便快速查找\n\ttopicMap := make(map[int]Topic)\n\tfor _, topic := range resp.Topics {\n\t\ttopicMap[topic.ID] = topic\n\t}\n\n\t// 遍历所有帖子\n\tfor _, post := range resp.Posts {\n\t\t// 获取对应的主题\n\t\ttopic, found := topicMap[post.TopicID]\n\t\tif !found {\n\t\t\t// 如果找不到主题，使用默认值\n\t\t\ttopic = Topic{\n\t\t\t\tID:    post.TopicID,\n\t\t\t\tTitle: \"未知标题\",\n\t\t\t\tTags:  []string{},\n\t\t\t}\n\t\t}\n\n\t\t// 从blurb中提取网盘链接\n\t\tlinks := p.extractNetDiskLinksFromBlurb(post.Blurb)\n\n\t\t// 如果没有提取到链接，跳过这个结果\n\t\tif len(links) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 解析时间\n\t\tcreatedAt, _ := time.Parse(time.RFC3339, post.CreatedAt)\n\n\t\t// 构建 SearchResult\n\t\tresult := model.SearchResult{\n\t\t\tUniqueID: fmt.Sprintf(\"%s-%d\", pluginName, post.ID),\n\t\t\tTitle:    topic.Title,\n\t\t\tContent:  p.cleanContent(post.Blurb),\n\t\t\tLinks:    links,\n\t\t\tTags:     topic.Tags,\n\t\t\tChannel:  \"\", // 插件搜索结果必须为空\n\t\t\tDatetime: createdAt,\n\t\t}\n\n\t\tresults = append(results, result)\n\t}\n\n\treturn results\n}\n\n// extractNetDiskLinksFromBlurb 从blurb文本中提取网盘链接\nfunc (p *DiscourseAsyncPlugin) extractNetDiskLinksFromBlurb(blurb string) []model.Link {\n\tvar links []model.Link\n\n\t// 提取夸克网盘\n\tquarkLinks := quarkRegex.FindAllString(blurb, -1)\n\tfor _, linkURL := range quarkLinks {\n\t\tlinks = append(links, model.Link{\n\t\t\tType: \"quark\",\n\t\t\tURL:  linkURL,\n\t\t})\n\t}\n\n\t// 提取百度网盘（带提取码）\n\tbaiduMatches := baiduRegex.FindAllStringSubmatch(blurb, -1)\n\tfor _, match := range baiduMatches {\n\t\tlink := model.Link{\n\t\t\tType: \"baidu\",\n\t\t\tURL:  match[0],\n\t\t}\n\t\t// 如果URL中包含pwd参数\n\t\tif len(match) > 1 && match[1] != \"\" {\n\t\t\tlink.Password = match[1]\n\t\t} else {\n\t\t\t// 尝试从文本中查找提取码\n\t\t\tpwdMatch := baiduPwdRegex.FindStringSubmatch(blurb)\n\t\t\tif len(pwdMatch) > 1 {\n\t\t\t\tlink.Password = pwdMatch[1]\n\t\t\t}\n\t\t}\n\t\tlinks = append(links, link)\n\t}\n\n\t// 提取阿里云盘\n\taliyunLinks := aliyunRegex.FindAllString(blurb, -1)\n\tfor _, linkURL := range aliyunLinks {\n\t\tlinks = append(links, model.Link{\n\t\t\tType: \"aliyun\",\n\t\t\tURL:  linkURL,\n\t\t})\n\t}\n\n\t// 提取迅雷网盘（带提取码）\n\txunleiMatches := xunleiRegex.FindAllStringSubmatch(blurb, -1)\n\tfor _, match := range xunleiMatches {\n\t\tlink := model.Link{\n\t\t\tType: \"xunlei\",\n\t\t\tURL:  match[0],\n\t\t}\n\t\tif len(match) > 1 && match[1] != \"\" {\n\t\t\tlink.Password = match[1]\n\t\t}\n\t\tlinks = append(links, link)\n\t}\n\n\t// 提取天翼云盘\n\ttianyiLinks := tianyiRegex.FindAllString(blurb, -1)\n\tfor _, linkURL := range tianyiLinks {\n\t\tlinks = append(links, model.Link{\n\t\t\tType: \"tianyi\",\n\t\t\tURL:  linkURL,\n\t\t})\n\t}\n\n\t// 提取UC网盘\n\tucLinks := ucRegex.FindAllString(blurb, -1)\n\tfor _, linkURL := range ucLinks {\n\t\tlinks = append(links, model.Link{\n\t\t\tType: \"uc\",\n\t\t\tURL:  linkURL,\n\t\t})\n\t}\n\n\t// 提取115网盘\n\tpan115Links := pan115Regex.FindAllString(blurb, -1)\n\tfor _, linkURL := range pan115Links {\n\t\tlinks = append(links, model.Link{\n\t\t\tType: \"115\",\n\t\t\tURL:  linkURL,\n\t\t})\n\t}\n\n\treturn links\n}\n\n// cleanContent 清理内容，移除HTML标签\nfunc (p *DiscourseAsyncPlugin) cleanContent(content string) string {\n\t// 移除HTML标签\n\tcontent = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(content, \"\")\n\t\n\t// 解码HTML实体\n\tcontent = strings.ReplaceAll(content, \"&amp;\", \"&\")\n\tcontent = strings.ReplaceAll(content, \"&lt;\", \"<\")\n\tcontent = strings.ReplaceAll(content, \"&gt;\", \">\")\n\tcontent = strings.ReplaceAll(content, \"&quot;\", \"\\\"\")\n\tcontent = strings.ReplaceAll(content, \"&#39;\", \"'\")\n\t\n\t// 移除多余空白\n\tcontent = regexp.MustCompile(`\\s+`).ReplaceAllString(content, \" \")\n\tcontent = strings.TrimSpace(content)\n\t\n\t// 限制长度\n\tif len(content) > 200 {\n\t\tcontent = content[:200] + \"...\"\n\t}\n\t\n\treturn content\n}\n\n// GetTopicDetail 获取主题详情（可选实现，用于获取完整链接）\nfunc (p *DiscourseAsyncPlugin) GetTopicDetail(topicID int) ([]model.Link, error) {\n\t// 检查 cloudscraper 是否初始化成功\n\tif p.scraper == nil {\n\t\treturn nil, fmt.Errorf(\"cloudscraper not initialized\")\n\t}\n\n\t// 构建详情URL\n\tdetailURL := fmt.Sprintf(detailURLTemplate, topicID)\n\n\t// 发送详情请求\n\tresp, err := p.scraper.Get(detailURL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"detail request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// 检查HTTP状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"unexpected status code: %d\", resp.StatusCode)\n\t}\n\n\t// 读取响应体\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response failed: %w\", err)\n\t}\n\n\t// 解析JSON响应\n\tvar detailResp DetailResponse\n\tif err := json.Unmarshal(body, &detailResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse json failed: %w\", err)\n\t}\n\n\t// 提取第一个帖子的链接\n\tif len(detailResp.PostStream.Posts) == 0 {\n\t\treturn nil, fmt.Errorf(\"no posts found\")\n\t}\n\n\tmainPost := detailResp.PostStream.Posts[0]\n\t\n\t// 从 link_counts 中提取网盘链接\n\tvar links []model.Link\n\tfor _, linkCount := range mainPost.LinkCounts {\n\t\t// 跳过内部链接\n\t\tif linkCount.Internal {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 判断是否为网盘链接并解析\n\t\tlink := p.parseNetDiskLink(linkCount.URL)\n\t\tif link != nil {\n\t\t\tlinks = append(links, *link)\n\t\t}\n\t}\n\n\treturn links, nil\n}\n\n// parseNetDiskLink 解析网盘链接\nfunc (p *DiscourseAsyncPlugin) parseNetDiskLink(linkURL string) *model.Link {\n\t// 夸克网盘\n\tif quarkRegex.MatchString(linkURL) {\n\t\treturn &model.Link{\n\t\t\tType: \"quark\",\n\t\t\tURL:  linkURL,\n\t\t}\n\t}\n\n\t// 百度网盘\n\tif baiduRegex.MatchString(linkURL) {\n\t\tlink := &model.Link{\n\t\t\tType: \"baidu\",\n\t\t\tURL:  linkURL,\n\t\t}\n\t\t// 提取pwd参数\n\t\tif matches := baiduRegex.FindStringSubmatch(linkURL); len(matches) > 1 && matches[1] != \"\" {\n\t\t\tlink.Password = matches[1]\n\t\t}\n\t\treturn link\n\t}\n\n\t// 阿里云盘\n\tif aliyunRegex.MatchString(linkURL) {\n\t\treturn &model.Link{\n\t\t\tType: \"aliyun\",\n\t\t\tURL:  linkURL,\n\t\t}\n\t}\n\n\t// 迅雷网盘\n\tif xunleiRegex.MatchString(linkURL) {\n\t\tlink := &model.Link{\n\t\t\tType: \"xunlei\",\n\t\t\tURL:  linkURL,\n\t\t}\n\t\t// 提取pwd参数\n\t\tif matches := xunleiRegex.FindStringSubmatch(linkURL); len(matches) > 1 && matches[1] != \"\" {\n\t\t\tlink.Password = matches[1]\n\t\t}\n\t\treturn link\n\t}\n\n\t// 天翼云盘\n\tif tianyiRegex.MatchString(linkURL) {\n\t\treturn &model.Link{\n\t\t\tType: \"tianyi\",\n\t\t\tURL:  linkURL,\n\t\t}\n\t}\n\n\t// UC网盘\n\tif ucRegex.MatchString(linkURL) {\n\t\treturn &model.Link{\n\t\t\tType: \"uc\",\n\t\t\tURL:  linkURL,\n\t\t}\n\t}\n\n\t// 115网盘\n\tif pan115Regex.MatchString(linkURL) {\n\t\treturn &model.Link{\n\t\t\tType: \"115\",\n\t\t\tURL:  linkURL,\n\t\t}\n\t}\n\n\t// 不是网盘链接\n\treturn nil\n}\n\n"
  },
  {
    "path": "plugin/discourse/json结构分析.md",
    "content": "# Linux.do 搜索API JSON结构分析\n\n## 接口信息\n\n- **接口名称**: Linux.do 论坛搜索API (Discourse)\n- **接口地址**: `https://linux.do/search.json`\n- **请求方法**: `GET`\n- **Content-Type**: `application/json`\n- **主要特点**: 基于Discourse论坛系统，搜索网盘资源分享帖子，需要绕过Cloudflare防护\n\n## 请求结构\n\n### 搜索API请求格式\n\n```\nGET https://linux.do/search.json?q={keyword}%20%23resource%3Acloud-asset%20in%3Atitle&page={page}\n```\n\n### 请求参数说明\n\n| 参数名 | 类型 | 必需 | 默认值 | 说明 |\n|--------|------|------|--------|------|\n| `q` | string | 是 | - | 搜索查询，包含关键词和过滤条件，需要URL编码 |\n| `page` | int | 否 | 1 | 页码，从1开始 |\n\n### 查询字符串格式\n\n```\n{keyword} #resource:cloud-asset in:title\n```\n\n说明：\n- `{keyword}`: 搜索关键词（如：遮天）\n- `#resource:cloud-asset`: 过滤标签，只搜索云盘资源类别\n- `in:title`: 只在标题中搜索\n\n## 响应结构\n\n### 完整响应格式\n\n```json\n{\n  \"posts\": [...],\n  \"topics\": [...],\n  \"users\": [],\n  \"categories\": [],\n  \"tags\": [],\n  \"groups\": [],\n  \"grouped_search_result\": {\n    \"more_posts\": null,\n    \"more_users\": null,\n    \"more_categories\": null,\n    \"term\": \"遮天 #resource:cloud-asset in:title\",\n    \"search_log_id\": 16604511,\n    \"more_full_page_results\": true,\n    \"can_create_topic\": true,\n    \"error\": null,\n    \"extra\": {},\n    \"post_ids\": [...],\n    \"user_ids\": [],\n    \"category_ids\": [],\n    \"tag_ids\": [],\n    \"group_ids\": []\n  }\n}\n```\n\n### 响应字段详解\n\n#### 1. posts 数组（帖子列表）\n\n包含搜索到的帖子信息，每个帖子包含网盘链接：\n\n```json\n{\n  \"id\": 9619992,\n  \"name\": \"lxwh\",\n  \"username\": \"lxwh\",\n  \"avatar_template\": \"/user_avatar/linux.do/lxwh/{size}/387453_2.png\",\n  \"created_at\": \"2025-10-21T10:29:05.613Z\",\n  \"like_count\": 2,\n  \"blurb\": \"紫川更新 遮天... 夸克网盘： https://pan.quark.cn/s/99758a147076 点击进入 百度网盘： https://pan.baidu.com/s/1wF1YzQ14Vo8us_k9UfFNJQ?pwd=hccn 点击进入...\",\n  \"post_number\": 1,\n  \"topic_id\": 1067663\n}\n```\n\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| `id` | int | 帖子ID |\n| `name` | string | 发帖人姓名 |\n| `username` | string | 发帖人用户名 |\n| `avatar_template` | string | 头像模板URL |\n| `created_at` | string | 发布时间（ISO 8601格式） |\n| `like_count` | int | 点赞数 |\n| `blurb` | string | **帖子内容摘要（包含网盘链接）** |\n| `post_number` | int | 帖子楼层号 |\n| `topic_id` | int | 主题ID |\n\n#### 2. topics 数组（主题列表）\n\n包含搜索到的主题信息：\n\n```json\n{\n  \"fancy_title\": \"遮天 第132集＆紫川2更15集 【4K高码】\",\n  \"id\": 1067663,\n  \"title\": \"遮天 第132集＆紫川2更15集 【4K高码】\",\n  \"slug\": \"topic\",\n  \"posts_count\": 8,\n  \"reply_count\": 2,\n  \"highest_post_number\": 8,\n  \"created_at\": \"2025-10-21T10:29:05.493Z\",\n  \"last_posted_at\": \"2025-10-22T00:28:29.185Z\",\n  \"bumped\": true,\n  \"bumped_at\": \"2025-10-22T00:28:29.185Z\",\n  \"archetype\": \"regular\",\n  \"unseen\": false,\n  \"pinned\": false,\n  \"unpinned\": null,\n  \"visible\": true,\n  \"closed\": false,\n  \"archived\": false,\n  \"bookmarked\": null,\n  \"liked\": null,\n  \"tags\": [\n    \"夸克网盘\",\n    \"影视\",\n    \"百度网盘\",\n    \"动漫\"\n  ],\n  \"tags_descriptions\": {},\n  \"category_id\": 94,\n  \"has_accepted_answer\": false,\n  \"can_have_answer\": false\n}\n```\n\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| `id` | int | 主题ID |\n| `title` | string | 主题标题 |\n| `fancy_title` | string | 格式化标题（HTML实体） |\n| `tags` | array | **标签列表（包含网盘类型）** |\n| `posts_count` | int | 回复数 |\n| `created_at` | string | 创建时间 |\n| `last_posted_at` | string | 最后回复时间 |\n| `category_id` | int | 分类ID（94=云盘资源） |\n\n#### 3. grouped_search_result（搜索元数据）\n\n```json\n{\n  \"term\": \"遮天 #resource:cloud-asset in:title\",\n  \"search_log_id\": 16604511,\n  \"more_full_page_results\": true,\n  \"can_create_topic\": true,\n  \"error\": null,\n  \"post_ids\": [9619992, 9620329, ...],\n  \"user_ids\": [],\n  \"category_ids\": [],\n  \"tag_ids\": [],\n  \"group_ids\": []\n}\n```\n\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| `term` | string | 搜索词 |\n| `post_ids` | array | 所有帖子ID列表 |\n| `more_full_page_results` | bool | 是否有更多结果 |\n\n## 数据提取逻辑\n\n### 1. 从 blurb 中提取网盘链接\n\n`blurb` 字段包含帖子的文本摘要，其中包含网盘链接。需要使用正则表达式提取：\n\n#### 网盘链接格式\n\n| 网盘类型 | URL 格式 | 提取码格式 |\n|----------|----------|-----------|\n| **夸克网盘** | `https://pan.quark.cn/s/{code}` | 无需提取码 |\n| **百度网盘** | `https://pan.baidu.com/s/{code}?pwd={password}` | `?pwd={password}` |\n| **阿里云盘** | `https://www.aliyundrive.com/s/{code}` | 无需提取码 |\n| **迅雷网盘** | `https://pan.xunlei.com/s/{code}?pwd={password}` | `?pwd={password}` |\n| **天翼云盘** | `https://cloud.189.cn/t/{code}` | 访问码: {code} |\n| **UC网盘** | `https://drive.uc.cn/s/{code}` | 无需提取码 |\n\n#### 正则表达式模式\n\n```go\n// 夸克网盘\nquarkPattern := regexp.MustCompile(`https://pan\\.quark\\.cn/s/[0-9a-zA-Z]+`)\n\n// 百度网盘（带提取码）\nbaiduPattern := regexp.MustCompile(`https://pan\\.baidu\\.com/s/[0-9a-zA-Z_\\-]+(?:\\?pwd=([0-9a-zA-Z]+))?`)\n\n// 阿里云盘\naliyunPattern := regexp.MustCompile(`https://(?:www\\.)?aliyundrive\\.com/s/[0-9a-zA-Z]+`)\n\n// 迅雷网盘\nxunleiPattern := regexp.MustCompile(`https://pan\\.xunlei\\.com/s/[0-9a-zA-Z_\\-]+(?:\\?pwd=([0-9a-zA-Z]+))?`)\n\n// 天翼云盘\ntianyiPattern := regexp.MustCompile(`https://cloud\\.189\\.cn/t/[0-9a-zA-Z]+`)\n\n// UC网盘\nucPattern := regexp.MustCompile(`https://drive\\.uc\\.cn/s/[0-9a-zA-Z]+`)\n```\n\n### 2. 从 tags 中获取网盘类型\n\n`tags` 数组包含网盘类型标签，可以用于过滤和分类：\n\n```go\ntags := []string{\"夸克网盘\", \"百度网盘\", \"动漫\"}\n```\n\n### 3. 网盘类型映射\n\n| 标签名 | 英文标识 |\n|--------|---------|\n| 夸克网盘 | quark |\n| 百度网盘 | baidu |\n| 阿里云盘 | aliyun |\n| 迅雷网盘 | xunlei |\n| UC网盘 | uc |\n| 天翼云盘 | tianyi |\n| 115网盘 | 115 |\n| 123网盘 | 123 |\n\n### 4. 时间格式转换\n\n```go\n// 输入格式: \"2025-10-21T10:29:05.613Z\" (ISO 8601)\n// 解析为 time.Time\nparsedTime, err := time.Parse(time.RFC3339, \"2025-10-21T10:29:05.613Z\")\n```\n\n## 实现要点\n\n### 1. Cloudflare 绕过\n\nLinux.do 使用 Cloudflare 防护，必须使用 cloudscraper 库绕过：\n\n```go\nimport \"github.com/Advik-B/cloudscraper/lib\"\n\n// 创建 cloudscraper 客户端\nsc, err := cloudscraper.New()\n\n// 发送请求\nresp, err := sc.Get(searchURL)\n```\n\n### 2. URL 构建\n\n```go\n// 搜索关键词需要 URL 编码\nkeyword := \"遮天\"\nquery := fmt.Sprintf(\"%s #resource:cloud-asset in:title\", keyword)\nsearchURL := fmt.Sprintf(\"https://linux.do/search.json?q=%s&page=%d\", \n    url.QueryEscape(query), page)\n```\n\n### 3. 链接提取逻辑\n\n```go\n// 从 blurb 中提取所有网盘链接\nfunc extractNetDiskLinks(blurb string) []model.Link {\n    var links []model.Link\n    \n    // 提取夸克网盘\n    quarkLinks := quarkPattern.FindAllString(blurb, -1)\n    for _, linkURL := range quarkLinks {\n        links = append(links, model.Link{\n            Type: \"quark\",\n            URL:  linkURL,\n        })\n    }\n    \n    // 提取百度网盘（带提取码）\n    baiduMatches := baiduPattern.FindAllStringSubmatch(blurb, -1)\n    for _, match := range baiduMatches {\n        link := model.Link{\n            Type: \"baidu\",\n            URL:  match[0],\n        }\n        if len(match) > 1 && match[1] != \"\" {\n            link.Password = match[1]\n        }\n        links = append(links, link)\n    }\n    \n    // ... 其他网盘类型\n    \n    return links\n}\n```\n\n### 4. SearchResult 构建\n\n```go\nfunc convertToSearchResult(post Post, topic Topic) model.SearchResult {\n    // 提取网盘链接\n    links := extractNetDiskLinks(post.Blurb)\n    \n    // 解析时间\n    createdAt, _ := time.Parse(time.RFC3339, post.CreatedAt)\n    \n    return model.SearchResult{\n        UniqueID:  fmt.Sprintf(\"linuxdo-%d\", post.ID),\n        Title:     topic.Title,\n        Content:   post.Blurb,\n        Links:     links,\n        Tags:      topic.Tags,\n        Channel:   \"\", // 插件搜索结果必须为空\n        Datetime:  createdAt,\n    }\n}\n```\n\n## 注意事项\n\n1. **Cloudflare 防护**: 必须使用 cloudscraper 库绕过\n2. **查询格式**: 必须包含 `#resource:cloud-asset in:title` 过滤条件\n3. **链接提取**: blurb 是截断的文本，可能包含不完整的链接\n4. **去重**: 同一个资源可能在多个帖子中出现，需要去重\n5. **网盘类型**: 从 tags 和 链接URL 双重判断网盘类型\n6. **提取码**: 百度网盘和迅雷网盘的提取码在 URL 参数中\n7. **分页**: 支持 page 参数进行分页搜索\n\n## 优先级建议\n\n根据 Linux.do 的特点，建议设置插件优先级为 **2**：\n- ✅ 数据源质量良好，社区活跃\n- ✅ 资源更新及时，内容新鲜\n- ✅ 支持多种网盘类型\n- ⚠️ 需要绕过 Cloudflare 防护\n- ⚠️ 链接提取依赖文本解析，可能有误差\n\n## 详情页API\n\n### 接口说明\n\n当搜索结果中的 `blurb` 字段无法提供完整的网盘链接时，可以通过详情页API获取完整内容。\n\n### 请求格式\n\n```\nGET https://linux.do/t/{topic_id}.json?track_visit=true&forceLoad=true\n```\n\n| 参数名 | 类型 | 必需 | 说明 |\n|--------|------|------|------|\n| `topic_id` | int | 是 | 主题ID，从搜索结果的 `topic_id` 字段获取 |\n| `track_visit` | bool | 否 | 是否跟踪访问 |\n| `forceLoad` | bool | 否 | 是否强制加载 |\n\n### 响应结构\n\n```json\n{\n  \"post_stream\": {\n    \"posts\": [\n      {\n        \"id\": 9619992,\n        \"username\": \"lxwh\",\n        \"created_at\": \"2025-10-21T10:29:05.613Z\",\n        \"cooked\": \"<p>HTML格式的完整帖子内容...</p>\",\n        \"post_number\": 1,\n        \"topic_id\": 1067663,\n        \"link_counts\": [\n          {\n            \"url\": \"https://pan.quark.cn/s/d6b8b0908959\",\n            \"internal\": false,\n            \"reflection\": false,\n            \"clicks\": 29\n          },\n          {\n            \"url\": \"https://pan.baidu.com/s/1KJylsrBbKbMhi9e-i9YMVA?pwd=tn44\",\n            \"internal\": false,\n            \"reflection\": false,\n            \"clicks\": 16\n          }\n        ]\n      }\n    ]\n  },\n  \"id\": 1067663,\n  \"title\": \"遮天 第132集＆紫川2更15集 【4K高码】\",\n  \"fancy_title\": \"遮天 第132集＆紫川2更15集 【4K高码】\",\n  \"tags\": [\"夸克网盘\", \"影视\", \"百度网盘\", \"动漫\"],\n  \"category_id\": 94\n}\n```\n\n### 关键字段说明\n\n#### post_stream.posts[0]（主帖内容）\n\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| `cooked` | string | **HTML格式的完整帖子内容** |\n| `link_counts` | array | **所有外部链接列表（含网盘链接）** |\n\n#### link_counts 数组\n\n这是最可靠的链接提取来源，包含了帖子中所有外部链接：\n\n```json\n{\n  \"url\": \"https://pan.quark.cn/s/d6b8b0908959\",\n  \"internal\": false,\n  \"reflection\": false,\n  \"clicks\": 29\n}\n```\n\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| `url` | string | **完整的链接URL** |\n| `internal` | bool | 是否为内部链接（false表示外部链接） |\n| `clicks` | int | 点击次数 |\n\n### 数据提取策略\n\n**推荐方式**：优先使用 `link_counts` 数组\n\n1. ✅ **最可靠**：由服务器解析，不会遗漏或截断\n2. ✅ **最完整**：包含所有外部链接\n3. ✅ **易解析**：结构化数据，无需正则表达式\n\n```go\n// 从 link_counts 提取网盘链接\nfor _, linkCount := range post.LinkCounts {\n    // 跳过内部链接\n    if linkCount.Internal {\n        continue\n    }\n    \n    // 判断是否为网盘链接\n    if isNetDiskURL(linkCount.URL) {\n        link := parseNetDiskLink(linkCount.URL)\n        links = append(links, link)\n    }\n}\n```\n\n**备用方式**：从 `cooked` HTML 中提取\n\n当 `link_counts` 为空或不完整时，可以从 HTML 中提取：\n\n```go\n// 使用 goquery 解析 HTML\ndoc, _ := goquery.NewDocumentFromReader(strings.NewReader(post.Cooked))\n\n// 提取所有 <a> 标签\ndoc.Find(\"a\").Each(func(i int, s *goquery.Selection) {\n    href, exists := s.Attr(\"href\")\n    if exists && isNetDiskURL(href) {\n        link := parseNetDiskLink(href)\n        links = append(links, link)\n    }\n})\n```\n\n## 实现策略\n\n### 两步法获取完整数据\n\n1. **第一步：搜索API**\n   - 获取帖子列表和基本信息\n   - 从 `blurb` 快速提取部分链接\n   - 获取 `topic_id` 用于详情请求\n\n2. **第二步：详情API**（按需）\n   - 当 `blurb` 中链接不完整时\n   - 或需要获取完整帖子内容时\n   - 使用 `link_counts` 获取所有链接\n\n### 性能优化建议\n\n- ✅ **批量获取**：使用协程并发请求多个详情页\n- ✅ **智能跳过**：如果搜索结果已有完整链接，跳过详情请求\n- ✅ **缓存结果**：相同 `topic_id` 的详情可缓存\n- ⚠️ **速率限制**：避免请求过快被限流\n\n## 示例\n\n### 1. 搜索API 示例\n\n#### 请求\n```\nGET https://linux.do/search.json?q=%E9%81%AE%E5%A4%A9%20%23resource%3Acloud-asset%20in%3Atitle&page=1\n```\n\n#### 响应（简化）\n```json\n{\n  \"posts\": [\n    {\n      \"id\": 9619992,\n      \"username\": \"lxwh\",\n      \"created_at\": \"2025-10-21T10:29:05.613Z\",\n      \"blurb\": \"夸克网盘： https://pan.quark.cn/s/99758a147076\",\n      \"topic_id\": 1067663\n    }\n  ],\n  \"topics\": [\n    {\n      \"id\": 1067663,\n      \"title\": \"遮天 第132集＆紫川2更15集 【4K高码】\",\n      \"tags\": [\"夸克网盘\", \"影视\", \"动漫\"]\n    }\n  ]\n}\n```\n\n### 2. 详情API 示例\n\n#### 请求\n```\nGET https://linux.do/t/1067663.json?track_visit=true&forceLoad=true\n```\n\n#### 响应（简化）\n```json\n{\n  \"post_stream\": {\n    \"posts\": [\n      {\n        \"id\": 9619992,\n        \"link_counts\": [\n          {\n            \"url\": \"https://pan.quark.cn/s/d6b8b0908959\",\n            \"internal\": false,\n            \"clicks\": 29\n          },\n          {\n            \"url\": \"https://pan.baidu.com/s/1KJylsrBbKbMhi9e-i9YMVA?pwd=tn44\",\n            \"internal\": false,\n            \"clicks\": 16\n          }\n        ]\n      }\n    ]\n  },\n  \"title\": \"遮天 第132集＆紫川2更15集 【4K高码】\"\n}\n```\n\n"
  },
  {
    "path": "plugin/djgou/djgou.go",
    "content": "package djgou\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\n// 预编译的正则表达式\nvar (\n\t// 夸克网盘链接正则表达式（网站只有夸克网盘）\n\t// 注意：夸克链接可能包含字母、数字、下划线、连字符等字符\n\tquarkLinkRegex = regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9a-zA-Z_\\-]+`)\n\t\n\t// 提取码正则表达式\n\tpwdRegex = regexp.MustCompile(`提取码[:：]\\s*([a-zA-Z0-9]{4})`)\n\t\n\t// 缓存相关\n\tdetailCache      = sync.Map{} // 缓存详情页解析结果\n\tlastCleanupTime  = time.Now()\n\tcacheTTL         = 1 * time.Hour\n)\n\nconst (\n\t// 超时时间\n\tDefaultTimeout = 8 * time.Second\n\tDetailTimeout  = 6 * time.Second\n\t\n\t// 并发数（精简后的代码使用较低的并发即可）\n\tMaxConcurrency = 15\n\t\n\t// HTTP连接池配置\n\tMaxIdleConns        = 50\n\tMaxIdleConnsPerHost = 20\n\tMaxConnsPerHost     = 30\n\tIdleConnTimeout     = 90 * time.Second\n\t\n\t// 网站URL\n\tSiteURL = \"https://duanjugou.top\"\n)\n\n// 在init函数中注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewDjgouPlugin())\n\t\n\t// 启动缓存清理goroutine\n\tgo startCacheCleaner()\n}\n\n// startCacheCleaner 启动一个定期清理缓存的goroutine\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(30 * time.Minute)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\t// 清空所有缓存\n\t\tdetailCache = sync.Map{}\n\t\tlastCleanupTime = time.Now()\n\t}\n}\n\n// DjgouPlugin 短剧狗插件\ntype DjgouPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t}\n\treturn &http.Client{Transport: transport, Timeout: DefaultTimeout}\n}\n\n// NewDjgouPlugin 创建新的短剧狗插件\nfunc NewDjgouPlugin() *DjgouPlugin {\n\treturn &DjgouPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"djgou\", 2), // 优先级2：质量良好的数据源\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *DjgouPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *DjgouPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实现具体的搜索逻辑\nfunc (p *DjgouPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 1. 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"%s/search.php?q=%s&page=1\", SiteURL, url.QueryEscape(keyword))\n\t\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\t\n\t// 3. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 4. 设置完整的请求头（避免反爬虫）\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Referer\", SiteURL)\n\t\n\t// 5. 发送请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 6. 解析搜索结果页面\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 7. 提取搜索结果\n\tvar results []model.SearchResult\n\t\n\t// 查找主列表容器\n\tmainListSection := doc.Find(\"div.erx-list-box\")\n\tif mainListSection.Length() == 0 {\n\t\treturn nil, fmt.Errorf(\"[%s] 未找到erx-list-box容器\", p.Name())\n\t}\n\t\n\t// 查找列表项\n\titems := mainListSection.Find(\"ul.erx-list li.item\")\n\tif items.Length() == 0 {\n\t\treturn []model.SearchResult{}, nil // 没有搜索结果\n\t}\n\t\n\t// 8. 解析每个搜索结果项\n\titems.Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchItem(s, keyword)\n\t\tif result.UniqueID != \"\" {\n\t\t\tresults = append(results, result)\n\t\t}\n\t})\n\t\n\t// 9. 异步获取详情页信息\n\tenhancedResults := p.enhanceWithDetails(client, results)\n\t\n\t// 10. 关键词过滤\n\treturn plugin.FilterResultsByKeyword(enhancedResults, keyword), nil\n}\n\n// parseSearchItem 解析单个搜索结果项\nfunc (p *DjgouPlugin) parseSearchItem(s *goquery.Selection, keyword string) model.SearchResult {\n\tresult := model.SearchResult{}\n\t\n\t// 提取标题区域\n\taDiv := s.Find(\"div.a\")\n\tif aDiv.Length() == 0 {\n\t\treturn result\n\t}\n\t\n\t// 提取链接和标题\n\tlinkElem := aDiv.Find(\"a.main\")\n\tif linkElem.Length() == 0 {\n\t\treturn result\n\t}\n\t\n\ttitle := strings.TrimSpace(linkElem.Text())\n\tlink, exists := linkElem.Attr(\"href\")\n\tif !exists || link == \"\" {\n\t\treturn result\n\t}\n\t\n\t// 处理相对路径\n\tif !strings.HasPrefix(link, \"http\") {\n\t\tif strings.HasPrefix(link, \"/\") {\n\t\t\tlink = SiteURL + link\n\t\t} else {\n\t\t\tlink = SiteURL + \"/\" + link\n\t\t}\n\t}\n\t\n\t// 提取时间\n\ttimeText := \"\"\n\tiDiv := s.Find(\"div.i\")\n\tif iDiv.Length() > 0 {\n\t\ttimeSpan := iDiv.Find(\"span.time\")\n\t\tif timeSpan.Length() > 0 {\n\t\t\ttimeText = strings.TrimSpace(timeSpan.Text())\n\t\t}\n\t}\n\t\n\t// 生成唯一ID（使用链接的路径部分）\n\titemID := strings.TrimPrefix(link, SiteURL)\n\titemID = strings.Trim(itemID, \"/\")\n\tresult.UniqueID = fmt.Sprintf(\"%s-%s\", p.Name(), url.QueryEscape(itemID))\n\t\n\tresult.Title = title\n\tresult.Datetime = p.parseTime(timeText)\n\tresult.Tags = []string{\"短剧\"}\n\tresult.Channel = \"\" // 插件搜索结果必须为空字符串\n\t\n\t// 将详情页链接存储在Content中，后续获取详情\n\tresult.Content = link\n\t\n\treturn result\n}\n\n// parseTime 解析时间字符串\nfunc (p *DjgouPlugin) parseTime(timeStr string) time.Time {\n\tif timeStr == \"\" {\n\t\treturn time.Now()\n\t}\n\t\n\t// 尝试多种时间格式\n\tformats := []string{\n\t\t\"2006-01-02 15:04:05\",\n\t\t\"2006-01-02 15:04\",\n\t\t\"2006-01-02\",\n\t\t\"2006/01/02 15:04:05\",\n\t\t\"2006/01/02 15:04\",\n\t\t\"2006/01/02\",\n\t}\n\t\n\tfor _, format := range formats {\n\t\tif t, err := time.Parse(format, timeStr); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\t\n\treturn time.Now()\n}\n\n// enhanceWithDetails 异步获取详情页信息\nfunc (p *DjgouPlugin) enhanceWithDetails(client *http.Client, results []model.SearchResult) []model.SearchResult {\n\tvar wg sync.WaitGroup\n\tvar mu sync.Mutex\n\t\n\t// 使用信号量控制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\n\tenhancedResults := make([]model.SearchResult, 0, len(results))\n\t\n\tfor _, result := range results {\n\t\twg.Add(1)\n\t\tgo func(r model.SearchResult) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 从缓存或详情页获取链接\n\t\t\tlinks, content := p.getDetailInfo(client, r.Content)\n\t\t\t\n\t\t\t// 更新结果\n\t\t\tr.Links = links\n\t\t\tr.Content = content\n\t\t\t\n\t\t\t// 只添加有链接的结果\n\t\t\tif len(links) > 0 {\n\t\t\t\tmu.Lock()\n\t\t\t\tenhancedResults = append(enhancedResults, r)\n\t\t\t\tmu.Unlock()\n\t\t\t}\n\t\t}(result)\n\t}\n\t\n\twg.Wait()\n\treturn enhancedResults\n}\n\n// getDetailInfo 获取详情页信息（带缓存）\nfunc (p *DjgouPlugin) getDetailInfo(client *http.Client, detailURL string) ([]model.Link, string) {\n\t// 检查缓存\n\tif cached, ok := detailCache.Load(detailURL); ok {\n\t\tcachedData := cached.(DetailCacheData)\n\t\tif time.Since(cachedData.Timestamp) < cacheTTL {\n\t\t\treturn cachedData.Links, cachedData.Content\n\t\t}\n\t}\n\t\n\t// 获取详情页\n\tlinks, content := p.fetchDetailPage(client, detailURL)\n\t\n\t// 存入缓存\n\tif len(links) > 0 {\n\t\tdetailCache.Store(detailURL, DetailCacheData{\n\t\t\tLinks:     links,\n\t\t\tContent:   content,\n\t\t\tTimestamp: time.Now(),\n\t\t})\n\t}\n\t\n\treturn links, content\n}\n\n// DetailCacheData 详情页缓存数据\ntype DetailCacheData struct {\n\tLinks     []model.Link\n\tContent   string\n\tTimestamp time.Time\n}\n\n// fetchDetailPage 获取详情页信息\nfunc (p *DjgouPlugin) fetchDetailPage(client *http.Client, detailURL string) ([]model.Link, string) {\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DetailTimeout)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn nil, \"\"\n\t}\n\t\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Referer\", SiteURL)\n\t\n\t// 发送请求\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, \"\"\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil, \"\"\n\t}\n\t\n\t// 解析页面\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, \"\"\n\t}\n\t\n\t// 查找主内容区域（用于提取简介）\n\tmainContent := doc.Find(\"div.erx-wrap\")\n\tif mainContent.Length() == 0 {\n\t\treturn nil, \"\"\n\t}\n\t\n\t// 提取网盘链接（从整个页面HTML中提取，不仅仅是mainContent）\n\tlinks := p.extractLinksFromDoc(doc)\n\t\n\t// 提取简介（从mainContent提取）\n\tcontent := p.extractContent(mainContent)\n\t\n\treturn links, content\n}\n\n// extractLinksFromDoc 从整个文档中提取夸克网盘链接（重要：从整个页面HTML中提取，不限于某个div）\nfunc (p *DjgouPlugin) extractLinksFromDoc(doc *goquery.Document) []model.Link {\n\tvar links []model.Link\n\tlinkMap := make(map[string]bool) // 去重\n\t\n\t// 获取整个页面的HTML内容（这是关键！）\n\thtmlContent, _ := doc.Html()\n\t\n\t// 提取提取码\n\tpassword := \"\"\n\tif match := pwdRegex.FindStringSubmatch(htmlContent); len(match) > 1 {\n\t\tpassword = match[1]\n\t}\n\t\n\t// 方法1：使用专用正则表达式提取夸克网盘链接\n\tquarkLinks := quarkLinkRegex.FindAllString(htmlContent, -1)\n\tfor _, quarkURL := range quarkLinks {\n\t\t// 去重\n\t\tif !linkMap[quarkURL] {\n\t\t\tlinkMap[quarkURL] = true\n\t\t\tlinks = append(links, model.Link{\n\t\t\t\tType:     \"quark\",\n\t\t\t\tURL:      quarkURL,\n\t\t\t\tPassword: password,\n\t\t\t})\n\t\t}\n\t}\n\t\n\t// 方法2：从所有<a>标签中查找夸克链接（作为补充）\n\tdoc.Find(\"a\").Each(func(i int, s *goquery.Selection) {\n\t\thref, exists := s.Attr(\"href\")\n\t\tif !exists || href == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 检查是否是夸克网盘链接\n\t\tif strings.Contains(href, \"pan.quark.cn\") {\n\t\t\t// 去重\n\t\t\tif !linkMap[href] {\n\t\t\t\tlinkMap[href] = true\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tType:     \"quark\",\n\t\t\t\t\tURL:      href,\n\t\t\t\t\tPassword: password,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t})\n\t\n\treturn links\n}\n\n// extractContent 提取简介\nfunc (p *DjgouPlugin) extractContent(mainContent *goquery.Selection) string {\n\tcontent := strings.TrimSpace(mainContent.Text())\n\t\n\t// 清理空白字符\n\tcontent = regexp.MustCompile(`\\s+`).ReplaceAllString(content, \" \")\n\t\n\t// 限制长度\n\tif len(content) > 300 {\n\t\tcontent = content[:300] + \"...\"\n\t}\n\t\n\treturn content\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *DjgouPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n"
  },
  {
    "path": "plugin/duoduo/duoduo.go",
    "content": "package duoduo\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\n// 预编译的正则表达式\nvar (\n\t// 从详情页URL中提取ID的正则表达式\n\tdetailIDRegex = regexp.MustCompile(`/vod/detail/id/(\\d+)\\.html`)\n\t\n\t// 常见网盘链接的正则表达式（支持16种类型）\n\tquarkLinkRegex     = regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9a-zA-Z]+`)\n\tucLinkRegex        = regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9a-zA-Z]+(\\?[^\"'\\s]*)?`)\n\tbaiduLinkRegex     = regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9a-zA-Z_\\-]+(\\?pwd=[0-9a-zA-Z]+)?`)\n\taliyunLinkRegex    = regexp.MustCompile(`https?://(www\\.)?(aliyundrive\\.com|alipan\\.com)/s/[0-9a-zA-Z]+`)\n\txunleiLinkRegex    = regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9a-zA-Z_\\-]+(\\?pwd=[0-9a-zA-Z]+)?`)\n\ttianyiLinkRegex    = regexp.MustCompile(`https?://cloud\\.189\\.cn/t/[0-9a-zA-Z]+`)\n\tlink115Regex       = regexp.MustCompile(`https?://115\\.com/s/[0-9a-zA-Z]+`)\n\tmobileLinkRegex    = regexp.MustCompile(`https?://caiyun\\.feixin\\.10086\\.cn/[0-9a-zA-Z]+`)\n\tweiyunLinkRegex    = regexp.MustCompile(`https?://share\\.weiyun\\.com/[0-9a-zA-Z]+`)\n\tlanzouLinkRegex    = regexp.MustCompile(`https?://(www\\.)?(lanzou[uixys]*|lan[zs]o[ux])\\.(com|net|org)/[0-9a-zA-Z]+`)\n\tjianguoyunLinkRegex = regexp.MustCompile(`https?://(www\\.)?jianguoyun\\.com/p/[0-9a-zA-Z]+`)\n\tlink123Regex       = regexp.MustCompile(`https?://123pan\\.com/s/[0-9a-zA-Z]+`)\n\tpikpakLinkRegex    = regexp.MustCompile(`https?://mypikpak\\.com/s/[0-9a-zA-Z]+`)\n\tmagnetLinkRegex    = regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9a-fA-F]{40}`)\n\ted2kLinkRegex      = regexp.MustCompile(`ed2k://\\|file\\|.+\\|\\d+\\|[0-9a-fA-F]{32}\\|/`)\n\t\n\t// 年份提取正则表达式\n\tyearRegex = regexp.MustCompile(`(\\d{4})`)\n\t\n\t// 缓存相关\n\tdetailCache = sync.Map{} // 缓存详情页解析结果\n\tlastCleanupTime = time.Now()\n\tcacheTTL = 1 * time.Hour // 优化为更短的缓存时间\n)\n\nconst (\n\t// 默认超时时间 - 优化为更短时间\n\tDefaultTimeout = 8 * time.Second\n\tDetailTimeout  = 6 * time.Second\n\n\t// 并发数限制 - 大幅提高并发数\n\tMaxConcurrency = 20\n\n\t// HTTP连接池配置\n\tMaxIdleConns        = 200\n\tMaxIdleConnsPerHost = 50\n\tMaxConnsPerHost     = 100\n\tIdleConnTimeout     = 90 * time.Second\n)\n\n// 性能统计（原子操作）\nvar (\n\tsearchRequests     int64 = 0\n\tdetailPageRequests int64 = 0\n\tcacheHits          int64 = 0\n\tcacheMisses        int64 = 0\n\ttotalSearchTime    int64 = 0 // 纳秒\n\ttotalDetailTime    int64 = 0 // 纳秒\n)\n\n// 在init函数中注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewDuoduoPlugin())\n\t\n\t// 启动缓存清理goroutine\n\tgo startCacheCleaner()\n}\n\n// startCacheCleaner 启动一个定期清理缓存的goroutine\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(30 * time.Minute)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\t// 清空所有缓存\n\t\tdetailCache = sync.Map{}\n\t\tlastCleanupTime = time.Now()\n\t}\n}\n\n// DuoduoAsyncPlugin Duoduo异步插件\ntype DuoduoAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t}\n\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   DefaultTimeout,\n\t}\n}\n\n// NewDuoduoPlugin 创建新的Duoduo异步插件\nfunc NewDuoduoPlugin() *DuoduoAsyncPlugin {\n\treturn &DuoduoAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"duoduo\", 2),\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *DuoduoAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *DuoduoAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实现具体的搜索逻辑\nfunc (p *DuoduoAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 性能统计\n\tstart := time.Now()\n\tatomic.AddInt64(&searchRequests, 1)\n\tdefer func() {\n\t\tduration := time.Since(start).Nanoseconds()\n\t\tatomic.AddInt64(&totalSearchTime, duration)\n\t}()\n\n\t// 使用优化的客户端\n\tif p.optimizedClient != nil {\n\t\tclient = p.optimizedClient\n\t}\n\n\t// 1. 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"https://tv.yydsys.top/index.php/vod/search/wd/%s.html\", url.QueryEscape(keyword))\n\t\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\t\n\t// 3. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 4. 设置完整的请求头（避免反爬虫）\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Referer\", \"https://tv.yydsys.top/\")\n\t\n\t// 5. 发送请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 6. 解析搜索结果页面\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 7. 提取搜索结果\n\tvar results []model.SearchResult\n\t\n\tdoc.Find(\".module-search-item\").Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchItem(s, keyword)\n\t\tif result.UniqueID != \"\" {\n\t\t\tresults = append(results, result)\n\t\t}\n\t})\n\t\n\t// 8. 异步获取详情页信息\n\tenhancedResults := p.enhanceWithDetails(client, results)\n\t\n\t// 9. 关键词过滤\n\treturn plugin.FilterResultsByKeyword(enhancedResults, keyword), nil\n}\n\n// parseSearchItem 解析单个搜索结果项\nfunc (p *DuoduoAsyncPlugin) parseSearchItem(s *goquery.Selection, keyword string) model.SearchResult {\n\tresult := model.SearchResult{}\n\t\n\t// 提取详情页链接和ID（从标题链接提取，不是播放链接）\n\tdetailLink, exists := s.Find(\".video-info-header h3 a\").First().Attr(\"href\")\n\tif !exists {\n\t\treturn result\n\t}\n\t\n\t// 提取ID\n\tmatches := detailIDRegex.FindStringSubmatch(detailLink)\n\tif len(matches) < 2 {\n\t\treturn result\n\t}\n\t\n\titemID := matches[1]\n\tresult.UniqueID = fmt.Sprintf(\"%s-%s\", p.Name(), itemID)\n\t\n\t// 提取标题\n\ttitleElement := s.Find(\".video-info-header h3 a\")\n\tresult.Title = strings.TrimSpace(titleElement.Text())\n\t\n\t// 提取资源类型/质量\n\tqualityElement := s.Find(\".video-serial\")\n\tquality := strings.TrimSpace(qualityElement.Text())\n\t\n\t// 提取分类信息\n\tvar tags []string\n\ts.Find(\".video-info-aux .tag-link a\").Each(func(i int, tag *goquery.Selection) {\n\t\ttagText := strings.TrimSpace(tag.Text())\n\t\tif tagText != \"\" {\n\t\t\ttags = append(tags, tagText)\n\t\t}\n\t})\n\tresult.Tags = tags\n\t\n\t// 提取导演信息\n\tdirector := \"\"\n\ts.Find(\".video-info-items\").Each(func(i int, item *goquery.Selection) {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\tif strings.Contains(title, \"导演\") {\n\t\t\tdirector = strings.TrimSpace(item.Find(\".video-info-actor a\").Text())\n\t\t}\n\t})\n\t\n\t// 提取主演信息\n\tvar actors []string\n\ts.Find(\".video-info-items\").Each(func(i int, item *goquery.Selection) {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\tif strings.Contains(title, \"主演\") {\n\t\t\titem.Find(\".video-info-actor a\").Each(func(j int, actor *goquery.Selection) {\n\t\t\t\tactorName := strings.TrimSpace(actor.Text())\n\t\t\t\tif actorName != \"\" {\n\t\t\t\t\tactors = append(actors, actorName)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\t\n\t// 提取剧情简介\n\tplotElement := s.Find(\".video-info-items\").FilterFunction(func(i int, item *goquery.Selection) bool {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\treturn strings.Contains(title, \"剧情\")\n\t})\n\tplot := strings.TrimSpace(plotElement.Find(\".video-info-item\").Text())\n\n\t// 提取封面图片 (参考 Pan_mogg.js 的选择器)\n\tvar images []string\n\tif picURL, exists := s.Find(\".module-item-pic > img\").Attr(\"data-src\"); exists && picURL != \"\" {\n\t\timages = append(images, picURL)\n\t}\n\tresult.Images = images\n\n\t// 构建内容描述\n\tvar contentParts []string\n\tif quality != \"\" {\n\t\tcontentParts = append(contentParts, \"【\"+quality+\"】\")\n\t}\n\tif director != \"\" {\n\t\tcontentParts = append(contentParts, \"导演：\"+director)\n\t}\n\tif len(actors) > 0 {\n\t\tactorStr := strings.Join(actors[:min(3, len(actors))], \"、\") // 只显示前3个演员\n\t\tif len(actors) > 3 {\n\t\t\tactorStr += \"等\"\n\t\t}\n\t\tcontentParts = append(contentParts, \"主演：\"+actorStr)\n\t}\n\tif plot != \"\" {\n\t\tcontentParts = append(contentParts, plot)\n\t}\n\t\n\tresult.Content = strings.Join(contentParts, \"\\n\")\n\tresult.Channel = \"\" // 插件搜索结果不设置频道名，只有Telegram频道结果才设置\n\tresult.Datetime = time.Time{} // 使用零值而不是nil，参考jikepan插件标准\n\t\n\treturn result\n}\n\n// enhanceWithDetails 异步获取详情页信息以获取下载链接\nfunc (p *DuoduoAsyncPlugin) enhanceWithDetails(client *http.Client, results []model.SearchResult) []model.SearchResult {\n\tvar enhancedResults []model.SearchResult\n\tvar mu sync.Mutex\n\tvar wg sync.WaitGroup\n\t\n\t// 限制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\n\tfor _, result := range results {\n\t\twg.Add(1)\n\t\tgo func(r model.SearchResult) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 从UniqueID提取ID\n\t\t\tparts := strings.Split(r.UniqueID, \"-\")\n\t\t\tif len(parts) < 2 {\n\t\t\t\tmu.Lock()\n\t\t\t\tenhancedResults = append(enhancedResults, r)\n\t\t\t\tmu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\titemID := parts[1]\n\n\t\t\t// 检查缓存\n\t\t\tif cached, ok := detailCache.Load(itemID); ok {\n\t\t\t\tif cachedResult, ok := cached.(model.SearchResult); ok {\n\t\t\t\t\tatomic.AddInt64(&cacheHits, 1)\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\tenhancedResults = append(enhancedResults, cachedResult)\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tatomic.AddInt64(&cacheMisses, 1)\n\n\t\t\t// 获取详情页链接和图片\n\t\t\tdetailLinks, detailImages := p.fetchDetailLinksAndImages(client, itemID)\n\t\t\tr.Links = detailLinks\n\n\t\t\t// 合并图片：优先使用详情页的海报，如果没有则使用搜索结果的图片\n\t\t\tif len(detailImages) > 0 {\n\t\t\t\tr.Images = detailImages\n\t\t\t}\n\n\t\t\t// 缓存结果\n\t\t\tdetailCache.Store(itemID, r)\n\n\t\t\tmu.Lock()\n\t\t\tenhancedResults = append(enhancedResults, r)\n\t\t\tmu.Unlock()\n\t\t}(result)\n\t}\n\t\n\twg.Wait()\n\treturn enhancedResults\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *DuoduoAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n\n// fetchDetailLinksAndImages 获取详情页的下载链接和图片\nfunc (p *DuoduoAsyncPlugin) fetchDetailLinksAndImages(client *http.Client, itemID string) ([]model.Link, []string) {\n\t// 性能统计\n\tstart := time.Now()\n\tatomic.AddInt64(&detailPageRequests, 1)\n\tdefer func() {\n\t\tduration := time.Since(start).Nanoseconds()\n\t\tatomic.AddInt64(&totalDetailTime, duration)\n\t}()\n\n\tdetailURL := fmt.Sprintf(\"https://tv.yydsys.top/index.php/vod/detail/id/%s.html\", itemID)\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DetailTimeout)\n\tdefer cancel()\n\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"https://tv.yydsys.top/\")\n\n\t// 发送请求（带重试）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, nil\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\n\tvar links []model.Link\n\tvar images []string\n\n\t// 提取详情页的海报图片 (参考 Pan_mogg.js 的选择器)\n\tif posterURL, exists := doc.Find(\".mobile-play .lazyload\").Attr(\"data-src\"); exists && posterURL != \"\" {\n\t\timages = append(images, posterURL)\n\t}\n\n\t// 查找下载链接区域\n\tdoc.Find(\"#download-list .module-row-one\").Each(func(i int, s *goquery.Selection) {\n\t\t// 从data-clipboard-text属性提取链接\n\t\tif linkURL, exists := s.Find(\"[data-clipboard-text]\").Attr(\"data-clipboard-text\"); exists {\n\t\t\t// 过滤掉无效链接\n\t\t\tif p.isValidNetworkDriveURL(linkURL) {\n\t\t\t\tif linkType := p.determineLinkType(linkURL); linkType != \"\" {\n\t\t\t\t\tlink := model.Link{\n\t\t\t\t\t\tType:     linkType,\n\t\t\t\t\t\tURL:      linkURL,\n\t\t\t\t\t\tPassword: \"\", // 大部分网盘不需要密码\n\t\t\t\t\t}\n\t\t\t\t\tlinks = append(links, link)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 也检查直接的href属性\n\t\ts.Find(\"a[href]\").Each(func(j int, a *goquery.Selection) {\n\t\t\tif linkURL, exists := a.Attr(\"href\"); exists {\n\t\t\t\t// 过滤掉无效链接\n\t\t\t\tif p.isValidNetworkDriveURL(linkURL) {\n\t\t\t\t\tif linkType := p.determineLinkType(linkURL); linkType != \"\" {\n\t\t\t\t\t\t// 避免重复添加\n\t\t\t\t\t\tisDuplicate := false\n\t\t\t\t\t\tfor _, existingLink := range links {\n\t\t\t\t\t\t\tif existingLink.URL == linkURL {\n\t\t\t\t\t\t\t\tisDuplicate = true\n\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif !isDuplicate {\n\t\t\t\t\t\t\tlink := model.Link{\n\t\t\t\t\t\t\t\tType:     linkType,\n\t\t\t\t\t\t\t\tURL:      linkURL,\n\t\t\t\t\t\t\t\tPassword: \"\",\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tlinks = append(links, link)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t})\n\n\treturn links, images\n}\n\n// fetchDetailLinks 获取详情页的下载链接（兼容性方法，仅返回链接）\nfunc (p *DuoduoAsyncPlugin) fetchDetailLinks(client *http.Client, itemID string) []model.Link {\n\tlinks, _ := p.fetchDetailLinksAndImages(client, itemID)\n\treturn links\n}\n\n// isValidNetworkDriveURL 检查URL是否为有效的网盘链接\nfunc (p *DuoduoAsyncPlugin) isValidNetworkDriveURL(url string) bool {\n\t// 过滤掉明显无效的链接\n\tif strings.Contains(url, \"javascript:\") || \n\t   strings.Contains(url, \"#\") ||\n\t   url == \"\" ||\n\t   (!strings.HasPrefix(url, \"http\") && !strings.HasPrefix(url, \"magnet:\") && !strings.HasPrefix(url, \"ed2k:\")) {\n\t\treturn false\n\t}\n\t\n\t// 检查是否匹配任何支持的网盘格式（16种）\n\treturn quarkLinkRegex.MatchString(url) ||\n\t\t   ucLinkRegex.MatchString(url) ||\n\t\t   baiduLinkRegex.MatchString(url) ||\n\t\t   aliyunLinkRegex.MatchString(url) ||\n\t\t   xunleiLinkRegex.MatchString(url) ||\n\t\t   tianyiLinkRegex.MatchString(url) ||\n\t\t   link115Regex.MatchString(url) ||\n\t\t   mobileLinkRegex.MatchString(url) ||\n\t\t   weiyunLinkRegex.MatchString(url) ||\n\t\t   lanzouLinkRegex.MatchString(url) ||\n\t\t   jianguoyunLinkRegex.MatchString(url) ||\n\t\t   link123Regex.MatchString(url) ||\n\t\t   pikpakLinkRegex.MatchString(url) ||\n\t\t   magnetLinkRegex.MatchString(url) ||\n\t\t   ed2kLinkRegex.MatchString(url)\n}\n\n// determineLinkType 根据URL确定链接类型（支持16种类型）\nfunc (p *DuoduoAsyncPlugin) determineLinkType(url string) string {\n\tswitch {\n\tcase quarkLinkRegex.MatchString(url):\n\t\treturn \"quark\"\n\tcase ucLinkRegex.MatchString(url):\n\t\treturn \"uc\"\n\tcase baiduLinkRegex.MatchString(url):\n\t\treturn \"baidu\" \n\tcase aliyunLinkRegex.MatchString(url):\n\t\treturn \"aliyun\"\n\tcase xunleiLinkRegex.MatchString(url):\n\t\treturn \"xunlei\"\n\tcase tianyiLinkRegex.MatchString(url):\n\t\treturn \"tianyi\"\n\tcase link115Regex.MatchString(url):\n\t\treturn \"115\"\n\tcase mobileLinkRegex.MatchString(url):\n\t\treturn \"mobile\"\n\tcase weiyunLinkRegex.MatchString(url):\n\t\treturn \"weiyun\"\n\tcase lanzouLinkRegex.MatchString(url):\n\t\treturn \"lanzou\"\n\tcase jianguoyunLinkRegex.MatchString(url):\n\t\treturn \"jianguoyun\"\n\tcase link123Regex.MatchString(url):\n\t\treturn \"123\"\n\tcase pikpakLinkRegex.MatchString(url):\n\t\treturn \"pikpak\"\n\tcase magnetLinkRegex.MatchString(url):\n\t\treturn \"magnet\"\n\tcase ed2kLinkRegex.MatchString(url):\n\t\treturn \"ed2k\"\n\tdefault:\n\t\treturn \"\" // 不支持的类型返回空字符串\n\t}\n}\n\n// min 返回两个整数中的较小值\nfunc min(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}\n\n// GetPerformanceStats 获取性能统计信息\nfunc (p *DuoduoAsyncPlugin) GetPerformanceStats() map[string]interface{} {\n\ttotalSearchRequests := atomic.LoadInt64(&searchRequests)\n\ttotalDetailRequests := atomic.LoadInt64(&detailPageRequests)\n\ttotalCacheHits := atomic.LoadInt64(&cacheHits)\n\ttotalCacheMisses := atomic.LoadInt64(&cacheMisses)\n\ttotalSearchTime := atomic.LoadInt64(&totalSearchTime)\n\ttotalDetailTime := atomic.LoadInt64(&totalDetailTime)\n\t\n\tvar avgSearchTime, avgDetailTime, cacheHitRate float64\n\tif totalSearchRequests > 0 {\n\t\tavgSearchTime = float64(totalSearchTime) / float64(totalSearchRequests) / 1e6 // 转换为毫秒\n\t}\n\tif totalDetailRequests > 0 {\n\t\tavgDetailTime = float64(totalDetailTime) / float64(totalDetailRequests) / 1e6 // 转换为毫秒\n\t}\n\tif totalCacheHits+totalCacheMisses > 0 {\n\t\tcacheHitRate = float64(totalCacheHits) / float64(totalCacheHits+totalCacheMisses) * 100\n\t}\n\t\n\treturn map[string]interface{}{\n\t\t\"search_requests\":        totalSearchRequests,\n\t\t\"detail_page_requests\":   totalDetailRequests,\n\t\t\"cache_hits\":            totalCacheHits,\n\t\t\"cache_misses\":          totalCacheMisses,\n\t\t\"cache_hit_rate\":        cacheHitRate,\n\t\t\"avg_search_time_ms\":    avgSearchTime,\n\t\t\"avg_detail_time_ms\":    avgDetailTime,\n\t\t\"total_search_time_ns\":  totalSearchTime,\n\t\t\"total_detail_time_ns\":  totalDetailTime,\n\t}\n}"
  },
  {
    "path": "plugin/duoduo/html结构分析.md",
    "content": "# Duoduo网站 (多多) 搜索结果HTML结构分析\n\n## 网站信息\n\n- **网站名称**: 多多\n- **域名**: `tv.yydsys.top`\n- **搜索URL格式**: `https://tv.yydsys.top/index.php/vod/search/wd/{关键词}.html`\n- **详情页URL格式**: `https://tv.yydsys.top/index.php/vod/detail/id/{ID}.html`\n- **主要特点**: 多多系列网盘资源站，提供高清影视资源\n\n## HTML结构\n\n基于DYXS2模板系统，与 Labi、Shandian、Muou 网站使用**完全相同的HTML结构**：\n\n### 搜索结果页面结构\n\n```html\n<div class=\"module\">\n    <div class=\"module-list\">\n        <div class=\"module-items\">\n            <div class=\"module-search-item\">\n                <!-- 单个搜索结果 -->\n            </div>\n        </div>\n    </div>\n</div>\n```\n\n### 单个搜索结果结构\n\n- **封面图片**: `.video-cover .module-item-cover .module-item-pic`\n- **详情页链接**: `href=\"/index.php/vod/detail/id/{ID}.html\"`\n- **标题**: `.video-info-header h3 a`\n- **资源状态**: `.video-serial`（如\"更新至11集\"）\n- **分类信息**: `.video-info-aux .tag-link`\n- **导演主演**: `.video-info-main .video-info-items`\n- **操作按钮**: `.video-info-footer`\n\n### 详情页面结构\n\n- **下载链接区域**: `#download-list`\n- **网盘类型**: `.module-tab-item span[data-dropdown-value]`\n- **下载链接**: `data-clipboard-text` 属性或 `.module-row-title p`\n\n## 与其他网站对比\n\n| 项目 | Duoduo (多多) | Labi | Shandian | Muou |\n|------|---------------|------|----------|------|\n| 域名 | tv.yydsys.top | xiaocge.fun | 1.95.79.193 | 123.666291.xyz |\n| 协议 | HTTPS | HTTP | HTTP | HTTP |\n| HTML结构 | **完全一致** | **完全一致** | **完全一致** | **完全一致** |\n| 模板系统 | DYXS2 | DYXS2 | DYXS2 | DYXS2 |\n\n## 提取逻辑\n\n与 Labi、Shandian、Muou 插件使用相同的提取逻辑：\n\n1. **搜索结果页面**: 查找 `.module-search-item` 元素\n2. **详情页面**: 查找 `#download-list .module-row-one` 获取下载链接\n3. **网盘类型**: 根据链接URL自动识别（可能是夸克、UC、百度等）\n\n## 重要发现和修正\n\n### 1. 详情页链接提取 ⚠️ 重要修正\n\n**错误的提取方式：**\n```html\n<!-- 这是播放页链接，不是详情页链接 -->\n<div class=\"module-item-pic\">\n    <a href=\"/index.php/vod/play/id/8468/sid/1/nid/1.html\">\n</div>\n```\n\n**正确的提取方式：**\n```html\n<!-- 这是详情页链接，应该从这里提取 -->\n<div class=\"video-info-header\">\n    <h3><a href=\"/index.php/vod/detail/id/8468.html\" title=\"凡人修仙传真人剧\">凡人修仙传真人剧</a></h3>\n</div>\n```\n\n### 2. 反爬虫机制 🚫\n\n网站具有时间限制的反爬虫遮罩层：\n- **限制时间**: 05:00-18:00 显示遮罩\n- **可用时间**: 18:00-05:00 不显示遮罩\n- **绕过方式**: 设置适当的请求头，模拟正常浏览器行为\n\n### 3. 网盘类型支持 💾\n\n该网站支持四种主要网盘：\n- **夸克网盘**: `https://pan.quark.cn/s/5c258bac77e9`\n- **百度网盘**: `https://pan.baidu.com/s/1-3T82ScmmHORlxNCzBiDxQ?pwd=yyds`\n- **UC网盘**: `https://drive.uc.cn/s/985330f160cd4`\n- **迅雷网盘**: `https://pan.xunlei.com/s/VOW914w3izuHrOBPtJlwFYkuA1?pwd=nxv9`\n\n### 4. 下载链接提取\n\n在详情页中，下载链接位于：\n```html\n<div id=\"download-list\">\n    <div class=\"module-row-one\">\n        <a class=\"module-row-text copy\" data-clipboard-text=\"网盘链接\">\n        <a class=\"btn-down\" href=\"网盘链接\">\n    </div>\n</div>\n```\n\n## 注意事项\n\n1. **协议**: 使用HTTPS，安全性更高\n2. **反爬虫**: 注意时间限制，可能需要在特定时间段访问\n3. **多网盘**: 支持夸克、百度、UC、迅雷四种网盘\n4. **链接提取**: 必须从 `.video-info-header h3 a` 提取详情页链接\n5. **域名稳定性**: 注意域名可能变化，需要支持域名切换"
  },
  {
    "path": "plugin/dyyj/dyyj.go",
    "content": "package dyyj\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tPluginName      = \"dyyj\"\n\tDisplayName     = \"电影云集\"\n\tDescription     = \"电影云集 - 影视资源网盘链接搜索\"\n\tBaseURL         = \"https://bbs.dyyjmax.org\"\n\tSearchPath      = \"/?q=%s\"\n\tUserAgent       = \"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\"\n\tMaxResults      = 100\n\tMaxConcurrency  = 100\n\tRequestTimeout  = 30 * time.Second\n\t\n\t// HTTP连接池配置（性能优化）\n\tMaxIdleConns        = 100  // 最大空闲连接数\n\tMaxIdleConnsPerHost = 100   // 每个主机的最大空闲连接数\n\tMaxConnsPerHost     = 100   // 每个主机的最大连接数\n\tIdleConnTimeout     = 90 * time.Second  // 空闲连接超时\n\tTLSHandshakeTimeout = 10 * time.Second  // TLS握手超时\n\tExpectContinueTimeout = 1 * time.Second // Expect: 100-continue超时\n)\n\n// 预编译的正则表达式（性能优化：避免重复编译）\nvar (\n\t// 提取文章ID的正则\n\tpostIDRegex = regexp.MustCompile(`/d/(\\d+)`)\n\t\n\t// 提取noscript标签的正则\n\tnoscriptRegex = regexp.MustCompile(`<noscript[^>]*id=[\"']flarum-content[\"'][^>]*>([\\s\\S]*?)</noscript>`)\n\t\n\t// 提取li标签内链接的正则\n\tliLinkRegex = regexp.MustCompile(`<li[^>]*>\\s*<a[^>]*href=[\"']([^\"']*\\/d\\/[^\"']*)[\"'][^>]*>([\\s\\S]*?)</a>\\s*</li>`)\n\t\n\t// 清理HTML标签的正则\n\thtmlTagRegex = regexp.MustCompile(`<[^>]+>`)\n\t\n\t// 提取链接的正则\n\tlinkHrefRegex = regexp.MustCompile(`href=[\"']([^\"']*\\/d\\/[^\"']*)[\"']`)\n\t\n\t// 提取发布时间meta标签的正则\n\tpublishTimeRegexes = []*regexp.Regexp{\n\t\tregexp.MustCompile(`<meta\\s+name=[\"']article:published_time[\"']\\s+content=[\"']([^\"']+)[\"']`),\n\t\tregexp.MustCompile(`<meta\\s+property=[\"']article:published_time[\"']\\s+content=[\"']([^\"']+)[\"']`),\n\t\tregexp.MustCompile(`<meta\\s+name=[\"']article:updated_time[\"']\\s+content=[\"']([^\"']+)[\"']`),\n\t\tregexp.MustCompile(`<time[^>]*datetime=[\"']([^\"']+)[\"']`),\n\t}\n\t\n\t// 网盘链接匹配模式（预编译，性能优化）\n\tnetworkDiskPatterns = []struct {\n\t\tname    string\n\t\tregex   *regexp.Regexp\n\t\turlType string\n\t}{\n\t\t{\"夸克网盘\", regexp.MustCompile(`<p><strong>夸克[^<]*</strong></p>\\s*<p><a[^>]*href\\s*=\\s*[\"']([^\"']+)[\"'][^>]*>`), \"quark\"},\n\t\t{\"百度网盘\", regexp.MustCompile(`<p><strong>百度[^<]*</strong></p>\\s*<p><a[^>]*href\\s*=\\s*[\"']([^\"']+)[\"'][^>]*>`), \"baidu\"},\n\t\t{\"阿里云盘\", regexp.MustCompile(`<p><strong>阿里[^<]*</strong></p>\\s*<p><a[^>]*href\\s*=\\s*[\"']([^\"']+)[\"'][^>]*>`), \"aliyun\"},\n\t\t{\"天翼云盘\", regexp.MustCompile(`<p><strong>天翼[^<]*</strong></p>\\s*<p><a[^>]*href\\s*=\\s*[\"']([^\"']+)[\"'][^>]*>`), \"tianyi\"},\n\t\t{\"迅雷网盘\", regexp.MustCompile(`<p><strong>迅雷[^<]*</strong></p>\\s*<p><a[^>]*href\\s*=\\s*[\"']([^\"']+)[\"'][^>]*>`), \"xunlei\"},\n\t\t{\"通用网盘\", regexp.MustCompile(`<a[^>]*href\\s*=\\s*[\"'](https?://[^\"']*(?:pan|drive|cloud)[^\"']*)[\"'][^>]*>`), \"others\"},\n\t}\n)\n\n// DyyjPlugin 电影云集插件\ntype DyyjPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tdebugMode       bool\n\tdetailCache     sync.Map // 缓存详情页结果\n\tcacheTTL        time.Duration\n\toptimizedClient *http.Client // 优化的HTTP客户端（连接池）\n}\n\n// init 注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewDyyjPlugin())\n}\n\n// NewDyyjPlugin 创建新的电影云集插件实例\nfunc NewDyyjPlugin() *DyyjPlugin {\n\tdebugMode := false // 生产环境关闭调试\n\n\tp := &DyyjPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(PluginName, 2), // 质量良好，优先级2\n\t\tdebugMode:       debugMode,\n\t\tcacheTTL:        30 * time.Minute, // 详情页缓存30分钟\n\t\toptimizedClient: createOptimizedHTTPClient(), // 创建优化的HTTP客户端\n\t}\n\n\treturn p\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端（连接池配置）\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:          MaxIdleConns,\n\t\tMaxIdleConnsPerHost:   MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:       MaxConnsPerHost,\n\t\tIdleConnTimeout:       IdleConnTimeout,\n\t\tTLSHandshakeTimeout:   TLSHandshakeTimeout,\n\t\tExpectContinueTimeout: ExpectContinueTimeout,\n\t\tForceAttemptHTTP2:     true, // 启用HTTP/2支持\n\t\tDisableKeepAlives:     false, // 启用Keep-Alive连接复用\n\t}\n\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   RequestTimeout,\n\t}\n}\n\n// Name 插件名称\nfunc (p *DyyjPlugin) Name() string {\n\treturn PluginName\n}\n\n// DisplayName 插件显示名称\nfunc (p *DyyjPlugin) DisplayName() string {\n\treturn DisplayName\n}\n\n// Description 插件描述\nfunc (p *DyyjPlugin) Description() string {\n\treturn Description\n}\n\n// Search 搜索接口\nfunc (p *DyyjPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *DyyjPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 搜索实现\nfunc (p *DyyjPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 开始搜索: %s\", keyword)\n\t}\n\n\t// 第一步：执行搜索获取结果列表\n\t// 使用优化的客户端（连接池）而不是传入的client\n\tsearchResults, err := p.executeSearch(p.optimizedClient, keyword)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 执行搜索失败: %v\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"[%s] 执行搜索失败: %w\", p.Name(), err)\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 搜索获取到 %d 个结果\", len(searchResults))\n\t}\n\n\t// 第二步：先对标题进行关键词过滤，只处理包含关键词的结果（避免不必要的详情页请求）\n\ttitleFilteredResults := p.filterByTitleKeyword(searchResults, keyword)\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 标题关键词过滤后剩余 %d 个结果（将只对这些结果获取详情页）\", len(titleFilteredResults))\n\t}\n\n\t// 第三步：并发获取详情页链接（只对标题包含关键词的结果）\n\t// 使用优化的客户端（连接池）而不是传入的client\n\tfinalResults := p.fetchDetailLinks(p.optimizedClient, titleFilteredResults, keyword)\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 最终获取到 %d 个有效结果\", len(finalResults))\n\t}\n\n\t// 第四步：最终关键词过滤（对标题和内容都进行过滤，标准网盘插件需要过滤）\n\tfilteredResults := plugin.FilterResultsByKeyword(finalResults, keyword)\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 最终关键词过滤后剩余 %d 个结果\", len(filteredResults))\n\t}\n\n\treturn filteredResults, nil\n}\n\n// executeSearch 执行搜索请求\nfunc (p *DyyjPlugin) executeSearch(client *http.Client, keyword string) ([]model.SearchResult, error) {\n\t// 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"%s%s\", BaseURL, fmt.Sprintf(SearchPath, url.QueryEscape(keyword)))\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 搜索URL: %s\", searchURL)\n\t}\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), RequestTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 创建搜索请求失败: %v\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"[%s] 创建搜索请求失败: %w\", p.Name(), err)\n\t}\n\n\t// 设置完整的请求头\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 搜索请求失败: %v\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 搜索请求响应状态码: %d\", resp.StatusCode)\n\t}\n\n\tif resp.StatusCode != 200 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 搜索请求HTTP状态错误: %d\", resp.StatusCode)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求HTTP状态错误: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\t// 读取响应体用于调试\n\tbodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 读取响应体失败: %v\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"[%s] 读取响应体失败: %w\", p.Name(), err)\n\t}\n\n\tbodyString := string(bodyBytes)\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 响应体大小: %d 字节\", len(bodyString))\n\t\t\n\t\t// 保存完整HTML到文件用于分析\n\t\tfilename := fmt.Sprintf(\"./dyyj_search_%s_%d.html\", url.QueryEscape(keyword), time.Now().Unix())\n\t\tif err := os.WriteFile(filename, bodyBytes, 0644); err == nil {\n\t\t\tlog.Printf(\"[DYYJ] 完整HTML已保存到: %s\", filename)\n\t\t} else {\n\t\t\tlog.Printf(\"[DYYJ] 保存HTML文件失败: %v\", err)\n\t\t}\n\t\t\n\t\t// 输出HTML的前2000个字符用于调试\n\t\tpreviewLen := 2000\n\t\tif len(bodyString) < previewLen {\n\t\t\tpreviewLen = len(bodyString)\n\t\t}\n\t\tlog.Printf(\"[DYYJ] HTML内容预览（前%d字符）:\\n%s\", previewLen, bodyString[:previewLen])\n\t\t\n\t\t// 检查关键元素是否存在\n\t\thasNoscript := strings.Contains(bodyString, \"<noscript\")\n\t\thasFlarumContent := strings.Contains(bodyString, \"flarum-content\")\n\t\thasContainer := strings.Contains(bodyString, \"container\")\n\t\thasUL := strings.Contains(bodyString, \"<ul>\")\n\t\thasLI := strings.Contains(bodyString, \"<li>\")\n\t\thasIDFlarumContent := strings.Contains(bodyString, \"id=\\\"flarum-content\\\"\") || strings.Contains(bodyString, \"id='flarum-content'\")\n\t\tlog.Printf(\"[DYYJ] HTML结构检查: noscript=%v, flarum-content=%v, id=flarum-content=%v, container=%v, ul=%v, li=%v\", \n\t\t\thasNoscript, hasFlarumContent, hasIDFlarumContent, hasContainer, hasUL, hasLI)\n\t\t\n\t\t// 查找所有noscript标签\n\t\tnoscriptCount := strings.Count(bodyString, \"<noscript\")\n\t\tlog.Printf(\"[DYYJ] 找到 %d 个noscript标签\", noscriptCount)\n\t\t\n\t\t// 尝试查找所有包含flarum-content的noscript\n\t\tif hasNoscript {\n\t\t\tnoscriptIndex := 0\n\t\t\tstart := 0\n\t\t\tfor {\n\t\t\t\tnoscriptStart := strings.Index(bodyString[start:], \"<noscript\")\n\t\t\t\tif noscriptStart < 0 {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tnoscriptStart += start\n\t\t\t\tnoscriptEnd := strings.Index(bodyString[noscriptStart:], \"</noscript>\")\n\t\t\t\tif noscriptEnd > 0 {\n\t\t\t\t\tnoscriptContent := bodyString[noscriptStart : noscriptStart+noscriptEnd+10]\n\t\t\t\t\tnoscriptIndex++\n\t\t\t\t\t\n\t\t\t\t\thasFlarumInNoscript := strings.Contains(noscriptContent, \"flarum-content\")\n\t\t\t\t\thasULInNoscript := strings.Contains(noscriptContent, \"<ul>\")\n\t\t\t\t\thasLIInNoscript := strings.Contains(noscriptContent, \"<li>\")\n\t\t\t\t\t\n\t\t\t\t\tpreviewLen := 1000\n\t\t\t\t\tif len(noscriptContent) < previewLen {\n\t\t\t\t\t\tpreviewLen = len(noscriptContent)\n\t\t\t\t\t}\n\t\t\t\t\tlog.Printf(\"[DYYJ] noscript标签 #%d 内容预览（前%d字符，flarum-content=%v, ul=%v, li=%v）:\\n%s\", \n\t\t\t\t\t\tnoscriptIndex, previewLen, hasFlarumInNoscript, hasULInNoscript, hasLIInNoscript, noscriptContent[:previewLen])\n\t\t\t\t\t\n\t\t\t\t\tstart = noscriptStart + noscriptEnd + 10\n\t\t\t\t} else {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 查找所有包含/d/的链接（使用预编译的正则）\n\t\tmatches := linkHrefRegex.FindAllStringSubmatch(bodyString, -1)\n\t\tlog.Printf(\"[DYYJ] 使用正则表达式找到 %d 个包含'/d/'的链接\", len(matches))\n\t\tfor i, match := range matches {\n\t\t\tif i < 10 {\n\t\t\t\tlog.Printf(\"[DYYJ]   链接 %d: %s\", i+1, match[1])\n\t\t\t}\n\t\t}\n\t}\n\n\t// 解析HTML提取搜索结果\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(bodyString))\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 解析搜索结果HTML失败: %v\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索结果HTML失败: %w\", p.Name(), err)\n\t}\n\n\tresults, err := p.parseSearchResults(doc, bodyString)\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 解析搜索结果完成，获取到 %d 个结果\", len(results))\n\t}\n\n\treturn results, err\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *DyyjPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[DYYJ] 重试请求 (第 %d 次)，等待 %v: %s\", i, backoff, req.URL.String())\n\t\t\t}\n\t\t\ttime.Sleep(backoff)\n\t\t} else if p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 发送请求: %s\", req.URL.String())\n\t\t}\n\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\tif p.debugMode && i > 0 {\n\t\t\t\tlog.Printf(\"[DYYJ] 重试成功 (第 %d 次): %s\", i+1, req.URL.String())\n\t\t\t}\n\t\t\treturn resp, nil\n\t\t}\n\n\t\tif resp != nil {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[DYYJ] 请求失败，状态码: %d (尝试 %d/%d): %s\", resp.StatusCode, i+1, maxRetries, req.URL.String())\n\t\t\t}\n\t\t\tresp.Body.Close()\n\t\t} else if err != nil && p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 请求失败，错误: %v (尝试 %d/%d): %s\", err, i+1, maxRetries, req.URL.String())\n\t\t}\n\t\tlastErr = err\n\t}\n\n\treturn nil, fmt.Errorf(\"[%s] 重试 %d 次后仍然失败: %w\", p.Name(), maxRetries, lastErr)\n}\n\n// parseSearchResults 解析搜索结果HTML\nfunc (p *DyyjPlugin) parseSearchResults(doc *goquery.Document, htmlContent string) ([]model.SearchResult, error) {\n\tvar results []model.SearchResult\n\n\t// 尝试多个选择器（注意：goquery可能无法正确解析noscript标签，需要特殊处理）\n\tselectors := []string{\n\t\t\"noscript#flarum-content .container ul li\",\n\t\t\"noscript#flarum-content ul li\",\n\t\t\"noscript[id='flarum-content'] .container ul li\",\n\t\t\"noscript[id=\\\"flarum-content\\\"] .container ul li\",\n\t\t\"noscript .container ul li\",\n\t\t\"noscript ul li\",\n\t\t\"#flarum-content .container ul li\",\n\t\t\".container ul li\",\n\t\t\"ul li\",\n\t\t\"li\",\n\t}\n\t\n\t// 如果goquery无法解析noscript，尝试直接使用正则表达式从HTML中提取\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 如果选择器都失败，将使用正则表达式从HTML中提取链接\")\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 开始解析搜索结果，尝试多个选择器\")\n\t}\n\n\tvar foundCount int\n\tvar usedSelector string\n\n\tfor _, selector := range selectors {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 尝试选择器: %s\", selector)\n\t\t}\n\n\t\tcount := 0\n\t\tdoc.Find(selector).Each(func(i int, s *goquery.Selection) {\n\t\t\tcount++\n\t\t})\n\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 选择器 '%s' 找到 %d 个元素\", selector, count)\n\t\t}\n\n\t\tif count > 0 {\n\t\t\tusedSelector = selector\n\t\t\tfoundCount = count\n\t\t\tbreak\n\t\t}\n\t}\n\n\t\tif usedSelector == \"\" {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[DYYJ] 所有选择器都未找到结果，使用正则表达式从HTML中提取链接\")\n\t\t\t}\n\t\t\t// 使用正则表达式直接从HTML中提取链接（因为goquery可能无法解析noscript）\n\t\t\tresults = p.parseSearchResultsWithRegex(htmlContent)\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[DYYJ] 正则表达式解析完成，获取到 %d 个结果\", len(results))\n\t\t\t}\n\t\t\treturn results, nil\n\t\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 使用选择器: %s，找到 %d 个元素\", usedSelector, foundCount)\n\t}\n\n\t// 使用找到的选择器解析结果\n\tdoc.Find(usedSelector).Each(func(i int, s *goquery.Selection) {\n\t\tif len(results) >= MaxResults {\n\t\t\treturn\n\t\t}\n\n\t\tresult := p.parseResultItem(s, i+1)\n\t\tif result != nil {\n\t\t\tresults = append(results, *result)\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[DYYJ] 解析结果项 %d: %s\", i+1, result.Title)\n\t\t\t}\n\t\t} else if p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 跳过无效结果项 %d\", i+1)\n\t\t}\n\t})\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 找到 %d 个结果项，成功解析 %d 个\", foundCount, len(results))\n\t}\n\n\treturn results, nil\n}\n\n// parseSearchResultsWithRegex 使用正则表达式从HTML中提取搜索结果\nfunc (p *DyyjPlugin) parseSearchResultsWithRegex(htmlContent string) []model.SearchResult {\n\tvar results []model.SearchResult\n\t\n\t// 首先尝试找到noscript#flarum-content标签内的内容（使用预编译的正则）\n\tnoscriptMatches := noscriptRegex.FindStringSubmatch(htmlContent)\n\t\n\tvar searchArea string\n\tif len(noscriptMatches) > 1 {\n\t\tsearchArea = noscriptMatches[1]\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 找到noscript#flarum-content标签，内容长度: %d 字节\", len(searchArea))\n\t\t}\n\t} else {\n\t\t// 如果找不到，使用整个HTML\n\t\tsearchArea = htmlContent\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 未找到noscript#flarum-content标签，使用整个HTML\")\n\t\t}\n\t}\n\t\n\t// 匹配 <li> 标签内的链接（使用预编译的正则）\n\tmatches := liLinkRegex.FindAllStringSubmatch(searchArea, -1)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 正则表达式找到 %d 个匹配项\", len(matches))\n\t}\n\t\n\tfor i, match := range matches {\n\t\tif len(results) >= MaxResults {\n\t\t\tbreak\n\t\t}\n\t\t\n\t\tif len(match) >= 3 {\n\t\t\thref := match[1]\n\t\t\ttitle := strings.TrimSpace(match[2])\n\t\t\t// 清理HTML标签（使用预编译的正则）\n\t\t\ttitle = htmlTagRegex.ReplaceAllString(title, \"\")\n\t\t\ttitle = strings.TrimSpace(title)\n\t\t\t\n\t\t\tif title == \"\" || !strings.Contains(href, \"/d/\") {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t\n\t\t\t// 确保是完整URL\n\t\t\tif !strings.HasPrefix(href, \"http\") {\n\t\t\t\tif strings.HasPrefix(href, \"/\") {\n\t\t\t\t\thref = BaseURL + href\n\t\t\t\t} else {\n\t\t\t\t\thref = BaseURL + \"/\" + href\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 从href中提取ID\n\t\t\tpostID := p.extractPostID(href)\n\t\t\tif postID == \"\" {\n\t\t\t\tpostID = fmt.Sprintf(\"regex-%d\", i+1)\n\t\t\t}\n\t\t\t\n\t\t\tresult := model.SearchResult{\n\t\t\t\tTitle:     title,\n\t\t\t\tContent:   fmt.Sprintf(\"详情页: %s\", href),\n\t\t\t\tChannel:   \"\",\n\t\t\t\tUniqueID:  fmt.Sprintf(\"%s-%s\", p.Name(), postID),\n\t\t\t\tDatetime:  time.Time{}, // 初始化为零值，稍后从详情页获取\n\t\t\t\tLinks:     []model.Link{},\n\t\t\t\tTags:      []string{},\n\t\t\t}\n\t\t\t\n\t\t\tresults = append(results, result)\n\t\t\t\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[DYYJ] 正则解析结果 %d: %s -> %s\", i+1, title, href)\n\t\t\t}\n\t\t}\n\t}\n\t\n\treturn results\n}\n\n// parseResultItem 解析单个搜索结果项\nfunc (p *DyyjPlugin) parseResultItem(s *goquery.Selection, index int) *model.SearchResult {\n\t// 提取链接\n\tlinkEl := s.Find(\"a\")\n\tif linkEl.Length() == 0 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 结果项 %d: 未找到链接元素\", index)\n\t\t}\n\t\treturn nil\n\t}\n\n\t// 提取标题\n\ttitle := strings.TrimSpace(linkEl.Text())\n\tif title == \"\" {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 结果项 %d: 标题为空\", index)\n\t\t}\n\t\treturn nil\n\t}\n\n\t// 提取详情页链接\n\tdetailURL, exists := linkEl.Attr(\"href\")\n\tif !exists || detailURL == \"\" {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 结果项 %d: 未找到详情页链接，标题: %s\", index, title)\n\t\t}\n\t\treturn nil\n\t}\n\n\t// 确保是完整URL\n\tif !strings.HasPrefix(detailURL, \"http\") {\n\t\tif strings.HasPrefix(detailURL, \"/\") {\n\t\t\tdetailURL = BaseURL + detailURL\n\t\t} else {\n\t\t\tdetailURL = BaseURL + \"/\" + detailURL\n\t\t}\n\t}\n\n\t// 从URL中提取ID\n\tpostID := p.extractPostID(detailURL)\n\tif postID == \"\" {\n\t\tpostID = fmt.Sprintf(\"unknown-%d\", index)\n\t}\n\n\t\t// 构建初始结果对象（详情页链接稍后获取）\n\t\tresult := model.SearchResult{\n\t\t\tTitle:     title,\n\t\t\tContent:   fmt.Sprintf(\"详情页: %s\", detailURL),\n\t\t\tChannel:   \"\", // 插件搜索结果必须为空字符串（按开发指南要求）\n\t\t\tUniqueID:  fmt.Sprintf(\"%s-%s\", p.Name(), postID),\n\t\t\tDatetime:  time.Time{}, // 初始化为零值，稍后从详情页获取\n\t\t\tLinks:     []model.Link{}, // 先为空，详情页处理后添加\n\t\t\tTags:      []string{},\n\t\t}\n\n\treturn &result\n}\n\n// extractPostID 从URL中提取文章ID\nfunc (p *DyyjPlugin) extractPostID(url string) string {\n\t// 使用预编译的正则表达式\n\tmatches := postIDRegex.FindStringSubmatch(url)\n\tif len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// filterByTitleKeyword 根据标题过滤结果（只保留标题包含关键词的结果）\nfunc (p *DyyjPlugin) filterByTitleKeyword(results []model.SearchResult, keyword string) []model.SearchResult {\n\tif keyword == \"\" {\n\t\treturn results\n\t}\n\n\tlowerKeyword := strings.ToLower(keyword)\n\tkeywords := strings.Fields(lowerKeyword) // 支持多关键词\n\n\tfiltered := make([]model.SearchResult, 0, len(results))\n\tfor _, result := range results {\n\t\tlowerTitle := strings.ToLower(result.Title)\n\t\t\n\t\t// 检查每个关键词是否都在标题中\n\t\tmatched := true\n\t\tfor _, kw := range keywords {\n\t\t\tif !strings.Contains(lowerTitle, kw) {\n\t\t\t\tmatched = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif matched {\n\t\t\tfiltered = append(filtered, result)\n\t\t} else if p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 标题不包含关键词，跳过: %s\", result.Title)\n\t\t}\n\t}\n\n\treturn filtered\n}\n\n// fetchDetailLinks 并发获取详情页链接\nfunc (p *DyyjPlugin) fetchDetailLinks(client *http.Client, searchResults []model.SearchResult, keyword string) []model.SearchResult {\n\tif len(searchResults) == 0 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 没有搜索结果需要获取详情页\")\n\t\t}\n\t\treturn []model.SearchResult{}\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 开始并发获取 %d 个详情页链接，最大并发数: %d\", len(searchResults), MaxConcurrency)\n\t}\n\n\t// 使用通道控制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\tvar wg sync.WaitGroup\n\tresultsChan := make(chan model.SearchResult, len(searchResults))\n\n\tfor _, result := range searchResults {\n\t\twg.Add(1)\n\t\tgo func(r model.SearchResult) {\n\t\t\tdefer wg.Done()\n\t\t\tsemaphore <- struct{}{} // 获取信号量\n\t\t\tdefer func() { <-semaphore }() // 释放信号量\n\n\t\t\t// 从Content中提取详情页URL\n\t\t\tdetailURL := p.extractDetailURLFromContent(r.Content)\n\t\t\tif detailURL == \"\" {\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[DYYJ] 跳过无详情页URL的结果: %s\", r.Title)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[DYYJ] 获取详情页链接: %s (标题: %s)\", detailURL, r.Title)\n\t\t\t}\n\n\t\t\t// 获取详情页链接和时间信息\n\t\t\tlinks, publishTime := p.fetchDetailPageLinks(client, detailURL)\n\t\t\tif len(links) > 0 {\n\t\t\t\tr.Links = links\n\t\t\t\t// 如果获取到了发布时间，更新Datetime\n\t\t\t\tif !publishTime.IsZero() {\n\t\t\t\t\tr.Datetime = publishTime\n\t\t\t\t\tif p.debugMode {\n\t\t\t\t\t\tlog.Printf(\"[DYYJ] 更新发布时间: %s -> %s\", r.Title, publishTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// 如果没有获取到时间，使用当前时间作为默认值\n\t\t\t\t\tr.Datetime = time.Now()\n\t\t\t\t\tif p.debugMode {\n\t\t\t\t\t\tlog.Printf(\"[DYYJ] 未获取到发布时间，使用当前时间: %s\", r.Title)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// 清理Content中的详情页URL\n\t\t\t\tr.Content = p.cleanContent(r.Content)\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[DYYJ] 成功获取详情页链接: %s，找到 %d 个网盘链接\", r.Title, len(links))\n\t\t\t\t}\n\t\t\t\tresultsChan <- r\n\t\t\t} else if p.debugMode {\n\t\t\t\tlog.Printf(\"[DYYJ] 详情页无有效链接: %s (URL: %s)\", r.Title, detailURL)\n\t\t\t}\n\t\t}(result)\n\t}\n\n\t// 等待所有goroutine完成\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultsChan)\n\t}()\n\n\t// 收集结果\n\tvar finalResults []model.SearchResult\n\tfor result := range resultsChan {\n\t\tfinalResults = append(finalResults, result)\n\t}\n\n\treturn finalResults\n}\n\n// extractDetailURLFromContent 从Content中提取详情页URL\nfunc (p *DyyjPlugin) extractDetailURLFromContent(content string) string {\n\tlines := strings.Split(content, \"\\n\")\n\tfor _, line := range lines {\n\t\tif strings.HasPrefix(line, \"详情页: \") {\n\t\t\treturn strings.TrimPrefix(line, \"详情页: \")\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// cleanContent 清理Content，移除详情页URL行\nfunc (p *DyyjPlugin) cleanContent(content string) string {\n\tlines := strings.Split(content, \"\\n\")\n\tvar cleanedLines []string\n\tfor _, line := range lines {\n\t\tif !strings.HasPrefix(line, \"详情页: \") {\n\t\t\tcleanedLines = append(cleanedLines, line)\n\t\t}\n\t}\n\treturn strings.Join(cleanedLines, \"\\n\")\n}\n\n// fetchDetailPageLinks 获取详情页的网盘链接和发布时间\nfunc (p *DyyjPlugin) fetchDetailPageLinks(client *http.Client, detailURL string) ([]model.Link, time.Time) {\n\t// 检查缓存\n\tif cached, found := p.detailCache.Load(detailURL); found {\n\t\tif cacheData, ok := cached.(*cacheItem); ok {\n\t\t\tif time.Since(cacheData.Timestamp) < p.cacheTTL {\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[DYYJ] 使用缓存的详情页链接: %s (缓存了 %d 个链接)\", detailURL, len(cacheData.Links))\n\t\t\t\t}\n\t\t\t\treturn cacheData.Links, cacheData.PublishTime\n\t\t\t}\n\t\t}\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 开始获取详情页: %s\", detailURL)\n\t}\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), RequestTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 创建详情页请求失败: %v (URL: %s)\", err, detailURL)\n\t\t}\n\t\treturn []model.Link{}, time.Time{}\n\t}\n\n\t// 设置请求头\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\n\t// 使用重试机制\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 详情页请求失败: %v (URL: %s)\", err, detailURL)\n\t\t}\n\t\treturn []model.Link{}, time.Time{}\n\t}\n\tdefer resp.Body.Close()\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 详情页响应状态码: %d (URL: %s)\", resp.StatusCode, detailURL)\n\t}\n\n\tif resp.StatusCode != 200 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 详情页HTTP状态错误: %d (URL: %s)\", resp.StatusCode, detailURL)\n\t\t}\n\t\treturn []model.Link{}, time.Time{}\n\t}\n\n\t// 读取响应体\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 读取详情页响应失败: %v (URL: %s)\", err, detailURL)\n\t\t}\n\t\treturn []model.Link{}, time.Time{}\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 详情页响应体大小: %d 字节 (URL: %s)\", len(body), detailURL)\n\t}\n\n\t// 解析网盘链接\n\tlinks := p.parseNetworkDiskLinks(string(body))\n\n\t// 提取发布时间\n\tpublishTime := p.extractPublishTime(string(body))\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 从详情页提取到 %d 个链接: %s\", len(links), detailURL)\n\t\tfor i, link := range links {\n\t\t\tlog.Printf(\"[DYYJ]   链接 %d: %s (%s, 密码: %s)\", i+1, link.URL, link.Type, link.Password)\n\t\t}\n\t\tif !publishTime.IsZero() {\n\t\t\tlog.Printf(\"[DYYJ] 提取到发布时间: %s\", publishTime.Format(\"2006-01-02 15:04:05\"))\n\t\t} else {\n\t\t\tlog.Printf(\"[DYYJ] 未提取到发布时间\")\n\t\t}\n\t}\n\n\t// 缓存结果（即使为空也缓存，避免重复请求）\n\tp.detailCache.Store(detailURL, &cacheItem{\n\t\tLinks:       links,\n\t\tPublishTime: publishTime,\n\t\tTimestamp:   time.Now(),\n\t})\n\n\treturn links, publishTime\n}\n\n// cacheItem 缓存项\ntype cacheItem struct {\n\tLinks       []model.Link\n\tPublishTime time.Time\n\tTimestamp   time.Time\n}\n\n// extractPublishTime 从HTML中提取发布时间\nfunc (p *DyyjPlugin) extractPublishTime(htmlContent string) time.Time {\n\t// 使用预编译的正则表达式（性能优化）\n\tfor _, re := range publishTimeRegexes {\n\t\tmatches := re.FindStringSubmatch(htmlContent)\n\t\tif len(matches) >= 2 {\n\t\t\ttimeStr := strings.TrimSpace(matches[1])\n\t\t\t// 尝试多种时间格式\n\t\t\ttimeFormats := []string{\n\t\t\t\ttime.RFC3339,                    // 2006-01-02T15:04:05Z07:00\n\t\t\t\t\"2006-01-02T15:04:05+00:00\",    // 2024-05-05T17:04:11+00:00\n\t\t\t\t\"2006-01-02T15:04:05Z\",         // 2006-01-02T15:04:05Z\n\t\t\t\t\"2006-01-02 15:04:05\",         // 2006-01-02 15:04:05\n\t\t\t\t\"2006-01-02\",                   // 2006-01-02\n\t\t\t}\n\n\t\t\tfor _, format := range timeFormats {\n\t\t\t\tif t, err := time.Parse(format, timeStr); err == nil {\n\t\t\t\t\tif p.debugMode {\n\t\t\t\t\t\tlog.Printf(\"[DYYJ] 成功解析时间: %s (格式: %s)\", timeStr, format)\n\t\t\t\t\t}\n\t\t\t\t\treturn t\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[DYYJ] 无法解析时间格式: %s\", timeStr)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn time.Time{}\n}\n\n// parseNetworkDiskLinks 解析网盘链接\nfunc (p *DyyjPlugin) parseNetworkDiskLinks(htmlContent string) []model.Link {\n\tvar links []model.Link\n\tseen := make(map[string]bool) // 用于去重\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 开始解析网盘链接，HTML内容长度: %d 字节\", len(htmlContent))\n\t}\n\n\t// 使用goquery解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] goquery解析失败，使用正则表达式备选: %v\", err)\n\t\t}\n\t\t// 如果goquery解析失败，使用正则表达式\n\t\treturn p.parseNetworkDiskLinksWithRegex(htmlContent)\n\t}\n\n\t// 查找noscript标签中的内容\n\tselector := \"noscript#flarum-content .container article .Post-body\"\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 使用goquery选择器: %s\", selector)\n\t}\n\n\tfoundPostBody := false\n\tdoc.Find(selector).Each(func(i int, s *goquery.Selection) {\n\t\tfoundPostBody = true\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 找到Post-body元素 %d\", i+1)\n\t\t}\n\n\t\t// 查找所有p标签，检查是否包含strong标签（网盘名称）和链接\n\t\tpCount := 0\n\t\ts.Find(\"p\").Each(func(j int, pEl *goquery.Selection) {\n\t\t\tpCount++\n\t\t\tstrongEl := pEl.Find(\"strong\")\n\t\t\tif strongEl.Length() == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tstrongText := strings.TrimSpace(strongEl.Text())\n\t\t\t\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[DYYJ]   检查p标签 %d，strong文本: %s\", j+1, strongText)\n\t\t\t}\n\n\t\t\t// 检查是否是网盘名称\n\t\t\tif !p.isNetworkDiskName(strongText) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[DYYJ]   找到网盘名称: %s\", strongText)\n\t\t\t}\n\n\t\t\t// 在当前p标签或下一个p标签中查找链接\n\t\t\tvar linkEl *goquery.Selection\n\t\t\t\n\t\t\t// 先检查当前p标签\n\t\t\tlinkEl = pEl.Find(\"a\")\n\t\t\tif linkEl.Length() == 0 {\n\t\t\t\t// 如果当前p没有链接，查找下一个p标签\n\t\t\t\tnextP := pEl.Next()\n\t\t\t\tif nextP.Length() > 0 {\n\t\t\t\t\tlinkEl = nextP.Find(\"a\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif linkEl.Length() > 0 {\n\t\t\t\tlinkURL, exists := linkEl.Attr(\"href\")\n\t\t\t\tif exists && linkURL != \"\" {\n\t\t\t\t\t// 去重检查\n\t\t\t\t\tif seen[linkURL] {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tseen[linkURL] = true\n\n\t\t\t\t\t// 确定网盘类型\n\t\t\t\t\turlType := p.determineCloudType(linkURL)\n\t\t\t\t\tif urlType != \"others\" {\n\t\t\t\t\t\t// 提取密码\n\t\t\t\t\t\tpassword := p.extractPasswordFromURL(linkURL)\n\t\t\t\t\t\t\n\t\t\t\t\t\tlink := model.Link{\n\t\t\t\t\t\t\tType:     urlType,\n\t\t\t\t\t\t\tURL:      linkURL,\n\t\t\t\t\t\t\tPassword: password,\n\t\t\t\t\t\t}\n\t\t\t\t\t\t\n\t\t\t\t\t\tif p.debugMode {\n\t\t\t\t\t\t\tlog.Printf(\"[DYYJ]   找到网盘链接: %s (%s, 密码: %s)\", linkURL, urlType, password)\n\t\t\t\t\t\t}\n\t\t\t\t\t\t\n\t\t\t\t\t\tlinks = append(links, link)\n\t\t\t\t\t} else if p.debugMode {\n\t\t\t\t\t\tlog.Printf(\"[DYYJ]   链接类型为others，跳过: %s\", linkURL)\n\t\t\t\t\t}\n\t\t\t\t} else if p.debugMode {\n\t\t\t\t\tlog.Printf(\"[DYYJ]   p标签 %d 中未找到链接\", j+1)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] Post-body %d 中共有 %d 个p标签\", i+1, pCount)\n\t\t}\n\t})\n\n\tif !foundPostBody && p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 未找到Post-body元素，尝试使用正则表达式\")\n\t}\n\n\t// 如果goquery没有找到链接，使用正则表达式作为备选\n\tif len(links) == 0 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] goquery未找到链接，使用正则表达式备选方案\")\n\t\t}\n\t\tlinks = p.parseNetworkDiskLinksWithRegex(htmlContent)\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 解析完成，共找到 %d 个网盘链接\", len(links))\n\t}\n\n\treturn links\n}\n\n// parseNetworkDiskLinksWithRegex 使用正则表达式解析网盘链接（备选方案）\nfunc (p *DyyjPlugin) parseNetworkDiskLinksWithRegex(htmlContent string) []model.Link {\n\tvar links []model.Link\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 使用正则表达式解析网盘链接\")\n\t}\n\n\t// 去重用的map\n\tseen := make(map[string]bool)\n\n\t// 使用预编译的正则表达式（性能优化）\n\tfor _, pattern := range networkDiskPatterns {\n\t\tmatches := pattern.regex.FindAllStringSubmatch(htmlContent, -1)\n\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[DYYJ] 正则模式 '%s' 找到 %d 个匹配\", pattern.name, len(matches))\n\t\t}\n\n\t\tfor _, match := range matches {\n\t\t\tif len(match) >= 2 {\n\t\t\t\tlinkURL := match[1]\n\n\t\t\t\t// 去重\n\t\t\t\tif seen[linkURL] {\n\t\t\t\t\tif p.debugMode {\n\t\t\t\t\t\tlog.Printf(\"[DYYJ] 跳过重复链接: %s\", linkURL)\n\t\t\t\t\t}\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tseen[linkURL] = true\n\n\t\t\t\t// 确定网盘类型\n\t\t\t\turlType := p.determineCloudType(linkURL)\n\t\t\t\tif urlType == \"others\" {\n\t\t\t\t\turlType = pattern.urlType\n\t\t\t\t}\n\n\t\t\t\t// 只添加有效的网盘链接\n\t\t\t\tif urlType != \"others\" {\n\t\t\t\t\t// 提取密码\n\t\t\t\t\tpassword := p.extractPasswordFromURL(linkURL)\n\n\t\t\t\t\tlink := model.Link{\n\t\t\t\t\t\tType:     urlType,\n\t\t\t\t\t\tURL:      linkURL,\n\t\t\t\t\t\tPassword: password,\n\t\t\t\t\t}\n\n\t\t\t\t\tif p.debugMode {\n\t\t\t\t\t\tlog.Printf(\"[DYYJ] 正则找到网盘链接: %s (%s, 密码: %s)\", linkURL, urlType, password)\n\t\t\t\t\t}\n\n\t\t\t\t\tlinks = append(links, link)\n\t\t\t\t} else if p.debugMode {\n\t\t\t\t\tlog.Printf(\"[DYYJ] 链接类型为others，跳过: %s\", linkURL)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[DYYJ] 正则表达式解析完成，共找到 %d 个网盘链接\", len(links))\n\t}\n\n\treturn links\n}\n\n// isNetworkDiskName 检查是否是网盘名称\nfunc (p *DyyjPlugin) isNetworkDiskName(text string) bool {\n\tnetworkDiskNames := []string{\n\t\t\"夸克\", \"百度\", \"阿里\", \"天翼\", \"迅雷\", \"115\", \"123\", \"蓝奏\",\n\t\t\"夸克网盘\", \"百度网盘\", \"阿里云盘\", \"天翼云盘\", \"迅雷网盘\", \"115网盘\", \"123网盘\",\n\t}\n\n\tlowerText := strings.ToLower(text)\n\tfor _, name := range networkDiskNames {\n\t\tif strings.Contains(lowerText, strings.ToLower(name)) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// extractPasswordFromURL 从URL中提取密码\nfunc (p *DyyjPlugin) extractPasswordFromURL(linkURL string) string {\n\t// 从URL参数中提取密码\n\tpatterns := []string{\n\t\t`[?&]pwd=([A-Za-z0-9]{4,8})`,\n\t\t`[?&]password=([A-Za-z0-9]{4,8})`,\n\t\t`[?&]code=([A-Za-z0-9]{4,8})`,\n\t}\n\n\tfor _, pattern := range patterns {\n\t\tre := regexp.MustCompile(pattern)\n\t\tmatches := re.FindStringSubmatch(linkURL)\n\t\tif len(matches) > 1 {\n\t\t\treturn matches[1]\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\n// determineCloudType 根据URL自动识别网盘类型（按开发指南完整列表）\nfunc (p *DyyjPlugin) determineCloudType(url string) string {\n\tswitch {\n\tcase strings.Contains(url, \"pan.quark.cn\"):\n\t\treturn \"quark\"\n\tcase strings.Contains(url, \"drive.uc.cn\"):\n\t\treturn \"uc\"\n\tcase strings.Contains(url, \"pan.baidu.com\"):\n\t\treturn \"baidu\"\n\tcase strings.Contains(url, \"aliyundrive.com\") || strings.Contains(url, \"alipan.com\"):\n\t\treturn \"aliyun\"\n\tcase strings.Contains(url, \"pan.xunlei.com\"):\n\t\treturn \"xunlei\"\n\tcase strings.Contains(url, \"cloud.189.cn\"):\n\t\treturn \"tianyi\"\n\tcase strings.Contains(url, \"caiyun.139.com\"):\n\t\treturn \"mobile\"\n\tcase strings.Contains(url, \"115.com\") || strings.Contains(url, \"115cdn.com\") || strings.Contains(url, \"anxia.com\"):\n\t\treturn \"115\"\n\tcase strings.Contains(url, \"123684.com\") || strings.Contains(url, \"123685.com\") ||\n\t\tstrings.Contains(url, \"123912.com\") || strings.Contains(url, \"123pan.com\") ||\n\t\tstrings.Contains(url, \"123pan.cn\") || strings.Contains(url, \"123592.com\"):\n\t\treturn \"123\"\n\tcase strings.Contains(url, \"mypikpak.com\"):\n\t\treturn \"pikpak\"\n\tcase strings.Contains(url, \"magnet:\"):\n\t\treturn \"magnet\"\n\tcase strings.Contains(url, \"ed2k://\"):\n\t\treturn \"ed2k\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n"
  },
  {
    "path": "plugin/dyyj/html结构分析.md",
    "content": "# DYYJ（电影云集）插件HTML结构分析\n\n## 网站概述\n- **网站名称**: 电影云集\n- **域名**: https://bbs.dyyjmax.org\n- **类型**: 影视资源论坛，提供网盘下载链接\n- **技术栈**: Flarum论坛系统\n\n## API流程概述\n\n### 搜索页面\n- **请求URL**: `https://bbs.dyyjmax.org/?q={keyword}`\n- **方法**: GET\n- **Headers**: 标准浏览器请求头\n- **特点**: Flarum论坛，搜索结果在noscript标签中\n\n### 详情页面\n- **请求URL**: `https://bbs.dyyjmax.org/d/{id}`\n- **方法**: GET\n- **Headers**: 标准浏览器请求头\n- **特点**: 网盘链接在HTML内容中\n\n## 搜索结果结构\n\n### 搜索结果页面HTML结构\n```html\n<noscript id=\"flarum-content\">\n    <div class=\"container\">\n        <h1>全部主题</h1>\n        <ul>\n            <li>\n                <a href=\"https://bbs.dyyjmax.org/d/7208\">\n                    遮天 (2023)\n                </a>\n            </li>\n            <li>\n                <a href=\"https://bbs.dyyjmax.org/d/7285\">\n                    重生之医手遮天 (2024)\n                </a>\n            </li>\n        </ul>\n    </div>\n</noscript>\n```\n\n### 详情页面HTML结构\n```html\n<noscript id=\"flarum-content\">\n    <div class=\"container\">\n        <article>\n            <div class=\"PostUser\">\n                <h3 class=\"PostUser-name\">dyyjpro</h3>\n            </div>\n            <div class=\"Post-body\">\n                <p><strong>剧情简介</strong></p>\n                <p>本作动画改编自起点白金作者辰东遮天三部曲的第一部——遮天...</p>\n                \n                <p><strong>夸克网盘</strong></p>\n                <p><a href=\"https://pan.quark.cn/s/f05fc94a755a\" rel=\"ugc noopener nofollow\" target=\"_blank\">https://pan.quark.cn/s/f05fc94a755a</a></p>\n                \n                <p><strong>百度网盘 1-65</strong></p>\n                <p><a href=\"https://pan.baidu.com/s/1c2ZQGzzCYFvEw6j0fuimaA?pwd=dyyj\" rel=\"ugc noopener nofollow\" target=\"_blank\">https://pan.baidu.com/s/1c2ZQGzzCYFvEw6j0fuimaA?pwd=dyyj</a></p>\n                \n                <p><strong>迅雷云盘</strong></p>\n                <p><a href=\"https://pan.xunlei.com/s/VNx8hVvMfSH5PjUnM8tO-DzVA1?pwd=jxdp#\" rel=\"ugc noopener nofollow\" target=\"_blank\">https://pan.xunlei.com/s/VNx8hVvMfSH5PjUnM8tO-DzVA1?pwd=jxdp#</a></p>\n            </div>\n        </article>\n    </div>\n</noscript>\n```\n\n## 数据提取要点\n\n### 搜索结果页面\n1. **结果容器**: `noscript#flarum-content .container ul` - 搜索结果列表\n2. **结果项**: `li` - 每个搜索结果\n3. **标题**: `li > a` - 获取文本和href属性\n4. **详情页链接**: `li > a[href]` - 格式为 `https://bbs.dyyjmax.org/d/{id}`\n5. **ID提取**: 从URL中提取，如 `/d/7208` 中的 `7208`\n\n### 详情页面  \n1. **内容容器**: `noscript#flarum-content .container article .Post-body`\n2. **标题**: 从URL或meta标签中提取（如 `<title>遮天 (2023) - ...</title>`）\n3. **发布时间**: `<meta name=\"article:published_time\" content=\"2024-05-05T17:04:11+00:00\">`\n4. **网盘链接提取**: \n   - 模式: `<p><strong>{网盘名}</strong></p><p><a href=\"{链接}\">{链接文本}</a></p>`\n   - 支持的网盘: 夸克网盘、百度网盘、迅雷云盘等\n   - 链接格式: 直接是网盘URL，可能包含密码参数（如 `?pwd=dyyj`）\n5. **提取码提取**:\n   - 从URL参数中提取: `?pwd=xxx` 或 `pwd=xxx`\n   - 从链接文本附近搜索\n\n## 网盘链接识别规则\n\n### 支持的网盘类型\n- **夸克网盘**: `pan.quark.cn`\n- **百度网盘**: `pan.baidu.com`  \n- **阿里云盘**: `aliyundrive.com` / `alipan.com`\n- **天翼云盘**: `cloud.189.cn`\n- **迅雷网盘**: `pan.xunlei.com`\n- **115网盘**: `115.com`\n- **123网盘**: `123pan.com`\n\n### 链接提取策略\n1. 在详情页的 `.Post-body` 内容区域搜索\n2. 查找 `<strong>` 标签包含网盘名称的段落\n3. 在下一个 `<p>` 标签中查找 `<a>` 标签的href属性\n4. 从URL参数中提取密码（如 `?pwd=xxx`）\n5. 链接去重和验证\n\n## 特殊处理\n\n### 时间解析\n- 格式: ISO 8601格式 `2024-05-05T17:04:11+00:00`\n- 来源: `<meta name=\"article:published_time\">` 或 `<meta name=\"article:updated_time\">`\n\n### 内容清理\n- 移除HTML标签\n- 处理特殊字符和编码\n- 清理多余空格和换行\n\n### 错误处理\n- 网络超时重试\n- 解析失败的降级处理\n- 空结果的处理\n- 详情页访问失败的处理\n\n## 注意事项\n\n1. **反爬虫**: 网站可能有基础的反爬虫措施，需要设置合理的请求头\n2. **限频**: 避免请求过于频繁\n3. **编码**: 处理中文关键词的URL编码\n4. **更新**: 网站结构可能会变化，需要定期维护选择器\n5. **noscript标签**: 搜索结果和详情页内容都在 `<noscript>` 标签中，需要特别注意\n6. **并发控制**: 详情页需要并发获取，需要控制并发数避免被封IP\n\n"
  },
  {
    "path": "plugin/erxiao/erxiao.go",
    "content": "package erxiao\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\t\"context\"\n\t\"sync\"\n\t\"sync/atomic\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\t// 默认超时时间\n\tDefaultTimeout = 8 * time.Second\n\tDetailTimeout  = 6 * time.Second\n\n\t// HTTP连接池配置\n\tMaxIdleConns        = 200\n\tMaxIdleConnsPerHost = 50\n\tMaxConnsPerHost     = 100\n\tIdleConnTimeout     = 90 * time.Second\n\n\t// 并发控制\n\tMaxConcurrency = 20\n\n\t// 缓存TTL\n\tcacheTTL = 1 * time.Hour\n)\n\n// 性能统计（原子操作）\nvar (\n\tsearchRequests    int64 = 0\n\ttotalSearchTime   int64 = 0 // 纳秒\n\tdetailPageRequests int64 = 0\n\ttotalDetailTime   int64 = 0 // 纳秒\n\tcacheHits         int64 = 0\n\tcacheMisses       int64 = 0\n)\n\n// Detail page缓存\nvar (\n\tdetailCache sync.Map\n\tcacheMutex  sync.RWMutex\n)\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewErxiaoPlugin())\n}\n\n// 预编译的正则表达式\nvar (\n\t// 密码提取正则表达式\n\tpasswordRegex = regexp.MustCompile(`\\?pwd=([0-9a-zA-Z]+)`)\n\n\t// 详情页ID提取正则表达式\n\tdetailIDRegex = regexp.MustCompile(`/id/(\\d+)`)\n\n\t// 常见网盘链接的正则表达式（支持16种类型）\n\tquarkLinkRegex     = regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9a-zA-Z]+`)\n\tucLinkRegex        = regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9a-zA-Z]+(\\?[^\"'\\s]*)?`)\n\tbaiduLinkRegex     = regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9a-zA-Z_\\-]+(\\?pwd=[0-9a-zA-Z]+)?`)\n\taliyunLinkRegex    = regexp.MustCompile(`https?://(www\\.)?(aliyundrive\\.com|alipan\\.com)/s/[0-9a-zA-Z]+`)\n\txunleiLinkRegex    = regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9a-zA-Z_\\-]+(\\?pwd=[0-9a-zA-Z]+)?`)\n\ttianyiLinkRegex    = regexp.MustCompile(`https?://cloud\\.189\\.cn/t/[0-9a-zA-Z]+`)\n\tlink115Regex       = regexp.MustCompile(`https?://115\\.com/s/[0-9a-zA-Z]+`)\n\tmobileLinkRegex    = regexp.MustCompile(`https?://caiyun\\.feixin\\.10086\\.cn/[0-9a-zA-Z]+`)\n\tlink123Regex       = regexp.MustCompile(`https?://123pan\\.com/s/[0-9a-zA-Z]+`)\n\tpikpakLinkRegex    = regexp.MustCompile(`https?://mypikpak\\.com/s/[0-9a-zA-Z]+`)\n\tmagnetLinkRegex    = regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9a-fA-F]{40}`)\n\ted2kLinkRegex      = regexp.MustCompile(`ed2k://\\|file\\|.+\\|\\d+\\|[0-9a-fA-F]{32}\\|/`)\n)\n\n\ntype ErxiaoAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t}\n\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   DefaultTimeout,\n\t}\n}\n\nfunc NewErxiaoPlugin() *ErxiaoAsyncPlugin {\n\treturn &ErxiaoAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"erxiao\", 1),\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Search 同步搜索接口\nfunc (p *ErxiaoAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 带结果统计的搜索接口\nfunc (p *ErxiaoAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 搜索实现 - HTML解析版本\nfunc (p *ErxiaoAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 性能统计\n\tstart := time.Now()\n\tatomic.AddInt64(&searchRequests, 1)\n\tdefer func() {\n\t\tduration := time.Since(start).Nanoseconds()\n\t\tatomic.AddInt64(&totalSearchTime, duration)\n\t}()\n\n\t// 使用优化的客户端\n\tif p.optimizedClient != nil {\n\t\tclient = p.optimizedClient\n\t}\n\n\t// 1. 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"https://erxiaofn.click/index.php/vod/search/wd/%s.html\", url.QueryEscape(keyword))\n\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\n\t// 3. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\n\t// 4. 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"https://erxiaofn.click/\")\n\n\t// 5. 发送请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\t// 6. 解析搜索结果页面\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\n\t// 7. 提取搜索结果\n\tvar results []model.SearchResult\n\n\tdoc.Find(\".module-search-item\").Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchItem(s, keyword)\n\t\tif result.UniqueID != \"\" {\n\t\t\tresults = append(results, result)\n\t\t}\n\t})\n\n\t// 8. 异步获取详情页信息\n\tenhancedResults := p.enhanceWithDetails(client, results)\n\n\t// 9. 关键词过滤\n\treturn plugin.FilterResultsByKeyword(enhancedResults, keyword), nil\n}\n\n// parseSearchItem 解析单个搜索结果项\nfunc (p *ErxiaoAsyncPlugin) parseSearchItem(s *goquery.Selection, keyword string) model.SearchResult {\n\tresult := model.SearchResult{}\n\n\t// 提取详情页链接和ID\n\tdetailLink, exists := s.Find(\".video-info-header h3 a\").First().Attr(\"href\")\n\tif !exists {\n\t\treturn result\n\t}\n\n\t// 提取ID\n\tmatches := detailIDRegex.FindStringSubmatch(detailLink)\n\tif len(matches) < 2 {\n\t\treturn result\n\t}\n\titemID := matches[1]\n\n\t// 构建唯一ID\n\tuniqueID := fmt.Sprintf(\"%s-%s\", p.Name(), itemID)\n\n\t// 提取标题\n\ttitle := strings.TrimSpace(s.Find(\".video-info-header h3 a\").First().Text())\n\tif title == \"\" {\n\t\treturn result\n\t}\n\n\t// 提取分类\n\tcategory := strings.TrimSpace(s.Find(\".video-info-items\").First().Find(\".video-info-item\").First().Text())\n\n\t// 提取导演\n\tdirectorElement := s.Find(\".video-info-items\").FilterFunction(func(i int, item *goquery.Selection) bool {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\treturn strings.Contains(title, \"导演\")\n\t})\n\tdirector := strings.TrimSpace(directorElement.Find(\".video-info-item\").Text())\n\n\t// 提取主演\n\tactorElement := s.Find(\".video-info-items\").FilterFunction(func(i int, item *goquery.Selection) bool {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\treturn strings.Contains(title, \"主演\")\n\t})\n\tactor := strings.TrimSpace(actorElement.Find(\".video-info-item\").Text())\n\n\t// 提取年份\n\tyear := strings.TrimSpace(s.Find(\".video-info-items\").Last().Find(\".video-info-item\").First().Text())\n\n\t// 提取质量/状态\n\tquality := strings.TrimSpace(s.Find(\".video-info-header .video-info-remarks\").Text())\n\n\t// 提取剧情简介\n\tplotElement := s.Find(\".video-info-items\").FilterFunction(func(i int, item *goquery.Selection) bool {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\treturn strings.Contains(title, \"剧情\")\n\t})\n\tplot := strings.TrimSpace(plotElement.Find(\".video-info-item\").Text())\n\n\t// 提取封面图片\n\tvar images []string\n\tif picURL, exists := s.Find(\".module-item-pic > img\").Attr(\"data-src\"); exists && picURL != \"\" {\n\t\timages = append(images, picURL)\n\t}\n\tresult.Images = images\n\n\t// 构建内容描述\n\tvar contentParts []string\n\tif quality != \"\" {\n\t\tcontentParts = append(contentParts, \"【\"+quality+\"】\")\n\t}\n\tif director != \"\" {\n\t\tcontentParts = append(contentParts, \"导演：\"+director)\n\t}\n\tif actor != \"\" {\n\t\tcontentParts = append(contentParts, \"主演：\"+actor)\n\t}\n\tif year != \"\" {\n\t\tcontentParts = append(contentParts, \"年份：\"+year)\n\t}\n\tif plot != \"\" {\n\t\tcontentParts = append(contentParts, \"剧情：\"+plot)\n\t}\n\tcontent := strings.Join(contentParts, \"\\n\")\n\n\t// 构建标签\n\tvar tags []string\n\tif year != \"\" {\n\t\ttags = append(tags, year)\n\t}\n\tif category != \"\" {\n\t\ttags = append(tags, category)\n\t}\n\n\tresult.UniqueID = uniqueID\n\tresult.Title = title\n\tresult.Content = content\n\tresult.Tags = tags\n\tresult.Channel = \"\" // 插件搜索结果Channel为空\n\tresult.Datetime = time.Time{} // 使用零值\n\n\treturn result\n}\n\n// enhanceWithDetails 异步获取详情页信息\nfunc (p *ErxiaoAsyncPlugin) enhanceWithDetails(client *http.Client, results []model.SearchResult) []model.SearchResult {\n\tvar enhancedResults []model.SearchResult\n\tvar wg sync.WaitGroup\n\tvar mu sync.Mutex\n\n\t// 创建信号量限制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\n\tfor _, result := range results {\n\t\twg.Add(1)\n\t\tgo func(result model.SearchResult) {\n\t\t\tdefer wg.Done()\n\t\t\tsemaphore <- struct{}{}        // 获取信号量\n\t\t\tdefer func() { <-semaphore }() // 释放信号量\n\n\t\t\t// 从UniqueID中提取itemID\n\t\t\tparts := strings.Split(result.UniqueID, \"-\")\n\t\t\tif len(parts) < 2 {\n\t\t\t\tmu.Lock()\n\t\t\t\tenhancedResults = append(enhancedResults, result)\n\t\t\t\tmu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\t\t\titemID := parts[1]\n\n\t\t\t// 检查缓存\n\t\t\tif cached, ok := detailCache.Load(itemID); ok {\n\t\t\t\tatomic.AddInt64(&cacheHits, 1)\n\t\t\t\tr := cached.(model.SearchResult)\n\t\t\t\tmu.Lock()\n\t\t\t\tenhancedResults = append(enhancedResults, r)\n\t\t\t\tmu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tatomic.AddInt64(&cacheMisses, 1)\n\n\t\t\t// 获取详情页链接和图片\n\t\t\tdetailLinks, detailImages := p.fetchDetailLinksAndImages(client, itemID)\n\t\t\tresult.Links = detailLinks\n\n\t\t\t// 合并图片：优先使用详情页的海报，如果没有则使用搜索结果的图片\n\t\t\tif len(detailImages) > 0 {\n\t\t\t\tresult.Images = detailImages\n\t\t\t}\n\n\t\t\t// 缓存结果\n\t\t\tdetailCache.Store(itemID, result)\n\n\t\t\tmu.Lock()\n\t\t\tenhancedResults = append(enhancedResults, result)\n\t\t\tmu.Unlock()\n\t\t}(result)\n\t}\n\n\twg.Wait()\n\treturn enhancedResults\n}\n\n// fetchDetailLinksAndImages 获取详情页的下载链接和图片\nfunc (p *ErxiaoAsyncPlugin) fetchDetailLinksAndImages(client *http.Client, itemID string) ([]model.Link, []string) {\n\t// 性能统计\n\tstart := time.Now()\n\tatomic.AddInt64(&detailPageRequests, 1)\n\tdefer func() {\n\t\tduration := time.Since(start).Nanoseconds()\n\t\tatomic.AddInt64(&totalDetailTime, duration)\n\t}()\n\n\tdetailURL := fmt.Sprintf(\"https://erxiaofn.click/index.php/vod/detail/id/%s.html\", itemID)\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DetailTimeout)\n\tdefer cancel()\n\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"https://erxiaofn.click/\")\n\n\t// 发送请求（带重试）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, nil\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\n\tvar links []model.Link\n\tvar images []string\n\n\t// 提取详情页的海报图片\n\tif posterURL, exists := doc.Find(\".mobile-play .lazyload\").Attr(\"data-src\"); exists && posterURL != \"\" {\n\t\timages = append(images, posterURL)\n\t}\n\n\t// 查找下载链接区域\n\tdoc.Find(\"#download-list .module-row-one\").Each(func(i int, s *goquery.Selection) {\n\t\t// 从data-clipboard-text属性提取链接\n\t\tif linkURL, exists := s.Find(\"[data-clipboard-text]\").Attr(\"data-clipboard-text\"); exists {\n\t\t\t// 过滤掉无效链接\n\t\t\tif p.isValidNetworkDriveURL(linkURL) {\n\t\t\t\tif linkType := p.determineLinkType(linkURL); linkType != \"\" {\n\t\t\t\t\tlink := model.Link{\n\t\t\t\t\t\tType:     linkType,\n\t\t\t\t\t\tURL:      linkURL,\n\t\t\t\t\t\tPassword: \"\", // 大部分网盘不需要密码\n\t\t\t\t\t}\n\t\t\t\t\tlinks = append(links, link)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n\n\treturn links, images\n}\n\n// isValidNetworkDriveURL 验证是否为有效的网盘URL\nfunc (p *ErxiaoAsyncPlugin) isValidNetworkDriveURL(url string) bool {\n\tif strings.Contains(url, \"javascript:\") ||\n\t   strings.Contains(url, \"#\") ||\n\t   url == \"\" ||\n\t   (!strings.HasPrefix(url, \"http\") && !strings.HasPrefix(url, \"magnet:\") && !strings.HasPrefix(url, \"ed2k:\")) {\n\t\treturn false\n\t}\n\treturn true\n}\n\n\n// determineLinkType 根据URL确定链接类型\nfunc (p *ErxiaoAsyncPlugin) determineLinkType(url string) string {\n\tswitch {\n\tcase quarkLinkRegex.MatchString(url):\n\t\treturn \"quark\"\n\tcase ucLinkRegex.MatchString(url):\n\t\treturn \"uc\"\n\tcase baiduLinkRegex.MatchString(url):\n\t\treturn \"baidu\"\n\tcase aliyunLinkRegex.MatchString(url):\n\t\treturn \"aliyun\"\n\tcase xunleiLinkRegex.MatchString(url):\n\t\treturn \"xunlei\"\n\tcase tianyiLinkRegex.MatchString(url):\n\t\treturn \"tianyi\"\n\tcase link115Regex.MatchString(url):\n\t\treturn \"115\"\n\tcase mobileLinkRegex.MatchString(url):\n\t\treturn \"mobile\"\n\tcase link123Regex.MatchString(url):\n\t\treturn \"123\"\n\tcase pikpakLinkRegex.MatchString(url):\n\t\treturn \"pikpak\"\n\tcase magnetLinkRegex.MatchString(url):\n\t\treturn \"magnet\"\n\tcase ed2kLinkRegex.MatchString(url):\n\t\treturn \"ed2k\"\n\tdefault:\n\t\treturn \"\" // 不支持的类型返回空字符串\n\t}\n}\n\n// extractPassword 从URL中提取密码\nfunc (p *ErxiaoAsyncPlugin) extractPassword(url string) string {\n\tmatches := passwordRegex.FindStringSubmatch(url)\n\tif len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// doRequestWithRetry 带重试的HTTP请求\nfunc (p *ErxiaoAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 2\n\tvar lastErr error\n\n\tfor i := 0; i < maxRetries; i++ {\n\t\tresp, err := client.Do(req)\n\t\tif err == nil {\n\t\t\tif resp.StatusCode == http.StatusOK {\n\t\t\t\treturn resp, nil\n\t\t\t}\n\t\t\tresp.Body.Close()\n\t\t\tlastErr = fmt.Errorf(\"HTTP状态码: %d\", resp.StatusCode)\n\t\t} else {\n\t\t\tlastErr = err\n\t\t}\n\n\t\t// 快速重试：只等待很短时间\n\t\tif i < maxRetries-1 {\n\t\t\ttime.Sleep(100 * time.Millisecond)\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"[%s] 请求失败，重试%d次后仍失败: %w\", p.Name(), maxRetries, lastErr)\n}\n\n// GetPerformanceStats 获取性能统计信息\nfunc (p *ErxiaoAsyncPlugin) GetPerformanceStats() map[string]interface{} {\n\ttotalRequests := atomic.LoadInt64(&searchRequests)\n\ttotalTime := atomic.LoadInt64(&totalSearchTime)\n\tdetailRequests := atomic.LoadInt64(&detailPageRequests)\n\tdetailTime := atomic.LoadInt64(&totalDetailTime)\n\thits := atomic.LoadInt64(&cacheHits)\n\tmisses := atomic.LoadInt64(&cacheMisses)\n\n\tvar avgTime float64\n\tif totalRequests > 0 {\n\t\tavgTime = float64(totalTime) / float64(totalRequests) / 1e6 // 转换为毫秒\n\t}\n\n\tvar avgDetailTime float64\n\tif detailRequests > 0 {\n\t\tavgDetailTime = float64(detailTime) / float64(detailRequests) / 1e6 // 转换为毫秒\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"search_requests\":      totalRequests,\n\t\t\"avg_search_time_ms\":   avgTime,\n\t\t\"total_search_time_ns\": totalTime,\n\t\t\"detail_page_requests\": detailRequests,\n\t\t\"avg_detail_time_ms\":   avgDetailTime,\n\t\t\"total_detail_time_ns\": detailTime,\n\t\t\"cache_hits\":           hits,\n\t\t\"cache_misses\":         misses,\n\t}\n}"
  },
  {
    "path": "plugin/erxiao/html结构分析.md",
    "content": "# Erxiao HTML 数据结构分析\n\n## 基本信息\n- **数据源类型**: HTML 网页\n- **搜索URL格式**: `https://erxiaofn.click/index.php/vod/search/wd/{关键词}.html`\n- **详情URL格式**: `https://erxiaofn.click/index.php/vod/detail/id/{资源ID}.html`\n- **数据特点**: 视频点播(VOD)系统网页，提供HTML格式的影视资源数据\n- **特殊说明**: 使用HTML解析替代JSON API，与zhizhen/muou插件使用相同的HTML结构\n\n## HTML 页面结构\n\n### 搜索结果页面 (`.module-search-item`)\n搜索结果页面包含多个搜索项，每个搜索项的HTML结构如下：\n\n```html\n<div class=\"module-search-item\">\n  <div class=\"module-item-pic\">\n    <img data-src=\"https://...\" />\n  </div>\n  <div class=\"module-item-text\">\n    <div class=\"video-info-header\">\n      <h3><a href=\"/index.php/vod/detail/id/12345.html\">电影标题</a></h3>\n      <span class=\"video-info-remarks\">HD</span>\n    </div>\n    <div class=\"video-info-items\">\n      <div class=\"video-info-item\">\n        <span class=\"video-info-itemtitle\">分类：</span>\n        <span class=\"video-info-item\">动作</span>\n      </div>\n      <div class=\"video-info-item\">\n        <span class=\"video-info-itemtitle\">导演：</span>\n        <span class=\"video-info-item\">导演名字</span>\n      </div>\n      <div class=\"video-info-item\">\n        <span class=\"video-info-itemtitle\">主演：</span>\n        <span class=\"video-info-item\">演员1,演员2</span>\n      </div>\n      <div class=\"video-info-item\">\n        <span class=\"video-info-itemtitle\">年份：</span>\n        <span class=\"video-info-item\">2024</span>\n      </div>\n      <div class=\"video-info-item\">\n        <span class=\"video-info-itemtitle\">剧情：</span>\n        <span class=\"video-info-item\">这是一部精彩的电影...</span>\n      </div>\n    </div>\n  </div>\n</div>\n```\n\n### 详情页面 (`.mobile-play` 和 `#download-list`)\n详情页面包含海报图片和下载链接：\n\n```html\n<div class=\"mobile-play\">\n  <img class=\"lazyload\" data-src=\"https://poster-url.jpg\" />\n</div>\n\n<div id=\"download-list\">\n  <div class=\"module-row-one\">\n    <div class=\"module-row-text\">\n      <span data-clipboard-text=\"https://pan.quark.cn/s/xxxxx\">夸克网盘</span>\n    </div>\n  </div>\n  <div class=\"module-row-one\">\n    <div class=\"module-row-text\">\n      <span data-clipboard-text=\"https://pan.baidu.com/s/xxxxx?pwd=xxxx\">百度网盘</span>\n    </div>\n  </div>\n</div>\n```\n\n## CSS 选择器参考\n\n### 搜索结果提取\n- **搜索结果容器**: `.module-search-item`\n- **标题**: `.video-info-header h3 a` (文本内容)\n- **详情页链接**: `.video-info-header h3 a` (href属性)\n- **封面图片**: `.module-item-pic > img` (data-src属性)\n- **质量/状态**: `.video-info-header .video-info-remarks` (文本内容)\n\n### 详情页下载链接提取\n- **海报图片**: `.mobile-play .lazyload` (data-src属性)\n- **下载链接容器**: `#download-list .module-row-one`\n- **下载链接**: `[data-clipboard-text]` (data-clipboard-text属性)\n\n## 支持的网盘类型\n- **Quark网盘**: `https://pan.quark.cn/s/{分享码}`\n- **百度网盘**: `https://pan.baidu.com/s/{分享码}?pwd={密码}`\n- **阿里云盘**: `https://www.aliyundrive.com/s/{分享码}`\n- **迅雷网盘**: `https://pan.xunlei.com/s/{分享码}`\n- **天翼云盘**: `https://cloud.189.cn/t/{分享码}`\n- **UC网盘**: `https://drive.uc.cn/s/{分享码}`\n- **115网盘**: `https://115.com/s/{分享码}`\n- **123网盘**: `https://123pan.com/s/{分享码}`\n- **PikPak**: `https://mypikpak.com/s/{分享码}`\n- **移动云盘**: `https://caiyun.feixin.10086.cn/{分享码}`\n- **磁力链接**: `magnet:?xt=urn:btih:{hash}`\n- **ED2K链接**: `ed2k://|file|...`\n\n## 数据流程\n\n### 搜索流程\n1. **构建搜索URL**: `https://erxiaofn.click/index.php/vod/search/wd/{keyword}.html`\n2. **发送HTTP请求**: 获取搜索结果页面\n3. **解析HTML**: 使用goquery解析页面\n4. **提取搜索项**: 遍历`.module-search-item`元素\n5. **异步获取详情**: 并发请求详情页面获取下载链接\n6. **缓存管理**: 使用sync.Map缓存详情页结果，TTL为1小时\n7. **关键词过滤**: 过滤不相关的结果\n\n### 详情页请求示例\n```go\ndetailURL := fmt.Sprintf(\"https://erxiaofn.click/index.php/vod/detail/id/%s.html\", itemID)\n```\n\n## 并发控制\n- **最大并发数**: 20 (MaxConcurrency)\n- **搜索超时**: 8秒 (DefaultTimeout)\n- **详情页超时**: 6秒 (DetailTimeout)\n- **缓存TTL**: 1小时 (cacheTTL)\n\n## 性能统计\n- **搜索请求数**: 总搜索请求数\n- **平均搜索时间**: 单次搜索平均耗时(毫秒)\n- **详情页请求数**: 总详情页请求数\n- **平均详情页时间**: 单次详情页请求平均耗时(毫秒)\n- **缓存命中数**: 详情页缓存命中次数\n- **缓存未命中数**: 详情页缓存未命中次数\n\n## 注意事项\n1. **HTML解析**: 使用goquery库进行HTML解析\n2. **异步获取详情**: 搜索结果只包含基本信息，需要异步请求详情页获取下载链接\n3. **并发控制**: 使用信号量限制并发数为20\n4. **缓存管理**: 使用sync.Map缓存详情页结果，避免重复请求\n5. **链接验证**: 过滤掉无效链接（如包含`javascript:`、`#`等）\n6. **密码提取**: 从URL中提取`?pwd=`参数作为密码\n\n"
  },
  {
    "path": "plugin/feikuai/feikuai.go",
    "content": "package feikuai\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\nconst (\n\t// API URL格式\n\tSearchAPIURL = \"https://feikuai.tv/t_search/bm_search.php?kw=%s\"\n\t\n\t// 默认超时时间\n\tDefaultTimeout = 15 * time.Second\n\t\n\t// HTTP连接池配置\n\tMaxIdleConns        = 100\n\tMaxIdleConnsPerHost = 30\n\tMaxConnsPerHost     = 50\n\tIdleConnTimeout     = 90 * time.Second\n)\n\n// 预编译正则表达式\nvar (\n\t// 文件扩展名正则\n\tfileExtRegex = regexp.MustCompile(`\\.(mkv|mp4|avi|rmvb|wmv|flv|mov|ts|m2ts|iso)$`)\n\t\n\t// 文件大小信息正则\n\tfileSizeRegex = regexp.MustCompile(`\\s*·\\s*[\\d.]+\\s*[KMGT]B\\s*$`)\n\t\n\t// 日期时间提取正则\n\tdateTimeRegex = regexp.MustCompile(`@[^-]+-(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})`)\n)\n\n// FeikuaiPlugin Feikuai磁力搜索插件\ntype FeikuaiPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t}\n\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   DefaultTimeout,\n\t}\n}\n\n// NewFeikuaiPlugin 创建新的Feikuai插件\nfunc NewFeikuaiPlugin() *FeikuaiPlugin {\n\treturn &FeikuaiPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"feikuai\", 3, true), // 跳过Service层过滤\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewFeikuaiPlugin())\n}\n\n// Search 同步搜索接口\nfunc (p *FeikuaiPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 带结果统计的搜索接口\nfunc (p *FeikuaiPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 搜索实现\nfunc (p *FeikuaiPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 使用优化的客户端\n\tif p.optimizedClient != nil {\n\t\tclient = p.optimizedClient\n\t}\n\n\t// 构建API搜索URL\n\tsearchURL := fmt.Sprintf(SearchAPIURL, url.QueryEscape(keyword))\n\t\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"https://feikuai.tv/\")\n\t\n\t// 发送请求（带重试）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 读取并解析JSON响应\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 读取响应失败: %w\", p.Name(), err)\n\t}\n\t\n\tvar apiResp FeikuaiAPIResponse\n\tif err := json.Unmarshal(body, &apiResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] JSON解析失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 检查API响应状态\n\tif apiResp.Code != 0 {\n\t\treturn nil, fmt.Errorf(\"[%s] API返回错误: %s (code: %d)\", p.Name(), apiResp.Msg, apiResp.Code)\n\t}\n\t\n\t// 解析搜索结果\n\tvar results []model.SearchResult\n\tfor _, item := range apiResp.Items {\n\t\t// 每个item可能包含多个种子\n\t\tfor _, torrent := range item.Torrents {\n\t\t\tresult := p.parseTorrent(keyword, item, torrent)\n\t\t\tif result.Title != \"\" && len(result.Links) > 0 {\n\t\t\t\tresults = append(results, result)\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 使用关键词过滤结果\n\treturn plugin.FilterResultsByKeyword(results, keyword), nil\n}\n\n// FeikuaiAPIResponse API响应结构\ntype FeikuaiAPIResponse struct {\n\tCode    int                `json:\"code\"`\n\tMsg     string             `json:\"msg\"`\n\tKeyword string             `json:\"keyword\"`\n\tCount   int                `json:\"count\"`\n\tItems   []FeikuaiAPIItem   `json:\"items\"`\n}\n\n// FeikuaiAPIItem API数据项\ntype FeikuaiAPIItem struct {\n\tContentID *string           `json:\"content_id\"`\n\tTitle     string            `json:\"title\"`\n\tType      string            `json:\"type\"`\n\tYear      *int              `json:\"year\"`\n\tTorrents  []FeikuaiTorrent  `json:\"torrents\"`\n}\n\n// FeikuaiTorrent 种子数据\ntype FeikuaiTorrent struct {\n\tInfoHash     string  `json:\"info_hash\"`\n\tMagnet       string  `json:\"magnet\"`\n\tName         string  `json:\"name\"`\n\tSizeBytes    int64   `json:\"size_bytes\"`\n\tSizeGB       float64 `json:\"size_gb\"`\n\tSeeders      int     `json:\"seeders\"`\n\tLeechers     int     `json:\"leechers\"`\n\tPublishedAt  string  `json:\"published_at\"`\n\tPublishedAgo string  `json:\"published_ago\"`\n\tFilePath     string  `json:\"file_path\"`\n\tFileExt      string  `json:\"file_ext\"`\n}\n\n// parseTorrent 解析种子数据为SearchResult\nfunc (p *FeikuaiPlugin) parseTorrent(keyword string, item FeikuaiAPIItem, torrent FeikuaiTorrent) model.SearchResult {\n\t// 构建唯一ID\n\tuniqueID := fmt.Sprintf(\"%s-%s\", p.Name(), torrent.InfoHash)\n\t\n\t// 构建work_title\n\tworkTitle := p.buildWorkTitle(keyword, torrent.Name)\n\t\n\t// 构建描述信息\n\tcontent := p.buildContent(item, torrent)\n\t\n\t// 解析发布时间\n\tdatetime := p.parsePublishedTime(torrent.PublishedAt)\n\t\n\t// 构建标签\n\ttags := p.extractTags(item.Title, torrent.Name)\n\t\n\t// 构建磁力链接\n\tlinks := []model.Link{\n\t\t{\n\t\t\tType:      \"magnet\",\n\t\t\tURL:       torrent.Magnet,\n\t\t\tPassword:  \"\",  // 磁力链接无密码\n\t\t\tDatetime:  datetime,\n\t\t\tWorkTitle: workTitle,\n\t\t},\n\t}\n\t\n\treturn model.SearchResult{\n\t\tUniqueID: uniqueID,\n\t\tTitle:    workTitle,  // 使用处理后的work_title作为标题\n\t\tContent:  content,\n\t\tLinks:    links,\n\t\tTags:     tags,\n\t\tChannel:  \"\", // 插件搜索结果Channel为空\n\t\tDatetime: datetime,\n\t}\n}\n\n// buildWorkTitle 构建work_title（核心功能）\nfunc (p *FeikuaiPlugin) buildWorkTitle(keyword, fileName string) string {\n\t// 1. 清洗文件名\n\tcleanedName := p.cleanFileName(fileName)\n\t\n\t// 2. 检查是否包含关键词\n\tif p.containsKeywords(keyword, cleanedName) {\n\t\treturn cleanedName\n\t}\n\t\n\t// 3. 不包含关键词，拼接中文关键词\n\treturn fmt.Sprintf(\"%s-%s\", keyword, cleanedName)\n}\n\n// cleanFileName 清洗文件名\nfunc (p *FeikuaiPlugin) cleanFileName(fileName string) string {\n\t// 去除文件扩展名\n\tfileName = fileExtRegex.ReplaceAllString(fileName, \"\")\n\t\n\t// 去除文件大小信息\n\tfileName = fileSizeRegex.ReplaceAllString(fileName, \"\")\n\t\n\t// 去除日期时间部分（@来源-日期 时间）\n\tif idx := strings.Index(fileName, \"@\"); idx != -1 {\n\t\tfileName = fileName[:idx]\n\t}\n\t\n\treturn strings.TrimSpace(fileName)\n}\n\n// containsKeywords 检查文本是否包含关键词\nfunc (p *FeikuaiPlugin) containsKeywords(keyword, text string) bool {\n\t// 简化处理：分词并检查\n\tkeywords := p.splitKeywords(keyword)\n\tlowerText := strings.ToLower(text)\n\t\n\tfor _, kw := range keywords {\n\t\tif strings.Contains(lowerText, strings.ToLower(kw)) {\n\t\t\treturn true\n\t\t}\n\t}\n\t\n\treturn false\n}\n\n// splitKeywords 分词提取关键词\nfunc (p *FeikuaiPlugin) splitKeywords(keyword string) []string {\n\t// 移除标点符号和空格\n\tkeyword = strings.TrimSpace(keyword)\n\t\n\t// 简单按空格、中文标点分割\n\tseparators := []string{\" \", \"　\", \"，\", \"。\", \"、\", \"；\", \"：\", \"！\", \"？\", \"-\", \"_\"}\n\t\n\tparts := []string{keyword}\n\tfor _, sep := range separators {\n\t\tvar newParts []string\n\t\tfor _, part := range parts {\n\t\t\tif strings.Contains(part, sep) {\n\t\t\t\tnewParts = append(newParts, strings.Split(part, sep)...)\n\t\t\t} else {\n\t\t\t\tnewParts = append(newParts, part)\n\t\t\t}\n\t\t}\n\t\tparts = newParts\n\t}\n\t\n\t// 过滤空字符串和过短的词\n\tvar result []string\n\tfor _, part := range parts {\n\t\tpart = strings.TrimSpace(part)\n\t\tif len(part) >= 2 { // 至少2个字符\n\t\t\tresult = append(result, part)\n\t\t}\n\t}\n\t\n\treturn result\n}\n\n// buildContent 构建内容描述\nfunc (p *FeikuaiPlugin) buildContent(item FeikuaiAPIItem, torrent FeikuaiTorrent) string {\n\tvar contentParts []string\n\t\n\t// 文件名\n\tcontentParts = append(contentParts, fmt.Sprintf(\"文件名: %s\", torrent.Name))\n\t\n\t// 文件大小\n\tcontentParts = append(contentParts, fmt.Sprintf(\"大小: %.2f GB\", torrent.SizeGB))\n\t\n\t// 做种数和下载数\n\tcontentParts = append(contentParts, fmt.Sprintf(\"做种: %d\", torrent.Seeders))\n\tcontentParts = append(contentParts, fmt.Sprintf(\"下载: %d\", torrent.Leechers))\n\t\n\t// 发布时间（人类可读格式）\n\tif torrent.PublishedAgo != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"发布: %s\", torrent.PublishedAgo))\n\t}\n\t\n\treturn strings.Join(contentParts, \" | \")\n}\n\n// extractTags 提取标签\nfunc (p *FeikuaiPlugin) extractTags(title, fileName string) []string {\n\tvar tags []string\n\tcombinedText := strings.ToUpper(title + \" \" + fileName)\n\t\n\t// 分辨率标签\n\tif strings.Contains(combinedText, \"2160P\") || strings.Contains(combinedText, \"4K\") {\n\t\ttags = append(tags, \"4K\")\n\t} else if strings.Contains(combinedText, \"1080P\") {\n\t\ttags = append(tags, \"1080P\")\n\t} else if strings.Contains(combinedText, \"720P\") {\n\t\ttags = append(tags, \"720P\")\n\t}\n\t\n\t// 编码格式\n\tif strings.Contains(combinedText, \"H265\") || strings.Contains(combinedText, \"HEVC\") {\n\t\ttags = append(tags, \"H265\")\n\t} else if strings.Contains(combinedText, \"H264\") || strings.Contains(combinedText, \"AVC\") {\n\t\ttags = append(tags, \"H264\")\n\t}\n\t\n\t// HDR标签\n\tif strings.Contains(combinedText, \"HDR\") {\n\t\ttags = append(tags, \"HDR\")\n\t}\n\t\n\t// 60帧\n\tif strings.Contains(combinedText, \"60FPS\") || strings.Contains(combinedText, \"60HZ\") {\n\t\ttags = append(tags, \"60fps\")\n\t}\n\t\n\treturn tags\n}\n\n// parsePublishedTime 解析发布时间\nfunc (p *FeikuaiPlugin) parsePublishedTime(timeStr string) time.Time {\n\tif timeStr == \"\" {\n\t\treturn time.Now()\n\t}\n\t\n\t// 解析ISO 8601格式: \"2025-11-18 00:54:20.659664+00\"\n\tlayouts := []string{\n\t\t\"2006-01-02 15:04:05.999999-07\",\n\t\t\"2006-01-02 15:04:05.999999+07\",\n\t\t\"2006-01-02 15:04:05-07\",\n\t\t\"2006-01-02 15:04:05+07\",\n\t\t\"2006-01-02 15:04:05\",\n\t}\n\t\n\tfor _, layout := range layouts {\n\t\tif t, err := time.Parse(layout, timeStr); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\t\n\t// 解析失败，返回当前时间\n\treturn time.Now()\n}\n\n// doRequestWithRetry 带重试的HTTP请求\nfunc (p *FeikuaiPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil {\n\t\t\tif resp.StatusCode == 200 {\n\t\t\t\treturn resp, nil\n\t\t\t}\n\t\t\tresp.Body.Close()\n\t\t\tlastErr = fmt.Errorf(\"HTTP状态码: %d\", resp.StatusCode)\n\t\t} else {\n\t\t\tlastErr = err\n\t\t}\n\t}\n\t\n\treturn nil, fmt.Errorf(\"[%s] 重试 %d 次后仍然失败: %w\", p.Name(), maxRetries, lastErr)\n}\n"
  },
  {
    "path": "plugin/feikuai/html结构分析.md",
    "content": "# Feikuai网站 (飞快TV) HTML结构分析\n\n## 网站信息\n\n- **网站名称**: 飞快TV\n- **域名**: `feikuai.tv`\n- **搜索URL格式**: `https://feikuai.tv/vodsearch/-------------.html?wd={关键词}`\n- **详情页URL格式**: `https://feikuai.tv/voddetail/{ID}.html`\n- **主要特点**: 影视网盘资源站，支持多种网盘类型下载\n\n## 搜索结果页面结构\n\n搜索结果页面的主要内容位于 `.module-items.module-card-items` 元素内，每个搜索结果项包含在 `.module-card-item.module-item` 元素中。\n\n```html\n<div class=\"module-main module-page\" id=\"ajaxRoot\">\n  <div class=\"module-items module-card-items\" id=\"resultList\">\n    <div class=\"module-card-item module-item\">\n      <!-- 单个搜索结果 -->\n    </div>\n  </div>\n</div>\n```\n\n### 单个搜索结果结构\n\n每个搜索结果包含以下主要元素：\n\n#### 1. 分类标签\n\n```html\n<div class=\"module-card-item-class\">剧集</div>\n```\n\n- 类型：电影、剧集、综艺、动漫\n\n#### 2. 封面图片和详情页链接\n\n```html\n<a href=\"/voddetail/157546.html\" class=\"module-card-item-poster\">\n  <div class=\"module-item-cover\">\n    <div class=\"module-item-note\">30集完结</div>\n    <div class=\"module-item-douban\">豆瓣:9.3分</div>\n    <div class=\"module-item-pic\">\n      <img class=\"lazy lazyload\" \n           data-original=\"/upload/vod/20250727-1/5a8143a6b2e3fea89e11df8090bbdeff.jpg\" \n           alt=\"凡人修仙传\" \n           referrerpolicy=\"no-referrer\" \n           src=\"/upload/mxprocms/20250310-1/4dd2e7fd412a71590c02b9514bf1805c.gif\">\n    </div>\n  </div>\n</a>\n```\n\n- **详情页链接**: 从 `<a>` 标签的 `href` 属性提取\n- **资源ID**: 从URL中提取（如 `157546`）\n- **更新状态**: `.module-item-note` 包含集数信息\n- **豆瓣评分**: `.module-item-douban` 包含评分（可选）\n- **封面图片**: `img` 标签的 `data-original` 属性\n\n#### 3. 标题和基本信息\n\n```html\n<div class=\"module-card-item-info\">\n  <div class=\"module-card-item-title\">\n    <a href=\"/voddetail/157546.html\"><strong>凡人修仙传</strong></a>\n  </div>\n  <div class=\"module-info-item\">\n    <div class=\"module-info-item-content\">2025 <span class=\"slash\">/</span>中国大陆 <span class=\"slash\">/</span> 奇幻,古装</div>\n  </div>\n  <div class=\"module-info-item\">\n    <div class=\"module-info-item-content\">杨洋,金晨,汪铎,赵小棠,...</div>\n  </div>\n</div>\n```\n\n- **标题**: `.module-card-item-title strong` 的文本内容\n- **年份/地区/类型**: 第一个 `.module-info-item-content` 包含，用 `/` 分隔\n- **演员信息**: 第二个 `.module-info-item-content` 包含演员列表\n\n#### 4. 操作按钮\n\n```html\n<div class=\"module-card-item-footer\">\n  <a href=\"/vodplay/157546-1-1.html\" class=\"play-btn icon-btn\">\n    <i class=\"icon-play\"></i><span>播放</span>\n  </a>\n  <a href=\"/voddetail/157546.html\" class=\"play-btn-o\"><span>详情</span></a>\n</div>\n```\n\n### 搜索结果数量\n\n```html\n<div class=\"module-heading-search-result\">\n  搜索 \"<strong>凡人修仙传</strong>\"，\n  找到 <strong class=\"mac_total\">26</strong> <span class=\"mac_suffix\">部影片</span>\n</div>\n```\n\n- **搜索关键词**: `.module-heading-search-result strong` (第一个)\n- **结果数量**: `.mac_total` 的文本内容\n\n### 分页结构\n\n```html\n<div id=\"resultPaging\">\n  <div id=\"page\">\n    <a href=\"/vodsearch/%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0----------1---.html\" class=\"page-link page-previous\">首页</a>\n    <span class=\"page-link page-number page-current display\">1</span>\n    <a href=\"/vodsearch/%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0----------2---.html\" class=\"page-link page-number display\">2</a>\n    <a href=\"/vodsearch/%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0----------2---.html\" class=\"page-link page-next\">下一页</a>\n  </div>\n</div>\n```\n\n## 详情页面结构\n\n### 1. 基本信息区域\n\n```html\n<div class=\"module module-info\">\n  <div class=\"module-main\">\n    <div class=\"module-info-poster\">\n      <div class=\"module-item-cover\">\n        <div class=\"module-item-pic\">\n          <img class=\"ls-is-cached lazy lazyload\" \n               data-original=\"/upload/vod/20250727-1/5a8143a6b2e3fea89e11df8090bbdeff.jpg\" \n               alt=\"凡人修仙传\">\n        </div>\n      </div>\n    </div>\n    <div class=\"module-info-main\">\n      <div class=\"module-info-heading\">\n        <h1>凡人修仙传</h1>\n        <div class=\"module-info-tag\">\n          <div class=\"module-info-tag-link\"><a title=\"2025\" href=\"/vodshow/13-----------2025.html\">2025</a></div>\n          <div class=\"module-info-tag-link\"><a title=\"中国大陆\" href=\"/vodshow/13-%E4%B8%AD%E5%9B%BD%E5%A4%A7%E9%99%86----------.html\">中国大陆</a></div>\n          <div class=\"module-info-tag-link\">\n            <a href=\"/vodshow/13---%E5%A5%87%E5%B9%BB--------.html\">奇幻</a><span class=\"slash\">/</span>\n            <a href=\"/vodshow/13---%E5%8F%A4%E8%A3%85--------.html\">古装</a>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</div>\n```\n\n- **标题**: `h1` 标签的文本内容\n- **年份**: 第一个 `.module-info-tag-link a` 的 `title` 属性\n- **地区**: 第二个 `.module-info-tag-link a` 的 `title` 属性\n- **类型**: 第三个 `.module-info-tag-link` 内的所有 `a` 标签文本\n\n### 2. 详细信息\n\n```html\n<div class=\"module-info-content\">\n  <div class=\"module-info-items\">\n    <div class=\"module-info-item module-info-introduction\">\n      <div class=\"module-info-introduction-content\">\n        <p>该剧改编自忘语的同名小说...</p>\n      </div>\n    </div>\n    <div class=\"module-info-item\">\n      <span class=\"module-info-item-title\">导演：</span>\n      <div class=\"module-info-item-content\">\n        <a href=\"/vodsearch/-----%E6%9D%A8%E9%98%B3--------.html\" target=\"_blank\">杨阳</a><span class=\"slash\">/</span>\n      </div>\n    </div>\n    <div class=\"module-info-item\">\n      <span class=\"module-info-item-title\">主演：</span>\n      <div class=\"module-info-item-content\">\n        <a href=\"/vodsearch/-%E6%9D%A8%E6%B4%8B------------.html\" target=\"_blank\">杨洋</a><span class=\"slash\">/</span>\n        <a href=\"/vodsearch/-%E9%87%91%E6%99%A8------------.html\" target=\"_blank\">金晨</a><span class=\"slash\">/</span>\n        ...\n      </div>\n    </div>\n  </div>\n</div>\n```\n\n- **剧情简介**: `.module-info-introduction-content p` 的文本内容\n- **导演**: 查找包含 \"导演：\" 的 `.module-info-item-title`，然后提取 `.module-info-item-content` 中的演员链接\n- **主演**: 查找包含 \"主演：\" 的 `.module-info-item-title`，然后提取 `.module-info-item-content` 中的演员链接\n\n### 3. 网盘下载链接区域 ⭐ 核心\n\n```html\n<div class=\"module\" id=\"download-list\" name=\"download-list\">\n  <div class=\"module-heading player-heading\">\n    <h2 class=\"module-title\">影片下载</h2>\n    <div class=\"module-tab\">\n      <div class=\"module-tab-items\">\n        <div class=\"module-tab-items-box hisSwiper\" id=\"y-downList\">\n          <div class=\"module-tab-item tab-item selected active\" \n               data-index=\"3\" \n               data-dropdown-value=\"百度网盘\">\n            <span>百度网盘</span>\n            <small>1</small>\n          </div>\n          <div class=\"module-tab-item tab-item\" \n               data-index=\"2\" \n               data-dropdown-value=\"夸克网盘\">\n            <span>夸克网盘</span>\n            <small>1</small>\n          </div>\n          <!-- 更多网盘类型... -->\n        </div>\n      </div>\n    </div>\n  </div>\n</div>\n```\n\n#### 网盘类型标签\n\n- **网盘类型**: `.module-tab-item span` 的文本内容\n- **数量**: `.module-tab-item small` 的文本内容\n- **网盘标识**: `data-dropdown-value` 属性或 `span` 文本\n\n支持的网盘/链接类型：\n- 百度网盘 (`baidu`)\n- 夸克网盘 (`quark`)\n- 迅雷云盘 (`xunlei`)\n- 阿里云盘 (`aliyun`)\n- 天翼云盘 (`tianyi`)\n- UC网盘 (`uc`)\n- 115网盘 (`115`)\n- 123云盘 (`123`)\n- 移动云盘 (`mobile`)\n- 磁力链接 (`magnet`)\n\n#### 下载链接列表\n\n```html\n<div class=\"module-list module-player-list sort-list module-downlist\">\n  <div class=\"tab-content selected\" id=\"tab-content-3\">\n    <div class=\"module-row-info\">\n      <a class=\"module-row-text copy\"\n         href=\"https://pan.baidu.com/s/1u9aaXsTkL1GdOMIH9qnPCA?pwd=B5B3\" \n         target=\"_blank\"\n         title=\"下载《凡人修仙传》\">\n        <i class=\"icon-video-file\"></i>\n        <div class=\"module-row-title-dlist\">\n          <h4>凡人修仙传（2025）4K 高码率 更至EP169@一键搜片-2025-11-16 18:55:25</h4>\n          <p>https://pan.baidu.com/s/1u9aaXsTkL1GdOMIH9qnPCA?pwd=B5B3</p>\n        </div>\n      </a>\n    </div>\n  </div>\n  \n  <div class=\"tab-content\" id=\"tab-content-2\">\n    <div class=\"module-row-info\">\n      <a class=\"module-row-text copy\"\n         href=\"https://pan.quark.cn/s/063ce74fbf41\" \n         target=\"_blank\"\n         title=\"下载《凡人修仙传》\">\n        <i class=\"icon-video-file\"></i>\n        <div class=\"module-row-title-dlist\">\n          <h4>凡人修仙传：外海风云篇 4K [更新至169集]@一键搜片-2025-11-16 18:55:25</h4>\n          <p>https://pan.quark.cn/s/063ce74fbf41</p>\n        </div>\n      </a>\n    </div>\n  </div>\n  \n  <div class=\"tab-content\" id=\"tab-content-6\">\n    <div class=\"module-row-info\">\n      <a class=\"module-row-text copy\"\n         href=\"magnet:?xt=urn:btih:C3A3A53C2408396D64450046361F00650CB9E53E&dn=Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv&xl=2458041664\" \n         target=\"_blank\"\n         title=\"下载《唐朝诡事录之长安》\">\n        <i class=\"icon-video-file\"></i>\n        <div class=\"module-row-title-dlist\">\n          <h4>Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv · 2.29GB@一键搜片-2025-11-18 17:09:52</h4>\n          <p>magnet:?xt=urn:btih:C3A3A53C2408396D64450046361F00650CB9E53E&dn=Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv&xl=2458041664</p>\n        </div>\n      </a>\n    </div>\n  </div>\n</div>\n```\n\n##### 链接数据提取\n\n- **下载链接URL**: `.module-row-text` 的 `href` 属性 或 `.module-row-title-dlist p` 的文本内容\n- **网盘/链接类型**: 根据链接URL自动识别\n  - 网盘链接：`baidu`, `quark`, `aliyun`, `xunlei`, `tianyi`, `uc`, `115`, `123`, `mobile`\n  - 磁力链接：`magnet:?xt=urn:btih:` 开头识别为 `magnet`\n  \n- **独立标题** (⭐ 重要 - 对应API的 `work_title` 字段):\n  - **基础提取**: 从 `.module-row-title-dlist h4` 提取文本内容\n  - **清洗处理**: \n    1. 去除末尾的日期时间部分（`@来源-日期 时间`）\n    2. 去除文件扩展名（如 `.mkv`, `.mp4` 等）\n    3. 去除文件大小信息（如 `· 2.29GB`）\n  - **标题拼接规则** (关键):\n    - 检查清洗后的独立标题是否包含详情页主标题的关键词\n    - **判断方法**: 将详情页标题分词，检查独立标题中是否包含任一关键词（忽略标点和空格）\n    - **需要拼接**: 如果不包含关键词，则拼接格式为 `{详情页主标题}-{独立标题}`\n    - **无需拼接**: 如果包含关键词，直接使用独立标题\n  - **示例**:\n    - 网盘链接：`凡人修仙传（2025）4K 高码率 更至EP169@一键搜片-2025-11-16 18:55:25` \n      → 清洗后：`凡人修仙传（2025）4K 高码率 更至EP169`\n      → 包含关键词\"凡人修仙传\"，无需拼接\n    - 磁力链接：`Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv · 2.29GB@一键搜片-2025-11-18 17:09:52`\n      → 详情页标题：`唐朝诡事录之长安`\n      → 清洗后：`Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV`\n      → 不包含关键词，需要拼接\n      → 最终：`唐朝诡事录之长安-Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV`\n\n- **日期提取** (对应API的 `datetime` 字段):\n  - 从独立标题中提取日期时间信息\n  - 日期格式：`@来源-YYYY-MM-DD HH:mm:ss`\n  - 正则表达式：`@[^-]+-(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})`\n  - 示例：从 `@一键搜片-2025-11-16 18:55:25` 提取 `2025-11-16 18:55:25`\n\n## 提取逻辑\n\n### 搜索结果页面提取逻辑\n\n1. 定位所有的 `.module-card-item.module-item` 元素\n2. 对于每个元素：\n   - 从 `.module-card-item-poster` 的 `href` 属性提取详情页链接\n   - 从链接中提取资源ID（如 `157546`）\n   - 从 `.module-card-item-title strong` 提取标题\n   - 从 `.module-card-item-class` 提取分类\n   - 从 `.module-item-note` 提取更新状态\n   - 从 `.module-item-douban` 提取豆瓣评分（可选）\n   - 从第一个 `.module-info-item-content` 提取年份/地区/类型\n   - 从第二个 `.module-info-item-content` 提取演员列表\n   - 从 `img` 的 `data-original` 属性提取封面图片URL\n\n### 详情页面提取逻辑\n\n1. 获取资源基本信息：\n   - 标题：`h1` 的文本内容\n   - 年份：第一个 `.module-info-tag-link a[title]` 的 `title` 属性\n   - 地区：第二个 `.module-info-tag-link a[title]` 的 `title` 属性\n   - 类型：第三个 `.module-info-tag-link` 内的所有 `a` 标签文本\n   - 封面图片：`.module-info-poster img` 的 `data-original` 属性\n\n2. 提取详细信息：\n   - 剧情简介：`.module-info-introduction-content p` 的文本内容\n   - 导演：查找包含 \"导演：\" 的 `.module-info-item`，提取其中的 `a` 标签文本\n   - 主演：查找包含 \"主演：\" 的 `.module-info-item`，提取其中的 `a` 标签文本\n\n3. 提取下载链接（⭐ 核心）：\n   - 遍历所有 `.module-tab-item`，获取网盘类型和数量\n   - 对应每个 `.tab-content`，提取其中的 `.module-row-info`\n   - 对每个 `.module-row-info`：\n     - **链接URL**: 从 `.module-row-text` 的 `href` 属性或 `.module-row-title-dlist p` 提取\n     - **链接类型**: 根据链接URL自动识别（网盘类型或 `magnet`）\n     - **原始标题**: 从 `.module-row-title-dlist h4` 提取完整文本\n     - **独立标题** (`work_title`): \n       1. 清洗原始标题（去除日期、扩展名、文件大小）\n       2. 检查是否包含详情页主标题关键词\n       3. 如不包含，拼接为 `{详情页主标题}-{清洗后标题}`\n     - **日期时间** (`datetime`): 从原始标题中提取日期，使用正则 `@[^-]+-(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})`\n     - **密码**: 从URL参数中提取（如 `?pwd=xxx` 或 `?password=xxx`，仅适用于部分网盘）\n\n## 网盘链接和磁力链接格式\n\n| 类型 | URL特征 | 密码格式 |\n|---------|---------|---------|\n| 百度网盘 | `pan.baidu.com` | `?pwd=` 参数 |\n| 夸克网盘 | `pan.quark.cn` | 无密码或单独提供 |\n| 阿里云盘 | `alipan.com` 或 `aliyundrive.com` | 无密码 |\n| 迅雷网盘 | `pan.xunlei.com` | `?pwd=` 参数 |\n| 天翼云盘 | `cloud.189.cn` | 无密码 |\n| UC网盘 | `drive.uc.cn` | 无密码 |\n| 115网盘 | `115cdn.com` | `?password=` 参数 |\n| 123网盘 | `123684.com`, `123685.com`, `123912.com` | 无密码 |\n| 移动云盘 | `caiyun.139.com` | 无密码 |\n| 磁力链接 | `magnet:?xt=urn:btih:` | 无密码 |\n\n## API字段映射\n\n根据README的API文档，Link对象字段映射关系：\n\n| API字段 | HTML提取位置 | 提取方法 | 示例 |\n|---------|------------|---------|------|\n| `type` | 链接URL | 自动识别URL特征 | `baidu`, `quark`, `tianyi`, `magnet` 等 |\n| `url` | `.module-row-title-dlist p` 或 `href` | 文本内容或属性值 | `https://pan.baidu.com/s/xxx` 或 `magnet:?xt=...` |\n| `password` | 链接URL参数 | 提取 `?pwd=` 或 `?password=` | `B5B3`, `yyds` (仅部分网盘) |\n| `datetime` | `.module-row-title-dlist h4` | 正则提取日期时间 | `2025-11-16 18:55:25` |\n| `work_title` | `.module-row-title-dlist h4` + 详情页主标题 | 清洗+关键词检查+拼接 | 见下方详细说明 |\n\n**`work_title` 字段详细处理流程**:\n\n1. **提取原始标题**: 从 `.module-row-title-dlist h4` 获取完整文本\n   - 示例1: `凡人修仙传（2025）4K 高码率 更至EP169@一键搜片-2025-11-16 18:55:25`\n   - 示例2: `Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv · 2.29GB@一键搜片-2025-11-18 17:09:52`\n\n2. **清洗标题**:\n   - 去除日期时间部分: 删除 `@来源-日期 时间` 格式的后缀\n   - 去除文件扩展名: 删除 `.mkv`, `.mp4`, `.avi` 等\n   - 去除文件大小: 删除 `· 2.29GB` 等文件大小信息\n   - 清洗结果1: `凡人修仙传（2025）4K 高码率 更至EP169`\n   - 清洗结果2: `Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV`\n\n3. **关键词检查与拼接**:\n   - 获取详情页主标题（如 `唐朝诡事录之长安`）\n   - 将主标题分词，提取关键词（忽略标点符号和空格）\n   - 检查清洗后的独立标题是否包含任一关键词\n   - **包含关键词**: 直接使用清洗后的标题\n     - 示例: `凡人修仙传（2025）4K 高码率 更至EP169` (包含\"凡人修仙传\")\n   - **不包含关键词**: 拼接格式为 `{详情页主标题}-{清洗后标题}`\n     - 示例: `唐朝诡事录之长安-Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV`\n\n**其他字段说明**:\n- `datetime`: 从原始 `h4` 标题中提取的时间戳，格式为 `YYYY-MM-DD HH:mm:ss`\n- `password`: 部分网盘（百度、迅雷、115）的密码在URL参数中，需要单独提取；磁力链接无密码\n\n## 注意事项\n\n1. **图片延迟加载**: 封面图片使用了 `lazy lazyload` 类，实际图片URL在 `data-original` 属性中\n\n2. **资源ID提取**: 从URL中提取ID的正则表达式：`/voddetail/(\\d+)\\.html`\n\n3. **链接类型识别**:\n   - 网盘链接：通过域名识别（`pan.baidu.com`, `pan.quark.cn` 等）\n   - 磁力链接：通过 `magnet:?xt=urn:btih:` 前缀识别\n\n4. **网盘链接密码**: 某些网盘的密码包含在URL参数中（如 `?pwd=B5B3`），需要分离链接和密码；磁力链接无密码\n\n5. **独立标题处理** (⭐ 核心重点):\n   - 每个链接都有独立的 `h4` 标题，必须单独提取\n   - 需要清洗标题（去除日期、扩展名、文件大小）\n   - **关键词检查**: 必须检查清洗后标题是否包含详情页主标题的关键词\n   - **拼接规则**: 不包含关键词时，需拼接为 `{详情页主标题}-{清洗后标题}`\n   - 特别注意磁力链接的标题通常是英文文件名，大概率需要拼接中文标题\n\n6. **日期时间提取** (重要): \n   - 从 `h4` 标题末尾提取日期时间\n   - 格式为 `@来源-YYYY-MM-DD HH:mm:ss`\n   - 正则表达式: `@[^-]+-(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})`\n\n7. **多链接支持**: 一个资源可能有多个网盘和磁力链接，每个链接都有独立的标题、时间和密码\n\n8. **分页处理**: 搜索结果有分页，URL格式为 `/vodsearch/{关键词}----------{页码}---.html`\n\n9. **AJAX加载**: 网站使用AJAX动态加载搜索结果，需要注意异步请求处理\n\n10. **反爬虫**: 图片设置了 `referrerpolicy=\"no-referrer\"`，需要在请求头中处理\n"
  },
  {
    "path": "plugin/feikuai/json结构分析.md",
    "content": "# Feikuai API 数据结构分析\n\n## 基本信息\n- **数据源类型**: JSON API  \n- **网站名称**: 飞快TV (feikuai.tv)\n- **API URL格式**: `https://feikuai.tv/t_search/bm_search.php?kw={URL编码的关键词}`\n- **数据特点**: 磁力链接搜索API，提供结构化的BT/磁力资源数据\n- **特殊说明**: 专注于磁力链接，包含详细的种子信息（做种数、下载数等）\n\n## API响应结构\n\n### 顶层结构\n```json\n{\n    \"code\": 0,                    // 状态码：0表示成功\n    \"msg\": \"ok\",                  // 响应消息\n    \"keyword\": \"唐朝诡事录之长安\", // 搜索关键词\n    \"count\": 8,                   // 搜索结果总数\n    \"items\": []                   // 数据列表数组\n}\n```\n\n### `items`数组中的数据项结构\n```json\n{\n    \"content_id\": null,           // 内容ID（通常为null）\n    \"title\": \"【高清剧集网发布 www.BPHDTV.com】唐朝诡事录之长安[第07-08集][国语音轨+简繁英字幕].2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV\",\n    \"type\": \"movie\",              // 资源类型（通常为movie）\n    \"year\": null,                 // 年份（通常为null）\n    \"torrents\": []                // 磁力链接数组\n}\n```\n\n### `torrents`数组中的种子数据结构\n```json\n{\n    \"info_hash\": \"c3a3a53c2408396d64450046361f00650cb9e53e\",  // 种子哈希值\n    \"magnet\": \"magnet:?xt=urn:btih:C3A3A53C2408396D64450046361F00650CB9E53E&dn=Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv&xl=2458041664\",\n    \"name\": \"Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv\",\n    \"size_bytes\": 2458041664,     // 文件大小（字节）\n    \"size_gb\": 2.29,              // 文件大小（GB）\n    \"seeders\": 4,                 // 做种数\n    \"leechers\": 4,                // 下载数\n    \"published_at\": \"2025-11-18 00:54:20.659664+00\",  // 发布时间（带时区）\n    \"published_ago\": \"约 8 小时前\",  // 发布时间（人类可读）\n    \"file_path\": \"Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv\",\n    \"file_ext\": \"mkv\"             // 文件扩展名\n}\n```\n\n## 插件所需字段映射\n\n| 源字段 | 目标字段 | 说明 |\n|--------|----------|------|\n| `content_id` 或基于 `info_hash` | `UniqueID` | 格式: `feikuai-{info_hash}` 或 `feikuai-{index}` |\n| `title` | `Title` | 资源标题（包含发布组信息） |\n| `title` + `name` + `size_gb` + `seeders` + `leechers` | `Content` | 组合描述信息 |\n| 从 `title` 或 `name` 提取 | `Tags` | 标签数组（如分辨率、格式等） |\n| `torrents` | `Links` | 解析为Link数组，每个种子对应一个Link |\n| `\"\"` | `Channel` | 插件搜索结果Channel为空 |\n| `published_at` | `Datetime` | 磁力链接发布时间 |\n\n## 下载链接解析\n\n### 磁力链接特点\n- **链接类型**: 全部为 `magnet` 类型\n- **无需密码**: 磁力链接不需要提取码\n- **多种子支持**: 一个资源（item）可能包含多个种子（torrents）\n\n### 磁力链接格式\n```\nmagnet:?xt=urn:btih:{INFO_HASH}&dn={URL编码的文件名}&xl={文件大小}\n```\n\n**示例**:\n```\nmagnet:?xt=urn:btih:C3A3A53C2408396D64450046361F00650CB9E53E&dn=Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv&xl=2458041664\n```\n\n### 种子信息提取\n从 `torrents` 数组中，每个种子可提取以下信息：\n- **磁力链接**: `magnet` 字段\n- **文件名**: `name` 或 `file_path` 字段\n- **文件大小**: `size_gb` (GB) 或 `size_bytes` (字节)\n- **做种/下载数**: `seeders` / `leechers`\n- **发布时间**: `published_at` 或 `published_ago`\n\n## work_title 处理规则\n\n根据HTML结构分析中的规则，需要对每个磁力链接的标题进行处理：\n\n### 处理流程\n1. **提取标题**: 从 `name` 或 `file_path` 字段获取文件名\n2. **清洗标题**:\n   - 去除文件扩展名（`.mkv`, `.mp4` 等）\n   - 去除文件大小信息（如果在文件名中）\n3. **关键词检查**: \n   - 检查清洗后的文件名是否包含搜索关键词\n   - 或检查是否包含 `title` 字段中的关键词\n4. **拼接规则**:\n   - **包含关键词**: 直接使用清洗后的文件名\n   - **不包含关键词**: 拼接为 `{搜索关键词}-{清洗后文件名}`\n\n### 示例\n\n**场景1: 英文文件名，不包含中文关键词**\n```\n搜索关键词: \"唐朝诡事录之长安\"\n文件名: \"Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV.mkv\"\n清洗后: \"Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV\"\nwork_title: \"唐朝诡事录之长安-Strange.Tales.of.Tang.Dynasty.S03E07.2025.2160p.IQ.WEB-DL.H265.DDP5.1-BlackTV\"\n```\n\n**场景2: 中文文件名，包含关键词**\n```\n搜索关键词: \"唐朝诡事录之长安\"\n文件名: \"唐朝诡事录之长安.Horror.Stories.of.Tang.Dynasty.S03E05.2022.2160p.WEB-DL.H265.DDP5.1-ColorTV.mkv\"\n清洗后: \"唐朝诡事录之长安.Horror.Stories.of.Tang.Dynasty.S03E05.2022.2160p.WEB-DL.H265.DDP5.1-ColorTV\"\nwork_title: \"唐朝诡事录之长安.Horror.Stories.of.Tang.Dynasty.S03E05.2022.2160p.WEB-DL.H265.DDP5.1-ColorTV\"\n(包含关键词，无需拼接)\n```\n\n## 插件开发指导\n\n### 请求示例\n```go\nsearchURL := fmt.Sprintf(\"https://feikuai.tv/t_search/bm_search.php?kw=%s\", url.QueryEscape(keyword))\n```\n\n### SearchResult构建示例\n```go\n// 遍历items\nfor _, item := range apiResponse.Items {\n    // 遍历每个item的torrents\n    for _, torrent := range item.Torrents {\n        // 清洗文件名\n        cleanedName := cleanFileName(torrent.Name)\n        \n        // 检查是否包含关键词并拼接\n        workTitle := buildWorkTitle(keyword, cleanedName)\n        \n        // 构建SearchResult\n        result := model.SearchResult{\n            UniqueID: fmt.Sprintf(\"feikuai-%s\", torrent.InfoHash),\n            Title:    item.Title,  // 或使用workTitle\n            Content:  buildContent(item, torrent),\n            Links:    []model.Link{\n                {\n                    Type:      \"magnet\",\n                    URL:       torrent.Magnet,\n                    Password:  \"\",  // 磁力链接无密码\n                    Datetime:  parseTime(torrent.PublishedAt),\n                    WorkTitle: workTitle,  // ⭐ 重要：独立标题\n                },\n            },\n            Tags:     extractTags(item.Title, torrent.Name),\n            Channel:  \"\", // 插件搜索结果Channel为空\n            Datetime: parseTime(torrent.PublishedAt),\n        }\n        results = append(results, result)\n    }\n}\n```\n\n### 关键函数示例\n\n#### 1. 清洗文件名\n```go\nfunc cleanFileName(fileName string) string {\n    // 去除文件扩展名\n    ext := filepath.Ext(fileName)\n    if ext != \"\" {\n        fileName = strings.TrimSuffix(fileName, ext)\n    }\n    \n    // 去除文件大小信息（如果存在）\n    fileName = regexp.MustCompile(`\\s*·\\s*[\\d.]+\\s*[KMGT]B\\s*$`).ReplaceAllString(fileName, \"\")\n    \n    return strings.TrimSpace(fileName)\n}\n```\n\n#### 2. 构建work_title\n```go\nfunc buildWorkTitle(keyword, cleanedName string) string {\n    // 检查是否包含关键词（忽略大小写和标点）\n    if containsKeywords(keyword, cleanedName) {\n        return cleanedName\n    }\n    \n    // 不包含关键词，需要拼接\n    return fmt.Sprintf(\"%s-%s\", keyword, cleanedName)\n}\n\nfunc containsKeywords(keyword, text string) bool {\n    // 简单实现：分词后检查\n    keywords := splitKeywords(keyword)\n    for _, kw := range keywords {\n        if strings.Contains(strings.ToLower(text), strings.ToLower(kw)) {\n            return true\n        }\n    }\n    return false\n}\n```\n\n#### 3. 构建内容描述\n```go\nfunc buildContent(item FeikuaiAPIItem, torrent Torrent) string {\n    var contentParts []string\n    \n    contentParts = append(contentParts, fmt.Sprintf(\"文件名: %s\", torrent.Name))\n    contentParts = append(contentParts, fmt.Sprintf(\"大小: %.2f GB\", torrent.SizeGB))\n    contentParts = append(contentParts, fmt.Sprintf(\"做种: %d\", torrent.Seeders))\n    contentParts = append(contentParts, fmt.Sprintf(\"下载: %d\", torrent.Leechers))\n    contentParts = append(contentParts, fmt.Sprintf(\"发布: %s\", torrent.PublishedAgo))\n    \n    return strings.Join(contentParts, \" | \")\n}\n```\n\n#### 4. 提取标签\n```go\nfunc extractTags(title, fileName string) []string {\n    var tags []string\n    \n    // 提取分辨率\n    if strings.Contains(title, \"2160p\") || strings.Contains(fileName, \"2160p\") {\n        tags = append(tags, \"4K\")\n    } else if strings.Contains(title, \"1080p\") || strings.Contains(fileName, \"1080p\") {\n        tags = append(tags, \"1080p\")\n    }\n    \n    // 提取编码格式\n    if strings.Contains(title, \"H265\") || strings.Contains(fileName, \"H265\") {\n        tags = append(tags, \"H265\")\n    }\n    \n    // 提取HDR\n    if strings.Contains(title, \"HDR\") || strings.Contains(fileName, \"HDR\") {\n        tags = append(tags, \"HDR\")\n    }\n    \n    return tags\n}\n```\n\n#### 5. 时间解析\n```go\nfunc parseTime(timeStr string) time.Time {\n    // 解析ISO 8601格式: \"2025-11-18 00:54:20.659664+00\"\n    t, err := time.Parse(\"2006-01-02 15:04:05.999999-07\", timeStr)\n    if err != nil {\n        // 解析失败，返回当前时间\n        return time.Now()\n    }\n    return t\n}\n```\n\n## API字段映射表\n\n| API字段 | Link对象字段 | 提取方法 | 示例 |\n|---------|-------------|---------|------|\n| `magnet` | `URL` | 直接使用 | `magnet:?xt=urn:btih:...` |\n| - | `Type` | 固定值 | `magnet` |\n| - | `Password` | 固定值 | `\"\"` (空字符串) |\n| `published_at` | `Datetime` | 时间解析 | `2025-11-18T00:54:20Z` |\n| `name` | `WorkTitle` | 清洗+关键词检查+拼接 | `唐朝诡事录之长安-Strange.Tales...` |\n\n## 与其他插件的差异\n\n| 特性 | feikuai | wanou/ouge/zhizhen | huban | 说明 |\n|------|---------|-------------------|-------|------|\n| **链接类型** | 仅磁力链接 | 网盘链接 | 网盘链接 | 专注BT资源 |\n| **多链接** | 一对多 | 多对一 | 多对多 | 一个资源多个种子 |\n| **种子信息** | 详细 | 无 | 无 | 包含做种数等 |\n| **work_title** | 必需拼接 | 可选 | 可选 | 文件名通常不含中文 |\n| **时间信息** | 精确 | 当前时间 | 当前时间 | API提供发布时间 |\n\n## 注意事项\n\n1. **磁力链接专用**: 此API仅返回磁力链接，不包含网盘链接\n2. **多种子处理**: 一个资源可能有多个种子，需要全部提取\n3. **文件名处理**: 文件名通常是英文，需要拼接中文关键词\n4. **时区处理**: `published_at` 包含时区信息（+00），需要正确解析\n5. **做种数排序**: 建议按做种数（seeders）降序排序，优先显示热门资源\n6. **空值处理**: `content_id` 和 `year` 通常为 null，需要处理\n7. **标题清洗**: `title` 字段包含发布组信息（如【高清剧集网发布 www.BPHDTV.com】），可选择性去除\n\n## 开发建议\n\n1. **独立实现**: 不能复用网盘类插件的代码，需要专门处理磁力链接\n2. **work_title关键**: 文件名拼接中文关键词是核心功能\n3. **种子排序**: 实现按做种数排序，提升用户体验\n4. **时间解析**: 正确解析带时区的ISO 8601时间格式\n5. **内容丰富**: 充分利用API提供的文件大小、做种数等信息\n6. **错误处理**: API可能返回 `code != 0` 的错误状态\n7. **测试覆盖**: 重点测试中英文文件名的work_title拼接逻辑\n"
  },
  {
    "path": "plugin/fox4k/fox4k.go",
    "content": "package fox4k\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"golang.org/x/net/proxy\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\n// 常量定义\nconst (\n\t// 基础URL\n\tBaseURL = \"https://4kfox.com\"\n\t// BaseURL = \"https://btnull.pro/\"\n\t// BaseURL = \"https://www.4kdy.vip/\"\n\t\n\t// 搜索URL格式\n\tSearchURL = BaseURL + \"/search/%s-------------.html\"\n\t\n\t// 分页搜索URL格式\n\tSearchPageURL = BaseURL + \"/search/%s----------%d---.html\"\n\t\n\t// 详情页URL格式\n\tDetailURL = BaseURL + \"/video/%s.html\"\n\t\n\t// 默认超时时间 - 增加超时时间避免网络慢的问题\n\tDefaultTimeout = 15 * time.Second\n\t\n\t// 代理配置\n\tDefaultHTTPProxy  = \"http://154.219.110.34:51422\"\n\tDefaultSocks5Proxy = \"socks5://154.219.110.34:51423\"\n\t\n\t// 调试开关 - 默认关闭\n\tDebugMode = false\n\t\n\t// 代理开关 - 默认关闭\n\tProxyEnabled = false\n\t\n\t// 并发数限制 - 大幅提高并发数\n\tMaxConcurrency = 50\n\t\n\t// 最大分页数（避免无限请求）\n\tMaxPages = 10\n\t\n\t// HTTP连接池配置\n\tMaxIdleConns        = 200\n\tMaxIdleConnsPerHost = 50\n\tMaxConnsPerHost     = 100\n\tIdleConnTimeout     = 90 * time.Second\n)\n\n// 预编译正则表达式\nvar (\n\t// 从详情页URL中提取ID的正则表达式\n\tdetailIDRegex = regexp.MustCompile(`/video/(\\d+)\\.html`)\n\t\n\t// 磁力链接的正则表达式\n\tmagnetLinkRegex = regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9a-fA-F]{40}[^\"'\\s]*`)\n\t\n\t// 电驴链接的正则表达式\n\ted2kLinkRegex = regexp.MustCompile(`ed2k://\\|file\\|[^|]+\\|[^|]+\\|[^|]+\\|/?`)\n\t\n\t// 年份提取正则表达式\n\tyearRegex = regexp.MustCompile(`(\\d{4})`)\n\t\n\t// 网盘链接正则表达式（排除夸克）\n\tpanLinkRegexes = map[string]*regexp.Regexp{\n\t\t\"baidu\":   regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9a-zA-Z_-]+(?:\\?pwd=[0-9a-zA-Z]+)?(?:&v=\\d+)?`),\n\t\t\"aliyun\":  regexp.MustCompile(`https?://(?:www\\.)?alipan\\.com/s/[0-9a-zA-Z_-]+`),\n\t\t\"tianyi\":  regexp.MustCompile(`https?://cloud\\.189\\.cn/t/[0-9a-zA-Z_-]+(?:\\([^)]*\\))?`),\n\t\t\"uc\":      regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9a-fA-F]+(?:\\?[^\"\\s]*)?`),\n\t\t\"mobile\":  regexp.MustCompile(`https?://caiyun\\.139\\.com/[^\"\\s]+`),\n\t\t\"115\":     regexp.MustCompile(`https?://115\\.com/s/[0-9a-zA-Z_-]+`),\n\t\t\"pikpak\":  regexp.MustCompile(`https?://mypikpak\\.com/s/[0-9a-zA-Z_-]+`),\n\t\t\"xunlei\":  regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9a-zA-Z_-]+(?:\\?pwd=[0-9a-zA-Z]+)?`),\n\t\t\"123\":     regexp.MustCompile(`https?://(?:www\\.)?123pan\\.com/s/[0-9a-zA-Z_-]+`),\n\t}\n\t\n\t// 夸克网盘链接正则表达式（用于排除）\n\tquarkLinkRegex = regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9a-fA-F]+(?:\\?pwd=[0-9a-zA-Z]+)?`)\n\t\n\t// 密码提取正则表达式\n\tpasswordRegexes = []*regexp.Regexp{\n\t\tregexp.MustCompile(`\\?pwd=([0-9a-zA-Z]+)`),                           // URL中的pwd参数\n\t\tregexp.MustCompile(`提取码[：:]\\s*([0-9a-zA-Z]+)`),                    // 提取码：xxxx\n\t\tregexp.MustCompile(`访问码[：:]\\s*([0-9a-zA-Z]+)`),                    // 访问码：xxxx\n\t\tregexp.MustCompile(`密码[：:]\\s*([0-9a-zA-Z]+)`),                     // 密码：xxxx\n\t\tregexp.MustCompile(`（访问码[：:]\\s*([0-9a-zA-Z]+)）`),                  // （访问码：xxxx）\n\t}\n\t\n\t// 缓存相关\n\tdetailCache     = sync.Map{} // 缓存详情页解析结果\n\tlastCleanupTime = time.Now()\n\tcacheTTL        = 1 * time.Hour // 缩短缓存时间\n\t\n\t// 性能统计（原子操作）\n\tsearchRequests     int64 = 0\n\tdetailPageRequests int64 = 0\n\tcacheHits          int64 = 0\n\tcacheMisses        int64 = 0\n\ttotalSearchTime    int64 = 0 // 纳秒\n\ttotalDetailTime    int64 = 0 // 纳秒\n)\n\n// 缓存的详情页响应\ntype detailPageResponse struct {\n\tTitle     string\n\tImageURL  string\n\tDownloads []model.Link\n\tTags      []string\n\tContent   string\n\tTimestamp time.Time\n}\n\n// Fox4kPlugin 极狐4K搜索插件\ntype Fox4kPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createProxyTransport 创建支持代理的传输层\nfunc createProxyTransport(proxyURL string) (*http.Transport, error) {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t\tDisableCompression:  false,\n\t\tWriteBufferSize:     16 * 1024,\n\t\tReadBufferSize:      16 * 1024,\n\t}\n\n\tif proxyURL == \"\" {\n\t\treturn transport, nil\n\t}\n\n\tif strings.HasPrefix(proxyURL, \"socks5://\") {\n\t\t// SOCKS5代理\n\t\tdialer, err := proxy.SOCKS5(\"tcp\", strings.TrimPrefix(proxyURL, \"socks5://\"), nil, proxy.Direct)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"创建SOCKS5代理失败: %w\", err)\n\t\t}\n\t\ttransport.Dial = dialer.Dial\n\t\tdebugPrintf(\"🔧 [Fox4k DEBUG] 使用SOCKS5代理: %s\\n\", proxyURL)\n\t} else {\n\t\t// HTTP代理\n\t\tparsedURL, err := url.Parse(proxyURL)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"解析代理URL失败: %w\", err)\n\t\t}\n\t\ttransport.Proxy = http.ProxyURL(parsedURL)\n\t\tdebugPrintf(\"🔧 [Fox4k DEBUG] 使用HTTP代理: %s\\n\", proxyURL)\n\t}\n\n\treturn transport, nil\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端（支持代理）\nfunc createOptimizedHTTPClient() *http.Client {\n\tvar selectedProxy string\n\t\n\tif ProxyEnabled {\n\t\t// 随机选择代理类型\n\t\tproxyTypes := []string{\"\", DefaultHTTPProxy, DefaultSocks5Proxy}\n\t\tselectedProxy = proxyTypes[rand.Intn(len(proxyTypes))]\n\t} else {\n\t\t// 代理未启用，使用直连\n\t\tselectedProxy = \"\"\n\t\tdebugPrintf(\"🔧 [Fox4k DEBUG] 代理功能已禁用，使用直连模式\\n\")\n\t}\n\t\n\ttransport, err := createProxyTransport(selectedProxy)\n\tif err != nil {\n\t\tdebugPrintf(\"❌ [Fox4k DEBUG] 创建代理传输层失败: %v，使用直连\\n\", err)\n\t\ttransport, _ = createProxyTransport(\"\")\n\t}\n\t\n\tif selectedProxy == \"\" && ProxyEnabled {\n\t\tdebugPrintf(\"🔧 [Fox4k DEBUG] 使用直连模式\\n\")\n\t}\n\t\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   DefaultTimeout,\n\t}\n}\n\n// NewFox4kPlugin 创建新的极狐4K搜索异步插件\nfunc NewFox4kPlugin() *Fox4kPlugin {\n\treturn &Fox4kPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"fox4k\", 3), \n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// debugPrintf 调试输出函数\nfunc debugPrintf(format string, args ...interface{}) {\n\tif DebugMode {\n\t\tfmt.Printf(format, args...)\n\t}\n}\n\n// 初始化插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewFox4kPlugin())\n\t\n\t// 启动缓存清理\n\tgo startCacheCleaner()\n}\n\n// startCacheCleaner 定期清理缓存\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(30 * time.Minute)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\t// 清空详情页缓存\n\t\tdetailCache = sync.Map{}\n\t\tlastCleanupTime = time.Now()\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *Fox4kPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *Fox4kPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\tdebugPrintf(\"🔧 [Fox4k DEBUG] SearchWithResult 开始 - keyword: %s, MainCacheKey: '%s'\\n\", keyword, p.MainCacheKey)\n\t\n\tresult, err := p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n\t\n\tdebugPrintf(\"🔧 [Fox4k DEBUG] SearchWithResult 完成 - 结果数: %d, IsFinal: %v, 错误: %v\\n\", \n\t\tlen(result.Results), result.IsFinal, err)\n\t\n\tif len(result.Results) > 0 {\n\t\tdebugPrintf(\"🔧 [Fox4k DEBUG] 前3个结果示例:\\n\")\n\t\tfor i, r := range result.Results {\n\t\t\tif i >= 3 { break }\n\t\t\tdebugPrintf(\"  %d. 标题: %s, 链接数: %d\\n\", i+1, r.Title, len(r.Links))\n\t\t}\n\t}\n\t\n\treturn result, err\n}\n\n// searchImpl 实现具体的搜索逻辑（支持分页）\nfunc (p *Fox4kPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tdebugPrintf(\"🔧 [Fox4k DEBUG] searchImpl 开始执行 - keyword: %s\\n\", keyword)\n\tstartTime := time.Now()\n\tatomic.AddInt64(&searchRequests, 1)\n\t\n\t// 使用优化的客户端\n\tif p.optimizedClient != nil {\n\t\tclient = p.optimizedClient\n\t}\n\t\n\tencodedKeyword := url.QueryEscape(keyword)\n\tallResults := make([]model.SearchResult, 0)\n\t\n\t// 1. 搜索第一页，获取总页数\n\tfirstPageResults, totalPages, err := p.searchPage(client, encodedKeyword, 1)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tallResults = append(allResults, firstPageResults...)\n\t\n\t// 2. 如果有多页，继续搜索其他页面（限制最大页数）\n\tmaxPagesToSearch := totalPages\n\tif maxPagesToSearch > MaxPages {\n\t\tmaxPagesToSearch = MaxPages\n\t}\n\t\n\tif totalPages > 1 && maxPagesToSearch > 1 {\n\t\t// 并发搜索其他页面\n\t\tvar wg sync.WaitGroup\n\t\tvar mu sync.Mutex\n\t\tresults := make([][]model.SearchResult, maxPagesToSearch-1)\n\t\t\n\t\tfor page := 2; page <= maxPagesToSearch; page++ {\n\t\t\twg.Add(1)\n\t\t\tgo func(pageNum int) {\n\t\t\t\tdefer wg.Done()\n\t\t\t\tpageResults, _, err := p.searchPage(client, encodedKeyword, pageNum)\n\t\t\t\tif err == nil {\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\tresults[pageNum-2] = pageResults\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t}\n\t\t\t}(page)\n\t\t}\n\t\t\n\t\twg.Wait()\n\t\t\n\t\t// 合并所有页面的结果\n\t\tfor _, pageResults := range results {\n\t\t\tallResults = append(allResults, pageResults...)\n\t\t}\n\t}\n\t\n\t// 3. 并发获取详情页信息\n\tallResults = p.enrichWithDetailInfo(allResults, client)\n\t\n\t// 4. 过滤关键词匹配的结果\n\tresults := plugin.FilterResultsByKeyword(allResults, keyword)\n\t\n\t// 记录性能统计\n\tsearchDuration := time.Since(startTime)\n\tatomic.AddInt64(&totalSearchTime, int64(searchDuration))\n\t\n\tdebugPrintf(\"🔧 [Fox4k DEBUG] searchImpl 完成 - 原始结果: %d, 过滤后结果: %d, 耗时: %v\\n\", \n\t\tlen(allResults), len(results), searchDuration)\n\t\n\treturn results, nil\n}\n\n\n\n// searchPage 搜索指定页面\nfunc (p *Fox4kPlugin) searchPage(client *http.Client, encodedKeyword string, page int) ([]model.SearchResult, int, error) {\n\tdebugPrintf(\"🔧 [Fox4k DEBUG] searchPage 开始 - 第%d页, keyword: %s\\n\", page, encodedKeyword)\n\t\n\t// 1. 构建搜索URL\n\tvar searchURL string\n\tif page == 1 {\n\t\tsearchURL = fmt.Sprintf(SearchURL, encodedKeyword)\n\t} else {\n\t\tsearchURL = fmt.Sprintf(SearchPageURL, encodedKeyword, page)\n\t}\n\t\n\tdebugPrintf(\"🔧 [Fox4k DEBUG] 构建的URL: %s\\n\", searchURL)\n\t\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\t\n\t// 3. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, 0, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 4. 设置完整的请求头（包含随机UA和IP）\n\trandomUA := getRandomUA()\n\trandomIP := generateRandomIP()\n\t\n\treq.Header.Set(\"User-Agent\", randomUA)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\treq.Header.Set(\"X-Forwarded-For\", randomIP)\n\treq.Header.Set(\"X-Real-IP\", randomIP)\n\treq.Header.Set(\"sec-ch-ua-platform\", \"macOS\")\n\t\n\tdebugPrintf(\"🔧 [Fox4k DEBUG] 使用随机UA: %s\\n\", randomUA)\n\tdebugPrintf(\"🔧 [Fox4k DEBUG] 使用随机IP: %s\\n\", randomIP)\n\t\n\t// 5. 发送HTTP请求\n\tdebugPrintf(\"🔧 [Fox4k DEBUG] 开始发送HTTP请求到: %s\\n\", searchURL)\n\tdebugPrintf(\"🔧 [Fox4k DEBUG] 请求头信息:\\n\")\n\tif DebugMode {\n\t\tfor key, values := range req.Header {\n\t\t\tfor _, value := range values {\n\t\t\t\tdebugPrintf(\"    %s: %s\\n\", key, value)\n\t\t\t}\n\t\t}\n\t}\n\t\n\tstartTime := time.Now()\n\tresp, err := p.doRequestWithRetry(req, client)\n\trequestDuration := time.Since(startTime)\n\t\n\tif err != nil {\n\t\tdebugPrintf(\"❌ [Fox4k DEBUG] HTTP请求失败 (耗时: %v): %v\\n\", requestDuration, err)\n\t\tdebugPrintf(\"❌ [Fox4k DEBUG] 错误类型分析:\\n\")\n\t\tif netErr, ok := err.(*url.Error); ok {\n\t\t\tfmt.Printf(\"    URL错误: %v\\n\", netErr.Err)\n\t\t\tif netErr.Timeout() {\n\t\t\t\tfmt.Printf(\"    -> 这是超时错误\\n\")\n\t\t\t}\n\t\t\tif netErr.Temporary() {\n\t\t\t\tfmt.Printf(\"    -> 这是临时错误\\n\")\n\t\t\t}\n\t\t}\n\t\treturn nil, 0, fmt.Errorf(\"[%s] 第%d页搜索请求失败: %w\", p.Name(), page, err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tdebugPrintf(\"✅ [Fox4k DEBUG] HTTP请求成功 (耗时: %v)\\n\", requestDuration)\n\t\n\t// 6. 检查状态码\n\tdebugPrintf(\"🔧 [Fox4k DEBUG] HTTP响应状态码: %d\\n\", resp.StatusCode)\n\tif resp.StatusCode != 200 {\n\t\tdebugPrintf(\"❌ [Fox4k DEBUG] 状态码异常: %d\\n\", resp.StatusCode)\n\t\treturn nil, 0, fmt.Errorf(\"[%s] 第%d页请求返回状态码: %d\", p.Name(), page, resp.StatusCode)\n\t}\n\t\n\t// 7. 读取并打印HTML响应\n\thtmlBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, 0, fmt.Errorf(\"[%s] 第%d页读取响应失败: %w\", p.Name(), page, err)\n\t}\n\t\n\thtmlContent := string(htmlBytes)\n\tdebugPrintf(\"🔧 [Fox4k DEBUG] 第%d页 HTML长度: %d bytes\\n\", page, len(htmlContent))\n\t\n\t// 保存HTML到文件（仅在调试模式下）\n\tif DebugMode {\n\t\thtmlDir := \"./html\"\n\t\tos.MkdirAll(htmlDir, 0755)\n\t\t\n\t\tfilename := fmt.Sprintf(\"fox4k_page_%d_%s.html\", page, strings.ReplaceAll(encodedKeyword, \"%\", \"_\"))\n\t\tfilepath := filepath.Join(htmlDir, filename)\n\t\t\n\t\terr = os.WriteFile(filepath, htmlBytes, 0644)\n\t\tif err != nil {\n\t\t\tdebugPrintf(\"❌ [Fox4k DEBUG] 保存HTML文件失败: %v\\n\", err)\n\t\t} else {\n\t\t\tdebugPrintf(\"✅ [Fox4k DEBUG] HTML已保存到: %s\\n\", filepath)\n\t\t}\n\t}\n\t\n\t// 解析HTML响应\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))\n\tif err != nil {\n\t\treturn nil, 0, fmt.Errorf(\"[%s] 第%d页HTML解析失败: %w\", p.Name(), page, err)\n\t}\n\t\n\t// 8. 解析分页信息\n\ttotalPages := p.parseTotalPages(doc)\n\t\n\t// 9. 提取搜索结果\n\tresults := make([]model.SearchResult, 0)\n\tdoc.Find(\".hl-list-item\").Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchResultItem(s)\n\t\tif result != nil {\n\t\t\tresults = append(results, *result)\n\t\t}\n\t})\n\t\n\treturn results, totalPages, nil\n}\n\n// parseTotalPages 解析总页数\nfunc (p *Fox4kPlugin) parseTotalPages(doc *goquery.Document) int {\n\t// 查找分页信息，格式为 \"1 / 2\"\n\tpageInfo := doc.Find(\".hl-page-tips a\").Text()\n\tif pageInfo == \"\" {\n\t\treturn 1\n\t}\n\t\n\t// 解析 \"1 / 2\" 格式\n\tparts := strings.Split(pageInfo, \"/\")\n\tif len(parts) != 2 {\n\t\treturn 1\n\t}\n\t\n\ttotalPagesStr := strings.TrimSpace(parts[1])\n\ttotalPages, err := strconv.Atoi(totalPagesStr)\n\tif err != nil || totalPages < 1 {\n\t\treturn 1\n\t}\n\t\n\treturn totalPages\n}\n\n// parseSearchResultItem 解析单个搜索结果项\nfunc (p *Fox4kPlugin) parseSearchResultItem(s *goquery.Selection) *model.SearchResult {\n\t// 获取详情页链接\n\tlinkElement := s.Find(\".hl-item-pic a\").First()\n\thref, exists := linkElement.Attr(\"href\")\n\tif !exists || href == \"\" {\n\t\treturn nil\n\t}\n\t\n\t// 补全URL\n\tif strings.HasPrefix(href, \"/\") {\n\t\thref = BaseURL + href\n\t}\n\t\n\t// 提取ID\n\tmatches := detailIDRegex.FindStringSubmatch(href)\n\tif len(matches) < 2 {\n\t\treturn nil\n\t}\n\tid := matches[1]\n\t\n\t// 获取标题\n\ttitleElement := s.Find(\".hl-item-title a\").First()\n\ttitle := strings.TrimSpace(titleElement.Text())\n\tif title == \"\" {\n\t\treturn nil\n\t}\n\t\n\t// 获取封面图片\n\timgElement := s.Find(\".hl-item-thumb\")\n\timageURL, _ := imgElement.Attr(\"data-original\")\n\tif imageURL != \"\" && strings.HasPrefix(imageURL, \"/\") {\n\t\timageURL = BaseURL + imageURL\n\t}\n\t\n\t// 获取资源状态\n\tstatus := strings.TrimSpace(s.Find(\".hl-pic-text .remarks\").Text())\n\t\n\t// 获取评分\n\tscore := strings.TrimSpace(s.Find(\".hl-text-conch.score\").Text())\n\t\n\t// 获取基本信息（年份、地区、类型）\n\tbasicInfo := strings.TrimSpace(s.Find(\".hl-item-sub\").First().Text())\n\t\n\t// 获取简介\n\tdescription := strings.TrimSpace(s.Find(\".hl-item-sub\").Last().Text())\n\t\n\t// 解析年份、地区、类型\n\tvar year, region, category string\n\tif basicInfo != \"\" {\n\t\tparts := strings.Split(basicInfo, \"·\")\n\t\tfor i, part := range parts {\n\t\t\tpart = strings.TrimSpace(part)\n\t\t\tif part == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t\n\t\t\t// 跳过评分\n\t\t\tif strings.Contains(part, score) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t\n\t\t\t// 第一个通常是年份\n\t\t\tif i == 0 || (i == 1 && strings.Contains(parts[0], score)) {\n\t\t\t\tif yearRegex.MatchString(part) {\n\t\t\t\t\tyear = part\n\t\t\t\t}\n\t\t\t} else if region == \"\" {\n\t\t\t\tregion = part\n\t\t\t} else if category == \"\" {\n\t\t\t\tcategory = part\n\t\t\t} else {\n\t\t\t\tcategory += \" \" + part\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 构建标签\n\ttags := make([]string, 0)\n\tif status != \"\" {\n\t\ttags = append(tags, status)\n\t}\n\tif year != \"\" {\n\t\ttags = append(tags, year)\n\t}\n\tif region != \"\" {\n\t\ttags = append(tags, region)\n\t}\n\tif category != \"\" {\n\t\ttags = append(tags, category)\n\t}\n\t\n\t// 构建内容描述\n\tcontent := description\n\tif basicInfo != \"\" {\n\t\tcontent = basicInfo + \"\\n\" + description\n\t}\n\tif score != \"\" {\n\t\tcontent = \"评分: \" + score + \"\\n\" + content\n\t}\n\t\n\treturn &model.SearchResult{\n\t\tUniqueID: fmt.Sprintf(\"%s-%s\", p.Name(), id),\n\t\tTitle:    title,\n\t\tContent:  content,\n\t\tDatetime: time.Time{}, // 使用零值而不是nil，参考jikepan插件标准\n\t\tTags:     tags,\n\t\tLinks:    []model.Link{}, // 初始为空，后续在详情页中填充\n\t\tChannel:  \"\",             // 插件搜索结果，Channel必须为空\n\t}\n}\n\n// enrichWithDetailInfo 并发获取详情页信息并丰富搜索结果\nfunc (p *Fox4kPlugin) enrichWithDetailInfo(results []model.SearchResult, client *http.Client) []model.SearchResult {\n\tif len(results) == 0 {\n\t\treturn results\n\t}\n\t\n\t// 使用信号量控制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\tvar wg sync.WaitGroup\n\tvar mutex sync.Mutex\n\t\n\tenrichedResults := make([]model.SearchResult, len(results))\n\tcopy(enrichedResults, results)\n\t\n\tfor i := range enrichedResults {\n\t\twg.Add(1)\n\t\tgo func(index int) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 从UniqueID中提取ID\n\t\t\tparts := strings.Split(enrichedResults[index].UniqueID, \"-\")\n\t\t\tif len(parts) < 2 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tid := parts[len(parts)-1]\n\t\t\t\n\t\t\t// 获取详情页信息\n\t\t\tdetailInfo := p.getDetailInfo(id, client)\n\t\t\tif detailInfo != nil {\n\t\t\t\tmutex.Lock()\n\t\t\t\tenrichedResults[index].Links = detailInfo.Downloads\n\t\t\t\tif detailInfo.Content != \"\" {\n\t\t\t\t\tenrichedResults[index].Content = detailInfo.Content\n\t\t\t\t}\n\t\t\t\t// 补充标签\n\t\t\t\tfor _, tag := range detailInfo.Tags {\n\t\t\t\t\tfound := false\n\t\t\t\t\tfor _, existingTag := range enrichedResults[index].Tags {\n\t\t\t\t\t\tif existingTag == tag {\n\t\t\t\t\t\t\tfound = true\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif !found {\n\t\t\t\t\t\tenrichedResults[index].Tags = append(enrichedResults[index].Tags, tag)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tmutex.Unlock()\n\t\t\t}\n\t\t}(i)\n\t}\n\t\n\twg.Wait()\n\t\n\t// 过滤掉没有有效下载链接的结果\n\tvar validResults []model.SearchResult\n\tfor _, result := range enrichedResults {\n\t\tif len(result.Links) > 0 {\n\t\t\tvalidResults = append(validResults, result)\n\t\t}\n\t}\n\t\n\treturn validResults\n}\n\n// getDetailInfo 获取详情页信息\nfunc (p *Fox4kPlugin) getDetailInfo(id string, client *http.Client) *detailPageResponse {\n\tstartTime := time.Now()\n\tatomic.AddInt64(&detailPageRequests, 1)\n\t\n\t// 检查缓存\n\tif cached, ok := detailCache.Load(id); ok {\n\t\tif detail, ok := cached.(*detailPageResponse); ok {\n\t\t\tif time.Since(detail.Timestamp) < cacheTTL {\n\t\t\t\tatomic.AddInt64(&cacheHits, 1)\n\t\t\t\treturn detail\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 缓存未命中\n\tatomic.AddInt64(&cacheMisses, 1)\n\t\n\t// 构建详情页URL\n\tdetailURL := fmt.Sprintf(DetailURL, id)\n\t\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\t\n\t// 发送请求\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil\n\t}\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\t// 解析详情页信息\n\tdetail := &detailPageResponse{\n\t\tDownloads: make([]model.Link, 0),\n\t\tTags:      make([]string, 0),\n\t\tTimestamp: time.Now(),\n\t}\n\t\n\t// 获取标题\n\tdetail.Title = strings.TrimSpace(doc.Find(\"h2.hl-dc-title\").Text())\n\t\n\t// 获取封面图片\n\timgElement := doc.Find(\".hl-dc-pic .hl-item-thumb\")\n\tif imageURL, exists := imgElement.Attr(\"data-original\"); exists && imageURL != \"\" {\n\t\tif strings.HasPrefix(imageURL, \"/\") {\n\t\t\timageURL = BaseURL + imageURL\n\t\t}\n\t\tdetail.ImageURL = imageURL\n\t}\n\t\n\t// 获取剧情简介\n\tdetail.Content = strings.TrimSpace(doc.Find(\".hl-content-wrap .hl-content-text\").Text())\n\t\n\t// 提取详细信息作为标签\n\tdoc.Find(\".hl-vod-data ul li\").Each(func(i int, s *goquery.Selection) {\n\t\ttext := strings.TrimSpace(s.Text())\n\t\tif text != \"\" {\n\t\t\t// 清理标签文本\n\t\t\ttext = strings.ReplaceAll(text, \"：\", \": \")\n\t\t\tif strings.Contains(text, \"类型:\") || strings.Contains(text, \"地区:\") || strings.Contains(text, \"语言:\") {\n\t\t\t\tdetail.Tags = append(detail.Tags, text)\n\t\t\t}\n\t\t}\n\t})\n\t\n\t// 提取下载链接\n\tp.extractDownloadLinks(doc, detail)\n\t\n\t// 缓存结果\n\tdetailCache.Store(id, detail)\n\t\n\t// 记录性能统计\n\tdetailDuration := time.Since(startTime)\n\tatomic.AddInt64(&totalDetailTime, int64(detailDuration))\n\t\n\treturn detail\n}\n\n// GetPerformanceStats 获取性能统计信息（调试用）\nfunc (p *Fox4kPlugin) GetPerformanceStats() map[string]interface{} {\n\ttotalSearches := atomic.LoadInt64(&searchRequests)\n\ttotalDetails := atomic.LoadInt64(&detailPageRequests)\n\thits := atomic.LoadInt64(&cacheHits)\n\tmisses := atomic.LoadInt64(&cacheMisses)\n\tsearchTime := atomic.LoadInt64(&totalSearchTime)\n\tdetailTime := atomic.LoadInt64(&totalDetailTime)\n\t\n\tstats := map[string]interface{}{\n\t\t\"search_requests\":      totalSearches,\n\t\t\"detail_page_requests\": totalDetails,\n\t\t\"cache_hits\":           hits,\n\t\t\"cache_misses\":         misses,\n\t\t\"cache_hit_rate\":       float64(hits) / float64(hits+misses) * 100,\n\t}\n\t\n\tif totalSearches > 0 {\n\t\tstats[\"avg_search_time_ms\"] = float64(searchTime) / float64(totalSearches) / 1000000\n\t}\n\tif totalDetails > 0 {\n\t\tstats[\"avg_detail_time_ms\"] = float64(detailTime) / float64(totalDetails) / 1000000\n\t}\n\t\n\treturn stats\n}\n\n// extractDownloadLinks 提取下载链接（包括磁力链接、电驴链接和网盘链接）\nfunc (p *Fox4kPlugin) extractDownloadLinks(doc *goquery.Document, detail *detailPageResponse) {\n\t// 提取页面中所有文本内容，寻找链接\n\tpageText := doc.Text()\n\t\n\t// 1. 提取磁力链接\n\tmagnetMatches := magnetLinkRegex.FindAllString(pageText, -1)\n\tfor _, magnetLink := range magnetMatches {\n\t\tp.addDownloadLink(detail, \"magnet\", magnetLink, \"\")\n\t}\n\t\n\t// 2. 提取电驴链接\n\ted2kMatches := ed2kLinkRegex.FindAllString(pageText, -1)\n\tfor _, ed2kLink := range ed2kMatches {\n\t\tp.addDownloadLink(detail, \"ed2k\", ed2kLink, \"\")\n\t}\n\t\n\t// 3. 提取网盘链接（排除夸克）\n\tfor panType, regex := range panLinkRegexes {\n\t\tmatches := regex.FindAllString(pageText, -1)\n\t\tfor _, panLink := range matches {\n\t\t\t// 提取密码（如果有）\n\t\t\tpassword := p.extractPasswordFromText(pageText, panLink)\n\t\t\tp.addDownloadLink(detail, panType, panLink, password)\n\t\t}\n\t}\n\t\n\t// 4. 在特定的下载区域查找链接\n\tdoc.Find(\".hl-rb-downlist\").Each(func(i int, downlistSection *goquery.Selection) {\n\t\t// 获取质量版本信息\n\t\tvar currentQuality string\n\t\tdownlistSection.Find(\".hl-tabs-btn\").Each(func(j int, tabBtn *goquery.Selection) {\n\t\t\tif tabBtn.HasClass(\"active\") {\n\t\t\t\tcurrentQuality = strings.TrimSpace(tabBtn.Text())\n\t\t\t}\n\t\t})\n\t\t\n\t\t// 提取各种下载链接\n\t\tdownlistSection.Find(\".hl-downs-list li\").Each(func(k int, linkItem *goquery.Selection) {\n\t\t\titemText := linkItem.Text()\n\t\t\titemHTML, _ := linkItem.Html()\n\t\t\t\n\t\t\t// 从 data-clipboard-text 属性提取链接\n\t\t\tif clipboardText, exists := linkItem.Find(\".down-copy\").Attr(\"data-clipboard-text\"); exists {\n\t\t\t\tp.processFoundLink(detail, clipboardText, currentQuality)\n\t\t\t}\n\t\t\t\n\t\t\t// 从 href 属性提取链接\n\t\t\tlinkItem.Find(\"a\").Each(func(l int, link *goquery.Selection) {\n\t\t\t\tif href, exists := link.Attr(\"href\"); exists {\n\t\t\t\t\tp.processFoundLink(detail, href, currentQuality)\n\t\t\t\t}\n\t\t\t})\n\t\t\t\n\t\t\t// 从文本内容中提取链接\n\t\t\tp.extractLinksFromText(detail, itemText, currentQuality)\n\t\t\tp.extractLinksFromText(detail, itemHTML, currentQuality)\n\t\t})\n\t})\n\t\n\t// 5. 在播放源区域也查找链接\n\tdoc.Find(\".hl-rb-playlist\").Each(func(i int, playlistSection *goquery.Selection) {\n\t\tsectionText := playlistSection.Text()\n\t\tsectionHTML, _ := playlistSection.Html()\n\t\tp.extractLinksFromText(detail, sectionText, \"播放源\")\n\t\tp.extractLinksFromText(detail, sectionHTML, \"播放源\")\n\t})\n}\n\n// processFoundLink 处理找到的链接\nfunc (p *Fox4kPlugin) processFoundLink(detail *detailPageResponse, link, quality string) {\n\tif link == \"\" {\n\t\treturn\n\t}\n\t\n\t// 排除夸克网盘链接\n\tif quarkLinkRegex.MatchString(link) {\n\t\treturn\n\t}\n\t\n\t// 检查磁力链接\n\tif magnetLinkRegex.MatchString(link) {\n\t\tp.addDownloadLink(detail, \"magnet\", link, \"\")\n\t\treturn\n\t}\n\t\n\t// 检查电驴链接\n\tif ed2kLinkRegex.MatchString(link) {\n\t\tp.addDownloadLink(detail, \"ed2k\", link, \"\")\n\t\treturn\n\t}\n\t\n\t// 检查网盘链接\n\tfor panType, regex := range panLinkRegexes {\n\t\tif regex.MatchString(link) {\n\t\t\tpassword := p.extractPasswordFromLink(link)\n\t\t\tp.addDownloadLink(detail, panType, link, password)\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// extractLinksFromText 从文本中提取各种类型的链接\nfunc (p *Fox4kPlugin) extractLinksFromText(detail *detailPageResponse, text, quality string) {\n\t// 排除包含夸克链接的文本\n\tif quarkLinkRegex.MatchString(text) {\n\t\t// 如果文本中有夸克链接，我们跳过整个文本块\n\t\t// 这是因为通常一个区域要么是夸克专区，要么不是\n\t\treturn\n\t}\n\t\n\t// 磁力链接\n\tmagnetMatches := magnetLinkRegex.FindAllString(text, -1)\n\tfor _, magnetLink := range magnetMatches {\n\t\tp.addDownloadLink(detail, \"magnet\", magnetLink, \"\")\n\t}\n\t\n\t// 电驴链接\n\ted2kMatches := ed2kLinkRegex.FindAllString(text, -1)\n\tfor _, ed2kLink := range ed2kMatches {\n\t\tp.addDownloadLink(detail, \"ed2k\", ed2kLink, \"\")\n\t}\n\t\n\t// 网盘链接\n\tfor panType, regex := range panLinkRegexes {\n\t\tmatches := regex.FindAllString(text, -1)\n\t\tfor _, panLink := range matches {\n\t\t\tpassword := p.extractPasswordFromText(text, panLink)\n\t\t\tp.addDownloadLink(detail, panType, panLink, password)\n\t\t}\n\t}\n}\n\n// extractPasswordFromLink 从链接URL中提取密码\nfunc (p *Fox4kPlugin) extractPasswordFromLink(link string) string {\n\t// 首先检查URL参数中的密码\n\tfor _, regex := range passwordRegexes {\n\t\tif matches := regex.FindStringSubmatch(link); len(matches) > 1 {\n\t\t\treturn matches[1]\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// extractPasswordFromText 从文本中提取指定链接的密码\nfunc (p *Fox4kPlugin) extractPasswordFromText(text, link string) string {\n\t// 首先从链接本身提取密码\n\tif password := p.extractPasswordFromLink(link); password != \"\" {\n\t\treturn password\n\t}\n\t\n\t// 然后从周围文本中查找密码\n\tfor _, regex := range passwordRegexes {\n\t\tif matches := regex.FindStringSubmatch(text); len(matches) > 1 {\n\t\t\treturn matches[1]\n\t\t}\n\t}\n\t\n\treturn \"\"\n}\n\n// addDownloadLink 添加下载链接\nfunc (p *Fox4kPlugin) addDownloadLink(detail *detailPageResponse, linkType, linkURL, password string) {\n\tif linkURL == \"\" {\n\t\treturn\n\t}\n\t\n\t// 跳过夸克网盘链接\n\tif quarkLinkRegex.MatchString(linkURL) {\n\t\treturn\n\t}\n\t\n\t// 检查是否已存在\n\tfor _, existingLink := range detail.Downloads {\n\t\tif existingLink.URL == linkURL {\n\t\t\treturn\n\t\t}\n\t}\n\t\n\t// 创建链接对象\n\tlink := model.Link{\n\t\tType:     linkType,\n\t\tURL:      linkURL,\n\t\tPassword: password,\n\t}\n\t\n\tdetail.Downloads = append(detail.Downloads, link)\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *Fox4kPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tdebugPrintf(\"🔄 [Fox4k DEBUG] 开始重试机制 - 最大重试次数: %d\\n\", maxRetries)\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tdebugPrintf(\"🔄 [Fox4k DEBUG] 第 %d/%d 次尝试\\n\", i+1, maxRetries)\n\t\t\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\tdebugPrintf(\"⏳ [Fox4k DEBUG] 等待 %v 后重试\\n\", backoff)\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tattemptStart := time.Now()\n\t\tresp, err := client.Do(reqClone)\n\t\tattemptDuration := time.Since(attemptStart)\n\t\t\n\t\tdebugPrintf(\"🔧 [Fox4k DEBUG] 第 %d 次尝试耗时: %v\\n\", i+1, attemptDuration)\n\t\t\n\t\tif err != nil {\n\t\t\tdebugPrintf(\"❌ [Fox4k DEBUG] 第 %d 次尝试失败: %v\\n\", i+1, err)\n\t\t\tlastErr = err\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\tdebugPrintf(\"🔧 [Fox4k DEBUG] 第 %d 次尝试获得响应 - 状态码: %d\\n\", i+1, resp.StatusCode)\n\t\t\n\t\tif resp.StatusCode == 200 {\n\t\t\tdebugPrintf(\"✅ [Fox4k DEBUG] 第 %d 次尝试成功!\\n\", i+1)\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tdebugPrintf(\"❌ [Fox4k DEBUG] 第 %d 次尝试状态码异常: %d\\n\", i+1, resp.StatusCode)\n\t\t\n\t\t// 读取响应体以便调试\n\t\tif resp.Body != nil {\n\t\t\tbodyBytes, readErr := io.ReadAll(resp.Body)\n\t\t\tresp.Body.Close()\n\t\t\tif readErr == nil && len(bodyBytes) > 0 {\n\t\t\t\tbodyPreview := string(bodyBytes)\n\t\t\t\tif len(bodyPreview) > 200 {\n\t\t\t\t\tbodyPreview = bodyPreview[:200] + \"...\"\n\t\t\t\t}\n\t\t\t\tdebugPrintf(\"🔧 [Fox4k DEBUG] 响应体预览: %s\\n\", bodyPreview)\n\t\t\t}\n\t\t}\n\t\t\n\t\tlastErr = fmt.Errorf(\"状态码 %d\", resp.StatusCode)\n\t}\n\t\n\tdebugPrintf(\"❌ [Fox4k DEBUG] 所有重试都失败了!\\n\")\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n\n// getRandomUA 获取随机User-Agent\nfunc getRandomUA() string {\n\tuserAgents := []string{\n\t\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36\",\n\t\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36\",\n\t\t\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36\",\n\t\t\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15\",\n\t\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0\",\n\t\t\"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36\",\n\t\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0\",\n\t\t\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36\",\n\t}\n\treturn userAgents[rand.Intn(len(userAgents))]\n}\n\n// generateRandomIP 生成随机IP地址\nfunc generateRandomIP() string {\n\t// 生成随机的私有IP地址段\n\tsegments := [][]int{\n\t\t{192, 168, rand.Intn(256), rand.Intn(256)},\n\t\t{10, rand.Intn(256), rand.Intn(256), rand.Intn(256)},\n\t\t{172, 16 + rand.Intn(16), rand.Intn(256), rand.Intn(256)},\n\t}\n\t\n\tsegment := segments[rand.Intn(len(segments))]\n\treturn fmt.Sprintf(\"%d.%d.%d.%d\", segment[0], segment[1], segment[2], segment[3])\n}"
  },
  {
    "path": "plugin/fox4k/html结构分析.md",
    "content": "# 极狐4K (4kfox.com) 网站搜索结果HTML结构分析\n\n## 网站信息\n\n- **网站名称**: 极狐4K\n- **网站域名**: www.4kfox.com\n- **搜索URL格式**: `https://www.4kfox.com/search/{关键词}-------------.html`\n- **详情页URL格式**: `https://www.4kfox.com/video/{ID}.html`\n- **主要特点**: 专注于4K高清影视资源，提供磁力链接和在线播放\n\n## 搜索结果页面结构\n\n搜索结果页面的主要内容位于`.hl-list-wrap .hl-one-list`元素内，每个搜索结果项包含在`.hl-list-item`元素中。\n\n```html\n<div class=\"hl-list-wrap\">\n    <ul class=\"hl-one-list hl-theme-by362695000 clearfix\">\n        <li class=\"hl-list-item hl-col-xs-12\">\n            <!-- 单个搜索结果 -->\n        </li>\n        <!-- 更多搜索结果... -->\n    </ul>\n</div>\n```\n\n### 单个搜索结果结构\n\n每个搜索结果包含以下主要元素：\n\n#### 1. 封面图片和详情页链接\n\n封面图片位于`.hl-item-wrap .hl-item-pic`元素中：\n\n```html\n<div class=\"hl-item-wrap clearfix\">\n    <div class=\"hl-item-div\">\n        <div class=\"hl-item-pic\">\n            <a class=\"hl-item-thumb hl-lazy\" href=\"/video/61516.html\" title=\"变形金刚：赛博坦之战第二季\" data-original=\"/upload/vod/20250724-17/759cc2eabfa1a13ff498481d1a8f0b36.jpg\">\n                <div class=\"hl-pic-icon hl-hidden-xs\"><i class=\"iconfont hl-icon-bofang-fill\"></i></div>\n                <div class=\"hl-pic-text\">\n                    <span class=\"hl-lc-1 remarks\">已完结</span>\n                </div>\n            </a>\n        </div>\n    </div>\n</div>\n```\n\n- 详情页链接：`href`属性，格式为`/video/{ID}.html`\n- 封面图片：`data-original`属性\n- 资源状态：`.hl-pic-text .remarks`元素中的文本（如\"已完结\"、\"HD\"等）\n\n#### 2. 标题和基本信息\n\n标题和基本信息位于`.hl-item-content`元素中：\n\n```html\n<div class=\"hl-item-content\">\n    <div class=\"hl-item-title hl-text-site hl-lc-2\">\n        <a href=\"/video/61516.html\" title=\"变形金刚：赛博坦之战第二季\">变形金刚：赛博坦之战第二季</a>\n    </div>\n    <p class=\"hl-item-sub hl-lc-1\">\n        <span class=\"hl-text-conch score\">6.9</span>&nbsp;·&nbsp;2020&nbsp;·&nbsp;美国&nbsp;·&nbsp;科幻&nbsp;机战&nbsp;\n    </p>\n    <p class=\"hl-item-sub hl-text-muted hl-lc-1 hl-hidden-xs\"></p>\n    <p class=\"hl-item-sub hl-text-muted hl-lc-2\">\n        《变形金刚：赛博坦之战 第二季》讲述的是：　　《赛博坦之战》推出第二章\"地出\"！...\n    </p>\n    <div class=\"hl-item-btn\">\n        <a class=\"hl-btn-border\" href=\"/video/61516.html\">查看详情</a>\n    </div>\n</div>\n```\n\n- 标题：`.hl-item-title a`的文本内容\n- 评分：`.hl-text-conch.score`的文本内容\n- 年份、地区、类型：第一个`.hl-item-sub`中的信息，以`·`分隔\n- 简介：最后一个`.hl-item-sub`的文本内容\n\n#### 3. 分页信息\n\n分页信息位于`.hl-page-wrap`元素中：\n\n```html\n<ul class=\"hl-page-wrap hl-text-center cleafix\">\n    <li class=\"hl-hide-sm\"><a href=\"/search/...----------1---.html\" class=\"hl-disad\"><i class=\"iconfont hl-icon-jiantoushou\"></i></a></li>\n    <li><a href=\"/search/...----------1---.html\" class=\"hl-disad\">上一页</a></li>\n    <li class=\"hl-hidden-xs\"><a href=\"javascript:;\" class=\"active\">1</a></li>\n    <li class=\"hl-hidden-xs\"><a href=\"/search/...----------2---.html\">2</a></li>\n    <li><a href=\"/search/...----------2---.html\">下一页</a></li>\n    <li class=\"hl-hide-sm\"><a href=\"/search/...----------2---.html\"><i class=\"iconfont hl-icon-jiantouwei\"></i></a></li>\n</ul>\n```\n\n## 详情页面结构\n\n详情页面包含更完整的资源信息，特别是磁力链接和播放源等下载信息。\n\n### 1. 基本信息\n\n基本信息位于`.hl-detail-content`元素中：\n\n```html\n<div class=\"hl-detail-content hl-marg-right50 clearfix\">\n    <div class=\"hl-dc-pic\">\n        <span class=\"hl-item-thumb hl-lazy\" title=\"变形金刚：赛博坦之战第二季\" data-original=\"/upload/vod/20250724-17/759cc2eabfa1a13ff498481d1a8f0b36.jpg\">\n            <div class=\"hl-pic-tag\">\n                <span class=\"douban\">6.9</span>\n            </div>\n        </span>\n    </div>\n    <div class=\"hl-dc-content\">\n        <div class=\"hl-dc-headwrap\">\n            <h2 class=\"hl-dc-title hl-data-menu\">变形金刚：赛博坦之战第二季 (2020)</h2>\n        </div>\n    </div>\n</div>\n```\n\n### 2. 详细信息\n\n详细信息位于`.hl-vod-data`元素中：\n\n```html\n<div class=\"hl-vod-data hl-full-items\">\n    <div class=\"hl-data-sm hl-full-alert hl-full-x100\">\n        <div class=\"hl-full-box clearfix\">\n            <ul class=\"clearfix\">\n                <li class=\"hl-col-xs-12\"><em class=\"hl-text-muted\">类型：</em><a href=\"/search/----%E7%A7%91%E5%B9%BB---------.html\" target=\"_blank\">科幻</a><i>/</i><a href=\"/search/----%E6%9C%BA%E6%88%98---------.html\" target=\"_blank\">机战</a><i>/</i></li>\n                <li class=\"hl-col-xs-12\"><em class=\"hl-text-muted\">地区：</em>美国</li>\n                <li class=\"hl-col-xs-12\"><em class=\"hl-text-muted\">语言：</em>英语</li>\n                <li class=\"hl-col-xs-12\"><em class=\"hl-text-muted\">上映：</em>2020-12-30(美国)</li>\n                <li class=\"hl-col-xs-12\"><em class=\"hl-text-muted\">时长：</em>30分钟</li>\n            </ul>\n        </div>\n    </div>\n</div>\n```\n\n### 3. 播放列表\n\n播放列表位于`.hl-rb-playlist`元素中：\n\n```html\n<div class=\"hl-row-box hl-rb-playlist hl-tabs-item clearfix\" id=\"playlist\">\n    <div class=\"hl-rb-head clearfix\">\n        <h3 class=\"hl-rb-title\">播放列表</h3>\n    </div>\n    <div class=\"hl-play-source hl-hidden\">\n        <div class=\"hl-plays-from hl-tabs swiper-wrapper clearfix\">\n            <a class=\"hl-tabs-btn hl-slide-swiper active\" href=\"javascript:void(0);\" alt=\"天堂源\">天堂源</a>\n            <a class=\"hl-tabs-btn hl-slide-swiper\" href=\"javascript:void(0);\" alt=\"暴风源\">暴风源</a>\n            <a class=\"hl-tabs-btn hl-slide-swiper\" href=\"javascript:void(0);\" alt=\"非凡源\">非凡源</a>\n        </div>\n        <div class=\"hl-tabs-box hl-fadeIn\" style=\"display: block;\">\n            <div class=\"hl-list-wrap\">\n                <ul class=\"hl-plays-list hl-sort-list clearfix\" id=\"hl-plays-list\">\n                    <li class=\"hl-col-xs-4 hl-col-sm-2\"><a href=\"/play/61516-1-1.html\">第01集</a></li>\n                    <li class=\"hl-col-xs-4 hl-col-sm-2\"><a href=\"/play/61516-1-2.html\">第02集</a></li>\n                    <!-- 更多集数... -->\n                </ul>\n            </div>\n        </div>\n    </div>\n</div>\n```\n\n### 4. 磁力&网盘下载区域\n\n下载链接区域位于`.hl-rb-downlist`元素中：\n\n```html\n<div class=\"hl-row-box hl-rb-downlist hl-tabs-item clearfix\" id=\"downlist\">\n    <div class=\"hl-rb-head clearfix\">\n        <h3 class=\"hl-rb-title\">磁力&网盘</h3>\n    </div>\n    <div class=\"hl-play-source hl-hidden\">\n        <div class=\"hl-plays-from hl-tabs swiper-wrapper clearfix\">\n            <a class=\"hl-tabs-btn hl-slide-swiper active\" href=\"javascript:void(0);\" alt=\"中字720P\">中字720P <span>6</span></a>\n            <a class=\"hl-tabs-btn hl-slide-swiper\" href=\"javascript:void(0);\" alt=\"中字1080P\">中字1080P <span>1</span></a>\n        </div>\n        <div class=\"hl-tabs-box hl-fadeIn\" style=\"display: block;\">\n            <div class=\"hl-list-wrap\">\n                <ul class=\"swiper-slide hl-downs-list hl-sort-list clearfix\" id=\"hl-downs-list\">\n                    <li>\n                        <div class=\"hl-downs-box\">\n                            <span class=\"text hl-lc-1\">\n                                <a class=\"down-name\" href=\"magnet:?xt=urn:btih:E18A64B7A04B52891C520427D1565697031A1201\" target=\"_blank\">\n                                    <em class=\"filename\">变形金刚：赛博坦之战.Transformers.War.For.Cybertron.Trilogy.S02E01.官方中字.WEBrip.720P.mp4[262.49MB]</em>\n                                    <em class=\"filesize\"></em>\n                                </a>\n                            </span>\n                            <span class=\"btns\">\n                                <a class=\"hl-text-white down-copy conch-copy down-xm\" href=\"javascript:void(0)\" \n                                   data-clipboard-action=\"copy\" \n                                   data-clipboard-text=\"magnet:?xt=urn:btih:E18A64B7A04B52891C520427D1565697031A1201\">复制链接</a>\n                            </span>\n                        </div>\n                    </li>\n                    <!-- 更多下载链接... -->\n                </ul>\n            </div>\n        </div>\n    </div>\n</div>\n```\n\n#### 4.1 磁力链接\n\n磁力链接位于`.down-name`元素的`href`属性中，或者`.down-copy`元素的`data-clipboard-text`属性中。\n\n- 链接格式：`magnet:?xt=urn:btih:...`\n- 文件名：`.filename`元素的文本内容\n- 文件大小：`.filesize`元素的文本内容（可能为空）\n\n### 5. 剧情简介\n\n剧情简介位于`.hl-rb-content`元素中：\n\n```html\n<div class=\"hl-row-box hl-rb-content clearfix\">\n    <div class=\"hl-rb-head clearfix\">\n        <h3 class=\"hl-rb-title\">剧情简介</h3>\n    </div>\n    <div class=\"hl-content-wrap hl-content-hide\">\n        <span class=\"hl-content-text\">\n            <em>《赛博坦之战》推出第二章\"地出\"！随着火种源的消失，威震天被迫面对残酷的现实...</em>\n        </span>\n    </div>\n</div>\n```\n\n## 提取逻辑\n\n### 搜索结果页面提取逻辑\n\n1. 定位所有的`.hl-list-item`元素\n2. 对于每个元素：\n   - 从`.hl-item-pic a`的`href`属性提取详情页链接\n   - 从链接中提取资源ID（格式：`/video/(\\d+)\\.html`）\n   - 从`.hl-item-title a`提取标题\n   - 从`.hl-pic-text .remarks`提取资源状态\n   - 从`.hl-text-conch.score`提取评分\n   - 从第一个`.hl-item-sub`提取年份、地区、类型信息\n   - 从最后一个`.hl-item-sub`提取简介\n   - 从`data-original`属性提取封面图片URL\n\n3. 检查分页：\n   - 从`.hl-page-wrap`中提取分页链接，用于继续抓取后续页面\n\n### 详情页面提取逻辑\n\n1. 获取资源基本信息：\n   - 标题：`h2.hl-dc-title`的文本内容\n   - 评分：`.hl-pic-tag .douban`的文本内容\n   - 封面图片：`.hl-dc-pic .hl-item-thumb`的`data-original`属性\n\n2. 提取详细信息：\n   - 从`.hl-vod-data ul li`中提取类型、地区、语言、上映日期、时长等信息\n\n3. 提取磁力链接：\n   - 定位`.hl-rb-downlist`区域\n   - 遍历所有`.hl-tabs-btn`获取不同质量版本\n   - 从`.hl-downs-list li`中提取磁力链接：\n     - 磁力链接：`.down-name`的`href`属性或`.down-copy`的`data-clipboard-text`属性\n     - 文件名：`.filename`的文本内容\n     - 文件大小：`.filesize`的文本内容\n\n4. 提取剧情简介：\n   - 从`.hl-content-wrap .hl-content-text`提取剧情简介\n\n## 注意事项\n\n1. **搜索URL格式**: `https://www.4kfox.com/search/{关键词}-------------.html`，关键词需要URL编码\n2. **详情页URL格式**: `https://www.4kfox.com/video/{ID}.html`\n3. **资源类型**: 主要提供磁力链接，以4K高清资源为主\n4. **分页处理**: 搜索结果支持分页，需要根据`.hl-page-wrap`中的链接继续抓取\n5. **图片延迟加载**: 封面图片使用`data-original`属性进行延迟加载\n6. **ID提取**: 从URL中提取ID的正则表达式：`/video/(\\d+)\\.html`\n7. **磁力链接**: 提供多种质量版本（720P、1080P等），每个版本可能有多集\n8. **播放源**: 提供多个在线播放源（天堂源、暴风源、非凡源等）\n9. **网站编码**: 页面使用UTF-8编码\n10. **反爬虫**: 需要设置合适的User-Agent和请求头，避免被反爬虫机制拦截"
  },
  {
    "path": "plugin/gying/README.md",
    "content": "# Gying 搜索插件\n\n## 📖 简介\n\nGying是PanSou的搜索插件，用于从 www.gying.net 网站搜索影视资源。支持多用户登录并配置账户，在搜索时自动聚合所有用户的搜索结果。\n\n## ✨ 核心特性\n\n- ✅ **多用户支持** - 每个用户独立配置，互不干扰\n- ✅ **用户名密码登录** - 支持使用用户名和密码登录\n- ✅ **智能去重** - 多用户搜索时自动去重\n- ✅ **负载均衡** - 任务均匀分配，避免单用户限流\n- ✅ **内存缓存** - 用户数据缓存到内存，搜索性能极高\n- ✅ **持久化存储** - Cookie和用户配置自动保存，重启不丢失\n- ✅ **Web管理界面** - 一站式配置，简单易用\n- ✅ **RESTful API** - 支持程序化调用\n- ✅ **默认账户自动登录** - 插件启动时自动使用默认账户登录\n\n## 🚀 快速开始\n\n### 步骤1: 启动服务\n\n```bash\ncd /Users/macbookpro/Desktop/fish2018/pansou\ngo run main.go\n\n# 或者编译后运行\ngo build -o pansou main.go\n./pansou\n```\n\n### 步骤2: 访问管理页面\n\n如果需要添加更多账户或管理现有账户，可以访问管理页面：\n\n```\nhttp://localhost:8888/gying/你的用户名\n```\n\n**示例**：\n```\nhttp://localhost:8888/gying/myusername\n```\n\n系统会自动：\n1. 根据用户名生成专属64位hash（不可逆）\n2. 重定向到专属管理页面：`http://localhost:8888/gying/{hash}`\n3. 显示登录表单供手动登录\n\n**📌 提示**：请收藏hash后的URL（包含你的专属hash），方便下次访问。\n\n### 步骤3: 手动登录\n\n在\"登录状态\"区域输入：\n- 用户名\n- 密码\n\n点击\"**登录**\"按钮。\n\n### 步骤4: 开始搜索\n\n在PanSou主页搜索框输入关键词，系统会**自动聚合所有用户**的Gying搜索结果！\n\n```bash\n# 通过API搜索\ncurl \"http://localhost:8888/api/search?kw=遮天\"\n\n# 只搜索插件（包括gying）\ncurl \"http://localhost:8888/api/search?kw=遮天&src=plugin\"\n```\n\n## 📡 API文档\n\n### 统一接口\n\n所有操作通过统一的POST接口：\n\n```\nPOST /gying/{hash}\nContent-Type: application/json\n\n{\n  \"action\": \"操作类型\",\n  ...其他参数\n}\n```\n\n### API列表\n\n| Action | 说明 | 需要登录 |\n|--------|------|---------|\n| `get_status` | 获取状态 | ❌ |\n| `login` | 登录 | ❌ |\n| `logout` | 退出登录 | ✅ |\n| `test_search` | 测试搜索 | ✅ |\n\n---\n\n### 1️⃣ get_status - 获取用户状态\n\n**请求**：\n```bash\ncurl -X POST \"http://localhost:8888/gying/{hash}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"action\": \"get_status\"}'\n```\n\n**成功响应（已登录）**：\n```json\n{\n  \"success\": true,\n  \"message\": \"获取成功\",\n  \"data\": {\n    \"hash\": \"abc123...\",\n    \"logged_in\": true,\n    \"status\": \"active\",\n    \"username_masked\": \"pa****ou\",\n    \"login_time\": \"2025-10-28 12:00:00\",\n    \"expire_time\": \"2026-02-26 12:00:00\",\n    \"expires_in_days\": 121\n  }\n}\n```\n\n**成功响应（未登录）**：\n```json\n{\n  \"success\": true,\n  \"message\": \"获取成功\",\n  \"data\": {\n    \"hash\": \"abc123...\",\n    \"logged_in\": false,\n    \"status\": \"pending\"\n  }\n}\n```\n\n---\n\n### 2️⃣ login - 登录\n\n**请求**：\n```bash\ncurl -X POST \"http://localhost:8888/gying/{hash}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"action\": \"login\", \"username\": \"xxx\", \"password\": \"xxx\"}'\n```\n\n**成功响应**：\n```json\n{\n  \"success\": true,\n  \"message\": \"登录成功\",\n  \"data\": {\n    \"status\": \"active\",\n    \"username_masked\": \"pa****ou\"\n  }\n}\n```\n\n**失败响应**：\n```json\n{\n  \"success\": false,\n  \"message\": \"登录失败: 用户名或密码错误\"\n}\n```\n\n---\n\n### 3️⃣ logout - 退出登录\n\n**请求**：\n```bash\ncurl -X POST \"http://localhost:8888/gying/{hash}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"action\": \"logout\"}'\n```\n\n**成功响应**：\n```json\n{\n  \"success\": true,\n  \"message\": \"已退出登录\",\n  \"data\": {\n    \"status\": \"pending\"\n  }\n}\n```\n\n---\n\n### 4️⃣ test_search - 测试搜索\n\n**请求**：\n```bash\ncurl -X POST \"http://localhost:8888/gying/{hash}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"action\": \"test_search\", \"keyword\": \"遮天\"}'\n```\n\n**成功响应**：\n```json\n{\n  \"success\": true,\n  \"message\": \"找到 5 条结果\",\n  \"data\": {\n    \"keyword\": \"遮天\",\n    \"total_results\": 5,\n    \"results\": [\n      {\n        \"title\": \"遮天：禁区\",\n        \"links\": [\n          {\n            \"type\": \"quark\",\n            \"url\": \"https://pan.quark.cn/s/89f7aeef9681\",\n            \"password\": \"\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\n---\n\n## 🔧 配置说明\n\n### 环境变量（可选）\n\n```bash\n# Hash Salt（推荐自定义，增强安全性）\nexport GYING_HASH_SALT=\"your-custom-salt-here\"\n\n# Cookie加密密钥（32字节，推荐自定义）\nexport GYING_ENCRYPTION_KEY=\"your-32-byte-key-here!!!!!!!!!!\"\n```\n\n### 代码内配置\n\n在 `gying.go` 第20-24行修改：\n\n```go\nconst (\n    MaxConcurrentUsers   = 10    // 最多使用的用户数（搜索时）\n    MaxConcurrentDetails = 50    // 最大并发详情请求数\n    DebugLog             = false // 调试日志开关\n)\n```\n\n### 默认账户配置\n\n在 `gying.go` 第27-32行修改默认账户：\n\n```go\nvar DefaultAccounts = []struct {\n    Username string\n    Password string\n}{\n    // 可以添加更多默认账户\n    // {\"user2\", \"password2\"},\n}\n```\n\n**参数说明**：\n\n| 参数 | 默认值 | 说明 | 建议 |\n|------|--------|------|------|\n| `MaxConcurrentUsers` | 10 | 单次搜索最多使用的用户数 | 10-20足够 |\n| `MaxConcurrentDetails` | 50 | 最大并发详情请求数 | 50-100 |\n| `DebugLog` | false | 是否开启调试日志 | 生产环境false |\n\n## 📂 数据存储\n\n### 存储位置\n\n```\ncache/gying_users/{hash}.json\n```\n\n### 数据结构\n\n```json\n{\n  \"hash\": \"abc123...\",\n  \"username\": \"pansou\",\n  \"username_masked\": \"pa****ou\",\n  \"cookie\": \"BT_auth=xxx; BT_cookietime=xxx\",\n  \"status\": \"active\",\n  \"created_at\": \"2025-10-28T12:00:00+08:00\",\n  \"login_at\": \"2025-10-28T12:00:00+08:00\",\n  \"expire_at\": \"2026-02-26T12:00:00+08:00\",\n  \"last_access_at\": \"2025-10-28T13:00:00+08:00\"\n}\n```\n\n**字段说明**：\n- `hash`: 用户唯一标识（SHA256，不可逆推用户名）\n- `username`: 原始用户名（存储）\n- `username_masked`: 脱敏用户名（如`pa****ou`）\n- `cookie`: 登录Cookie（明文存储，建议配置加密）\n- `status`: 用户状态（`pending`/`active`/`expired`）\n- `expire_at`: Cookie过期时间（121天）"
  },
  {
    "path": "plugin/gying/gying.go",
    "content": "package gying\n\nimport (\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\t\"unsafe\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n\t\n\tcloudscraper \"github.com/Advik-B/cloudscraper/lib\"\n)\n\n// 插件配置参数\nconst (\n\tMaxConcurrentUsers = 10    // 最多使用的用户数\n\tMaxConcurrentDetails = 50  // 最大并发详情请求数\n\tDebugLog = false           // 调试日志开关（排查问题时改为true）\n)\n\n// 默认账户配置（可通过Web界面添加更多账户）\n// 用户数据会保存到文件，重启后自动恢复\nvar DefaultAccounts = []struct {\n\tUsername string\n\tPassword string\n}{\n\t// 请使用 Web 接口添加用户：\n\t// POST /gying/add_user?username=xxx&password=xxx\n}\n\n// 存储目录\nvar StorageDir string\n\n// 初始化存储目录\n\n// HTML模板\nconst HTMLTemplate = `<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>PanSou Gying搜索配置</title>\n    <style>\n        * { margin: 0; padding: 0; box-sizing: border-box; }\n        body { \n            font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n            padding: 20px;\n        }\n        .container {\n            max-width: 800px;\n            margin: 0 auto;\n            background: white;\n            border-radius: 16px;\n            box-shadow: 0 20px 60px rgba(0,0,0,0.3);\n            overflow: hidden;\n        }\n        .header {\n            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n            color: white;\n            padding: 30px;\n            text-align: center;\n        }\n        .section {\n            padding: 30px;\n            border-bottom: 1px solid #eee;\n        }\n        .section:last-child { border-bottom: none; }\n        .section-title {\n            font-size: 18px;\n            font-weight: bold;\n            margin-bottom: 15px;\n            color: #333;\n        }\n        .status-box {\n            background: #f8f9fa;\n            padding: 20px;\n            border-radius: 8px;\n            margin-bottom: 15px;\n        }\n        .status-item {\n            display: flex;\n            justify-content: space-between;\n            padding: 8px 0;\n        }\n        .form-group {\n            margin-bottom: 15px;\n        }\n        .form-group label {\n            display: block;\n            margin-bottom: 5px;\n            font-weight: bold;\n        }\n        .form-group input {\n            width: 100%;\n            padding: 10px;\n            border: 1px solid #ddd;\n            border-radius: 6px;\n        }\n        .btn {\n            padding: 10px 20px;\n            border: none;\n            border-radius: 6px;\n            cursor: pointer;\n            font-size: 14px;\n            transition: all 0.3s;\n        }\n        .btn-primary {\n            background: #667eea;\n            color: white;\n        }\n        .btn-primary:hover { background: #5568d3; }\n        .btn-danger {\n            background: #f56565;\n            color: white;\n        }\n        .btn-danger:hover { background: #e53e3e; }\n        .alert {\n            padding: 12px 15px;\n            border-radius: 6px;\n            margin: 10px 0;\n        }\n        .alert-success {\n            background: #c6f6d5;\n            color: #22543d;\n        }\n        .alert-error {\n            background: #fed7d7;\n            color: #742a2a;\n        }\n        .test-results {\n            max-height: 300px;\n            overflow-y: auto;\n            background: #f8f9fa;\n            padding: 15px;\n            border-radius: 6px;\n            margin-top: 10px;\n        }\n        .hidden { display: none; }\n    </style>\n</head>\n<body>\n    <div class=\"container\">\n        <div class=\"header\">\n            <h1>🔍 PanSou Gying搜索</h1>\n            <p>配置你的专属搜索服务</p>\n            <p style=\"font-size: 12px; margin-top: 10px; opacity: 0.8;\">\n                🔗 当前地址: <span id=\"current-url\">HASH_PLACEHOLDER</span>\n            </p>\n        </div>\n\n        <div class=\"section\" id=\"login-section\">\n            <div class=\"section-title\">🔐 登录状态</div>\n            \n            <div id=\"logged-in-view\" class=\"hidden\">\n                <div class=\"status-box\">\n                    <div class=\"status-item\">\n                        <span>状态</span>\n                        <span><strong style=\"color: #48bb78;\">✅ 已登录</strong></span>\n                    </div>\n                    <div class=\"status-item\">\n                        <span>用户名</span>\n                        <span id=\"username-display\">-</span>\n                    </div>\n                    <div class=\"status-item\">\n                        <span>登录时间</span>\n                        <span id=\"login-time\">-</span>\n                    </div>\n                    <div class=\"status-item\">\n                        <span>有效期</span>\n                        <span id=\"expire-info\">-</span>\n                    </div>\n                </div>\n                <button class=\"btn btn-danger\" onclick=\"logout()\">退出登录</button>\n            </div>\n\n            <div id=\"not-logged-in-view\" class=\"hidden\">\n                <div id=\"alert-box\"></div>\n                <div class=\"form-group\">\n                    <label>用户名</label>\n                    <input type=\"text\" id=\"username\" placeholder=\"输入用户名\">\n                </div>\n                <div class=\"form-group\">\n                    <label>密码</label>\n                    <input type=\"password\" id=\"password\" placeholder=\"输入密码\">\n                </div>\n                <button class=\"btn btn-primary\" onclick=\"login()\">登录</button>\n            </div>\n        </div>\n\n        <div class=\"section\" id=\"test-section\">\n            <div class=\"section-title\">🔍 测试搜索(限制返回10条数据)</div>\n            \n            <div style=\"display: flex; gap: 10px;\">\n                <input type=\"text\" id=\"search-keyword\" placeholder=\"输入关键词测试搜索\" style=\"flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 6px;\">\n                <button class=\"btn btn-primary\" onclick=\"testSearch()\">搜索</button>\n            </div>\n\n            <div id=\"search-results\" class=\"test-results hidden\"></div>\n        </div>\n\n        <div class=\"section\">\n            <div class=\"section-title\">📖 API调用说明</div>\n            \n            <p style=\"margin-bottom: 15px;\">你可以通过API程序化管理：</p>\n\n            <details>\n                <summary style=\"cursor: pointer; padding: 10px 0; font-weight: bold;\">登录</summary>\n                <div style=\"background: #2d3748; color: #68d391; padding: 10px; border-radius: 6px; font-family: monospace; font-size: 12px; overflow-x: auto;\">curl -X POST https://your-domain.com/gying/HASH_PLACEHOLDER \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"action\": \"login\", \"username\": \"user\", \"password\": \"pass\"}'</div>\n            </details>\n        </div>\n    </div>\n\n    <script>\n        const HASH = 'HASH_PLACEHOLDER';\n        const API_URL = '/gying/' + HASH;\n        let statusCheckInterval = null;\n\n        window.onload = function() {\n            updateStatus();\n            startStatusPolling();\n        };\n\n        function startStatusPolling() {\n            statusCheckInterval = setInterval(updateStatus, 5000);\n        }\n\n        async function postAction(action, extraData = {}) {\n            try {\n                const response = await fetch(API_URL, {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ action: action, ...extraData })\n                });\n                return await response.json();\n            } catch (error) {\n                console.error('请求失败:', error);\n                return { success: false, message: '请求失败: ' + error.message };\n            }\n        }\n\n        async function updateStatus() {\n            const result = await postAction('get_status');\n            if (result.success && result.data) {\n                const data = result.data;\n                \n                if (data.logged_in === true && data.status === 'active') {\n                    document.getElementById('logged-in-view').classList.remove('hidden');\n                    document.getElementById('not-logged-in-view').classList.add('hidden');\n                    \n                    document.getElementById('username-display').textContent = data.username_masked || '-';\n                    document.getElementById('login-time').textContent = data.login_time || '-';\n                    document.getElementById('expire-info').textContent = '剩余 ' + (data.expires_in_days || 0) + ' 天';\n                } else {\n                    document.getElementById('logged-in-view').classList.add('hidden');\n                    document.getElementById('not-logged-in-view').classList.remove('hidden');\n                }\n            }\n        }\n\n        function showAlert(message, type = 'success') {\n            const alertBox = document.getElementById('alert-box');\n            alertBox.innerHTML = '<div class=\"alert alert-' + type + '\">' + message + '</div>';\n            setTimeout(() => {\n                alertBox.innerHTML = '';\n            }, 3000);\n        }\n\n        async function login() {\n            const username = document.getElementById('username').value.trim();\n            const password = document.getElementById('password').value.trim();\n            \n            if (!username || !password) {\n                showAlert('请输入用户名和密码', 'error');\n                return;\n            }\n\n            const result = await postAction('login', { username, password });\n            if (result.success) {\n                showAlert(result.message);\n                updateStatus();\n            } else {\n                showAlert(result.message, 'error');\n            }\n        }\n\n        async function logout() {\n            if (!confirm('确定要退出登录吗？')) return;\n            \n            const result = await postAction('logout');\n            if (result.success) {\n                showAlert(result.message);\n                updateStatus();\n            } else {\n                showAlert(result.message, 'error');\n            }\n        }\n\n        async function testSearch() {\n            const keyword = document.getElementById('search-keyword').value.trim();\n            \n            if (!keyword) {\n                showAlert('请输入搜索关键词', 'error');\n                return;\n            }\n\n            const resultsDiv = document.getElementById('search-results');\n            resultsDiv.classList.remove('hidden');\n            resultsDiv.innerHTML = '<div>🔍 搜索中...</div>';\n\n            const result = await postAction('test_search', { keyword });\n            \n            if (result.success) {\n                const results = result.data.results || [];\n                \n                if (results.length === 0) {\n                    resultsDiv.innerHTML = '<p style=\"text-align: center; color: #999;\">未找到结果</p>';\n                    return;\n                }\n\n                let html = '<p><strong>找到 ' + result.data.total_results + ' 条结果</strong></p>';\n                results.forEach((item, index) => {\n                    html += '<div style=\"margin: 15px 0; padding: 10px; background: white; border-radius: 6px;\">';\n                    html += '<p><strong>' + (index + 1) + '. ' + item.title + '</strong></p>';\n                    item.links.forEach(link => {\n                        html += '<p style=\"font-size: 12px; color: #666; margin: 5px 0; word-break: break-all;\">';\n                        html += '[' + link.type + '] ' + link.url;\n                        if (link.password) html += ' 密码: ' + link.password;\n                        html += '</p>';\n                    });\n                    html += '</div>';\n                });\n                resultsDiv.innerHTML = html;\n            } else {\n                resultsDiv.innerHTML = '<p style=\"color: red;\">' + result.message + '</p>';\n            }\n        }\n\n        document.getElementById('search-keyword').addEventListener('keypress', function(e) {\n            if (e.key === 'Enter') testSearch();\n        });\n    </script>\n</body>\n</html>`\n\n// GyingPlugin 插件结构\ntype GyingPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tusers       sync.Map // 内存缓存：hash -> *User\n\tscrapers    sync.Map // cloudscraper实例缓存：hash -> *cloudscraper.Scraper\n\tmu          sync.RWMutex\n\tsearchCache sync.Map // 插件级缓存：关键词->model.PluginSearchResult\n\tinitialized bool     // 初始化状态标记\n}\n\n// User 用户数据结构\ntype User struct {\n\tHash              string    `json:\"hash\"`\n\tUsername          string    `json:\"username\"`           // 原始用户名（存储）\n\tUsernameMasked    string    `json:\"username_masked\"`    // 脱敏用户名（显示）\n\tEncryptedPassword string    `json:\"encrypted_password\"` // 加密后的密码（用于重启恢复）\n\tCookie            string    `json:\"cookie\"`             // 登录Cookie字符串（仅供参考）\n\tStatus            string    `json:\"status\"`             // pending/active/expired\n\tCreatedAt         time.Time `json:\"created_at\"`\n\tLoginAt           time.Time `json:\"login_at\"`\n\tExpireAt          time.Time `json:\"expire_at\"`\n\tLastAccessAt      time.Time `json:\"last_access_at\"`\n}\n\n// SearchData 搜索页面JSON数据结构\ntype SearchData struct {\n\tQ  string   `json:\"q\"`  // 搜索关键词\n\tWD []string `json:\"wd\"` // 分词\n\tN  string   `json:\"n\"`  // 结果数量\n\tL  struct {\n\t\tTitle  []string `json:\"title\"`  // 标题数组\n\t\tYear   []int    `json:\"year\"`   // 年份数组\n\t\tD      []string `json:\"d\"`      // 类型数组（mv/ac/tv）\n\t\tI      []string `json:\"i\"`      // 资源ID数组\n\t\tInfo   []string `json:\"info\"`   // 信息数组\n\t\tDaoyan []string `json:\"daoyan\"` // 导演数组\n\t\tZhuyan []string `json:\"zhuyan\"` // 主演数组\n\t} `json:\"l\"`\n}\n\n// DetailData 详情接口JSON数据结构\ntype DetailData struct {\n\tCode int  `json:\"code\"`\n\tWP   bool `json:\"wp\"`\n\tPanlist struct {\n\t\tID    []string `json:\"id\"`\n\t\tName  []string `json:\"name\"`\n\t\tP     []string `json:\"p\"`     // 提取码数组\n\t\tURL   []string `json:\"url\"`   // 链接数组\n\t\tType  []int    `json:\"type\"`  // 类型标识\n\t\tUser  []string `json:\"user\"`  // 分享用户\n\t\tTime  []string `json:\"time\"`  // 分享时间\n\t\tTName []string `json:\"tname\"` // 网盘类型名称\n\t} `json:\"panlist\"`\n}\n\nfunc init() {\n\tp := &GyingPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"gying\", 3),\n\t}\n\n\tplugin.RegisterGlobalPlugin(p)\n}\n\n// Initialize 实现 InitializablePlugin 接口，延迟初始化插件\nfunc (p *GyingPlugin) Initialize() error {\n\tif p.initialized {\n\t\treturn nil\n\t}\n\n\t// 初始化存储目录路径\n\tcachePath := os.Getenv(\"CACHE_PATH\")\n\tif cachePath == \"\" {\n\t\tcachePath = \"./cache\"\n\t}\n\tStorageDir = filepath.Join(cachePath, \"gying_users\")\n\n\t// 初始化存储目录\n\tif err := os.MkdirAll(StorageDir, 0755); err != nil {\n\t\treturn fmt.Errorf(\"创建存储目录失败: %v\", err)\n\t}\n\n\t// 加载所有用户到内存\n\tp.loadAllUsers()\n\n\t// 异步初始化默认账户（不阻塞启动）\n\tgo func() {\n\t\t// 延迟1秒，等待主程序完全启动\n\t\ttime.Sleep(1 * time.Second)\n\t\tp.initDefaultAccounts()\n\t}()\n\n\t// 启动定期清理任务\n\tgo p.startCleanupTask()\n\t\n\t// 启动session保活任务（防止session超时）\n\tgo p.startSessionKeepAlive()\n\n\tp.initialized = true\n\treturn nil\n}\n\n// ============ 插件接口实现 ============\n\n// RegisterWebRoutes 注册Web路由\nfunc (p *GyingPlugin) RegisterWebRoutes(router *gin.RouterGroup) {\n\tgying := router.Group(\"/gying\")\n\tgying.GET(\"/:param\", p.handleManagePage)\n\tgying.POST(\"/:param\", p.handleManagePagePOST)\n\t\n\tfmt.Printf(\"[Gying] Web路由已注册: /gying/:param\\n\")\n}\n\n// Search 执行搜索并返回结果\nfunc (p *GyingPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\n// 注意：gying插件不使用AsyncSearchWithResult的缓存机制，因为：\n// 1. 使用自己的cloudscraper实例而不是传入的http.Client\n// 2. 有自己的用户会话管理\n// 3. Service层已经有缓存，无需插件层再次缓存\nfunc (p *GyingPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n    // 解析 ext[\"refresh\"]\n    forceRefresh := false\n    if ext != nil {\n        if v, ok := ext[\"refresh\"]; ok {\n            if b, ok := v.(bool); ok && b {\n                forceRefresh = true\n            }\n        }\n    }\n\n    if !forceRefresh {\n        if cacheItem, ok := p.searchCache.Load(keyword); ok {\n            cached := cacheItem.(model.PluginSearchResult)\n            if DebugLog {\n                fmt.Printf(\"[Gying] 命中插件缓存: %s\\n\", keyword)\n            }\n            return cached, nil\n        }\n    } else {\n        if DebugLog {\n            fmt.Printf(\"[Gying] 强制刷新，此次跳过插件缓存，关键词: %s\\n\", keyword)\n        }\n    }\n\n    // 原有真实抓取逻辑\n    if DebugLog {\n        fmt.Printf(\"[Gying] searchWithScraper REAL 执行: %s\\n\", keyword)\n    }\n    users := p.getActiveUsers()\n    if DebugLog {\n        fmt.Printf(\"[Gying] 找到 %d 个有效用户\\n\", len(users))\n    }\n    if len(users) == 0 {\n        if DebugLog {\n            fmt.Printf(\"[Gying] 没有有效用户，返回空结果\\n\")\n        }\n        return model.PluginSearchResult{Results: []model.SearchResult{}, IsFinal: true}, nil\n    }\n    if len(users) > MaxConcurrentUsers {\n        sort.Slice(users, func(i, j int) bool {\n            return users[i].LastAccessAt.After(users[j].LastAccessAt)\n        })\n        users = users[:MaxConcurrentUsers]\n    }\n    results := p.executeSearchTasks(users, keyword)\n    if DebugLog {\n        fmt.Printf(\"[Gying] 搜索完成，获得 %d 条结果\\n\", len(results))\n    }\n    realResult := model.PluginSearchResult{\n        Results: results,\n        IsFinal: true,\n    }\n    // 写入缓存\n    if len(results) > 0 {\n        p.searchCache.Store(keyword, realResult)\n    }\n    return realResult, nil\n}\n\n// ============ 用户管理 ============\n\n// loadAllUsers 加载所有用户到内存（包括用户名、加密密码等）\n// 注意：只加载用户数据，scraper实例将在initDefaultAccounts中使用密码重新登录获取\nfunc (p *GyingPlugin) loadAllUsers() {\n\tfiles, err := ioutil.ReadDir(StorageDir)\n\tif err != nil {\n\t\treturn\n\t}\n\n\ttotalFiles := 0\n\tloadedCount := 0\n\tskippedInactive := 0\n\t\n\tfor _, file := range files {\n\t\tif file.IsDir() || filepath.Ext(file.Name()) != \".json\" {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\ttotalFiles++\n\n\t\tfilePath := filepath.Join(StorageDir, file.Name())\n\t\tdata, err := ioutil.ReadFile(filePath)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar user User\n\t\tif err := json.Unmarshal(data, &user); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 过滤条件：status必须是active\n\t\tif user.Status != \"active\" {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Gying] ⏭️  跳过用户 %s: status=%s (非active)\\n\", user.UsernameMasked, user.Status)\n\t\t\t}\n\t\t\tskippedInactive++\n\t\t\tcontinue\n\t\t}\n\n\t\t// 只存储用户数据（包括用户名和加密密码）\n\t\t// scraper实例将在initDefaultAccounts中通过重新登录获取\n\t\tp.users.Store(user.Hash, &user)\n\t\tloadedCount++\n\t\t\n\t\tif DebugLog {\n\t\t\thasPassword := \"无\"\n\t\t\tif user.EncryptedPassword != \"\" {\n\t\t\t\thasPassword = \"有\"\n\t\t\t}\n\t\t\tfmt.Printf(\"[Gying] ✅ 已加载用户 %s (密码:%s, 将在初始化时登录)\\n\", user.UsernameMasked, hasPassword)\n\t\t}\n\t}\n\n\tfmt.Printf(\"[Gying] 用户加载完成: 总文件=%d, 已加载=%d, 跳过(非active)=%d\\n\", \n\t\ttotalFiles, loadedCount, skippedInactive)\n}\n\n// initDefaultAccounts 初始化所有账户（异步执行，不阻塞启动）\n// 包括：1. DefaultAccounts（代码配置）  2. 从文件加载的用户（使用加密密码重新登录）\nfunc (p *GyingPlugin) initDefaultAccounts() {\n\t// fmt.Printf(\"[Gying] ========== 异步初始化所有账户 ==========\\n\")\n\t\n\t// 步骤1：处理DefaultAccounts（代码中配置的默认账户）\n\tfor i, account := range DefaultAccounts {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] [默认账户 %d/%d] 处理: %s\\n\", i+1, len(DefaultAccounts), account.Username)\n\t\t}\n\n\t\tp.initOrRestoreUser(account.Username, account.Password, \"default\")\n\t}\n\t\n\t// 步骤2：遍历所有已加载的用户，恢复没有scraper的用户\n\tvar usersToRestore []*User\n\tp.users.Range(func(key, value interface{}) bool {\n\t\tuser := value.(*User)\n\t\t// 检查scraper是否存在\n\t\t_, scraperExists := p.scrapers.Load(user.Hash)\n\t\tif !scraperExists && user.EncryptedPassword != \"\" {\n\t\t\tusersToRestore = append(usersToRestore, user)\n\t\t}\n\t\treturn true\n\t})\n\t\n\tif len(usersToRestore) > 0 {\n\t\tfmt.Printf(\"[Gying] 发现 %d 个需要恢复的用户（使用加密密码重新登录）\\n\", len(usersToRestore))\n\t\tfor i, user := range usersToRestore {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Gying] [恢复用户 %d/%d] 处理: %s\\n\", i+1, len(usersToRestore), user.UsernameMasked)\n\t\t\t}\n\t\t\t\n\t\t\t// 解密密码\n\t\t\tpassword, err := p.decryptPassword(user.EncryptedPassword)\n\t\t\tif err != nil {\n\t\t\t\tfmt.Printf(\"[Gying] ❌ 用户 %s 解密密码失败: %v\\n\", user.UsernameMasked, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t\n\t\t\tp.initOrRestoreUser(user.Username, password, \"restore\")\n\t\t}\n\t}\n\n\t// fmt.Printf(\"[Gying] ========== 所有账户初始化完成 ==========\\n\")\n}\n\n// initOrRestoreUser 初始化或恢复单个用户（登录并保存）\nfunc (p *GyingPlugin) initOrRestoreUser(username, password, source string) {\n\thash := p.generateHash(username)\n\t\n\t// 检查scraper是否已存在\n\t_, scraperExists := p.scrapers.Load(hash)\n\tif scraperExists {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] 用户 %s scraper已存在，跳过\\n\", p.maskUsername(username))\n\t\t}\n\t\treturn\n\t}\n\t\n\t// 登录\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 开始登录账户: %s\\n\", username)\n\t}\n\tscraper, cookie, err := p.doLogin(username, password)\n\tif err != nil {\n\t\tfmt.Printf(\"[Gying] ❌ 账户 %s 登录失败: %v\\n\", username, err)\n\t\treturn\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 登录成功，已获取cloudscraper实例\\n\")\n\t}\n\n\t// 加密密码\n\tencryptedPassword, err := p.encryptPassword(password)\n\tif err != nil {\n\t\tfmt.Printf(\"[Gying] ❌ 加密密码失败: %v\\n\", err)\n\t\treturn\n\t}\n\t\n\t// 保存用户\n\tuser := &User{\n\t\tHash:              hash,\n\t\tUsername:          username,\n\t\tUsernameMasked:    p.maskUsername(username),\n\t\tEncryptedPassword: encryptedPassword,\n\t\tCookie:            cookie,\n\t\tStatus:            \"active\",\n\t\tCreatedAt:         time.Now(),\n\t\tLoginAt:           time.Now(),\n\t\tExpireAt:          time.Now().AddDate(0, 4, 0), // 121天有效期\n\t\tLastAccessAt:      time.Now(),\n\t}\n\t\n\t// 保存scraper实例到内存\n\tp.scrapers.Store(hash, scraper)\n\t\n\tif err := p.saveUser(user); err != nil {\n\t\tfmt.Printf(\"[Gying] ❌ 保存账户失败: %v\\n\", err)\n\t\treturn\n\t}\n\n\tfmt.Printf(\"[Gying] ✅ 账户 %s 初始化成功 (来源:%s)\\n\", user.UsernameMasked, source)\n}\n\n// getUserByHash 获取用户\nfunc (p *GyingPlugin) getUserByHash(hash string) (*User, bool) {\n\tvalue, ok := p.users.Load(hash)\n\tif !ok {\n\t\treturn nil, false\n\t}\n\treturn value.(*User), true\n}\n\n// saveUser 保存用户\nfunc (p *GyingPlugin) saveUser(user *User) error {\n\tp.users.Store(user.Hash, user)\n\treturn p.persistUser(user)\n}\n\n// persistUser 持久化用户到文件\nfunc (p *GyingPlugin) persistUser(user *User) error {\n\tfilePath := filepath.Join(StorageDir, user.Hash+\".json\")\n\tdata, err := json.MarshalIndent(user, \"\", \"  \")\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn ioutil.WriteFile(filePath, data, 0644)\n}\n\n// deleteUser 删除用户\nfunc (p *GyingPlugin) deleteUser(hash string) error {\n\tp.users.Delete(hash)\n\tfilePath := filepath.Join(StorageDir, hash+\".json\")\n\treturn os.Remove(filePath)\n}\n\n// getActiveUsers 获取有效用户\nfunc (p *GyingPlugin) getActiveUsers() []*User {\n\tvar users []*User\n\t\n\tp.users.Range(func(key, value interface{}) bool {\n\t\tuser := value.(*User)\n\t\tif user.Status == \"active\" && user.Cookie != \"\" {\n\t\t\tusers = append(users, user)\n\t\t}\n\t\treturn true\n\t})\n\t\n\treturn users\n}\n\n// ============ HTTP路由处理 ============\n\n// handleManagePage GET路由处理\nfunc (p *GyingPlugin) handleManagePage(c *gin.Context) {\n\tparam := c.Param(\"param\")\n\n\t// 判断是用户名还是hash\n\tif len(param) == 64 && p.isHexString(param) {\n\t\thtml := strings.ReplaceAll(HTMLTemplate, \"HASH_PLACEHOLDER\", param)\n\t\tc.Data(200, \"text/html; charset=utf-8\", []byte(html))\n\t} else {\n\t\thash := p.generateHash(param)\n\t\tc.Redirect(302, \"/gying/\"+hash)\n\t}\n}\n\n// handleManagePagePOST POST路由处理\nfunc (p *GyingPlugin) handleManagePagePOST(c *gin.Context) {\n\thash := c.Param(\"param\")\n\n\tvar reqData map[string]interface{}\n\tif err := c.ShouldBindJSON(&reqData); err != nil {\n\t\trespondError(c, \"无效的请求格式: \"+err.Error())\n\t\treturn\n\t}\n\n\taction, ok := reqData[\"action\"].(string)\n\tif !ok || action == \"\" {\n\t\trespondError(c, \"缺少action字段\")\n\t\treturn\n\t}\n\n\tswitch action {\n\tcase \"get_status\":\n\t\tp.handleGetStatus(c, hash)\n\tcase \"login\":\n\t\tp.handleLogin(c, hash, reqData)\n\tcase \"logout\":\n\t\tp.handleLogout(c, hash)\n\tcase \"test_search\":\n\t\tp.handleTestSearch(c, hash, reqData)\n\tdefault:\n\t\trespondError(c, \"未知的操作类型: \"+action)\n\t}\n}\n\n// handleGetStatus 获取状态\nfunc (p *GyingPlugin) handleGetStatus(c *gin.Context, hash string) {\n\tuser, exists := p.getUserByHash(hash)\n\n\tif !exists {\n\t\tuser = &User{\n\t\t\tHash:         hash,\n\t\t\tStatus:       \"pending\",\n\t\t\tCreatedAt:    time.Now(),\n\t\t\tLastAccessAt: time.Now(),\n\t\t}\n\t\tp.saveUser(user)\n\t} else {\n\t\tuser.LastAccessAt = time.Now()\n\t\tp.saveUser(user)\n\t}\n\n\tloggedIn := false\n\tif user.Status == \"active\" && user.Cookie != \"\" {\n\t\tloggedIn = true\n\t}\n\n\texpiresInDays := 0\n\tif !user.ExpireAt.IsZero() {\n\t\texpiresInDays = int(time.Until(user.ExpireAt).Hours() / 24)\n\t\tif expiresInDays < 0 {\n\t\t\texpiresInDays = 0\n\t\t}\n\t}\n\n\trespondSuccess(c, \"获取成功\", gin.H{\n\t\t\"hash\":             hash,\n\t\t\"logged_in\":        loggedIn,\n\t\t\"status\":           user.Status,\n\t\t\"username_masked\":  user.UsernameMasked,\n\t\t\"login_time\":       user.LoginAt.Format(\"2006-01-02 15:04:05\"),\n\t\t\"expire_time\":      user.ExpireAt.Format(\"2006-01-02 15:04:05\"),\n\t\t\"expires_in_days\":  expiresInDays,\n\t})\n}\n\n// handleLogin 处理登录\nfunc (p *GyingPlugin) handleLogin(c *gin.Context, hash string, reqData map[string]interface{}) {\n\tusername, _ := reqData[\"username\"].(string)\n\tpassword, _ := reqData[\"password\"].(string)\n\n\tif username == \"\" || password == \"\" {\n\t\trespondError(c, \"缺少用户名或密码\")\n\t\treturn\n\t}\n\n\t// 执行登录\n\tscraper, cookie, err := p.doLogin(username, password)\n\tif err != nil {\n\t\trespondError(c, \"登录失败: \"+err.Error())\n\t\treturn\n\t}\n\n\t// 保存scraper实例到内存\n\tp.scrapers.Store(hash, scraper)\n\n\t// 加密密码\n\tencryptedPassword, err := p.encryptPassword(password)\n\tif err != nil {\n\t\trespondError(c, \"加密密码失败: \"+err.Error())\n\t\treturn\n\t}\n\t\n\t// 保存用户\n\tuser := &User{\n\t\tHash:              hash,\n\t\tUsername:          username,\n\t\tUsernameMasked:    p.maskUsername(username),\n\t\tEncryptedPassword: encryptedPassword,\n\t\tCookie:            cookie,\n\t\tStatus:            \"active\",\n\t\tLoginAt:           time.Now(),\n\t\tExpireAt:          time.Now().AddDate(0, 4, 0), // 121天\n\t\tLastAccessAt:      time.Now(),\n\t}\n\t\n\tif _, exists := p.getUserByHash(hash); !exists {\n\t\tuser.CreatedAt = time.Now()\n\t}\n\n\tif err := p.saveUser(user); err != nil {\n\t\trespondError(c, \"保存失败: \"+err.Error())\n\t\treturn\n\t}\n\n\trespondSuccess(c, \"登录成功\", gin.H{\n\t\t\"status\":          \"active\",\n\t\t\"username_masked\": user.UsernameMasked,\n\t})\n}\n\n// handleLogout 退出登录\nfunc (p *GyingPlugin) handleLogout(c *gin.Context, hash string) {\n\tuser, exists := p.getUserByHash(hash)\n\tif !exists {\n\t\trespondError(c, \"用户不存在\")\n\t\treturn\n\t}\n\n\tuser.Cookie = \"\"\n\tuser.Status = \"pending\"\n\n\tif err := p.saveUser(user); err != nil {\n\t\trespondError(c, \"退出失败\")\n\t\treturn\n\t}\n\n\trespondSuccess(c, \"已退出登录\", gin.H{\n\t\t\"status\": \"pending\",\n\t})\n}\n\n// handleTestSearch 测试搜索\nfunc (p *GyingPlugin) handleTestSearch(c *gin.Context, hash string, reqData map[string]interface{}) {\n\tkeyword, ok := reqData[\"keyword\"].(string)\n\tif !ok || keyword == \"\" {\n\t\trespondError(c, \"缺少keyword字段\")\n\t\treturn\n\t}\n\n\tuser, exists := p.getUserByHash(hash)\n\tif !exists || user.Cookie == \"\" {\n\t\trespondError(c, \"请先登录\")\n\t\treturn\n\t}\n\n\t// 获取scraper实例\n\tscraperVal, exists := p.scrapers.Load(hash)\n\tif !exists {\n\t\trespondError(c, \"用户scraper实例不存在，请重新登录\")\n\t\treturn\n\t}\n\t\n\tscraper, ok := scraperVal.(*cloudscraper.Scraper)\n\tif !ok || scraper == nil {\n\t\trespondError(c, \"scraper实例无效，请重新登录\")\n\t\treturn\n\t}\n\t\n\t// 执行搜索（带403自动重新登录）\n\tresults, err := p.searchWithScraperWithRetry(keyword, scraper, user)\n\tif err != nil {\n\t\trespondError(c, \"搜索失败: \"+err.Error())\n\t\treturn\n\t}\n\n\t// 限制返回数量\n\tmaxResults := 10\n\tif len(results) > maxResults {\n\t\tresults = results[:maxResults]\n\t}\n\n\t// 转换为前端格式\n\tfrontendResults := make([]gin.H, 0, len(results))\n\tfor _, r := range results {\n\t\tlinks := make([]gin.H, 0, len(r.Links))\n\t\tfor _, link := range r.Links {\n\t\t\tlinks = append(links, gin.H{\n\t\t\t\t\"type\":     link.Type,\n\t\t\t\t\"url\":      link.URL,\n\t\t\t\t\"password\": link.Password,\n\t\t\t})\n\t\t}\n\n\t\tfrontendResults = append(frontendResults, gin.H{\n\t\t\t\"title\": r.Title,\n\t\t\t\"links\": links,\n\t\t})\n\t}\n\n\trespondSuccess(c, fmt.Sprintf(\"找到 %d 条结果\", len(frontendResults)), gin.H{\n\t\t\"keyword\":       keyword,\n\t\t\"total_results\": len(frontendResults),\n\t\t\"results\":       frontendResults,\n\t})\n}\n\n// ============ 密码加密/解密 ============\n\n// encryptPassword 使用AES加密密码\nfunc (p *GyingPlugin) encryptPassword(password string) (string, error) {\n\t// 使用固定密钥（实际应用中可以使用配置或环境变量）\n\tkey := []byte(\"gying-secret-key-32bytes-long!!!\") // 32字节密钥用于AES-256\n\t\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\t\n\t// 创建GCM模式\n\tgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\t\n\t// 生成随机nonce\n\tnonce := make([]byte, gcm.NonceSize())\n\tif _, err := io.ReadFull(rand.Reader, nonce); err != nil {\n\t\treturn \"\", err\n\t}\n\t\n\t// 加密\n\tciphertext := gcm.Seal(nonce, nonce, []byte(password), nil)\n\t\n\t// 返回base64编码的密文\n\treturn base64.StdEncoding.EncodeToString(ciphertext), nil\n}\n\n// decryptPassword 解密密码\nfunc (p *GyingPlugin) decryptPassword(encrypted string) (string, error) {\n\t// 使用与加密相同的密钥\n\tkey := []byte(\"gying-secret-key-32bytes-long!!!\")\n\t\n\t// base64解码\n\tciphertext, err := base64.StdEncoding.DecodeString(encrypted)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\t\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\t\n\tgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\t\n\tnonceSize := gcm.NonceSize()\n\tif len(ciphertext) < nonceSize {\n\t\treturn \"\", fmt.Errorf(\"ciphertext too short\")\n\t}\n\t\n\tnonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]\n\tplaintext, err := gcm.Open(nil, nonce, ciphertext, nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\t\n\treturn string(plaintext), nil\n}\n\n// ============ Cookie管理 ============\n\n// createScraperWithCookies 创建一个带有指定cookies的cloudscraper实例\n// 使用反射访问内部的http.Client并设置cookies到cookiejar\n// 关键：禁用session refresh以防止cookies被清空\nfunc (p *GyingPlugin) createScraperWithCookies(cookieStr string) (*cloudscraper.Scraper, error) {\n\t// 创建cloudscraper实例，配置以保护cookies不被刷新\n\tscraper, err := cloudscraper.New(\n\t\tcloudscraper.WithSessionConfig(\n\t\t\tfalse,              // refreshOn403 = false，禁用403时自动刷新\n\t\t\t365*24*time.Hour,   // interval = 1年，基本不刷新\n\t\t\t0,                  // maxRetries = 0\n\t\t),\n\t)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建cloudscraper失败: %w\", err)\n\t}\n\t\n\t// 如果有保存的cookies，使用反射设置到scraper的内部http.Client\n\tif cookieStr != \"\" {\n\t\tcookies := parseCookieString(cookieStr)\n\t\t\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] 正在恢复 %d 个cookie到scraper实例\\n\", len(cookies))\n\t\t}\n\t\t\n\t\t// 使用反射访问scraper的unexported client字段\n\t\tscraperValue := reflect.ValueOf(scraper).Elem()\n\t\tclientField := scraperValue.FieldByName(\"client\")\n\t\t\n\t\tif clientField.IsValid() && !clientField.IsNil() {\n\t\t\t// 使用反射访问client (需要使用Elem()因为是指针)\n\t\t\tclientValue := reflect.NewAt(clientField.Type(), unsafe.Pointer(clientField.UnsafeAddr())).Elem()\n\t\t\tclient, ok := clientValue.Interface().(*http.Client)\n\t\t\t\n\t\t\tif ok && client != nil && client.Jar != nil {\n\t\t\t\t// 将cookies设置到cookiejar\n\t\t\t\t// 注意：必须使用正确的URL和cookie属性\n\t\t\t\tgyingURL, _ := url.Parse(\"https://www.gying.net\")\n\t\t\t\tvar httpCookies []*http.Cookie\n\t\t\t\t\n\t\t\t\tfor name, value := range cookies {\n\t\t\t\t\tcookie := &http.Cookie{\n\t\t\t\t\t\tName:   name,\n\t\t\t\t\t\tValue:  value,\n\t\t\t\t\t\t// 不设置Domain和Path，让cookiejar根据URL自动推导\n\t\t\t\t\t\t// cookiejar.SetCookies会根据提供的URL自动设置正确的Domain和Path\n\t\t\t\t\t}\n\t\t\t\t\thttpCookies = append(httpCookies, cookie)\n\t\t\t\t\t\n\t\t\t\t\tif DebugLog {\n\t\t\t\t\t\tfmt.Printf(\"[Gying]   准备恢复Cookie: %s=%s\\n\", \n\t\t\t\t\t\t\tcookie.Name, cookie.Value[:min(10, len(cookie.Value))])\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tclient.Jar.SetCookies(gyingURL, httpCookies)\n\t\t\t\t\n\t\t\t\t// 验证cookies是否被正确设置\n\t\t\t\tif DebugLog {\n\t\t\t\t\tstoredCookies := client.Jar.Cookies(gyingURL)\n\t\t\t\t\tfmt.Printf(\"[Gying] ✅ 成功恢复 %d 个cookie到scraper的cookiejar\\n\", len(cookies))\n\t\t\t\t\tfmt.Printf(\"[Gying] 验证: cookiejar中现有 %d 个cookie\\n\", len(storedCookies))\n\t\t\t\t\t\n\t\t\t\t\t// 详细打印每个cookie以便调试  \n\t\t\t\t\tfor i, c := range storedCookies {\n\t\t\t\t\t\tfmt.Printf(\"[Gying]   设置后Cookie[%d]: %s=%s (Domain:%s, Path:%s)\\n\", \n\t\t\t\t\t\t\ti, c.Name, c.Value[:min(10, len(c.Value))], c.Domain, c.Path)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[Gying] ⚠️  无法获取http.Client或其Jar\\n\")\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Gying] ⚠️  无法通过反射访问client字段\\n\")\n\t\t\t}\n\t\t}\n\t}\n\t\n\treturn scraper, nil\n}\n\n// parseCookieString 解析cookie字符串为map\nfunc parseCookieString(cookieStr string) map[string]string {\n\tcookies := make(map[string]string)\n\tparts := strings.Split(cookieStr, \";\")\n\t\n\tfor _, part := range parts {\n\t\tpart = strings.TrimSpace(part)\n\t\tif idx := strings.Index(part, \"=\"); idx > 0 {\n\t\t\tname := part[:idx]\n\t\t\tvalue := part[idx+1:]\n\t\t\tcookies[name] = value\n\t\t}\n\t}\n\t\n\treturn cookies\n}\n\n// ============ 登录逻辑 ============\n\n// doLogin 执行登录，返回scraper实例和cookie字符串\n// \n// 登录流程（3步）：\n//   1. GET登录页 (https://www.gying.net/user/login/) → 获取PHPSESSID\n//   2. POST登录  (https://www.gying.net/user/login)  → 获取BT_auth、BT_cookietime等认证cookies\n//   3. GET详情页 (https://www.gying.net/mv/wkMn)     → 触发防爬cookies (vrg_sc、vrg_go等)\n//\n// 返回: (*cloudscraper.Scraper, cookie字符串, error)\nfunc (p *GyingPlugin) doLogin(username, password string) (*cloudscraper.Scraper, string, error) {\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] ========== 开始登录 ==========\\n\")\n\t\tfmt.Printf(\"[Gying] 用户名: %s\\n\", username)\n\t\tfmt.Printf(\"[Gying] 密码长度: %d\\n\", len(password))\n\t}\n\n\t// 创建cloudscraper实例（每个用户独立的实例）\n\t// 关键配置：禁用403自动刷新,防止cookie被清空\n\tscraper, err := cloudscraper.New(\n\t\tcloudscraper.WithSessionConfig(\n\t\t\tfalse,              // refreshOn403 = false，禁用403时自动刷新（重要！）\n\t\t\t365*24*time.Hour,   // interval = 1年，基本不刷新\n\t\t\t0,                  // maxRetries = 0\n\t\t),\n\t)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] 创建cloudscraper失败: %v\\n\", err)\n\t\t}\n\t\treturn nil, \"\", fmt.Errorf(\"创建cloudscraper失败: %w\", err)\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] cloudscraper创建成功（已禁用403自动刷新）\\n\")\n\t}\n\n\t// 创建cookieMap用于收集所有cookies\n\tcookieMap := make(map[string]string)\n\t\n\t// ========== 步骤1: GET登录页 (获取初始PHPSESSID) ==========\n\tloginPageURL := \"https://www.gying.net/user/login/\"\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 步骤1: 访问登录页面: %s\\n\", loginPageURL)\n\t}\n\n\tgetResp, err := scraper.Get(loginPageURL)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] 访问登录页面失败: %v\\n\", err)\n\t\t}\n\t\treturn nil, \"\", fmt.Errorf(\"访问登录页面失败: %w\", err)\n\t}\n\tdefer getResp.Body.Close()\n\tioutil.ReadAll(getResp.Body) // 读取body\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 登录页面状态码: %d\\n\", getResp.StatusCode)\n\t}\n\t\n\t// 从登录页响应中收集cookies\n\tfor _, setCookie := range getResp.Header[\"Set-Cookie\"] {\n\t\tparts := strings.Split(setCookie, \";\")\n\t\tif len(parts) > 0 {\n\t\t\tcookiePart := strings.TrimSpace(parts[0])\n\t\t\tif idx := strings.Index(cookiePart, \"=\"); idx > 0 {\n\t\t\t\tname := cookiePart[:idx]\n\t\t\t\tvalue := cookiePart[idx+1:]\n\t\t\t\tcookieMap[name] = value\n\t\t\t\tif DebugLog {\n\t\t\t\t\tdisplayValue := value\n\t\t\t\t\tif len(displayValue) > 20 {\n\t\t\t\t\t\tdisplayValue = displayValue[:20] + \"...\"\n\t\t\t\t\t}\n\t\t\t\t\tfmt.Printf(\"[Gying]   登录页Cookie: %s=%s\\n\", name, displayValue)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// ========== 步骤2: POST登录 (获取认证cookies) ==========\n\tloginURL := \"https://www.gying.net/user/login\"\n\tpostData := fmt.Sprintf(\"code=&siteid=1&dosubmit=1&cookietime=10506240&username=%s&password=%s\",\n\t\turl.QueryEscape(username),\n\t\turl.QueryEscape(password))\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 步骤2: POST登录\\n\")\n\t\tfmt.Printf(\"[Gying] 登录URL: %s\\n\", loginURL)\n\t\tfmt.Printf(\"[Gying] POST数据: %s\\n\", postData)\n\t}\n\n\tresp, err := scraper.Post(loginURL, \"application/x-www-form-urlencoded\", strings.NewReader(postData))\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] 登录POST请求失败: %v\\n\", err)\n\t\t}\n\t\treturn nil, \"\", fmt.Errorf(\"登录POST请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 响应状态码: %d\\n\", resp.StatusCode)\n\t}\n\t\n\t// 从POST登录响应中收集cookies\n\tfor _, setCookie := range resp.Header[\"Set-Cookie\"] {\n\t\tparts := strings.Split(setCookie, \";\")\n\t\tif len(parts) > 0 {\n\t\t\tcookiePart := strings.TrimSpace(parts[0])\n\t\t\tif idx := strings.Index(cookiePart, \"=\"); idx > 0 {\n\t\t\t\tname := cookiePart[:idx]\n\t\t\t\tvalue := cookiePart[idx+1:]\n\t\t\t\tcookieMap[name] = value\n\t\t\t\tif DebugLog {\n\t\t\t\t\tdisplayValue := value\n\t\t\t\t\tif len(displayValue) > 20 {\n\t\t\t\t\t\tdisplayValue = displayValue[:20] + \"...\"\n\t\t\t\t\t}\n\t\t\t\t\tfmt.Printf(\"[Gying]   POST登录Cookie: %s=%s\\n\", name, displayValue)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 读取响应\n\tbody, err := ioutil.ReadAll(resp.Body)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] 读取响应失败: %v\\n\", err)\n\t\t}\n\t\treturn nil, \"\", fmt.Errorf(\"读取响应失败: %w\", err)\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 响应内容: %s\\n\", string(body))\n\t}\n\n\tvar loginResp map[string]interface{}\n\tif err := json.Unmarshal(body, &loginResp); err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] JSON解析失败: %v\\n\", err)\n\t\t}\n\t\treturn nil, \"\", fmt.Errorf(\"JSON解析失败: %w, 响应内容: %s\", err, string(body))\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 解析后的响应: %+v\\n\", loginResp)\n\t\tfmt.Printf(\"[Gying] code字段类型: %T, 值: %v\\n\", loginResp[\"code\"], loginResp[\"code\"])\n\t}\n\n\t// 检查登录结果（兼容多种类型：int、float64、json.Number、string）\n\tvar codeValue int\n\tcodeInterface := loginResp[\"code\"]\n\t\n\tswitch v := codeInterface.(type) {\n\tcase int:\n\t\tcodeValue = v\n\tcase float64:\n\t\tcodeValue = int(v)\n\tcase int64:\n\t\tcodeValue = int(v)\n\tdefault:\n\t\t// 尝试转换为字符串再解析\n\t\tcodeStr := fmt.Sprintf(\"%v\", codeInterface)\n\t\tparsed, err := strconv.Atoi(codeStr)\n\t\tif err != nil {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Gying] 无法解析code字段: %T, 值: %v, 错误: %v\\n\", codeInterface, codeInterface, err)\n\t\t\t}\n\t\t\treturn nil, \"\", fmt.Errorf(\"无法解析code字段，类型: %T, 值: %v\", codeInterface, codeInterface)\n\t\t}\n\t\tcodeValue = parsed\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 解析后的code值: %d\\n\", codeValue)\n\t}\n\n\tif codeValue != 200 {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] 登录失败: code=%d (期望200)\\n\", codeValue)\n\t\t}\n\t\treturn nil, \"\", fmt.Errorf(\"登录失败: code=%d, 响应=%s\", codeValue, string(body))\n\t}\n\n\t// ========== 步骤3: GET详情页 (触发防爬cookies如vrg_sc、vrg_go等) ==========\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 步骤3: GET详情页收集完整Cookie\\n\")\n\t}\n\t\n\tdetailResp, err := scraper.Get(\"https://www.gying.net/mv/wkMn\")\n\tif err == nil {\n\t\tdefer detailResp.Body.Close()\n\t\tioutil.ReadAll(detailResp.Body)\n\t\t\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] 详情页状态码: %d\\n\", detailResp.StatusCode)\n\t\t}\n\t\t\n\t\t// 从详情页响应中收集cookies\n\t\tfor _, setCookie := range detailResp.Header[\"Set-Cookie\"] {\n\t\t\tparts := strings.Split(setCookie, \";\")\n\t\t\tif len(parts) > 0 {\n\t\t\t\tcookiePart := strings.TrimSpace(parts[0])\n\t\t\t\tif idx := strings.Index(cookiePart, \"=\"); idx > 0 {\n\t\t\t\t\tname := cookiePart[:idx]\n\t\t\t\t\tvalue := cookiePart[idx+1:]\n\t\t\t\t\tcookieMap[name] = value\n\t\t\t\t\tif DebugLog {\n\t\t\t\t\t\tdisplayValue := value\n\t\t\t\t\t\tif len(displayValue) > 30 {\n\t\t\t\t\t\t\tdisplayValue = displayValue[:30] + \"...\"\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfmt.Printf(\"[Gying]   详情页Cookie: %s=%s\\n\", name, displayValue)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 构建cookie字符串\n\tvar cookieParts []string\n\tfor name, value := range cookieMap {\n\t\tcookieParts = append(cookieParts, fmt.Sprintf(\"%s=%s\", name, value))\n\t}\n\tcookieStr := strings.Join(cookieParts, \"; \")\n\t\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] ✅ 登录成功！提取到 %d 个Cookie\\n\", len(cookieMap))\n\t\tfmt.Printf(\"[Gying] Cookie字符串长度: %d\\n\", len(cookieStr))\n\t\tfor name, value := range cookieMap {\n\t\t\tdisplayValue := value\n\t\t\tif len(displayValue) > 30 {\n\t\t\t\tdisplayValue = displayValue[:30] + \"...\"\n\t\t\t}\n\t\t\tfmt.Printf(\"[Gying]   %s=%s (len:%d)\\n\", name, displayValue, len(value))\n\t\t}\n\t\tfmt.Printf(\"[Gying] ========== 登录完成 ==========\\n\")\n\t}\n\n\t// 返回scraper实例和实际的cookie字符串\n\treturn scraper, cookieStr, nil\n}\n\n// min 辅助函数\nfunc min(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}\n\n// ============ 重新登录逻辑 ============\n\n// reloginUser 重新登录指定用户\nfunc (p *GyingPlugin) reloginUser(user *User) error {\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 🔄 开始重新登录用户: %s\\n\", user.UsernameMasked)\n\t}\n\t\n\t// 解密密码\n\tpassword, err := p.decryptPassword(user.EncryptedPassword)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] ❌ 解密密码失败: %v\\n\", err)\n\t\t}\n\t\treturn fmt.Errorf(\"解密密码失败: %w\", err)\n\t}\n\t\n\t// 执行登录\n\tscraper, cookie, err := p.doLogin(user.Username, password)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] ❌ 重新登录失败: %v\\n\", err)\n\t\t}\n\t\treturn fmt.Errorf(\"重新登录失败: %w\", err)\n\t}\n\t\n\t// 更新scraper实例\n\tp.scrapers.Store(user.Hash, scraper)\n\t\n\t// 更新用户信息\n\tuser.Cookie = cookie\n\tuser.LoginAt = time.Now()\n\tuser.ExpireAt = time.Now().AddDate(0, 4, 0)\n\tuser.Status = \"active\"\n\t\n\tif err := p.saveUser(user); err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] ⚠️  保存用户失败: %v\\n\", err)\n\t\t}\n\t}\n\t\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] ✅ 用户 %s 重新登录成功\\n\", user.UsernameMasked)\n\t}\n\t\n\treturn nil\n}\n\n// ============ 搜索逻辑 ============\n\n// executeSearchTasks 并发执行搜索任务\nfunc (p *GyingPlugin) executeSearchTasks(users []*User, keyword string) []model.SearchResult {\n\tvar allResults []model.SearchResult\n\tvar mu sync.Mutex\n\tvar wg sync.WaitGroup\n\n\tfor _, user := range users {\n\t\twg.Add(1)\n\t\tgo func(u *User) {\n\t\t\tdefer wg.Done()\n\n\t\t\t// 获取用户的scraper实例\n\t\t\tscraperVal, exists := p.scrapers.Load(u.Hash)\n\t\t\tvar scraper *cloudscraper.Scraper\n\t\t\t\n\t\t\tif !exists {\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[Gying] 用户 %s 没有scraper实例，尝试使用已保存的cookie创建\\n\", u.UsernameMasked)\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// 使用已保存的cookie创建scraper实例（关键！）\n\t\t\t\tnewScraper, err := p.createScraperWithCookies(u.Cookie)\n\t\t\t\tif err != nil {\n\t\t\t\t\tif DebugLog {\n\t\t\t\t\t\tfmt.Printf(\"[Gying] 为用户 %s 创建scraper失败: %v\\n\", u.UsernameMasked, err)\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// 存储新创建的scraper实例\n\t\t\t\tp.scrapers.Store(u.Hash, newScraper)\n\t\t\t\tscraper = newScraper\n\t\t\t\t\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[Gying] 已为用户 %s 恢复scraper实例（含cookie）\\n\", u.UsernameMasked)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tvar ok bool\n\t\t\t\tscraper, ok = scraperVal.(*cloudscraper.Scraper)\n\t\t\t\tif !ok || scraper == nil {\n\t\t\t\t\tif DebugLog {\n\t\t\t\t\t\tfmt.Printf(\"[Gying] 用户 %s scraper实例无效，跳过\\n\", u.UsernameMasked)\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tresults, err := p.searchWithScraperWithRetry(keyword, scraper, u)\n\t\t\tif err != nil {\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[Gying] 用户 %s 搜索失败（已重试）: %v\\n\", u.UsernameMasked, err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tmu.Lock()\n\t\t\tallResults = append(allResults, results...)\n\t\t\tmu.Unlock()\n\t\t}(user)\n\t}\n\n\twg.Wait()\n\n\t// 去重\n\treturn p.deduplicateResults(allResults)\n}\n\n// searchWithScraperWithRetry 使用scraper搜索（带403自动重新登录重试）\nfunc (p *GyingPlugin) searchWithScraperWithRetry(keyword string, scraper *cloudscraper.Scraper, user *User) ([]model.SearchResult, error) {\n\tresults, err := p.searchWithScraper(keyword, scraper)\n\t\n\t// 检测是否为403错误\n\tif err != nil && strings.Contains(err.Error(), \"403\") {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] ⚠️  检测到403错误，尝试重新登录用户 %s\\n\", user.UsernameMasked)\n\t\t}\n\t\t\n\t\t// 尝试重新登录\n\t\tif reloginErr := p.reloginUser(user); reloginErr != nil {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Gying] ❌ 重新登录失败: %v\\n\", reloginErr)\n\t\t\t}\n\t\t\treturn nil, fmt.Errorf(\"403错误且重新登录失败: %w\", reloginErr)\n\t\t}\n\t\t\n\t\t// 获取新的scraper实例\n\t\tscraperVal, exists := p.scrapers.Load(user.Hash)\n\t\tif !exists {\n\t\t\treturn nil, fmt.Errorf(\"重新登录后未找到scraper实例\")\n\t\t}\n\t\t\n\t\tnewScraper, ok := scraperVal.(*cloudscraper.Scraper)\n\t\tif !ok || newScraper == nil {\n\t\t\treturn nil, fmt.Errorf(\"重新登录后scraper实例无效\")\n\t\t}\n\t\t\n\t\t// 使用新scraper重试搜索\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] 🔄 使用新登录状态重试搜索\\n\")\n\t\t}\n\t\tresults, err = p.searchWithScraper(keyword, newScraper)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"重新登录后搜索仍然失败: %w\", err)\n\t\t}\n\t}\n\t\n\treturn results, err\n}\n\n// searchWithScraper 使用scraper搜索\nfunc (p *GyingPlugin) searchWithScraper(keyword string, scraper *cloudscraper.Scraper) ([]model.SearchResult, error) {\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] ---------- searchWithScraper 开始 ----------\\n\")\n\t\tfmt.Printf(\"[Gying] 关键词: %s\\n\", keyword)\n\t}\n\n\t// 1. 使用cloudscraper请求搜索页面\n\tsearchURL := fmt.Sprintf(\"https://www.gying.net/s/2-0--1/%s\", url.QueryEscape(keyword))\n\t\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 搜索URL: %s\\n\", searchURL)\n\t\tfmt.Printf(\"[Gying] 使用cloudscraper发送请求\\n\")\n\t}\n\n\tresp, err := scraper.Get(searchURL)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] 搜索请求失败: %v\\n\", err)\n\t\t}\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 搜索响应状态码: %d\\n\", resp.StatusCode)\n\t}\n\t\n\t// 读取响应body\n\tbody, err := ioutil.ReadAll(resp.Body)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] 读取响应失败: %v\\n\", err)\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 响应Body长度: %d 字节\\n\", len(body))\n\t\tif len(body) > 0 {\n\t\t\t// 打印前500字符\n\t\t\tpreview := string(body)\n\t\t\tif len(preview) > 500 {\n\t\t\t\tpreview = preview[:500] + \"...\"\n\t\t\t}\n\t\t\tfmt.Printf(\"[Gying] 响应预览: %s\\n\", preview)\n\t\t}\n\t}\n\t\n\t// 检查403错误\n\tif resp.StatusCode == 403 {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] ❌ 收到403 Forbidden - Cookie可能已过期或被网站拒绝\\n\")\n\t\t\tif len(body) > 0 {\n\t\t\t\tpreview := string(body)\n\t\t\t\tif len(preview) > 300 {\n\t\t\t\t\tpreview = preview[:300] + \"...\"\n\t\t\t\t}\n\t\t\t\tfmt.Printf(\"[Gying] 403响应内容: %s\\n\", preview)\n\t\t\t}\n\t\t}\n\t\treturn nil, fmt.Errorf(\"HTTP 403 Forbidden - 可能需要重新登录\")\n\t}\n\n\t// 2. 提取 _obj.search JSON\n\tre := regexp.MustCompile(`_obj\\.search=(\\{.*?\\});`)\n\tmatches := re.FindSubmatch(body)\n\t\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 正则匹配结果: 找到 %d 个匹配\\n\", len(matches))\n\t}\n\n\tif len(matches) < 2 {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] ❌ 未找到 _obj.search JSON数据\\n\")\n\t\t\t// 尝试查找是否有其他模式\n\t\t\tif strings.Contains(string(body), \"_obj.search\") {\n\t\t\t\tfmt.Printf(\"[Gying] 但是Body中包含 '_obj.search' 字符串\\n\")\n\t\t\t} else {\n\t\t\t\tfmt.Printf(\"[Gying] Body中不包含 '_obj.search' 字符串\\n\")\n\t\t\t}\n\t\t}\n\t\treturn nil, fmt.Errorf(\"未找到搜索结果数据\")\n\t}\n\n\tif DebugLog {\n\t\tjsonStr := string(matches[1])\n\t\tif len(jsonStr) > 200 {\n\t\t\tjsonStr = jsonStr[:200] + \"...\"\n\t\t}\n\t\tfmt.Printf(\"[Gying] 提取的JSON数据: %s\\n\", jsonStr)\n\t}\n\n\tvar searchData SearchData\n\tif err := json.Unmarshal(matches[1], &searchData); err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] JSON解析失败: %v\\n\", err)\n\t\t\tfmt.Printf(\"[Gying] 原始JSON: %s\\n\", string(matches[1]))\n\t\t}\n\t\treturn nil, fmt.Errorf(\"解析搜索数据失败: %w\", err)\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 搜索数据解析成功:\\n\")\n\t\tfmt.Printf(\"[Gying]   - 关键词: %s\\n\", searchData.Q)\n\t\tfmt.Printf(\"[Gying]   - 结果数量字符串: %s\\n\", searchData.N)\n\t\tfmt.Printf(\"[Gying]   - 资源ID数组长度: %d\\n\", len(searchData.L.I))\n\t\tfmt.Printf(\"[Gying]   - 标题数组长度: %d\\n\", len(searchData.L.Title))\n\t\tif len(searchData.L.I) > 0 {\n\t\t\tfmt.Printf(\"[Gying]   - 前3个资源ID: %v\\n\", searchData.L.I[:min(3, len(searchData.L.I))])\n\t\t\tfmt.Printf(\"[Gying]   - 前3个标题: %v\\n\", searchData.L.Title[:min(3, len(searchData.L.Title))])\n\t\t}\n\t}\n\n\t// 3. 刷新防爬cookies（关键！访问详情页触发vrg_sc、vrg_go等防爬cookies）\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] 刷新防爬cookies...\\n\")\n\t}\n\trefreshResp, err := scraper.Get(\"https://www.gying.net/mv/wkMn\")\n\tif err == nil && refreshResp != nil {\n\t\trefreshResp.Body.Close()\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] 防爬cookies刷新成功 (状态码: %d)\\n\", refreshResp.StatusCode)\n\t\t}\n\t}\n\t\n\t// 4. 并发请求详情接口\n\tresults, err := p.fetchAllDetails(&searchData, scraper, keyword)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] fetchAllDetails 失败: %v\\n\", err)\n\t\t\tfmt.Printf(\"[Gying] ---------- searchWithScraper 结束 ----------\\n\")\n\t\t}\n\t\treturn nil, err\n\t}\n\t\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] fetchAllDetails 返回 %d 条结果\\n\", len(results))\n\t\tfmt.Printf(\"[Gying] ---------- searchWithScraper 结束 ----------\\n\")\n\t}\n\n\treturn results, nil\n}\n\n// fetchAllDetails 并发获取所有详情\nfunc (p *GyingPlugin) fetchAllDetails(searchData *SearchData, scraper *cloudscraper.Scraper, keyword string) ([]model.SearchResult, error) {\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] >>> fetchAllDetails 开始\\n\")\n\t\tfmt.Printf(\"[Gying] 需要获取 %d 个详情，关键词: %s\\n\", len(searchData.L.I), keyword)\n\t}\n\n\tvar results []model.SearchResult\n\tvar mu sync.Mutex\n\tvar wg sync.WaitGroup\n\n\tsemaphore := make(chan struct{}, MaxConcurrentDetails)\n\terrChan := make(chan error, 1) // 用于接收403错误\n\n\tsuccessCount := 0\n\tfailCount := 0\n\thas403 := false\n\t\n\t// 将关键词转为小写，用于不区分大小写的匹配\n\tkeywordLower := strings.ToLower(keyword)\n\n\tfor i := 0; i < len(searchData.L.I); i++ {\n\t\twg.Add(1)\n\t\tgo func(index int) {\n\t\t\tdefer wg.Done()\n\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\n\t\t\t// 检查是否已经遇到403错误\n\t\t\tmu.Lock()\n\t\t\tif has403 {\n\t\t\t\tmu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tmu.Unlock()\n\n\t\t\t// 检查标题是否包含搜索关键词\n\t\t\tif index >= len(searchData.L.Title) {\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[Gying]   [%d/%d] ⏭️  跳过: 索引超出标题数组范围\\n\", \n\t\t\t\t\t\tindex+1, len(searchData.L.I))\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\ttitle := searchData.L.Title[index]\n\t\t\ttitleLower := strings.ToLower(title)\n\t\t\tif !strings.Contains(titleLower, keywordLower) {\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[Gying]   [%d/%d] ⏭️  跳过: 标题不包含关键词 '%s' (标题: %s)\\n\", \n\t\t\t\t\t\tindex+1, len(searchData.L.I), keyword, title)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Gying]   [%d/%d] 获取详情: ID=%s, Type=%s, 标题=%s\\n\", \n\t\t\t\t\tindex+1, len(searchData.L.I), searchData.L.I[index], searchData.L.D[index], title)\n\t\t\t}\n\n\t\t\tdetail, err := p.fetchDetail(searchData.L.I[index], searchData.L.D[index], scraper)\n\t\t\tif err != nil {\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[Gying]   [%d/%d] ❌ 获取详情失败: %v\\n\", index+1, len(searchData.L.I), err)\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// 检查是否是403错误\n\t\t\t\tif strings.Contains(err.Error(), \"403\") {\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\tif !has403 {\n\t\t\t\t\t\thas403 = true\n\t\t\t\t\t\tselect {\n\t\t\t\t\t\tcase errChan <- err:\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tmu.Lock()\n\t\t\t\tfailCount++\n\t\t\t\tmu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tresult := p.buildResult(detail, searchData, index)\n\t\t\tif result.Title != \"\" && len(result.Links) > 0 {\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[Gying]   [%d/%d] ✅ 成功: %s (%d个链接)\\n\", \n\t\t\t\t\t\tindex+1, len(searchData.L.I), result.Title, len(result.Links))\n\t\t\t\t}\n\t\t\t\tmu.Lock()\n\t\t\t\tresults = append(results, result)\n\t\t\t\tsuccessCount++\n\t\t\t\tmu.Unlock()\n\t\t\t} else {\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[Gying]   [%d/%d] ⚠️  跳过: 标题或链接为空 (标题:%s, 链接数:%d)\\n\", \n\t\t\t\t\t\tindex+1, len(searchData.L.I), result.Title, len(result.Links))\n\t\t\t\t}\n\t\t\t}\n\t\t}(i)\n\t}\n\n\twg.Wait()\n\n\t// 检查是否有403错误\n\tselect {\n\tcase err := <-errChan:\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] <<< fetchAllDetails 检测到403错误，需要重新登录\\n\")\n\t\t}\n\t\treturn nil, err\n\tdefault:\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] <<< fetchAllDetails 完成: 成功=%d, 失败=%d, 总计=%d\\n\", \n\t\t\tsuccessCount, failCount, len(searchData.L.I))\n\t}\n\n\treturn results, nil\n}\n\n// fetchDetail 获取详情\nfunc (p *GyingPlugin) fetchDetail(resourceID, resourceType string, scraper *cloudscraper.Scraper) (*DetailData, error) {\n\tdetailURL := fmt.Sprintf(\"https://www.gying.net/res/downurl/%s/%s\", resourceType, resourceID)\n\t\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying]     fetchDetail: %s\\n\", detailURL)\n\t}\n\n\t// 使用cloudscraper发送请求（自动管理Cookie和绕过反爬虫）\n\tresp, err := scraper.Get(detailURL)\n\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying]     请求失败: %v\\n\", err)\n\t\t}\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying]     响应状态码: %d\\n\", resp.StatusCode)\n\t}\n\n\t// 检查403错误\n\tif resp.StatusCode == 403 {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying]     ❌ 详情接口返回403 - Cookie可能已过期\\n\")\n\t\t}\n\t\treturn nil, fmt.Errorf(\"HTTP 403 Forbidden\")\n\t}\n\n\tif resp.StatusCode != 200 {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying]     ❌ HTTP错误: %d\\n\", resp.StatusCode)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"HTTP %d\", resp.StatusCode)\n\t}\n\n\tbody, err := ioutil.ReadAll(resp.Body)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying]     读取响应失败: %v\\n\", err)\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying]     响应长度: %d 字节\\n\", len(body))\n\t}\n\n\tvar detail DetailData\n\tif err := json.Unmarshal(body, &detail); err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying]     JSON解析失败: %v\\n\", err)\n\t\t\t// 打印前200字符\n\t\t\tpreview := string(body)\n\t\t\tif len(preview) > 200 {\n\t\t\t\tpreview = preview[:200] + \"...\"\n\t\t\t}\n\t\t\tfmt.Printf(\"[Gying]     响应内容: %s\\n\", preview)\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying]     详情Code: %d, 网盘链接数: %d\\n\", detail.Code, len(detail.Panlist.URL))\n\t}\n\n\t// 检查JSON响应中的code字段（关键！）\n\tif detail.Code == 403 {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying]     ❌ 详情接口返回Code=403 - 登录状态可能已失效\\n\")\n\t\t}\n\t\treturn nil, fmt.Errorf(\"Detail API returned code 403 - authentication may have expired\")\n\t}\n\n\treturn &detail, nil\n}\n\n// buildResult 构建SearchResult\nfunc (p *GyingPlugin) buildResult(detail *DetailData, searchData *SearchData, index int) model.SearchResult {\n\tif index >= len(searchData.L.Title) {\n\t\treturn model.SearchResult{}\n\t}\n\n\ttitle := searchData.L.Title[index]\n\tresourceType := searchData.L.D[index]\n\tresourceID := searchData.L.I[index]\n\t\n\t// 获取年份并拼接到标题后面\n\tvar year int\n\tif index < len(searchData.L.Year) && searchData.L.Year[index] > 0 {\n\t\tyear = searchData.L.Year[index]\n\t\t// 拼接年份到标题：遮天（2023）\n\t\ttitle = fmt.Sprintf(\"%s（%d）\", title, year)\n\t}\n\n\t// 构建描述\n\tvar contentParts []string\n\tif index < len(searchData.L.Info) && searchData.L.Info[index] != \"\" {\n\t\tcontentParts = append(contentParts, searchData.L.Info[index])\n\t}\n\tif index < len(searchData.L.Daoyan) && searchData.L.Daoyan[index] != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"导演: %s\", searchData.L.Daoyan[index]))\n\t}\n\tif index < len(searchData.L.Zhuyan) && searchData.L.Zhuyan[index] != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"主演: %s\", searchData.L.Zhuyan[index]))\n\t}\n\n\t// 提取网盘链接\n\tlinks := p.extractPanLinks(detail)\n\n\t// 构建标签（保留年份标签，提供额外的过滤维度）\n\tvar tags []string\n\tif year > 0 {\n\t\ttags = append(tags, fmt.Sprintf(\"%d\", year))\n\t}\n\n\t// 从网盘时间数组中选择最新的时间（最小的相对时间值）\n\t// 检查 detail 是否为 nil\n\tvar datetime time.Time\n\tif detail == nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] buildResult: detail为nil，使用当前时间\\n\")\n\t\t}\n\t\tdatetime = time.Now()\n\t} else {\n\t\tdatetime = p.parseUpdateTime(detail.Panlist.Time)\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] buildResult时间解析: 时间数组长度=%d, 解析后时间=%v\\n\", \n\t\t\t\tlen(detail.Panlist.Time), datetime.Format(\"2006-01-02 15:04:05\"))\n\t\t\tif len(detail.Panlist.Time) > 0 {\n\t\t\t\tfmt.Printf(\"[Gying]   前3个时间字符串: %v\\n\", detail.Panlist.Time[:min(3, len(detail.Panlist.Time))])\n\t\t\t}\n\t\t}\n\t}\n\n\treturn model.SearchResult{\n\t\tUniqueID: fmt.Sprintf(\"gying-%s-%s\", resourceType, resourceID),\n\t\tTitle:    title,\n\t\tContent:  strings.Join(contentParts, \" | \"),\n\t\tLinks:    links,\n\t\tTags:     tags,\n\t\tChannel:  \"\", // 插件搜索结果Channel为空\n\t\tDatetime: datetime,\n\t}\n}\n\n// parseUpdateTime 解析网盘更新时间数组，返回最新的更新时间\n// 时间字符串格式：[\"今天\", \"昨天\", \"2天前\", \"1月前\", \"1年前\"] 等\nfunc (p *GyingPlugin) parseUpdateTime(timeStrs []string) time.Time {\n\t// 处理 nil slice 的情况\n\tif timeStrs == nil || len(timeStrs) == 0 {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] parseUpdateTime: 时间数组为空或nil，返回当前时间\\n\")\n\t\t}\n\t\t// 如果没有时间信息，返回当前时间\n\t\treturn time.Now()\n\t}\n\n\tnow := time.Now()\n\tvar latestTime *time.Time\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] parseUpdateTime: 开始解析 %d 个时间字符串\\n\", len(timeStrs))\n\t}\n\n\t// 遍历所有时间字符串，找到最新的（最接近当前时间的）那个\n\tfor i, timeStr := range timeStrs {\n\t\tif timeStr == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tparsedTime := p.parseRelativeTime(timeStr, now)\n\t\tif parsedTime != nil {\n\t\t\tif DebugLog && i < 5 { // 只打印前5个，避免日志过多\n\t\t\t\tfmt.Printf(\"[Gying]   [%d] '%s' -> %v\\n\", i, timeStr, parsedTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t}\n\t\t\t// 找到最接近当前时间的（最新的）\n\t\t\tif latestTime == nil || parsedTime.After(*latestTime) {\n\t\t\t\tlatestTime = parsedTime\n\t\t\t}\n\t\t} else {\n\t\t\tif DebugLog && i < 5 {\n\t\t\t\tfmt.Printf(\"[Gying]   [%d] '%s' -> 解析失败\\n\", i, timeStr)\n\t\t\t}\n\t\t}\n\t}\n\n\t// 如果解析失败，返回当前时间\n\tif latestTime == nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Gying] parseUpdateTime: 所有时间解析失败，返回当前时间\\n\")\n\t\t\t// 输出前几个时间字符串用于调试\n\t\t\tif len(timeStrs) > 0 {\n\t\t\t\tfmt.Printf(\"[Gying]   前3个时间字符串: %v\\n\", timeStrs[:min(3, len(timeStrs))])\n\t\t\t}\n\t\t}\n\t\treturn time.Now()\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Gying] parseUpdateTime: 最终选择时间 %v\\n\", latestTime.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn *latestTime\n}\n\n// parseRelativeTime 解析单个相对时间字符串，返回对应的time.Time\n// 支持格式：今天、昨天、N天前、N月前、N年前\nfunc (p *GyingPlugin) parseRelativeTime(timeStr string, baseTime time.Time) *time.Time {\n\ttimeStr = strings.TrimSpace(timeStr)\n\tif timeStr == \"\" {\n\t\treturn nil\n\t}\n\n\tswitch timeStr {\n\tcase \"今天\":\n\t\tt := baseTime.Truncate(24 * time.Hour)\n\t\treturn &t\n\tcase \"昨天\":\n\t\tt := baseTime.AddDate(0, 0, -1).Truncate(24 * time.Hour)\n\t\treturn &t\n\tdefault:\n\t\t// 解析 \"N天前\"、\"N月前\"、\"N年前\" 格式\n\t\tif strings.HasSuffix(timeStr, \"天前\") {\n\t\t\tdaysStr := strings.TrimSuffix(timeStr, \"天前\")\n\t\t\tdays, err := strconv.Atoi(daysStr)\n\t\t\tif err == nil && days >= 0 {\n\t\t\t\tt := baseTime.AddDate(0, 0, -days).Truncate(24 * time.Hour)\n\t\t\t\treturn &t\n\t\t\t}\n\t\t} else if strings.HasSuffix(timeStr, \"月前\") {\n\t\t\tmonthsStr := strings.TrimSuffix(timeStr, \"月前\")\n\t\t\tmonths, err := strconv.Atoi(monthsStr)\n\t\t\tif err == nil && months >= 0 {\n\t\t\t\tt := baseTime.AddDate(0, -months, 0).Truncate(24 * time.Hour)\n\t\t\t\treturn &t\n\t\t\t}\n\t\t} else if strings.HasSuffix(timeStr, \"年前\") {\n\t\t\tyearsStr := strings.TrimSuffix(timeStr, \"年前\")\n\t\t\tyears, err := strconv.Atoi(yearsStr)\n\t\t\tif err == nil && years >= 0 {\n\t\t\t\tt := baseTime.AddDate(-years, 0, 0).Truncate(24 * time.Hour)\n\t\t\t\treturn &t\n\t\t\t}\n\t\t}\n\t}\n\n\t// 无法解析，返回nil\n\treturn nil\n}\n\n// extractPanLinks 提取网盘链接\nfunc (p *GyingPlugin) extractPanLinks(detail *DetailData) []model.Link {\n\tvar links []model.Link\n\tseen := make(map[string]bool)\n\n\tfor i := 0; i < len(detail.Panlist.URL); i++ {\n\t\tlinkURL := strings.TrimSpace(detail.Panlist.URL[i])\n\t\t\n\t\t// 去除URL中的访问码标记\n\t\tlinkURL = regexp.MustCompile(`（访问码：.*?）`).ReplaceAllString(linkURL, \"\")\n\t\tlinkURL = regexp.MustCompile(`\\(访问码：.*?\\)`).ReplaceAllString(linkURL, \"\")\n\t\tlinkURL = strings.TrimSpace(linkURL)\n\n\t\tif linkURL == \"\" || seen[linkURL] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[linkURL] = true\n\n\t\t// 识别网盘类型\n\t\tlinkType := p.determineLinkType(linkURL)\n\t\tif linkType == \"others\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 提取提取码\n\t\tpassword := \"\"\n\t\tif i < len(detail.Panlist.P) && detail.Panlist.P[i] != \"\" {\n\t\t\tpassword = detail.Panlist.P[i]\n\t\t}\n\t\t\n\t\t// 从URL提取提取码（优先）\n\t\tif urlPwd := p.extractPasswordFromURL(linkURL); urlPwd != \"\" {\n\t\t\tpassword = urlPwd\n\t\t}\n\n\t\t// 解析对应的时间\n\t\tvar linkDatetime time.Time\n\t\tif i < len(detail.Panlist.Time) && detail.Panlist.Time[i] != \"\" {\n\t\t\ttimeStr := detail.Panlist.Time[i]\n\t\t\tparsedTime := p.parseRelativeTime(timeStr, time.Now())\n\t\t\tif parsedTime != nil {\n\t\t\t\tlinkDatetime = *parsedTime\n\t\t\t}\n\t\t\t// 如果解析失败，保持为零值，合并逻辑会使用result.Datetime\n\t\t}\n\t\t// 如果没有时间信息，保持为零值，合并逻辑会使用result.Datetime\n\n\t\tlinks = append(links, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      linkURL,\n\t\t\tPassword: password,\n\t\t\tDatetime: linkDatetime,\n\t\t})\n\t}\n\n\treturn links\n}\n\n// determineLinkType 识别网盘类型\nfunc (p *GyingPlugin) determineLinkType(linkURL string) string {\n\tswitch {\n\tcase strings.Contains(linkURL, \"pan.quark.cn\"):\n\t\treturn \"quark\"\n\tcase strings.Contains(linkURL, \"drive.uc.cn\"):\n\t\treturn \"uc\"\n\tcase strings.Contains(linkURL, \"pan.baidu.com\"):\n\t\treturn \"baidu\"\n\tcase strings.Contains(linkURL, \"aliyundrive.com\") || strings.Contains(linkURL, \"alipan.com\"):\n\t\treturn \"aliyun\"\n\tcase strings.Contains(linkURL, \"pan.xunlei.com\"):\n\t\treturn \"xunlei\"\n\tcase strings.Contains(linkURL, \"cloud.189.cn\"):\n\t\treturn \"tianyi\"\n\tcase strings.Contains(linkURL, \"115.com\") || strings.Contains(linkURL, \"115cdn.com\") || strings.Contains(linkURL, \"anxia.com\"):\n\t\treturn \"115\"\n\tcase strings.Contains(linkURL, \"123684.com\") || strings.Contains(linkURL, \"123685.com\") || \n\t\tstrings.Contains(linkURL, \"123912.com\") || strings.Contains(linkURL, \"123pan.com\") || \n\t\tstrings.Contains(linkURL, \"123pan.cn\") || strings.Contains(linkURL, \"123592.com\"): \n\t\treturn \"123\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n// extractPasswordFromURL 从URL提取提取码\nfunc (p *GyingPlugin) extractPasswordFromURL(linkURL string) string {\n\t// 百度网盘: ?pwd=xxxx\n\tif strings.Contains(linkURL, \"?pwd=\") {\n\t\tre := regexp.MustCompile(`\\?pwd=([a-zA-Z0-9]+)`)\n\t\tif matches := re.FindStringSubmatch(linkURL); len(matches) > 1 {\n\t\t\treturn matches[1]\n\t\t}\n\t}\n\t\n\t// 115网盘: ?password=xxxx\n\tif strings.Contains(linkURL, \"?password=\") {\n\t\tre := regexp.MustCompile(`\\?password=([a-zA-Z0-9]+)`)\n\t\tif matches := re.FindStringSubmatch(linkURL); len(matches) > 1 {\n\t\t\treturn matches[1]\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\n// deduplicateResults 去重\nfunc (p *GyingPlugin) deduplicateResults(results []model.SearchResult) []model.SearchResult {\n\tseen := make(map[string]bool)\n\tvar deduplicated []model.SearchResult\n\n\tfor _, result := range results {\n\t\tif !seen[result.UniqueID] {\n\t\t\tseen[result.UniqueID] = true\n\t\t\tdeduplicated = append(deduplicated, result)\n\t\t}\n\t}\n\n\treturn deduplicated\n}\n\n// ============ 工具函数 ============\n\n// generateHash 生成hash\nfunc (p *GyingPlugin) generateHash(username string) string {\n\tsalt := os.Getenv(\"GYING_HASH_SALT\")\n\tif salt == \"\" {\n\t\tsalt = \"pansou_gying_secret_2025\"\n\t}\n\tdata := username + salt\n\thash := sha256.Sum256([]byte(data))\n\treturn hex.EncodeToString(hash[:])\n}\n\n// maskUsername 脱敏用户名\nfunc (p *GyingPlugin) maskUsername(username string) string {\n\tif len(username) <= 2 {\n\t\treturn username\n\t}\n\tif len(username) <= 4 {\n\t\treturn username[:1] + \"**\" + username[len(username)-1:]\n\t}\n\treturn username[:2] + \"****\" + username[len(username)-2:]\n}\n\n// isHexString 判断是否为十六进制\nfunc (p *GyingPlugin) isHexString(s string) bool {\n\tfor _, c := range s {\n\t\tif !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// respondSuccess 成功响应\nfunc respondSuccess(c *gin.Context, message string, data interface{}) {\n\tc.JSON(200, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": message,\n\t\t\"data\":    data,\n\t})\n}\n\n// respondError 错误响应\nfunc respondError(c *gin.Context, message string) {\n\tc.JSON(200, gin.H{\n\t\t\"success\": false,\n\t\t\"message\": message,\n\t\t\"data\":    nil,\n\t})\n}\n\n// ============ Cookie加密（可选） ============\n\nfunc getEncryptionKey() []byte {\n\tkey := os.Getenv(\"GYING_ENCRYPTION_KEY\")\n\tif key == \"\" {\n\t\tkey = \"default-32-byte-key-change-me!\"\n\t}\n\treturn []byte(key)[:32]\n}\n\nfunc encryptCookie(plaintext string) (string, error) {\n\tkey := getEncryptionKey()\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tnonce := make([]byte, gcm.NonceSize())\n\tif _, err := io.ReadFull(rand.Reader, nonce); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)\n\treturn base64.StdEncoding.EncodeToString(ciphertext), nil\n}\n\nfunc decryptCookie(encrypted string) (string, error) {\n\tkey := getEncryptionKey()\n\tciphertext, err := base64.StdEncoding.DecodeString(encrypted)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tnonceSize := gcm.NonceSize()\n\tif len(ciphertext) < nonceSize {\n\t\treturn \"\", fmt.Errorf(\"ciphertext too short\")\n\t}\n\n\tnonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]\n\tplaintext, err := gcm.Open(nil, nonce, ciphertext, nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn string(plaintext), nil\n}\n\n// ============ Session保活 ============\n\n// startSessionKeepAlive 启动session保活任务\nfunc (p *GyingPlugin) startSessionKeepAlive() {\n\t// 首次启动后延迟3分钟再开始（避免启动时过多请求）\n\ttime.Sleep(3 * time.Minute)\n\t\n\t// 立即执行一次保活\n\tp.keepAllSessionsAlive()\n\t\n\t// 每3分钟执行一次保活\n\tticker := time.NewTicker(3 * time.Minute)\n\tfor range ticker.C {\n\t\tp.keepAllSessionsAlive()\n\t}\n}\n\n// keepAllSessionsAlive 保持所有用户的session活跃\nfunc (p *GyingPlugin) keepAllSessionsAlive() {\n\tcount := 0\n\t\n\tp.users.Range(func(key, value interface{}) bool {\n\t\tuser := value.(*User)\n\t\t\n\t\t// 只为active状态的用户保活\n\t\tif user.Status != \"active\" {\n\t\t\treturn true\n\t\t}\n\t\t\n\t\t// 获取scraper实例\n\t\tscraperVal, exists := p.scrapers.Load(user.Hash)\n\t\tif !exists {\n\t\t\treturn true\n\t\t}\n\t\t\n\t\tscraper, ok := scraperVal.(*cloudscraper.Scraper)\n\t\tif !ok || scraper == nil {\n\t\t\treturn true\n\t\t}\n\t\t\n\t\t// 访问首页保持session活跃\n\t\tgo func(s *cloudscraper.Scraper, username string) {\n\t\t\tresp, err := s.Get(\"https://www.gying.net/\")\n\t\t\tif err == nil && resp != nil {\n\t\t\t\tresp.Body.Close()\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[Gying] 💓 Session保活成功: %s (状态码: %d)\\n\", username, resp.StatusCode)\n\t\t\t\t}\n\t\t\t}\n\t\t}(scraper, user.UsernameMasked)\n\t\t\n\t\tcount++\n\t\treturn true\n\t})\n\t\n\tif DebugLog && count > 0 {\n\t\tfmt.Printf(\"[Gying] 💓 已为 %d 个用户执行session保活\\n\", count)\n\t}\n}\n\n// ============ 定期清理 ============\n\nfunc (p *GyingPlugin) startCleanupTask() {\n\tticker := time.NewTicker(24 * time.Hour)\n\tfor range ticker.C {\n\t\tdeleted := p.cleanupExpiredUsers()\n\t\tmarked := p.markInactiveUsers()\n\n\t\tif deleted > 0 || marked > 0 {\n\t\t\tfmt.Printf(\"[Gying] 清理任务完成: 删除 %d 个过期用户, 标记 %d 个不活跃用户\\n\", deleted, marked)\n\t\t}\n\t}\n}\n\nfunc (p *GyingPlugin) cleanupExpiredUsers() int {\n\tdeletedCount := 0\n\tnow := time.Now()\n\texpireThreshold := now.AddDate(0, 0, -30)\n\n\tp.users.Range(func(key, value interface{}) bool {\n\t\tuser := value.(*User)\n\t\tif user.Status == \"expired\" && user.LastAccessAt.Before(expireThreshold) {\n\t\t\tif err := p.deleteUser(user.Hash); err == nil {\n\t\t\t\tdeletedCount++\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n\n\treturn deletedCount\n}\n\nfunc (p *GyingPlugin) markInactiveUsers() int {\n\tmarkedCount := 0\n\tnow := time.Now()\n\tinactiveThreshold := now.AddDate(0, 0, -90)\n\n\tp.users.Range(func(key, value interface{}) bool {\n\t\tuser := value.(*User)\n\t\tif user.LastAccessAt.Before(inactiveThreshold) && user.Status != \"expired\" {\n\t\t\tuser.Status = \"expired\"\n\t\t\tuser.Cookie = \"\"\n\n\t\t\tif err := p.saveUser(user); err == nil {\n\t\t\t\tmarkedCount++\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n\n\treturn markedCount\n}\n\n\n"
  },
  {
    "path": "plugin/gying/html结构分析.md",
    "content": "# Gying 网站结构分析\n\n## 基本信息\n- **网站URL**: https://www.gying.net\n- **数据源类型**: 混合型（HTML + JSON API）\n- **特殊架构**: 需要登录 + 搜索结果在HTML内嵌JSON + 详情接口返回JSON\n- **支持多账户**: 是（支持负载均衡搜索）\n\n## 登录认证\n\n### 登录接口\n- **URL**: `https://www.gying.net/user/login`\n- **方法**: POST\n- **Content-Type**: `application/x-www-form-urlencoded`\n\n### 登录请求参数\n```\ncode=&siteid=1&dosubmit=1&cookietime=10506240&username={用户名}&password={密码}\n```\n\n| 参数 | 说明 | 示例值 |\n|------|------|--------|\n| `code` | 验证码（可为空） | `` |\n| `siteid` | 站点ID（固定） | `1` |\n| `dosubmit` | 提交标识（固定） | `1` |\n| `cookietime` | Cookie有效期（秒） | `10506240` (约121天) |\n| `username` | 用户名 | `xxx` |\n| `password` | 密码 | `xxx` |\n\n### 登录响应\n```json\n{\"code\":200}\n```\n\n### 登录Cookie\n- **BT_auth**: 认证Cookie（HttpOnly, Secure, 121天有效期）\n  ```\n  BT_auth=433cnQGx2Obm5YAMWnGaG-ZCcuma9JvULO1CSvPz7JzBhj3-t4HhwhSXrxaEVO53lSVoFtT_0-Ilzglvh0vFvv7RLqFfPdE17Maen0B3sWPwnO5GSQszEW9ZyjOU4KLx8TuRvDj3mF7bVVX4rgtgOq9gP0ljq_X-APtIPf3tkliblls\n  ```\n- **BT_cookietime**: Cookie时间标识\n  ```\n  BT_cookietime=a9f5uPN9hZE-fXuzGhTxM8Vh6K5BUIVqeg4ESRHGbcU3jM7ZuuIB\n  ```\n\n### 重要请求头\n```\nUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36\nAccept: */*\nAccept-Language: zh-CN,zh;q=0.9,en;q=0.8\nContent-Type: application/x-www-form-urlencoded\nOrigin: https://www.gying.net\nReferer: https://www.gying.net/user/login/\n```\n\n## 搜索接口\n\n### 搜索URL\n- **格式**: `https://www.gying.net/s/1---1/{关键词}`\n- **方法**: GET\n- **关键词**: 需要URL编码（如：`遮天` -> `%E9%81%AE%E5%A4%A9`）\n\n### 搜索响应格式\n搜索结果返回HTML页面，但实际数据在JavaScript变量 `_obj.search` 中：\n\n```javascript\n_obj.search = {\n    \"q\": \"遮天\",                      // 搜索关键词\n    \"wd\": [\"天\",\"遮\"],                // 分词结果\n    \"n\": \"14\",                        // 结果数量（字符串）\n    \"ns\": [14,6,4,4,35],              // 各类型结果统计\n    \"ty\": 0,                          // 类型标识\n    \"l\": {                            // 详细信息列表\n        \"daoyan\": [...],               // 导演\n        \"bianju\": [...],               // 编剧\n        \"zhuyan\": [...],               // 主演\n        \"info\": [...],                 // 信息（地区/类型等）\n        \"pf\": {...},                   // 平台评分（豆瓣/IMDb）\n        \"title\": [...],                // 标题\n        \"name\": [...],                 // 名称（英文等）\n        \"ename\": [...],                // 别名\n        \"year\": [...],                 // 年份\n        \"d\": [...],                    // 类型（mv=电影，ac=动画，tv=电视剧）\n        \"i\": [...]                     // 资源ID（用于详情页）\n    }\n}\n```\n\n### 搜索结果字段映射\n\n| 字段 | 说明 | 示例 | 用途 |\n|------|------|------|------|\n| `l.i` | 资源ID数组 | `[\"xJe3\", \"rzoj\", ...]` | 用于构建详情接口URL |\n| `l.title` | 标题数组 | `[\"遮天：禁区\", ...]` | 显示标题 |\n| `l.year` | 年份数组 | `[2023, 2023, ...]` | 年份标签 |\n| `l.d` | 类型数组 | `[\"mv\", \"ac\", \"tv\"]` | 资源类型 |\n| `l.info` | 信息数组 | `[\"大陆 / 动作 / 冒险 / 奇幻\", ...]` | 描述信息 |\n| `l.daoyan` | 导演数组 | `[\"罗乐\", ...]` | 导演信息 |\n| `l.zhuyan` | 主演数组 | `[\"冯荔军 / 彭高唱 / ...\", ...]` | 主演信息 |\n\n### 类型标识（d字段）\n- `mv`: 电影\n- `ac`: 动画\n- `tv`: 电视剧\n\n## 详情接口\n\n### 详情URL\n- **格式**: `https://www.gying.net/res/downurl/{类型}/{资源ID}`\n- **示例**: `https://www.gying.net/res/downurl/mv/xJe3`\n- **方法**: GET\n- **认证**: 需要登录Cookie\n\n### 详情响应结构\n```json\n{\n    \"code\": 200,\n    \"wp\": false,                      // 是否需要网盘\n    \"downlist\": {                     // 下载列表\n        \"imdb\": \"\",                   // IMDb ID\n        \"type\": {\n            \"a\": [\"1080P\", \"中字1080P\", \"中字4K\"],  // 清晰度类型数组\n            \"b\": [\"i3\", \"i7\", \"i4\"]                 // 类型标识数组\n        },\n        \"hex\": \"a0a74991cb03e4d43bb6564018c46c4034edff3cf4e32f356f735744258cbe5e\",\n        \"list\": {                     // 下载文件列表\n            \"m\": [\"hash1\", \"hash2\", ...],           // 文件hash数组\n            \"t\": [\"文件名1.mkv\", \"文件名2.mkv\", ...],  // 文件名数组\n            \"s\": [\"999.46M\", \"4.87G\", ...],         // 文件大小数组\n            \"e\": [3, 0, 2, ...],                    // 编码类型\n            \"p\": [\"i3\", \"i4\", \"i7\", ...],           // 类型标识\n            \"u\": [\"短链1\", \"短链2\", ...],              // 短链接数组\n            \"k\": [0, 0, 0, ...],                    // 密码标识（0=无密码）\n            \"n\": [\"1年前\", \"2年前\", ...]             // 上传时间\n        }\n    },\n    \"playlist\": [...],                // 播放列表（在线播放）\n    \"panlist\": {                      // 网盘链接列表\n        \"id\": [\"lYPNk\", \"oJ858\", ...],              // 网盘分享ID\n        \"name\": [\"标题1\", \"标题2\", ...],             // 分享标题\n        \"p\": [\"\", \"\", \"917d\", ...],                 // 提取码数组\n        \"url\": [                                    // 分享链接数组\n            \"https://pan.quark.cn/s/89f7aeef9681\",\n            \"https://cloud.189.cn/t\\/3aQbiynAzEVn（访问码：7dsf）\",\n            \"https://pan.baidu.com/s/1B_BnI7IDtQexYiytiZXOwg?pwd=917d\",\n            ...\n        ],\n        \"type\": [2, 3, 0, ...],                     // 网盘类型标识\n        \"user\": [\"沸羊羊爱分享\", \"大狗熊A\", ...],     // 分享用户\n        \"gid\": [5, 4, 4, ...],                      // 用户组ID\n        \"time\": [\"7天前\", \"12天前\", ...],            // 分享时间\n        \"e\": [0, 0, 0, ...],                        // 过期标识\n        \"heart\": [0, 0, 0, ...],                    // 点赞数\n        \"tname\": [\"百度网盘\", \"迅雷网盘\", ...]       // 网盘类型名称数组\n    }\n}\n```\n\n### 网盘类型标识（panlist.type）\n| 标识 | 网盘类型 | 说明 |\n|-----|---------|------|\n| `0` | 百度网盘 | baidu |\n| `1` | 迅雷网盘 | xunlei |\n| `2` | 夸克网盘 | quark |\n| `3` | 天翼网盘 | tianyi |\n| `4` | UC网盘 | uc |\n| `5` | 阿里网盘 | aliyun |\n\n### 提取码处理\n- 提取码在 `panlist.p` 数组中\n- 如果URL中包含 `?pwd=` 或 `访问码：`，优先从URL提取\n- 如果 `panlist.p` 为空字符串，则无提取码\n\n## 插件所需字段映射\n\n### SearchResult构建\n```go\nresult := model.SearchResult{\n    UniqueID: fmt.Sprintf(\"gying-%s-%s\", resourceType, resourceID),  // 如 gying-mv-xJe3\n    Title:    title,                                                 // 从 l.title\n    Content:  buildContent(info, director, actors),                  // 组合信息\n    Links:    extractPanLinks(panlist),                              // 从详情接口获取\n    Tags:     []string{year},                                        // 从 l.year\n    Channel:  \"\",                                                    // 插件搜索结果Channel为空\n    Datetime: time.Now(),                                            // 当前时间\n}\n```\n\n### 链接提取逻辑\n从 `panlist` 中提取，需要处理：\n1. 识别网盘类型（通过type标识或URL域名）\n2. 提取提取码（优先从URL，其次从p数组）\n3. 过滤无效链接（空URL或过期）\n4. 去重（同一URL只保留一次）\n\n## 支持的网盘类型\n\n### 主流网盘\n- **quark (夸克网盘)**: `https://pan.quark.cn/s/{分享码}`\n- **baidu (百度网盘)**: `https://pan.baidu.com/s/{分享码}?pwd={密码}`\n- **aliyun (阿里云盘)**: `https://www.alipan.com/s/{分享码}`\n- **uc (UC网盘)**: `https://drive.uc.cn/s/{分享码}`\n- **xunlei (迅雷网盘)**: `https://pan.xunlei.com/s/{分享码}`\n- **tianyi (天翼云盘)**: `https://cloud.189.cn/t/{分享码}`\n\n## 插件开发指导\n\n### 登录管理策略\n参考QQPD插件实现：\n1. **初始化登录**: 插件启动时，从缓存加载已登录用户\n2. **Cookie持久化**: 将Cookie保存到 `cache/gying_users/{hash}.json`\n3. **多账户支持**: 支持配置多个账户，进行负载均衡\n4. **Web管理界面**: 提供 `/gying/:param` 路由管理账户\n\n### 用户数据结构\n```json\n{\n    \"hash\": \"用户hash（SHA256）\",\n    \"username\": \"用户名（脱敏）\",\n    \"cookie\": \"BT_auth=xxx; BT_cookietime=xxx\",\n    \"status\": \"active/pending/expired\",\n    \"created_at\": \"2025-10-28T12:00:00+08:00\",\n    \"login_at\": \"2025-10-28T12:00:00+08:00\",\n    \"expire_at\": \"2026-02-26T12:00:00+08:00\",  // 121天后\n    \"last_access_at\": \"2025-10-28T13:00:00+08:00\"\n}\n```\n\n### 搜索流程\n```\n1. 用户搜索 \"遮天\"\n   ↓\n2. 获取所有有效用户（status=active）\n   ↓\n3. 负载均衡分配任务（每个用户处理部分搜索）\n   ↓\n4. 并发执行搜索：\n   a. 使用用户Cookie请求搜索页面\n   b. 提取HTML中的 _obj.search JSON数据\n   c. 遍历 l.i 数组，并发请求详情接口\n   d. 解析网盘链接\n   ↓\n5. 合并所有用户的结果\n   ↓\n6. 去重并返回\n```\n\n### 关键函数示例\n\n#### 登录函数\n```go\nfunc (p *GyingPlugin) login(username, password string) (string, error) {\n    data := url.Values{}\n    data.Set(\"code\", \"\")\n    data.Set(\"siteid\", \"1\")\n    data.Set(\"dosubmit\", \"1\")\n    data.Set(\"cookietime\", \"10506240\")  // 121天\n    data.Set(\"username\", username)\n    data.Set(\"password\", password)\n    \n    req, _ := http.NewRequest(\"POST\", \"https://www.gying.net/user/login\", \n                               strings.NewReader(data.Encode()))\n    req.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n    req.Header.Set(\"User-Agent\", \"Mozilla/5.0...\")\n    \n    resp, err := client.Do(req)\n    // ... 处理响应\n    \n    // 提取Cookie\n    cookies := resp.Cookies()\n    var btAuth, btCookietime string\n    for _, cookie := range cookies {\n        if cookie.Name == \"BT_auth\" {\n            btAuth = cookie.Value\n        } else if cookie.Name == \"BT_cookietime\" {\n            btCookietime = cookie.Value\n        }\n    }\n    \n    return fmt.Sprintf(\"BT_auth=%s; BT_cookietime=%s\", btAuth, btCookietime), nil\n}\n```\n\n#### 搜索函数\n```go\nfunc (p *GyingPlugin) searchWithCookie(keyword, cookie string) ([]model.SearchResult, error) {\n    // 1. 请求搜索页面\n    searchURL := fmt.Sprintf(\"https://www.gying.net/s/1---1/%s\", url.QueryEscape(keyword))\n    req, _ := http.NewRequest(\"GET\", searchURL, nil)\n    req.Header.Set(\"Cookie\", cookie)\n    req.Header.Set(\"User-Agent\", \"Mozilla/5.0...\")\n    \n    resp, err := client.Do(req)\n    // ... 处理响应\n    \n    // 2. 提取 _obj.search JSON\n    body, _ := ioutil.ReadAll(resp.Body)\n    re := regexp.MustCompile(`_obj\\.search=(\\{.*?\\});`)\n    matches := re.FindSubmatch(body)\n    if len(matches) < 2 {\n        return nil, fmt.Errorf(\"未找到搜索结果\")\n    }\n    \n    var searchData SearchData\n    json.Unmarshal(matches[1], &searchData)\n    \n    // 3. 并发请求详情接口\n    var results []model.SearchResult\n    for i, resourceID := range searchData.L.I {\n        // 并发获取详情\n        detail := p.fetchDetail(resourceID, searchData.L.D[i], cookie)\n        result := p.buildResult(detail, searchData, i)\n        results = append(results, result)\n    }\n    \n    return results, nil\n}\n```\n\n#### 详情获取函数\n```go\nfunc (p *GyingPlugin) fetchDetail(resourceID, resourceType, cookie string) (*DetailData, error) {\n    detailURL := fmt.Sprintf(\"https://www.gying.net/res/downurl/%s/%s\", resourceType, resourceID)\n    req, _ := http.NewRequest(\"GET\", detailURL, nil)\n    req.Header.Set(\"Cookie\", cookie)\n    req.Header.Set(\"User-Agent\", \"Mozilla/5.0...\")\n    \n    resp, err := client.Do(req)\n    // ... 处理响应\n    \n    var detail DetailData\n    json.NewDecoder(resp.Body).Decode(&detail)\n    return &detail, nil\n}\n```\n\n## 注意事项\n\n1. **登录验证**: 每次请求前验证Cookie是否有效，失效则重新登录\n2. **并发控制**: 控制详情接口的并发数，避免触发反爬虫（建议50并发）\n3. **错误处理**: 处理网络超时、JSON解析失败等异常情况\n4. **提取码处理**: 优先从URL中提取提取码，兼容多种格式\n5. **去重逻辑**: 同一资源可能有多个网盘链接，需要去重\n6. **Cookie刷新**: Cookie有效期121天，接近过期时提前刷新\n7. **多账户负载**: 当用户数大于1时，均匀分配搜索任务\n\n## 与其他插件的差异\n\n| 特性 | gying | qqpd | huban |\n|------|-------|------|-------|\n| **认证方式** | 用户名密码 | QQ扫码 | 无需登录 |\n| **数据格式** | HTML内嵌JSON + 详情API | API | JSON API |\n| **多账户** | 支持 | 支持 | 不支持 |\n| **Cookie管理** | 需要 | 需要 | 不需要 |\n| **负载均衡** | 支持 | 支持 | 不支持 |\n\n## 开发建议\n\n1. **分步实现**: \n   - 先实现单账户登录和搜索\n   - 再扩展多账户支持\n   - 最后添加Web管理界面\n\n2. **测试重点**:\n   - Cookie失效后的自动重新登录\n   - 并发详情请求的稳定性\n   - 多账户负载均衡的正确性\n\n3. **性能优化**:\n   - 缓存搜索结果（5分钟）\n   - 批量并发请求详情接口\n   - 复用HTTP连接\n\n4. **容错机制**:\n   - 单个详情请求失败不影响整体\n   - Cookie失效时自动降级到其他账户\n   - 网络异常时自动重试3次\n\n"
  },
  {
    "path": "plugin/haisou/haisou.go",
    "content": "package haisou\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\nconst (\n\t// 调试日志开关\n\tDebugLog = false\n\t// 默认每种网盘类型获取页数\n\tDefaultPagesPerType = 2\n\t// 最大允许每种网盘类型页数（防止过度请求）\n\tMaxAllowedPagesPerType = 3\n)\n\n// 支持的网盘类型列表 (haisou API支持的类型)\nvar SupportedCloudTypes = []string{\"ali\", \"baidu\", \"quark\", \"xunlei\", \"tianyi\"}\n\n// HaisouPlugin 海搜插件\ntype HaisouPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\n// SearchAPIResponse 搜索API响应结构\ntype SearchAPIResponse struct {\n\tCode int    `json:\"code\"`\n\tMsg  string `json:\"msg\"`\n\tData struct {\n\t\tQuery string      `json:\"query\"`\n\t\tCount int         `json:\"count\"`\n\t\tTime  int         `json:\"time\"`\n\t\tPages int         `json:\"pages\"`\n\t\tPage  int         `json:\"page\"`\n\t\tList  []ShareItem `json:\"list\"`\n\t} `json:\"data\"`\n}\n\n// ShareItem 搜索结果项\ntype ShareItem struct {\n\tHSID      string `json:\"hsid\"`      // 海搜ID，用于获取具体链接\n\tPlatform  string `json:\"platform\"`  // 网盘类型\n\tShareName string `json:\"share_name\"` // 分享名称，可能包含HTML标签\n\tStatFile  int    `json:\"stat_file\"` // 文件数量\n\tStatSize  int64  `json:\"stat_size\"` // 总大小(字节)\n}\n\n// FetchAPIResponse 链接获取API响应结构\ntype FetchAPIResponse struct {\n\tCode int    `json:\"code\"`\n\tMsg  string `json:\"msg\"`\n\tData struct {\n\t\tShareCode string  `json:\"share_code\"` // 网盘分享码\n\t\tSharePwd  *string `json:\"share_pwd\"`  // 网盘提取密码，可能为null\n\t} `json:\"data\"`\n}\n\n// PageResult 页面搜索结果\ntype PageResult struct {\n\tpageNo     int\n\tcloudType  string\n\tshareItems []ShareItem\n\terr        error\n}\n\n// LinkResult 链接获取结果\ntype LinkResult struct {\n\thsid     string\n\tshareURL string\n\tpassword string\n\terr      error\n}\n\nfunc init() {\n\tp := &HaisouPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"haisou\", 3), \n\t}\n\tplugin.RegisterGlobalPlugin(p)\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *HaisouPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果（推荐方法）\nfunc (p *HaisouPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实际的搜索实现\nfunc (p *HaisouPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] 开始搜索，关键词: %s\\n\", p.Name(), keyword)\n\t}\n\n\t// 1. 从扩展参数中获取每种网盘类型的页数配置\n\tpagesPerType := DefaultPagesPerType\n\tif ext != nil {\n\t\tif pages, ok := ext[\"pages_per_type\"].(int); ok && pages > 0 {\n\t\t\tpagesPerType = pages\n\t\t\tif pagesPerType > MaxAllowedPagesPerType {\n\t\t\t\tpagesPerType = MaxAllowedPagesPerType\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[%s] 每种网盘类型页数限制在最大值: %d\\n\", p.Name(), MaxAllowedPagesPerType)\n\t\t\t\t}\n\t\t\t}\n\t\t} else if pagesFloat, ok := ext[\"pages_per_type\"].(float64); ok && pagesFloat > 0 {\n\t\t\tpagesPerType = int(pagesFloat)\n\t\t\tif pagesPerType > MaxAllowedPagesPerType {\n\t\t\t\tpagesPerType = MaxAllowedPagesPerType\n\t\t\t}\n\t\t}\n\t}\n\n\ttotalTasks := len(SupportedCloudTypes) * pagesPerType\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] 将分别搜索 %d 种网盘类型，每种 %d 页，总计 %d 个并发任务\\n\",\n\t\t\tp.Name(), len(SupportedCloudTypes), pagesPerType, totalTasks)\n\t}\n\n\t// 2. 第一阶段：并发搜索获取所有hsid\n\tvar wg sync.WaitGroup\n\tshareItemsChan := make(chan PageResult, totalTasks)\n\n\t// 启动并发搜索任务\n\tfor _, cloudType := range SupportedCloudTypes {\n\t\tfor pageNo := 1; pageNo <= pagesPerType; pageNo++ {\n\t\t\twg.Add(1)\n\t\t\tgo func(cType string, page int) {\n\t\t\t\tdefer wg.Done()\n\n\t\t\t\tshareItems, err := p.fetchSearchPage(client, keyword, page, cType)\n\t\t\t\tshareItemsChan <- PageResult{\n\t\t\t\t\tpageNo:     page,\n\t\t\t\t\tcloudType:  cType,\n\t\t\t\t\tshareItems: shareItems,\n\t\t\t\t\terr:        err,\n\t\t\t\t}\n\t\t\t}(cloudType, pageNo)\n\t\t}\n\t}\n\n\t// 等待所有搜索任务完成\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(shareItemsChan)\n\t}()\n\n\t// 3. 收集所有hsid\n\tvar allShareItems []ShareItem\n\tsuccessTasks := 0\n\terrorTasks := 0\n\tresultsByType := make(map[string]int)\n\n\tfor pageResult := range shareItemsChan {\n\t\tif pageResult.err != nil {\n\t\t\terrorTasks++\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[%s] %s网盘第%d页搜索失败: %v\\n\", p.Name(), pageResult.cloudType, pageResult.pageNo, pageResult.err)\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tsuccessTasks++\n\t\tallShareItems = append(allShareItems, pageResult.shareItems...)\n\t\tresultsByType[pageResult.cloudType] += len(pageResult.shareItems)\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[%s] %s网盘第%d页成功获取 %d 个结果\\n\", p.Name(), pageResult.cloudType, pageResult.pageNo, len(pageResult.shareItems))\n\t\t}\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] 搜索阶段完成: 成功%d任务, 失败%d任务, 总hsid%d个\\n\",\n\t\t\tp.Name(), successTasks, errorTasks, len(allShareItems))\n\t\tfor cloudType, count := range resultsByType {\n\t\t\tfmt.Printf(\"[%s]   - %s网盘: %d个结果\\n\", p.Name(), cloudType, count)\n\t\t}\n\t}\n\n\t// 4. 如果所有搜索任务都失败，返回错误\n\tif successTasks == 0 {\n\t\treturn nil, fmt.Errorf(\"[%s] 所有搜索任务都失败\", p.Name())\n\t}\n\n\t// 5. 第二阶段：并发获取所有链接\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] 开始第二阶段：并发获取 %d 个链接\\n\", p.Name(), len(allShareItems))\n\t}\n\n\tlinkResultsChan := make(chan LinkResult, len(allShareItems))\n\tvar linkWg sync.WaitGroup\n\n\t// 启动并发链接获取任务\n\tfor _, shareItem := range allShareItems {\n\t\tlinkWg.Add(1)\n\t\tgo func(item ShareItem) {\n\t\t\tdefer linkWg.Done()\n\n\t\t\tshareURL, password, err := p.fetchShareLink(client, item.HSID, item.Platform)\n\t\t\tlinkResultsChan <- LinkResult{\n\t\t\t\thsid:     item.HSID,\n\t\t\t\tshareURL: shareURL,\n\t\t\t\tpassword: password,\n\t\t\t\terr:      err,\n\t\t\t}\n\t\t}(shareItem)\n\t}\n\n\t// 等待所有链接获取任务完成\n\tgo func() {\n\t\tlinkWg.Wait()\n\t\tclose(linkResultsChan)\n\t}()\n\n\t// 6. 建立hsid到链接的映射\n\thsidToLink := make(map[string]LinkResult)\n\tlinkSuccessCount := 0\n\tlinkErrorCount := 0\n\n\tfor linkResult := range linkResultsChan {\n\t\tif linkResult.err != nil {\n\t\t\tlinkErrorCount++\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[%s] 获取链接失败 hsid=%s: %v\\n\", p.Name(), linkResult.hsid, linkResult.err)\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tlinkSuccessCount++\n\t\thsidToLink[linkResult.hsid] = linkResult\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] 链接获取阶段完成: 成功%d个, 失败%d个\\n\", p.Name(), linkSuccessCount, linkErrorCount)\n\t}\n\n\t// 7. 组合搜索结果和链接信息\n\tvar results []model.SearchResult\n\tprocessedCount := 0\n\tskippedCount := 0\n\n\tfor _, shareItem := range allShareItems {\n\t\t// 获取对应的链接信息\n\t\tlinkResult, exists := hsidToLink[shareItem.HSID]\n\t\tif !exists {\n\t\t\tskippedCount++\n\t\t\tcontinue\n\t\t}\n\n\t\t// 清理HTML标签获取纯文本标题\n\t\ttitle := cleanHTMLTags(shareItem.ShareName)\n\t\tif title == \"\" {\n\t\t\ttitle = \"未知资源\"\n\t\t}\n\n\t\t// 创建链接对象\n\t\tlink := model.Link{\n\t\t\tType:     mapPlatformType(shareItem.Platform),\n\t\t\tURL:      linkResult.shareURL,\n\t\t\tPassword: linkResult.password,\n\t\t}\n\n\t\t// 构建搜索结果\n\t\tresult := model.SearchResult{\n\t\t\tUniqueID: fmt.Sprintf(\"%s-%s\", p.Name(), shareItem.HSID),\n\t\t\tTitle:    title,\n\t\t\tContent:  fmt.Sprintf(\"文件数量: %d | 网盘类型: %s | 大小: %s\", shareItem.StatFile, shareItem.Platform, formatSize(shareItem.StatSize)),\n\t\t\tLinks:    []model.Link{link},\n\t\t\tTags:     []string{shareItem.Platform},\n\t\t\tChannel:  \"\", // 插件搜索结果必须为空字符串\n\t\t\tDatetime: time.Now(),\n\t\t}\n\n\t\tresults = append(results, result)\n\t\tprocessedCount++\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] 结果组合完成: 处理%d项 -> 有效%d项 -> 跳过%d项\\n\",\n\t\t\tp.Name(), len(allShareItems), processedCount, skippedCount)\n\t}\n\n\t// 8. 关键词过滤\n\tbeforeFilterCount := len(results)\n\tfilteredResults := plugin.FilterResultsByKeyword(results, keyword)\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] 关键词过滤: 过滤前%d项 -> 过滤后%d项\\n\",\n\t\t\tp.Name(), beforeFilterCount, len(filteredResults))\n\t}\n\n\treturn filteredResults, nil\n}\n\n// fetchSearchPage 获取指定网盘类型的单页搜索结果\nfunc (p *HaisouPlugin) fetchSearchPage(client *http.Client, keyword string, pageNo int, panType string) ([]ShareItem, error) {\n\t// 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"https://haisou.cc/api/pan/share/search?query=%s&scope=title&pan=%s&page=%d&filter_valid=true&filter_has_files=false\",\n\t\turl.QueryEscape(keyword), panType, pageNo)\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] 请求%s网盘第%d页: %s\\n\", p.Name(), panType, pageNo, searchURL)\n\t}\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\t// 创建请求对象\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] %s网盘第%d页创建请求失败: %w\", p.Name(), panType, pageNo, err)\n\t}\n\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"https://haisou.cc/\")\n\n\t// 发送HTTP请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] %s网盘第%d页请求失败: %w\", p.Name(), panType, pageNo, err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] %s网盘第%d页返回状态码: %d\", p.Name(), panType, pageNo, resp.StatusCode)\n\t}\n\n\t// 读取响应体\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] %s网盘第%d页读取响应失败: %w\", p.Name(), panType, pageNo, err)\n\t}\n\n\t// 解析响应\n\tvar apiResp SearchAPIResponse\n\tif err := json.Unmarshal(body, &apiResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] %s网盘第%d页JSON解析失败: %w\", p.Name(), panType, pageNo, err)\n\t}\n\n\t// 检查API响应状态\n\tif apiResp.Code != 0 {\n\t\treturn nil, fmt.Errorf(\"[%s] %s网盘第%d页API错误: %s\", p.Name(), panType, pageNo, apiResp.Msg)\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] %s网盘第%d页获取到 %d 个搜索结果\\n\", p.Name(), panType, pageNo, len(apiResp.Data.List))\n\t}\n\n\treturn apiResp.Data.List, nil\n}\n\n// fetchShareLink 通过hsid获取具体的分享链接\nfunc (p *HaisouPlugin) fetchShareLink(client *http.Client, hsid string, platform string) (string, string, error) {\n\t// 构建获取链接的URL\n\tfetchURL := fmt.Sprintf(\"https://haisou.cc/api/pan/share/%s/fetch\", hsid)\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] 获取链接 hsid=%s platform=%s: %s\\n\", p.Name(), hsid, platform, fetchURL)\n\t}\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\n\t// 创建请求对象\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", fetchURL, nil)\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"[%s] hsid=%s创建链接请求失败: %w\", p.Name(), hsid, err)\n\t}\n\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"https://haisou.cc/\")\n\n\t// 发送HTTP请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"[%s] hsid=%s链接请求失败: %w\", p.Name(), hsid, err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn \"\", \"\", fmt.Errorf(\"[%s] hsid=%s链接请求返回状态码: %d\", p.Name(), hsid, resp.StatusCode)\n\t}\n\n\t// 读取响应体\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"[%s] hsid=%s读取响应失败: %w\", p.Name(), hsid, err)\n\t}\n\n\t// 解析响应\n\tvar apiResp FetchAPIResponse\n\tif err := json.Unmarshal(body, &apiResp); err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"[%s] hsid=%s链接JSON解析失败: %w\", p.Name(), hsid, err)\n\t}\n\n\t// 检查API响应状态\n\tif apiResp.Code != 0 {\n\t\treturn \"\", \"\", fmt.Errorf(\"[%s] hsid=%s链接API错误: %s\", p.Name(), hsid, apiResp.Msg)\n\t}\n\n\t// 根据平台类型构建完整的分享链接\n\tshareURL := buildShareURL(platform, apiResp.Data.ShareCode)\n\tif shareURL == \"\" {\n\t\treturn \"\", \"\", fmt.Errorf(\"[%s] hsid=%s不支持的网盘平台: %s\", p.Name(), hsid, platform)\n\t}\n\n\t// 获取密码\n\tpassword := \"\"\n\tif apiResp.Data.SharePwd != nil {\n\t\tpassword = *apiResp.Data.SharePwd\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] hsid=%s成功获取链接: %s password=%s\\n\", p.Name(), hsid, shareURL, password)\n\t}\n\n\treturn shareURL, password, nil\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *HaisouPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n\n// buildShareURL 根据平台类型和分享码构建完整的分享链接\nfunc buildShareURL(platform, shareCode string) string {\n\tswitch strings.ToLower(platform) {\n\tcase \"ali\":\n\t\treturn fmt.Sprintf(\"https://www.alipan.com/s/%s\", shareCode)\n\tcase \"baidu\":\n\t\treturn fmt.Sprintf(\"https://pan.baidu.com/s/%s\", shareCode)\n\tcase \"quark\":\n\t\treturn fmt.Sprintf(\"https://pan.quark.cn/s/%s\", shareCode)\n\tcase \"xunlei\":\n\t\treturn fmt.Sprintf(\"https://pan.xunlei.com/s/%s\", shareCode)\n\tcase \"tianyi\":\n\t\treturn fmt.Sprintf(\"https://cloud.189.cn/t/%s\", shareCode)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// mapPlatformType 映射网盘平台类型到PanSou标准类型\nfunc mapPlatformType(platform string) string {\n\tswitch strings.ToLower(platform) {\n\tcase \"ali\":\n\t\treturn \"aliyun\" // PanSou内部使用aliyun标识阿里云盘\n\tcase \"baidu\":\n\t\treturn \"baidu\"\n\tcase \"quark\":\n\t\treturn \"quark\"\n\tcase \"xunlei\":\n\t\treturn \"xunlei\"\n\tcase \"tianyi\":\n\t\treturn \"tianyi\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n// cleanHTMLTags 清理HTML标签\nfunc cleanHTMLTags(text string) string {\n\t// 移除高亮标签 <span class=\"highlight\">...</span>\n\tre := regexp.MustCompile(`<span[^>]*class=\"highlight\"[^>]*>(.*?)</span>`)\n\tcleaned := re.ReplaceAllString(text, \"$1\")\n\n\t// 移除其他可能的HTML标签\n\tre2 := regexp.MustCompile(`<[^>]*>`)\n\tcleaned = re2.ReplaceAllString(cleaned, \"\")\n\n\treturn strings.TrimSpace(cleaned)\n}\n\n// formatSize 格式化文件大小显示\nfunc formatSize(size int64) string {\n\tconst (\n\t\tB  = 1\n\t\tKB = 1024 * B\n\t\tMB = 1024 * KB\n\t\tGB = 1024 * MB\n\t\tTB = 1024 * GB\n\t)\n\n\tswitch {\n\tcase size >= TB:\n\t\treturn fmt.Sprintf(\"%.2f TB\", float64(size)/float64(TB))\n\tcase size >= GB:\n\t\treturn fmt.Sprintf(\"%.2f GB\", float64(size)/float64(GB))\n\tcase size >= MB:\n\t\treturn fmt.Sprintf(\"%.2f MB\", float64(size)/float64(MB))\n\tcase size >= KB:\n\t\treturn fmt.Sprintf(\"%.2f KB\", float64(size)/float64(KB))\n\tdefault:\n\t\treturn fmt.Sprintf(\"%d B\", size)\n\t}\n}\n"
  },
  {
    "path": "plugin/haisou/json结构分析.md",
    "content": "# Haisou 搜索API JSON结构分析\n\n## 接口信息\n\n- **接口名称**: 海搜网盘资源搜索API\n- **接口地址**: `https://haisou.cc/api/pan/share/search` (搜索API)\n- **辅助接口**: `https://haisou.cc/api/pan/share/{hsid}/fetch` (链接获取API)\n- **请求方法**: `GET`\n- **Content-Type**: `application/json`\n- **主要特点**: 支持按网盘类型分类搜索，需要两步API调用获取完整链接信息\n\n## 请求结构\n\n### 搜索API请求格式\n\n```\nGET https://haisou.cc/api/pan/share/search?query={keyword}&scope=title&pan={type}&page={page}&filter_valid=true&filter_has_files=false\n```\n\n### 搜索请求参数说明\n\n| 参数名 | 类型 | 必需 | 默认值 | 说明 |\n|--------|------|------|--------|------|\n| `query` | string | 是 | - | 搜索关键词，需要URL编码 |\n| `scope` | string | 否 | \"title\" | 搜索范围，固定为\"title\" |\n| `pan` | string | 否 | 全部 | 网盘类型过滤 |\n| `page` | int | 否 | 1 | 页码，从1开始 |\n| `filter_valid` | bool | 否 | true | 过滤有效链接 |\n| `filter_has_files` | bool | 否 | false | 过滤包含文件的分享 |\n\n### 链接获取API请求格式\n\n```\nGET https://haisou.cc/api/pan/share/{hsid}/fetch\n```\n\n| 参数名 | 类型 | 必需 | 说明 |\n|--------|------|------|------|\n| `hsid` | string | 是 | 从搜索结果中获取的海搜ID |\n\n## 响应结构\n\n### 搜索API响应格式\n\n```json\n{\n  \"code\": 0,\n  \"msg\": null,\n  \"data\": {\n    \"query\": \"凡人修仙传\",\n    \"count\": 64,\n    \"time\": 3,\n    \"pages\": 7,\n    \"page\": 1,\n    \"list\": [\n      {\n        \"hsid\": \"nlSwOaKeLW\",\n        \"platform\": \"tianyi\",\n        \"share_name\": \"\\u003Cspan class=\\\"highlight\\\"\\u003E凡人\\u003C/span\\u003E\\u003Cspan class=\\\"highlight\\\"\\u003E修仙\\u003C/span\\u003E\\u003Cspan class=\\\"highlight\\\"\\u003E传\\u003C/span\\u003E\",\n        \"stat_file\": 65,\n        \"stat_size\": 81843197420\n      }\n    ]\n  }\n}\n```\n\n### 搜索API响应字段详解\n\n#### 1. 基本信息\n\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| `code` | int | 状态码，0表示成功 |\n| `msg` | string/null | 错误信息，成功时为null |\n\n#### 2. 数据信息 (data)\n\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| `query` | string | 搜索关键词 |\n| `count` | int | 搜索结果总数 |\n| `time` | int | 搜索耗时（毫秒） |\n| `pages` | int | 总页数 |\n| `page` | int | 当前页码 |\n| `list` | array | 搜索结果列表 |\n\n#### 3. 搜索结果项 (list)\n\n```json\n{\n  \"hsid\": \"nlSwOaKeLW\",\n  \"platform\": \"tianyi\",\n  \"share_name\": \"\\u003Cspan class=\\\"highlight\\\"\\u003E凡人\\u003C/span\\u003E\\u003Cspan class=\\\"highlight\\\"\\u003E修仙\\u003C/span\\u003E\\u003Cspan class=\\\"highlight\\\"\\u003E传\\u003C/span\\u003E\",\n  \"stat_file\": 65,\n  \"stat_size\": 81843197420\n}\n```\n\n| 字段名 | 类型 | 必需 | 说明 |\n|--------|------|------|------|\n| `hsid` | string | 是 | 海搜ID，用于获取具体链接 |\n| `platform` | string | 是 | 网盘类型标识 |\n| `share_name` | string | 是 | 分享名称，可能包含HTML高亮标签 |\n| `stat_file` | int | 是 | 文件数量 |\n| `stat_size` | int64 | 是 | 总大小（字节） |\n\n### 链接获取API响应格式\n\n```json\n{\n  \"code\": 0,\n  \"msg\": null,\n  \"data\": {\n    \"share_code\": \"RBRniaAVJbEb\",\n    \"share_pwd\": null\n  }\n}\n```\n\n#### 链接获取响应字段详解\n\n| 字段名 | 类型 | 必需 | 说明 |\n|--------|------|------|------|\n| `code` | int | 是 | 状态码，0表示成功 |\n| `msg` | string/null | 否 | 错误信息，成功时为null |\n| `data.share_code` | string | 是 | 网盘分享码 |\n| `data.share_pwd` | string/null | 否 | 网盘提取密码，可能为null |\n\n## 支持的网盘类型\n\n| 网盘类型 | API标识 | 域名特征 | 链接格式 |\n|---------|---------|----------|----------|\n| **阿里云盘** | `ali` | alipan.com | `https://www.alipan.com/s/{share_code}` |\n| **百度网盘** | `baidu` | pan.baidu.com | `https://pan.baidu.com/s/{share_code}` |\n| **夸克网盘** | `quark` | pan.quark.cn | `https://pan.quark.cn/s/{share_code}` |\n| **迅雷网盘** | `xunlei` | pan.xunlei.com | `https://pan.xunlei.com/s/{share_code}` |\n| **天翼云盘** | `tianyi` | cloud.189.cn | `https://cloud.189.cn/t/{share_code}` |\n\n## 数据特点\n\n### 1. HTML标签处理 🏷️\n- `share_name` 字段包含HTML高亮标签\n- 格式：`<span class=\"highlight\">关键词</span>`\n- 需要清理HTML标签获取纯文本标题\n\n### 2. 分页机制 📄\n- 支持分页搜索，每页包含若干结果\n- 通过 `pages` 字段判断总页数\n- 页码从1开始递增\n\n### 3. 两阶段API调用 🔄\n- 第一阶段：搜索API获取 `hsid` 列表\n- 第二阶段：链接获取API获取实际分享码\n- 需要并发处理提高效率\n\n### 4. 网盘分类搜索 🗂️\n- 可按网盘类型精确搜索\n- 不指定 `pan` 参数返回所有类型结果\n- 支持多种主流网盘平台\n\n## 重要特性\n\n### 1. 分类搜索 🔍\n- 按网盘类型分别搜索\n- 支持5种主流网盘平台\n- 可并发搜索多个网盘类型\n\n### 2. 异步获取 ⚡\n- 搜索阶段快速返回hsid列表\n- 链接获取阶段并发处理\n- 提高整体搜索效率\n\n### 3. 文件信息 📊\n- 提供文件数量统计\n- 提供总大小信息\n- 便于用户筛选资源\n\n### 4. 高亮显示 🌟\n- 搜索结果中关键词高亮\n- HTML标签标识匹配部分\n- 提升用户体验\n\n## 提取逻辑\n\n### 搜索请求构建\n```go\ntype SearchAPIResponse struct {\n    Code int    `json:\"code\"`\n    Msg  string `json:\"msg\"`\n    Data struct {\n        Query string      `json:\"query\"`\n        Count int         `json:\"count\"`\n        Time  int         `json:\"time\"`\n        Pages int         `json:\"pages\"`\n        Page  int         `json:\"page\"`\n        List  []ShareItem `json:\"list\"`\n    } `json:\"data\"`\n}\n\ntype ShareItem struct {\n    HSID      string `json:\"hsid\"`      // 海搜ID\n    Platform  string `json:\"platform\"`  // 网盘类型\n    ShareName string `json:\"share_name\"` // 分享名称\n    StatFile  int    `json:\"stat_file\"` // 文件数量\n    StatSize  int64  `json:\"stat_size\"` // 总大小\n}\n```\n\n### 链接获取响应解析\n```go\ntype FetchAPIResponse struct {\n    Code int    `json:\"code\"`\n    Msg  string `json:\"msg\"`\n    Data struct {\n        ShareCode string  `json:\"share_code\"` // 分享码\n        SharePwd  *string `json:\"share_pwd\"`  // 密码\n    } `json:\"data\"`\n}\n```\n\n### 链接还原\n```go\n// 根据平台类型和分享码构建完整链接\nfunc buildShareURL(platform, shareCode string) string {\n    switch strings.ToLower(platform) {\n    case \"ali\":\n        return fmt.Sprintf(\"https://www.alipan.com/s/%s\", shareCode)\n    case \"baidu\":\n        return fmt.Sprintf(\"https://pan.baidu.com/s/%s\", shareCode)\n    case \"quark\":\n        return fmt.Sprintf(\"https://pan.quark.cn/s/%s\", shareCode)\n    case \"xunlei\":\n        return fmt.Sprintf(\"https://pan.xunlei.com/s/%s\", shareCode)\n    case \"tianyi\":\n        return fmt.Sprintf(\"https://cloud.189.cn/t/%s\", shareCode)\n    default:\n        return \"\"\n    }\n}\n```\n\n### HTML标签清理\n```go\n// 清理HTML高亮标签\nfunc cleanHTMLTags(text string) string {\n    // 移除高亮标签\n    re := regexp.MustCompile(`<span[^>]*class=\"highlight\"[^>]*>(.*?)</span>`)\n    cleaned := re.ReplaceAllString(text, \"$1\")\n    \n    // 移除其他HTML标签\n    re2 := regexp.MustCompile(`<[^>]*>`)\n    cleaned = re2.ReplaceAllString(cleaned, \"\")\n    \n    return strings.TrimSpace(cleaned)\n}\n```\n\n## 错误处理\n\n### 常见错误类型\n1. **搜索API错误**: 网络连接失败或API服务错误\n2. **链接获取失败**: hsid无效或链接已失效\n3. **JSON解析错误**: 响应格式不符合预期\n4. **网盘类型不支持**: 未知的platform类型\n\n### 容错机制\n- **部分失败容忍**: 搜索失败时不影响其他网盘类型\n- **链接获取重试**: 对失败的hsid进行重试\n- **数据验证**: 验证hsid和share_code有效性\n- **降级处理**: API错误时返回已获取的部分结果\n\n## 性能优化建议\n\n1. **并发搜索**: 同时搜索多种网盘类型，提高效率\n2. **分页控制**: 根据需要限制每种网盘类型的搜索页数\n3. **缓存策略**: 对hsid到链接的映射实现缓存\n4. **超时设置**: 合理设置搜索和链接获取的超时时间\n5. **批量处理**: 对多个hsid进行批量链接获取\n\n## 开发注意事项\n\n1. **优先级设置**: 建议设置为优先级2，数据质量良好\n2. **Service层过滤**: 使用标准的Service层过滤，不跳过\n3. **HTML处理**: 正确处理share_name中的HTML标签\n4. **密码分离**: 密码作为独立字段，不拼接到URL中\n5. **链接格式**: 严格按照各网盘的标准格式构建链接\n6. **错误日志**: 详细记录API调用失败的原因和上下文\n7. **请求头设置**: 设置合适的User-Agent和Referer避免反爬虫\n8. **重试机制**: 对临时失败的请求实现指数退避重试\n\n## API调用示例\n\n### 搜索请求示例\n```bash\ncurl \"https://haisou.cc/api/pan/share/search?query=%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0&scope=title&pan=tianyi&page=1&filter_valid=true&filter_has_files=false\"\n```\n\n### 链接获取请求示例\n```bash\ncurl \"https://haisou.cc/api/pan/share/nlSwOaKeLW/fetch\"\n```\n\n### 完整流程示例\n1. **搜索各网盘类型**: 并发请求5种网盘类型的搜索结果\n2. **收集hsid**: 从所有搜索结果中提取hsid列表\n3. **批量获取链接**: 并发调用链接获取API\n4. **组合结果**: 将搜索信息与链接信息合并\n5. **格式化输出**: 转换为PanSou标准格式返回\n"
  },
  {
    "path": "plugin/hdmoli/hdmoli.go",
    "content": "package hdmoli\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tPluginName     = \"hdmoli\"\n\tDisplayName    = \"HDmoli\"\n\tDescription    = \"HDmoli - 影视资源网盘下载链接搜索\"\n\tBaseURL        = \"https://www.hdmoli.pro\"\n\tSearchPath     = \"/search.php?searchkey=%s&submit=\"\n\tUserAgent      = \"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\"\n\tMaxResults     = 50\n\tMaxConcurrency = 20\n)\n\n// HdmoliPlugin HDmoli插件\ntype HdmoliPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tdebugMode   bool\n\tdetailCache sync.Map // 缓存详情页结果\n\tcacheTTL    time.Duration\n}\n\n// init 注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewHdmoliPlugin())\n}\n\n// NewHdmoliPlugin 创建新的HDmoli插件实例\nfunc NewHdmoliPlugin() *HdmoliPlugin {\n\tdebugMode := false // 生产环境关闭调试\n\n\tp := &HdmoliPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(PluginName, 2), // 标准网盘插件，启用Service层过滤\n\t\tdebugMode:       debugMode,\n\t\tcacheTTL:        30 * time.Minute, // 详情页缓存30分钟\n\t}\n\n\treturn p\n}\n\n// Name 插件名称\nfunc (p *HdmoliPlugin) Name() string {\n\treturn PluginName\n}\n\n// DisplayName 插件显示名称\nfunc (p *HdmoliPlugin) DisplayName() string {\n\treturn DisplayName\n}\n\n// Description 插件描述\nfunc (p *HdmoliPlugin) Description() string {\n\treturn Description\n}\n\n// Search 搜索接口\nfunc (p *HdmoliPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\treturn p.searchImpl(&http.Client{Timeout: 30 * time.Second}, keyword, ext)\n}\n\n// searchImpl 搜索实现\nfunc (p *HdmoliPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif p.debugMode {\n\t\tlog.Printf(\"[HDMOLI] 开始搜索: %s\", keyword)\n\t}\n\n\t// 第一步：执行搜索获取结果列表\n\tsearchResults, err := p.executeSearch(client, keyword)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 执行搜索失败: %w\", p.Name(), err)\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[HDMOLI] 搜索获取到 %d 个结果\", len(searchResults))\n\t}\n\n\t// 第二步：并发获取详情页链接\n\tfinalResults := p.fetchDetailLinks(client, searchResults, keyword)\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[HDMOLI] 最终获取到 %d 个有效结果\", len(finalResults))\n\t}\n\n\t// 第三步：关键词过滤（标准网盘插件需要过滤）\n\tfilteredResults := plugin.FilterResultsByKeyword(finalResults, keyword)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[HDMOLI] 关键词过滤后剩余 %d 个结果\", len(filteredResults))\n\t}\n\n\treturn filteredResults, nil\n}\n\n// executeSearch 执行搜索请求\nfunc (p *HdmoliPlugin) executeSearch(client *http.Client, keyword string) ([]model.SearchResult, error) {\n\t// 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"%s%s\", BaseURL, fmt.Sprintf(SearchPath, url.QueryEscape(keyword)))\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建搜索请求失败: %w\", p.Name(), err)\n\t}\n\n\t// 设置完整的请求头\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Referer\", BaseURL+\"/\") // HDmoli需要设置referer\n\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求HTTP状态错误: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\t// 解析HTML提取搜索结果\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索结果HTML失败: %w\", p.Name(), err)\n\t}\n\n\treturn p.parseSearchResults(doc)\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *HdmoliPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"[%s] 重试 %d 次后仍然失败: %w\", p.Name(), maxRetries, lastErr)\n}\n\n// parseSearchResults 解析搜索结果HTML\nfunc (p *HdmoliPlugin) parseSearchResults(doc *goquery.Document) ([]model.SearchResult, error) {\n\tvar results []model.SearchResult\n\n\t// 查找搜索结果项: #searchList > li.active.clearfix\n\tdoc.Find(\"#searchList > li.active.clearfix\").Each(func(i int, s *goquery.Selection) {\n\t\tif len(results) >= MaxResults {\n\t\t\treturn\n\t\t}\n\n\t\tresult := p.parseResultItem(s, i+1)\n\t\tif result != nil {\n\t\t\tresults = append(results, *result)\n\t\t}\n\t})\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[HDMOLI] 解析到 %d 个原始结果\", len(results))\n\t}\n\n\treturn results, nil\n}\n\n// parseResultItem 解析单个搜索结果项\nfunc (p *HdmoliPlugin) parseResultItem(s *goquery.Selection, index int) *model.SearchResult {\n\t// 提取标题和链接\n\ttitleEl := s.Find(\".detail h4.title a\")\n\tif titleEl.Length() == 0 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[HDMOLI] 跳过无标题链接的结果\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t// 提取标题\n\ttitle := strings.TrimSpace(titleEl.Text())\n\tif title == \"\" {\n\t\treturn nil\n\t}\n\n\t// 提取详情页链接\n\tdetailURL, _ := titleEl.Attr(\"href\")\n\tif detailURL == \"\" {\n\t\t// 尝试从缩略图获取链接\n\t\tthumbEl := s.Find(\".thumb a\")\n\t\tif thumbEl.Length() > 0 {\n\t\t\tdetailURL, _ = thumbEl.Attr(\"href\")\n\t\t}\n\t}\n\n\tif detailURL == \"\" {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[HDMOLI] 跳过无链接的结果: %s\", title)\n\t\t}\n\t\treturn nil\n\t}\n\n\t// 处理相对路径\n\tif strings.HasPrefix(detailURL, \"/\") {\n\t\tdetailURL = BaseURL + detailURL\n\t}\n\n\t// 提取评分\n\trating := p.extractRating(s)\n\n\t// 提取更新状态\n\tupdateStatus := p.extractUpdateStatus(s)\n\n\t// 提取导演\n\tdirector := p.extractDirector(s)\n\n\t// 提取主演\n\tactors := p.extractActors(s)\n\n\t// 提取分类信息\n\tcategory, region, year := p.extractCategoryInfo(s)\n\n\t// 提取简介\n\tdescription := p.extractDescription(s)\n\n\t// 构建内容\n\tvar contentParts []string\n\tif rating != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"评分：%s\", rating))\n\t}\n\tif updateStatus != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"状态：%s\", updateStatus))\n\t}\n\tif director != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"导演：%s\", director))\n\t}\n\tif len(actors) > 0 {\n\t\tactorStr := strings.Join(actors, \" \")\n\t\tif len(actorStr) > 100 {\n\t\t\tactorStr = actorStr[:100] + \"...\"\n\t\t}\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"主演：%s\", actorStr))\n\t}\n\tif category != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"分类：%s\", category))\n\t}\n\tif region != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"地区：%s\", region))\n\t}\n\tif year != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"年份：%s\", year))\n\t}\n\tif description != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"简介：%s\", description))\n\t}\n\n\tcontent := strings.Join(contentParts, \"\\n\")\n\n\t// 构建标签\n\tvar tags []string\n\tif category != \"\" {\n\t\ttags = append(tags, category)\n\t}\n\tif region != \"\" {\n\t\ttags = append(tags, region)\n\t}\n\tif year != \"\" {\n\t\ttags = append(tags, year)\n\t}\n\n\t// 构建初始结果对象（详情页链接稍后获取）\n\tresult := model.SearchResult{\n\t\tTitle:     title,\n\t\tContent:   content,\n\t\tChannel:   \"\", // 插件搜索结果必须为空字符串（按开发指南要求）\n\t\tMessageID: fmt.Sprintf(\"%s-%d-%d\", p.Name(), index, time.Now().Unix()),\n\t\tUniqueID:  fmt.Sprintf(\"%s-%d-%d\", p.Name(), index, time.Now().Unix()),\n\t\tDatetime:  time.Now(), // 搜索结果页没有明确时间，使用当前时间\n\t\tLinks:     []model.Link{}, // 先为空，详情页处理后添加\n\t\tTags:      tags,\n\t}\n\n\t// 添加详情页URL到临时字段（用于后续处理）\n\tresult.Content += fmt.Sprintf(\"\\n详情页URL: %s\", detailURL)\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[HDMOLI] 解析结果: %s (%s)\", title, category)\n\t}\n\n\treturn &result\n}\n\n// extractRating 提取评分\nfunc (p *HdmoliPlugin) extractRating(s *goquery.Selection) string {\n\tratingEl := s.Find(\".pic-tag\")\n\tif ratingEl.Length() > 0 {\n\t\trating := strings.TrimSpace(ratingEl.Text())\n\t\treturn rating\n\t}\n\treturn \"\"\n}\n\n// extractUpdateStatus 提取更新状态\nfunc (p *HdmoliPlugin) extractUpdateStatus(s *goquery.Selection) string {\n\tstatusEl := s.Find(\".pic-text\")\n\tif statusEl.Length() > 0 {\n\t\tstatus := strings.TrimSpace(statusEl.Text())\n\t\treturn status\n\t}\n\treturn \"\"\n}\n\n// extractDirector 提取导演\nfunc (p *HdmoliPlugin) extractDirector(s *goquery.Selection) string {\n\tvar director string\n\ts.Find(\"p\").Each(func(i int, p *goquery.Selection) {\n\t\tif director != \"\" {\n\t\t\treturn // 已找到，跳过\n\t\t}\n\t\ttext := p.Text()\n\t\tif strings.Contains(text, \"导演：\") {\n\t\t\t// 提取导演名称\n\t\t\tparts := strings.Split(text, \"导演：\")\n\t\t\tif len(parts) > 1 {\n\t\t\t\tdirector = strings.TrimSpace(parts[1])\n\t\t\t}\n\t\t}\n\t})\n\treturn director\n}\n\n// extractActors 提取主演\nfunc (p *HdmoliPlugin) extractActors(s *goquery.Selection) []string {\n\tvar actors []string\n\ts.Find(\"p\").Each(func(i int, p *goquery.Selection) {\n\t\ttext := p.Text()\n\t\tif strings.Contains(text, \"主演：\") {\n\t\t\t// 在这个p标签中查找所有链接\n\t\t\tp.Find(\"a\").Each(func(j int, a *goquery.Selection) {\n\t\t\t\tactor := strings.TrimSpace(a.Text())\n\t\t\t\tif actor != \"\" {\n\t\t\t\t\tactors = append(actors, actor)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\treturn actors\n}\n\n// extractCategoryInfo 提取分类信息（分类、地区、年份）\nfunc (p *HdmoliPlugin) extractCategoryInfo(s *goquery.Selection) (category, region, year string) {\n\ts.Find(\"p\").Each(func(i int, p *goquery.Selection) {\n\t\ttext := p.Text()\n\t\tif strings.Contains(text, \"分类：\") {\n\t\t\t// 解析分类信息行\n\t\t\tparts := strings.Split(text, \"：\")\n\t\t\tfor i, part := range parts {\n\t\t\t\tpart = strings.TrimSpace(part)\n\t\t\t\tif strings.HasSuffix(parts[i], \"分类\") && i+1 < len(parts) {\n\t\t\t\t\t// 提取分类，可能包含地区和年份信息\n\t\t\t\t\tinfo := strings.TrimSpace(parts[i+1])\n\t\t\t\t\t// 按分隔符分割\n\t\t\t\t\tinfoParts := regexp.MustCompile(`[，,\\s]+`).Split(info, -1)\n\t\t\t\t\tif len(infoParts) > 0 && infoParts[0] != \"\" {\n\t\t\t\t\t\tcategory = infoParts[0]\n\t\t\t\t\t}\n\t\t\t\t} else if strings.HasSuffix(parts[i], \"地区\") && i+1 < len(parts) {\n\t\t\t\t\tregionPart := strings.TrimSpace(parts[i+1])\n\t\t\t\t\tregionParts := regexp.MustCompile(`[，,\\s]+`).Split(regionPart, -1)\n\t\t\t\t\tif len(regionParts) > 0 && regionParts[0] != \"\" {\n\t\t\t\t\t\tregion = regionParts[0]\n\t\t\t\t\t}\n\t\t\t\t} else if strings.HasSuffix(parts[i], \"年份\") && i+1 < len(parts) {\n\t\t\t\t\tyearPart := strings.TrimSpace(parts[i+1])\n\t\t\t\t\tyearParts := regexp.MustCompile(`[，,\\s]+`).Split(yearPart, -1)\n\t\t\t\t\tif len(yearParts) > 0 && yearParts[0] != \"\" {\n\t\t\t\t\t\tyear = yearParts[0]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n\treturn category, region, year\n}\n\n// extractDescription 提取简介\nfunc (p *HdmoliPlugin) extractDescription(s *goquery.Selection) string {\n\tvar description string\n\tdescEl := s.Find(\"p.hidden-xs\")\n\tdescEl.Each(func(i int, p *goquery.Selection) {\n\t\tif description != \"\" {\n\t\t\treturn // 已找到，跳过\n\t\t}\n\t\ttext := p.Text()\n\t\tif strings.Contains(text, \"简介：\") {\n\t\t\tparts := strings.Split(text, \"简介：\")\n\t\t\tif len(parts) > 1 {\n\t\t\t\tdesc := strings.TrimSpace(parts[1])\n\t\t\t\t// 限制长度\n\t\t\t\tif len(desc) > 200 {\n\t\t\t\t\tdesc = desc[:200] + \"...\"\n\t\t\t\t}\n\t\t\t\tdescription = desc\n\t\t\t}\n\t\t}\n\t})\n\treturn description\n}\n\n// fetchDetailLinks 并发获取详情页链接\nfunc (p *HdmoliPlugin) fetchDetailLinks(client *http.Client, searchResults []model.SearchResult, keyword string) []model.SearchResult {\n\tif len(searchResults) == 0 {\n\t\treturn []model.SearchResult{}\n\t}\n\n\t// 使用通道控制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\tvar wg sync.WaitGroup\n\tresultsChan := make(chan model.SearchResult, len(searchResults))\n\n\tfor _, result := range searchResults {\n\t\twg.Add(1)\n\t\tgo func(r model.SearchResult) {\n\t\t\tdefer wg.Done()\n\t\t\tsemaphore <- struct{}{} // 获取信号量\n\t\t\tdefer func() { <-semaphore }() // 释放信号量\n\n\t\t\t// 从Content中提取详情页URL\n\t\t\tdetailURL := p.extractDetailURLFromContent(r.Content)\n\t\t\tif detailURL == \"\" {\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[HDMOLI] 跳过无详情页URL的结果: %s\", r.Title)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 获取详情页链接\n\t\t\tlinks := p.fetchDetailPageLinks(client, detailURL)\n\t\t\tif len(links) > 0 {\n\t\t\t\tr.Links = links\n\t\t\t\t// 清理Content中的详情页URL\n\t\t\t\tr.Content = p.cleanContent(r.Content)\n\t\t\t\tresultsChan <- r\n\t\t\t} else if p.debugMode {\n\t\t\t\tlog.Printf(\"[HDMOLI] 详情页无有效链接: %s\", r.Title)\n\t\t\t}\n\t\t}(result)\n\t}\n\n\t// 等待所有goroutine完成\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultsChan)\n\t}()\n\n\t// 收集结果\n\tvar finalResults []model.SearchResult\n\tfor result := range resultsChan {\n\t\tfinalResults = append(finalResults, result)\n\t}\n\n\treturn finalResults\n}\n\n// extractDetailURLFromContent 从Content中提取详情页URL\nfunc (p *HdmoliPlugin) extractDetailURLFromContent(content string) string {\n\tlines := strings.Split(content, \"\\n\")\n\tfor _, line := range lines {\n\t\tif strings.HasPrefix(line, \"详情页URL: \") {\n\t\t\treturn strings.TrimPrefix(line, \"详情页URL: \")\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// cleanContent 清理Content，移除详情页URL行\nfunc (p *HdmoliPlugin) cleanContent(content string) string {\n\tlines := strings.Split(content, \"\\n\")\n\tvar cleanedLines []string\n\tfor _, line := range lines {\n\t\tif !strings.HasPrefix(line, \"详情页URL: \") {\n\t\t\tcleanedLines = append(cleanedLines, line)\n\t\t}\n\t}\n\treturn strings.Join(cleanedLines, \"\\n\")\n}\n\n// fetchDetailPageLinks 获取详情页的网盘链接\nfunc (p *HdmoliPlugin) fetchDetailPageLinks(client *http.Client, detailURL string) []model.Link {\n\t// 检查缓存\n\tif cached, found := p.detailCache.Load(detailURL); found {\n\t\tif links, ok := cached.([]model.Link); ok {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[HDMOLI] 使用缓存的详情页链接: %s\", detailURL)\n\t\t\t}\n\t\t\treturn links\n\t\t}\n\t}\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[HDMOLI] 创建详情页请求失败: %v\", err)\n\t\t}\n\t\treturn []model.Link{}\n\t}\n\n\t// 设置请求头\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[HDMOLI] 详情页请求失败: %v\", err)\n\t\t}\n\t\treturn []model.Link{}\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[HDMOLI] 详情页HTTP状态错误: %d\", resp.StatusCode)\n\t\t}\n\t\treturn []model.Link{}\n\t}\n\n\t// 读取响应体\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[HDMOLI] 读取详情页响应失败: %v\", err)\n\t\t}\n\t\treturn []model.Link{}\n\t}\n\n\t// 解析网盘链接\n\tlinks := p.parseNetworkDiskLinks(string(body))\n\n\t// 缓存结果\n\tif len(links) > 0 {\n\t\tp.detailCache.Store(detailURL, links)\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[HDMOLI] 从详情页提取到 %d 个链接: %s\", len(links), detailURL)\n\t}\n\n\treturn links\n}\n\n// parseNetworkDiskLinks 解析网盘链接\nfunc (p *HdmoliPlugin) parseNetworkDiskLinks(htmlContent string) []model.Link {\n\tvar links []model.Link\n\n\t// 解析HTML文档以便更精确的提取\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[HDMOLI] 解析详情页HTML失败: %v\", err)\n\t\t}\n\t\t// 如果解析失败，使用正则表达式作为备选\n\t\treturn p.parseNetworkDiskLinksWithRegex(htmlContent)\n\t}\n\n\t// 在\"视频下载\"区域查找网盘链接\n\tdoc.Find(\".downlist\").Each(func(i int, s *goquery.Selection) {\n\t\ts.Find(\"p\").Each(func(j int, pEl *goquery.Selection) {\n\t\t\ttext := pEl.Text()\n\t\t\t\n\t\t\t// 查找夸克网盘\n\t\t\tif strings.Contains(text, \"夸 克：\") || strings.Contains(text, \"夸克：\") {\n\t\t\t\tpEl.Find(\"a\").Each(func(k int, a *goquery.Selection) {\n\t\t\t\t\thref, exists := a.Attr(\"href\")\n\t\t\t\t\tif exists && strings.Contains(href, \"pan.quark.cn\") {\n\t\t\t\t\t\tlink := model.Link{\n\t\t\t\t\t\t\tType:     \"quark\",\n\t\t\t\t\t\t\tURL:      href,\n\t\t\t\t\t\t\tPassword: p.extractPasswordFromQuarkURL(href),\n\t\t\t\t\t\t}\n\t\t\t\t\t\tlinks = append(links, link)\n\t\t\t\t\t\tif p.debugMode {\n\t\t\t\t\t\t\tlog.Printf(\"[HDMOLI] 找到夸克链接: %s\", href)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t}\n\t\t\t\n\t\t\t// 查找百度网盘\n\t\t\tif strings.Contains(text, \"百 度：\") || strings.Contains(text, \"百度：\") {\n\t\t\t\tpEl.Find(\"a\").Each(func(k int, a *goquery.Selection) {\n\t\t\t\t\thref, exists := a.Attr(\"href\")\n\t\t\t\t\tif exists && strings.Contains(href, \"pan.baidu.com\") {\n\t\t\t\t\t\tpassword := p.extractPasswordFromBaiduURL(href)\n\t\t\t\t\t\tlink := model.Link{\n\t\t\t\t\t\t\tType:     \"baidu\",\n\t\t\t\t\t\t\tURL:      href,\n\t\t\t\t\t\t\tPassword: password,\n\t\t\t\t\t\t}\n\t\t\t\t\t\tlinks = append(links, link)\n\t\t\t\t\t\tif p.debugMode {\n\t\t\t\t\t\t\tlog.Printf(\"[HDMOLI] 找到百度链接: %s (密码: %s)\", href, password)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t}\n\t\t})\n\t})\n\n\treturn links\n}\n\n// parseNetworkDiskLinksWithRegex 使用正则表达式解析网盘链接（备选方案）\nfunc (p *HdmoliPlugin) parseNetworkDiskLinksWithRegex(htmlContent string) []model.Link {\n\tvar links []model.Link\n\n\t// 夸克网盘链接模式\n\tquarkPattern := regexp.MustCompile(`<b>夸\\s*克：</b><a[^>]*href\\s*=\\s*[\"']([^\"']*pan\\.quark\\.cn[^\"']*)[\"'][^>]*>`)\n\tquarkMatches := quarkPattern.FindAllStringSubmatch(htmlContent, -1)\n\tfor _, match := range quarkMatches {\n\t\tif len(match) > 1 {\n\t\t\tlink := model.Link{\n\t\t\t\tType:     \"quark\",\n\t\t\t\tURL:      match[1],\n\t\t\t\tPassword: \"\",\n\t\t\t}\n\t\t\tlinks = append(links, link)\n\t\t}\n\t}\n\n\t// 百度网盘链接模式\n\tbaiduPattern := regexp.MustCompile(`<b>百\\s*度：</b><a[^>]*href\\s*=\\s*[\"']([^\"']*pan\\.baidu\\.com[^\"']*)[\"'][^>]*>`)\n\tbaiduMatches := baiduPattern.FindAllStringSubmatch(htmlContent, -1)\n\tfor _, match := range baiduMatches {\n\t\tif len(match) > 1 {\n\t\t\tpassword := p.extractPasswordFromBaiduURL(match[1])\n\t\t\tlink := model.Link{\n\t\t\t\tType:     \"baidu\",\n\t\t\t\tURL:      match[1],\n\t\t\t\tPassword: password,\n\t\t\t}\n\t\t\tlinks = append(links, link)\n\t\t}\n\t}\n\n\treturn links\n}\n\n// extractPasswordFromQuarkURL 从夸克网盘URL提取提取码\nfunc (p *HdmoliPlugin) extractPasswordFromQuarkURL(panURL string) string {\n\t// 夸克网盘一般不需要提取码，直接返回空\n\treturn \"\"\n}\n\n// extractPasswordFromBaiduURL 从百度网盘URL提取提取码\nfunc (p *HdmoliPlugin) extractPasswordFromBaiduURL(panURL string) string {\n\t// 检查URL中是否包含pwd参数\n\tif strings.Contains(panURL, \"?pwd=\") {\n\t\tparts := strings.Split(panURL, \"?pwd=\")\n\t\tif len(parts) > 1 {\n\t\t\treturn parts[1]\n\t\t}\n\t}\n\tif strings.Contains(panURL, \"&pwd=\") {\n\t\tparts := strings.Split(panURL, \"&pwd=\")\n\t\tif len(parts) > 1 {\n\t\t\treturn parts[1]\n\t\t}\n\t}\n\treturn \"\"\n}"
  },
  {
    "path": "plugin/hdmoli/html结构分析.md",
    "content": "# HDMOLI（HDmoli）插件HTML结构分析\n\n## 网站概述\n- **网站名称**: HDmoli\n- **域名**: https://www.hdmoli.pro/\n- **类型**: 影视资源网站，主要提供网盘下载链接（夸克网盘、百度网盘）\n\n## API流程概述\n\n### 搜索页面\n- **请求URL**: `https://www.hdmoli.pro/search.php?searchkey={keyword}&submit=`\n- **方法**: GET\n- **Headers**: 需要设置 `Referer: https://www.hdmoli.pro/`\n- **特点**: 简单的GET请求搜索\n\n## 搜索结果结构\n\n### 搜索结果页面HTML结构\n```html\n<ul class=\"myui-vodlist__media clearfix\" id=\"searchList\">\n    <li class=\"active clearfix\">\n        <div class=\"thumb\">\n            <a class=\"myui-vodlist__thumb\" href=\"/movie/index2976.html\" title=\"怪兽8号 第二季\">\n                <span class=\"pic-tag pic-tag-top\" style=\"background-color: #5bb7fe;\">\n                    7.6分\n                </span>\n                <span class=\"pic-text text-right\">\n                    更新至06集\n                </span>\n            </a>\n        </div>\n        <div class=\"detail\">\n            <h4 class=\"title\">\n                <a href=\"/movie/index2976.html\">怪兽8号 第二季</a>\n            </h4>\n            <p><span class=\"text-muted\">导演：</span>宫繁之</p>\n            <p><span class=\"text-muted\">主演：</span>\n                <a href=\"...\">福西胜也</a>&nbsp;\n                <a href=\"...\">濑户麻沙美</a>&nbsp;\n            </p>\n            <p><span class=\"text-muted\">分类：</span>日本\n                <span class=\"split-line\"></span>\n                <span class=\"text-muted hidden-xs\">地区：</span>日本\n                <span class=\"split-line\"></span>\n                <span class=\"text-muted hidden-xs\">年份：</span>2025\n            </p>\n            <p class=\"hidden-xs\"><span class=\"text-muted\">简介：</span>...</p>\n            <p class=\"margin-0\">\n                <a class=\"btn btn-lg btn-warm\" href=\"/movie/index2976.html\">立即播放</a>\n            </p>\n        </div>\n    </li>\n</ul>\n```\n\n### 详情页面HTML结构\n```html\n<div class=\"myui-content__detail\">\n    <h1 class=\"title text-fff\">怪兽8号 第二季</h1>\n    \n    <!-- 评分 -->\n    <div id=\"rating\" class=\"score\" data-id=\"2976\">\n        <span class=\"branch\">7.6</span>\n    </div>\n    \n    <!-- 基本信息 -->\n    <p class=\"data\">\n        <span class=\"text-muted\">分类：</span>动作,科幻\n        <span class=\"text-muted hidden-xs\">地区：</span>日本\n        <span class=\"text-muted hidden-xs\">年份：</span>2025\n    </p>\n    <p class=\"data\"><span class=\"text-muted\">演员：</span>...</p>\n    <p class=\"data\"><span class=\"text-muted\">导演：</span>...</p>\n    <p class=\"data hidden-sm\"><span class=\"text-muted hidden-xs\">更新：</span>2025-08-24 02:21</p>\n</div>\n\n<!-- 视频下载区域 -->\n<div class=\"myui-panel myui-panel-bg clearfix\">\n    <div class=\"myui-panel_hd\">\n        <h3 class=\"title\">视频下载</h3>\n    </div>\n    <ul class=\"stui-vodlist__text downlist col-pd clearfix\">\n        <div class=\"row\">\n            <p class=\"text-muted col-pd\">\n                <b>夸 克：</b>\n                <a title=\"夸克链接\" href=\"https://pan.quark.cn/s/a061332a75e9\" target=\"_blank\">\n                    https://pan.quark.cn/s/a061332a75e9\n                </a>\n            </p>\n            <p class=\"text-muted col-pd\">\n                <b>百 度：</b>\n                <a title=\"百度网盘\" href=\"https://pan.baidu.com/s/xxx?pwd=moil\" target=\"_blank\">\n                    https://pan.baidu.com/s/...\n                </a>\n            </p>\n        </div>\n    </ul>\n</div>\n```\n\n## 数据提取要点\n\n### 搜索结果页面\n1. **结果列表**: `#searchList > li.active.clearfix` - 每个搜索结果\n2. **标题**: `.detail h4.title a` - 获取文本和href属性\n3. **详情页链接**: `.detail h4.title a[href]` 或 `.thumb a[href]`\n4. **评分**: `.pic-tag` - 数字+分\n5. **更新状态**: `.pic-text` - 如\"更新至06集\"、\"12集全\"\n6. **导演**: 包含\"导演：\"的`<p>`标签内容\n7. **主演**: 包含\"主演：\"的`<p>`标签内的链接\n8. **分类信息**: 包含\"分类：\"的`<p>`标签 - 分类/地区/年份\n9. **简介**: 包含\"简介：\"的`<p>`标签（可能为空或很短）\n\n### 详情页面\n1. **标题**: `h1.title` - 影片完整标题\n2. **豆瓣评分**: `.score .branch` - 数字评分\n3. **基本信息**: `.data`标签中的各种信息\n   - 分类: \"分类：\" 后的内容\n   - 地区: \"地区：\" 后的内容  \n   - 年份: \"年份：\" 后的内容\n   - 又名: \"又名：\" 后的内容（如有）\n4. **演员**: 包含\"演员：\"的`.data`标签内的链接\n5. **导演**: 包含\"导演：\"的`.data`标签内的链接\n6. **更新时间**: 包含\"更新：\"的`.data`标签\n7. **网盘链接提取**:\n   - 夸克网盘: `<b>夸 克：</b>` 后的 `<a>` 标签\n   - 百度网盘: `<b>百 度：</b>` 后的 `<a>` 标签\n   - 其他可能的网盘类型\n\n## 网盘链接识别规则\n\n### 支持的网盘类型\n- **夸克网盘**: `pan.quark.cn`\n- **百度网盘**: `pan.baidu.com`\n- **阿里云盘**: `aliyundrive.com` / `alipan.com`（可能出现）\n- **天翼云盘**: `cloud.189.cn`（可能出现）\n\n### 链接提取策略\n1. 在详情页的\"视频下载\"区域搜索\n2. 按网盘类型标识符匹配（夸 克：、百 度：等）\n3. 提取对应的`<a>`标签的`href`属性\n4. 从URL或周围文本提取可能的提取码（如`?pwd=xxx`）\n\n## 特殊处理\n\n### 时间解析\n- 搜索结果页无明确时间信息\n- 详情页有更新时间：格式 `2025-08-24 02:21`\n- 可使用更新时间作为发布时间\n\n### 内容处理\n- 评分处理：提取数字部分\n- 更新状态：如\"更新至06集\"、\"完结\"等\n- 简介可能很短或为空\n- 标题清理：移除多余空格\n\n### 分页处理\n- 搜索结果有分页：`.myui-page` 区域\n- 分页链接格式：`?page=2&searchkey=xxx&searchtype=`\n\n## 注意事项\n\n1. **网盘为主**: 此网站主要提供网盘下载链接，而非在线播放\n2. **referer必需**: 请求时需要设置正确的referer头\n3. **编码处理**: 关键词需要URL编码\n4. **链接验证**: 网盘链接可能失效，需要验证有效性\n5. **提取码**: 百度网盘链接通常有提取码，在URL参数或文本中"
  },
  {
    "path": "plugin/hdr4k/hdr4k.go",
    "content": "package hdr4k\n\nimport (\n\t\"fmt\"\n\t\"math/rand\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\n// 缓存相关变量\nvar (\n\t// 详情页缓存\n\tdetailPageCache = sync.Map{}\n\t\n\t// 搜索结果缓存\n\tsearchResultCache = sync.Map{}\n\t\n\t// 链接类型判断缓存\n\tlinkTypeCache = sync.Map{}\n\t\n\t// 最后一次清理缓存的时间\n\tlastCacheCleanTime = time.Now()\n\t\n\t// 缓存有效期\n\tcacheTTL = 1 * time.Hour\n)\n\n// 常用UA列表\nvar userAgents = []string{\n\t\"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\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36\",\n\t\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0\",\n\t\"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36\",\n}\n\n// 缓存响应结构\ntype cachedResponse struct {\n\tdata      interface{}\n\ttimestamp time.Time\n}\n\n// 初始化插件\nfunc init() {\n\t// 注册插件\n\tplugin.RegisterGlobalPlugin(NewHdr4kAsyncPlugin())\n\t\n\t// 启动缓存清理\n\tgo startCacheCleaner()\n\t\n\t// 初始化随机数种子\n\trand.Seed(time.Now().UnixNano())\n}\n\n// startCacheCleaner 启动一个定期清理缓存的goroutine\nfunc startCacheCleaner() {\n\t// 每小时清理一次缓存\n\tticker := time.NewTicker(1 * time.Hour)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\t// 清空所有缓存\n\t\tdetailPageCache = sync.Map{}\n\t\tsearchResultCache = sync.Map{}\n\t\tlinkTypeCache = sync.Map{}\n\t\tlastCacheCleanTime = time.Now()\n\t}\n}\n\n// getRandomUA 获取随机UA\nfunc getRandomUA() string {\n\treturn userAgents[rand.Intn(len(userAgents))]\n}\n\nconst (\n\t// 搜索API\n\tSearchURL = \"https://www.4khdr.cn/search.php?mod=forum\"\n\t// 详情页URL模式\n\tThreadURLPattern = \"https://www.4khdr.cn/thread-%s-1-1.html\"\n\t// 默认超时时间\n\tDefaultTimeout = 10 * time.Second\n\t// 最大重试次数\n\tMaxRetries = 2\n\t// 最大并发数\n\tMaxConcurrency = 20\n)\n\n// Hdr4kAsyncPlugin 4KHDR网站搜索异步插件\ntype Hdr4kAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\n// NewHdr4kAsyncPlugin 创建新的4KHDR搜索异步插件\nfunc NewHdr4kAsyncPlugin() *Hdr4kAsyncPlugin {\n\treturn &Hdr4kAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"hdr4k\", 1), // 高优先级\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *Hdr4kAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *Hdr4kAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)\n}\n\n// doSearch 实际的搜索实现\nfunc (p *Hdr4kAsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 处理ext参数\n\tsearchKeyword := keyword\n\tif ext != nil {\n\t\t// 使用类型断言安全地获取参数\n\t\tif titleEn, ok := ext[\"title_en\"].(string); ok && titleEn != \"\" {\n\t\t\t// 使用英文标题替换关键词\n\t\t\tsearchKeyword = titleEn\n\t\t}\n\t}\n\t\n\t// 构建POST请求数据\n\tdata := url.Values{}\n\tdata.Set(\"srchtxt\", searchKeyword)\n\tdata.Set(\"searchsubmit\", \"yes\")\n\t\n\t// 发送POST请求\n\treq, err := http.NewRequest(\"POST\", SearchURL, strings.NewReader(data.Encode()))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建请求失败: %w\", err)\n\t}\n\t\n\t// 设置请求头\n\treq.Header.Set(\"User-Agent\", getRandomUA())\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\treq.Header.Set(\"Referer\", \"https://www.4khdr.cn/\")\n\t\n\t// 发送请求（带重试）\n\tresp, err := p.doRequestWithRetry(client, req, MaxRetries)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"解析HTML失败: %w\", err)\n\t}\n\t\n\t// 提取搜索结果\n\tvar wg sync.WaitGroup\n\tresultChan := make(chan model.SearchResult, 20)\n\terrorChan := make(chan error, 20)\n\t\n\t// 创建信号量控制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\n\t// 预先收集所有需要处理的项\n\tvar items []*goquery.Selection\n\t\n\t// 将关键词转为小写，用于不区分大小写的比较\n\tlowerKeyword := strings.ToLower(keyword)\n\t\n\t// 将关键词按空格分割，用于支持多关键词搜索\n\tkeywords := strings.Fields(lowerKeyword)\n\t\n\t// 预先过滤不包含关键词的帖子\n\tdoc.Find(\".slst.mtw ul li.pbw\").Each(func(i int, s *goquery.Selection) {\n\t\t// 提取帖子ID\n\t\tpostID, exists := s.Attr(\"id\")\n\t\tif !exists || postID == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 提取标题\n\t\ttitleElement := s.Find(\"h3.xs3 a\")\n\t\ttitle := p.cleanHTML(titleElement.Text())\n\t\ttitle = strings.TrimSpace(title)\n\t\tlowerTitle := strings.ToLower(title)\n\t\t\n\t\tif title == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 提取内容描述\n\t\tcontentElement := s.Find(\"p\").First()\n\t\tcontent := p.cleanHTML(contentElement.Text())\n\t\tcontent = strings.TrimSpace(content)\n\t\tlowerContent := strings.ToLower(content)\n\t\t\n\t\t// 检查每个关键词是否在标题或内容中\n\t\tmatched := true\n\t\tfor _, kw := range keywords {\n\t\t\t// 对于所有关键词，检查是否在标题或内容中\n\t\t\tif !strings.Contains(lowerTitle, kw) && !strings.Contains(lowerContent, kw) {\n\t\t\t\tmatched = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 只添加匹配的帖子\n\t\tif matched {\n\t\t\titems = append(items, s)\n\t\t}\n\t})\n\t\n\t// 并发处理每个搜索结果项\n\tfor i, s := range items {\n\t\twg.Add(1)\n\t\t\n\t\tgo func(index int, s *goquery.Selection) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 提取帖子ID\n\t\t\tpostID, exists := s.Attr(\"id\")\n\t\t\tif !exists || postID == \"\" {\n\t\t\t\terrorChan <- fmt.Errorf(\"无法提取帖子ID: index=%d\", index)\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\t// 提取标题\n\t\t\ttitleElement := s.Find(\"h3.xs3 a\")\n\t\t\ttitle := p.cleanHTML(titleElement.Text())\n\t\t\ttitle = strings.TrimSpace(title)\n\t\t\t\n\t\t\t// 提取内容描述\n\t\t\tcontentElement := s.Find(\"p\").First()\n\t\t\tcontent := p.cleanHTML(contentElement.Text())\n\t\t\tcontent = strings.TrimSpace(content)\n\t\t\t\n\t\t\t// 提取日期时间\n\t\t\tvar datetime time.Time\n\t\t\tdateElements := s.Find(\"p span\")\n\t\t\tif dateElements.Length() > 0 {\n\t\t\t\tdateStr := strings.TrimSpace(dateElements.First().Text())\n\t\t\t\tif dateStr != \"\" {\n\t\t\t\t\tparsedTime, err := p.parseDateTime(dateStr)\n\t\t\t\t\tif err == nil {\n\t\t\t\t\t\tdatetime = parsedTime\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 提取分类标签\n\t\t\tvar tags []string\n\t\t\tcategoryElement := s.Find(\"p span a.xi1\")\n\t\t\tif categoryElement.Length() > 0 {\n\t\t\t\tcategory := strings.TrimSpace(categoryElement.Text())\n\t\t\t\tif category != \"\" {\n\t\t\t\t\ttags = append(tags, category)\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 获取详情页链接，并尝试获取下载链接\n\t\t\tlinks, detailContent, err := p.getLinksFromDetail(client, postID)\n\t\t\tif err != nil {\n\t\t\t\t// 如果获取链接失败，仍然返回结果，但没有链接\n\t\t\t\tlinks = []model.Link{}\n\t\t\t}\n\t\t\t\n\t\t\t// 如果从详情页获取到了更详细的内容，使用详情页的内容\n\t\t\tif detailContent != \"\" {\n\t\t\t\tcontent = detailContent\n\t\t\t}\n\t\t\t\n\t\t\t// 检查是否是无意义的求片帖（没有实际资源的求片帖）\n\t\t\tif p.isEmptyRequestPost(title, links) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\t// 创建搜索结果\n\t\t\tresult := model.SearchResult{\n\t\t\t\tUniqueID:  fmt.Sprintf(\"hdr4k-%s\", postID),\n\t\t\t\tTitle:     title,\n\t\t\t\tContent:   content,\n\t\t\t\tDatetime:  datetime,\n\t\t\t\tLinks:     links,\n\t\t\t\tTags:      tags,\n\t\t\t}\n\t\t\t\n\t\t\tresultChan <- result\n\t\t}(i, s)\n\t}\n\t\n\t// 等待所有goroutine完成\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultChan)\n\t\tclose(errorChan)\n\t}()\n\t\n\t// 收集结果\n\tvar results []model.SearchResult\n\tfor result := range resultChan {\n\t\tresults = append(results, result)\n\t}\n\t\n\t// 由于我们已经在前面过滤了不匹配的帖子，这里不需要再次过滤\n\treturn results, nil\n}\n\n// isEmptyRequestPost 判断是否是没有实际资源的求片帖子\nfunc (p *Hdr4kAsyncPlugin) isEmptyRequestPost(title string, links []model.Link) bool {\n\tlowerTitle := strings.ToLower(title)\n\t\n\t// 如果有实际的下载链接，不过滤\n\tif len(links) > 0 {\n\t\treturn false\n\t}\n\t\n\t// 只过滤明确的无资源求片关键词\n\temptyRequestKeywords := []string{\n\t\t\"求片\",\n\t\t\"有资源吗\",\n\t\t\"有没有资源\",\n\t\t\"跪求\",\n\t\t\"求资源\",\n\t}\n\t\n\tfor _, keyword := range emptyRequestKeywords {\n\t\tif strings.Contains(lowerTitle, keyword) {\n\t\t\treturn true\n\t\t}\n\t}\n\t\n\t// 对于求网盘的帖子，如果没有链接才过滤\n\tcloudRequestKeywords := []string{\n\t\t\"求阿里云盘\", \n\t\t\"求百度网盘\",\n\t\t\"求夸克网盘\",\n\t\t\"求迅雷网盘\",\n\t\t\"求天翼云盘\",\n\t}\n\t\n\tfor _, keyword := range cloudRequestKeywords {\n\t\tif strings.Contains(lowerTitle, keyword) {\n\t\t\t// 只有当没有实际链接时才过滤\n\t\t\treturn len(links) == 0\n\t\t}\n\t}\n\t\n\t// 检查是否以\"求\"开头，但要排除正常的电影名称\n\tif strings.HasPrefix(lowerTitle, \"求\") {\n\t\t// 如果标题很短且以\"求\"开头，且没有链接，很可能是求片帖\n\t\tif len([]rune(title)) < 10 && !strings.Contains(lowerTitle, \"年\") && !strings.Contains(lowerTitle, \"季\") && len(links) == 0 {\n\t\t\treturn true\n\t\t}\n\t}\n\t\n\treturn false\n}\n\n// getLinksFromDetail 从详情页获取下载链接（改进版，支持重试）\nfunc (p *Hdr4kAsyncPlugin) getLinksFromDetail(client *http.Client, postID string) ([]model.Link, string, error) {\n\t// 生成缓存键\n\tcacheKey := fmt.Sprintf(\"detail:%s\", postID)\n\t\n\t// 检查缓存中是否已有结果\n\tif cachedData, ok := detailPageCache.Load(cacheKey); ok {\n\t\t// 检查缓存是否过期\n\t\tcachedResult := cachedData.(cachedResponse)\n\t\tif time.Since(cachedResult.timestamp) < cacheTTL {\n\t\t\tdata := cachedResult.data.(struct {\n\t\t\t\tLinks   []model.Link\n\t\t\t\tContent string\n\t\t\t})\n\t\t\treturn data.Links, data.Content, nil\n\t\t}\n\t}\n\t\n\t// 构建详情页URL\n\tdetailURL := fmt.Sprintf(ThreadURLPattern, postID)\n\t\n\t// 发送GET请求获取详情页\n\treq, err := http.NewRequest(\"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn []model.Link{}, \"\", fmt.Errorf(\"创建请求失败: %w\", err)\n\t}\n\t\n\t// 设置请求头\n\treq.Header.Set(\"User-Agent\", getRandomUA())\n\treq.Header.Set(\"Referer\", \"https://www.4khdr.cn/\")\n\t\n\t// 发送请求（带重试）\n\tresp, err := p.doRequestWithRetry(client, req, MaxRetries)\n\tif err != nil {\n\t\treturn []model.Link{}, \"\", fmt.Errorf(\"请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn []model.Link{}, \"\", fmt.Errorf(\"解析HTML失败: %w\", err)\n\t}\n\t\n\t// 提取详情页内容\n\tvar links []model.Link\n\tvar detailContent string\n\t\n\t// 查找帖子内容区域和回复区域\n\tcontentSelectors := []string{\n\t\t\".t_f\",           // 主帖内容\n\t\t\"[id^=postmessage_]\", // 回复内容（以postmessage_开头的id）\n\t}\n\t\n\tfor _, selector := range contentSelectors {\n\t\tdoc.Find(selector).Each(func(i int, contentArea *goquery.Selection) {\n\t\t\t// 如果还没有提取到详细内容，提取剧情简介等\n\t\t\tif detailContent == \"\" {\n\t\t\t\tcontent := p.cleanHTML(contentArea.Text())\n\t\t\t\tcontent = strings.TrimSpace(content)\n\t\t\t\t\n\t\t\t\t// 提取前500个字符作为详细描述\n\t\t\t\tif len(content) > 500 {\n\t\t\t\t\tdetailContent = content[:500] + \"...\"\n\t\t\t\t} else if len(content) > 50 {\n\t\t\t\t\tdetailContent = content\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 提取下载链接\n\t\t\tcontentArea.Find(\"a\").Each(func(j int, linkElement *goquery.Selection) {\n\t\t\t\thref, exists := linkElement.Attr(\"href\")\n\t\t\t\tif !exists || href == \"\" {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// 检查是否是网盘链接\n\t\t\t\tlinkType := p.determineLinkType(href, \"\")\n\t\t\t\tif linkType != \"others\" {\n\t\t\t\t\t// 检查是否已经存在相同的链接\n\t\t\t\t\texists := false\n\t\t\t\t\tfor _, existingLink := range links {\n\t\t\t\t\t\tif existingLink.URL == href {\n\t\t\t\t\t\t\texists = true\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\tif !exists {\n\t\t\t\t\t\tlink := model.Link{\n\t\t\t\t\t\t\tURL:      href,\n\t\t\t\t\t\t\tType:     linkType,\n\t\t\t\t\t\t\tPassword: \"\", // 4KHDR通常不提供密码\n\t\t\t\t\t\t}\n\t\t\t\t\t\tlinks = append(links, link)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t})\n\t\t})\n\t}\n\t\n\t// 缓存结果\n\tcacheData := struct {\n\t\tLinks   []model.Link\n\t\tContent string\n\t}{\n\t\tLinks:   links,\n\t\tContent: detailContent,\n\t}\n\t\n\tdetailPageCache.Store(cacheKey, cachedResponse{\n\t\tdata:      cacheData,\n\t\ttimestamp: time.Now(),\n\t})\n\t\n\treturn links, detailContent, nil\n}\n\n// determineLinkType 根据URL和名称确定链接类型（改进版）\nfunc (p *Hdr4kAsyncPlugin) determineLinkType(url, name string) string {\n\t// 生成缓存键\n\tcacheKey := fmt.Sprintf(\"%s:%s\", url, name)\n\t\n\t// 检查缓存\n\tif cachedType, ok := linkTypeCache.Load(cacheKey); ok {\n\t\treturn cachedType.(string)\n\t}\n\t\n\tlowerURL := strings.ToLower(url)\n\tlowerName := strings.ToLower(name)\n\t\n\tvar linkType string\n\t\n\t// 根据URL判断\n\tswitch {\n\tcase strings.Contains(lowerURL, \"pan.quark.cn\"):\n\t\tlinkType = \"quark\"\n\tcase strings.Contains(lowerURL, \"pan.baidu.com\"):\n\t\tlinkType = \"baidu\"\n\tcase strings.Contains(lowerURL, \"alipan.com\") || strings.Contains(lowerURL, \"aliyundrive.com\"):\n\t\tlinkType = \"aliyun\"\n\tcase strings.Contains(lowerURL, \"pan.xunlei.com\"):\n\t\tlinkType = \"xunlei\"\n\tcase strings.Contains(lowerURL, \"cloud.189.cn\"):\n\t\tlinkType = \"tianyi\"\n\tcase strings.Contains(lowerURL, \"115.com\"):\n\t\tlinkType = \"115\"\n\tcase strings.Contains(lowerURL, \"drive.uc.cn\"):\n\t\tlinkType = \"uc\"\n\tcase strings.Contains(lowerURL, \"caiyun.139.com\"):\n\t\tlinkType = \"mobile\"\n\tcase strings.Contains(lowerURL, \"share.weiyun.com\"):\n\t\tlinkType = \"weiyun\"\n\tcase strings.Contains(lowerURL, \"lanzou\"):\n\t\tlinkType = \"lanzou\"\n\tcase strings.Contains(lowerURL, \"jianguoyun.com\"):\n\t\tlinkType = \"jianguoyun\"\n\tcase strings.Contains(lowerURL, \"123pan.com\"):\n\t\tlinkType = \"123\"\n\tcase strings.Contains(lowerURL, \"mypikpak.com\"):\n\t\tlinkType = \"pikpak\"\n\tcase strings.HasPrefix(lowerURL, \"magnet:\"):\n\t\tlinkType = \"magnet\"\n\tcase strings.HasPrefix(lowerURL, \"ed2k:\"):\n\t\tlinkType = \"ed2k\"\n\tdefault:\n\t\t// 根据名称判断\n\t\tswitch {\n\t\tcase strings.Contains(lowerName, \"百度\"):\n\t\t\tlinkType = \"baidu\"\n\t\tcase strings.Contains(lowerName, \"阿里\"):\n\t\t\tlinkType = \"aliyun\"\n\t\tcase strings.Contains(lowerName, \"迅雷\"):\n\t\t\tlinkType = \"xunlei\"\n\t\tcase strings.Contains(lowerName, \"夸克\"):\n\t\t\tlinkType = \"quark\"\n\t\tcase strings.Contains(lowerName, \"天翼\"):\n\t\t\tlinkType = \"tianyi\"\n\t\tcase strings.Contains(lowerName, \"115\"):\n\t\t\tlinkType = \"115\"\n\t\tcase strings.Contains(lowerName, \"uc\"):\n\t\t\tlinkType = \"uc\"\n\t\tcase strings.Contains(lowerName, \"移动\") || strings.Contains(lowerName, \"彩云\"):\n\t\t\tlinkType = \"mobile\"\n\t\tcase strings.Contains(lowerName, \"微云\"):\n\t\t\tlinkType = \"weiyun\"\n\t\tcase strings.Contains(lowerName, \"蓝奏\"):\n\t\t\tlinkType = \"lanzou\"\n\t\tcase strings.Contains(lowerName, \"坚果\"):\n\t\t\tlinkType = \"jianguoyun\"\n\t\tcase strings.Contains(lowerName, \"123\"):\n\t\t\tlinkType = \"123\"\n\t\tcase strings.Contains(lowerName, \"pikpak\"):\n\t\t\tlinkType = \"pikpak\"\n\t\tdefault:\n\t\t\tlinkType = \"others\"\n\t\t}\n\t}\n\t\n\t// 缓存结果\n\tlinkTypeCache.Store(cacheKey, linkType)\n\t\n\treturn linkType\n}\n\n// doRequestWithRetry 发送HTTP请求并支持重试\nfunc (p *Hdr4kAsyncPlugin) doRequestWithRetry(client *http.Client, req *http.Request, maxRetries int) (*http.Response, error) {\n\tvar resp *http.Response\n\tvar err error\n\t\n\tfor i := 0; i <= maxRetries; i++ {\n\t\t// 如果不是第一次尝试，等待一段时间\n\t\tif i > 0 {\n\t\t\t// 指数退避算法\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 500 * time.Millisecond\n\t\t\tif backoff > 5*time.Second {\n\t\t\t\tbackoff = 5 * time.Second\n\t\t\t}\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求，避免重用同一个请求对象\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\t// 发送请求\n\t\tresp, err = client.Do(reqClone)\n\t\t\n\t\t// 如果请求成功或者是不可重试的错误，则退出循环\n\t\tif err == nil || !p.isRetriableError(err) {\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\treturn resp, err\n}\n\n// isRetriableError 判断错误是否可以重试\nfunc (p *Hdr4kAsyncPlugin) isRetriableError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\t\n\t// 判断是否是网络错误或超时错误\n\tif netErr, ok := err.(net.Error); ok {\n\t\treturn netErr.Timeout() || netErr.Temporary()\n\t}\n\t\n\t// 其他可能需要重试的错误类型\n\terrStr := err.Error()\n\treturn strings.Contains(errStr, \"connection refused\") ||\n\t\t   strings.Contains(errStr, \"connection reset\") ||\n\t\t   strings.Contains(errStr, \"EOF\")\n}\n\n// parseDateTime 解析日期时间字符串\nfunc (p *Hdr4kAsyncPlugin) parseDateTime(dateStr string) (time.Time, error) {\n\t// 4KHDR的时间格式：2025-4-9 19:55\n\tlayouts := []string{\n\t\t\"2006-1-2 15:04\",\n\t\t\"2006-01-02 15:04:05\",\n\t\t\"2006-1-2 15:04:05\",\n\t\t\"2006-01-02 15:04\",\n\t}\n\t\n\tfor _, layout := range layouts {\n\t\tif t, err := time.Parse(layout, dateStr); err == nil {\n\t\t\treturn t, nil\n\t\t}\n\t}\n\t\n\treturn time.Time{}, fmt.Errorf(\"无法解析日期时间: %s\", dateStr)\n}\n\n// cleanHTML 清理HTML标签和特殊字符\nfunc (p *Hdr4kAsyncPlugin) cleanHTML(html string) string {\n\t// 替换常见HTML标签和实体\n\treplacements := map[string]string{\n\t\t\"<strong>\":                     \"\",\n\t\t\"</strong>\":                    \"\",\n\t\t\"<font color=\\\"#ff0000\\\">\":     \"\",\n\t\t\"</font>\":                      \"\",\n\t\t\"<em>\":                         \"\",\n\t\t\"</em>\":                        \"\",\n\t\t\"<b>\":                          \"\",\n\t\t\"</b>\":                         \"\",\n\t\t\"<br>\":                         \"\\n\",\n\t\t\"<br/>\":                        \"\\n\",\n\t\t\"<br />\":                       \"\\n\",\n\t\t\"&nbsp;\":                       \" \",\n\t\t\"&hellip;\":                     \"...\",\n\t\t\"&amp;\":                        \"&\",\n\t\t\"&lt;\":                         \"<\",\n\t\t\"&gt;\":                         \">\",\n\t\t\"&quot;\":                       \"\\\"\",\n\t\t\"&#039;\":                       \"'\",\n\t}\n\t\n\tresult := html\n\tfor old, new := range replacements {\n\t\tresult = strings.ReplaceAll(result, old, new)\n\t}\n\t\n\t// 移除其他HTML标签（简单的正则表达式）\n\tre := regexp.MustCompile(`<[^>]*>`)\n\tresult = re.ReplaceAllString(result, \"\")\n\t\n\t// 清理多余的空白字符\n\tre = regexp.MustCompile(`\\s+`)\n\tresult = re.ReplaceAllString(result, \" \")\n\t\n\treturn strings.TrimSpace(result)\n}\n"
  },
  {
    "path": "plugin/hdr4k/html结构分析.md",
    "content": "# 4KHDR网站搜索结果HTML结构分析\n\n## 搜索接口\n\n- **搜索URL**: `https://www.4khdr.cn/search.php?mod=forum`\n- **请求方法**: POST\n- **请求参数**: \n  - `srchtxt`: 搜索关键词\n  - `searchsubmit`: \"yes\"\n  - Content-Type: `application/x-www-form-urlencoded`\n\n## 页面整体结构\n\n搜索结果页面的主要内容位于`.slst.mtw`元素内，每个搜索结果项包含在`<li class=\"pbw\">`元素中。\n\n```html\n<div class=\"slst mtw\" id=\"threadlist\">\n    <ul>\n        <li class=\"pbw\" id=\"32374\">\n            <!-- 单个搜索结果 -->\n        </li>\n        <li class=\"pbw\" id=\"26211\">\n            <!-- 单个搜索结果 -->\n        </li>\n    </ul>\n</div>\n```\n\n## 单个搜索结果结构\n\n### 1. 帖子ID\n\n帖子ID可以从以下位置提取：\n- li元素的id属性：`<li class=\"pbw\" id=\"32374\">`，其中`32374`为帖子ID\n- 详情页链接：`href=\"thread-32374-1-1.html\"`\n\n### 2. 标题\n\n标题位于`.xs3 a`元素中：\n\n```html\n<h3 class=\"xs3\">\n    <a href=\"thread-32374-1-1.html\" target=\"_blank\">\n        机动战士<strong><font color=\"#ff0000\">高达</font></strong>：跨时之战 機動戦士Gundam GQuuuuuuX -Beginning- 2025\n        机动战士<strong><font color=\"#ff0000\">高达</font></strong>GQuuuuuuX序章/機動戦士ガンダム ジークアクス\n        -Beginning-‎/Mobile Suit Gundam GQuuuuuuX -Beginning- 日本 6.8\n    </a>\n</h3>\n```\n\n**注意**: 需要清理HTML标签，特别是高亮搜索关键词的`<strong><font>`标签。\n\n### 3. 内容描述\n\n内容描述位于h3下方的第一个`<p>`元素中：\n\n```html\n<p>名 称: 机动战士<strong><font color=\"#ff0000\">高达</font></strong>：跨时之战 機動戦士Gundam GQuuuuuuX\n    -Beginning-\n    年 代: 2025\n    又 名: 机动战士<strong><font color=\"#ff0000\">高达</font></strong>GQuuuuuuX序章 / 機動戦士ガンダム ジークアクス\n    -Beginning-‎ / Mobile Suit Gundam GQuuuuuuX -Be ...\n</p>\n```\n\n### 4. 日期时间\n\n日期时间信息位于最后一个`<p>`元素的第一个`<span>`中：\n\n```html\n<p>\n    <span>2025-4-9 19:55</span>\n    -\n    <span>\n        <a href=\"space-uid-3.html\" target=\"_blank\">4KHDR世界</a>\n    </span>\n    -\n    <span><a href=\"forum-2-1.html\" target=\"_blank\" class=\"xi1\">4K电影美剧下载 - HDR杜比视界资源</a></span>\n</p>\n```\n\n### 5. 分类信息\n\n分类信息位于最后一个`<p>`元素的最后一个链接中：\n\n```html\n<span><a href=\"forum-2-1.html\" target=\"_blank\" class=\"xi1\">4K电影美剧下载 - HDR杜比视界资源</a></span>\n```\n\n### 6. 作者信息\n\n作者信息位于日期和分类之间：\n\n```html\n<span>\n    <a href=\"space-uid-3.html\" target=\"_blank\">4KHDR世界</a>\n</span>\n```\n\n## 详情页面结构分析\n\n### 详情页URL格式\n\n- **URL模式**: `https://www.4khdr.cn/thread-{帖子ID}-1-1.html`\n- **示例**: `https://www.4khdr.cn/thread-32358-1-1.html`\n\n### 详情页内容结构\n\n#### 1. 页面标题\n\n页面标题位于`<h1 class=\"ts\">`元素中：\n\n```html\n<h1 class=\"ts\">\n    <a href=\"forum.php?mod=forumdisplay&amp;fid=37&amp;filter=typeid&amp;typeid=55\">[夸克网盘]</a>\n    <span id=\"thread_subject\">机动战士高达GQuuuuuuX 機動戦士Gundam GQuuuuuuX 2025 Mobile Suit Gundam GQuuuuuuX/機動戦士ガンダム ジークアクス 日本</span>\n</h1>\n```\n\n#### 2. 海报图片\n\n海报图片位于`.t_f`元素内的第一个img标签：\n\n```html\n<img id=\"aimg_39462\" aid=\"39462\" src=\"static/image/common/none.gif\" \n     zoomfile=\"data/attachment/forum/202504/09/100901qjr9gkl33339tk7g.jpg\" \n     file=\"data/attachment/forum/202504/09/100901qjr9gkl33339tk7g.jpg\" \n     class=\"zoom\" width=\"270\" />\n```\n\n**注意**: 实际图片URL在`zoomfile`或`file`属性中。\n\n#### 3. 影片基本信息\n\n影片信息在`.t_f`元素中，以`<strong>`标签标识字段名：\n\n```html\n<strong>名 称:&nbsp;&nbsp;</strong> 机动战士高达GQuuuuuuX 機動戦士Gundam GQuuuuuuX<br />\n<strong>年 代:&nbsp;&nbsp;</strong> 2025<br />\n<strong>又 名:&nbsp;&nbsp;</strong> Mobile Suit Gundam GQuuuuuuX / 機動戦士ガンダム ジークアクス<br />\n<strong>导 演:&nbsp;&nbsp;</strong> 鹤卷和哉<br />\n<strong>编 剧:&nbsp;&nbsp;</strong> 榎户洋司 / 庵野秀明<br />\n<strong>类 型:&nbsp;&nbsp;</strong> 科幻 / 动画<br />\n<strong>地 区:&nbsp;&nbsp;</strong> 日本<br />\n<strong>语 言:&nbsp;&nbsp;</strong> 日语<br />\n<strong>首 播:&nbsp;&nbsp;</strong> 2025-04-08(日本)<br />\n<strong>豆 瓣:&nbsp;&nbsp;</strong> 分 (共0人参与评分)<br />\n```\n\n#### 4. 主演信息\n\n主演信息位于\"主演名单\"标题后：\n\n```html\n<hr class=\"l\" /><strong>主演名单</strong><br />\n黑泽朋世 / 石川由依 / 土屋神叶 / 川田绅司 / 山下诚一郎 / 藤田茜 / 钉宫理惠<br />\n```\n\n#### 5. 剧情简介\n\n剧情简介位于\"剧情简介\"标题后：\n\n```html\n<hr class=\"l\" /><strong>剧情简介</strong><br />\n&nbsp; &nbsp;&nbsp; &nbsp;天手让叶是名女高中生，在空中的宇宙殖民卫星过着平静的生活。<br />\n但在遇到难民少女尼娅安后，她被卷入了非法MS决斗竞技\"军团战\"之中。她化名\"玛秋\"参赛，驾驶着GQuuuuuuX，每日投身激烈的战斗。<br />\n同时，被宇宙军和警察两方追捕的神秘MS\"高达\"及其驾驶员──少年修司，出现在她们面前。<br />\n接着，世界迎向了新时代。<br />\n```\n\n#### 6. 下载地址\n\n下载地址位于\"下载地址⏬\"标题后的链接：\n\n```html\n<strong>下载地址⏬</strong><br />\n<br />\n<br />\n<a href=\"https://pan.quark.cn/s/7a19ff270969\" target=\"_blank\">https://pan.quark.cn/s/7a19ff270969</a><br />\n```\n\n## 数据提取要点\n\n### 1. 网盘链接类型识别\n\n根据URL域名识别网盘类型：\n- `pan.quark.cn` → `quark`\n- `pan.baidu.com` → `baidu`\n- `alipan.com`, `aliyundrive.com` → `aliyun`\n- `pan.xunlei.com` → `xunlei`\n- `cloud.189.cn` → `tianyi`\n- 其他 → `others`\n\n### 2. HTML标签清理\n\n需要清理的HTML标签：\n- `<strong>` 和 `</strong>`\n- `<font color=\"#ff0000\">` 和 `</font>`\n- `<br />` 转换为换行符\n- `&nbsp;` 转换为空格\n- `&hellip;` 转换为省略号\n\n### 3. 时间格式\n\n时间格式为：`YYYY-M-D H:MM`，如 `2025-4-9 19:55`\n需要解析为标准时间格式。\n\n### 4. 分类标签提取\n\n从分类链接中提取分类名称，去除HTML标签后作为标签使用。\n\n## 注意事项\n\n1. **搜索结果可能包含求片帖**：标题以\"求片\"、\"求阿里云盘\"等开头的帖子需要过滤或特殊处理\n2. **搜索关键词高亮**：搜索结果中的关键词会被`<strong><font>`标签包围，需要正确清理\n3. **详情页访问**：需要构造正确的详情页URL进行二次请求获取完整信息\n4. **图片URL处理**：需要将相对路径转换为绝对路径\n5. **字符编码**：网站使用UTF-8编码，注意正确处理中文字符"
  },
  {
    "path": "plugin/hdr4k/设计文档.md",
    "content": "# 4KHDR插件开发设计文档\n\n## 📋 目录\n\n1. [项目概述](#项目概述)\n2. [架构设计](#架构设计)\n3. [技术实现](#技术实现)\n4. [核心优化](#核心优化)\n5. [API设计](#api设计)\n6. [性能优化](#性能优化)\n7. [错误处理](#错误处理)\n8. [缓存策略](#缓存策略)\n9. [测试策略](#测试策略)\n10. [部署维护](#部署维护)\n\n---\n\n## 📖 项目概述\n\n### 项目背景\n\n4KHDR插件是PanSou搜索引擎的核心组件之一，专门用于搜索4KHDR.CN网站的影视资源。该网站是一个高质量的4K影视资源分享社区，包含大量蓝光原盘、网盘资源等。\n\n### 核心目标\n\n- 🎯 **高效搜索**：快速准确地搜索4KHDR网站的影视资源\n- 🔗 **智能链接提取**：从帖子和回复中提取各种网盘下载链接\n- 🎭 **智能过滤**：区分求片帖和资源帖，保留有价值的内容\n- ⚡ **高性能**：支持并发处理，提供毫秒级响应速度\n- 🛡️ **高可靠性**：具备完善的错误处理和重试机制\n\n### 技术栈\n\n- **编程语言**：Go 1.21+\n- **HTTP客户端**：net/http标准库\n- **HTML解析**：goquery（基于jQuery语法）\n- **并发控制**：goroutine + channel + sync.WaitGroup\n- **缓存**：sync.Map（内存缓存）\n- **架构模式**：异步插件架构\n\n---\n\n## 🏗️ 架构设计\n\n### 整体架构\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                    PanSou 搜索引擎                         │\n└─────────────────────┬───────────────────────────────────────┘\n                      │\n┌─────────────────────▼───────────────────────────────────────┐\n│                插件管理系统                                 │\n│  ┌─────────────┬─────────────┬─────────────┬─────────────┐  │\n│  │   susu      │   hdr4k     │   jikepan   │   其他...   │  │\n│  └─────────────┴─────────────┴─────────────┴─────────────┘  │\n└─────────────────────┬───────────────────────────────────────┘\n                      │\n┌─────────────────────▼───────────────────────────────────────┐\n│              hdr4k异步插件架构                              │\n│                                                             │\n│  ┌─────────────────────────────────────────────────────────┐ │\n│  │               搜索控制层                                │ │\n│  │  ├─ 异步搜索控制 (AsyncSearch)                          │ │\n│  │  ├─ 缓存管理 (CacheManager)                            │ │\n│  │  └─ 超时控制 (TimeoutControl)                          │ │\n│  └─────────────────────────────────────────────────────────┘ │\n│                               │                             │\n│  ┌─────────────────────────────▼─────────────────────────────┐ │\n│  │               并发处理层                                │ │\n│  │  ├─ 信号量控制 (Semaphore)                              │ │\n│  │  ├─ Goroutine池管理                                     │ │\n│  │  ├─ Channel通信                                         │ │\n│  │  └─ WaitGroup协调                                       │ │\n│  └─────────────────────────────────────────────────────────┘ │\n│                               │                             │\n│  ┌─────────────────────────────▼─────────────────────────────┐ │\n│  │               网络请求层                                │ │\n│  │  ├─ HTTP客户端管理                                      │ │\n│  │  ├─ 重试机制 (Retry Logic)                              │ │\n│  │  ├─ UA轮换 (User-Agent Pool)                            │ │\n│  │  └─ 连接池管理                                          │ │\n│  └─────────────────────────────────────────────────────────┘ │\n│                               │                             │\n│  ┌─────────────────────────────▼─────────────────────────────┐ │\n│  │               数据处理层                                │ │\n│  │  ├─ HTML解析 (goquery)                                  │ │\n│  │  ├─ 内容清理 (HTML Cleaning)                            │ │\n│  │  ├─ 链接提取 (Link Extraction)                          │ │\n│  │  └─ 数据结构化 (Data Structuring)                       │ │\n│  └─────────────────────────────────────────────────────────┘ │\n└─────────────────────────────────────────────────────────────┘\n```\n\n### 核心组件\n\n#### 1. Hdr4kAsyncPlugin（主插件类）\n\n```go\ntype Hdr4kAsyncPlugin struct {\n    *plugin.BaseAsyncPlugin\n}\n```\n\n**职责**：\n- 实现PanSou插件接口\n- 管理插件生命周期\n- 提供异步搜索能力\n\n#### 2. 缓存系统\n\n```go\nvar (\n    detailPageCache   = sync.Map{}  // 详情页缓存\n    searchResultCache = sync.Map{}  // 搜索结果缓存\n    linkTypeCache     = sync.Map{}  // 链接类型缓存\n)\n```\n\n**职责**：\n- 减少重复网络请求\n- 提升响应速度\n- 降低服务器压力\n\n#### 3. 并发控制器\n\n```go\nsemaphore := make(chan struct{}, MaxConcurrency)\nvar wg sync.WaitGroup\n```\n\n**职责**：\n- 控制并发数量\n- 协调goroutine执行\n- 防止资源过度消耗\n\n---\n\n## 🔧 技术实现\n\n### 搜索流程\n\n```mermaid\nsequenceDiagram\n    participant U as 用户\n    participant P as PanSou引擎\n    participant H as hdr4k插件\n    participant S as 4KHDR网站\n    participant C as 缓存系统\n\n    U->>P: 搜索请求\n    P->>H: 调用插件搜索\n    H->>C: 检查缓存\n    alt 缓存命中\n        C-->>H: 返回缓存结果\n    else 缓存未命中\n        H->>S: 发送搜索请求\n        S-->>H: 返回搜索页面\n        H->>H: 解析搜索结果\n        par 并发处理每个结果\n            H->>S: 获取详情页1\n            and\n            H->>S: 获取详情页2\n            and\n            H->>S: 获取详情页N\n        end\n        S-->>H: 返回详情页内容\n        H->>H: 提取下载链接\n        H->>H: 过滤求片帖\n        H->>C: 更新缓存\n    end\n    H-->>P: 返回结构化结果\n    P-->>U: 返回搜索结果\n```\n\n### 核心算法\n\n#### 1. 智能求片帖过滤算法\n\n```go\nfunc (p *Hdr4kAsyncPlugin) isEmptyRequestPost(title string, links []model.Link) bool {\n    // 算法逻辑：\n    // 1. 如果有实际下载链接，保留帖子\n    // 2. 识别明确的求片关键词\n    // 3. 对求网盘帖子特殊处理\n    // 4. 考虑标题长度和内容特征\n}\n```\n\n**优势**：\n- ✅ 避免误杀有资源的求片帖\n- ✅ 过滤无价值的纯求片帖\n- ✅ 支持多种求片模式识别\n\n#### 2. 并发详情页处理算法\n\n```go\nfunc (p *Hdr4kAsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n    // 算法流程：\n    // 1. 预过滤：只处理包含关键词的帖子\n    // 2. 信号量控制：限制并发数量\n    // 3. 并发处理：每个详情页独立处理\n    // 4. 结果收集：通过channel安全汇总\n}\n```\n\n**优势**：\n- ⚡ 大幅提升处理速度\n- 🛡️ 避免过度并发导致的问题\n- 🔄 支持错误隔离\n\n#### 3. 智能重试算法\n\n```go\nfunc (p *Hdr4kAsyncPlugin) doRequestWithRetry(client *http.Client, req *http.Request, maxRetries int) (*http.Response, error) {\n    // 指数退避算法：\n    // 第1次重试：等待500ms\n    // 第2次重试：等待1000ms\n    // 第3次重试：等待2000ms\n    // 最大等待：5000ms\n}\n```\n\n**优势**：\n- 🔄 自动处理网络抖动\n- ⏱️ 避免请求风暴\n- 📈 提高请求成功率\n\n---\n\n## 🚀 核心优化\n\n### 1. 异步插件架构\n\n**优化前**：同步插件架构\n```go\ntype Hdr4kPlugin struct {\n    client  *http.Client\n    timeout time.Duration\n}\n```\n\n**优化后**：异步插件架构\n```go\ntype Hdr4kAsyncPlugin struct {\n    *plugin.BaseAsyncPlugin\n}\n```\n\n**收益**：\n- ⏱️ 更好的超时控制\n- 🔄 内置缓存管理\n- 📊 性能监控支持\n- 🛡️ 错误恢复机制\n\n### 2. 并发处理优化\n\n**优化前**：串行处理详情页\n```go\ndoc.Find(\".slst.mtw ul li.pbw\").Each(func(i int, s *goquery.Selection) {\n    // 串行处理每个结果\n    links, content := getLinksFromDetail(postID)\n    // ...\n})\n```\n\n**优化后**：并发处理详情页\n```go\n// 预收集需要处理的项\nvar items []*goquery.Selection\ndoc.Find(\".slst.mtw ul li.pbw\").Each(func(i int, s *goquery.Selection) {\n    if matched {\n        items = append(items, s)\n    }\n})\n\n// 并发处理\nfor i, s := range items {\n    wg.Add(1)\n    go func(index int, s *goquery.Selection) {\n        defer wg.Done()\n        semaphore <- struct{}{}\n        defer func() { <-semaphore }()\n        \n        // 处理详情页\n        links, content, err := getLinksFromDetail(client, postID)\n        // ...\n    }(i, s)\n}\n```\n\n**收益**：\n- ⚡ 速度提升3-5倍\n- 🔄 更好的资源利用\n- 📊 支持动态并发调整\n\n### 3. 缓存策略优化\n\n**多层级缓存设计**：\n\n```go\n// 1. 搜索结果缓存\nsearchResultCache.Store(searchKey, results)\n\n// 2. 详情页缓存\ndetailPageCache.Store(postID, detailContent)\n\n// 3. 链接类型缓存\nlinkTypeCache.Store(urlKey, linkType)\n```\n\n**缓存策略**：\n- ⏱️ TTL: 1小时自动过期\n- 🔄 LRU: 内存压力时自动清理\n- 🔑 智能键值: 基于内容特征生成\n\n### 4. 网络优化\n\n**User-Agent轮换**：\n```go\nvar userAgents = []string{\n    \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36...\",\n    \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...\",\n    // ...\n}\n\nfunc getRandomUA() string {\n    return userAgents[rand.Intn(len(userAgents))]\n}\n```\n\n**重试机制**：\n- 🔄 指数退避算法\n- 🎯 智能错误判断\n- ⏱️ 合理超时设置\n\n---\n\n## 📡 API设计\n\n### 插件接口\n\n```go\ntype Plugin interface {\n    Name() string\n    Priority() int\n    Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error)\n}\n```\n\n### 数据结构\n\n#### SearchResult（搜索结果）\n\n```go\ntype SearchResult struct {\n    UniqueID  string    `json:\"unique_id\"`  // 唯一标识\n    Title     string    `json:\"title\"`      // 标题\n    Content   string    `json:\"content\"`    // 内容描述\n    Datetime  time.Time `json:\"datetime\"`   // 发布时间\n    Links     []Link    `json:\"links\"`      // 下载链接\n    Tags      []string  `json:\"tags\"`       // 分类标签\n}\n```\n\n#### Link（下载链接）\n\n```go\ntype Link struct {\n    URL      string `json:\"url\"`      // 下载地址\n    Type     string `json:\"type\"`     // 链接类型\n    Password string `json:\"password\"` // 提取密码\n}\n```\n\n### 支持的链接类型\n\n| 类型 | 说明 | 示例 |\n|------|------|------|\n| `quark` | 夸克网盘 | https://pan.quark.cn/s/xxx |\n| `baidu` | 百度网盘 | https://pan.baidu.com/s/xxx |\n| `aliyun` | 阿里云盘 | https://alipan.com/s/xxx |\n| `xunlei` | 迅雷网盘 | https://pan.xunlei.com/s/xxx |\n| `tianyi` | 天翼云盘 | https://cloud.189.cn/xxx |\n| `115` | 115网盘 | https://115.com/xxx |\n| `magnet` | 磁力链接 | magnet:?xt=urn:btih:xxx |\n| `ed2k` | 电驴链接 | ed2k://xxx |\n\n---\n\n## ⚡ 性能优化\n\n### 性能优化策略\n\n#### 1. 预过滤机制\n\n```go\n// 在发起网络请求前先过滤\ndoc.Find(\".slst.mtw ul li.pbw\").Each(func(i int, s *goquery.Selection) {\n    title := extractTitle(s)\n    if !matchesKeywords(title, keywords) {\n        return // 跳过不匹配的帖子\n    }\n    items = append(items, s)\n})\n```\n\n**收益**：减少60-80%的无效网络请求\n\n#### 2. 智能缓存\n\n```go\n// 基于内容特征生成缓存键\ncacheKey := fmt.Sprintf(\"detail:%s\", postID)\nif cachedData, ok := detailPageCache.Load(cacheKey); ok {\n    if time.Since(cachedResult.timestamp) < cacheTTL {\n        return cachedData // 直接返回缓存\n    }\n}\n```\n\n**收益**：缓存命中时响应速度提升95%\n\n#### 3. 连接复用\n\n```go\n// 使用带连接池的HTTP客户端\nclient := &http.Client{\n    Timeout: timeout,\n    Transport: &http.Transport{\n        MaxIdleConns:        100,\n        MaxIdleConnsPerHost: 20,\n        IdleConnTimeout:     90 * time.Second,\n    },\n}\n```\n\n**收益**：减少TCP连接建立开销，提升20-30%性能\n\n---\n\n## 🛡️ 错误处理\n\n### 错误分类\n\n#### 1. 网络错误\n\n```go\nfunc (p *Hdr4kAsyncPlugin) isRetriableError(err error) bool {\n    if netErr, ok := err.(net.Error); ok {\n        return netErr.Timeout() || netErr.Temporary()\n    }\n    \n    errStr := err.Error()\n    return strings.Contains(errStr, \"connection refused\") ||\n           strings.Contains(errStr, \"connection reset\") ||\n           strings.Contains(errStr, \"EOF\")\n}\n```\n\n**处理策略**：\n- 🔄 自动重试（最多2次）\n- ⏱️ 指数退避等待\n- 📊 错误统计记录\n\n#### 2. 解析错误\n\n```go\ndoc, err := goquery.NewDocumentFromReader(resp.Body)\nif err != nil {\n    return nil, fmt.Errorf(\"解析HTML失败: %w\", err)\n}\n```\n\n**处理策略**：\n- 详细错误日志\n- 降级处理机制\n- 监控告警\n\n#### 3. 数据错误\n\n```go\nif title == \"\" {\n    return // 跳过无效数据\n}\n```\n\n**处理策略**：\n- 🧹 数据清洗\n- 🔄 容错处理\n- 📊 质量监控\n\n### 容错设计\n\n#### 1. 优雅降级\n\n```go\nlinks, detailContent, err := p.getLinksFromDetail(client, postID)\nif err != nil {\n    // 降级：返回搜索页面的基本信息\n    links = []model.Link{}\n    detailContent = content\n}\n```\n\n#### 2. 部分失败容忍\n\n```go\n// 即使部分详情页获取失败，仍返回成功获取的结果\nfor result := range resultChan {\n    results = append(results, result)\n}\n```\n\n#### 3. 超时保护\n\n```go\n// 通过异步插件架构提供超时保护\nreturn p.AsyncSearch(keyword, p.doSearch, p.MainCacheKey, ext)\n```\n\n---\n\n## 💾 缓存策略\n\n### 缓存架构\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                    内存缓存系统                             │\n│                                                             │\n│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐ │\n│  │   搜索结果缓存   │  │   详情页缓存    │  │  链接类型缓存   │ │\n│  │                 │  │                 │  │                 │ │\n│  │ searchResultCache│  │ detailPageCache │  │ linkTypeCache   │ │\n│  │                 │  │                 │  │                 │ │\n│  │ TTL: 1小时      │  │ TTL: 1小时      │  │ TTL: 1小时      │ │\n│  │ 命中率: 70-85%  │  │ 命中率: 60-75%  │  │ 命中率: 90%+    │ │\n│  └─────────────────┘  └─────────────────┘  └─────────────────┘ │\n└─────────────────────────────────────────────────────────────┘\n```\n\n### 缓存键设计\n\n#### 1. 搜索结果缓存键\n\n```go\n// 格式：search:{keyword}:{hash(ext)}\nsearchKey := fmt.Sprintf(\"search:%s:%x\", keyword, hashExt(ext))\n```\n\n#### 2. 详情页缓存键\n\n```go\n// 格式：detail:{postID}\ndetailKey := fmt.Sprintf(\"detail:%s\", postID)\n```\n\n#### 3. 链接类型缓存键\n\n```go\n// 格式：linktype:{url}:{name}\nlinkTypeKey := fmt.Sprintf(\"%s:%s\", url, name)\n```\n\n### 缓存更新策略\n\n#### 1. 时间过期（TTL）\n\n```go\ntype cachedResponse struct {\n    data      interface{}\n    timestamp time.Time\n}\n\n// 检查是否过期\nif time.Since(cachedResult.timestamp) < cacheTTL {\n    return cachedData\n}\n```\n\n#### 2. 定期清理\n\n```go\nfunc startCacheCleaner() {\n    ticker := time.NewTicker(1 * time.Hour)\n    defer ticker.Stop()\n    \n    for range ticker.C {\n        // 清空所有缓存\n        detailPageCache = sync.Map{}\n        searchResultCache = sync.Map{}\n        linkTypeCache = sync.Map{}\n    }\n}\n```\n\n#### 3. 内存压力清理\n\n```go\n// TODO: 实现基于内存使用量的智能清理\n// 当内存使用超过阈值时，优先清理最久未使用的缓存\n```\n\n---\n\n## 🧪 测试策略\n\n### 单元测试\n\n#### 1. 核心功能测试\n\n```go\nfunc TestHTMLParsingWithRealData(t *testing.T) {\n    plugin := NewHdr4kAsyncPlugin()\n    \n    // 测试HTML清理\n    htmlTitle := `<strong><font color=\"#ff0000\">水饺皇后</font></strong> 2025`\n    cleanedTitle := plugin.cleanHTML(htmlTitle)\n    expected := \"水饺皇后 2025\"\n    \n    assert.Equal(t, expected, cleanedTitle)\n}\n```\n\n#### 2. 求片帖过滤测试\n\n```go\nfunc TestEmptyRequestPostFiltering(t *testing.T) {\n    plugin := NewHdr4kAsyncPlugin()\n    \n    testCases := []struct {\n        title    string\n        links    []model.Link\n        expected bool\n    }{\n        {\"求夸克网盘 犬夜叉\", []model.Link{}, true},\n        {\"求夸克网盘 犬夜叉\", []model.Link{{URL: \"https://pan.quark.cn/s/xxx\", Type: \"quark\"}}, false},\n        {\"犬夜叉 2000\", []model.Link{}, false},\n    }\n    \n    for _, tc := range testCases {\n        result := plugin.isEmptyRequestPost(tc.title, tc.links)\n        assert.Equal(t, tc.expected, result)\n    }\n}\n```\n\n#### 3. 并发安全测试\n\n```go\nfunc TestConcurrentSearches(t *testing.T) {\n    plugin := NewHdr4kAsyncPlugin()\n    \n    var wg sync.WaitGroup\n    const numGoroutines = 10\n    \n    for i := 0; i < numGoroutines; i++ {\n        wg.Add(1)\n        go func() {\n            defer wg.Done()\n            _, err := plugin.Search(\"测试关键词\", nil)\n            assert.NoError(t, err)\n        }()\n    }\n    \n    wg.Wait()\n}\n```\n\n### 集成测试\n\n#### 1. 端到端测试\n\n```go\nfunc TestE2ESearch(t *testing.T) {\n    plugin := NewHdr4kAsyncPlugin()\n    \n    // 测试真实搜索\n    results, err := plugin.Search(\"犬夜叉\", nil)\n    assert.NoError(t, err)\n    assert.NotEmpty(t, results)\n    \n    // 验证结果质量\n    for _, result := range results {\n        assert.NotEmpty(t, result.Title)\n        assert.NotEmpty(t, result.UniqueID)\n        assert.Contains(t, strings.ToLower(result.Title), \"犬夜叉\")\n    }\n}\n```\n\n#### 2. 性能测试\n\n```go\nfunc BenchmarkSearch(b *testing.B) {\n    plugin := NewHdr4kAsyncPlugin()\n    \n    b.ResetTimer()\n    for i := 0; i < b.N; i++ {\n        _, err := plugin.Search(\"测试\", nil)\n        if err != nil {\n            b.Fatal(err)\n        }\n    }\n}\n```\n\n### 压力测试\n\n#### 1. 并发压力测试\n\n```bash\n# 使用wrk进行压力测试\nwrk -t10 -c100 -d30s --timeout 10s \\\n    'http://localhost:8888/api/search?kw=犬夜叉&src=plugin&plugins=hdr4k'\n```\n\n#### 2. 内存压力测试\n\n```go\nfunc TestMemoryUsage(t *testing.T) {\n    plugin := NewHdr4kAsyncPlugin()\n    \n    var m1, m2 runtime.MemStats\n    runtime.ReadMemStats(&m1)\n    \n    // 执行大量搜索\n    for i := 0; i < 100; i++ {\n        plugin.Search(fmt.Sprintf(\"test%d\", i), nil)\n    }\n    \n    runtime.ReadMemStats(&m2)\n    memUsage := m2.Alloc - m1.Alloc\n    \n    // 验证内存使用在合理范围内\n    assert.Less(t, memUsage, uint64(50*1024*1024)) // 50MB\n}\n```\n\n---\n\n## 🚀 部署维护\n\n### 部署要求\n\n#### 系统要求\n\n- **操作系统**：Linux/macOS/Windows\n- **Go版本**：1.21+\n- **内存**：最小512MB，推荐2GB+\n- **CPU**：最小1核，推荐2核+\n- **网络**：稳定的外网连接\n\n#### 依赖库\n\n```go\nrequire (\n    github.com/PuerkitoBio/goquery v1.8.1\n    // 其他依赖...\n)\n```\n\n### 配置参数\n\n#### 性能配置\n\n```go\nconst (\n    DefaultTimeout   = 10 * time.Second  // 请求超时时间\n    MaxRetries       = 2                 // 最大重试次数\n    MaxConcurrency   = 20                // 最大并发数\n    CacheTTL         = 1 * time.Hour     // 缓存有效期\n)\n```\n\n#### 环境变量\n\n```bash\n# 可选的环境变量配置\nexport HDR4K_TIMEOUT=10s\nexport HDR4K_MAX_RETRIES=2\nexport HDR4K_MAX_CONCURRENCY=20\nexport HDR4K_CACHE_TTL=1h\n```\n\n### 监控指标\n\n#### 关键指标\n\n1. **性能指标**\n   - 平均响应时间\n   - P95/P99响应时间\n   - QPS（每秒查询数）\n   - 错误率\n\n2. **资源指标**\n   - CPU使用率\n   - 内存使用量\n   - 网络I/O\n   - 并发连接数\n\n3. **业务指标**\n   - 搜索成功率\n   - 缓存命中率\n   - 链接提取成功率\n   - 求片帖过滤准确率\n\n#### 监控实现\n\n```go\n// 添加prometheus指标（示例）\nvar (\n    searchDuration = prometheus.NewHistogramVec(\n        prometheus.HistogramOpts{\n            Name: \"hdr4k_search_duration_seconds\",\n            Help: \"Time spent on search requests\",\n        },\n        []string{\"status\"},\n    )\n    \n    cacheHitRate = prometheus.NewGaugeVec(\n        prometheus.GaugeOpts{\n            Name: \"hdr4k_cache_hit_rate\",\n            Help: \"Cache hit rate\",\n        },\n        []string{\"cache_type\"},\n    )\n)\n```\n\n### 日志管理\n\n#### 日志级别\n\n```go\n// 建议的日志级别\nconst (\n    DEBUG = \"debug\"  // 详细调试信息\n    INFO  = \"info\"   // 一般信息\n    WARN  = \"warn\"   // 警告信息\n    ERROR = \"error\"  // 错误信息\n)\n```\n\n#### 关键日志点\n\n```go\n// 搜索开始\nlog.Info(\"开始搜索\", \"keyword\", keyword, \"plugin\", \"hdr4k\")\n\n// 性能统计\nlog.Info(\"搜索完成\", \"keyword\", keyword, \"结果数\", len(results), \"耗时\", duration)\n\n// 错误记录\nlog.Error(\"详情页获取失败\", \"postID\", postID, \"error\", err)\n\n// 缓存状态\nlog.Debug(\"缓存命中\", \"key\", cacheKey, \"type\", \"detail_page\")\n```\n\n### 故障排查\n\n#### 常见问题\n\n1. **搜索无结果**\n   - 检查网络连接\n   - 验证目标网站可访问性\n   - 检查HTML结构是否发生变化\n\n2. **响应时间过长**\n   - 检查并发设置是否合理\n   - 验证缓存是否正常工作\n   - 监控网络延迟\n\n3. **内存使用过高**\n   - 检查缓存清理是否正常\n   - 监控goroutine泄漏\n   - 验证连接池配置\n\n---\n\n## 开发指南\n\n### 代码规范\n\n#### 命名规范\n\n```go\n// 1. 包名：小写，简洁\npackage hdr4k\n\n// 2. 结构体：大驼峰命名\ntype Hdr4kAsyncPlugin struct {}\n\n// 3. 接口：大驼峰命名，以er结尾\ntype Searcher interface {}\n\n// 4. 方法：大驼峰命名（公开），小驼峰命名（私有）\nfunc (p *Hdr4kAsyncPlugin) Search() {}\nfunc (p *Hdr4kAsyncPlugin) doSearch() {}\n\n// 5. 变量：小驼峰命名\nvar detailPageCache = sync.Map{}\n\n// 6. 常量：大写，下划线分隔\nconst MAX_RETRIES = 3\n```\n\n#### 注释规范\n\n```go\n// Hdr4kAsyncPlugin 4KHDR网站搜索异步插件\n// \n// 该插件实现了对4KHDR.CN网站的高效搜索功能，支持：\n// - 并发搜索处理\n// - 智能缓存机制\n// - 自动重试功能\n// - 求片帖智能过滤\ntype Hdr4kAsyncPlugin struct {\n    *plugin.BaseAsyncPlugin\n}\n\n// Search 执行搜索并返回结果\n//\n// 参数：\n//   keyword: 搜索关键词\n//   ext: 扩展参数，可包含title_en等字段\n//\n// 返回值：\n//   []model.SearchResult: 搜索结果列表\n//   error: 错误信息\nfunc (p *Hdr4kAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n    // 实现逻辑...\n}\n```\n\n### 最佳实践\n\n#### 1. 错误处理\n\n```go\n// ✅ 好的做法：使用wrap error\nif err != nil {\n    return nil, fmt.Errorf(\"解析HTML失败: %w\", err)\n}\n\n// ❌ 不好的做法：丢失错误上下文\nif err != nil {\n    return nil, err\n}\n```\n\n#### 2. 资源管理\n\n```go\n// ✅ 好的做法：确保资源释放\nresp, err := client.Do(req)\nif err != nil {\n    return err\n}\ndefer resp.Body.Close()\n\n// ❌ 不好的做法：忘记释放资源\nresp, err := client.Do(req)\nif err != nil {\n    return err\n}\n// 缺少 defer resp.Body.Close()\n```\n\n#### 3. 并发安全\n\n```go\n// ✅ 好的做法：使用sync.Map保证并发安全\nvar cache = sync.Map{}\n\n// ❌ 不好的做法：使用普通map可能导致race condition\nvar cache = make(map[string]interface{})\n```\n"
  },
  {
    "path": "plugin/huban/html结构分析.md",
    "content": "# Huban HTML 数据结构分析\n\n## 基本信息\n- **数据源类型**: HTML 网页\n- **搜索URL格式**: `http://xsayang.fun:12512/index.php/vod/search/wd/{关键词}.html`\n- **详情URL格式**: `http://xsayang.fun:12512/index.php/vod/detail/id/{资源ID}.html`\n- **数据特点**: 视频点播(VOD)系统网页，提供HTML格式的影视资源数据\n- **特殊说明**: 使用HTML解析替代JSON API，与erxiao/zhizhen/muou插件使用相同的HTML结构\n\n## HTML 页面结构\n\n### 搜索结果页面 (`.module-search-item`)\n搜索结果页面包含多个搜索项，每个搜索项的HTML结构如下：\n\n```html\n<div class=\"module-search-item\">\n  <div class=\"module-item-pic\">\n    <img data-src=\"https://...\" />\n  </div>\n  <div class=\"module-item-text\">\n    <div class=\"video-info-header\">\n      <h3><a href=\"/index.php/vod/detail/id/12345.html\">电影标题</a></h3>\n      <span class=\"video-info-remarks\">HD</span>\n    </div>\n    <div class=\"video-info-items\">\n      <div class=\"video-info-item\">\n        <span class=\"video-info-itemtitle\">分类：</span>\n        <span class=\"video-info-item\">动作</span>\n      </div>\n      <div class=\"video-info-item\">\n        <span class=\"video-info-itemtitle\">导演：</span>\n        <span class=\"video-info-item\">导演名字</span>\n      </div>\n      <div class=\"video-info-item\">\n        <span class=\"video-info-itemtitle\">主演：</span>\n        <span class=\"video-info-item\">演员1,演员2</span>\n      </div>\n      <div class=\"video-info-item\">\n        <span class=\"video-info-itemtitle\">年份：</span>\n        <span class=\"video-info-item\">2024</span>\n      </div>\n      <div class=\"video-info-item\">\n        <span class=\"video-info-itemtitle\">剧情：</span>\n        <span class=\"video-info-item\">这是一部精彩的电影...</span>\n      </div>\n    </div>\n  </div>\n</div>\n```\n\n### 详情页面 (`.mobile-play` 和 `#download-list`)\n详情页面包含海报图片和下载链接：\n\n```html\n<div class=\"mobile-play\">\n  <img class=\"lazyload\" data-src=\"https://poster-url.jpg\" />\n</div>\n\n<div id=\"download-list\">\n  <div class=\"module-row-one\">\n    <div class=\"module-row-text\">\n      <span data-clipboard-text=\"https://pan.quark.cn/s/xxxxx\">夸克网盘</span>\n    </div>\n  </div>\n  <div class=\"module-row-one\">\n    <div class=\"module-row-text\">\n      <span data-clipboard-text=\"https://pan.baidu.com/s/xxxxx?pwd=xxxx\">百度网盘</span>\n    </div>\n  </div>\n</div>\n```\n\n## CSS 选择器参考\n\n### 搜索结果提取\n- **搜索结果容器**: `.module-search-item`\n- **标题**: `.video-info-header h3 a` (文本内容)\n- **详情页链接**: `.video-info-header h3 a` (href属性)\n- **封面图片**: `.module-item-pic > img` (data-src属性)\n- **质量/状态**: `.video-info-header .video-info-remarks` (文本内容)\n\n### 详情页下载链接提取\n- **海报图片**: `.mobile-play .lazyload` (data-src属性)\n- **下载链接容器**: `#download-list .module-row-one`\n- **下载链接**: `[data-clipboard-text]` (data-clipboard-text属性)\n\n## 支持的网盘类型\n- **Quark网盘**: `https://pan.quark.cn/s/{分享码}`\n- **百度网盘**: `https://pan.baidu.com/s/{分享码}?pwd={密码}`\n- **阿里云盘**: `https://www.aliyundrive.com/s/{分享码}`\n- **迅雷网盘**: `https://pan.xunlei.com/s/{分享码}`\n- **天翼云盘**: `https://cloud.189.cn/t/{分享码}`\n- **UC网盘**: `https://drive.uc.cn/s/{分享码}`\n- **115网盘**: `https://115.com/s/{分享码}`\n- **123网盘**: `https://123pan.com/s/{分享码}`\n- **PikPak**: `https://mypikpak.com/s/{分享码}`\n- **移动云盘**: `https://caiyun.feixin.10086.cn/{分享码}`\n- **磁力链接**: `magnet:?xt=urn:btih:{hash}`\n- **ED2K链接**: `ed2k://|file|...`\n\n## 数据流程\n\n### 搜索流程\n1. **构建搜索URL**: `http://xsayang.fun:12512/index.php/vod/search/wd/{keyword}.html`\n2. **发送HTTP请求**: 获取搜索结果页面\n3. **解析HTML**: 使用goquery解析页面\n4. **提取搜索项**: 遍历`.module-search-item`元素\n5. **异步获取详情**: 并发请求详情页面获取下载链接\n6. **缓存管理**: 使用sync.Map缓存详情页结果，TTL为1小时\n7. **关键词过滤**: 过滤不相关的结果\n\n## 并发控制\n- **最大并发数**: 20 (MaxConcurrency)\n- **搜索超时**: 8秒 (DefaultTimeout)\n- **详情页超时**: 6秒 (DetailTimeout)\n- **缓存TTL**: 1小时 (cacheTTL)\n\n## 性能统计\n- **搜索请求数**: 总搜索请求数\n- **平均搜索时间**: 单次搜索平均耗时(毫秒)\n- **详情页请求数**: 总详情页请求数\n- **平均详情页时间**: 单次详情页请求平均耗时(毫秒)\n- **缓存命中数**: 详情页缓存命中次数\n- **缓存未命中数**: 详情页缓存未命中次数\n\n## 注意事项\n1. **HTML解析**: 使用goquery库进行HTML解析\n2. **异步获取详情**: 搜索结果只包含基本信息，需要异步请求详情页获取下载链接\n3. **并发控制**: 使用信号量限制并发数为20\n4. **缓存管理**: 使用sync.Map缓存详情页结果，避免重复请求\n5. **链接验证**: 过滤掉无效链接（如包含`javascript:`、`#`等）\n6. **密码提取**: 从URL中提取`?pwd=`参数作为密码\n\n"
  },
  {
    "path": "plugin/huban/huban.go",
    "content": "package huban\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\t\"context\"\n\t\"sync\"\n\t\"sync/atomic\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\t// 默认超时时间\n\tDefaultTimeout = 8 * time.Second\n\tDetailTimeout  = 6 * time.Second\n\n\t// HTTP连接池配置\n\tMaxIdleConns        = 200\n\tMaxIdleConnsPerHost = 50\n\tMaxConnsPerHost     = 100\n\tIdleConnTimeout     = 90 * time.Second\n\n\t// 并发控制\n\tMaxConcurrency = 20\n\n\t// 缓存TTL\n\tcacheTTL = 1 * time.Hour\n\n\t// 请求来源控制 - 默认开启，提高安全性\n\tEnableRefererCheck = false\n\n\t// 调试日志开关\n\tDebugLog = false\n)\n\n// 性能统计（原子操作）\nvar (\n\tsearchRequests     int64 = 0\n\ttotalSearchTime    int64 = 0 // 纳秒\n\tdetailPageRequests int64 = 0\n\ttotalDetailTime    int64 = 0 // 纳秒\n\tcacheHits          int64 = 0\n\tcacheMisses        int64 = 0\n)\n\n// Detail page缓存\nvar (\n\tdetailCache sync.Map\n\tcacheMutex  sync.RWMutex\n)\n\n// 请求来源控制配置\nvar (\n\t// 允许的请求来源列表 - 参考panyq插件实现\n\t// 支持前缀匹配，例如 \"https://example.com\" 会匹配 \"https://example.com/path\"\n\tAllowedReferers = []string{\n\t\t\"https://dm.xueximeng.com\",\n\t\t\"http://localhost:8888\",\n\t\t// 可以根据需要添加更多允许的来源\n\t}\n)\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewHubanPlugin())\n}\n\n// 预编译的正则表达式\nvar (\n\t// 密码提取正则表达式\n\tpasswordRegex = regexp.MustCompile(`\\?pwd=([0-9a-zA-Z]+)`)\n\tpassword115Regex = regexp.MustCompile(`password=([0-9a-zA-Z]+)`)\n\n\t// 详情页ID提取正则表达式\n\tdetailIDRegex = regexp.MustCompile(`/id/(\\d+)`)\n\t\n\t// 常见网盘链接的正则表达式（支持16种类型）\n\tquarkLinkRegex     = regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9a-zA-Z]+`)\n\tucLinkRegex        = regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9a-zA-Z]+(\\?[^\"'\\s]*)?`)\n\tbaiduLinkRegex     = regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9a-zA-Z_\\-]+(\\?pwd=[0-9a-zA-Z]+)?`)\n\taliyunLinkRegex    = regexp.MustCompile(`https?://(www\\.)?(aliyundrive\\.com|alipan\\.com)/s/[0-9a-zA-Z]+`)\n\txunleiLinkRegex    = regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9a-zA-Z_\\-]+(\\?pwd=[0-9a-zA-Z]+)?`)\n\ttianyiLinkRegex    = regexp.MustCompile(`https?://cloud\\.189\\.cn/t/[0-9a-zA-Z]+`)\n\tlink115Regex       = regexp.MustCompile(`https?://(115\\.com|115cdn\\.com)/s/[0-9a-zA-Z]+`)\n\tmobileLinkRegex    = regexp.MustCompile(`https?://caiyun\\.feixin\\.10086\\.cn/[0-9a-zA-Z]+`)\n\tweiyunLinkRegex    = regexp.MustCompile(`https?://share\\.weiyun\\.com/[0-9a-zA-Z]+`)\n\tlanzouLinkRegex    = regexp.MustCompile(`https?://(www\\.)?(lanzou[uixys]*|lan[zs]o[ux])\\.(com|net|org)/[0-9a-zA-Z]+`)\n\tjianguoyunLinkRegex = regexp.MustCompile(`https?://(www\\.)?jianguoyun\\.com/p/[0-9a-zA-Z]+`)\n\tlink123Regex       = regexp.MustCompile(`https?://(123pan\\.com|www\\.123912\\.com|www\\.123865\\.com|www\\.123684\\.com)/s/[0-9a-zA-Z]+`)\n\tpikpakLinkRegex    = regexp.MustCompile(`https?://mypikpak\\.com/s/[0-9a-zA-Z]+`)\n\tmagnetLinkRegex    = regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9a-fA-F]{40}`)\n\ted2kLinkRegex      = regexp.MustCompile(`ed2k://\\|file\\|.+\\|\\d+\\|[0-9a-fA-F]{32}\\|/`)\n)\n\n// HubanAsyncPlugin Huban异步插件\ntype HubanAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t}\n\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   DefaultTimeout,\n\t}\n}\n\n// NewHubanPlugin 创建新的Huban异步插件\nfunc NewHubanPlugin() *HubanAsyncPlugin {\n\treturn &HubanAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"huban\", 2),\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Search 同步搜索接口\nfunc (p *HubanAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 请求来源检查 - 参考panyq插件实现\n\tif EnableRefererCheck && ext != nil {\n\t\treferer := \"\"\n\t\tif refererVal, ok := ext[\"referer\"].(string); ok {\n\t\t\treferer = refererVal\n\t\t}\n\t\t\n\t\t// 检查referer是否在允许列表中\n\t\tallowed := false\n\t\tfor _, allowedReferer := range AllowedReferers {\n\t\t\tif strings.HasPrefix(referer, allowedReferer) {\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[%s] 允许来自 %s 的请求\\n\", p.Name(), referer)\n\t\t\t\t}\n\t\t\t\tallowed = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\t\n\t\tif !allowed {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[%s] 拒绝来自 %s 的请求\\n\", p.Name(), referer)\n\t\t\t}\n\t\t\treturn nil, fmt.Errorf(\"[%s] 请求来源不被允许\", p.Name())\n\t\t}\n\t}\n\t\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 带结果统计的搜索接口\nfunc (p *HubanAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 搜索实现 - HTML解析版本\nfunc (p *HubanAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 性能统计\n\tstart := time.Now()\n\tatomic.AddInt64(&searchRequests, 1)\n\tdefer func() {\n\t\tduration := time.Since(start).Nanoseconds()\n\t\tatomic.AddInt64(&totalSearchTime, duration)\n\t}()\n\n\t// 使用优化的客户端\n\tif p.optimizedClient != nil {\n\t\tclient = p.optimizedClient\n\t}\n\n\t// 1. 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"http://103.45.162.207:20720/index.php/vod/search/wd/%s.html\", url.QueryEscape(keyword))\n\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\n\t// 3. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\n\t// 4. 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"http://103.45.162.207:20720/\")\n\n\t// 5. 发送请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\t// 6. 解析搜索结果页面\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\n\t// 7. 提取搜索结果\n\tvar results []model.SearchResult\n\n\tdoc.Find(\".module-search-item\").Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchItem(s, keyword)\n\t\tif result.UniqueID != \"\" {\n\t\t\tresults = append(results, result)\n\t\t}\n\t})\n\n\t// 8. 异步获取详情页信息\n\tenhancedResults := p.enhanceWithDetails(client, results)\n\n\t// 9. 关键词过滤\n\treturn plugin.FilterResultsByKeyword(enhancedResults, keyword), nil\n}\n\n// parseSearchItem 解析单个搜索结果项\nfunc (p *HubanAsyncPlugin) parseSearchItem(s *goquery.Selection, keyword string) model.SearchResult {\n\tresult := model.SearchResult{}\n\n\t// 提取详情页链接和ID\n\tdetailLink, exists := s.Find(\".video-info-header h3 a\").First().Attr(\"href\")\n\tif !exists {\n\t\treturn result\n\t}\n\n\t// 提取ID\n\tmatches := detailIDRegex.FindStringSubmatch(detailLink)\n\tif len(matches) < 2 {\n\t\treturn result\n\t}\n\titemID := matches[1]\n\n\t// 构建唯一ID\n\tuniqueID := fmt.Sprintf(\"%s-%s\", p.Name(), itemID)\n\n\t// 提取标题\n\ttitle := strings.TrimSpace(s.Find(\".video-info-header h3 a\").First().Text())\n\tif title == \"\" {\n\t\treturn result\n\t}\n\n\t// 提取分类\n\tcategory := strings.TrimSpace(s.Find(\".video-info-items\").First().Find(\".video-info-item\").First().Text())\n\n\t// 提取导演\n\tdirectorElement := s.Find(\".video-info-items\").FilterFunction(func(i int, item *goquery.Selection) bool {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\treturn strings.Contains(title, \"导演\")\n\t})\n\tdirector := strings.TrimSpace(directorElement.Find(\".video-info-item\").Text())\n\n\t// 提取主演\n\tactorElement := s.Find(\".video-info-items\").FilterFunction(func(i int, item *goquery.Selection) bool {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\treturn strings.Contains(title, \"主演\")\n\t})\n\tactor := strings.TrimSpace(actorElement.Find(\".video-info-item\").Text())\n\n\t// 提取年份\n\tyear := strings.TrimSpace(s.Find(\".video-info-items\").Last().Find(\".video-info-item\").First().Text())\n\n\t// 提取质量/状态\n\tquality := strings.TrimSpace(s.Find(\".video-info-header .video-info-remarks\").Text())\n\n\t// 提取剧情简介\n\tplotElement := s.Find(\".video-info-items\").FilterFunction(func(i int, item *goquery.Selection) bool {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\treturn strings.Contains(title, \"剧情\")\n\t})\n\tplot := strings.TrimSpace(plotElement.Find(\".video-info-item\").Text())\n\n\t// 提取封面图片\n\tcoverImage, _ := s.Find(\".module-item-pic > img\").Attr(\"data-src\")\n\n\t// 构建内容描述\n\tvar contentParts []string\n\tif category != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"分类: %s\", category))\n\t}\n\tif director != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"导演: %s\", director))\n\t}\n\tif actor != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"主演: %s\", actor))\n\t}\n\tif quality != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"质量: %s\", quality))\n\t}\n\tif plot != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"剧情: %s\", plot))\n\t}\n\n\t// 构建标签\n\tvar tags []string\n\tif year != \"\" {\n\t\ttags = append(tags, year)\n\t}\n\n\t// 构建图片数组\n\tvar images []string\n\tif coverImage != \"\" {\n\t\timages = append(images, coverImage)\n\t}\n\n\treturn model.SearchResult{\n\t\tUniqueID: uniqueID,\n\t\tTitle:    title,\n\t\tContent:  strings.Join(contentParts, \" | \"),\n\t\tImages:   images,\n\t\tTags:     tags,\n\t\tChannel:  \"\",\n\t\tDatetime: time.Time{},\n\t}\n}\n\n// enhanceWithDetails 异步获取详情页信息\nfunc (p *HubanAsyncPlugin) enhanceWithDetails(client *http.Client, results []model.SearchResult) []model.SearchResult {\n\tvar enhancedResults []model.SearchResult\n\tvar wg sync.WaitGroup\n\tvar mu sync.Mutex\n\n\t// 创建信号量限制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\n\tfor _, result := range results {\n\t\twg.Add(1)\n\t\tgo func(result model.SearchResult) {\n\t\t\tdefer wg.Done()\n\t\t\tsemaphore <- struct{}{}        // 获取信号量\n\t\t\tdefer func() { <-semaphore }() // 释放信号量\n\n\t\t\t// 从UniqueID中提取itemID\n\t\t\tparts := strings.Split(result.UniqueID, \"-\")\n\t\t\tif len(parts) < 2 {\n\t\t\t\tmu.Lock()\n\t\t\t\tenhancedResults = append(enhancedResults, result)\n\t\t\t\tmu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\t\t\titemID := parts[1]\n\n\t\t\t// 检查缓存\n\t\t\tif cached, ok := detailCache.Load(itemID); ok {\n\t\t\t\tatomic.AddInt64(&cacheHits, 1)\n\t\t\t\tr := cached.(model.SearchResult)\n\t\t\t\tmu.Lock()\n\t\t\t\tenhancedResults = append(enhancedResults, r)\n\t\t\t\tmu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tatomic.AddInt64(&cacheMisses, 1)\n\n\t\t\t// 获取详情页链接和图片\n\t\t\tdetailLinks, detailImages := p.fetchDetailLinksAndImages(client, itemID)\n\t\t\tresult.Links = detailLinks\n\n\t\t\t// 合并图片：优先使用详情页的海报，如果没有则使用搜索结果的图片\n\t\t\tif len(detailImages) > 0 {\n\t\t\t\tresult.Images = detailImages\n\t\t\t}\n\n\t\t\t// 缓存结果\n\t\t\tdetailCache.Store(itemID, result)\n\n\t\t\tmu.Lock()\n\t\t\tenhancedResults = append(enhancedResults, result)\n\t\t\tmu.Unlock()\n\t\t}(result)\n\t}\n\n\twg.Wait()\n\treturn enhancedResults\n}\n\n// fetchDetailLinksAndImages 获取详情页的下载链接和图片\nfunc (p *HubanAsyncPlugin) fetchDetailLinksAndImages(client *http.Client, itemID string) ([]model.Link, []string) {\n\t// 性能统计\n\tstart := time.Now()\n\tatomic.AddInt64(&detailPageRequests, 1)\n\tdefer func() {\n\t\tduration := time.Since(start).Nanoseconds()\n\t\tatomic.AddInt64(&totalDetailTime, duration)\n\t}()\n\n\tdetailURL := fmt.Sprintf(\"http://103.45.162.207:20720/index.php/vod/detail/id/%s.html\", itemID)\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DetailTimeout)\n\tdefer cancel()\n\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"http://103.45.162.207:20720/\")\n\n\t// 发送请求（带重试）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, nil\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\n\tvar links []model.Link\n\tvar images []string\n\n\t// 提取详情页的海报图片\n\tif posterURL, exists := doc.Find(\".mobile-play .lazyload\").Attr(\"data-src\"); exists && posterURL != \"\" {\n\t\timages = append(images, posterURL)\n\t}\n\n\t// 查找下载链接区域\n\tdoc.Find(\"#download-list .module-row-one\").Each(func(i int, s *goquery.Selection) {\n\t\t// 从data-clipboard-text属性提取链接\n\t\tif linkURL, exists := s.Find(\"[data-clipboard-text]\").Attr(\"data-clipboard-text\"); exists {\n\t\t\t// 过滤掉无效链接\n\t\t\tif p.isValidNetworkDriveURL(linkURL) {\n\t\t\t\tif linkType := p.determineLinkType(linkURL); linkType != \"\" {\n\t\t\t\t\tlink := model.Link{\n\t\t\t\t\t\tType:     linkType,\n\t\t\t\t\t\tURL:      linkURL,\n\t\t\t\t\t\tPassword: \"\", // 大部分网盘不需要密码\n\t\t\t\t\t}\n\t\t\t\t\tlinks = append(links, link)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n\n\treturn links, images\n}\n\n\n\n// isValidNetworkDriveURL 检查URL是否为有效的网盘链接\nfunc (p *HubanAsyncPlugin) isValidNetworkDriveURL(url string) bool {\n\t// 过滤掉明显无效的链接\n\tif strings.Contains(url, \"javascript:\") || \n\t   url == \"\" ||\n\t   (!strings.HasPrefix(url, \"http\") && !strings.HasPrefix(url, \"magnet:\") && !strings.HasPrefix(url, \"ed2k:\")) {\n\t\treturn false\n\t}\n\t\n\t// 检查是否匹配任何支持的网盘格式（16种）\n\treturn quarkLinkRegex.MatchString(url) ||\n\t\t   ucLinkRegex.MatchString(url) ||\n\t\t   baiduLinkRegex.MatchString(url) ||\n\t\t   aliyunLinkRegex.MatchString(url) ||\n\t\t   xunleiLinkRegex.MatchString(url) ||\n\t\t   tianyiLinkRegex.MatchString(url) ||\n\t\t   link115Regex.MatchString(url) ||\n\t\t   mobileLinkRegex.MatchString(url) ||\n\t\t   weiyunLinkRegex.MatchString(url) ||\n\t\t   lanzouLinkRegex.MatchString(url) ||\n\t\t   jianguoyunLinkRegex.MatchString(url) ||\n\t\t   link123Regex.MatchString(url) ||\n\t\t   pikpakLinkRegex.MatchString(url) ||\n\t\t   magnetLinkRegex.MatchString(url) ||\n\t\t   ed2kLinkRegex.MatchString(url)\n}\n\n// determineLinkType 根据URL确定链接类型（支持16种类型）\nfunc (p *HubanAsyncPlugin) determineLinkType(url string) string {\n\tswitch {\n\tcase quarkLinkRegex.MatchString(url):\n\t\treturn \"quark\"\n\tcase ucLinkRegex.MatchString(url):\n\t\treturn \"uc\"\n\tcase baiduLinkRegex.MatchString(url):\n\t\treturn \"baidu\"\n\tcase aliyunLinkRegex.MatchString(url):\n\t\treturn \"aliyun\"\n\tcase xunleiLinkRegex.MatchString(url):\n\t\treturn \"xunlei\"\n\tcase tianyiLinkRegex.MatchString(url):\n\t\treturn \"tianyi\"\n\tcase link115Regex.MatchString(url):\n\t\treturn \"115\"\n\tcase mobileLinkRegex.MatchString(url):\n\t\treturn \"mobile\"\n\tcase weiyunLinkRegex.MatchString(url):\n\t\treturn \"weiyun\"\n\tcase lanzouLinkRegex.MatchString(url):\n\t\treturn \"lanzou\"\n\tcase jianguoyunLinkRegex.MatchString(url):\n\t\treturn \"jianguoyun\"\n\tcase link123Regex.MatchString(url):\n\t\treturn \"123\"\n\tcase pikpakLinkRegex.MatchString(url):\n\t\treturn \"pikpak\"\n\tcase magnetLinkRegex.MatchString(url):\n\t\treturn \"magnet\"\n\tcase ed2kLinkRegex.MatchString(url):\n\t\treturn \"ed2k\"\n\tdefault:\n\t\treturn \"\" // 不支持的类型返回空字符串\n\t}\n}\n\n// extractPassword 从URL中提取密码\nfunc (p *HubanAsyncPlugin) extractPassword(url string) string {\n\t// 百度网盘密码\n\tif matches := passwordRegex.FindStringSubmatch(url); len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\t\n\t// 115网盘密码\n\tif matches := password115Regex.FindStringSubmatch(url); len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\t\n\treturn \"\"\n}\n\n// doRequestWithRetry 带重试的HTTP请求\nfunc (p *HubanAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 2\n\tvar lastErr error\n\n\tfor i := 0; i < maxRetries; i++ {\n\t\tresp, err := client.Do(req)\n\t\tif err == nil {\n\t\t\tif resp.StatusCode == http.StatusOK {\n\t\t\t\treturn resp, nil\n\t\t\t}\n\t\t\tresp.Body.Close()\n\t\t\tlastErr = fmt.Errorf(\"HTTP状态码: %d\", resp.StatusCode)\n\t\t} else {\n\t\t\tlastErr = err\n\t\t}\n\n\t\t// 快速重试：只等待很短时间\n\t\tif i < maxRetries-1 {\n\t\t\ttime.Sleep(100 * time.Millisecond)\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"[%s] 请求失败，重试%d次后仍失败: %w\", p.Name(), maxRetries, lastErr)\n}\n\n// GetPerformanceStats 获取性能统计信息\nfunc (p *HubanAsyncPlugin) GetPerformanceStats() map[string]interface{} {\n\ttotalRequests := atomic.LoadInt64(&searchRequests)\n\ttotalTime := atomic.LoadInt64(&totalSearchTime)\n\tdetailRequests := atomic.LoadInt64(&detailPageRequests)\n\tdetailTime := atomic.LoadInt64(&totalDetailTime)\n\thits := atomic.LoadInt64(&cacheHits)\n\tmisses := atomic.LoadInt64(&cacheMisses)\n\n\tvar avgTime float64\n\tif totalRequests > 0 {\n\t\tavgTime = float64(totalTime) / float64(totalRequests) / 1e6 // 转换为毫秒\n\t}\n\n\tvar avgDetailTime float64\n\tif detailRequests > 0 {\n\t\tavgDetailTime = float64(detailTime) / float64(detailRequests) / 1e6 // 转换为毫秒\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"search_requests\":      totalRequests,\n\t\t\"avg_search_time_ms\":   avgTime,\n\t\t\"total_search_time_ns\": totalTime,\n\t\t\"detail_page_requests\": detailRequests,\n\t\t\"avg_detail_time_ms\":   avgDetailTime,\n\t\t\"total_detail_time_ns\": detailTime,\n\t\t\"cache_hits\":           hits,\n\t\t\"cache_misses\":         misses,\n\t}\n}\n\n// AddAllowedReferer 添加允许的请求来源\nfunc AddAllowedReferer(referer string) {\n\tfor _, existing := range AllowedReferers {\n\t\tif existing == referer {\n\t\t\treturn // 已存在，不重复添加\n\t\t}\n\t}\n\tAllowedReferers = append(AllowedReferers, referer)\n}\n\n// RemoveAllowedReferer 移除允许的请求来源\nfunc RemoveAllowedReferer(referer string) {\n\tfor i, existing := range AllowedReferers {\n\t\tif existing == referer {\n\t\t\tAllowedReferers = append(AllowedReferers[:i], AllowedReferers[i+1:]...)\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// GetAllowedReferers 获取当前允许的请求来源列表\nfunc GetAllowedReferers() []string {\n\tresult := make([]string, len(AllowedReferers))\n\tcopy(result, AllowedReferers)\n\treturn result\n}\n\n// IsRefererAllowed 检查指定的referer是否被允许\nfunc IsRefererAllowed(referer string) bool {\n\tfor _, allowedReferer := range AllowedReferers {\n\t\tif strings.HasPrefix(referer, allowedReferer) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}"
  },
  {
    "path": "plugin/huban/json结构分析.md",
    "content": "# Huban API 数据结构分析\n\n## 基本信息\n- **数据源类型**: JSON API  \n- **特殊架构**: **双域名支持** - 需要处理两个不同的API端点\n- **API URL格式1**: `http://103.45.162.207:20720/api.php/provide/vod?ac=detail&wd={关键词}`\n- **API URL格式2**: `http://xsayang.fun:12512/api.php/provide/vod?ac=detail&wd={关键词}`\n- **数据特点**: 视频点播(VOD)系统API，使用独特的链接格式和网盘标识符\n\n## 双域名架构设计\n\n### 实现策略\n1. **主备模式**: 优先使用域名1，失败时切换到域名2\n2. **并发模式**: 同时请求两个域名，取更快响应\n3. **负载均衡**: 随机选择域名或按策略分配\n\n### 域名差异\n| 特性 | 域名1 (103.45.162.207:20720) | 域名2 (xsayang.fun:12512) |\n|------|------------------------------|---------------------------|\n| **协议** | HTTP | HTTP |\n| **响应速度** | 待测试 | 待测试 |\n| **数据完整性** | 9条记录 | 11条记录 |\n| **网盘支持** | 6种类型 | 6种类型 |\n\n## API响应结构\n\n### 顶层结构\n```json\n{\n    \"code\": 1,                    // 状态码：1表示成功\n    \"msg\": \"数据列表\",             // 响应消息\n    \"page\": 1,                    // 当前页码\n    \"pagecount\": 1,               // 总页数\n    \"limit\": \"20\",                // 每页限制条数（字符串格式）\n    \"total\": 9,                   // 总记录数（域名1）/ 11（域名2）\n    \"list\": []                    // 数据列表数组\n}\n```\n\n### `list`数组中的数据项结构\n```json\n{\n    \"vod_id\": 437206,                   // 资源唯一ID\n    \"vod_name\": \"凡人修仙传真人版（臻彩）\", // 资源标题\n    \"vod_actor\": \",杨洋,金晨,汪铎...\",   // 主演（前后有逗号）\n    \"vod_director\": \",杨阳,\",           // 导演（前后有逗号）\n    \"vod_area\": \"\",                     // 地区（可能为空）\n    \"vod_lang\": \"\",                     // 语言（可能为空）\n    \"vod_year\": \"2025\",                 // 年份\n    \"vod_remarks\": \"4K SDR 60HZ\",       // 更新状态/备注\n    \"vod_pubdate\": \"\",                  // 发布日期（通常为空）\n    \"vod_blurb\": \"[小虎斑的口粮]...\",    // 简介（包含特殊标记）\n    \"vod_content\": \"[小虎斑的口粮]...\",  // 内容描述（包含特殊标记）\n    \"vod_pic\": \"https://...\",           // 封面图片URL\n    \n    // 关键字段：下载链接相关（huban特有格式）\n    \"vod_down_from\": \"UCWP$$$KKWP\",\n    \"vod_down_server\": \"$$$\",\n    \"vod_down_note\": \"$$$\",\n    \"vod_down_url\": \"小虎斑$https://drive.uc.cn/s/3544ba9f8ac64#凡人修仙传真人版（臻彩）$https://drive.uc.cn/s/7e1c30d8e41d4#$$$小虎斑$https://pan.quark.cn/s/409afef6d77c#凡人修仙传真人版（臻彩）$https://pan.quark.cn/s/6f70c1f66e54#凡人修仙传真人版（臻彩）$https://pan.quark.cn/s/d228bf3a6e44#\"\n}\n```\n\n## 插件所需字段映射\n\n| 源字段 | 目标字段 | 说明 |\n|--------|----------|------|\n| `vod_id` | `UniqueID` | 格式: `huban-{vod_id}` |\n| `vod_name` | `Title` | 资源标题 |\n| `vod_actor`, `vod_director`, `vod_year`, `vod_remarks` | `Content` | 组合描述信息（需清理逗号） |\n| `vod_year` | `Tags` | 标签数组（area通常为空） |\n| `vod_down_from` + `vod_down_url` | `Links` | 解析为Link数组 |\n| `\"\"` | `Channel` | 插件搜索结果Channel为空 |\n| `time.Now()` | `Datetime` | 当前时间 |\n\n## 下载链接解析（huban特有格式）\n\n### 分隔符规则\n- **一级分隔**: 多个网盘类型使用 `$$$` 分隔\n- **二级分隔**: 每个网盘类型内的多个链接使用 `#` 分隔\n- **格式**: `{来源}${链接1}#{标题1}${链接2}#{标题2}#$$$...`\n\n### 下载源标识映射（huban特有）\n| API标识 | 网盘类型 | 域名示例 | 备注 |\n|---------|----------|----------|------|\n| `UCWP` | uc (UC网盘) | `drive.uc.cn` | UC网盘 |\n| `KKWP` | quark (夸克网盘) | `pan.quark.cn` | 夸克网盘 |\n| `ALWP` | aliyun (阿里云盘) | `alipan.com` | 阿里云盘 |\n| `bdWP` | baidu (百度网盘) | `pan.baidu.com` | 百度网盘 |\n| `123WP` | 123 (123网盘) | `123pan.com` | 123网盘 |\n| `115WP` | 115 (115网盘) | `115.com` | 115网盘 |\n| `TYWP` | tianyi (天翼云盘) | `cloud.189.cn` | 天翼云盘 |\n\n### 复杂链接格式示例\n```\n原始格式:\n\"vod_down_from\": \"UCWP$$$KKWP$$$ALWP$$$bdWP$$$123WP$$$115WP\"\n\"vod_down_url\": \"小虎斑$https://drive.uc.cn/s/3544ba9f8ac64#凡人修仙传$https://drive.uc.cn/s/3c3b890905a14?public=1#$$$小虎斑$https://pan.quark.cn/s/409afef6d77c#凡人修仙传$https://pan.quark.cn/s/c6a8281edf6b#$$$小虎斑$#凡人修仙传$https://www.alipan.com/s/7Ks9ccmdNcv#凡人修仙传$https://www.alipan.com/s/7Ks9ccmdNcv#$$$小虎斑$#凡人修仙传$https://pan.baidu.com/s/1nSz0-zft_h0Vg7rRhyJvxg?pwd=39qu#$$$小虎斑$#凡人修仙传$https://www.123912.com/s/gXCjTd-pVObv#$$$小虎斑$#凡人修仙传$https://115cdn.com/s/swhqwhw36c8?password=bc57#凡人修仙传$https://115cdn.com/s/swhqwhw36c8?password=bc57#\"\n\n解析后:\nUC网盘: \n  - https://drive.uc.cn/s/3544ba9f8ac64 (凡人修仙传)\n  - https://drive.uc.cn/s/3c3b890905a14?public=1\n夸克网盘:\n  - https://pan.quark.cn/s/409afef6d77c (凡人修仙传)\n  - https://pan.quark.cn/s/c6a8281edf6b\n阿里云盘:\n  - https://www.alipan.com/s/7Ks9ccmdNcv (凡人修仙传, 重复)\n百度网盘:\n  - https://pan.baidu.com/s/1nSz0-zft_h0Vg7rRhyJvxg?pwd=39qu (凡人修仙传)\n123网盘:\n  - https://www.123912.com/s/gXCjTd-pVObv (凡人修仙传)\n115网盘:\n  - https://115cdn.com/s/swhqwhw36c8?password=bc57 (凡人修仙传, 重复)\n```\n\n## 支持的网盘类型（16种）\n\n### 主流网盘\n- **baidu (百度网盘)**: `https://pan.baidu.com/s/{分享码}?pwd={密码}`\n- **quark (夸克网盘)**: `https://pan.quark.cn/s/{分享码}`\n- **aliyun (阿里云盘)**: `https://aliyundrive.com/s/{分享码}`, `https://www.alipan.com/s/{分享码}`\n- **uc (UC网盘)**: `https://drive.uc.cn/s/{分享码}`\n- **xunlei (迅雷网盘)**: `https://pan.xunlei.com/s/{分享码}`\n\n### 运营商网盘\n- **tianyi (天翼云盘)**: `https://cloud.189.cn/t/{分享码}`\n- **mobile (移动网盘)**: `https://caiyun.feixin.10086.cn/{分享码}`\n\n### 专业网盘\n- **115 (115网盘)**: `https://115.com/s/{分享码}`, `https://115cdn.com/s/{分享码}`\n- **weiyun (微云)**: `https://share.weiyun.com/{分享码}`\n- **lanzou (蓝奏云)**: `https://lanzou.com/{分享码}`\n- **jianguoyun (坚果云)**: `https://jianguoyun.com/{分享码}`\n- **123 (123网盘)**: `https://123pan.com/s/{分享码}`, `https://www.123912.com/s/{分享码}`\n- **pikpak (PikPak)**: `https://mypikpak.com/s/{分享码}`\n\n### 其他协议\n- **magnet (磁力链接)**: `magnet:?xt=urn:btih:{hash}`\n- **ed2k (电驴链接)**: `ed2k://|file|{filename}|{size}|{hash}|/`\n- **others (其他类型)**: 其他不在上述分类中的链接\n\n## 插件开发指导\n\n### 双域名请求策略示例\n```go\nfunc (p *HubanAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n    // 定义双域名\n    urls := []string{\n        fmt.Sprintf(\"http://103.45.162.207:20720/api.php/provide/vod?ac=detail&wd=%s\", url.QueryEscape(keyword)),\n        fmt.Sprintf(\"http://xsayang.fun:12512/api.php/provide/vod?ac=detail&wd=%s\", url.QueryEscape(keyword)),\n    }\n    \n    // 策略1: 主备模式\n    for _, searchURL := range urls {\n        if results, err := p.tryRequest(searchURL, client); err == nil {\n            return results, nil\n        }\n    }\n    \n    // 策略2: 并发模式（可选）\n    // return p.requestConcurrently(urls, client)\n}\n```\n\n### SearchResult构建示例\n```go\nresult := model.SearchResult{\n    UniqueID: fmt.Sprintf(\"huban-%d\", item.VodID),\n    Title:    item.VodName,\n    Content:  p.buildContent(item), // 需要清理逗号和特殊标记\n    Links:    p.parseHubanLinks(item.VodDownFrom, item.VodDownURL),\n    Tags:     []string{item.VodYear}, // area通常为空\n    Channel:  \"\", // 插件搜索结果Channel为空\n    Datetime: time.Now(),\n}\n```\n\n### 特殊链接解析函数\n```go\nfunc (p *HubanAsyncPlugin) parseHubanLinks(vodDownFrom, vodDownURL string) []model.Link {\n    if vodDownFrom == \"\" || vodDownURL == \"\" {\n        return nil\n    }\n    \n    // 按$$$分隔网盘类型\n    fromParts := strings.Split(vodDownFrom, \"$$$\")\n    urlParts := strings.Split(vodDownURL, \"$$$\")\n    \n    var links []model.Link\n    minLen := len(fromParts)\n    if len(urlParts) < minLen {\n        minLen = len(urlParts)\n    }\n    \n    for i := 0; i < minLen; i++ {\n        linkType := p.mapHubanCloudType(fromParts[i])\n        if linkType == \"\" {\n            continue\n        }\n        \n        // 解析单个网盘类型的多个链接\n        // 格式: \"来源$链接1#标题1$链接2#标题2#\"\n        urlSection := urlParts[i]\n        if strings.Contains(urlSection, \"$\") {\n            urlSection = urlSection[strings.Index(urlSection, \"$\")+1:] // 移除来源前缀\n        }\n        \n        // 按#分隔多个链接\n        linkParts := strings.Split(urlSection, \"#\")\n        for j := 0; j < len(linkParts); j += 2 { // 每两个为一组（链接和标题）\n            if j < len(linkParts) && linkParts[j] != \"\" {\n                linkURL := strings.TrimSpace(linkParts[j])\n                if p.isValidNetworkDriveURL(linkURL) {\n                    password := p.extractPassword(linkURL)\n                    links = append(links, model.Link{\n                        Type:     linkType,\n                        URL:      linkURL,\n                        Password: password,\n                    })\n                }\n            }\n        }\n    }\n    \n    return links\n}\n\nfunc (p *HubanAsyncPlugin) mapHubanCloudType(apiType string) string {\n    switch strings.ToUpper(apiType) {\n    case \"UCWP\":\n        return \"uc\"\n    case \"KKWP\":\n        return \"quark\"\n    case \"ALWP\":\n        return \"aliyun\"\n    case \"BDWP\":\n        return \"baidu\"\n    case \"123WP\":\n        return \"123\"\n    case \"115WP\":\n        return \"115\"\n    case \"TYWP\":\n        return \"tianyi\"\n    default:\n        return \"\"\n    }\n}\n```\n\n### 内容清理函数\n```go\nfunc (p *HubanAsyncPlugin) buildContent(item HubanAPIItem) string {\n    var contentParts []string\n    \n    // 清理演员字段（移除前后逗号）\n    if item.VodActor != \"\" {\n        actor := strings.Trim(item.VodActor, \",\")\n        if actor != \"\" {\n            contentParts = append(contentParts, fmt.Sprintf(\"主演: %s\", actor))\n        }\n    }\n    \n    // 清理导演字段（移除前后逗号）\n    if item.VodDirector != \"\" {\n        director := strings.Trim(item.VodDirector, \",\")\n        if director != \"\" {\n            contentParts = append(contentParts, fmt.Sprintf(\"导演: %s\", director))\n        }\n    }\n    \n    if item.VodYear != \"\" {\n        contentParts = append(contentParts, fmt.Sprintf(\"年份: %s\", item.VodYear))\n    }\n    \n    if item.VodRemarks != \"\" {\n        contentParts = append(contentParts, fmt.Sprintf(\"状态: %s\", item.VodRemarks))\n    }\n    \n    return strings.Join(contentParts, \" | \")\n}\n```\n\n## 与其他插件的差异\n\n| 特性 | huban | wanou/ouge/zhizhen | 说明 |\n|------|-------|-------------------|------|\n| **API架构** | 双域名 | 单域名 | 需要容错处理 |\n| **链接格式** | `来源$链接#标题#` | `链接` | 复杂多层分隔 |\n| **网盘标识** | `UCWP`, `KKWP` | `UC`, `KG` | 自定义后缀 |\n| **数据清理** | 需要 | 不需要 | 字段有特殊字符 |\n| **链接数量** | 多链接 | 单链接 | 每种类型多个链接 |\n\n## 注意事项\n1. **双域名处理**: 需要实现容错机制，一个失败时尝试另一个\n2. **复杂解析**: 链接格式比其他插件复杂，需要多层分隔处理\n3. **数据清理**: 演员、导演字段有多余逗号，需要清理\n4. **重复链接**: 可能存在重复链接，需要去重\n5. **空链接**: 某些位置可能为空，需要过滤\n6. **特殊标记**: 内容包含`[小虎斑的口粮]`等标记，需要处理\n\n## 开发建议\n- **分步实现**: 先实现单域名，再扩展双域名支持\n- **容错机制**: 重点测试网络异常和API错误的处理\n- **解析测试**: 针对复杂链接格式编写详细的单元测试\n- **性能优化**: 考虑并发请求双域名以提高响应速度\n- **缓存策略**: 双域名结果可以合并缓存，避免重复请求"
  },
  {
    "path": "plugin/hunhepan/hunhepan.go",
    "content": "package hunhepan\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\nvar debugEnabled = false\n\nfunc debugLog(format string, args ...interface{}) {\n\tif debugEnabled {\n\t\tlog.Printf(\"[hunhepan DEBUG] \"+format, args...)\n\t}\n}\n\n// 在init函数中注册插件\nfunc init() {\n\t// 注册插件\n\tplugin.RegisterGlobalPlugin(NewHunhepanAsyncPlugin())\n}\n\nconst (\n\t// API端点\n\tHunhepanAPI = \"https://hunhepan.com/open/search/disk\"\n\tQkpansoAPI  = \"https://qkpanso.com/v1/search/disk\"\n\tKuakeAPI    = \"https://kuake8.com/v1/search/disk\"\n\tMisosoAPI   = \"https://www.misoso.cc/v1/search/disk\"\n\n\t// 默认页大小\n\tDefaultPageSize = 30\n)\n\n// HunhepanAsyncPlugin 混合盘搜索异步插件\ntype HunhepanAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\n// NewHunhepanAsyncPlugin 创建新的混合盘搜索异步插件\nfunc NewHunhepanAsyncPlugin() *HunhepanAsyncPlugin {\n\treturn &HunhepanAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"hunhepan\", 3),\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *HunhepanAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *HunhepanAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)\n}\n\n// doSearch 实际的搜索实现\nfunc (p *HunhepanAsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tdebugLog(\"开始搜索，关键词: %s\", keyword)\n\t\n\t// 创建结果通道和错误通道\n\tresultChan := make(chan []HunhepanItem, 4)\n\terrChan := make(chan error, 4)\n\n\t// 创建等待组\n\tvar wg sync.WaitGroup\n\twg.Add(4)\n\n\t// 并行请求三个API\n\tgo func() {\n\t\tdefer wg.Done()\n\t\titems, err := p.searchAPI(client, HunhepanAPI, keyword)\n\t\tif err != nil {\n\t\t\terrChan <- fmt.Errorf(\"hunhepan API error: %w\", err)\n\t\t\treturn\n\t\t}\n\t\tresultChan <- items\n\t}()\n\n\tgo func() {\n\t\tdefer wg.Done()\n\t\titems, err := p.searchAPI(client, QkpansoAPI, keyword)\n\t\tif err != nil {\n\t\t\terrChan <- fmt.Errorf(\"qkpanso API error: %w\", err)\n\t\t\treturn\n\t\t}\n\t\tresultChan <- items\n\t}()\n\n\tgo func() {\n\t\tdefer wg.Done()\n\t\titems, err := p.searchAPI(client, KuakeAPI, keyword)\n\t\tif err != nil {\n\t\t\terrChan <- fmt.Errorf(\"kuake API error: %w\", err)\n\t\t\treturn\n\t\t}\n\t\tresultChan <- items\n\t}()\n\n\tgo func() {\n\t\tdefer wg.Done()\n\t\tdebugLog(\"调用 misoso API\")\n\t\titems, err := p.searchAPI(client, MisosoAPI, keyword)\n\t\tif err != nil {\n\t\t\tdebugLog(\"misoso API 错误: %v\", err)\n\t\t\terrChan <- fmt.Errorf(\"misoso API error: %w\", err)\n\t\t\treturn\n\t\t}\n\t\tdebugLog(\"misoso API 返回 %d 条结果\", len(items))\n\t\tresultChan <- items\n\t}()\n\n\t// 启动一个goroutine等待所有请求完成并关闭通道\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultChan)\n\t\tclose(errChan)\n\t}()\n\n\t// 收集结果\n\tvar allItems []HunhepanItem\n\tvar errors []error\n\n\t// 从通道读取结果\n\tfor items := range resultChan {\n\t\tallItems = append(allItems, items...)\n\t}\n\n\t// 收集错误（不阻止处理）\n\tfor err := range errChan {\n\t\terrors = append(errors, err)\n\t}\n\n\tdebugLog(\"收集到 %d 条原始结果，%d 个错误\", len(allItems), len(errors))\n\n\t// 如果没有获取到任何结果且有错误，则返回第一个错误\n\tif len(allItems) == 0 && len(errors) > 0 {\n\t\treturn nil, errors[0]\n\t}\n\n\t// 去重处理\n\tuniqueItems := p.deduplicateItems(allItems)\n\tdebugLog(\"去重后剩余 %d 条结果\", len(uniqueItems))\n\n\t// 转换为标准格式\n\tresults := p.convertResults(uniqueItems)\n\tdebugLog(\"转换后得到 %d 条最终结果\", len(results))\n\n\treturn results, nil\n}\n\n// searchAPI 向单个API发送请求\nfunc (p *HunhepanAsyncPlugin) searchAPI(client *http.Client, apiURL, keyword string) ([]HunhepanItem, error) {\n\tmaxPages := 3 // 最多获取3页数据，可以根据需要调整\n\n\t// 创建结果通道和错误通道\n\tresultChan := make(chan []HunhepanItem, maxPages)\n\terrChan := make(chan error, maxPages)\n\n\t// 创建等待组，用于等待所有页面请求完成\n\tvar wg sync.WaitGroup\n\n\t// 并发请求每一页\n\tfor page := 1; page <= maxPages; page++ {\n\t\twg.Add(1)\n\n\t\tgo func(pageNum int) {\n\t\t\tdefer wg.Done()\n\n\t\t\t// 构建请求体 - 根据1.txt的实际请求格式\n\t\t\treqBody := map[string]interface{}{\n\t\t\t\t\"page\":         pageNum,\n\t\t\t\t\"q\":            keyword,\n\t\t\t\t\"user\":         \"\",\n\t\t\t\t\"exact\":        false,\n\t\t\t\t\"format\":       []string{},\n\t\t\t\t\"share_time\":   \"\",\n\t\t\t\t\"size\":         DefaultPageSize,\n\t\t\t\t\"type\":         \"\",\n\t\t\t\t\"exclude_user\": []string{},\n\t\t\t\t\"adv_params\": map[string]interface{}{\n\t\t\t\t\t\"wechat_pwd\": \"\",\n\t\t\t\t\t\"platform\":   \"pc\",\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tjsonData, err := json.Marshal(reqBody)\n\t\t\tif err != nil {\n\t\t\t\tdebugLog(\"序列化请求失败 (page %d): %v\", pageNum, err)\n\t\t\t\terrChan <- fmt.Errorf(\"marshal request failed (page %d): %w\", pageNum, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tdebugLog(\"发送请求到 %s (page %d): %s\", apiURL, pageNum, string(jsonData))\n\n\t\t\treq, err := http.NewRequest(\"POST\", apiURL, bytes.NewBuffer(jsonData))\n\t\t\tif err != nil {\n\t\t\t\tdebugLog(\"创建请求失败 (page %d): %v\", pageNum, err)\n\t\t\t\terrChan <- fmt.Errorf(\"create request failed (page %d): %w\", pageNum, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\t\treq.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\")\n\t\t\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\t\t\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\n\t\t\t// 根据不同的API设置不同的Referer\n\t\t\tif strings.Contains(apiURL, \"qkpanso.com\") {\n\t\t\t\treq.Header.Set(\"Referer\", \"https://qkpanso.com/search\")\n\t\t\t} else if strings.Contains(apiURL, \"kuake8.com\") {\n\t\t\t\treq.Header.Set(\"Referer\", \"https://kuake8.com/search\")\n\t\t\t} else if strings.Contains(apiURL, \"hunhepan.com\") {\n\t\t\t\treq.Header.Set(\"Referer\", \"https://hunhepan.com/search\")\n\t\t\t} else if strings.Contains(apiURL, \"misoso.cc\") {\n\t\t\t\treq.Header.Set(\"Referer\", \"https://www.misoso.cc/search\")\n\t\t\t\treq.Header.Set(\"Origin\", \"https://www.misoso.cc\")\n\t\t\t}\n\n\t\t\t// 发送请求\n\t\t\tresp, err := client.Do(req)\n\t\t\tif err != nil {\n\t\t\t\tdebugLog(\"请求失败 (page %d): %v\", pageNum, err)\n\t\t\t\terrChan <- fmt.Errorf(\"request failed (page %d): %w\", pageNum, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer resp.Body.Close()\n\n\t\t\tdebugLog(\"收到响应 (page %d), 状态码: %d\", pageNum, resp.StatusCode)\n\n\t\t\t// 读取响应体\n\t\t\trespBody, err := io.ReadAll(resp.Body)\n\t\t\tif err != nil {\n\t\t\t\tdebugLog(\"读取响应失败 (page %d): %v\", pageNum, err)\n\t\t\t\terrChan <- fmt.Errorf(\"read response body failed (page %d): %w\", pageNum, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tdebugLog(\"响应内容 (page %d, 前500字符): %s\", pageNum, string(respBody[:min(500, len(respBody))]))\n\n\t\t\t// 解析响应\n\t\t\tvar apiResp HunhepanResponse\n\t\t\tif err := json.Unmarshal(respBody, &apiResp); err != nil {\n\t\t\t\tdebugLog(\"JSON解析失败 (page %d): %v\", pageNum, err)\n\t\t\t\terrChan <- fmt.Errorf(\"decode response failed (page %d): %w\", pageNum, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 检查响应状态\n\t\t\tif apiResp.Code != 200 {\n\t\t\t\tdebugLog(\"API返回错误 (page %d): code=%d, msg=%s\", pageNum, apiResp.Code, apiResp.Msg)\n\t\t\t\terrChan <- fmt.Errorf(\"API returned error (page %d): %s\", pageNum, apiResp.Msg)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tdebugLog(\"成功获取第 %d 页数据，共 %d 条结果\", pageNum, len(apiResp.Data.List))\n\n\t\t\t// 将结果发送到通道\n\t\t\tresultChan <- apiResp.Data.List\n\t\t}(page)\n\t}\n\n\t// 启动一个goroutine等待所有页面请求完成并关闭通道\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultChan)\n\t\tclose(errChan)\n\t}()\n\n\t// 收集结果\n\tvar allItems []HunhepanItem\n\tfor items := range resultChan {\n\t\tallItems = append(allItems, items...)\n\t}\n\n\t// 检查是否有错误\n\tvar errors []error\n\tfor err := range errChan {\n\t\terrors = append(errors, err)\n\t}\n\n\t// 如果没有获取到任何结果且有错误，则返回第一个错误\n\tif len(allItems) == 0 && len(errors) > 0 {\n\t\treturn nil, errors[0]\n\t}\n\n\treturn allItems, nil\n}\n\nfunc min(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}\n\n// deduplicateItems 去重处理\nfunc (p *HunhepanAsyncPlugin) deduplicateItems(items []HunhepanItem) []HunhepanItem {\n\t// 使用map进行去重\n\tuniqueMap := make(map[string]HunhepanItem)\n\n\tfor _, item := range items {\n\t\t// 清理DiskName中的HTML标签\n\t\tcleanedName := cleanTitle(item.DiskName)\n\t\titem.DiskName = cleanedName\n\n\t\t// 创建复合键：优先使用DiskID，如果为空则使用Link+DiskName组合\n\t\tvar key string\n\t\tif item.DiskID != \"\" {\n\t\t\tkey = item.DiskID\n\t\t} else if item.Link != \"\" {\n\t\t\t// 使用Link和清理后的DiskName组合作为键\n\t\t\tkey = item.Link + \"|\" + cleanedName\n\t\t} else {\n\t\t\t// 如果DiskID和Link都为空，则使用DiskName+DiskType作为键\n\t\t\tkey = cleanedName + \"|\" + item.DiskType\n\t\t}\n\n\t\t// 如果已存在，保留信息更丰富的那个\n\t\tif existing, exists := uniqueMap[key]; exists {\n\t\t\t// 比较文件列表长度和其他信息\n\t\t\texistingScore := len(existing.Files)\n\t\t\tnewScore := len(item.Files)\n\n\t\t\t// 如果新项有密码而现有项没有，增加新项分数\n\t\t\tif existing.DiskPass == \"\" && item.DiskPass != \"\" {\n\t\t\t\tnewScore += 5\n\t\t\t}\n\n\t\t\t// 如果新项有时间而现有项没有，增加新项分数\n\t\t\tif existing.SharedTime == \"\" && item.SharedTime != \"\" {\n\t\t\t\tnewScore += 3\n\t\t\t}\n\n\t\t\tif newScore > existingScore {\n\t\t\t\tuniqueMap[key] = item\n\t\t\t}\n\t\t} else {\n\t\t\tuniqueMap[key] = item\n\t\t}\n\t}\n\n\t// 将map转回切片\n\tresult := make([]HunhepanItem, 0, len(uniqueMap))\n\tfor _, item := range uniqueMap {\n\t\tresult = append(result, item)\n\t}\n\n\treturn result\n}\n\n// convertResults 将API响应转换为标准SearchResult格式\nfunc (p *HunhepanAsyncPlugin) convertResults(items []HunhepanItem) []model.SearchResult {\n\tresults := make([]model.SearchResult, 0, len(items))\n\n\tfor i, item := range items {\n\t\t// 跳过无效链接的结果\n\t\tif item.Link == \"\" {\n\t\t\tdebugLog(\"跳过无链接的结果: %s\", item.DiskName)\n\t\t\tcontinue\n\t\t}\n\n\t\t// 创建链接\n\t\tlink := model.Link{\n\t\t\tURL:      item.Link,\n\t\t\tType:     p.convertDiskType(item.DiskType),\n\t\t\tPassword: item.DiskPass,\n\t\t}\n\n\t\t// 创建唯一ID\n\t\tuniqueID := fmt.Sprintf(\"hunhepan-%s\", item.DiskID)\n\t\tif item.DiskID == \"\" {\n\t\t\t// 使用索引作为后备\n\t\t\tuniqueID = fmt.Sprintf(\"hunhepan-%d-%d\", time.Now().Unix(), i)\n\t\t}\n\n\t\t// 解析时间\n\t\tvar datetime time.Time\n\t\tif item.SharedTime != \"\" {\n\t\t\t// 尝试解析时间，格式：2025-07-07 13:19:48\n\t\t\tparsedTime, err := time.Parse(\"2006-01-02 15:04:05\", item.SharedTime)\n\t\t\tif err == nil {\n\t\t\t\tdatetime = parsedTime\n\t\t\t} else {\n\t\t\t\tdebugLog(\"时间解析失败: %s, err: %v\", item.SharedTime, err)\n\t\t\t}\n\t\t}\n\n\t\t// 如果时间解析失败，使用零值\n\t\tif datetime.IsZero() {\n\t\t\tdatetime = time.Time{}\n\t\t}\n\n\t\t// 创建搜索结果\n\t\tresult := model.SearchResult{\n\t\t\tUniqueID: uniqueID,\n\t\t\tTitle:    cleanTitle(item.DiskName),\n\t\t\tContent:  item.Files,\n\t\t\tDatetime: datetime,\n\t\t\tLinks:    []model.Link{link},\n\t\t\tChannel:  \"\", // 插件搜索结果必须为空字符串\n\t\t}\n\n\t\tdebugLog(\"转换结果: ID=%s, Title=%s, Type=%s, Link=%s\", uniqueID, result.Title, link.Type, link.URL)\n\t\tresults = append(results, result)\n\t}\n\n\treturn results\n}\n\n// convertDiskType 将API的网盘类型转换为标准链接类型\nfunc (p *HunhepanAsyncPlugin) convertDiskType(diskType string) string {\n\tswitch diskType {\n\tcase \"BDY\":\n\t\treturn \"baidu\"\n\tcase \"ALY\":\n\t\treturn \"aliyun\"\n\tcase \"QUARK\":\n\t\treturn \"quark\"\n\tcase \"TIANYI\":\n\t\treturn \"tianyi\"\n\tcase \"UC\":\n\t\treturn \"uc\"\n\tcase \"CAIYUN\":\n\t\treturn \"mobile\"\n\tcase \"115\":\n\t\treturn \"115\"\n\tcase \"XUNLEI\":\n\t\treturn \"xunlei\"\n\tcase \"123PAN\":\n\t\treturn \"123\"\n\tcase \"PIKPAK\":\n\t\treturn \"pikpak\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n// cleanTitle 清理标题中的HTML标签\nfunc cleanTitle(title string) string {\n\t// 一次性替换所有常见HTML标签\n\treplacements := map[string]string{\n\t\t\"<em>\":      \"\",\n\t\t\"</em>\":     \"\",\n\t\t\"<b>\":       \"\",\n\t\t\"</b>\":      \"\",\n\t\t\"<strong>\":  \"\",\n\t\t\"</strong>\": \"\",\n\t\t\"<i>\":       \"\",\n\t\t\"</i>\":      \"\",\n\t}\n\n\tresult := title\n\tfor tag, replacement := range replacements {\n\t\tresult = strings.Replace(result, tag, replacement, -1)\n\t}\n\n\t// 移除多余的空格\n\treturn strings.TrimSpace(result)\n}\n\n// HunhepanResponse API响应结构\ntype HunhepanResponse struct {\n\tCode int    `json:\"code\"`\n\tMsg  string `json:\"msg\"`\n\tData struct {\n\t\tTotal   int            `json:\"total\"`\n\t\tPerSize int            `json:\"per_size\"`\n\t\tList    []HunhepanItem `json:\"list\"`\n\t} `json:\"data\"`\n}\n\n// HunhepanItem API响应中的单个结果项\ntype HunhepanItem struct {\n\tDiskID     string `json:\"disk_id\"`\n\tDiskName   string `json:\"disk_name\"`\n\tDiskPass   string `json:\"disk_pass\"`\n\tDiskType   string `json:\"disk_type\"`\n\tFiles      string `json:\"files\"`\n\tDocID      string `json:\"doc_id\"`\n\tShareUser  string `json:\"share_user\"`\n\tSharedTime string `json:\"shared_time\"`\n\tLink       string `json:\"link\"`\n\tEnabled    bool   `json:\"enabled\"`\n\tWeight     int    `json:\"weight\"`\n\tStatus     int    `json:\"status\"`\n}\n"
  },
  {
    "path": "plugin/javdb/javdb.go",
    "content": "package javdb\n\nimport (\n\t\"context\"\n\t\"crypto/md5\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tPluginName          = \"javdb\"\n\tDisplayName         = \"JavDB\"\n\tDescription         = \"JavDB - 影片数据库，专门提供磁力链接搜索\"\n\tBaseURL             = \"https://javdb.com\"\n\tSearchPath          = \"/search?q=%s&f=all\"\n\tUserAgent           = \"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\"\n\tMaxResults          = 50\n\tMaxConcurrency      = 10\n\t\n\t// 429限流重试配置\n\tMaxRetryOnRateLimit = 0    // 遇到429时的最大重试次数，设为0则不重试\n\tMinRetryDelay       = 4    // 最小延迟秒数\n\tMaxRetryDelay       = 8    // 最大延迟秒数\n)\n\n// JavdbPlugin JavDB插件\ntype JavdbPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tdebugMode      bool\n\tdetailCache    sync.Map // 缓存详情页结果\n\tcacheTTL       time.Duration\n\trateLimited    int32  // 429限流标志位，使用atomic操作\n\trateLimitCount int32  // 429错误计数\n}\n\n// init 注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewJavdbPlugin())\n}\n\n// NewJavdbPlugin 创建新的JavDB插件实例\nfunc NewJavdbPlugin() *JavdbPlugin {\n\tdebugMode := false \n\t\n\t// 初始化随机种子\n\trand.Seed(time.Now().UnixNano())\n\n\tp := &JavdbPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(PluginName, 5, true), \n\t\tdebugMode:       debugMode,\n\t\tcacheTTL:        30 * time.Minute, // 详情页缓存30分钟\n\t}\n\n\treturn p\n}\n\n// Name 插件名称\nfunc (p *JavdbPlugin) Name() string {\n\treturn PluginName\n}\n\n// DisplayName 插件显示名称\nfunc (p *JavdbPlugin) DisplayName() string {\n\treturn DisplayName\n}\n\n// Description 插件描述\nfunc (p *JavdbPlugin) Description() string {\n\treturn Description\n}\n\n// SkipServiceFilter 磁力搜索插件，跳过Service层过滤\nfunc (p *JavdbPlugin) SkipServiceFilter() bool {\n\treturn true // 磁力搜索，跳过网盘服务过滤\n}\n\n// Search 搜索接口（兼容性方法）\nfunc (p *JavdbPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *JavdbPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 搜索实现\nfunc (p *JavdbPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 开始搜索: %s\", keyword)\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 开始搜索，客户端超时: %v\", client.Timeout)\n\t}\n\n\t// 第一步：执行搜索获取结果列表\n\tsearchResults, err, isRateLimited := p.executeSearchWithRateLimit(client, keyword)\n\tif err != nil && !isRateLimited {\n\t\treturn nil, fmt.Errorf(\"[%s] 执行搜索失败: %w\", p.Name(), err)\n\t}\n\n\tif p.debugMode {\n\t\tif isRateLimited {\n\t\t\tlog.Printf(\"[JAVDB] ⚡ 遇到429限流，但继续处理已获取的 %d 个结果\", len(searchResults))\n\t\t} else {\n\t\t\tlog.Printf(\"[JAVDB] 搜索获取到 %d 个结果\", len(searchResults))\n\t\t}\n\t}\n\n\t// 如果没有搜索结果，直接返回\n\tif len(searchResults) == 0 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] 无搜索结果，直接返回\")\n\t\t}\n\t\treturn []model.SearchResult{}, nil\n\t}\n\n\t// 第二步：并发获取详情页磁力链接（设定合理超时）\n\tfinalResults := p.fetchDetailMagnetLinks(client, searchResults, keyword)\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 最终获取到 %d 个有效结果\", len(finalResults))\n\t\tif isRateLimited {\n\t\t\tlog.Printf(\"[JAVDB] ⚡ 由于429限流，结果可能不完整，系统将在后台继续获取\")\n\t\t}\n\t}\n\n\treturn finalResults, nil\n}\n\n// executeSearchWithRateLimit 执行搜索请求，支持限流检测\nfunc (p *JavdbPlugin) executeSearchWithRateLimit(client *http.Client, keyword string) ([]model.SearchResult, error, bool) {\n\t// 重置限流状态，每次新搜索都重新尝试\n\tatomic.StoreInt32(&p.rateLimited, 0)\n\t\n\t// 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"%s%s\", BaseURL, fmt.Sprintf(SearchPath, url.QueryEscape(keyword)))\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 搜索URL: %s\", searchURL)\n\t\t// 显示重试配置信息\n\t\tif MaxRetryOnRateLimit > 0 {\n\t\t\tlog.Printf(\"[JAVDB] 429重试配置: 最大%d次，延迟%d-%d秒\", MaxRetryOnRateLimit, MinRetryDelay, MaxRetryDelay)\n\t\t} else {\n\t\t\tlog.Printf(\"[JAVDB] 429重试配置: 禁用重试\")\n\t\t}\n\t\t// 如果之前有限流，显示统计信息\n\t\tif count := atomic.LoadInt32(&p.rateLimitCount); count > 0 {\n\t\t\tlog.Printf(\"[JAVDB] 历史429限流次数: %d\", count)\n\t\t}\n\t}\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建搜索请求失败: %w\", p.Name(), err), false\n\t}\n\n\t// 设置完整的请求头\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 发送搜索请求...\")\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err), false\n\t}\n\tdefer resp.Body.Close()\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 搜索请求响应状态: %d\", resp.StatusCode)\n\t}\n\n\t// 检测429限流 - 立即返回，不延迟\n\tif resp.StatusCode == 429 {\n\t\tatomic.StoreInt32(&p.rateLimited, 1)\n\t\tatomic.AddInt32(&p.rateLimitCount, 1)\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] ⚡ 检测到429限流，立即返回空结果\")\n\t\t}\n\t\treturn []model.SearchResult{}, nil, true // 返回空结果和限流标志\n\t}\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求HTTP状态错误: %d\", p.Name(), resp.StatusCode), false\n\t}\n\n\t// 读取响应体用于调试\n\tbodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 读取搜索结果失败: %w\", p.Name(), err), false\n\t}\n\n\tif p.debugMode {\n\t\tbodyStr := string(bodyBytes)\n\t\tlog.Printf(\"[JAVDB] 响应体长度: %d\", len(bodyStr))\n\t\t// 输出前500个字符用于调试\n\t\tif len(bodyStr) > 500 {\n\t\t\tlog.Printf(\"[JAVDB] 响应体前500字符: %s\", bodyStr[:500])\n\t\t} else {\n\t\t\tlog.Printf(\"[JAVDB] 完整响应体: %s\", bodyStr)\n\t\t}\n\t}\n\n\t// 解析HTML提取搜索结果\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(string(bodyBytes)))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索结果HTML失败: %w\", p.Name(), err), false\n\t}\n\n\tresults, err := p.parseSearchResults(doc)\n\treturn results, err, false\n}\n\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *JavdbPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"[%s] 重试 %d 次后仍然失败: %w\", p.Name(), maxRetries, lastErr)\n}\n\n// doRequestWithRateLimitRetry 带429重试机制的HTTP请求\nfunc (p *JavdbPlugin) doRequestWithRateLimitRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tvar lastErr error\n\t\n\tfor attempt := 0; attempt <= MaxRetryOnRateLimit; attempt++ {\n\t\tif attempt > 0 {\n\t\t\t// 随机延迟，避免同时重试造成更大压力\n\t\t\tdelaySeconds := rand.Intn(MaxRetryDelay-MinRetryDelay+1) + MinRetryDelay\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[JAVDB] 429重试 %d/%d，随机延迟 %d 秒\", attempt, MaxRetryOnRateLimit, delaySeconds)\n\t\t\t}\n\t\t\ttime.Sleep(time.Duration(delaySeconds) * time.Second)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err != nil {\n\t\t\tlastErr = err\n\t\t\tif resp != nil {\n\t\t\t\tresp.Body.Close()\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 如果不是429，直接返回（无论成功还是其他错误）\n\t\tif resp.StatusCode != 429 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\t// 遇到429\n\t\tatomic.AddInt32(&p.rateLimitCount, 1)\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] 遇到429限流，尝试 %d/%d\", attempt+1, MaxRetryOnRateLimit+1)\n\t\t}\n\t\t\n\t\t// 如果不允许重试或已达到最大重试次数\n\t\tif MaxRetryOnRateLimit == 0 || attempt >= MaxRetryOnRateLimit {\n\t\t\tatomic.StoreInt32(&p.rateLimited, 1)\n\t\t\tresp.Body.Close()\n\t\t\treturn nil, fmt.Errorf(\"[%s] 429限流，%s\", p.Name(), \n\t\t\t\tfunc() string {\n\t\t\t\t\tif MaxRetryOnRateLimit == 0 {\n\t\t\t\t\t\treturn \"不重试\"\n\t\t\t\t\t}\n\t\t\t\t\treturn fmt.Sprintf(\"重试%d次后仍然限流\", MaxRetryOnRateLimit)\n\t\t\t\t}())\n\t\t}\n\t\t\n\t\tresp.Body.Close()\n\t\tlastErr = fmt.Errorf(\"429 Too Many Requests\")\n\t}\n\t\n\treturn nil, lastErr\n}\n\n// parseSearchResults 解析搜索结果HTML\nfunc (p *JavdbPlugin) parseSearchResults(doc *goquery.Document) ([]model.SearchResult, error) {\n\tvar results []model.SearchResult\n\n\tif p.debugMode {\n\t\t// 检查是否找到了.movie-list元素\n\t\tmovieListEl := doc.Find(\".movie-list\")\n\t\tlog.Printf(\"[JAVDB] 找到.movie-list元素数量: %d\", movieListEl.Length())\n\t\t\n\t\t// 检查是否找到了.item元素\n\t\titemEls := doc.Find(\".movie-list .item\")\n\t\tlog.Printf(\"[JAVDB] 找到.movie-list .item元素数量: %d\", itemEls.Length())\n\t\t\n\t\t// 如果没有找到预期元素，尝试其他可能的选择器\n\t\tif itemEls.Length() == 0 {\n\t\t\tlog.Printf(\"[JAVDB] 尝试查找其他可能的结果元素...\")\n\t\t\t\n\t\t\t// 尝试其他可能的选择器\n\t\t\taltSelectors := []string{\n\t\t\t\t\".movie-list > div\",\n\t\t\t\t\".movie-list div.item\",\n\t\t\t\t\"[class*='movie'] [class*='item']\",\n\t\t\t\t\".video-list .item\",\n\t\t\t\t\".search-results .item\",\n\t\t\t}\n\t\t\t\n\t\t\tfor _, selector := range altSelectors {\n\t\t\t\taltEls := doc.Find(selector)\n\t\t\t\tif altEls.Length() > 0 {\n\t\t\t\t\tlog.Printf(\"[JAVDB] 找到替代选择器 '%s' 的元素数量: %d\", selector, altEls.Length())\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 输出页面的主要结构用于调试\n\t\t\tdoc.Find(\"div[class*='movie'], div[class*='video'], div[class*='search'], div[class*='result']\").Each(func(i int, s *goquery.Selection) {\n\t\t\t\tclassName, _ := s.Attr(\"class\")\n\t\t\t\tlog.Printf(\"[JAVDB] 找到可能相关的div元素: class='%s'\", className)\n\t\t\t})\n\t\t}\n\t}\n\n\t// 查找搜索结果项: .movie-list .item\n\tdoc.Find(\".movie-list .item\").Each(func(i int, s *goquery.Selection) {\n\t\tif len(results) >= MaxResults {\n\t\t\treturn\n\t\t}\n\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] 开始解析第 %d 个结果项\", i+1)\n\t\t}\n\n\t\tresult := p.parseResultItem(s, i+1)\n\t\tif result != nil {\n\t\t\tresults = append(results, *result)\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[JAVDB] 成功解析第 %d 个结果项: %s\", i+1, result.Title)\n\t\t\t}\n\t\t} else if p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] 第 %d 个结果项解析失败\", i+1)\n\t\t}\n\t})\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 解析到 %d 个原始结果\", len(results))\n\t}\n\n\treturn results, nil\n}\n\n// parseResultItem 解析单个搜索结果项\nfunc (p *JavdbPlugin) parseResultItem(s *goquery.Selection, index int) *model.SearchResult {\n\tif p.debugMode {\n\t\t// 输出当前结果项的HTML结构用于调试\n\t\titemHTML, _ := s.Html()\n\t\tif len(itemHTML) > 300 {\n\t\t\tlog.Printf(\"[JAVDB] 结果项 %d HTML前300字符: %s\", index, itemHTML[:300])\n\t\t} else {\n\t\t\tlog.Printf(\"[JAVDB] 结果项 %d 完整HTML: %s\", index, itemHTML)\n\t\t}\n\t}\n\n\t// 提取详情页链接\n\tlinkEl := s.Find(\"a.box\")\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 结果项 %d 找到a.box元素数量: %d\", index, linkEl.Length())\n\t\t\n\t\t// 如果没有找到a.box，尝试其他可能的链接选择器\n\t\tif linkEl.Length() == 0 {\n\t\t\taltLinkSelectors := []string{\"a\", \"a[href*='/v/']\", \".box\", \"[href*='/v/']\"}\n\t\t\tfor _, selector := range altLinkSelectors {\n\t\t\t\taltLinks := s.Find(selector)\n\t\t\t\tif altLinks.Length() > 0 {\n\t\t\t\t\tlog.Printf(\"[JAVDB] 结果项 %d 找到替代链接选择器 '%s' 的元素数量: %d\", index, selector, altLinks.Length())\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif linkEl.Length() == 0 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] 跳过无链接的结果\")\n\t\t}\n\t\treturn nil\n\t}\n\n\tdetailURL, _ := linkEl.Attr(\"href\")\n\ttitle, _ := linkEl.Attr(\"title\")\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 结果项 %d 详情页URL: %s\", index, detailURL)\n\t\tlog.Printf(\"[JAVDB] 结果项 %d 标题: %s\", index, title)\n\t}\n\n\tif detailURL == \"\" || title == \"\" {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] 跳过无效链接或标题的结果\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t// 处理相对路径\n\tif strings.HasPrefix(detailURL, \"/\") {\n\t\tdetailURL = BaseURL + detailURL\n\t}\n\n\t// 提取番号和标题\n\tvideoNumber, _ := p.extractVideoInfo(s)\n\n\t// 提取评分\n\trating := p.extractRating(s)\n\n\t// 提取发布日期\n\treleaseDate := p.extractReleaseDate(s)\n\n\t// 提取标签\n\ttags := p.extractTags(s)\n\n\t// 构建内容\n\tvar contentParts []string\n\tif videoNumber != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"番號：%s\", videoNumber))\n\t}\n\tif rating != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"評分：%s\", rating))\n\t}\n\tif releaseDate != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"發布日期：%s\", releaseDate))\n\t}\n\tif len(tags) > 0 {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"標籤：%s\", strings.Join(tags, \" \")))\n\t}\n\n\tcontent := strings.Join(contentParts, \"\\n\")\n\n\t// 解析时间\n\tdatetime := p.parseTime(releaseDate)\n\n\t// 构建初始结果对象（磁力链接稍后获取）\n\tresult := model.SearchResult{\n\t\tTitle:     p.cleanTitle(title),\n\t\tContent:   content,\n\t\tChannel:   \"\", // 插件搜索结果必须为空字符串\n\t\tMessageID: fmt.Sprintf(\"%s-%d-%d\", p.Name(), index, time.Now().Unix()),\n\t\tUniqueID:  fmt.Sprintf(\"%s-%d\", p.Name(), index),\n\t\tDatetime:  datetime,\n\t\tLinks:     []model.Link{}, // 先为空，详情页处理后添加\n\t\tTags:      tags,\n\t}\n\n\t// 添加详情页URL到临时字段（用于后续处理）\n\tresult.Content += fmt.Sprintf(\"\\n详情页URL: %s\", detailURL)\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 解析结果: %s (%s)\", title, videoNumber)\n\t}\n\n\treturn &result\n}\n\n// extractVideoInfo 提取番号和标题信息\nfunc (p *JavdbPlugin) extractVideoInfo(s *goquery.Selection) (videoNumber, videoTitle string) {\n\tvideoTitleEl := s.Find(\".video-title\")\n\tif videoTitleEl.Length() > 0 {\n\t\tfullTitle := strings.TrimSpace(videoTitleEl.Text())\n\t\t\n\t\t// 提取番号 (在<strong>标签中)\n\t\tstrongEl := videoTitleEl.Find(\"strong\")\n\t\tif strongEl.Length() > 0 {\n\t\t\tvideoNumber = strings.TrimSpace(strongEl.Text())\n\t\t\t// 从完整标题中移除番号，得到作品标题\n\t\t\tvideoTitle = strings.TrimSpace(strings.Replace(fullTitle, videoNumber, \"\", 1))\n\t\t} else {\n\t\t\tvideoTitle = fullTitle\n\t\t}\n\t}\n\treturn videoNumber, videoTitle\n}\n\n// extractRating 提取评分\nfunc (p *JavdbPlugin) extractRating(s *goquery.Selection) string {\n\tratingEl := s.Find(\".score .value\")\n\tif ratingEl.Length() > 0 {\n\t\trating := strings.TrimSpace(ratingEl.Text())\n\t\t// 清理评分文本，只保留主要信息\n\t\trating = strings.ReplaceAll(rating, \"\\n\", \" \")\n\t\trating = regexp.MustCompile(`\\s+`).ReplaceAllString(rating, \" \")\n\t\treturn rating\n\t}\n\treturn \"\"\n}\n\n// extractReleaseDate 提取发布日期\nfunc (p *JavdbPlugin) extractReleaseDate(s *goquery.Selection) string {\n\tmetaEl := s.Find(\".meta\")\n\tif metaEl.Length() > 0 {\n\t\tdate := strings.TrimSpace(metaEl.Text())\n\t\treturn date\n\t}\n\treturn \"\"\n}\n\n// extractTags 提取标签\nfunc (p *JavdbPlugin) extractTags(s *goquery.Selection) []string {\n\tvar tags []string\n\ts.Find(\".tags .tag\").Each(func(i int, tagEl *goquery.Selection) {\n\t\ttag := strings.TrimSpace(tagEl.Text())\n\t\tif tag != \"\" {\n\t\t\ttags = append(tags, tag)\n\t\t}\n\t})\n\treturn tags\n}\n\n// cleanTitle 清理标题\nfunc (p *JavdbPlugin) cleanTitle(title string) string {\n\ttitle = strings.TrimSpace(title)\n\t// 移除多余的空格\n\ttitle = regexp.MustCompile(`\\s+`).ReplaceAllString(title, \" \")\n\treturn title\n}\n\n// parseTime 解析时间字符串\nfunc (p *JavdbPlugin) parseTime(dateStr string) time.Time {\n\tif dateStr == \"\" {\n\t\treturn time.Now()\n\t}\n\n\t// 常见的日期格式\n\tlayouts := []string{\n\t\t\"2006-01-02\",\n\t\t\"2006/01/02\",\n\t\t\"01-02-2006\",\n\t\t\"01/02/2006\",\n\t}\n\n\tfor _, layout := range layouts {\n\t\tif t, err := time.Parse(layout, dateStr); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\n\treturn time.Now()\n}\n\n// fetchDetailMagnetLinks 并发获取详情页磁力链接\nfunc (p *JavdbPlugin) fetchDetailMagnetLinks(client *http.Client, searchResults []model.SearchResult, keyword string) []model.SearchResult {\n\tif len(searchResults) == 0 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] 无搜索结果需要获取详情页\")\n\t\t}\n\t\treturn []model.SearchResult{}\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 开始获取 %d 个搜索结果的详情页磁力链接\", len(searchResults))\n\t}\n\n\t// 使用通道控制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\tvar wg sync.WaitGroup\n\tresultsChan := make(chan []model.SearchResult, len(searchResults))\n\t\n\t// 根据客户端超时调整策略\n\tvar finalResults []model.SearchResult\n\tuseTimeout := client.Timeout <= 5*time.Second // 短超时客户端使用超时机制\n\n\tfor i, result := range searchResults {\n\t\t// 检查是否已经被限流，如果是则停止启动新的goroutine\n\t\tif atomic.LoadInt32(&p.rateLimited) == 1 {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[JAVDB] 检测到限流状态，停止启动新的详情页请求\")\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\n\t\twg.Add(1)\n\t\tgo func(r model.SearchResult, index int) {\n\t\t\tdefer wg.Done()\n\t\t\tsemaphore <- struct{}{} // 获取信号量\n\t\t\tdefer func() { <-semaphore }() // 释放信号量\n\n\t\t\t// 在goroutine内部再次检查限流状态\n\t\t\tif atomic.LoadInt32(&p.rateLimited) == 1 {\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[JAVDB] goroutine内检测到限流状态，跳过详情页请求: %s\", r.Title)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[JAVDB] 开始处理第 %d 个搜索结果: %s\", index+1, r.Title)\n\t\t\t}\n\n\t\t\t// 从Content中提取详情页URL\n\t\t\tdetailURL := p.extractDetailURLFromContent(r.Content)\n\t\t\tif detailURL == \"\" {\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[JAVDB] 跳过无详情页URL的结果: %s\", r.Title)\n\t\t\t\t\tlog.Printf(\"[JAVDB] Content内容: %s\", r.Content)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[JAVDB] 第 %d 个结果详情页URL: %s\", index+1, detailURL)\n\t\t\t}\n\n\t\t\t// 获取详情页磁力链接\n\t\t\tmagnetLinks := p.fetchDetailPageMagnetLinks(client, detailURL)\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[JAVDB] 第 %d 个结果获取到 %d 个磁力链接\", index+1, len(magnetLinks))\n\t\t\t}\n\n\t\t\tif len(magnetLinks) > 0 {\n\t\t\t\t// 为每个磁力链接创建一个SearchResult\n\t\t\t\tvar results []model.SearchResult\n\t\t\t\tfor _, link := range magnetLinks {\n\t\t\t\t\t// 复制基础结果\n\t\t\t\t\tnewResult := r\n\t\t\t\t\t// 清理Content中的详情页URL\n\t\t\t\t\tnewResult.Content = p.cleanContent(r.Content)\n\t\t\t\t\t// 设置磁力链接\n\t\t\t\t\tnewResult.Links = []model.Link{link}\n\t\t\t\t\t// 更新唯一ID - 基于磁力链接URL哈希确保一致性\n\t\t\t\t\tlinkHash := fmt.Sprintf(\"%x\", md5.Sum([]byte(link.URL)))[:8]\n\t\t\t\t\tnewResult.UniqueID = fmt.Sprintf(\"%s-magnet-%s\", newResult.UniqueID, linkHash)\n\t\t\t\t\tnewResult.MessageID = newResult.UniqueID\n\t\t\t\t\tresults = append(results, newResult)\n\t\t\t\t}\n\t\t\t\tresultsChan <- results\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[JAVDB] 第 %d 个结果成功创建 %d 个最终结果\", index+1, len(results))\n\t\t\t\t}\n\t\t\t} else if p.debugMode {\n\t\t\t\tlog.Printf(\"[JAVDB] 详情页无磁力链接: %s\", r.Title)\n\t\t\t}\n\t\t}(result, i)\n\t}\n\n\t// 等待所有goroutine完成的信号\n\tdone := make(chan struct{})\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultsChan)\n\t\tclose(done)\n\t}()\n\n\t// 收集结果\n\tif useTimeout {\n\t\t// 短超时客户端：4秒超时机制，快速返回部分结果\n\t\ttimeout := time.After(4 * time.Second)\n\tcollectLoop:\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase results, ok := <-resultsChan:\n\t\t\t\tif !ok {\n\t\t\t\t\tbreak collectLoop\n\t\t\t\t}\n\t\t\t\tfinalResults = append(finalResults, results...)\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[JAVDB] 收集到一批结果，数量: %d，总数: %d\", len(results), len(finalResults))\n\t\t\t\t}\n\t\t\tcase <-timeout:\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[JAVDB] ⏰ 4秒超时，返回已获取的 %d 个结果\", len(finalResults))\n\t\t\t\t}\n\t\t\t\tbreak collectLoop\n\t\t\tcase <-done:\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[JAVDB] 所有详情页请求完成\")\n\t\t\t\t}\n\t\t\t\tbreak collectLoop\n\t\t\t}\n\t\t}\n\t} else {\n\t\t// 长超时客户端：等待所有结果完成\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase results, ok := <-resultsChan:\n\t\t\t\tif !ok {\n\t\t\t\t\tgoto finished\n\t\t\t\t}\n\t\t\t\tfinalResults = append(finalResults, results...)\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[JAVDB] 收集到一批结果，数量: %d，总数: %d\", len(results), len(finalResults))\n\t\t\t\t}\n\t\t\tcase <-done:\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[JAVDB] 所有详情页请求完成\")\n\t\t\t\t}\n\t\t\t\tgoto finished\n\t\t\t}\n\t\t}\n\tfinished:\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 最终收集到 %d 个结果\", len(finalResults))\n\t\t// 如果遇到了限流，提示用户\n\t\tif atomic.LoadInt32(&p.rateLimited) == 1 {\n\t\t\tlog.Printf(\"[JAVDB] 本次搜索遇到429限流，结果可能不完整\")\n\t\t}\n\t}\n\n\treturn finalResults\n}\n\n\n\n// extractDetailURLFromContent 从Content中提取详情页URL\nfunc (p *JavdbPlugin) extractDetailURLFromContent(content string) string {\n\tlines := strings.Split(content, \"\\n\")\n\tfor _, line := range lines {\n\t\tif strings.HasPrefix(line, \"详情页URL: \") {\n\t\t\treturn strings.TrimPrefix(line, \"详情页URL: \")\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// cleanContent 清理Content，移除详情页URL行\nfunc (p *JavdbPlugin) cleanContent(content string) string {\n\tlines := strings.Split(content, \"\\n\")\n\tvar cleanedLines []string\n\tfor _, line := range lines {\n\t\tif !strings.HasPrefix(line, \"详情页URL: \") {\n\t\t\tcleanedLines = append(cleanedLines, line)\n\t\t}\n\t}\n\treturn strings.Join(cleanedLines, \"\\n\")\n}\n\n// fetchDetailPageMagnetLinks 获取详情页的磁力链接\nfunc (p *JavdbPlugin) fetchDetailPageMagnetLinks(client *http.Client, detailURL string) []model.Link {\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 开始获取详情页磁力链接: %s\", detailURL)\n\t}\n\n\t// 检查缓存\n\tif cached, found := p.detailCache.Load(detailURL); found {\n\t\tif links, ok := cached.([]model.Link); ok {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[JAVDB] 使用缓存的详情页链接: %s\", detailURL)\n\t\t\t}\n\t\t\treturn links\n\t\t}\n\t}\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] 创建详情页请求失败: %v\", err)\n\t\t}\n\t\treturn []model.Link{}\n\t}\n\n\t// 设置请求头\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 发送详情页请求...\")\n\t}\n\n\tresp, err := p.doRequestWithRateLimitRetry(req, client)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] 详情页请求失败: %v\", err)\n\t\t}\n\t\treturn []model.Link{}\n\t}\n\tdefer resp.Body.Close()\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 详情页请求响应状态: %d\", resp.StatusCode)\n\t}\n\n\tif resp.StatusCode != 200 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] 详情页HTTP状态错误: %d\", resp.StatusCode)\n\t\t}\n\t\treturn []model.Link{}\n\t}\n\n\t// 读取响应体\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] 读取详情页响应失败: %v\", err)\n\t\t}\n\t\treturn []model.Link{}\n\t}\n\n\tif p.debugMode {\n\t\tbodyStr := string(body)\n\t\tlog.Printf(\"[JAVDB] 详情页响应体长度: %d\", len(bodyStr))\n\t\t// 检查页面是否包含磁力链接相关内容\n\t\tif strings.Contains(bodyStr, \"magnet:\") {\n\t\t\tmagnetCount := strings.Count(bodyStr, \"magnet:\")\n\t\t\tlog.Printf(\"[JAVDB] 详情页包含 %d 个magnet字符串\", magnetCount)\n\t\t} else {\n\t\t\tlog.Printf(\"[JAVDB] 详情页不包含magnet字符串\")\n\t\t}\n\t\t\n\t\t// 检查是否包含预期的磁力链接容器元素\n\t\tif strings.Contains(bodyStr, \"magnets-content\") {\n\t\t\tlog.Printf(\"[JAVDB] 找到magnets-content容器\")\n\t\t} else {\n\t\t\tlog.Printf(\"[JAVDB] 未找到magnets-content容器\")\n\t\t}\n\t\t\n\t\tif strings.Contains(bodyStr, \"magnet-links\") {\n\t\t\tlog.Printf(\"[JAVDB] 找到magnet-links容器\")\n\t\t} else {\n\t\t\tlog.Printf(\"[JAVDB] 未找到magnet-links容器\")\n\t\t}\n\t}\n\n\t// 解析磁力链接\n\tlinks := p.parseMagnetLinks(string(body))\n\n\t// 缓存结果\n\tif len(links) > 0 {\n\t\tp.detailCache.Store(detailURL, links)\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 从详情页提取到 %d 个磁力链接: %s\", len(links), detailURL)\n\t}\n\n\treturn links\n}\n\n// parseMagnetLinks 解析磁力链接\nfunc (p *JavdbPlugin) parseMagnetLinks(htmlContent string) []model.Link {\n\tvar links []model.Link\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 开始解析磁力链接\")\n\t}\n\n\t// 使用goquery解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] 解析详情页HTML失败: %v\", err)\n\t\t}\n\t\treturn links\n\t}\n\n\tif p.debugMode {\n\t\t// 检查关键容器元素\n\t\tmagnetsContentEl := doc.Find(\"#magnets-content\")\n\t\tlog.Printf(\"[JAVDB] 找到#magnets-content元素数量: %d\", magnetsContentEl.Length())\n\t\t\n\t\tmagnetLinksEl := doc.Find(\"#magnets-content .magnet-links\")\n\t\tlog.Printf(\"[JAVDB] 找到#magnets-content .magnet-links元素数量: %d\", magnetLinksEl.Length())\n\t\t\n\t\tmagnetItemsEl := doc.Find(\"#magnets-content .magnet-links .item\")\n\t\tlog.Printf(\"[JAVDB] 找到#magnets-content .magnet-links .item元素数量: %d\", magnetItemsEl.Length())\n\t\t\n\t\t// 如果没有找到预期元素，尝试其他可能的选择器\n\t\tif magnetItemsEl.Length() == 0 {\n\t\t\tlog.Printf(\"[JAVDB] 尝试其他可能的磁力链接选择器...\")\n\t\t\t\n\t\t\taltSelectors := []string{\n\t\t\t\t\".magnet-links .item\",\n\t\t\t\t\"[href^='magnet:']\",\n\t\t\t\t\"a[href*='magnet:']\",\n\t\t\t\t\".item [href^='magnet:']\",\n\t\t\t}\n\t\t\t\n\t\t\tfor _, selector := range altSelectors {\n\t\t\t\taltEls := doc.Find(selector)\n\t\t\t\tif altEls.Length() > 0 {\n\t\t\t\t\tlog.Printf(\"[JAVDB] 找到替代选择器 '%s' 的元素数量: %d\", selector, altEls.Length())\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 查找磁力链接区域: .magnet-links .item (因为#magnets-content本身就有magnet-links类)\n\tdoc.Find(\".magnet-links .item\").Each(func(i int, s *goquery.Selection) {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] 开始解析第 %d 个磁力链接项\", i+1)\n\t\t}\n\n\t\t// 提取磁力链接URL - 从.magnet-name下的a标签获取href\n\t\tmagnetEl := s.Find(\".magnet-name a\")\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] 第 %d 个项找到.magnet-name a元素数量: %d\", i+1, magnetEl.Length())\n\t\t}\n\n\t\tif magnetEl.Length() == 0 {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[JAVDB] 第 %d 个项无.magnet-name a元素，跳过\", i+1)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tmagnetURL, _ := magnetEl.Attr(\"href\")\n\t\tif magnetURL == \"\" {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[JAVDB] 第 %d 个项磁力链接URL为空，跳过\", i+1)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\t// 验证是否为磁力链接\n\t\tif !strings.HasPrefix(magnetURL, \"magnet:\") {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[JAVDB] 第 %d 个项不是磁力链接: %s，跳过\", i+1, magnetURL)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] 第 %d 个项原始磁力URL: %s\", i+1, magnetURL)\n\t\t}\n\n\t\t// 解码HTML实体\n\t\tmagnetURL = strings.ReplaceAll(magnetURL, \"&amp;\", \"&\")\n\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[JAVDB] 第 %d 个项解码后磁力URL: %s\", i+1, magnetURL)\n\t\t}\n\n\t\tlink := model.Link{\n\t\t\tType:     \"magnet\",\n\t\t\tURL:      magnetURL,\n\t\t\tPassword: \"\", // 磁力链接无需密码\n\t\t}\n\n\t\tlinks = append(links, link)\n\n\t\tif p.debugMode {\n\t\t\t// 提取资源名称用于调试日志\n\t\t\tnameEl := s.Find(\".magnet-name .name\")\n\t\t\tresourceName := strings.TrimSpace(nameEl.Text())\n\t\t\t// 提取文件信息用于调试日志\n\t\t\tmetaEl := s.Find(\".magnet-name .meta\")\n\t\t\tfileInfo := strings.TrimSpace(metaEl.Text())\n\t\t\tlog.Printf(\"[JAVDB] 成功提取第 %d 个磁力链接: %s (%s)\", i+1, resourceName, fileInfo)\n\t\t}\n\t})\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[JAVDB] 磁力链接解析完成，共找到 %d 个链接\", len(links))\n\t}\n\n\treturn links\n}"
  },
  {
    "path": "plugin/jikepan/jikepan.go",
    "content": "package jikepan\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\n// 在init函数中注册插件\nfunc init() {\n\t// 注册插件\n\tplugin.RegisterGlobalPlugin(NewJikepanAsyncV2Plugin())\n}\n\nconst (\n\t// JikepanAPIURL 即刻盘API地址\n\tJikepanAPIURL = \"https://api.jikepan.xyz/search\"\n)\n\n// JikepanAsyncV2Plugin 即刻盘搜索异步V2插件\ntype JikepanAsyncV2Plugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\n// NewJikepanAsyncV2Plugin 创建新的即刻盘搜索异步V2插件\nfunc NewJikepanAsyncV2Plugin() *JikepanAsyncV2Plugin {\n\treturn &JikepanAsyncV2Plugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"jikepan\", 3),\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *JikepanAsyncV2Plugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *JikepanAsyncV2Plugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)\n}\n\n// doSearch 实际的搜索实现\nfunc (p *JikepanAsyncV2Plugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 构建请求\n\treqBody := map[string]interface{}{\n\t\t\"name\":   keyword,\n\t\t\"is_all\": false,\n\t}\n\t\n\t// 检查ext中是否包含自定义参数，如果有则使用它\n\tif ext != nil {\n\t\tif isAll, ok := ext[\"is_all\"].(bool); ok && isAll {\n\t\t\t// 使用全量搜索，时间大约10秒\n\t\t\treqBody[\"is_all\"] = true\n\t\t}\n\t}\n\t\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"marshal request failed: %w\", err)\n\t}\n\t\n\treq, err := http.NewRequest(\"POST\", JikepanAPIURL, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request failed: %w\", err)\n\t}\n\t\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"referer\", \"https://jikepan.xyz/\")\n\treq.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\")\n\t\n\t// 发送请求\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 解析响应\n\tvar apiResp JikepanResponse\n\tbodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response body failed: %w\", err)\n\t}\n\t\n\tif err := json.Unmarshal(bodyBytes, &apiResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"decode response failed: %w\", err)\n\t}\n\t\n\t// 检查响应状态\n\tif apiResp.Msg != \"success\" {\n\t\treturn nil, fmt.Errorf(\"API returned error: %s\", apiResp.Msg)\n\t}\n\t\n\t// 转换结果格式\n\tresults := p.convertResults(apiResp.List)\n\t\n\treturn results, nil\n}\n\n// convertResults 将API响应转换为标准SearchResult格式\nfunc (p *JikepanAsyncV2Plugin) convertResults(items []JikepanItem) []model.SearchResult {\n\tresults := make([]model.SearchResult, 0, len(items))\n\t\n\tfor i, item := range items {\n\t\t// 跳过没有链接的结果\n\t\tif len(item.Links) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 创建链接列表\n\t\tlinks := make([]model.Link, 0, len(item.Links))\n\t\tfor _, link := range item.Links {\n\t\t\tlinkType := p.convertLinkType(link.Service)\n\t\t\t\n\t\t\t// 特殊处理other类型，检查链接URL\n\t\t\tif linkType == \"others\" && strings.Contains(strings.ToLower(link.Link), \"drive.uc.cn\") {\n\t\t\t\tlinkType = \"uc\"\n\t\t\t}\n\t\t\t\n\t\t\t// 跳过未知类型的链接（linkType为空）\n\t\t\tif linkType == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t\n\t\t\t// 创建链接\n\t\t\tlinks = append(links, model.Link{\n\t\t\t\tURL:      link.Link,\n\t\t\t\tType:     linkType,\n\t\t\t\tPassword: link.Pwd,\n\t\t\t})\n\t\t}\n\t\t\n\t\t// 创建唯一ID：插件名-索引\n\t\tuniqueID := fmt.Sprintf(\"jikepan-%d\", i)\n\t\t\n\t\t// 创建搜索结果\n\t\tresult := model.SearchResult{\n\t\t\tUniqueID:  uniqueID,\n\t\t\tTitle:     item.Name,\n\t\t\tDatetime:  time.Time{}, // 使用零值而不是nil\n\t\t\tLinks:     links,\n\t\t}\n\t\t\n\t\tresults = append(results, result)\n\t}\n\t\n\treturn results\n}\n\n// convertLinkType 将API的服务类型转换为标准链接类型\nfunc (p *JikepanAsyncV2Plugin) convertLinkType(service string) string {\n\tservice = strings.ToLower(service)\n\t\n\tswitch service {\n\tcase \"baidu\":\n\t\treturn \"baidu\"\n\tcase \"aliyun\":\n\t\treturn \"aliyun\"\n\tcase \"xunlei\":\n\t\treturn \"xunlei\"\n\tcase \"quark\":\n\t\treturn \"quark\"\n\tcase \"189cloud\":\n\t\treturn \"tianyi\"\n\tcase \"115\":\n\t\treturn \"115\"\n\tcase \"123\":\n\t\treturn \"123\"\n\tcase \"pikpak\":\n\t\treturn \"pikpak\"\n\tcase \"caiyun\":\n\t\treturn \"mobile\"\n\tcase \"ed2k\":\n\t\treturn \"ed2k\"\n\tcase \"magnet\":\n\t\treturn \"magnet\"\n\tcase \"unknown\":\n\t\t// 对于未知类型，返回空字符串，以便在后续处理中跳过\n\t\treturn \"\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n// JikepanResponse API响应结构\ntype JikepanResponse struct {\n\tMsg  string        `json:\"msg\"`\n\tList []JikepanItem `json:\"list\"`\n}\n\n// JikepanItem API响应中的单个结果项\ntype JikepanItem struct {\n\tName  string        `json:\"name\"`\n\tLinks []JikepanLink `json:\"links\"`\n}\n\n// JikepanLink API响应中的链接信息\ntype JikepanLink struct {\n\tService string `json:\"service\"`\n\tLink    string `json:\"link\"`\n\tPwd     string `json:\"pwd,omitempty\"`\n} "
  },
  {
    "path": "plugin/jsnoteclub/jsnoteclub.go",
    "content": "package jsnoteclub\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tpluginName      = \"jsnoteclub\"\n\tdefaultPriority = 2\n\n\tpostsCacheTTL       = time.Hour\n\tdetailCacheTTL      = time.Hour\n\tmaxMatchedPosts     = 30\n\tmaxDetailWorkers    = 8\n\trequestTimeout      = 12 * time.Second\n\tdetailTimeout       = 10 * time.Second\n\thttpMaxIdleConns    = 64\n\thttpMaxIdlePerHost  = 16\n\thttpMaxConnsPerHost = 32\n\tretryBaseDelay      = 200 * time.Millisecond\n\tmaxRequestRetries   = 3\n)\n\nvar (\n\tdataKeyRegex = regexp.MustCompile(`data-key=\"([0-9a-fA-F]+)\"`)\n\n\tlinkPatterns = []struct {\n\t\treg *regexp.Regexp\n\t\ttyp string\n\t}{\n\t\t{regexp.MustCompile(`https?://pan\\.quark\\.cn/(?:s|g)/[0-9A-Za-z]+`), \"quark\"},\n\t\t{regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9A-Za-z\\-_]+`), \"xunlei\"},\n\t\t{regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9A-Za-z\\-_]+`), \"baidu\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?(aliyundrive\\.com|alipan\\.com)/s/[0-9A-Za-z]+`), \"aliyun\"},\n\t\t{regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9A-Za-z]+`), \"uc\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?(123pan\\.com|123pan\\.cn|123684\\.com|123685\\.com|123912\\.com|123592\\.com)/s/[0-9A-Za-z]+`), \"123\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?mypikpak\\.com/s/[0-9A-Za-z]+`), \"pikpak\"},\n\t\t{regexp.MustCompile(`https?://caiyun\\.139\\.com/[^\\s<>\"']+`), \"mobile\"},\n\t\t{regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9A-Za-z]+`), \"magnet\"},\n\t\t{regexp.MustCompile(`ed2k://[^\\s<>\"']+`), \"ed2k\"},\n\t}\n\n\tpasswordPatterns = []*regexp.Regexp{\n\t\tregexp.MustCompile(`提取码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`密码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`pwd\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`code\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t}\n\n\ttextURLRegex = regexp.MustCompile(`https?://[^\\s<>\"']+`)\n\n\tpostsCache = struct {\n\t\tsync.RWMutex\n\t\tentries []ghostPost\n\t\texpire  time.Time\n\t\tkey     string\n\t}{}\n\n\tdetailCache sync.Map\n)\n\ntype detailCacheEntry struct {\n\tlinks     []model.Link\n\texpiresAt time.Time\n}\n\n// JsNoteClubPlugin 实现灵犀笔记插件\ntype JsNoteClubPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tclient *http.Client\n}\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewJsNoteClubPlugin())\n\tgo startDetailCacheCleaner()\n}\n\n// NewJsNoteClubPlugin 创建插件实例\nfunc NewJsNoteClubPlugin() *JsNoteClubPlugin {\n\treturn &JsNoteClubPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),\n\t\tclient:          newHTTPClient(),\n\t}\n}\n\n// Search 兼容方法\nfunc (p *JsNoteClubPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 扩展方法\nfunc (p *JsNoteClubPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\nfunc (p *JsNoteClubPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif p.client != nil {\n\t\tclient = p.client\n\t}\n\n\tsearchKeyword := strings.TrimSpace(keyword)\n\tif searchKeyword == \"\" {\n\t\treturn nil, fmt.Errorf(\"[%s] 关键词不能为空\", p.Name())\n\t}\n\tif titleEn, ok := ext[\"title_en\"].(string); ok {\n\t\ttitleEn = strings.TrimSpace(titleEn)\n\t\tif titleEn != \"\" {\n\t\t\tsearchKeyword = fmt.Sprintf(\"%s %s\", searchKeyword, titleEn)\n\t\t}\n\t}\n\n\tallPosts, err := p.getAllPosts(client)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tmatched := filterPostsByKeyword(allPosts, searchKeyword)\n\tif len(matched) == 0 {\n\t\treturn nil, fmt.Errorf(\"[%s] 未找到相关资源\", p.Name())\n\t}\n\tif len(matched) > maxMatchedPosts {\n\t\tmatched = matched[:maxMatchedPosts]\n\t}\n\n\tvar (\n\t\twg      sync.WaitGroup\n\t\tresultM sync.Mutex\n\t\tresults []model.SearchResult\n\t\tsem     = make(chan struct{}, maxDetailWorkers)\n\t)\n\n\tfor _, post := range matched {\n\t\tpost := post\n\t\twg.Add(1)\n\t\tsem <- struct{}{}\n\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tdefer func() { <-sem }()\n\n\t\t\tlinks := p.fetchDetailLinks(client, post.URL)\n\t\t\tif len(links) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tresult := model.SearchResult{\n\t\t\t\tUniqueID: fmt.Sprintf(\"%s-%s\", p.Name(), post.ID),\n\t\t\t\tTitle:    strings.TrimSpace(post.Title),\n\t\t\t\tContent:  strings.TrimSpace(post.Excerpt),\n\t\t\t\tLinks:    links,\n\t\t\t\tTags:     []string{strings.TrimSpace(post.Slug)},\n\t\t\t\tChannel:  \"\",\n\t\t\t\tDatetime: post.updatedAtTime(),\n\t\t\t}\n\n\t\t\tresultM.Lock()\n\t\t\tresults = append(results, result)\n\t\t\tresultM.Unlock()\n\t\t}()\n\t}\n\n\twg.Wait()\n\n\tif len(results) == 0 {\n\t\treturn nil, fmt.Errorf(\"[%s] 未能获取到有效网盘链接\", p.Name())\n\t}\n\n\treturn plugin.FilterResultsByKeyword(results, searchKeyword), nil\n}\n\nfunc (p *JsNoteClubPlugin) getAllPosts(client *http.Client) ([]ghostPost, error) {\n\tnow := time.Now()\n\n\tpostsCache.RLock()\n\tif len(postsCache.entries) > 0 && now.Before(postsCache.expire) {\n\t\tdefer postsCache.RUnlock()\n\t\treturn postsCache.entries, nil\n\t}\n\tpostsCache.RUnlock()\n\n\tpostsCache.Lock()\n\tdefer postsCache.Unlock()\n\n\t// Double-check after acquiring write lock\n\tif len(postsCache.entries) > 0 && now.Before(postsCache.expire) {\n\t\treturn postsCache.entries, nil\n\t}\n\n\tdataKey, err := p.fetchDataKey(client)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tposts, err := p.fetchPosts(client, dataKey)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tpostsCache.entries = posts\n\tpostsCache.expire = time.Now().Add(postsCacheTTL)\n\tpostsCache.key = dataKey\n\n\treturn posts, nil\n}\n\nfunc (p *JsNoteClubPlugin) fetchDataKey(client *http.Client) (string, error) {\n\tctx, cancel := context.WithTimeout(context.Background(), requestTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, \"https://jsnoteclub.com/\", nil)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"[%s] 创建首页请求失败: %w\", p.Name(), err)\n\t}\n\tsetHTMLHeaders(req, \"https://jsnoteclub.com/\")\n\n\tresp, err := p.doRequestWithRetry(req, client, maxRequestRetries)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"[%s] 访问首页失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"[%s] 首页返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"[%s] 解析首页失败: %w\", p.Name(), err)\n\t}\n\n\tvar htmlBuilder strings.Builder\n\tdoc.Find(\"script\").Each(func(_ int, s *goquery.Selection) {\n\t\tif html, err := goquery.OuterHtml(s); err == nil {\n\t\t\thtmlBuilder.WriteString(html)\n\t\t}\n\t})\n\n\tmatch := dataKeyRegex.FindStringSubmatch(htmlBuilder.String())\n\tif len(match) < 2 {\n\t\treturn \"\", fmt.Errorf(\"[%s] 未能在首页找到 data-key\", p.Name())\n\t}\n\n\treturn match[1], nil\n}\n\nfunc (p *JsNoteClubPlugin) fetchPosts(client *http.Client, dataKey string) ([]ghostPost, error) {\n\tparams := url.Values{}\n\tparams.Set(\"key\", dataKey)\n\tparams.Set(\"limit\", \"10000\")\n\tparams.Set(\"fields\", \"id,slug,title,excerpt,url,updated_at,visibility\")\n\tparams.Set(\"order\", \"updated_at DESC\")\n\n\treqURL := fmt.Sprintf(\"https://jsnoteclub.com/ghost/api/content/posts/?%s\", params.Encode())\n\n\tctx, cancel := context.WithTimeout(context.Background(), requestTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建内容请求失败: %w\", p.Name(), err)\n\t}\n\tsetAPIHeaders(req, \"https://jsnoteclub.com/\")\n\n\tresp, err := p.doRequestWithRetry(req, client, maxRequestRetries)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 获取内容失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"[%s] 内容接口返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\tvar payload ghostPostsResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析内容数据失败: %w\", p.Name(), err)\n\t}\n\n\treturn payload.Posts, nil\n}\n\nfunc (p *JsNoteClubPlugin) fetchDetailLinks(client *http.Client, detailURL string) []model.Link {\n\tif cached, ok := detailCache.Load(detailURL); ok {\n\t\tif entry, valid := cached.(detailCacheEntry); valid && time.Now().Before(entry.expiresAt) {\n\t\t\treturn entry.links\n\t\t}\n\t\tdetailCache.Delete(detailURL)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), detailTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tsetHTMLHeaders(req, detailURL)\n\n\tresp, err := p.doRequestWithRetry(req, client, maxRequestRetries)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\tcontent := doc.Find(\"section.gh-content\")\n\tif content.Length() == 0 {\n\t\tcontent = doc.Find(\".gh-content\")\n\t}\n\tif content.Length() == 0 {\n\t\tcontent = doc.Find(\"article\")\n\t}\n\tif content.Length() == 0 {\n\t\tcontent = doc.Selection\n\t}\n\n\tcontent.Find(\"aside\").Remove()\n\tcontent.Find(\".gh-sidebar\").Remove()\n\tcontent.Find(\".sidebar-left\").Remove()\n\tcontent.Find(\".left-ads\").Remove()\n\n\tlinks := extractLinksFromSelection(content)\n\tif len(links) > 0 {\n\t\tdetailCache.Store(detailURL, detailCacheEntry{\n\t\t\tlinks:     links,\n\t\t\texpiresAt: time.Now().Add(detailCacheTTL),\n\t\t})\n\t}\n\treturn links\n}\n\nfunc extractLinksFromSelection(sel *goquery.Selection) []model.Link {\n\tvar (\n\t\tresults []model.Link\n\t\tseen    = make(map[string]struct{})\n\t)\n\n\tsel.Find(\"a[href]\").Each(func(_ int, node *goquery.Selection) {\n\t\thref, ok := node.Attr(\"href\")\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\t\thref = strings.TrimSpace(href)\n\t\tif href == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tlinkType, normalized := classifyLink(href)\n\t\tif linkType == \"\" {\n\t\t\treturn\n\t\t}\n\t\tif _, exists := seen[normalized]; exists {\n\t\t\treturn\n\t\t}\n\n\t\tpassword := extractPassword(node)\n\t\tresults = append(results, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      normalized,\n\t\t\tPassword: password,\n\t\t})\n\t\tseen[normalized] = struct{}{}\n\t})\n\n\ttext := sel.Text()\n\tfor _, loc := range textURLRegex.FindAllStringIndex(text, -1) {\n\t\traw := text[loc[0]:loc[1]]\n\t\tlinkType, normalized := classifyLink(raw)\n\t\tif linkType == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := seen[normalized]; exists {\n\t\t\tcontinue\n\t\t}\n\n\t\tcontext := substring(text, loc[0]-80, loc[1]+80)\n\t\tpassword := matchPassword(context)\n\n\t\tresults = append(results, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      normalized,\n\t\t\tPassword: password,\n\t\t})\n\t\tseen[normalized] = struct{}{}\n\t}\n\n\treturn results\n}\n\nfunc classifyLink(raw string) (string, string) {\n\tfor _, pattern := range linkPatterns {\n\t\tif loc := pattern.reg.FindString(raw); loc != \"\" {\n\t\t\treturn pattern.typ, loc\n\t\t}\n\t}\n\treturn \"\", \"\"\n}\n\nfunc extractPassword(node *goquery.Selection) string {\n\tcandidates := []string{node.Text()}\n\n\tif title, ok := node.Attr(\"title\"); ok {\n\t\tcandidates = append(candidates, title)\n\t}\n\tif parent := node.Parent(); parent != nil && parent.Length() > 0 {\n\t\tcandidates = append(candidates, parent.Text())\n\t\tif next := parent.Next(); next.Length() > 0 {\n\t\t\tcandidates = append(candidates, next.Text())\n\t\t}\n\t}\n\tif sibling := node.Next(); sibling.Length() > 0 {\n\t\tcandidates = append(candidates, sibling.Text())\n\t}\n\n\tfor _, c := range candidates {\n\t\tif pwd := matchPassword(c); pwd != \"\" {\n\t\t\treturn pwd\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc matchPassword(text string) string {\n\ttext = strings.TrimSpace(text)\n\tif text == \"\" {\n\t\treturn \"\"\n\t}\n\tfor _, pattern := range passwordPatterns {\n\t\tif matches := pattern.FindStringSubmatch(text); len(matches) > 1 {\n\t\t\treturn strings.TrimSpace(matches[1])\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc substring(text string, start, end int) string {\n\tif start < 0 {\n\t\tstart = 0\n\t}\n\tif end > len(text) {\n\t\tend = len(text)\n\t}\n\treturn text[start:end]\n}\n\nfunc filterPostsByKeyword(posts []ghostPost, keyword string) []ghostPost {\n\tif keyword == \"\" {\n\t\treturn posts\n\t}\n\tlowerKeyword := strings.ToLower(keyword)\n\tparts := strings.Fields(lowerKeyword)\n\n\tvar matched []ghostPost\n\tfor _, post := range posts {\n\t\ttarget := strings.ToLower(fmt.Sprintf(\"%s %s %s\", post.Title, post.Excerpt, post.Slug))\n\t\tmatch := true\n\t\tfor _, part := range parts {\n\t\t\tif !strings.Contains(target, part) {\n\t\t\t\tmatch = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif match {\n\t\t\tmatched = append(matched, post)\n\t\t}\n\t}\n\treturn matched\n}\n\nfunc (p *JsNoteClubPlugin) doRequestWithRetry(req *http.Request, client *http.Client, maxRetries int) (*http.Response, error) {\n\tvar lastErr error\n\n\tfor attempt := 0; attempt < maxRetries; attempt++ {\n\t\tresp, err := client.Do(req.Clone(req.Context()))\n\t\tif err == nil && resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tif resp != nil && resp.StatusCode >= 500 {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t\tif attempt < maxRetries-1 {\n\t\t\ttime.Sleep(retryBaseDelay * time.Duration(1<<attempt))\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"重试 %d 次后失败: %w\", maxRetries, lastErr)\n}\n\nfunc newHTTPClient() *http.Client {\n\treturn &http.Client{\n\t\tTimeout: requestTimeout,\n\t\tTransport: &http.Transport{\n\t\t\tMaxIdleConns:        httpMaxIdleConns,\n\t\t\tMaxIdleConnsPerHost: httpMaxIdlePerHost,\n\t\t\tMaxConnsPerHost:     httpMaxConnsPerHost,\n\t\t\tIdleConnTimeout:     90 * time.Second,\n\t\t\tTLSHandshakeTimeout: 10 * time.Second,\n\t\t},\n\t}\n}\n\nfunc setHTMLHeaders(req *http.Request, referer string) {\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", referer)\n}\n\nfunc setAPIHeaders(req *http.Request, referer string) {\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36\")\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", referer)\n}\n\nfunc startDetailCacheCleaner() {\n\tticker := time.NewTicker(30 * time.Minute)\n\tdefer ticker.Stop()\n\n\tfor range ticker.C {\n\t\tnow := time.Now()\n\t\tdetailCache.Range(func(key, value interface{}) bool {\n\t\t\tentry, ok := value.(detailCacheEntry)\n\t\t\tif !ok || now.After(entry.expiresAt) {\n\t\t\t\tdetailCache.Delete(key)\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n}\n\ntype ghostPostsResponse struct {\n\tPosts []ghostPost `json:\"posts\"`\n}\n\ntype ghostPost struct {\n\tID         string `json:\"id\"`\n\tSlug       string `json:\"slug\"`\n\tTitle      string `json:\"title\"`\n\tExcerpt    string `json:\"excerpt\"`\n\tURL        string `json:\"url\"`\n\tUpdatedAt  string `json:\"updated_at\"`\n\tVisibility string `json:\"visibility\"`\n}\n\nfunc (p ghostPost) updatedAtTime() time.Time {\n\tlayouts := []string{\n\t\ttime.RFC3339Nano,\n\t\ttime.RFC3339,\n\t\t\"2006-01-02 15:04:05\",\n\t}\n\n\tfor _, layout := range layouts {\n\t\tif t, err := time.Parse(layout, p.UpdatedAt); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\treturn time.Now()\n}\n"
  },
  {
    "path": "plugin/jutoushe/html结构分析.md",
    "content": "# JUTOUSHE（剧透社）HTML结构分析\n\n## 网站信息\n- **网站名称**: 剧透社\n- **域名**: https://1.star2.cn/\n- **类型**: 网盘资源分享站，主要提供电视剧、电影、短剧、综艺等影视资源\n- **特点**: 提供多种网盘下载链接（夸克、百度、阿里等）\n\n## 搜索页面结构\n\n### 1. 搜索URL模式\n```\nhttps://1.star2.cn/search/?keyword={关键词}\n\n示例:\nhttps://1.star2.cn/search/?keyword=%E7%91%9E%E5%85%8B%E5%92%8C%E8%8E%AB%E8%92%82\nhttps://1.star2.cn/search/?keyword=%E4%B8%8E%E6%99%8B%E9%95%BF%E5%AE%89\n\n参数说明:\n- keyword: URL编码的搜索关键词\n```\n\n### 2. 搜索结果容器\n- **父容器**: `<ul class=\"erx-list\">`\n- **结果项**: `<li class=\"item\">` (每个搜索结果)\n\n### 3. 单个搜索结果结构\n\n#### 标题和链接区域 (.a)\n```html\n<div class=\"a\">\n    <a href=\"/dm/8100.html\" class=\"main\">【动漫】瑞克和莫蒂8.全集</a>\n    <span class=\"tags\"></span>\n</div>\n\n提取要素:\n- 详情页链接: a.main 的 href 属性 (需要拼接完整域名)\n- 标题: a.main 的文本内容 (包含分类标签)\n```\n\n#### 时间信息区域 (.i)\n```html\n<div class=\"i\">\n    <span class=\"erx-num-font time\">2025-05-26</span>\n</div>\n\n提取要素:\n- 发布时间: span.time 的文本内容 (格式: YYYY-MM-DD)\n```\n\n## 详情页面结构\n\n### 1. 详情页URL模式\n```\nhttps://1.star2.cn/{分类}/{ID}.html\n\n示例:\nhttps://1.star2.cn/dm/8100.html  (动漫类别)\nhttps://1.star2.cn/ju/8737.html  (国剧类别)\n\n分类说明:\n- /dm/ : 动漫\n- /ju/ : 国剧  \n- /dj/ : 短剧\n- /zy/ : 综艺\n- /mv/ : 电影\n- /rh/ : 韩日剧\n- /ym/ : 英美剧\n- /wj/ : 外剧\n- /qt/ : 其他\n```\n\n### 2. 详情页面关键区域\n\n#### 标题区域\n```html\n<h1>【动漫】瑞克和莫蒂8.全集</h1>\n\n提取要素:\n- 标题: h1 的文本内容\n```\n\n#### 元信息区域\n```html\n<section class=\"erx-tct i\">\n    <span class=\"time\">2025-05-26</span>\n    <span class=\"view\">823次浏览</span>\n</section>\n\n提取要素:\n- 发布时间: span.time 的文本内容\n- 浏览次数: span.view 的文本内容 (可选)\n```\n\n#### 下载链接区域\n```html\n<div class=\"dlipp-cont-wp\">\n    <div class=\"dlipp-cont-inner\">\n        <div class=\"dlipp-cont-hd\">\n            <img src=\"/skin/images/tv.png\" alt=\"影片地址\">\n            <span>影片地址</span>\n        </div>\n        <div class=\"dlipp-cont-bd\">\n            <a class=\"dlipp-dl-btn j-wbdlbtn-dlipp\" href=\"https://pan.quark.cn/s/2b941bc45d86\" target=\"_blank\">\n                <img src=\"/skin/images/kk.png\" alt=\"夸克网盘\">\n                <span>夸克网盘</span>\n            </a>\n            <a class=\"dlipp-dl-btn j-wbdlbtn-dlipp\" href=\"https://pan.baidu.com/s/1E92Hy50UxJnTTrU3qD9jqQ?pwd=8888\" target=\"_blank\">\n                <img src=\"/skin/images/bd.png\" alt=\"百度网盘\">\n                <span>百度网盘</span>\n            </a>\n        </div>\n    </div>\n</div>\n\n提取要素:\n- 网盘链接: .dlipp-cont-bd a.dlipp-dl-btn 的 href 属性\n- 网盘类型: 从链接URL自动识别 (quark.cn, baidu.com 等)\n- 提取码: 从URL参数中提取 (如 ?pwd=8888)\n```\n\n## CSS选择器总结\n\n| 数据项 | 页面类型 | CSS选择器 | 提取方式 |\n|--------|----------|-----------|----------|\n| 搜索结果列表 | 搜索页 | `ul.erx-list li.item` | 遍历所有结果项 |\n| 标题 | 搜索页 | `.a a.main` | 文本内容 |\n| 详情页链接 | 搜索页 | `.a a.main` | href 属性 |\n| 发布时间 | 搜索页 | `.i span.time` | 文本内容 |\n| 详情页标题 | 详情页 | `h1` | 文本内容 |\n| 详情页时间 | 详情页 | `section.i span.time` | 文本内容 |\n| 浏览次数 | 详情页 | `section.i span.view` | 文本内容 |\n| 下载链接 | 详情页 | `.dlipp-cont-bd a.dlipp-dl-btn` | href 属性 |\n\n## 实现要点\n\n### 1. 网盘类型自动识别\n根据链接URL自动识别网盘类型：\n```\npan.quark.cn     → quark     (夸克网盘)\npan.baidu.com    → baidu     (百度网盘)\naliyundrive.com  → aliyun    (阿里云盘)\nalipan.com       → aliyun    (阿里云盘新域名)\ncloud.189.cn     → tianyi    (天翼云盘)\npan.xunlei.com   → xunlei    (迅雷网盘)\n115.com          → 115       (115网盘)\n123pan.com       → 123       (123网盘)\ncaiyun.139.com   → mobile    (移动云盘)\n```\n\n### 2. 提取码处理\n- 百度网盘: `?pwd=1234` 格式\n- 其他网盘: 一般无需提取码或在URL中已包含\n\n### 3. 标题清理\n- 保留分类标签如 `【动漫】`、`【国剧】` 等\n- 去除多余空格和特殊字符\n\n### 4. 时间格式处理\n- 原格式: `2025-05-26`\n- 需转换为标准时间对象\n\n### 5. 内容描述\n- 可以从标题中提取分类信息作为描述\n- 或使用固定描述如 \"剧透社影视资源\"\n\n## 支持的分类\n\n| 分类代码 | 中文名称 | 路径 | 说明 |\n|----------|----------|------|------|\n| dm | 动漫 | /dm/ | 动画、动漫作品 |\n| ju | 国剧 | /ju/ | 国产电视剧 |\n| dj | 短剧 | /dj/ | 短视频剧集 |\n| zy | 综艺 | /zy/ | 综艺节目 |\n| mv | 电影 | /mv/ | 电影作品 |\n| rh | 韩日 | /rh/ | 韩国、日本影视剧 |\n| ym | 英美 | /ym/ | 英美影视剧 |\n| wj | 外剧 | /wj/ | 其他外国影视剧 |\n| qt | 其他 | /qt/ | 其他类型内容 |\n\n## 错误处理\n\n1. **网络超时**: 设置合理的超时时间，实现重试机制\n2. **解析失败**: 对于解析失败的页面，记录日志但不中断流程\n3. **空结果**: 搜索无结果时返回空数组\n4. **链接失效**: 验证链接格式，过滤掉明显无效的链接\n\n## 反爬虫处理\n\n1. **请求头设置**: 使用标准浏览器User-Agent\n2. **请求频率**: 控制请求间隔，避免被封IP\n3. **错误重试**: 遇到403/429等状态码时适当延迟重试\n\n## 特殊说明\n\n1. **域名**: 网站可能使用多个域名或动态域名，需要灵活处理\n2. **编码**: 确保中文关键词正确URL编码\n3. **链接拼接**: 详情页链接为相对路径，需要拼接完整URL\n4. **缓存**: 建议缓存搜索结果，避免重复请求\n"
  },
  {
    "path": "plugin/jutoushe/jutoushe.go",
    "content": "package jutoushe\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\ntype JutoushePlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\nfunc init() {\n\tp := &JutoushePlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"jutoushe\", 1), \n\t}\n\tplugin.RegisterGlobalPlugin(p)\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *JutoushePlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果（推荐方法）\nfunc (p *JutoushePlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实现搜索逻辑\nfunc (p *JutoushePlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 1. 构建搜索URL\n\tbaseURL := \"https://1.star2.cn\"\n\tsearchURL := fmt.Sprintf(\"%s/search/?keyword=%s\", baseURL, url.QueryEscape(keyword))\n\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\t// 3. 创建请求对象\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\n\t// 4. 设置请求头，避免反爬虫检测\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", baseURL+\"/\")\n\n\t// 5. 发送HTTP请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// 6. 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\t// 7. 解析搜索结果页面\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] HTML解析失败: %w\", p.Name(), err)\n\t}\n\n\t// 8. 提取搜索结果\n\tvar results []model.SearchResult\n\tdoc.Find(\"ul.erx-list li.item\").Each(func(i int, s *goquery.Selection) {\n\t\t// 提取标题和链接\n\t\tlinkElem := s.Find(\".a a.main\")\n\t\ttitle := strings.TrimSpace(linkElem.Text())\n\t\tdetailPath, exists := linkElem.Attr(\"href\")\n\t\t\n\t\tif !exists || title == \"\" {\n\t\t\treturn // 跳过无效项\n\t\t}\n\n\t\t// 构建完整的详情页URL\n\t\tdetailURL := baseURL + detailPath\n\n\t\t// 提取发布时间\n\t\ttimeStr := strings.TrimSpace(s.Find(\".i span.time\").Text())\n\t\tpublishTime := p.parseDate(timeStr)\n\n\t\t// 构建唯一ID\n\t\tuniqueID := fmt.Sprintf(\"%s-%s\", p.Name(), p.extractIDFromURL(detailPath))\n\n\t\t// 创建搜索结果（先不获取下载链接）\n\t\tresult := model.SearchResult{\n\t\t\tUniqueID:  uniqueID,\n\t\t\tTitle:     title,\n\t\t\tContent:   fmt.Sprintf(\"剧透社影视资源：%s\", title),\n\t\t\tDatetime:  publishTime,\n\t\t\tTags:      p.extractTags(title),\n\t\t\tLinks:     []model.Link{}, // 稍后从详情页获取\n\t\t\tChannel:   \"\",             // 插件搜索结果必须为空字符串\n\t\t}\n\n\t\t// 异步获取详情页的下载链接\n\t\tif links := p.getDetailLinks(client, detailURL); len(links) > 0 {\n\t\t\tresult.Links = links\n\t\t\tresults = append(results, result)\n\t\t}\n\t})\n\n\t// 9. 关键词过滤\n\tfilteredResults := plugin.FilterResultsByKeyword(results, keyword)\n\t\n\treturn filteredResults, nil\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *JutoushePlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n\n// getDetailLinks 获取详情页的下载链接\nfunc (p *JutoushePlugin) getDetailLinks(client *http.Client, detailURL string) []model.Link {\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Referer\", \"https://1.star2.cn/\")\n\n\tresp, err := client.Do(req)\n\tif err != nil || resp.StatusCode != 200 {\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\tvar links []model.Link\n\n\t// 提取下载链接\n\tdoc.Find(\".dlipp-cont-bd a.dlipp-dl-btn\").Each(func(i int, s *goquery.Selection) {\n\t\thref, exists := s.Attr(\"href\")\n\t\tif !exists || href == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\t// 过滤掉无效链接\n\t\tif !p.isValidNetworkDriveURL(href) {\n\t\t\treturn\n\t\t}\n\n\t\t// 确定网盘类型和提取提取码\n\t\tcloudType := p.determineCloudType(href)\n\t\tpassword := p.extractPassword(href)\n\n\t\tlink := model.Link{\n\t\t\tType:     cloudType,\n\t\t\tURL:      href,\n\t\t\tPassword: password,\n\t\t}\n\n\t\tlinks = append(links, link)\n\t})\n\n\treturn links\n}\n\n// determineCloudType 根据URL确定网盘类型\nfunc (p *JutoushePlugin) determineCloudType(url string) string {\n\tswitch {\n\tcase strings.Contains(url, \"pan.quark.cn\"):\n\t\treturn \"quark\"\n\tcase strings.Contains(url, \"drive.uc.cn\"):\n\t\treturn \"uc\"\n\tcase strings.Contains(url, \"pan.baidu.com\"):\n\t\treturn \"baidu\"\n\tcase strings.Contains(url, \"aliyundrive.com\") || strings.Contains(url, \"alipan.com\"):\n\t\treturn \"aliyun\"\n\tcase strings.Contains(url, \"pan.xunlei.com\"):\n\t\treturn \"xunlei\"\n\tcase strings.Contains(url, \"cloud.189.cn\"):\n\t\treturn \"tianyi\"\n\tcase strings.Contains(url, \"115.com\"):\n\t\treturn \"115\"\n\tcase strings.Contains(url, \"123pan.com\"):\n\t\treturn \"123\"\n\tcase strings.Contains(url, \"caiyun.139.com\"):\n\t\treturn \"mobile\"\n\tcase strings.Contains(url, \"mypikpak.com\"):\n\t\treturn \"pikpak\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n// extractPassword 从URL中提取提取码\nfunc (p *JutoushePlugin) extractPassword(url string) string {\n\t// 处理百度网盘的pwd参数\n\tif strings.Contains(url, \"pan.baidu.com\") && strings.Contains(url, \"pwd=\") {\n\t\tre := regexp.MustCompile(`pwd=([^&]+)`)\n\t\tmatches := re.FindStringSubmatch(url)\n\t\tif len(matches) > 1 {\n\t\t\treturn matches[1]\n\t\t}\n\t}\n\t\n\t// 其他网盘暂不处理提取码\n\treturn \"\"\n}\n\n// isValidNetworkDriveURL 验证是否为有效的网盘链接\nfunc (p *JutoushePlugin) isValidNetworkDriveURL(url string) bool {\n\tif url == \"\" {\n\t\treturn false\n\t}\n\n\t// 检查是否为HTTP/HTTPS链接\n\tif !strings.HasPrefix(url, \"http://\") && !strings.HasPrefix(url, \"https://\") {\n\t\treturn false\n\t}\n\n\t// 检查是否包含已知网盘域名\n\tknownDomains := []string{\n\t\t\"pan.quark.cn\", \"drive.uc.cn\", \"pan.baidu.com\", \n\t\t\"aliyundrive.com\", \"alipan.com\", \"pan.xunlei.com\",\n\t\t\"cloud.189.cn\", \"115.com\", \"123pan.com\", \n\t\t\"caiyun.139.com\", \"mypikpak.com\",\n\t}\n\n\tfor _, domain := range knownDomains {\n\t\tif strings.Contains(url, domain) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// extractIDFromURL 从URL路径中提取ID\nfunc (p *JutoushePlugin) extractIDFromURL(urlPath string) string {\n\t// 从 /dm/8100.html 提取 8100\n\tre := regexp.MustCompile(`/([^/]+)/(\\d+)\\.html`)\n\tmatches := re.FindStringSubmatch(urlPath)\n\tif len(matches) > 2 {\n\t\treturn matches[2]\n\t}\n\t\n\t// 如果无法提取，使用完整路径作为ID\n\treturn strings.ReplaceAll(urlPath, \"/\", \"_\")\n}\n\n// extractTags 从标题中提取标签\nfunc (p *JutoushePlugin) extractTags(title string) []string {\n\tvar tags []string\n\t\n\t// 提取分类标签\n\tcategoryPattern := regexp.MustCompile(`【([^】]+)】`)\n\tmatches := categoryPattern.FindAllStringSubmatch(title, -1)\n\tfor _, match := range matches {\n\t\tif len(match) > 1 {\n\t\t\ttags = append(tags, match[1])\n\t\t}\n\t}\n\t\n\t// 如果没有提取到分类，添加默认标签\n\tif len(tags) == 0 {\n\t\ttags = append(tags, \"影视资源\")\n\t}\n\t\n\treturn tags\n}\n\n// parseDate 解析日期字符串\nfunc (p *JutoushePlugin) parseDate(dateStr string) time.Time {\n\tif dateStr == \"\" {\n\t\treturn time.Now()\n\t}\n\n\t// 尝试解析 YYYY-MM-DD 格式\n\tif t, err := time.Parse(\"2006-01-02\", dateStr); err == nil {\n\t\treturn t\n\t}\n\n\t// 尝试解析 YYYY年MM月DD日 格式\n\tre := regexp.MustCompile(`(\\d{4})年(\\d{1,2})月(\\d{1,2})日`)\n\tmatches := re.FindStringSubmatch(dateStr)\n\tif len(matches) == 4 {\n\t\tyear, _ := strconv.Atoi(matches[1])\n\t\tmonth, _ := strconv.Atoi(matches[2])\n\t\tday, _ := strconv.Atoi(matches[3])\n\t\treturn time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.Local)\n\t}\n\n\t// 解析失败，返回当前时间\n\treturn time.Now()\n}\n"
  },
  {
    "path": "plugin/kkmao/html结构分析.md",
    "content": "# kkmao (夸克猫) HTML结构分析\n\n## 网站信息\n- **网站名称**: 夸克猫资源\n- **域名**: `www.kuakemao.com`\n- **类型**: 夸克网盘影视资源分享站（WordPress 主题站）\n- **特点**: 每篇文章提供 1~N 个夸克网盘链接，正文结构高度统一，仅包含夸克网盘\n\n## 搜索页结构\n\n### 1. 搜索入口\n```\nhttps://www.kuakemao.com/?s={关键词}\n\n示例:\nhttps://www.kuakemao.com/?s=物\n```\n- 直接使用 UTF-8 中文或 URL 编码均可\n- 页面为标准 WordPress 搜索结果页\n\n### 2. 结果容器\n- **父容器**: `section.container > div.content-wrap > div.content`\n- **结果项**: `article.excerpt`（会附带 `excerpt-1/2` 等序号类名）\n\n### 3. 单个结果结构\n\n#### 封面/详情链接\n```html\n<a class=\"focus\" href=\"https://www.kuakemao.com/653.html\">\n    <img data-src=\"https://img.kuakemao.com/.../c4ac4195bed96c7-220x150.webp\" class=\"thumb\">\n</a>\n```\n- `href` 即详情页地址，形如 `/数字.html`\n\n#### 标题\n```html\n<header>\n  <h2>\n    <a href=\"https://www.kuakemao.com/653.html\"\n       title=\"某种物质 (2024) 夸克网盘 法国 恐怖 4K 豆瓣7.5 - 夸克猫资源\">\n       某种物质 (2024) 夸克网盘 法国 恐怖 4K 豆瓣7.5\n    </a>\n  </h2>\n</header>\n```\n- 提取要素:\n  - **标题**: `h2 > a` 文本\n  - **详情页 URL**: `h2 > a` 的 `href`\n\n#### 简介\n```html\n<p class=\"note\">\n  某种物质 夸克网盘资源 https://pan.quark.cn/s/631243a6189a ... \n</p>\n```\n- 用于填充 `SearchResult.Content`\n- 文本中偶尔包含裸露的夸克链接，但仍需访问详情页获取规范链接\n\n#### 元数据\n```html\n<div class=\"meta\">\n  <time>2025-11-26</time>\n  <a class=\"cat\" href=\"https://www.kuakemao.com/dy\">电影</a>\n  <span class=\"pv\">阅读(...)</span>\n</div>\n```\n- **发布时间**: `<time>` 文本（`YYYY-MM-DD`）\n- **分类标签**: `.meta a.cat` 文本\n\n## 详情页结构\n\n### 1. URL 规则\n```\nhttps://www.kuakemao.com/{文章ID}.html\n示例: https://www.kuakemao.com/653.html\n```\n- 文章 ID 可由 `/{id}.html` 提取，用于唯一 ID\n\n### 2. 主要节点\n- **标题**: `.article-title`\n- **元信息**: `.article-meta .item`（日期、分类、阅读数等）\n- **正文容器**: `.article-content`\n\n### 3. 夸克链接位置\n```html\n<div class=\"article-content\">\n  <h2>某种物质 夸克网盘资源</h2>\n  <p>\n    <a rel=\"nofollow\" href=\"https://pan.quark.cn/s/631243a6189a\" target=\"_blank\">\n      https://pan.quark.cn/s/631243a6189a\n    </a>\n  </p>\n  ...\n</div>\n```\n- 所有下载链接位于 `.article-content` 中\n- 仅出现夸克域名 (`pan.quark.cn`)\n- 提取码通常在链接同一段落后续文字，需解析 `提取码/密码/pwd/code` 关键词\n\n## CSS 选择器速查表\n\n| 数据项 | 选择器 / 规则 | 备注 |\n|--------|---------------|------|\n| 结果列表 | `article.excerpt` | 遍历搜索结果 |\n| 标题 | `article.excerpt h2 a` | 文本 & `href` |\n| 简介 | `article.excerpt p.note` | 文本描述 |\n| 分类 | `article.excerpt .meta a.cat` | 可能 0/1 个 |\n| 发布时间 | `article.excerpt .meta time` | `YYYY-MM-DD` |\n| 详情正文 | `.article-content` | 包含所有下载信息 |\n| 夸克链接 | `.article-content a[href*=\"pan.quark.cn\"]` | href 即下载地址 |\n| 提取码 | 链接文本 / 父节点文本 | 关键词：`提取码/密码/pwd/code` |\n\n## 实现要点\n\n1. **请求策略**\n   - 搜索页：`GET https://www.kuakemao.com/?s=关键词`\n   - 设置常规浏览器 UA、Referer，必要时加入重试\n2. **列表解析**\n   - 遍历 `article.excerpt`，提取标题、摘要、分类、时间\n   - 由详情 URL 提取 `articleID` 作为唯一后缀\n3. **详情页抓取**\n   - 进入 `.article-content`，收集 `a[href*=\"pan.quark.cn\"]`\n   - 一篇可能提供多条夸克链接，需要全部返回\n   - 通过父节点/兄弟文本匹配提取码\n4. **链接过滤**\n   - 本站只提供夸克网盘，其他域名全部忽略\n5. **结果构建**\n   - `UniqueID = kkmao-{articleID}`\n   - `Channel` 置空\n   - `Datetime` 使用搜索结果页的 `<time>`（格式 `2006-01-02`）\n   - `Links` 仅包含 `Type=\"quark\"` 的条目\n\n## 示例流程\n```\n关键词: 物\n↓\n搜索页: https://www.kuakemao.com/?s=物\n  - 解析 article.excerpt\n  - 取得标题「某种物质 (2024)...」、详情链接 https://www.kuakemao.com/653.html\n↓\n详情页: https://www.kuakemao.com/653.html\n  - 在 .article-content 中找到 <a href=\"https://pan.quark.cn/s/631243a6189a\">\n↓\n结果:\n  UniqueID: kkmao-653\n  Title: 某种物质 (2024) 夸克网盘 法国 恐怖 4K 豆瓣7.5\n  Content: 搜索结果页的摘要\n  Links: [{Type:\"quark\", URL:\"https://pan.quark.cn/s/631243a6189a\", Password:\"\"}]\n  Tags: [\"电影\"]\n  Datetime: 2025-11-26\n```\n\n## 注意事项\n1. 搜索页的 `<time>` 可能缺失，需兜底为当前时间\n2. `.note` 中的裸露链接可忽略，以详情页数据为准\n3. 页面加载较快，但仍建议设置 10~12 秒超时与 2~3 次重试\n4. 站点仅有夸克网盘，插件实现时可直接过滤其它域名\n5. 文章正文含大量 `<h2>` 与 `<pre>`，解析提取码时需遍历父节点文本，避免遗漏\n\n"
  },
  {
    "path": "plugin/kkmao/kkmao.go",
    "content": "package kkmao\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nvar (\n\tarticleIDRegex = regexp.MustCompile(`/(\\d+)\\.html`)\n\tquarkRegex     = regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9A-Za-z]+`)\n\tpwdPatterns    = []*regexp.Regexp{\n\t\tregexp.MustCompile(`提取码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`密码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`pwd\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`code\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t}\n\tdetailCache = sync.Map{}\n\n\tcacheTTL             = 1 * time.Hour\n\tcacheCleanupInterval = 30 * time.Minute\n)\n\ntype detailCacheEntry struct {\n\tlinks     []model.Link\n\texpiresAt time.Time\n}\n\nconst (\n\tpluginName            = \"kkmao\"\n\tdefaultPriority       = 2\n\tsearchTimeout         = 12 * time.Second\n\tdetailTimeout         = 10 * time.Second\n\tmaxConcurrency        = 8\n\tmaxIdleConns          = 64\n\tmaxIdlePerHost        = 8\n\tmaxConnsPerHost       = 32\n\tidleConnLifetime      = 90 * time.Second\n\ttlsHandshakeTimeout   = 10 * time.Second\n\texpectContinueTimeout = 1 * time.Second\n\n\tsearchMaxRetries = 3\n\tdetailMaxRetries = 2\n\tretryBaseDelay   = 200 * time.Millisecond\n)\n\n// KkMaoPlugin 夸克猫插件\ntype KkMaoPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tclient *http.Client\n}\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewKkMaoPlugin())\n\tgo startDetailCacheCleaner()\n}\n\n// NewKkMaoPlugin 构造函数\nfunc NewKkMaoPlugin() *KkMaoPlugin {\n\treturn &KkMaoPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),\n\t\tclient:          newHTTPClient(),\n\t}\n}\n\n// Search 兼容方法\nfunc (p *KkMaoPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 主搜索实现\nfunc (p *KkMaoPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\nfunc newHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:          maxIdleConns,\n\t\tMaxIdleConnsPerHost:   maxIdlePerHost,\n\t\tMaxConnsPerHost:       maxConnsPerHost,\n\t\tIdleConnTimeout:       idleConnLifetime,\n\t\tTLSHandshakeTimeout:   tlsHandshakeTimeout,\n\t\tExpectContinueTimeout: expectContinueTimeout,\n\t\tForceAttemptHTTP2:     true,\n\t}\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   searchTimeout,\n\t}\n}\n\nfunc (p *KkMaoPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif p.client != nil {\n\t\tclient = p.client\n\t}\n\n\tsearchURL := fmt.Sprintf(\"https://www.kuakemao.com/?s=%s\", url.QueryEscape(keyword))\n\tctx, cancel := context.WithTimeout(context.Background(), searchTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\n\tsetCommonHeaders(req, \"https://www.kuakemao.com/\")\n\n\tresp, err := p.doRequestWithRetry(req, client, searchMaxRetries, retryBaseDelay)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\n\tvar (\n\t\tresults []model.SearchResult\n\t\twg      sync.WaitGroup\n\t\tmu      sync.Mutex\n\t\tsem     = make(chan struct{}, maxConcurrency)\n\t)\n\n\tdoc.Find(\"article.excerpt\").Each(func(_ int, item *goquery.Selection) {\n\t\ttitleSel := item.Find(\"header h2 a\")\n\t\ttitle := strings.TrimSpace(titleSel.Text())\n\t\tdetailURL, ok := titleSel.Attr(\"href\")\n\t\tif !ok || title == \"\" || detailURL == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tarticleID := extractArticleID(detailURL)\n\t\tif articleID == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tsummary := strings.TrimSpace(item.Find(\"p.note\").Text())\n\n\t\tvar tags []string\n\t\tcategory := strings.TrimSpace(item.Find(\".meta a.cat\").First().Text())\n\t\tif category != \"\" {\n\t\t\ttags = append(tags, category)\n\t\t}\n\n\t\trawTime := strings.TrimSpace(item.Find(\".meta time\").Text())\n\t\tpublishTime := parsePublishTime(rawTime)\n\n\t\twg.Add(1)\n\t\tsem <- struct{}{}\n\t\tgo func(title, detailURL, articleID, summary string, tags []string, publishTime time.Time) {\n\t\t\tdefer wg.Done()\n\t\t\tdefer func() { <-sem }()\n\n\t\t\tlinks := p.fetchDetailLinks(client, detailURL, articleID)\n\t\t\tif len(links) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tresult := model.SearchResult{\n\t\t\t\tUniqueID: fmt.Sprintf(\"%s-%s\", p.Name(), articleID),\n\t\t\t\tTitle:    title,\n\t\t\t\tContent:  summary,\n\t\t\t\tLinks:    links,\n\t\t\t\tTags:     tags,\n\t\t\t\tChannel:  \"\",\n\t\t\t\tDatetime: publishTime,\n\t\t\t}\n\n\t\t\tmu.Lock()\n\t\t\tresults = append(results, result)\n\t\t\tmu.Unlock()\n\t\t}(title, detailURL, articleID, summary, tags, publishTime)\n\t})\n\n\twg.Wait()\n\n\treturn plugin.FilterResultsByKeyword(results, keyword), nil\n}\n\nfunc extractArticleID(detailURL string) string {\n\tmatches := articleIDRegex.FindStringSubmatch(detailURL)\n\tif len(matches) >= 2 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\nfunc parsePublishTime(value string) time.Time {\n\tvalue = strings.TrimSpace(value)\n\tif value == \"\" {\n\t\treturn time.Now()\n\t}\n\n\tlayouts := []string{\n\t\t\"2006-01-02\",\n\t\t\"2006-01-02 15:04:05\",\n\t\ttime.RFC3339,\n\t}\n\n\tfor _, layout := range layouts {\n\t\tif t, err := time.Parse(layout, value); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\n\treturn time.Now()\n}\n\nfunc (p *KkMaoPlugin) fetchDetailLinks(client *http.Client, detailURL, articleID string) []model.Link {\n\tif cached, ok := detailCache.Load(articleID); ok {\n\t\tif entry, valid := cached.(detailCacheEntry); valid {\n\t\t\tif time.Now().Before(entry.expiresAt) && len(entry.links) > 0 {\n\t\t\t\treturn entry.links\n\t\t\t}\n\t\t\tdetailCache.Delete(articleID)\n\t\t}\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), detailTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tsetCommonHeaders(req, detailURL)\n\n\tresp, err := p.doRequestWithRetry(req, client, detailMaxRetries, retryBaseDelay)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\tlinks := extractQuarkLinks(doc)\n\tif len(links) > 0 {\n\t\tdetailCache.Store(articleID, detailCacheEntry{\n\t\t\tlinks:     links,\n\t\t\texpiresAt: time.Now().Add(cacheTTL),\n\t\t})\n\t}\n\treturn links\n}\n\nfunc extractQuarkLinks(doc *goquery.Document) []model.Link {\n\tvar (\n\t\tresults []model.Link\n\t\tseen    = make(map[string]struct{})\n\t)\n\n\tdoc.Find(\".article-content a[href]\").Each(func(_ int, link *goquery.Selection) {\n\t\thref, _ := link.Attr(\"href\")\n\t\thref = strings.TrimSpace(href)\n\t\tif href == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tloc := quarkRegex.FindString(href)\n\t\tif loc == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tif _, exists := seen[loc]; exists {\n\t\t\treturn\n\t\t}\n\n\t\tpassword := extractPassword(link)\n\n\t\tresults = append(results, model.Link{\n\t\t\tType:     \"quark\",\n\t\t\tURL:      loc,\n\t\t\tPassword: password,\n\t\t})\n\t\tseen[loc] = struct{}{}\n\t})\n\n\treturn results\n}\n\nfunc extractPassword(link *goquery.Selection) string {\n\tif pwd := matchPassword(link.Text()); pwd != \"\" {\n\t\treturn pwd\n\t}\n\n\tif title, ok := link.Attr(\"title\"); ok {\n\t\tif pwd := matchPassword(title); pwd != \"\" {\n\t\t\treturn pwd\n\t\t}\n\t}\n\n\tif parent := link.Parent(); parent != nil && parent.Length() > 0 {\n\t\tif pwd := matchPassword(parent.Text()); pwd != \"\" {\n\t\t\treturn pwd\n\t\t}\n\t\tif next := parent.Next(); next.Length() > 0 {\n\t\t\tif pwd := matchPassword(next.Text()); pwd != \"\" {\n\t\t\t\treturn pwd\n\t\t\t}\n\t\t}\n\t}\n\n\tif sibling := link.Next(); sibling.Length() > 0 {\n\t\tif pwd := matchPassword(sibling.Text()); pwd != \"\" {\n\t\t\treturn pwd\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\nfunc matchPassword(text string) string {\n\ttext = strings.TrimSpace(text)\n\tif text == \"\" {\n\t\treturn \"\"\n\t}\n\n\tfor _, pattern := range pwdPatterns {\n\t\tif matches := pattern.FindStringSubmatch(text); len(matches) >= 2 {\n\t\t\treturn strings.TrimSpace(matches[1])\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\nfunc setCommonHeaders(req *http.Request, referer string) {\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", referer)\n}\n\nfunc (p *KkMaoPlugin) doRequestWithRetry(req *http.Request, client *http.Client, maxRetries int, baseDelay time.Duration) (*http.Response, error) {\n\tvar lastErr error\n\n\tfor attempt := 0; attempt < maxRetries; attempt++ {\n\t\tresp, err := client.Do(req.Clone(req.Context()))\n\t\tif err == nil && resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t\tif attempt < maxRetries-1 {\n\t\t\tbackoff := baseDelay * time.Duration(1<<attempt)\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"重试 %d 次后失败: %w\", maxRetries, lastErr)\n}\n\nfunc startDetailCacheCleaner() {\n\tticker := time.NewTicker(cacheCleanupInterval)\n\tdefer ticker.Stop()\n\n\tfor range ticker.C {\n\t\tnow := time.Now()\n\t\tdetailCache.Range(func(key, value interface{}) bool {\n\t\t\tentry, ok := value.(detailCacheEntry)\n\t\t\tif !ok || now.After(entry.expiresAt) {\n\t\t\t\tdetailCache.Delete(key)\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "plugin/kkv/html结构分析.md",
    "content": "# KKV (小悠家) HTML结构分析\n\n## 网站信息\n- 网站名称: 小悠家\n- 域名: kkv.q-23.cn\n- 类型: 影视资源搜索（支持多种网盘）\n\n## 1. 搜索页面\n\n### URL格式\n```\nhttp://kkv.q-23.cn/?s={keyword}\n```\n\n### HTML结构\n- 容器: `article.post` (多个article元素)\n  - ID格式: `id=\"post-{id}\"` (如 `id=\"post-72474\"`)\n  - Class: `post-{id} post type-post status-publish format-standard hentry category-{category}`\n  \n- 每个搜索结果包含:\n  - **标题**: `.entry-header h2.entry-title a`\n    - href: `http://kkv.q-23.cn/?p={id}`\n    - text: 影片标题\n  - **发布时间**: `.entry-meta time.entry-date`\n    - datetime属性: ISO格式时间\n  - **更新时间**: `.entry-meta time.entry-modified-date.updated`\n    - datetime属性: ISO格式时间\n  - **简介**: `.entry-summary` 或 `.entry-summary p`\n\n### 提取信息\n- 影片ID: 从href提取 `?p=(\\d+)`\n- 影片标题: 从 `h2.entry-title a` 获取\n- 更新时间: 从 `time.updated` 的datetime属性获取\n\n## 2. 详情页面\n\n### URL格式\n```\nhttp://kkv.q-23.cn/?p={id}\n```\n\n### HTML结构\n\n#### 基本信息\n- 标题: `.entry-header h1.entry-title`\n- 发布时间: `.entry-meta time.entry-date` (datetime属性)\n- 更新时间: `.entry-meta time.updated` (datetime属性)\n- 分类: `.entry-meta .categories-links a`\n\n#### 内容信息\n- 详细信息: `.entry-content p` (第一个p标签)\n  - 包含导演、编剧、主演等信息\n- 剧情简介: `.entry-content #link-report span`\n\n#### 网盘链接区域\n网盘链接在 `.entry-content` 中，位于 `<hr/>` 标签之后的区域\n\n**链接格式示例**:\n\n1. **迅雷云盘**:\n```html\n<p>\n    视频：<a href=\"https://pan.xunlei.com/s/VOeeCkzFwv09p0ERN-vV4vQ1A1?pwd=f26g#\">迅雷云盘</a>\n</p>\n```\n\n2. **百度网盘**:\n```html\n<p>\n    视频：<a href=\"https://pan.baidu.com/s/1NWbakSbG1rLZnM9x2KrSZA?pwd=1234\">百度网盘</a>\n    提取码：1234\n</p>\n```\n\n3. **其他网盘** (推测可能的格式):\n```html\n<p>\n    视频：<a href=\"https://pan.quark.cn/s/xxx\">夸克网盘</a>\n</p>\n<p>\n    视频：<a href=\"https://www.alipan.com/s/xxx\">阿里云盘</a>\n</p>\n```\n\n### 网盘链接提取规则\n\n1. **查找策略**: 遍历 `.entry-content` 下的所有 `<p>` 标签\n2. **匹配规则**: \n   - 查找包含 `<a>` 标签的段落\n   - 检查链接href是否包含网盘域名特征\n3. **密码提取**:\n   - 优先从URL的 `?pwd=` 参数提取\n   - 如果URL中没有，查找文本中的\"提取码：\"、\"密码：\"等关键词后面的内容\n   - 密码通常是4位字母或数字\n\n## 3. 支持的网盘类型\n\n根据插件开发指南，需要识别以下网盘类型：\n\n| 网盘名称 | 类型标识 | 域名特征 |\n|---------|---------|----------|\n| 夸克网盘 | `quark` | `pan.quark.cn` |\n| UC网盘 | `uc` | `drive.uc.cn` |\n| 百度网盘 | `baidu` | `pan.baidu.com` |\n| 阿里云盘 | `aliyun` | `aliyundrive.com`, `alipan.com` |\n| 迅雷网盘 | `xunlei` | `pan.xunlei.com` |\n| 天翼云盘 | `tianyi` | `cloud.189.cn` |\n| 115网盘 | `115` | `115.com`, `anxia.com` |\n| 123网盘 | `123` | `123pan.com`, `123684.com` 等 |\n| 移动云盘 | `mobile` | `caiyun.139.com` |\n| PikPak | `pikpak` | `mypikpak.com` |\n\n## 4. 插件实现要点\n\n### 搜索流程\n1. 构造搜索URL: `http://kkv.q-23.cn/?s={URLEncode(keyword)}`\n2. 发送GET请求，解析HTML\n3. 提取所有 `article.post` 元素\n4. 对每个结果提取：\n   - 影片ID (从 `?p=` 参数)\n   - 影片标题\n   - 详情页URL\n\n### 详情页处理\n1. 请求详情页\n2. 提取标题、更新时间、剧情简介\n3. 在 `.entry-content` 中查找所有包含网盘链接的段落\n4. 对每个链接：\n   - 识别网盘类型\n   - 提取URL\n   - 提取密码（URL参数或文本）\n\n### 密码提取策略\n```go\n// 1. 从URL参数提取\npwd := url.Query().Get(\"pwd\")\n\n// 2. 从文本中提取\npatterns := []string{\n    `提取码[：:]\\s*([a-zA-Z0-9]{4})`,\n    `密码[：:]\\s*([a-zA-Z0-9]{4})`,\n    `pwd[：:]\\s*([a-zA-Z0-9]{4})`,\n}\n\n// 3. 密码验证（必须是4位）\nif len(pwd) == 4 {\n    return pwd\n}\n```\n\n### 更新时间提取\n```go\n// 从datetime属性提取\ntimeStr := doc.Find(\"time.updated\").AttrOr(\"datetime\", \"\")\n// 格式: 2025-12-06T20:26:57+08:00\nt, _ := time.Parse(time.RFC3339, timeStr)\n```\n\n## 5. 特殊处理\n\n### 并发控制\n- 详情页并发数: 3-5个\n- 请求超时: 30秒\n\n### 错误处理\n- 网络请求失败 → 重试3次\n- HTML解析失败 → 跳过该项\n- 未找到网盘链接 → 跳过该影片\n- 密码提取失败 → 密码字段留空\n\n### 结果去重\n- UniqueID格式: `kkv-{影片ID}`\n- 同一影片包含所有找到的网盘链接\n\n## 6. SearchResult结构\n\n```go\nSearchResult{\n    UniqueID: \"kkv-30027\",\n    Title:    \"[凡人修仙传][更新至172集][动画]\",\n    Content:  \"导演: 伍镇焯 / 王裕仁 编剧: 忘语...\",\n    Links: []Link{\n        {Type: \"xunlei\", URL: \"https://pan.xunlei.com/s/xxx\", Password: \"f26g\"},\n        {Type: \"baidu\", URL: \"https://pan.baidu.com/s/xxx\", Password: \"1234\"},\n    },\n    Channel:  \"\",\n    Datetime: time.Parse(...),\n}\n```\n\n## 7. 优先级设置\n\n建议设置为优先级3（标准网盘搜索插件）\n\n## 8. 请求头设置\n\n```\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\nAccept-Language: zh-CN,zh;q=0.9,en;q=0.8\nReferer: http://kkv.q-23.cn/\n```\n"
  },
  {
    "path": "plugin/kkv/kkv.go",
    "content": "package kkv\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tbaseURL       = \"http://kkv.q-23.cn\"\n\tsearchPath    = \"/\"\n\tmaxResults    = 10\n\tmaxConcurrent = 3\n)\n\nvar debugMode = false\n\nfunc debugPrintf(format string, args ...interface{}) {\n\tif debugMode {\n\t\tfmt.Printf(\"[KKV DEBUG] \"+format, args...)\n\t}\n}\n\ntype KKVPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\nfunc init() {\n\tp := &KKVPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"kkv\", 3),\n\t}\n\tplugin.RegisterGlobalPlugin(p)\n}\n\nfunc (p *KKVPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\nfunc (p *KKVPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\nfunc (p *KKVPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tdebugPrintf(\"🔍 开始搜索 - keyword: %s\\n\", keyword)\n\tsearchURL := fmt.Sprintf(\"%s%s?s=%s\", baseURL, searchPath, url.QueryEscape(keyword))\n\tdebugPrintf(\"📝 搜索URL: %s\\n\", searchURL)\n\t\n\titems, err := p.fetchSearchResults(searchURL, client)\n\tif err != nil {\n\t\tdebugPrintf(\"❌ 获取搜索结果失败: %v\\n\", err)\n\t\treturn nil, err\n\t}\n\t\n\tdebugPrintf(\"✅ 获取到 %d 个搜索结果\\n\", len(items))\n\t\n\tif len(items) == 0 {\n\t\tdebugPrintf(\"⚠️ 没有搜索结果\\n\")\n\t\treturn []model.SearchResult{}, nil\n\t}\n\t\n\tfilteredItems := p.filterItemsByKeyword(items, keyword)\n\tdebugPrintf(\"🔎 标题过滤后剩余 %d 个结果（从 %d 个）\\n\", len(filteredItems), len(items))\n\t\n\tif len(filteredItems) == 0 {\n\t\tdebugPrintf(\"⚠️ 标题过滤后没有匹配的结果\\n\")\n\t\treturn []model.SearchResult{}, nil\n\t}\n\t\n\tif len(filteredItems) > maxResults {\n\t\tdebugPrintf(\"✂️ 限制结果数量从 %d 到 %d\\n\", len(filteredItems), maxResults)\n\t\tfilteredItems = filteredItems[:maxResults]\n\t}\n\t\n\tresults := p.processDetailPages(filteredItems, client)\n\tdebugPrintf(\"📊 处理完成，获得 %d 个有效结果\\n\", len(results))\n\t\n\treturn results, nil\n}\n\ntype searchItem struct {\n\tID        string\n\tTitle     string\n\tDetailURL string\n}\n\nfunc (p *KKVPlugin) filterItemsByKeyword(items []searchItem, keyword string) []searchItem {\n\tlowerKeyword := strings.ToLower(keyword)\n\tvar filtered []searchItem\n\t\n\tfor _, item := range items {\n\t\tlowerTitle := strings.ToLower(item.Title)\n\t\tif strings.Contains(lowerTitle, lowerKeyword) {\n\t\t\tdebugPrintf(\"✅ 标题匹配: %s\\n\", item.Title)\n\t\t\tfiltered = append(filtered, item)\n\t\t} else {\n\t\t\tdebugPrintf(\"❌ 标题不匹配，跳过: %s\\n\", item.Title)\n\t\t}\n\t}\n\t\n\treturn filtered\n}\n\nfunc (p *KKVPlugin) fetchSearchResults(searchURL string, client *http.Client) ([]searchItem, error) {\n\tdebugPrintf(\"🌐 请求搜索页面: %s\\n\", searchURL)\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\t\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\tp.setHeaders(req, baseURL)\n\t\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tdebugPrintf(\"📡 HTTP状态码: %d\\n\", resp.StatusCode)\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] HTML解析失败: %w\", p.Name(), err)\n\t}\n\t\n\tvar items []searchItem\n\tdoc.Find(\"article.post\").Each(func(i int, s *goquery.Selection) {\n\t\tlink := s.Find(\".entry-header h2.entry-title a\")\n\t\thref, exists := link.Attr(\"href\")\n\t\tif !exists {\n\t\t\tdebugPrintf(\"⚠️ 第%d个结果没有href属性\\n\", i+1)\n\t\t\treturn\n\t\t}\n\t\t\n\t\ttitle := strings.TrimSpace(link.Text())\n\t\tif title == \"\" {\n\t\t\tdebugPrintf(\"⚠️ 第%d个结果标题为空\\n\", i+1)\n\t\t\treturn\n\t\t}\n\t\t\n\t\tre := regexp.MustCompile(`\\?p=(\\d+)`)\n\t\tmatches := re.FindStringSubmatch(href)\n\t\tif len(matches) < 2 {\n\t\t\tdebugPrintf(\"⚠️ 无法从href提取ID: %s\\n\", href)\n\t\t\treturn\n\t\t}\n\t\t\n\t\titem := searchItem{\n\t\t\tID:        matches[1],\n\t\t\tTitle:     title,\n\t\t\tDetailURL: href,\n\t\t}\n\t\tdebugPrintf(\"📌 找到影片: ID=%s, Title=%s\\n\", item.ID, item.Title)\n\t\titems = append(items, item)\n\t})\n\t\n\tdebugPrintf(\"✅ 解析到 %d 个搜索项\\n\", len(items))\n\treturn items, nil\n}\n\nfunc (p *KKVPlugin) processDetailPages(items []searchItem, client *http.Client) []model.SearchResult {\n\tvar results []model.SearchResult\n\tvar mu sync.Mutex\n\tvar wg sync.WaitGroup\n\tsem := make(chan struct{}, maxConcurrent)\n\t\n\tfor _, item := range items {\n\t\twg.Add(1)\n\t\tgo func(it searchItem) {\n\t\t\tdefer wg.Done()\n\t\t\tsem <- struct{}{}\n\t\t\tdefer func() { <-sem }()\n\t\t\t\n\t\t\tresult := p.processDetailPage(it, client)\n\t\t\tif result != nil {\n\t\t\t\tmu.Lock()\n\t\t\t\tresults = append(results, *result)\n\t\t\t\tmu.Unlock()\n\t\t\t}\n\t\t}(item)\n\t}\n\t\n\twg.Wait()\n\treturn results\n}\n\nfunc (p *KKVPlugin) processDetailPage(item searchItem, client *http.Client) *model.SearchResult {\n\tdebugPrintf(\"🎬 处理详情页: %s (ID: %s)\\n\", item.Title, item.ID)\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\t\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", item.DetailURL, nil)\n\tif err != nil {\n\t\tdebugPrintf(\"❌ 创建请求失败: %v\\n\", err)\n\t\treturn nil\n\t}\n\t\n\tp.setHeaders(req, baseURL)\n\t\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\tdebugPrintf(\"❌ 详情页请求失败: %v\\n\", err)\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\tdebugPrintf(\"❌ 详情页状态码: %d\\n\", resp.StatusCode)\n\t\treturn nil\n\t}\n\t\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\tdebugPrintf(\"❌ HTML解析失败: %v\\n\", err)\n\t\treturn nil\n\t}\n\t\n\ttitle := strings.TrimSpace(doc.Find(\".entry-header h1.entry-title\").Text())\n\tif title == \"\" {\n\t\ttitle = item.Title\n\t}\n\tdebugPrintf(\"📝 影片标题: %s\\n\", title)\n\t\n\tvar description string\n\tdoc.Find(\".entry-content p\").First().Each(func(i int, s *goquery.Selection) {\n\t\tdescription = strings.TrimSpace(s.Text())\n\t\tif len(description) > 200 {\n\t\t\tdescription = description[:200] + \"...\"\n\t\t}\n\t})\n\t\n\tupdateTime := p.extractUpdateTime(doc)\n\tdebugPrintf(\"🕐 更新时间: %v\\n\", updateTime)\n\t\n\tpanLinks := p.extractPanLinks(doc)\n\tif len(panLinks) == 0 {\n\t\tdebugPrintf(\"❌ 未找到网盘链接\\n\")\n\t\treturn nil\n\t}\n\t\n\tdebugPrintf(\"✅ 找到 %d 个网盘链接\\n\", len(panLinks))\n\t\n\treturn &model.SearchResult{\n\t\tUniqueID: fmt.Sprintf(\"%s-%s\", p.Name(), item.ID),\n\t\tTitle:    title,\n\t\tContent:  description,\n\t\tLinks:    panLinks,\n\t\tChannel:  \"\",\n\t\tDatetime: updateTime,\n\t}\n}\n\nfunc (p *KKVPlugin) extractUpdateTime(doc *goquery.Document) time.Time {\n\ttimeStr, exists := doc.Find(\"time.updated\").Attr(\"datetime\")\n\tif !exists {\n\t\tdebugPrintf(\"⚠️ 未找到更新时间\\n\")\n\t\treturn time.Now()\n\t}\n\t\n\tdebugPrintf(\"🔍 提取到时间字符串: %s\\n\", timeStr)\n\t\n\tt, err := time.Parse(time.RFC3339, timeStr)\n\tif err != nil {\n\t\tdebugPrintf(\"❌ 时间解析失败: %v\\n\", err)\n\t\treturn time.Now()\n\t}\n\t\n\treturn t\n}\n\nfunc (p *KKVPlugin) extractPanLinks(doc *goquery.Document) []model.Link {\n\tdebugPrintf(\"🔎 开始提取网盘链接\\n\")\n\tvar links []model.Link\n\t\n\tdoc.Find(\".entry-content p\").Each(func(i int, s *goquery.Selection) {\n\t\ts.Find(\"a\").Each(func(j int, a *goquery.Selection) {\n\t\t\thref, exists := a.Attr(\"href\")\n\t\t\tif !exists {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\thref = strings.TrimSpace(href)\n\t\t\tcloudType := p.determinePanType(href)\n\t\t\tif cloudType == \"\" {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\tdebugPrintf(\"🔗 找到%s链接: %s\\n\", cloudType, href)\n\t\t\t\n\t\t\tpassword := p.extractPassword(href, s.Text())\n\t\t\tdebugPrintf(\"🔑 密码: %s\\n\", password)\n\t\t\t\n\t\t\tlinks = append(links, model.Link{\n\t\t\t\tType:     cloudType,\n\t\t\t\tURL:      href,\n\t\t\t\tPassword: password,\n\t\t\t})\n\t\t})\n\t})\n\t\n\tdebugPrintf(\"✅ 共提取到 %d 个网盘链接\\n\", len(links))\n\treturn links\n}\n\nfunc (p *KKVPlugin) determinePanType(panURL string) string {\n\tlower := strings.ToLower(panURL)\n\t\n\tswitch {\n\tcase strings.Contains(lower, \"pan.baidu.com\"):\n\t\treturn \"baidu\"\n\tcase strings.Contains(lower, \"pan.quark.cn\"):\n\t\treturn \"quark\"\n\tcase strings.Contains(lower, \"drive.uc.cn\"):\n\t\treturn \"uc\"\n\tcase strings.Contains(lower, \"pan.xunlei.com\"):\n\t\treturn \"xunlei\"\n\tcase strings.Contains(lower, \"aliyundrive.com\"), strings.Contains(lower, \"alipan.com\"):\n\t\treturn \"aliyun\"\n\tcase strings.Contains(lower, \"cloud.189.cn\"):\n\t\treturn \"tianyi\"\n\tcase strings.Contains(lower, \"115.com\"), strings.Contains(lower, \"115cdn.com\"), strings.Contains(lower, \"anxia.com\"):\n\t\treturn \"115\"\n\tcase strings.Contains(lower, \"123684.com\"), strings.Contains(lower, \"123685.com\"),\n\t\tstrings.Contains(lower, \"123912.com\"), strings.Contains(lower, \"123pan.com\"),\n\t\tstrings.Contains(lower, \"123pan.cn\"), strings.Contains(lower, \"123592.com\"):\n\t\treturn \"123\"\n\tcase strings.Contains(lower, \"caiyun.139.com\"):\n\t\treturn \"mobile\"\n\tcase strings.Contains(lower, \"mypikpak.com\"):\n\t\treturn \"pikpak\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc (p *KKVPlugin) extractPassword(panURL, contextText string) string {\n\tparsed, err := url.Parse(panURL)\n\tif err == nil {\n\t\tpwd := parsed.Query().Get(\"pwd\")\n\t\tif pwd != \"\" && len(pwd) == 4 {\n\t\t\treturn pwd\n\t\t}\n\t}\n\t\n\tpwdPatterns := []*regexp.Regexp{\n\t\tregexp.MustCompile(`提取码[：:]\\s*([a-zA-Z0-9]{4})`),\n\t\tregexp.MustCompile(`密码[：:]\\s*([a-zA-Z0-9]{4})`),\n\t\tregexp.MustCompile(`pwd[：:]\\s*([a-zA-Z0-9]{4})`),\n\t}\n\t\n\tfor _, pattern := range pwdPatterns {\n\t\tif matches := pattern.FindStringSubmatch(contextText); len(matches) > 1 {\n\t\t\treturn matches[1]\n\t\t}\n\t}\n\t\n\treturn \"\"\n}\n\nfunc (p *KKVPlugin) setHeaders(req *http.Request, referer string) {\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", referer)\n}\n\nfunc (p *KKVPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\treqClone := req.Clone(req.Context())\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n"
  },
  {
    "path": "plugin/labi/html结构分析.md",
    "content": "# Labi网站 (xiaocge.fun/duopan.fun) 搜索结果HTML结构分析\n\n## 网站信息\n\n- **网站名称**: 免费的云盘分享平台\n- **搜索URL格式**: `http://xiaocge.fun/index.php/vod/search/wd/{关键词}.html`\n- **详情页URL格式**: `http://xiaocge.fun/index.php/vod/detail/id/{ID}.html`\n- **主要特点**: 蜡笔系列网盘资源站，提供4K高清影视资源\n\n## 搜索结果页面结构\n\n搜索结果页面的主要内容位于`.module .module-list .module-items`元素内，每个搜索结果项包含在`.module-search-item`元素中。\n\n```html\n<div class=\"module\">\n    <div class=\"module-list\">\n        <div class=\"module-items\">\n            <div class=\"module-search-item\">\n                <!-- 单个搜索结果 -->\n            </div>\n            <div class=\"module-search-item\">\n                <!-- 单个搜索结果 -->\n            </div>\n        </div>\n    </div>\n</div>\n```\n\n### 单个搜索结果结构\n\n每个搜索结果包含以下主要元素：\n\n#### 1. 封面图片和详情页链接\n\n封面图片和播放按钮位于`.video-cover .module-item-cover .module-item-pic`元素中：\n\n```html\n<div class=\"video-cover\">\n    <div class=\"module-item-cover\">\n        <div class=\"module-item-pic\">\n            <a href=\"/index.php/vod/detail/id/11277.html\" title=\"立刻播放折腰(臻彩)\">\n                <i class=\"icon-play\"></i>\n            </a>\n            <img class=\"lazy lazyload\" data-src=\"https://wsrv.nl/?url=https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2921477016.jpg\" src=\"/template/DYXS2/static/picture/loading.png\" alt=\"折腰(臻彩)\">\n        </div>\n    </div>\n</div>\n```\n\n#### 2. 详情页链接和ID\n\n详情页链接在多个位置出现，格式为`/index.php/vod/detail/id/{ID}.html`，其中`{ID}`是资源的唯一标识符（如`11277`）。\n\n#### 3. 标题和资源类型\n\n标题位于`.video-info-header`元素中：\n\n```html\n<div class=\"video-info-header\">\n    <a class=\"video-serial\" href=\"/index.php/vod/detail/id/11277.html\" title=\"折腰(臻彩)\">4K HDR 60帧</a>\n    <h3><a href=\"/index.php/vod/detail/id/11277.html\" title=\"折腰(臻彩)\">折腰(臻彩)</a></h3>\n    <div class=\"video-info-aux\">\n        <a href=\"/index.php/vod/type/id/29.html\" title=\"蜡笔臻彩\" class=\"tag-link\">\n            <span class=\"video-tag-icon\">蜡笔臻彩</span>\n        </a>\n        <div class=\"tag-link\"><a href=\"/index.php/vod/search/year/2025.html\" target=\"_blank\">2025</a></div>\n        <div class=\"tag-link\"><a href=\"/index.php/vod/search/area/%E4%B8%AD%E5%9B%BD%E5%A4%A7%E9%99%86.html\" target=\"_blank\">中国大陆</a></div>\n    </div>\n</div>\n```\n\n- 资源类型/质量信息在`.video-serial`元素中（如\"4K HDR 60帧\"、\"第36集完结\"等）\n- 主标题在`h3 a`标签中\n- 分类、年代、地区信息在`.video-info-aux`中\n\n#### 4. 导演和主演信息\n\n导演和主演信息位于`.video-info-main`元素中：\n\n```html\n<div class=\"video-info-main\">\n    <div class=\"video-info-items\">\n        <span class=\"video-info-itemtitle\">导演：</span>\n        <div class=\"video-info-item video-info-actor\">\n            <span class=\"slash\">/</span>\n            <a href=\"/index.php/vod/search/director/%E9%82%93%E7%A7%91.html\" target=\"_blank\">邓科</a>\n            <span class=\"slash\">/</span>\n        </div>\n    </div>\n    <div class=\"video-info-items\">\n        <span class=\"video-info-itemtitle\">主演：</span>\n        <div class=\"video-info-item video-info-actor\">\n            <span class=\"slash\">/</span>\n            <a href=\"/index.php/vod/search/actor/%E5%AE%8B%E7%A5%96%E5%84%BF.html\" target=\"_blank\">宋祖儿</a>\n            <span class=\"slash\">/</span>\n            <a href=\"/index.php/vod/search/actor/%E5%88%98%E5%AE%87%E5%AE%81.html\" target=\"_blank\">刘宇宁</a>\n            <!-- 更多演员... -->\n        </div>\n    </div>\n    <div class=\"video-info-items\">\n        <span class=\"video-info-itemtitle\">剧情：</span>\n        <div class=\"video-info-item\">小乔（宋祖儿 饰）祖父曾因阵前撤兵致魏氏祖孙被害...</div>\n    </div>\n</div>\n```\n\n#### 5. 操作按钮\n\n操作按钮位于`.video-info-footer`元素中：\n\n```html\n<div class=\"video-info-footer\">\n    <a href=\"/index.php/vod/detail/id/11277.html\" class=\"btn-important btn-base\" title=\"立刻播放折腰(臻彩)\">\n        <i class=\"icon-play\"></i><strong>查看详情</strong>\n    </a>\n    <a href=\"/index.php/vod/detail/id/11277.html\" class=\"btn-aux btn-aux-o btn-base\" title=\"下载折腰(臻彩)\">\n        <i class=\"icon-download\"></i><strong>下载</strong>\n    </a>\n</div>\n```\n\n## 详情页面结构\n\n详情页面包含更完整的资源信息，特别是下载链接等详细信息。\n\n### 1. 页面标题和基本信息\n\n页面标题和基本信息位于`.box.view-heading`元素中：\n\n```html\n<div class=\"box view-heading\">\n    <div class=\"video-cover\">\n        <div class=\"module-item-cover\">\n            <div class=\"module-item-pic\">\n                <a href=\"\" title=\"立刻播放折腰(臻彩)\"><i class=\"icon-play\"></i></a>\n                <img class=\"lazyload\" alt=\"折腰(臻彩)\" data-src=\"https://wsrv.nl/?url=https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2921477016.jpg\" src=\"/template/DYXS2/static/picture/loading.png\">\n            </div>\n        </div>\n    </div>\n    <div class=\"video-info\">\n        <div class=\"video-info-header\">\n            <h1 class=\"page-title\">折腰(臻彩)</h1>\n            <h2 class=\"video-subtitle\" title=\"又名：zheyaozhencai\">zheyaozhencai</h2>\n            <!-- 分类、年代、地区等信息 -->\n        </div>\n        <!-- 导演、主演、剧情等详细信息 -->\n    </div>\n</div>\n```\n\n### 2. 下载链接区域\n\n下载链接是该网站的核心功能，位于`#download-list`元素中：\n\n```html\n<div class=\"module\" id=\"download-list\" name=\"download-list\">\n    <div class=\"module-heading\">\n        <h2 class=\"module-title\" title=\"折腰(臻彩)的影片下载列表\">影片下载</h2>\n        <div class=\"module-tab module-player-tab\">\n            <div class=\"module-tab-items\">\n                <div class=\"module-tab-content\">\n                    <div class=\"module-tab-item downtab-item selected\">\n                        <span data-dropdown-value=\"夸克云盘\">夸克云盘</span><small>1</small>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n    <div class=\"module-list module-player-list sort-list module-downlist selected\">\n        <div class=\"scroll-box-y\">\n            <div class=\"module-row-one\">\n                <div class=\"module-row-info\">\n                    <a class=\"module-row-text copy\" href=\"javascript:;\" \n                       data-clipboard-text=\"https://pan.quark.cn/s/c406e7634b0d\"\n                       title=\"复制《折腰(臻彩)》第1集下载地址\">\n                        <i class=\"icon-video-file\"></i>\n                        <div class=\"module-row-title\">\n                            <h4>折腰(臻彩) - 第1集</h4>\n                            <p>https://pan.quark.cn/s/c406e7634b0d</p>\n                        </div>\n                    </a>\n                    <div class=\"module-row-shortcuts\">\n                        <a class=\"btn-pc btn-down\" href=\"https://pan.quark.cn/s/c406e7634b0d\"\n                           title=\"下载《折腰(臻彩)》第1集\">\n                            <i class=\"icon-download\"></i><span>下载</span>\n                        </a>\n                        <a class=\"btn-copyurl copy\" href=\"javascript:;\"\n                           data-clipboard-text=\"https://pan.quark.cn/s/c406e7634b0d\"\n                           title=\"复制《折腰(臻彩)》第1集下载地址\">\n                            <i class=\"icon-url\"></i><span class=\"btn-pc\">复制链接</span>\n                        </a>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n```\n\n#### 2.1 网盘类型\n\n网盘类型信息在`.module-tab-item`元素中，通过`data-dropdown-value`属性或文本内容获取（如\"夸克云盘\"）。\n\n#### 2.2 下载链接\n\n下载链接有多个位置可以提取：\n- `data-clipboard-text`属性：`https://pan.quark.cn/s/c406e7634b0d`\n- `.module-row-title p`元素的文本内容\n- `.btn-down`元素的`href`属性\n\n### 3. 相关影片推荐\n\n相关影片推荐位于页面底部，结构类似搜索结果，位于`.module-lines-list .module-items`中。\n\n## 提取逻辑\n\n### 搜索结果页面提取逻辑\n\n1. 定位所有的`.module-search-item`元素\n2. 对于每个元素：\n   - 从`.module-item-pic a`的`href`属性提取详情页链接\n   - 从链接中提取资源ID（如`11277`）\n   - 从`h3 a`提取标题\n   - 从`.video-serial`提取资源类型/质量信息\n   - 从`.video-info-aux`提取分类、年代、地区信息\n   - 从`.video-info-main`提取导演、主演、剧情信息\n   - 从`img`的`data-src`属性提取封面图片URL\n\n### 详情页面提取逻辑\n\n1. 获取资源基本信息：\n   - 标题：`h1.page-title`的文本内容\n   - 又名：`h2.video-subtitle`的`title`属性\n   - 封面图片：`.module-item-pic img`的`data-src`属性\n\n2. 提取下载链接：\n   - 网盘类型：`.module-tab-item span[data-dropdown-value]`的属性值\n   - 下载链接：`data-clipboard-text`属性或`.module-row-title p`的文本内容\n   - 集数信息：`.module-row-title h4`的文本内容\n\n3. 提取分类和详细信息：\n   - 从`.video-info-aux`提取分类、年代、地区\n   - 从`.video-info-main`提取导演、主演、剧情等详细信息\n\n## 注意事项\n\n1. **网盘链接格式**: 主要使用夸克网盘，格式为`https://pan.quark.cn/s/{分享码}`，无需单独的密码\n2. **图片处理**: 封面图片使用了代理服务`https://wsrv.nl/?url=`来处理原始图片URL\n3. **资源分类**: \n   - 蜡笔电影、蜡笔剧集、蜡笔动漫、蜡笔综艺\n   - 臻彩4K、蜡笔臻彩、蜡笔短剧等高清分类\n4. **延迟加载**: 图片使用了`lazy lazyload`类进行延迟加载\n5. **ID提取**: 从URL中提取ID的正则表达式：`/vod/detail/id/(\\d+)\\.html`\n6. **搜索结果分页**: 需要检查是否有分页结构（本次示例中未涉及）\n7. **资源状态**: 通过`.video-serial`可以获取资源状态（如\"第36集完结\"、\"4K HDR 60帧\"等）"
  },
  {
    "path": "plugin/labi/labi.go",
    "content": "package labi\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\n// 预编译的正则表达式\nvar (\n\t// 从详情页URL中提取ID的正则表达式\n\tdetailIDRegex = regexp.MustCompile(`/vod/detail/id/(\\d+)\\.html`)\n\t\n\t// 夸克网盘链接的正则表达式\n\tquarkLinkRegex = regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9a-zA-Z]+`)\n\t\n\t// 年份提取正则表达式\n\tyearRegex = regexp.MustCompile(`(\\d{4})`)\n\t\n\t// 缓存相关\n\tdetailCache = sync.Map{} // 缓存详情页解析结果\n\tlastCleanupTime = time.Now()\n\tcacheTTL = 1 * time.Hour // 优化为更短的缓存时间\n)\n\nconst (\n\t// 超时时间优化\n\tDefaultTimeout = 8 * time.Second\n\tDetailTimeout  = 6 * time.Second\n\t// 并发数优化\n\tMaxConcurrency = 20\n\t// HTTP连接池配置\n\tMaxIdleConns        = 200\n\tMaxIdleConnsPerHost = 50\n\tMaxConnsPerHost     = 100\n\tIdleConnTimeout     = 90 * time.Second\n)\n\n// 性能统计\nvar (\n\tsearchRequests     int64 = 0\n\tdetailPageRequests int64 = 0\n\tcacheHits          int64 = 0\n\tcacheMisses        int64 = 0\n\ttotalSearchTime    int64 = 0\n\ttotalDetailTime    int64 = 0\n)\n\n// 在init函数中注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewLabiPlugin())\n\t\n\t// 启动缓存清理goroutine\n\tgo startCacheCleaner()\n}\n\n// startCacheCleaner 启动一个定期清理缓存的goroutine\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(30 * time.Minute)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\t// 清空所有缓存\n\t\tdetailCache = sync.Map{}\n\t\tlastCleanupTime = time.Now()\n\t}\n}\n\n// LabiAsyncPlugin Labi异步插件\ntype LabiAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t}\n\treturn &http.Client{Transport: transport, Timeout: DefaultTimeout}\n}\n\n// NewLabiPlugin 创建新的Labi异步插件\nfunc NewLabiPlugin() *LabiAsyncPlugin {\n\treturn &LabiAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"labi\", 1),\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *LabiAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *LabiAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实现具体的搜索逻辑\nfunc (p *LabiAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 1. 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"http://xiaocge.fun/index.php/vod/search/wd/%s.html\", url.QueryEscape(keyword))\n\t\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\t\n\t// 3. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 4. 设置完整的请求头（避免反爬虫）\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Referer\", \"http://xiaocge.fun/\")\n\t\n\t// 5. 发送请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 3. 解析搜索结果页面\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 4. 提取搜索结果\n\tvar results []model.SearchResult\n\t\n\tdoc.Find(\".module-search-item\").Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchItem(s, keyword)\n\t\tif result.UniqueID != \"\" {\n\t\t\tresults = append(results, result)\n\t\t}\n\t})\n\t\n\t// 5. 异步获取详情页信息\n\tenhancedResults := p.enhanceWithDetails(client, results)\n\t\n\t// 6. 关键词过滤\n\treturn plugin.FilterResultsByKeyword(enhancedResults, keyword), nil\n}\n\n// parseSearchItem 解析单个搜索结果项\nfunc (p *LabiAsyncPlugin) parseSearchItem(s *goquery.Selection, keyword string) model.SearchResult {\n\tresult := model.SearchResult{}\n\t\n\t// 提取详情页链接和ID\n\tdetailLink, exists := s.Find(\".module-item-pic a\").First().Attr(\"href\")\n\tif !exists {\n\t\treturn result\n\t}\n\t\n\t// 提取ID\n\tmatches := detailIDRegex.FindStringSubmatch(detailLink)\n\tif len(matches) < 2 {\n\t\treturn result\n\t}\n\t\n\titemID := matches[1]\n\tresult.UniqueID = fmt.Sprintf(\"%s-%s\", p.Name(), itemID)\n\t\n\t// 提取标题\n\ttitleElement := s.Find(\".video-info-header h3 a\")\n\tresult.Title = strings.TrimSpace(titleElement.Text())\n\t\n\t// 提取资源类型/质量\n\tqualityElement := s.Find(\".video-serial\")\n\tquality := strings.TrimSpace(qualityElement.Text())\n\t\n\t// 提取分类信息\n\tvar tags []string\n\ts.Find(\".video-info-aux .tag-link a\").Each(func(i int, tag *goquery.Selection) {\n\t\ttagText := strings.TrimSpace(tag.Text())\n\t\tif tagText != \"\" {\n\t\t\ttags = append(tags, tagText)\n\t\t}\n\t})\n\tresult.Tags = tags\n\t\n\t// 提取导演信息\n\tdirector := \"\"\n\ts.Find(\".video-info-items\").Each(func(i int, item *goquery.Selection) {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\tif strings.Contains(title, \"导演\") {\n\t\t\tdirector = strings.TrimSpace(item.Find(\".video-info-actor a\").Text())\n\t\t}\n\t})\n\t\n\t// 提取主演信息\n\tvar actors []string\n\ts.Find(\".video-info-items\").Each(func(i int, item *goquery.Selection) {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\tif strings.Contains(title, \"主演\") {\n\t\t\titem.Find(\".video-info-actor a\").Each(func(j int, actor *goquery.Selection) {\n\t\t\t\tactorName := strings.TrimSpace(actor.Text())\n\t\t\t\tif actorName != \"\" {\n\t\t\t\t\tactors = append(actors, actorName)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\t\n\t// 提取剧情简介\n\tplotElement := s.Find(\".video-info-items\").FilterFunction(func(i int, item *goquery.Selection) bool {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\treturn strings.Contains(title, \"剧情\")\n\t})\n\tplot := strings.TrimSpace(plotElement.Find(\".video-info-item\").Text())\n\n\t// 提取封面图片 (参考 Pan_wogg.js 的选择器)\n\tvar images []string\n\tif picURL, exists := s.Find(\".module-item-pic > img\").Attr(\"data-src\"); exists && picURL != \"\" {\n\t\timages = append(images, picURL)\n\t}\n\tresult.Images = images\n\n\t// 构建内容描述\n\tvar contentParts []string\n\tif quality != \"\" {\n\t\tcontentParts = append(contentParts, \"【\"+quality+\"】\")\n\t}\n\tif director != \"\" {\n\t\tcontentParts = append(contentParts, \"导演：\"+director)\n\t}\n\tif len(actors) > 0 {\n\t\tactorStr := strings.Join(actors[:min(3, len(actors))], \"、\") // 只显示前3个演员\n\t\tif len(actors) > 3 {\n\t\t\tactorStr += \"等\"\n\t\t}\n\t\tcontentParts = append(contentParts, \"主演：\"+actorStr)\n\t}\n\tif plot != \"\" {\n\t\tcontentParts = append(contentParts, plot)\n\t}\n\n\tresult.Content = strings.Join(contentParts, \"\\n\")\n\tresult.Channel = \"\" // 插件搜索结果不设置频道名，只有Telegram频道结果才设置\n\tresult.Datetime = time.Time{} // 使用零值而不是nil，参考jikepan插件标准\n\n\treturn result\n}\n\n// enhanceWithDetails 异步获取详情页信息以获取下载链接\nfunc (p *LabiAsyncPlugin) enhanceWithDetails(client *http.Client, results []model.SearchResult) []model.SearchResult {\n\tvar enhancedResults []model.SearchResult\n\tvar mu sync.Mutex\n\tvar wg sync.WaitGroup\n\t\n\t// 限制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\n\tfor _, result := range results {\n\t\twg.Add(1)\n\t\tgo func(r model.SearchResult) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 从UniqueID提取ID\n\t\t\tparts := strings.Split(r.UniqueID, \"-\")\n\t\t\tif len(parts) < 2 {\n\t\t\t\tmu.Lock()\n\t\t\t\tenhancedResults = append(enhancedResults, r)\n\t\t\t\tmu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\titemID := parts[1]\n\t\t\t\n\t\t\t// 检查缓存\n\t\t\tif cached, ok := detailCache.Load(itemID); ok {\n\t\t\t\tif cachedResult, ok := cached.(model.SearchResult); ok {\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\tenhancedResults = append(enhancedResults, cachedResult)\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 获取详情页链接和图片\n\t\t\tdetailLinks, detailImages := p.fetchDetailLinksAndImages(client, itemID)\n\t\t\tr.Links = detailLinks\n\n\t\t\t// 合并图片：优先使用详情页的海报，如果没有则使用搜索结果的图片\n\t\t\tif len(detailImages) > 0 {\n\t\t\t\tr.Images = detailImages\n\t\t\t}\n\n\t\t\t// 缓存结果\n\t\t\tdetailCache.Store(itemID, r)\n\t\t\t\n\t\t\tmu.Lock()\n\t\t\tenhancedResults = append(enhancedResults, r)\n\t\t\tmu.Unlock()\n\t\t}(result)\n\t}\n\t\n\twg.Wait()\n\treturn enhancedResults\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *LabiAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n\n// fetchDetailLinksAndImages 获取详情页的下载链接和图片\nfunc (p *LabiAsyncPlugin) fetchDetailLinksAndImages(client *http.Client, itemID string) ([]model.Link, []string) {\n\tdetailURL := fmt.Sprintf(\"http://xiaocge.fun/index.php/vod/detail/id/%s.html\", itemID)\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DetailTimeout)\n\tdefer cancel()\n\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"http://xiaocge.fun/\")\n\n\t// 发送请求（带重试）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, nil\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\n\tvar links []model.Link\n\tvar images []string\n\n\t// 提取详情页的海报图片 (参考 Pan_wogg.js 的选择器)\n\tif posterURL, exists := doc.Find(\".module-item-pic > img\").Attr(\"data-src\"); exists && posterURL != \"\" {\n\t\timages = append(images, posterURL)\n\t}\n\n\t// 查找下载链接区域\n\tdoc.Find(\"#download-list .module-row-one\").Each(func(i int, s *goquery.Selection) {\n\t\t// 从data-clipboard-text属性提取链接\n\t\tif linkURL, exists := s.Find(\"[data-clipboard-text]\").Attr(\"data-clipboard-text\"); exists {\n\t\t\t// 过滤掉无效链接\n\t\t\tif p.isValidNetworkDriveURL(linkURL) && quarkLinkRegex.MatchString(linkURL) {\n\t\t\t\tlink := model.Link{\n\t\t\t\t\tType:     \"quark\",\n\t\t\t\t\tURL:      linkURL,\n\t\t\t\t\tPassword: \"\", // 夸克网盘通常不需要密码\n\t\t\t\t}\n\t\t\t\tlinks = append(links, link)\n\t\t\t}\n\t\t}\n\n\t\t// 也检查直接的href属性\n\t\ts.Find(\"a[href]\").Each(func(j int, a *goquery.Selection) {\n\t\t\tif linkURL, exists := a.Attr(\"href\"); exists {\n\t\t\t\t// 过滤掉无效链接\n\t\t\t\tif p.isValidNetworkDriveURL(linkURL) && quarkLinkRegex.MatchString(linkURL) {\n\t\t\t\t\t// 避免重复添加\n\t\t\t\t\tisDuplicate := false\n\t\t\t\t\tfor _, existingLink := range links {\n\t\t\t\t\t\tif existingLink.URL == linkURL {\n\t\t\t\t\t\t\tisDuplicate = true\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tif !isDuplicate {\n\t\t\t\t\t\tlink := model.Link{\n\t\t\t\t\t\t\tType:     \"quark\",\n\t\t\t\t\t\t\tURL:      linkURL,\n\t\t\t\t\t\t\tPassword: \"\",\n\t\t\t\t\t\t}\n\t\t\t\t\t\tlinks = append(links, link)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t})\n\n\treturn links, images\n}\n\n// fetchDetailLinks 获取详情页的下载链接（兼容性方法，仅返回链接）\nfunc (p *LabiAsyncPlugin) fetchDetailLinks(client *http.Client, itemID string) []model.Link {\n\tlinks, _ := p.fetchDetailLinksAndImages(client, itemID)\n\treturn links\n}\n\n// isValidNetworkDriveURL 检查URL是否为有效的网盘链接\nfunc (p *LabiAsyncPlugin) isValidNetworkDriveURL(url string) bool {\n\t// 过滤掉明显无效的链接\n\tif strings.Contains(url, \"javascript:\") || \n\t   strings.Contains(url, \"#\") ||\n\t   url == \"\" ||\n\t   !strings.HasPrefix(url, \"http\") {\n\t\treturn false\n\t}\n\t\n\t// 对于labi插件，只检查夸克网盘格式\n\treturn quarkLinkRegex.MatchString(url)\n}\n\n// min 返回两个整数中的较小值\nfunc min(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}"
  },
  {
    "path": "plugin/leijing/html结构分析.md",
    "content": "# leijing插件HTML结构分析\n\n## 网站信息\n- 网站名称：雷鲸小站-天翼云盘交流站\n- 主域名：https://leijing.xyz\n- 网站类型：天翼云盘资源分享论坛\n- 特点：\n  - **专注天翼云盘**（只有天翼云盘链接）\n  - 部分帖子需要回复才能看到链接（这些会被自动忽略）\n  - 有些搜索结果直接在摘要中包含链接\n\n## 1. 搜索页面结构\n\n### 搜索URL格式\n```\nhttps://leijing.xyz/search?keyword={关键词}\n```\n\n### 搜索结果容器\n- 主容器：`<div class=\"topicModule\">`\n- 结果列表：`<div class=\"topicList\">`\n- 单个结果项：`<div class=\"topicItem\">`\n\n### 搜索结果项结构\n```html\n<div class=\"topicItem\">\n    <div class=\"avatarBox\">\n        <!-- 用户头像 -->\n    </div>\n    \n    <div class=\"content clearfix\">\n        <ul class=\"info\">\n            <li>\n                <span class=\"module\">话题</span>\n                <span class=\"tag\">剧集</span>\n                <a class=\"userName\" href=\"...\">用户名</a>\n                <span class=\"postTime\">发表时间：2025-07-27 12:15:41</span>\n                <span class=\"lastReplyTime\">最新回复：2025-08-09 18:48:25</span>\n            </li>\n        </ul>\n        <h2 class=\"title highlight clearfix\">\n            <a href=\"thread?topicId=42230\">凡人修仙传 (2025) 杨洋/金晨 4K 普码+高码 首更 04 集</a>\n        </h2>\n        <div class=\"detail\">\n            <h2 class=\"summary highlight\">\n                凡人修仙传 (2025) 杨洋/金晨 首更 04 集 \n                普码 -https://cloud.189.cn/t/YZRfuuAnaeQz \n                4K60 帧 -https://cloud.189.cn/t/aiuYru7zIfqq \n                4KHQ 高码 - https://cloud.189.cn/t/RZBjQ3Y77ZNb\n            </h2>\n        </div>\n    </div>\n    \n    <div class=\"statistic clearfix\">\n        <div class=\"viewTotal\">\n            <i class=\"cms-view icon\"></i>\n            7442\n        </div>\n        <div class=\"commentTotal\">\n            <i class=\"cms-commentCount icon\"></i>\n            3\n        </div>\n    </div>\n</div>\n```\n\n### 字段提取要点\n- **标题**：`.title a` 的文本内容\n- **详情页链接**：`.title a` 的 `href` 属性（格式：`thread?topicId={id}`）\n- **摘要**：`.summary` 的文本内容（可能包含天翼云盘链接）\n- **分类标签**：`.tag` 的文本内容\n- **发布时间**：`.postTime` 的文本内容\n- **查看数**：`.viewTotal` 的文本内容\n- **评论数**：`.commentTotal` 的文本内容\n\n### 天翼云盘链接提取\n从摘要文本中使用正则表达式提取：\n```\nhttps://cloud.189.cn/t/[a-zA-Z0-9]+\n```\n\n## 2. 详情页面结构\n\n### 详情页URL格式\n```\nhttps://leijing.xyz/thread?topicId={id}\n```\n\n### 页面结构\n```html\n<div class=\"topicContentModule\">\n    <div class=\"left\">\n        <div class=\"topic-wrap\">\n            <div class=\"topicBox\">\n                <div class=\"title\">\n                    凡人修仙传 (2025) 杨洋/金晨 4K 普码+高码 首更 04 集\n                </div>\n                <div class=\"topicInfo clearfix\">\n                    <div class=\"postTime\">2025-07-27 12:15:41</div>\n                    <div class=\"viewTotal\">7443次阅读</div>\n                    <div class=\"comment\">3个评论</div>\n                </div>\n                <div topicId=\"42230\" class=\"topicContent\">\n                    <div style=\"text-align: center;\">\n                        <strong>凡人修仙传 (2025) 杨洋/金晨 首更 04 集</strong>\n                        <br><strong>普码</strong>\n                        <br><strong><a href=\"https://cloud.189.cn/t/YZRfuuAnaeQz\">https://cloud.189.cn/t/YZRfuuAnaeQz</a></strong>\n                        <br><strong>4K60 帧</strong>\n                        <br><strong><a href=\"https://cloud.189.cn/t/aiuYru7zIfqq\">https://cloud.189.cn/t/aiuYru7zIfqq</a></strong>\n                        <!-- 更多内容 -->\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n```\n\n### 字段提取要点\n- **标题**：`.topicBox .title` 的文本内容\n- **内容**：`.topicContent` 的HTML内容\n- **发布时间**：`.topicInfo .postTime` 的文本内容\n- **查看数**：`.topicInfo .viewTotal` 的文本内容\n\n### 天翼云盘链接提取\n从详情页内容中提取：\n1. 查找所有 `<a>` 标签\n2. 过滤出包含 `cloud.189.cn` 的链接\n3. 提取 `href` 属性\n\n## 3. 特殊处理事项\n\n### 回复可见内容\n- 有些帖子内容需要回复才能看到\n- 这类帖子通常不包含可提取的链接\n- 如果提取不到链接，直接忽略该结果\n\n### 链接格式统一\n天翼云盘链接格式：\n- 正常格式：`https://cloud.189.cn/t/{shareCode}`\n- 部分可能有访问码：`https://cloud.189.cn/t/{shareCode}?pwd={password}`\n\n### 搜索策略\n1. 先从搜索结果的摘要中提取链接（速度快）\n2. 如果摘要中有链接，直接使用\n3. 如果摘要中没有链接，访问详情页提取\n4. 如果详情页也没有链接（需要回复），忽略该结果\n\n## 4. 实现建议\n\n### 优化策略\n1. **优先使用摘要链接**：很多搜索结果的摘要中已包含完整链接\n2. **批量处理**：对需要访问详情页的结果进行并发处理\n3. **缓存机制**：缓存详情页结果，避免重复访问\n\n### 错误处理\n1. 处理需要回复才能看到的内容（返回空结果）\n2. 处理链接提取失败的情况\n3. 处理网站访问异常\n\n### 链接类型\n所有链接统一标记为 `tianyi`（天翼云盘）"
  },
  {
    "path": "plugin/leijing/leijing.go",
    "content": "package leijing\n\nimport (\n\t\"compress/gzip\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tBaseURL        = \"https://leijing.xyz\"\n\tSearchPath     = \"/search\"\n\tUserAgent      = \"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\"\n\tMaxConcurrency = 20 // 详情页最大并发数\n\tMaxPages       = 1  // 最大搜索页数（暂时只搜索第一页）\n)\n\n// LeijingPlugin 雷鲸小站插件\ntype LeijingPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tdebugMode    bool\n\tdetailCache  sync.Map // 缓存详情页结果\n\tcacheTTL     time.Duration\n}\n\n// NewLeijingPlugin 创建新的雷鲸小站插件实例\nfunc NewLeijingPlugin() *LeijingPlugin {\n\t// 检查调试模式\n\tdebugMode := false // 默认关闭调试\n\t\n\tp := &LeijingPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"leijing\", 2),\n\t\tdebugMode:       debugMode,\n\t\tcacheTTL:        30 * time.Minute,\n\t}\n\t\n\treturn p\n}\n\n// Name 返回插件名称\nfunc (p *LeijingPlugin) Name() string {\n\treturn \"leijing\"\n}\n\n// DisplayName 返回插件显示名称\nfunc (p *LeijingPlugin) DisplayName() string {\n\treturn \"雷鲸小站\"\n}\n\n// Description 返回插件描述\nfunc (p *LeijingPlugin) Description() string {\n\treturn \"雷鲸小站 - 天翼云盘资源分享站\"\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *LeijingPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *LeijingPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// setRequestHeaders 设置请求头\nfunc (p *LeijingPlugin) setRequestHeaders(req *http.Request, referer string) {\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Accept-Encoding\", \"gzip, deflate\")\n\treq.Header.Set(\"Cache-Control\", \"no-cache\")\n\treq.Header.Set(\"Pragma\", \"no-cache\")\n\tif referer != \"\" {\n\t\treq.Header.Set(\"Referer\", referer)\n\t}\n}\n\n// doRequest 发送HTTP请求\nfunc (p *LeijingPlugin) doRequest(client *http.Client, url string, referer string) (*http.Response, error) {\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\tp.setRequestHeaders(req, referer)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Leijing] 发送请求: %s\", url)\n\t}\n\t\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Leijing] 请求失败: %v\", err)\n\t\t}\n\t\treturn nil, err\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Leijing] 响应状态: %d\", resp.StatusCode)\n\t}\n\t\n\treturn resp, nil\n}\n\n// searchImpl 实际的搜索实现\nfunc (p *LeijingPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tsearchURL := fmt.Sprintf(\"%s%s?keyword=%s\", BaseURL, SearchPath, url.QueryEscape(keyword))\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Leijing] 开始搜索: %s\", keyword)\n\t\tlog.Printf(\"[Leijing] 搜索URL: %s\", searchURL)\n\t}\n\t\n\t// 发送搜索请求\n\tresp, err := p.doRequest(client, searchURL, BaseURL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"发送搜索请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"搜索响应状态码异常: %d\", resp.StatusCode)\n\t}\n\t\n\t// 处理响应体（可能是gzip压缩的）\n\treader, err := p.getResponseReader(resp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(reader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"解析HTML失败: %w\", err)\n\t}\n\t\n\t// 提取搜索结果\n\tresults := p.extractSearchResults(doc, keyword)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Leijing] 找到 %d 个搜索结果\", len(results))\n\t}\n\t\n\t// 对于没有直接提取到链接的结果，访问详情页获取链接\n\tresults = p.enrichWithDetailLinks(client, results, keyword)\n\t\n\t// 过滤结果（去掉没有链接的）\n\tfilteredResults := p.filterValidResults(results)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Leijing] 过滤后剩余 %d 个有效结果\", len(filteredResults))\n\t}\n\t\n\treturn filteredResults, nil\n}\n\n// getResponseReader 获取响应读取器（处理gzip压缩）\nfunc (p *LeijingPlugin) getResponseReader(resp *http.Response) (io.Reader, error) {\n\tvar reader io.Reader = resp.Body\n\t\n\t// 检查Content-Encoding\n\tcontentEncoding := resp.Header.Get(\"Content-Encoding\")\n\tif p.debugMode {\n\t\tlog.Printf(\"[Leijing] Content-Encoding: %s\", contentEncoding)\n\t}\n\t\n\t// 如果是gzip压缩，手动解压\n\tif contentEncoding == \"gzip\" {\n\t\tgzReader, err := gzip.NewReader(resp.Body)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"创建gzip reader失败: %w\", err)\n\t\t}\n\t\treader = gzReader\n\t}\n\t\n\treturn reader, nil\n}\n\n// extractSearchResults 从HTML中提取搜索结果\nfunc (p *LeijingPlugin) extractSearchResults(doc *goquery.Document, keyword string) []model.SearchResult {\n\tvar results []model.SearchResult\n\t\n\t// 选择所有搜索结果项\n\tdoc.Find(\".topicItem\").Each(func(i int, s *goquery.Selection) {\n\t\t// 提取标题和详情页链接\n\t\ttitleElem := s.Find(\".title a\")\n\t\ttitle := strings.TrimSpace(titleElem.Text())\n\t\tdetailPath, _ := titleElem.Attr(\"href\")\n\t\t\n\t\tif title == \"\" || detailPath == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 构建完整的详情页URL\n\t\tdetailURL := BaseURL + \"/\" + strings.TrimPrefix(detailPath, \"/\")\n\t\t\n\t\t// 提取摘要（可能包含链接）\n\t\tsummary := strings.TrimSpace(s.Find(\".summary\").Text())\n\t\t\n\t\t// 提取其他信息\n\t\tpostTime := strings.TrimSpace(s.Find(\".postTime\").Text())\n\t\tpostTime = strings.TrimPrefix(postTime, \"发表时间：\")\n\t\t\n\t\t// 从详情页路径提取ID（如：thread?topicId=42230 -> 42230）\n\t\tidMatch := regexp.MustCompile(`topicId=(\\d+)`).FindStringSubmatch(detailPath)\n\t\tresourceID := \"\"\n\t\tif len(idMatch) > 1 {\n\t\t\tresourceID = idMatch[1]\n\t\t} else {\n\t\t\tresourceID = fmt.Sprintf(\"%d\", time.Now().UnixNano())\n\t\t}\n\t\t\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Leijing] 提取结果 %d: %s, URL: %s\", i+1, title, detailURL)\n\t\t}\n\t\t\n\t\t// 尝试从摘要中提取天翼云盘链接\n\t\tlinks := p.extractTianyiLinks(summary)\n\t\t\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Leijing] 从摘要中提取到 %d 个链接\", len(links))\n\t\t}\n\t\t\n\t\t// 解析时间\n\t\tvar publishTime time.Time\n\t\tif postTime != \"\" {\n\t\t\tparsedTime, err := time.Parse(\"2006-01-02 15:04:05\", postTime)\n\t\t\tif err == nil {\n\t\t\t\tpublishTime = parsedTime\n\t\t\t} else {\n\t\t\t\tpublishTime = time.Now()\n\t\t\t}\n\t\t} else {\n\t\t\tpublishTime = time.Now()\n\t\t}\n\t\t\n\t\tresult := model.SearchResult{\n\t\t\tTitle:     title,\n\t\t\tContent:   summary,\n\t\t\tChannel:   \"\",\n\t\t\tMessageID: fmt.Sprintf(\"%s-%s\", p.Name(), resourceID),\n\t\t\tUniqueID:  fmt.Sprintf(\"%s-%s\", p.Name(), resourceID),\n\t\t\tDatetime:  publishTime,\n\t\t\tLinks:     links,\n\t\t}\n\t\t\n\t\t// 如果没有从摘要中提取到链接，将详情页URL存储在Tags中供后续使用\n\t\tif len(links) == 0 {\n\t\t\tresult.Tags = []string{detailURL}\n\t\t}\n\t\t\n\t\tresults = append(results, result)\n\t})\n\t\n\treturn results\n}\n\n// extractTianyiLinks 从文本中提取天翼云盘链接\nfunc (p *LeijingPlugin) extractTianyiLinks(text string) []model.Link {\n\tvar links []model.Link\n\t\n\t// 天翼云盘链接正则\n\ttianyiRegex := regexp.MustCompile(`https://cloud\\.189\\.cn/t/[a-zA-Z0-9]+`)\n\tmatches := tianyiRegex.FindAllString(text, -1)\n\t\n\t// 去重\n\tlinkMap := make(map[string]bool)\n\tfor _, match := range matches {\n\t\tif !linkMap[match] {\n\t\t\tlinkMap[match] = true\n\t\t\tlinks = append(links, model.Link{\n\t\t\t\tURL:  match,\n\t\t\t\tType: \"tianyi\",\n\t\t\t})\n\t\t}\n\t}\n\t\n\treturn links\n}\n\n// enrichWithDetailLinks 并发获取详情页的下载链接\nfunc (p *LeijingPlugin) enrichWithDetailLinks(client *http.Client, results []model.SearchResult, keyword string) []model.SearchResult {\n\tif p.debugMode {\n\t\tlog.Printf(\"[Leijing] 开始获取详情页链接\")\n\t}\n\t\n\tvar wg sync.WaitGroup\n\tvar mu sync.Mutex\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\n\tfor i := range results {\n\t\t// 如果已经有链接了，跳过\n\t\tif len(results[i].Links) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 如果没有详情页URL，跳过\n\t\tif len(results[i].Tags) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\twg.Add(1)\n\t\tgo func(idx int) {\n\t\t\tdefer wg.Done()\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 添加小延迟避免请求过快\n\t\t\ttime.Sleep(time.Duration(idx*50) * time.Millisecond)\n\t\t\t\n\t\t\tdetailURL := results[idx].Tags[0]\n\t\t\tlinks := p.fetchDetailPageLinks(client, detailURL)\n\t\t\t\n\t\t\tmu.Lock()\n\t\t\tif len(links) > 0 {\n\t\t\t\tresults[idx].Links = links\n\t\t\t}\n\t\t\t// 清空Tags\n\t\t\tresults[idx].Tags = nil\n\t\t\tmu.Unlock()\n\t\t\t\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Leijing] 详情页 %d/%d 获取到 %d 个链接\", idx+1, len(results), len(links))\n\t\t\t}\n\t\t}(i)\n\t}\n\t\n\twg.Wait()\n\t\n\treturn results\n}\n\n// fetchDetailPageLinks 获取详情页的下载链接\nfunc (p *LeijingPlugin) fetchDetailPageLinks(client *http.Client, detailURL string) []model.Link {\n\t// 检查缓存\n\tif cached, ok := p.detailCache.Load(detailURL); ok {\n\t\tif links, ok := cached.([]model.Link); ok {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Leijing] 使用缓存的详情页结果: %s\", detailURL)\n\t\t\t}\n\t\t\treturn links\n\t\t}\n\t}\n\t\n\t// 访问详情页\n\tresp, err := p.doRequest(client, detailURL, BaseURL)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Leijing] 获取详情页失败: %v\", err)\n\t\t}\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != http.StatusOK {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Leijing] 详情页响应状态码异常: %d\", resp.StatusCode)\n\t\t}\n\t\treturn nil\n\t}\n\t\n\t// 处理响应体\n\treader, err := p.getResponseReader(resp)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(reader)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Leijing] 解析详情页HTML失败: %v\", err)\n\t\t}\n\t\treturn nil\n\t}\n\t\n\t// 提取详情页中的天翼云盘链接\n\tlinks := p.extractDetailPageLinks(doc)\n\t\n\t// 缓存结果\n\tif len(links) > 0 {\n\t\tp.detailCache.Store(detailURL, links)\n\t\t\n\t\t// 设置缓存过期\n\t\tgo func() {\n\t\t\ttime.Sleep(p.cacheTTL)\n\t\t\tp.detailCache.Delete(detailURL)\n\t\t}()\n\t}\n\t\n\treturn links\n}\n\n// extractDetailPageLinks 从详情页HTML中提取天翼云盘链接\nfunc (p *LeijingPlugin) extractDetailPageLinks(doc *goquery.Document) []model.Link {\n\tvar links []model.Link\n\tlinkMap := make(map[string]bool) // 用于去重\n\t\n\t// 从详情页内容中查找所有链接\n\tdoc.Find(\".topicContent a[href*='cloud.189.cn']\").Each(func(i int, s *goquery.Selection) {\n\t\thref, exists := s.Attr(\"href\")\n\t\tif !exists || href == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 去重\n\t\tif linkMap[href] {\n\t\t\treturn\n\t\t}\n\t\tlinkMap[href] = true\n\t\t\n\t\tlinks = append(links, model.Link{\n\t\t\tURL:  href,\n\t\t\tType: \"tianyi\",\n\t\t})\n\t\t\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Leijing] 提取到天翼云盘链接: %s\", href)\n\t\t}\n\t})\n\t\n\t// 如果没有找到链接，尝试从文本中提取\n\tif len(links) == 0 {\n\t\tcontent := doc.Find(\".topicContent\").Text()\n\t\tlinks = p.extractTianyiLinks(content)\n\t}\n\t\n\treturn links\n}\n\n// filterValidResults 过滤有效结果（去掉没有链接的）\nfunc (p *LeijingPlugin) filterValidResults(results []model.SearchResult) []model.SearchResult {\n\tvar validResults []model.SearchResult\n\t\n\tfor _, result := range results {\n\t\tif len(result.Links) > 0 {\n\t\t\tvalidResults = append(validResults, result)\n\t\t} else if p.debugMode {\n\t\t\tlog.Printf(\"[Leijing] 忽略无链接结果: %s\", result.Title)\n\t\t}\n\t}\n\t\n\treturn validResults\n}\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewLeijingPlugin())\n}"
  },
  {
    "path": "plugin/libvio/html结构分析.md",
    "content": "# libvio插件HTML结构分析\n\n## 网站信息\n- 网站名称：LIBVIO\n- 主域名：https://www.libvio.mov\n- 网站类型：影视资源在线播放/下载网站\n- 特点：提供网盘下载链接（夸克、UC等）\n\n## 访问流程说明\n该网站需要通过三步来获取网盘链接：\n1. **搜索页面** → 获取详情页链接\n2. **详情页面** → 获取网盘下载页链接（注意选择\"下载\"而非\"播放\"）\n3. **播放页面** → 从JavaScript对象中提取网盘URL\n\n## 1. 搜索页面结构\n\n### 搜索URL格式\n```\nhttps://www.libvio.mov/search/-------------.html?wd={关键词}&submit=\n```\n\n### 搜索结果容器\n- 主容器：`<ul class=\"stui-vodlist clearfix\">`\n- 单个结果项：`<li class=\"col-md-6 col-sm-4 col-xs-3\">`\n\n### 搜索结果项结构\n```html\n<li class=\"col-md-6 col-sm-4 col-xs-3\">\n    <div class=\"stui-vodlist__box\">\n        <a class=\"stui-vodlist__thumb lazyload\" href=\"/detail/4095.html\" title=\"瑞克和莫蒂 第五季\" \n           data-original=\"https://xxx.jpg\">\n            <span class=\"play hidden-xs\"></span>\n            <span class=\"pic-text text-right\">10集全</span>\n            <span class=\"pic-tag pic-tag-top\">9.6</span>\n        </a>\n        <div class=\"stui-vodlist__detail\">\n            <h4 class=\"title text-overflow\">\n                <a href=\"/detail/4095.html\" title=\"瑞克和莫蒂 第五季\">瑞克和莫蒂 第五季</a>\n            </h4>\n        </div>\n    </div>\n</li>\n```\n\n### 字段提取要点\n- **标题**：`.stui-vodlist__detail h4 a` 的文本内容或 `title` 属性\n- **详情页链接**：`.stui-vodlist__thumb` 或 `.stui-vodlist__detail h4 a` 的 `href` 属性\n- **封面图片**：`.stui-vodlist__thumb` 的 `data-original` 属性\n- **集数信息**：`.pic-text` 的文本内容\n- **评分**：`.pic-tag` 的文本内容\n\n## 2. 详情页面结构\n\n### 详情页URL格式\n```\nhttps://www.libvio.mov/detail/{id}.html\n```\n\n### 下载链接容器\n详情页包含多个播放/下载源，我们需要查找带有\"下载\"关键字的源：\n\n```html\n<div class=\"stui-vodlist__head\">\n    <div class=\"stui-pannel__head clearfix\">\n        <span class=\"more text-muted pull-right\"></span>\n        <h3 class=\"iconfont icon-iconfontplay2\">视频下载(UC) </h3>\n    </div>\n    <ul class=\"stui-content__playlist clearfix\">\n        <li>\n            <a href=\"/play/714892571-2-1.html\">合集</a>\n        </li>\n    </ul>\n</div>\n\n<div class=\"stui-vodlist__head\">\n    <div class=\"stui-pannel__head clearfix\">\n        <span class=\"more text-muted pull-right\"></span>\n        <h3 class=\"iconfont icon-iconfontplay2\">视频下载 (夸克) </h3>\n    </div>\n    <ul class=\"stui-content__playlist clearfix\">\n        <li>\n            <a href=\"/play/714892571-1-1.html\">合集</a>\n        </li>\n    </ul>\n</div>\n```\n\n### 字段提取要点\n- **下载源标题**：`h3` 标签内容，需要包含\"下载\"关键字\n- **播放页链接**：`.stui-content__playlist li a` 的 `href` 属性\n- **网盘类型识别**：从标题中提取（如\"UC\"、\"夸克\"等）\n\n### 筛选规则\n- 只提取标题包含\"下载\"的播放源\n- 忽略标题只有\"播放\"的源（这些通常是在线播放链接）\n\n## 3. 播放页面结构（获取网盘链接）\n\n### 播放页URL格式\n```\nhttps://www.libvio.mov/play/{id}-{sid}-{nid}.html\n```\n- `id`：影片ID\n- `sid`：播放源ID\n- `nid`：集数ID\n\n### 网盘链接提取方式\n网盘链接存储在页面内的JavaScript对象中：\n\n```javascript\n<script type=\"text/javascript\">\n    var player_aaaa = {\n        \"flag\": \"play\",\n        \"encrypt\": 3,\n        \"trysee\": 10,\n        \"points\": 0,\n        \"link\": \"/play/714892571-1-1.html\",\n        \"link_next\": \"\",\n        \"link_pre\": \"\",\n        \"url\": \"https://drive.uc.cn/s/132a6339c94d4?public=1\",\n        \"url_next\": \"\",\n        \"from\": \"uc\",\n        \"server\": \"no\",\n        \"note\": \"\",\n        \"id\": \"714892571\",\n        \"sid\": 2,\n        \"nid\": 1\n    }\n</script>\n```\n\n### 字段提取要点\n- **网盘链接**：`player_aaaa.url` 字段\n- **网盘类型**：`player_aaaa.from` 字段（如 \"uc\"、\"quark\" 等）\n- **集数信息**：`player_aaaa.nid` 字段\n\n### 提取方法\n1. 使用正则表达式匹配 `var player_aaaa = {...}` 内容\n2. 解析JSON对象\n3. 提取 `url` 字段即为网盘链接\n\n## 4. 支持的网盘类型\n\n根据HTML结构分析，网站主要支持：\n- UC网盘（drive.uc.cn）\n- 夸克网盘（pan.quark.cn）\n\n## 5. 特殊处理事项\n\n### JavaScript对象解析\n- 需要处理转义字符（如 `\\/` → `/`）\n- 注意JSON对象的格式可能不标准，需要容错处理\n\n### 多集处理\n- 电视剧/动漫可能有多集，每集有独立的播放页链接\n- 需要处理\"合集\"类型的资源（通常包含整季资源）\n\n### 错误处理\n- 需要处理播放页没有player_aaaa对象的情况\n- 需要处理URL字段为空的情况\n- 需要处理网站改版导致的结构变化\n\n## 6. 实现建议\n\n### 搜索流程\n1. 发送搜索请求，解析HTML获取搜索结果\n2. 提取每个结果的详情页链接\n\n### 详情页处理\n1. 访问详情页，查找所有播放源\n2. 筛选出标题包含\"下载\"的源\n3. 提取对应的播放页链接\n\n### 网盘链接提取\n1. 访问播放页\n2. 使用正则表达式提取player_aaaa对象\n3. 解析JSON获取网盘URL\n4. 根据from字段确定网盘类型\n\n### 并发优化\n- 可以对多个详情页访问进行并发\n- 可以对多个播放页访问进行并发\n- 使用缓存避免重复请求\n\n### 链接类型映射\n```\nfrom字段 → 网盘类型\n\"uc\" → \"UC网盘\"\n\"quark\" → \"夸克网盘\"\n```"
  },
  {
    "path": "plugin/libvio/libvio.go",
    "content": "package libvio\n\nimport (\n\t\"compress/gzip\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\nconst (\n\tBaseURL        = \"https://www.libvio.mov\"\n\tSearchPath     = \"/search/-------------.html\"\n\tUserAgent      = \"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\"\n\tMaxConcurrency = 20 // 详情页最大并发数\n\tMaxPages       = 1  // 最大搜索页数（暂时只搜索第一页）\n)\n\n// LibvioPlugin LIBVIO插件\ntype LibvioPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tdebugMode    bool\n\tdetailCache  sync.Map // 缓存详情页结果\n\tplayCache    sync.Map // 缓存播放页结果\n\tcacheTTL     time.Duration\n}\n\n// NewLibvioPlugin 创建新的LIBVIO插件实例\nfunc NewLibvioPlugin() *LibvioPlugin {\n\t// 检查调试模式\n\tdebugMode := false // 开启调试模式\n\t\n\tp := &LibvioPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"libvio\", 1, true ),\t\n\t\tdebugMode:       debugMode,\n\t\tcacheTTL:        30 * time.Minute,\n\t}\n\t\n\treturn p\n}\n\n// Name 返回插件名称\nfunc (p *LibvioPlugin) Name() string {\n\treturn \"libvio\"\n}\n\n// DisplayName 返回插件显示名称\nfunc (p *LibvioPlugin) DisplayName() string {\n\treturn \"LIBVIO\"\n}\n\n// Description 返回插件描述\nfunc (p *LibvioPlugin) Description() string {\n\treturn \"LIBVIO - 影视资源网盘下载\"\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *LibvioPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *LibvioPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// setRequestHeaders 设置请求头\nfunc (p *LibvioPlugin) setRequestHeaders(req *http.Request, referer string) {\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Accept-Encoding\", \"gzip, deflate\")\n\treq.Header.Set(\"Cache-Control\", \"no-cache\")\n\treq.Header.Set(\"Pragma\", \"no-cache\")\n\tif referer != \"\" {\n\t\treq.Header.Set(\"Referer\", referer)\n\t}\n}\n\n// doRequest 发送HTTP请求\nfunc (p *LibvioPlugin) doRequest(client *http.Client, url string, referer string) (*http.Response, error) {\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\tp.setRequestHeaders(req, referer)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Libvio] 发送请求: %s\", url)\n\t}\n\t\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Libvio] 请求失败: %v\", err)\n\t\t}\n\t\treturn nil, err\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Libvio] 响应状态: %d\", resp.StatusCode)\n\t}\n\t\n\treturn resp, nil\n}\n\n// searchImpl 实际的搜索实现\nfunc (p *LibvioPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tsearchURL := fmt.Sprintf(\"%s%s?wd=%s&submit=\", BaseURL, SearchPath, url.QueryEscape(keyword))\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Libvio] 开始搜索: %s\", keyword)\n\t\tlog.Printf(\"[Libvio] 搜索URL: %s\", searchURL)\n\t}\n\t\n\t// 发送搜索请求\n\tresp, err := p.doRequest(client, searchURL, BaseURL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"发送搜索请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"搜索响应状态码异常: %d\", resp.StatusCode)\n\t}\n\t\n\t// 处理响应体（可能是gzip压缩的）\n\treader, err := p.getResponseReader(resp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(reader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"解析HTML失败: %w\", err)\n\t}\n\t\n\t// 提取搜索结果\n\tresults := p.extractSearchResults(doc, keyword)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Libvio] 找到 %d 个搜索结果\", len(results))\n\t}\n\t\n\t// 并发获取详情页的下载链接\n\tresults = p.enrichWithDetailLinks(client, results, keyword)\n\t\n\tif p.debugMode {\n\t\t// 统计链接数量\n\t\ttotalLinks := 0\n\t\tfor i, r := range results {\n\t\t\tlog.Printf(\"[Libvio] 结果 %d: %s, 链接数: %d\", i+1, r.Title, len(r.Links))\n\t\t\ttotalLinks += len(r.Links)\n\t\t}\n\t\tlog.Printf(\"[Libvio] 总计: %d 个结果，%d 个链接\", len(results), totalLinks)\n\t}\n\t\n\t// 过滤结果\n\tfilteredResults := plugin.FilterResultsByKeyword(results, keyword)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Libvio] 过滤后剩余 %d 个结果\", len(filteredResults))\n\t}\n\t\n\treturn filteredResults, nil\n}\n\n// getResponseReader 获取响应读取器（处理gzip压缩）\nfunc (p *LibvioPlugin) getResponseReader(resp *http.Response) (io.Reader, error) {\n\tvar reader io.Reader = resp.Body\n\t\n\t// 检查Content-Encoding\n\tcontentEncoding := resp.Header.Get(\"Content-Encoding\")\n\tif p.debugMode {\n\t\tlog.Printf(\"[Libvio] Content-Encoding: %s\", contentEncoding)\n\t}\n\t\n\t// 如果是gzip压缩，手动解压\n\tif contentEncoding == \"gzip\" {\n\t\tgzReader, err := gzip.NewReader(resp.Body)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"创建gzip reader失败: %w\", err)\n\t\t}\n\t\t// 注意：不要在这里关闭gzReader，它需要在外部使用\n\t\treader = gzReader\n\t}\n\t\n\treturn reader, nil\n}\n\n// extractSearchResults 从HTML中提取搜索结果\nfunc (p *LibvioPlugin) extractSearchResults(doc *goquery.Document, keyword string) []model.SearchResult {\n\tvar results []model.SearchResult\n\t\n\t// 选择所有搜索结果项\n\tdoc.Find(\"ul.stui-vodlist li\").Each(func(i int, s *goquery.Selection) {\n\t\t// 提取标题和详情页链接\n\t\ttitleElem := s.Find(\".stui-vodlist__detail h4 a\")\n\t\ttitle := strings.TrimSpace(titleElem.Text())\n\t\tif title == \"\" {\n\t\t\ttitle, _ = titleElem.Attr(\"title\")\n\t\t}\n\t\t\n\t\tdetailPath, _ := titleElem.Attr(\"href\")\n\t\tif detailPath == \"\" {\n\t\t\t// 尝试从缩略图链接获取\n\t\t\tthumbLink := s.Find(\"a.stui-vodlist__thumb\")\n\t\t\tdetailPath, _ = thumbLink.Attr(\"href\")\n\t\t}\n\t\t\n\t\tif title == \"\" || detailPath == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 构建完整的详情页URL\n\t\tdetailURL := BaseURL + detailPath\n\t\t\n\t\t// 提取其他信息\n\t\tepisodeInfo := strings.TrimSpace(s.Find(\".pic-text\").Text())\n\t\trating := strings.TrimSpace(s.Find(\".pic-tag\").Text())\n\t\t\n\t\t// 从详情页路径提取ID（如：/detail/4095.html -> 4095）\n\t\tidMatch := regexp.MustCompile(`/detail/(\\d+)\\.html`).FindStringSubmatch(detailPath)\n\t\tresourceID := \"\"\n\t\tif len(idMatch) > 1 {\n\t\t\tresourceID = idMatch[1]\n\t\t} else {\n\t\t\tresourceID = fmt.Sprintf(\"%d\", time.Now().UnixNano())\n\t\t}\n\t\t\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Libvio] 提取结果 %d: %s, URL: %s\", i+1, title, detailURL)\n\t\t}\n\t\t\n\t\t// 构建内容描述\n\t\tcontent := \"\"\n\t\tif episodeInfo != \"\" {\n\t\t\tcontent = episodeInfo\n\t\t}\n\t\tif rating != \"\" {\n\t\t\tif content != \"\" {\n\t\t\t\tcontent += \" | \"\n\t\t\t}\n\t\t\tcontent += \"评分: \" + rating\n\t\t}\n\t\t\n\t\tresult := model.SearchResult{\n\t\t\tTitle:     title,\n\t\t\tContent:   content,\n\t\t\tChannel:   \"\",\n\t\t\tMessageID: fmt.Sprintf(\"%s-%s\", p.Name(), resourceID),\n\t\t\tUniqueID:  fmt.Sprintf(\"%s-%s\", p.Name(), resourceID),\n\t\t\tDatetime:  time.Now(),\n\t\t\tLinks:     []model.Link{}, // 稍后填充\n\t\t}\n\t\t\n\t\t// 将详情页URL存储在Tags中供后续使用\n\t\tresult.Tags = []string{detailURL}\n\t\t\n\t\tresults = append(results, result)\n\t})\n\t\n\treturn results\n}\n\n// enrichWithDetailLinks 并发获取详情页的下载链接\nfunc (p *LibvioPlugin) enrichWithDetailLinks(client *http.Client, results []model.SearchResult, keyword string) []model.SearchResult {\n\tif len(results) == 0 {\n\t\treturn results\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Libvio] 开始获取 %d 个详情页的下载链接\", len(results))\n\t}\n\t\n\tvar wg sync.WaitGroup\n\tvar mu sync.Mutex\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\n\tfor i := range results {\n\t\twg.Add(1)\n\t\tgo func(idx int) {\n\t\t\tdefer wg.Done()\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 添加小延迟避免请求过快\n\t\t\ttime.Sleep(time.Duration(idx*50) * time.Millisecond)\n\t\t\t\n\t\t\t// 从Tags中获取详情页URL\n\t\t\tif len(results[idx].Tags) > 0 {\n\t\t\t\tdetailURL := results[idx].Tags[0]\n\t\t\t\tlinks := p.fetchDetailPageLinks(client, detailURL, keyword)\n\t\t\t\t\n\t\t\t\tmu.Lock()\n\t\t\t\tresults[idx].Links = links\n\t\t\t\t// 清空Tags，避免返回给用户\n\t\t\t\tresults[idx].Tags = nil\n\t\t\t\tmu.Unlock()\n\t\t\t\t\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[Libvio] 详情页 %d/%d 获取到 %d 个链接\", idx+1, len(results), len(links))\n\t\t\t\t}\n\t\t\t}\n\t\t}(i)\n\t}\n\t\n\twg.Wait()\n\t\n\treturn results\n}\n\n// fetchDetailPageLinks 获取详情页的下载链接\nfunc (p *LibvioPlugin) fetchDetailPageLinks(client *http.Client, detailURL string, keyword string) []model.Link {\n\tif p.debugMode {\n\t\tlog.Printf(\"[Libvio] 开始获取详情页: %s\", detailURL)\n\t}\n\t\n\t// 检查缓存\n\tif cached, ok := p.detailCache.Load(detailURL); ok {\n\t\tif links, ok := cached.([]model.Link); ok {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Libvio] 使用缓存的详情页结果: %s, 链接数: %d\", detailURL, len(links))\n\t\t\t}\n\t\t\treturn links\n\t\t}\n\t}\n\t\n\t// 访问详情页\n\tresp, err := p.doRequest(client, detailURL, BaseURL)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Libvio] 获取详情页失败: %s, 错误: %v\", detailURL, err)\n\t\t}\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != http.StatusOK {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Libvio] 详情页响应状态码异常: %s, 状态码: %d\", detailURL, resp.StatusCode)\n\t\t}\n\t\treturn nil\n\t}\n\t\n\t// 处理响应体\n\treader, err := p.getResponseReader(resp)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(reader)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Libvio] 解析详情页HTML失败: %v\", err)\n\t\t}\n\t\treturn nil\n\t}\n\t\n\t// 提取下载播放页链接（只提取包含\"下载\"的）\n\tplayLinks := p.extractDownloadPlayLinks(doc)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Libvio] 找到 %d 个下载播放页链接\", len(playLinks))\n\t}\n\t\n\tif len(playLinks) == 0 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Libvio] 未找到下载链接\")\n\t\t}\n\t\treturn nil\n\t}\n\t\n\t// 获取网盘链接\n\tvar links []model.Link\n\tfor _, playLink := range playLinks {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Libvio] 获取网盘链接: %s\", playLink.URL)\n\t\t}\n\t\tpanLink := p.fetchPanLink(client, playLink.URL, detailURL)\n\t\tif panLink != nil {\n\t\t\tlinks = append(links, *panLink)\n\t\t} else if p.debugMode {\n\t\t\tlog.Printf(\"[Libvio] 未能获取网盘链接: %s\", playLink.URL)\n\t\t}\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Libvio] 详情页 %s 最终获取到 %d 个网盘链接\", detailURL, len(links))\n\t}\n\t\n\t// 缓存结果\n\tp.detailCache.Store(detailURL, links)\n\t\n\t// 设置缓存过期\n\tgo func() {\n\t\ttime.Sleep(p.cacheTTL)\n\t\tp.detailCache.Delete(detailURL)\n\t}()\n\t\n\treturn links\n}\n\n// PlayLinkInfo 播放链接信息\ntype PlayLinkInfo struct {\n\tURL      string\n\tPanType  string // 网盘类型（从标题提取）\n}\n\n// extractDownloadPlayLinks 提取下载播放页链接\nfunc (p *LibvioPlugin) extractDownloadPlayLinks(doc *goquery.Document) []PlayLinkInfo {\n\tvar playLinks []PlayLinkInfo\n\t\n\t// 查找所有播放源\n\tallHeads := doc.Find(\".stui-vodlist__head\")\n\tif p.debugMode {\n\t\tlog.Printf(\"[Libvio] 找到 %d 个播放源头部\", allHeads.Length())\n\t}\n\t\n\tallHeads.Each(func(i int, s *goquery.Selection) {\n\t\t// 获取标题\n\t\ttitle := strings.TrimSpace(s.Find(\"h3\").Text())\n\t\t\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Libvio] 播放源 %d 标题: %s\", i+1, title)\n\t\t}\n\t\t\n\t\t// 只处理包含\"下载\"的源\n\t\tif !strings.Contains(title, \"下载\") {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Libvio] 跳过非下载源: %s\", title)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 提取网盘类型\n\t\tpanType := \"\"\n\t\tif strings.Contains(title, \"夸克\") || strings.Contains(title, \"quark\") {\n\t\t\tpanType = \"quark\"\n\t\t} else if strings.Contains(title, \"UC\") || strings.Contains(title, \"uc\") {\n\t\t\tpanType = \"uc\"\n\t\t} else if strings.Contains(title, \"百度\") || strings.Contains(title, \"baidu\") {\n\t\t\tpanType = \"baidu\"\n\t\t}\n\t\t\n\t\t// 提取播放页链接\n\t\tplaylistLinks := s.Find(\".stui-content__playlist li a\")\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Libvio] 播放列表中有 %d 个链接\", playlistLinks.Length())\n\t\t}\n\t\t\n\t\t// 通常只取第一个链接（合集）\n\t\tfirstLink := playlistLinks.First()\n\t\tif firstLink.Length() > 0 {\n\t\t\thref, exists := firstLink.Attr(\"href\")\n\t\t\tif exists && href != \"\" {\n\t\t\t\t// 构建完整URL\n\t\t\t\tplayURL := BaseURL + href\n\t\t\t\t\n\t\t\t\tplayLinks = append(playLinks, PlayLinkInfo{\n\t\t\t\t\tURL:     playURL,\n\t\t\t\t\tPanType: panType,\n\t\t\t\t})\n\t\t\t\t\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlinkText := strings.TrimSpace(firstLink.Text())\n\t\t\t\t\tlog.Printf(\"[Libvio] 找到下载链接: %s (%s) [%s]\", playURL, panType, linkText)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n\t\n\treturn playLinks\n}\n\n// fetchPanLink 获取网盘链接\nfunc (p *LibvioPlugin) fetchPanLink(client *http.Client, playURL string, referer string) *model.Link {\n\t// 检查缓存\n\tif cached, ok := p.playCache.Load(playURL); ok {\n\t\tif link, ok := cached.(*model.Link); ok {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Libvio] 使用缓存的播放页结果: %s\", playURL)\n\t\t\t}\n\t\t\treturn link\n\t\t}\n\t}\n\t\n\t// 访问播放页\n\tresp, err := p.doRequest(client, playURL, referer)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Libvio] 获取播放页失败: %v\", err)\n\t\t}\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != http.StatusOK {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Libvio] 播放页响应状态码异常: %d\", resp.StatusCode)\n\t\t}\n\t\treturn nil\n\t}\n\t\n\t// 处理响应体（可能是gzip压缩的）\n\treader, err := p.getResponseReader(resp)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\t// 读取响应体\n\tbody, err := io.ReadAll(reader)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\t// 提取player_aaaa对象\n\tplayerDataRegex := regexp.MustCompile(`var\\s+player_aaaa\\s*=\\s*({[^}]+})`)\n\tmatches := playerDataRegex.FindStringSubmatch(string(body))\n\t\n\tif len(matches) < 2 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Libvio] 未找到player_aaaa对象\")\n\t\t\t// 输出部分body内容用于调试\n\t\t\tbodyStr := string(body)\n\t\t\tif len(bodyStr) > 500 {\n\t\t\t\tlog.Printf(\"[Libvio] 页面内容前500字符: %s\", bodyStr[:500])\n\t\t\t} else {\n\t\t\t\tlog.Printf(\"[Libvio] 页面内容: %s\", bodyStr)\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\t\n\t// 解析JSON\n\tplayerJSON := matches[1]\n\tif p.debugMode {\n\t\tlog.Printf(\"[Libvio] 找到player_aaaa: %s\", playerJSON)\n\t}\n\t\n\t// 处理转义字符\n\tplayerJSON = strings.ReplaceAll(playerJSON, `\\/`, `/`)\n\t\n\tvar playerData map[string]interface{}\n\tif err := json.Unmarshal([]byte(playerJSON), &playerData); err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Libvio] 解析player_aaaa失败: %v, JSON: %s\", err, playerJSON)\n\t\t}\n\t\treturn nil\n\t}\n\t\n\t// 提取URL\n\tpanURL, ok := playerData[\"url\"].(string)\n\tif !ok || panURL == \"\" {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Libvio] player_aaaa中没有url字段\")\n\t\t}\n\t\treturn nil\n\t}\n\t\n\t// 提取网盘类型\n\tfrom, _ := playerData[\"from\"].(string)\n\tlinkType := p.mapPanType(from, panURL)\n\t\n\tlink := &model.Link{\n\t\tURL:  panURL,\n\t\tType: linkType,\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Libvio] 提取到网盘链接: %s (from=%s, type=%s)\", panURL, from, linkType)\n\t}\n\t\n\t// 缓存结果\n\tp.playCache.Store(playURL, link)\n\t\n\t// 设置缓存过期\n\tgo func() {\n\t\ttime.Sleep(p.cacheTTL)\n\t\tp.playCache.Delete(playURL)\n\t}()\n\t\n\treturn link\n}\n\n// mapPanType 映射网盘类型\nfunc (p *LibvioPlugin) mapPanType(from string, url string) string {\n\t// 首先根据from字段判断\n\tswitch strings.ToLower(from) {\n\tcase \"uc\":\n\t\treturn \"uc\"\n\tcase \"quark\":\n\t\treturn \"quark\"\n\tcase \"baidu\":\n\t\treturn \"baidu\"\n\tcase \"aliyun\", \"alipan\":\n\t\treturn \"aliyun\"\n\tcase \"xunlei\", \"thunder\":\n\t\treturn \"xunlei\"\n\tcase \"115\":\n\t\treturn \"115\"\n\tcase \"123\", \"123pan\":\n\t\treturn \"123\"\n\t}\n\t\n\t// 如果from字段不明确，根据URL判断\n\turl = strings.ToLower(url)\n\tif strings.Contains(url, \"drive.uc.cn\") {\n\t\treturn \"uc\"\n\t} else if strings.Contains(url, \"pan.quark.cn\") {\n\t\treturn \"quark\"\n\t} else if strings.Contains(url, \"pan.baidu.com\") {\n\t\treturn \"baidu\"\n\t} else if strings.Contains(url, \"alipan.com\") || strings.Contains(url, \"aliyundrive.com\") {\n\t\treturn \"aliyun\"\n\t} else if strings.Contains(url, \"pan.xunlei.com\") {\n\t\treturn \"xunlei\"\n\t} else if strings.Contains(url, \"115.com\") {\n\t\treturn \"115\"\n\t} else if strings.Contains(url, \"123pan.com\") || strings.Contains(url, \"123684.com\") {\n\t\treturn \"123\"\n\t} else if strings.Contains(url, \"cloud.189.cn\") {\n\t\treturn \"tianyi\"\n\t}\n\t\n\t// 默认返回others\n\treturn \"others\"\n}\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewLibvioPlugin())\n}"
  },
  {
    "path": "plugin/lou1/lou1.go",
    "content": "package lou1\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"hash/crc32\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tpluginName      = \"lou1\"\n\tdefaultPriority = 1\n\n\tbaseURL             = \"https://www.1lou.me\"\n\tsearchPathFormat    = baseURL + \"/search-%s.htm\"\n\trequestTimeout      = 12 * time.Second\n\tdetailTimeout       = 12 * time.Second\n\tmaxRequestRetries   = 3\n\tretryBaseDelay      = 200 * time.Millisecond\n\tsearchLimit         = 12\n\tdetailWorkers       = 6\n\thttpMaxIdleConns    = 64\n\thttpMaxIdlePerHost  = 16\n\thttpMaxConnsPerHost = 32\n)\n\nvar (\n\tlinkPatterns = []struct {\n\t\treg *regexp.Regexp\n\t\ttyp string\n\t}{\n\t\t{regexp.MustCompile(`https?://pan\\.quark\\.cn/(?:s|g)/[0-9A-Za-z]+`), \"quark\"},\n\t\t{regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9A-Za-z\\-_?=&]+`), \"baidu\"},\n\t\t{regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9A-Za-z\\-_?=&]+`), \"xunlei\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?(aliyundrive\\.com|alipan\\.com)/s/[0-9A-Za-z]+`), \"aliyun\"},\n\t\t{regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9A-Za-z]+`), \"uc\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?(123pan\\.com|123pan\\.cn|123684\\.com|123685\\.com|123912\\.com|123592\\.com)/s/[0-9A-Za-z]+`), \"123\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?mypikpak\\.com/s/[0-9A-Za-z]+`), \"pikpak\"},\n\t\t{regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9A-Za-z]+`), \"magnet\"},\n\t\t{regexp.MustCompile(`ed2k://[^\\s<>\"']+`), \"ed2k\"},\n\t}\n\n\tpasswordPatterns = []*regexp.Regexp{\n\t\tregexp.MustCompile(`提取码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`密码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`pwd\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`code\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t}\n\n\ttextURLRegex  = regexp.MustCompile(`https?://[^\\s<>\"']+`)\n\tthreadIDRegex = regexp.MustCompile(`thread-(\\d+)`)\n)\n\n// Lou1Plugin implements BT之家 1lou 插件\ntype Lou1Plugin struct {\n\t*plugin.BaseAsyncPlugin\n\tclient *http.Client\n}\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewLou1Plugin())\n}\n\n// NewLou1Plugin creates plugin instance\nfunc NewLou1Plugin() *Lou1Plugin {\n\treturn &Lou1Plugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),\n\t\tclient:          newHTTPClient(),\n\t}\n}\n\n// Search compatibility helper\nfunc (p *Lou1Plugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult entry\nfunc (p *Lou1Plugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\nfunc (p *Lou1Plugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif p.client != nil {\n\t\tclient = p.client\n\t}\n\n\tsearchKeyword := strings.TrimSpace(keyword)\n\tif searchKeyword == \"\" {\n\t\treturn nil, fmt.Errorf(\"[%s] 关键词不能为空\", p.Name())\n\t}\n\n\tthreads, err := p.fetchSearchResults(client, searchKeyword)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(threads) == 0 {\n\t\treturn nil, fmt.Errorf(\"[%s] 未找到相关结果\", p.Name())\n\t}\n\n\tvar (\n\t\twg      sync.WaitGroup\n\t\tresultM sync.Mutex\n\t\tresults []model.SearchResult\n\t\tsem     = make(chan struct{}, detailWorkers)\n\t)\n\n\tfor _, thread := range threads {\n\t\tthread := thread\n\t\twg.Add(1)\n\t\tsem <- struct{}{}\n\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tdefer func() { <-sem }()\n\n\t\t\tdetail, err := p.fetchDetail(client, thread.URL)\n\t\t\tif err != nil || len(detail.links) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tcontent := thread.Summary\n\t\t\tif content == \"\" {\n\t\t\t\tcontent = detail.description\n\t\t\t}\n\n\t\t\tresult := model.SearchResult{\n\t\t\t\tUniqueID: buildUniqueID(thread.URL),\n\t\t\t\tTitle:    thread.Title,\n\t\t\t\tContent:  strings.TrimSpace(content),\n\t\t\t\tLinks:    detail.links,\n\t\t\t\tTags:     mergeTags(thread.Tags, detail.tags),\n\t\t\t\tChannel:  \"\",\n\t\t\t\tDatetime: detail.datetime,\n\t\t\t}\n\n\t\t\tresultM.Lock()\n\t\t\tresults = append(results, result)\n\t\t\tresultM.Unlock()\n\t\t}()\n\t}\n\n\twg.Wait()\n\n\tif len(results) == 0 {\n\t\treturn nil, fmt.Errorf(\"[%s] 未能抓取到有效网盘链接\", p.Name())\n\t}\n\n\treturn plugin.FilterResultsByKeyword(results, searchKeyword), nil\n}\n\ntype searchThread struct {\n\tTitle   string\n\tURL     string\n\tTags    []string\n\tSummary string\n}\n\nfunc (p *Lou1Plugin) fetchSearchResults(client *http.Client, keyword string) ([]searchThread, error) {\n\tsearchURL := fmt.Sprintf(searchPathFormat, encodeKeyword(keyword))\n\n\tctx, cancel := context.WithTimeout(context.Background(), requestTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建搜索请求失败: %w\", p.Name(), err)\n\t}\n\tsetHTMLHeaders(req, baseURL)\n\n\tresp, err := p.doRequestWithRetry(req, client, maxRequestRetries)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\n\tvar threads []searchThread\n\tdoc.Find(\"ul.threadlist li.thread\").Each(func(_ int, li *goquery.Selection) {\n\t\tif len(threads) >= searchLimit {\n\t\t\treturn\n\t\t}\n\n\t\tsubject := li.Find(\".subject a\").First()\n\t\thref, exists := subject.Attr(\"href\")\n\t\tif !exists || strings.TrimSpace(href) == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\ttitle := strings.TrimSpace(subject.Text())\n\t\tif title == \"\" {\n\t\t\treturn\n\t\t}\n\t\tif !strings.Contains(title, \"夸克\") {\n\t\t\treturn\n\t\t}\n\n\t\tthreadURL := toAbsoluteURL(href)\n\n\t\tvar tags []string\n\t\tli.Find(\".subject a.badge\").Each(func(_ int, tagNode *goquery.Selection) {\n\t\t\ttag := strings.TrimSpace(tagNode.Text())\n\t\t\tif tag != \"\" {\n\t\t\t\ttags = append(tags, tag)\n\t\t\t}\n\t\t})\n\n\t\tsummary := strings.TrimSpace(li.Find(\"p.note\").Text())\n\n\t\tthreads = append(threads, searchThread{\n\t\t\tTitle:   title,\n\t\t\tURL:     threadURL,\n\t\t\tTags:    tags,\n\t\t\tSummary: summary,\n\t\t})\n\t})\n\n\treturn threads, nil\n}\n\ntype detailResult struct {\n\tlinks       []model.Link\n\tdatetime    time.Time\n\ttags        []string\n\tdescription string\n}\n\nfunc (p *Lou1Plugin) fetchDetail(client *http.Client, detailURL string) (detailResult, error) {\n\tctx, cancel := context.WithTimeout(context.Background(), detailTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil)\n\tif err != nil {\n\t\treturn detailResult{}, fmt.Errorf(\"[%s] 创建详情页请求失败: %w\", p.Name(), err)\n\t}\n\tsetHTMLHeaders(req, baseURL)\n\n\tresp, err := p.doRequestWithRetry(req, client, maxRequestRetries)\n\tif err != nil {\n\t\treturn detailResult{}, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn detailResult{}, fmt.Errorf(\"[%s] 详情页返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn detailResult{}, fmt.Errorf(\"[%s] 解析详情页失败: %w\", p.Name(), err)\n\t}\n\n\tcontent := doc.Find(\"div.message[isfirst='1']\")\n\tif content.Length() == 0 {\n\t\tcontent = doc.Find(\".message\")\n\t}\n\tif content.Length() == 0 {\n\t\tcontent = doc.Selection\n\t}\n\n\tcontent.Find(\"script, style\").Remove()\n\n\tlinks := extractLinksFromSelection(content)\n\tlinks = filterQuarkLinks(links)\n\tdescription := strings.TrimSpace(doc.Find(\"meta[name='description']\").AttrOr(\"content\", \"\"))\n\tif description == \"\" {\n\t\tdescription = truncateString(strings.TrimSpace(content.Text()), 200)\n\t}\n\n\ttags := collectDetailTags(doc)\n\tdatetime := extractPostDatetime(doc)\n\n\treturn detailResult{\n\t\tlinks:       links,\n\t\tdatetime:    datetime,\n\t\ttags:        tags,\n\t\tdescription: description,\n\t}, nil\n}\n\nfunc collectDetailTags(doc *goquery.Document) []string {\n\ttagSet := make(map[string]struct{})\n\tdoc.Find(\".breadcrumb a, ol.breadcrumb a\").Each(func(_ int, s *goquery.Selection) {\n\t\ttext := strings.TrimSpace(s.Text())\n\t\tif text == \"\" || text == \"首页\" {\n\t\t\treturn\n\t\t}\n\t\ttagSet[text] = struct{}{}\n\t})\n\tdoc.Find(\"h4 a.badge\").Each(func(_ int, s *goquery.Selection) {\n\t\ttext := strings.TrimSpace(s.Text())\n\t\tif text != \"\" {\n\t\t\ttagSet[text] = struct{}{}\n\t\t}\n\t})\n\n\tvar tags []string\n\tfor tag := range tagSet {\n\t\ttags = append(tags, tag)\n\t}\n\treturn tags\n}\n\nfunc extractPostDatetime(doc *goquery.Document) time.Time {\n\tdateText := strings.TrimSpace(doc.Find(\".card-thread span.date\").First().Text())\n\tlayouts := []string{\n\t\t\"2006-01-02 15:04\",\n\t\ttime.RFC3339,\n\t\t\"2006/1/2 15:04\",\n\t}\n\tfor _, layout := range layouts {\n\t\tif t, err := time.ParseInLocation(layout, dateText, time.Local); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\treturn time.Now()\n}\n\nfunc extractLinksFromSelection(sel *goquery.Selection) []model.Link {\n\tvar (\n\t\tresults []model.Link\n\t\tseen    = make(map[string]struct{})\n\t)\n\n\tsel.Find(\"a[href]\").Each(func(_ int, node *goquery.Selection) {\n\t\thref, ok := node.Attr(\"href\")\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\n\t\tlinkType, normalized := classifyLink(href)\n\t\tif linkType == \"\" || normalized == \"\" {\n\t\t\treturn\n\t\t}\n\t\tif _, exists := seen[normalized]; exists {\n\t\t\treturn\n\t\t}\n\n\t\tpassword := extractPassword(node)\n\t\tresults = append(results, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      normalized,\n\t\t\tPassword: password,\n\t\t})\n\t\tseen[normalized] = struct{}{}\n\t})\n\n\ttext := sel.Text()\n\tfor _, loc := range textURLRegex.FindAllStringIndex(text, -1) {\n\t\traw := text[loc[0]:loc[1]]\n\t\tlinkType, normalized := classifyLink(raw)\n\t\tif linkType == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := seen[normalized]; exists {\n\t\t\tcontinue\n\t\t}\n\n\t\tcontext := substring(text, loc[0]-80, loc[1]+80)\n\t\tpassword := matchPassword(context)\n\t\tresults = append(results, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      normalized,\n\t\t\tPassword: password,\n\t\t})\n\t\tseen[normalized] = struct{}{}\n\t}\n\n\treturn results\n}\n\nfunc classifyLink(raw string) (string, string) {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tfor _, pattern := range linkPatterns {\n\t\tif loc := pattern.reg.FindString(raw); loc != \"\" {\n\t\t\treturn pattern.typ, loc\n\t\t}\n\t}\n\treturn \"\", \"\"\n}\n\nfunc extractPassword(node *goquery.Selection) string {\n\tcandidates := []string{node.Text()}\n\tif title, ok := node.Attr(\"title\"); ok {\n\t\tcandidates = append(candidates, title)\n\t}\n\tif parent := node.Parent(); parent != nil && parent.Length() > 0 {\n\t\tcandidates = append(candidates, parent.Text())\n\t\tif next := parent.Next(); next.Length() > 0 {\n\t\t\tcandidates = append(candidates, next.Text())\n\t\t}\n\t}\n\tif sibling := node.Next(); sibling.Length() > 0 {\n\t\tcandidates = append(candidates, sibling.Text())\n\t}\n\tfor _, text := range candidates {\n\t\tif pwd := matchPassword(text); pwd != \"\" {\n\t\t\treturn pwd\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc matchPassword(text string) string {\n\ttext = strings.TrimSpace(text)\n\tif text == \"\" {\n\t\treturn \"\"\n\t}\n\tfor _, pattern := range passwordPatterns {\n\t\tif matches := pattern.FindStringSubmatch(text); len(matches) > 1 {\n\t\t\treturn strings.TrimSpace(matches[1])\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc substring(text string, start, end int) string {\n\tif start < 0 {\n\t\tstart = 0\n\t}\n\tif end > len(text) {\n\t\tend = len(text)\n\t}\n\treturn text[start:end]\n}\n\nfunc encodeKeyword(keyword string) string {\n\tkeyword = strings.TrimSpace(keyword)\n\tif keyword == \"\" {\n\t\treturn \"\"\n\t}\n\tvar builder strings.Builder\n\tfor _, b := range []byte(keyword) {\n\t\tbuilder.WriteByte('_')\n\t\tbuilder.WriteString(strings.ToUpper(fmt.Sprintf(\"%02x\", b)))\n\t}\n\treturn builder.String()\n}\n\nfunc toAbsoluteURL(href string) string {\n\thref = strings.TrimSpace(href)\n\tif href == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(href, \"http\") {\n\t\treturn href\n\t}\n\tif strings.HasPrefix(href, \"//\") {\n\t\treturn \"https:\" + href\n\t}\n\treturn fmt.Sprintf(\"%s/%s\", baseURL, strings.TrimLeft(href, \"./\"))\n}\n\nfunc mergeTags(a []string, b []string) []string {\n\ttagSet := make(map[string]struct{})\n\tfor _, tag := range a {\n\t\tif tag = strings.TrimSpace(tag); tag != \"\" {\n\t\t\ttagSet[tag] = struct{}{}\n\t\t}\n\t}\n\tfor _, tag := range b {\n\t\tif tag = strings.TrimSpace(tag); tag != \"\" {\n\t\t\ttagSet[tag] = struct{}{}\n\t\t}\n\t}\n\tvar tags []string\n\tfor tag := range tagSet {\n\t\ttags = append(tags, tag)\n\t}\n\treturn tags\n}\n\nfunc buildUniqueID(detailURL string) string {\n\tid := \"\"\n\tif matches := threadIDRegex.FindStringSubmatch(detailURL); len(matches) > 1 {\n\t\tid = matches[1]\n\t}\n\tif id == \"\" {\n\t\tsum := crc32.ChecksumIEEE([]byte(detailURL))\n\t\tid = fmt.Sprintf(\"%d\", sum)\n\t}\n\treturn fmt.Sprintf(\"%s-%s\", pluginName, id)\n}\n\nfunc truncateString(text string, length int) string {\n\trunes := []rune(strings.TrimSpace(text))\n\tif len(runes) <= length {\n\t\treturn string(runes)\n\t}\n\treturn string(runes[:length])\n}\n\nfunc filterQuarkLinks(links []model.Link) []model.Link {\n\tif len(links) == 0 {\n\t\treturn links\n\t}\n\tresult := links[:0]\n\tfor _, link := range links {\n\t\tif link.Type == \"quark\" {\n\t\t\tresult = append(result, link)\n\t\t}\n\t}\n\treturn result\n}\n\nfunc newHTTPClient() *http.Client {\n\treturn &http.Client{\n\t\tTimeout: requestTimeout,\n\t\tTransport: &http.Transport{\n\t\t\tMaxIdleConns:        httpMaxIdleConns,\n\t\t\tMaxIdleConnsPerHost: httpMaxIdlePerHost,\n\t\t\tMaxConnsPerHost:     httpMaxConnsPerHost,\n\t\t\tIdleConnTimeout:     90 * time.Second,\n\t\t\tTLSHandshakeTimeout: 10 * time.Second,\n\t\t},\n\t}\n}\n\nfunc setHTMLHeaders(req *http.Request, referer string) {\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", referer)\n}\n\nfunc (p *Lou1Plugin) doRequestWithRetry(req *http.Request, client *http.Client, maxRetries int) (*http.Response, error) {\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxRetries; attempt++ {\n\t\tresp, err := client.Do(req.Clone(req.Context()))\n\t\tif err == nil && resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t\tif attempt < maxRetries-1 {\n\t\t\ttime.Sleep(retryBaseDelay * time.Duration(1<<attempt))\n\t\t}\n\t}\n\treturn nil, fmt.Errorf(\"重试 %d 次后失败: %w\", maxRetries, lastErr)\n}\n"
  },
  {
    "path": "plugin/meitizy/json结构分析.md",
    "content": "# Meitizy（美体资源）插件JSON API结构分析\n\n## 网站概述\n- **网站名称**: 美体资源\n- **API域名**: https://video.451024.xyz\n- **类型**: JSON API接口，提供影视资源网盘链接搜索\n- **接口类型**: RESTful API\n\n## API流程概述\n\n### 搜索接口\n- **请求URL**: `https://video.451024.xyz/api/search`\n- **请求方法**: POST\n- **请求头**: \n  - `Content-Type: application/json`\n  - `User-Agent`: 标准浏览器User-Agent\n- **请求体**: JSON格式\n  ```json\n  {\n    \"title\": \"遮天\",\n    \"page\": 1,\n    \"size\": 1000\n  }\n  ```\n- **特点**: 直接返回JSON数据，无需解析HTML\n\n## 请求参数说明\n\n### 请求体字段\n| 字段 | 类型 | 必填 | 说明 | 示例 |\n|------|------|------|------|------|\n| `title` | string | 是 | 搜索关键词 | \"遮天\" |\n| `page` | int | 是 | 页码，从1开始 | 1 |\n| `size` | int | 是 | 每页返回数量，最大1000 | 1000 |\n\n## 响应结构\n\n### 响应格式\n```json\n{\n  \"data\": [\n    {\n      \"id\": 458790,\n      \"title\": \"遮天 年番3 (2025) 4K 更新至137集\",\n      \"content\": \"\",\n      \"link\": \"https://www.alipan.com/s/6NB4Wop9fJc\",\n      \"link_type\": \"alipan\",\n      \"tags\": \"\",\n      \"created_at\": \"2025-11-25T22:59:53.000Z\",\n      \"updated_at\": \"2025-11-25T23:39:18.000Z\"\n    }\n  ],\n  \"total\": 441\n}\n```\n\n### 响应字段说明\n\n#### 根对象\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| `data` | array | 搜索结果数组 |\n| `total` | int | 总结果数 |\n\n#### data数组中的对象\n| 字段 | 类型 | 说明 | 示例 |\n|------|------|------|------|\n| `id` | int | 资源唯一ID | 458790 |\n| `title` | string | 资源标题 | \"遮天 年番3 (2025) 4K 更新至137集\" |\n| `content` | string | 资源描述/简介 | \"冰冷与黑暗并存的宇宙深处...\" |\n| `link` | string | 网盘链接URL | \"https://www.alipan.com/s/6NB4Wop9fJc\" |\n| `link_type` | string | 网盘类型标识 | \"alipan\", \"xunlei\", \"baidu\", \"quark\" |\n| `tags` | string | 标签（可能为空） | \"\" |\n| `created_at` | string | 创建时间（ISO 8601格式） | \"2025-11-25T22:59:53.000Z\" |\n| `updated_at` | string | 更新时间（ISO 8601格式） | \"2025-11-25T23:39:18.000Z\" |\n\n## 网盘类型映射\n\n### link_type 到系统网盘类型的映射\n| API返回的link_type | 系统网盘类型 | 说明 |\n|-------------------|------------|------|\n| `alipan` | `aliyun` | 阿里云盘 |\n| `xunlei` | `xunlei` | 迅雷网盘 |\n| `baidu` | `baidu` | 百度网盘 |\n| `quark` | `quark` | 夸克网盘 |\n| 其他 | `others` | 其他类型 |\n\n## 数据提取要点\n\n### 1. 请求构建\n- 使用POST方法\n- 设置Content-Type为application/json\n- 请求体为JSON格式\n- page参数从1开始\n- size建议设置为1000（最大返回数量）\n\n### 2. 响应解析\n- 解析JSON响应\n- 提取data数组\n- 遍历每个item，转换为标准SearchResult格式\n\n### 3. 字段映射\n- `id` → `UniqueID` (格式: `{插件名}-{id}`)\n- `title` → `Title`\n- `content` → `Content`\n- `link` → `Links[0].URL`\n- `link_type` → `Links[0].Type` (需要映射)\n- `created_at` → `Datetime` (需要解析ISO 8601格式)\n- `tags` → `Tags` (如果为空则忽略)\n\n### 4. 时间解析\n- 格式: ISO 8601格式 `2025-11-25T22:59:53.000Z`\n- 优先使用 `created_at`，如果为空则使用 `updated_at`\n- 使用 `time.Parse(time.RFC3339, timeStr)` 解析\n\n### 5. 链接处理\n- 直接使用API返回的link字段\n- 根据link_type映射到系统网盘类型\n- 检查链接有效性（非空且为有效URL）\n\n## 特殊处理\n\n### 错误处理\n- HTTP状态码检查（200为成功）\n- JSON解析错误处理\n- 空结果处理\n- 网络超时重试\n\n### 性能优化\n- 使用连接池（HTTP Transport配置）\n- 预编译JSON解析器\n- 批量处理结果\n- 关键词过滤优化\n\n### 注意事项\n1. **API限频**: 避免请求过于频繁\n2. **编码**: 处理中文关键词的JSON编码\n3. **超时**: 设置合理的请求超时时间\n4. **重试**: 实现重试机制提高稳定性\n5. **缓存**: 合理使用缓存减少API请求\n\n## 实现建议\n\n### 1. 请求优化\n- 使用优化的HTTP客户端（连接池）\n- 设置合理的超时时间\n- 实现重试机制\n\n### 2. 响应处理\n- 使用项目统一的JSON工具（`pansou/util/json`）\n- 错误处理和降级策略\n- 数据验证和清理\n\n### 3. 性能优化\n- 关键词过滤（标题匹配）\n- 结果去重\n- 合理的并发控制\n\n"
  },
  {
    "path": "plugin/meitizy/meitizy.go",
    "content": "package meitizy\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\nconst (\n\tPluginName      = \"meitizy\"\n\tDisplayName     = \"美体资源\"\n\tDescription     = \"美体资源 - 影视资源网盘链接搜索\"\n\tBaseURL         = \"https://video.451024.xyz\"\n\tSearchPath      = \"/api/search\"\n\tUserAgent       = \"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\"\n\tMaxResults      = 100\n\tRequestTimeout  = 30 * time.Second\n\tMaxPageSize     = 1000 // API支持的最大size参数\n\t\n\t// HTTP连接池配置（性能优化）\n\tMaxIdleConns        = 100\n\tMaxIdleConnsPerHost = 30\n\tMaxConnsPerHost     = 50\n\tIdleConnTimeout     = 90 * time.Second\n\tTLSHandshakeTimeout = 10 * time.Second\n\tExpectContinueTimeout = 1 * time.Second\n)\n\n// MeitizyPlugin 美体资源插件\ntype MeitizyPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client // 优化的HTTP客户端（连接池）\n}\n\n// API请求结构\ntype searchRequest struct {\n\tTitle string `json:\"title\"`\n\tPage  int    `json:\"page\"`\n\tSize  int    `json:\"size\"`\n}\n\n// API响应结构\ntype searchResponse struct {\n\tData  []apiItem `json:\"data\"`\n\tTotal int       `json:\"total\"`\n}\n\n// API返回的单个结果项\ntype apiItem struct {\n\tID        int    `json:\"id\"`\n\tTitle     string `json:\"title\"`\n\tContent   string `json:\"content\"`\n\tLink      string `json:\"link\"`\n\tLinkType  string `json:\"link_type\"`\n\tTags      string `json:\"tags\"`\n\tCreatedAt string `json:\"created_at\"`\n\tUpdatedAt string `json:\"updated_at\"`\n}\n\n// init 注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewMeitizyPlugin())\n}\n\n// NewMeitizyPlugin 创建新的美体资源插件实例\nfunc NewMeitizyPlugin() *MeitizyPlugin {\n\tp := &MeitizyPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(PluginName, 2), // 质量良好，优先级2\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n\n\treturn p\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端（连接池配置）\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:          MaxIdleConns,\n\t\tMaxIdleConnsPerHost:   MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:       MaxConnsPerHost,\n\t\tIdleConnTimeout:       IdleConnTimeout,\n\t\tTLSHandshakeTimeout:   TLSHandshakeTimeout,\n\t\tExpectContinueTimeout: ExpectContinueTimeout,\n\t\tForceAttemptHTTP2:     true,\n\t\tDisableKeepAlives:     false,\n\t}\n\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   RequestTimeout,\n\t}\n}\n\n// Name 插件名称\nfunc (p *MeitizyPlugin) Name() string {\n\treturn PluginName\n}\n\n// DisplayName 插件显示名称\nfunc (p *MeitizyPlugin) DisplayName() string {\n\treturn DisplayName\n}\n\n// Description 插件描述\nfunc (p *MeitizyPlugin) Description() string {\n\treturn Description\n}\n\n// Search 搜索接口\nfunc (p *MeitizyPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *MeitizyPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 搜索实现\nfunc (p *MeitizyPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 构建请求体\n\treqBody := searchRequest{\n\t\tTitle: keyword,\n\t\tPage:  1,\n\t\tSize:  MaxPageSize,\n\t}\n\n\t// 序列化请求体\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 序列化请求体失败: %w\", p.Name(), err)\n\t}\n\n\t// 构建请求URL\n\tapiURL := BaseURL + SearchPath\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), RequestTimeout)\n\tdefer cancel()\n\n\t// 创建POST请求\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", apiURL, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\n\t// 设置请求头\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\n\t// 使用优化的客户端发送请求（带重试）\n\tresp, err := p.doRequestWithRetry(req, p.optimizedClient)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求HTTP状态错误: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\t// 读取响应体\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 读取响应体失败: %w\", p.Name(), err)\n\t}\n\n\t// 解析JSON响应\n\tvar apiResp searchResponse\n\tif err := json.Unmarshal(body, &apiResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] JSON解析失败: %w\", p.Name(), err)\n\t}\n\n\t// 转换为标准格式\n\tresults := p.convertToSearchResults(apiResp.Data)\n\n\t// 关键词过滤（标准网盘插件需要过滤）\n\tfilteredResults := plugin.FilterResultsByKeyword(results, keyword)\n\n\treturn filteredResults, nil\n}\n\n// convertToSearchResults 将API响应转换为标准SearchResult格式\nfunc (p *MeitizyPlugin) convertToSearchResults(items []apiItem) []model.SearchResult {\n\tresults := make([]model.SearchResult, 0, len(items))\n\n\tfor _, item := range items {\n\t\t// 跳过无效链接\n\t\tif item.Link == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 解析发布时间\n\t\tpublishTime := p.parseTime(item.CreatedAt)\n\t\tif publishTime.IsZero() {\n\t\t\tpublishTime = p.parseTime(item.UpdatedAt)\n\t\t}\n\t\tif publishTime.IsZero() {\n\t\t\tpublishTime = time.Now()\n\t\t}\n\n\t\t// 映射网盘类型\n\t\tlinkType := p.mapLinkType(item.LinkType)\n\t\t// 如果无法从link_type识别，尝试从URL中识别\n\t\tif linkType == \"others\" {\n\t\t\tlinkType = p.determineCloudTypeFromURL(item.Link)\n\t\t}\n\n\t\t// 构建链接\n\t\tlinks := []model.Link{\n\t\t\t{\n\t\t\t\tType:     linkType,\n\t\t\t\tURL:      item.Link,\n\t\t\t\tPassword: \"\", // API未提供密码信息\n\t\t\t},\n\t\t}\n\n\t\t// 构建标签\n\t\tvar tags []string\n\t\tif item.Tags != \"\" {\n\t\t\ttags = []string{item.Tags}\n\t\t}\n\n\t\tresult := model.SearchResult{\n\t\t\tUniqueID: fmt.Sprintf(\"%s-%d\", p.Name(), item.ID),\n\t\t\tTitle:    item.Title,\n\t\t\tContent:  item.Content,\n\t\t\tChannel:  \"\", // 插件搜索结果必须为空字符串\n\t\t\tDatetime: publishTime,\n\t\t\tLinks:    links,\n\t\t\tTags:     tags,\n\t\t}\n\n\t\tresults = append(results, result)\n\t}\n\n\treturn results\n}\n\n// mapLinkType 映射API返回的link_type到系统网盘类型\nfunc (p *MeitizyPlugin) mapLinkType(apiLinkType string) string {\n\tswitch strings.ToLower(apiLinkType) {\n\tcase \"alipan\":\n\t\treturn \"aliyun\"\n\tcase \"xunlei\":\n\t\treturn \"xunlei\"\n\tcase \"baidu\":\n\t\treturn \"baidu\"\n\tcase \"quark\":\n\t\treturn \"quark\"\n\tcase \"uc\":\n\t\treturn \"uc\"\n\tcase \"115\":\n\t\treturn \"115\"\n\tcase \"123\":\n\t\treturn \"123\"\n\tcase \"tianyi\":\n\t\treturn \"tianyi\"\n\tcase \"mobile\":\n\t\treturn \"mobile\"\n\tcase \"pikpak\":\n\t\treturn \"pikpak\"\n\tdefault:\n\t\t// 如果无法识别，返回others，后续会从URL中判断\n\t\treturn \"others\"\n\t}\n}\n\n// determineCloudTypeFromURL 从URL中自动识别网盘类型（备选方案）\nfunc (p *MeitizyPlugin) determineCloudTypeFromURL(url string) string {\n\tswitch {\n\tcase strings.Contains(url, \"pan.quark.cn\"):\n\t\treturn \"quark\"\n\tcase strings.Contains(url, \"drive.uc.cn\"):\n\t\treturn \"uc\"\n\tcase strings.Contains(url, \"pan.baidu.com\"):\n\t\treturn \"baidu\"\n\tcase strings.Contains(url, \"aliyundrive.com\") || strings.Contains(url, \"alipan.com\") || strings.Contains(url, \"www.alipan.com\"):\n\t\treturn \"aliyun\"\n\tcase strings.Contains(url, \"pan.xunlei.com\"):\n\t\treturn \"xunlei\"\n\tcase strings.Contains(url, \"cloud.189.cn\"):\n\t\treturn \"tianyi\"\n\tcase strings.Contains(url, \"caiyun.139.com\"):\n\t\treturn \"mobile\"\n\tcase strings.Contains(url, \"115.com\") || strings.Contains(url, \"115cdn.com\") || strings.Contains(url, \"anxia.com\"):\n\t\treturn \"115\"\n\tcase strings.Contains(url, \"123684.com\") || strings.Contains(url, \"123685.com\") ||\n\t\tstrings.Contains(url, \"123912.com\") || strings.Contains(url, \"123pan.com\") ||\n\t\tstrings.Contains(url, \"123pan.cn\") || strings.Contains(url, \"123592.com\"):\n\t\treturn \"123\"\n\tcase strings.Contains(url, \"mypikpak.com\"):\n\t\treturn \"pikpak\"\n\tcase strings.Contains(url, \"magnet:\"):\n\t\treturn \"magnet\"\n\tcase strings.Contains(url, \"ed2k://\"):\n\t\treturn \"ed2k\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n// parseTime 解析ISO 8601格式的时间字符串\nfunc (p *MeitizyPlugin) parseTime(timeStr string) time.Time {\n\tif timeStr == \"\" {\n\t\treturn time.Time{}\n\t}\n\n\t// 尝试多种时间格式\n\ttimeFormats := []string{\n\t\ttime.RFC3339,                    // 2006-01-02T15:04:05Z07:00\n\t\t\"2006-01-02T15:04:05.000Z\",     // 2025-11-25T22:59:53.000Z\n\t\t\"2006-01-02T15:04:05Z\",         // 2006-01-02T15:04:05Z\n\t\t\"2006-01-02 15:04:05\",         // 2006-01-02 15:04:05\n\t\t\"2006-01-02\",                   // 2006-01-02\n\t}\n\n\tfor _, format := range timeFormats {\n\t\tif t, err := time.Parse(format, timeStr); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\n\treturn time.Time{}\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *MeitizyPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\n\t\t// 克隆请求避免并发问题（需要重新创建body）\n\t\treqClone := req.Clone(req.Context())\n\t\tif req.Body != nil {\n\t\t\t// 读取原始body\n\t\t\tbodyBytes, err := io.ReadAll(req.Body)\n\t\t\tif err != nil {\n\t\t\t\tlastErr = err\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treq.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))\n\t\t\treqClone.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))\n\t\t}\n\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\n\treturn nil, fmt.Errorf(\"[%s] 重试 %d 次后仍然失败: %w\", p.Name(), maxRetries, lastErr)\n}\n\n"
  },
  {
    "path": "plugin/miaoso/json结构分析.md",
    "content": "# Miaoso API JSON 结构分析\n\n## 概述\n\nMiaoso 是一个网盘搜索平台，提供 RESTful API 接口进行内容搜索。本文档基于对 `1.txt` 文件中 API 响应数据的分析，详细说明 Miaoso API 的请求格式和响应结构。\n\n## API 接口信息\n\n### 请求地址\n- **URL**: `https://miaosou.fun/api/secendsearch`\n- **方法**: GET\n- **参数**:\n  - `name`: 搜索关键词（URL编码）\n  - `pageNo`: 页码（从1开始）\n\n### 请求头设置\n```http\nGET /api/secendsearch?name=%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0&pageNo=1 HTTP/1.1\nHost: miaosou.fun\nReferer: https://miaosou.fun/info?searchKey=%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0\nUser-Agent: 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\n```\n\n## 响应数据结构\n\n### 根级响应结构\n\n```json\n{\n  \"code\": 200,\n  \"msg\": \"SUCCESS\", \n  \"data\": {\n    \"total\": 10000,\n    \"list\": []\n  }\n}\n```\n\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| `code` | number | 响应状态码，200表示成功 |\n| `msg` | string | 响应消息，成功时为\"SUCCESS\" |\n| `data` | object | 搜索结果数据对象 |\n\n### data 对象结构\n\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| `total` | number | 搜索结果总数 |\n| `list` | array | 搜索结果列表 |\n\n### list 数组中的结果项结构\n\n每个搜索结果项包含以下字段：\n\n| 字段名 | 类型 | 必填 | 示例值 | 说明 |\n|--------|------|------|--------|------|\n| `id` | string | ✓ | `\"6778d087cfbc8d3b625ab777\"` | 资源唯一标识 |\n| `name` | string | ✓ | `\"<span style=\\\"color: red;\\\">凡人</span><span style=\\\"color: red;\\\">修仙</span>记\"` | 资源名称，包含HTML高亮标签 |\n| `url` | string | ✓ | `\"c31Z+932nn/F5/KFKdkp6JJkqq6efxy9GL444RG8PJELZGyXtrJvn7J+OHV7v6XL\"` | 加密的分享链接 |\n| `type` | string/null | - | `\"folder\"` | 资源类型，如folder或null |\n| `from` | string | ✓ | `\"quark\"` | 来源平台（quark、baidu等） |\n| `content` | string/null | - | `null` | 资源描述内容 |\n| `gmtCreate` | string | ✓ | `\"2025-01-04 14:09:11\"` | 资源创建时间 |\n| `gmtShare` | string | ✓ | `\"2025-01-04 14:09:11\"` | 资源分享时间 |\n| `fileCount` | number | ✓ | `1` | 文件数量 |\n| `creatorId` | string/null | - | `\"kf0009\"` | 创建者ID |\n| `creatorName` | string | ✓ | `\"夸父0009\"` | 创建者昵称 |\n| `fileInfos` | array | ✓ | `[]` | 文件信息列表 |\n\n### fileInfos 数组中的文件信息结构\n\n| 字段名 | 类型 | 必填 | 示例值 | 说明 |\n|--------|------|------|--------|------|\n| `category` | string/null | - | `null` | 文件分类 |\n| `fileExtension` | string/null | - | `null` | 文件扩展名 |\n| `fileId` | string | ✓ | `\"6778d087cfbc8d3b625ab777\"` | 文件ID |\n| `fileName` | string | ✓ | `\"凡人修仙记\"` | 文件名称 |\n| `type` | string/null | - | `\"folder\"` | 文件类型 |\n\n## 支持的网盘平台\n\n根据响应数据中的 `from` 字段，Miaoso 支持以下网盘平台：\n\n| 平台标识 | 平台名称 | 说明 |\n|----------|----------|------|\n| `quark` | 夸克网盘 | 主要支持的网盘平台 |\n| `baidu` | 百度网盘 | 传统网盘平台 |\n| `uc` | UC网盘 | UC浏览器网盘 |\n| `aliyun` | 阿里云盘 | 阿里巴巴云存储 |\n\n## 特殊处理说明\n\n### 1. HTML标签处理\n- `name` 字段包含HTML高亮标签 `<span style=\"color: red;\">关键词</span>`\n- 需要去除HTML标签获取纯文本标题\n\n### 2. URL解密\n- `url` 字段是加密的分享链接，需要通过特定算法解密\n- 解密后应该是标准的网盘分享链接\n\n### 3. 时间格式\n- `gmtCreate` 和 `gmtShare` 使用格式：`\"YYYY-MM-DD HH:mm:ss\"`\n- 需要转换为 Go 的 time.Time 类型\n\n### 4. 数据验证\n- 某些字段可能为 `null`，需要进行空值检查\n- `fileInfos` 数组可能包含重复的文件信息\n\n## 插件实现要点\n\n根据 PanSou 插件开发指南，实现 Miaoso 插件需要注意：\n\n### 1. 插件配置\n- **插件名称**: `miaoso`\n- **优先级**: 建议设置为 3（标准质量数据源）\n- **Service层过滤**: 启用（标准网盘搜索插件）\n\n### 2. 数据转换映射\n\n| Miaoso字段 | PanSou SearchResult字段 | 转换说明 |\n|------------|-------------------------|----------|\n| `id` | `UniqueID` | 格式：`miaoso-{id}` |\n| `name` | `Title` | 去除HTML标签 |\n| `content` | `Content` | 描述信息，可能为空 |\n| `gmtShare` | `Datetime` | 解析时间格式 |\n| `url` + `from` | `Links` | 解密URL并识别网盘类型 |\n| - | `Tags` | 可根据`from`设置网盘类型标签 |\n| - | `Channel` | 设置为空字符串（插件搜索结果） |\n\n### 3. 错误处理\n- 处理API返回的错误状态码\n- 处理URL解密失败的情况\n- 处理网络请求超时和重试\n\n### 4. 性能优化\n- 实现HTTP连接复用\n- 合理设置请求超时时间\n- 使用关键词过滤提高结果相关性\n\n## 示例代码结构\n\n```go\ntype MiaosouResponse struct {\n    Code int `json:\"code\"`\n    Msg  string `json:\"msg\"`\n    Data MiaosouData `json:\"data\"`\n}\n\ntype MiaosouData struct {\n    Total int `json:\"total\"`\n    List  []MiaosouItem `json:\"list\"`\n}\n\ntype MiaosouItem struct {\n    ID          string           `json:\"id\"`\n    Name        string           `json:\"name\"`\n    URL         string           `json:\"url\"`\n    Type        *string          `json:\"type\"`\n    From        string           `json:\"from\"`\n    Content     *string          `json:\"content\"`\n    GmtCreate   string           `json:\"gmtCreate\"`\n    GmtShare    string           `json:\"gmtShare\"`\n    FileCount   int              `json:\"fileCount\"`\n    CreatorID   *string          `json:\"creatorId\"`\n    CreatorName string           `json:\"creatorName\"`\n    FileInfos   []MiaosouFileInfo `json:\"fileInfos\"`\n}\n\ntype MiaosouFileInfo struct {\n    Category      *string `json:\"category\"`\n    FileExtension *string `json:\"fileExtension\"`\n    FileID        string  `json:\"fileId\"`\n    FileName      string  `json:\"fileName\"`\n    Type          *string `json:\"type\"`\n}\n```\n"
  },
  {
    "path": "plugin/miaoso/miaoso.go",
    "content": "package miaoso\n\nimport (\n\t\"context\"\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\n// 在init函数中注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewMiaosouPlugin())\n}\n\nconst (\n\t// API基础URL\n\tBaseURL = \"https://miaosou.fun/api/secendsearch\"\n\t\n\t// 默认参数\n\tMaxRetries = 3\n\tTimeoutSeconds = 30\n\t\n\t// AES解密参数\n\tAESKey = \"4OToScUFOaeVTrHE\"\n\tAESIV  = \"9CLGao1vHKqm17Oz\"\n)\n\n// 预编译的正则表达式\nvar (\n\t// HTML标签清理正则\n\thtmlTagRegex = regexp.MustCompile(`<[^>]*>`)\n\t\n\t// 时间格式解析\n\ttimeLayout = \"2006-01-02 15:04:05\"\n)\n\n// 常用UA列表\nvar userAgents = []string{\n\t\"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\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36\",\n}\n\n// MiaosouPlugin miaoso网盘搜索插件\ntype MiaosouPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\n// NewMiaosouPlugin 创建新的miaosou插件\nfunc NewMiaosouPlugin() *MiaosouPlugin {\n\treturn &MiaosouPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"miaoso\", 3), // 优先级3，标准质量数据源\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *MiaosouPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *MiaosouPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实际的搜索实现\nfunc (p *MiaosouPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 处理扩展参数\n\tsearchKeyword := keyword\n\tif ext != nil {\n\t\tif titleEn, exists := ext[\"title_en\"]; exists {\n\t\t\tif titleEnStr, ok := titleEn.(string); ok && titleEnStr != \"\" {\n\t\t\t\tsearchKeyword = titleEnStr\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 构建请求URL\n\tsearchURL := fmt.Sprintf(\"%s?name=%s&pageNo=1\", BaseURL, url.QueryEscape(searchKeyword))\n\t\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), TimeoutSeconds*time.Second)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 设置请求头\n\tp.setRequestHeaders(req, searchKeyword)\n\t\n\t// 发送HTTP请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 读取响应体\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 读取响应失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 解析响应\n\tvar apiResp MiaosouResponse\n\tif err := json.Unmarshal(body, &apiResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] JSON解析失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 检查API响应状态\n\tif apiResp.Code != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] API错误: %s\", p.Name(), apiResp.Msg)\n\t}\n\t\n\t// 转换为标准格式\n\tresults := make([]model.SearchResult, 0, len(apiResp.Data.List))\n\tfor _, item := range apiResp.Data.List {\n\t\tresult, err := p.convertToSearchResult(item)\n\t\tif err != nil {\n\t\t\tcontinue // 跳过转换失败的项目\n\t\t}\n\t\t\n\t\t// 只添加有链接的结果\n\t\tif len(result.Links) > 0 {\n\t\t\tresults = append(results, result)\n\t\t}\n\t}\n\t\n\t// 关键词过滤\n\treturn results, nil\n}\n\n// setRequestHeaders 设置请求头\nfunc (p *MiaosouPlugin) setRequestHeaders(req *http.Request, keyword string) {\n\treq.Header.Set(\"User-Agent\", userAgents[0])\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"satoken\", \"503eb9c9-a07f-485c-a659-6c99facbb67f\")\n\treq.Header.Set(\"Referer\", fmt.Sprintf(\"https://miaosou.fun/info?searchKey=%s\", url.QueryEscape(keyword)))\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *MiaosouPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tvar lastErr error\n\t\n\tfor i := 0; i < MaxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", MaxRetries, lastErr)\n}\n\n// convertToSearchResult 将API响应项转换为SearchResult\nfunc (p *MiaosouPlugin) convertToSearchResult(item MiaosouItem) (model.SearchResult, error) {\n\t// 清理HTML标签\n\ttitle := p.cleanHTMLTags(item.Name)\n\tcontent := \"\"\n\tif item.Content != nil {\n\t\tcontent = *item.Content\n\t}\n\t\n\t// 解析时间\n\tdatetime, err := time.Parse(timeLayout, item.GmtShare)\n\tif err != nil {\n\t\tdatetime = time.Now() // 如果解析失败，使用当前时间\n\t}\n\t\n\t// 构造链接（这里需要解密URL，目前先保持原样）\n\tlinks := []model.Link{}\n\tif item.URL != \"\" {\n\t\t// 尝试解密URL（这里需要实现具体的解密逻辑）\n\t\tdecryptedURL := p.decryptURL(item.URL)\n\t\tif decryptedURL != \"\" {\n\t\t\tlink := model.Link{\n\t\t\t\tType:     p.determineCloudType(item.From),\n\t\t\t\tURL:      decryptedURL,\n\t\t\t\tPassword: \"\", // 如果有提取码，需要从其他地方获取\n\t\t\t}\n\t\t\tlinks = append(links, link)\n\t\t}\n\t}\n\t\n\t// 设置标签\n\ttags := []string{}\n\tif item.From != \"\" {\n\t\ttags = append(tags, item.From)\n\t}\n\tif item.Type != nil && *item.Type != \"\" {\n\t\ttags = append(tags, *item.Type)\n\t}\n\t\n\tresult := model.SearchResult{\n\t\tUniqueID: fmt.Sprintf(\"%s-%s\", p.Name(), item.ID),\n\t\tTitle:    title,\n\t\tContent:  content,\n\t\tDatetime: datetime,\n\t\tTags:     tags,\n\t\tLinks:    links,\n\t\tChannel:  \"\", // 插件搜索结果必须为空字符串\n\t}\n\t\n\treturn result, nil\n}\n\n// cleanHTMLTags 清理HTML标签\nfunc (p *MiaosouPlugin) cleanHTMLTags(text string) string {\n\t// 移除HTML标签\n\tcleaned := htmlTagRegex.ReplaceAllString(text, \"\")\n\t// 清理多余的空格\n\tcleaned = strings.TrimSpace(cleaned)\n\treturn cleaned\n}\n\n// decryptURL 使用AES-CBC解密URL\nfunc (p *MiaosouPlugin) decryptURL(encryptedURL string) string {\n\tif encryptedURL == \"\" {\n\t\treturn \"\"\n\t}\n\t\n\t// Base64解码\n\tciphertext, err := base64.StdEncoding.DecodeString(encryptedURL)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\t\n\t// 准备密钥和IV\n\tkey := []byte(AESKey)\n\tiv := []byte(AESIV)\n\t\n\t// 创建AES解密器\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\t\n\t// 检查密文长度\n\tif len(ciphertext) < aes.BlockSize {\n\t\treturn \"\"\n\t}\n\t\n\t// 使用CBC模式解密\n\tmode := cipher.NewCBCDecrypter(block, iv)\n\t\n\t// 解密\n\tmode.CryptBlocks(ciphertext, ciphertext)\n\t\n\t// 去除PKCS7填充\n\tplaintext := p.removePKCS7Padding(ciphertext)\n\tif plaintext == nil {\n\t\treturn \"\"\n\t}\n\t\n\treturn string(plaintext)\n}\n\n// removePKCS7Padding 去除PKCS7填充\nfunc (p *MiaosouPlugin) removePKCS7Padding(data []byte) []byte {\n\tif len(data) == 0 {\n\t\treturn nil\n\t}\n\t\n\t// 获取填充长度\n\tpadding := int(data[len(data)-1])\n\t\n\t// 验证填充\n\tif padding > len(data) || padding > aes.BlockSize {\n\t\treturn nil\n\t}\n\t\n\t// 检查填充是否正确\n\tfor i := len(data) - padding; i < len(data); i++ {\n\t\tif data[i] != byte(padding) {\n\t\t\treturn nil\n\t\t}\n\t}\n\t\n\t// 返回去除填充后的数据\n\treturn data[:len(data)-padding]\n}\n\n// determineCloudType 根据平台标识确定网盘类型\nfunc (p *MiaosouPlugin) determineCloudType(from string) string {\n\tswitch strings.ToLower(from) {\n\tcase \"quark\":\n\t\treturn \"quark\"\n\tcase \"baidu\":\n\t\treturn \"baidu\"\n\tcase \"uc\":\n\t\treturn \"uc\"\n\tcase \"ali\":\n\t\treturn \"aliyun\"\n\tcase \"xunlei\":\n\t\treturn \"xunlei\"\n\tcase \"tianyi\":\n\t\treturn \"tianyi\"\n\tcase \"115\":\n\t\treturn \"115\"\n\tcase \"123\":\n\t\treturn \"123\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n// API响应结构定义\ntype MiaosouResponse struct {\n\tCode int         `json:\"code\"`\n\tMsg  string      `json:\"msg\"`\n\tData MiaosouData `json:\"data\"`\n}\n\ntype MiaosouData struct {\n\tTotal int           `json:\"total\"`\n\tList  []MiaosouItem `json:\"list\"`\n}\n\ntype MiaosouItem struct {\n\tID          string              `json:\"id\"`\n\tName        string              `json:\"name\"`\n\tURL         string              `json:\"url\"`\n\tType        *string             `json:\"type\"`\n\tFrom        string              `json:\"from\"`\n\tContent     *string             `json:\"content\"`\n\tGmtCreate   string              `json:\"gmtCreate\"`\n\tGmtShare    string              `json:\"gmtShare\"`\n\tFileCount   int                 `json:\"fileCount\"`\n\tCreatorID   *string             `json:\"creatorId\"`\n\tCreatorName string              `json:\"creatorName\"`\n\tFileInfos   []MiaosouFileInfo   `json:\"fileInfos\"`\n}\n\ntype MiaosouFileInfo struct {\n\tCategory      *string `json:\"category\"`\n\tFileExtension *string `json:\"fileExtension\"`\n\tFileID        string  `json:\"fileId\"`\n\tFileName      string  `json:\"fileName\"`\n\tType          *string `json:\"type\"`\n}\n"
  },
  {
    "path": "plugin/mikuclub/html结构分析.md",
    "content": "# mikuclub (初音社) HTML结构分析\n\n## 页面概览\n- 详情页 URL: `https://www.mikuclub.uk/{post_id}`\n- 主体容器：`div.article_content`\n- 常见内容顺序：\n  1. 顶部广告表格/图片\n  2. 资源简介（`<p>` 或 `<blockquote>`）\n  3. 下载链接段落（`链接：...`、`地址：...`）\n  4. 标签、入群提示、二维码等\n\n## 关键节点\n\n| 数据项 | 选择器/特征 | 说明 |\n|--------|-------------|------|\n| 标题 | `.post .title h1` | 文章标题 |\n| 作者/分类/时间 | `.post .title .info span` | 包含 `<i class=\"fa fa-user\">`、`fa-columns`、`fa-clock-o` |\n| 正文 | `div.article_content` | 下载信息、描述等均位于此 |\n| 标签 | `span.tag a` | 站内标签 |\n| 相关推荐 | `div.related li` | 可忽略 |\n\n## 下载链接形态\n\n### 1. 标准段落\n```html\n<p>链接：<a href=\"https://pan.quark.cn/s/08211da2cb83\" rel=\"nofollow\">https://pan.quark.cn/s/08211da2cb83</a></p>\n<p>百度：https://pan.baidu.com/s/1_BG5kkk... ?pwd=2222</p>\n```\n\n### 2. 文本块\n```html\n<p>https://pan.quark.cn/s/c3ec23e8f698</p>\n<p>夸克网盘：<a href=\"https://pan.quark.cn/s/0d70b5e3b554\">点击跳转</a></p>\n```\n\n### 3. 表格/列表\n```html\n<table>\n  <tr><td>夸克：</td><td><a href=\"https://pan.quark.cn/s/bee413c5e8e8\">https://pan.quark.cn/s/bee413c5e8e8</a></td></tr>\n</table>\n```\n\n### 提取码位置\n- 链接参数：`https://pan.baidu.com/... ?pwd=2222`\n- 同段文本：`提取码：xxxx`、`密码：xxxx`\n- 邻近节点（`<span>`、`<strong>` 等）\n\n## 实现提示\n1. 解析时优先选择 `.article_content`，若不存在则退回 `article.post` 或 `body`。\n2. 使用 goquery 遍历 `<a href>`，根据域名判断网盘类型。\n3. 对正文纯文本执行正则匹配，捕获未包裹 `<a>` 的 URL。\n4. 在链接文本、`title`、父节点和相邻节点中搜索提取码关键词；若无则再在链接上下文文本中匹配。\n5. 过滤广告/站内跳转链接（无网盘域名）。\n\n## 支持的网盘域名\n- 夸克：`pan.quark.cn/s/`、`pan.quark.cn/g/`\n- 百度：`pan.baidu.com/s/`\n- 阿里云盘：`www.aliyundrive.com/s/`\n- 迅雷：`pan.xunlei.com/s/`\n- 123网盘：`123pan.com/s/`\n- 可按需继续扩展（迅雷群等）\n\n"
  },
  {
    "path": "plugin/mikuclub/json结构分析.md",
    "content": "# mikuclub JSON结构分析\n\n## 搜索接口\n- **URL**\n```\nhttps://www.mikuclub.uk/wp-json/utils/v2/post_list?search={kw}&s={kw}&page_type=search&paged=1&custom_orderby=relevance&no_cache=1&custom_cat={catId}\n```\n- **分类 ID**\n  - `9305`: 影视区\n  - `942`: 动漫区\n- **请求方式**: GET，关键词需要 URL 编码，两个分类需并发请求后合并结果。\n\n### 响应示例\n```json\n{\n  \"max_num_pages\": 0,\n  \"posts\": [\n    {\n      \"id\": 1909169,\n      \"post_title\": \"凡人修仙传【真人】...\",\n      \"post_href\": \"https://www.mikuclub.uk/1909169\",\n      \"post_image\": \"https://file6.mikuhome.top/.../326x280.webp\",\n      \"post_views\": 3094,\n      \"post_likes\": 17,\n      \"post_rank_description\": \"多半好评\",\n      \"post_comments\": 20,\n      \"post_author\": {\n        \"id\": 474211,\n        \"display_name\": \"南缘DH2\",\n        \"user_href\": \"https://www.mikuclub.uk/author/qq_1743601741\"\n      },\n      \"post_main_cat_id\": 9305,\n      \"post_main_cat_name\": \"影视区\",\n      \"post_cat_id\": 21306,\n      \"post_cat_name\": \"电视剧-网剧\",\n      \"post_cat_href\": \"https://www.mikuclub.uk/videos/tv-series\",\n      \"post_date\": \"2025-07-28 21:16:11\",\n      \"post_down_total_count\": \"558\"\n    }\n  ]\n}\n```\n\n### 字段说明\n| 字段 | 说明 |\n|------|------|\n| `id` | 文章 ID（用于详情/唯一标识） |\n| `post_title` | 标题 |\n| `post_href` | 详情页 URL |\n| `post_image` | 封面图 |\n| `post_main_cat_name` | 主分类（影视区/动漫区） |\n| `post_cat_name` | 子分类 |\n| `post_date` | 发布时间 |\n| `post_views`, `post_likes`, ... | 统计指标，可用于扩展 |\n\n## 详情接口\n- **URL**\n```\nhttps://www.mikuclub.uk/wp-json/wp/v2/posts/{post_id}\n```\n- **关键字段**\n```json\n{\n  \"id\": 1909169,\n  \"date\": \"2025-07-28T21:16:11\",\n  \"link\": \"https://www.mikuclub.uk/1909169\",\n  \"content\": {\n    \"rendered\": \"<p>...含夸克/百度等网盘链接...</p>\"\n  }\n}\n```\n- `content.rendered` 为完整 HTML，包含下载信息、图片和广告。\n\n### 链接提取\n1. 优先解析 `<a href>`，根据域名识别网盘类型：\n   - 夸克 `https://pan.quark.cn/s/...`\n   - 百度 `https://pan.baidu.com/s/...`\n   - 阿里云盘 `https://www.aliyundrive.com/s/...`\n   - 其他：迅雷、123网盘等，可按需扩展\n2. 文本中可能存在裸露 URL（无 `<a>`），需用正则额外匹配。\n3. 提取码/密码通常与链接在同一段落，或以 `?pwd=` 参数出现，需要关键词匹配 `提取码/密码/pwd/code`。\n\n## 实现要点\n1. **双区并发搜索**  \n   - 同时向 `custom_cat=9305` 与 `custom_cat=942` 发起请求，待双方返回后合并结果。  \n   - 若某分类请求失败，仅记录日志，另一分类结果仍可返回；若全部失败则整体报错。\n2. **详情页并发解析**  \n   - 对候选文章再次并发请求 `wp-json/wp/v2/posts/{id}`，并通过缓存（TTL）避免重复抓取。\n3. **结果合并**  \n   - `UniqueID` 建议使用 `mikuclub-{id}`，合并两个分类后去重。  \n   - `Tags` 可包含主分类、子分类及站内标签。\n4. **性能优化**  \n   - 自定义 `http.Client`（连接池、HTTP/2、TLS 超时）。  \n   - 请求加入指数退避重试。  \n   - 详情内容解析使用 goquery + 正则组合，抽取链接与提取码。  \n   - 对正文文本传入 `substring` 限制上下文长度，降低扫描开销。\n\n"
  },
  {
    "path": "plugin/mikuclub/mikuclub.go",
    "content": "package mikuclub\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nvar (\n\tcategoryIDs = []string{\"9305\", \"942\"}\n\n\tlinkPatterns = []struct {\n\t\treg *regexp.Regexp\n\t\ttyp string\n\t}{\n\t\t{regexp.MustCompile(`https?://pan\\.quark\\.cn/(s|g)/[0-9A-Za-z]+`), \"quark\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?(aliyundrive\\.com|alipan\\.com)/s/[0-9A-Za-z]+`), \"aliyun\"},\n\t\t{regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9A-Za-z\\-_]+`), \"baidu\"},\n\t\t{regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9A-Za-z\\-_]+`), \"xunlei\"},\n\t\t{regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9A-Za-z]+`), \"uc\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?mypikpak\\.com/s/[0-9A-Za-z]+`), \"pikpak\"},\n\t\t{regexp.MustCompile(`https?://caiyun\\.139\\.com/[^\\s]+`), \"mobile\"},\n\t\t{regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9A-Za-z]+`), \"magnet\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?(123pan\\.com|123pan\\.cn|123684\\.com|123685\\.com|123912\\.com|123592\\.com)/s/[0-9A-Za-z]+`), \"123\"},\n\t}\n\n\ttextURLRegex = regexp.MustCompile(`https?://[^\\s<>\"']+`)\n\n\tpasswordPatterns = []*regexp.Regexp{\n\t\tregexp.MustCompile(`提取码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`密码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`pwd\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`code\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t}\n\n\tdetailCache          = sync.Map{}\n\tcacheTTL             = 1 * time.Hour\n\tcacheCleanupInterval = 30 * time.Minute\n)\n\ntype cacheEntry struct {\n\tlinks     []model.Link\n\texpiresAt time.Time\n}\n\nconst (\n\tpluginName            = \"mikuclub\"\n\tdefaultPriority       = 2\n\tsearchTimeout         = 12 * time.Second\n\tdetailTimeout         = 10 * time.Second\n\tmaxConcurrency        = 12\n\tmaxIdleConns          = 64\n\tmaxIdlePerHost        = 16\n\tmaxConnsPerHost       = 32\n\tidleConnLifetime      = 90 * time.Second\n\ttlsHandshakeTimeout   = 10 * time.Second\n\texpectContinueTimeout = 1 * time.Second\n\n\tsearchMaxRetries = 3\n\tdetailMaxRetries = 2\n\tretryBaseDelay   = 200 * time.Millisecond\n)\n\n// MikuclubPlugin 插件\ntype MikuclubPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tclient *http.Client\n}\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewMikuclubPlugin())\n\tgo startCacheCleaner()\n}\n\n// NewMikuclubPlugin 创建实例\nfunc NewMikuclubPlugin() *MikuclubPlugin {\n\treturn &MikuclubPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),\n\t\tclient:          newHTTPClient(),\n\t}\n}\n\n// Search 兼容方法\nfunc (p *MikuclubPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 主搜索\nfunc (p *MikuclubPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\nfunc newHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:          maxIdleConns,\n\t\tMaxIdleConnsPerHost:   maxIdlePerHost,\n\t\tMaxConnsPerHost:       maxConnsPerHost,\n\t\tIdleConnTimeout:       idleConnLifetime,\n\t\tTLSHandshakeTimeout:   tlsHandshakeTimeout,\n\t\tExpectContinueTimeout: expectContinueTimeout,\n\t\tForceAttemptHTTP2:     true,\n\t}\n\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   searchTimeout,\n\t}\n}\n\nfunc (p *MikuclubPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif p.client != nil {\n\t\tclient = p.client\n\t}\n\n\ttype catResult struct {\n\t\tposts []postItem\n\t\terr   error\n\t}\n\n\tvar (\n\t\twg        sync.WaitGroup\n\t\tresultCh  = make(chan catResult, len(categoryIDs))\n\t\tallPosts  []postItem\n\t\tfirstErr  error\n\t\tseenPosts = make(map[int64]postItem)\n\t)\n\n\tfor _, cat := range categoryIDs {\n\t\twg.Add(1)\n\t\tgo func(catID string) {\n\t\t\tdefer wg.Done()\n\t\t\tposts, err := p.fetchCategoryPosts(client, keyword, catID)\n\t\t\tresultCh <- catResult{posts: posts, err: err}\n\t\t}(cat)\n\t}\n\n\twg.Wait()\n\tclose(resultCh)\n\n\tfor res := range resultCh {\n\t\tif res.err != nil {\n\t\t\tif firstErr == nil {\n\t\t\t\tfirstErr = res.err\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tfor _, post := range res.posts {\n\t\t\tif _, exists := seenPosts[post.ID]; exists {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseenPosts[post.ID] = post\n\t\t\tallPosts = append(allPosts, post)\n\t\t}\n\t}\n\n\tif len(allPosts) == 0 {\n\t\tif firstErr != nil {\n\t\t\treturn nil, firstErr\n\t\t}\n\t\treturn nil, fmt.Errorf(\"[%s] 未找到相关结果\", p.Name())\n\t}\n\n\tvar (\n\t\tresults []model.SearchResult\n\t\tdwg     sync.WaitGroup\n\t\tmu      sync.Mutex\n\t\tsem     = make(chan struct{}, maxConcurrency)\n\t)\n\n\tfor _, post := range allPosts {\n\t\tpost := post\n\t\tdwg.Add(1)\n\t\tsem <- struct{}{}\n\t\tgo func() {\n\t\t\tdefer dwg.Done()\n\t\t\tdefer func() { <-sem }()\n\n\t\t\tlinks := p.fetchDetailLinks(client, post.ID, post.PostHref)\n\t\t\tif len(links) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tresult := model.SearchResult{\n\t\t\t\tUniqueID: fmt.Sprintf(\"%s-%d\", p.Name(), post.ID),\n\t\t\t\tTitle:    strings.TrimSpace(post.PostTitle),\n\t\t\t\tContent:  post.summary(),\n\t\t\t\tLinks:    links,\n\t\t\t\tTags:     post.tags(),\n\t\t\t\tChannel:  \"\",\n\t\t\t\tDatetime: post.publishTime(),\n\t\t\t}\n\n\t\t\tmu.Lock()\n\t\t\tresults = append(results, result)\n\t\t\tmu.Unlock()\n\t\t}()\n\t}\n\n\tdwg.Wait()\n\n\tif len(results) == 0 && firstErr != nil {\n\t\treturn nil, firstErr\n\t}\n\n\treturn plugin.FilterResultsByKeyword(results, keyword), nil\n}\n\nfunc (p *MikuclubPlugin) fetchCategoryPosts(client *http.Client, keyword, catID string) ([]postItem, error) {\n\tparams := url.Values{}\n\tparams.Set(\"search\", keyword)\n\tparams.Set(\"s\", keyword)\n\tparams.Set(\"page\", \"\")\n\tparams.Set(\"pagename\", \"search_page\")\n\tparams.Set(\"page_type\", \"search\")\n\tparams.Set(\"paged\", \"1\")\n\tparams.Set(\"custom_orderby\", \"relevance\")\n\tparams.Set(\"no_cache\", \"1\")\n\tparams.Set(\"custom_cat\", catID)\n\n\treqURL := fmt.Sprintf(\"https://www.mikuclub.uk/wp-json/utils/v2/post_list?%s\", params.Encode())\n\n\tctx, cancel := context.WithTimeout(context.Background(), searchTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建搜索请求失败: %w\", p.Name(), err)\n\t}\n\tsetCommonHeaders(req, \"https://www.mikuclub.uk/\")\n\n\tresp, err := p.doRequestWithRetry(req, client, searchMaxRetries)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\tvar payload postListResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索结果失败: %w\", p.Name(), err)\n\t}\n\n\treturn payload.Posts, nil\n}\n\nfunc (p *MikuclubPlugin) fetchDetailLinks(client *http.Client, postID int64, detailURL string) []model.Link {\n\tif cached, ok := detailCache.Load(postID); ok {\n\t\tif entry, valid := cached.(cacheEntry); valid {\n\t\t\tif time.Now().Before(entry.expiresAt) && len(entry.links) > 0 {\n\t\t\t\treturn entry.links\n\t\t\t}\n\t\t\tdetailCache.Delete(postID)\n\t\t}\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), detailTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tsetCommonHeaders(req, detailURL)\n\n\tresp, err := p.doRequestWithRetry(req, client, detailMaxRetries)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\tfmt.Printf(\"[%s] 解析详情页失败: %v\\n\", pluginName, err)\n\t\treturn nil\n\t}\n\n\tcontainer := doc.Find(\".article_content\")\n\tif container.Length() == 0 {\n\t\tcontainer = doc.Find(\"article.post, .entry-content\")\n\t}\n\tif container.Length() == 0 {\n\t\tcontainer = doc.Selection\n\t}\n\n\tlinks := extractLinksFromSelection(container)\n\tif len(links) > 0 {\n\t\tdetailCache.Store(postID, cacheEntry{\n\t\t\tlinks:     links,\n\t\t\texpiresAt: time.Now().Add(cacheTTL),\n\t\t})\n\t}\n\treturn links\n}\n\nfunc extractLinksFromSelection(sel *goquery.Selection) []model.Link {\n\tvar (\n\t\tresults []model.Link\n\t\tseen    = make(map[string]struct{})\n\t)\n\n\tsel.Find(\"a[href]\").Each(func(_ int, node *goquery.Selection) {\n\t\thref, ok := node.Attr(\"href\")\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\t\thref = strings.TrimSpace(href)\n\t\tif href == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tlinkType, normalized := classifyLink(href)\n\t\tif linkType == \"\" {\n\t\t\treturn\n\t\t}\n\t\tif _, exists := seen[normalized]; exists {\n\t\t\treturn\n\t\t}\n\n\t\tpassword := extractPassword(node)\n\n\t\tresults = append(results, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      normalized,\n\t\t\tPassword: password,\n\t\t})\n\t\tseen[normalized] = struct{}{}\n\t})\n\n\ttext := sel.Text()\n\tfor _, idx := range textURLRegex.FindAllStringIndex(text, -1) {\n\t\traw := text[idx[0]:idx[1]]\n\t\tlinkType, normalized := classifyLink(raw)\n\t\tif linkType == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := seen[normalized]; exists {\n\t\t\tcontinue\n\t\t}\n\n\t\tcontext := substring(text, idx[0]-80, idx[1]+80)\n\t\tpassword := matchPassword(context)\n\n\t\tresults = append(results, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      normalized,\n\t\t\tPassword: password,\n\t\t})\n\t\tseen[normalized] = struct{}{}\n\t}\n\n\treturn results\n}\n\nfunc classifyLink(raw string) (string, string) {\n\tfor _, pattern := range linkPatterns {\n\t\tif loc := pattern.reg.FindString(raw); loc != \"\" {\n\t\t\treturn pattern.typ, loc\n\t\t}\n\t}\n\treturn \"\", \"\"\n}\n\nfunc extractPassword(node *goquery.Selection) string {\n\tcandidates := []string{\n\t\tnode.Text(),\n\t}\n\n\tif title, ok := node.Attr(\"title\"); ok {\n\t\tcandidates = append(candidates, title)\n\t}\n\n\tif parent := node.Parent(); parent != nil && parent.Length() > 0 {\n\t\tcandidates = append(candidates, parent.Text())\n\t\tif next := parent.Next(); next.Length() > 0 {\n\t\t\tcandidates = append(candidates, next.Text())\n\t\t}\n\t}\n\n\tif sibling := node.Next(); sibling.Length() > 0 {\n\t\tcandidates = append(candidates, sibling.Text())\n\t}\n\n\tfor _, text := range candidates {\n\t\tif pwd := matchPassword(text); pwd != \"\" {\n\t\t\treturn pwd\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc matchPassword(text string) string {\n\ttext = strings.TrimSpace(text)\n\tif text == \"\" {\n\t\treturn \"\"\n\t}\n\tfor _, pattern := range passwordPatterns {\n\t\tif matches := pattern.FindStringSubmatch(text); len(matches) >= 2 {\n\t\t\treturn strings.TrimSpace(matches[1])\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc substring(text string, start, end int) string {\n\tif start < 0 {\n\t\tstart = 0\n\t}\n\tif end > len(text) {\n\t\tend = len(text)\n\t}\n\treturn text[start:end]\n}\n\nfunc setCommonHeaders(req *http.Request, referer string) {\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"application/json, text/html;q=0.9,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", referer)\n}\n\nfunc (p *MikuclubPlugin) doRequestWithRetry(req *http.Request, client *http.Client, maxRetries int) (*http.Response, error) {\n\tvar lastErr error\n\n\tfor attempt := 0; attempt < maxRetries; attempt++ {\n\t\tresp, err := client.Do(req.Clone(req.Context()))\n\t\tif err == nil && resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t\tif attempt < maxRetries-1 {\n\t\t\tbackoff := retryBaseDelay * time.Duration(1<<attempt)\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"重试 %d 次后失败: %w\", maxRetries, lastErr)\n}\n\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(cacheCleanupInterval)\n\tdefer ticker.Stop()\n\n\tfor range ticker.C {\n\t\tnow := time.Now()\n\t\tdetailCache.Range(func(key, value interface{}) bool {\n\t\t\tentry, ok := value.(cacheEntry)\n\t\t\tif !ok || now.After(entry.expiresAt) {\n\t\t\t\tdetailCache.Delete(key)\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n}\n\ntype postListResponse struct {\n\tPosts []postItem `json:\"posts\"`\n}\n\ntype postItem struct {\n\tID              int64  `json:\"id\"`\n\tPostTitle       string `json:\"post_title\"`\n\tPostHref        string `json:\"post_href\"`\n\tPostMainCatName string `json:\"post_main_cat_name\"`\n\tPostCatName     string `json:\"post_cat_name\"`\n\tPostDate        string `json:\"post_date\"`\n\tPostRank        string `json:\"post_rank_description\"`\n\tPostViews       int64  `json:\"post_views\"`\n}\n\nfunc (p postItem) summary() string {\n\tbuilder := strings.Builder{}\n\tif p.PostRank != \"\" {\n\t\tbuilder.WriteString(fmt.Sprintf(\"口碑：%s \", strings.TrimSpace(p.PostRank)))\n\t}\n\tif p.PostViews > 0 {\n\t\tbuilder.WriteString(fmt.Sprintf(\"浏览：%d \", p.PostViews))\n\t}\n\tif builder.Len() == 0 {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(builder.String())\n}\n\nfunc (p postItem) tags() []string {\n\tvar tags []string\n\tif p.PostMainCatName != \"\" {\n\t\ttags = append(tags, p.PostMainCatName)\n\t}\n\tif p.PostCatName != \"\" {\n\t\ttags = append(tags, p.PostCatName)\n\t}\n\treturn tags\n}\n\nfunc (p postItem) publishTime() time.Time {\n\tlayouts := []string{\n\t\t\"2006-01-02 15:04:05\",\n\t\ttime.RFC3339,\n\t}\n\n\tfor _, layout := range layouts {\n\t\tif t, err := time.Parse(layout, p.PostDate); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\n\treturn time.Now()\n}\n"
  },
  {
    "path": "plugin/mizixing/mizixing.go",
    "content": "package mizixing\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"hash/crc32\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tpluginName      = \"mizixing\"\n\tdefaultPriority = 3\n\n\tbaseURL             = \"https://mizixing.com\"\n\tsearchEndpoint      = baseURL + \"/\"\n\tsearchLimit         = 12\n\tdetailWorkers       = 6\n\trequestTimeout      = 12 * time.Second\n\tdetailTimeout       = 10 * time.Second\n\thttpMaxIdleConns    = 64\n\thttpMaxIdlePerHost  = 16\n\thttpMaxConnsPerHost = 32\n\tretryBaseDelay      = 200 * time.Millisecond\n\tmaxRequestRetries   = 3\n)\n\nvar (\n\tlinkPatterns = []struct {\n\t\treg *regexp.Regexp\n\t\ttyp string\n\t}{\n\t\t{regexp.MustCompile(`https?://pan\\.quark\\.cn/(?:s|g)/[0-9A-Za-z]+`), \"quark\"},\n\t\t{regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9A-Za-z\\-_?=&]+`), \"baidu\"},\n\t\t{regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9A-Za-z\\-_?=&]+`), \"xunlei\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?(aliyundrive\\.com|alipan\\.com)/s/[0-9A-Za-z]+`), \"aliyun\"},\n\t\t{regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9A-Za-z]+`), \"uc\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?(123pan\\.com|123pan\\.cn|123684\\.com|123685\\.com|123912\\.com|123592\\.com)/s/[0-9A-Za-z]+`), \"123\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?mypikpak\\.com/s/[0-9A-Za-z]+`), \"pikpak\"},\n\t\t{regexp.MustCompile(`https?://caiyun\\.139\\.com/[^\\s<>\"']+`), \"mobile\"},\n\t\t{regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9A-Za-z]+`), \"magnet\"},\n\t\t{regexp.MustCompile(`ed2k://[^\\s<>\"']+`), \"ed2k\"},\n\t}\n\n\tpasswordPatterns = []*regexp.Regexp{\n\t\tregexp.MustCompile(`提取码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`密码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`pwd\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`code\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t}\n\n\ttextURLRegex = regexp.MustCompile(`https?://[^\\s<>\"']+`)\n)\n\n// MizixingPlugin implements the async plugin for mizixing.com\ntype MizixingPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tclient *http.Client\n}\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewMizixingPlugin())\n}\n\n// NewMizixingPlugin builds plugin instance\nfunc NewMizixingPlugin() *MizixingPlugin {\n\treturn &MizixingPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),\n\t\tclient:          newHTTPClient(),\n\t}\n}\n\n// Search compatibility wrapper\nfunc (p *MizixingPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult entrypoint\nfunc (p *MizixingPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\nfunc (p *MizixingPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif p.client != nil {\n\t\tclient = p.client\n\t}\n\n\tsearchKeyword := strings.TrimSpace(keyword)\n\tif searchKeyword == \"\" {\n\t\treturn nil, fmt.Errorf(\"[%s] 关键词不能为空\", p.Name())\n\t}\n\n\titems, err := p.fetchSearchResults(client, searchKeyword)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(items) == 0 {\n\t\treturn nil, fmt.Errorf(\"[%s] 未找到相关资源\", p.Name())\n\t}\n\n\tvar (\n\t\twg      sync.WaitGroup\n\t\tsem     = make(chan struct{}, detailWorkers)\n\t\tresultM sync.Mutex\n\t\tresults []model.SearchResult\n\t)\n\n\tfor _, item := range items {\n\t\titem := item\n\t\twg.Add(1)\n\t\tsem <- struct{}{}\n\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tdefer func() { <-sem }()\n\n\t\t\tdetail, err := p.fetchDetailData(client, item.URL)\n\t\t\tif err != nil || len(detail.links) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tcontent := item.Summary\n\t\t\tif content == \"\" {\n\t\t\t\tcontent = detail.description\n\t\t\t}\n\n\t\t\tresult := model.SearchResult{\n\t\t\t\tUniqueID: buildUniqueID(item.URL),\n\t\t\t\tTitle:    item.Title,\n\t\t\t\tContent:  strings.TrimSpace(content),\n\t\t\t\tLinks:    detail.links,\n\t\t\t\tTags:     mergeTags(item.Category, detail.tags),\n\t\t\t\tChannel:  \"\",\n\t\t\t\tDatetime: detail.datetime,\n\t\t\t}\n\n\t\t\tresultM.Lock()\n\t\t\tresults = append(results, result)\n\t\t\tresultM.Unlock()\n\t\t}()\n\t}\n\n\twg.Wait()\n\n\tif len(results) == 0 {\n\t\treturn nil, fmt.Errorf(\"[%s] 未能抓取到有效网盘链接\", p.Name())\n\t}\n\n\treturn plugin.FilterResultsByKeyword(results, searchKeyword), nil\n}\n\ntype searchItem struct {\n\tTitle    string\n\tURL      string\n\tCategory string\n\tSummary  string\n}\n\nfunc (p *MizixingPlugin) fetchSearchResults(client *http.Client, keyword string) ([]searchItem, error) {\n\tsearchURL := fmt.Sprintf(\"%s?s=%s\", searchEndpoint, url.QueryEscape(keyword))\n\n\tctx, cancel := context.WithTimeout(context.Background(), requestTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建搜索请求失败: %w\", p.Name(), err)\n\t}\n\tsetHTMLHeaders(req, baseURL)\n\n\tresp, err := p.doRequestWithRetry(req, client, maxRequestRetries)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\n\tvar items []searchItem\n\tdoc.Find(\"article.excerpt\").Each(func(_ int, s *goquery.Selection) {\n\t\tif len(items) >= searchLimit {\n\t\t\treturn\n\t\t}\n\n\t\ttitleNode := s.Find(\"h2 a\")\n\t\turlStr, ok := titleNode.Attr(\"href\")\n\t\tif !ok || strings.TrimSpace(urlStr) == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tcategory := strings.TrimSpace(s.Find(\"header .label\").Text())\n\t\tsummary := strings.TrimSpace(s.Find(\"p.note\").Text())\n\t\ttitle := strings.TrimSpace(titleNode.Text())\n\n\t\tif title == \"\" {\n\t\t\ttitle = strings.TrimSpace(s.Find(\"h2\").Text())\n\t\t}\n\n\t\titems = append(items, searchItem{\n\t\t\tTitle:    title,\n\t\t\tURL:      normalizeURL(urlStr),\n\t\t\tCategory: category,\n\t\t\tSummary:  summary,\n\t\t})\n\t})\n\n\treturn items, nil\n}\n\ntype detailData struct {\n\tlinks       []model.Link\n\tdatetime    time.Time\n\ttags        []string\n\tdescription string\n}\n\nfunc (p *MizixingPlugin) fetchDetailData(client *http.Client, detailURL string) (detailData, error) {\n\tctx, cancel := context.WithTimeout(context.Background(), detailTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil)\n\tif err != nil {\n\t\treturn detailData{}, fmt.Errorf(\"[%s] 创建详情页请求失败: %w\", p.Name(), err)\n\t}\n\tsetHTMLHeaders(req, detailURL)\n\n\tresp, err := p.doRequestWithRetry(req, client, maxRequestRetries)\n\tif err != nil {\n\t\treturn detailData{}, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn detailData{}, fmt.Errorf(\"[%s] 详情页返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn detailData{}, fmt.Errorf(\"[%s] 解析详情页失败: %w\", p.Name(), err)\n\t}\n\n\tcontent := doc.Find(\"article.article-content\")\n\tif content.Length() == 0 {\n\t\tcontent = doc.Find(\".article-content\")\n\t}\n\tif content.Length() == 0 {\n\t\tcontent = doc.Find(\".entry-content\")\n\t}\n\tif content.Length() == 0 {\n\t\tcontent = doc.Selection\n\t}\n\n\tcontent.Find(\"script, style, .bdsharebuttonbox, #respond, .post-views, .share, .relates\").Remove()\n\n\tlinks := extractLinksFromSelection(content)\n\n\tdescription := strings.TrimSpace(doc.Find(\"meta[name='description']\").AttrOr(\"content\", \"\"))\n\n\ttags := collectTags(doc)\n\tdatetime := extractDateTime(doc)\n\n\treturn detailData{\n\t\tlinks:       links,\n\t\tdatetime:    datetime,\n\t\ttags:        tags,\n\t\tdescription: description,\n\t}, nil\n}\n\nfunc collectTags(doc *goquery.Document) []string {\n\ttagSet := make(map[string]struct{})\n\n\tdoc.Find(\".breadcrumbs a\").Each(func(_ int, s *goquery.Selection) {\n\t\ttext := strings.TrimSpace(s.Text())\n\t\tif text == \"\" || strings.Contains(text, \"首页\") {\n\t\t\treturn\n\t\t}\n\t\ttagSet[text] = struct{}{}\n\t})\n\n\tvar tags []string\n\tfor tag := range tagSet {\n\t\ttags = append(tags, tag)\n\t}\n\treturn tags\n}\n\nfunc extractDateTime(doc *goquery.Document) time.Time {\n\tselectors := []string{\n\t\t\"meta[property='article:modified_time']\",\n\t\t\"meta[property='article:published_time']\",\n\t\t\"meta[name='article:modified_time']\",\n\t\t\"meta[name='article:published_time']\",\n\t}\n\n\tfor _, sel := range selectors {\n\t\tif node := doc.Find(sel); node.Length() > 0 {\n\t\t\tif value, ok := node.Attr(\"content\"); ok {\n\t\t\t\tif t, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil {\n\t\t\t\t\treturn t\n\t\t\t\t}\n\t\t\t\tlayouts := []string{\n\t\t\t\t\t\"2006-01-02 15:04:05\",\n\t\t\t\t\ttime.RFC3339Nano,\n\t\t\t\t}\n\t\t\t\tfor _, layout := range layouts {\n\t\t\t\t\tif t, err := time.Parse(layout, strings.TrimSpace(value)); err == nil {\n\t\t\t\t\t\treturn t\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn time.Now()\n}\n\nfunc extractLinksFromSelection(sel *goquery.Selection) []model.Link {\n\tvar (\n\t\tresults []model.Link\n\t\tseen    = make(map[string]struct{})\n\t)\n\n\tsel.Find(\"a[href]\").Each(func(_ int, node *goquery.Selection) {\n\t\thref, ok := node.Attr(\"href\")\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\t\tlinkType, normalized := classifyLink(href)\n\t\tif linkType == \"\" || normalized == \"\" {\n\t\t\treturn\n\t\t}\n\t\tif _, exists := seen[normalized]; exists {\n\t\t\treturn\n\t\t}\n\n\t\tpassword := extractPassword(node)\n\t\tresults = append(results, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      normalized,\n\t\t\tPassword: password,\n\t\t})\n\t\tseen[normalized] = struct{}{}\n\t})\n\n\ttext := sel.Text()\n\tfor _, loc := range textURLRegex.FindAllStringIndex(text, -1) {\n\t\traw := text[loc[0]:loc[1]]\n\t\tlinkType, normalized := classifyLink(raw)\n\t\tif linkType == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := seen[normalized]; exists {\n\t\t\tcontinue\n\t\t}\n\n\t\tcontext := substring(text, loc[0]-80, loc[1]+80)\n\t\tpassword := matchPassword(context)\n\t\tresults = append(results, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      normalized,\n\t\t\tPassword: password,\n\t\t})\n\t\tseen[normalized] = struct{}{}\n\t}\n\n\treturn results\n}\n\nfunc classifyLink(raw string) (string, string) {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tfor _, pattern := range linkPatterns {\n\t\tif loc := pattern.reg.FindString(raw); loc != \"\" {\n\t\t\treturn pattern.typ, loc\n\t\t}\n\t}\n\treturn \"\", \"\"\n}\n\nfunc extractPassword(node *goquery.Selection) string {\n\tcandidates := []string{node.Text()}\n\n\tif title, ok := node.Attr(\"title\"); ok {\n\t\tcandidates = append(candidates, title)\n\t}\n\tif parent := node.Parent(); parent != nil && parent.Length() > 0 {\n\t\tcandidates = append(candidates, parent.Text())\n\t\tif next := parent.Next(); next.Length() > 0 {\n\t\t\tcandidates = append(candidates, next.Text())\n\t\t}\n\t}\n\tif sibling := node.Next(); sibling.Length() > 0 {\n\t\tcandidates = append(candidates, sibling.Text())\n\t}\n\n\tfor _, text := range candidates {\n\t\tif pwd := matchPassword(text); pwd != \"\" {\n\t\t\treturn pwd\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc matchPassword(text string) string {\n\ttext = strings.TrimSpace(text)\n\tif text == \"\" {\n\t\treturn \"\"\n\t}\n\tfor _, pattern := range passwordPatterns {\n\t\tif matches := pattern.FindStringSubmatch(text); len(matches) > 1 {\n\t\t\treturn strings.TrimSpace(matches[1])\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc substring(text string, start, end int) string {\n\tif start < 0 {\n\t\tstart = 0\n\t}\n\tif end > len(text) {\n\t\tend = len(text)\n\t}\n\treturn text[start:end]\n}\n\nfunc mergeTags(primary string, extra []string) []string {\n\ttagSet := make(map[string]struct{})\n\tif primary != \"\" {\n\t\ttagSet[strings.TrimSpace(primary)] = struct{}{}\n\t}\n\tfor _, tag := range extra {\n\t\tif tag == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\ttagSet[strings.TrimSpace(tag)] = struct{}{}\n\t}\n\tvar tags []string\n\tfor tag := range tagSet {\n\t\tif tag != \"\" {\n\t\t\ttags = append(tags, tag)\n\t\t}\n\t}\n\treturn tags\n}\n\nfunc buildUniqueID(detailURL string) string {\n\tsum := crc32.ChecksumIEEE([]byte(detailURL))\n\treturn fmt.Sprintf(\"%s-%d\", pluginName, sum)\n}\n\nfunc normalizeURL(raw string) string {\n\tif strings.HasPrefix(raw, \"http\") {\n\t\treturn raw\n\t}\n\treturn baseURL + strings.TrimSpace(raw)\n}\n\nfunc newHTTPClient() *http.Client {\n\treturn &http.Client{\n\t\tTimeout: requestTimeout,\n\t\tTransport: &http.Transport{\n\t\t\tMaxIdleConns:        httpMaxIdleConns,\n\t\t\tMaxIdleConnsPerHost: httpMaxIdlePerHost,\n\t\t\tMaxConnsPerHost:     httpMaxConnsPerHost,\n\t\t\tIdleConnTimeout:     90 * time.Second,\n\t\t\tTLSHandshakeTimeout: 10 * time.Second,\n\t\t},\n\t}\n}\n\nfunc setHTMLHeaders(req *http.Request, referer string) {\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", referer)\n}\n\nfunc (p *MizixingPlugin) doRequestWithRetry(req *http.Request, client *http.Client, maxRetries int) (*http.Response, error) {\n\tvar lastErr error\n\n\tfor attempt := 0; attempt < maxRetries; attempt++ {\n\t\tresp, err := client.Do(req.Clone(req.Context()))\n\t\tif err == nil && resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t\tif attempt < maxRetries-1 {\n\t\t\ttime.Sleep(retryBaseDelay * time.Duration(1<<attempt))\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"重试 %d 次后失败: %w\", maxRetries, lastErr)\n}\n"
  },
  {
    "path": "plugin/muou/html结构分析.md",
    "content": "# 木偶(muou)网站HTML结构分析\n\n## 基本信息\n- **网站名称**: 中华人民共和国万岁(木偶网站)\n- **域名**: `123.666291.xyz`\n- **搜索URL格式**: `http://123.666291.xyz/index.php/vod/search/wd/{关键词}.html`\n- **详情页URL格式**: `http://123.666291.xyz/index.php/vod/detail/id/{ID}.html`\n- **网站特点**: 影视资源搜索网站，支持多种网盘下载\n\n## 搜索结果页面结构\n\n### 主要容器\n```html\n<div class=\"module\">\n    <div class=\"module-list\">\n        <div class=\"module-items\">\n            <!-- 搜索结果列表 -->\n        </div>\n    </div>\n</div>\n```\n\n### 单个搜索结果项\n```html\n<div class=\"module-search-item\">\n    <div class=\"video-cover\">\n        <div class=\"module-item-cover\">\n            <div class=\"module-item-pic\">\n                <a href=\"/index.php/vod/play/id/{id}/sid/1/nid/1.html\" title=\"立刻播放{标题}\">\n                    <i class=\"icon-play\"></i>\n                </a>\n                <img class=\"lazy lazyload\" data-src=\"{封面图片}\" alt=\"{标题}\">\n            </div>\n        </div>\n    </div>\n    <div class=\"video-info\">\n        <div class=\"video-info-header\">\n            <a class=\"video-serial\" href=\"/index.php/vod/detail/id/{id}.html\" title=\"{标题}\">更新至11集</a>\n            <h3><a href=\"/index.php/vod/detail/id/{id}.html\" title=\"{标题}\">{标题}</a></h3>\n            <div class=\"video-info-aux\">\n                <a href=\"/index.php/vod/type/id/2.html\" title=\"木偶剧集\" class=\"tag-link\">\n                    <span class=\"video-tag-icon\">\n                        <i class=\"icon-cate-ds\"></i>\n                        木偶剧集\n                    </span>\n                </a>\n                <!-- 年份、地区等信息 -->\n            </div>\n        </div>\n        <!-- 导演、主演、剧情等详细信息 -->\n    </div>\n</div>\n```\n\n## 详情页面结构\n\n### 下载链接容器\n```html\n<div class=\"module\" id=\"download-list\" name=\"download-list\">\n    <div class=\"module-heading\">\n        <h2 class=\"module-title\" title=\"凡人修仙传的影片下载列表\">影片下载</h2>\n        <div class=\"module-tab module-player-tab\">\n            <div class=\"module-tab-content\">\n                <div class=\"module-tab-item downtab-item selected\">\n                    <span data-dropdown-value=\"KK\">KK</span><small>1</small>\n                </div>\n                <div class=\"module-tab-item downtab-item\">\n                    <span data-dropdown-value=\"UC\">UC</span><small>1</small>\n                </div>\n            </div>\n        </div>\n    </div>\n    <div class=\"module-list module-player-list sort-list module-downlist selected\">\n        <div class=\"scroll-box-y\">\n            <!-- 下载链接列表 -->\n        </div>\n    </div>\n</div>\n```\n\n### 单个下载链接项\n```html\n<div class=\"module-row-one\">\n    <div class=\"module-row-info\">\n        <a class=\"module-row-text copy\" href=\"javascript:;\" \n           data-clipboard-text=\"https://pan.quark.cn/s/c6a8281edf6b\" \n           title=\"复制《凡人修仙传》第1集下载地址\">\n            <i class=\"icon-video-file\"></i>\n            <div class=\"module-row-title\">\n                <h4>凡人修仙传 - 第1集</h4>\n                <p>https://pan.quark.cn/s/c6a8281edf6b</p>\n            </div>\n        </a>\n        <div class=\"module-row-shortcuts\">\n            <a class=\"btn-pc btn-down\" href=\"https://pan.quark.cn/s/c6a8281edf6b\" \n               title=\"下载《凡人修仙传》第1集\">\n                <i class=\"icon-download\"></i><span>下载</span>\n            </a>\n            <a class=\"btn-copyurl copy\" href=\"javascript:;\" \n               data-clipboard-text=\"https://pan.quark.cn/s/c6a8281edf6b\" \n               title=\"复制《凡人修仙传》第1集下载地址\">\n                <i class=\"icon-url\"></i><span class=\"btn-pc\">复制链接</span>\n            </a>\n        </div>\n    </div>\n</div>\n```\n\n## CSS选择器总结\n\n### 搜索结果提取\n- **搜索结果容器**: `.module-search-item`\n- **标题**: `.video-info-header h3 a` (文本内容)\n- **详情页链接**: `.video-info-header h3 a` (href属性) - **重要：不是播放链接**\n- **播放链接**: `.module-item-pic a` (href属性，直接播放用)\n\n### 详情页下载链接提取\n- **下载链接容器**: `.module-row-one`\n- **下载链接**: `.module-row-text` (data-clipboard-text属性)\n- **文件标题**: `.module-row-title h4` (文本内容)\n- **直接链接**: `.module-row-title p` (文本内容，与data-clipboard-text相同)\n\n## 支持的网盘类型\n- **Quark网盘**: `https://pan.quark.cn/s/{分享码}`\n- **UC网盘**: `https://drive.uc.cn/s/{分享码}?public=1`\n\n## 反爬虫机制\n1. **时间延迟遮罩层**: 页面加载后显示全屏遮罩层覆盖内容\n2. **开发者工具检测**: 使用debugger语句检测开发者工具\n3. **右键菜单禁用**: 阻止右键菜单并显示警告\n4. **按键监听**: 禁用F12和Ctrl+Shift+I快捷键\n5. **域名弹窗**: 2秒后显示域名列表弹窗\n\n## 注意事项\n1. 需要设置完整的请求头模拟真实浏览器行为\n2. 应该提取详情页链接而不是播放链接进行下载信息获取\n3. 网站有多个域名备用，需要考虑域名切换的情况\n4. 下载链接支持多种网盘类型，需要正确识别链接类型"
  },
  {
    "path": "plugin/muou/muou.go",
    "content": "package muou\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\n// 预编译的正则表达式\nvar (\n\t// 从详情页URL中提取ID的正则表达式\n\tdetailIDRegex = regexp.MustCompile(`/vod/detail/id/(\\d+)\\.html`)\n\t\n\t// 常见网盘链接的正则表达式（支持16种类型）\n\tquarkLinkRegex     = regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9a-zA-Z]+`)\n\tucLinkRegex        = regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9a-zA-Z]+(\\?[^\"'\\s]*)?`)\n\tbaiduLinkRegex     = regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9a-zA-Z_\\-]+(\\?pwd=[0-9a-zA-Z]+)?`)\n\taliyunLinkRegex    = regexp.MustCompile(`https?://(www\\.)?(aliyundrive\\.com|alipan\\.com)/s/[0-9a-zA-Z]+`)\n\txunleiLinkRegex    = regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9a-zA-Z_\\-]+(\\?pwd=[0-9a-zA-Z]+)?`)\n\ttianyiLinkRegex    = regexp.MustCompile(`https?://cloud\\.189\\.cn/t/[0-9a-zA-Z]+`)\n\tlink115Regex       = regexp.MustCompile(`https?://115\\.com/s/[0-9a-zA-Z]+`)\n\tmobileLinkRegex    = regexp.MustCompile(`https?://caiyun\\.feixin\\.10086\\.cn/[0-9a-zA-Z]+`)\n\tweiyunLinkRegex    = regexp.MustCompile(`https?://share\\.weiyun\\.com/[0-9a-zA-Z]+`)\n\tlanzouLinkRegex    = regexp.MustCompile(`https?://(www\\.)?(lanzou[uixys]*|lan[zs]o[ux])\\.(com|net|org)/[0-9a-zA-Z]+`)\n\tjianguoyunLinkRegex = regexp.MustCompile(`https?://(www\\.)?jianguoyun\\.com/p/[0-9a-zA-Z]+`)\n\tlink123Regex       = regexp.MustCompile(`https?://123pan\\.com/s/[0-9a-zA-Z]+`)\n\tpikpakLinkRegex    = regexp.MustCompile(`https?://mypikpak\\.com/s/[0-9a-zA-Z]+`)\n\tmagnetLinkRegex    = regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9a-fA-F]{40}`)\n\ted2kLinkRegex      = regexp.MustCompile(`ed2k://\\|file\\|.+\\|\\d+\\|[0-9a-fA-F]{32}\\|/`)\n\t\n\t// 缓存相关\n\tdetailCache = sync.Map{} // 缓存详情页解析结果\n)\n\nconst (\n\t// 默认超时时间 - 优化为更短时间\n\tDefaultTimeout = 8 * time.Second\n\tDetailTimeout  = 6 * time.Second\n\n\t// 并发数限制 - 大幅提高并发数\n\tMaxConcurrency = 20\n\n\t// HTTP连接池配置\n\tMaxIdleConns        = 200\n\tMaxIdleConnsPerHost = 50\n\tMaxConnsPerHost     = 100\n\tIdleConnTimeout     = 90 * time.Second\n\n\t// 缓存TTL - 更短的缓存时间\n\tcacheTTL = 1 * time.Hour\n)\n\n// 性能统计（原子操作）\nvar (\n\tsearchRequests     int64 = 0\n\tdetailPageRequests int64 = 0\n\tcacheHits          int64 = 0\n\tcacheMisses        int64 = 0\n\ttotalSearchTime    int64 = 0 // 纳秒\n\ttotalDetailTime    int64 = 0 // 纳秒\n)\n\n// 在init函数中注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewMuouPlugin())\n}\n\n// MuouAsyncPlugin Muou异步插件\ntype MuouAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t}\n\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   DefaultTimeout,\n\t}\n}\n\n// NewMuouPlugin 创建新的Muou异步插件\nfunc NewMuouPlugin() *MuouAsyncPlugin {\n\treturn &MuouAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"muou\", 2),\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *MuouAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *MuouAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实现具体的搜索逻辑\nfunc (p *MuouAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 性能统计\n\tstart := time.Now()\n\tatomic.AddInt64(&searchRequests, 1)\n\tdefer func() {\n\t\tduration := time.Since(start).Nanoseconds()\n\t\tatomic.AddInt64(&totalSearchTime, duration)\n\t}()\n\n\t// 使用优化的客户端\n\tif p.optimizedClient != nil {\n\t\tclient = p.optimizedClient\n\t}\n\n\t// 1. 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"https://666.666291.xyz/index.php/vod/search/wd/%s.html\", url.QueryEscape(keyword))\n\t\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\t\n\t// 3. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 4. 设置完整的请求头（避免反爬虫）\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Referer\", \"https://666.666291.xyz/\")\n\t\n\t// 5. 发送请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 6. 解析搜索结果页面\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 7. 提取搜索结果\n\tvar results []model.SearchResult\n\t\n\tdoc.Find(\".module-search-item\").Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchItem(s, keyword)\n\t\tif result.UniqueID != \"\" {\n\t\t\tresults = append(results, result)\n\t\t}\n\t})\n\t\n\t// 8. 异步获取详情页信息\n\tenhancedResults := p.enhanceWithDetails(client, results)\n\t\n\t// 9. 关键词过滤\n\treturn plugin.FilterResultsByKeyword(enhancedResults, keyword), nil\n}\n\n// parseSearchItem 解析单个搜索结果项\nfunc (p *MuouAsyncPlugin) parseSearchItem(s *goquery.Selection, keyword string) model.SearchResult {\n\tresult := model.SearchResult{}\n\t\n\t// 提取详情页链接和ID (修正：使用正确的选择器)\n\tdetailLink, exists := s.Find(\".video-info-header h3 a\").First().Attr(\"href\")\n\tif !exists {\n\t\treturn result\n\t}\n\t\n\t// 提取ID\n\tmatches := detailIDRegex.FindStringSubmatch(detailLink)\n\tif len(matches) < 2 {\n\t\treturn result\n\t}\n\t\n\titemID := matches[1]\n\tresult.UniqueID = fmt.Sprintf(\"%s-%s\", p.Name(), itemID)\n\t\n\t// 提取标题\n\ttitleElement := s.Find(\".video-info-header h3 a\")\n\tresult.Title = strings.TrimSpace(titleElement.Text())\n\t\n\t// 提取资源类型/质量\n\tqualityElement := s.Find(\".video-serial\")\n\tquality := strings.TrimSpace(qualityElement.Text())\n\t\n\t// 提取分类信息\n\tvar tags []string\n\ts.Find(\".video-info-aux .tag-link a\").Each(func(i int, tag *goquery.Selection) {\n\t\ttagText := strings.TrimSpace(tag.Text())\n\t\tif tagText != \"\" {\n\t\t\ttags = append(tags, tagText)\n\t\t}\n\t})\n\tresult.Tags = tags\n\t\n\t// 提取导演信息\n\tdirector := \"\"\n\ts.Find(\".video-info-items\").Each(func(i int, item *goquery.Selection) {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\tif strings.Contains(title, \"导演\") {\n\t\t\tdirector = strings.TrimSpace(item.Find(\".video-info-actor a\").Text())\n\t\t}\n\t})\n\t\n\t// 提取主演信息\n\tvar actors []string\n\ts.Find(\".video-info-items\").Each(func(i int, item *goquery.Selection) {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\tif strings.Contains(title, \"主演\") {\n\t\t\titem.Find(\".video-info-actor a\").Each(func(j int, actor *goquery.Selection) {\n\t\t\t\tactorName := strings.TrimSpace(actor.Text())\n\t\t\t\tif actorName != \"\" {\n\t\t\t\t\tactors = append(actors, actorName)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\t\n\t// 提取剧情简介\n\tplotElement := s.Find(\".video-info-items\").FilterFunction(func(i int, item *goquery.Selection) bool {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\treturn strings.Contains(title, \"剧情\")\n\t})\n\tplot := strings.TrimSpace(plotElement.Find(\".video-info-item\").Text())\n\n\t// 提取封面图片 (参考 Pan_mogg.js 的选择器)\n\tvar images []string\n\tif picURL, exists := s.Find(\".module-item-pic > img\").Attr(\"data-src\"); exists && picURL != \"\" {\n\t\timages = append(images, picURL)\n\t}\n\tresult.Images = images\n\n\t// 构建内容描述\n\tvar contentParts []string\n\tif quality != \"\" {\n\t\tcontentParts = append(contentParts, \"【\"+quality+\"】\")\n\t}\n\tif director != \"\" {\n\t\tcontentParts = append(contentParts, \"导演：\"+director)\n\t}\n\tif len(actors) > 0 {\n\t\tactorStr := strings.Join(actors[:min(3, len(actors))], \"、\") // 只显示前3个演员\n\t\tif len(actors) > 3 {\n\t\t\tactorStr += \"等\"\n\t\t}\n\t\tcontentParts = append(contentParts, \"主演：\"+actorStr)\n\t}\n\tif plot != \"\" {\n\t\tcontentParts = append(contentParts, plot)\n\t}\n\n\tresult.Content = strings.Join(contentParts, \"\\n\")\n\tresult.Channel = \"\" // 插件搜索结果不设置频道名，只有Telegram频道结果才设置\n\tresult.Datetime = time.Time{} // 使用零值而不是nil，参考jikepan插件标准\n\n\treturn result\n}\n\n// enhanceWithDetails 异步获取详情页信息以获取下载链接\nfunc (p *MuouAsyncPlugin) enhanceWithDetails(client *http.Client, results []model.SearchResult) []model.SearchResult {\n\tvar enhancedResults []model.SearchResult\n\tvar mu sync.Mutex\n\tvar wg sync.WaitGroup\n\t\n\t// 限制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\n\tfor _, result := range results {\n\t\twg.Add(1)\n\t\tgo func(r model.SearchResult) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 从UniqueID提取ID\n\t\t\tparts := strings.Split(r.UniqueID, \"-\")\n\t\t\tif len(parts) < 2 {\n\t\t\t\tmu.Lock()\n\t\t\t\tenhancedResults = append(enhancedResults, r)\n\t\t\t\tmu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\titemID := parts[1]\n\n\t\t\t// 检查缓存\n\t\t\tif cached, ok := detailCache.Load(itemID); ok {\n\t\t\t\tif cachedResult, ok := cached.(model.SearchResult); ok {\n\t\t\t\t\tatomic.AddInt64(&cacheHits, 1)\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\tenhancedResults = append(enhancedResults, cachedResult)\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tatomic.AddInt64(&cacheMisses, 1)\n\n\t\t\t// 获取详情页链接和图片\n\t\t\tdetailLinks, detailImages := p.fetchDetailLinksAndImages(client, itemID)\n\t\t\tr.Links = detailLinks\n\n\t\t\t// 合并图片：优先使用详情页的海报，如果没有则使用搜索结果的图片\n\t\t\tif len(detailImages) > 0 {\n\t\t\t\tr.Images = detailImages\n\t\t\t}\n\n\t\t\t// 缓存结果\n\t\t\tdetailCache.Store(itemID, r)\n\n\t\t\tmu.Lock()\n\t\t\tenhancedResults = append(enhancedResults, r)\n\t\t\tmu.Unlock()\n\t\t}(result)\n\t}\n\t\n\twg.Wait()\n\treturn enhancedResults\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *MuouAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n\n// fetchDetailLinksAndImages 获取详情页的下载链接和图片\nfunc (p *MuouAsyncPlugin) fetchDetailLinksAndImages(client *http.Client, itemID string) ([]model.Link, []string) {\n\t// 性能统计\n\tstart := time.Now()\n\tatomic.AddInt64(&detailPageRequests, 1)\n\tdefer func() {\n\t\tduration := time.Since(start).Nanoseconds()\n\t\tatomic.AddInt64(&totalDetailTime, duration)\n\t}()\n\n\tdetailURL := fmt.Sprintf(\"https://666.666291.xyz/index.php/vod/detail/id/%s.html\", itemID)\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DetailTimeout)\n\tdefer cancel()\n\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"https://666.666291.xyz/\")\n\n\t// 发送请求（带重试）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, nil\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\n\tvar links []model.Link\n\tvar images []string\n\n\t// 提取详情页的海报图片 (参考 Pan_mogg.js 的选择器)\n\tif posterURL, exists := doc.Find(\".mobile-play .lazyload\").Attr(\"data-src\"); exists && posterURL != \"\" {\n\t\timages = append(images, posterURL)\n\t}\n\n\t// 查找下载链接区域\n\tdoc.Find(\"#download-list .module-row-one\").Each(func(i int, s *goquery.Selection) {\n\t\t// 从data-clipboard-text属性提取链接\n\t\tif linkURL, exists := s.Find(\"[data-clipboard-text]\").Attr(\"data-clipboard-text\"); exists {\n\t\t\t// 过滤掉无效链接\n\t\t\tif p.isValidNetworkDriveURL(linkURL) {\n\t\t\t\tif linkType := p.determineLinkType(linkURL); linkType != \"\" {\n\t\t\t\t\tlink := model.Link{\n\t\t\t\t\t\tType:     linkType,\n\t\t\t\t\t\tURL:      linkURL,\n\t\t\t\t\t\tPassword: \"\", // 大部分网盘不需要密码\n\t\t\t\t\t}\n\t\t\t\t\tlinks = append(links, link)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 也检查直接的href属性\n\t\ts.Find(\"a[href]\").Each(func(j int, a *goquery.Selection) {\n\t\t\tif linkURL, exists := a.Attr(\"href\"); exists {\n\t\t\t\t// 过滤掉无效链接\n\t\t\t\tif p.isValidNetworkDriveURL(linkURL) {\n\t\t\t\t\tif linkType := p.determineLinkType(linkURL); linkType != \"\" {\n\t\t\t\t\t\t// 避免重复添加\n\t\t\t\t\t\tisDuplicate := false\n\t\t\t\t\t\tfor _, existingLink := range links {\n\t\t\t\t\t\t\tif existingLink.URL == linkURL {\n\t\t\t\t\t\t\t\tisDuplicate = true\n\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif !isDuplicate {\n\t\t\t\t\t\t\tlink := model.Link{\n\t\t\t\t\t\t\t\tType:     linkType,\n\t\t\t\t\t\t\t\tURL:      linkURL,\n\t\t\t\t\t\t\t\tPassword: \"\",\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tlinks = append(links, link)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t})\n\n\treturn links, images\n}\n\n// fetchDetailLinks 获取详情页的下载链接（兼容性方法，仅返回链接）\nfunc (p *MuouAsyncPlugin) fetchDetailLinks(client *http.Client, itemID string) []model.Link {\n\tlinks, _ := p.fetchDetailLinksAndImages(client, itemID)\n\treturn links\n}\n\n// isValidNetworkDriveURL 检查URL是否为有效的网盘链接\nfunc (p *MuouAsyncPlugin) isValidNetworkDriveURL(url string) bool {\n\t// 过滤掉明显无效的链接\n\tif strings.Contains(url, \"javascript:\") || \n\t   strings.Contains(url, \"#\") ||\n\t   url == \"\" ||\n\t   (!strings.HasPrefix(url, \"http\") && !strings.HasPrefix(url, \"magnet:\") && !strings.HasPrefix(url, \"ed2k:\")) {\n\t\treturn false\n\t}\n\t\n\t// 检查是否匹配任何支持的网盘格式（16种）\n\treturn quarkLinkRegex.MatchString(url) ||\n\t\t   ucLinkRegex.MatchString(url) ||\n\t\t   baiduLinkRegex.MatchString(url) ||\n\t\t   aliyunLinkRegex.MatchString(url) ||\n\t\t   xunleiLinkRegex.MatchString(url) ||\n\t\t   tianyiLinkRegex.MatchString(url) ||\n\t\t   link115Regex.MatchString(url) ||\n\t\t   mobileLinkRegex.MatchString(url) ||\n\t\t   weiyunLinkRegex.MatchString(url) ||\n\t\t   lanzouLinkRegex.MatchString(url) ||\n\t\t   jianguoyunLinkRegex.MatchString(url) ||\n\t\t   link123Regex.MatchString(url) ||\n\t\t   pikpakLinkRegex.MatchString(url) ||\n\t\t   magnetLinkRegex.MatchString(url) ||\n\t\t   ed2kLinkRegex.MatchString(url)\n}\n\n// determineLinkType 根据URL确定链接类型（支持16种类型）\nfunc (p *MuouAsyncPlugin) determineLinkType(url string) string {\n\tswitch {\n\tcase quarkLinkRegex.MatchString(url):\n\t\treturn \"quark\"\n\tcase ucLinkRegex.MatchString(url):\n\t\treturn \"uc\"\n\tcase baiduLinkRegex.MatchString(url):\n\t\treturn \"baidu\"\n\tcase aliyunLinkRegex.MatchString(url):\n\t\treturn \"aliyun\"\n\tcase xunleiLinkRegex.MatchString(url):\n\t\treturn \"xunlei\"\n\tcase tianyiLinkRegex.MatchString(url):\n\t\treturn \"tianyi\"\n\tcase link115Regex.MatchString(url):\n\t\treturn \"115\"\n\tcase mobileLinkRegex.MatchString(url):\n\t\treturn \"mobile\"\n\tcase weiyunLinkRegex.MatchString(url):\n\t\treturn \"weiyun\"\n\tcase lanzouLinkRegex.MatchString(url):\n\t\treturn \"lanzou\"\n\tcase jianguoyunLinkRegex.MatchString(url):\n\t\treturn \"jianguoyun\"\n\tcase link123Regex.MatchString(url):\n\t\treturn \"123\"\n\tcase pikpakLinkRegex.MatchString(url):\n\t\treturn \"pikpak\"\n\tcase magnetLinkRegex.MatchString(url):\n\t\treturn \"magnet\"\n\tcase ed2kLinkRegex.MatchString(url):\n\t\treturn \"ed2k\"\n\tdefault:\n\t\treturn \"\" // 不支持的类型返回空字符串\n\t}\n}\n\n// min 返回两个整数中的较小值\nfunc min(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}\n\n// GetPerformanceStats 获取性能统计信息\nfunc (p *MuouAsyncPlugin) GetPerformanceStats() map[string]interface{} {\n\ttotalSearchRequests := atomic.LoadInt64(&searchRequests)\n\ttotalDetailRequests := atomic.LoadInt64(&detailPageRequests)\n\ttotalCacheHits := atomic.LoadInt64(&cacheHits)\n\ttotalCacheMisses := atomic.LoadInt64(&cacheMisses)\n\ttotalSearchTime := atomic.LoadInt64(&totalSearchTime)\n\ttotalDetailTime := atomic.LoadInt64(&totalDetailTime)\n\t\n\tvar avgSearchTime, avgDetailTime, cacheHitRate float64\n\tif totalSearchRequests > 0 {\n\t\tavgSearchTime = float64(totalSearchTime) / float64(totalSearchRequests) / 1e6 // 转换为毫秒\n\t}\n\tif totalDetailRequests > 0 {\n\t\tavgDetailTime = float64(totalDetailTime) / float64(totalDetailRequests) / 1e6 // 转换为毫秒\n\t}\n\tif totalCacheHits+totalCacheMisses > 0 {\n\t\tcacheHitRate = float64(totalCacheHits) / float64(totalCacheHits+totalCacheMisses) * 100\n\t}\n\t\n\treturn map[string]interface{}{\n\t\t\"search_requests\":        totalSearchRequests,\n\t\t\"detail_page_requests\":   totalDetailRequests,\n\t\t\"cache_hits\":            totalCacheHits,\n\t\t\"cache_misses\":          totalCacheMisses,\n\t\t\"cache_hit_rate\":        cacheHitRate,\n\t\t\"avg_search_time_ms\":    avgSearchTime,\n\t\t\"avg_detail_time_ms\":    avgDetailTime,\n\t\t\"total_search_time_ns\":  totalSearchTime,\n\t\t\"total_detail_time_ns\":  totalDetailTime,\n\t}\n}"
  },
  {
    "path": "plugin/nsgame/json结构分析.md",
    "content": "# NSGame API JSON 结构分析\n\n## 概述\n\nNSGame (NS游戏网) 是一个专门提供 Nintendo Switch 游戏资源的搜索平台，提供 RESTful API 接口进行游戏资源搜索。本文档详细说明 NSGame API 的请求格式和响应结构。\n\n## API 接口信息\n\n### 请求地址\n- **URL**: `https://nsthwj.com/thwj/game/query`\n- **方法**: GET\n- **参数**:\n  - `pageNum`: 页码（从1开始）\n  - `pageSize`: 每页大小（建议100）\n  - `type`: 游戏类型（可选，空字符串表示全部）\n  - `queryName`: 搜索关键词（URL编码）\n\n### 请求示例\n```\nGET https://nsthwj.com/thwj/game/query?pageNum=1&pageSize=100&type=&queryName=%E9%A9%AC%E9%87%8C%E5%A5%A5\n```\n\n### 请求头设置\n```http\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\nAccept: application/json, text/plain, */*\nAccept-Language: zh-CN,zh;q=0.9,en;q=0.8\nReferer: https://nsthwj.com/\n```\n\n## 响应数据结构\n\n### 根级响应结构\n\n```json\n{\n  \"success\": true,\n  \"data\": {\n    \"pageData\": {\n      \"totalCount\": 27,\n      \"pageNum\": 0,\n      \"data\": []\n    },\n    \"pageView\": null\n  },\n  \"code\": \"200\",\n  \"message\": null\n}\n```\n\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| `success` | boolean | 请求是否成功 |\n| `data` | object | 响应数据对象 |\n| `code` | string | 状态码，\"200\"表示成功 |\n| `message` | string/null | 错误消息，成功时为null |\n\n### data 对象结构\n\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| `pageData` | object | 分页数据对象 |\n| `pageView` | null | 页面视图信息（通常为null） |\n\n### pageData 对象结构\n\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| `totalCount` | number | 搜索结果总数 |\n| `pageNum` | number | 当前页码 |\n| `data` | array | 游戏资源列表 |\n\n### data 数组中的游戏资源项结构\n\n每个游戏资源项包含以下字段：\n\n```json\n{\n  \"name\": \"马里奥奥德赛|Super Mario Odyssey中文\",\n  \"url\": \"https://pan.baidu.com/s/1ZNTxWN-Vn7TUb6vq0QoIVA?pwd=thwj\\n[夸克网盘]：https://pan.quark.cn/s/2dab74360187\\n[UC网盘]：https://drive.uc.cn/s/843e8385fbb34\",\n  \"password\": \"最新版本：1.4.1\\n含1.4.1金手指\"\n}\n```\n\n| 字段名 | 类型 | 必填 | 示例值 | 说明 |\n|--------|------|------|--------|------|\n| `name` | string | ✓ | `\"马里奥奥德赛\\|Super Mario Odyssey中文\"` | 游戏名称（中文\\|英文） |\n| `url` | string | ✓ | 多行链接文本 | 网盘链接（换行符分隔） |\n| `password` | string | ✓ | `\"最新版本：1.4.1\\n含1.4.1金手指\"` | 版本信息和金手指说明 |\n\n## 特殊数据格式说明\n\n### 1. url 字段格式 ⭐ 重要\n\n`url` 字段包含多个网盘链接，使用换行符 `\\n` 分隔：\n\n```\nhttps://pan.baidu.com/s/1ZNTxWN-Vn7TUb6vq0QoIVA?pwd=thwj\n[夸克网盘]：https://pan.quark.cn/s/2dab74360187\n[UC网盘]：https://drive.uc.cn/s/843e8385fbb34\n```\n\n**格式规则**:\n- **百度网盘**: 直接链接，密码在URL参数中 `?pwd=xxxx`\n- **夸克网盘**: 格式 `[夸克网盘]：{链接}`，无密码\n- **UC网盘**: 格式 `[UC网盘]：{链接}`，无密码\n\n**提取方法**:\n1. 按 `\\n` 分割字符串\n2. 逐行解析链接\n3. 识别链接类型并提取\n\n### 2. password 字段格式\n\n`password` 字段实际上不是网盘提取码，而是游戏版本信息：\n\n```\n最新版本：1.4.1\n含1.4.1金手指\n```\n\n**内容说明**:\n- 第一行：游戏的最新版本号\n- 第二行：金手指信息（如果有）\n\n### 3. name 字段格式\n\n游戏名称使用竖线 `|` 分隔中英文：\n\n```\n马里奥奥德赛|Super Mario Odyssey中文\n```\n\n**格式规则**:\n- 中文名称 `|` 英文名称\n- 可能包含语言标识（中文、汉化等）\n\n## 支持的网盘平台\n\n| 平台标识 | 平台名称 | 域名特征 | 密码位置 |\n|----------|----------|----------|----------|\n| `baidu` | 百度网盘 | `pan.baidu.com` | URL参数 `?pwd=` |\n| `quark` | 夸克网盘 | `pan.quark.cn` | 无密码 |\n| `uc` | UC网盘 | `drive.uc.cn` | 无密码 |\n\n## 插件实现要点\n\n### 1. 插件配置\n- **插件名称**: `nsgame`\n- **优先级**: 建议设置为 2（高质量游戏资源）\n- **Service层过滤**: 启用（标准资源搜索插件）\n- **特点**: Nintendo Switch 游戏专属\n\n### 2. 数据转换映射\n\n| NSGame字段 | PanSou SearchResult字段 | 转换说明 |\n|------------|-------------------------|----------|\n| `name` | `UniqueID` | 格式：`nsgame-{游戏名hash}` |\n| `name` | `Title` | 游戏名称（中英文） |\n| `password` | `Content` | 版本信息和金手指说明 |\n| - | `Datetime` | 使用当前时间 |\n| `url` | `Links` | 解析多行链接文本 |\n| - | `Tags` | 添加\"NS游戏\"、\"Switch\"标签 |\n| - | `Channel` | 设置为空字符串（插件搜索结果） |\n\n### 3. 链接解析逻辑\n\n```go\n// 解析 url 字段中的多个网盘链接\nfunc parseMultipleLinks(urlText string) []model.Link {\n    var links []model.Link\n    \n    // 按换行符分割\n    lines := strings.Split(urlText, \"\\n\")\n    \n    for _, line := range lines {\n        line = strings.TrimSpace(line)\n        if line == \"\" {\n            continue\n        }\n        \n        // 提取链接和类型\n        var url, cloudType, password string\n        \n        if strings.Contains(line, \"[夸克网盘]\") {\n            // 夸克网盘格式\n            url = extractURL(line)\n            cloudType = \"quark\"\n        } else if strings.Contains(line, \"[UC网盘]\") {\n            // UC网盘格式\n            url = extractURL(line)\n            cloudType = \"uc\"\n        } else if strings.Contains(line, \"pan.baidu.com\") {\n            // 百度网盘格式\n            url, password = extractBaiduLink(line)\n            cloudType = \"baidu\"\n        }\n        \n        if url != \"\" {\n            links = append(links, model.Link{\n                Type:     cloudType,\n                URL:      url,\n                Password: password,\n            })\n        }\n    }\n    \n    return links\n}\n```\n\n### 4. 百度网盘密码提取\n\n百度网盘的密码在URL参数中，需要单独提取：\n\n```go\n// 从百度网盘URL中提取链接和密码\nfunc extractBaiduLink(line string) (url, password string) {\n    // 提取完整URL\n    re := regexp.MustCompile(`https://pan\\.baidu\\.com/s/[^?\\s]+(\\?pwd=[a-zA-Z0-9]+)?`)\n    matches := re.FindStringSubmatch(line)\n    if len(matches) > 0 {\n        fullURL := matches[0]\n        \n        // 提取密码参数\n        if strings.Contains(fullURL, \"?pwd=\") {\n            parts := strings.Split(fullURL, \"?pwd=\")\n            url = parts[0]\n            password = parts[1]\n        } else {\n            url = fullURL\n        }\n    }\n    \n    return\n}\n```\n\n### 5. 唯一ID生成\n\n由于API返回数据没有唯一ID字段，需要基于游戏名称生成：\n\n```go\nimport \"crypto/md5\"\n\nfunc generateUniqueID(gameName string) string {\n    // 使用游戏名称的MD5哈希的前12位\n    hash := md5.Sum([]byte(gameName))\n    return fmt.Sprintf(\"nsgame-%x\", hash)[:20]\n}\n```\n\n## 错误处理\n\n### 常见错误类型\n1. **API请求失败**: 网络连接失败或服务器错误\n2. **JSON解析错误**: 响应格式不符合预期\n3. **链接格式异常**: url字段格式不符合预期\n4. **空结果**: 关键词搜索无结果\n\n### 容错机制\n- **部分失败容忍**: 单个链接解析失败不影响其他链接\n- **数据验证**: 验证必填字段存在性\n- **默认值处理**: 缺失字段使用合理默认值\n- **日志记录**: 详细记录异常情况\n\n## 性能优化建议\n\n1. **分页控制**: 默认每页100条，避免过多请求\n2. **缓存策略**: 游戏资源更新不频繁，可设置较长缓存时间\n3. **超时设置**: 合理设置请求超时时间（建议10秒）\n4. **连接复用**: 使用HTTP连接池\n5. **关键词过滤**: 使用 `FilterResultsByKeyword` 提高相关性\n\n## 开发注意事项\n\n1. **链接解析**: 正确处理url字段中的多行链接文本\n2. **密码位置**: 百度网盘密码在URL参数中，不在password字段\n3. **版本信息**: password字段是版本信息，应作为Content展示\n4. **游戏名称**: name字段包含中英文，用竖线分隔\n5. **标签设置**: 添加\"NS游戏\"、\"Switch\"等标签帮助分类\n6. **唯一ID**: 基于游戏名称生成稳定的唯一标识\n7. **字符编码**: 确保正确处理中文字符\n8. **请求头**: 设置合适的User-Agent避免反爬虫\n\n## 示例代码结构\n\n```go\n// API响应结构\ntype NSGameResponse struct {\n    Success bool   `json:\"success\"`\n    Data    struct {\n        PageData struct {\n            TotalCount int            `json:\"totalCount\"`\n            PageNum    int            `json:\"pageNum\"`\n            Data       []NSGameItem   `json:\"data\"`\n        } `json:\"pageData\"`\n        PageView interface{} `json:\"pageView\"`\n    } `json:\"data\"`\n    Code    string      `json:\"code\"`\n    Message interface{} `json:\"message\"`\n}\n\n// 游戏资源项\ntype NSGameItem struct {\n    Name     string `json:\"name\"`     // 游戏名称\n    URL      string `json:\"url\"`      // 网盘链接（多行文本）\n    Password string `json:\"password\"` // 版本信息\n}\n```\n\n## API调用示例\n\n### 搜索马里奥游戏\n```bash\ncurl \"https://nsthwj.com/thwj/game/query?pageNum=1&pageSize=100&type=&queryName=%E9%A9%AC%E9%87%8C%E5%A5%A5\"\n```\n\n### 搜索塞尔达游戏\n```bash\ncurl \"https://nsthwj.com/thwj/game/query?pageNum=1&pageSize=100&type=&queryName=%E5%A1%9E%E5%B0%94%E8%BE%BE\"\n```\n\n## 总结\n\nNSGame API 的主要特点：\n- ✅ 专注于 Nintendo Switch 游戏资源\n- ✅ 支持多种主流网盘（百度、夸克、UC）\n- ✅ 提供详细的版本信息和金手指说明\n- ✅ 简单的分页接口设计\n- ⚠️ url字段格式特殊，需要特殊解析\n- ⚠️ password字段不是提取码，是版本信息\n\n实现此插件的关键在于正确解析 `url` 字段中的多行链接文本，并正确识别各网盘类型和提取密码。\n\n"
  },
  {
    "path": "plugin/nsgame/nsgame.go",
    "content": "package nsgame\n\nimport (\n\t\"context\"\n\t\"crypto/md5\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n)\n\nconst (\n\t// 插件名称\n\tpluginName = \"nsgame\"\n\t\n\t// API地址\n\tapiURL = \"https://nsthwj.com/thwj/game/query\"\n\t\n\t// 优先级\n\tdefaultPriority = 2\n\t\n\t// 超时时间\n\tdefaultTimeout = 10 * time.Second\n\t\n\t// 每页大小\n\tpageSize = 1000\n)\n\n// 预编译的正则表达式\nvar (\n\t// 提取URL的正则表达式\n\turlRegex = regexp.MustCompile(`https?://[^\\s]+`)\n\t\n\t// 百度网盘链接和密码提取\n\tbaiduLinkRegex = regexp.MustCompile(`https://pan\\.baidu\\.com/s/[^?\\s]+`)\n\tbaiduPwdRegex  = regexp.MustCompile(`\\?pwd=([a-zA-Z0-9]+)`)\n)\n\n// NSGameAsyncPlugin NSGame异步插件\ntype NSGameAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\n// NSGameResponse API响应结构\ntype NSGameResponse struct {\n\tSuccess bool   `json:\"success\"`\n\tData    struct {\n\t\tPageData struct {\n\t\t\tTotalCount int          `json:\"totalCount\"`\n\t\t\tPageNum    int          `json:\"pageNum\"`\n\t\t\tData       []NSGameItem `json:\"data\"`\n\t\t} `json:\"pageData\"`\n\t\tPageView interface{} `json:\"pageView\"`\n\t} `json:\"data\"`\n\tCode    string      `json:\"code\"`\n\tMessage interface{} `json:\"message\"`\n}\n\n// NSGameItem 游戏资源项\ntype NSGameItem struct {\n\tName     string `json:\"name\"`     // 游戏名称\n\tURL      string `json:\"url\"`      // 网盘链接（多行文本）\n\tPassword string `json:\"password\"` // 版本信息\n}\n\n// 在init函数中注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewNSGamePlugin())\n}\n\n// NewNSGamePlugin 创建新的NSGame异步插件\nfunc NewNSGamePlugin() *NSGameAsyncPlugin {\n\treturn &NSGameAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *NSGameAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *NSGameAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实现具体的搜索逻辑\nfunc (p *NSGameAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 1. 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"%s?pageNum=1&pageSize=%d&type=&queryName=%s\", \n\t\tapiURL, pageSize, url.QueryEscape(keyword))\n\t\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)\n\tdefer cancel()\n\t\n\t// 3. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 4. 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Referer\", \"https://nsthwj.com/\")\n\t\n\t// 5. 发送请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 6. 读取响应体\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 读取响应失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 7. 解析JSON响应\n\tvar apiResp NSGameResponse\n\tif err := json.Unmarshal(body, &apiResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] JSON解析失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 8. 检查响应状态\n\tif !apiResp.Success || apiResp.Code != \"200\" {\n\t\treturn nil, fmt.Errorf(\"[%s] API返回错误: success=%v, code=%s\", p.Name(), apiResp.Success, apiResp.Code)\n\t}\n\t\n\t// 9. 转换为标准格式\n\tvar results []model.SearchResult\n\tfor _, item := range apiResp.Data.PageData.Data {\n\t\t// 解析网盘链接\n\t\tlinks := p.parseLinks(item.URL)\n\t\tif len(links) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 生成唯一ID\n\t\tuniqueID := p.generateUniqueID(item.Name)\n\t\t\n\t\t// 将版本信息拼接到标题中\n\t\ttitle := item.Name\n\t\tif item.Password != \"\" {\n\t\t\t// 将换行符替换为空格，使标题更紧凑\n\t\t\tversionInfo := strings.ReplaceAll(item.Password, \"\\n\", \" \")\n\t\t\ttitle = fmt.Sprintf(\"%s（%s）\", item.Name, versionInfo)\n\t\t}\n\t\t\n\t\t// 构建结果\n\t\tresult := model.SearchResult{\n\t\t\tUniqueID: uniqueID,\n\t\t\tTitle:    title, // 标题包含版本信息\n\t\t\tContent:  item.Password, // 保留原始版本信息在Content中\n\t\t\tLinks:    links,\n\t\t\tTags:     []string{\"NS游戏\", \"Switch\"},\n\t\t\tChannel:  \"\", // 插件搜索结果 Channel 必须为空\n\t\t\tDatetime: time.Now(),\n\t\t}\n\t\t\n\t\tresults = append(results, result)\n\t}\n\t\n\t// 10. 关键词过滤\n\treturn plugin.FilterResultsByKeyword(results, keyword), nil\n}\n\n// parseLinks 解析url字段中的多个网盘链接\nfunc (p *NSGameAsyncPlugin) parseLinks(urlText string) []model.Link {\n\tvar links []model.Link\n\t\n\t// 按换行符分割\n\tlines := strings.Split(urlText, \"\\n\")\n\t\n\tfor _, line := range lines {\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 判断链接类型并提取\n\t\tif strings.Contains(line, \"[夸克网盘]\") {\n\t\t\t// 夸克网盘格式: [夸克网盘]：https://pan.quark.cn/s/xxx\n\t\t\tif url := p.extractURL(line); url != \"\" && strings.Contains(url, \"pan.quark.cn\") {\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tType:     \"quark\",\n\t\t\t\t\tURL:      url,\n\t\t\t\t\tPassword: \"\",\n\t\t\t\t})\n\t\t\t}\n\t\t} else if strings.Contains(line, \"[UC网盘]\") {\n\t\t\t// UC网盘格式: [UC网盘]：https://drive.uc.cn/s/xxx\n\t\t\tif url := p.extractURL(line); url != \"\" && strings.Contains(url, \"drive.uc.cn\") {\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tType:     \"uc\",\n\t\t\t\t\tURL:      url,\n\t\t\t\t\tPassword: \"\",\n\t\t\t\t})\n\t\t\t}\n\t\t} else if strings.Contains(line, \"pan.baidu.com\") {\n\t\t\t// 百度网盘格式: https://pan.baidu.com/s/xxx?pwd=xxxx\n\t\t\turl, password := p.extractBaiduLink(line)\n\t\t\tif url != \"\" {\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tType:     \"baidu\",\n\t\t\t\t\tURL:      url,\n\t\t\t\t\tPassword: password,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\t\n\treturn links\n}\n\n// extractURL 从文本中提取URL\nfunc (p *NSGameAsyncPlugin) extractURL(text string) string {\n\tmatches := urlRegex.FindString(text)\n\treturn strings.TrimSpace(matches)\n}\n\n// extractBaiduLink 从百度网盘链接中提取URL和密码\nfunc (p *NSGameAsyncPlugin) extractBaiduLink(line string) (url, password string) {\n\t// 提取完整URL\n\tfullURL := urlRegex.FindString(line)\n\tif fullURL == \"\" {\n\t\treturn\n\t}\n\t\n\t// 提取基础链接\n\tlinkMatches := baiduLinkRegex.FindString(fullURL)\n\tif linkMatches == \"\" {\n\t\treturn\n\t}\n\turl = linkMatches\n\t\n\t// 提取密码\n\tpwdMatches := baiduPwdRegex.FindStringSubmatch(fullURL)\n\tif len(pwdMatches) >= 2 {\n\t\tpassword = pwdMatches[1]\n\t}\n\t\n\treturn\n}\n\n// generateUniqueID 基于游戏名称生成唯一ID\nfunc (p *NSGameAsyncPlugin) generateUniqueID(gameName string) string {\n\t// 使用MD5哈希生成稳定的唯一ID\n\thash := md5.Sum([]byte(gameName))\n\treturn fmt.Sprintf(\"%s-%x\", p.Name(), hash)[:28]\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *NSGameAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n\n"
  },
  {
    "path": "plugin/nyaa/html结构分析.md",
    "content": "# Nyaa.si BT种子搜索站结构分析\n\n## 网站信息\n\n- **网站名称**: Nyaa.si\n- **网站URL**: https://nyaa.si\n- **网站类型**: 动漫BT种子搜索引擎\n- **数据源**: HTML页面爬虫\n- **主要特点**: 专注于动漫、漫画等ACG资源的BT种子搜索\n\n## 搜索URL格式\n\n```\nhttps://nyaa.si/?f=0&c=0_0&q={关键词}\n```\n\n### URL参数说明\n\n| 参数 | 说明 | 示例值 |\n|------|------|--------|\n| `q` | 搜索关键词 | `tomb` |\n| `f` | 过滤器 (0=无过滤, 1=无重制, 2=仅信任) | `0` |\n| `c` | 分类 (0_0=全部, 1_0=动漫, 1_2=英文动漫, 1_3=非英文动漫等) | `0_0` |\n| `s` | 排序字段 (id/size/comments/seeders/leechers/downloads) | 可选 |\n| `o` | 排序方式 (asc/desc) | 可选 |\n\n## 搜索结果页面结构\n\n### 主容器\n\n搜索结果显示在一个表格中：\n\n```html\n<table class=\"table table-bordered table-hover table-striped torrent-list\">\n  <thead>\n    <tr>\n      <th class=\"hdr-category\">Category</th>\n      <th class=\"hdr-name\">Name</th>\n      <th class=\"hdr-comments\">Comments</th>\n      <th class=\"hdr-link\">Link</th>\n      <th class=\"hdr-size\">Size</th>\n      <th class=\"hdr-date\">Date</th>\n      <th class=\"hdr-seeders\">Seeders</th>\n      <th class=\"hdr-leechers\">Leechers</th>\n      <th class=\"hdr-downloads\">Downloads</th>\n    </tr>\n  </thead>\n  <tbody>\n    <!-- 搜索结果行 -->\n  </tbody>\n</table>\n```\n\n### 单个搜索结果行结构\n\n每个搜索结果是一个 `<tr>` 元素，包含以下字段：\n\n```html\n<tr class=\"default\">  <!-- class可能是: default, success, danger, warning -->\n  <!-- 1. 分类 -->\n  <td>\n    <a href=\"/?c=1_3\" title=\"Anime - Non-English-translated\">\n      <img src=\"/static/img/icons/nyaa/1_3.png\" alt=\"Anime - Non-English-translated\" class=\"category-icon\">\n    </a>\n  </td>\n  \n  <!-- 2. 标题（跨2列） -->\n  <td colspan=\"2\">\n    <a href=\"/view/2024388\" title=\"[GM-Team][国漫][神墓 第3季][Tomb of Fallen Gods Ⅲ][2025][09][GB][4K HEVC 10Bit]\">\n      [GM-Team][国漫][神墓 第3季][Tomb of Fallen Gods Ⅲ][2025][09][GB][4K HEVC 10Bit]\n    </a>\n  </td>\n  \n  <!-- 3. 下载链接 -->\n  <td class=\"text-center\">\n    <a href=\"/download/2024388.torrent\"><i class=\"fa fa-fw fa-download\"></i></a>\n    <a href=\"magnet:?xt=urn:btih:e47fcca0f3f1e24b1cc871a07881350faca92636&amp;dn=%5BGM-Team%5D...\">\n      <i class=\"fa fa-fw fa-magnet\"></i>\n    </a>\n  </td>\n  \n  <!-- 4. 文件大小 -->\n  <td class=\"text-center\">1.1 GiB</td>\n  \n  <!-- 5. 发布时间 -->\n  <td class=\"text-center\" data-timestamp=\"1758941208\">2025-09-27 02:46</td>\n  \n  <!-- 6. 做种数 -->\n  <td class=\"text-center\">60</td>\n  \n  <!-- 7. 下载数 -->\n  <td class=\"text-center\">13</td>\n  \n  <!-- 8. 完成数 -->\n  <td class=\"text-center\">286</td>\n</tr>\n```\n\n## 字段提取规则\n\n### 1. 分类信息\n- **选择器**: `td:nth-child(1) a`\n- **提取**: `title` 属性\n- **示例**: \"Anime - Non-English-translated\"\n\n### 2. 标题和详情链接\n- **选择器**: `td[colspan=\"2\"] a`\n- **标题**: `text()` 或 `title` 属性\n- **详情链接**: `href` 属性 (如 `/view/2024388`)\n- **唯一ID**: 从href提取数字部分\n\n### 3. 下载链接\n- **种子文件**: `td.text-center a[href^=\"/download/\"]`\n  - 格式: `/download/{ID}.torrent`\n  - 完整URL: `https://nyaa.si/download/{ID}.torrent`\n  \n- **磁力链接**: `td.text-center a[href^=\"magnet:\"]`\n  - 格式: `magnet:?xt=urn:btih:{HASH}&dn={文件名}&tr={tracker列表}`\n  - 提取: 直接获取 `href` 属性\n\n### 4. 文件大小\n- **选择器**: `td.text-center` (第4个td)\n- **格式**: \"1.1 GiB\", \"500.0 MiB\", \"3.2 TiB\"\n- **提取**: 直接文本内容\n\n### 5. 发布时间\n- **选择器**: `td.text-center[data-timestamp]`\n- **时间戳**: `data-timestamp` 属性 (Unix timestamp)\n- **显示时间**: 文本内容 \"2025-09-27 02:46\"\n\n### 6. 种子统计信息\n- **做种数 (Seeders)**: 第6个 `td.text-center`\n- **下载数 (Leechers)**: 第7个 `td.text-center`\n- **完成数 (Downloads)**: 第8个 `td.text-center`\n\n## 搜索结果类型标识\n\n通过 `<tr>` 的 class 属性区分资源质量：\n\n| Class | 含义 | 说明 |\n|-------|------|------|\n| `default` | 普通资源 | 灰色背景 |\n| `success` | 可信任/已验证资源 | 绿色背景 |\n| `danger` | 重制版 | 红色背景 |\n| `warning` | 警告/可疑 | 黄色背景 |\n\n## 磁力链接格式\n\n```\nmagnet:?xt=urn:btih:{INFO_HASH}\n&dn={URL编码的文件名}\n&tr={tracker1}\n&tr={tracker2}\n&tr={tracker3}\n...\n```\n\n### 常见Tracker列表\n\n```\nhttp://nyaa.tracker.wf:7777/announce\nudp://open.stealth.si:80/announce\nudp://tracker.opentrackr.org:1337/announce\nudp://exodus.desync.com:6969/announce\nudp://tracker.torrent.eu.org:451/announce\n```\n\n## 反爬虫策略\n\n### 请求头设置\n\n```go\nreq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...\")\nreq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\")\nreq.Header.Set(\"Accept-Language\", \"en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7\")\nreq.Header.Set(\"Referer\", \"https://nyaa.si/\")\n```\n\n### 访问频率控制\n- 建议请求间隔：100-200ms\n- 超时时间：10秒\n- 重试次数：3次\n\n## 插件设计\n\n### 基本信息\n- **插件名称**: nyaa\n- **优先级**: 3 (普通质量数据源)\n- **Service层过滤**: 跳过 (磁力搜索插件，标题格式特殊)\n- **缓存TTL**: 30分钟\n\n### 搜索流程\n\n```\n1. 构建搜索URL\n   ↓\n2. 发送HTTP请求（带重试）\n   ↓\n3. 解析HTML页面 (goquery)\n   ↓\n4. 查找表格 table.torrent-list\n   ↓\n5. 遍历 tbody > tr 提取信息\n   ↓\n6. 提取磁力链接\n   ↓\n7. 关键词过滤（插件层）\n   ↓\n8. 返回结果\n```\n\n### 数据转换\n\n#### SearchResult 字段映射\n\n| Nyaa字段 | SearchResult字段 | 说明 |\n|---------|-----------------|------|\n| 标题 | Title | 资源标题 |\n| 分类+大小+统计 | Content | 拼接描述信息 |\n| 磁力链接 | Links[0].URL | magnet链接 |\n| 发布时间 | Datetime | Unix timestamp转换 |\n| 分类 | Tags[0] | 资源分类 |\n| 做种/下载/完成 | Tags[1-3] | 统计信息 |\n| 唯一ID | UniqueID | nyaa-{ID} |\n| 频道 | Channel | 空字符串 |\n\n#### Link 字段设置\n\n```go\nLink{\n    Type:     \"magnet\",  // 固定为magnet\n    URL:      magnetURL,  // 完整的磁力链接\n    Password: \"\",         // 磁力链接无密码\n}\n```\n\n## 性能优化\n\n### 1. HTTP连接池\n```go\nMaxIdleConns:        50\nMaxIdleConnsPerHost: 20\nMaxConnsPerHost:     30\nIdleConnTimeout:     90 * time.Second\n```\n\n### 2. 超时控制\n- 搜索请求超时：10秒\n- 重试间隔：指数退避（200ms, 400ms, 800ms）\n\n### 3. 缓存策略\n- 搜索结果缓存：30分钟\n- 定期清理：每小时清理一次过期缓存\n\n## 使用示例\n\n### API请求\n```bash\ncurl \"http://localhost:8888/api/search?kw=神墓&plugins=nyaa\"\n```\n\n### 预期响应\n```json\n{\n  \"code\": 0,\n  \"message\": \"success\",\n  \"data\": {\n    \"results\": [\n      {\n        \"unique_id\": \"nyaa-2024388\",\n        \"title\": \"[GM-Team][国漫][神墓 第3季][Tomb of Fallen Gods Ⅲ][2025][09][GB][4K HEVC 10Bit]\",\n        \"content\": \"分类: Anime - Non-English-translated | 大小: 1.1 GiB | 做种: 60 | 下载: 13 | 完成: 286\",\n        \"datetime\": \"2025-09-27T02:46:00Z\",\n        \"links\": [\n          {\n            \"type\": \"magnet\",\n            \"url\": \"magnet:?xt=urn:btih:e47fcca0f3f1e24b1cc871a07881350faca92636&dn=...\",\n            \"password\": \"\"\n          }\n        ],\n        \"tags\": [\"Anime - Non-English-translated\", \"做种:60\", \"下载:13\", \"完成:286\"],\n        \"channel\": \"\"\n      }\n    ]\n  }\n}\n```\n\n## 注意事项\n\n### 优点\n- ✅ **专业的ACG资源站**: 动漫资源质量高\n- ✅ **磁力链接直接可用**: 无需下载种子文件\n- ✅ **完整的统计信息**: 做种数、下载数、完成数\n- ✅ **分类清晰**: 多种分类便于筛选\n- ✅ **更新及时**: 最新动漫资源快速更新\n\n### 注意事项\n- ⚠️ **仅提供磁力链接**: 不是网盘资源\n- ⚠️ **标题格式特殊**: 使用方括号、点号等特殊格式\n- ⚠️ **需要跳过Service层过滤**: 避免误删有效结果\n- ⚠️ **英文为主**: 部分资源标题为英文\n- ⚠️ **BT下载**: 需要BT客户端支持\n\n## 维护建议\n\n1. **定期检查网站结构**: 网站可能更新HTML结构\n2. **监控成功率**: 检查请求成功率和解析准确率\n3. **优化关键词匹配**: 针对特殊标题格式优化过滤逻辑\n4. **tracker更新**: 定期更新tracker列表以提高连接成功率\n"
  },
  {
    "path": "plugin/nyaa/nyaa.go",
    "content": "package nyaa\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\n// 预编译的正则表达式\nvar (\n\t// 从详情链接提取ID的正则表达式\n\tviewIDRegex = regexp.MustCompile(`/view/(\\d+)`)\n\t\n\t// 磁力链接正则表达式\n\tmagnetRegex = regexp.MustCompile(`magnet:\\?xt=urn:btih:[a-zA-Z0-9]+[^\\s'\"<>]*`)\n)\n\nconst (\n\t// 超时时间\n\tDefaultTimeout = 10 * time.Second\n\t\n\t// HTTP连接池配置\n\tMaxIdleConns        = 50\n\tMaxIdleConnsPerHost = 20\n\tMaxConnsPerHost     = 30\n\tIdleConnTimeout     = 90 * time.Second\n\t\n\t// 网站URL\n\tSiteURL = \"https://nyaa.si\"\n)\n\n// 在init函数中注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewNyaaPlugin())\n}\n\n// NyaaPlugin Nyaa BT搜索插件\ntype NyaaPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t}\n\treturn &http.Client{Transport: transport, Timeout: DefaultTimeout}\n}\n\n// NewNyaaPlugin 创建新的Nyaa插件\nfunc NewNyaaPlugin() *NyaaPlugin {\n\treturn &NyaaPlugin{\n\t\t// 优先级3：普通质量数据源，跳过Service层过滤（磁力搜索插件）\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"nyaa\", 3, true),\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *NyaaPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *NyaaPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实现具体的搜索逻辑\nfunc (p *NyaaPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 支持英文搜索优化\n\tsearchKeyword := keyword\n\tif ext != nil {\n\t\tif titleEn, exists := ext[\"title_en\"]; exists {\n\t\t\tif titleEnStr, ok := titleEn.(string); ok && titleEnStr != \"\" {\n\t\t\t\tsearchKeyword = titleEnStr\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 1. 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"%s/?f=0&c=0_0&q=%s\", SiteURL, url.QueryEscape(searchKeyword))\n\t\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\t\n\t// 3. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 4. 设置完整的请求头（避免反爬虫）\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", SiteURL)\n\t\n\t// 5. 发送请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 6. 解析搜索结果页面\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 7. 提取搜索结果\n\tvar results []model.SearchResult\n\t\n\t// 查找种子列表表格\n\ttable := doc.Find(\"table.torrent-list tbody\")\n\tif table.Length() == 0 {\n\t\treturn []model.SearchResult{}, nil // 没有搜索结果\n\t}\n\t\n\t// 8. 解析每个搜索结果行\n\ttable.Find(\"tr\").Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchRow(s)\n\t\tif result.UniqueID != \"\" {\n\t\t\tresults = append(results, result)\n\t\t}\n\t})\n\t\n\t// 9. 关键词过滤（插件层过滤，使用实际搜索的关键词）\n\treturn plugin.FilterResultsByKeyword(results, searchKeyword), nil\n}\n\n// parseSearchRow 解析单个搜索结果行\nfunc (p *NyaaPlugin) parseSearchRow(s *goquery.Selection) model.SearchResult {\n\tresult := model.SearchResult{}\n\t\n\t// 1. 提取分类信息\n\tcategoryLink := s.Find(\"td:nth-child(1) a\")\n\tcategory := \"\"\n\tif categoryLink.Length() > 0 {\n\t\tcategory, _ = categoryLink.Attr(\"title\")\n\t}\n\t\n\t// 2. 提取标题和详情链接\n\ttitleLink := s.Find(\"td[colspan='2'] a\")\n\tif titleLink.Length() == 0 {\n\t\treturn result\n\t}\n\t\n\ttitle := strings.TrimSpace(titleLink.Text())\n\tif title == \"\" {\n\t\t// 如果text为空，尝试从title属性获取\n\t\ttitle, _ = titleLink.Attr(\"title\")\n\t}\n\t\n\tdetailHref, exists := titleLink.Attr(\"href\")\n\tif !exists || detailHref == \"\" {\n\t\treturn result\n\t}\n\t\n\t// 3. 从详情链接提取ID\n\tmatches := viewIDRegex.FindStringSubmatch(detailHref)\n\tif len(matches) < 2 {\n\t\treturn result\n\t}\n\titemID := matches[1]\n\tresult.UniqueID = fmt.Sprintf(\"%s-%s\", p.Name(), itemID)\n\tresult.Title = title\n\t\n\t// 4. 提取磁力链接\n\tmagnetLink := s.Find(\"td.text-center a[href^='magnet:']\")\n\tif magnetLink.Length() > 0 {\n\t\tmagnetURL, exists := magnetLink.Attr(\"href\")\n\t\tif exists && magnetURL != \"\" {\n\t\t\tresult.Links = []model.Link{\n\t\t\t\t{\n\t\t\t\t\tType:     \"magnet\",\n\t\t\t\t\tURL:      magnetURL,\n\t\t\t\t\tPassword: \"\",\n\t\t\t\t},\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 如果没有找到磁力链接，返回空结果\n\tif len(result.Links) == 0 {\n\t\tresult.UniqueID = \"\"\n\t\treturn result\n\t}\n\t\n\t// 5. 提取文件大小\n\tsizeTd := s.Find(\"td.text-center\").Eq(1) // 第4个td，索引从1开始（跳过链接td）\n\tsize := strings.TrimSpace(sizeTd.Text())\n\t\n\t// 6. 提取发布时间\n\tdateTd := s.Find(\"td.text-center[data-timestamp]\")\n\ttimestamp := int64(0)\n\tif dateTd.Length() > 0 {\n\t\tif timestampStr, exists := dateTd.Attr(\"data-timestamp\"); exists {\n\t\t\tif ts, err := strconv.ParseInt(timestampStr, 10, 64); err == nil {\n\t\t\t\ttimestamp = ts\n\t\t\t}\n\t\t}\n\t}\n\t\n\tif timestamp > 0 {\n\t\tresult.Datetime = time.Unix(timestamp, 0)\n\t} else {\n\t\tresult.Datetime = time.Now()\n\t}\n\t\n\t// 7. 提取种子统计信息\n\ttds := s.Find(\"td.text-center\")\n\tseeders := \"0\"\n\tleechers := \"0\"\n\tdownloads := \"0\"\n\t\n\tif tds.Length() >= 6 {\n\t\t// 倒数第3个是做种数\n\t\tseeders = strings.TrimSpace(tds.Eq(tds.Length() - 3).Text())\n\t\t// 倒数第2个是下载数\n\t\tleechers = strings.TrimSpace(tds.Eq(tds.Length() - 2).Text())\n\t\t// 倒数第1个是完成数\n\t\tdownloads = strings.TrimSpace(tds.Eq(tds.Length() - 1).Text())\n\t}\n\t\n\t// 8. 构建内容描述\n\tvar contentParts []string\n\tif category != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"分类: %s\", category))\n\t}\n\tif size != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"大小: %s\", size))\n\t}\n\tcontentParts = append(contentParts, fmt.Sprintf(\"做种: %s\", seeders))\n\tcontentParts = append(contentParts, fmt.Sprintf(\"下载: %s\", leechers))\n\tcontentParts = append(contentParts, fmt.Sprintf(\"完成: %s\", downloads))\n\t\n\tresult.Content = strings.Join(contentParts, \" | \")\n\t\n\t// 9. 设置标签\n\tvar tags []string\n\tif category != \"\" {\n\t\ttags = append(tags, category)\n\t}\n\ttags = append(tags, fmt.Sprintf(\"做种:%s\", seeders))\n\ttags = append(tags, fmt.Sprintf(\"下载:%s\", leechers))\n\ttags = append(tags, fmt.Sprintf(\"完成:%s\", downloads))\n\tresult.Tags = tags\n\t\n\t// 10. Channel必须为空字符串（插件搜索结果）\n\tresult.Channel = \"\"\n\t\n\treturn result\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *NyaaPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n"
  },
  {
    "path": "plugin/ouge/json结构分析.md",
    "content": "# Ouge API 数据结构分析\n\n## 基本信息\n- **数据源类型**: JSON API  \n- **API URL格式**: `https://woog.nxog.eu.org/api.php/provide/vod?ac=detail&wd={关键词}`\n- **数据特点**: 视频点播(VOD)系统API，提供结构化影视资源数据\n- **重要发现**: **与wanou插件使用完全相同的API和数据结构**\n\n## API响应结构\n\n### 顶层结构\n```json\n{\n    \"code\": 1,                    // 状态码：1表示成功\n    \"msg\": \"数据列表\",             // 响应消息\n    \"page\": 1,                    // 当前页码\n    \"pagecount\": 1,               // 总页数\n    \"limit\": 20,                  // 每页限制条数\n    \"total\": 3,                   // 总记录数\n    \"list\": []                    // 数据列表数组\n}\n```\n\n### `list`数组中的数据项结构\n```json\n{\n    \"vod_id\": 18010,                    // 资源唯一ID\n    \"vod_name\": \"凡人修仙传\",            // 资源标题\n    \"vod_actor\": \"杨洋,金晨,汪铎...\",    // 主演（逗号分隔）\n    \"vod_director\": \"杨阳\",             // 导演\n    \"vod_area\": \"中国大陆\",             // 地区\n    \"vod_year\": \"2025\",                 // 年份\n    \"vod_remarks\": \"第11集\",            // 更新状态/备注\n    \"vod_pubdate\": \"2025-07-27(中国大陆)\", // 发布日期\n    \"vod_content\": \"<p>...</p>\",        // 内容描述（HTML格式）\n    \"vod_pic\": \"https://...\",           // 封面图片URL\n    \n    // 关键字段：下载链接相关\n    \"vod_down_from\": \"bd$$$KG$$$UC\",    // 下载源标识（$$$分隔）\n    \"vod_down_url\": \"https://pan.baidu.com/s/13milLJZV5_7DCzGDQu-fcA?pwd=8888$$$https://pan.quark.cn/s/0fe46ed6eefc$$$https://drive.uc.cn/s/d83caf5d4fb74\"\n}\n```\n\n## 插件所需字段映射\n\n| 源字段 | 目标字段 | 说明 |\n|--------|----------|------|\n| `vod_id` | `UniqueID` | 格式: `ouge-{vod_id}` |\n| `vod_name` | `Title` | 资源标题 |\n| `vod_actor`, `vod_director`, `vod_area`, `vod_year`, `vod_remarks` | `Content` | 组合描述信息 |\n| `vod_year`, `vod_area` | `Tags` | 标签数组 |\n| `vod_down_from` + `vod_down_url` | `Links` | 解析为Link数组 |\n| `\"\"` | `Channel` | 插件搜索结果Channel为空 |\n| `time.Now()` | `Datetime` | 当前时间 |\n\n## 下载链接解析\n\n### 分隔符规则\n- **多个下载源**: 使用 `$$$` 分隔\n- **对应关系**: `vod_down_from`、`vod_down_url` 按相同位置对应\n\n### 下载源标识映射（与wanou相同）\n| API标识 | 网盘类型 | 域名示例 |\n|---------|----------|----------|\n| `bd`    | baidu (百度网盘) | `pan.baidu.com` |\n| `KG`    | quark (夸克网盘) | `pan.quark.cn` |\n| `UC`    | uc (UC网盘) | `drive.uc.cn` |\n\n### 链接格式示例\n```\n百度网盘: https://pan.baidu.com/s/13milLJZV5_7DCzGDQu-fcA?pwd=8888\n夸克网盘: https://pan.quark.cn/s/0fe46ed6eefc\nUC网盘: https://drive.uc.cn/s/d83caf5d4fb74\n```\n\n## 支持的网盘类型（16种）\n\n### 主流网盘\n- **baidu (百度网盘)**: `https://pan.baidu.com/s/{分享码}?pwd={密码}`\n- **quark (夸克网盘)**: `https://pan.quark.cn/s/{分享码}`\n- **aliyun (阿里云盘)**: `https://aliyundrive.com/s/{分享码}`, `https://www.alipan.com/s/{分享码}`\n- **uc (UC网盘)**: `https://drive.uc.cn/s/{分享码}`\n- **xunlei (迅雷网盘)**: `https://pan.xunlei.com/s/{分享码}`\n\n### 运营商网盘\n- **tianyi (天翼云盘)**: `https://cloud.189.cn/t/{分享码}`\n- **mobile (移动网盘)**: `https://caiyun.feixin.10086.cn/{分享码}`\n\n### 专业网盘\n- **115 (115网盘)**: `https://115.com/s/{分享码}`\n- **weiyun (微云)**: `https://share.weiyun.com/{分享码}`\n- **lanzou (蓝奏云)**: `https://lanzou.com/{分享码}`\n- **jianguoyun (坚果云)**: `https://jianguoyun.com/{分享码}`\n- **123 (123网盘)**: `https://123pan.com/s/{分享码}`\n- **pikpak (PikPak)**: `https://mypikpak.com/s/{分享码}`\n\n### 其他协议\n- **magnet (磁力链接)**: `magnet:?xt=urn:btih:{hash}`\n- **ed2k (电驴链接)**: `ed2k://|file|{filename}|{size}|{hash}|/`\n- **others (其他类型)**: 其他不在上述分类中的链接\n\n## 插件开发指导\n\n### 请求示例\n```go\nsearchURL := fmt.Sprintf(\"https://woog.nxog.eu.org/api.php/provide/vod?ac=detail&wd=%s\", url.QueryEscape(keyword))\n```\n\n### SearchResult构建示例\n```go\nresult := model.SearchResult{\n    UniqueID: fmt.Sprintf(\"ouge-%d\", item.VodID),\n    Title:    item.VodName,\n    Content:  buildContent(item),\n    Links:    parseDownloadLinks(item.VodDownFrom, item.VodDownURL),\n    Tags:     []string{item.VodYear, item.VodArea},\n    Channel:  \"\", // 插件搜索结果Channel为空\n    Datetime: time.Now(),\n}\n```\n\n### 链接解析逻辑\n```go\n// 按$$$分隔\nfromParts := strings.Split(item.VodDownFrom, \"$$$\")\nurlParts := strings.Split(item.VodDownURL, \"$$$\")\n\n// 遍历对应位置\nfor i := 0; i < min(len(fromParts), len(urlParts)); i++ {\n    linkType := mapCloudType(fromParts[i], urlParts[i])\n    password := extractPassword(urlParts[i])\n    // ...\n}\n```\n\n## 注意事项\n1. **API兼容性**: 与wanou插件完全兼容，可以共享代码实现\n2. **数据格式**: 纯JSON API，无需HTML解析\n3. **分隔符处理**: 多个值使用`$$$`分隔，需要split处理\n4. **密码提取**: 部分百度网盘链接包含`?pwd=`参数\n5. **错误处理**: API可能返回`code != 1`的错误状态\n6. **链接验证**: 应过滤无效链接（如`javascript:;`等）\n\n## 开发建议\n- **代码复用**: 可以直接复制wanou插件的实现，仅修改插件名称和API域名\n- **域名差异**: 唯一区别是使用`woog.nxog.eu.org`而不是其他域名\n- **缓存策略**: 建议使用相同的缓存机制和TTL设置"
  },
  {
    "path": "plugin/ouge/ouge.go",
    "content": "package ouge\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\t\"context\"\n\t\"sync/atomic\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\nconst (\n\t// 默认超时时间 - 优化为更短时间\n\tDefaultTimeout = 8 * time.Second\n\n\t// HTTP连接池配置\n\tMaxIdleConns        = 200\n\tMaxIdleConnsPerHost = 50\n\tMaxConnsPerHost     = 100\n\tIdleConnTimeout     = 90 * time.Second\n)\n\n// 性能统计（原子操作）\nvar (\n\tsearchRequests  int64 = 0\n\ttotalSearchTime int64 = 0 // 纳秒\n)\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewOugePlugin())\n}\n\n// 预编译的正则表达式\nvar (\n\t// 密码提取正则表达式\n\tpasswordRegex = regexp.MustCompile(`\\?pwd=([0-9a-zA-Z]+)`)\n\t\n\t// 常见网盘链接的正则表达式（支持16种类型）\n\tquarkLinkRegex     = regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9a-zA-Z]+`)\n\tucLinkRegex        = regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9a-zA-Z]+(\\?[^\"'\\s]*)?`)\n\tbaiduLinkRegex     = regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9a-zA-Z_\\-]+(\\?pwd=[0-9a-zA-Z]+)?`)\n\taliyunLinkRegex    = regexp.MustCompile(`https?://(www\\.)?(aliyundrive\\.com|alipan\\.com)/s/[0-9a-zA-Z]+`)\n\txunleiLinkRegex    = regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9a-zA-Z_\\-]+(\\?pwd=[0-9a-zA-Z]+)?`)\n\ttianyiLinkRegex    = regexp.MustCompile(`https?://cloud\\.189\\.cn/t/[0-9a-zA-Z]+`)\n\tlink115Regex       = regexp.MustCompile(`https?://115\\.com/s/[0-9a-zA-Z]+`)\n\tmobileLinkRegex    = regexp.MustCompile(`https?://caiyun\\.feixin\\.10086\\.cn/[0-9a-zA-Z]+`)\n\tlink123Regex       = regexp.MustCompile(`https?://123pan\\.com/s/[0-9a-zA-Z]+`)\n\tpikpakLinkRegex    = regexp.MustCompile(`https?://mypikpak\\.com/s/[0-9a-zA-Z]+`)\n\tmagnetLinkRegex    = regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9a-fA-F]{40}`)\n\ted2kLinkRegex      = regexp.MustCompile(`ed2k://\\|file\\|.+\\|\\d+\\|[0-9a-fA-F]{32}\\|/`)\n)\n\n// OugeAsyncPlugin Ouge异步插件\ntype OugeAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t}\n\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   DefaultTimeout,\n\t}\n}\n\n// NewOugePlugin 创建新的Ouge异步插件\nfunc NewOugePlugin() *OugeAsyncPlugin {\n\treturn &OugeAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"ouge\", 2),\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Search 同步搜索接口\nfunc (p *OugeAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 带结果统计的搜索接口\nfunc (p *OugeAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 搜索实现\nfunc (p *OugeAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 性能统计\n\tstart := time.Now()\n\tatomic.AddInt64(&searchRequests, 1)\n\tdefer func() {\n\t\tduration := time.Since(start).Nanoseconds()\n\t\tatomic.AddInt64(&totalSearchTime, duration)\n\t}()\n\n\t// 使用优化的客户端\n\tif p.optimizedClient != nil {\n\t\tclient = p.optimizedClient\n\t}\n\n\t// 构建API搜索URL - 使用ouge专用域名\n\tsearchURL := fmt.Sprintf(\"https://woog.nxog.eu.org/api.php/provide/vod?ac=detail&wd=%s\", url.QueryEscape(keyword))\n\t\n\t// 创建HTTP请求\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\t\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建搜索请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"https://woog.nxog.eu.org/\")\n\treq.Header.Set(\"Cache-Control\", \"no-cache\")\n\t\n\t// 发送请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 解析JSON响应\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 读取响应失败: %w\", p.Name(), err)\n\t}\n\t\n\tvar apiResponse OugeAPIResponse\n\tif err := json.Unmarshal(body, &apiResponse); err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析JSON响应失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 检查API响应状态\n\tif apiResponse.Code != 1 {\n\t\treturn nil, fmt.Errorf(\"[%s] API返回错误: %s\", p.Name(), apiResponse.Msg)\n\t}\n\t\n\t// 解析搜索结果\n\tvar results []model.SearchResult\n\tfor _, item := range apiResponse.List {\n\t\tif result := p.parseAPIItem(item); result.Title != \"\" {\n\t\t\tresults = append(results, result)\n\t\t}\n\t}\n\t\n\treturn results, nil\n}\n\n// OugeAPIResponse API响应结构\ntype OugeAPIResponse struct {\n\tCode      int           `json:\"code\"`\n\tMsg       string        `json:\"msg\"`\n\tPage      int           `json:\"page\"`\n\tPageCount int           `json:\"pagecount\"`\n\tLimit     int           `json:\"limit\"`\n\tTotal     int           `json:\"total\"`\n\tList      []OugeAPIItem `json:\"list\"`\n}\n\n// OugeAPIItem API数据项\ntype OugeAPIItem struct {\n\tVodID       int    `json:\"vod_id\"`\n\tVodName     string `json:\"vod_name\"`\n\tVodActor    string `json:\"vod_actor\"`\n\tVodDirector string `json:\"vod_director\"`\n\tVodDownFrom string `json:\"vod_down_from\"`\n\tVodDownURL  string `json:\"vod_down_url\"`\n\tVodRemarks  string `json:\"vod_remarks\"`\n\tVodPubdate  string `json:\"vod_pubdate\"`\n\tVodArea     string `json:\"vod_area\"`\n\tVodYear     string `json:\"vod_year\"`\n\tVodContent  string `json:\"vod_content\"`\n\tVodPic      string `json:\"vod_pic\"`\n}\n\n// parseAPIItem 解析API数据项\nfunc (p *OugeAsyncPlugin) parseAPIItem(item OugeAPIItem) model.SearchResult {\n\t// 构建唯一ID\n\tuniqueID := fmt.Sprintf(\"%s-%d\", p.Name(), item.VodID)\n\t\n\t// 构建标题\n\ttitle := strings.TrimSpace(item.VodName)\n\tif title == \"\" {\n\t\treturn model.SearchResult{}\n\t}\n\t\n\t// 构建描述\n\tvar contentParts []string\n\tif item.VodActor != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"主演: %s\", item.VodActor))\n\t}\n\tif item.VodDirector != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"导演: %s\", item.VodDirector))\n\t}\n\tif item.VodArea != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"地区: %s\", item.VodArea))\n\t}\n\tif item.VodYear != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"年份: %s\", item.VodYear))\n\t}\n\tif item.VodRemarks != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"状态: %s\", item.VodRemarks))\n\t}\n\tcontent := strings.Join(contentParts, \" | \")\n\t\n\t// 解析下载链接\n\tlinks := p.parseDownloadLinks(item.VodDownFrom, item.VodDownURL)\n\n\t// 提取封面图片\n\tvar images []string\n\tif item.VodPic != \"\" {\n\t\timages = append(images, item.VodPic)\n\t}\n\n\t// 构建标签\n\tvar tags []string\n\tif item.VodYear != \"\" {\n\t\ttags = append(tags, item.VodYear)\n\t}\n\tif item.VodArea != \"\" {\n\t\ttags = append(tags, item.VodArea)\n\t}\n\n\treturn model.SearchResult{\n\t\tUniqueID: uniqueID,\n\t\tTitle:    title,\n\t\tContent:  content,\n\t\tLinks:    links,\n\t\tTags:     tags,\n\t\tImages:   images,\n\t\tChannel:  \"\", // 插件搜索结果Channel为空\n\t\tDatetime: time.Time{}, // 使用零值而不是nil，参考jikepan插件标准\n\t}\n}\n\n// parseDownloadLinks 解析下载链接\nfunc (p *OugeAsyncPlugin) parseDownloadLinks(vodDownFrom, vodDownURL string) []model.Link {\n\tif vodDownFrom == \"\" || vodDownURL == \"\" {\n\t\treturn nil\n\t}\n\t\n\t// 按$$$分隔\n\tfromParts := strings.Split(vodDownFrom, \"$$$\")\n\turlParts := strings.Split(vodDownURL, \"$$$\")\n\t\n\t// 确保数组长度一致\n\tminLen := len(fromParts)\n\tif len(urlParts) < minLen {\n\t\tminLen = len(urlParts)\n\t}\n\t\n\tvar links []model.Link\n\tfor i := 0; i < minLen; i++ {\n\t\tfromType := strings.TrimSpace(fromParts[i])\n\t\turlStr := strings.TrimSpace(urlParts[i])\n\t\t\n\t\tif urlStr == \"\" || !p.isValidNetworkDriveURL(urlStr) {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 映射网盘类型\n\t\tlinkType := p.mapCloudType(fromType, urlStr)\n\t\tif linkType == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 提取密码\n\t\tpassword := p.extractPassword(urlStr)\n\t\t\n\t\tlinks = append(links, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      urlStr,\n\t\t\tPassword: password,\n\t\t})\n\t}\n\t\n\treturn links\n}\n\n// mapCloudType 映射网盘类型\nfunc (p *OugeAsyncPlugin) mapCloudType(apiType, url string) string {\n\t// 优先根据API标识映射\n\tswitch strings.ToUpper(apiType) {\n\tcase \"BD\":\n\t\treturn \"baidu\"\n\tcase \"KG\":\n\t\treturn \"quark\"\n\tcase \"UC\":\n\t\treturn \"uc\"\n\tcase \"ALY\":\n\t\treturn \"aliyun\"\n\tcase \"XL\":\n\t\treturn \"xunlei\"\n\tcase \"TY\":\n\t\treturn \"tianyi\"\n\tcase \"115\":\n\t\treturn \"115\"\n\tcase \"MB\":\n\t\treturn \"mobile\"\n\tcase \"123\":\n\t\treturn \"123\"\n\tcase \"PK\":\n\t\treturn \"pikpak\"\n\t}\n\t\n\t// 如果API标识无法识别，则通过URL模式匹配\n\treturn p.determineLinkType(url)\n}\n\n// isValidNetworkDriveURL 检查URL是否为有效的网盘链接\nfunc (p *OugeAsyncPlugin) isValidNetworkDriveURL(url string) bool {\n\t// 过滤掉明显无效的链接\n\tif strings.Contains(url, \"javascript:\") || \n\t   strings.Contains(url, \"#\") ||\n\t   url == \"\" ||\n\t   (!strings.HasPrefix(url, \"http\") && !strings.HasPrefix(url, \"magnet:\") && !strings.HasPrefix(url, \"ed2k:\")) {\n\t\treturn false\n\t}\n\t\n\t// 检查是否匹配任何支持的网盘格式（16种）\n\treturn quarkLinkRegex.MatchString(url) ||\n\t\t   ucLinkRegex.MatchString(url) ||\n\t\t   baiduLinkRegex.MatchString(url) ||\n\t\t   aliyunLinkRegex.MatchString(url) ||\n\t\t   xunleiLinkRegex.MatchString(url) ||\n\t\t   tianyiLinkRegex.MatchString(url) ||\n\t\t   link115Regex.MatchString(url) ||\n\t\t   mobileLinkRegex.MatchString(url) ||\n\t\t   link123Regex.MatchString(url) ||\n\t\t   pikpakLinkRegex.MatchString(url) ||\n\t\t   magnetLinkRegex.MatchString(url) ||\n\t\t   ed2kLinkRegex.MatchString(url)\n}\n\n// determineLinkType 根据URL确定链接类型（支持16种类型）\nfunc (p *OugeAsyncPlugin) determineLinkType(url string) string {\n\tswitch {\n\tcase quarkLinkRegex.MatchString(url):\n\t\treturn \"quark\"\n\tcase ucLinkRegex.MatchString(url):\n\t\treturn \"uc\"\n\tcase baiduLinkRegex.MatchString(url):\n\t\treturn \"baidu\"\n\tcase aliyunLinkRegex.MatchString(url):\n\t\treturn \"aliyun\"\n\tcase xunleiLinkRegex.MatchString(url):\n\t\treturn \"xunlei\"\n\tcase tianyiLinkRegex.MatchString(url):\n\t\treturn \"tianyi\"\n\tcase link115Regex.MatchString(url):\n\t\treturn \"115\"\n\tcase mobileLinkRegex.MatchString(url):\n\t\treturn \"mobile\"\n\tcase link123Regex.MatchString(url):\n\t\treturn \"123\"\n\tcase pikpakLinkRegex.MatchString(url):\n\t\treturn \"pikpak\"\n\tcase magnetLinkRegex.MatchString(url):\n\t\treturn \"magnet\"\n\tcase ed2kLinkRegex.MatchString(url):\n\t\treturn \"ed2k\"\n\tdefault:\n\t\treturn \"\" // 不支持的类型返回空字符串\n\t}\n}\n\n// extractPassword 从URL中提取密码\nfunc (p *OugeAsyncPlugin) extractPassword(url string) string {\n\tmatches := passwordRegex.FindStringSubmatch(url)\n\tif len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// doRequestWithRetry 带重试的HTTP请求（优化JSON API的重试策略）\nfunc (p *OugeAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 2  // 对于JSON API减少重试次数\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tresp, err := client.Do(req)\n\t\tif err == nil {\n\t\t\tif resp.StatusCode == http.StatusOK {\n\t\t\t\treturn resp, nil\n\t\t\t}\n\t\t\tresp.Body.Close()\n\t\t\tlastErr = fmt.Errorf(\"HTTP状态码: %d\", resp.StatusCode)\n\t\t} else {\n\t\t\tlastErr = err\n\t\t}\n\t\t\n\t\t// JSON API快速重试：只等待很短时间\n\t\tif i < maxRetries-1 {\n\t\t\ttime.Sleep(100 * time.Millisecond) // 从秒级改为100毫秒\n\t\t}\n\t}\n\t\n\treturn nil, fmt.Errorf(\"[%s] 请求失败，重试%d次后仍失败: %w\", p.Name(), maxRetries, lastErr)\n}\n\n// GetPerformanceStats 获取性能统计信息\nfunc (p *OugeAsyncPlugin) GetPerformanceStats() map[string]interface{} {\n\ttotalRequests := atomic.LoadInt64(&searchRequests)\n\ttotalTime := atomic.LoadInt64(&totalSearchTime)\n\t\n\tvar avgTime float64\n\tif totalRequests > 0 {\n\t\tavgTime = float64(totalTime) / float64(totalRequests) / 1e6 // 转换为毫秒\n\t}\n\t\n\treturn map[string]interface{}{\n\t\t\"search_requests\":    totalRequests,\n\t\t\"avg_search_time_ms\": avgTime,\n\t\t\"total_search_time_ns\": totalTime,\n\t}\n}"
  },
  {
    "path": "plugin/pan666/pan666.go",
    "content": "package pan666\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\n// 在init函数中注册插件\nfunc init() {\n\t// 注册插件\n\tplugin.RegisterGlobalPlugin(NewPan666AsyncPlugin())\n}\n\nconst (\n\t// API基础URL\n\tBaseURL = \"https://pan666.net/api/discussions\"\n\t\n\t// 默认参数\n\tPageSize = 50 // 符合API实际返回数量\n\tMaxRetries = 2\n)\n\n// 常用UA列表\nvar userAgents = []string{\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36\",\n\t\"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\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0\",\n\t\"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\",\n\t\"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36\",\n}\n\n// Pan666AsyncPlugin pan666网盘搜索异步插件\ntype Pan666AsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tretries int\n}\n\n// NewPan666AsyncPlugin 创建新的pan666异步插件\nfunc NewPan666AsyncPlugin() *Pan666AsyncPlugin {\n\treturn &Pan666AsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"pan666\", 3),\n\t\tretries:         MaxRetries,\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *Pan666AsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *Pan666AsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)\n}\n\n// doSearch 实际的搜索实现\nfunc (p *Pan666AsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 初始化随机数种子\n\trand.Seed(time.Now().UnixNano())\n\t\n\t// 只并发请求2个页面（0-1页）\n\tallResults, _, err := p.fetchBatch(client, keyword, 0, 2)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\t// 去重\n\tuniqueResults := p.deduplicateResults(allResults)\n\t\n\t// 使用过滤功能过滤结果\n\tfilteredResults := plugin.FilterResultsByKeyword(uniqueResults, keyword)\n\t\n\treturn filteredResults, nil\n}\n\n// fetchBatch 获取一批页面的数据\nfunc (p *Pan666AsyncPlugin) fetchBatch(client *http.Client, keyword string, startOffset, pageCount int) ([]model.SearchResult, bool, error) {\n\tvar wg sync.WaitGroup\n\tresultChan := make(chan struct{\n\t\toffset  int\n\t\tresults []model.SearchResult\n\t\thasMore bool\n\t\terr     error\n\t}, pageCount)\n\t\n\t// 并发请求多个页面，但每个请求之间添加随机延迟\n\tfor i := 0; i < pageCount; i++ {\n\t\toffset := (startOffset + i) * PageSize\n\t\twg.Add(1)\n\t\t\n\t\tgo func(offset int, index int) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 第一个请求立即执行，后续请求添加随机延迟\n\t\t\tif index > 0 {\n\t\t\t\t// 随机等待0-1秒\n\t\t\t\trandomDelay := time.Duration(100 + rand.Intn(900)) * time.Millisecond\n\t\t\t\ttime.Sleep(randomDelay)\n\t\t\t}\n\t\t\t\n\t\t\t// 请求特定页面\n\t\t\tresults, hasMore, err := p.fetchPage(client, keyword, offset)\n\t\t\t\n\t\t\tresultChan <- struct{\n\t\t\t\toffset  int\n\t\t\t\tresults []model.SearchResult\n\t\t\t\thasMore bool\n\t\t\t\terr     error\n\t\t\t}{\n\t\t\t\toffset:  offset,\n\t\t\t\tresults: results,\n\t\t\t\thasMore: hasMore,\n\t\t\t\terr:     err,\n\t\t\t}\n\t\t}(offset, i)\n\t}\n\t\n\t// 等待所有请求完成\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultChan)\n\t}()\n\t\n\t// 收集结果\n\tvar allResults []model.SearchResult\n\thasMore := false\n\t\n\tfor result := range resultChan {\n\t\tif result.err != nil {\n\t\t\treturn nil, false, result.err\n\t\t}\n\t\t\n\t\tallResults = append(allResults, result.results...)\n\t\thasMore = hasMore || result.hasMore\n\t}\n\t\n\treturn allResults, hasMore, nil\n}\n\n// deduplicateResults 去除重复结果\nfunc (p *Pan666AsyncPlugin) deduplicateResults(results []model.SearchResult) []model.SearchResult {\n\tseen := make(map[string]bool)\n\tunique := make([]model.SearchResult, 0, len(results))\n\t\n\tfor _, result := range results {\n\t\tif !seen[result.UniqueID] {\n\t\t\tseen[result.UniqueID] = true\n\t\t\tunique = append(unique, result)\n\t\t}\n\t}\n\t\n\t// 按时间降序排序\n\tsort.Slice(unique, func(i, j int) bool {\n\t\treturn unique[i].Datetime.After(unique[j].Datetime)\n\t})\n\t\n\treturn unique\n}\n\n// fetchPage 获取指定页的搜索结果\nfunc (p *Pan666AsyncPlugin) fetchPage(client *http.Client, keyword string, offset int) ([]model.SearchResult, bool, error) {\n\t// 构建API URL\n\tapiURL := fmt.Sprintf(\"%s?filter[q]=%s&include=mostRelevantPost&page[offset]=%d&page[limit]=%d\",\n\t\tBaseURL, url.QueryEscape(keyword), offset, PageSize)\n\t\n\t// 创建请求\n\treq, err := http.NewRequest(\"GET\", apiURL, nil)\n\tif err != nil {\n\t\treturn nil, false, fmt.Errorf(\"创建请求失败: %w\", err)\n\t}\n\t\n\t// 设置请求头\n\treq.Header.Set(\"User-Agent\", getRandomUA())\n\treq.Header.Set(\"X-Forwarded-For\", generateRandomIP())\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Sec-Fetch-Dest\", \"empty\")\n\treq.Header.Set(\"Sec-Fetch-Mode\", \"cors\")\n\treq.Header.Set(\"Sec-Fetch-Site\", \"same-origin\")\n\t\n\tvar resp *http.Response\n\tvar responseBody []byte\n\t\n\t// 重试逻辑\n\tfor i := 0; i <= p.retries; i++ {\n\t\t// 发送请求\n\t\tresp, err = client.Do(req)\n\t\tif err != nil {\n\t\t\tif i == p.retries {\n\t\t\t\treturn nil, false, fmt.Errorf(\"请求失败: %w\", err)\n\t\t\t}\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\tdefer resp.Body.Close()\n\t\t\n\t\t// 读取响应体\n\t\tresponseBody, err = io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tif i == p.retries {\n\t\t\t\treturn nil, false, fmt.Errorf(\"读取响应失败: %w\", err)\n\t\t\t}\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 状态码检查\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tif i == p.retries {\n\t\t\t\treturn nil, false, fmt.Errorf(\"API返回非200状态码: %d\", resp.StatusCode)\n\t\t\t}\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 请求成功，跳出重试循环\n\t\tbreak\n\t}\n\t\n\t// 解析响应\n\tvar apiResp Pan666Response\n\tif err := json.Unmarshal(responseBody, &apiResp); err != nil {\n\t\treturn nil, false, fmt.Errorf(\"解析响应失败: %w\", err)\n\t}\n\t\n\t// 处理结果\n\tresults := make([]model.SearchResult, 0, len(apiResp.Data))\n\tpostMap := make(map[string]Pan666Post)\n\t\n\t// 创建帖子ID到帖子内容的映射\n\tfor _, post := range apiResp.Included {\n\t\tpostMap[post.ID] = post\n\t}\n\t\n\t// 遍历搜索结果\n\tfor _, discussion := range apiResp.Data {\n\t\t// 获取相关帖子\n\t\tpostID := discussion.Relationships.MostRelevantPost.Data.ID\n\t\tpost, ok := postMap[postID]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 清理HTML内容\n\t\tcleanedHTML := cleanHTML(post.Attributes.ContentHTML)\n\t\t\n\t\t// 提取链接\n\t\tlinks := extractLinksFromText(cleanedHTML)\n\t\tif len(links) == 0 {\n\t\t\tlinks = extractLinks(cleanedHTML)\n\t\t}\n\t\t\n\t\t// 如果没有找到链接，跳过该结果\n\t\tif len(links) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 解析时间\n\t\tcreatedTime, err := time.Parse(time.RFC3339, discussion.Attributes.CreatedAt)\n\t\tif err != nil {\n\t\t\tcreatedTime = time.Now() // 如果解析失败，使用当前时间\n\t\t}\n\t\t\n\t\t// 创建唯一ID：插件名-帖子ID\n\t\tuniqueID := fmt.Sprintf(\"pan666-%s\", discussion.ID)\n\t\t\n\t\t// 创建搜索结果\n\t\tresult := model.SearchResult{\n\t\t\tUniqueID:  uniqueID,\n\t\t\tTitle:     discussion.Attributes.Title,\n\t\t\tDatetime:  createdTime,\n\t\t\tLinks:     links,\n\t\t}\n\t\t\n\t\tresults = append(results, result)\n\t}\n\t\n\t// 判断是否有更多结果\n\thasMore := apiResp.Links.Next != \"\"\n\t\n\treturn results, hasMore, nil\n}\n\n// 生成随机IP\nfunc generateRandomIP() string {\n\treturn fmt.Sprintf(\"%d.%d.%d.%d\", \n\t\trand.Intn(223)+1,  // 避免0和255\n\t\trand.Intn(255),\n\t\trand.Intn(255),\n\t\trand.Intn(254)+1)  // 避免0\n}\n\n// 获取随机UA\nfunc getRandomUA() string {\n\treturn userAgents[rand.Intn(len(userAgents))]\n}\n\n// 从文本提取链接\nfunc extractLinks(content string) []model.Link {\n\tvar allLinks []model.Link\n\t\n\t// 提取百度网盘链接\n\tbaiduLinks := extractLinksByPattern(content, \"链接: https://pan.baidu.com\", \"提取码:\", \"baidu\")\n\tallLinks = append(allLinks, baiduLinks...)\n\t\n\t// 提取阿里云盘链接\n\taliyunLinks := extractLinksByPattern(content, \"https://www.aliyundrive.com/s/\", \"提取码:\", \"aliyun\")\n\tallLinks = append(allLinks, aliyunLinks...)\n\t\n\t// 提取天翼云盘链接\n\ttianyiLinks := extractLinksByPattern(content, \"https://cloud.189.cn\", \"访问码:\", \"tianyi\")\n\tallLinks = append(allLinks, tianyiLinks...)\n\t\n\treturn allLinks\n}\n\n// 根据模式提取链接\nfunc extractLinksByPattern(content, pattern, altPattern, linkType string) []model.Link {\n\tvar links []model.Link\n\t\n\tlines := strings.Split(content, \"\\n\")\n\tfor i, line := range lines {\n\t\tif strings.Contains(line, pattern) {\n\t\t\tlink := extractLinkFromLine(line, pattern)\n\t\t\t\n\t\t\t// 如果在当前行找不到密码，尝试在下一行查找\n\t\t\tif link.Password == \"\" && i+1 < len(lines) && strings.Contains(lines[i+1], altPattern) {\n\t\t\t\tpasswordLine := lines[i+1]\n\t\t\t\tstart := strings.Index(passwordLine, altPattern) + len(altPattern)\n\t\t\t\tif start < len(passwordLine) {\n\t\t\t\t\tend := len(passwordLine)\n\t\t\t\t\t// 提取密码（移除前后空格）\n\t\t\t\t\tpassword := strings.TrimSpace(passwordLine[start:end])\n\t\t\t\t\tlink.Password = password\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tlink.Type = linkType\n\t\t\tlinks = append(links, link)\n\t\t}\n\t}\n\t\n\treturn links\n}\n\n// 从行中提取链接\nfunc extractLinkFromLine(line, prefix string) model.Link {\n\tvar link model.Link\n\t\n\tstart := strings.Index(line, prefix)\n\tif start < 0 {\n\t\treturn link\n\t}\n\t\n\t// 查找URL的结束位置\n\tend := len(line)\n\tpossibleEnds := []string{\" \", \"提取码\", \"密码\", \"访问码\"}\n\tfor _, endStr := range possibleEnds {\n\t\tpos := strings.Index(line[start:], endStr)\n\t\tif pos > 0 && start+pos < end {\n\t\t\tend = start + pos\n\t\t}\n\t}\n\t\n\t// 提取URL\n\turl := strings.TrimSpace(line[start:end])\n\tlink.URL = url\n\t\n\t// 尝试从同一行提取密码\n\tpasswordKeywords := []string{\"提取码:\", \"密码:\", \"访问码:\"}\n\tfor _, keyword := range passwordKeywords {\n\t\tpasswordStart := strings.Index(line, keyword)\n\t\tif passwordStart >= 0 {\n\t\t\tpasswordStart += len(keyword)\n\t\t\tpasswordEnd := len(line)\n\t\t\tpassword := strings.TrimSpace(line[passwordStart:passwordEnd])\n\t\t\tlink.Password = password\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\t// 尝试从URL中提取密码\n\tif link.Password == \"\" {\n\t\tlink.Password = extractPasswordFromURL(url)\n\t}\n\t\n\treturn link\n}\n\n// 清理HTML内容\nfunc cleanHTML(html string) string {\n\t// 移除<br>标签\n\thtml = strings.ReplaceAll(html, \"<br>\", \"\\n\")\n\thtml = strings.ReplaceAll(html, \"<br/>\", \"\\n\")\n\thtml = strings.ReplaceAll(html, \"<br />\", \"\\n\")\n\t\n\t// 移除其他HTML标签\n\tvar result strings.Builder\n\tinTag := false\n\t\n\tfor _, r := range html {\n\t\tif r == '<' {\n\t\t\tinTag = true\n\t\t\tcontinue\n\t\t}\n\t\tif r == '>' {\n\t\t\tinTag = false\n\t\t\tcontinue\n\t\t}\n\t\tif !inTag {\n\t\t\tresult.WriteRune(r)\n\t\t}\n\t}\n\t\n\t// 处理HTML实体\n\toutput := result.String()\n\toutput = strings.ReplaceAll(output, \"&amp;\", \"&\")\n\toutput = strings.ReplaceAll(output, \"&lt;\", \"<\")\n\toutput = strings.ReplaceAll(output, \"&gt;\", \">\")\n\toutput = strings.ReplaceAll(output, \"&quot;\", \"\\\"\")\n\toutput = strings.ReplaceAll(output, \"&apos;\", \"'\")\n\toutput = strings.ReplaceAll(output, \"&#39;\", \"'\")\n\toutput = strings.ReplaceAll(output, \"&nbsp;\", \" \")\n\t\n\t// 处理多行空白\n\tlines := strings.Split(output, \"\\n\")\n\tvar cleanedLines []string\n\t\n\tfor _, line := range lines {\n\t\ttrimmed := strings.TrimSpace(line)\n\t\tif trimmed != \"\" {\n\t\t\tcleanedLines = append(cleanedLines, trimmed)\n\t\t}\n\t}\n\t\n\treturn strings.Join(cleanedLines, \"\\n\")\n}\n\n// 提取文本中的链接\nfunc extractLinksFromText(content string) []model.Link {\n\tvar allLinks []model.Link\n\t\n\tlines := strings.Split(content, \"\\n\")\n\t\n\t// 收集所有可能的链接信息\n\tvar linkInfos []struct {\n\t\tlink     model.Link\n\t\tposition int\n\t\tcategory string\n\t}\n\t\n\t// 收集所有可能的密码信息\n\tvar passwordInfos []struct {\n\t\tkeyword   string\n\t\tposition  int\n\t\tpassword  string\n\t}\n\t\n\t// 第一遍：查找所有的链接和密码\n\tfor i, line := range lines {\n\t\t// 检查链接\n\t\tline = strings.TrimSpace(line)\n\t\t\n\t\t// 检查百度网盘\n\t\tif strings.Contains(line, \"pan.baidu.com\") {\n\t\t\turl := extractURLFromText(line)\n\t\t\tif url != \"\" {\n\t\t\t\tlinkInfos = append(linkInfos, struct {\n\t\t\t\t\tlink     model.Link\n\t\t\t\t\tposition int\n\t\t\t\t\tcategory string\n\t\t\t\t}{\n\t\t\t\t\tlink:     model.Link{URL: url, Type: \"baidu\"},\n\t\t\t\t\tposition: i,\n\t\t\t\t\tcategory: \"baidu\",\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 检查阿里云盘\n\t\tif strings.Contains(line, \"aliyundrive.com\") {\n\t\t\turl := extractURLFromText(line)\n\t\t\tif url != \"\" {\n\t\t\t\tlinkInfos = append(linkInfos, struct {\n\t\t\t\t\tlink     model.Link\n\t\t\t\t\tposition int\n\t\t\t\t\tcategory string\n\t\t\t\t}{\n\t\t\t\t\tlink:     model.Link{URL: url, Type: \"aliyun\"},\n\t\t\t\t\tposition: i,\n\t\t\t\t\tcategory: \"aliyun\",\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\t\n\t\t\n\t\t// 检查天翼云盘\n\t\tif strings.Contains(line, \"cloud.189.cn\") {\n\t\t\turl := extractURLFromText(line)\n\t\t\tif url != \"\" {\n\t\t\t\tlinkInfos = append(linkInfos, struct {\n\t\t\t\t\tlink     model.Link\n\t\t\t\t\tposition int\n\t\t\t\t\tcategory string\n\t\t\t\t}{\n\t\t\t\t\tlink:     model.Link{URL: url, Type: \"tianyi\"},\n\t\t\t\t\tposition: i,\n\t\t\t\t\tcategory: \"tianyi\",\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 检查提取码/密码/访问码\n\t\tpasswordKeywords := []string{\"提取码\", \"密码\", \"访问码\"}\n\t\tfor _, keyword := range passwordKeywords {\n\t\t\tif strings.Contains(line, keyword) {\n\t\t\t\t// 寻找冒号后面的内容\n\t\t\t\tcolonPos := strings.Index(line, \":\")\n\t\t\t\tif colonPos == -1 {\n\t\t\t\t\tcolonPos = strings.Index(line, \"：\")\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tif colonPos != -1 && colonPos+1 < len(line) {\n\t\t\t\t\tpassword := strings.TrimSpace(line[colonPos+1:])\n\t\t\t\t\t// 如果密码长度超过10个字符，可能不是密码\n\t\t\t\t\tif len(password) <= 10 {\n\t\t\t\t\t\tpasswordInfos = append(passwordInfos, struct {\n\t\t\t\t\t\t\tkeyword   string\n\t\t\t\t\t\t\tposition  int\n\t\t\t\t\t\t\tpassword  string\n\t\t\t\t\t\t}{\n\t\t\t\t\t\t\tkeyword:   keyword,\n\t\t\t\t\t\t\tposition:  i,\n\t\t\t\t\t\t\tpassword:  password,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 第二遍：将密码与链接匹配\n\tfor i := range linkInfos {\n\t\t// 检查链接自身是否包含密码\n\t\tpassword := extractPasswordFromURL(linkInfos[i].link.URL)\n\t\tif password != \"\" {\n\t\t\tlinkInfos[i].link.Password = password\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 查找最近的密码\n\t\tminDistance := 1000000\n\t\tvar closestPassword string\n\t\t\n\t\tfor _, pwInfo := range passwordInfos {\n\t\t\t// 根据链接类型和密码关键词进行匹配\n\t\t\tmatch := false\n\t\t\t\n\t\t\tif linkInfos[i].category == \"baidu\" && (pwInfo.keyword == \"提取码\" || pwInfo.keyword == \"密码\") {\n\t\t\t\tmatch = true\n\t\t\t} else if linkInfos[i].category == \"aliyun\" && (pwInfo.keyword == \"提取码\" || pwInfo.keyword == \"密码\") {\n\t\t\t\tmatch = true\n\t\t\t} else if linkInfos[i].category == \"tianyi\" && (pwInfo.keyword == \"访问码\" || pwInfo.keyword == \"密码\") {\n\t\t\t\tmatch = true\n\t\t\t}\n\t\t\t\n\t\t\tif match {\n\t\t\t\tdistance := abs(pwInfo.position - linkInfos[i].position)\n\t\t\t\tif distance < minDistance {\n\t\t\t\t\tminDistance = distance\n\t\t\t\t\tclosestPassword = pwInfo.password\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 只有当距离较近时才认为是匹配的密码\n\t\tif minDistance <= 3 {\n\t\t\tlinkInfos[i].link.Password = closestPassword\n\t\t}\n\t}\n\t\n\t// 收集所有有效链接\n\tfor _, info := range linkInfos {\n\t\tallLinks = append(allLinks, info.link)\n\t}\n\t\n\treturn allLinks\n}\n\n// 从文本中提取URL\nfunc extractURLFromText(text string) string {\n\t// 查找URL的起始位置\n\turlPrefixes := []string{\"http://\", \"https://\"}\n\tstart := -1\n\t\n\tfor _, prefix := range urlPrefixes {\n\t\tpos := strings.Index(text, prefix)\n\t\tif pos != -1 {\n\t\t\tstart = pos\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\tif start == -1 {\n\t\treturn \"\"\n\t}\n\t\n\t// 查找URL的结束位置\n\tend := len(text)\n\tendChars := []string{\" \", \"\\t\", \"\\n\", \"\\\"\", \"'\", \"<\", \">\", \")\", \"]\", \"}\", \",\", \";\"}\n\t\n\tfor _, char := range endChars {\n\t\tpos := strings.Index(text[start:], char)\n\t\tif pos != -1 && start+pos < end {\n\t\t\tend = start + pos\n\t\t}\n\t}\n\t\n\treturn text[start:end]\n}\n\n// 从URL中提取密码\nfunc extractPasswordFromURL(url string) string {\n\t// 查找密码参数\n\tpwdParams := []string{\"pwd=\", \"password=\", \"passcode=\", \"code=\"}\n\t\n\tfor _, param := range pwdParams {\n\t\tpos := strings.Index(url, param)\n\t\tif pos != -1 {\n\t\t\tstart := pos + len(param)\n\t\t\tend := len(url)\n\t\t\t\n\t\t\t// 查找参数结束位置\n\t\t\tfor i := start; i < len(url); i++ {\n\t\t\t\tif url[i] == '&' || url[i] == '#' {\n\t\t\t\t\tend = i\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tif start < end {\n\t\t\t\treturn url[start:end]\n\t\t\t}\n\t\t}\n\t}\n\t\n\treturn \"\"\n}\n\n// 绝对值函数\nfunc abs(n int) int {\n\tif n < 0 {\n\t\treturn -n\n\t}\n\treturn n\n}\n\n// Pan666Response API响应结构\ntype Pan666Response struct {\n\tLinks struct {\n\t\tFirst string `json:\"first\"`\n\t\tNext  string `json:\"next,omitempty\"`\n\t} `json:\"links\"`\n\tData     []Pan666Discussion `json:\"data\"`\n\tIncluded []Pan666Post       `json:\"included\"`\n}\n\n// Pan666Discussion 讨论信息\ntype Pan666Discussion struct {\n\tType       string `json:\"type\"`\n\tID         string `json:\"id\"`\n\tAttributes struct {\n\t\tTitle          string    `json:\"title\"`\n\t\tSlug           string    `json:\"slug\"`\n\t\tCommentCount   int       `json:\"commentCount\"`\n\t\tCreatedAt      string    `json:\"createdAt\"`\n\t\tLastPostedAt   string    `json:\"lastPostedAt\"`\n\t\tLastPostNumber int       `json:\"lastPostNumber\"`\n\t\tIsApproved     bool      `json:\"isApproved\"`\n\t} `json:\"attributes\"`\n\tRelationships struct {\n\t\tMostRelevantPost struct {\n\t\t\tData struct {\n\t\t\t\tType string `json:\"type\"`\n\t\t\t\tID   string `json:\"id\"`\n\t\t\t} `json:\"data\"`\n\t\t} `json:\"mostRelevantPost\"`\n\t} `json:\"relationships\"`\n}\n\n// Pan666Post 帖子内容\ntype Pan666Post struct {\n\tType       string `json:\"type\"`\n\tID         string `json:\"id\"`\n\tAttributes struct {\n\t\tNumber      int    `json:\"number\"`\n\t\tCreatedAt   string `json:\"createdAt\"`\n\t\tContentType string `json:\"contentType\"`\n\t\tContentHTML string `json:\"contentHtml\"`\n\t} `json:\"attributes\"`\n} "
  },
  {
    "path": "plugin/pansearch/pansearch.go",
    "content": "package pansearch\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n\t\"sync/atomic\"\n)\n\n// 预编译正则表达式\nvar (\n\t// 从HTML中提取buildId的正则表达式\n\tbuildIdRegex = regexp.MustCompile(`\"buildId\":\"([^\"]+)\"`)\n\n\t// 从__NEXT_DATA__脚本中提取数据的正则表达式\n\tnextDataRegex = regexp.MustCompile(`<script id=\"__NEXT_DATA__\" type=\"application/json\">(.*?)</script>`)\n\t\n\t// 缓存相关变量\n\tsearchResultCache = sync.Map{}\n\tlastCacheCleanTime = time.Now()\n\tcacheTTL = 1 * time.Hour\n)\n\n// 在init函数中注册插件\nfunc init() {\n\t// 使用全局超时时间创建插件实例并注册\n\tplugin.RegisterGlobalPlugin(NewPanSearchPlugin())\n\t\n\t// 启动缓存清理goroutine\n\tgo startCacheCleaner()\n}\n\n// startCacheCleaner 启动一个定期清理缓存的goroutine\nfunc startCacheCleaner() {\n\t// 每小时清理一次缓存\n\tticker := time.NewTicker(1 * time.Hour)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\t// 清空所有缓存\n\t\tsearchResultCache = sync.Map{}\n\t\tlastCacheCleanTime = time.Now()\n\t}\n}\n\n// 缓存响应结构\ntype cachedResponse struct {\n\tresults   []model.SearchResult\n\ttimestamp time.Time\n}\n\nconst (\n\t// 网站基础URL\n\tWebsiteURL = \"https://www.pansearch.me/search\"\n\n\t// API基础URL模板 - 需要替换buildId\n\tBaseURLTemplate = \"https://www.pansearch.me/_next/data/%s/search.json\"\n\n\t// 默认参数\n\tDefaultTimeout = 6 * time.Second // 减少默认超时时间\n\tPageSize       = 10\n\tMaxResults     = 1000\n\tMaxConcurrent  = 200 // 增加最大并发数\n\tMaxRetries     = 2\n\tMaxAPIPages    = 100 // API最大页数限制\n\n\t// HTTP 客户端配置\n\tMaxIdleConns          = 500 // 增加最大空闲连接数\n\tMaxIdleConnsPerHost   = 200 // 增加每个主机的最大空闲连接数\n\tMaxConnsPerHost       = 400 // 增加每个主机的最大连接数\n\tIdleConnTimeout       = 120 * time.Second\n\tTLSHandshakeTimeout   = 10 * time.Second\n\tExpectContinueTimeout = 1 * time.Second\n\tWriteBufferSize       = 16 * 1024\n\tReadBufferSize        = 16 * 1024\n\n\t// buildId缓存有效期（分钟）- 减少缓存时间以确保更及时更新\n\tBuildIdCacheDuration = 30\n)\n\n// 缓存buildId和过期时间\nvar (\n\tbuildIdCache     string\n\tbuildIdCacheTime time.Time\n\tbuildIdMutex     sync.RWMutex\n)\n\n// PanSearchAsyncPlugin 盘搜异步插件\ntype PanSearchAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\ttimeout       time.Duration\n\tmaxResults    int\n\tmaxConcurrent int\n\tretries       int\n\tworkerPool    *WorkerPool // 添加工作池\n}\n\n// WorkerPool 工作池结构\ntype WorkerPool struct {\n\ttasks   chan Task\n\tresults chan TaskResult\n\terrors  chan error\n\twg      sync.WaitGroup\n\tclosed  atomic.Bool // 添加原子标志来标记工作池是否已关闭\n\tmu      sync.Mutex  // 添加互斥锁保护提交操作\n}\n\n// Task 工作任务\ntype Task struct {\n\tkeyword string\n\toffset  int\n\tbaseURL string\n}\n\n// TaskResult 任务结果\ntype TaskResult struct {\n\toffset  int\n\tresults []PanSearchItem\n}\n\n// NewWorkerPool 创建新的工作池\nfunc NewWorkerPool(size int) *WorkerPool {\n\treturn &WorkerPool{\n\t\ttasks:   make(chan Task, size*3),       // 增加任务通道容量\n\t\tresults: make(chan TaskResult, size*3), // 增加结果通道容量\n\t\terrors:  make(chan error, size*3),      // 增加错误通道容量\n\t}\n}\n\n// Start 启动工作池\nfunc (wp *WorkerPool) Start(ctx context.Context, handler func(ctx context.Context, task Task) (TaskResult, error)) {\n\tfor i := 0; i < cap(wp.tasks); i++ {\n\t\twp.wg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wp.wg.Done()\n\t\t\tfor {\n\t\t\t\tselect {\n\t\t\t\tcase task, ok := <-wp.tasks:\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\tresult, err := handler(ctx, task)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tselect {\n\t\t\t\t\t\tcase wp.errors <- err:\n\t\t\t\t\t\t\t// 成功发送错误\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\t// 通道可能已关闭，忽略错误\n\t\t\t\t\t\t\tfmt.Printf(\"无法发送错误: %v\\n\", err)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tselect {\n\t\t\t\t\t\tcase wp.results <- result:\n\t\t\t\t\t\t\t// 成功发送结果\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\t// 通道可能已关闭，忽略结果\n\t\t\t\t\t\t\tfmt.Printf(\"无法发送结果\\n\")\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t}\n}\n\n// Submit 提交任务到工作池\nfunc (wp *WorkerPool) Submit(task Task) bool {\n\twp.mu.Lock()\n\tdefer wp.mu.Unlock()\n\n\t// 检查工作池是否已关闭\n\tif wp.closed.Load() {\n\t\treturn false\n\t}\n\n\tselect {\n\tcase wp.tasks <- task:\n\t\treturn true\n\tdefault:\n\t\t// 如果通道已满，返回失败\n\t\treturn false\n\t}\n}\n\n// Close 关闭工作池\nfunc (wp *WorkerPool) Close() {\n\twp.mu.Lock()\n\tif !wp.closed.Load() {\n\t\twp.closed.Store(true)\n\t\tclose(wp.tasks)\n\t}\n\twp.mu.Unlock()\n\n\twp.wg.Wait()\n\n\t// 安全关闭结果和错误通道\n\twp.mu.Lock()\n\tdefer wp.mu.Unlock()\n\n\tselect {\n\tcase _, ok := <-wp.results:\n\t\tif ok {\n\t\t\tclose(wp.results)\n\t\t}\n\tdefault:\n\t\tclose(wp.results)\n\t}\n\n\tselect {\n\tcase _, ok := <-wp.errors:\n\t\tif ok {\n\t\t\tclose(wp.errors)\n\t\t}\n\tdefault:\n\t\tclose(wp.errors)\n\t}\n}\n\n// NewPanSearchPlugin 创建新的盘搜异步插件\nfunc NewPanSearchPlugin() *PanSearchAsyncPlugin {\n\ttimeout := DefaultTimeout\n\tmaxConcurrent := MaxConcurrent\n\n\tp := &PanSearchAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"pansearch\", 3),\n\t\ttimeout:         timeout,\n\t\tmaxResults:      MaxResults,\n\t\tmaxConcurrent:   maxConcurrent,\n\t\tretries:         MaxRetries,\n\t\tworkerPool:      NewWorkerPool(maxConcurrent), // 初始化工作池\n\t}\n\n\t// 初始化时预热获取 buildId\n\tgo func() {\n\t\t_, err := p.getBuildId()\n\t\tif err != nil {\n\t\t\t// fmt.Printf(\"预热获取 buildId 失败: %v\\n\", err)\n\t\t}\n\t}()\n\n\t// 启动后台 buildId 更新器\n\tgo p.startBuildIdUpdater()\n\n\treturn p\n}\n\n// startBuildIdUpdater 启动一个定期更新 buildId 的后台协程\nfunc (p *PanSearchAsyncPlugin) startBuildIdUpdater() {\n\t// 每10分钟更新一次 buildId\n\tticker := time.NewTicker(10 * time.Minute)\n\tdefer ticker.Stop()\n\n\tfor range ticker.C {\n\t\tp.updateBuildId()\n\t}\n}\n\n// updateBuildId 更新 buildId 缓存\nfunc (p *PanSearchAsyncPlugin) updateBuildId() {\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), p.timeout)\n\tdefer cancel()\n\n\t// 发送请求获取页面\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", WebsiteURL, nil)\n\tif err != nil {\n\t\t// fmt.Printf(\"创建请求失败: %v\\n\", err)\n\t\treturn\n\t}\n\n\t// 设置完整的请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\n\tresp, err := p.GetClient().Do(req)\n\tif err != nil {\n\t\t// fmt.Printf(\"请求失败: %v\\n\", err)\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\tfmt.Printf(\"获取buildId时服务器返回非200状态码: %d\\n\", resp.StatusCode)\n\t\treturn\n\t}\n\n\t// 使用更高效的方式读取响应体\n\tvar bodyBuilder strings.Builder\n\t_, err = io.Copy(&bodyBuilder, resp.Body)\n\tif err != nil {\n\t\t// fmt.Printf(\"读取响应失败: %v\\n\", err)\n\t\treturn\n\t}\n\tbody := bodyBuilder.String()\n\n\t// 尝试提取 buildId\n\tnewBuildId := extractBuildId(body)\n\tif newBuildId == \"\" {\n\t\tfmt.Println(\"未能从响应中提取 buildId\")\n\t\treturn\n\t}\n\n\t// 更新缓存\n\tbuildIdMutex.Lock()\n\tdefer buildIdMutex.Unlock()\n\n\t// 只有当新的 buildId 不为空且与当前缓存不同时才更新\n\tif newBuildId != \"\" && newBuildId != buildIdCache {\n\t\tbuildIdCache = newBuildId\n\t\tbuildIdCacheTime = time.Now()\n\t\tfmt.Printf(\"成功更新 buildId: %s\\n\", newBuildId)\n\t}\n}\n\n// extractBuildId 从 HTML 内容中提取 buildId\nfunc extractBuildId(body string) string {\n\t// 使用预编译的正则表达式提取buildId\n\tmatches := buildIdRegex.FindStringSubmatch(body)\n\n\tif len(matches) >= 2 {\n\t\treturn matches[1]\n\t}\n\n\t// 尝试从NEXT_DATA中提取\n\tscriptMatches := nextDataRegex.FindStringSubmatch(body)\n\n\tif len(scriptMatches) >= 2 {\n\t\tvar nextData map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(scriptMatches[1]), &nextData); err == nil {\n\t\t\tif buildId, ok := nextData[\"buildId\"].(string); ok && buildId != \"\" {\n\t\t\t\treturn buildId\n\t\t\t}\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\n// Name 返回插件名称\nfunc (p *PanSearchAsyncPlugin) Name() string {\n\treturn \"pansearch\"\n}\n\n// Priority 返回插件优先级\nfunc (p *PanSearchAsyncPlugin) Priority() int {\n\treturn 3 // 中等优先级\n}\n\n// getBuildId 获取buildId，优先使用缓存\nfunc (p *PanSearchAsyncPlugin) getBuildId() (string, error) {\n\t// 检查缓存是否有效\n\tbuildIdMutex.RLock()\n\tif buildIdCache != \"\" && time.Since(buildIdCacheTime) < BuildIdCacheDuration*time.Minute {\n\t\tdefer buildIdMutex.RUnlock()\n\t\treturn buildIdCache, nil\n\t}\n\tbuildIdMutex.RUnlock()\n\n\t// 缓存无效，需要重新获取\n\tbuildIdMutex.Lock()\n\tdefer buildIdMutex.Unlock()\n\n\t// 双重检查\n\tif buildIdCache != \"\" && time.Since(buildIdCacheTime) < BuildIdCacheDuration*time.Minute {\n\t\treturn buildIdCache, nil\n\t}\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), p.timeout)\n\tdefer cancel()\n\n\t// 发送请求获取页面\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", WebsiteURL, nil)\n\tif err != nil {\n\t\t// 如果创建请求失败但有旧的缓存，使用旧的缓存（优雅降级）\n\t\tif buildIdCache != \"\" {\n\t\t\t// fmt.Printf(\"创建请求失败，使用旧的buildId: %v\\n\", err)\n\t\t\treturn buildIdCache, nil\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"创建请求失败: %w\", err)\n\t}\n\n\t// 设置完整的请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\n\t// 使用重试机制发送请求\n\tvar resp *http.Response\n\tvar respErr error\n\n\tfor retry := 0; retry <= p.retries; retry++ {\n\t\tif retry > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoffTime := time.Duration(1<<uint(retry-1)) * 100 * time.Millisecond\n\t\t\ttime.Sleep(backoffTime)\n\t\t}\n\n\t\tresp, respErr = p.GetClient().Do(req)\n\t\tif respErr == nil && resp.StatusCode == 200 {\n\t\t\tbreak\n\t\t}\n\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t}\n\n\t// 如果所有重试都失败，但有旧的缓存，使用旧的缓存（优雅降级）\n\tif respErr != nil || resp == nil {\n\t\tif buildIdCache != \"\" {\n\t\t\t// fmt.Printf(\"请求失败，使用旧的buildId: %v\\n\", respErr)\n\t\t\treturn buildIdCache, nil\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"请求失败: %w\", respErr)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\t// 如果状态码不是200，但有旧的缓存，使用旧的缓存（优雅降级）\n\t\tif buildIdCache != \"\" {\n\t\t\tfmt.Printf(\"获取buildId时服务器返回非200状态码: %d，使用旧的buildId\\n\", resp.StatusCode)\n\t\t\treturn buildIdCache, nil\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"获取buildId时服务器返回非200状态码: %d\", resp.StatusCode)\n\t}\n\n\t// 使用更高效的方式读取响应体\n\tvar bodyBuilder strings.Builder\n\t_, err = io.Copy(&bodyBuilder, resp.Body)\n\tif err != nil {\n\t\t// 如果读取响应失败，但有旧的缓存，使用旧的缓存（优雅降级）\n\t\tif buildIdCache != \"\" {\n\t\t\t// fmt.Printf(\"读取响应失败，使用旧的buildId: %v\\n\", err)\n\t\t\treturn buildIdCache, nil\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"读取响应失败: %w\", err)\n\t}\n\tbody := bodyBuilder.String()\n\n\t// 使用提取函数获取 buildId\n\tbuildId := extractBuildId(body)\n\n\t// 如果提取失败，但有旧的缓存，使用旧的缓存（优雅降级）\n\tif buildId == \"\" {\n\t\tif buildIdCache != \"\" {\n\t\t\t// fmt.Println(\"未找到buildId，使用旧的buildId\")\n\t\t\treturn buildIdCache, nil\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"未找到buildId\")\n\t}\n\n\t// 更新缓存\n\tbuildIdCache = buildId\n\tbuildIdCacheTime = time.Now()\n\n\treturn buildId, nil\n}\n\n// getBaseURL 获取完整的API基础URL\nfunc (p *PanSearchAsyncPlugin) getBaseURL(client *http.Client) (string, error) {\n\tbuildId, err := p.getBuildId()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn fmt.Sprintf(BaseURLTemplate, buildId), nil\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *PanSearchAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *PanSearchAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)\n}\n\n// doSearch 执行具体的搜索逻辑\nfunc (p *PanSearchAsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 获取API基础URL\n\tbaseURL, err := p.getBaseURL(client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"获取API基础URL失败: %w\", err)\n\t}\n\n\t// 1. 发起首次请求获取total和第一页数据\n\tfirstPageResults, total, err := p.fetchFirstPage(keyword, baseURL, client)\n\tif err != nil {\n\t\t// 如果返回404错误，可能是buildId过期，尝试强制刷新buildId\n\t\tif strings.Contains(err.Error(), \"404\") || strings.Contains(err.Error(), \"Not Found\") {\n\t\t\tfmt.Println(\"检测到404错误，buildId可能已过期，尝试强制刷新\")\n\n\t\t\t// 强制刷新buildId\n\t\t\tbuildIdMutex.Lock()\n\t\t\tbuildIdCache = \"\"              // 清空缓存\n\t\t\tbuildIdCacheTime = time.Time{} // 重置缓存时间\n\t\t\tbuildIdMutex.Unlock()\n\n\t\t\t// 重新获取buildId\n\t\t\tbaseURL, err = p.getBaseURL(client)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"刷新buildId失败: %w\", err)\n\t\t\t}\n\n\t\t\t// 重试请求\n\t\t\tfirstPageResults, total, err = p.fetchFirstPage(keyword, baseURL, client)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"刷新buildId后获取首页仍然失败: %w\", err)\n\t\t\t}\n\n\t\t\t// 成功刷新后，触发后台更新以保持最新状态\n\t\t\tgo p.updateBuildId()\n\t\t} else {\n\t\t\treturn nil, fmt.Errorf(\"获取首页失败: %w\", err)\n\t\t}\n\t}\n\n\tallResults := firstPageResults\n\n\t// 2. 计算需要的页数，但限制在最大结果数内和API最大页数内\n\tremainingResults := min(total-PageSize, p.maxResults-PageSize)\n\tif remainingResults <= 0 {\n\t\tresults := p.convertResults(allResults, keyword)\n\t\t\n\t\t// 缓存结果\n\t\tsearchResultCache.Store(keyword, cachedResponse{\n\t\t\tresults:   results,\n\t\t\ttimestamp: time.Now(),\n\t\t})\n\t\t\n\t\treturn results, nil\n\t}\n\n\t// 计算需要的页数，考虑API的100页限制\n\tneededPages := min((remainingResults+PageSize-1)/PageSize, MaxAPIPages-1) // 向上取整，减1是因为第一页已经获取\n\n\t// 如果只需要获取少量页面，直接返回\n\tif neededPages <= 0 {\n\t\tresults := p.convertResults(allResults, keyword)\n\t\t\n\t\t// 缓存结果\n\t\tsearchResultCache.Store(keyword, cachedResponse{\n\t\t\tresults:   results,\n\t\t\ttimestamp: time.Now(),\n\t\t})\n\t\t\n\t\treturn results, nil\n\t}\n\n\t// 根据实际页数确定并发数，但不超过最大并发数\n\tactualConcurrent := min(neededPages, p.maxConcurrent)\n\n\t// 创建适合实际并发数的工作池\n\tp.workerPool = NewWorkerPool(actualConcurrent)\n\n\t// 创建上下文用于管理所有请求\n\tctx, cancel := context.WithTimeout(context.Background(), p.timeout*2)\n\tdefer cancel()\n\n\t// 创建一个标志，用于标记是否需要刷新buildId\n\tneedRefreshBuildId := &atomic.Bool{}\n\n\t// 启动工作池\n\tp.workerPool.Start(ctx, func(ctx context.Context, task Task) (TaskResult, error) {\n\t\tvar pageResults []PanSearchItem\n\t\tvar err error\n\n\t\tfor retry := 0; retry <= p.retries; retry++ {\n\t\t\t// 如果有其他协程发现buildId过期，等待刷新完成\n\t\t\tif needRefreshBuildId.Load() {\n\t\t\t\ttime.Sleep(100 * time.Millisecond)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tpageResults, err = p.fetchPage(task.keyword, task.offset, task.baseURL)\n\t\t\tif err == nil {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// 如果返回404错误，可能是buildId过期\n\t\t\tif strings.Contains(err.Error(), \"404\") || strings.Contains(err.Error(), \"Not Found\") {\n\t\t\t\t// 标记需要刷新buildId\n\t\t\t\tif !needRefreshBuildId.Load() {\n\t\t\t\t\tneedRefreshBuildId.Store(true)\n\t\t\t\t\t// 在一个新的协程中刷新buildId\n\t\t\t\t\tgo func() {\n\t\t\t\t\t\tbuildIdMutex.Lock()\n\t\t\t\t\t\tbuildIdCache = \"\"              // 清空缓存\n\t\t\t\t\t\tbuildIdCacheTime = time.Time{} // 重置缓存时间\n\t\t\t\t\t\tbuildIdMutex.Unlock()\n\n\t\t\t\t\t\t// 重新获取buildId\n\t\t\t\t\t\tnewBuildId, err := p.getBuildId()\n\t\t\t\t\t\tif err == nil && newBuildId != \"\" {\n\t\t\t\t\t\t\t// 更新baseURL\n\t\t\t\t\t\t\ttask.baseURL = fmt.Sprintf(BaseURLTemplate, newBuildId)\n\t\t\t\t\t\t\tfmt.Printf(\"成功刷新buildId: %s\\n\", newBuildId)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// 重置标志\n\t\t\t\t\t\tneedRefreshBuildId.Store(false)\n\t\t\t\t\t}()\n\t\t\t\t}\n\n\t\t\t\t// 等待刷新完成\n\t\t\t\tfor i := 0; i < 10 && needRefreshBuildId.Load(); i++ {\n\t\t\t\t\ttime.Sleep(100 * time.Millisecond)\n\t\t\t\t}\n\n\t\t\t\t// 如果还在刷新，报告错误\n\t\t\t\tif needRefreshBuildId.Load() {\n\t\t\t\t\treturn TaskResult{}, fmt.Errorf(\"404错误，buildId可能已过期: %w\", err)\n\t\t\t\t}\n\n\t\t\t\t// 刷新完成后重试\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif retry < p.retries {\n\t\t\t\t// 指数退避重试\n\t\t\t\tselect {\n\t\t\t\tcase <-time.After(time.Duration(1<<retry) * 100 * time.Millisecond):\n\t\t\t\t\t// 继续重试\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\treturn TaskResult{}, ctx.Err()\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn TaskResult{}, fmt.Errorf(\"获取偏移量 %d 的结果失败: %w\", task.offset, err)\n\t\t}\n\n\t\treturn TaskResult{offset: task.offset, results: pageResults}, nil\n\t})\n\n\t// 提交任务计数器\n\tsubmittedTasks := 0\n\n\t// 简化批次处理逻辑，考虑到最多只有99页需要获取（第一页已经获取）\n\t// 使用两个批次：每批次最多50页，避免一次性提交过多任务\n\tbatchSize := (neededPages + 1) / 2 // 将总页数分成两批\n\tif batchSize < 1 {\n\t\tbatchSize = neededPages // 如果页数很少，就一次性提交所有任务\n\t}\n\n\t// 提交所有任务\n\tfor i := 0; i < neededPages; i += batchSize {\n\t\t// 检查上下文是否已取消\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\t// 上下文已取消，停止提交任务\n\t\t\tgoto CollectResults\n\t\tdefault:\n\t\t\t// 继续执行\n\t\t}\n\n\t\tend := i + batchSize\n\t\tif end > neededPages {\n\t\t\tend = neededPages\n\t\t}\n\n\t\t// 提交一批任务\n\t\tfor j := i; j < end; j++ {\n\t\t\toffset := PageSize + j*PageSize\n\t\t\tif offset < p.maxResults {\n\t\t\t\ttask := Task{\n\t\t\t\t\tkeyword: keyword,\n\t\t\t\t\toffset:  offset,\n\t\t\t\t\tbaseURL: baseURL,\n\t\t\t\t}\n\n\t\t\t\t// 尝试提交任务，如果失败则跳出循环\n\t\t\t\tif !p.workerPool.Submit(task) {\n\t\t\t\t\tfmt.Printf(\"无法提交任务，工作池可能已关闭\\n\")\n\t\t\t\t\tgoto CollectResults\n\t\t\t\t}\n\n\t\t\t\tsubmittedTasks++\n\t\t\t}\n\t\t}\n\n\t\t// 只有在有多个批次且不是最后一批时才等待\n\t\tif batchSize < neededPages && end < neededPages {\n\t\t\tselect {\n\t\t\tcase <-time.After(50 * time.Millisecond):\n\t\t\t\t// 继续执行\n\t\t\tcase <-ctx.Done():\n\t\t\t\t// 上下文已取消，停止提交任务\n\t\t\t\tgoto CollectResults\n\t\t\t}\n\t\t}\n\t}\n\nCollectResults:\n\t// 关闭任务提交通道\n\tgo p.workerPool.Close()\n\n\t// 收集结果\n\tresultCount := 0\n\terrorCount := 0\n\tvar lastError error\n\n\t// 使用select非阻塞地收集结果和错误\n\tfor resultCount+errorCount < submittedTasks {\n\t\tselect {\n\t\tcase result, ok := <-p.workerPool.results:\n\t\t\tif !ok {\n\t\t\t\t// 结果通道已关闭\n\t\t\t\tgoto ProcessResults\n\t\t\t}\n\t\t\tallResults = append(allResults, result.results...)\n\t\t\tresultCount++\n\n\t\tcase err, ok := <-p.workerPool.errors:\n\t\t\tif !ok {\n\t\t\t\t// 错误通道已关闭\n\t\t\t\tgoto ProcessResults\n\t\t\t}\n\t\t\terrorCount++\n\t\t\tlastError = err\n\n\t\tcase <-ctx.Done():\n\t\t\t// 上下文超时，返回已收集的结果\n\t\t\tresults := p.convertResults(allResults, keyword)\n\t\t\t\n\t\t\t// 缓存结果（即使超时也缓存已获取的结果）\n\t\t\tsearchResultCache.Store(keyword, cachedResponse{\n\t\t\t\tresults:   results,\n\t\t\t\ttimestamp: time.Now(),\n\t\t\t})\n\t\t\t\n\t\t\treturn results, fmt.Errorf(\"搜索超时: %w\", ctx.Err())\n\t\t}\n\t}\n\nProcessResults:\n\t// 如果所有请求都失败且没有获得首页以外的结果，则返回错误\n\tif submittedTasks > 0 && errorCount == submittedTasks && len(allResults) == len(firstPageResults) {\n\t\tresults := p.convertResults(allResults, keyword)\n\t\t\n\t\t// 缓存结果（即使有错误也缓存已获取的结果）\n\t\tsearchResultCache.Store(keyword, cachedResponse{\n\t\t\tresults:   results,\n\t\t\ttimestamp: time.Now(),\n\t\t})\n\t\t\n\t\treturn results, fmt.Errorf(\"所有后续页面请求失败: %v\", lastError)\n\t}\n\n\t// 4. 去重和格式化结果\n\tuniqueResults := p.deduplicateItems(allResults)\n\tresults := p.convertResults(uniqueResults, keyword)\n\t\n\t// 缓存结果\n\tsearchResultCache.Store(keyword, cachedResponse{\n\t\tresults:   results,\n\t\ttimestamp: time.Now(),\n\t})\n\n\treturn results, nil\n}\n\n// fetchFirstPage 获取第一页结果和总数\nfunc (p *PanSearchAsyncPlugin) fetchFirstPage(keyword string, baseURL string, client *http.Client) ([]PanSearchItem, int, error) {\n\t// 构建请求URL\n\treqURL := fmt.Sprintf(\"%s?keyword=%s&offset=0\", baseURL, url.QueryEscape(keyword))\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), p.timeout)\n\tdefer cancel()\n\n\t// 发送请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", reqURL, nil)\n\tif err != nil {\n\t\treturn nil, 0, fmt.Errorf(\"创建请求失败: %w\", err)\n\t}\n\n\t// 设置完整的请求头\n\treq.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\")\n\treq.Header.Set(\"Referer\", \"https://www.pansearch.me/\")\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Cache-Control\", \"no-cache\")\n\treq.Header.Set(\"Pragma\", \"no-cache\")\n\n\t// 发送请求\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, 0, fmt.Errorf(\"请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// 检查状态码\n\tif resp.StatusCode == 404 {\n\t\treturn nil, 0, fmt.Errorf(\"404 Not Found，buildId可能已过期\")\n\t}\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, 0, fmt.Errorf(\"服务器返回非200状态码: %d\", resp.StatusCode)\n\t}\n\n\t// 读取响应体\n\trespBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, 0, fmt.Errorf(\"读取响应失败: %w\", err)\n\t}\n\n\t// 解析响应\n\tvar apiResp PanSearchResponse\n\tif err := json.Unmarshal(respBody, &apiResp); err != nil {\n\t\treturn nil, 0, fmt.Errorf(\"解析响应失败: %w\", err)\n\t}\n\n\t// 获取total和结果\n\ttotal := apiResp.PageProps.Data.Total\n\titems := apiResp.PageProps.Data.Data\n\n\treturn items, total, nil\n}\n\n// fetchPage 获取指定偏移量的页面\nfunc (p *PanSearchAsyncPlugin) fetchPage(keyword string, offset int, baseURL string) ([]PanSearchItem, error) {\n\t// 构建请求URL\n\treqURL := fmt.Sprintf(\"%s?keyword=%s&offset=%d\", baseURL, url.QueryEscape(keyword), offset)\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), p.timeout)\n\tdefer cancel()\n\n\t// 发送请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", reqURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建请求失败: %w\", err)\n\t}\n\n\t// 设置完整的请求头\n\treq.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\")\n\treq.Header.Set(\"Referer\", \"https://www.pansearch.me/\")\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Cache-Control\", \"no-cache\")\n\treq.Header.Set(\"Pragma\", \"no-cache\")\n\n\t// 发送请求\n\tresp, err := p.GetClient().Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// 检查状态码\n\tif resp.StatusCode == 404 {\n\t\treturn nil, fmt.Errorf(\"404 Not Found，buildId可能已过期\")\n\t}\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"服务器返回非200状态码: %d\", resp.StatusCode)\n\t}\n\n\t// 读取响应体\n\trespBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"读取响应失败: %w\", err)\n\t}\n\n\t// 解析响应\n\tvar apiResp PanSearchResponse\n\tif err := json.Unmarshal(respBody, &apiResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"解析响应失败: %w\", err)\n\t}\n\n\treturn apiResp.PageProps.Data.Data, nil\n}\n\n// deduplicateItems 去重处理\nfunc (p *PanSearchAsyncPlugin) deduplicateItems(items []PanSearchItem) []PanSearchItem {\n\t// 使用map进行去重，键为资源ID\n\tuniqueMap := make(map[int]PanSearchItem)\n\n\tfor _, item := range items {\n\t\tuniqueMap[item.ID] = item\n\t}\n\n\t// 将map转回切片\n\tresult := make([]PanSearchItem, 0, len(uniqueMap))\n\tfor _, item := range uniqueMap {\n\t\tresult = append(result, item)\n\t}\n\n\treturn result\n}\n\n// convertResults 将API响应转换为标准SearchResult格式\nfunc (p *PanSearchAsyncPlugin) convertResults(items []PanSearchItem, keyword string) []model.SearchResult {\n\tresults := make([]model.SearchResult, 0, len(items))\n\n\tfor _, item := range items {\n\t\t// 提取链接和密码\n\t\tlinkInfo := extractLinkAndPassword(item.Content)\n\n\t\t// 获取链接类型，确保映射到系统支持的类型\n\t\tlinkType := item.Pan\n\t\t// 将aliyundrive映射到aliyun\n\t\tif linkType == \"aliyundrive\" {\n\t\t\tlinkType = \"aliyun\"\n\t\t}\n\n\t\t// 创建链接\n\t\tlink := model.Link{\n\t\t\tURL:      linkInfo.URL,\n\t\t\tType:     linkType,\n\t\t\tPassword: linkInfo.Password,\n\t\t}\n\n\t\t// 创建唯一ID\n\t\tuniqueID := fmt.Sprintf(\"pansearch-%d\", item.ID)\n\n\t\t// 解析时间\n\t\tvar datetime time.Time\n\t\tif item.Time != \"\" {\n\t\t\t// 尝试解析时间，格式：2025-07-07T13:54:43+08:00\n\t\t\tparsedTime, err := time.Parse(time.RFC3339, item.Time)\n\t\t\tif err == nil {\n\t\t\t\tdatetime = parsedTime\n\t\t\t}\n\t\t}\n\n\t\t// 如果时间解析失败，使用零值\n\t\tif datetime.IsZero() {\n\t\t\tdatetime = time.Time{}\n\t\t}\n\n\t\t// 创建搜索结果\n\t\tresult := model.SearchResult{\n\t\t\tUniqueID: uniqueID,\n\t\t\tTitle:    extractTitle(item.Content, keyword),\n\t\t\tContent:  item.Content,\n\t\t\tDatetime: datetime,\n\t\t\tLinks:    []model.Link{link},\n\t\t}\n\n\t\tresults = append(results, result)\n\t}\n\n\treturn results\n}\n\n// LinkInfo 链接信息\ntype LinkInfo struct {\n\tURL      string\n\tPassword string\n}\n\n// extractLinkAndPassword 从内容中提取链接和密码\nfunc extractLinkAndPassword(content string) LinkInfo {\n\t// 实现从内容中提取链接和密码的逻辑\n\t// 这里需要解析HTML内容，提取<a>标签中的链接和密码\n\t// 简单实现，实际可能需要使用正则表达式或HTML解析库\n\n\t// 示例实现\n\tlinkInfo := LinkInfo{}\n\n\t// 提取链接\n\tlinkStartIndex := strings.Index(content, \"href=\\\"\")\n\tif linkStartIndex != -1 {\n\t\tlinkStartIndex += 6 // \"href=\"的长度\n\t\tlinkEndIndex := strings.Index(content[linkStartIndex:], \"\\\"\")\n\t\tif linkEndIndex != -1 {\n\t\t\tlinkInfo.URL = content[linkStartIndex : linkStartIndex+linkEndIndex]\n\t\t}\n\t}\n\n\t// 提取密码\n\tpwdIndex := strings.Index(content, \"?pwd=\")\n\tif pwdIndex != -1 {\n\t\tpwdStartIndex := pwdIndex + 5 // \"?pwd=\"的长度\n\t\tpwdEndIndex := strings.Index(content[pwdStartIndex:], \"\\\"\")\n\t\tif pwdEndIndex != -1 {\n\t\t\tlinkInfo.Password = content[pwdStartIndex : pwdStartIndex+pwdEndIndex]\n\t\t} else {\n\t\t\t// 可能是百度网盘链接结尾形式\n\t\t\tpwdEndIndex = strings.Index(content[pwdStartIndex:], \"#\")\n\t\t\tif pwdEndIndex != -1 {\n\t\t\t\tlinkInfo.Password = content[pwdStartIndex : pwdStartIndex+pwdEndIndex]\n\t\t\t} else {\n\t\t\t\t// 取到结尾\n\t\t\t\tlinkInfo.Password = content[pwdStartIndex:]\n\t\t\t}\n\t\t}\n\t}\n\n\treturn linkInfo\n}\n\n// extractTitle 从内容中提取标题\nfunc extractTitle(content string, keyword string) string {\n\t// 实现从内容中提取标题的逻辑\n\t// 标题通常在\"名称：\"之后\n\ttitlePrefix := \"名称：\"\n\ttitleStartIndex := strings.Index(content, titlePrefix)\n\tif titleStartIndex == -1 {\n\t\treturn keyword // 使用搜索关键词作为默认标题\n\t}\n\n\ttitleStartIndex += len(titlePrefix)\n\ttitleEndIndex := strings.Index(content[titleStartIndex:], \"\\n\")\n\tif titleEndIndex == -1 {\n\t\treturn cleanHTML(content[titleStartIndex:])\n\t}\n\n\treturn cleanHTML(content[titleStartIndex : titleStartIndex+titleEndIndex])\n}\n\n// cleanHTML 清理HTML标签\nfunc cleanHTML(html string) string {\n\t// 实现清理HTML标签的逻辑\n\t// 这里简单实现，实际可能需要使用HTML解析库\n\n\t// 替换常见HTML标签\n\treplacements := map[string]string{\n\t\t\"<span class='highlight-keyword'>\": \"\",\n\t\t\"</span>\":                          \"\",\n\t\t\"<a class=\\\"resource-link\\\" target=\\\"_blank\\\" href=\\\"\": \"\",\n\t\t\"</a>\": \"\",\n\t\t\"<br>\": \"\\n\",\n\t\t\"<p>\":  \"\",\n\t\t\"</p>\": \"\\n\",\n\t}\n\n\tresult := html\n\tfor tag, replacement := range replacements {\n\t\tresult = strings.Replace(result, tag, replacement, -1)\n\t}\n\n\t// 清理其他HTML标签\n\tfor {\n\t\tstartIndex := strings.Index(result, \"<\")\n\t\tif startIndex == -1 {\n\t\t\tbreak\n\t\t}\n\n\t\tendIndex := strings.Index(result[startIndex:], \">\")\n\t\tif endIndex == -1 {\n\t\t\tbreak\n\t\t}\n\n\t\tresult = result[:startIndex] + result[startIndex+endIndex+1:]\n\t}\n\n\treturn strings.TrimSpace(result)\n}\n\n// min 返回两个int中的较小值\nfunc min(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}\n\n// PanSearchResponse API响应结构\ntype PanSearchResponse struct {\n\tPageProps struct {\n\t\tData struct {\n\t\t\tTotal int             `json:\"total\"`\n\t\t\tData  []PanSearchItem `json:\"data\"`\n\t\t\tTime  int             `json:\"time\"`\n\t\t} `json:\"data\"`\n\t\tLimit    int  `json:\"limit\"`\n\t\tIsMobile bool `json:\"isMobile\"`\n\t} `json:\"pageProps\"`\n\tNSSP bool `json:\"__N_SSP\"`\n}\n\n// PanSearchItem API响应中的单个结果项\ntype PanSearchItem struct {\n\tID      int    `json:\"id\"`\n\tContent string `json:\"content\"`\n\tPan     string `json:\"pan\"`\n\tImage   string `json:\"image\"`\n\tTime    string `json:\"time\"`\n}\n"
  },
  {
    "path": "plugin/panta/panta.go",
    "content": "package panta\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"sort\"\n)\n\n// 预编译的正则表达式\nvar (\n\t// 提取topicId的正则表达式\n\ttopicIDRegex = regexp.MustCompile(`topicId=(\\d+)`)\n\t\n\t// 从标题中提取年份的正则表达式\n\tyearRegex = regexp.MustCompile(`\\(([0-9]{4})\\)`)\n\t\n\t// 从文本中提取发表时间的正则表达式\n\tpostTimeRegex = regexp.MustCompile(`发表时间：(.+)`)\n\t\n\t// 从URL中提取提取码的正则表达式\n\tpwdParamRegex = regexp.MustCompile(`[?&]pwd=([0-9a-zA-Z]+)`)\n\t\n\t// 提取码匹配模式\n\tpwdPatterns = []*regexp.Regexp{\n\t\tregexp.MustCompile(`提取码[：:]\\s*([0-9a-zA-Z]+)`),\n\t\tregexp.MustCompile(`密码[：:]\\s*([0-9a-zA-Z]+)`),\n\t\tregexp.MustCompile(`pwd[=:：]\\s*([0-9a-zA-Z]+)`),\n\t}\n\t\n\t// 网盘链接的正则表达式\n\tnetDiskPatterns = []*regexp.Regexp{\n\t\t// 百度网盘链接格式\n\t\tregexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9a-zA-Z_\\-]+(?:\\?pwd=[0-9a-zA-Z]+)?`),\n\t\t// 夸克网盘链接格式\n\t\tregexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9a-zA-Z]+`),\n\t\t// 阿里云盘链接格式\n\t\tregexp.MustCompile(`https?://www\\.aliyundrive\\.com/s/[0-9a-zA-Z]+`),\n\t\tregexp.MustCompile(`https?://alipan\\.com/s/[0-9a-zA-Z]+`),\n\t\t// 迅雷网盘链接格式 - 修正以支持任意长度的提取码和特殊字符\n\t\tregexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9a-zA-Z_\\-]+(?:\\?pwd=[0-9a-zA-Z]+)?[#]?`),\n\t\t// 天翼云盘链接格式\n\t\tregexp.MustCompile(`https?://cloud\\.189\\.cn/t/[0-9a-zA-Z]+`),\n\t\t// 移动云盘链接格式\n\t\tregexp.MustCompile(`https?://caiyun\\.139\\.com/m/i\\?[0-9a-zA-Z]+(?:\\?pwd=[0-9a-zA-Z]+)?`),\n\t\tregexp.MustCompile(`https?://www\\.caiyun\\.139\\.com/m/i\\?[0-9a-zA-Z]+(?:\\?pwd=[0-9a-zA-Z]+)?`),\n\t\tregexp.MustCompile(`https?://caiyun\\.139\\.com/w/i\\?[0-9a-zA-Z]+(?:\\?pwd=[0-9a-zA-Z]+)?`),\n\t\tregexp.MustCompile(`https?://www\\.caiyun\\.139\\.com/w/i\\?[0-9a-zA-Z]+(?:\\?pwd=[0-9a-zA-Z]+)?`),\n\t}\n\t\n\t// 提取码相关关键词\n\tpwdKeywords = []string{\"提取码\", \"密码\", \"pwd\", \"验证码\", \"口令\"}\n\t\n\t// 网盘域名列表，用于快速检查URL是否为网盘链接\n\tnetDiskDomains = []string{\n\t\t\"pan.baidu.com\",\n\t\t\"pan.quark.cn\",\n\t\t\"aliyundrive.com\",\n\t\t\"alipan.com\",\n\t\t\"pan.xunlei.com\",\n\t\t\"cloud.189.cn\",\n\t\t\"caiyun.139.com\",\n\t\t\"www.caiyun.139.com\",\n\t\t\"drive.uc.cn\",\n\t\t\"115.com\",\n\t\t\"mypikpak.com\",\n\t}\n\t\n\t// 缓存相关\n\tisNetDiskLinkCache     = sync.Map{} // 缓存URL是否为网盘链接的结果\n\tdetermineLinkTypeCache = sync.Map{} // 缓存URL的链接类型\n\textractPasswordCache   = sync.Map{} // 缓存提取码提取结果\n\t\n\t// 新增缓存，用于存储已解析的topicId\n\ttopicIDCache = sync.Map{}\n\t\n\t// 新增缓存，用于存储已解析的发布时间\n\tpostTimeCache = sync.Map{}\n\t\n\t// 新增缓存，用于存储已解析的年份\n\tyearCache = sync.Map{}\n\t\n\t// 链接提取结果缓存\n\tlinkExtractCache = sync.Map{} // 缓存从文本中提取的链接结果\n\t\n\t// 线程链接缓存\n\tthreadLinksCache = sync.Map{} // 缓存帖子详情页中的链接\n)\n\n// 缓存键结构，用于extractPassword函数\ntype passwordCacheKey struct {\n\tcontent string\n\turl     string\n}\n\n// 常量定义\nconst (\n\t// 插件名称\n\tpluginName = \"panta\"\n\t\n\t// 搜索URL模板\n\tsearchURLTemplate = \"https://www.91panta.cn/search?keyword=%s\"\n\t\n\t// 帖子URL模板\n\tthreadURLTemplate = \"https://www.91panta.cn/thread?topicId=%s\"\n\t\n\t// 默认优先级\n\tdefaultPriority = 1\n\t\n\t// 默认超时时间（秒）\n\tdefaultTimeout = 6\n\t\n\t// 默认并发数\n\tdefaultConcurrency = 30\n\t\n\t// 最大重试次数\n\tmaxRetries = 2\n\t\n\t// 最小并发数\n\tminConcurrency = 5\n\t\n\t// 最大并发数\n\tmaxConcurrency = 50\n\t\n\t// 响应时间阈值（毫秒），超过此值则减少并发数\n\tresponseTimeThreshold = 500\n\t\n\t// 并发调整步长\n\tconcurrencyStep = 5\n\t\n\t// 并发调整间隔（秒）\n\tconcurrencyAdjustInterval = 30\n\t\n\t// 指数退避基数（毫秒）\n\tbackoffBase = 100\n\t\n\t// 最大退避时间（毫秒）\n\tmaxBackoff = 5000\n)\n\n// PantaAsyncPlugin 是PanTa网站的异步搜索插件实现\ntype PantaAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\t\n\t// 并发控制\n\tmaxConcurrency int\n\t\n\t// 自适应并发控制\n\tcurrentConcurrency int\n\tresponseTimes      []time.Duration\n\tresponseTimesMutex sync.Mutex\n\tlastAdjustTime     time.Time\n}\n\n// 确保PantaAsyncPlugin实现了AsyncSearchPlugin接口\nvar _ plugin.AsyncSearchPlugin = (*PantaAsyncPlugin)(nil)\n\n// 在包初始化时注册插件\nfunc init() {\n\t// 创建并注册插件实例\n\tplugin.RegisterGlobalPlugin(NewPantaAsyncPlugin())\n}\n\n// NewPantaAsyncPlugin 创建一个新的PanTa异步插件实例\nfunc NewPantaAsyncPlugin() *PantaAsyncPlugin {\n\t// 启动定期清理缓存的goroutine\n\tgo startCacheCleaner()\n\t\n\t// 创建插件实例\n\tp := &PantaAsyncPlugin{\n\t\tBaseAsyncPlugin:    plugin.NewBaseAsyncPlugin(\"panta\", defaultPriority),\n\t\tmaxConcurrency:     defaultConcurrency,\n\t\tcurrentConcurrency: defaultConcurrency,\n\t\tresponseTimes:      make([]time.Duration, 0, 10),\n\t\tlastAdjustTime:     time.Now(),\n\t}\n\t\n\treturn p\n}\n\n// startCacheCleaner 启动一个定期清理缓存的goroutine\nfunc startCacheCleaner() {\n\t// 每小时清理一次缓存\n\tticker := time.NewTicker(1 * time.Hour)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\t// 清空所有缓存\n\t\tisNetDiskLinkCache = sync.Map{}\n\t\tdetermineLinkTypeCache = sync.Map{}\n\t\textractPasswordCache = sync.Map{}\n\t\ttopicIDCache = sync.Map{}\n\t\tpostTimeCache = sync.Map{}\n\t\tyearCache = sync.Map{}\n\t\tlinkExtractCache = sync.Map{}\n\t\tthreadLinksCache = sync.Map{}\n\t}\n}\n\n// Name 返回插件名称\nfunc (p *PantaAsyncPlugin) Name() string {\n\treturn pluginName\n}\n\n// Priority 返回插件优先级\nfunc (p *PantaAsyncPlugin) Priority() int {\n\treturn defaultPriority\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *PantaAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *PantaAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)\n}\n\n// doSearch 执行具体的搜索逻辑\nfunc (p *PantaAsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 对关键词进行URL编码\n\tencodedKeyword := url.QueryEscape(keyword)\n\t\n\t// 构建搜索URL\n\tsearchURL := fmt.Sprintf(searchURLTemplate, encodedKeyword)\n\t\n\t// 创建一个带有超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), time.Duration(defaultTimeout)*time.Second)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建请求失败: %v\", err)\n\t}\n\t\n\t// 设置User-Agent和Referer\n\treq.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\")\n\treq.Header.Set(\"Referer\", \"https://www.91panta.cn/index\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\t\n\t// 使用带重试的请求方法发送HTTP请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"请求PanTa搜索页面失败: %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查状态码\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"请求PanTa搜索页面失败，状态码: %d\", resp.StatusCode)\n\t}\n\t\n\t// 使用goquery解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"解析HTML失败: %v\", err)\n\t}\n\t\n\t// 解析搜索结果\n\tresults, err := p.parseSearchResults(doc, client)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\t// 使用过滤功能过滤结果\n\tfilteredResults := plugin.FilterResultsByKeyword(results, keyword)\n\t\n\treturn filteredResults, nil\n}\n\n// parseSearchResults 使用goquery解析搜索结果\nfunc (p *PantaAsyncPlugin) parseSearchResults(doc *goquery.Document, client *http.Client) ([]model.SearchResult, error) {\n\tvar results []model.SearchResult\n\t\n\t// 创建信号量控制并发数，使用自适应并发数\n\tsemaphore := make(chan struct{}, p.currentConcurrency)\n\t\n\t// 创建结果通道和错误通道\n\tresultChan := make(chan model.SearchResult, 100)\n\terrorChan := make(chan error, 100)\n\t\n\t// 创建等待组\n\tvar wg sync.WaitGroup\n\t\n\t// 预先收集所有需要处理的话题项\n\tvar topicItems []*goquery.Selection\n\tdoc.Find(\"div.topicItem\").Each(func(i int, s *goquery.Selection) {\n\t\ttopicItems = append(topicItems, s)\n\t})\n\t\n\t// 如果没有找到任何话题，直接返回空结果\n\tif len(topicItems) == 0 {\n\t\treturn results, nil\n\t}\n\t\n\t// 记录开始时间，用于计算总处理时间\n\tstartTime := time.Now()\n\t\n\t// 批量处理所有话题项\n\tfor i, s := range topicItems {\n\t\twg.Add(1)\n\t\t\n\t\t// 为每个话题创建一个goroutine\n\t\tgo func(index int, s *goquery.Selection) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 获取信号量，限制并发数\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 记录处理开始时间\n\t\t\titemStartTime := time.Now()\n\t\t\t\n\t\t\t// 提取话题ID\n\t\t\ttopicLink := s.Find(\"a[href^='thread?topicId=']\")\n\t\t\thref, exists := topicLink.Attr(\"href\")\n\t\t\tif !exists {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\t// 从href中提取topicId - 使用缓存\n\t\t\tvar topicID string\n\t\t\tif cachedID, ok := topicIDCache.Load(href); ok {\n\t\t\t\ttopicID = cachedID.(string)\n\t\t\t} else {\n\t\t\t\tmatch := topicIDRegex.FindStringSubmatch(href)\n\t\t\t\tif len(match) < 2 {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\ttopicID = match[1]\n\t\t\t\ttopicIDCache.Store(href, topicID)\n\t\t\t}\n\t\t\t\n\t\t\t// 提取标题\n\t\t\ttitle := strings.TrimSpace(topicLink.Text())\n\t\t\t\n\t\t\t// 提取摘要\n\t\t\tsummary := strings.TrimSpace(s.Find(\"h2.summary\").Text())\n\t\t\t\n\t\t\t// 提取发布时间\n\t\t\tpostTimeText := s.Find(\"span.postTime\").Text()\n\t\t\tvar postTime time.Time\n\t\t\t\n\t\t\t// 使用缓存提取发布时间\n\t\t\tif cachedTime, ok := postTimeCache.Load(postTimeText); ok {\n\t\t\t\tpostTime = cachedTime.(time.Time)\n\t\t\t} else {\n\t\t\t\ttimeMatch := postTimeRegex.FindStringSubmatch(postTimeText)\n\t\t\t\tif len(timeMatch) >= 2 {\n\t\t\t\t\ttimeStr := strings.TrimSpace(timeMatch[1])\n\t\t\t\t\tparsedTime, err := time.Parse(\"2006-01-02 15:04:05\", timeStr)\n\t\t\t\t\tif err == nil {\n\t\t\t\t\t\tpostTime = parsedTime\n\t\t\t\t\t} else {\n\t\t\t\t\t\tpostTime = time.Now()\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tpostTime = time.Now()\n\t\t\t\t}\n\t\t\t\tpostTimeCache.Store(postTimeText, postTime)\n\t\t\t}\n\t\t\t\n\t\t\t// 从标题中提取年份作为可能的提取码\n\t\t\tvar yearFromTitle string\n\t\t\tif cachedYear, ok := yearCache.Load(title); ok {\n\t\t\t\tyearFromTitle = cachedYear.(string)\n\t\t\t} else {\n\t\t\t\tyearMatch := yearRegex.FindStringSubmatch(title)\n\t\t\t\tif len(yearMatch) >= 2 {\n\t\t\t\t\tyearFromTitle = yearMatch[1]\n\t\t\t\t}\n\t\t\t\tyearCache.Store(title, yearFromTitle)\n\t\t\t}\n\t\t\t\n\t\t\t// 尝试从摘要中提取链接\n\t\t\tvar links []model.Link\n\t\t\t\n\t\t\t// 首先尝试从当前元素中提取链接\n\t\t\tfoundLinks := p.extractLinksFromElement(s, yearFromTitle)\n\t\t\t\n\t\t\t// 如果没有找到链接，尝试获取帖子详情\n\t\t\tif len(foundLinks) == 0 {\n\t\t\t\t// 添加重试机制\n\t\t\t\tfor retry := 0; retry <= maxRetries; retry++ {\n\t\t\t\t\tif retry > 0 {\n\t\t\t\t\t\t// 重试前等待一段时间，使用指数退避\n\t\t\t\t\t\tbackoffTime := time.Duration(min(backoffBase*1<<uint(retry-1), maxBackoff)) * time.Millisecond\n\t\t\t\t\t\ttime.Sleep(backoffTime)\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\tthreadLinks, err := p.fetchThreadLinks(topicID, client)\n\t\t\t\t\tif err == nil && len(threadLinks) > 0 {\n\t\t\t\t\t\tfoundLinks = threadLinks\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 处理找到的链接\n\t\t\tfor _, link := range foundLinks {\n\t\t\t\tlinks = append(links, link)\n\t\t\t}\n\t\t\t\n\t\t\t// 只有包含链接的结果才添加到结果中\n\t\t\tif len(links) > 0 {\n\t\t\t\tresult := model.SearchResult{\n\t\t\t\t\tUniqueID: \"panta-\" + topicID,\n\t\t\t\t\tDatetime: postTime,\n\t\t\t\t\tTitle:    title,\n\t\t\t\t\tContent:  summary,\n\t\t\t\t\tLinks:    links,\n\t\t\t\t\tTags:     []string{\"panta\"},\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tresultChan <- result\n\t\t\t}\n\t\t\t\n\t\t\t// 记录处理时间\n\t\t\titemProcessTime := time.Since(itemStartTime)\n\t\t\tp.recordResponseTime(itemProcessTime)\n\t\t\t\n\t\t}(i, s)\n\t}\n\t\n\t// 等待所有goroutine完成\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultChan)\n\t\tclose(errorChan)\n\t}()\n\t\n\t// 收集所有结果\n\tfor result := range resultChan {\n\t\tresults = append(results, result)\n\t}\n\t\n\t// 检查是否有错误\n\tfor err := range errorChan {\n\t\tif err != nil {\n\t\t\treturn results, err\n\t\t}\n\t}\n\t\n\t// 记录总处理时间\n\ttotalProcessTime := time.Since(startTime)\n\t// 调整并发数\n\tif totalProcessTime > time.Duration(defaultTimeout/2)*time.Second {\n\t\t// 如果处理时间过长，减少并发数\n\t\tp.currentConcurrency = max(p.currentConcurrency-concurrencyStep, minConcurrency)\n\t}\n\t\n\treturn results, nil\n}\n\n// extractLinksFromElement 从元素中提取链接\nfunc (p *PantaAsyncPlugin) extractLinksFromElement(s *goquery.Selection, yearFromTitle string) []model.Link {\n\t// 创建缓存键\n\thtml, _ := s.Html()\n\tcacheKey := fmt.Sprintf(\"%s_%s\", html, yearFromTitle)\n\t\n\t// 检查缓存中是否已有结果\n\tif cachedLinks, ok := linkExtractCache.Load(cacheKey); ok {\n\t\treturn cachedLinks.([]model.Link)\n\t}\n\t\n\tvar links []model.Link\n\tvar foundURLs = make(map[string]bool) // 用于去重\n\t\n\t// 一次性获取所有链接\n\tvar allHrefs []string\n\tvar allTexts []string\n\t\n\ts.Find(\"a[href^='http']\").Each(func(i int, a *goquery.Selection) {\n\t\thref, exists := a.Attr(\"href\")\n\t\tif !exists {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 快速过滤非网盘链接\n\t\tisNetDisk := false\n\t\tfor _, domain := range netDiskDomains {\n\t\t\tif strings.Contains(strings.ToLower(href), domain) {\n\t\t\t\tisNetDisk = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\t\n\t\tif isNetDisk {\n\t\t\tallHrefs = append(allHrefs, href)\n\t\t\t\n\t\t\t// 获取周围文本，用于检查提取码相关信息\n\t\t\tsurroundingText := a.Text()\n\t\t\tif surroundingText == \"\" {\n\t\t\t\tsurroundingText = a.Parent().Text()\n\t\t\t}\n\t\t\tallTexts = append(allTexts, surroundingText)\n\t\t}\n\t})\n\t\n\t// 批量处理所有链接\n\tfor i, href := range allHrefs {\n\t\t// 如果链接已存在，跳过\n\t\tif foundURLs[href] {\n\t\t\tcontinue\n\t\t}\n\t\tfoundURLs[href] = true\n\t\t\n\t\t// 获取周围文本\n\t\tsurroundingText := allTexts[i]\n\t\tif surroundingText == \"\" {\n\t\t\tsurroundingText = s.Text()\n\t\t}\n\t\t\n\t\t// 确定链接类型\n\t\tlinkType := determineLinkType(href)\n\t\t\n\t\t// 提取密码\n\t\tpassword := extractPassword(surroundingText, href)\n\t\t\n\t\t// 根据链接类型进行特殊处理\n\t\tswitch linkType {\n\t\tcase \"quark\":\n\t\t\t// 夸克网盘链接，只有在明确需要提取码的情况下才添加\n\t\t\tif password != \"\" {\n\t\t\t\t// 检查周围文本是否包含提取码相关关键词\n\t\t\t\thasPasswordHint := false\n\t\t\t\tfor _, keyword := range pwdKeywords {\n\t\t\t\t\tif strings.Contains(surroundingText, keyword) {\n\t\t\t\t\t\thasPasswordHint = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// 如果没有提取码相关关键词，则不添加提取码\n\t\t\t\tif !hasPasswordHint {\n\t\t\t\t\tpassword = \"\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"mobile\":\n\t\t\t// 移动云盘链接，只有在明确指定提取码的情况下才使用\n\t\t\t// 检查是否明确包含提取码信息\n\t\t\thasExplicitPassword := false\n\t\t\tfor _, pattern := range pwdPatterns {\n\t\t\t\tif matches := pattern.FindStringSubmatch(surroundingText); len(matches) >= 2 {\n\t\t\t\t\t// 使用明确指定的提取码\n\t\t\t\t\tpassword = matches[1]\n\t\t\t\t\thasExplicitPassword = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 如果没有明确的提取码信息，则不使用提取码\n\t\t\tif !hasExplicitPassword && !strings.Contains(href, \"pwd=\") {\n\t\t\t\tpassword = \"\"\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 添加链接\n\t\tlinks = append(links, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      href,\n\t\t\tPassword: password,\n\t\t})\n\t}\n\t\n\t// 缓存结果\n\tlinkExtractCache.Store(cacheKey, links)\n\t\n\treturn links\n}\n\n// fetchThreadLinks 获取帖子详情页中的链接\nfunc (p *PantaAsyncPlugin) fetchThreadLinks(topicID string, client *http.Client) ([]model.Link, error) {\n\t// 检查缓存中是否已有结果\n\tif cachedLinks, ok := threadLinksCache.Load(topicID); ok {\n\t\treturn cachedLinks.([]model.Link), nil\n\t}\n\t\n\t// 构建帖子URL\n\tthreadURL := fmt.Sprintf(threadURLTemplate, topicID)\n\t\n\t// 创建一个带有超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), time.Duration(defaultTimeout)*time.Second)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", threadURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建请求失败: %v\", err)\n\t}\n\t\n\t// 设置User-Agent和Referer\n\treq.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\")\n\treq.Header.Set(\"Referer\", \"https://www.91panta.cn/index\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\t\n\t// 使用带重试的请求方法发送HTTP请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查状态码\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"请求帖子详情页失败，状态码: %d\", resp.StatusCode)\n\t}\n\t\n\t// 使用goquery解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"解析HTML失败: %v\", err)\n\t}\n\t\n\t// 提取标题\n\ttitle := strings.TrimSpace(doc.Find(\"div.title\").Text())\n\t\n\t// 从标题中提取年份作为可能的提取码\n\tvar yearFromTitle string\n\tyearMatch := yearRegex.FindStringSubmatch(title)\n\tif len(yearMatch) >= 2 {\n\t\tyearFromTitle = yearMatch[1]\n\t}\n\t\n\t// 提取帖子内容区域\n\tvar links []model.Link\n\tvar foundURLs = make(map[string]bool) // 用于去重\n\t\n\t// 找到帖子内容区域\n\tdoc.Find(\"div.topicContent\").Each(func(i int, content *goquery.Selection) {\n\t\t// 提取所有链接\n\t\tcontent.Find(\"a[href^='http']\").Each(func(i int, a *goquery.Selection) {\n\t\t\thref, exists := a.Attr(\"href\")\n\t\t\tif !exists {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\t// 检查是否为网盘链接\n\t\t\tif isNetDiskLink(href) {\n\t\t\t\t// 如果链接已存在，跳过\n\t\t\t\tif foundURLs[href] {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tfoundURLs[href] = true\n\t\t\t\t\n\t\t\t\t// 获取周围文本，用于检查提取码相关信息\n\t\t\t\tsurroundingText := a.Text()\n\t\t\t\tif surroundingText == \"\" {\n\t\t\t\t\tsurroundingText = a.Parent().Text()\n\t\t\t\t}\n\t\t\t\tif surroundingText == \"\" {\n\t\t\t\t\tsurroundingText = content.Text()\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// 确定链接类型\n\t\t\t\tlinkType := determineLinkType(href)\n\t\t\t\t\n\t\t\t\t// 提取密码\n\t\t\t\tpassword := extractPassword(surroundingText, href)\n\t\t\t\t\n\t\t\t\t// 根据链接类型进行特殊处理\n\t\t\t\tswitch linkType {\n\t\t\t\tcase \"quark\":\n\t\t\t\t\t// 夸克网盘链接，只有在明确需要提取码的情况下才添加\n\t\t\t\t\tif password != \"\" {\n\t\t\t\t\t\t// 检查周围文本是否包含提取码相关关键词\n\t\t\t\t\t\thasPasswordHint := false\n\t\t\t\t\t\tfor _, keyword := range pwdKeywords {\n\t\t\t\t\t\t\tif strings.Contains(surroundingText, keyword) {\n\t\t\t\t\t\t\t\thasPasswordHint = true\n\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\t\n\t\t\t\t\t\t// 如果没有提取码相关关键词，则不添加提取码\n\t\t\t\t\t\tif !hasPasswordHint {\n\t\t\t\t\t\t\tpassword = \"\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\tcase \"mobile\":\n\t\t\t\t\t// 移动云盘链接，只有在明确指定提取码的情况下才使用\n\t\t\t\t\t// 检查是否明确包含提取码信息\n\t\t\t\t\thasExplicitPassword := false\n\t\t\t\t\tfor _, pattern := range pwdPatterns {\n\t\t\t\t\t\tif matches := pattern.FindStringSubmatch(surroundingText); len(matches) >= 2 {\n\t\t\t\t\t\t\t// 使用明确指定的提取码\n\t\t\t\t\t\t\tpassword = matches[1]\n\t\t\t\t\t\t\thasExplicitPassword = true\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\t// 如果没有明确的提取码信息，则不使用提取码\n\t\t\t\t\tif !hasExplicitPassword && !strings.Contains(href, \"pwd=\") {\n\t\t\t\t\t\tpassword = \"\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// 添加链接\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tType:     linkType,\n\t\t\t\t\tURL:      href,\n\t\t\t\t\tPassword: password,\n\t\t\t\t})\n\t\t\t}\n\t\t})\n\t\t\n\t\t// 尝试从文本中提取可能的网盘链接\n\t\thtmlContent, _ := content.Html()\n\t\ttextLinks := extractTextLinks(htmlContent, yearFromTitle)\n\t\tfor _, link := range textLinks {\n\t\t\tif !foundURLs[link.URL] {\n\t\t\t\tfoundURLs[link.URL] = true\n\t\t\t\tlinks = append(links, link)\n\t\t\t}\n\t\t}\n\t})\n\t\n\t// 缓存结果\n\tthreadLinksCache.Store(topicID, links)\n\t\n\treturn links, nil\n}\n\n// extractTextLinks 从文本中提取网盘链接\nfunc extractTextLinks(text string, yearFromTitle string) []model.Link {\n\t// 存储最终的链接结果\n\tvar links []model.Link\n\t\n\t// 预处理：检查文本是否包含网盘域名和提取码关键词，快速过滤\n\thasNetDiskDomain := false\n\thasPasswordKeyword := false\n\t\n\t// 一次性检查所有域名和关键词\n\tfor _, domain := range netDiskDomains {\n\t\tif strings.Contains(text, domain) {\n\t\t\thasNetDiskDomain = true\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\t// 如果文本中不包含任何网盘域名，直接返回空结果\n\tif !hasNetDiskDomain {\n\t\treturn links\n\t}\n\t\n\t// 检查是否包含提取码关键词\n\tfor _, keyword := range pwdKeywords {\n\t\tif strings.Contains(text, keyword) {\n\t\t\thasPasswordKeyword = true\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\t// 按顺序存储所有找到的网盘链接和提取码\n\ttype linkInfo struct {\n\t\turl      string // 完整URL\n\t\tbaseURL  string // 不含提取码的基本URL\n\t\tposition int    // 在文本中的位置\n\t\tendPos   int    // 链接结束位置\n\t\tlinkType string // 链接类型\n\t\tpassword string // 从URL中提取的提取码\n\t}\n\t\n\ttype passwordInfo struct {\n\t\tpassword string // 提取码\n\t\tposition int    // 在文本中的位置\n\t\tendPos   int    // 提取码结束位置\n\t}\n\t\n\t// 合并所有网盘链接正则表达式为一个大正则，减少多次扫描\n\t// 由于Go不支持直接合并正则，我们仍然需要多次扫描，但可以优化处理逻辑\n\t\n\t// 1. 批量提取所有网盘链接和提取码\n\tvar foundLinks []linkInfo\n\tvar foundPasswords []passwordInfo\n\t\n\t// 提取所有网盘链接 - 使用并发处理加速\n\tvar wg sync.WaitGroup\n\tvar linksMutex sync.Mutex\n\t\n\t// 限制并发数量\n\tsemaphore := make(chan struct{}, 5) // 最多5个并发\n\t\n\tfor _, pattern := range netDiskPatterns {\n\t\twg.Add(1)\n\t\tgo func(pattern *regexp.Regexp) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\tmatches := pattern.FindAllStringIndex(text, -1)\n\t\t\tif len(matches) > 0 {\n\t\t\t\tlinksMutex.Lock()\n\t\t\t\tdefer linksMutex.Unlock()\n\t\t\t\t\n\t\t\t\tfor _, match := range matches {\n\t\t\t\t\tif match != nil && len(match) == 2 {\n\t\t\t\t\t\turl := text[match[0]:match[1]]\n\t\t\t\t\t\t\n\t\t\t\t\t\t// 提取基本URL和提取码\n\t\t\t\t\t\tbaseURL := url\n\t\t\t\t\t\tpassword := \"\"\n\t\t\t\t\t\t\n\t\t\t\t\t\t// 检查链接本身是否包含密码参数\n\t\t\t\t\t\tpwdMatch := pwdParamRegex.FindStringSubmatch(url)\n\t\t\t\t\t\tif len(pwdMatch) >= 2 {\n\t\t\t\t\t\t\tpassword = pwdMatch[1]\n\t\t\t\t\t\t\t// 移除URL中的密码参数，获取基本链接\n\t\t\t\t\t\t\tif strings.Contains(url, \"?pwd=\") {\n\t\t\t\t\t\t\t\tbaseURL = url[:strings.Index(url, \"?pwd=\")]\n\t\t\t\t\t\t\t} else if strings.Contains(url, \"&pwd=\") {\n\t\t\t\t\t\t\t\tbaseURL = url[:strings.Index(url, \"&pwd=\")]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\t\n\t\t\t\t\t\t// 移除URL末尾的特殊字符（如#）\n\t\t\t\t\t\tbaseURL = strings.TrimRight(baseURL, \"#\")\n\t\t\t\t\t\t\n\t\t\t\t\t\t// 确定链接类型\n\t\t\t\t\t\tlinkType := determineLinkType(baseURL)\n\t\t\t\t\t\t\n\t\t\t\t\t\t// 添加到链接列表\n\t\t\t\t\t\tfoundLinks = append(foundLinks, linkInfo{\n\t\t\t\t\t\t\turl:      url,\n\t\t\t\t\t\t\tbaseURL:  baseURL,\n\t\t\t\t\t\t\tposition: match[0],\n\t\t\t\t\t\t\tendPos:   match[1],\n\t\t\t\t\t\t\tlinkType: linkType,\n\t\t\t\t\t\t\tpassword: password,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}(pattern)\n\t}\n\t\n\t// 并发提取所有提取码\n\tif hasPasswordKeyword {\n\t\tfor _, pattern := range pwdPatterns {\n\t\t\twg.Add(1)\n\t\t\tgo func(pattern *regexp.Regexp) {\n\t\t\t\tdefer wg.Done()\n\t\t\t\t\n\t\t\t\t// 获取信号量\n\t\t\t\tsemaphore <- struct{}{}\n\t\t\t\tdefer func() { <-semaphore }()\n\t\t\t\t\n\t\t\t\tmatches := pattern.FindAllSubmatchIndex([]byte(text), -1)\n\t\t\t\tif len(matches) > 0 {\n\t\t\t\t\tlinksMutex.Lock()\n\t\t\t\t\tdefer linksMutex.Unlock()\n\t\t\t\t\t\n\t\t\t\t\tfor _, match := range matches {\n\t\t\t\t\t\tif match != nil && len(match) >= 4 {\n\t\t\t\t\t\t\t// match[2]和match[3]是第一个捕获组的开始和结束位置\n\t\t\t\t\t\t\tpassword := text[match[2]:match[3]]\n\t\t\t\t\t\t\tposition := match[0] // 整个匹配的开始位置\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tfoundPasswords = append(foundPasswords, passwordInfo{\n\t\t\t\t\t\t\t\tpassword: password,\n\t\t\t\t\t\t\t\tposition: position,\n\t\t\t\t\t\t\t\tendPos:   match[1],\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}(pattern)\n\t\t}\n\t}\n\t\n\t// 等待所有并发任务完成\n\twg.Wait()\n\t\n\t// 如果没有找到链接，直接返回空结果\n\tif len(foundLinks) == 0 {\n\t\treturn links\n\t}\n\t\n\t// 2. 按照在文本中的位置排序链接和提取码\n\tsort.Slice(foundLinks, func(i, j int) bool {\n\t\treturn foundLinks[i].position < foundLinks[j].position\n\t})\n\t\n\tif len(foundPasswords) > 0 {\n\t\tsort.Slice(foundPasswords, func(i, j int) bool {\n\t\t\treturn foundPasswords[i].position < foundPasswords[j].position\n\t\t})\n\t}\n\t\n\t// 3. 优化的关联算法：为每个链接找到最合适的提取码\n\t// 使用映射存储已处理的链接，避免重复\n\tprocessedLinks := make(map[string]bool)\n\t\n\t// 为每个链接创建一个提取码候选列表\n\ttype pwdCandidate struct {\n\t\tpassword string\n\t\tdistance int\n\t\tscore    int // 分数越高，越可能是正确的提取码\n\t}\n\t\n\t// 创建链接与提取码的关联映射\n\tlinkPasswordMap := make(map[int][]pwdCandidate)\n\t\n\t// 预处理：为每个链接计算所有可能的提取码候选\n\tif len(foundPasswords) > 0 {\n\t\tfor i, link := range foundLinks {\n\t\t\tvar candidates []pwdCandidate\n\t\t\t\n\t\t\t// 链接周围文本的范围（用于检查提取码相关关键词）\n\t\t\tcontextStart := link.position - 50\n\t\t\tif contextStart < 0 {\n\t\t\t\tcontextStart = 0\n\t\t\t}\n\t\t\tcontextEnd := link.endPos + 100\n\t\t\tif contextEnd > len(text) {\n\t\t\t\tcontextEnd = len(text)\n\t\t\t}\n\t\t\tsurroundingText := text[contextStart:contextEnd]\n\t\t\t\n\t\t\t// 检查周围文本是否包含提取码相关关键词\n\t\t\thasPasswordHint := false\n\t\t\tfor _, keyword := range pwdKeywords {\n\t\t\t\tif strings.Contains(surroundingText, keyword) {\n\t\t\t\t\thasPasswordHint = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 如果周围文本没有提取码相关关键词，且不是必须需要提取码的链接类型，则跳过\n\t\t\tif !hasPasswordHint && link.linkType != \"baidu\" && link.linkType != \"xunlei\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t\n\t\t\t// 为当前链接收集所有可能的提取码候选\n\t\t\tfor _, pwd := range foundPasswords {\n\t\t\t\t// 只考虑链接后面的提取码\n\t\t\t\tif pwd.position > link.endPos {\n\t\t\t\t\t// 计算距离\n\t\t\t\t\tdistance := pwd.position - link.endPos\n\t\t\t\t\t\n\t\t\t\t\t// 基础分数 - 距离越近分数越高\n\t\t\t\t\tscore := 1000 - distance\n\t\t\t\t\t\n\t\t\t\t\t// 检查这个提取码是否应该关联到当前链接\n\t\t\t\t\t// 如果提取码在下一个链接之后，则不应该关联到当前链接\n\t\t\t\t\tfor j := i + 1; j < len(foundLinks); j++ {\n\t\t\t\t\t\tif pwd.position > foundLinks[j].position {\n\t\t\t\t\t\t\tscore -= 500 // 大幅降低分数\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\t// 检查提取码是否紧跟在链接后面（50个字符内）\n\t\t\t\t\tif distance < 50 {\n\t\t\t\t\t\tscore += 300 // 提高分数\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\t// 检查提取码周围是否有关联关键词\n\t\t\t\t\tpwdContextStart := pwd.position - 20\n\t\t\t\t\tif pwdContextStart < 0 {\n\t\t\t\t\t\tpwdContextStart = 0\n\t\t\t\t\t}\n\t\t\t\t\tpwdContextEnd := pwd.endPos + 20\n\t\t\t\t\tif pwdContextEnd > len(text) {\n\t\t\t\t\t\tpwdContextEnd = len(text)\n\t\t\t\t\t}\n\t\t\t\t\tpwdSurroundingText := text[pwdContextStart:pwdContextEnd]\n\t\t\t\t\t\n\t\t\t\t\tfor _, keyword := range pwdKeywords {\n\t\t\t\t\t\tif strings.Contains(pwdSurroundingText, keyword) {\n\t\t\t\t\t\t\tscore += 200 // 提高分数\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\t// 添加到候选列表\n\t\t\t\t\tcandidates = append(candidates, pwdCandidate{\n\t\t\t\t\t\tpassword: pwd.password,\n\t\t\t\t\t\tdistance: distance,\n\t\t\t\t\t\tscore:    score,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 如果有候选提取码，存储到映射中\n\t\t\tif len(candidates) > 0 {\n\t\t\t\t// 按分数排序（从高到低）\n\t\t\t\tsort.Slice(candidates, func(i, j int) bool {\n\t\t\t\t\treturn candidates[i].score > candidates[j].score\n\t\t\t\t})\n\t\t\t\t\n\t\t\t\tlinkPasswordMap[i] = candidates\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 4. 批量处理所有链接，生成最终结果\n\tfor i, link := range foundLinks {\n\t\t// 如果链接已经处理过，跳过\n\t\tif processedLinks[link.baseURL] {\n\t\t\tcontinue\n\t\t}\n\t\tprocessedLinks[link.baseURL] = true\n\t\t\n\t\t// 如果链接已经有提取码，直接使用\n\t\tif link.password != \"\" {\n\t\t\tlinks = append(links, model.Link{\n\t\t\t\tType:     link.linkType,\n\t\t\t\tURL:      link.url,\n\t\t\t\tPassword: link.password,\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 变量用于存储最终的提取码和URL\n\t\tvar finalPassword string\n\t\tvar finalURL string = link.baseURL\n\t\t\n\t\t// 从映射中获取候选提取码\n\t\tif candidates, ok := linkPasswordMap[i]; ok && len(candidates) > 0 {\n\t\t\tfinalPassword = candidates[0].password\n\t\t}\n\t\t\n\t\t// 特殊处理不同类型的网盘链接\n\t\tswitch link.linkType {\n\t\tcase \"mobile\":\n\t\t\t// 移动云盘链接：不自动在URL中添加提取码\n\t\t\tif strings.Contains(link.url, \"pwd=\") {\n\t\t\t\t// 如果链接本身已经包含提取码参数，则保留原始URL\n\t\t\t\tfinalURL = link.url\n\t\t\t} else if finalPassword != \"\" {\n\t\t\t\t// 只有在原始HTML明确包含带提取码的链接时才使用带提取码的URL\n\t\t\t\t// 这里我们不自动构建带提取码的URL\n\t\t\t\tfinalURL = link.baseURL\n\t\t\t}\n\t\tcase \"baidu\", \"xunlei\":\n\t\t\t// 百度网盘和迅雷网盘：支持在URL中包含提取码\n\t\t\tif finalPassword != \"\" {\n\t\t\t\tif strings.Contains(finalURL, \"?\") {\n\t\t\t\t\tfinalURL = finalURL + \"&pwd=\" + finalPassword\n\t\t\t\t} else {\n\t\t\t\t\tfinalURL = finalURL + \"?pwd=\" + finalPassword\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 添加处理后的链接\n\t\tlinks = append(links, model.Link{\n\t\t\tType:     link.linkType,\n\t\t\tURL:      finalURL,\n\t\t\tPassword: finalPassword,\n\t\t})\n\t}\n\t\n\treturn links\n}\n\n// extractPassword 从文本中提取密码\nfunc extractPassword(content string, url string) string {\n\t// 创建缓存键\n\tkey := passwordCacheKey{\n\t\tcontent: content,\n\t\turl:     url,\n\t}\n\t\n\t// 检查缓存中是否已有结果\n\tif result, ok := extractPasswordCache.Load(key); ok {\n\t\treturn result.(string)\n\t}\n\t\n\t// 如果URL已经包含密码参数，直接提取\n\tpwdMatch := pwdParamRegex.FindStringSubmatch(url)\n\tif len(pwdMatch) >= 2 {\n\t\t// 缓存结果\n\t\textractPasswordCache.Store(key, pwdMatch[1])\n\t\treturn pwdMatch[1]\n\t}\n\t\n\t// 只有当内容中可能包含提取码时才进行提取\n\thasPasswordKeyword := false\n\tfor _, keyword := range pwdKeywords {\n\t\tif strings.Contains(content, keyword) {\n\t\t\thasPasswordKeyword = true\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\t// 如果内容中没有提取码相关关键词，直接返回空\n\tif !hasPasswordKeyword {\n\t\t// 缓存结果\n\t\textractPasswordCache.Store(key, \"\")\n\t\treturn \"\"\n\t}\n\t\n\t// 尝试从文本中提取密码\n\tfor _, pattern := range pwdPatterns {\n\t\tmatches := pattern.FindStringSubmatch(content)\n\t\t\n\t\tif len(matches) >= 2 {\n\t\t\t// 缓存结果\n\t\t\textractPasswordCache.Store(key, matches[1])\n\t\t\treturn matches[1]\n\t\t}\n\t}\n\t\n\t// 缓存结果\n\textractPasswordCache.Store(key, \"\")\n\treturn \"\"\n}\n\n// determineLinkType 根据URL确定链接类型\nfunc determineLinkType(url string) string {\n\t// 检查缓存中是否已有结果\n\tif result, ok := determineLinkTypeCache.Load(url); ok {\n\t\treturn result.(string)\n\t}\n\t\n\tlowerURL := strings.ToLower(url)\n\tvar linkType string\n\t\n\tswitch {\n\tcase strings.Contains(lowerURL, \"pan.baidu.com\"):\n\t\tlinkType = \"baidu\"\n\tcase strings.Contains(lowerURL, \"pan.quark.cn\"):\n\t\tlinkType = \"quark\"\n\tcase strings.Contains(lowerURL, \"alipan.com\") || strings.Contains(lowerURL, \"aliyundrive.com\"):\n\t\tlinkType = \"aliyun\"\n\tcase strings.Contains(lowerURL, \"cloud.189.cn\"):\n\t\tlinkType = \"tianyi\"\n\tcase strings.Contains(lowerURL, \"caiyun.139.com\"):\n\t\tlinkType = \"mobile\"  // 修改为mobile而不是caiyun\n\tcase strings.Contains(lowerURL, \"115.com\"):\n\t\tlinkType = \"115\"\n\tcase strings.Contains(lowerURL, \"pan.xunlei.com\"):\n\t\tlinkType = \"xunlei\"\n\tcase strings.Contains(lowerURL, \"mypikpak.com\"):\n\t\tlinkType = \"pikpak\"\n\tcase strings.Contains(lowerURL, \"123\"):\n\t\tlinkType = \"123\"\n\tdefault:\n\t\tlinkType = \"others\"\n\t}\n\t\n\t// 缓存结果\n\tdetermineLinkTypeCache.Store(url, linkType)\n\treturn linkType\n}\n\n// isNetDiskLink 检查链接是否为网盘链接\nfunc isNetDiskLink(url string) bool {\n\t// 检查缓存中是否已有结果\n\tif result, ok := isNetDiskLinkCache.Load(url); ok {\n\t\treturn result.(bool)\n\t}\n\t\n\tlowerURL := strings.ToLower(url)\n\t\n\t// 使用预定义的网盘域名列表进行快速检查\n\tfor _, domain := range netDiskDomains {\n\t\tif strings.Contains(lowerURL, domain) {\n\t\t\t// 缓存结果\n\t\t\tisNetDiskLinkCache.Store(url, true)\n\t\t\treturn true\n\t\t}\n\t}\n\t\n\t// 缓存结果\n\tisNetDiskLinkCache.Store(url, false)\n\treturn false\n} \n\n// startConcurrencyAdjuster 启动一个定期调整并发数的goroutine\nfunc (p *PantaAsyncPlugin) startConcurrencyAdjuster() {\n\tticker := time.NewTicker(concurrencyAdjustInterval * time.Second)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\tp.adjustConcurrency()\n\t}\n}\n\n// adjustConcurrency 根据响应时间调整并发数\nfunc (p *PantaAsyncPlugin) adjustConcurrency() {\n\tp.responseTimesMutex.Lock()\n\tdefer p.responseTimesMutex.Unlock()\n\t\n\t// 如果没有足够的响应时间样本，则不调整\n\tif len(p.responseTimes) < 5 {\n\t\treturn\n\t}\n\t\n\t// 计算平均响应时间\n\tvar totalTime time.Duration\n\tfor _, t := range p.responseTimes {\n\t\ttotalTime += t\n\t}\n\tavgTime := totalTime / time.Duration(len(p.responseTimes))\n\t\n\t// 根据平均响应时间调整并发数\n\tif avgTime > responseTimeThreshold*time.Millisecond {\n\t\t// 响应时间过长，减少并发数\n\t\tp.currentConcurrency = max(p.currentConcurrency-concurrencyStep, minConcurrency)\n\t} else {\n\t\t// 响应时间正常，尝试增加并发数\n\t\tp.currentConcurrency = min(p.currentConcurrency+concurrencyStep, maxConcurrency)\n\t}\n\t\n\t// 清空响应时间样本\n\tp.responseTimes = p.responseTimes[:0]\n}\n\n// recordResponseTime 记录请求响应时间\nfunc (p *PantaAsyncPlugin) recordResponseTime(d time.Duration) {\n\tp.responseTimesMutex.Lock()\n\tdefer p.responseTimesMutex.Unlock()\n\t\n\t// 限制样本数量\n\tif len(p.responseTimes) >= 100 {\n\t\t// 移除最早的样本\n\t\tp.responseTimes = p.responseTimes[1:]\n\t}\n\t\n\tp.responseTimes = append(p.responseTimes, d)\n}\n\n// doRequestWithRetry 发送HTTP请求，带重试机制\nfunc (p *PantaAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tvar resp *http.Response\n\tvar err error\n\tvar startTime time.Time\n\t\n\t// 重试循环\n\tfor retry := 0; retry <= maxRetries; retry++ {\n\t\t// 如果不是第一次尝试，则等待一段时间\n\t\tif retry > 0 {\n\t\t\t// 使用指数退避算法计算等待时间\n\t\t\tbackoffTime := time.Duration(min(backoffBase*1<<uint(retry-1), maxBackoff)) * time.Millisecond\n\t\t\ttime.Sleep(backoffTime)\n\t\t\t\n\t\t\t// 创建新的请求，因为原请求可能已经被关闭\n\t\t\tnewReq := req.Clone(req.Context())\n\t\t\treq = newReq\n\t\t}\n\t\t\n\t\t// 记录开始时间\n\t\tstartTime = time.Now()\n\t\t\n\t\t// 发送请求\n\t\tresp, err = client.Do(req)\n\t\t\n\t\t// 记录响应时间\n\t\tresponseTime := time.Since(startTime)\n\t\tp.recordResponseTime(responseTime)\n\t\t\n\t\t// 如果请求成功，或者是不可重试的错误，则退出重试循环\n\t\tif err == nil && resp.StatusCode < 500 {\n\t\t\tbreak\n\t\t}\n\t\t\n\t\t// 如果请求失败，但响应不为nil，则关闭响应体\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t}\n\t\n\treturn resp, err\n}\n\n// max 返回两个整数中的较大值\nfunc max(a, b int) int {\n\tif a > b {\n\t\treturn a\n\t}\n\treturn b\n}\n\n// min 返回两个整数中的较小值\nfunc min(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n} "
  },
  {
    "path": "plugin/panta/panta插件设计文档.md",
    "content": "# PanTa 搜索插件设计文档\n\n## 1. 概述\n\nPanTa搜索插件是一个用于从91panta.cn网站搜索并提取网盘链接的Go语言插件。该插件能够智能识别多种网盘链接类型，并自动关联提取码，是一个高性能、高可靠性的网络爬虫实现。\n\n## 2. 架构设计\n\n### 2.1 整体架构\n\n插件采用模块化设计，主要包含以下几个核心部分：\n\n1. **插件初始化与注册**：在程序启动时初始化插件并注册到全局插件管理器\n2. **HTTP客户端管理**：优化的HTTP客户端配置，支持连接池、重试机制和自适应并发控制\n3. **搜索执行模块**：负责构建搜索请求并获取搜索结果页面\n4. **结果解析模块**：解析HTML页面，提取搜索结果信息\n5. **链接提取模块**：从文本中识别并提取各类网盘链接\n6. **提取码关联模块**：智能关联链接与提取码\n7. **缓存管理模块**：多级缓存机制，提高性能并减少重复计算\n\n### 2.2 数据流\n\n1. 用户输入关键词 → 构建搜索请求 → 发送HTTP请求\n2. 接收HTML响应 → 解析搜索结果列表 → 并发处理每个搜索结果\n3. 提取链接和提取码 → 智能关联 → 返回最终结果\n\n## 3. 关键组件详解\n\n### 3.1 全局变量与常量\n\n#### 3.1.1 预编译正则表达式\n\n```go\n// 预编译的正则表达式\nvar (\n    topicIDRegex = regexp.MustCompile(`topicId=(\\d+)`)\n    yearRegex = regexp.MustCompile(`\\(([0-9]{4})\\)`)\n    postTimeRegex = regexp.MustCompile(`发表时间：(.+)`)\n    pwdParamRegex = regexp.MustCompile(`[?&]pwd=([0-9a-zA-Z]+)`)\n    pwdPatterns = []*regexp.Regexp{...}\n    netDiskPatterns = []*regexp.Regexp{...}\n)\n```\n\n**设计思想**：\n- 将正则表达式预编译为全局变量，避免每次使用时重新编译，显著提高性能\n- 模式化管理不同类型的正则表达式，便于维护和扩展\n\n#### 3.1.2 缓存系统\n\n```go\nvar (\n    isNetDiskLinkCache     = sync.Map{}\n    determineLinkTypeCache = sync.Map{}\n    extractPasswordCache   = sync.Map{}\n    topicIDCache = sync.Map{}\n    postTimeCache = sync.Map{}\n    yearCache = sync.Map{}\n    linkExtractCache = sync.Map{}\n    threadLinksCache = sync.Map{}\n)\n```\n\n**设计思想**：\n- 使用`sync.Map`实现线程安全的缓存，避免频繁的重复计算\n- 分层缓存设计，针对不同类型的操作设置独立缓存\n- 定期清理机制防止内存泄漏\n\n#### 3.1.3 常量配置\n\n```go\nconst (\n    pluginName = \"panta\"\n    searchURLTemplate = \"https://www.91panta.cn/search?keyword=%s\"\n    threadURLTemplate = \"https://www.91panta.cn/thread?topicId=%s\"\n    defaultPriority = 2\n    defaultTimeout = 10\n    defaultConcurrency = 30\n    // ... 其他配置常量\n)\n```\n\n**设计思想**：\n- 使用常量集中管理配置参数，提高可维护性\n- 参数化设计，便于调整和优化\n\n### 3.2 插件结构体\n\n```go\ntype PantaPlugin struct {\n    client *http.Client\n    maxConcurrency int\n    currentConcurrency int\n    responseTimes []time.Duration\n    responseTimesMutex sync.Mutex\n    lastAdjustTime time.Time\n}\n```\n\n**设计思想**：\n- 封装HTTP客户端，统一管理网络请求\n- 包含自适应并发控制相关字段，实现动态调整并发数\n- 使用互斥锁保护共享数据，确保线程安全\n\n## 4. 核心功能实现\n\n### 4.1 插件初始化\n\n```go\nfunc init() {\n    plugin.RegisterGlobalPlugin(NewPantaPlugin())\n}\n\nfunc NewPantaPlugin() *PantaPlugin {\n    // 创建优化的HTTP传输层\n    transport := &http.Transport{...}\n    \n    // 创建HTTP客户端\n    client := &http.Client{...}\n    \n    // 启动定期清理缓存的goroutine\n    go startCacheCleaner()\n    \n    // 创建插件实例\n    plugin := &PantaPlugin{...}\n    \n    // 启动自适应并发控制\n    go plugin.startConcurrencyAdjuster()\n    \n    return plugin\n}\n```\n\n**设计思想**：\n- 自动注册机制，确保插件在程序启动时自动加载\n- 优化的HTTP客户端配置，包括连接池、超时设置和HTTP/2支持\n- 后台任务管理，包括缓存清理和并发调整\n- 关注点分离，初始化逻辑独立封装\n\n### 4.2 搜索执行\n\n```go\nfunc (p *PantaPlugin) Search(keyword string) ([]model.SearchResult, error) {\n    // 对关键词进行URL编码\n    encodedKeyword := url.QueryEscape(keyword)\n    \n    // 构建搜索URL\n    searchURL := fmt.Sprintf(searchURLTemplate, encodedKeyword)\n    \n    // 创建请求\n    req, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n    // ... 设置请求头\n    \n    // 使用带重试的请求方法发送HTTP请求\n    resp, err := p.doRequestWithRetry(req)\n    // ... 处理响应\n    \n    // 解析搜索结果\n    return p.parseSearchResults(doc)\n}\n```\n\n**设计思想**：\n- 参数验证和处理，确保输入安全\n- 上下文管理，支持超时控制\n- 请求头优化，模拟真实浏览器行为\n- 错误处理和资源管理，确保资源正确释放\n- 重试机制，提高请求可靠性\n\n### 4.3 搜索结果解析\n\n```go\nfunc (p *PantaPlugin) parseSearchResults(doc *goquery.Document) ([]model.SearchResult, error) {\n    // ... 初始化变量\n    \n    // 创建信号量控制并发数\n    semaphore := make(chan struct{}, p.currentConcurrency)\n    \n    // 创建结果通道和错误通道\n    resultChan := make(chan model.SearchResult, 100)\n    errorChan := make(chan error, 100)\n    \n    // 预先收集所有需要处理的话题项\n    var topicItems []*goquery.Selection\n    // ... 收集话题项\n    \n    // 批量处理所有话题项\n    for i, s := range topicItems {\n        wg.Add(1)\n        \n        // 为每个话题创建一个goroutine\n        go func(index int, s *goquery.Selection) {\n            // ... 处理单个话题项\n            // 提取话题ID、标题、摘要、发布时间等\n            // 提取链接\n            // 将结果发送到结果通道\n        }(i, s)\n    }\n    \n    // 等待所有goroutine完成\n    // 收集结果\n    // 调整并发数\n    \n    return results, nil\n}\n```\n\n**设计思想**：\n- 并发处理模型，提高解析效率\n- 信号量控制并发数，避免资源过度消耗\n- 通道通信模式，安全地收集并发结果\n- 自适应并发控制，根据处理时间动态调整并发数\n- 批处理优化，减少重复操作\n\n### 4.4 链接提取\n\n```go\nfunc (p *PantaPlugin) extractLinksFromElement(s *goquery.Selection, yearFromTitle string) []model.Link {\n    // 创建缓存键\n    // 检查缓存\n    \n    // 批量处理所有链接\n    // 提取链接类型和提取码\n    // 根据链接类型进行特殊处理\n    \n    // 缓存结果\n    return links\n}\n```\n\n**设计思想**：\n- 缓存机制，避免重复提取\n- 批量处理，减少DOM遍历次数\n- 链接去重，确保结果唯一性\n- 上下文感知，考虑链接周围文本信息\n\n### 4.5 帖子详情获取\n\n```go\nfunc (p *PantaPlugin) fetchThreadLinks(topicID string) ([]model.Link, error) {\n    // 检查缓存\n    // 构建请求\n    // 发送请求并处理响应\n    // 解析HTML\n    // 提取链接和提取码\n    // 缓存结果\n    return links, nil\n}\n```\n\n**设计思想**：\n- 缓存优先，减少网络请求\n- 重试机制，处理临时网络问题\n- 深度提取，获取详细内容\n- 资源管理，确保连接正确关闭\n\n### 4.6 文本链接提取\n\n```go\nfunc extractTextLinks(text string, yearFromTitle string) []model.Link {\n    // 快速过滤\n    // 并发提取链接和提取码\n    // 智能关联算法\n    // 特殊处理不同类型的网盘链接\n    return links\n}\n```\n\n**设计思想**：\n- 预过滤机制，快速排除不包含目标内容的文本\n- 并发处理，加速大文本处理\n- 评分系统，智能关联链接与提取码\n- 特殊情况处理，针对不同网盘类型采用不同策略\n\n### 4.7 提取码提取\n\n```go\nfunc extractPassword(content string, url string) string {\n    // 缓存检查\n    // URL参数提取\n    // 关键词检查\n    // 正则匹配\n    // 缓存结果\n    return password\n}\n```\n\n**设计思想**：\n- 多级提取策略，优先从URL参数提取\n- 关键词预检查，避免不必要的正则匹配\n- 缓存结果，提高重复调用性能\n\n### 4.8 链接类型判断\n\n```go\nfunc determineLinkType(url string) string {\n    // 缓存检查\n    // 域名匹配\n    // 缓存结果\n    return linkType\n}\n```\n\n**设计思想**：\n- 缓存机制，减少重复判断\n- 简单高效的字符串匹配，避免复杂正则\n\n### 4.9 网盘链接判断\n\n```go\nfunc isNetDiskLink(url string) bool {\n    // 缓存检查\n    // 域名快速匹配\n    // 缓存结果\n    return isNetDisk\n}\n```\n\n**设计思想**：\n- 快速过滤，使用预定义域名列表\n- 缓存结果，提高性能\n\n## 5. 高级特性\n\n### 5.1 自适应并发控制\n\n```go\nfunc (p *PantaPlugin) startConcurrencyAdjuster() {\n    ticker := time.NewTicker(concurrencyAdjustInterval * time.Second)\n    defer ticker.Stop()\n    \n    for range ticker.C {\n        p.adjustConcurrency()\n    }\n}\n\nfunc (p *PantaPlugin) adjustConcurrency() {\n    // 计算平均响应时间\n    // 根据响应时间调整并发数\n}\n\nfunc (p *PantaPlugin) recordResponseTime(d time.Duration) {\n    // 记录响应时间\n}\n```\n\n**设计思想**：\n- 自适应算法，根据系统响应动态调整\n- 定时器触发，避免频繁调整\n- 样本收集，基于历史数据做出决策\n- 边界控制，确保并发数在合理范围内\n\n### 5.2 HTTP请求重试机制\n\n```go\nfunc (p *PantaPlugin) doRequestWithRetry(req *http.Request) (*http.Response, error) {\n    // 重试循环\n    // 指数退避算法\n    // 响应时间记录\n    return resp, err\n}\n```\n\n**设计思想**：\n- 指数退避算法，避免对服务器造成压力\n- 智能判断可重试错误，避免无效重试\n- 请求克隆，确保每次重试使用新的请求对象\n- 响应时间监控，用于并发控制\n\n### 5.3 缓存清理机制\n\n```go\nfunc startCacheCleaner() {\n    ticker := time.NewTicker(1 * time.Hour)\n    defer ticker.Stop()\n    \n    for range ticker.C {\n        // 清空所有缓存\n    }\n}\n```\n\n**设计思想**：\n- 定期清理，防止内存泄漏\n- 全面清理，确保所有缓存都得到处理\n- 后台执行，不影响主业务流程\n\n## 6. 智能算法\n\n### 6.1 链接与提取码关联算法\n\n```go\n// 为每个链接找到最合适的提取码\n// 使用评分系统选择最佳匹配\n```\n\n**设计思想**：\n- 评分系统，考虑多种因素：\n  - 距离：链接与提取码在文本中的距离\n  - 位置关系：提取码是否在链接之后\n  - 关键词：提取码周围是否有关联关键词\n  - 链接类型：不同网盘对提取码的要求不同\n- 候选筛选，为每个链接收集多个可能的提取码\n- 排序选择，选择得分最高的提取码\n\n### 6.2 网盘链接识别算法\n\n```go\n// 使用正则表达式和域名列表识别不同类型的网盘链接\n```\n\n**设计思想**：\n- 多级识别：\n  1. 快速过滤：使用域名列表快速判断是否可能是网盘链接\n  2. 精确匹配：使用正则表达式确认链接格式\n- 类型区分：根据不同网盘的URL特征区分链接类型\n- 缓存优化：缓存判断结果避免重复计算\n\n## 7. 性能优化\n\n### 7.1 HTTP连接池优化\n\n```go\ntransport := &http.Transport{\n    MaxIdleConns:          200,\n    IdleConnTimeout:       120 * time.Second,\n    MaxIdleConnsPerHost:   50,\n    MaxConnsPerHost:       100,\n    DisableKeepAlives:     false,\n    ForceAttemptHTTP2:     true,\n    WriteBufferSize:       16 * 1024,\n    ReadBufferSize:        16 * 1024,\n    // ... 其他配置\n}\n```\n\n**设计思想**：\n- 连接复用，减少TCP连接建立开销\n- 参数调优，根据实际负载调整连接池大小\n- HTTP/2支持，利用多路复用提高性能\n- 缓冲区优化，减少系统调用\n\n### 7.2 并发处理优化\n\n```go\n// 使用信号量控制并发数\n// 使用goroutine并行处理多个任务\n// 使用通道安全地收集结果\n```\n\n**设计思想**：\n- 并发粒度控制，选择合适的并发单位\n- 资源限制，使用信号量避免过度并发\n- 动态调整，根据系统响应调整并发数\n- 结果收集，使用通道安全地汇总结果\n\n### 7.3 缓存机制\n\n```go\n// 多级缓存系统\n// 定期清理机制\n```\n\n**设计思想**：\n- 分层缓存，针对不同操作设置独立缓存\n- 线程安全，使用sync.Map确保并发安全\n- 缓存键设计，确保唯一性和高效查找\n- 生命周期管理，定期清理避免内存泄漏\n\n### 7.4 正则表达式优化\n\n```go\n// 预编译正则表达式\n// 减少回溯的正则模式\n```\n\n**设计思想**：\n- 预编译，避免运行时编译开销\n- 模式优化，减少回溯提高匹配效率\n- 快速过滤，在使用正则前先进行简单字符串检查\n\n### 7.5 批处理优化\n\n```go\n// 预先收集所有需要处理的项\n// 批量处理减少重复操作\n```\n\n**设计思想**：\n- 减少重复扫描，一次收集多次使用\n- 批量处理，减少循环开销\n- 预处理过滤，减少需要处理的数据量\n\n## 8. 错误处理与容错\n\n### 8.1 重试机制\n\n```go\n// 指数退避重试\n// 错误类型判断\n```\n\n**设计思想**：\n- 区分错误类型，只对可恢复错误进行重试\n- 指数退避，避免对服务器造成压力\n- 最大重试次数限制，避免无限重试\n\n### 8.2 优雅降级\n\n```go\n// 当无法获取详细信息时使用已有信息\n// 当主要数据源失败时尝试备用方法\n```\n\n**设计思想**：\n- 多级提取，主要方法失败时尝试备用方法\n- 部分结果返回，即使不完整也返回有用信息\n- 错误隔离，单个结果处理失败不影响整体\n\n## 9. 可扩展性设计\n\n### 9.1 插件接口\n\n```go\n// 实现SearchPlugin接口\nvar _ plugin.SearchPlugin = (*PantaPlugin)(nil)\n```\n\n**设计思想**：\n- 接口设计，确保插件符合统一标准\n- 静态类型检查，编译时验证接口实现\n\n### 9.2 模块化结构\n\n```go\n// 功能分解为独立函数\n// 关注点分离\n```\n\n**设计思想**：\n- 单一职责原则，每个函数只负责一个功能\n- 模块化设计，便于维护和扩展\n- 依赖注入，减少组件间耦合\n\n### 9.3 配置参数化\n\n```go\n// 使用常量定义配置参数\n// 支持动态调整部分参数\n```\n\n**设计思想**：\n- 参数集中管理，提高可维护性\n- 运行时调整，支持动态优化\n- 默认值设计，确保系统在各种情况下都能正常工作\n\n## 10. 总结与最佳实践\n\n### 10.1 性能优化最佳实践\n\n1. **预编译正则表达式**：避免运行时编译开销\n2. **多级缓存**：减少重复计算和网络请求\n3. **并发处理**：利用多核提高处理效率\n4. **连接池优化**：减少网络连接开销\n5. **批处理**：减少重复操作和循环开销\n\n### 10.2 可靠性保障最佳实践\n\n1. **重试机制**：处理临时网络问题\n2. **错误处理**：全面的错误捕获和处理\n3. **资源管理**：确保资源正确释放\n4. **超时控制**：避免请求无限等待\n5. **并发控制**：避免资源过度消耗\n\n### 10.3 代码质量最佳实践\n\n1. **接口设计**：清晰定义组件职责\n2. **单一职责**：每个函数只做一件事\n3. **注释文档**：详细说明函数功能和参数\n4. **错误传播**：合理传递和包装错误信息\n5. **命名规范**：使用有意义的变量和函数名\n\n## 11. 附录\n\n### 11.1 支持的网盘类型\n\n- 移动云盘 (mobile)\n- 百度网盘 (baidu)\n- 夸克网盘 (quark)\n- 阿里云盘 (aliyun)\n- 迅雷网盘 (xunlei)\n- 天翼云盘 (tianyi)\n- 115网盘 (115)\n- PikPak网盘 (pikpak)\n\n### 11.2 关键依赖\n\n- github.com/PuerkitoBio/goquery：HTML解析\n- net/http：网络请求\n- regexp：正则表达式处理\n- sync：并发控制\n\n### 11.3 常见问题与解决方案\n\n1. **问题**：提取码关联不准确\n   **解决方案**：使用评分系统考虑多种因素，提高关联准确性\n\n2. **问题**：网络请求超时\n   **解决方案**：实现重试机制和超时控制\n\n3. **问题**：内存使用过高\n   **解决方案**：定期清理缓存，控制并发数量\n\n4. **问题**：CPU使用率过高\n   **解决方案**：优化正则表达式，减少不必要的计算\n\n5. **问题**：某些网盘链接识别失败\n   **解决方案**：持续更新网盘链接模式，支持新的网盘类型 "
  },
  {
    "path": "plugin/panwiki/panwiki.go",
    "content": "package panwiki\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tPrimaryBaseURL   = \"https://www.panwiki.com\"\n\tBackupBaseURL    = \"https://pan666.net\"\n\tSearchPath       = \"/search.php?mod=forum&srchtxt=%s&searchsubmit=yes&orderby=lastpost\"\n\tUserAgent        = \"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\"\n\tMaxConcurrency   = 40 // 详情页最大并发数\n\tMaxPages         = 2  // 最大搜索页数\n)\n\n// PanwikiPlugin Panwiki插件结构\ntype PanwikiPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tdetailCache sync.Map // 详情页缓存\n\tcacheTTL    time.Duration\n\tdebugMode   bool     // debug模式开关\n\tcurrentBaseURL string // 当前使用的域名\n}\n\n// NewPanwikiPlugin 创建Panwiki插件实例\nfunc NewPanwikiPlugin() *PanwikiPlugin {\n\t\n\t// 检查调试模式\n\tdebugMode := false\n\t\n\tp := &PanwikiPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"panwiki\", 3, true),\n\t\tcacheTTL:       30 * time.Minute,\n\t\tdebugMode:      debugMode,\n\t\tcurrentBaseURL: PrimaryBaseURL, // 默认使用主域名\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] Debug模式已启用\")\n\t}\n\t\n\treturn p\n}\n\n// getSearchURL 获取当前使用的搜索URL\nfunc (p *PanwikiPlugin) getSearchURL(keyword string, page int) string {\n\tvar searchURL string\n\tif page <= 1 {\n\t\tsearchURL = fmt.Sprintf(p.currentBaseURL+SearchPath, url.QueryEscape(keyword))\n\t} else {\n\t\tsearchURL = fmt.Sprintf(p.currentBaseURL+SearchPath+\"&page=%d\", url.QueryEscape(keyword), page)\n\t}\n\treturn searchURL\n}\n\n// switchToBackupDomain 切换到备用域名\nfunc (p *PanwikiPlugin) switchToBackupDomain() {\n\tif p.currentBaseURL == PrimaryBaseURL {\n\t\tp.currentBaseURL = BackupBaseURL\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 切换到备用域名: %s\", p.currentBaseURL)\n\t\t}\n\t}\n}\n\n// searchImpl 实现搜索逻辑\nfunc (p *PanwikiPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 第一页搜索\n\tfirstPageResults, err := p.searchPage(client, keyword, 1)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"搜索第一页失败: %w\", err)\n\t}\n\n\tvar allResults []model.SearchResult\n\tallResults = append(allResults, firstPageResults...)\n\n\t// 多页并发搜索\n\tif MaxPages > 1 {\n\t\tvar wg sync.WaitGroup\n\t\tvar mu sync.Mutex\n\t\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\tpageResults := make(map[int][]model.SearchResult)\n\n\t\tfor page := 2; page <= MaxPages; page++ {\n\t\t\twg.Add(1)\n\t\t\tgo func(pageNum int) {\n\t\t\t\tdefer wg.Done()\n\t\t\t\tsemaphore <- struct{}{}\n\t\t\t\tdefer func() { <-semaphore }()\n\t\t\t\t\n\t\t\t\t// 添加延时避免请求过快\n\t\t\t\ttime.Sleep(time.Duration(pageNum%3) * 100 * time.Millisecond)\n\t\t\t\t\n\t\t\t\tcurrentPageResults, err := p.searchPage(client, keyword, pageNum)\n\t\t\t\tif err == nil && len(currentPageResults) > 0 {\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\tpageResults[pageNum] = currentPageResults\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t}\n\t\t\t}(page)\n\t\t}\n\t\twg.Wait()\n\n\t\t// 按页码顺序添加结果\n\t\tfor page := 2; page <= MaxPages; page++ {\n\t\t\tif results, exists := pageResults[page]; exists {\n\t\t\t\tallResults = append(allResults, results...)\n\t\t\t}\n\t\t}\n\t}\n\n\t// 获取详情页链接\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 开始获取详情页链接前，结果数: %d\", len(allResults))\n\t}\n\t\n\tp.enrichWithDetailLinks(client, allResults, keyword)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 获取详情页链接后，结果数: %d\", len(allResults))\n\t\tfor i, result := range allResults {\n\t\t\tlog.Printf(\"[Panwiki] 返回前检查 - 结果#%d: 标题=%s, 链接数=%d\", i+1, result.Title, len(result.Links))\n\t\t\tlog.Printf(\"[Panwiki] 返回前检查 - 结果#%d: 链接=%s\", i+1, result.Links)\n\t\t}\n\t}\n\n\t// 进行关键词过滤\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 开始关键词过滤，关键词: %s\", keyword)\n\t}\n\t\n\tfilteredResults := plugin.FilterResultsByKeyword(allResults, keyword)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 关键词过滤完成，过滤前: %d，过滤后: %d\", len(allResults), len(filteredResults))\n\t\tfor i, result := range filteredResults {\n\t\t\tlog.Printf(\"[Panwiki] 最终结果%d: MessageID=%s, UniqueID=%s, 标题=%s, 链接数=%d\", i+1, result.MessageID, result.UniqueID, result.Title, len(result.Links))\n\t\t}\n\t\tlog.Printf(\"[Panwiki] 🚀 插件返回结果总数: %d\", len(filteredResults))\n\t}\n\n\treturn filteredResults, nil\n}\n\n// searchPage 搜索指定页面\nfunc (p *PanwikiPlugin) searchPage(client *http.Client, keyword string, page int) ([]model.SearchResult, error) {\n\t// Step 1: 发起初始搜索请求获取重定向URL\n\tinitialURL := p.getSearchURL(keyword, page)\n\t\n\treq, err := http.NewRequest(\"GET\", initialURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建初始请求失败: %w\", err)\n\t}\n\t\n\tp.setRequestHeaders(req)\n\t\n\t// 不自动跟随重定向\n\tclient.CheckRedirect = func(req *http.Request, via []*http.Request) error {\n\t\treturn http.ErrUseLastResponse\n\t}\n\t\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\t// 如果主域名失败，尝试切换到备用域名\n\t\tif p.currentBaseURL == PrimaryBaseURL {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Panwiki] 主域名请求失败，尝试备用域名: %v\", err)\n\t\t\t}\n\t\t\tp.switchToBackupDomain()\n\t\t\t\n\t\t\t// 重新构建URL并重试\n\t\t\tinitialURL = p.getSearchURL(keyword, page)\n\t\t\treq, err = http.NewRequest(\"GET\", initialURL, nil)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"创建备用域名请求失败: %w\", err)\n\t\t\t}\n\t\t\tp.setRequestHeaders(req)\n\t\t\t\n\t\t\tresp, err = client.Do(req)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"备用域名请求也失败: %w\", err)\n\t\t\t}\n\t\t} else {\n\t\t\treturn nil, fmt.Errorf(\"初始请求失败: %w\", err)\n\t\t}\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 重置重定向策略\n\tclient.CheckRedirect = nil\n\t\n\t// 获取重定向URL\n\tlocation := resp.Header.Get(\"Location\")\n\tif location == \"\" {\n\t\treturn nil, fmt.Errorf(\"未获取到重定向URL\")\n\t}\n\t\n\t// 构建完整的重定向URL\n\tvar searchURL string\n\tif strings.HasPrefix(location, \"http\") {\n\t\tsearchURL = location\n\t} else {\n\t\tsearchURL = p.currentBaseURL + \"/\" + strings.TrimPrefix(location, \"/\")\n\t}\n\t\n\t// 如果不是第一页，修改URL中的page参数\n\tif page > 1 {\n\t\tif strings.Contains(searchURL, \"searchid=\") {\n\t\t\t// 提取searchid并构建分页URL\n\t\t\tre := regexp.MustCompile(`searchid=(\\d+)`)\n\t\t\tmatches := re.FindStringSubmatch(searchURL)\n\t\t\tif len(matches) > 1 {\n\t\t\t\tsearchid := matches[1]\n\t\t\t\tsearchURL = fmt.Sprintf(\"%s/search.php?mod=forum&searchid=%s&orderby=lastpost&ascdesc=desc&searchsubmit=yes&page=%d\", p.currentBaseURL, searchid, page)\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// Step 2: 请求实际的搜索结果页面\n\treq2, err := http.NewRequest(\"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建搜索请求失败: %w\", err)\n\t}\n\t\n\tp.setRequestHeaders(req2)\n\t\n\tresp2, err := client.Do(req2)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"搜索请求失败: %w\", err)\n\t}\n\tdefer resp2.Body.Close()\n\t\n\tif resp2.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"搜索请求返回状态码: %d\", resp2.StatusCode)\n\t}\n\t\n\t// 解析搜索结果\n\tdoc, err := goquery.NewDocumentFromReader(resp2.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"解析HTML失败: %w\", err)\n\t}\n\t\n\treturn p.extractSearchResults(doc), nil\n}\n\n// setRequestHeaders 设置请求头\nfunc (p *PanwikiPlugin) setRequestHeaders(req *http.Request) {\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Referer\", p.currentBaseURL+\"/\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Cache-Control\", \"no-cache\")\n\treq.Header.Set(\"Pragma\", \"no-cache\")\n}\n\n// extractSearchResults 提取搜索结果\nfunc (p *PanwikiPlugin) extractSearchResults(doc *goquery.Document) []model.SearchResult {\n\tvar results []model.SearchResult\n\t\n\tdoc.Find(\".slst ul li.pbw\").Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchResult(s)\n\t\tif result.Title != \"\" {\n\t\t\tresults = append(results, result)\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Panwiki] 解析到结果 #%d: 标题=%s\", i+1, result.Title)\n\t\t\t}\n\t\t} else {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Panwiki] 第%d项解析失败，标题为空\", i+1)\n\t\t\t}\n\t\t}\n\t})\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 共解析出 %d 个有效搜索结果\", len(results))\n\t}\n\t\n\treturn results\n}\n\n// parseSearchResult 解析单个搜索结果\nfunc (p *PanwikiPlugin) parseSearchResult(s *goquery.Selection) model.SearchResult {\n\t// 提取标题和详情页链接\n\ttitleLink := s.Find(\"h3.xs3 a\").First()\n\ttitle := p.cleanTitle(titleLink.Text())\n\tdetailPath, _ := titleLink.Attr(\"href\")\n\t\n\tvar detailURL string\n\tif detailPath != \"\" {\n\t\tif strings.HasPrefix(detailPath, \"http\") {\n\t\t\tdetailURL = detailPath\n\t\t} else {\n\t\t\tdetailURL = p.currentBaseURL + \"/\" + strings.TrimPrefix(detailPath, \"/\")\n\t\t}\n\t}\n\t\n\t// 提取内容摘要\n\tvar content string\n\ts.Find(\"p\").Each(func(i int, p *goquery.Selection) {\n\t\tif i == 1 { // 第二个p标签通常包含内容摘要\n\t\t\tcontent = strings.TrimSpace(p.Text())\n\t\t}\n\t})\n\t\n\t// 提取统计信息（回复数和查看数）\n\tstatsText := s.Find(\"p.xg1\").First().Text()\n\tvar replyCount, viewCount int\n\tparseStats(statsText, &replyCount, &viewCount)\n\t\n\t// 提取时间、作者、分类信息\n\tvar publishTime, author, category string\n\tlastP := s.Find(\"p\").Last()\n\tspans := lastP.Find(\"span\")\n\tif spans.Length() >= 3 {\n\t\tpublishTime = strings.TrimSpace(spans.Eq(0).Text())\n\t\tauthor = strings.TrimSpace(spans.Eq(1).Find(\"a\").Text())\n\t\tcategory = strings.TrimSpace(spans.Eq(2).Find(\"a\").Text())\n\t}\n\t\n\t// 转换时间格式\n\tparsedTime := parseTime(publishTime)\n\t\n\t// 将详情页URL、作者、分类等信息包含在Content中\n\tenrichedContent := content\n\tif author != \"\" || category != \"\" {\n\t\tenrichedContent = fmt.Sprintf(\"%s | 作者: %s | 分类: %s | 详情: %s\", content, author, category, detailURL)\n\t} else if detailURL != \"\" {\n\t\tenrichedContent = fmt.Sprintf(\"%s | 详情: %s\", content, detailURL)\n\t}\n\t\n\t// 从详情页URL中提取帖子ID\n\tvar postID string\n\tif detailURL != \"\" {\n\t\tre := regexp.MustCompile(`tid=(\\d+)`)\n\t\tmatches := re.FindStringSubmatch(detailURL)\n\t\tif len(matches) > 1 {\n\t\t\tpostID = matches[1]\n\t\t}\n\t}\n\t\n\t// 如果没有找到帖子ID，使用时间戳\n\tif postID == \"\" {\n\t\tpostID = fmt.Sprintf(\"%d\", time.Now().UnixNano())\n\t}\n\t\n\treturn model.SearchResult{\n\t\tMessageID: fmt.Sprintf(\"%s-%s\", p.Name(), postID),\n\t\tUniqueID:  fmt.Sprintf(\"%s-%s\", p.Name(), postID),\n\t\tTitle:     title,\n\t\tContent:   enrichedContent,\n\t\tDatetime:  parsedTime,\n\t\tLinks:     []model.Link{}, // 初始为空，后续从详情页获取\n\t\tChannel:   \"\",\n\t}\n}\n\n// cleanTitle 清理标题中的广告内容\nfunc (p *PanwikiPlugin) cleanTitle(title string) string {\n\ttitle = strings.TrimSpace(title)\n\t\n\t// 移除【】和[]中的广告内容（保留有用的分类信息）\n\t// 只移除明显的广告，保留如【国漫】这样的分类标签\n\tadPatterns := []string{\n\t\t`【[^】]*(?:论坛|网站|\\.com|\\.net|\\.cn)[^】]*】`,\n\t\t`\\[[^\\]]*(?:论坛|网站|\\.com|\\.net|\\.cn)[^\\]]*\\]`,\n\t}\n\t\n\tfor _, pattern := range adPatterns {\n\t\tre := regexp.MustCompile(pattern)\n\t\ttitle = re.ReplaceAllString(title, \"\")\n\t}\n\t\n\treturn strings.TrimSpace(title)\n}\n\n// enrichWithDetailLinks 并发获取详情页链接\nfunc (p *PanwikiPlugin) enrichWithDetailLinks(client *http.Client, results []model.SearchResult, keyword string) {\n\tif len(results) == 0 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 没有结果需要获取详情页链接\")\n\t\t}\n\t\treturn\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 开始为 %d 个结果获取详情页链接\", len(results))\n\t}\n\t\n\tvar wg sync.WaitGroup\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\n\tfor i := range results {\n\t\twg.Add(1)\n\t\tgo func(index int) {\n\t\t\tdefer wg.Done()\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 添加延时避免请求过快\n\t\t\ttime.Sleep(time.Duration(index%3) * 50 * time.Millisecond)\n\t\t\t\n\t\t\t// 从Content中提取详情页URL\n\t\t\tdetailURL := p.extractDetailURLFromContent(results[index].Content)\n\t\t\tif detailURL != \"\" {\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[Panwiki] 结果#%d 提取到详情页URL: %s\", index+1, detailURL)\n\t\t\t\t}\n\t\t\t\tlinks := p.fetchDetailPageLinksWithKeyword(client, detailURL, keyword)\n\t\t\t\tif len(links) > 0 {\n\t\t\t\t\tresults[index].Links = append(results[index].Links, links...)\n\t\t\t\t\tif p.debugMode {\n\t\t\t\t\t\tlog.Printf(\"[Panwiki] 结果#%d 从详情页获取到 %d 个链接\", index+1, len(links))\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif p.debugMode {\n\t\t\t\t\t\tlog.Printf(\"[Panwiki] 结果#%d 详情页未获取到有效链接\", index+1)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[Panwiki] 结果#%d 未找到详情页URL\", index+1)\n\t\t\t\t}\n\t\t\t}\n\t\t}(i)\n\t}\n\t\n\twg.Wait()\n\t\n\tif p.debugMode {\n\t\ttotalLinks := 0\n\t\tfor i, result := range results {\n\t\t\ttotalLinks += len(result.Links)\n\t\t\tlog.Printf(\"[Panwiki] 结果#%d 最终链接数: %d\", i+1, len(result.Links))\n\t\t}\n\t\tlog.Printf(\"[Panwiki] 详情页链接获取完成，总计获得 %d 个链接\", totalLinks)\n\t}\n}\n\n// fetchDetailPageLinksWithKeyword 获取详情页中的网盘链接（带关键词过滤）\nfunc (p *PanwikiPlugin) fetchDetailPageLinksWithKeyword(client *http.Client, detailURL string, keyword string) []model.Link {\n\tif detailURL == \"\" {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 详情页URL为空，跳过获取链接\")\n\t\t}\n\t\treturn []model.Link{}\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 开始获取详情页链接: %s\", detailURL)\n\t}\n\t\n\t// 检查缓存\n\tif cached, ok := p.detailCache.Load(detailURL); ok {\n\t\tif cacheItem, ok := cached.(cacheItem); ok {\n\t\t\tif time.Since(cacheItem.timestamp) < p.cacheTTL {\n\t\t\t\treturn cacheItem.links\n\t\t\t}\n\t\t}\n\t}\n\t\n\treq, err := http.NewRequest(\"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn []model.Link{}\n\t}\n\t\n\tp.setRequestHeaders(req)\n\t\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn []model.Link{}\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn []model.Link{}\n\t}\n\t\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 解析详情页HTML失败: %v\", err)\n\t\t}\n\t\treturn []model.Link{}\n\t}\n\t\n\tlinks := p.extractDetailPageLinksWithFilter(doc, keyword)\n\t\n\t// 缓存结果\n\tp.detailCache.Store(detailURL, cacheItem{\n\t\tlinks:     links,\n\t\ttimestamp: time.Now(),\n\t})\n\t\n\treturn links\n}\n\n// extractDetailPageLinksWithFilter 智能过滤版的详情页链接提取\nfunc (p *PanwikiPlugin) extractDetailPageLinksWithFilter(doc *goquery.Document, keyword string) []model.Link {\n\tvar allLinks []model.Link\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] ==================== 开始智能过滤详情页链接 ====================\")\n\t\tlog.Printf(\"[Panwiki] 关键词: %s\", keyword)\n\t}\n\t\n\t// 查找主要内容区域\n\tcontentArea := doc.Find(\".t_f[id^=\\\"postmessage_\\\"]\").First()\n\tif contentArea.Length() == 0 {\n\t\tcontentArea = doc.Find(\".t_msgfont, .plhin, .message, [id^='postmessage_']\")\n\t}\n\t\n\tif contentArea.Length() == 0 {\n\t\treturn allLinks\n\t}\n\t\n\t// 先直接提取所有链接，看有多少个\n\tallFoundLinks := p.extractAllLinksDirectly(contentArea)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 提取到链接总数: %d\", len(allFoundLinks))\n\t}\n\t\n\t// 核心策略：4个或以下链接直接返回，超过4个才进行内容匹配\n\tif len(allFoundLinks) <= 4 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 链接数≤4，直接返回（帖子标题就是资源标题）\")\n\t\t}\n\t\treturn allFoundLinks\n\t}\n\t\n\t// 超过4个链接，需要精确匹配\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 链接数>4，需要精确匹配\")\n\t}\n\t\n\t// 获取HTML内容进行分析\n\thtmlContent, _ := contentArea.Html()\n\tlines := strings.Split(htmlContent, \"\\n\")\n\t\n\t// 检查是否是单行格式\n\tif p.isSingleLineFormat(lines, keyword) {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 检测到单行格式，使用精确匹配\")\n\t\t}\n\t\treturn p.extractLinksFromSingleLineFormat(lines, keyword)\n\t}\n\t\n\t// 非单行格式，使用分组逻辑\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 非单行格式，使用分组逻辑\")\n\t}\n\treturn p.extractLinksWithGrouping(htmlContent, keyword)\n}\n\n// filterLinksByContext 基于内容上下文过滤链接\nfunc (p *PanwikiPlugin) filterLinksByContext(links []model.Link, htmlContent, keyword string) []model.Link {\n\tif len(links) == 0 {\n\t\treturn links\n\t}\n\t\n\tvar filtered []model.Link\n\tcleanContent := p.cleanHtmlText(htmlContent)\n\tlines := strings.Split(cleanContent, \"\\n\")\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 开始上下文过滤，输入链接数: %d\", len(links))\n\t}\n\t\n\tfor _, link := range links {\n\t\t// 查找链接在内容中的位置\n\t\tworkName := \"\"\n\t\tfor _, line := range lines {\n\t\t\tif strings.Contains(line, link.URL) {\n\t\t\t\t// 提取这个链接对应的作品名\n\t\t\t\tworkName = p.extractWorkNameForLinkInLine(line, link.URL)\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[Panwiki] 链接 %s 对应作品: '%s'\", link.URL, workName)\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 检查作品名是否与关键词相关\n\t\tif workName != \"\" && p.isWorkTitleRelevant(workName, keyword) {\n\t\t\tfiltered = append(filtered, link)\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Panwiki] ✅ 保留相关链接: %s\", link.URL)\n\t\t\t}\n\t\t} else if p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] ❌ 过滤不相关链接: %s\", link.URL)\n\t\t}\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 上下文过滤完成，输出链接数: %d\", len(filtered))\n\t}\n\t\n\treturn filtered\n}\n\n// extractWorkNameForLinkInLine 从行中提取链接对应的作品名\nfunc (p *PanwikiPlugin) extractWorkNameForLinkInLine(line, url string) string {\n\t// 处理单行格式：作品名丨网盘：链接\n\tpattern := regexp.MustCompile(`([^丨]+)丨[^：]+：` + regexp.QuoteMeta(url))\n\tmatches := pattern.FindStringSubmatch(line)\n\tif len(matches) > 1 {\n\t\treturn strings.TrimSpace(matches[1])\n\t}\n\t\n\t// 处理合集格式\n\tif strings.Contains(line, \"合集：\") && strings.Contains(line, url) {\n\t\tparts := strings.Split(line, \"：\")\n\t\tif len(parts) > 0 {\n\t\t\treturn strings.TrimSpace(parts[0])\n\t\t}\n\t}\n\t\n\treturn \"\"\n}\n\n// isSimpleCase 检查是否是简单情况（单一内容，无需分组）\nfunc (p *PanwikiPlugin) isSimpleCase(htmlContent, keyword string) bool {\n\tlines := strings.Split(htmlContent, \"\\n\")\n\t\n\t// 如果是单行格式，不应该作为简单情况处理\n\tif p.isSingleLineFormat(lines, keyword) {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 检测到单行格式，不作为简单情况处理\")\n\t\t}\n\t\treturn false\n\t}\n\t\n\tvar titleCount int\n\tvar linkCount int\n\tvar hasRelevantTitle bool\n\tvar hasRelevantContent bool\n\t\n\t// 检查整个页面内容是否与关键词相关\n\thasRelevantContent = p.pageContentRelevant(htmlContent, keyword)\n\t\n\tfor _, line := range lines {\n\t\tcleanLine := p.cleanHtmlText(line)\n\t\tif len(strings.TrimSpace(cleanLine)) < 5 {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\tif p.isNewWorkTitle(cleanLine) {\n\t\t\ttitleCount++\n\t\t\tif p.isWorkTitleRelevant(cleanLine, keyword) {\n\t\t\t\thasRelevantTitle = true\n\t\t\t}\n\t\t}\n\t\t\n\t\tif strings.Contains(line, \"http\") && p.containsNetworkLink(line) {\n\t\t\tlinkCount++\n\t\t}\n\t}\n\t\n\t// 简单情况的判断条件：\n\t// 大多数帖子都是简单情况（帖子标题已包含关键词，内容只有链接）\n\t// 1. 标题数不多（<=2），或者\n\t// 2. 只有少量链接（<=3）且没有多个标题\n\t// 注：搜索结果本身就是相关的，不需要再次严格过滤\n\tisSimple := titleCount <= 2 || (linkCount <= 3 && titleCount <= 1)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 简单情况判断: 标题数=%d, 链接数=%d, 有相关标题=%v, 内容相关=%v, 结果=%v\", \n\t\t\ttitleCount, linkCount, hasRelevantTitle, hasRelevantContent, isSimple)\n\t}\n\t\n\treturn isSimple\n}\n\n// pageContentRelevant 检查页面整体内容是否与关键词相关\nfunc (p *PanwikiPlugin) pageContentRelevant(htmlContent, keyword string) bool {\n\ttext := p.cleanHtmlText(htmlContent)\n\tnormalizedText := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(text, \" \", \"\"), \".\", \"\"))\n\tnormalizedKeyword := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(keyword, \" \", \"\"), \".\", \"\"))\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 内容相关性检查 - 原文本长度: %d\", len(text))\n\t\tif len(text) < 300 {\n\t\t\tlog.Printf(\"[Panwiki] 原文本: %s\", text)\n\t\t}\n\t\tlog.Printf(\"[Panwiki] 标准化文本: %s\", normalizedText)\n\t\tlog.Printf(\"[Panwiki] 标准化关键词: %s\", normalizedKeyword)\n\t}\n\t\n\t// 基本匹配\n\tbasicMatch := strings.Contains(normalizedText, normalizedKeyword)\n\t\n\t// 对于\"凡人修仙传\"这样的关键词，还要检查分词匹配\n\tkeywordMatch := false\n\tif keyword == \"凡人修仙传\" {\n\t\t// 检查各种可能的写法\n\t\tvariants := []string{\n\t\t\t\"凡人修仙传\", \"凡.人.修.仙.传\", \"凡人修仙\", \"修仙传\",\n\t\t\t\"fanrenxiuxianchuan\", \"fanren\", \"xiuxian\",\n\t\t}\n\t\t\n\t\tfor _, variant := range variants {\n\t\t\tnormalizedVariant := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(variant, \" \", \"\"), \".\", \"\"))\n\t\t\tif strings.Contains(normalizedText, normalizedVariant) {\n\t\t\t\tkeywordMatch = true\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[Panwiki] 匹配到变体: %s\", variant)\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t\n\tresult := basicMatch || keywordMatch\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 内容相关性结果: 基本匹配=%v, 关键词匹配=%v, 最终结果=%v\", basicMatch, keywordMatch, result)\n\t}\n\t\n\treturn result\n}\n\n// extractAllLinksDirectly 直接提取所有网盘链接（简单情况）\nfunc (p *PanwikiPlugin) extractAllLinksDirectly(contentArea *goquery.Selection) []model.Link {\n\tvar links []model.Link\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 开始直接提取链接（简单情况）\")\n\t}\n\t\n\t// 提取直接的链接\n\tcontentArea.Find(\"a\").Each(func(i int, s *goquery.Selection) {\n\t\thref, exists := s.Attr(\"href\")\n\t\tif !exists {\n\t\t\treturn\n\t\t}\n\t\t\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 找到a标签链接: %s\", href)\n\t\t}\n\t\t\n\t\tlinkType := p.determineLinkType(href)\n\t\tif linkType != \"\" {\n\t\t\t// 从内容文本中查找对应的密码\n\t\t\tpassword := p.extractPasswordFromContent(contentArea.Text(), href)\n\t\t\tlinks = append(links, model.Link{\n\t\t\t\tURL:      href,\n\t\t\t\tType:     linkType,\n\t\t\t\tPassword: password,\n\t\t\t})\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Panwiki] 识别为网盘链接: %s (类型: %s)\", href, linkType)\n\t\t\t}\n\t\t} else if p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 不是支持的网盘链接: %s\", href)\n\t\t}\n\t})\n\t\n\t// 提取文本中的链接\n\tcontentText := contentArea.Text()\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 内容文本长度: %d\", len(contentText))\n\t\tif len(contentText) < 500 {\n\t\t\tlog.Printf(\"[Panwiki] 内容文本: %s\", contentText)\n\t\t}\n\t}\n\t\n\ttextLinks := p.extractLinksFromText(contentText)\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 从文本提取到 %d 个链接\", len(textLinks))\n\t}\n\tlinks = append(links, textLinks...)\n\t\n\tdeduplicatedLinks := p.deduplicateLinks(links)\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 直接提取完成: 原始 %d 个, 去重后 %d 个\", len(links), len(deduplicatedLinks))\n\t}\n\t\n\treturn deduplicatedLinks\n}\n\n// extractLinksWithGrouping 使用分组逻辑提取链接（复杂情况）\nfunc (p *PanwikiPlugin) extractLinksWithGrouping(htmlContent, keyword string) []model.Link {\n\tvar allLinks []model.Link\n\t\n\t// 按行分割并分组处理\n\tlines := strings.Split(htmlContent, \"\\n\")\n\t\n\t// 使用传统的分组逻辑\n\t// 注意：单行格式已经在extractDetailPageLinksWithFilter中优先处理了\n\tvar currentGroup []string\n\tvar isRelevantGroup bool\n\t\n\tfor _, line := range lines {\n\t\tcleanLine := p.cleanHtmlText(line)\n\t\t\n\t\t// 跳过空行和无意义内容\n\t\tif len(strings.TrimSpace(cleanLine)) < 5 {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 检查是否是新的作品标题行\n\t\tisTitle := p.isNewWorkTitle(cleanLine)\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 检查标题: '%s' -> 是否为标题: %v\", cleanLine, isTitle)\n\t\t}\n\t\tif isTitle {\n\t\t\t// 处理之前的组\n\t\t\tif len(currentGroup) > 0 && isRelevantGroup {\n\t\t\t\tgroupLinks := p.extractLinksFromGroup(currentGroup)\n\t\t\t\tallLinks = append(allLinks, groupLinks...)\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[Panwiki] 从相关组提取到 %d 个链接\", len(groupLinks))\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 开始新组\n\t\t\tcurrentGroup = []string{line}\n\t\t\tisRelevantGroup = p.isWorkTitleRelevant(cleanLine, keyword)\n\t\t\t\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Panwiki] 新作品组: %s, 相关性: %v, 关键词: %s\", cleanLine, isRelevantGroup, keyword)\n\t\t\t}\n\t\t} else {\n\t\t\t// 添加到当前组\n\t\t\tif len(currentGroup) > 0 {\n\t\t\t\tcurrentGroup = append(currentGroup, line)\n\t\t\t\tif p.debugMode && strings.Contains(line, \"http\") {\n\t\t\t\t\tlog.Printf(\"[Panwiki] 添加链接行到当前组: %s\", cleanLine)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 处理最后一组\n\tif len(currentGroup) > 0 && isRelevantGroup {\n\t\tgroupLinks := p.extractLinksFromGroup(currentGroup)\n\t\tallLinks = append(allLinks, groupLinks...)\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 从最后相关组提取到 %d 个链接\", len(groupLinks))\n\t\t}\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 分组过滤完成，共提取 %d 个相关链接\", len(allLinks))\n\t}\n\t\n\treturn p.deduplicateLinks(allLinks)\n}\n\n// isSingleLineFormat 检查是否是\"作品名丨网盘：链接\"的单行格式\nfunc (p *PanwikiPlugin) isSingleLineFormat(lines []string, keyword string) bool {\n\tvar validLineCount int\n\tvar matchingLineCount int\n\t\n\t// 检查有多少行符合\"作品名丨网盘：链接\"或\"作品名：子标题丨网盘：链接\"格式\n\t// 支持两种格式：\n\t// 1. \"斗破苍穹年番丨夸克：https://...\"\n\t// 2. \"凡人修仙传：再临天南丨夸克：https://...\"\n\tsingleLinePattern := regexp.MustCompile(`[^丨]*丨[^：]*：https?://[^\\s]+`)\n\t\n\tfor _, line := range lines {\n\t\tcleanLine := p.cleanHtmlText(line)\n\t\tif len(strings.TrimSpace(cleanLine)) < 10 {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 检查是否符合单行格式\n\t\tif singleLinePattern.MatchString(cleanLine) {\n\t\t\tvalidLineCount++\n\t\t\t\n\t\t\t// 检查是否与关键词相关\n\t\t\tif p.isLineTitleRelevant(cleanLine, keyword) {\n\t\t\t\tmatchingLineCount++\n\t\t\t}\n\t\t\t\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Panwiki] 单行格式检查: '%s', 相关性: %v\", cleanLine, p.isLineTitleRelevant(cleanLine, keyword))\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 如果有至少2行符合单行格式，且有匹配的行，就认为是单行格式\n\tisMatch := validLineCount >= 2 && matchingLineCount > 0\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 单行格式判断: 有效行=%d, 匹配行=%d, 结果=%v\", validLineCount, matchingLineCount, isMatch)\n\t}\n\t\n\treturn isMatch\n}\n\n// extractLinksFromSingleLineFormat 从单行格式中提取链接\nfunc (p *PanwikiPlugin) extractLinksFromSingleLineFormat(lines []string, keyword string) []model.Link {\n\tvar allLinks []model.Link\n\t\n\tfor _, line := range lines {\n\t\tcleanLine := p.cleanHtmlText(line)\n\t\tif len(strings.TrimSpace(cleanLine)) < 10 {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 检查是否包含\"丨\"和\"：\"的单行格式\n\t\tif strings.Contains(cleanLine, \"丨\") && strings.Contains(cleanLine, \"：\") {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Panwiki] 处理单行格式: %s\", cleanLine)\n\t\t\t}\n\t\t\t\n\t\t\t// 精确提取相关作品的链接\n\t\t\trelevantLinks := p.extractLinksFromSingleLine(cleanLine, keyword)\n\t\t\tallLinks = append(allLinks, relevantLinks...)\n\t\t}\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 单行格式处理完成，共提取 %d 个链接\", len(allLinks))\n\t}\n\t\n\treturn p.deduplicateLinks(allLinks)\n}\n\n// extractLinksFromSingleLine 从单行中提取\"作品名丨网盘：链接\"格式的相关链接\nfunc (p *PanwikiPlugin) extractLinksFromSingleLine(line, keyword string) []model.Link {\n\tvar results []model.Link\n\t\n\t// 使用正则表达式匹配 \"作品名丨网盘：链接\" 的完整模式\n\tpattern := regexp.MustCompile(`([^丨]+)丨([^：]+)：(https?://[a-zA-Z0-9\\.\\-\\_\\?\\=\\&\\/]+)`)\n\tmatches := pattern.FindAllStringSubmatch(line, -1)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 单行匹配到 %d 个模式\", len(matches))\n\t}\n\t\n\tfor _, match := range matches {\n\t\tif len(match) >= 4 {\n\t\t\tworkName := strings.TrimSpace(match[1])\n\t\t\tnetdisk := strings.TrimSpace(match[2])\n\t\t\turl := strings.TrimSpace(match[3])\n\t\t\t\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Panwiki] 作品: '%s', 网盘: '%s', 链接: '%s'\", workName, netdisk, url)\n\t\t\t}\n\t\t\t\n\t\t\tif p.isWorkTitleRelevant(workName, keyword) {\n\t\t\t\tlinkType := p.determineLinkType(url)\n\t\t\t\tif linkType != \"\" {\n\t\t\t\t\t_, password := p.extractPasswordFromURL(url)\n\t\t\t\t\t\n\t\t\t\t\tresults = append(results, model.Link{\n\t\t\t\t\t\tURL:      url,\n\t\t\t\t\t\tType:     linkType,\n\t\t\t\t\t\tPassword: password,\n\t\t\t\t\t})\n\t\t\t\t\t\n\t\t\t\t\tif p.debugMode {\n\t\t\t\t\t\tlog.Printf(\"[Panwiki] ✅ 相关作品链接: %s -> %s\", workName, url)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if p.debugMode {\n\t\t\t\tlog.Printf(\"[Panwiki] ❌ 不相关作品: %s\", workName)\n\t\t\t}\n\t\t}\n\t}\n\t\n\treturn results\n}\n\n// isLineTitleRelevant 检查单行中的标题是否与关键词相关\nfunc (p *PanwikiPlugin) isLineTitleRelevant(line, keyword string) bool {\n\t// 改进版：处理一行多个作品的情况\n\t// 使用正则表达式找到所有的\"作品名丨网盘：\"模式\n\tworkPattern := regexp.MustCompile(`([^丨]+)丨[^：]+：`)\n\tmatches := workPattern.FindAllStringSubmatch(line, -1)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 单行标题相关性检查: 原行='%s', 关键词='%s'\", line, keyword)\n\t}\n\t\n\tfor _, match := range matches {\n\t\tif len(match) > 1 {\n\t\t\tworkTitle := strings.TrimSpace(match[1])\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Panwiki] 检查作品标题: '%s'\", workTitle)\n\t\t\t}\n\t\t\tif p.isWorkTitleRelevant(workTitle, keyword) {\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[Panwiki] ✅ 找到相关作品: '%s'\", workTitle)\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 单行标题相关性结果: false\")\n\t}\n\t\n\treturn false\n}\n\n// containsNetworkLink 检查是否包含网盘链接\nfunc (p *PanwikiPlugin) containsNetworkLink(text string) bool {\n\tnetworkDomains := []string{\n\t\t\"pan.quark.cn\", \"pan.baidu.com\", \"www.alipan.com\", \"caiyun.139.com\",\n\t\t\"pan.xunlei.com\", \"drive.uc.cn\", \"www.123684.com\", \"115cdn.com\",\n\t\t\"cloud.189.cn\", \"pan.uc.cn\", \"www.123pan.com\", \"pan.pikpak.com\",\n\t}\n\t\n\tfor _, domain := range networkDomains {\n\t\tif strings.Contains(text, domain) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// cleanHtmlText 清理HTML文本\nfunc (p *PanwikiPlugin) cleanHtmlText(html string) string {\n\t// 移除HTML标签\n\tre := regexp.MustCompile(`<[^>]*>`)\n\ttext := re.ReplaceAllString(html, \"\")\n\t// 清理HTML实体\n\ttext = strings.ReplaceAll(text, \"&nbsp;\", \" \")\n\ttext = strings.ReplaceAll(text, \"&amp;\", \"&\")\n\treturn strings.TrimSpace(text)\n}\n\n// isNewWorkTitle 检查是否是新作品标题\nfunc (p *PanwikiPlugin) isNewWorkTitle(text string) bool {\n\ttext = strings.TrimSpace(text)\n\t\n\t// 如果文本太短，不太可能是标题\n\tif len(text) < 3 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 标题检查 '%s': 太短，不是标题\", text)\n\t\t}\n\t\treturn false\n\t}\n\t\n\t// 1. 包含年份 (2025)\n\tif matched, _ := regexp.MatchString(`\\(\\d{4}\\)`, text); matched {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 标题检查 '%s': 匹配年份格式\", text)\n\t\t}\n\t\treturn true\n\t}\n\t\n\t// 2. 包含分类标签 [剧情]、[古装]等 或 【作品名】格式\n\tif matched, _ := regexp.MatchString(`\\[[^\\]]*\\]|【[^\\]]*】`, text); matched {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 标题检查 '%s': 匹配标签格式\", text)\n\t\t}\n\t\treturn true\n\t}\n\t\n\t// 3. 包含明显的作品信息  \n\tindicators := []string{\n\t\t\"4K持续更新\", \"集完结\", \"完结\", \"4K高码\", \"持续更新\",\n\t\t\"全集\", \"集】\", \"更新\", \"剧版\", \"真人版\", \"动画版\",\n\t}\n\tfor _, indicator := range indicators {\n\t\tif strings.Contains(text, indicator) {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Panwiki] 标题检查 '%s': 匹配指示词 '%s'\", text, indicator)\n\t\t\t}\n\t\t\treturn true\n\t\t}\n\t}\n\t\n\t// 4. 检查集数格式：【全30集】、【40全】、[全36集]等\n\tif matched, _ := regexp.MatchString(`【[全\\d]+[集\\d]*】|【\\d+[全集]】|\\[\\d+[全集]\\]|【完结】`, text); matched {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 标题检查 '%s': 匹配集数格式\", text)\n\t\t}\n\t\treturn true\n\t}\n\t\n\t// 排除明显不是标题的内容\n\tnonTitlePrefixes := []string{\n\t\t\"导演:\", \"编剧:\", \"主演:\", \"类型:\", \"制片国家\", \"语言:\", \"首播:\", \n\t\t\"集数:\", \"单集片长:\", \"评分:\", \"简介:\", \"链接：\", \"链接:\",\n\t\t\"夸克网盘：\", \"百度网盘：\", \"阿里云盘：\", \"迅雷网盘：\",\n\t}\n\tfor _, prefix := range nonTitlePrefixes {\n\t\tif strings.HasPrefix(text, prefix) {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Panwiki] 标题检查 '%s': 排除非标题内容\", text)\n\t\t\t}\n\t\t\treturn false\n\t\t}\n\t}\n\t\n\t// 5. 检查是否是常见作品名称格式（仅包含中文、英文、数字、少量符号）\n\t// 且不包含HTML标记或URL\n\tif !strings.Contains(text, \"http\") && !strings.Contains(text, \"<\") && !strings.Contains(text, \">\") {\n\t\t// 优先检查短标题（3-6个字符，如\"定风波\"、\"锦月如歌\"）\n\t\truneText := []rune(text)\n\t\ttextLength := len(runeText)\n\t\t\n\t\tif textLength >= 3 && textLength <= 6 {\n\t\t\t// 短标题：主要是中文字符\n\t\t\tchineseCount := 0\n\t\t\tfor _, r := range runeText {\n\t\t\t\tif r >= 0x4e00 && r <= 0x9fff {\n\t\t\t\t\tchineseCount++\n\t\t\t\t}\n\t\t\t}\n\t\t\tchineseRatio := float64(chineseCount) / float64(textLength)\n\t\t\t\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Panwiki] 标题检查 '%s': 短标题检查 - 长度=%d, 中文字符数=%d, 中文比例=%.1f%%\", text, textLength, chineseCount, chineseRatio*100)\n\t\t\t}\n\t\t\t\n\t\t\t// 如果主要是中文字符，认为是短标题\n\t\t\tif chineseRatio >= 0.8 { // 至少80%是中文\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[Panwiki] 标题检查 '%s': 匹配短中文标题\", text)\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 检查是否包含常见的作品名称特征\n\t\tif matched, _ := regexp.MatchString(`^[A-Za-z]*[^\\s]*(?:传|剧|版|之|的|与|和|：|丨|\\s)+`, text); matched {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Panwiki] 标题检查 '%s': 匹配作品名称特征\", text)\n\t\t\t}\n\t\t\treturn true\n\t\t}\n\t\t\n\t\t// 长标题检查（7-50个字符）\n\t\tif textLength >= 7 && textLength <= 50 {\n\t\t\tif matched, _ := regexp.MatchString(`^[\\u4e00-\\u9fff\\w\\s\\-\\(\\)（）]+$`, text); matched {\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[Panwiki] 标题检查 '%s': 匹配长标题\", text)\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 标题检查 '%s': 不符合任何标题规则\", text)\n\t}\n\treturn false\n}\n\n// isWorkTitleRelevant 检查作品标题是否与关键词相关\nfunc (p *PanwikiPlugin) isWorkTitleRelevant(title, keyword string) bool {\n\t// 标准化 - 移除空格和点号\n\tnormalizedTitle := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(title, \" \", \"\"), \".\", \"\"))\n\tnormalizedKeyword := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(keyword, \" \", \"\"), \".\", \"\"))\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 相关性检查 - 原标题: %s, 原关键词: %s\", title, keyword)\n\t\tlog.Printf(\"[Panwiki] 相关性检查 - 标准化标题: %s, 标准化关键词: %s\", normalizedTitle, normalizedKeyword)\n\t}\n\t\n\t// 针对\"凡人修仙传\"的严格检查\n\tif normalizedKeyword == \"凡人修仙传\" {\n\t\t// 只有真正包含\"凡人修仙传\"相关内容的标题才算相关\n\t\trelevantPatterns := []string{\n\t\t\t\"凡人修仙传\", \"凡.人.修.仙.传\", \"凡人修仙\", \"修仙传\",\n\t\t\t\"fanrenxiuxianchuan\", \"fanren\", \"xiuxian\",\n\t\t}\n\t\t\n\t\tfor _, pattern := range relevantPatterns {\n\t\t\tnormalizedPattern := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(pattern, \" \", \"\"), \".\", \"\"))\n\t\t\tif strings.Contains(normalizedTitle, normalizedPattern) {\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[Panwiki] 匹配到相关模式: %s\", pattern)\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\t\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 凡人修仙传检查：不相关\")\n\t\t}\n\t\treturn false\n\t}\n\t\n\t// 对于其他关键词，进行精确匹配\n\tif strings.Contains(normalizedTitle, normalizedKeyword) {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Panwiki] 其他关键词精确匹配成功\")\n\t\t}\n\t\treturn true\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 不相关\")\n\t}\n\t\n\treturn false\n}\n\n// extractLinksFromGroup 从作品组中提取链接\nfunc (p *PanwikiPlugin) extractLinksFromGroup(group []string) []model.Link {\n\tvar links []model.Link\n\t\n\t// 将组合并成HTML文档进行解析\n\tgroupHTML := strings.Join(group, \"\\n\")\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(\"<div>\" + groupHTML + \"</div>\"))\n\tif err != nil {\n\t\treturn links\n\t}\n\t\n\t// 提取链接\n\tdoc.Find(\"a\").Each(func(i int, s *goquery.Selection) {\n\t\thref, exists := s.Attr(\"href\")\n\t\tif !exists {\n\t\t\treturn\n\t\t}\n\t\t\n\t\tlinkType := p.determineLinkType(href)\n\t\tif linkType != \"\" {\n\t\t\tlinks = append(links, model.Link{\n\t\t\t\tURL:      href,\n\t\t\t\tType:     linkType,\n\t\t\t\tPassword: \"\",\n\t\t\t})\n\t\t}\n\t})\n\t\n\t// 从文本中提取链接\n\ttext := doc.Text()\n\ttextLinks := p.extractLinksFromText(text)\n\tlinks = append(links, textLinks...)\n\t\n\treturn links\n}\n\n// determineLinkType 确定链接类型\nfunc (p *PanwikiPlugin) determineLinkType(url string) string {\n\tlinkPatterns := map[string]string{\n\t\t`pan\\.quark\\.cn`:          \"quark\",\n\t\t`pan\\.baidu\\.com`:         \"baidu\",\n\t\t`www\\.alipan\\.com`:        \"aliyun\",\n\t\t`pan\\.xunlei\\.com`:        \"xunlei\",\n\t\t`cloud\\.189\\.cn`:          \"tianyi\",\n\t\t`pan\\.uc\\.cn`:             \"uc\",\n\t\t`www\\.123pan\\.com`:        \"123\",\n\t\t`www\\.123684\\.com`:        \"123\",\n\t\t`115cdn\\.com`:             \"115\",\n\t\t`pan\\.pikpak\\.com`:        \"pikpak\",\n\t\t`caiyun\\.139\\.cn`:         \"mobile\",\n\t}\n\t\n\tfor pattern, linkType := range linkPatterns {\n\t\tmatched, _ := regexp.MatchString(pattern, url)\n\t\tif matched {\n\t\t\treturn linkType\n\t\t}\n\t}\n\t\n\treturn \"\"\n}\n\n// extractLinksFromText 从文本中提取链接\nfunc (p *PanwikiPlugin) extractLinksFromText(text string) []model.Link {\n\tvar links []model.Link\n\t\n\t// 网盘链接正则模式 (修复迅雷链接截断问题，添加下划线和连字符支持)\n\tpatterns := []string{\n\t\t`https://pan\\.quark\\.cn/s/[a-zA-Z0-9_-]+`,\n\t\t`https://pan\\.baidu\\.com/s/[a-zA-Z0-9_-]+`,\n\t\t`https://www\\.alipan\\.com/s/[a-zA-Z0-9_-]+`,\n\t\t`https://pan\\.xunlei\\.com/s/[a-zA-Z0-9_-]+`,  // 修复：添加下划线和连字符\n\t\t`https://cloud\\.189\\.cn/[a-zA-Z0-9_-]+`,\n\t\t`https://pan\\.uc\\.cn/s/[a-zA-Z0-9_-]+`,\n\t\t`https://www\\.123pan\\.com/s/[a-zA-Z0-9_-]+`,\n\t\t`https://www\\.123684\\.com/s/[a-zA-Z0-9_-]+`,\n\t\t`https://115cdn\\.com/s/[a-zA-Z0-9_-]+`,\n\t\t`https://pan\\.pikpak\\.com/s/[a-zA-Z0-9_-]+`,\n\t\t`https://caiyun\\.139\\.cn/s/[a-zA-Z0-9_-]+`,\n\t}\n\t\n\tfor _, pattern := range patterns {\n\t\tre := regexp.MustCompile(pattern)\n\t\tmatches := re.FindAllString(text, -1)\n\t\t\n\t\tfor _, match := range matches {\n\t\t\tlinkType := p.determineLinkType(match)\n\t\t\tif linkType != \"\" {\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tURL:      match,\n\t\t\t\t\tType:     linkType,\n\t\t\t\t\tPassword: \"\",\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\t\n\treturn links\n}\n\n// deduplicateLinks 智能去重链接（合并相同资源的不同版本）\nfunc (p *PanwikiPlugin) deduplicateLinks(links []model.Link) []model.Link {\n\tlinkMap := make(map[string]model.Link)\n\t\n\tfor _, link := range links {\n\t\t// 提取和设置密码\n\t\tnormalizedURL, password := p.extractPasswordFromURL(link.URL)\n\t\t\n\t\t// 创建带密码信息的新链接\n\t\tnewLink := model.Link{\n\t\t\tURL:      link.URL,\n\t\t\tType:     link.Type,\n\t\t\tPassword: password,\n\t\t}\n\t\t\n\t\t// 使用标准化URL作为key进行去重\n\t\tif existingLink, exists := linkMap[normalizedURL]; exists {\n\t\t\t// 如果已存在，保留更完整的版本（优先带密码的）\n\t\t\tif password != \"\" && existingLink.Password == \"\" {\n\t\t\t\tlinkMap[normalizedURL] = newLink\n\t\t\t} else if password == \"\" && existingLink.Password != \"\" {\n\t\t\t\t// 保持原有的（已有密码的版本）\n\t\t\t\tcontinue\n\t\t\t} else if len(link.URL) > len(existingLink.URL) {\n\t\t\t\t// 保留URL更长的版本（通常更完整）\n\t\t\t\tlinkMap[normalizedURL] = newLink\n\t\t\t}\n\t\t} else {\n\t\t\tlinkMap[normalizedURL] = newLink\n\t\t}\n\t}\n\t\n\t// 转换为切片\n\tvar result []model.Link\n\tfor _, link := range linkMap {\n\t\tresult = append(result, link)\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Panwiki] 去重前: %d 个链接, 去重后: %d 个链接\", len(links), len(result))\n\t}\n\t\n\treturn result\n}\n\n// extractPasswordFromURL 从URL中提取密码并返回标准化URL\nfunc (p *PanwikiPlugin) extractPasswordFromURL(rawURL string) (normalizedURL string, password string) {\n\t// 解析URL\n\tparsedURL, err := url.Parse(rawURL)\n\tif err != nil {\n\t\treturn rawURL, \"\"\n\t}\n\t\n\t// 获取查询参数\n\tquery := parsedURL.Query()\n\t\n\t// 检查常见的密码参数\n\tpasswordKeys := []string{\"pwd\", \"password\", \"pass\", \"code\"}\n\tfor _, key := range passwordKeys {\n\t\tif val := query.Get(key); val != \"\" {\n\t\t\tpassword = val\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\t// 构建标准化URL（去除密码参数）\n\tfor _, key := range passwordKeys {\n\t\tquery.Del(key)\n\t}\n\t\n\tparsedURL.RawQuery = query.Encode()\n\tnormalizedURL = parsedURL.String()\n\t\n\t// 如果查询参数为空，去掉问号\n\tif parsedURL.RawQuery == \"\" {\n\t\tnormalizedURL = strings.TrimSuffix(normalizedURL, \"?\")\n\t}\n\t\n\treturn normalizedURL, password\n}\n\n// cacheItem 缓存项结构\ntype cacheItem struct {\n\tlinks     []model.Link\n\ttimestamp time.Time\n}\n\n// extractDetailURLFromContent 从Content中提取详情页URL\nfunc (p *PanwikiPlugin) extractDetailURLFromContent(content string) string {\n\t// 查找详情URL模式\n\tre := regexp.MustCompile(`详情:\\s*(https?://[^\\s]+)`)\n\tmatches := re.FindStringSubmatch(content)\n\tif len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// 辅助函数\nfunc parseStats(statsText string, replyCount, viewCount *int) {\n\t// 解析如 \"1 个回复 - 87 次查看\" 格式\n\tre := regexp.MustCompile(`(\\d+)\\s*个回复\\s*-\\s*(\\d+)\\s*次查看`)\n\tmatches := re.FindStringSubmatch(statsText)\n\tif len(matches) >= 3 {\n\t\tif reply, err := strconv.Atoi(matches[1]); err == nil {\n\t\t\t*replyCount = reply\n\t\t}\n\t\tif view, err := strconv.Atoi(matches[2]); err == nil {\n\t\t\t*viewCount = view\n\t\t}\n\t}\n}\n\nfunc parseTime(timeStr string) time.Time {\n\t// 解析如 \"2025-8-14 21:21\" 格式\n\ttimeStr = strings.TrimSpace(timeStr)\n\t\n\tformats := []string{\n\t\t\"2006-1-2 15:04\",\n\t\t\"2006-1-2 15:04:05\",\n\t\t\"2025-1-2 15:04\",\n\t\t\"2025-1-2 15:04:05\",\n\t}\n\t\n\tfor _, format := range formats {\n\t\tif t, err := time.Parse(format, timeStr); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\t\n\t// 如果解析失败，返回当前时间\n\treturn time.Now()\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *PanwikiPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *PanwikiPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// extractPasswordFromContent 从内容文本中提取指定链接的密码\nfunc (p *PanwikiPlugin) extractPasswordFromContent(content, linkURL string) string {\n\t// 查找链接在内容中的位置\n\tlinkIndex := strings.Index(content, linkURL)\n\tif linkIndex == -1 {\n\t\treturn \"\"\n\t}\n\t\n\t// 提取链接周围的文本（前20字符，后100字符）- 缩小范围避免错误匹配\n\tstart := linkIndex - 20\n\tif start < 0 {\n\t\tstart = 0\n\t}\n\tend := linkIndex + len(linkURL) + 100\n\tif end > len(content) {\n\t\tend = len(content)\n\t}\n\t\n\tsurroundingText := content[start:end]\n\t\n\t// 查找密码模式\n\tpasswordPatterns := []string{\n\t\t`提取码[：:]\\s*([A-Za-z0-9]+)`,\n\t\t`密码[：:]\\s*([A-Za-z0-9]+)`,\n\t\t`pwd[：:=]\\s*([A-Za-z0-9]+)`,\n\t\t`password[：:=]\\s*([A-Za-z0-9]+)`,\n\t}\n\t\n\tfor _, pattern := range passwordPatterns {\n\t\tre := regexp.MustCompile(pattern)\n\t\tmatches := re.FindStringSubmatch(surroundingText)\n\t\tif len(matches) > 1 {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Panwiki] 为链接 %s 找到密码: %s\", linkURL, matches[1])\n\t\t\t}\n\t\t\treturn matches[1]\n\t\t}\n\t}\n\t\n\t// 也尝试从URL查询参数中提取\n\t_, urlPassword := p.extractPasswordFromURL(linkURL)\n\treturn urlPassword\n}\n\nfunc init() {\n\tp := NewPanwikiPlugin()\n\tplugin.RegisterGlobalPlugin(p)\n}"
  },
  {
    "path": "plugin/panyq/panyq.go",
    "content": "package panyq\n\nimport (\n\t\"crypto/tls\"\n\t\"pansou/util/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\t\"net/http/cookiejar\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\n// 常量定义\nconst (\n\t// 默认超时时间\n\tDefaultTimeout = 15 * time.Second\n\t// 最大并发数\n\tMaxConcurrency = 100\n\t// 默认请求重试次数\n\tMaxRetries = 0 // 重试\n\t// 是否开启调试日志\n\tDebugLog = false // 修改为true，使得调试信息可见\n\t// 配置文件名\n\tConfigFileName = \"panyq_config.json\"\n\t// 基础URL\n\tBaseURL = \"https://panyq.com\"\n\t// 请求来源控制默认为开启状态\n\tEnableRefererCheck = false\n)\n\n// 动态Action ID的键名\nvar ActionIDKeys = []string{\n\t\"credential_action_id\",     // 获取凭证用的ID\n\t\"intermediate_action_id\",   // 中间步骤用的ID\n\t\"final_link_action_id\",     // 获取最终链接用的ID\n}\n\n// 凭证结构\ntype Credentials struct {\n\tSign string `json:\"sign\"`\n\tHash string `json:\"hash\"`\n\tSha  string `json:\"sha\"`\n}\n\n// 搜索结果项目\ntype SearchHit struct {\n\tEID     string `json:\"eid\"`\n\tDesc    string `json:\"desc\"`\n\tSizeStr string `json:\"size_str\"`\n}\n\n// 搜索响应\ntype SearchResponse struct {\n\tData struct {\n\t\tHits       []SearchHit `json:\"hits\"`\n\t\tMaxPageNum int         `json:\"maxPageNum\"`\n\t} `json:\"data\"`\n}\n\n// 配置缓存，用于在多个搜索过程中复用Action ID\nvar (\n\tactionIDCache     = make(map[string]string)\n\tactionIDCacheLock sync.RWMutex\n\t\n\t// 允许的请求来源列表，可以直接修改这个变量来控制 ext={\"referer\":\"xxx\"}\n\tAllowedReferers = []string{\n\t\t\"https://dm.xueximeng.com\",\n\t\t\"http://localhost:8888\",\n\t\t// 可以添加更多允许的来源\n\t}\n\t\n\t// 新增缓存\n\t// 最终链接缓存\n\tfinalLinkCache     = make(map[string]string)\n\tfinalLinkCacheLock sync.RWMutex\n\t\n\t// 搜索结果缓存\n\tsearchResultCache     = make(map[string][]model.SearchResult)\n\tsearchResultCacheLock sync.RWMutex\n)\n\n// PanyqPlugin 盘友圈搜索插件\ntype PanyqPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tclient *http.Client\n}\n\n// NewPanyqPlugin 创建新的盘友圈搜索插件\nfunc NewPanyqPlugin() *PanyqPlugin {\n\t// 创建一个可以忽略HTTPS证书验证并支持Cookie的HTTP客户端\n\tjar, _ := cookiejar.New(nil)\n\ttransport := &http.Transport{\n\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: true},\n\t\t// 启用HTTP/2\n\t\tForceAttemptHTTP2: true,\n\t\t// 启用连接复用\n\t\tMaxIdleConns:        100,\n\t\tMaxIdleConnsPerHost: 10,\n\t\tIdleConnTimeout:     90 * time.Second,\n\t}\n\t\n\tclient := &http.Client{\n\t\tTimeout:   DefaultTimeout,\n\t\tTransport: transport,\n\t\tJar:       jar, // 使用Cookie管理\n\t\t// 自动处理重定向\n\t\tCheckRedirect: func(req *http.Request, via []*http.Request) error {\n\t\t\tif len(via) >= 10 {\n\t\t\t\treturn fmt.Errorf(\"stopped after 10 redirects\")\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn &PanyqPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"panyq\", 2),\n\t\tclient:          client,\n\t}\n}\n\n// Search 执行搜索并返回结果\nfunc (p *PanyqPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif DebugLog {\n\t\tfmt.Println(\"panyq: ext 参数内容:\", ext)\n\t}\n\n\t// 检查搜索结果缓存\n\tcacheKey := fmt.Sprintf(\"search:%s\", keyword)\n\tsearchResultCacheLock.RLock()\n\tif cachedResults, ok := searchResultCache[cacheKey]; ok {\n\t\tsearchResultCacheLock.RUnlock()\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"panyq: 缓存命中搜索结果: %s\\n\", keyword)\n\t\t}\n\t\treturn cachedResults, nil\n\t}\n\tsearchResultCacheLock.RUnlock()\n\n\t// 请求来源检查\n\tif EnableRefererCheck && ext != nil {\n\t\treferer := \"\"\n\t\tif refererVal, ok := ext[\"referer\"].(string); ok {\n\t\t\treferer = refererVal\n\t\t}\n\t\t\n\t\t// 检查referer是否在允许列表中\n\t\tallowed := false\n\t\tfor _, allowedReferer := range AllowedReferers {\n\t\t\tif strings.HasPrefix(referer, allowedReferer) {\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"panyq: 允许来自 %s 的请求\\n\", referer)\n\t\t\t\t}\n\t\t\t\tallowed = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\t\n\t\tif !allowed {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"panyq: 拒绝来自 %s 的请求\\n\", referer)\n\t\t\t}\n\t\t\treturn nil, fmt.Errorf(\"请求来源不被允许\")\n\t\t}\n\t}\n\t\n\t// 使用新的异步搜索方法\n\tresult, err := p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tresults := result.Results\n\t\n\t// 如果搜索成功，缓存结果\n\tif err == nil && len(results) > 0 {\n\t\tsearchResultCacheLock.Lock()\n\t\tsearchResultCache[cacheKey] = results\n\t\tsearchResultCacheLock.Unlock()\n\t}\n\t\n\treturn results, err\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *PanyqPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)\n}\n\n// doSearch 实际的搜索实现\nfunc (p *PanyqPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif DebugLog {\n\t\tfmt.Println(\"panyq: searching for\", keyword)\n\t}\n\n\t// 尝试获取或发现 Action ID\n\tactionIDs, err := p.getOrDiscoverActionIDs()\n\tif err != nil {\n\t\t// fmt.Println(\"panyq: failed to get Action IDs:\", err)\n\t\treturn nil, fmt.Errorf(\"获取Action ID失败: %w\", err)\n\t}\n\n\t// 步骤1: 获取搜索凭证\n\tcredentials, err := p.getCredentials(keyword, actionIDs[ActionIDKeys[0]], client)\n\tif err != nil {\n\t\t// 如果获取凭证失败，尝试刷新Action ID并重试\n\t\tactionIDs, err = p.discoverActionIDs()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"刷新Action ID失败: %w\", err)\n\t\t}\n\t\t\n\t\t// 使用新的Action ID重试获取凭证\n\t\tcredentials, err = p.getCredentials(keyword, actionIDs[ActionIDKeys[0]], client)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"获取搜索凭证失败: %w\", err)\n\t\t}\n\t}\n\n\t// 步骤2: 获取第一页搜索结果列表\n\thits, maxPageNum, err := p.getSearchResults(credentials.Sign, 1, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"获取搜索结果失败: %w\", err)\n\t}\n\n\tif len(hits) == 0 {\n\t\tif DebugLog {\n\t\t\tfmt.Println(\"panyq: no results found for\", keyword)\n\t\t}\n\t\treturn []model.SearchResult{}, nil\n\t}\n\t\n\t// 如果有多页结果，并发获取其他页的数据\n\tif maxPageNum > 1 {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"panyq: found %d pages, fetching additional pages...\\n\", maxPageNum)\n\t\t}\n\t\tif maxPageNum >= 3 {\n\t\t\tmaxPageNum = 3\n\t\t}\n\t\t// 创建通道存储其他页的结果\n\t\thitsChan := make(chan []SearchHit, maxPageNum-1)\n\t\tvar wg sync.WaitGroup\n\t\t\n\t\t// 并发获取第2页到最后一页\n\t\tfor page := 2; page <= maxPageNum; page++ {\n\t\t\twg.Add(1)\n\t\t\tgo func(pageNum int) {\n\t\t\t\tdefer wg.Done()\n\t\t\t\t\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"panyq: fetching page %d...\\n\", pageNum)\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tpageHits, _, err := p.getSearchResults(credentials.Sign, pageNum, client)\n\t\t\t\tif err != nil {\n\t\t\t\t\t// fmt.Printf(\"panyq: failed to get page %d: %v\\n\", pageNum, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\thitsChan <- pageHits\n\t\t\t}(page)\n\t\t}\n\t\t\n\t\t// 等待所有页面获取完成\n\t\tgo func() {\n\t\t\twg.Wait()\n\t\t\tclose(hitsChan)\n\t\t}()\n\t\t\n\t\t// 合并所有页面的结果\n\t\tfor pageHits := range hitsChan {\n\t\t\thits = append(hits, pageHits...)\n\t\t}\n\t\t\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"panyq: total %d results from all pages\\n\", len(hits))\n\t\t}\n\t}\n\n\t// 使用并发控制通道限制并发数\n\tsem := make(chan struct{}, MaxConcurrency)\n\tvar wg sync.WaitGroup\n\t\n\t// 创建结果和错误通道\n\tresultChan := make(chan model.SearchResult, len(hits))\n\t\n\t// 并发处理每个搜索结果\n\tfor i, hit := range hits {\n\t\twg.Add(1)\n\t\tsem <- struct{}{} // 获取信号量\n\t\t\n\t\tgo func(index int, item SearchHit) {\n\t\t\tdefer wg.Done()\n\t\t\tdefer func() { <-sem }() // 释放信号量\n\t\t\t\n\t\t\t// 步骤3: 执行中间状态确认\n\t\t\terr := p.performIntermediateStep(actionIDs[ActionIDKeys[1]], credentials.Hash, credentials.Sha, item.EID, client)\n\t\t\tif err != nil {\n\t\t\t\t// fmt.Println(\"panyq: intermediate step failed for\", item.EID, \":\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\t// 步骤4: 获取最终链接\n\t\t\tfinalLink, err := p.getFinalLink(actionIDs[ActionIDKeys[2]], item.EID, client)\n\t\t\tif err != nil {\n\t\t\t\t// fmt.Println(\"panyq: get final link failed for\", item.EID, \":\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\tif finalLink == \"\" {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\t// 创建链接\n\t\t\tlinkType := p.determineLinkType(finalLink)\n\t\t\tlinks := []model.Link{\n\t\t\t\t{\n\t\t\t\t\tURL:      finalLink,\n\t\t\t\t\tType:     linkType,\n\t\t\t\t\tPassword: p.extractPassword(finalLink, linkType),\n\t\t\t\t},\n\t\t\t}\n\t\t\t\n\t\t\t// 清理标题和内容中的HTML标签\n\t\t\ttitle := p.extractTitle(item.Desc)\n\t\t\tcleanedDesc := p.cleanEscapedHTML(item.Desc)\n\t\t\t\n\t\t\t// 创建搜索结果\n\t\t\tresult := model.SearchResult{\n\t\t\t\tUniqueID:  fmt.Sprintf(\"panyq-%d\", index),\n\t\t\t\tTitle:     title,\n\t\t\t\tContent:   cleanedDesc,\n\t\t\t\tLinks:     links,\n\t\t\t\tDatetime:  time.Time{}, // 没有时间信息，使用零值\n\t\t\t}\n\t\t\t\n\t\t\tresultChan <- result\n\t\t}(i, hit)\n\t}\n\t\n\t// 启动协程等待所有任务完成并关闭通道\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultChan)\n\t}()\n\t\n\t// 收集结果\n\tvar results []model.SearchResult\n\tfor result := range resultChan {\n\t\t// 确保result中的标题和内容已清理HTML标签\n\t\tresult.Title = p.cleanEscapedHTML(result.Title)\n\t\tresult.Content = p.cleanEscapedHTML(result.Content)\n\t\tresults = append(results, result)\n\t}\n\n\t// 使用关键词过滤结果\n\tfilteredResults := plugin.FilterResultsByKeyword(results, keyword)\n\n\tif DebugLog {\n\t\tfmt.Println(\"panyq: returning\", len(filteredResults), \"filtered results\")\n\t}\n\n\treturn filteredResults, nil\n}\n\n// getOrDiscoverActionIDs 获取或发现Action ID\nfunc (p *PanyqPlugin) getOrDiscoverActionIDs() (map[string]string, error) {\n\t// 先检查缓存\n\tactionIDCacheLock.RLock()\n\tif len(actionIDCache) >= len(ActionIDKeys) {\n\t\tids := make(map[string]string)\n\t\tfor _, key := range ActionIDKeys {\n\t\t\tif id, ok := actionIDCache[key]; ok {\n\t\t\t\tids[key] = id\n\t\t\t}\n\t\t}\n\t\t\n\t\tif len(ids) == len(ActionIDKeys) {\n\t\t\tactionIDCacheLock.RUnlock()\n\t\t\treturn ids, nil\n\t\t}\n\t}\n\tactionIDCacheLock.RUnlock()\n\t\n\t// 没有缓存或缓存不完整，发现新的Action ID\n\treturn p.discoverActionIDs()\n}\n\n// discoverActionIDs 发现Action ID\nfunc (p *PanyqPlugin) discoverActionIDs() (map[string]string, error) {\n\tif DebugLog {\n\t\tfmt.Println(\"panyq: discovering Action IDs...\")\n\t}\n\t\n\t// 尝试从缓存文件加载\n\tfinalIDs, err := p.loadActionIDsFromFile()\n\tif err == nil && len(finalIDs) == len(ActionIDKeys) {\n\t\tif DebugLog {\n\t\t\tfmt.Println(\"panyq: loaded Action IDs from file cache\")\n\t\t}\n\t\t\n\t\t// 保存到内存缓存\n\t\tactionIDCacheLock.Lock()\n\t\tfor k, v := range finalIDs {\n\t\t\tactionIDCache[k] = v\n\t\t}\n\t\tactionIDCacheLock.Unlock()\n\t\t\n\t\treturn finalIDs, nil\n\t}\n\t\n\t// 从网站获取潜在的Action ID\n\tpotentialIDs, err := p.findPotentialActionIDs(p.client)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\tif len(potentialIDs) == 0 {\n\t\treturn nil, fmt.Errorf(\"未找到潜在的Action ID\")\n\t}\n\t\n\tif DebugLog {\n\t\t// fmt.Printf(\"panyq: 找到 %d 个潜在的 Action ID\\n\", len(potentialIDs))\n\t\tif len(potentialIDs) > 0 {\n\t\t\tfmt.Printf(\"panyq: 样例ID: %s\\n\", potentialIDs[0])\n\t\t}\n\t}\n\t\n\tfinalIDs = make(map[string]string)\n\t\n\t// 1. 验证credential_action_id - 并发验证\n\tif DebugLog {\n\t\tfmt.Println(\"panyq: validating credential_action_id...\")\n\t}\n\t\n\t// 使用通道存储验证成功的ID\n\tcredIDChan := make(chan string, len(potentialIDs))\n\t\n\t// 并发验证所有ID\n\tvar wg sync.WaitGroup\n\tfor i, id := range potentialIDs {\n\t\twg.Add(1)\n\t\tgo func(index int, actionID string) {\n\t\t\tdefer wg.Done()\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"panyq: 并发尝试第 %d 个ID作为credential_action_id: %.10s...\\n\", index+1, actionID)\n\t\t\t}\n\t\t\tif p.validateCredentialID(actionID) {\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"panyq: 找到有效的credential_action_id: %s\\n\", actionID)\n\t\t\t\t}\n\t\t\t\tcredIDChan <- actionID\n\t\t\t}\n\t\t}(i, id)\n\t}\n\t\n\t// 等待所有验证完成\n\twg.Wait()\n\tclose(credIDChan)\n\t\n\t// 从通道中获取第一个有效ID\n\tvar credentialIDFound bool\n\tfor id := range credIDChan {\n\t\tfinalIDs[ActionIDKeys[0]] = id\n\t\tcredentialIDFound = true\n\t\tbreak\n\t}\n\t\n\tif !credentialIDFound {\n\t\treturn nil, fmt.Errorf(\"未能验证credential_action_id\")\n\t}\n\t\n\t// 获取测试凭证用于后续验证\n\ttestCreds, err := p.getCredentials(\"test\", finalIDs[ActionIDKeys[0]], p.client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"获取测试凭证失败: %w\", err)\n\t}\n\t\n\tif DebugLog {\n\t\tfmt.Printf(\"panyq: 获取到测试凭证: sign=%.10s..., hash=%.10s..., sha=%.10s...\\n\", \n\t\t\ttestCreds.Sign, testCreds.Hash, testCreds.Sha)\n\t}\n\t\n\t// 从剩余ID中排除已使用的ID\n\tremainingIDs := make([]string, 0, len(potentialIDs)-1)\n\tfor _, id := range potentialIDs {\n\t\tif id != finalIDs[ActionIDKeys[0]] {\n\t\t\tremainingIDs = append(remainingIDs, id)\n\t\t}\n\t}\n\t\n\t// 2. 验证intermediate_action_id - 从后向前验证\n\tif DebugLog {\n\t\tfmt.Printf(\"panyq: validating intermediate_action_id (%d candidates)...\\n\", len(remainingIDs))\n\t}\n\t\n\tvar intermediateIDFound bool\n\t\n\t// 从后向前验证\n\tfor i := len(remainingIDs) - 1; i >= 0; i-- {\n\t\tid := remainingIDs[i]\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"panyq: 尝试第 %d 个剩余ID作为intermediate_action_id: %.10s...\\n\", i+1, id)\n\t\t}\n\t\tif p.validateIntermediateID(id, testCreds.Hash, testCreds.Sha) {\n\t\t\tfinalIDs[ActionIDKeys[1]] = id\n\t\t\tintermediateIDFound = true\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"panyq: 找到有效的intermediate_action_id: %s\\n\", id)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\tif !intermediateIDFound {\n\t\treturn nil, fmt.Errorf(\"未能验证intermediate_action_id\")\n\t}\n\t\n\t// 获取测试EID\n\ttestHits, _, err := p.getSearchResults(testCreds.Sign, 1, p.client) // 获取第一页测试结果\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"获取测试结果失败: %w\", err)\n\t}\n\t\n\tif len(testHits) == 0 {\n\t\treturn nil, fmt.Errorf(\"获取测试EID失败: 无搜索结果\")\n\t}\n\t\n\ttestEID := testHits[0].EID\n\t\n\tif DebugLog {\n\t\tfmt.Printf(\"panyq: 获取到测试EID: %s\\n\", testEID)\n\t}\n\t\n\t// 从剩余ID中排除已使用的ID\n\tnewRemainingIDs := make([]string, 0, len(remainingIDs)-1)\n\tfor _, id := range remainingIDs {\n\t\tif id != finalIDs[ActionIDKeys[1]] {\n\t\t\tnewRemainingIDs = append(newRemainingIDs, id)\n\t\t}\n\t}\n\tremainingIDs = newRemainingIDs\n\t\n\t// 3. 验证final_link_action_id\n\tif DebugLog {\n\t\tfmt.Printf(\"panyq: validating final_link_action_id (%d candidates)...\\n\", len(remainingIDs))\n\t}\n\t\n\tvar finalLinkIDFound bool\n\tfor i, id := range remainingIDs {\n\t\t// 针对每个候选ID都执行一次中间步骤\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"panyq: 尝试第 %d 个ID作为final_link_action_id: %.10s...\\n\", i+1, id)\n\t\t\tfmt.Println(\"panyq: 执行中间步骤...\")\n\t\t}\n\t\t\n\t\terr = p.performIntermediateStep(finalIDs[ActionIDKeys[1]], testCreds.Hash, testCreds.Sha, testEID, p.client)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"panyq: 中间步骤执行失败, 继续尝试下一个ID: %v\\n\", err)\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\tif DebugLog {\n\t\t\tfmt.Println(\"panyq: 验证final_link_action_id...\")\n\t\t}\n\t\t\n\t\tif p.validateFinalLinkID(id, testEID) {\n\t\t\tfinalIDs[ActionIDKeys[2]] = id\n\t\t\tfinalLinkIDFound = true\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"panyq: 找到有效的final_link_action_id: %s\\n\", id)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\tif !finalLinkIDFound {\n\t\t// 如果只剩下一个ID且验证失败，尝试交换intermediate_action_id和final_link_action_id\n\t\tif len(remainingIDs) == 1 && len(potentialIDs) == 3 {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Println(\"panyq: final_link_action_id验证失败，尝试交换intermediate_action_id和final_link_action_id...\")\n\t\t\t}\n\t\t\t\n\t\t\t// 保存当前的intermediate_action_id\n\t\t\toldInterID := finalIDs[ActionIDKeys[1]]\n\t\t\t\n\t\t\t// 使用剩余的ID作为intermediate_action_id\n\t\t\tfinalIDs[ActionIDKeys[1]] = remainingIDs[0]\n\t\t\t\n\t\t\t// 使用原来的intermediate_action_id作为final_link_action_id\n\t\t\tfinalIDs[ActionIDKeys[2]] = oldInterID\n\t\t\t\n\t\t\t// 执行中间步骤\n\t\t\terr = p.performIntermediateStep(finalIDs[ActionIDKeys[1]], testCreds.Hash, testCreds.Sha, testEID, p.client)\n\t\t\tif err != nil {\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"panyq: 交换后中间步骤执行失败: %v\\n\", err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// 验证final_link_action_id\n\t\t\t\tif p.validateFinalLinkID(finalIDs[ActionIDKeys[2]], testEID) {\n\t\t\t\t\tfinalLinkIDFound = true\n\t\t\t\t\tif DebugLog {\n\t\t\t\t\t\tfmt.Println(\"panyq: 交换ID后验证成功!\")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\tif !finalLinkIDFound {\n\t\t\treturn nil, fmt.Errorf(\"未能验证final_link_action_id\")\n\t\t}\n\t}\n\t\n\t// 保存到内存缓存\n\tactionIDCacheLock.Lock()\n\tfor k, v := range finalIDs {\n\t\tactionIDCache[k] = v\n\t}\n\tactionIDCacheLock.Unlock()\n\t\n\t// 保存到文件缓存\n\tif err := p.saveActionIDsToFile(finalIDs); err != nil {\n\t\tfmt.Printf(\"panyq: 保存Action IDs到文件失败: %v\\n\", err)\n\t\t// 继续执行，不返回错误\n\t}\n\t\n\tif DebugLog {\n\t\tfmt.Println(\"panyq: all Action IDs validated successfully:\")\n\t\tfor _, key := range ActionIDKeys {\n\t\t\tfmt.Printf(\"panyq:   %s = %s\\n\", key, finalIDs[key])\n\t\t}\n\t}\n\t\n\treturn finalIDs, nil\n}\n\n// findPotentialActionIDs 从网站获取潜在的Action ID\nfunc (p *PanyqPlugin) findPotentialActionIDs(client *http.Client) ([]string, error) {\n\t// 请求网站首页\n\treq, err := http.NewRequest(\"GET\", BaseURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建请求失败: %w\", err)\n\t}\n\t\n\t// 只保留指定的请求头\n\t// req.Header.Set(\"sec-ch-ua\", `\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"`)\n\treq.Header.Set(\"User-Agent\", \"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\")\n\t\n\t// 发送请求\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"请求网站首页失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查状态码\n\tif resp.StatusCode != http.StatusOK {\n\t\t// 读取响应体以获取服务器返回的具体错误信息\n\t\tbodyBytes, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\t// 如果连响应体都读取失败，则返回状态码错误并附上读取错误\n\t\t\treturn nil, fmt.Errorf(\"请求失败，状态码: %d，且读取响应体错误: %v\", resp.StatusCode, err)\n\t\t}\n\t\t// 将更详细的状态信息 (如 \"404 Not Found\") 和响应体内容一起作为错误返回\n\t\treturn nil, fmt.Errorf(\"请求失败，状态: %s, 详情: %s\", resp.Status, string(bodyBytes))\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"读取响应失败: %w\", err)\n\t}\n\t\n\t// 提取JS文件路径\n\tjsRegex := regexp.MustCompile(`<script src=\"(/_next/static/[^\"]+\\.js)\"`)\n\tmatches := jsRegex.FindAllStringSubmatch(string(body), -1)\n\t\n\tif len(matches) == 0 {\n\t\treturn nil, fmt.Errorf(\"未找到JS文件\")\n\t}\n\t\n\t// 收集所有潜在的Action ID\n\tidSet := make(map[string]struct{})\n\tidRegex := regexp.MustCompile(`[\"\\']([a-f0-9]{40})[\"\\']{1}`)\n\t\n\tfor _, match := range matches {\n\t\tjsURL := BaseURL + match[1]\n\t\t\n\t\t// 创建JS文件请求\n\t\tjsReq, err := http.NewRequest(\"GET\", jsURL, nil)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 设置JS文件请求头，保持与首页请求一致\n\t\tjsReq.Header.Set(\"Referer\", BaseURL)\n\t\tjsReq.Header.Set(\"Origin\", BaseURL)\n\t\tjsReq.Header.Set(\"sec-ch-ua\", `\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"`)\n\t\tjsReq.Header.Set(\"User-Agent\", \"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\")\n\t\t\n\t\t// 发送JS文件请求\n\t\tjsResp, err := client.Do(jsReq)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\tjsBody, err := io.ReadAll(jsResp.Body)\n\t\tjsResp.Body.Close() // 确保关闭body\n\t\t\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\tidMatches := idRegex.FindAllStringSubmatch(string(jsBody), -1)\n\t\tfor _, idMatch := range idMatches {\n\t\t\tidSet[idMatch[1]] = struct{}{}\n\t\t}\n\t}\n\t\n\t// 转换为切片\n\tids := make([]string, 0, len(idSet))\n\tfor id := range idSet {\n\t\tids = append(ids, id)\n\t}\n\t\n\tif DebugLog {\n\t\tfmt.Println(\"panyq: found\", len(ids), \"potential Action IDs\")\n\t}\n\t\n\treturn ids, nil\n}\n\n// validateCredentialID 验证credential_action_id\nfunc (p *PanyqPlugin) validateCredentialID(actionID string) bool {\n\t_, err := p.getCredentials(\"test\", actionID, p.client)\n\treturn err == nil\n}\n\n// validateIntermediateID 验证intermediate_action_id\nfunc (p *PanyqPlugin) validateIntermediateID(actionID, testHash, testSha string) bool {\n\terr := p.performIntermediateStep(actionID, testHash, testSha, \"fake_eid_for_validation\", p.client)\n\treturn err == nil\n}\n\n// validateFinalLinkID 验证final_link_action_id\nfunc (p *PanyqPlugin) validateFinalLinkID(actionID, testEID string) bool {\n\tresponseText, err := p.getRawFinalLinkResponse(actionID, testEID, p.client)\n\tif err != nil {\n\t\t// 记录错误但继续尝试验证，因为Python版本在出现请求异常时返回None，但仍然会尝试验证\n\t\tfmt.Println(\"panyq: 获取响应失败，但仍尝试验证:\", err)\n\t\t// 即使出错，responseText可能包含部分响应内容\n\t\tif responseText == \"\" {\n\t\t\treturn false\n\t\t}\n\t}\n\t\n\t// 检查原始响应中是否包含链接相关的关键词\n\tkeywords := []string{\"http\", \"magnet\", \"aliyundrive\", `\"url\"`}\n\tfor _, kw := range keywords {\n\t\tif strings.Contains(responseText, kw) {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Println(\"panyq: found keyword in response:\", kw)\n\t\t\t}\n\t\t\treturn true\n\t\t}\n\t}\n\t\n\treturn false\n}\n\n// doRequestWithRetry 发送HTTP请求并支持重试\nfunc (p *PanyqPlugin) doRequestWithRetry(client *http.Client, req *http.Request, maxRetries int) (*http.Response, error) {\n\tvar resp *http.Response\n\tvar err error\n\t\n\tfor i := 0; i <= maxRetries; i++ {\n\t\t// 如果不是第一次尝试，等待一段时间\n\t\tif i > 0 {\n\t\t\t// 指数退避算法\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 500 * time.Millisecond\n\t\t\tif backoff > 5*time.Second {\n\t\t\t\tbackoff = 5 * time.Second\n\t\t\t}\n\t\t\ttime.Sleep(backoff)\n\t\t\t\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"panyq: 重试请求 #%d，等待 %v\\n\", i, backoff)\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 克隆请求，避免重用同一个请求对象\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\t// 发送请求\n\t\tresp, err = client.Do(reqClone)\n\t\t\n\t\t// 如果请求成功或者是不可重试的错误，则退出循环\n\t\tif err == nil || !isRetriableError(err) {\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\treturn resp, err\n}\n\n// isRetriableError 判断错误是否可以重试\nfunc isRetriableError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\t\n\t// 判断是否是网络错误或超时错误\n\tif netErr, ok := err.(net.Error); ok {\n\t\treturn netErr.Timeout() || netErr.Temporary()\n\t}\n\t\n\t// 其他可能需要重试的错误类型\n\terrStr := err.Error()\n\treturn strings.Contains(errStr, \"connection refused\") ||\n\t\t   strings.Contains(errStr, \"connection reset\") ||\n\t\t   strings.Contains(errStr, \"EOF\")\n}\n\n// getRawFinalLinkResponse 获取最终链接的原始响应文本\nfunc (p *PanyqPlugin) getRawFinalLinkResponse(actionID, eid string, client *http.Client) (string, error) {\n\t// 检查缓存\n\tfinalLinkCacheLock.RLock()\n\tcacheKey := fmt.Sprintf(\"%s:%s\", actionID, eid)\n\tif cachedResponse, ok := finalLinkCache[cacheKey]; ok {\n\t\tfinalLinkCacheLock.RUnlock()\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"panyq: 缓存命中 raw final link: %s\\n\", eid)\n\t\t}\n\t\treturn cachedResponse, nil\n\t}\n\tfinalLinkCacheLock.RUnlock()\n\n\t// 构建URL\n\tfinalURL := fmt.Sprintf(\"%s/go/%s\", BaseURL, eid)\n\t\n\t// 构建路由状态树\n\trouterStateTree := []interface{}{\n\t\t\"\",\n\t\tmap[string]interface{}{\n\t\t\t\"children\": []interface{}{\n\t\t\t\t\"go\",\n\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\"children\": []interface{}{\n\t\t\t\t\t\t[]interface{}{\"eid\", eid, \"d\"},\n\t\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\t\"children\": []interface{}{\"__PAGE__\", map[string]interface{}{}, fmt.Sprintf(\"/go/%s\", eid), \"refresh\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tnil,\n\t\tnil,\n\t\ttrue,\n\t}\n\t\n\trouterStateTreeJSON, err := json.Marshal(routerStateTree)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\t\n\t// 构建请求体\n\tpayload := fmt.Sprintf(`[{\"eid\":\"%s\"}]`, eid)\n\t\n\t// 创建请求\n\treq, err := http.NewRequest(\"POST\", finalURL, strings.NewReader(payload))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\t\n\t// 设置请求头，保持一致性\n\treq.Header.Set(\"Content-Type\", \"text/plain;charset=UTF-8\")\n\treq.Header.Set(\"next-action\", actionID)\n\treq.Header.Set(\"Referer\", finalURL)\n\treq.Header.Set(\"Origin\", BaseURL)\n\treq.Header.Set(\"sec-ch-ua\", `\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"`)\n\treq.Header.Set(\"next-router-state-tree\", url.QueryEscape(string(routerStateTreeJSON)))\n\treq.Header.Set(\"User-Agent\", \"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\")\n\t\n\t// 添加超时设置，与Python版本一致\n\tif client.Timeout == 0 {\n\t\tclient = &http.Client{\n\t\t\tTimeout: 15 * time.Second,\n\t\t\tTransport: client.Transport,\n\t\t}\n\t}\n\t\n\t// 发送请求并支持重试\n\tresp, err := p.doRequestWithRetry(client, req, MaxRetries)\n\tif err != nil {\n\t\t// 网络错误等情况下返回空字符串和错误\n\t\tif DebugLog {\n\t\t\tfmt.Println(\"panyq: network error:\", err)\n\t\t}\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查状态码\n\tif resp.StatusCode != http.StatusOK {\n\t\tif DebugLog {\n\t\t\tfmt.Println(\"panyq: bad status code:\", resp.StatusCode)\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"HTTP status code: %d\", resp.StatusCode)\n\t}\n\t\n\t// 读取原始响应\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\t// 读取错误时返回空字符串和错误\n\t\tif DebugLog {\n\t\t\tfmt.Println(\"panyq: read error:\", err)\n\t\t}\n\t\treturn \"\", err\n\t}\n\t\n\tresponseText := string(body)\n\n\t// 保存到缓存\n\tfinalLinkCacheLock.Lock()\n\tfinalLinkCache[cacheKey] = responseText\n\tfinalLinkCacheLock.Unlock()\n\t\n\treturn responseText, nil\n}\n\n// getCredentials 获取搜索凭证\nfunc (p *PanyqPlugin) getCredentials(query, actionID string, client *http.Client) (*Credentials, error) {\n\t// 构建请求体\n\tpayload := fmt.Sprintf(`[{\"cat\":\"all\",\"query\":\"%s\",\"pageNum\":1}]`, query)\n\t\n\t// 创建请求\n\treq, err := http.NewRequest(\"POST\", BaseURL, strings.NewReader(payload))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\t// 设置请求头，保持一致性\n\treq.Header.Set(\"Content-Type\", \"text/plain;charset=UTF-8\")\n\treq.Header.Set(\"next-action\", actionID)\n\treq.Header.Set(\"Referer\", BaseURL)\n\treq.Header.Set(\"Origin\", BaseURL)\n\treq.Header.Set(\"sec-ch-ua\", `\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"`)\n\treq.Header.Set(\"User-Agent\", \"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\")\n\t\n\t// 发送请求并支持重试\n\tresp, err := p.doRequestWithRetry(client, req, MaxRetries)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 读取响应\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\t// 使用正则表达式提取凭证\n\tsignRegex := regexp.MustCompile(`\"sign\":\"([^\"]+)\"`)\n\tshaRegex := regexp.MustCompile(`\"sha\":\"([a-f0-9]{64})\"`)\n\thashRegex := regexp.MustCompile(`\"hash\",\"([^\"]+)\"`)\n\t\n\tsignMatch := signRegex.FindStringSubmatch(string(body))\n\tshaMatch := shaRegex.FindStringSubmatch(string(body))\n\thashMatch := hashRegex.FindStringSubmatch(string(body))\n\t\n\tif len(signMatch) < 2 || len(shaMatch) < 2 || len(hashMatch) < 2 {\n\t\treturn nil, fmt.Errorf(\"提取凭证失败\")\n\t}\n\t\n\treturn &Credentials{\n\t\tSign: signMatch[1],\n\t\tSha:  shaMatch[1],\n\t\tHash: hashMatch[1],\n\t}, nil\n}\n\n// getSearchResults 获取搜索结果列表\nfunc (p *PanyqPlugin) getSearchResults(sign string, pageNum int, client *http.Client) ([]SearchHit, int, error) {\n\t// 构建URL\n\tsearchURL := fmt.Sprintf(\"%s/api/search?sign=%s&page=%d\", BaseURL, sign, pageNum)\n\t\n\t// 创建请求\n\treq, err := http.NewRequest(\"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\t\n\t// 设置请求头，保持一致性\n\treq.Header.Set(\"Referer\", BaseURL)\n\treq.Header.Set(\"Origin\", BaseURL)\n\treq.Header.Set(\"sec-ch-ua\", `\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"`)\n\treq.Header.Set(\"User-Agent\", \"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\")\n\t\n\t// 从缓存中获取credential_action_id并添加到请求头\n\tactionIDCacheLock.RLock()\n\tif actionID, ok := actionIDCache[ActionIDKeys[0]]; ok {\n\t\treq.Header.Set(\"next-action\", actionID)\n\t}\n\tactionIDCacheLock.RUnlock()\n\t\n\t// 发送请求并支持重试\n\tresp, err := p.doRequestWithRetry(client, req, MaxRetries)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 读取响应\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\t\n\t// 解析JSON\n\tvar searchResp SearchResponse\n\tif err := json.Unmarshal(body, &searchResp); err != nil {\n\t\treturn nil, 0, err\n\t}\n\t\n\treturn searchResp.Data.Hits, searchResp.Data.MaxPageNum, nil\n}\n\n// performIntermediateStep 执行中间状态确认\nfunc (p *PanyqPlugin) performIntermediateStep(actionID, hashVal, shaVal, eid string, client *http.Client) error {\n\t// 构建URL\n\tintermediateURL := fmt.Sprintf(\"%s/search/%s\", BaseURL, hashVal)\n\t\n\t// 构建路由状态树\n\trouterStateTree := []interface{}{\n\t\t\"\",\n\t\tmap[string]interface{}{\n\t\t\t\"children\": []interface{}{\n\t\t\t\t\"search\",\n\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\"children\": []interface{}{\n\t\t\t\t\t\t[]interface{}{\"hash\", hashVal, \"d\"},\n\t\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\t\"children\": []interface{}{\"__PAGE__\", map[string]interface{}{}, fmt.Sprintf(\"/search/%s\", hashVal), \"refresh\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tnil,\n\t\tnil,\n\t\ttrue,\n\t}\n\t\n\trouterStateTreeJSON, err := json.Marshal(routerStateTree)\n\tif err != nil {\n\t\treturn err\n\t}\n\t\n\t// 构建请求体\n\tpayload := fmt.Sprintf(`[{\"eid\":\"%s\",\"sha\":\"%s\",\"page_num\":\"1\"}]`, eid, shaVal)\n\t\n\t// 创建请求\n\treq, err := http.NewRequest(\"POST\", intermediateURL, strings.NewReader(payload))\n\tif err != nil {\n\t\treturn err\n\t}\n\t\n\t// 设置请求头，保持一致性\n\treq.Header.Set(\"Content-Type\", \"text/plain;charset=UTF-8\")\n\treq.Header.Set(\"next-action\", actionID)\n\treq.Header.Set(\"Referer\", intermediateURL)\n\treq.Header.Set(\"Origin\", BaseURL)\n\treq.Header.Set(\"next-router-state-tree\", url.QueryEscape(string(routerStateTreeJSON)))\n\treq.Header.Set(\"sec-ch-ua\", `\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"`)\n\treq.Header.Set(\"User-Agent\", \"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\")\n\t\n\t// 发送请求并支持重试\n\tresp, err := p.doRequestWithRetry(client, req, MaxRetries)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 确认请求成功\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"中间步骤请求失败，状态码: %d\", resp.StatusCode)\n\t}\n\t\n\treturn nil\n}\n\n// getFinalLink 获取最终链接\nfunc (p *PanyqPlugin) getFinalLink(actionID, eid string, client *http.Client) (string, error) {\n\t// 检查缓存\n\tfinalLinkCacheLock.RLock()\n\tlinkCacheKey := fmt.Sprintf(\"link:%s:%s\", actionID, eid)\n\tif cachedLink, ok := finalLinkCache[linkCacheKey]; ok {\n\t\tfinalLinkCacheLock.RUnlock()\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"panyq: 缓存命中最终链接: %s\\n\", eid)\n\t\t}\n\t\treturn cachedLink, nil\n\t}\n\tfinalLinkCacheLock.RUnlock()\n\n\t// 获取原始响应\n\tresponseText, err := p.getRawFinalLinkResponse(actionID, eid, client)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\t\n\t// 尝试从JSON中提取URL\n\tlines := strings.Split(responseText, \"\\n\")\n\tvar finalLink string\n\n\tif len(lines) > 0 {\n\t\tlastLine := lines[len(lines)-1]\n\t\t\n\t\tvar linkData []interface{}\n\t\tif err := json.Unmarshal([]byte(lastLine), &linkData); err == nil {\n\t\t\tif len(linkData) > 1 {\n\t\t\t\tif linkMap, ok := linkData[1].(map[string]interface{}); ok {\n\t\t\t\t\tif url, ok := linkMap[\"url\"].(string); ok && url != \"\" {\n\t\t\t\t\t\tfinalLink = url\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 如果JSON解析失败，尝试使用正则表达式\n\tif finalLink == \"\" {\n\t\turlRegex := regexp.MustCompile(`(https?://[^\\s\"'<>]+|magnet:\\?[^\\s\"'<>]+)`)\n\t\turlMatch := urlRegex.FindStringSubmatch(responseText)\n\t\t\n\t\tif len(urlMatch) > 0 {\n\t\t\tfinalLink = urlMatch[0]\n\t\t}\n\t}\n\t\n\tif finalLink == \"\" {\n\t\treturn \"\", fmt.Errorf(\"提取链接失败\")\n\t}\n\n\t// 保存链接到缓存\n\tfinalLinkCacheLock.Lock()\n\tfinalLinkCache[linkCacheKey] = finalLink\n\tfinalLinkCacheLock.Unlock()\n\t\n\treturn finalLink, nil\n}\n\n// determineLinkType 根据URL确定链接类型\nfunc (p *PanyqPlugin) determineLinkType(url string) string {\n\tlowerURL := strings.ToLower(url)\n\t\n\tswitch {\n\tcase strings.Contains(lowerURL, \"pan.baidu.com\"):\n\t\treturn \"baidu\"\n\tcase strings.Contains(lowerURL, \"alipan.com\") || strings.Contains(lowerURL, \"aliyundrive.com\"):\n\t\treturn \"aliyun\"\n\tcase strings.Contains(lowerURL, \"pan.xunlei.com\"):\n\t\treturn \"xunlei\"\n\tcase strings.Contains(lowerURL, \"cloud.189.cn\"):\n\t\treturn \"tianyi\"\n\tcase strings.Contains(lowerURL, \"caiyun.139.com\") || strings.Contains(lowerURL, \"yun.139.com\"):\n\t\treturn \"mobile\"\n\tcase strings.Contains(lowerURL, \"pan.quark.cn\"):\n\t\treturn \"quark\"\n\tcase strings.Contains(lowerURL, \"115.com\"):\n\t\treturn \"115\"\n\tcase strings.Contains(lowerURL, \"weiyun.com\"):\n\t\treturn \"weiyun\"\n\tcase strings.Contains(lowerURL, \"lanzou\"):\n\t\treturn \"lanzou\"\n\tcase strings.Contains(lowerURL, \"jianguoyun.com\"):\n\t\treturn \"jianguoyun\"\n\tcase strings.Contains(lowerURL, \"123pan.com\"):\n\t\treturn \"123\"\n\tcase strings.Contains(lowerURL, \"drive.uc.cn\"):\n\t\treturn \"uc\"\n\tcase strings.Contains(lowerURL, \"mypikpak.com\"):\n\t\treturn \"pikpak\"\n\tcase strings.HasPrefix(lowerURL, \"magnet:\"):\n\t\treturn \"magnet\"\n\tcase strings.HasPrefix(lowerURL, \"ed2k:\"):\n\t\treturn \"ed2k\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n// extractPassword 从URL或内容中提取密码\nfunc (p *PanyqPlugin) extractPassword(url string, linkType string) string {\n\t// 百度网盘密码通常在URL后面以?pwd=形式出现\n\tif linkType == \"baidu\" {\n\t\tif idx := strings.Index(url, \"?pwd=\"); idx >= 0 {\n\t\t\tpwd := url[idx+5:]\n\t\t\tif len(pwd) >= 4 {\n\t\t\t\treturn pwd[:4] // 百度网盘密码通常为4位\n\t\t\t}\n\t\t\treturn pwd\n\t\t}\n\t}\n\t\n\t// 阿里云盘密码可能在URL参数中\n\tif linkType == \"aliyun\" {\n\t\tif idx := strings.Index(url, \"password=\"); idx >= 0 {\n\t\t\tpwd := url[idx+9:]\n\t\t\tif endIdx := strings.Index(pwd, \"&\"); endIdx >= 0 {\n\t\t\t\treturn pwd[:endIdx]\n\t\t\t}\n\t\t\treturn pwd\n\t\t}\n\t}\n\t\n\treturn \"\"\n}\n\n// cleanEscapedHTML 清理HTML转义字符\nfunc (p *PanyqPlugin) cleanEscapedHTML(text string) string {\n\t// 处理Unicode转义序列\n\treplacers := map[string]string{\n\t\t`\\u003Cmark\\u003E`:   \"\",\n\t\t`\\u003C/mark\\u003E`:  \"\",\n\t\t`\\u003Cb\\u003E`:      \"\",\n\t\t`\\u003C/b\\u003E`:     \"\",\n\t\t`\\u003Cem\\u003E`:     \"\",\n\t\t`\\u003C/em\\u003E`:    \"\",\n\t\t`\\u003Cstrong\\u003E`: \"\",\n\t\t`\\u003C/strong\\u003E`: \"\",\n\t\t`\\u003Ci\\u003E`:      \"\",\n\t\t`\\u003C/i\\u003E`:     \"\",\n\t\t`\\u003Cu\\u003E`:      \"\",\n\t\t`\\u003C/u\\u003E`:     \"\",\n\t\t`\\u003Cbr\\u003E`:     \" \",\n\t\t`\\u003Cbr/\\u003E`:    \" \",\n\t\t`\\u003Cbr /\\u003E`:   \" \",\n\t}\n\t\n\tresult := text\n\tfor old, new := range replacers {\n\t\tresult = strings.ReplaceAll(result, old, new)\n\t}\n\t\n\t// 处理实际的HTML标签\n\thtmlReplacers := map[string]string{\n\t\t`<mark>`:   \"\",\n\t\t`</mark>`:  \"\",\n\t\t`<b>`:      \"\",\n\t\t`</b>`:     \"\",\n\t\t`<em>`:     \"\",\n\t\t`</em>`:    \"\",\n\t\t`<strong>`: \"\",\n\t\t`</strong>`: \"\",\n\t\t`<i>`:      \"\",\n\t\t`</i>`:     \"\",\n\t\t`<u>`:      \"\",\n\t\t`</u>`:     \"\",\n\t\t`<br>`:     \" \",\n\t\t`<br/>`:    \" \",\n\t\t`<br />`:   \" \",\n\t}\n\t\n\tfor old, new := range htmlReplacers {\n\t\tresult = strings.ReplaceAll(result, old, new)\n\t}\n\t\n\t// 处理已解码的Unicode转义序列\n\tdecodedReplacers := map[string]string{\n\t\tstring([]byte{0x3C, 0x6D, 0x61, 0x72, 0x6B, 0x3E}):                 \"\", // <mark>\n\t\tstring([]byte{0x3C, 0x2F, 0x6D, 0x61, 0x72, 0x6B, 0x3E}):           \"\", // </mark>\n\t\tstring([]byte{0x3C, 0x62, 0x3E}):                                   \"\", // <b>\n\t\tstring([]byte{0x3C, 0x2F, 0x62, 0x3E}):                             \"\", // </b>\n\t\tstring([]byte{0x3C, 0x65, 0x6D, 0x3E}):                             \"\", // <em>\n\t\tstring([]byte{0x3C, 0x2F, 0x65, 0x6D, 0x3E}):                       \"\", // </em>\n\t\tstring([]byte{0x3C, 0x73, 0x74, 0x72, 0x6F, 0x6E, 0x67, 0x3E}):     \"\", // <strong>\n\t\tstring([]byte{0x3C, 0x2F, 0x73, 0x74, 0x72, 0x6F, 0x6E, 0x67, 0x3E}): \"\", // </strong>\n\t}\n\t\n\tfor old, new := range decodedReplacers {\n\t\tresult = strings.ReplaceAll(result, old, new)\n\t}\n\t\n\treturn result\n}\n\n// extractTitle 从描述中提取标题\nfunc (p *PanyqPlugin) extractTitle(desc string) string {\n\t// 先清理HTML标签\n\tcleanDesc := p.cleanEscapedHTML(desc)\n\t\n\t// 尝试匹配标题\n\t// 1. 尝试匹配《》内的内容\n\ttitleRegex := regexp.MustCompile(`《([^》]+)》`)\n\tif matches := titleRegex.FindStringSubmatch(cleanDesc); len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\t\n\t// 2. 尝试匹配【】内的内容\n\ttitleRegex = regexp.MustCompile(`【([^】]+)】`)\n\tif matches := titleRegex.FindStringSubmatch(cleanDesc); len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\t\n\t// 3. 尝试提取开头的一段（到第一个分隔符为止）\n\tparts := strings.Split(cleanDesc, \"✔\")\n\tif len(parts) > 0 && len(parts[0]) > 0 {\n\t\treturn strings.TrimSpace(parts[0])\n\t}\n\t\n\t// 如果以上方法都无法提取标题，则取前30个字符作为标题\n\tif len(cleanDesc) > 30 {\n\t\treturn strings.TrimSpace(cleanDesc[:30]) + \"...\"\n\t}\n\t\n\treturn strings.TrimSpace(cleanDesc)\n}\n\n// 在init函数中注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewPanyqPlugin())\n\t\n\t// 启动缓存清理\n\tgo startCacheCleaner()\n}\n\n// 启动缓存清理器\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(30 * time.Minute)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\tif DebugLog {\n\t\t\tfmt.Println(\"panyq: 开始清理缓存\")\n\t\t}\n\t\t\n\t\t// 清理finalLinkCache\n\t\tfinalLinkCacheLock.Lock()\n\t\tfinalLinkCache = make(map[string]string)\n\t\tfinalLinkCacheLock.Unlock()\n\t\t\n\t\t// 清理searchResultCache\n\t\tsearchResultCacheLock.Lock()\n\t\tsearchResultCache = make(map[string][]model.SearchResult)\n\t\tsearchResultCacheLock.Unlock()\n\t\t\n\t\tif DebugLog {\n\t\t\tfmt.Println(\"panyq: 缓存清理完成\")\n\t\t}\n\t}\n}\n\n// loadActionIDsFromFile 从文件加载Action IDs\nfunc (p *PanyqPlugin) loadActionIDsFromFile() (map[string]string, error) {\n\tconfigPath := filepath.Join(\".\", ConfigFileName)\n\t\n\tdata, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\tvar ids map[string]string\n\tif err := json.Unmarshal(data, &ids); err != nil {\n\t\treturn nil, err\n\t}\n\t\n\t// 验证所有必需的键是否存在\n\tfor _, key := range ActionIDKeys {\n\t\tif _, ok := ids[key]; !ok {\n\t\t\treturn nil, fmt.Errorf(\"缓存文件中缺少键: %s\", key)\n\t\t}\n\t}\n\t\n\treturn ids, nil\n}\n\n// saveActionIDsToFile 保存Action IDs到文件\nfunc (p *PanyqPlugin) saveActionIDsToFile(ids map[string]string) error {\n\tdata, err := json.Marshal(ids)\n\tif err != nil {\n\t\treturn err\n\t}\n\t\n\tconfigPath := filepath.Join(\".\", ConfigFileName)\n\treturn os.WriteFile(configPath, data, 0644)\n}\n"
  },
  {
    "path": "plugin/pianku/html结构分析.md",
    "content": "# 片库网 (btnull.pro) 网站搜索结果HTML结构分析\n\n## 网站信息\n\n- **网站名称**: 片库网 BTNULL\n- **网站域名**: btnull.pro  \n- **搜索URL格式**: `https://btnull.pro/search/-------------.html?wd={关键词}`\n- **详情页URL格式**: `https://btnull.pro/movie/{ID}.html`\n- **播放页URL格式**: `https://btnull.pro/play/{ID}-{源ID}-{集ID}.html`\n- **主要特点**: 提供电影、剧集、动漫等多类型影视资源，支持在线播放\n\n## 搜索结果页面结构\n\n搜索结果页面的主要内容位于`.sr_lists`元素内，每个搜索结果项包含在`dl`元素中。\n\n```html\n<div class=\"sr_lists\">\n    <dl>\n        <dt><a href=\"/movie/63114.html\"><img src=\"...\" referrerpolicy=\"no-referrer\"></a></dt>\n        <dd>\n            <!-- 详细信息 -->\n        </dd>\n    </dl>\n    <!-- 更多搜索结果... -->\n</div>\n```\n\n### 单个搜索结果结构\n\n每个搜索结果包含以下主要元素：\n\n#### 1. 封面图片和详情页链接\n\n封面图片和链接位于`dt`元素中：\n\n```html\n<dt>\n    <a href=\"/movie/63114.html\">\n        <img src=\"https://www.4kfox.com/upload/vod/20250727-1/a75d775236aec4128ef805c6461ef07a.jpg\" referrerpolicy=\"no-referrer\">\n    </a>\n</dt>\n```\n\n- 详情页链接：`dt > a`的`href`属性，格式为`/movie/{ID}.html`\n- 封面图片：`dt > a > img`的`src`属性\n- ID提取：从链接URL中提取数字ID（如63114）\n\n#### 2. 详细信息\n\n详细信息位于`dd`元素中，包含多个`p`元素：\n\n```html\n<dd>\n    <p>名称：<strong><a href=\"/movie/63114.html\">凡人修仙传(2025)</a></strong><span class=\"ss1\"> [剧集][30集全]</span></p>\n    <p class=\"p0\">又名：The Immortal Ascension</p>\n    <p>地区：大陆　　类型：奇幻,古装</p>\n    <p class=\"p0\">主演：杨洋,金晨,汪铎,赵小棠,赵晴,...</p>\n    <p>简介：《凡人修仙传》讲述的是：该剧改编自忘语的同名小说，...</p>\n</dd>\n```\n\n##### 字段解析\n\n| 字段类型 | 选择器 | 说明 | 示例 |\n|---------|--------|------|------|\n| **标题** | `dd > p:first-child strong a` | 影片名称和详情页链接 | `凡人修仙传(2025)` |\n| **状态标签** | `dd > p:first-child span.ss1` | 影片状态和类型 | `[剧集][30集全]` |\n| **又名** | `dd > p.p0:contains('又名：')` | 影片别名（可能不存在） | `The Immortal Ascension` |\n| **地区类型** | `dd > p:contains('地区：')` | 地区和类型信息 | `地区：大陆　　类型：奇幻,古装` |\n| **主演** | `dd > p.p0:contains('主演：')` | 主要演员列表 | `主演：杨洋,金晨,汪铎,...` |\n| **简介** | `dd > p:last-child` | 影片简介描述 | `《凡人修仙传》讲述的是：...` |\n\n##### 数据处理说明\n\n1. **标题提取**: 从`strong > a`的文本内容中提取，通常包含年份\n2. **状态解析**: 从`span.ss1`中提取类型（剧集/电影/动漫）和状态信息\n3. **地区类型分离**: 需要解析\"地区：xxx　　类型：xxx\"格式的文本\n4. **主演处理**: 从以\"主演：\"开头的段落中提取，多个演员用逗号分隔\n5. **简介清理**: 提取纯文本内容，去除HTML标签\n\n## 详情页面结构\n\n详情页面包含更完整的影片信息、播放源链接和下载资源。\n\n### 1. 基本信息\n\n详情页的基本信息位于`.main-ui-meta`元素中：\n\n```html\n<div class=\"main-ui-meta\">\n    <h1>凡人修仙传<span class=\"year\">(2025)</span></h1>\n    <div class=\"otherbox\">当前为 30集全 资源，最后更新于 23小时前</div>\n    <div><span>导演：</span><a href=\"...\" target=\"_blank\">杨阳</a></div>\n    <div class=\"text-overflow\"><span>主演：</span><a href=\"...\" target=\"_blank\">杨洋</a>...</div>\n    <div><span>类型：</span><a href=\"...\" target=\"_blank\">奇幻</a>...</div>\n    <div><span>地区：</span>大陆</div>\n    <div><span>语言：</span>国语</div>\n    <div><span>上映：</span>2025-07-27(中国大陆)</div>\n    <div><span>时长：</span>45分钟</div>\n    <div><span>又名：</span>The Immortal Ascension</div>\n</div>\n```\n\n### 2. 播放源信息\n\n播放源信息位于`.sBox`元素中：\n\n```html\n<div class=\"sBox wrap row\">\n    <h2>在线播放\n        <div class=\"hd right\">\n            <ul class=\"py-tabs\">\n                <li class=\"on\">量子源</li>\n                <li class=\"\">如意源</li>\n            </ul>\n        </div>\n    </h2>\n    <div class=\"bd\">\n        <ul class=\"player ckp gdt bf-w\">\n            <li><a href=\"/play/63114-1-1.html\">第01集</a></li>\n            <li><a href=\"/play/63114-1-2.html\">第02集</a></li>\n            <!-- 更多集数... -->\n        </ul>\n        <ul class=\"player ckp gdt bf-w\">\n            <li><a href=\"/play/63114-2-1.html\">第01集</a></li>\n            <li><a href=\"/play/63114-2-2.html\">第02集</a></li>\n            <!-- 其他播放源... -->\n        </ul>\n    </div>\n</div>\n```\n\n#### 播放链接解析\n\n- **播放源切换**: `.py-tabs li`元素，通过`class=\"on\"`识别当前选中源\n- **播放链接**: `.player li a`的`href`属性\n- **链接格式**: `/play/{ID}-{源ID}-{集ID}.html`\n- **集数标题**: `a`元素的文本内容\n\n### 3. 磁力&网盘下载部分 ⭐ 重要\n\n这是详情页最有价值的部分，位于`#donLink`元素中：\n\n```html\n<div class=\"wrap row\">\n    <h2>磁力&网盘</h2>\n    <div class=\"down-link\" id=\"donLink\">\n        <div class=\"hd\">\n            <ul class=\"nav-tabs tab-title\">\n                <li class=\"title\">中字1080P</li>\n                <li class=\"title\">中字4K</li>\n                <li class=\"title\">百度网盘</li>\n                <li class=\"title\">迅雷网盘</li>\n                <li class=\"title\">夸克网盘</li>\n                <li class=\"title\">阿里网盘</li>\n                <li class=\"title\">天翼网盘</li>\n                <li class=\"title\">115网盘</li>\n                <li class=\"title\">UC网盘</li>\n            </ul>\n        </div>\n        <div class=\"down-list tab-content\">\n            <!-- 各个标签页的内容 -->\n        </div>\n    </div>\n</div>\n```\n\n#### 下载链接分类\n\n| 标签页类型 | 说明 | 内容 |\n|-----------|------|------|\n| **中字1080P** | 磁力链接 | 1080P分辨率的磁力资源 |\n| **中字4K** | 磁力链接 | 4K分辨率的磁力资源 |\n| **百度网盘** | 网盘链接 | 百度网盘分享链接 |\n| **迅雷网盘** | 网盘链接 | 迅雷网盘分享链接 |\n| **夸克网盘** | 网盘链接 | 夸克网盘分享链接 |\n| **阿里网盘** | 网盘链接 | 阿里云盘分享链接 |\n| **天翼网盘** | 网盘链接 | 天翼云盘分享链接 |\n| **115网盘** | 网盘链接 | 115网盘分享链接 |\n| **UC网盘** | 网盘链接 | UC网盘分享链接 |\n\n#### 单个下载链接结构\n\n每个下载项都采用统一的HTML结构：\n\n```html\n<ul class=\"gdt content\">\n    <li class=\"down-list2\">\n        <p class=\"down-list3\">\n            <a href=\"实际链接\" title=\"完整标题\" class=\"folder\">\n                显示标题\n            </a>\n        </p>\n        <span>\n            <a href=\"javascript:void(0);\" class=\"copy-btn\" data-clipboard-text=\"实际链接\">\n                <i class=\"far fa-copy\"></i> 复制\n            </a>\n        </span>\n    </li>\n</ul>\n```\n\n#### 链接类型和格式\n\n##### 磁力链接格式\n\n```html\n<a href=\"magnet:?xt=urn:btih:dde51e7d23800702e9d946f103b5c54c93d538a8&dn=The.Immortal.Ascension.2025.EP01-30.HD1080P.X264.AAC.Mandarin.CHS.XLYS\" \n   title=\"The.Immortal.Ascension.2025.EP0130.HD1080P.X264.AAC.Mandarin.CHS.XLYS[12.28G]\" \n   class=\"folder\">\n    The.Immortal.Ascension.2025.EP0130.HD1080P.X264.AAC.Mandarin.CHS.XLYS[12.28G]\n</a>\n```\n\n##### 网盘链接格式\n\n**百度网盘**:\n```html\n<a href=\"https://pan.baidu.com/s/1qg5KF7J-guvt8-jCORPf0w?pwd=1234&v=918\" \n   title=\"【国剧】凡人修仙传（2025）4K 持续更新中奇幻 古装 杨洋 金晨 4K60FPS\" \n   class=\"folder\">\n    【国剧】凡人修仙传（2025）4K 持续更新中奇幻 古装 杨洋 金晨 4K60FPS\n</a>\n```\n\n**迅雷网盘**:\n```html\n<a href=\"https://pan.xunlei.com/s/VOW_0D7L3HlSe9g4m5XN-c8XA1?pwd=3suf\" \n   title=\". ⊙o⊙【全30集.已完结】 【凡人修仙传2025】【4K高码】【国语中字】【类型：奇幻 古装】【主演：杨洋 金晨 汪铎】\" \n   class=\"folder\">\n    . ⊙o⊙【全30集.已完结】 【凡人修仙传2025】【4K高码】【国语中字】【类型：奇幻 古装】【主演：杨洋 金晨 汪铎】\n</a>\n```\n\n**夸克网盘**:\n```html\n<a href=\"https://pan.quark.cn/s/914548c6f323\" \n   title=\"⊙o⊙【全30集已完结】【凡人修仙传2025】【4K高码率】【国语中字】【类型：奇幻 古装】【主演：杨洋金晨汪铎.】【纯净分享】\" \n   class=\"folder\">\n    ⊙o⊙【全30集已完结】【凡人修仙传2025】【4K高码率】【国语中字】【类型：奇幻 古装】【主演：杨洋金晨汪铎.】【纯净分享】\n</a>\n```\n\n#### 下载链接提取策略\n\n```go\n// 提取所有下载链接\nfunc extractDownloadLinks(doc *goquery.Document) map[string][]DownloadLink {\n    links := make(map[string][]DownloadLink)\n    \n    // 遍历每个标签页\n    doc.Find(\"#donLink .nav-tabs .title\").Each(func(i int, title *goquery.Selection) {\n        tabName := strings.TrimSpace(title.Text())\n        \n        // 找到对应的内容区域\n        contentArea := doc.Find(\"#donLink .tab-content\").Eq(i)\n        \n        var tabLinks []DownloadLink\n        contentArea.Find(\".down-list2\").Each(func(j int, item *goquery.Selection) {\n            link, exists := item.Find(\".down-list3 a\").Attr(\"href\")\n            if !exists {\n                return\n            }\n            \n            title := item.Find(\".down-list3 a\").Text()\n            fullTitle, _ := item.Find(\".down-list3 a\").Attr(\"title\")\n            \n            linkType := determineLinkType(link)\n            password := extractPassword(link, title)\n            \n            downloadLink := DownloadLink{\n                Type:     linkType,\n                URL:      link,\n                Title:    strings.TrimSpace(title),\n                FullTitle: fullTitle,\n                Password: password,\n            }\n            \n            tabLinks = append(tabLinks, downloadLink)\n        })\n        \n        if len(tabLinks) > 0 {\n            links[tabName] = tabLinks\n        }\n    })\n    \n    return links\n}\n\n// 判断链接类型\nfunc determineLinkType(url string) string {\n    switch {\n    case strings.Contains(url, \"magnet:\"):\n        return \"magnet\"\n    case strings.Contains(url, \"pan.baidu.com\"):\n        return \"baidu\"\n    case strings.Contains(url, \"pan.xunlei.com\"):\n        return \"xunlei\"\n    case strings.Contains(url, \"pan.quark.cn\"):\n        return \"quark\"\n    case strings.Contains(url, \"aliyundrive.com\"), strings.Contains(url, \"alipan.com\"):\n        return \"aliyun\"\n    case strings.Contains(url, \"cloud.189.cn\"):\n        return \"tianyi\"\n    case strings.Contains(url, \"115.com\"):\n        return \"115\"\n    case strings.Contains(url, \"drive.uc.cn\"):\n        return \"uc\"\n    default:\n        return \"others\"\n    }\n}\n\n// 提取密码\nfunc extractPassword(url, title string) string {\n    // 从URL中提取\n    if match := regexp.MustCompile(`[?&]pwd=([^&]+)`).FindStringSubmatch(url); len(match) > 1 {\n        return match[1]\n    }\n    \n    // 从标题中提取\n    patterns := []string{\n        `提取码[：:]\\s*([0-9a-zA-Z]+)`,\n        `密码[：:]\\s*([0-9a-zA-Z]+)`,\n        `pwd[：:]\\s*([0-9a-zA-Z]+)`,\n    }\n    \n    for _, pattern := range patterns {\n        if match := regexp.MustCompile(pattern).FindStringSubmatch(title); len(match) > 1 {\n            return match[1]\n        }\n    }\n    \n    return \"\"\n}\n```\n\n## 分页结构\n\n由于提供的HTML示例中没有明显的分页结构，可能需要进一步分析或该网站采用Ajax加载更多结果的方式。\n\n## 请求头要求\n\n根据搜索请求信息，建议设置以下请求头：\n\n```http\nGET /search/-------------.html?wd={关键词} HTTP/1.1\nHost: btnull.pro\nReferer: https://btnull.pro/\nUser-Agent: 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\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\nAccept-Language: zh-CN,zh;q=0.9,en;q=0.8\nConnection: keep-alive\n```\n\n## 数据提取策略\n\n### 1. 搜索结果提取\n\n```go\n// 伪代码示例\nfunc extractSearchResults(doc *goquery.Document) []SearchResult {\n    var results []SearchResult\n    \n    doc.Find(\".sr_lists dl\").Each(func(i int, s *goquery.Selection) {\n        // 提取链接和ID\n        link, _ := s.Find(\"dt a\").Attr(\"href\")\n        id := extractIDFromURL(link) // 从 /movie/63114.html 提取 63114\n        \n        // 提取封面图片\n        image, _ := s.Find(\"dt a img\").Attr(\"src\")\n        \n        // 提取标题\n        title := s.Find(\"dd p:first-child strong a\").Text()\n        \n        // 提取状态标签\n        status := s.Find(\"dd p:first-child span.ss1\").Text()\n        \n        // 提取其他信息\n        var actors, description, region, types string\n        s.Find(\"dd p\").Each(func(j int, p *goquery.Selection) {\n            text := p.Text()\n            if strings.Contains(text, \"主演：\") {\n                actors = strings.TrimPrefix(text, \"主演：\")\n            } else if strings.Contains(text, \"地区：\") {\n                // 解析地区和类型\n                parseRegionAndTypes(text, &region, &types)\n            } else if j == s.Find(\"dd p\").Length()-1 {\n                // 最后一个p元素通常是简介\n                description = strings.TrimPrefix(text, \"简介：\")\n            }\n        })\n        \n        result := SearchResult{\n            ID:          id,\n            Title:       title,\n            Status:      status,\n            Image:       image,\n            Link:        link,\n            Actors:      actors,\n            Description: description,\n            Region:      region,\n            Types:       types,\n        }\n        results = append(results, result)\n    })\n    \n    return results\n}\n```\n\n### 2. 详情页信息提取\n\n详情页可以提取更完整的信息，包括：\n- 导演信息\n- 完整的演员列表  \n- 上映时间\n- 影片时长\n- 播放源和集数列表\n\n### 3. 播放源提取\n\n```go\nfunc extractPlaySources(doc *goquery.Document) []PlaySource {\n    var sources []PlaySource\n    \n    // 提取播放源名称\n    sourceNames := []string{}\n    doc.Find(\".py-tabs li\").Each(func(i int, s *goquery.Selection) {\n        sourceNames = append(sourceNames, s.Text())\n    })\n    \n    // 提取每个播放源的集数链接\n    doc.Find(\".player\").Each(func(i int, player *goquery.Selection) {\n        source := PlaySource{\n            Name: sourceNames[i],\n            Episodes: []Episode{},\n        }\n        \n        player.Find(\"li a\").Each(func(j int, a *goquery.Selection) {\n            href, _ := a.Attr(\"href\")\n            title := a.Text()\n            \n            episode := Episode{\n                Title: title,\n                URL:   href,\n            }\n            source.Episodes = append(source.Episodes, episode)\n        })\n        \n        sources = append(sources, source)\n    })\n    \n    return sources\n}\n```\n\n## 注意事项\n\n1. **图片防盗链**: 图片标签包含`referrerpolicy=\"no-referrer\"`属性，需要注意请求头设置\n2. **URL编码**: 搜索关键词需要进行URL编码\n3. **容错处理**: 某些字段（如又名、主演）可能不存在，需要进行空值检查\n4. **ID提取**: 需要从URL路径中正确提取数字ID\n5. **文本清理**: 需要去除多余的空格、换行符等字符\n6. **播放源**: 不同播放源可能有不同的集数，需要分别处理\n\n## 总结\n\n片库网采用较为标准的HTML结构，搜索结果以列表形式展示，每个结果包含基本的影片信息。详情页提供更完整的信息和播放源。在实现插件时需要注意处理各种边界情况和数据清理工作。"
  },
  {
    "path": "plugin/pianku/pianku.go",
    "content": "package pianku\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\n// 在init函数中注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewPiankuPlugin())\n}\n\nconst (\n\t// 基础URL\n\tBaseURL = \"https://btnull.pro\"\n\tSearchPath = \"/search/-------------.html\"\n\t\n\t// 默认参数\n\tMaxRetries = 3\n\tTimeoutSeconds = 30\n)\n\n// 预编译的正则表达式\nvar (\n\t// 提取电影ID的正则表达式\n\tmovieIDRegex = regexp.MustCompile(`/movie/(\\d+)\\.html`)\n\t\n\t// 年份提取正则\n\tyearRegex = regexp.MustCompile(`\\((\\d{4})\\)`)\n\t\n\t// 地区和类型分离正则\n\tregionTypeRegex = regexp.MustCompile(`地区：([^　]*?)　+类型：(.*)`)\n\t\n\t// 磁力链接正则\n\tmagnetLinkRegex = regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9a-fA-F]{40}[^\"'\\s]*`)\n\t\n\t// ED2K链接正则\n\ted2kLinkRegex = regexp.MustCompile(`ed2k://\\|file\\|[^|]+\\|[^|]+\\|[^|]+\\|/?`)\n\t\n\t// 网盘链接正则表达式\n\tpanLinkRegexes = map[string]*regexp.Regexp{\n\t\t\"baidu\":   regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9a-zA-Z_-]+(?:\\?pwd=[0-9a-zA-Z]+)?(?:&v=\\d+)?`),\n\t\t\"aliyun\":  regexp.MustCompile(`https?://(?:www\\.)?alipan\\.com/s/[0-9a-zA-Z_-]+`),\n\t\t\"tianyi\":  regexp.MustCompile(`https?://cloud\\.189\\.cn/t/[0-9a-zA-Z_-]+(?:\\([^)]*\\))?`),\n\t\t\"uc\":      regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9a-fA-F]+(?:\\?[^\"\\s]*)?`),\n\t\t\"mobile\":  regexp.MustCompile(`https?://caiyun\\.139\\.com/[^\"\\s]+`),\n\t\t\"115\":     regexp.MustCompile(`https?://(?:115\\.com|115cdn\\.com)/s/[0-9a-zA-Z_-]+(?:\\?[^\"\\s]*)?`),\n\t\t\"pikpak\":  regexp.MustCompile(`https?://mypikpak\\.com/s/[0-9a-zA-Z_-]+`),\n\t\t\"xunlei\":  regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9a-zA-Z_-]+(?:\\?pwd=[0-9a-zA-Z]+)?`),\n\t\t\"123\":     regexp.MustCompile(`https?://(?:www\\.)?(?:123pan\\.com|123684\\.com)/s/[0-9a-zA-Z_-]+(?:\\?[^\"\\s]*)?`),\n\t\t\"quark\":   regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9a-fA-F]+(?:\\?pwd=[0-9a-zA-Z]+)?`),\n\t}\n\t\n\t// 密码提取正则表达式\n\tpasswordRegexes = []*regexp.Regexp{\n\t\tregexp.MustCompile(`[?&]pwd=([0-9a-zA-Z]+)`),                        // URL中的pwd参数\n\t\tregexp.MustCompile(`[?&]password=([0-9a-zA-Z]+)`),                   // URL中的password参数\n\t\tregexp.MustCompile(`提取码[：:]\\s*([0-9a-zA-Z]+)`),                    // 提取码：xxxx\n\t\tregexp.MustCompile(`访问码[：:]\\s*([0-9a-zA-Z]+)`),                    // 访问码：xxxx\n\t\tregexp.MustCompile(`密码[：:]\\s*([0-9a-zA-Z]+)`),                     // 密码：xxxx\n\t\tregexp.MustCompile(`验证码[：:]\\s*([0-9a-zA-Z]+)`),                    // 验证码：xxxx\n\t\tregexp.MustCompile(`口令[：:]\\s*([0-9a-zA-Z]+)`),                     // 口令：xxxx\n\t\tregexp.MustCompile(`（访问码[：:]\\s*([0-9a-zA-Z]+)）`),                  // （访问码：xxxx）\n\t}\n)\n\n// 常用UA列表\nvar userAgents = []string{\n\t\"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\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36\",\n}\n\n// PiankuPlugin 片库网搜索插件\ntype PiankuPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\n// NewPiankuPlugin 创建新的片库网插件\nfunc NewPiankuPlugin() *PiankuPlugin {\n\treturn &PiankuPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"pianku\", 3), // 优先级3，标准质量数据源\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *PiankuPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *PiankuPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实际的搜索实现\nfunc (p *PiankuPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 处理扩展参数\n\tsearchKeyword := keyword\n\tif ext != nil {\n\t\tif titleEn, exists := ext[\"title_en\"]; exists {\n\t\t\tif titleEnStr, ok := titleEn.(string); ok && titleEnStr != \"\" {\n\t\t\t\tsearchKeyword = titleEnStr\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 构建请求URL\n\tsearchURL := fmt.Sprintf(\"%s%s?wd=%s\", BaseURL, SearchPath, url.QueryEscape(searchKeyword))\n\t\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), TimeoutSeconds*time.Second)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 设置请求头\n\tp.setRequestHeaders(req)\n\t\n\t// 发送HTTP请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] HTML解析失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 提取搜索结果基本信息\n\tsearchResults := p.extractSearchResults(doc)\n\t\n\t// 为每个搜索结果获取详情页的下载链接\n\tvar finalResults []model.SearchResult\n\tfor _, result := range searchResults {\n\t\t// 获取详情页链接\n\t\tif len(result.Links) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tdetailURL := result.Links[0].URL\n\t\t\n\t\t// 请求详情页并解析下载链接\n\t\tdownloadLinks, err := p.fetchDetailPageLinks(client, detailURL)\n\t\tif err != nil {\n\t\t\t// 如果获取详情页失败，仍然保留原始结果\n\t\t\tfinalResults = append(finalResults, result)\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 更新结果的链接为真正的下载链接\n\t\tif len(downloadLinks) > 0 {\n\t\t\tresult.Links = downloadLinks\n\t\t\tfinalResults = append(finalResults, result)\n\t\t}\n\t}\n\t\n\t// 关键词过滤\n\treturn plugin.FilterResultsByKeyword(finalResults, searchKeyword), nil\n}\n\n// setRequestHeaders 设置请求头\nfunc (p *PiankuPlugin) setRequestHeaders(req *http.Request) {\n\treq.Header.Set(\"User-Agent\", userAgents[0])\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *PiankuPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tvar lastErr error\n\t\n\tfor i := 0; i < MaxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", MaxRetries, lastErr)\n}\n\n// extractSearchResults 提取搜索结果\nfunc (p *PiankuPlugin) extractSearchResults(doc *goquery.Document) []model.SearchResult {\n\tvar results []model.SearchResult\n\t\n\t// 查找搜索结果容器\n\tdoc.Find(\".sr_lists dl\").Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.extractSingleResult(s)\n\t\tif result.UniqueID != \"\" && len(result.Links) > 0 {\n\t\t\tresults = append(results, result)\n\t\t}\n\t})\n\t\n\treturn results\n}\n\n// extractSingleResult 提取单个搜索结果\nfunc (p *PiankuPlugin) extractSingleResult(s *goquery.Selection) model.SearchResult {\n\t// 提取链接和ID\n\tlink, exists := s.Find(\"dt a\").Attr(\"href\")\n\tif !exists {\n\t\treturn model.SearchResult{} // 返回空结果\n\t}\n\t\n\t// 提取电影ID\n\tmovieID := p.extractMovieID(link)\n\tif movieID == \"\" {\n\t\treturn model.SearchResult{}\n\t}\n\t\n\t// 提取封面图片（暂时不使用，但保留用于未来扩展）\n\t_, _ = s.Find(\"dt a img\").Attr(\"src\")\n\t\n\t// 提取标题\n\ttitle := strings.TrimSpace(s.Find(\"dd p:first-child strong a\").Text())\n\tif title == \"\" {\n\t\treturn model.SearchResult{}\n\t}\n\t\n\t// 提取状态标签\n\tstatus := strings.TrimSpace(s.Find(\"dd p:first-child span.ss1\").Text())\n\t\n\t// 解析详细信息\n\tvar actors, description, region, types, altName string\n\t\n\ts.Find(\"dd p\").Each(func(j int, p *goquery.Selection) {\n\t\ttext := strings.TrimSpace(p.Text())\n\t\t\n\t\tif strings.HasPrefix(text, \"又名：\") {\n\t\t\taltName = strings.TrimPrefix(text, \"又名：\")\n\t\t} else if strings.Contains(text, \"地区：\") && strings.Contains(text, \"类型：\") {\n\t\t\t// 解析地区和类型\n\t\t\tregion, types = parseRegionAndTypes(text)\n\t\t} else if strings.HasPrefix(text, \"主演：\") {\n\t\t\tactors = strings.TrimPrefix(text, \"主演：\")\n\t\t} else if strings.HasPrefix(text, \"简介：\") {\n\t\t\tdescription = strings.TrimPrefix(text, \"简介：\")\n\t\t} else if !strings.Contains(text, \"名称：\") && !strings.Contains(text, \"又名：\") && \n\t\t\t\t !strings.Contains(text, \"地区：\") && !strings.Contains(text, \"主演：\") && text != \"\" {\n\t\t\t// 可能是简介（没有\"简介：\"前缀的情况）\n\t\t\tif description == \"\" && len(text) > 10 {\n\t\t\t\tdescription = text\n\t\t\t}\n\t\t}\n\t})\n\t\n\t// 构建完整的详情页URL\n\tfullLink := p.buildFullURL(link)\n\t\n\t// 设置标签\n\ttags := []string{}\n\tif region != \"\" {\n\t\ttags = append(tags, region)\n\t}\n\tif types != \"\" {\n\t\t// 分割类型标签\n\t\ttypeList := strings.Split(types, \",\")\n\t\tfor _, t := range typeList {\n\t\t\tt = strings.TrimSpace(t)\n\t\t\tif t != \"\" {\n\t\t\t\ttags = append(tags, t)\n\t\t\t}\n\t\t}\n\t}\n\tif status != \"\" {\n\t\ttags = append(tags, status)\n\t}\n\t\n\t// 构建内容描述\n\tcontent := description\n\tif actors != \"\" && content != \"\" {\n\t\tcontent = fmt.Sprintf(\"主演：%s\\n%s\", actors, content)\n\t} else if actors != \"\" {\n\t\tcontent = fmt.Sprintf(\"主演：%s\", actors)\n\t}\n\t\n\tif altName != \"\" {\n\t\tif content != \"\" {\n\t\t\tcontent = fmt.Sprintf(\"又名：%s\\n%s\", altName, content)\n\t\t} else {\n\t\t\tcontent = fmt.Sprintf(\"又名：%s\", altName)\n\t\t}\n\t}\n\t\n\t// 创建链接（使用详情页作为主要链接）\n\tlinks := []model.Link{\n\t\t{\n\t\t\tType: \"others\", // 详情页链接\n\t\t\tURL:  fullLink,\n\t\t},\n\t}\n\t\n\tresult := model.SearchResult{\n\t\tUniqueID: fmt.Sprintf(\"%s-%s\", p.Name(), movieID),\n\t\tTitle:    title,\n\t\tContent:  content,\n\t\tDatetime: time.Now(), // 无法从搜索结果获取准确时间，使用当前时间\n\t\tTags:     tags,\n\t\tLinks:    links,\n\t\tChannel:  \"\", // 插件搜索结果必须为空字符串\n\t}\n\t\n\treturn result\n}\n\n// extractMovieID 从URL中提取电影ID\nfunc (p *PiankuPlugin) extractMovieID(url string) string {\n\tmatches := movieIDRegex.FindStringSubmatch(url)\n\tif len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// parseRegionAndTypes 解析地区和类型信息\nfunc parseRegionAndTypes(text string) (region, types string) {\n\tmatches := regionTypeRegex.FindStringSubmatch(text)\n\tif len(matches) > 2 {\n\t\tregion = strings.TrimSpace(matches[1])\n\t\ttypes = strings.TrimSpace(matches[2])\n\t}\n\treturn\n}\n\n// buildFullURL 构建完整的URL\nfunc (p *PiankuPlugin) buildFullURL(path string) string {\n\tif strings.HasPrefix(path, \"http\") {\n\t\treturn path\n\t}\n\treturn BaseURL + path\n}\n\n// fetchDetailPageLinks 获取详情页的下载链接\nfunc (p *PiankuPlugin) fetchDetailPageLinks(client *http.Client, detailURL string) ([]model.Link, error) {\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), TimeoutSeconds*time.Second)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建详情页请求失败: %w\", err)\n\t}\n\t\n\t// 设置请求头\n\tp.setRequestHeaders(req)\n\t\n\t// 发送HTTP请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"详情页请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"详情页请求返回状态码: %d\", resp.StatusCode)\n\t}\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"详情页HTML解析失败: %w\", err)\n\t}\n\t\n\t// 提取下载链接\n\treturn p.extractDownloadLinks(doc), nil\n}\n\n// extractDownloadLinks 提取详情页中的下载链接\nfunc (p *PiankuPlugin) extractDownloadLinks(doc *goquery.Document) []model.Link {\n\tvar links []model.Link\n\tseenURLs := make(map[string]bool) // 用于去重\n\t\n\t// 查找下载链接区域\n\tdoc.Find(\"#donLink .down-list2\").Each(func(i int, s *goquery.Selection) {\n\t\tlinkURL, exists := s.Find(\".down-list3 a\").Attr(\"href\")\n\t\tif !exists || linkURL == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 获取链接标题\n\t\ttitle := strings.TrimSpace(s.Find(\".down-list3 a\").Text())\n\t\tif title == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 验证链接有效性\n\t\tif !p.isValidLink(linkURL) {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 去重检查\n\t\tif seenURLs[linkURL] {\n\t\t\treturn\n\t\t}\n\t\tseenURLs[linkURL] = true\n\t\t\n\t\t// 判断链接类型\n\t\tlinkType := p.determineLinkType(linkURL)\n\t\t\n\t\t// 提取密码\n\t\tpassword := p.extractPassword(linkURL, title)\n\t\t\n\t\t// 创建链接对象\n\t\tlink := model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      linkURL,\n\t\t\tPassword: password,\n\t\t}\n\t\t\n\t\tlinks = append(links, link)\n\t})\n\t\n\treturn links\n}\n\n// isValidLink 验证链接是否有效\nfunc (p *PiankuPlugin) isValidLink(url string) bool {\n\t// 检查是否为磁力链接\n\tif magnetLinkRegex.MatchString(url) {\n\t\treturn true\n\t}\n\t\n\t// 检查是否为ED2K链接\n\tif ed2kLinkRegex.MatchString(url) {\n\t\treturn true\n\t}\n\t\n\t// 检查是否为有效的网盘链接\n\tfor _, regex := range panLinkRegexes {\n\t\tif regex.MatchString(url) {\n\t\t\treturn true\n\t\t}\n\t}\n\t\n\t// 如果都不匹配，则不是有效链接\n\treturn false\n}\n\n// determineLinkType 判断链接类型\nfunc (p *PiankuPlugin) determineLinkType(url string) string {\n\t// 检查磁力链接\n\tif magnetLinkRegex.MatchString(url) {\n\t\treturn \"magnet\"\n\t}\n\t\n\t// 检查ED2K链接\n\tif ed2kLinkRegex.MatchString(url) {\n\t\treturn \"ed2k\"\n\t}\n\t\n\t// 检查网盘链接\n\tfor panType, regex := range panLinkRegexes {\n\t\tif regex.MatchString(url) {\n\t\t\treturn panType\n\t\t}\n\t}\n\t\n\treturn \"others\"\n}\n\n// extractPassword 提取密码\nfunc (p *PiankuPlugin) extractPassword(url, title string) string {\n\t// 首先从链接URL中提取密码\n\tfor _, regex := range passwordRegexes {\n\t\tif matches := regex.FindStringSubmatch(url); len(matches) > 1 {\n\t\t\treturn matches[1]\n\t\t}\n\t}\n\t\n\t// 然后从标题文本中提取密码\n\tfor _, regex := range passwordRegexes {\n\t\tif matches := regex.FindStringSubmatch(title); len(matches) > 1 {\n\t\t\treturn matches[1]\n\t\t}\n\t}\n\t\n\treturn \"\"\n}"
  },
  {
    "path": "plugin/plugin.go",
    "content": "package plugin\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"pansou/config\"\n\t\"pansou/model\"\n)\n\n// ============================================================\n// 第一部分：接口定义和类型\n// ============================================================\n\n// AsyncSearchPlugin 异步搜索插件接口\ntype AsyncSearchPlugin interface {\n\t// Name 返回插件名称\n\tName() string\n\t\n\t// Priority 返回插件优先级\n\tPriority() int\n\t\n\t// AsyncSearch 异步搜索方法\n\tAsyncSearch(keyword string, searchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error), mainCacheKey string, ext map[string]interface{}) ([]model.SearchResult, error)\n\t\n\t// SetMainCacheKey 设置主缓存键\n\tSetMainCacheKey(key string)\n\t\n\t// SetCurrentKeyword 设置当前搜索关键词（用于日志显示）\n\tSetCurrentKeyword(keyword string)\n\t\n\t// Search 兼容性方法（内部调用AsyncSearch）\n\tSearch(keyword string, ext map[string]interface{}) ([]model.SearchResult, error)\n\t\n\t// SkipServiceFilter 返回是否跳过Service层的关键词过滤\n\t// 对于磁力搜索等需要宽泛结果的插件，应返回true\n\tSkipServiceFilter() bool\n}\n\n// PluginWithWebHandler 支持Web路由的插件接口\n// 插件可以选择实现此接口来注册自定义的HTTP路由\ntype PluginWithWebHandler interface {\n\tAsyncSearchPlugin // 继承搜索插件接口\n\t\n\t// RegisterWebRoutes 注册Web路由\n\t// router: gin的路由组，插件可以在此注册自己的路由\n\tRegisterWebRoutes(router *gin.RouterGroup)\n}\n\n// InitializablePlugin 支持延迟初始化的插件接口\n// 插件可以实现此接口，将初始化逻辑延迟到真正被使用时执行\ntype InitializablePlugin interface {\n\tAsyncSearchPlugin // 继承搜索插件接口\n\t\n\t// Initialize 执行插件初始化（创建目录、加载数据等）\n\t// 只会被调用一次，应该是幂等的\n\tInitialize() error\n}\n\n// ============================================================\n// 第二部分：全局变量和注册表\n// ============================================================\n\n// 全局异步插件注册表\nvar (\n\tglobalRegistry     = make(map[string]AsyncSearchPlugin)\n\tglobalRegistryLock sync.RWMutex\n)\n\n// 工作池和统计相关变量\nvar (\n\t// API响应缓存，键为关键词，值为缓存的响应（仅内存，不持久化）\n\tapiResponseCache = sync.Map{}\n\t\n\t// 工作池相关变量\n\tbackgroundWorkerPool chan struct{}\n\tbackgroundTasksCount int32 = 0\n\t\n\t// 统计数据 (仅用于内部监控)\n\tcacheHits         int64 = 0\n\tcacheMisses       int64 = 0\n\tasyncCompletions  int64 = 0\n\t\n\t// 初始化标志\n\tinitialized       bool = false\n\tinitLock          sync.Mutex\n\t\n\t// 默认配置值\n\tdefaultAsyncResponseTimeout = 4 * time.Second\n\tdefaultPluginTimeout = 30 * time.Second\n\tdefaultCacheTTL = 1 * time.Hour  // 恢复但仅用于内存缓存\n\tdefaultMaxBackgroundWorkers = 20\n\tdefaultMaxBackgroundTasks = 100\n\t\n\t// 缓存访问频率记录\n\tcacheAccessCount = sync.Map{}\n\t\n\t// 缓存清理相关变量\n\tlastCleanupTime = time.Now()\n\tcleanupMutex    sync.Mutex\n)\n\n// 全局序列化器引用（由主程序设置）\nvar globalCacheSerializer interface {\n\tSerialize(interface{}) ([]byte, error)\n\tDeserialize([]byte, interface{}) error\n}\n\n// 缓存响应结构（仅内存，不持久化到磁盘）\ntype cachedResponse struct {\n\tResults   []model.SearchResult `json:\"results\"`\n\tTimestamp time.Time           `json:\"timestamp\"`\n\tComplete  bool                `json:\"complete\"`\n\tLastAccess time.Time          `json:\"last_access\"`\n\tAccessCount int               `json:\"access_count\"`\n}\n\n// ============================================================\n// 第三部分：插件注册和管理\n// ============================================================\n\n// RegisterGlobalPlugin 注册异步插件到全局注册表\nfunc RegisterGlobalPlugin(plugin AsyncSearchPlugin) {\n\tif plugin == nil {\n\t\treturn\n\t}\n\t\n\tglobalRegistryLock.Lock()\n\tdefer globalRegistryLock.Unlock()\n\t\n\tname := plugin.Name()\n\tif name == \"\" {\n\t\treturn\n\t}\n\t\n\tglobalRegistry[name] = plugin\n}\n\n// GetRegisteredPlugins 获取所有已注册的异步插件\nfunc GetRegisteredPlugins() []AsyncSearchPlugin {\n\tglobalRegistryLock.RLock()\n\tdefer globalRegistryLock.RUnlock()\n\t\n\tplugins := make([]AsyncSearchPlugin, 0, len(globalRegistry))\n\tfor _, plugin := range globalRegistry {\n\t\tplugins = append(plugins, plugin)\n\t}\n\t\n\treturn plugins\n}\n\n// GetPluginByName 根据名称获取已注册的插件\nfunc GetPluginByName(name string) (AsyncSearchPlugin, bool) {\n\tglobalRegistryLock.RLock()\n\tdefer globalRegistryLock.RUnlock()\n\t\n\tplugin, exists := globalRegistry[name]\n\treturn plugin, exists\n}\n\n// PluginManager 异步插件管理器\ntype PluginManager struct {\n\tplugins []AsyncSearchPlugin\n}\n\n// NewPluginManager 创建新的异步插件管理器\nfunc NewPluginManager() *PluginManager {\n\treturn &PluginManager{\n\t\tplugins: make([]AsyncSearchPlugin, 0),\n\t}\n}\n\n// RegisterPlugin 注册异步插件\nfunc (pm *PluginManager) RegisterPlugin(plugin AsyncSearchPlugin) {\n\t// 如果插件支持延迟初始化，先执行初始化\n\tif initPlugin, ok := plugin.(InitializablePlugin); ok {\n\t\tif err := initPlugin.Initialize(); err != nil {\n\t\t\tfmt.Printf(\"[PluginManager] 插件 %s 初始化失败: %v，跳过注册\\n\", plugin.Name(), err)\n\t\t\treturn\n\t\t}\n\t}\n\t\n\tpm.plugins = append(pm.plugins, plugin)\n}\n\n// RegisterAllGlobalPlugins 注册所有全局异步插件\nfunc (pm *PluginManager) RegisterAllGlobalPlugins() {\n\tallPlugins := GetRegisteredPlugins()\n\tfor _, plugin := range allPlugins {\n\t\tpm.RegisterPlugin(plugin)\n\t}\n}\n\n// RegisterGlobalPluginsWithFilter 根据过滤器注册全局异步插件\n// enabledPlugins: nil表示未设置（不启用任何插件），空切片表示设置为空（不启用任何插件），具体列表表示启用指定插件\nfunc (pm *PluginManager) RegisterGlobalPluginsWithFilter(enabledPlugins []string) {\n\tallPlugins := GetRegisteredPlugins()\n\t\n\t// nil 表示未设置环境变量，不启用任何插件\n\tif enabledPlugins == nil {\n\t\treturn\n\t}\n\t\n\t// 空切片表示设置为空字符串，也不启用任何插件\n\tif len(enabledPlugins) == 0 {\n\t\treturn\n\t}\n\t\n\t// 创建启用插件名称的映射表，用于快速查找\n\tenabledMap := make(map[string]bool)\n\tfor _, name := range enabledPlugins {\n\t\tenabledMap[name] = true\n\t}\n\t\n\t// 只注册在启用列表中的插件\n\tfor _, plugin := range allPlugins {\n\t\tif enabledMap[plugin.Name()] {\n\t\t\tpm.RegisterPlugin(plugin)\n\t\t}\n\t}\n}\n\n// GetPlugins 获取所有注册的异步插件\nfunc (pm *PluginManager) GetPlugins() []AsyncSearchPlugin {\n\treturn pm.plugins\n}\n\n// ============================================================\n// 第四部分：工具函数\n// ============================================================\n\n// FilterResultsByKeyword 根据关键词过滤搜索结果的全局辅助函数\nfunc FilterResultsByKeyword(results []model.SearchResult, keyword string) []model.SearchResult {\n\tif keyword == \"\" {\n\t\treturn results\n\t}\n\t\n\t// 预估过滤后会保留80%的结果\n\tfilteredResults := make([]model.SearchResult, 0, len(results)*8/10)\n\n\t// 将关键词转为小写，用于不区分大小写的比较\n\tlowerKeyword := strings.ToLower(keyword)\n\n\t// 将关键词按空格分割，用于支持多关键词搜索\n\tkeywords := strings.Fields(lowerKeyword)\n\n\tfor _, result := range results {\n\t\t// 将标题和内容转为小写\n\t\tlowerTitle := strings.ToLower(result.Title)\n\t\tlowerContent := strings.ToLower(result.Content)\n\n\t\t// 检查每个关键词是否在标题或内容中\n\t\tmatched := true\n\t\tfor _, kw := range keywords {\n\t\t\t// 对于所有关键词，检查是否在标题或内容中\n\t\t\tif !strings.Contains(lowerTitle, kw) && !strings.Contains(lowerContent, kw) {\n\t\t\t\tmatched = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif matched {\n\t\t\tfilteredResults = append(filteredResults, result)\n\t\t}\n\t}\n\n\treturn filteredResults\n}\n\n// ============================================================\n// 第五部分：异步插件基础设施（初始化、工作池、缓存）\n// ============================================================\n\n// cleanupExpiredApiCache 清理过期API缓存的函数\nfunc cleanupExpiredApiCache() {\n\tcleanupMutex.Lock()\n\tdefer cleanupMutex.Unlock()\n\t\n\tnow := time.Now()\n\t// 只有距离上次清理超过30分钟才执行\n\tif now.Sub(lastCleanupTime) < 30*time.Minute {\n\t\treturn\n\t}\n\t\n\tcleanedCount := 0\n\ttotalCount := 0\n\tdeletedKeys := make([]string, 0)\n\t\n\t// 清理已过期的缓存（基于实际TTL + 合理的宽限期）\n\tapiResponseCache.Range(func(key, value interface{}) bool {\n\t\ttotalCount++\n\t\tif cached, ok := value.(cachedResponse); ok {\n\t\t\t// 使用默认TTL + 30分钟宽限期，避免过于激进的清理\n\t\t\texpireThreshold := defaultCacheTTL + 30*time.Minute\n\t\t\tif now.Sub(cached.Timestamp) > expireThreshold {\n\t\t\t\tkeyStr := key.(string)\n\t\t\t\tapiResponseCache.Delete(key)\n\t\t\t\tdeletedKeys = append(deletedKeys, keyStr)\n\t\t\t\tcleanedCount++\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n\t\n\t// 清理访问计数缓存中对应的项\n\tfor _, key := range deletedKeys {\n\t\tcacheAccessCount.Delete(key)\n\t}\n\t\n\tlastCleanupTime = now\n\t\n\t// 记录清理日志（仅在有清理时输出）\n\tif cleanedCount > 0 {\n\t\tfmt.Printf(\"[Cache] 清理过期缓存: 删除 %d/%d 项，释放内存\\n\", cleanedCount, totalCount)\n\t}\n}\n\n// initAsyncPlugin 初始化异步插件配置\nfunc initAsyncPlugin() {\n\tinitLock.Lock()\n\tdefer initLock.Unlock()\n\t\n\tif initialized {\n\t\treturn\n\t}\n\t\n\t// 如果配置已加载，则从配置读取工作池大小\n\tmaxWorkers := defaultMaxBackgroundWorkers\n\tif config.AppConfig != nil {\n\t\tmaxWorkers = config.AppConfig.AsyncMaxBackgroundWorkers\n\t}\n\t\n\tbackgroundWorkerPool = make(chan struct{}, maxWorkers)\n\t\n\t// 异步插件本地缓存系统已移除，现在只依赖主缓存系统\n\t\n\tinitialized = true\n}\n\n// InitAsyncPluginSystem 导出的初始化函数，用于确保异步插件系统初始化\nfunc InitAsyncPluginSystem() {\n\tinitAsyncPlugin()\n}\n\n// acquireWorkerSlot 尝试获取工作槽\nfunc acquireWorkerSlot() bool {\n\t// 获取最大任务数\n\tmaxTasks := int32(defaultMaxBackgroundTasks)\n\tif config.AppConfig != nil {\n\t\tmaxTasks = int32(config.AppConfig.AsyncMaxBackgroundTasks)\n\t}\n\t\n\t// 检查总任务数\n\tif atomic.LoadInt32(&backgroundTasksCount) >= maxTasks {\n\t\treturn false\n\t}\n\t\n\t// 尝试获取工作槽\n\tselect {\n\tcase backgroundWorkerPool <- struct{}{}:\n\t\tatomic.AddInt32(&backgroundTasksCount, 1)\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// releaseWorkerSlot 释放工作槽\nfunc releaseWorkerSlot() {\n\t<-backgroundWorkerPool\n\tatomic.AddInt32(&backgroundTasksCount, -1)\n}\n\n// recordCacheHit 记录缓存命中 (内部使用)\nfunc recordCacheHit() {\n\tatomic.AddInt64(&cacheHits, 1)\n}\n\n// recordCacheMiss 记录缓存未命中 (内部使用)\nfunc recordCacheMiss() {\n\tatomic.AddInt64(&cacheMisses, 1)\n}\n\n// recordAsyncCompletion 记录异步完成 (内部使用)\nfunc recordAsyncCompletion() {\n\tatomic.AddInt64(&asyncCompletions, 1)\n}\n\n// recordCacheAccess 记录缓存访问次数，用于智能缓存策略（仅内存）\nfunc recordCacheAccess(key string) {\n\t// 更新缓存项的访问时间和计数\n\tif cached, ok := apiResponseCache.Load(key); ok {\n\t\tcachedItem := cached.(cachedResponse)\n\t\tcachedItem.LastAccess = time.Now()\n\t\tcachedItem.AccessCount++\n\t\tapiResponseCache.Store(key, cachedItem)\n\t}\n\t\n\t// 更新全局访问计数\n\tif count, ok := cacheAccessCount.Load(key); ok {\n\t\tcacheAccessCount.Store(key, count.(int) + 1)\n\t} else {\n\t\tcacheAccessCount.Store(key, 1)\n\t}\n\t\n\t// 触发定期清理（异步执行，不阻塞当前操作）\n\tgo cleanupExpiredApiCache()\n}\n\n// ============================================================\n// 第六部分：BaseAsyncPlugin 结构和构造函数\n// ============================================================\n\n// BaseAsyncPlugin 基础异步插件结构\ntype BaseAsyncPlugin struct {\n\tname               string\n\tpriority           int\n\tclient             *http.Client  // 用于短超时的客户端\n\tbackgroundClient   *http.Client  // 用于长超时的客户端\n\tcacheTTL           time.Duration // 内存缓存有效期\n\tmainCacheUpdater   func(string, []model.SearchResult, time.Duration, bool, string) error // 主缓存更新函数（支持IsFinal参数，接收原始数据，最后参数为关键词）\n\tMainCacheKey       string        // 主缓存键，导出字段\n\tcurrentKeyword     string        // 当前搜索的关键词，用于日志显示\n\tfinalUpdateTracker map[string]bool // 追踪已更新的最终结果缓存\n\tfinalUpdateMutex   sync.RWMutex  // 保护finalUpdateTracker的并发访问\n\tskipServiceFilter  bool          // 是否跳过Service层的关键词过滤\n}\n\n// NewBaseAsyncPlugin 创建基础异步插件\nfunc NewBaseAsyncPlugin(name string, priority int) *BaseAsyncPlugin {\n\t// 确保异步插件已初始化\n\tif !initialized {\n\t\tinitAsyncPlugin()\n\t}\n\t\n\t// 确定超时和缓存时间\n\tresponseTimeout := defaultAsyncResponseTimeout\n\tprocessingTimeout := defaultPluginTimeout\n\tcacheTTL := defaultCacheTTL\n\t\n\t// 如果配置已初始化，则使用配置中的值\n\tif config.AppConfig != nil {\n\t\tresponseTimeout = config.AppConfig.AsyncResponseTimeoutDur\n\t\tprocessingTimeout = config.AppConfig.PluginTimeout\n\t\tcacheTTL = time.Duration(config.AppConfig.AsyncCacheTTLHours) * time.Hour\n\t}\n\t\n\treturn &BaseAsyncPlugin{\n\t\tname:     name,\n\t\tpriority: priority,\n\t\tclient: &http.Client{\n\t\t\tTimeout: responseTimeout,\n\t\t},\n\t\tbackgroundClient: &http.Client{\n\t\t\tTimeout: processingTimeout,\n\t\t},\n\t\tcacheTTL:           cacheTTL,\n\t\tfinalUpdateTracker: make(map[string]bool), // 初始化缓存更新追踪器\n\t\tskipServiceFilter:  false,                  // 默认不跳过Service层过滤\n\t}\n}\n\n// NewBaseAsyncPluginWithFilter 创建基础异步插件（支持设置Service层过滤参数）\nfunc NewBaseAsyncPluginWithFilter(name string, priority int, skipServiceFilter bool) *BaseAsyncPlugin {\n\t// 确保异步插件已初始化\n\tif !initialized {\n\t\tinitAsyncPlugin()\n\t}\n\t\n\t// 确定超时和缓存时间\n\tresponseTimeout := defaultAsyncResponseTimeout\n\tprocessingTimeout := defaultPluginTimeout\n\tcacheTTL := defaultCacheTTL\n\t\n\t// 如果配置已初始化，则使用配置中的值\n\tif config.AppConfig != nil {\n\t\tresponseTimeout = config.AppConfig.AsyncResponseTimeoutDur\n\t\tprocessingTimeout = config.AppConfig.PluginTimeout\n\t\tcacheTTL = time.Duration(config.AppConfig.AsyncCacheTTLHours) * time.Hour\n\t}\n\t\n\treturn &BaseAsyncPlugin{\n\t\tname:     name,\n\t\tpriority: priority,\n\t\tclient: &http.Client{\n\t\t\tTimeout: responseTimeout,\n\t\t},\n\t\tbackgroundClient: &http.Client{\n\t\t\tTimeout: processingTimeout,\n\t\t},\n\t\tcacheTTL:           cacheTTL,\n\t\tfinalUpdateTracker: make(map[string]bool), // 初始化缓存更新追踪器\n\t\tskipServiceFilter:  skipServiceFilter,     // 使用传入的过滤设置\n\t}\n}\n\n// ============================================================\n// 第七部分：BaseAsyncPlugin 接口实现方法\n// ============================================================\n\n// SetMainCacheKey 设置主缓存键\nfunc (p *BaseAsyncPlugin) SetMainCacheKey(key string) {\n\tp.MainCacheKey = key\n}\n\n// SetCurrentKeyword 设置当前搜索关键词（用于日志显示）\nfunc (p *BaseAsyncPlugin) SetCurrentKeyword(keyword string) {\n\tp.currentKeyword = keyword\n}\n\n// SetMainCacheUpdater 设置主缓存更新函数（修复后的签名，增加关键词参数）\nfunc (p *BaseAsyncPlugin) SetMainCacheUpdater(updater func(string, []model.SearchResult, time.Duration, bool, string) error) {\n\tp.mainCacheUpdater = updater\n}\n\n// Name 返回插件名称\nfunc (p *BaseAsyncPlugin) Name() string {\n\treturn p.name\n}\n\n// Priority 返回插件优先级\nfunc (p *BaseAsyncPlugin) Priority() int {\n\treturn p.priority\n}\n\n// SkipServiceFilter 返回是否跳过Service层的关键词过滤\nfunc (p *BaseAsyncPlugin) SkipServiceFilter() bool {\n\treturn p.skipServiceFilter\n}\n\n// GetClient 返回短超时客户端\nfunc (p *BaseAsyncPlugin) GetClient() *http.Client {\n\treturn p.client\n}\n\n// ============================================================\n// 第八部分：异步搜索核心逻辑\n// ============================================================\n\n// AsyncSearch 异步搜索基础方法\nfunc (p *BaseAsyncPlugin) AsyncSearch(\n\tkeyword string,\n\tsearchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error),\n\tmainCacheKey string,\n\text map[string]interface{},\n) ([]model.SearchResult, error) {\n\t// 确保ext不为nil\n\tif ext == nil {\n\t\text = make(map[string]interface{})\n\t}\n\t\n\tnow := time.Now()\n\t\n\t// 修改缓存键，确保包含插件名称\n\tpluginSpecificCacheKey := fmt.Sprintf(\"%s:%s\", p.name, keyword)\n\t\n\t// 检查缓存\n\tif cachedItems, ok := apiResponseCache.Load(pluginSpecificCacheKey); ok {\n\t\tcachedResult := cachedItems.(cachedResponse)\n\t\t\n\t\t// 缓存完全有效（未过期且完整）\n\t\tif time.Since(cachedResult.Timestamp) < p.cacheTTL && cachedResult.Complete {\n\t\t\trecordCacheHit()\n\t\t\trecordCacheAccess(pluginSpecificCacheKey)\n\t\t\t\n\t\t\t// 如果缓存接近过期（已用时间超过TTL的80%），在后台刷新缓存\n\t\t\tif time.Since(cachedResult.Timestamp) > (p.cacheTTL * 4 / 5) {\n\t\t\t\tgo p.refreshCacheInBackground(keyword, pluginSpecificCacheKey, searchFunc, cachedResult, mainCacheKey, ext)\n\t\t\t}\n\t\t\t\n\t\t\treturn cachedResult.Results, nil\n\t\t}\n\t\t\n\t\t// 缓存已过期但有结果，启动后台刷新，同时返回旧结果\n\t\tif len(cachedResult.Results) > 0 {\n\t\t\trecordCacheHit()\n\t\t\trecordCacheAccess(pluginSpecificCacheKey)\n\t\t\t\n\t\t\t// 标记为部分过期\n\t\t\tif time.Since(cachedResult.Timestamp) >= p.cacheTTL {\n\t\t\t\t// 在后台刷新缓存\n\t\t\t\tgo p.refreshCacheInBackground(keyword, pluginSpecificCacheKey, searchFunc, cachedResult, mainCacheKey, ext)\n\t\t\t\t\n\t\t\t\t// 日志记录\n\t\t\t\tfmt.Printf(\"[%s] 缓存已过期，后台刷新中: %s (已过期: %v)\\n\", \n\t\t\t\t\tp.name, pluginSpecificCacheKey, time.Since(cachedResult.Timestamp))\n\t\t\t}\n\t\t\t\n\t\t\treturn cachedResult.Results, nil\n\t\t}\n\t}\n\t\n\trecordCacheMiss()\n\t\n\t// 创建通道\n\tresultChan := make(chan []model.SearchResult, 1)\n\terrorChan := make(chan error, 1)\n\tdoneChan := make(chan struct{})\n\t\n\t// 启动后台处理\n\tgo func() {\n\t\t// 尝试获取工作槽\n\t\tif !acquireWorkerSlot() {\n\t\t\t// 工作池已满，使用快速响应客户端直接处理\n\t\t\tresults, err := searchFunc(p.client, keyword, ext)\n\t\t\tif err != nil {\n\t\t\t\tselect {\n\t\t\t\tcase errorChan <- err:\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\tselect {\n\t\t\tcase resultChan <- results:\n\t\t\tdefault:\n\t\t\t}\n\t\t\t\n\t\t\t// 缓存结果\n\t\t\tapiResponseCache.Store(pluginSpecificCacheKey, cachedResponse{\n\t\t\t\tResults:     results,\n\t\t\t\tTimestamp:   now,\n\t\t\t\tComplete:    true,\n\t\t\t\tLastAccess:  now,\n\t\t\t\tAccessCount: 1,\n\t\t\t})\n\t\t\t\n\t\t\t// 🔧 工作池满时短超时(默认4秒)内完成，这是完整结果\n\t\t\tp.updateMainCacheWithFinal(mainCacheKey, results, true)\n\t\t\t\n\t\t\treturn\n\t\t}\n\t\tdefer releaseWorkerSlot()\n\t\t\n\t\t// 执行搜索\n\t\tresults, err := searchFunc(p.backgroundClient, keyword, ext)\n\t\t\n\t\t// 检查是否已经响应\n\t\tselect {\n\t\tcase <-doneChan:\n\t\t\t// 已经响应，只更新缓存\n\t\t\tif err == nil {\n\t\t\t\t// 检查是否存在旧缓存\n\t\t\t\tvar accessCount int = 1\n\t\t\t\tvar lastAccess time.Time = now\n\t\t\t\t\n\t\t\t\tif oldCache, ok := apiResponseCache.Load(pluginSpecificCacheKey); ok {\n\t\t\t\t\toldCachedResult := oldCache.(cachedResponse)\n\t\t\t\t\taccessCount = oldCachedResult.AccessCount\n\t\t\t\t\tlastAccess = oldCachedResult.LastAccess\n\t\t\t\t\t\n\t\t\t\t\t// 合并结果（新结果优先）\n\t\t\t\t\tif len(oldCachedResult.Results) > 0 {\n\t\t\t\t\t\t// 创建合并结果集\n\t\t\t\t\t\tmergedResults := make([]model.SearchResult, 0, len(results) + len(oldCachedResult.Results))\n\t\t\t\t\t\t\n\t\t\t\t\t\t// 创建已有结果ID的映射\n\t\t\t\t\t\texistingIDs := make(map[string]bool)\n\t\t\t\t\t\tfor _, r := range results {\n\t\t\t\t\t\t\texistingIDs[r.UniqueID] = true\n\t\t\t\t\t\t\tmergedResults = append(mergedResults, r)\n\t\t\t\t\t\t}\n\t\t\t\t\t\t\n\t\t\t\t\t\t// 添加旧结果中不存在的项\n\t\t\t\t\t\tfor _, r := range oldCachedResult.Results {\n\t\t\t\t\t\t\tif !existingIDs[r.UniqueID] {\n\t\t\t\t\t\t\t\tmergedResults = append(mergedResults, r)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\t\n\t\t\t\t\t\t// 使用合并结果\n\t\t\t\t\t\tresults = mergedResults\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tapiResponseCache.Store(pluginSpecificCacheKey, cachedResponse{\n\t\t\t\t\tResults:     results,\n\t\t\t\t\tTimestamp:   now,\n\t\t\t\t\tComplete:    true,\n\t\t\t\t\tLastAccess:  lastAccess,\n\t\t\t\t\tAccessCount: accessCount,\n\t\t\t\t})\n\t\t\t\trecordAsyncCompletion()\n\t\t\t\t\n\t\t\t\t// 异步插件后台完成时更新主缓存（标记为最终结果）\n\t\t\t\tp.updateMainCacheWithFinal(mainCacheKey, results, true)\n\t\t\t\t\n\t\t\t\t// 异步插件本地缓存系统已移除\n\t\t\t}\n\t\tdefault:\n\t\t\t// 尚未响应，发送结果\n\t\t\tif err != nil {\n\t\t\t\tselect {\n\t\t\t\tcase errorChan <- err:\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// 检查是否存在旧缓存用于合并\n\t\t\t\tif oldCache, ok := apiResponseCache.Load(pluginSpecificCacheKey); ok {\n\t\t\t\t\toldCachedResult := oldCache.(cachedResponse)\n\t\t\t\t\tif len(oldCachedResult.Results) > 0 {\n\t\t\t\t\t\t// 创建合并结果集\n\t\t\t\t\t\tmergedResults := make([]model.SearchResult, 0, len(results) + len(oldCachedResult.Results))\n\t\t\t\t\t\t\n\t\t\t\t\t\t// 创建已有结果ID的映射\n\t\t\t\t\t\texistingIDs := make(map[string]bool)\n\t\t\t\t\t\tfor _, r := range results {\n\t\t\t\t\t\t\texistingIDs[r.UniqueID] = true\n\t\t\t\t\t\t\tmergedResults = append(mergedResults, r)\n\t\t\t\t\t\t}\n\t\t\t\t\t\t\n\t\t\t\t\t\t// 添加旧结果中不存在的项\n\t\t\t\t\t\tfor _, r := range oldCachedResult.Results {\n\t\t\t\t\t\t\tif !existingIDs[r.UniqueID] {\n\t\t\t\t\t\t\t\tmergedResults = append(mergedResults, r)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\t\n\t\t\t\t\t\t// 使用合并结果\n\t\t\t\t\t\tresults = mergedResults\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tselect {\n\t\t\t\tcase resultChan <- results:\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// 更新缓存\n\t\t\t\tapiResponseCache.Store(pluginSpecificCacheKey, cachedResponse{\n\t\t\t\t\tResults:     results,\n\t\t\t\t\tTimestamp:   now,\n\t\t\t\t\tComplete:    true,\n\t\t\t\t\tLastAccess:  now,\n\t\t\t\t\tAccessCount: 1,\n\t\t\t\t})\n\t\t\t\t\n\t\t\t\t// 🔧 短超时(默认4秒)内正常完成，这是完整的最终结果\n\t\t\t\tp.updateMainCacheWithFinal(mainCacheKey, results, true)\n\t\t\t\t\n\t\t\t\t// 异步插件本地缓存系统已移除\n\t\t\t}\n\t\t}\n\t}()\n\t\n\t// 获取响应超时时间\n\tresponseTimeout := defaultAsyncResponseTimeout\n\tif config.AppConfig != nil {\n\t\tresponseTimeout = config.AppConfig.AsyncResponseTimeoutDur\n\t}\n\t\n\t// 等待响应超时或结果\n\tselect {\n\tcase results := <-resultChan:\n\t\tclose(doneChan)\n\t\treturn results, nil\n\tcase err := <-errorChan:\n\t\tclose(doneChan)\n\t\treturn nil, err\n\tcase <-time.After(responseTimeout):\n\t\t// 插件响应超时，后台继续处理（优化完成，日志简化）\n\t\t\n\t\t// 响应超时，返回空结果，后台继续处理\n\t\tgo func() {\n\t\t\tdefer close(doneChan)\n\t\t}()\n\t\t\n\t\t// 检查是否有部分缓存可用\n\t\tif cachedItems, ok := apiResponseCache.Load(pluginSpecificCacheKey); ok {\n\t\t\tcachedResult := cachedItems.(cachedResponse)\n\t\t\tif len(cachedResult.Results) > 0 {\n\t\t\t\t// 有部分缓存可用，记录访问并返回\n\t\t\t\trecordCacheAccess(pluginSpecificCacheKey)\n\t\t\t\tfmt.Printf(\"[%s] 响应超时，返回部分缓存: %s (项目数: %d)\\n\", \n\t\t\t\t\tp.name, pluginSpecificCacheKey, len(cachedResult.Results))\n\t\t\t\treturn cachedResult.Results, nil\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 创建空的临时缓存，以便后台处理完成后可以更新\n\t\tapiResponseCache.Store(pluginSpecificCacheKey, cachedResponse{\n\t\t\tResults:     []model.SearchResult{},\n\t\t\tTimestamp:   now,\n\t\t\tComplete:    false, // 标记为不完整\n\t\t\tLastAccess:  now,\n\t\t\tAccessCount: 1,\n\t\t})\n\t\t\n\t\t// 🔧 修复：4秒超时时也要更新主缓存，标记为部分结果（空结果）\n\t\tp.updateMainCacheWithFinal(mainCacheKey, []model.SearchResult{}, false)\n\t\t\n\t\t// fmt.Printf(\"[%s] 响应超时，后台继续处理: %s\\n\", p.name, pluginSpecificCacheKey)\n\t\treturn []model.SearchResult{}, nil\n\t}\n}\n\n// AsyncSearchWithResult 异步搜索方法，返回PluginSearchResult\nfunc (p *BaseAsyncPlugin) AsyncSearchWithResult(\n\tkeyword string,\n\tsearchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error),\n\tmainCacheKey string,\n\text map[string]interface{},\n) (model.PluginSearchResult, error) {\n\t// 确保ext不为nil\n\tif ext == nil {\n\t\text = make(map[string]interface{})\n\t}\n\t\n\tnow := time.Now()\n\t\n\t// 修改缓存键，确保包含插件名称\n\tpluginSpecificCacheKey := fmt.Sprintf(\"%s:%s\", p.name, keyword)\n\t\n\t// 检查缓存\n\tif cachedItems, ok := apiResponseCache.Load(pluginSpecificCacheKey); ok {\n\t\tcachedResult := cachedItems.(cachedResponse)\n\t\t\n\t\t// 缓存完全有效（未过期且完整）\n\t\tif time.Since(cachedResult.Timestamp) < p.cacheTTL && cachedResult.Complete {\n\t\t\trecordCacheHit()\n\t\t\trecordCacheAccess(pluginSpecificCacheKey)\n\t\t\t\n\t\t\t// 如果缓存接近过期（已用时间超过TTL的80%），在后台刷新缓存\n\t\t\tif time.Since(cachedResult.Timestamp) > (p.cacheTTL * 4 / 5) {\n\t\t\t\tgo p.refreshCacheInBackground(keyword, pluginSpecificCacheKey, searchFunc, cachedResult, mainCacheKey, ext)\n\t\t\t}\n\t\t\t\n\t\t\treturn model.PluginSearchResult{\n\t\t\t\tResults:   cachedResult.Results,\n\t\t\t\tIsFinal:   cachedResult.Complete,\n\t\t\t\tTimestamp: cachedResult.Timestamp,\n\t\t\t\tSource:    p.name,\n\t\t\t\tMessage:   \"从缓存获取\",\n\t\t\t}, nil\n\t\t}\n\t\t\n\t\t// 缓存已过期但有结果，启动后台刷新，同时返回旧结果\n\t\tif len(cachedResult.Results) > 0 {\n\t\t\trecordCacheHit()\n\t\t\trecordCacheAccess(pluginSpecificCacheKey)\n\t\t\t\n\t\t\t// 标记为部分过期\n\t\t\tif time.Since(cachedResult.Timestamp) >= p.cacheTTL {\n\t\t\t\t// 在后台刷新缓存\n\t\t\t\tgo p.refreshCacheInBackground(keyword, pluginSpecificCacheKey, searchFunc, cachedResult, mainCacheKey, ext)\n\t\t\t}\n\t\t\t\n\t\t\treturn model.PluginSearchResult{\n\t\t\t\tResults:   cachedResult.Results,\n\t\t\t\tIsFinal:   false, // 🔥 过期数据标记为非最终结果\n\t\t\t\tTimestamp: cachedResult.Timestamp,\n\t\t\t\tSource:    p.name,\n\t\t\t\tMessage:   \"缓存已过期，后台刷新中\",\n\t\t\t}, nil\n\t\t}\n\t}\n\t\n\trecordCacheMiss()\n\t\n\t// 创建通道\n\tresultChan := make(chan []model.SearchResult, 1)\n\terrorChan := make(chan error, 1)\n\tdoneChan := make(chan struct{})\n\t\n\t// 启动后台处理\n\tgo func() {\n\t\tdefer func() {\n\t\t\tselect {\n\t\t\tcase <-doneChan:\n\t\t\tdefault:\n\t\t\t\tclose(doneChan)\n\t\t\t}\n\t\t}()\n\t\t\n\t\t// 尝试获取工作槽\n\t\tif !acquireWorkerSlot() {\n\t\t\t// 工作池已满，使用快速响应客户端直接处理\n\t\t\tresults, err := searchFunc(p.client, keyword, ext)\n\t\t\tif err != nil {\n\t\t\t\tselect {\n\t\t\t\tcase errorChan <- err:\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\tselect {\n\t\t\tcase resultChan <- results:\n\t\t\tdefault:\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tdefer releaseWorkerSlot()\n\t\t\n\t\t// 使用长超时客户端进行搜索\n\t\tresults, err := searchFunc(p.backgroundClient, keyword, ext)\n\t\tif err != nil {\n\t\t\tselect {\n\t\t\tcase errorChan <- err:\n\t\t\tdefault:\n\t\t\t}\n\t\t} else {\n\t\t\tselect {\n\t\t\tcase resultChan <- results:\n\t\t\tdefault:\n\t\t\t}\n\t\t}\n\t}()\n\t\n\t// 等待结果或超时\n\tresponseTimeout := defaultAsyncResponseTimeout\n\tif config.AppConfig != nil {\n\t\tresponseTimeout = config.AppConfig.AsyncResponseTimeoutDur\n\t}\n\t\n\tselect {\n\tcase results := <-resultChan:\n\t\t// 不直接关闭，让defer处理\n\t\t\n\t\t// 缓存结果\n\t\tapiResponseCache.Store(pluginSpecificCacheKey, cachedResponse{\n\t\t\tResults:     results,\n\t\t\tTimestamp:   now,\n\t\t\tComplete:    true, // 🔥 及时完成，标记为完整结果\n\t\t\tLastAccess:  now,\n\t\t\tAccessCount: 1,\n\t\t})\n\t\t\n\t\t// 🔧 恢复主缓存更新：使用统一的GOB序列化\n\t\t// 传递原始数据，由主程序负责序列化\n\t\tif mainCacheKey != \"\" && p.mainCacheUpdater != nil {\n\t\t\terr := p.mainCacheUpdater(mainCacheKey, results, p.cacheTTL, true, p.currentKeyword)\n\t\t\tif err != nil {\n\t\t\t\tfmt.Printf(\"❌ [%s] 及时完成缓存更新失败: %s | 错误: %v\\n\", p.name, mainCacheKey, err)\n\t\t\t}\n\t\t}\n\t\t\n\t\treturn model.PluginSearchResult{\n\t\t\tResults:   results,\n\t\t\tIsFinal:   true, // 🔥 及时完成，最终结果\n\t\t\tTimestamp: now,\n\t\t\tSource:    p.name,\n\t\t\tMessage:   \"搜索完成\",\n\t\t}, nil\n\t\t\n\tcase err := <-errorChan:\n\t\t// 不直接关闭，让defer处理\n\t\treturn model.PluginSearchResult{}, err\n\t\t\n\tcase <-time.After(responseTimeout):\n\t\t// 🔥 超时处理：返回空结果，后台继续处理\n\t\tgo p.completeSearchInBackground(keyword, searchFunc, pluginSpecificCacheKey, mainCacheKey, doneChan, ext)\n\t\t\n\t\t// 存储临时缓存（标记为不完整）\n\t\tapiResponseCache.Store(pluginSpecificCacheKey, cachedResponse{\n\t\t\tResults:     []model.SearchResult{},\n\t\t\tTimestamp:   now,\n\t\t\tComplete:    false, // 🔥 标记为不完整\n\t\t\tLastAccess:  now,\n\t\t\tAccessCount: 1,\n\t\t})\n\t\t\n\t\treturn model.PluginSearchResult{\n\t\t\tResults:   []model.SearchResult{},\n\t\t\tIsFinal:   false, // 🔥 超时返回，非最终结果\n\t\t\tTimestamp: now,\n\t\t\tSource:    p.name,\n\t\t\tMessage:   \"处理中，后台继续...\",\n\t\t}, nil\n\t}\n}\n\n// completeSearchInBackground 后台完成搜索\nfunc (p *BaseAsyncPlugin) completeSearchInBackground(\n\tkeyword string,\n\tsearchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error),\n\tpluginCacheKey string,\n\tmainCacheKey string,\n\tdoneChan chan struct{},\n\text map[string]interface{},\n) {\n\tdefer func() {\n\t\tselect {\n\t\tcase <-doneChan:\n\t\tdefault:\n\t\t\tclose(doneChan)\n\t\t}\n\t}()\n\t\n\t// 执行完整搜索\n\tresults, err := searchFunc(p.backgroundClient, keyword, ext)\n\tif err != nil {\n\t\treturn\n\t}\n\t\n\t// 更新插件缓存\n\tnow := time.Now()\n\tapiResponseCache.Store(pluginCacheKey, cachedResponse{\n\t\tResults:     results,\n\t\tTimestamp:   now,\n\t\tComplete:    true, // 🔥 标记为完整结果\n\t\tLastAccess:  now,\n\t\tAccessCount: 1,\n\t})\n\t\n\t// 🔧 恢复主缓存更新：使用统一的GOB序列化\n\t// 传递原始数据，由主程序负责序列化\n\tif mainCacheKey != \"\" && p.mainCacheUpdater != nil {\n\t\terr := p.mainCacheUpdater(mainCacheKey, results, p.cacheTTL, true, p.currentKeyword)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"❌ [%s] 后台完成缓存更新失败: %s | 错误: %v\\n\", p.name, mainCacheKey, err)\n\t\t}\n\t}\n}\n\n// refreshCacheInBackground 在后台刷新缓存\nfunc (p *BaseAsyncPlugin) refreshCacheInBackground(\n\tkeyword string,\n\tcacheKey string,\n\tsearchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error),\n\toldCache cachedResponse,\n\toriginalCacheKey string,\n\text map[string]interface{},\n) {\n\t// 确保ext不为nil\n\tif ext == nil {\n\t\text = make(map[string]interface{})\n\t}\n\t\n\t// 注意：这里的cacheKey已经是插件特定的了，因为是从AsyncSearch传入的\n\t\n\t// 检查是否有足够的工作槽\n\tif !acquireWorkerSlot() {\n\t\treturn\n\t}\n\tdefer releaseWorkerSlot()\n\t\n\t// 记录刷新开始时间\n\trefreshStart := time.Now()\n\t\n\t// 执行搜索\n\tresults, err := searchFunc(p.backgroundClient, keyword, ext)\n\tif err != nil || len(results) == 0 {\n\t\treturn\n\t}\n\t\n\t// 创建合并结果集\n\tmergedResults := make([]model.SearchResult, 0, len(results) + len(oldCache.Results))\n\t\n\t// 创建已有结果ID的映射\n\texistingIDs := make(map[string]bool)\n\tfor _, r := range results {\n\t\texistingIDs[r.UniqueID] = true\n\t\tmergedResults = append(mergedResults, r)\n\t}\n\t\n\t// 添加旧结果中不存在的项\n\tfor _, r := range oldCache.Results {\n\t\tif !existingIDs[r.UniqueID] {\n\t\t\tmergedResults = append(mergedResults, r)\n\t\t}\n\t}\n\t\n\t// 更新缓存\n\tapiResponseCache.Store(cacheKey, cachedResponse{\n\t\tResults:     mergedResults,\n\t\tTimestamp:   time.Now(),\n\t\tComplete:    true,\n\t\tLastAccess:  oldCache.LastAccess,\n\t\tAccessCount: oldCache.AccessCount,\n\t})\n\t\n\t// 🔥 异步插件后台刷新完成时更新主缓存（标记为最终结果）\n\tp.updateMainCacheWithFinal(originalCacheKey, mergedResults, true)\n\t\n\t// 记录刷新时间\n\trefreshTime := time.Since(refreshStart)\n\tfmt.Printf(\"[%s] 后台刷新完成: %s (耗时: %v, 新项目: %d, 合并项目: %d)\\n\", \n\t\tp.name, cacheKey, refreshTime, len(results), len(mergedResults))\n\t\n\t// 异步插件本地缓存系统已移除\n} \n\n// ============================================================\n// 第九部分：缓存管理\n// ============================================================\n\n// updateMainCache 更新主缓存系统（兼容性方法，默认IsFinal=true）\nfunc (p *BaseAsyncPlugin) updateMainCache(cacheKey string, results []model.SearchResult) {\n\tp.updateMainCacheWithFinal(cacheKey, results, true)\n}\n\n// updateMainCacheWithFinal 更新主缓存系统，支持IsFinal参数\nfunc (p *BaseAsyncPlugin) updateMainCacheWithFinal(cacheKey string, results []model.SearchResult, isFinal bool) {\n\t// 如果主缓存更新函数为空或缓存键为空，直接返回\n\tif p.mainCacheUpdater == nil || cacheKey == \"\" {\n\t\treturn\n\t}\n\t\n\t// 🚀 优化：如果新结果为空，跳过缓存更新（避免无效操作）\n\tif len(results) == 0 {\n\t\treturn\n\t}\n\t\n\t// 🔥 增强防重复更新机制 - 使用数据哈希确保真正的去重\n\t// 生成结果数据的简单哈希标识\n\tdataHash := fmt.Sprintf(\"%d_%d\", len(results), results[0].UniqueID)\n\tif len(results) > 1 {\n\t\tdataHash += fmt.Sprintf(\"_%d\", results[len(results)-1].UniqueID)\n\t}\n\tupdateKey := fmt.Sprintf(\"final_%s_%s_%s_%t\", p.name, cacheKey, dataHash, isFinal)\n\t\n\t// 检查是否已经处理过相同的数据\n\tif p.hasUpdatedFinalCache(updateKey) {\n\t\treturn\n\t}\n\t\n\t// 标记已更新\n\tp.markFinalCacheUpdated(updateKey)\n\t\n\t// 🔧 恢复异步插件缓存更新，使用修复后的统一序列化\n\t// 传递原始数据，由主程序负责GOB序列化\n\tif p.mainCacheUpdater != nil {\n\t\terr := p.mainCacheUpdater(cacheKey, results, p.cacheTTL, isFinal, p.currentKeyword)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"❌ [%s] 主缓存更新失败: %s | 错误: %v\\n\", p.name, cacheKey, err)\n\t\t}\n\t}\n} \n\n// hasUpdatedFinalCache 检查是否已经更新过指定的最终结果缓存\nfunc (p *BaseAsyncPlugin) hasUpdatedFinalCache(updateKey string) bool {\n\tp.finalUpdateMutex.RLock()\n\tdefer p.finalUpdateMutex.RUnlock()\n\treturn p.finalUpdateTracker[updateKey]\n}\n\n// markFinalCacheUpdated 标记已更新指定的最终结果缓存\nfunc (p *BaseAsyncPlugin) markFinalCacheUpdated(updateKey string) {\n\tp.finalUpdateMutex.Lock()\n\tdefer p.finalUpdateMutex.Unlock()\n\tp.finalUpdateTracker[updateKey] = true\n}\n\n// ============================================================\n// 第十部分：序列化器\n// ============================================================\n\n// SetGlobalCacheSerializer 设置全局缓存序列化器（由主程序调用）\nfunc SetGlobalCacheSerializer(serializer interface {\n\tSerialize(interface{}) ([]byte, error)\n\tDeserialize([]byte, interface{}) error\n}) {\n\tglobalCacheSerializer = serializer\n}\n\n// getEnhancedCacheSerializer 获取增强缓存的序列化器\nfunc getEnhancedCacheSerializer() interface {\n\tSerialize(interface{}) ([]byte, error)\n\tDeserialize([]byte, interface{}) error\n} {\n\treturn globalCacheSerializer\n}\n"
  },
  {
    "path": "plugin/qingying/html结构分析.md",
    "content": "# 清影 (revohd.com) HTML结构分析\n\n## 网站信息\n- 网站名称: 清影\n- 域名: www.revohd.com\n- 类型: 影视资源搜索（仅123网盘）\n\n## 1. 搜索页面\n\n### URL格式\n```\nhttps://www.revohd.com/vodsearch/-------------.html?wd={keyword}\n```\n\n### HTML结构\n- 容器: `div.module-search-item` (多个)\n- 每个结果包含:\n  - 封面: `.video-cover .module-item-cover .module-item-pic a`\n    - href: `/voddetail/{id}.html`\n  - 标题: `.video-info .video-info-header h3 a`\n    - href: `/voddetail/{id}.html`\n    - title: 影片标题\n    - text: 影片标题\n  - 分类标签: `.video-info-aux .video-tag-icon`\n  - 年份/地区: `.video-info-aux .tag-link`\n  - 导演: `.video-info-items .video-info-actor` (导演)\n  - 主演: `.video-info-items .video-info-actor` (主演)\n  - 剧情简介: `.video-info-items .video-info-item` (剧情)\n\n### 提取信息\n- 影片ID: 从详情页链接提取 `/voddetail/(\\d+)\\.html`\n- 影片标题: 从标题链接获取\n\n## 2. 详情页面\n\n### URL格式\n```\nhttps://www.revohd.com/voddetail/{id}.html\n```\n\n### HTML结构\n\n#### 基本信息\n- 标题: `.video-info .video-info-header h1.page-title a`\n- 更新时间: `.video-info-items` 中查找包含\"更新：\"的元素\n  - 格式: `更新：2025-12-09 07:22:37，最后更新于 4天前`\n  - 提取: `2025-12-09 07:22:37`\n- 剧情: `.video-info-items .video-info-item.video-info-content .vod_content span`\n\n#### 123网盘下载链接区域\n- 标题区: `div.module-heading h2.module-title` 包含 \"123云盘链接\"\n- 链接容器: `div.module-list.module-player-list.module-downlist`\n  - 链接项: `.module-row-one .module-row-info`\n    - 链接文本: `a.module-row-text`\n      - data-clipboard-text: 完整123网盘链接\n      - 格式: `https://www.123684.com/s/H6Y7Vv-2oDFv?pwd=REVO`\n\n### 123网盘链接格式\n```\nhttps://www.123684.com/s/{shareCode}?pwd={password}\n```\n- shareCode: 分享码（如 `H6Y7Vv-2oDFv`）\n- password: 密码（4位大写字母，如 `REVO`）\n\n## 3. 插件实现要点\n\n### 搜索流程\n1. 构造搜索URL: `baseURL + searchPath + \"?wd=\" + URLEncode(keyword)`\n2. 发送GET请求，解析HTML\n3. 提取所有 `.module-search-item` 元素\n4. 对每个结果提取：\n   - 详情页URL (`/voddetail/{id}.html`)\n   - 影片标题\n   - 影片ID\n\n### 详情页处理\n1. 并发请求详情页\n2. 查找包含\"123云盘链接\"的下载区域\n3. 提取123网盘链接：\n   - 选择器: `.module-downlist .module-row-text`\n   - 属性: `data-clipboard-text`\n4. 解析链接提取密码（从 `?pwd=` 参数）\n\n### 更新时间提取\n1. 查找包含\"更新：\"文本的元素\n2. 使用正则提取时间: `更新[：:]\\s*(\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}:\\d{2})`\n3. 解析为time.Time对象\n\n### 特殊说明\n- **仅支持123网盘**：此网站只提供123网盘链接\n- **无需播放页**：网盘链接直接在详情页展示\n- **密码格式**：固定4位（通常是大写字母）\n- **链接唯一性**：每部影片通常只有一个123网盘链接\n\n## 4. 网盘类型\n\n固定为 `pan123`（123网盘）\n\n## 5. 并发控制\n\n### 建议配置\n- 详情页并发数: 3-5个\n- 请求超时: 30秒\n- 使用信号量控制并发\n\n### 错误处理\n- 网络请求失败 → 重试3次\n- HTML解析失败 → 跳过该项\n- 未找到123网盘链接 → 跳过该影片\n- 密码提取失败 → 记录但仍返回链接\n\n## 6. 结果结构\n\n### UniqueID格式\n```\nqingying-{影片ID}\n```\n\n### SearchResult\n- **UniqueID**: `qingying-{id}`\n- **Title**: 影片标题\n- **Content**: 剧情简介\n- **Links**: 123网盘链接数组（通常只有1个）\n- **Channel**: 空字符串\n- **Datetime**: 从\"更新：\"字段提取的时间\n\n### Link对象\n- **Type**: `pan123`\n- **URL**: 完整的123网盘链接\n- **Password**: 从URL参数提取的4位密码\n\n## 7. 优先级设置\n\n建议设置为优先级3（标准网盘搜索插件）\n\n## 8. 请求头设置\n\n```\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\nAccept-Language: zh-CN,zh;q=0.9,en;q=0.8\nReferer: https://www.revohd.com\n```\n"
  },
  {
    "path": "plugin/qingying/qingying.go",
    "content": "package qingying\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tbaseURL       = \"http://revohd.com\"\n\tsearchPath    = \"/vodsearch/-------------.html\"\n\tmaxResults    = 10\n\tmaxConcurrent = 3\n)\n\nvar debugMode = false\n\nfunc debugPrintf(format string, args ...interface{}) {\n\tif debugMode {\n\t\tfmt.Printf(\"[QingYing DEBUG] \"+format, args...)\n\t}\n}\n\ntype QingYingPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\nfunc init() {\n\tp := &QingYingPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"qingying\", 3),\n\t}\n\tplugin.RegisterGlobalPlugin(p)\n}\n\nfunc (p *QingYingPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\nfunc (p *QingYingPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\nfunc (p *QingYingPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tdebugPrintf(\"🔍 开始搜索 - keyword: %s\\n\", keyword)\n\tsearchURL := fmt.Sprintf(\"%s%s?wd=%s\", baseURL, searchPath, url.QueryEscape(keyword))\n\tdebugPrintf(\"📝 搜索URL: %s\\n\", searchURL)\n\t\n\titems, err := p.fetchSearchResults(searchURL, client)\n\tif err != nil {\n\t\tdebugPrintf(\"❌ 获取搜索结果失败: %v\\n\", err)\n\t\treturn nil, err\n\t}\n\t\n\tdebugPrintf(\"✅ 获取到 %d 个搜索结果\\n\", len(items))\n\t\n\tif len(items) == 0 {\n\t\tdebugPrintf(\"⚠️ 没有搜索结果\\n\")\n\t\treturn []model.SearchResult{}, nil\n\t}\n\t\n\tfilteredItems := p.filterItemsByKeyword(items, keyword)\n\tdebugPrintf(\"🔎 标题过滤后剩余 %d 个结果（从 %d 个）\\n\", len(filteredItems), len(items))\n\t\n\tif len(filteredItems) == 0 {\n\t\tdebugPrintf(\"⚠️ 标题过滤后没有匹配的结果\\n\")\n\t\treturn []model.SearchResult{}, nil\n\t}\n\t\n\tif len(filteredItems) > maxResults {\n\t\tdebugPrintf(\"✂️ 限制结果数量从 %d 到 %d\\n\", len(filteredItems), maxResults)\n\t\tfilteredItems = filteredItems[:maxResults]\n\t}\n\t\n\tresults := p.processDetailPages(filteredItems, client)\n\tdebugPrintf(\"📊 处理完成，获得 %d 个有效结果\\n\", len(results))\n\t\n\treturn results, nil\n}\n\ntype searchItem struct {\n\tID        string\n\tTitle     string\n\tDetailURL string\n}\n\nfunc (p *QingYingPlugin) filterItemsByKeyword(items []searchItem, keyword string) []searchItem {\n\tlowerKeyword := strings.ToLower(keyword)\n\tvar filtered []searchItem\n\t\n\tfor _, item := range items {\n\t\tlowerTitle := strings.ToLower(item.Title)\n\t\tif strings.Contains(lowerTitle, lowerKeyword) {\n\t\t\tdebugPrintf(\"✅ 标题匹配: %s\\n\", item.Title)\n\t\t\tfiltered = append(filtered, item)\n\t\t} else {\n\t\t\tdebugPrintf(\"❌ 标题不匹配，跳过: %s\\n\", item.Title)\n\t\t}\n\t}\n\t\n\treturn filtered\n}\n\nfunc (p *QingYingPlugin) fetchSearchResults(searchURL string, client *http.Client) ([]searchItem, error) {\n\tdebugPrintf(\"🌐 请求搜索页面: %s\\n\", searchURL)\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\t\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\tp.setHeaders(req, baseURL)\n\t\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tdebugPrintf(\"📡 HTTP状态码: %d\\n\", resp.StatusCode)\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] HTML解析失败: %w\", p.Name(), err)\n\t}\n\t\n\tvar items []searchItem\n\tdoc.Find(\"div.module-search-item\").Each(func(i int, s *goquery.Selection) {\n\t\tlink := s.Find(\".video-info .video-info-header h3 a\")\n\t\thref, exists := link.Attr(\"href\")\n\t\tif !exists {\n\t\t\tdebugPrintf(\"⚠️ 第%d个结果没有href属性\\n\", i+1)\n\t\t\treturn\n\t\t}\n\t\t\n\t\ttitle := strings.TrimSpace(link.Text())\n\t\tif title == \"\" {\n\t\t\ttitle, _ = link.Attr(\"title\")\n\t\t\ttitle = strings.TrimSpace(title)\n\t\t}\n\t\t\n\t\tif title == \"\" {\n\t\t\tdebugPrintf(\"⚠️ 第%d个结果标题为空\\n\", i+1)\n\t\t\treturn\n\t\t}\n\t\t\n\t\tre := regexp.MustCompile(`/voddetail/(\\d+)\\.html`)\n\t\tmatches := re.FindStringSubmatch(href)\n\t\tif len(matches) < 2 {\n\t\t\tdebugPrintf(\"⚠️ 无法从href提取ID: %s\\n\", href)\n\t\t\treturn\n\t\t}\n\t\t\n\t\titem := searchItem{\n\t\t\tID:        matches[1],\n\t\t\tTitle:     title,\n\t\t\tDetailURL: p.buildAbsURL(href),\n\t\t}\n\t\tdebugPrintf(\"📌 找到影片: ID=%s, Title=%s\\n\", item.ID, item.Title)\n\t\titems = append(items, item)\n\t})\n\t\n\tdebugPrintf(\"✅ 解析到 %d 个搜索项\\n\", len(items))\n\treturn items, nil\n}\n\nfunc (p *QingYingPlugin) processDetailPages(items []searchItem, client *http.Client) []model.SearchResult {\n\tvar results []model.SearchResult\n\tvar mu sync.Mutex\n\tvar wg sync.WaitGroup\n\tsem := make(chan struct{}, maxConcurrent)\n\t\n\tfor _, item := range items {\n\t\twg.Add(1)\n\t\tgo func(it searchItem) {\n\t\t\tdefer wg.Done()\n\t\t\tsem <- struct{}{}\n\t\t\tdefer func() { <-sem }()\n\t\t\t\n\t\t\tresult := p.processDetailPage(it, client)\n\t\t\tif result != nil {\n\t\t\t\tmu.Lock()\n\t\t\t\tresults = append(results, *result)\n\t\t\t\tmu.Unlock()\n\t\t\t}\n\t\t}(item)\n\t}\n\t\n\twg.Wait()\n\treturn results\n}\n\nfunc (p *QingYingPlugin) processDetailPage(item searchItem, client *http.Client) *model.SearchResult {\n\tdebugPrintf(\"🎬 处理详情页: %s (ID: %s)\\n\", item.Title, item.ID)\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\t\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", item.DetailURL, nil)\n\tif err != nil {\n\t\tdebugPrintf(\"❌ 创建请求失败: %v\\n\", err)\n\t\treturn nil\n\t}\n\t\n\tp.setHeaders(req, baseURL)\n\t\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\tdebugPrintf(\"❌ 详情页请求失败: %v\\n\", err)\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\tdebugPrintf(\"❌ 详情页状态码: %d\\n\", resp.StatusCode)\n\t\treturn nil\n\t}\n\t\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tdebugPrintf(\"❌ 读取响应失败: %v\\n\", err)\n\t\treturn nil\n\t}\n\t\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body)))\n\tif err != nil {\n\t\tdebugPrintf(\"❌ HTML解析失败: %v\\n\", err)\n\t\treturn nil\n\t}\n\t\n\ttitle := strings.TrimSpace(doc.Find(\".video-info .video-info-header h1.page-title a\").Text())\n\tif title == \"\" {\n\t\ttitle = item.Title\n\t}\n\tdebugPrintf(\"📝 影片标题: %s\\n\", title)\n\t\n\tvar description string\n\tvar updateTime time.Time\n\tdoc.Find(\".video-info-items\").Each(func(i int, s *goquery.Selection) {\n\t\titemTitle := strings.TrimSpace(s.Find(\".video-info-itemtitle\").Text())\n\t\t\n\t\tif strings.Contains(itemTitle, \"更新\") {\n\t\t\ttimeText := strings.TrimSpace(s.Find(\".video-info-item\").Text())\n\t\t\tdebugPrintf(\"🕐 找到更新时间文本: %s\\n\", timeText)\n\t\t\tupdateTime = p.parseUpdateTimeFromHTML(timeText)\n\t\t\tif !updateTime.IsZero() {\n\t\t\t\tdebugPrintf(\"✅ 解析更新时间成功: %v\\n\", updateTime)\n\t\t\t}\n\t\t}\n\t\t\n\t\tif strings.Contains(itemTitle, \"剧情\") {\n\t\t\tcontent := s.Find(\".video-info-item.video-info-content span\")\n\t\t\tif content.Length() > 0 {\n\t\t\t\tdescription = strings.TrimSpace(content.Text())\n\t\t\t} else {\n\t\t\t\tdescription = strings.TrimSpace(s.Find(\".video-info-item\").Text())\n\t\t\t}\n\t\t\tif len(description) > 50 {\n\t\t\t\tdebugPrintf(\"📖 剧情简介: %s...\\n\", description[:50])\n\t\t\t} else {\n\t\t\t\tdebugPrintf(\"📖 剧情简介: %s\\n\", description)\n\t\t\t}\n\t\t}\n\t})\n\t\n\tif updateTime.IsZero() {\n\t\tupdateTime = time.Now()\n\t\tdebugPrintf(\"⚠️ 未找到更新时间，使用当前时间\\n\")\n\t}\n\t\n\tpanLink := p.extract123PanLink(doc)\n\tif panLink == nil {\n\t\tdebugPrintf(\"❌ 未找到123网盘链接\\n\")\n\t\treturn nil\n\t}\n\t\n\tdebugPrintf(\"✅ 找到123网盘链接: %s (密码: %s)\\n\", panLink.URL, panLink.Password)\n\t\n\treturn &model.SearchResult{\n\t\tUniqueID: fmt.Sprintf(\"%s-%s\", p.Name(), item.ID),\n\t\tTitle:    title,\n\t\tContent:  description,\n\t\tLinks:    []model.Link{*panLink},\n\t\tChannel:  \"\",\n\t\tDatetime: updateTime,\n\t}\n}\n\nfunc min(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}\n\nfunc (p *QingYingPlugin) extract123PanLink(doc *goquery.Document) *model.Link {\n\tdebugPrintf(\"🔎 开始提取123网盘链接\\n\")\n\tvar panURL string\n\t\n\tfound := false\n\tdoc.Find(\".module-heading h2.module-title\").Each(func(i int, s *goquery.Selection) {\n\t\ttext := strings.TrimSpace(s.Text())\n\t\tdebugPrintf(\"📋 找到标题: %s\\n\", text)\n\t\tif strings.Contains(text, \"123\") && strings.Contains(text, \"云盘\") {\n\t\t\tfound = true\n\t\t\tdebugPrintf(\"✅ 匹配到123云盘标题\\n\")\n\t\t}\n\t})\n\t\n\tif !found {\n\t\tdebugPrintf(\"❌ 未找到123云盘标题区域\\n\")\n\t\treturn nil\n\t}\n\t\n\tdoc.Find(\".module-downlist .module-row-text\").Each(func(i int, s *goquery.Selection) {\n\t\tif panURL != \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\tclipboardText, exists := s.Attr(\"data-clipboard-text\")\n\t\tdebugPrintf(\"🔗 检查链接 #%d: exists=%v, text=%s\\n\", i+1, exists, clipboardText)\n\t\tif exists {\n\t\t\turl := strings.TrimSpace(clipboardText)\n\t\t\tif strings.Contains(url, \"123684.com\") || strings.Contains(url, \"123685.com\") || \n\t\t\t   strings.Contains(url, \"123912.com\") || strings.Contains(url, \"123pan.com\") ||\n\t\t\t   strings.Contains(url, \"123pan.cn\") || strings.Contains(url, \"123592.com\") {\n\t\t\t\tpanURL = url\n\t\t\t\tdebugPrintf(\"✅ 找到123网盘链接: %s\\n\", panURL)\n\t\t\t}\n\t\t}\n\t})\n\t\n\tif panURL == \"\" {\n\t\tdebugPrintf(\"❌ 未找到123网盘链接\\n\")\n\t\treturn nil\n\t}\n\t\n\tpassword := p.extractPassword(panURL)\n\tdebugPrintf(\"🔑 提取密码: %s\\n\", password)\n\t\n\treturn &model.Link{\n\t\tType:     \"123\",\n\t\tURL:      panURL,\n\t\tPassword: password,\n\t}\n}\n\nfunc (p *QingYingPlugin) parseUpdateTimeFromHTML(timeText string) time.Time {\n\tre := regexp.MustCompile(`(\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}:\\d{2})`)\n\tmatches := re.FindStringSubmatch(timeText)\n\tif len(matches) < 2 {\n\t\tdebugPrintf(\"❌ 无法从文本提取时间: %s\\n\", timeText)\n\t\treturn time.Time{}\n\t}\n\t\n\ttimeStr := strings.TrimSpace(matches[1])\n\tdebugPrintf(\"🔍 提取到时间字符串: %s\\n\", timeStr)\n\t\n\tt, err := time.ParseInLocation(\"2006-01-02 15:04:05\", timeStr, time.Local)\n\tif err != nil {\n\t\tdebugPrintf(\"❌ 时间解析失败: %v\\n\", err)\n\t\treturn time.Time{}\n\t}\n\t\n\treturn t\n}\n\nfunc (p *QingYingPlugin) extractPassword(panURL string) string {\n\tparsed, err := url.Parse(panURL)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\t\n\tpwd := parsed.Query().Get(\"pwd\")\n\tif pwd != \"\" && len(pwd) == 4 {\n\t\treturn pwd\n\t}\n\t\n\tpwdRegex := regexp.MustCompile(`pwd=([a-zA-Z0-9]{4})`)\n\tif matches := pwdRegex.FindStringSubmatch(panURL); len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\t\n\treturn \"\"\n}\n\nfunc (p *QingYingPlugin) buildAbsURL(path string) string {\n\tif strings.HasPrefix(path, \"http://\") || strings.HasPrefix(path, \"https://\") {\n\t\treturn path\n\t}\n\tif strings.HasPrefix(path, \"//\") {\n\t\treturn \"https:\" + path\n\t}\n\tif !strings.HasPrefix(path, \"/\") {\n\t\tpath = \"/\" + path\n\t}\n\treturn baseURL + path\n}\n\nfunc (p *QingYingPlugin) setHeaders(req *http.Request, referer string) {\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", referer)\n}\n\nfunc (p *QingYingPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\treqClone := req.Clone(req.Context())\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n"
  },
  {
    "path": "plugin/qqpd/README.md",
    "content": "# QQ频道搜索插件 (QQPD)\n\n## 📖 简介\n\nQQPD是PanSou的QQ频道搜索插件，支持多用户登录QQ频道并配置频道列表，在搜索时自动聚合所有用户的频道资源。\n\n## ✨ 核心特性\n\n- ✅ **多用户支持** - 每个用户独立配置，互不干扰\n- ✅ **扫码登录** - 手机QQ扫码，自动获取Cookie\n- ✅ **Session保活** - 自动定期访问保持Cookie活跃，防止失效 🆕\n- ✅ **智能去重** - 多用户配置相同频道时自动去重\n- ✅ **负载均衡** - 任务均匀分配，避免单用户限流\n- ✅ **内存缓存** - 用户数据和guild_id缓存到内存，搜索性能极高\n- ✅ **持久化存储** - Cookie和频道配置自动保存，重启不丢失\n- ✅ **Web管理界面** - 一站式配置，简单易用\n- ✅ **RESTful API** - 支持程序化调用\n\n## 🚀 快速开始\n\n### 步骤1: 启动服务\n\n```bash\ncd /Users/macbookpro/Desktop/fish2018/pansou\nENABLED_PLUGINS=qqpd go run main.go\n\n# 或者编译后运行\ngo build -o pansou main.go\nENABLED_PLUGINS=qqpd ./pansou\n```\n\n### 步骤2: 访问管理页面\n\n浏览器打开：\n```\nhttp://localhost:8888/qqpd/你的QQ号\n```\n\n**示例**：\n```\nhttp://localhost:8888/qqpd/1234567\n```\n\n系统会自动：\n1. 根据QQ号生成专属64位hash（不可逆）\n2. 重定向到专属管理页面：`http://localhost:8888/qqpd/{hash}`\n3. 显示二维码供扫码登录\n\n**📌 提示**：请收藏hash后的URL（包含你的专属hash），方便下次访问。\n\n### 步骤3: 扫码登录\n\n1. 页面会自动显示QQ登录二维码\n2. 使用**手机QQ**扫描二维码\n3. 扫码后系统会**自动检测登录状态**（每2秒检查一次）\n4. 登录成功后自动显示用户信息\n\n### 步骤4: 配置频道\n\n在\"频道管理\"区域输入频道号，**每行一个**：\n\n```\npd97631607\nlanguan8K115\nm250319e25\n```\n\n**支持格式**：\n- ✅ 纯频道号：`pd97631607`\n- ✅ 完整URL：`https://pd.qq.com/g/pd97631607`\n\n点击\"**保存频道配置**\"按钮。\n\n### 步骤5: 开始搜索\n\n在PanSou主页搜索框输入关键词，系统会**自动聚合所有用户**的QQ频道结果！\n\n```bash\n# 通过API搜索\ncurl \"http://localhost:8888/api/search?kw=遮天\"\n\n# 只搜索插件（包括qqpd）\ncurl \"http://localhost:8888/api/search?kw=遮天&src=plugin\"\n```\n\n## 📡 API文档\n\n### 统一接口\n\n所有操作通过统一的POST接口：\n\n```\nPOST /qqpd/{hash}\nContent-Type: application/json\n\n{\n  \"action\": \"操作类型\",\n  ...其他参数\n}\n```\n\n### API列表\n\n| Action | 说明 | 需要登录 | 前端调用时机 |\n|--------|------|---------|-------------|\n| `get_status` | 获取状态 | ❌ | 每3秒自动调用 |\n| `refresh_qrcode` | 刷新二维码 | ❌ | 用户点击刷新按钮 |\n| `check_login` | 检查登录状态 | ❌ | 未登录时每2秒调用 |\n| `logout` | 退出登录 | ✅ | 用户点击退出按钮 |\n| `set_channels` | 设置频道列表 | ✅ | 用户点击保存按钮 |\n| `test_search` | 测试搜索 | ✅ | 用户点击搜索按钮 |\n\n---\n\n### 1️⃣ get_status - 获取用户状态\n\n**作用**：获取当前用户的登录状态、频道配置等信息\n\n**请求**：\n```bash\ncurl -X POST \"http://localhost:8888/qqpd/{hash}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"action\": \"get_status\"}'\n```\n\n**成功响应（已登录）**：\n```json\n{\n  \"success\": true,\n  \"message\": \"获取成功\",\n  \"data\": {\n    \"hash\": \"1dd868cc...\",\n    \"logged_in\": true,\n    \"status\": \"active\",\n    \"qq_masked\": \"1851****32\",\n    \"login_time\": \"2025-10-24 12:00:00\",\n    \"expire_time\": \"2035-10-24 12:00:00\",\n    \"expires_in_days\": 3650,\n    \"channels\": [\"pd97631607\", \"kuake12345\"],\n    \"channel_count\": 2,\n    \"qrcode_base64\": \"\"\n  }\n}\n```\n\n**成功响应（未登录）**：\n```json\n{\n  \"success\": true,\n  \"message\": \"获取成功\",\n  \"data\": {\n    \"hash\": \"1dd868cc...\",\n    \"logged_in\": false,\n    \"status\": \"pending\",\n    \"qq_masked\": \"\",\n    \"channels\": [],\n    \"channel_count\": 0,\n    \"qrcode_base64\": \"data:image/png;base64,iVBORw0KGgo...\"  // Base64二维码\n  }\n}\n```\n\n---\n\n### 2️⃣ refresh_qrcode - 刷新二维码\n\n**作用**：强制生成新的二维码（当二维码过期时）\n\n**请求**：\n```bash\ncurl -X POST \"http://localhost:8888/qqpd/{hash}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"action\": \"refresh_qrcode\"}'\n```\n\n**成功响应**：\n```json\n{\n  \"success\": true,\n  \"message\": \"二维码已刷新\",\n  \"data\": {\n    \"qrcode_base64\": \"data:image/png;base64,iVBORw0KGgo...\"\n  }\n}\n```\n\n**说明**：\n- 二维码有效期约2分钟\n- 系统会自动缓存30秒，避免频繁生成\n- 过期后需要点击刷新\n\n---\n\n### 3️⃣ check_login - 检查登录状态\n\n**作用**：检查二维码是否被扫描，登录是否成功（扫码后轮询调用）\n\n**请求**：\n```bash\ncurl -X POST \"http://localhost:8888/qqpd/{hash}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"action\": \"check_login\"}'\n```\n\n**响应（等待扫码）**：\n```json\n{\n  \"success\": true,\n  \"message\": \"等待扫码\",\n  \"data\": {\n    \"login_status\": \"waiting\"\n  }\n}\n```\n\n**响应（登录成功）**：\n```json\n{\n  \"success\": true,\n  \"message\": \"登录成功\",\n  \"data\": {\n    \"login_status\": \"success\",\n    \"qq_masked\": \"1851****32\"\n  }\n}\n```\n\n**响应（二维码过期）**：\n```json\n{\n  \"success\": false,\n  \"message\": \"二维码已失效，请刷新\"\n}\n```\n\n**说明**：\n- 前端未登录时每2秒自动调用\n- 登录成功后前端会停止轮询\n- 后端会自动获取完整Cookie并保存\n\n---\n\n### 4️⃣ logout - 退出登录\n\n**作用**：清除Cookie，退出登录状态\n\n**请求**：\n```bash\ncurl -X POST \"http://localhost:8888/qqpd/{hash}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"action\": \"logout\"}'\n```\n\n**成功响应**：\n```json\n{\n  \"success\": true,\n  \"message\": \"已退出登录\",\n  \"data\": {\n    \"status\": \"pending\"\n  }\n}\n```\n\n---\n\n### 5️⃣ set_channels - 设置频道列表\n\n**作用**：配置或更新频道列表（覆盖式更新）\n\n**请求**：\n```bash\ncurl -X POST \"http://localhost:8888/qqpd/{hash}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"action\": \"set_channels\",\n    \"channels\": [\"pd97631607\", \"kuake12345\", \"https://pd.qq.com/g/languan8K115\"]\n  }'\n```\n\n**成功响应**：\n```json\n{\n  \"success\": true,\n  \"message\": \"频道列表已更新\",\n  \"data\": {\n    \"channels\": [\"pd97631607\", \"kuake12345\", \"languan8K115\"],\n    \"channel_count\": 3,\n    \"invalid_channels\": [],\n    \"guild_ids_cached\": 3\n  }\n}\n```\n\n**说明**：\n- 自动提取频道号（支持URL格式）\n- 自动去重\n- 自动获取并缓存guild_id（首次添加频道时）\n- guild_id永久缓存，搜索时0网络请求\n\n---\n\n### 6️⃣ test_search - 测试搜索\n\n**作用**：在管理页面测试搜索功能\n\n**请求**：\n```bash\ncurl -X POST \"http://localhost:8888/qqpd/{hash}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"action\": \"test_search\",\n    \"keyword\": \"遮天\",\n    \"max_results\": 10\n  }'\n```\n\n**参数**：\n- `keyword`（必需）：搜索关键词\n- `max_results`（可选）：最大返回数量，默认10\n\n**成功响应**：\n```json\n{\n  \"success\": true,\n  \"message\": \"找到 5 条结果\",\n  \"data\": {\n    \"keyword\": \"遮天\",\n    \"total_results\": 5,\n    \"channels_searched\": [\"pd97631607\", \"kuake12345\", \"languan8K115\"],\n    \"results\": [\n      {\n        \"unique_id\": \"qqpd-pd97631607-0\",\n        \"title\": \"遮天 (2023) 臻彩4K.更新至109集\",\n        \"links\": [\n          {\n            \"type\": \"quark\",\n            \"url\": \"https://pan.quark.cn/s/779d98f49e88\",\n            \"password\": \"\"\n          }\n        ]\n      },\n      ...\n    ]\n  }\n}\n```\n\n---\n\n## 🔧 配置说明\n\n### 环境变量（可选）\n\n```bash\n# Hash Salt（推荐自定义，增强安全性）\nexport QQPD_HASH_SALT=\"your-custom-salt-here\"\n\n# Cookie加密密钥（32字节，推荐自定义）\nexport QQPD_ENCRYPTION_KEY=\"your-32-byte-key-here!!!!!!!!!!\"\n```\n\n### 代码内配置\n\n在 `qqpd.go` 第33-37行修改：\n\n```go\nconst (\n    MaxConcurrentUsers    = 10              // 最多使用的用户数（搜索时）\n    MaxConcurrentChannels = 50              // 最大并发频道数\n    KeepAliveInterval     = 3 * time.Minute // Session保活间隔\n    DebugLog              = false           // 调试日志开关\n)\n```\n\n**参数说明**：\n\n| 参数 | 默认值 | 说明 | 建议 |\n|------|--------|------|------|\n| `MaxConcurrentUsers` | 10 | 单次搜索最多使用的用户数 | 10-20足够 |\n| `MaxConcurrentChannels` | 50 | 最大并发频道数 | 50-100 |\n| `KeepAliveInterval` | 3分钟 | Session保活间隔 | 2-5分钟 |\n| `DebugLog` | false | 是否开启调试日志 | 生产环境false |\n\n## 📂 数据存储\n\n### 存储位置\n\n```\ncache/qqpd_users/{hash}.json\n```\n\n### 数据结构\n\n```json\n{\n  \"hash\": \"1dd868cc97f5540db170bb3208a4ad737cd7aea3e8df85535178dcbacfa46300\",\n  \"qq_masked\": \"123**67\",\n  \"cookie\": \"p_skey=xxx; uin=xxx; ...\",\n  \"status\": \"active\",\n  \"channels\": [\"pd97631607\", \"kuake12345\", \"languan8K115\"],\n  \"channel_guild_ids\": {\n    \"pd97631607\": \"592843764045681811\",\n    \"kuake12345\": \"987654321098765432\",\n    \"languan8K115\": \"612109904026776189\"\n  },\n  \"created_at\": \"2025-10-24T12:00:00+08:00\",\n  \"login_at\": \"2025-10-24T12:05:00+08:00\",\n  \"expire_at\": \"2035-10-24T12:05:00+08:00\",\n  \"last_access_at\": \"2025-10-24T13:00:00+08:00\"\n}\n```\n\n**字段说明**：\n- `hash`: 用户唯一标识（SHA256，不可逆推QQ号）\n- `qq_masked`: 脱敏QQ号（如`1851****32`）\n- `cookie`: QQ登录Cookie（明文存储，建议配置加密）\n- `status`: 用户状态（`pending`/`active`/`expired`）\n- `channels`: 频道号列表\n- `channel_guild_ids`: 频道号→guild_id映射（性能优化缓存）\n- `expire_at`: Cookie过期时间\n\n## 🔒 安全特性\n\n### 1. QQ号隐私保护\n\n- ✅ **不存储明文QQ号**：只存储SHA256 hash（64位十六进制）\n- ✅ **不可逆**：无法从hash反推QQ号\n- ✅ **加盐hash**：支持自定义salt，进一步增强安全性\n\n### 2. Cookie安全\n\n- ⚠️ **当前**：明文存储到JSON（方便调试）\n- ✅ **可选**：通过环境变量配置加密密钥\n- ✅ **建议**：生产环境配置`QQPD_ENCRYPTION_KEY`\n\n### 3. 自动清理\n\n**定期清理任务**（每24小时）：\n- 删除：状态为`expired`且30天未访问的用户\n- 标记：90天未访问的用户标记为`expired`\n\n### 4. 二维码安全\n\n- ✅ 每次生成新的qrsig\n- ✅ 30秒缓存，减少暴露\n- ✅ 2分钟自动过期\n\n## ⚙️ 高级特性\n\n### 1. Session保活机制 🆕\n\n**问题**：QQ频道的Cookie会在2天左右失效，导致搜索失败。\n\n**解决方案**：自动保活机制\n\n```\n插件启动后:\n  ↓ 延迟3分钟\n  ↓\n定期执行保活 (每3分钟):\n  ↓\n遍历所有active用户:\n  ↓ 异步执行\n  访问 https://pd.qq.com/ (带Cookie)\n  ↓\n刷新服务器端session\n  ↓\nCookie保持活跃状态 ✅\n```\n\n**工作原理**：\n- 🔄 每3分钟访问一次QQ频道首页\n- 🍪 携带用户Cookie发送请求\n- 💓 让QQ服务器知道session还活跃\n- ⚡ 异步执行，不阻塞搜索功能\n\n**日志示例**（DebugLog=true时）：\n```\n[QQPD] 💓 Session保活: 已为 2 个用户执行保活任务\n[QQPD] 💓 Session保活成功: 1851****32 (状态码: 200)\n[QQPD] 💓 Session保活成功: 1234****56 (状态码: 200)\n```\n\n**配置建议**：\n- 默认间隔：3分钟（推荐）\n- 可调整范围：2-5分钟\n- 太频繁：可能被视为异常\n- 太慢：可能无法防止超时\n\n### 2. 多用户支持\n\n**场景**：多个用户各自配置不同的频道\n\n```\n用户A (QQ: 111111111)\n  ↓\n配置频道: [频道1, 频道2, 频道3]\n\n用户B (QQ: 222222222)\n  ↓\n配置频道: [频道2, 频道4, 频道5]\n\n用户C (QQ: 333333333)\n  ↓\n配置频道: [频道3, 频道5, 频道6]\n\n搜索时:\n  ↓\n去重后的频道: [频道1, 频道2, 频道3, 频道4, 频道5, 频道6]\n  ↓\n负载均衡分配:\n  - 用户A: 搜索频道1, 频道4\n  - 用户B: 搜索频道2, 频道5\n  - 用户C: 搜索频道3, 频道6\n```\n\n### 3. guild_id缓存优化\n\n**性能提升**：\n```\n首次保存频道:\n  pd97631607 → 访问 https://pd.qq.com/g/pd97631607\n              → 提取 guild_id: 592843764045681811\n              → 缓存到JSON\n\n搜索时:\n  pd97631607 → 从内存读取 guild_id: 592843764045681811\n              → 0网络请求 ⚡\n              → 直接调用搜索API\n```\n\n**效果**：\n- 首次配置：稍慢（需要获取guild_id）\n- 后续搜索：极快（从内存读取）\n- 性能提升：每个频道节省100-200ms\n\n### 4. 智能去重\n\n```\n用户A配置: [频道1, 频道2, 频道3]\n用户B配置: [频道2, 频道3, 频道4]\n\n去重后:\n  频道1 → 分配给用户A\n  频道2 → 分配给用户A（负载均衡）\n  频道3 → 分配给用户B（负载均衡）\n  频道4 → 分配给用户B\n```\n\n### 5. 负载均衡\n\n```\n任务分配算法:\n  for each 频道:\n    选择当前任务数最少的用户来执行\n  \n效果:\n  - 避免单用户请求过多被限流\n  - 任务均匀分配\n  - 提高成功率\n```"
  },
  {
    "path": "plugin/qqpd/qqpd.go",
    "content": "package qqpd\n\nimport (\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"crypto/tls\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// 插件配置参数（代码内配置）\nconst (\n\tMaxConcurrentUsers    = 10    // 最多使用的用户数\n\tMaxConcurrentChannels = 50    // 最大并发频道数\n\tDebugLog              = false // 调试日志开关（临时开启排查问题）\n)\n\n// 存储目录 - 从环境变量动态获取\nvar StorageDir string\n\n// 初始化存储目录\n\n// HTML模板（完整的管理页面）\nconst HTMLTemplate = `<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>PanSou QQ频道搜索配置</title>\n    <style>\n        * { margin: 0; padding: 0; box-sizing: border-box; }\n        body { \n            font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n            padding: 20px;\n        }\n        .container {\n            max-width: 800px;\n            margin: 0 auto;\n            background: white;\n            border-radius: 16px;\n            box-shadow: 0 20px 60px rgba(0,0,0,0.3);\n            overflow: hidden;\n        }\n        .header {\n            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n            color: white;\n            padding: 30px;\n            text-align: center;\n        }\n        .section {\n            padding: 30px;\n            border-bottom: 1px solid #eee;\n        }\n        .section:last-child { border-bottom: none; }\n        .section-title {\n            font-size: 18px;\n            font-weight: bold;\n            margin-bottom: 15px;\n            color: #333;\n        }\n        .status-box {\n            background: #f8f9fa;\n            padding: 20px;\n            border-radius: 8px;\n            margin-bottom: 15px;\n        }\n        .status-item {\n            display: flex;\n            justify-content: space-between;\n            padding: 8px 0;\n        }\n        .qrcode-container {\n            text-align: center;\n            padding: 20px;\n        }\n        .qrcode-img {\n            max-width: 200px;\n            border: 2px solid #ddd;\n            border-radius: 8px;\n        }\n        .btn {\n            padding: 10px 20px;\n            border: none;\n            border-radius: 6px;\n            cursor: pointer;\n            font-size: 14px;\n            transition: all 0.3s;\n        }\n        .btn-primary {\n            background: #667eea;\n            color: white;\n        }\n        .btn-primary:hover { background: #5568d3; }\n        .btn-danger {\n            background: #f56565;\n            color: white;\n        }\n        .btn-danger:hover { background: #e53e3e; }\n        .btn-secondary {\n            background: #e2e8f0;\n            color: #333;\n        }\n        .btn-secondary:hover { background: #cbd5e0; }\n        textarea {\n            width: 100%;\n            padding: 10px 15px;\n            border: 1px solid #ddd;\n            border-radius: 6px;\n            font-size: 14px;\n            resize: vertical;\n            font-family: monospace;\n        }\n        .test-results {\n            max-height: 300px;\n            overflow-y: auto;\n            background: #f8f9fa;\n            padding: 15px;\n            border-radius: 6px;\n            margin-top: 10px;\n        }\n        .hidden { display: none; }\n        .alert {\n            padding: 12px 15px;\n            border-radius: 6px;\n            margin: 10px 0;\n        }\n        .alert-success {\n            background: #c6f6d5;\n            color: #22543d;\n        }\n        .alert-error {\n            background: #fed7d7;\n            color: #742a2a;\n        }\n        .api-code {\n            background: #2d3748;\n            color: #68d391;\n            padding: 10px;\n            border-radius: 6px;\n            font-family: 'Courier New', monospace;\n            font-size: 12px;\n            overflow-x: auto;\n            margin: 10px 0;\n            white-space: pre-wrap;\n        }\n    </style>\n</head>\n<body>\n    <div class=\"container\">\n        <div class=\"header\">\n            <h1>🔍 PanSou QQ频道搜索</h1>\n            <p>配置你的专属搜索服务</p>\n            <p style=\"font-size: 12px; margin-top: 10px; opacity: 0.8;\">\n                🔗 当前地址: <span id=\"current-url\">HASH_PLACEHOLDER</span>\n            </p>\n        </div>\n\n        <div class=\"section\" id=\"login-section\">\n            <div class=\"section-title\">📱 登录状态</div>\n            \n            <div id=\"logged-in-view\" class=\"hidden\">\n                <div style=\"text-align: center; padding: 20px;\">\n                    <div style=\"width: 100px; height: 100px; margin: 0 auto 15px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 36px; font-weight: bold;\">\n                        <span id=\"qq-avatar\">QQ</span>\n                    </div>\n                </div>\n                <div class=\"status-box\">\n                    <div class=\"status-item\">\n                        <span>状态</span>\n                        <span><strong style=\"color: #48bb78;\">✅ 已登录</strong></span>\n                    </div>\n                    <div class=\"status-item\">\n                        <span>QQ号</span>\n                        <span id=\"qq-masked\">-</span>\n                    </div>\n                    <div class=\"status-item\">\n                        <span>登录时间</span>\n                        <span id=\"login-time\">-</span>\n                    </div>\n                    <div class=\"status-item\">\n                        <span>有效期</span>\n                        <span id=\"expire-info\">-</span>\n                    </div>\n                </div>\n                <button class=\"btn btn-danger\" onclick=\"logout()\">退出登录</button>\n            </div>\n\n            <div id=\"not-logged-in-view\" class=\"hidden\">\n                <div class=\"qrcode-container\">\n                    <img id=\"qrcode-img\" class=\"qrcode-img\" src=\"\" alt=\"二维码\">\n                    <p style=\"margin-top: 10px; color: #666;\">\n                        请使用手机QQ扫描二维码登录\n                    </p>\n                    <p style=\"font-size: 12px; color: #999;\">扫码后自动检测登录状态</p>\n                    <button class=\"btn btn-secondary\" onclick=\"refreshQRCode()\" style=\"margin-top: 10px;\">\n                        刷新二维码\n                    </button>\n                </div>\n            </div>\n        </div>\n\n        <div class=\"section\" id=\"channels-section\">\n            <div class=\"section-title\">📋 频道管理 (<span id=\"channel-count\">0</span> 个)</div>\n            \n            <div id=\"alert-box\"></div>\n            \n            <p style=\"margin-bottom: 10px; color: #666;\">每行一个频道号或链接，保存时自动去重</p>\n            <textarea id=\"channels-textarea\" rows=\"10\" placeholder=\"pd97631607\nkuake12345\nlanguan8K115\"></textarea>\n            \n            <button class=\"btn btn-primary\" onclick=\"saveChannels()\" style=\"margin-top: 10px;\">保存频道配置</button>\n        </div>\n\n        <div class=\"section\" id=\"test-section\">\n            <div class=\"section-title\">🔍 测试搜索(限制返回10条数据)</div>\n            \n            <div style=\"display: flex; gap: 10px;\">\n                <input type=\"text\" id=\"search-keyword\" placeholder=\"输入关键词测试搜索\" style=\"flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 6px;\">\n                <button class=\"btn btn-primary\" onclick=\"testSearch()\">搜索</button>\n            </div>\n\n            <div id=\"search-results\" class=\"test-results hidden\"></div>\n        </div>\n\n        <div class=\"section\">\n            <div class=\"section-title\">📖 API调用说明</div>\n            \n            <p style=\"margin-bottom: 15px;\">你可以通过API程序化管理频道和搜索：</p>\n\n            <details>\n                <summary style=\"cursor: pointer; padding: 10px 0; font-weight: bold;\">获取状态</summary>\n                <div class=\"api-code\">curl -X POST https://your-domain.com/qqpd/HASH_PLACEHOLDER \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"action\": \"get_status\"}'</div>\n            </details>\n\n            <details>\n                <summary style=\"cursor: pointer; padding: 10px 0; font-weight: bold;\">设置频道列表</summary>\n                <div class=\"api-code\">curl -X POST https://your-domain.com/qqpd/HASH_PLACEHOLDER \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"action\": \"set_channels\", \"channels\": [\"pd97631607\", \"kuake12345\"]}'</div>\n            </details>\n\n            <details>\n                <summary style=\"cursor: pointer; padding: 10px 0; font-weight: bold;\">测试搜索</summary>\n                <div class=\"api-code\">curl -X POST https://your-domain.com/qqpd/HASH_PLACEHOLDER \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"action\": \"test_search\", \"keyword\": \"遮天\"}'</div>\n            </details>\n        </div>\n    </div>\n\n    <script>\n        const HASH = 'HASH_PLACEHOLDER';\n        const API_URL = '/qqpd/' + HASH;\n        let statusCheckInterval = null;\n        let loginCheckInterval = null;\n\n        window.onload = function() {\n            updateStatus();\n            startStatusPolling();\n        };\n\n        function startStatusPolling() {\n            statusCheckInterval = setInterval(updateStatus, 3000);\n        }\n\n        function startLoginPolling() {\n            if (loginCheckInterval) return; // 避免重复启动\n            loginCheckInterval = setInterval(checkLogin, 2000);\n        }\n\n        function stopLoginPolling() {\n            if (loginCheckInterval) {\n                clearInterval(loginCheckInterval);\n                loginCheckInterval = null;\n            }\n        }\n\n        async function postAction(action, extraData = {}) {\n            try {\n                const response = await fetch(API_URL, {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ action: action, ...extraData })\n                });\n                return await response.json();\n            } catch (error) {\n                console.error('请求失败:', error);\n                return { success: false, message: '请求失败: ' + error.message };\n            }\n        }\n\n        async function updateStatus() {\n            const result = await postAction('get_status');\n            if (result.success && result.data) {\n                const data = result.data;\n                \n                if (data.logged_in === true && data.status === 'active') {\n                    // 已登录：显示用户信息，隐藏二维码\n                    document.getElementById('logged-in-view').classList.remove('hidden');\n                    document.getElementById('not-logged-in-view').classList.add('hidden');\n                    \n                    // 更新用户信息\n                    const qqMasked = data.qq_masked || 'QQ';\n                    document.getElementById('qq-masked').textContent = qqMasked;\n                    document.getElementById('login-time').textContent = data.login_time || '-';\n                    document.getElementById('expire-info').textContent = '剩余 ' + (data.expires_in_days || 0) + ' 天';\n                    \n                    // 显示QQ号首位作为头像\n                    const firstChar = qqMasked.charAt(0) || 'Q';\n                    document.getElementById('qq-avatar').textContent = firstChar;\n                    \n                    // 停止登录检测\n                    stopLoginPolling();\n                } else {\n                    // 未登录：显示二维码，隐藏用户信息\n                    document.getElementById('logged-in-view').classList.add('hidden');\n                    document.getElementById('not-logged-in-view').classList.remove('hidden');\n                    \n                    if (data.qrcode_base64) {\n                        document.getElementById('qrcode-img').src = data.qrcode_base64;\n                    }\n                    \n                    // 启动登录检测（每2秒检查一次）\n                    startLoginPolling();\n                }\n\n                updateChannelList(data.channels || []);\n            }\n        }\n\n        async function checkLogin() {\n            const result = await postAction('check_login');\n            if (result.success && result.data) {\n                if (result.data.login_status === 'success') {\n                    // 登录成功，停止轮询并刷新状态\n                    stopLoginPolling();\n                    showAlert('登录成功！');\n                    updateStatus();\n                }\n            }\n        }\n\n        function updateChannelList(channels) {\n            const textarea = document.getElementById('channels-textarea');\n            const count = document.getElementById('channel-count');\n            \n            count.textContent = channels.length;\n            \n            // 只在用户没有聚焦输入框时更新内容\n            if (document.activeElement !== textarea) {\n                textarea.value = channels.join('\\n');\n            }\n        }\n\n        function showAlert(message, type = 'success') {\n            const alertBox = document.getElementById('alert-box');\n            alertBox.innerHTML = '<div class=\"alert alert-' + type + '\">' + message + '</div>';\n            setTimeout(() => {\n                alertBox.innerHTML = '';\n            }, 3000);\n        }\n\n        async function refreshQRCode() {\n            const result = await postAction('refresh_qrcode');\n            if (result.success) {\n                showAlert(result.message);\n                updateStatus();\n                // 启动登录检测\n                startLoginPolling();\n            } else {\n                showAlert(result.message, 'error');\n            }\n        }\n\n        async function logout() {\n            if (!confirm('确定要退出登录吗？')) return;\n            \n            const result = await postAction('logout');\n            if (result.success) {\n                showAlert(result.message);\n                updateStatus();\n            } else {\n                showAlert(result.message, 'error');\n            }\n        }\n\n        async function saveChannels() {\n            const textarea = document.getElementById('channels-textarea');\n            const channelsText = textarea.value.trim();\n            \n            const channels = channelsText\n                .split('\\n')\n                .map(line => line.trim())\n                .filter(line => line.length > 0);\n            \n            const result = await postAction('set_channels', { channels });\n            if (result.success) {\n                showAlert(result.message);\n                updateStatus();\n            } else {\n                showAlert(result.message, 'error');\n            }\n        }\n\n        async function testSearch() {\n            const keyword = document.getElementById('search-keyword').value.trim();\n            \n            if (!keyword) {\n                showAlert('请输入搜索关键词', 'error');\n                return;\n            }\n\n            const resultsDiv = document.getElementById('search-results');\n            resultsDiv.classList.remove('hidden');\n            resultsDiv.innerHTML = '<div>🔍 搜索中...</div>';\n\n            const result = await postAction('test_search', { keyword });\n            \n            if (result.success) {\n                const results = result.data.results || [];\n                \n                if (results.length === 0) {\n                    resultsDiv.innerHTML = '<p style=\"text-align: center; color: #999;\">未找到结果</p>';\n                    return;\n                }\n\n                let html = '<p><strong>找到 ' + result.data.total_results + ' 条结果</strong></p>';\n                results.forEach((item, index) => {\n                    html += '<div style=\"margin: 15px 0; padding: 10px; background: white; border-radius: 6px;\">';\n                    html += '<p><strong>' + (index + 1) + '. ' + item.title + '</strong></p>';\n                    item.links.forEach(link => {\n                        html += '<p style=\"font-size: 12px; color: #666; margin: 5px 0; word-break: break-all;\">';\n                        html += '[' + link.type + '] ' + link.url;\n                        if (link.password) html += ' 密码: ' + link.password;\n                        html += '</p>';\n                    });\n                    html += '</div>';\n                });\n                resultsDiv.innerHTML = html;\n            } else {\n                resultsDiv.innerHTML = '<p style=\"color: red;\">' + result.message + '</p>';\n            }\n        }\n\n        document.getElementById('search-keyword').addEventListener('keypress', function(e) {\n            if (e.key === 'Enter') testSearch();\n        });\n    </script>\n</body>\n</html>`\n\n// QQPDPlugin 插件结构\ntype QQPDPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tusers       sync.Map // 内存缓存：hash -> *User\n\tmu          sync.RWMutex\n\tinitialized bool // 初始化状态标记\n}\n\n// User 用户数据结构\ntype User struct {\n\tHash            string            `json:\"hash\"`\n\tQQMasked        string            `json:\"qq_masked\"`\n\tCookie          string            `json:\"cookie\"`\n\tStatus          string            `json:\"status\"`\n\tChannels        []string          `json:\"channels\"`\n\tChannelGuildIDs map[string]string `json:\"channel_guild_ids\"` // 频道号->guild_id映射（持久化缓存）\n\tCreatedAt       time.Time         `json:\"created_at\"`\n\tLoginAt         time.Time         `json:\"login_at\"`\n\tExpireAt        time.Time         `json:\"expire_at\"`\n\tLastAccessAt    time.Time         `json:\"last_access_at\"`\n\n\t// 二维码相关（不持久化）\n\tQRCodeCache     []byte    `json:\"-\"` // 二维码缓存\n\tQRCodeCacheTime time.Time `json:\"-\"` // 二维码生成时间\n\tQrsig           string    `json:\"-\"` // qrsig（用于登录检测）\n}\n\n// ChannelTask 频道搜索任务\ntype ChannelTask struct {\n\tChannelID string // 频道号\n\tGuildID   string // 真实的guild_id（从缓存或实时获取）\n\tUserHash  string // 分配给哪个用户\n\tCookie    string // 使用的Cookie\n}\n\nfunc init() {\n\tp := &QQPDPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"qqpd\", 3),\n\t}\n\n\tplugin.RegisterGlobalPlugin(p)\n}\n\n// Initialize 实现 InitializablePlugin 接口，延迟初始化插件\nfunc (p *QQPDPlugin) Initialize() error {\n\tif p.initialized {\n\t\treturn nil\n\t}\n\n\t// 初始化存储目录路径\n\tcachePath := os.Getenv(\"CACHE_PATH\")\n\tif cachePath == \"\" {\n\t\tcachePath = \"./cache\"\n\t}\n\tStorageDir = filepath.Join(cachePath, \"qqpd_users\")\n\n\t// 初始化存储目录\n\tif err := os.MkdirAll(StorageDir, 0755); err != nil {\n\t\treturn fmt.Errorf(\"创建存储目录失败: %v\", err)\n\t}\n\n\t// 加载所有用户到内存\n\tp.loadAllUsers()\n\n\t// 启动定期清理任务\n\tgo p.startCleanupTask()\n\n\tp.initialized = true\n\treturn nil\n}\n\n// ============ 插件接口实现 ============\n\n// SkipServiceFilter 返回是否跳过Service层的关键词过滤\n// 注释掉：让Service层来处理过滤，Service层会根据每个链接的标题进行精确过滤\n// func (p *QQPDPlugin) SkipServiceFilter() bool {\n// \treturn true\n// }\n\n// RegisterWebRoutes 注册Web路由\nfunc (p *QQPDPlugin) RegisterWebRoutes(router *gin.RouterGroup) {\n\tqqpd := router.Group(\"/qqpd\")\n\tqqpd.GET(\"/:param\", p.handleManagePage)\n\tqqpd.POST(\"/:param\", p.handleManagePagePOST)\n\n\tfmt.Printf(\"[QQPD] Web路由已注册: /qqpd/:param\\n\")\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *QQPDPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *QQPDPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\tif DebugLog {\n\t\tfmt.Printf(\"[QQPD] ========== 开始搜索: %s ==========\\n\", keyword)\n\t}\n\n\t// 1. 获取所有有效用户\n\tusers := p.getActiveUsers()\n\tif DebugLog {\n\t\tfmt.Printf(\"[QQPD] 找到 %d 个有效用户\\n\", len(users))\n\t}\n\n\tif len(users) == 0 {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] 没有有效用户，返回空结果\\n\")\n\t\t}\n\t\treturn model.PluginSearchResult{Results: []model.SearchResult{}, IsFinal: true}, nil\n\t}\n\n\t// 2. 限制用户数量（取最近活跃的）\n\tif len(users) > MaxConcurrentUsers {\n\t\tsort.Slice(users, func(i, j int) bool {\n\t\t\treturn users[i].LastAccessAt.After(users[j].LastAccessAt)\n\t\t})\n\t\tusers = users[:MaxConcurrentUsers]\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] 限制用户数量为: %d\\n\", MaxConcurrentUsers)\n\t\t}\n\t}\n\n\t// 3. 收集并去重频道，智能分配给用户\n\ttasks := p.buildChannelTasks(users)\n\tif DebugLog {\n\t\tfmt.Printf(\"[QQPD] 生成 %d 个频道任务（去重后）\\n\", len(tasks))\n\t\tfor i, task := range tasks {\n\t\t\tif i < 5 { // 只打印前5个\n\t\t\t\tfmt.Printf(\"[QQPD]   任务%d: 频道=%s, 用户=%s\\n\", i+1, task.ChannelID, task.UserHash[:8]+\"...\")\n\t\t\t}\n\t\t}\n\t}\n\n\t// 4. 并发执行所有任务\n\tresults := p.executeTasks(tasks, keyword)\n\tif DebugLog {\n\t\tfmt.Printf(\"[QQPD] 所有任务完成，获得 %d 条原始结果\\n\", len(results))\n\t}\n\n\t// 5. 不在插件内过滤，交给Service层处理（Service层会根据每个链接的标题精确过滤）\n\t// filtered := plugin.FilterResultsByKeyword(results, keyword)\n\tif DebugLog {\n\t\tfmt.Printf(\"[QQPD] 返回 %d 条结果（交由Service层过滤）\\n\", len(results))\n\t\tfmt.Printf(\"[QQPD] ========== 搜索完成 ==========\\n\")\n\t}\n\n\treturn model.PluginSearchResult{\n\t\tResults: results, // 返回原始结果，不过滤\n\t\tIsFinal: true,\n\t}, nil\n}\n\n// ============ 内存缓存管理 ============\n\n// loadAllUsers 启动时加载所有用户到内存\nfunc (p *QQPDPlugin) loadAllUsers() {\n\tfiles, err := ioutil.ReadDir(StorageDir)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tcount := 0\n\tfor _, file := range files {\n\t\tif file.IsDir() || filepath.Ext(file.Name()) != \".json\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tfilePath := filepath.Join(StorageDir, file.Name())\n\t\tdata, err := ioutil.ReadFile(filePath)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar user User\n\t\tif err := json.Unmarshal(data, &user); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 加载到内存\n\t\tp.users.Store(user.Hash, &user)\n\t\tcount++\n\t}\n\n\tfmt.Printf(\"[QQPD] 已加载 %d 个用户到内存\\n\", count)\n}\n\n// getUserByHash 获取用户（从内存）\nfunc (p *QQPDPlugin) getUserByHash(hash string) (*User, bool) {\n\tvalue, ok := p.users.Load(hash)\n\tif !ok {\n\t\treturn nil, false\n\t}\n\treturn value.(*User), true\n}\n\n// saveUser 保存用户（内存+文件）\nfunc (p *QQPDPlugin) saveUser(user *User) error {\n\t// 更新内存\n\tp.users.Store(user.Hash, user)\n\n\t// 持久化到文件\n\treturn p.persistUser(user)\n}\n\n// persistUser 持久化用户到文件\nfunc (p *QQPDPlugin) persistUser(user *User) error {\n\tfilePath := filepath.Join(StorageDir, user.Hash+\".json\")\n\n\tdata, err := json.MarshalIndent(user, \"\", \"  \")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn ioutil.WriteFile(filePath, data, 0644)\n}\n\n// deleteUser 删除用户（内存+文件）\nfunc (p *QQPDPlugin) deleteUser(hash string) error {\n\t// 从内存删除\n\tp.users.Delete(hash)\n\n\t// 从文件删除\n\tfilePath := filepath.Join(StorageDir, hash+\".json\")\n\treturn os.Remove(filePath)\n}\n\n// getActiveUsers 获取有效的活跃用户\nfunc (p *QQPDPlugin) getActiveUsers() []*User {\n\tvar users []*User\n\n\ttotalUsers := 0\n\tactiveUsers := 0\n\texpiredUsers := 0\n\tnoChannelUsers := 0\n\n\tp.users.Range(func(key, value interface{}) bool {\n\t\tuser := value.(*User)\n\t\ttotalUsers++\n\n\t\t// 双重过滤\n\t\tif user.Status != \"active\" {\n\t\t\tif DebugLog && totalUsers <= 3 {\n\t\t\t\tfmt.Printf(\"[QQPD]   用户%s: 状态=%s (非active，跳过)\\n\", user.Hash[:8]+\"...\", user.Status)\n\t\t\t}\n\t\t\treturn true\n\t\t}\n\n\t\t// 检查Cookie是否过期（根据ExpireAt时间判断）\n\t\tif !user.ExpireAt.IsZero() && time.Now().After(user.ExpireAt) {\n\t\t\t// Cookie已过期，标记用户状态为过期\n\t\t\texpiredUsers++\n\t\t\tuser.Status = \"expired\"\n\t\t\tuser.Cookie = \"\" // 清空Cookie\n\t\t\tp.saveUser(user)\n\t\t\tif DebugLog && expiredUsers <= 3 {\n\t\t\t\tfmt.Printf(\"[QQPD]   用户%s: Cookie已过期 (过期时间: %s)\\n\", user.Hash[:8]+\"...\", user.ExpireAt.Format(\"2006-01-02 15:04:05\"))\n\t\t\t}\n\t\t\treturn true\n\t\t}\n\n\t\tif len(user.Channels) == 0 {\n\t\t\tnoChannelUsers++\n\t\t\tif DebugLog && noChannelUsers <= 3 {\n\t\t\t\tfmt.Printf(\"[QQPD]   用户%s: 频道数=0 (跳过)\\n\", user.Hash[:8]+\"...\")\n\t\t\t}\n\t\t\treturn true\n\t\t}\n\n\t\t// 通过所有过滤\n\t\tactiveUsers++\n\t\tif DebugLog && activeUsers <= 3 {\n\t\t\tremainingDays := 0\n\t\t\tif !user.ExpireAt.IsZero() {\n\t\t\t\tremainingDays = int(time.Until(user.ExpireAt).Hours() / 24)\n\t\t\t}\n\t\t\tfmt.Printf(\"[QQPD]   用户%s: 有效 (频道数=%d, 剩余有效期=%d天)\\n\", user.Hash[:8]+\"...\", len(user.Channels), remainingDays)\n\t\t}\n\t\tusers = append(users, user)\n\t\treturn true\n\t})\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[QQPD] 用户统计: 总数=%d, 有效=%d, 已过期=%d, 无频道=%d\\n\",\n\t\t\ttotalUsers, activeUsers, expiredUsers, noChannelUsers)\n\t}\n\n\treturn users\n}\n\n// ============ HTTP路由处理 ============\n\n// handleManagePage GET路由处理（合并QQ号转hash和显示页面）\nfunc (p *QQPDPlugin) handleManagePage(c *gin.Context) {\n\tparam := c.Param(\"param\")\n\n\t// 判断是QQ号还是hash（hash是64字符的十六进制）\n\tif len(param) == 64 && p.isHexString(param) {\n\t\t// 这是hash，直接显示管理页面\n\t\thtml := strings.ReplaceAll(HTMLTemplate, \"HASH_PLACEHOLDER\", param)\n\t\tc.Data(200, \"text/html; charset=utf-8\", []byte(html))\n\t} else {\n\t\t// 这是QQ号，计算hash并重定向\n\t\thash := p.generateHash(param)\n\t\tc.Redirect(302, \"/qqpd/\"+hash)\n\t}\n}\n\n// handleManagePagePOST POST路由处理\nfunc (p *QQPDPlugin) handleManagePagePOST(c *gin.Context) {\n\thash := c.Param(\"param\")\n\n\t// 读取完整的请求体到map\n\tvar reqData map[string]interface{}\n\tif err := c.ShouldBindJSON(&reqData); err != nil {\n\t\trespondError(c, \"无效的请求格式: \"+err.Error())\n\t\treturn\n\t}\n\n\t// 获取action字段\n\taction, ok := reqData[\"action\"].(string)\n\tif !ok || action == \"\" {\n\t\trespondError(c, \"缺少action字段\")\n\t\treturn\n\t}\n\n\t// 根据action路由到不同的处理函数\n\tswitch action {\n\tcase \"get_status\":\n\t\tp.handleGetStatus(c, hash)\n\tcase \"refresh_qrcode\":\n\t\tp.handleRefreshQRCode(c, hash)\n\tcase \"logout\":\n\t\tp.handleLogout(c, hash)\n\tcase \"set_channels\":\n\t\tp.handleSetChannelsWithData(c, hash, reqData)\n\tcase \"test_search\":\n\t\tp.handleTestSearchWithData(c, hash, reqData)\n\tcase \"manual_login\":\n\t\t// 测试用：手动设置登录状态\n\t\tp.handleManualLogin(c, hash, reqData)\n\tcase \"check_login\":\n\t\t// 检查登录状态（扫码后调用）\n\t\tp.handleCheckLogin(c, hash)\n\tdefault:\n\t\trespondError(c, \"未知的操作类型: \"+action)\n\t}\n}\n\n// ============ POST Action处理 ============\n\n// handleGetStatus 获取状态\nfunc (p *QQPDPlugin) handleGetStatus(c *gin.Context, hash string) {\n\tuser, exists := p.getUserByHash(hash)\n\n\tif !exists {\n\t\t// 创建新用户（内存+文件）\n\t\tuser = &User{\n\t\t\tHash:         hash,\n\t\t\tStatus:       \"pending\",\n\t\t\tChannels:     []string{},\n\t\t\tCreatedAt:    time.Now(),\n\t\t\tLastAccessAt: time.Now(),\n\t\t}\n\t\tp.saveUser(user)\n\t} else {\n\t\t// 更新最后访问时间\n\t\tuser.LastAccessAt = time.Now()\n\t\tp.saveUser(user)\n\t}\n\n\t// 检查登录状态（简化逻辑）\n\tloggedIn := false\n\tif user.Status == \"active\" && user.Cookie != \"\" {\n\t\t// 状态是active且有Cookie，刷新cookies（更新uuid等动态字段）\n\t\trefreshedCookie := p.refreshCookie(user.Cookie)\n\t\tif refreshedCookie != user.Cookie {\n\t\t\tuser.Cookie = refreshedCookie\n\t\t\tp.saveUser(user)\n\t\t}\n\t\tloggedIn = true\n\t} else if user.Status == \"active\" && user.Cookie == \"\" {\n\t\t// 状态是active但Cookie为空，异常情况，重置为pending\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] 用户 %s 状态异常（active但Cookie为空），重置为pending\\n\", hash[:8]+\"...\")\n\t\t}\n\t\tuser.Status = \"pending\"\n\t\tuser.QQMasked = \"\"\n\t\tp.saveUser(user)\n\t}\n\n\t// 生成二维码（如果需要）\n\tvar qrcodeBase64 string\n\tif !loggedIn {\n\t\t// 使用缓存的二维码（30秒内有效）\n\t\tif user.QRCodeCache != nil && time.Since(user.QRCodeCacheTime) < 30*time.Second {\n\t\t\tqrcodeBase64 = \"data:image/png;base64,\" + base64.StdEncoding.EncodeToString(user.QRCodeCache)\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[QQPD] 使用缓存的二维码（还剩 %.0f 秒）\\n\", 30-time.Since(user.QRCodeCacheTime).Seconds())\n\t\t\t}\n\t\t} else {\n\t\t\t// 生成新二维码\n\t\t\tqrcodeBytes, qrsig, err := p.generateQRCodeWithSig()\n\t\t\tif err != nil {\n\t\t\t\tfmt.Printf(\"[QQPD] 生成二维码失败: %v\\n\", err)\n\t\t\t\tqrcodeBase64 = \"\"\n\t\t\t} else {\n\t\t\t\tqrcodeBase64 = \"data:image/png;base64,\" + base64.StdEncoding.EncodeToString(qrcodeBytes)\n\t\t\t\t// 缓存二维码和qrsig\n\t\t\t\tuser.QRCodeCache = qrcodeBytes\n\t\t\t\tuser.QRCodeCacheTime = time.Now()\n\t\t\t\tuser.Qrsig = qrsig\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[QQPD] 生成新二维码并缓存30秒\\n\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 计算剩余天数\n\texpiresInDays := 0\n\tif !user.ExpireAt.IsZero() {\n\t\texpiresInDays = int(time.Until(user.ExpireAt).Hours() / 24)\n\t\tif expiresInDays < 0 {\n\t\t\texpiresInDays = 0\n\t\t}\n\t}\n\n\trespondSuccess(c, \"获取成功\", gin.H{\n\t\t\"hash\":            hash,\n\t\t\"logged_in\":       loggedIn,\n\t\t\"status\":          user.Status,\n\t\t\"qq_masked\":       user.QQMasked,\n\t\t\"login_time\":      user.LoginAt.Format(\"2006-01-02 15:04:05\"),\n\t\t\"expire_time\":     user.ExpireAt.Format(\"2006-01-02 15:04:05\"),\n\t\t\"expires_in_days\": expiresInDays,\n\t\t\"channels\":        user.Channels,\n\t\t\"channel_count\":   len(user.Channels),\n\t\t\"qrcode_base64\":   qrcodeBase64,\n\t})\n}\n\n// handleRefreshQRCode 刷新二维码\nfunc (p *QQPDPlugin) handleRefreshQRCode(c *gin.Context, hash string) {\n\tuser, exists := p.getUserByHash(hash)\n\tif !exists {\n\t\trespondError(c, \"用户不存在\")\n\t\treturn\n\t}\n\n\t// 强制生成新二维码\n\tqrcodeBytes, qrsig, err := p.generateQRCodeWithSig()\n\tif err != nil {\n\t\trespondError(c, \"生成二维码失败: \"+err.Error())\n\t\treturn\n\t}\n\n\t// 缓存二维码\n\tuser.QRCodeCache = qrcodeBytes\n\tuser.QRCodeCacheTime = time.Now()\n\tuser.Qrsig = qrsig\n\n\tqrcodeBase64 := \"data:image/png;base64,\" + base64.StdEncoding.EncodeToString(qrcodeBytes)\n\n\trespondSuccess(c, \"二维码已刷新\", gin.H{\n\t\t\"qrcode_base64\": qrcodeBase64,\n\t})\n}\n\n// handleLogout 退出登录\nfunc (p *QQPDPlugin) handleLogout(c *gin.Context, hash string) {\n\tuser, exists := p.getUserByHash(hash)\n\tif !exists {\n\t\trespondError(c, \"用户不存在\")\n\t\treturn\n\t}\n\n\t// 清除Cookie\n\tuser.Cookie = \"\"\n\tuser.Status = \"pending\"\n\tuser.QQMasked = \"\"\n\n\tif err := p.saveUser(user); err != nil {\n\t\trespondError(c, \"退出失败\")\n\t\treturn\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[QQPD] 用户 %s 已退出登录\\n\", hash[:8]+\"...\")\n\t}\n\n\trespondSuccess(c, \"已退出登录\", gin.H{\n\t\t\"status\": \"pending\",\n\t})\n}\n\n// handleCheckLogin 检查登录状态（前端轮询调用）\nfunc (p *QQPDPlugin) handleCheckLogin(c *gin.Context, hash string) {\n\tuser, exists := p.getUserByHash(hash)\n\tif !exists {\n\t\trespondError(c, \"用户不存在\")\n\t\treturn\n\t}\n\n\t// 检查是否有qrsig\n\tif user.Qrsig == \"\" {\n\t\trespondError(c, \"请先刷新二维码\")\n\t\treturn\n\t}\n\n\t// 检查登录状态\n\tloginResult, err := p.checkQRLoginStatus(user.Qrsig)\n\tif err != nil {\n\t\trespondError(c, err.Error())\n\t\treturn\n\t}\n\n\tif loginResult.Status == \"success\" {\n\t\t// 登录成功，更新用户信息\n\t\tuser.Cookie = loginResult.Cookie\n\t\tuser.Status = \"active\"\n\t\tuser.QQMasked = loginResult.QQMasked\n\t\tuser.LoginAt = time.Now()\n\t\t// QQ Cookie的实际有效期通常是2天，设置为2天后过期（留一点缓冲时间）\n\t\tuser.ExpireAt = time.Now().AddDate(0, 0, 2)\n\n\t\tif err := p.saveUser(user); err != nil {\n\t\t\trespondError(c, \"保存失败: \"+err.Error())\n\t\t\treturn\n\t\t}\n\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] 用户 %s 登录成功，QQ: %s, Cookie包含keys: \", hash[:8]+\"...\", loginResult.QQMasked)\n\t\t\t// 打印Cookie中的所有key（不打印value保护隐私）\n\t\t\tcookies := parseCookieString(loginResult.Cookie)\n\t\t\tkeys := make([]string, 0, len(cookies))\n\t\t\tfor k := range cookies {\n\t\t\t\tkeys = append(keys, k)\n\t\t\t}\n\t\t\tfmt.Printf(\"%v\\n\", keys)\n\t\t}\n\n\t\trespondSuccess(c, \"登录成功\", gin.H{\n\t\t\t\"login_status\": \"success\",\n\t\t\t\"qq_masked\":    loginResult.QQMasked,\n\t\t})\n\t} else if loginResult.Status == \"waiting\" {\n\t\trespondSuccess(c, \"等待扫码\", gin.H{\n\t\t\t\"login_status\": \"waiting\",\n\t\t})\n\t} else if loginResult.Status == \"expired\" {\n\t\trespondError(c, \"二维码已失效，请刷新\")\n\t} else {\n\t\trespondError(c, \"登录检测失败\")\n\t}\n}\n\n// handleManualLogin 手动登录（测试用）\nfunc (p *QQPDPlugin) handleManualLogin(c *gin.Context, hash string, reqData map[string]interface{}) {\n\tuser, exists := p.getUserByHash(hash)\n\tif !exists {\n\t\trespondError(c, \"用户不存在\")\n\t\treturn\n\t}\n\n\t// 获取cookie和qq_masked参数\n\tcookie, _ := reqData[\"cookie\"].(string)\n\tqqMasked, _ := reqData[\"qq_masked\"].(string)\n\n\tif cookie == \"\" {\n\t\trespondError(c, \"缺少cookie参数\")\n\t\treturn\n\t}\n\n\t// 测试Cookie有效性\n\tif !p.testCookieValid(cookie) {\n\t\trespondError(c, \"Cookie无效或已失效\")\n\t\treturn\n\t}\n\n\t// 更新用户状态\n\tuser.Cookie = cookie\n\tuser.Status = \"active\"\n\tuser.QQMasked = qqMasked\n\tuser.LoginAt = time.Now()\n\t// QQ Cookie的实际有效期通常是2天，设置为2天后过期（留一点缓冲时间）\n\tuser.ExpireAt = time.Now().AddDate(0, 0, 2)\n\n\tif err := p.saveUser(user); err != nil {\n\t\trespondError(c, \"保存失败: \"+err.Error())\n\t\treturn\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[QQPD] 用户 %s 手动登录成功，QQ: %s, Cookie包含keys: \", hash[:8]+\"...\", qqMasked)\n\t\tcookies := parseCookieString(cookie)\n\t\tkeys := make([]string, 0, len(cookies))\n\t\tfor k := range cookies {\n\t\t\tkeys = append(keys, k)\n\t\t}\n\t\tfmt.Printf(\"%v\\n\", keys)\n\t}\n\n\trespondSuccess(c, \"登录成功\", gin.H{\n\t\t\"status\":      \"active\",\n\t\t\"qq_masked\":   qqMasked,\n\t\t\"login_time\":  user.LoginAt.Format(\"2006-01-02 15:04:05\"),\n\t\t\"expire_time\": user.ExpireAt.Format(\"2006-01-02 15:04:05\"),\n\t})\n}\n\n// handleSetChannelsWithData 设置频道列表（覆盖式）\nfunc (p *QQPDPlugin) handleSetChannelsWithData(c *gin.Context, hash string, reqData map[string]interface{}) {\n\t// 从reqData中提取channels字段\n\tchannelsInterface, ok := reqData[\"channels\"]\n\tif !ok {\n\t\trespondError(c, \"缺少channels字段\")\n\t\treturn\n\t}\n\n\t// 转换为字符串数组\n\tchannels := []string{}\n\tif channelsList, ok := channelsInterface.([]interface{}); ok {\n\t\tfor _, ch := range channelsList {\n\t\t\tif chStr, ok := ch.(string); ok {\n\t\t\t\tchannels = append(channels, chStr)\n\t\t\t}\n\t\t}\n\t}\n\n\tuser, exists := p.getUserByHash(hash)\n\tif !exists {\n\t\trespondError(c, \"用户不存在\")\n\t\treturn\n\t}\n\n\t// 规范化频道列表（提取频道号，去重）\n\tnormalizedChannels := []string{}\n\tseen := make(map[string]bool)\n\tinvalid := []string{}\n\n\tfor _, ch := range channels {\n\t\tnormalized := p.normalizeChannel(ch)\n\t\tif normalized == \"\" {\n\t\t\tinvalid = append(invalid, ch)\n\t\t\tcontinue\n\t\t}\n\n\t\tif !seen[normalized] {\n\t\t\tnormalizedChannels = append(normalizedChannels, normalized)\n\t\t\tseen[normalized] = true\n\t\t}\n\t}\n\n\t// 初始化guild_id映射（如果不存在）\n\tif user.ChannelGuildIDs == nil {\n\t\tuser.ChannelGuildIDs = make(map[string]string)\n\t}\n\n\t// 批量获取guild_id并缓存（并发获取，提高速度）\n\tneedFetch := []string{}\n\tfor _, channelNumber := range normalizedChannels {\n\t\t// 如果已有缓存，跳过\n\t\tif _, exists := user.ChannelGuildIDs[channelNumber]; exists {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[QQPD]   频道 %s: 使用缓存的guild_id\\n\", channelNumber)\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tneedFetch = append(needFetch, channelNumber)\n\t}\n\n\tif len(needFetch) > 0 {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] 开始并发获取 %d 个频道的guild_id...\\n\", len(needFetch))\n\t\t}\n\n\t\t// 使用并发获取guild_id（大幅提升速度）\n\t\tvar wg sync.WaitGroup\n\t\tvar mapMutex sync.Mutex\n\n\t\tfor _, channelNumber := range needFetch {\n\t\t\twg.Add(1)\n\t\t\tgo func(ch string) {\n\t\t\t\tdefer wg.Done()\n\n\t\t\t\t// 获取guild_id\n\t\t\t\tguildID := p.extractGuildIDFromChannelNumber(ch)\n\n\t\t\t\t// 线程安全地写入map\n\t\t\t\tmapMutex.Lock()\n\t\t\t\tuser.ChannelGuildIDs[ch] = guildID\n\t\t\t\tmapMutex.Unlock()\n\n\t\t\t\tif DebugLog {\n\t\t\t\t\tif guildID != ch {\n\t\t\t\t\t\tfmt.Printf(\"[QQPD]   频道 %s → guild_id %s (已缓存)\\n\", ch, guildID)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tfmt.Printf(\"[QQPD]   频道 %s: 无法获取guild_id，使用原值\\n\", ch)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}(channelNumber)\n\t\t}\n\n\t\t// 等待所有并发请求完成\n\t\twg.Wait()\n\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] 所有频道的guild_id获取完成\\n\")\n\t\t}\n\t}\n\n\t// 清理已删除频道的缓存\n\tfor channelNumber := range user.ChannelGuildIDs {\n\t\tif !seen[channelNumber] {\n\t\t\tdelete(user.ChannelGuildIDs, channelNumber)\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[QQPD]   清理已删除频道的缓存: %s\\n\", channelNumber)\n\t\t\t}\n\t\t}\n\t}\n\n\t// 更新用户数据（内存+文件）\n\tuser.Channels = normalizedChannels\n\tuser.LastAccessAt = time.Now()\n\n\tif err := p.saveUser(user); err != nil {\n\t\trespondError(c, \"保存失败: \"+err.Error())\n\t\treturn\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[QQPD] 频道配置已保存，共缓存 %d 个guild_id\\n\", len(user.ChannelGuildIDs))\n\t}\n\n\trespondSuccess(c, \"频道列表已更新\", gin.H{\n\t\t\"channels\":         normalizedChannels,\n\t\t\"channel_count\":    len(normalizedChannels),\n\t\t\"invalid_channels\": invalid,\n\t\t\"guild_ids_cached\": len(user.ChannelGuildIDs),\n\t})\n}\n\n// handleTestSearchWithData 测试搜索\nfunc (p *QQPDPlugin) handleTestSearchWithData(c *gin.Context, hash string, reqData map[string]interface{}) {\n\t// 提取参数\n\tkeyword, ok := reqData[\"keyword\"].(string)\n\tif !ok || keyword == \"\" {\n\t\trespondError(c, \"缺少keyword字段\")\n\t\treturn\n\t}\n\n\tmaxResults := 10\n\tif mr, ok := reqData[\"max_results\"].(float64); ok {\n\t\tmaxResults = int(mr)\n\t}\n\n\tuser, exists := p.getUserByHash(hash)\n\tif !exists || user.Cookie == \"\" {\n\t\trespondError(c, \"请先登录\")\n\t\treturn\n\t}\n\n\tif len(user.Channels) == 0 {\n\t\trespondError(c, \"请先配置频道\")\n\t\treturn\n\t}\n\n\t// 执行真实搜索\n\ttasks := []ChannelTask{}\n\tfor _, channelID := range user.Channels {\n\t\t// 从缓存获取guild_id\n\t\tvar guildID string\n\t\tif user.ChannelGuildIDs != nil {\n\t\t\tif cachedGuildID, exists := user.ChannelGuildIDs[channelID]; exists {\n\t\t\t\tguildID = cachedGuildID\n\t\t\t}\n\t\t}\n\t\t// 如果缓存中没有，实时获取\n\t\tif guildID == \"\" {\n\t\t\tguildID = p.extractGuildIDFromChannelNumber(channelID)\n\t\t}\n\n\t\ttasks = append(tasks, ChannelTask{\n\t\t\tChannelID: channelID,\n\t\t\tGuildID:   guildID,\n\t\t\tUserHash:  user.Hash,\n\t\t\tCookie:    user.Cookie,\n\t\t})\n\t}\n\n\t// 并发搜索所有频道\n\tallResults := p.executeTasks(tasks, keyword)\n\n\t// 不在插件内过滤，交给Service层处理\n\t// filteredResults := plugin.FilterResultsByKeyword(allResults, keyword)\n\n\t// 限制返回数量\n\tif len(allResults) > maxResults {\n\t\tallResults = allResults[:maxResults]\n\t}\n\n\t// 转换为前端需要的格式\n\tresults := make([]gin.H, 0, len(allResults))\n\tfor _, r := range allResults {\n\t\tlinks := make([]gin.H, 0, len(r.Links))\n\t\tfor _, link := range r.Links {\n\t\t\tlinks = append(links, gin.H{\n\t\t\t\t\"type\":     link.Type,\n\t\t\t\t\"url\":      link.URL,\n\t\t\t\t\"password\": link.Password,\n\t\t\t})\n\t\t}\n\n\t\tresults = append(results, gin.H{\n\t\t\t\"unique_id\": r.UniqueID, // 添加unique_id，显示来源频道\n\t\t\t\"title\":     r.Title,\n\t\t\t\"links\":     links,\n\t\t})\n\t}\n\n\trespondSuccess(c, fmt.Sprintf(\"找到 %d 条结果\", len(results)), gin.H{\n\t\t\"keyword\":           keyword,\n\t\t\"total_results\":     len(results),\n\t\t\"channels_searched\": user.Channels,\n\t\t\"results\":           results,\n\t})\n}\n\n// ============ 搜索逻辑 ============\n\n// buildChannelTasks 构建频道任务列表（去重+负载均衡）\nfunc (p *QQPDPlugin) buildChannelTasks(users []*User) []ChannelTask {\n\t// 1. 收集所有频道及其所属用户\n\tchannelOwners := make(map[string][]*User)\n\n\tfor _, user := range users {\n\t\tfor _, channelID := range user.Channels {\n\t\t\tchannelOwners[channelID] = append(channelOwners[channelID], user)\n\t\t}\n\t}\n\n\t// 2. 为每个频道分配一个用户（负载均衡）\n\ttasks := []ChannelTask{}\n\tuserTaskCount := make(map[string]int)\n\n\tfor channelID, owners := range channelOwners {\n\t\t// 选择任务最少的用户来执行\n\t\tselectedUser := owners[0]\n\t\tminTasks := userTaskCount[selectedUser.Hash]\n\n\t\tfor _, owner := range owners {\n\t\t\tif count := userTaskCount[owner.Hash]; count < minTasks {\n\t\t\t\tselectedUser = owner\n\t\t\t\tminTasks = count\n\t\t\t}\n\t\t}\n\n\t\t// 从缓存中获取guild_id（优先使用缓存）\n\t\tvar guildID string\n\t\tif selectedUser.ChannelGuildIDs != nil {\n\t\t\tif cachedGuildID, exists := selectedUser.ChannelGuildIDs[channelID]; exists {\n\t\t\t\tguildID = cachedGuildID\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[QQPD]   频道 %s: 使用缓存的guild_id %s\\n\", channelID, guildID)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 如果缓存中没有，实时获取（这种情况应该很少发生）\n\t\tif guildID == \"\" {\n\t\t\tguildID = p.extractGuildIDFromChannelNumber(channelID)\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[QQPD]   频道 %s: 缓存未命中，实时获取guild_id %s\\n\", channelID, guildID)\n\t\t\t}\n\t\t}\n\n\t\t// 创建任务\n\t\ttasks = append(tasks, ChannelTask{\n\t\t\tChannelID: channelID,\n\t\t\tGuildID:   guildID,\n\t\t\tUserHash:  selectedUser.Hash,\n\t\t\tCookie:    selectedUser.Cookie,\n\t\t})\n\n\t\t// 更新任务计数\n\t\tuserTaskCount[selectedUser.Hash]++\n\t}\n\n\treturn tasks\n}\n\n// executeTasks 并发执行所有频道搜索任务\nfunc (p *QQPDPlugin) executeTasks(tasks []ChannelTask, keyword string) []model.SearchResult {\n\tvar allResults []model.SearchResult\n\tvar mu sync.Mutex\n\tvar wg sync.WaitGroup\n\n\t// 使用信号量控制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrentChannels)\n\n\tfor _, task := range tasks {\n\t\twg.Add(1)\n\t\tgo func(t ChannelTask) {\n\t\t\tdefer wg.Done()\n\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\n\t\t\t// 搜索单个频道（使用预先获取的guild_id）\n\t\t\tresults := p.searchSingleChannel(keyword, t.Cookie, t.ChannelID, t.GuildID)\n\n\t\t\t// 安全地追加结果（UniqueID已在extractResultInfo中设置）\n\t\t\tmu.Lock()\n\t\t\tallResults = append(allResults, results...)\n\t\t\tmu.Unlock()\n\t\t}(task)\n\t}\n\n\twg.Wait()\n\treturn allResults\n}\n\n// extractGuildIDFromChannelNumber 从频道号提取真实的guild_id\nfunc (p *QQPDPlugin) extractGuildIDFromChannelNumber(channelNumber string) string {\n\t// 如果已经是纯数字的guild_id，直接返回\n\tif matched, _ := regexp.MatchString(`^\\d+$`, channelNumber); matched {\n\t\treturn channelNumber\n\t}\n\n\t// 访问频道页面获取guild_id\n\turl := fmt.Sprintf(\"https://pd.qq.com/g/%s\", channelNumber)\n\n\tclient := &http.Client{\n\t\tTimeout: 10 * time.Second,\n\t\tTransport: &http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: true},\n\t\t},\n\t}\n\n\tresp, err := client.Get(url)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] 访问频道页面失败: %v\\n\", err)\n\t\t}\n\t\treturn channelNumber\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := ioutil.ReadAll(resp.Body)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] 读取页面失败: %v\\n\", err)\n\t\t}\n\t\treturn channelNumber\n\t}\n\n\t// 从HTML中提取guild_id\n\t// 查找类似: https://groupprohead.gtimg.cn/592843764045681811/\n\tpattern := regexp.MustCompile(`https://groupprohead\\.gtimg\\.cn/(\\d+)/`)\n\tmatches := pattern.FindSubmatch(body)\n\n\tif len(matches) > 1 {\n\t\tguildID := string(matches[1])\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] 频道号 %s → guild_id %s\\n\", channelNumber, guildID)\n\t\t}\n\t\treturn guildID\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[QQPD] 未能从页面提取guild_id，使用原始值: %s\\n\", channelNumber)\n\t}\n\treturn channelNumber\n}\n\n// searchSingleChannel 搜索单个频道\nfunc (p *QQPDPlugin) searchSingleChannel(keyword, cookieStr, channelID, guildID string) []model.SearchResult {\n\tif DebugLog {\n\t\tfmt.Printf(\"[QQPD] 开始搜索频道: %s (guild_id: %s), 关键词: %s\\n\", channelID, guildID, keyword)\n\t}\n\n\t// 搜索前刷新cookies（更新uuid等动态字段）\n\tcookieStr = p.refreshCookie(cookieStr)\n\n\t// 解析Cookie\n\tcookies := parseCookieString(cookieStr)\n\tpSkey, ok := cookies[\"p_skey\"]\n\tif !ok {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] Cookie中缺少p_skey\\n\")\n\t\t}\n\t\treturn []model.SearchResult{}\n\t}\n\n\t// 计算bkn\n\tbknValue := bkn(pSkey)\n\tapiURL := fmt.Sprintf(\"https://pd.qq.com/qunng/guild/gotrpc/auth/trpc.group_pro.in_guild_search_svr.InGuildSearch/NewSearch?bkn=%d\", bknValue)\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[QQPD] API URL: %s\\n\", apiURL)\n\t\tfmt.Printf(\"[QQPD] bkn: %d\\n\", bknValue)\n\t}\n\n\t// 构建请求payload\n\tpayload := map[string]interface{}{\n\t\t\"guild_id\":      guildID,\n\t\t\"query\":         keyword,\n\t\t\"cookie\":        \"\",\n\t\t\"member_cookie\": \"\",\n\t\t\"search_type\": map[string]int{\n\t\t\t\"type\":      0,\n\t\t\t\"feed_type\": 0,\n\t\t},\n\t\t\"cond\": map[string]interface{}{\n\t\t\t\"channel_ids\":    []string{},\n\t\t\t\"feed_rank_type\": 0,\n\t\t\t\"type_list\":      []int{2, 3},\n\t\t},\n\t}\n\n\tpayloadBytes, _ := json.Marshal(payload)\n\tif DebugLog {\n\t\tfmt.Printf(\"[QQPD] Payload: %s\\n\", string(payloadBytes))\n\t}\n\n\t// 创建HTTP请求\n\tclient := &http.Client{\n\t\tTimeout: 15 * time.Second,\n\t\tTransport: &http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: true},\n\t\t},\n\t}\n\n\treq, err := http.NewRequest(\"POST\", apiURL, strings.NewReader(string(payloadBytes)))\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] 创建请求失败: %v\\n\", err)\n\t\t}\n\t\treturn []model.SearchResult{}\n\t}\n\n\t// 设置请求头\n\treq.Header.Set(\"x-oidb\", `{\"uint32_command\":\"0x9287\",\"uint32_service_type\":\"2\"}`)\n\treq.Header.Set(\"content-type\", \"application/json\")\n\treq.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\")\n\treq.Header.Set(\"Referer\", \"https://pd.qq.com/\")\n\treq.Header.Set(\"Origin\", \"https://pd.qq.com\")\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\n\t// 设置Cookie\n\tfor k, v := range cookies {\n\t\treq.AddCookie(&http.Cookie{Name: k, Value: v})\n\t}\n\n\t// 发送请求\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] 请求失败: %v\\n\", err)\n\t\t}\n\t\treturn []model.SearchResult{}\n\t}\n\tdefer resp.Body.Close()\n\n\t// 读取响应体（无论成功与否都要读取，以便诊断问题）\n\tbody, err := ioutil.ReadAll(resp.Body)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] 读取响应体失败: %v\\n\", err)\n\t\t}\n\t\treturn []model.SearchResult{}\n\t}\n\n\tif resp.StatusCode != 200 {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] 请求返回状态码: %d\\n\", resp.StatusCode)\n\t\t\tfmt.Printf(\"[QQPD] 响应头: %v\\n\", resp.Header)\n\t\t\tif len(body) < 1000 {\n\t\t\t\tfmt.Printf(\"[QQPD] 响应内容: %s\\n\", string(body))\n\t\t\t} else {\n\t\t\t\tfmt.Printf(\"[QQPD] 响应内容(前500字符): %s...\\n\", string(body[:500]))\n\t\t\t}\n\t\t}\n\t\treturn []model.SearchResult{}\n\t}\n\n\t// 解析响应（body已在上面读取）\n\tif DebugLog {\n\t\tfmt.Printf(\"[QQPD] 响应长度: %d 字节\\n\", len(body))\n\t\tif len(body) < 500 {\n\t\t\tfmt.Printf(\"[QQPD] 响应内容: %s\\n\", string(body))\n\t\t} else {\n\t\t\tfmt.Printf(\"[QQPD] 响应内容: %s...\\n\", string(body[:500]))\n\t\t}\n\t}\n\n\tvar apiResp map[string]interface{}\n\tif err := json.Unmarshal(body, &apiResp); err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] JSON解析失败: %v\\n\", err)\n\t\t}\n\t\treturn []model.SearchResult{}\n\t}\n\n\t// 提取搜索结果\n\tdata, ok := apiResp[\"data\"].(map[string]interface{})\n\tif !ok {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] 响应中没有data字段\\n\")\n\t\t}\n\t\treturn []model.SearchResult{}\n\t}\n\n\tunionResult, ok := data[\"union_result\"].(map[string]interface{})\n\tif !ok {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] data中没有union_result字段\\n\")\n\t\t}\n\t\treturn []model.SearchResult{}\n\t}\n\n\tguildFeeds, ok := unionResult[\"guild_feeds\"].([]interface{})\n\tif !ok {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] union_result中没有guild_feeds字段\\n\")\n\t\t}\n\t\treturn []model.SearchResult{}\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[QQPD] 找到 %d 条原始结果\\n\", len(guildFeeds))\n\t}\n\n\t// 转换为标准格式\n\tvar results []model.SearchResult\n\tfor i, item := range guildFeeds {\n\t\titemMap, ok := item.(map[string]interface{})\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tresult := p.extractResultInfo(itemMap, channelID, i)\n\t\tif result.Title != \"\" && len(result.Links) > 0 {\n\t\t\tresults = append(results, result)\n\t\t}\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[QQPD] 频道 %s 返回 %d 条有效结果\\n\", guildID, len(results))\n\t}\n\n\treturn results\n}\n\n// extractResultInfo 从搜索结果中提取信息\nfunc (p *QQPDPlugin) extractResultInfo(item map[string]interface{}, channelID string, index int) model.SearchResult {\n\t// 提取标题（去掉\"名称：\"前缀，只取第一行）\n\ttitle, _ := item[\"title\"].(string)\n\tif strings.HasPrefix(title, \"名称：\") {\n\t\ttitle = title[len(\"名称：\"):]\n\t}\n\tif idx := strings.Index(title, \"\\n\"); idx > 0 {\n\t\ttitle = title[:idx]\n\t}\n\ttitle = strings.TrimSpace(title)\n\n\t// 从content提取网盘链接（不在插件层过滤，交给Service层处理）\n\tcontent, _ := item[\"content\"].(string)\n\tlinks := p.extractLinksFromContent(content)\n\n\t// 提取时间戳（从create_time字段）\n\tdatetime := time.Now() // 默认使用当前时间\n\tif createTimeStr, ok := item[\"create_time\"].(string); ok && createTimeStr != \"\" {\n\t\t// create_time是Unix时间戳字符串，转换为int64\n\t\tif timestamp, err := strconv.ParseInt(createTimeStr, 10, 64); err == nil {\n\t\t\tdatetime = time.Unix(timestamp, 0)\n\t\t}\n\t}\n\n\t// 提取图片URL列表\n\tvar images []string\n\tif imagesInterface, ok := item[\"images\"].([]interface{}); ok {\n\t\tfor _, imgItem := range imagesInterface {\n\t\t\tif imgMap, ok := imgItem.(map[string]interface{}); ok {\n\t\t\t\t// 提取url字段\n\t\t\t\tif imgURL, ok := imgMap[\"url\"].(string); ok && imgURL != \"\" {\n\t\t\t\t\timages = append(images, imgURL)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn model.SearchResult{\n\t\tUniqueID: fmt.Sprintf(\"qqpd-%s-%d\", channelID, index),\n\t\tTitle:    title,\n\t\tContent:  content,\n\t\tLinks:    links,\n\t\tDatetime: datetime,\n\t\tImages:   images,\n\t\tChannel:  \"\", // 插件搜索结果Channel必须为空\n\t}\n}\n\n// extractLinksFromContent 从内容中提取网盘链接（自动去重）\nfunc (p *QQPDPlugin) extractLinksFromContent(content string) []model.Link {\n\tvar links []model.Link\n\tseen := make(map[string]bool) // 用于去重\n\n\t// 定义网盘链接正则模式\n\tlinkPatterns := []struct {\n\t\tpattern  string\n\t\tlinkType string\n\t}{\n\t\t{`https://pan\\.quark\\.cn/s/[^\\s\\n]+`, \"quark\"},\n\t\t{`https://drive\\.uc\\.cn/s/[^\\s\\n]+`, \"uc\"},\n\t\t{`https://pan\\.baidu\\.com/s/[^\\s\\n?]+(?:\\?pwd=[a-zA-Z0-9]+)?`, \"baidu\"},\n\t\t{`https://(?:aliyundrive\\.com|www\\.alipan\\.com)/s/[^\\s\\n]+`, \"aliyun\"},\n\t\t{`https://pan\\.xunlei\\.com/s/[^\\s\\n]+`, \"xunlei\"},\n\t\t{`https://cloud\\.189\\.cn/(?:t|web/share)/[^\\s\\n]+`, \"tianyi\"},\n\t\t{`https://(?:115\\.com|115cdn\\.com)/s/[^\\s\\n?]+(?:\\?password=[a-zA-Z0-9]+)?`, \"115\"},\n\t\t{`https://(?:123pan\\.cn|www\\.123912\\.com|www\\.123684\\.com|www\\.123685\\.com|www\\.123592\\.com|www\\.123pan\\.com)/s/[^\\s\\n]+`, \"123\"},\n\t\t{`https://caiyun\\.(?:139\\.com|feixin\\.10086\\.cn)/[^\\s\\n]+`, \"mobile\"},\n\t\t{`https://mypikpak\\.com/s/[^\\s\\n]+`, \"pikpak\"},\n\t\t{`magnet:\\?xt=urn:btih:[^\\n]+`, \"magnet\"},\n\t\t{`ed2k://\\|file\\|[^\\n]+?\\|/`, \"ed2k\"},\n\t}\n\n\tfor _, lp := range linkPatterns {\n\t\tre := regexp.MustCompile(lp.pattern)\n\t\tmatches := re.FindAllString(content, -1)\n\n\t\tfor _, linkURL := range matches {\n\t\t\t// 去重检查（同一个URL只保留一次）\n\t\t\tif seen[linkURL] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[linkURL] = true\n\n\t\t\tpassword := \"\"\n\n\t\t\t// 提取密码\n\t\t\tif strings.Contains(linkURL, \"pwd=\") {\n\t\t\t\tpwdRe := regexp.MustCompile(`pwd=([a-zA-Z0-9]+)`)\n\t\t\t\tif pwdMatch := pwdRe.FindStringSubmatch(linkURL); len(pwdMatch) > 1 {\n\t\t\t\t\tpassword = pwdMatch[1]\n\t\t\t\t}\n\t\t\t} else if strings.Contains(linkURL, \"password=\") {\n\t\t\t\tpwdRe := regexp.MustCompile(`password=([a-zA-Z0-9]+)`)\n\t\t\t\tif pwdMatch := pwdRe.FindStringSubmatch(linkURL); len(pwdMatch) > 1 {\n\t\t\t\t\tpassword = pwdMatch[1]\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlinks = append(links, model.Link{\n\t\t\t\tType:     lp.linkType,\n\t\t\t\tURL:      linkURL,\n\t\t\t\tPassword: password,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn links\n}\n\n// ============ QQ登录相关 ============\n\n// LoginResult 登录检测结果\ntype LoginResult struct {\n\tStatus   string // success/waiting/expired/error\n\tCookie   string // 完整Cookie（登录成功时）\n\tQQMasked string // 脱敏QQ号\n}\n\n// checkQRLoginStatus 检查二维码登录状态（参考Python代码）\nfunc (p *QQPDPlugin) checkQRLoginStatus(qrsig string) (*LoginResult, error) {\n\t// 计算ptqrtoken\n\tptqrtoken := getptqrtoken(qrsig)\n\n\t// 登录检测URL\n\tloginCheckURL := fmt.Sprintf(\"https://xui.ptlogin2.qq.com/ssl/ptqrlogin?u1=https%%3A%%2F%%2Fpd.qq.com%%2Fexplore&ptqrtoken=%s&ptredirect=1&h=1&t=1&g=1&from_ui=1&ptlang=2052&action=0-0-1761211119400&js_ver=25100115&js_type=1&login_sig=&pt_uistyle=40&aid=1600001587&daid=823&&o1vId=11f3315cde61b7b5da200e4a09fe308c&pt_js_version=28d22679\", ptqrtoken)\n\n\tclient := &http.Client{\n\t\tTimeout: 10 * time.Second,\n\t\tTransport: &http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: true},\n\t\t},\n\t}\n\n\treq, err := http.NewRequest(\"GET\", loginCheckURL, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 设置qrsig cookie\n\treq.AddCookie(&http.Cookie{Name: \"qrsig\", Value: qrsig})\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, _ := ioutil.ReadAll(resp.Body)\n\tbodyStr := string(body)\n\n\t// 检查登录状态\n\tif strings.Contains(bodyStr, \"二维码已失效\") {\n\t\treturn &LoginResult{Status: \"expired\"}, nil\n\t}\n\n\tif strings.Contains(bodyStr, \"登录成功\") {\n\t\t// 提取ptsigx和uin\n\t\tptsigx, uin, err := p.extractLoginInfo(bodyStr)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"[QQPD] 提取登录信息失败: %v, 响应: %s\\n\", err, bodyStr)\n\t\t\treturn nil, fmt.Errorf(\"提取登录信息失败: %w\", err)\n\t\t}\n\n\t\t// 获取完整Cookie（传递ptqrlogin返回的所有Set-Cookie）\n\t\tallSetCookies := resp.Header.Values(\"Set-Cookie\")\n\t\tsetCookieStr := strings.Join(allSetCookies, \"; \")\n\n\t\tcookie, err := p.fetchFullCookie(uin, ptsigx, setCookieStr)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"[QQPD] 获取Cookie失败: %v\\n\", err)\n\t\t\treturn nil, fmt.Errorf(\"获取Cookie失败: %w\", err)\n\t\t}\n\n\t\t// 生成脱敏QQ号\n\t\tqqMasked := p.maskQQ(uin)\n\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[QQPD] 登录成功！QQ: %s, Cookie长度: %d, 包含keys: \", qqMasked, len(cookie))\n\t\t\tcookies := parseCookieString(cookie)\n\t\t\tkeys := make([]string, 0, len(cookies))\n\t\t\tfor k := range cookies {\n\t\t\t\tkeys = append(keys, k)\n\t\t\t}\n\t\t\tfmt.Printf(\"%v\\n\", keys)\n\t\t}\n\n\t\treturn &LoginResult{\n\t\t\tStatus:   \"success\",\n\t\t\tCookie:   cookie,\n\t\t\tQQMasked: qqMasked,\n\t\t}, nil\n\t}\n\n\t// 等待扫码\n\treturn &LoginResult{Status: \"waiting\"}, nil\n}\n\n// extractLoginInfo 从登录响应中提取ptsigx和uin\nfunc (p *QQPDPlugin) extractLoginInfo(responseText string) (string, string, error) {\n\t// 解析返回的JavaScript回调：ptuiCB('0','0','url',...)\n\t// 需要提取第3个参数的URL\n\tstart := strings.Index(responseText, \"ptuiCB(\")\n\tif start == -1 {\n\t\treturn \"\", \"\", fmt.Errorf(\"未找到ptuiCB\")\n\t}\n\n\t// 简单解析，提取URL部分\n\tre := regexp.MustCompile(`ptuiCB\\('0','0','([^']+)'`)\n\tmatches := re.FindStringSubmatch(responseText)\n\tif len(matches) < 2 {\n\t\treturn \"\", \"\", fmt.Errorf(\"无法解析响应\")\n\t}\n\n\turl := matches[1]\n\n\t// 提取ptsigx\n\tptsigxRe := regexp.MustCompile(`ptsigx=([A-Za-z0-9]+)`)\n\tptsigxMatches := ptsigxRe.FindStringSubmatch(url)\n\tif len(ptsigxMatches) < 2 {\n\t\treturn \"\", \"\", fmt.Errorf(\"未找到ptsigx\")\n\t}\n\tptsigx := ptsigxMatches[1]\n\n\t// 提取uin\n\tuinRe := regexp.MustCompile(`uin=(\\d+)`)\n\tuinMatches := uinRe.FindStringSubmatch(url)\n\tif len(uinMatches) < 2 {\n\t\treturn \"\", \"\", fmt.Errorf(\"未找到uin\")\n\t}\n\tuin := uinMatches[1]\n\n\treturn ptsigx, uin, nil\n}\n\n// fetchFullCookie 获取完整Cookie\nfunc (p *QQPDPlugin) fetchFullCookie(uin, ptsigx, setCookieHeader string) (string, error) {\n\tcheckSigURL := fmt.Sprintf(\"https://ptlogin2.pd.qq.com/check_sig?pttype=1&uin=%s&service=ptqrlogin&nodirect=1&ptsigx=%s&s_url=https%%3A%%2F%%2Fpd.qq.com%%2Fexplore&f_url=&ptlang=2052&ptredirect=101&aid=1600001587&daid=823&j_later=0&low_login_hour=0&regmaster=0&pt_login_type=3&pt_aid=0&pt_aaid=16&pt_light=0&pt_3rd_aid=0\", uin, ptsigx)\n\n\tclient := &http.Client{\n\t\tTimeout: 10 * time.Second,\n\t\tTransport: &http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: true},\n\t\t},\n\t}\n\n\treq, err := http.NewRequest(\"GET\", checkSigURL, nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// 设置Cookie头\n\treq.Header.Set(\"Cookie\", setCookieHeader)\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\t// 优先使用resp.Cookies()获取cookies（Go的http.Client自动解析Set-Cookie）\n\tcookieDict := make(map[string]string)\n\t\n\t// 首先从resp.Cookies()获取（更可靠，自动处理Set-Cookie）\n\tfor _, cookie := range resp.Cookies() {\n\t\tif cookie.Value != \"\" {\n\t\t\tcookieDict[cookie.Name] = cookie.Value\n\t\t}\n\t}\n\n\t// 补充从Set-Cookie头解析（处理resp.Cookies()可能遗漏的cookies）\n\tallSetCookies := resp.Header.Values(\"Set-Cookie\")\n\tfor _, setCookie := range allSetCookies {\n\t\t// 解析Set-Cookie头：只提取cookie名称和值，忽略属性\n\t\tcookieName, cookieValue := p.parseSetCookieHeader(setCookie)\n\t\tif cookieName != \"\" && cookieValue != \"\" {\n\t\t\t// 如果resp.Cookies()中没有，则添加\n\t\t\tif _, exists := cookieDict[cookieName]; !exists {\n\t\t\t\tcookieDict[cookieName] = cookieValue\n\t\t\t}\n\t\t}\n\t}\n\n\t// 手动添加uin（加上o0前缀）\n\tif _, exists := cookieDict[\"uin\"]; !exists || !strings.HasPrefix(cookieDict[\"uin\"], \"o\") {\n\t\tcookieDict[\"uin\"] = \"o0\" + uin\n\t}\n\n\t// 转换为Cookie字符串\n\tvar cookiePairs []string\n\tfor k, v := range cookieDict {\n\t\tcookiePairs = append(cookiePairs, fmt.Sprintf(\"%s=%s\", k, v))\n\t}\n\n\treturn strings.Join(cookiePairs, \"; \"), nil\n}\n\n// parseSetCookieHeader 从Set-Cookie响应头中解析cookie（只提取名称和值，忽略属性）\nfunc (p *QQPDPlugin) parseSetCookieHeader(setCookie string) (string, string) {\n\t// Set-Cookie格式: \"name=value; Path=/; Domain=.qq.com; ...\"\n\t// 只取第一个分号之前的部分\n\tparts := strings.Split(setCookie, \";\")\n\tif len(parts) == 0 {\n\t\treturn \"\", \"\"\n\t}\n\t\n\tnameValue := strings.TrimSpace(parts[0])\n\tidx := strings.Index(nameValue, \"=\")\n\tif idx <= 0 {\n\t\treturn \"\", \"\"\n\t}\n\t\n\tkey := strings.TrimSpace(nameValue[:idx])\n\tvalue := strings.TrimSpace(nameValue[idx+1:])\n\t\n\t// 跳过cookie属性（不是真正的cookie名称）\n\tskipAttrs := map[string]bool{\n\t\t\"Domain\": true, \"Path\": true, \"Expires\": true, \"Max-Age\": true,\n\t\t\"SameSite\": true, \"Secure\": true, \"HttpOnly\": true,\n\t}\n\tif skipAttrs[key] {\n\t\treturn \"\", \"\"\n\t}\n\t\n\treturn key, value\n}\n\n// refreshCookie 刷新cookies（更新uuid等动态字段）\nfunc (p *QQPDPlugin) refreshCookie(cookieStr string) string {\n\tif cookieStr == \"\" {\n\t\treturn cookieStr\n\t}\n\n\t// 解析现有cookies\n\toldCookies := parseCookieString(cookieStr)\n\tuin := oldCookies[\"uin\"]\n\tif uin == \"\" {\n\t\treturn cookieStr\n\t}\n\n\t// 去掉o0前缀\n\tif strings.HasPrefix(uin, \"o0\") {\n\t\tuin = uin[2:]\n\t} else if strings.HasPrefix(uin, \"o\") {\n\t\tuin = uin[1:]\n\t}\n\n\t// 访问pd.qq.com获取新的cookies（主要是uuid）\n\tpdURL := \"https://pd.qq.com/explore\"\n\tclient := &http.Client{\n\t\tTimeout: 10 * time.Second,\n\t\tTransport: &http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: true},\n\t\t},\n\t}\n\n\treq, err := http.NewRequest(\"GET\", pdURL, nil)\n\tif err != nil {\n\t\treturn cookieStr\n\t}\n\n\treq.Header.Set(\"Cookie\", cookieStr)\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\")\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn cookieStr\n\t}\n\tdefer resp.Body.Close()\n\n\t// 从响应中提取新cookies\n\tnewCookies := make(map[string]string)\n\t\n\t// 优先使用resp.Cookies()\n\tfor _, cookie := range resp.Cookies() {\n\t\tif cookie.Value != \"\" {\n\t\t\tnewCookies[cookie.Name] = cookie.Value\n\t\t}\n\t}\n\n\t// 补充从Set-Cookie头解析\n\tfor _, setCookie := range resp.Header.Values(\"Set-Cookie\") {\n\t\tkey, value := p.parseSetCookieHeader(setCookie)\n\t\tif key != \"\" && value != \"\" {\n\t\t\tif _, exists := newCookies[key]; !exists {\n\t\t\t\tnewCookies[key] = value\n\t\t\t}\n\t\t}\n\t}\n\n\t// 如果有新cookies，合并更新\n\tif len(newCookies) > 0 {\n\t\tmergedCookies := make(map[string]string)\n\t\t// 先复制旧的\n\t\tfor k, v := range oldCookies {\n\t\t\tmergedCookies[k] = v\n\t\t}\n\t\t// 用新的覆盖\n\t\tfor k, v := range newCookies {\n\t\t\tmergedCookies[k] = v\n\t\t}\n\n\t\t// 确保uin格式正确\n\t\tif uinRaw, exists := mergedCookies[\"uin\"]; !exists || !strings.HasPrefix(uinRaw, \"o\") {\n\t\t\tmergedCookies[\"uin\"] = \"o0\" + uin\n\t\t}\n\n\t\t// 转换为Cookie字符串\n\t\tvar cookiePairs []string\n\t\tfor k, v := range mergedCookies {\n\t\t\tcookiePairs = append(cookiePairs, fmt.Sprintf(\"%s=%s\", k, v))\n\t\t}\n\n\t\treturn strings.Join(cookiePairs, \"; \")\n\t}\n\n\treturn cookieStr\n}\n\n// maskQQ 生成脱敏QQ号\nfunc (p *QQPDPlugin) maskQQ(uin string) string {\n\tif len(uin) <= 4 {\n\t\treturn uin\n\t}\n\t// 前4位 + **** + 后2位\n\tif len(uin) > 6 {\n\t\treturn uin[:4] + \"****\" + uin[len(uin)-2:]\n\t}\n\treturn uin[:2] + \"****\" + uin[len(uin)-2:]\n}\n\n// generateQRCodeWithSig 生成QQ登录二维码并返回qrsig\nfunc (p *QQPDPlugin) generateQRCodeWithSig() ([]byte, string, error) {\n\tqrcodeURL := \"https://xui.ptlogin2.qq.com/ssl/ptqrshow?appid=1600001587&e=2&l=M&s=3&d=72&v=4&t=0.3680011491059967&daid=823&pt_3rd_aid=0\"\n\n\tclient := &http.Client{\n\t\tTimeout: 15 * time.Second,\n\t\tTransport: &http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: true},\n\t\t},\n\t}\n\n\tresp, err := client.Get(qrcodeURL)\n\tif err != nil {\n\t\treturn nil, \"\", fmt.Errorf(\"请求二维码失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, \"\", fmt.Errorf(\"二维码请求返回状态码: %d\", resp.StatusCode)\n\t}\n\n\t// 读取二维码图片\n\tqrcodeBytes, err := ioutil.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, \"\", fmt.Errorf(\"读取二维码失败: %w\", err)\n\t}\n\n\t// 提取qrsig（用于后续登录检测）\n\tsetCookie := resp.Header.Get(\"Set-Cookie\")\n\tqrsig := extractQrsig(setCookie)\n\tif qrsig != \"\" && DebugLog {\n\t\tfmt.Printf(\"[QQPD] 二维码生成成功，qrsig: %s\\n\", qrsig[:20]+\"...\")\n\t}\n\n\treturn qrcodeBytes, qrsig, nil\n}\n\n// extractQrsig 从Set-Cookie中提取qrsig\nfunc extractQrsig(setCookie string) string {\n\tcookies := strings.Split(setCookie, \";\")\n\tfor _, cookie := range cookies {\n\t\tcookie = strings.TrimSpace(cookie)\n\t\tif strings.HasPrefix(cookie, \"qrsig=\") {\n\t\t\treturn strings.TrimPrefix(cookie, \"qrsig=\")\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// getptqrtoken 计算ptqrtoken\nfunc getptqrtoken(qrsig string) string {\n\te := 0\n\tfor i := 1; i <= len(qrsig); i++ {\n\t\te += (e << 5) + int(qrsig[i-1])\n\t}\n\treturn fmt.Sprintf(\"%d\", 2147483647&e)\n}\n\n// bkn 计算bkn值\nfunc bkn(skey string) int64 {\n\tt, n, o := int64(5381), 0, len(skey)\n\tfor n < o {\n\t\tt += (t << 5) + int64(skey[n])\n\t\tn++\n\t}\n\treturn t & 2147483647\n}\n\n// testCookieValid 测试Cookie是否有效\nfunc (p *QQPDPlugin) testCookieValid(cookieStr string) bool {\n\t// 测试前刷新cookies（更新uuid等动态字段）\n\tcookieStr = p.refreshCookie(cookieStr)\n\t\n\t// 解析cookie获取p_skey\n\tcookies := parseCookieString(cookieStr)\n\tpSkey, ok := cookies[\"p_skey\"]\n\tif !ok || pSkey == \"\" {\n\t\treturn false\n\t}\n\n\t// 计算bkn\n\tbknValue := bkn(pSkey)\n\n\t// 尝试一个简单的请求测试\n\ttestURL := fmt.Sprintf(\"https://pd.qq.com/qunng/guild/gotrpc/auth/trpc.group_pro.in_guild_search_svr.InGuildSearch/NewSearch?bkn=%d\", bknValue)\n\n\theaders := map[string]string{\n\t\t\"x-oidb\":       `{\"uint32_command\":\"0x9287\",\"uint32_service_type\":\"2\"}`,\n\t\t\"content-type\": \"application/json\",\n\t}\n\n\tpayload := map[string]interface{}{\n\t\t\"guild_id\":      \"592843764045681811\",\n\t\t\"query\":         \"test\",\n\t\t\"cookie\":        \"\",\n\t\t\"member_cookie\": \"\",\n\t\t\"search_type\":   map[string]int{\"type\": 0, \"feed_type\": 0},\n\t\t\"cond\":          map[string]interface{}{\"channel_ids\": []string{}, \"feed_rank_type\": 0, \"type_list\": []int{2, 3}},\n\t}\n\n\tclient := &http.Client{Timeout: 10 * time.Second}\n\tpayloadBytes, _ := json.Marshal(payload)\n\n\treq, err := http.NewRequest(\"POST\", testURL, strings.NewReader(string(payloadBytes)))\n\tif err != nil {\n\t\treturn false\n\t}\n\n\tfor k, v := range headers {\n\t\treq.Header.Set(k, v)\n\t}\n\n\t// 设置Cookie\n\tfor k, v := range cookies {\n\t\treq.AddCookie(&http.Cookie{Name: k, Value: v})\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn false\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode == 200 {\n\t\tvar result map[string]interface{}\n\t\tbody, _ := ioutil.ReadAll(resp.Body)\n\t\tif err := json.Unmarshal(body, &result); err == nil {\n\t\t\tif retcode, ok := result[\"retcode\"].(float64); ok && retcode == 0 {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tif _, hasData := result[\"data\"]; hasData {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\n\treturn false\n}\n\n// parseCookieString 解析Cookie字符串为map（用于读取保存的cookie文件）\nfunc parseCookieString(cookieStr string) map[string]string {\n\tcookies := make(map[string]string)\n\tif cookieStr == \"\" {\n\t\treturn cookies\n\t}\n\n\tpairs := strings.Split(cookieStr, \";\")\n\tskipAttrs := map[string]bool{\n\t\t\"Domain\": true, \"Path\": true, \"Expires\": true, \"Max-Age\": true,\n\t\t\"SameSite\": true, \"Secure\": true, \"HttpOnly\": true,\n\t}\n\n\tfor _, pair := range pairs {\n\t\tpair = strings.TrimSpace(pair)\n\t\tif pair == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif idx := strings.Index(pair, \"=\"); idx > 0 {\n\t\t\tkey := strings.TrimSpace(pair[:idx])\n\t\t\tvalue := strings.TrimSpace(pair[idx+1:])\n\t\t\t// 跳过cookie属性（只保留真正的cookie名称）\n\t\t\tif key != \"\" && value != \"\" && !skipAttrs[key] {\n\t\t\t\tcookies[key] = value\n\t\t\t}\n\t\t}\n\t}\n\n\treturn cookies\n}\n\n// ============ 工具函数 ============\n\n// generateHash hash生成函数（完整hash，不截取）\nfunc (p *QQPDPlugin) generateHash(qq string) string {\n\tsalt := os.Getenv(\"QQPD_HASH_SALT\")\n\tif salt == \"\" {\n\t\tsalt = \"pansou_qqpd_secret_2025\"\n\t}\n\tdata := qq + salt\n\thash := sha256.Sum256([]byte(data))\n\treturn hex.EncodeToString(hash[:])\n}\n\n// normalizeChannel 从URL或纯文本中提取频道号\nfunc (p *QQPDPlugin) normalizeChannel(input string) string {\n\tinput = strings.TrimSpace(input)\n\n\t// 如果是URL格式: https://pd.qq.com/g/pd97631607\n\tif strings.Contains(input, \"pd.qq.com/g/\") {\n\t\tparts := strings.Split(input, \"/g/\")\n\t\tif len(parts) == 2 {\n\t\t\treturn strings.TrimSpace(parts[1])\n\t\t}\n\t}\n\n\t// 直接返回（假设是频道号）\n\treturn input\n}\n\n// isHexString 判断字符串是否为十六进制\nfunc (p *QQPDPlugin) isHexString(s string) bool {\n\tfor _, c := range s {\n\t\tif !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// respondSuccess 成功响应\nfunc respondSuccess(c *gin.Context, message string, data interface{}) {\n\tc.JSON(200, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": message,\n\t\t\"data\":    data,\n\t})\n}\n\n// respondError 错误响应\nfunc respondError(c *gin.Context, message string) {\n\tc.JSON(200, gin.H{\n\t\t\"success\": false,\n\t\t\"message\": message,\n\t\t\"data\":    nil,\n\t})\n}\n\n// ============ Cookie加密 ============\n\n// getEncryptionKey 获取加密密钥\nfunc getEncryptionKey() []byte {\n\tkey := os.Getenv(\"QQPD_ENCRYPTION_KEY\")\n\tif key == \"\" {\n\t\tkey = \"default-32-byte-key-change-me!\" // 32字节\n\t}\n\treturn []byte(key)[:32]\n}\n\n// encryptCookie 加密Cookie\nfunc encryptCookie(plaintext string) (string, error) {\n\tkey := getEncryptionKey()\n\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tnonce := make([]byte, gcm.NonceSize())\n\tif _, err := io.ReadFull(rand.Reader, nonce); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)\n\treturn base64.StdEncoding.EncodeToString(ciphertext), nil\n}\n\n// decryptCookie 解密Cookie\nfunc decryptCookie(encrypted string) (string, error) {\n\tkey := getEncryptionKey()\n\n\tciphertext, err := base64.StdEncoding.DecodeString(encrypted)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tnonceSize := gcm.NonceSize()\n\tif len(ciphertext) < nonceSize {\n\t\treturn \"\", fmt.Errorf(\"ciphertext too short\")\n\t}\n\n\tnonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]\n\tplaintext, err := gcm.Open(nil, nonce, ciphertext, nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn string(plaintext), nil\n}\n\n// ============ 定期清理 ============\n\n// startCleanupTask 定期清理任务\nfunc (p *QQPDPlugin) startCleanupTask() {\n\tticker := time.NewTicker(24 * time.Hour)\n\tfor range ticker.C {\n\t\tdeleted := p.cleanupExpiredUsers()\n\t\tmarked := p.markInactiveUsers()\n\n\t\tif deleted > 0 || marked > 0 {\n\t\t\tfmt.Printf(\"[QQPD] 清理任务完成: 删除 %d 个过期用户, 标记 %d 个不活跃用户\\n\", deleted, marked)\n\t\t}\n\t}\n}\n\n// cleanupExpiredUsers 清理过期用户（从内存和文件）\nfunc (p *QQPDPlugin) cleanupExpiredUsers() int {\n\tdeletedCount := 0\n\tnow := time.Now()\n\texpireThreshold := now.AddDate(0, 0, -30) // 30天前\n\n\t// 遍历内存中的用户\n\tp.users.Range(func(key, value interface{}) bool {\n\t\tuser := value.(*User)\n\n\t\t// 删除条件：状态为expired且超过30天未访问\n\t\tif user.Status == \"expired\" && user.LastAccessAt.Before(expireThreshold) {\n\t\t\tif err := p.deleteUser(user.Hash); err == nil {\n\t\t\t\tdeletedCount++\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n\n\treturn deletedCount\n}\n\n// markInactiveUsers 标记长期未使用的用户为过期\nfunc (p *QQPDPlugin) markInactiveUsers() int {\n\tmarkedCount := 0\n\tnow := time.Now()\n\tinactiveThreshold := now.AddDate(0, 0, -90) // 90天前\n\n\t// 遍历内存中的用户\n\tp.users.Range(func(key, value interface{}) bool {\n\t\tuser := value.(*User)\n\n\t\t// 标记条件：超过90天未访问\n\t\tif user.LastAccessAt.Before(inactiveThreshold) && user.Status != \"expired\" {\n\t\t\tuser.Status = \"expired\"\n\t\t\tuser.Cookie = \"\" // 清空Cookie\n\n\t\t\t// 更新内存和文件\n\t\t\tif err := p.saveUser(user); err == nil {\n\t\t\t\tmarkedCount++\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n\n\treturn markedCount\n}"
  },
  {
    "path": "plugin/quark4k/json结构分析.md",
    "content": "# Quark4K API 数据结构分析\n\n## 基本信息\n- **数据源类型**: JSON API  \n- **API URL格式**: `https://quark4k.com/api/discussions?include=user%2ClastPostedUser%2CmostRelevantPost%2CmostRelevantPost.user%2Ctags%2Ctags.parent%2CfirstPost&filter[q]={关键词}&sort&page[offset]=0`\n- **请求方法**: `GET`\n- **Content-Type**: `application/json`\n- **Referer**: `https://quark4k.com/`\n- **特殊说明**: 该网站**主要提供夸克网盘(quark)链接**，域名固定为`pan.quark.cn`，需要从HTML内容中解析网盘链接和密码\n\n## API响应结构\n\n### 顶层结构\n```json\n{\n    \"links\": {\n        \"first\": \"https://quark4k.com/api/discussions?include=...\"\n    },\n    \"data\": [\n        // 讨论帖子数组\n    ],\n    \"included\": [\n        // 相关回复内容、用户、标签数组\n    ]\n}\n```\n\n### `data`数组中的讨论帖子结构\n```json\n{\n    \"type\": \"discussions\",\n    \"id\": \"1006\",\n    \"attributes\": {\n        \"title\": \"【印度剧】黑手遮天 第2季  (2025)  4K HDR 内封简中 夸克网盘资源下载\",\n        \"slug\": \"1006-yin-du-ju-hei-shou-zhe-tian-di-2ji-2025-4k-hdr-nei-feng-jian-zhong-kua-ke-wang-pan-zi-yuan-xia-zai\",\n        \"commentCount\": 1,\n        \"participantCount\": 1,\n        \"createdAt\": \"2025-06-13T12:55:57+00:00\",\n        \"lastPostedAt\": \"2025-06-13T12:55:57+00:00\",\n        \"lastPostNumber\": 1,\n        \"canReply\": false,\n        \"isApproved\": true,\n        \"isLocked\": false\n    },\n    \"relationships\": {\n        \"user\": {\n            \"data\": {\n                \"type\": \"users\",\n                \"id\": \"2\"\n            }\n        },\n        \"mostRelevantPost\": {\n            \"data\": {\n                \"type\": \"posts\",\n                \"id\": \"1124\"\n            }\n        },\n        \"tags\": {\n            \"data\": [\n                {\n                    \"type\": \"tags\",\n                    \"id\": \"1\"\n                }\n            ]\n        },\n        \"firstPost\": {\n            \"data\": {\n                \"type\": \"posts\",\n                \"id\": \"1124\"\n            }\n        }\n    }\n}\n```\n\n### `included`数组中的回复内容结构\n```json\n{\n    \"type\": \"posts\",\n    \"id\": \"1124\",\n    \"attributes\": {\n        \"number\": 1,\n        \"createdAt\": \"2025-06-13T12:55:57+00:00\",\n        \"contentType\": \"comment\",\n        \"contentHtml\": \"<p><img src=\\\"...\\\" title=\\\"\\\" alt=\\\"黑手遮天 第2季\\\"><br>\\n剧名：黑手遮天 第2季 Rana Naidu Season 2<br>\\n类型: 剧情<br>\\n制片国家/地区: 印度<br>\\n语言: 印地语<br>\\n首播: 2025-06-13(印度网络)<br>\\nIMDb: tt27547185</p>\\n\\n<p>黑手遮天 第2季的剧情............</p>\\n\\n<p>《黑手遮天 第2季》夸克网盘链接：<a href=\\\"https://pan.quark.cn/s/5881dd6b25e4\\\" rel=\\\"ugc noopener nofollow\\\" target=\\\"_blank\\\">https://pan.quark.cn/s/5881dd6b25e4</a></p>\",\n        \"renderFailed\": false,\n        \"editedAt\": \"2025-09-18T07:31:04+00:00\",\n        \"isApproved\": true,\n        \"likesCount\": 0\n    },\n    \"relationships\": {\n        \"user\": {\n            \"data\": {\n                \"type\": \"users\",\n                \"id\": \"2\"\n            }\n        }\n    }\n}\n```\n\n## 插件所需字段映射\n\n| 源字段 | 目标字段 | 说明 |\n|--------|----------|------|\n| `data[].id` | `UniqueID` | 格式: `quark4k-{discussion_id}` |\n| `data[].attributes.title` | `Title` | 讨论标题 |\n| `data[].attributes.createdAt` | `Datetime` | 创建时间 |\n| `included[].attributes.contentHtml` | `Content` | HTML内容，需要解析提取网盘链接 |\n| `\"\"` | `Channel` | 插件搜索结果Channel为空 |\n| `[]` | `Tags` | 标签数组（从标题或内容中提取） |\n| 解析的网盘链接 | `Links` | 从HTML内容中提取的网盘链接 |\n\n## 网盘链接解析\n\n### HTML内容特点\n- **格式**: 包含HTML标签的文本内容，需要清理HTML标签获取纯文本\n- **链接**: 以`<a href=\"...\">`标签形式存在，但更多是纯文本格式\n- **示例**: \n  - HTML格式: `<a href=\"https://pan.quark.cn/s/5881dd6b25e4\" rel=\"ugc noopener nofollow\" target=\"_blank\">https://pan.quark.cn/s/5881dd6b25e4</a>`\n  - 纯文本格式: `https://pan.quark.cn/s/5881dd6b25e4`\n\n### 支持的网盘类型（quark4k专用）\n\n| 网盘类型 | 域名特征 | 示例链接 | 密码关键词 |\n|---------|----------|----------|------------|\n| **夸克网盘** | `pan.quark.cn` | `https://pan.quark.cn/s/5881dd6b25e4` | 提取码、密码 |\n\n**重要说明**: quark4k插件**主要支持夸克网盘**，所有链接都是`pan.quark.cn`域名，也可能包含其他网盘类型。\n\n### 链接解析策略（quark4k专用）\n1. **HTML清理**: 移除HTML标签，保留纯文本内容\n2. **链接提取**: 从纯文本中提取**夸克网盘链接**（主要处理`pan.quark.cn`）\n3. **密码匹配**: 匹配\"提取码\"或\"密码\"关键词\n4. **位置关联**: 密码通常出现在链接附近的行中\n\n## 插件开发指导\n\n### 请求示例\n```go\nsearchURL := fmt.Sprintf(\"https://quark4k.com/api/discussions?include=user%%2ClastPostedUser%%2CmostRelevantPost%%2CmostRelevantPost.user%%2Ctags%%2Ctags.parent%%2CfirstPost&filter[q]=%s&sort&page[offset]=%d&page[limit]=%d\", url.QueryEscape(keyword), offset, PageSize)\n```\n\n### 请求头设置（参考pan666实现）\n```go\nreq.Header.Set(\"User-Agent\", getRandomUA()) // 使用随机UA避免反爬虫\nreq.Header.Set(\"X-Forwarded-For\", generateRandomIP()) // 随机IP\nreq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\nreq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\nreq.Header.Set(\"Connection\", \"keep-alive\")\nreq.Header.Set(\"Sec-Fetch-Dest\", \"empty\")\nreq.Header.Set(\"Sec-Fetch-Mode\", \"cors\")\nreq.Header.Set(\"Sec-Fetch-Site\", \"same-origin\")\nreq.Header.Set(\"Referer\", \"https://quark4k.com/\")\n```\n\n### SearchResult构建示例\n```go\nresult := model.SearchResult{\n    UniqueID: fmt.Sprintf(\"quark4k-%s\", discussion.ID),\n    Title:    discussion.Attributes.Title,\n    Content:  extractTextFromHTML(post.Attributes.ContentHTML),\n    Links:    extractLinksFromHTML(post.Attributes.ContentHTML),\n    Tags:     extractTagsFromTitle(discussion.Attributes.Title),\n    Channel:  \"\", // 插件搜索结果Channel为空\n    Datetime: parseTime(discussion.Attributes.CreatedAt),\n}\n```\n\n### HTML内容解析函数（参考pan666实现）\n```go\n// 清理HTML内容（参考pan666的cleanHTML函数）\nfunc (p *Quark4KAsyncPlugin) cleanHTML(html string) string {\n    // 移除<br>标签\n    html = strings.ReplaceAll(html, \"<br>\", \"\\n\")\n    html = strings.ReplaceAll(html, \"<br/>\", \"\\n\")\n    html = strings.ReplaceAll(html, \"<br />\", \"\\n\")\n    \n    // 移除其他HTML标签\n    var result strings.Builder\n    inTag := false\n    \n    for _, r := range html {\n        if r == '<' {\n            inTag = true\n            continue\n        }\n        if r == '>' {\n            inTag = false\n            continue\n        }\n        if !inTag {\n            result.WriteRune(r)\n        }\n    }\n    \n    // 处理HTML实体\n    output := result.String()\n    output = strings.ReplaceAll(output, \"&amp;\", \"&\")\n    output = strings.ReplaceAll(output, \"&lt;\", \"<\")\n    output = strings.ReplaceAll(output, \"&gt;\", \">\")\n    output = strings.ReplaceAll(output, \"&quot;\", \"\\\"\")\n    output = strings.ReplaceAll(output, \"&apos;\", \"'\")\n    output = strings.ReplaceAll(output, \"&#39;\", \"'\")\n    output = strings.ReplaceAll(output, \"&nbsp;\", \" \")\n    \n    // 处理多行空白\n    lines := strings.Split(output, \"\\n\")\n    var cleanedLines []string\n    \n    for _, line := range lines {\n        trimmed := strings.TrimSpace(line)\n        if trimmed != \"\" {\n            cleanedLines = append(cleanedLines, trimmed)\n        }\n    }\n    \n    return strings.Join(cleanedLines, \"\\n\")\n}\n\n// 从文本中提取链接（参考pan666的extractLinksFromText函数）\nfunc (p *Quark4KAsyncPlugin) extractLinksFromText(content string) []model.Link {\n    var allLinks []model.Link\n    \n    lines := strings.Split(content, \"\\n\")\n    \n    // 收集所有可能的链接信息\n    var linkInfos []struct {\n        link     model.Link\n        position int\n        category string\n    }\n    \n    // 收集所有可能的密码信息\n    var passwordInfos []struct {\n        keyword   string\n        position  int\n        password  string\n    }\n    \n    // 第一遍：查找所有的链接和密码\n    for i, line := range lines {\n        line = strings.TrimSpace(line)\n        \n        // 主要检查夸克网盘\n        if strings.Contains(line, \"pan.quark.cn\") {\n            url := p.extractURLFromText(line)\n            if url != \"\" {\n                linkInfos = append(linkInfos, struct {\n                    link     model.Link\n                    position int\n                    category string\n                }{\n                    link:     model.Link{URL: url, Type: \"quark\"},\n                    position: i,\n                    category: \"quark\",\n                })\n            }\n        }\n        \n        // 检查提取码/密码\n        passwordKeywords := []string{\"提取码\", \"密码\"}\n        for _, keyword := range passwordKeywords {\n            if strings.Contains(line, keyword) {\n                // 寻找冒号后面的内容\n                colonPos := strings.Index(line, \":\")\n                if colonPos == -1 {\n                    colonPos = strings.Index(line, \"：\")\n                }\n                \n                if colonPos != -1 && colonPos+1 < len(line) {\n                    password := strings.TrimSpace(line[colonPos+1:])\n                    // 如果密码长度超过10个字符，可能不是密码\n                    if len(password) <= 10 {\n                        passwordInfos = append(passwordInfos, struct {\n                            keyword   string\n                            position  int\n                            password  string\n                        }{\n                            keyword:   keyword,\n                            position:  i,\n                            password:  password,\n                        })\n                    }\n                }\n            }\n        }\n    }\n    \n    // 第二遍：将密码与链接匹配\n    for i := range linkInfos {\n        // 检查链接自身是否包含密码\n        password := p.extractPasswordFromURL(linkInfos[i].link.URL)\n        if password != \"\" {\n            linkInfos[i].link.Password = password\n            continue\n        }\n        \n        // 查找最近的密码\n        minDistance := 1000000\n        var closestPassword string\n        \n        for _, pwInfo := range passwordInfos {\n            // 夸克网盘匹配提取码或密码\n            match := false\n            \n            if linkInfos[i].category == \"quark\" && (pwInfo.keyword == \"提取码\" || pwInfo.keyword == \"密码\") {\n                match = true\n            }\n            \n            if match {\n                distance := abs(pwInfo.position - linkInfos[i].position)\n                if distance < minDistance {\n                    minDistance = distance\n                    closestPassword = pwInfo.password\n                }\n            }\n        }\n        \n        // 只有当距离较近时才认为是匹配的密码\n        if minDistance <= 3 {\n            linkInfos[i].link.Password = closestPassword\n        }\n    }\n    \n    // 收集所有有效链接\n    for _, info := range linkInfos {\n        allLinks = append(allLinks, info.link)\n    }\n    \n    return allLinks\n}\n```\n\n### 辅助函数（参考pan666实现）\n```go\n// 从文本中提取URL\nfunc (p *Quark4KAsyncPlugin) extractURLFromText(text string) string {\n    // 查找URL的起始位置\n    urlPrefixes := []string{\"http://\", \"https://\"}\n    start := -1\n    \n    for _, prefix := range urlPrefixes {\n        pos := strings.Index(text, prefix)\n        if pos != -1 {\n            start = pos\n            break\n        }\n    }\n    \n    if start == -1 {\n        return \"\"\n    }\n    \n    // 查找URL的结束位置\n    end := len(text)\n    endChars := []string{\" \", \"\\t\", \"\\n\", \"\\\"\", \"'\", \"<\", \">\", \")\", \"]\", \"}\", \",\", \";\"}\n    \n    for _, char := range endChars {\n        pos := strings.Index(text[start:], char)\n        if pos != -1 && start+pos < end {\n            end = start + pos\n        }\n    }\n    \n    return text[start:end]\n}\n\n// 从URL中提取密码\nfunc (p *Quark4KAsyncPlugin) extractPasswordFromURL(url string) string {\n    // 查找密码参数\n    pwdParams := []string{\"pwd=\", \"password=\", \"passcode=\", \"code=\"}\n    \n    for _, param := range pwdParams {\n        pos := strings.Index(url, param)\n        if pos != -1 {\n            start := pos + len(param)\n            end := len(url)\n            \n            // 查找参数结束位置\n            for i := start; i < len(url); i++ {\n                if url[i] == '&' || url[i] == '#' {\n                    end = i\n                    break\n                }\n            }\n            \n            if start < end {\n                return url[start:end]\n            }\n        }\n    }\n    \n    return \"\"\n}\n\n// 绝对值函数\nfunc abs(n int) int {\n    if n < 0 {\n        return -n\n    }\n    return n\n}\n\n// 生成随机UA\nfunc getRandomUA() string {\n    userAgents := []string{\n        \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\",\n        \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36\",\n        \"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\",\n        \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0\",\n        \"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\",\n        \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36\",\n    }\n    return userAgents[rand.Intn(len(userAgents))]\n}\n\n// 生成随机IP\nfunc generateRandomIP() string {\n    return fmt.Sprintf(\"%d.%d.%d.%d\", \n        rand.Intn(223)+1,  // 避免0和255\n        rand.Intn(255),\n        rand.Intn(255),\n        rand.Intn(254)+1)  // 避免0\n}\n```\n\n### 时间解析函数\n```go\nfunc (p *Quark4KAsyncPlugin) parseTime(timeStr string) time.Time {\n    // 解析ISO 8601格式时间\n    t, err := time.Parse(time.RFC3339, timeStr)\n    if err != nil {\n        return time.Now()\n    }\n    return t\n}\n```\n\n## 数据结构定义\n\n### API响应结构体\n```go\ntype Quark4KResponse struct {\n    Links    Quark4KLinks `json:\"links\"`\n    Data     []Quark4KDiscussion `json:\"data\"`\n    Included []Quark4KIncludedItem `json:\"included\"`\n}\n\ntype Quark4KLinks struct {\n    First string `json:\"first\"`\n    Next  string `json:\"next,omitempty\"`\n}\n\ntype Quark4KDiscussion struct {\n    Type         string `json:\"type\"`\n    ID           string `json:\"id\"`\n    Attributes   Quark4KDiscussionAttributes `json:\"attributes\"`\n    Relationships Quark4KRelationships `json:\"relationships\"`\n}\n\ntype Quark4KDiscussionAttributes struct {\n    Title           string    `json:\"title\"`\n    Slug            string    `json:\"slug\"`\n    CommentCount    int       `json:\"commentCount\"`\n    ParticipantCount int      `json:\"participantCount\"`\n    CreatedAt       string    `json:\"createdAt\"`\n    LastPostedAt    string    `json:\"lastPostedAt\"`\n    LastPostNumber  int       `json:\"lastPostNumber\"`\n    IsApproved      bool      `json:\"isApproved\"`\n    IsLocked        bool      `json:\"isLocked\"`\n}\n\ntype Quark4KRelationships struct {\n    MostRelevantPost Quark4KPostRef `json:\"mostRelevantPost\"`\n}\n\ntype Quark4KPostRef struct {\n    Data Quark4KPostData `json:\"data\"`\n}\n\ntype Quark4KPostData struct {\n    Type string `json:\"type\"`\n    ID   string `json:\"id\"`\n}\n\n// Included 数组中可能包含多种类型（posts, users, tags）\ntype Quark4KIncludedItem struct {\n    Type       string      `json:\"type\"`\n    ID         string      `json:\"id\"`\n    Attributes json.RawMessage `json:\"attributes\"` // 使用RawMessage以便灵活处理\n}\n\n// Quark4KPost 帖子内容（从Included中提取）\ntype Quark4KPost struct {\n    Type       string `json:\"type\"`\n    ID         string `json:\"id\"`\n    Attributes Quark4KPostAttributes `json:\"attributes\"`\n}\n\ntype Quark4KPostAttributes struct {\n    Number           int    `json:\"number\"`\n    CreatedAt        string `json:\"createdAt\"`\n    ContentType      string `json:\"contentType\"`\n    ContentHTML      string `json:\"contentHtml\"`\n    RenderFailed     bool   `json:\"renderFailed\"`\n    EditedAt         string `json:\"editedAt,omitempty\"`\n    IsApproved       bool   `json:\"isApproved\"`\n    LikesCount       int    `json:\"likesCount\"`\n}\n```\n\n## 特殊处理逻辑\n\n### 1. 讨论与回复关联\n- 通过`relationships.mostRelevantPost.data.id`关联讨论和回复\n- 需要在`included`数组中查找对应的回复内容\n- `included`数组可能包含多种类型（posts, users, tags），需要过滤出posts类型\n\n### 2. HTML内容清理\n- 移除HTML标签获取纯文本内容\n- 解码HTML实体（如`&lt;`、`&gt;`等）\n- 提取链接时保留原始URL\n\n### 3. 链接验证\n- 验证链接是否为有效的网盘链接\n- 过滤掉无效链接（如`javascript:`、`#`等）\n- 提取链接中的密码信息\n\n### 4. 标签提取\n- 从讨论标题中提取关键词作为标签\n- 可以基于内容类型、年份等信息生成标签\n- 支持中文和英文标签\n\n## 与pan666/bixin插件的相似性\n\n| 特性 | quark4k | pan666/bixin | 说明 |\n|------|---------|-------------|------|\n| **数据源** | 论坛讨论API | 论坛讨论API | 使用相同的论坛系统 |\n| **API结构** | 相同 | 相同 | JSON结构完全一致 |\n| **链接解析** | 文本解析 | 文本解析 | 都需要从HTML清理后的文本中提取 |\n| **主要网盘** | 夸克网盘 | 移动云盘/多种网盘 | 主要提供不同网盘链接 |\n| **密码匹配** | 位置关联 | 位置关联 | 使用相同的密码匹配策略 |\n| **过滤策略** | 跳过Service层过滤 | 跳过Service层过滤 | 都使用`NewBaseAsyncPluginWithFilter` |\n\n## 与其他插件的差异\n\n| 特性 | quark4k/pan666/bixin | 其他插件 | 说明 |\n|------|---------------------|----------|------|\n| **数据源** | 论坛讨论API | 网盘搜索API | 需要解析HTML内容 |\n| **链接格式** | 纯文本格式 | 直接URL字符串 | 需要从文本中提取 |\n| **内容结构** | 讨论+回复 | 直接资源信息 | 需要关联处理 |\n| **链接验证** | 必需 | 可选 | 论坛可能包含无效链接 |\n| **过滤策略** | 跳过Service层过滤 | 启用Service层过滤 | 论坛内容需要宽泛搜索 |\n\n## 注意事项\n\n1. **HTML解析**: 需要正确处理HTML标签和实体，参考pan666的cleanHTML函数\n2. **链接提取**: 主要从纯文本中提取链接，而非HTML标签\n3. **内容关联**: 需要将讨论和回复内容正确关联\n4. **链接验证**: 论坛内容可能包含无效链接，需要过滤\n5. **时间解析**: 使用ISO 8601格式解析时间\n6. **错误处理**: API可能返回空数据或格式错误\n7. **反爬虫**: 使用随机UA和IP避免反爬虫检测\n8. **密码匹配**: 使用位置关联策略匹配密码和链接\n9. **Included数组处理**: 需要区分posts、users、tags等不同类型\n\n## 开发建议\n\n- **优先级设置**: 建议设置为优先级3，数据质量一般\n- **Service层过滤**: 跳过Service层过滤，使用`NewBaseAsyncPluginWithFilter(\"quark4k\", 3, true)`\n- **HTML处理**: 重点处理HTML内容的解析和清理，参考pan666实现\n- **链接提取**: 实现robust的链接提取和验证机制，**主要处理夸克网盘**（pan.quark.cn）\n- **缓存策略**: 建议使用较短的缓存TTL，论坛内容更新频繁\n- **错误日志**: 详细记录HTML解析和链接提取的错误信息\n- **基于pan666/bixin**: 可以直接基于pan666或bixin插件进行修改，主要更改API URL和插件名称\n\n## API调用示例\n\n### 搜索请求示例\n```bash\ncurl \"https://quark4k.com/api/discussions?include=user%2ClastPostedUser%2CmostRelevantPost%2CmostRelevantPost.user%2Ctags%2Ctags.parent%2CfirstPost&filter[q]=遮天&sort&page[offset]=0&page[limit]=50\" \\\n  -H \"Referer: https://quark4k.com/\" \\\n  -H \"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\"\n```\n\n### 完整流程示例\n1. **发送搜索请求**: 获取讨论列表和回复内容\n2. **解析讨论数据**: 提取标题、时间等基本信息\n3. **关联回复内容**: 通过ID关联讨论和回复（从included数组中查找posts类型）\n4. **清理HTML内容**: 移除HTML标签，获取纯文本\n5. **提取网盘链接**: 从纯文本中提取**夸克网盘链接**（主要处理pan.quark.cn）\n6. **匹配密码**: 使用位置关联策略匹配密码和链接\n7. **验证链接有效性**: 过滤无效链接\n8. **构建搜索结果**: 转换为PanSou标准格式\n9. **返回结果**: 包含标题、内容、链接等信息\n\n### 插件实现建议\n```go\n// 基于pan666/bixin插件进行修改\nfunc NewQuark4KAsyncPlugin() *Quark4KAsyncPlugin {\n    return &Quark4KAsyncPlugin{\n        BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"quark4k\", 3, true), // 跳过Service层过滤\n        retries:         MaxRetries,\n    }\n}\n\n// 主要修改点：\n// 1. 更改API URL: \"https://quark4k.com/api/discussions\"\n// 2. 更改插件名称: \"quark4k\"\n// 3. 简化链接提取：主要处理夸克网盘（pan.quark.cn）\n// 4. 简化密码匹配：只匹配\"提取码\"和\"密码\"关键词\n// 5. 保持相同的HTML解析逻辑\n// 6. 处理included数组时区分不同类型\n```\n"
  },
  {
    "path": "plugin/quark4k/quark4k.go",
    "content": "package quark4k\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\n// 在init函数中注册插件\nfunc init() {\n\t// 注册插件\n\tplugin.RegisterGlobalPlugin(NewQuark4KAsyncPlugin())\n}\n\nconst (\n\t// API基础URL\n\tBaseURL = \"https://quark4k.com/api/discussions\"\n\t\n\t// 默认参数\n\tPageSize = 50 // 符合API实际返回数量\n\tMaxRetries = 2\n)\n\n// 常用UA列表\nvar userAgents = []string{\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36\",\n\t\"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\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0\",\n\t\"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\",\n\t\"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36\",\n}\n\n// Quark4KAsyncPlugin quark4k网盘搜索异步插件\ntype Quark4KAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tretries int\n}\n\n// NewQuark4KAsyncPlugin 创建新的quark4k异步插件\nfunc NewQuark4KAsyncPlugin() *Quark4KAsyncPlugin {\n\treturn &Quark4KAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"quark4k\", 3, true), // 跳过Service层过滤\n\t\tretries:         MaxRetries,\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *Quark4KAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *Quark4KAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)\n}\n\n// doSearch 实际的搜索实现\nfunc (p *Quark4KAsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 初始化随机数种子\n\trand.Seed(time.Now().UnixNano())\n\t\n\t// 只并发请求2个页面（0-1页）\n\tallResults, _, err := p.fetchBatch(client, keyword, 0, 2)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\t// 去重\n\tuniqueResults := p.deduplicateResults(allResults)\n\t\n\t// 使用过滤功能过滤结果\n\tfilteredResults := plugin.FilterResultsByKeyword(uniqueResults, keyword)\n\t\n\treturn filteredResults, nil\n}\n\n// fetchBatch 获取一批页面的数据\nfunc (p *Quark4KAsyncPlugin) fetchBatch(client *http.Client, keyword string, startOffset, pageCount int) ([]model.SearchResult, bool, error) {\n\tvar wg sync.WaitGroup\n\tresultChan := make(chan struct{\n\t\toffset  int\n\t\tresults []model.SearchResult\n\t\thasMore bool\n\t\terr     error\n\t}, pageCount)\n\t\n\t// 并发请求多个页面，但每个请求之间添加随机延迟\n\tfor i := 0; i < pageCount; i++ {\n\t\toffset := (startOffset + i) * PageSize\n\t\twg.Add(1)\n\t\t\n\t\tgo func(offset int, index int) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 第一个请求立即执行，后续请求添加随机延迟\n\t\t\tif index > 0 {\n\t\t\t\t// 随机等待0-1秒\n\t\t\t\trandomDelay := time.Duration(100 + rand.Intn(900)) * time.Millisecond\n\t\t\t\ttime.Sleep(randomDelay)\n\t\t\t}\n\t\t\t\n\t\t\t// 请求特定页面\n\t\t\tresults, hasMore, err := p.fetchPage(client, keyword, offset)\n\t\t\t\n\t\t\tresultChan <- struct{\n\t\t\t\toffset  int\n\t\t\t\tresults []model.SearchResult\n\t\t\t\thasMore bool\n\t\t\t\terr     error\n\t\t\t}{\n\t\t\t\toffset:  offset,\n\t\t\t\tresults: results,\n\t\t\t\thasMore: hasMore,\n\t\t\t\terr:     err,\n\t\t\t}\n\t\t}(offset, i)\n\t}\n\t\n\t// 等待所有请求完成\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultChan)\n\t}()\n\t\n\t// 收集结果\n\tvar allResults []model.SearchResult\n\thasMore := false\n\t\n\tfor result := range resultChan {\n\t\tif result.err != nil {\n\t\t\treturn nil, false, result.err\n\t\t}\n\t\t\n\t\tallResults = append(allResults, result.results...)\n\t\thasMore = hasMore || result.hasMore\n\t}\n\t\n\treturn allResults, hasMore, nil\n}\n\n// deduplicateResults 去除重复结果\nfunc (p *Quark4KAsyncPlugin) deduplicateResults(results []model.SearchResult) []model.SearchResult {\n\tseen := make(map[string]bool)\n\tunique := make([]model.SearchResult, 0, len(results))\n\t\n\tfor _, result := range results {\n\t\tif !seen[result.UniqueID] {\n\t\t\tseen[result.UniqueID] = true\n\t\t\tunique = append(unique, result)\n\t\t}\n\t}\n\t\n\t// 按时间降序排序\n\tsort.Slice(unique, func(i, j int) bool {\n\t\treturn unique[i].Datetime.After(unique[j].Datetime)\n\t})\n\t\n\treturn unique\n}\n\n// fetchPage 获取指定页的搜索结果\nfunc (p *Quark4KAsyncPlugin) fetchPage(client *http.Client, keyword string, offset int) ([]model.SearchResult, bool, error) {\n\t// 构建API URL\n\tapiURL := fmt.Sprintf(\"%s?include=user%%2ClastPostedUser%%2CmostRelevantPost%%2CmostRelevantPost.user%%2Ctags%%2Ctags.parent%%2CfirstPost&filter[q]=%s&sort&page[offset]=%d&page[limit]=%d\",\n\t\tBaseURL, url.QueryEscape(keyword), offset, PageSize)\n\t\n\t// 创建请求\n\treq, err := http.NewRequest(\"GET\", apiURL, nil)\n\tif err != nil {\n\t\treturn nil, false, fmt.Errorf(\"创建请求失败: %w\", err)\n\t}\n\t\n\t// 设置请求头\n\treq.Header.Set(\"User-Agent\", getRandomUA())\n\treq.Header.Set(\"X-Forwarded-For\", generateRandomIP())\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Sec-Fetch-Dest\", \"empty\")\n\treq.Header.Set(\"Sec-Fetch-Mode\", \"cors\")\n\treq.Header.Set(\"Sec-Fetch-Site\", \"same-origin\")\n\treq.Header.Set(\"Referer\", \"https://quark4k.com/\")\n\t\n\tvar resp *http.Response\n\tvar responseBody []byte\n\t\n\t// 重试逻辑\n\tfor i := 0; i <= p.retries; i++ {\n\t\t// 发送请求\n\t\tresp, err = client.Do(req)\n\t\tif err != nil {\n\t\t\tif i == p.retries {\n\t\t\t\treturn nil, false, fmt.Errorf(\"请求失败: %w\", err)\n\t\t\t}\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\tdefer resp.Body.Close()\n\t\t\n\t\t// 读取响应体\n\t\tresponseBody, err = io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tif i == p.retries {\n\t\t\t\treturn nil, false, fmt.Errorf(\"读取响应失败: %w\", err)\n\t\t\t}\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 状态码检查\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tif i == p.retries {\n\t\t\t\treturn nil, false, fmt.Errorf(\"API返回非200状态码: %d\", resp.StatusCode)\n\t\t\t}\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 请求成功，跳出重试循环\n\t\tbreak\n\t}\n\t\n\t// 解析响应\n\tvar apiResp Quark4KResponse\n\tif err := json.Unmarshal(responseBody, &apiResp); err != nil {\n\t\treturn nil, false, fmt.Errorf(\"解析响应失败: %w\", err)\n\t}\n\t\n\t// 处理结果\n\tresults := make([]model.SearchResult, 0, len(apiResp.Data))\n\t\n\t// 从included数组中提取posts，创建帖子ID到帖子内容的映射\n\tpostMap := make(map[string]Quark4KPost)\n\tfor _, item := range apiResp.Included {\n\t\t// 只处理posts类型\n\t\tif item.Type == \"posts\" {\n\t\t\t// 将整个item转换为JSON字节，然后解析为Quark4KPost结构\n\t\t\titemBytes, err := json.Marshal(item)\n\t\t\tif err == nil {\n\t\t\t\tvar post Quark4KPost\n\t\t\t\tif err := json.Unmarshal(itemBytes, &post); err == nil {\n\t\t\t\t\tpostMap[post.ID] = post\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 将关键词转为小写，用于不区分大小写的比较\n\tlowerKeyword := strings.ToLower(keyword)\n\tkeywords := strings.Fields(lowerKeyword)\n\t\n\t// 遍历搜索结果\n\tfor _, discussion := range apiResp.Data {\n\t\t// 提前检查标题是否包含关键词，避免不必要的处理\n\t\tlowerTitle := strings.ToLower(discussion.Attributes.Title)\n\t\ttitleMatched := true\n\t\tfor _, kw := range keywords {\n\t\t\tif !strings.Contains(lowerTitle, kw) {\n\t\t\t\ttitleMatched = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !titleMatched {\n\t\t\tcontinue // 标题中不包含关键词，跳过\n\t\t}\n\t\t\n\t\t// 获取相关帖子\n\t\tpostID := discussion.Relationships.MostRelevantPost.Data.ID\n\t\tpost, ok := postMap[postID]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 清理HTML内容\n\t\tcleanedHTML := cleanHTML(post.Attributes.ContentHTML)\n\t\t\n\t\t// 提取链接（主要处理夸克网盘）\n\t\tlinks := extractQuarkLinksFromText(cleanedHTML)\n\t\t\n\t\t// 如果没有找到链接，跳过该结果\n\t\tif len(links) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 解析时间\n\t\tcreatedTime, err := time.Parse(time.RFC3339, discussion.Attributes.CreatedAt)\n\t\tif err != nil {\n\t\t\tcreatedTime = time.Now() // 如果解析失败，使用当前时间\n\t\t}\n\t\t\n\t\t// 创建唯一ID：插件名-帖子ID\n\t\tuniqueID := fmt.Sprintf(\"quark4k-%s\", discussion.ID)\n\t\t\n\t\t// 创建搜索结果\n\t\tresult := model.SearchResult{\n\t\t\tUniqueID:  uniqueID,\n\t\t\tTitle:     discussion.Attributes.Title,\n\t\t\tContent:   cleanedHTML, // 使用清理后的HTML作为内容\n\t\t\tDatetime:  createdTime,\n\t\t\tLinks:     links,\n\t\t\tChannel:   \"\", // 插件搜索结果Channel为空\n\t\t}\n\t\t\n\t\tresults = append(results, result)\n\t}\n\t\n\t// 判断是否有更多结果\n\thasMore := apiResp.Links.Next != \"\"\n\t\n\treturn results, hasMore, nil\n}\n\n// 生成随机IP\nfunc generateRandomIP() string {\n\treturn fmt.Sprintf(\"%d.%d.%d.%d\", \n\t\trand.Intn(223)+1,  // 避免0和255\n\t\trand.Intn(255),\n\t\trand.Intn(255),\n\t\trand.Intn(254)+1)  // 避免0\n}\n\n// 获取随机UA\nfunc getRandomUA() string {\n\treturn userAgents[rand.Intn(len(userAgents))]\n}\n\n// 清理HTML内容（参考pan666的cleanHTML函数）\nfunc cleanHTML(html string) string {\n\t// 移除<br>标签\n\thtml = strings.ReplaceAll(html, \"<br>\", \"\\n\")\n\thtml = strings.ReplaceAll(html, \"<br/>\", \"\\n\")\n\thtml = strings.ReplaceAll(html, \"<br />\", \"\\n\")\n\t\n\t// 移除其他HTML标签\n\tvar result strings.Builder\n\tinTag := false\n\t\n\tfor _, r := range html {\n\t\tif r == '<' {\n\t\t\tinTag = true\n\t\t\tcontinue\n\t\t}\n\t\tif r == '>' {\n\t\t\tinTag = false\n\t\t\tcontinue\n\t\t}\n\t\tif !inTag {\n\t\t\tresult.WriteRune(r)\n\t\t}\n\t}\n\t\n\t// 处理HTML实体\n\toutput := result.String()\n\toutput = strings.ReplaceAll(output, \"&amp;\", \"&\")\n\toutput = strings.ReplaceAll(output, \"&lt;\", \"<\")\n\toutput = strings.ReplaceAll(output, \"&gt;\", \">\")\n\toutput = strings.ReplaceAll(output, \"&quot;\", \"\\\"\")\n\toutput = strings.ReplaceAll(output, \"&apos;\", \"'\")\n\toutput = strings.ReplaceAll(output, \"&#39;\", \"'\")\n\toutput = strings.ReplaceAll(output, \"&nbsp;\", \" \")\n\t\n\t// 处理多行空白\n\tlines := strings.Split(output, \"\\n\")\n\tvar cleanedLines []string\n\t\n\tfor _, line := range lines {\n\t\ttrimmed := strings.TrimSpace(line)\n\t\tif trimmed != \"\" {\n\t\t\tcleanedLines = append(cleanedLines, trimmed)\n\t\t}\n\t}\n\t\n\treturn strings.Join(cleanedLines, \"\\n\")\n}\n\n// 从文本提取夸克网盘链接（quark4k专用）\nfunc extractQuarkLinksFromText(content string) []model.Link {\n\tvar allLinks []model.Link\n\t\n\tlines := strings.Split(content, \"\\n\")\n\t\n\t// 收集所有可能的链接信息\n\tvar linkInfos []struct {\n\t\tlink     model.Link\n\t\tposition int\n\t\tcategory string\n\t}\n\t\n\t// 收集所有可能的密码信息\n\tvar passwordInfos []struct {\n\t\tkeyword   string\n\t\tposition  int\n\t\tpassword  string\n\t}\n\t\n\t// 第一遍：查找所有的链接和密码\n\tfor i, line := range lines {\n\t\tline = strings.TrimSpace(line)\n\t\t\n\t\t// 主要检查夸克网盘\n\t\tif strings.Contains(line, \"pan.quark.cn\") {\n\t\t\turl := extractURLFromText(line)\n\t\t\tif url != \"\" {\n\t\t\t\tlinkInfos = append(linkInfos, struct {\n\t\t\t\t\tlink     model.Link\n\t\t\t\t\tposition int\n\t\t\t\t\tcategory string\n\t\t\t\t}{\n\t\t\t\t\tlink:     model.Link{URL: url, Type: \"quark\"},\n\t\t\t\t\tposition: i,\n\t\t\t\t\tcategory: \"quark\",\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 检查提取码/密码\n\t\tpasswordKeywords := []string{\"提取码\", \"密码\"}\n\t\tfor _, keyword := range passwordKeywords {\n\t\t\tif strings.Contains(line, keyword) {\n\t\t\t\t// 寻找冒号后面的内容\n\t\t\t\tcolonPos := strings.Index(line, \":\")\n\t\t\t\tif colonPos == -1 {\n\t\t\t\t\tcolonPos = strings.Index(line, \"：\")\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tif colonPos != -1 && colonPos+1 < len(line) {\n\t\t\t\t\tpassword := strings.TrimSpace(line[colonPos+1:])\n\t\t\t\t\t// 如果密码长度超过10个字符，可能不是密码\n\t\t\t\t\tif len(password) <= 10 {\n\t\t\t\t\t\tpasswordInfos = append(passwordInfos, struct {\n\t\t\t\t\t\t\tkeyword   string\n\t\t\t\t\t\t\tposition  int\n\t\t\t\t\t\t\tpassword  string\n\t\t\t\t\t\t}{\n\t\t\t\t\t\t\tkeyword:   keyword,\n\t\t\t\t\t\t\tposition:  i,\n\t\t\t\t\t\t\tpassword:  password,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 第二遍：将密码与链接匹配\n\tfor i := range linkInfos {\n\t\t// 检查链接自身是否包含密码\n\t\tpassword := extractPasswordFromURL(linkInfos[i].link.URL)\n\t\tif password != \"\" {\n\t\t\tlinkInfos[i].link.Password = password\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 查找最近的密码\n\t\tminDistance := 1000000\n\t\tvar closestPassword string\n\t\t\n\t\tfor _, pwInfo := range passwordInfos {\n\t\t\t// 夸克网盘匹配提取码或密码\n\t\t\tmatch := false\n\t\t\t\n\t\t\tif linkInfos[i].category == \"quark\" && (pwInfo.keyword == \"提取码\" || pwInfo.keyword == \"密码\") {\n\t\t\t\tmatch = true\n\t\t\t}\n\t\t\t\n\t\t\tif match {\n\t\t\t\tdistance := abs(pwInfo.position - linkInfos[i].position)\n\t\t\t\tif distance < minDistance {\n\t\t\t\t\tminDistance = distance\n\t\t\t\t\tclosestPassword = pwInfo.password\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 只有当距离较近时才认为是匹配的密码\n\t\tif minDistance <= 3 {\n\t\t\tlinkInfos[i].link.Password = closestPassword\n\t\t}\n\t}\n\t\n\t// 收集所有有效链接\n\tfor _, info := range linkInfos {\n\t\tallLinks = append(allLinks, info.link)\n\t}\n\t\n\treturn allLinks\n}\n\n// 从文本中提取URL\nfunc extractURLFromText(text string) string {\n\t// 查找URL的起始位置\n\turlPrefixes := []string{\"http://\", \"https://\"}\n\tstart := -1\n\t\n\tfor _, prefix := range urlPrefixes {\n\t\tpos := strings.Index(text, prefix)\n\t\tif pos != -1 {\n\t\t\tstart = pos\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\tif start == -1 {\n\t\treturn \"\"\n\t}\n\t\n\t// 查找URL的结束位置\n\tend := len(text)\n\tendChars := []string{\" \", \"\\t\", \"\\n\", \"\\\"\", \"'\", \"<\", \">\", \")\", \"]\", \"}\", \",\", \";\"}\n\t\n\tfor _, char := range endChars {\n\t\tpos := strings.Index(text[start:], char)\n\t\tif pos != -1 && start+pos < end {\n\t\t\tend = start + pos\n\t\t}\n\t}\n\t\n\treturn text[start:end]\n}\n\n// 从URL中提取密码\nfunc extractPasswordFromURL(url string) string {\n\t// 查找密码参数\n\tpwdParams := []string{\"pwd=\", \"password=\", \"passcode=\", \"code=\"}\n\t\n\tfor _, param := range pwdParams {\n\t\tpos := strings.Index(url, param)\n\t\tif pos != -1 {\n\t\t\tstart := pos + len(param)\n\t\t\tend := len(url)\n\t\t\t\n\t\t\t// 查找参数结束位置\n\t\t\tfor i := start; i < len(url); i++ {\n\t\t\t\tif url[i] == '&' || url[i] == '#' {\n\t\t\t\t\tend = i\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tif start < end {\n\t\t\t\treturn url[start:end]\n\t\t\t}\n\t\t}\n\t}\n\t\n\treturn \"\"\n}\n\n// 绝对值函数\nfunc abs(n int) int {\n\tif n < 0 {\n\t\treturn -n\n\t}\n\treturn n\n}\n\n// Quark4KResponse API响应结构\ntype Quark4KResponse struct {\n\tLinks    Quark4KLinks      `json:\"links\"`\n\tData     []Quark4KDiscussion `json:\"data\"`\n\tIncluded []Quark4KIncludedItem `json:\"included\"`\n}\n\n// Quark4KLinks 链接信息\ntype Quark4KLinks struct {\n\tFirst string `json:\"first\"`\n\tNext  string `json:\"next,omitempty\"`\n}\n\n// Quark4KDiscussion 讨论信息\ntype Quark4KDiscussion struct {\n\tType       string `json:\"type\"`\n\tID         string `json:\"id\"`\n\tAttributes  Quark4KDiscussionAttributes `json:\"attributes\"`\n\tRelationships Quark4KRelationships `json:\"relationships\"`\n}\n\n// Quark4KDiscussionAttributes 讨论属性\ntype Quark4KDiscussionAttributes struct {\n\tTitle           string `json:\"title\"`\n\tSlug            string `json:\"slug\"`\n\tCommentCount    int    `json:\"commentCount\"`\n\tParticipantCount int   `json:\"participantCount\"`\n\tCreatedAt       string `json:\"createdAt\"`\n\tLastPostedAt    string `json:\"lastPostedAt\"`\n\tLastPostNumber  int    `json:\"lastPostNumber\"`\n\tIsApproved      bool   `json:\"isApproved\"`\n\tIsLocked        bool   `json:\"isLocked\"`\n}\n\n// Quark4KRelationships 关系信息\ntype Quark4KRelationships struct {\n\tMostRelevantPost Quark4KPostRef `json:\"mostRelevantPost\"`\n}\n\n// Quark4KPostRef 帖子引用\ntype Quark4KPostRef struct {\n\tData Quark4KPostData `json:\"data\"`\n}\n\n// Quark4KPostData 帖子数据\ntype Quark4KPostData struct {\n\tType string `json:\"type\"`\n\tID   string `json:\"id\"`\n}\n\n// Quark4KIncludedItem Included 数组项（可能包含多种类型）\ntype Quark4KIncludedItem struct {\n\tType       string      `json:\"type\"`\n\tID         string      `json:\"id\"`\n\tAttributes interface{} `json:\"attributes\"` // 使用interface{}以便灵活处理不同类型\n}\n\n// Quark4KPost 帖子内容\ntype Quark4KPost struct {\n\tType       string              `json:\"type\"`\n\tID         string              `json:\"id\"`\n\tAttributes Quark4KPostAttributes `json:\"attributes\"`\n}\n\n// Quark4KPostAttributes 帖子属性\ntype Quark4KPostAttributes struct {\n\tNumber      int    `json:\"number\"`\n\tCreatedAt   string `json:\"createdAt\"`\n\tContentType string `json:\"contentType\"`\n\tContentHTML string `json:\"contentHtml\"`\n\tRenderFailed bool  `json:\"renderFailed\"`\n\tEditedAt    string `json:\"editedAt,omitempty\"`\n\tIsApproved  bool   `json:\"isApproved\"`\n\tLikesCount  int    `json:\"likesCount\"`\n}\n"
  },
  {
    "path": "plugin/quarksoo/html结构分析.md",
    "content": "# Quarksoo HTML 结构分析\n\n## 基本信息\n- **数据源类型**: HTML 页面  \n- **API URL格式**: `https://quarksoo.cc/search.php?q={关键词}`\n- **请求方法**: `GET`\n- **Content-Type**: `text/html`\n- **特殊说明**: 该网站返回 HTML 格式的搜索结果页面，需要从 HTML 表格中解析剧名和网盘链接\n\n## HTML 页面结构\n\n### 搜索请求\n```\nGET https://quarksoo.cc/search.php?q=华山论剑\n```\n\n### HTML 响应结构\n\n```html\n<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n    <meta charset=\"UTF-8\">\n    <title>搜索完整剧名</title>\n</head>\n<body>\n    <h1>搜索完整剧名</h1>\n    <form method=\"get\" action=\"\">\n        <input type=\"text\" name=\"q\" value=\"华山论剑\">\n        <button type=\"submit\">搜索</button>\n    </form>\n    \n    <h2>搜索结果：4 条</h2>\n    <table>\n        <tr>\n            <th>剧名</th>\n            <th>网盘链接</th>\n        </tr>\n        <tr>\n            <td>华山论剑之九阴真经</td>\n            <td>\n                <a href=\"https://pan.qoark.cn/s/hslj\" target=\"_blank\">\n                    夸克网盘\n                </a>\n            </td>\n        </tr>\n        <tr>\n            <td>华山论剑之东邪西毒</td>\n            <td>\n                <a href=\"https://pan.qoark.cn/s/dxxd\" target=\"_blank\">\n                    夸克网盘\n                </a>\n            </td>\n        </tr>\n        <!-- 更多结果... -->\n    </table>\n</body>\n</html>\n```\n\n## 数据结构分析\n\n### 表格结构\n- **容器**: `<table>` 标签\n- **表头**: `<tr><th>剧名</th><th>网盘链接</th></tr>`（第一行，可忽略）\n- **数据行**: `<tr><td>剧名</td><td><a href=\"链接\">夸克网盘</a></td></tr>`\n\n### 数据提取规则\n\n| 数据项 | HTML 位置 | 提取方法 |\n|--------|----------|----------|\n| **剧名** | `<tr>` 内第一个 `<td>` | 提取文本内容 |\n| **网盘链接** | `<tr>` 内第二个 `<td>` 中的 `<a href=\"...\">` | 提取 href 属性值 |\n\n### 网盘链接格式\n\n| 网盘类型 | 域名特征 | 示例链接 | 说明 |\n|---------|----------|----------|------|\n| **夸克网盘** | `pan.qoark.cn` | `https://pan.qoark.cn/s/hslj` | 主要网盘类型 |\n\n**注意**: \n- 域名是 `pan.qoark.cn`（注意是 qoark，不是 quark）\n- 但按照系统标准，这应该识别为 `quark` 类型\n- 需要在识别时同时支持 `pan.quark.cn` 和 `pan.qoark.cn`\n\n## 插件所需字段映射\n\n| HTML 字段 | 目标字段 | 说明 |\n|-----------|----------|------|\n| 第一个 `<td>` 文本 | `Title` | 剧名 |\n| `<a href=\"...\">` 的 href | `Links[].URL` | 网盘链接 |\n| `\"\"` | `Links[].Password` | 无密码（默认为空） |\n| `\"quark\"` | `Links[].Type` | 网盘类型（夸克网盘） |\n| `\"\"` | `Channel` | 插件搜索结果Channel为空 |\n| `[]` | `Tags` | 标签数组（可选） |\n| 当前时间 | `Datetime` | 发布时间（页面中无时间信息，使用当前时间） |\n| `quarksoo-{行号}` | `UniqueID` | 唯一ID |\n\n## HTML 解析策略\n\n### 方法1: 正则表达式（推荐）\n\n使用正则表达式匹配表格行：\n```go\n// 匹配表格行的正则表达式\npattern := `<tr>\\s*<td>([^<]+)</td>\\s*<td>\\s*<a[^>]*href\\s*=\\s*[\"']([^\"']+)[\"'][^>]*>`\n\n// 提取所有匹配\nre := regexp.MustCompile(pattern)\nmatches := re.FindAllStringSubmatch(htmlContent, -1)\n\nfor _, match := range matches {\n    title := strings.TrimSpace(match[1])      // 剧名\n    linkURL := strings.TrimSpace(match[2])    // 网盘链接\n}\n```\n\n### 方法2: 字符串分割（备选）\n\n```go\n// 按 <tr> 分割\nrows := strings.Split(htmlContent, \"<tr>\")\n\nfor _, row := range rows {\n    // 跳过表头\n    if strings.Contains(row, \"<th>\") {\n        continue\n    }\n    \n    // 提取第一个 <td> 内容（剧名）\n    // 提取 <a href=\"...\"> 中的链接\n}\n```\n\n### 链接验证和处理\n\n1. **链接格式验证**: 确保链接包含 `pan.qoark.cn` 或 `pan.quark.cn`\n2. **网盘类型识别**: 识别为 `quark` 类型\n3. **URL 清理**: 移除多余的空白字符和参数\n\n## 插件开发指导\n\n### 请求示例\n```go\nsearchURL := fmt.Sprintf(\"https://quarksoo.cc/search.php?q=%s\", url.QueryEscape(keyword))\n```\n\n### 请求头设置\n```go\nreq.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\")\nreq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\nreq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\nreq.Header.Set(\"Connection\", \"keep-alive\")\nreq.Header.Set(\"Referer\", \"https://quarksoo.cc/\")\n```\n\n### SearchResult构建示例\n```go\nresult := model.SearchResult{\n    UniqueID: fmt.Sprintf(\"quarksoo-%d\", index),\n    Title:    title,\n    Links: []model.Link{\n        {\n            Type:     \"quark\",\n            URL:      linkURL,\n            Password: \"\", // 无密码\n        },\n    },\n    Channel:  \"\", // 插件搜索结果Channel为空\n    Datetime: time.Now(), // 页面无时间信息，使用当前时间\n}\n```\n\n### HTML解析函数示例\n```go\n// 从HTML中解析搜索结果\nfunc parseSearchResults(htmlContent string, keyword string) []model.SearchResult {\n    var results []model.SearchResult\n    \n    // 提前过滤：检查标题是否包含关键词\n    lowerKeyword := strings.ToLower(keyword)\n    keywords := strings.Fields(lowerKeyword)\n    \n    // 使用正则表达式提取表格行\n    pattern := `<tr>\\s*<td>([^<]+)</td>\\s*<td>\\s*<a[^>]*href\\s*=\\s*[\"']([^\"']+)[\"'][^>]*>`\n    re := regexp.MustCompile(pattern)\n    matches := re.FindAllStringSubmatch(htmlContent, -1)\n    \n    for i, match := range matches {\n        if len(match) < 3 {\n            continue\n        }\n        \n        title := strings.TrimSpace(match[1])\n        linkURL := strings.TrimSpace(match[2])\n        \n        // 验证链接是否为夸克网盘\n        if !strings.Contains(linkURL, \"pan.qoark.cn\") && !strings.Contains(linkURL, \"pan.quark.cn\") {\n            continue\n        }\n        \n        // 检查标题是否包含关键词\n        lowerTitle := strings.ToLower(title)\n        titleMatched := true\n        for _, kw := range keywords {\n            if !strings.Contains(lowerTitle, kw) {\n                titleMatched = false\n                break\n            }\n        }\n        if !titleMatched {\n            continue\n        }\n        \n        // 识别网盘类型\n        linkType := \"quark\"\n        \n        result := model.SearchResult{\n            UniqueID: fmt.Sprintf(\"quarksoo-%d\", i),\n            Title:    title,\n            Links: []model.Link{\n                {\n                    Type:     linkType,\n                    URL:      linkURL,\n                    Password: \"\",\n                },\n            },\n            Channel:  \"\",\n            Datetime: time.Now(),\n        }\n        \n        results = append(results, result)\n    }\n    \n    return results\n}\n```\n\n## 特殊处理逻辑\n\n### 1. 标题过滤\n- 在解析时直接检查标题是否包含关键词\n- 支持多关键词搜索（空格分隔）\n- 不区分大小写\n\n### 2. 链接验证\n- 验证链接是否包含 `pan.qoark.cn` 或 `pan.quark.cn`\n- 过滤无效链接\n\n### 3. 网盘类型识别\n- `pan.qoark.cn` 和 `pan.quark.cn` 都识别为 `quark` 类型\n\n### 4. 去重处理\n- 基于 `UniqueID` 进行去重\n- `UniqueID` 格式: `quarksoo-{行号}`\n\n## 与其他插件的差异\n\n| 特性 | quarksoo | 其他插件 | 说明 |\n|------|---------|----------|------|\n| **数据源** | HTML 页面 | JSON API | 需要解析 HTML |\n| **链接格式** | HTML 表格 | JSON 对象 | 需要从 HTML 中提取 |\n| **解析方法** | 正则表达式 | JSON解析 | HTML 解析更复杂 |\n| **时间信息** | 无 | 有 | 使用当前时间 |\n\n## 注意事项\n\n1. **HTML 解析**: 使用正则表达式解析 HTML，注意处理各种格式变化\n2. **链接验证**: 确保提取的链接都是有效的夸克网盘链接\n3. **标题过滤**: 在解析时就进行标题过滤，提高效率\n4. **错误处理**: HTML 格式可能变化，需要健壮的错误处理\n5. **反爬虫**: 使用随机 UA 避免反爬虫检测\n6. **去重**: 确保结果不重复\n\n## 开发建议\n\n- **优先级设置**: 建议设置为优先级3，数据质量一般\n- **Service层过滤**: 启用Service层过滤，使用 `NewBaseAsyncPlugin(\"quarksoo\", 3)`\n- **HTML解析**: 使用正则表达式解析，注意处理边界情况\n- **链接提取**: 只处理夸克网盘链接（`pan.qoark.cn` 或 `pan.quark.cn`）\n- **缓存策略**: 建议使用较短的缓存TTL\n- **错误日志**: 详细记录HTML解析错误信息\n\n## API调用示例\n\n### 搜索请求示例\n```bash\ncurl \"https://quarksoo.cc/search.php?q=华山论剑\" \\\n  -H \"Referer: https://quarksoo.cc/\" \\\n  -H \"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\"\n```\n\n### 完整流程示例\n1. **发送搜索请求**: 获取 HTML 搜索结果页面\n2. **解析 HTML**: 使用正则表达式提取表格数据\n3. **提取剧名和链接**: 从 `<tr>` 行中提取剧名和网盘链接\n4. **标题过滤**: 检查标题是否包含关键词\n5. **链接验证**: 验证链接是否为有效的夸克网盘链接\n6. **构建搜索结果**: 转换为PanSou标准格式\n7. **返回结果**: 包含标题、链接等信息\n"
  },
  {
    "path": "plugin/quarksoo/quarksoo.go",
    "content": "package quarksoo\n\nimport (\n\t\"crypto/md5\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\n// 在init函数中注册插件\nfunc init() {\n\t// 注册插件\n\tplugin.RegisterGlobalPlugin(NewQuarksooAsyncPlugin())\n}\n\nconst (\n\t// API基础URL\n\tBaseURL = \"https://quarksoo.cc/search.php\"\n\t\n\t// 默认参数\n\tMaxRetries = 2\n)\n\n// 常用UA列表\nvar userAgents = []string{\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36\",\n\t\"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\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0\",\n\t\"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\",\n\t\"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36\",\n}\n\n// QuarksooAsyncPlugin quarksoo网盘搜索异步插件\ntype QuarksooAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tretries int\n}\n\n// NewQuarksooAsyncPlugin 创建新的quarksoo异步插件\nfunc NewQuarksooAsyncPlugin() *QuarksooAsyncPlugin {\n\treturn &QuarksooAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"quarksoo\", 3), // 启用Service层过滤\n\t\tretries:         MaxRetries,\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *QuarksooAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *QuarksooAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)\n}\n\n// doSearch 实际的搜索实现\nfunc (p *QuarksooAsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 初始化随机数种子\n\trand.Seed(time.Now().UnixNano())\n\t\n\t// 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"%s?q=%s\", BaseURL, url.QueryEscape(keyword))\n\t\n\t// 创建请求\n\treq, err := http.NewRequest(\"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建请求失败: %w\", err)\n\t}\n\t\n\t// 设置请求头\n\treq.Header.Set(\"User-Agent\", getRandomUA())\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"https://quarksoo.cc/\")\n\t\n\tvar resp *http.Response\n\tvar responseBody []byte\n\t\n\t// 重试逻辑\n\tfor i := 0; i <= p.retries; i++ {\n\t\t// 发送请求\n\t\tresp, err = client.Do(req)\n\t\tif err != nil {\n\t\t\tif i == p.retries {\n\t\t\t\treturn nil, fmt.Errorf(\"请求失败: %w\", err)\n\t\t\t}\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\tdefer resp.Body.Close()\n\t\t\n\t\t// 读取响应体\n\t\tresponseBody, err = io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tif i == p.retries {\n\t\t\t\treturn nil, fmt.Errorf(\"读取响应失败: %w\", err)\n\t\t\t}\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 状态码检查\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tif i == p.retries {\n\t\t\t\treturn nil, fmt.Errorf(\"API返回非200状态码: %d\", resp.StatusCode)\n\t\t\t}\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 请求成功，跳出重试循环\n\t\tbreak\n\t}\n\t\n\t// 解析HTML内容\n\thtmlContent := string(responseBody)\n\tresults := p.parseSearchResults(htmlContent, keyword)\n\t\n\t// 去重\n\tuniqueResults := p.deduplicateResults(results)\n\t\n\t// 使用过滤功能过滤结果（二次过滤）\n\tfilteredResults := plugin.FilterResultsByKeyword(uniqueResults, keyword)\n\t\n\treturn filteredResults, nil\n}\n\n// parseSearchResults 从HTML中解析搜索结果\nfunc (p *QuarksooAsyncPlugin) parseSearchResults(htmlContent string, keyword string) []model.SearchResult {\n\tvar results []model.SearchResult\n\t\n\t// 提前过滤：检查标题是否包含关键词\n\tlowerKeyword := strings.ToLower(keyword)\n\tkeywords := strings.Fields(lowerKeyword)\n\t\n\t// 使用正则表达式提取表格行\n\t// 匹配格式: <tr><td>剧名</td><td><a href=\"链接\">...</a></td></tr>\n\t// 注意处理可能的空白字符\n\tpattern := `<tr>\\s*<td>([^<]+)</td>\\s*<td>\\s*<a[^>]*href\\s*=\\s*[\"']([^\"']+)[\"'][^>]*>`\n\tre := regexp.MustCompile(pattern)\n\tmatches := re.FindAllStringSubmatch(htmlContent, -1)\n\t\n\tfor _, match := range matches {\n\t\tif len(match) < 3 {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\ttitle := strings.TrimSpace(match[1])\n\t\tlinkURL := strings.TrimSpace(match[2])\n\t\t\n\t\t// 跳过表头（如果匹配到）\n\t\tif strings.Contains(title, \"剧名\") || strings.Contains(title, \"网盘链接\") {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 验证链接是否为夸克网盘\n\t\tif !strings.Contains(linkURL, \"pan.qoark.cn\") && !strings.Contains(linkURL, \"pan.quark.cn\") {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 检查标题是否包含关键词（提前过滤）\n\t\tlowerTitle := strings.ToLower(title)\n\t\ttitleMatched := true\n\t\tfor _, kw := range keywords {\n\t\t\tif !strings.Contains(lowerTitle, kw) {\n\t\t\t\ttitleMatched = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !titleMatched {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 识别网盘类型\n\t\tlinkType := \"quark\"\n\t\t\n\t\t// 生成唯一ID：使用标题和链接的MD5哈希\n\t\tuniqueIDKey := fmt.Sprintf(\"%s|%s\", title, linkURL)\n\t\thash := md5.Sum([]byte(uniqueIDKey))\n\t\tuniqueID := fmt.Sprintf(\"quarksoo-%x\", hash[:8]) // 使用前8字节作为ID\n\t\t\n\t\tresult := model.SearchResult{\n\t\t\tUniqueID: uniqueID,\n\t\t\tTitle:    title,\n\t\t\tLinks: []model.Link{\n\t\t\t\t{\n\t\t\t\t\tType:     linkType,\n\t\t\t\t\tURL:      linkURL,\n\t\t\t\t\tPassword: \"\", // 无密码\n\t\t\t\t},\n\t\t\t},\n\t\t\tChannel:  \"\", // 插件搜索结果Channel为空\n\t\t\tDatetime: time.Now(), // 页面无时间信息，使用当前时间\n\t\t}\n\t\t\n\t\tresults = append(results, result)\n\t}\n\t\n\treturn results\n}\n\n// deduplicateResults 去除重复结果\nfunc (p *QuarksooAsyncPlugin) deduplicateResults(results []model.SearchResult) []model.SearchResult {\n\tseen := make(map[string]bool)\n\tunique := make([]model.SearchResult, 0, len(results))\n\t\n\tfor _, result := range results {\n\t\t// 使用UniqueID进行去重\n\t\tif !seen[result.UniqueID] {\n\t\t\tseen[result.UniqueID] = true\n\t\t\tunique = append(unique, result)\n\t\t}\n\t}\n\t\n\t// 按标题排序（保持一致性）\n\tsort.Slice(unique, func(i, j int) bool {\n\t\treturn unique[i].Title < unique[j].Title\n\t})\n\t\n\treturn unique\n}\n\n// 生成随机IP\nfunc generateRandomIP() string {\n\treturn fmt.Sprintf(\"%d.%d.%d.%d\", \n\t\trand.Intn(223)+1,  // 避免0和255\n\t\trand.Intn(255),\n\t\trand.Intn(255),\n\t\trand.Intn(254)+1)  // 避免0\n}\n\n// 获取随机UA\nfunc getRandomUA() string {\n\treturn userAgents[rand.Intn(len(userAgents))]\n}\n"
  },
  {
    "path": "plugin/qupanshe/qupanshe.go",
    "content": "package qupanshe\n\nimport (\n\t\"compress/gzip\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/cookiejar\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tBaseURL    = \"https://www.qupanshe.com\"\n\tUserAgent  = \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\"\n\tMaxRetries = 3\n)\n\nvar (\n\tDebugLog = false // Debug开关，默认开启\n)\n\n// QupanshePlugin 趣盘社插件结构\ntype QupanshePlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\n// NewQupanshePlugin 创建趣盘社插件实例\nfunc NewQupanshePlugin() *QupanshePlugin {\n\treturn &QupanshePlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"qupanshe\", 3), // 优先级3 = 普通质量数据源\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *QupanshePlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *QupanshePlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实现搜索逻辑\nfunc (p *QupanshePlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif DebugLog {\n\t\tfmt.Printf(\"[qupanshe] 开始搜索: keyword=%s\\n\", keyword)\n\t}\n\n\t// 创建带有Cookie管理的专用客户端，确保整个搜索过程使用同一个session\n\tsessionClient, err := p.createSessionClient(client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建session客户端失败: %w\", p.Name(), err)\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[qupanshe] 创建session客户端成功，开始三步搜索流程\\n\")\n\t}\n\n\t// Step 1: 获取首页formhash（使用session客户端）\n\tformhash, err := p.getFormhash(sessionClient)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[qupanshe] 获取formhash失败: %v\\n\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"[%s] 获取formhash失败: %w\", p.Name(), err)\n\t}\n\tif DebugLog {\n\t\tfmt.Printf(\"[qupanshe] 获取到formhash: %s\\n\", formhash)\n\t}\n\n\t// Step 2: POST请求获取搜索结果URL（使用同一个session客户端）\n\tsearchURL, err := p.postSearchRequest(sessionClient, keyword, formhash)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[qupanshe] POST搜索请求失败: %v\\n\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"[%s] POST搜索请求失败: %w\", p.Name(), err)\n\t}\n\tif DebugLog {\n\t\tfmt.Printf(\"[qupanshe] 获取搜索URL成功: %s\\n\", searchURL)\n\t}\n\n\t// Step 3: GET请求获取搜索结果（使用同一个session客户端）\n\tresults, err := p.getSearchResults(sessionClient, searchURL, keyword)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[qupanshe] 获取搜索结果失败: %v\\n\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"[%s] 获取搜索结果失败: %w\", p.Name(), err)\n\t}\n\tif DebugLog {\n\t\tfmt.Printf(\"[qupanshe] 获取搜索结果成功: 结果数=%d\\n\", len(results))\n\t}\n\n\t// Step 4: 关键词过滤\n\tfilteredResults := plugin.FilterResultsByKeyword(results, keyword)\n\tif DebugLog {\n\t\tfmt.Printf(\"[qupanshe] 关键词过滤后: 过滤前=%d, 过滤后=%d\\n\", len(results), len(filteredResults))\n\t}\n\n\treturn filteredResults, nil\n}\n\n// createSessionClient 创建带有Cookie管理的HTTP客户端\nfunc (p *QupanshePlugin) createSessionClient(baseClient *http.Client) (*http.Client, error) {\n\t// 创建Cookie Jar来管理cookies\n\tjar, err := cookiejar.New(nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建cookie jar失败: %w\", err)\n\t}\n\n\t// 创建新的客户端，复制基础客户端的配置但添加Cookie管理\n\tsessionClient := &http.Client{\n\t\tTimeout:   baseClient.Timeout,\n\t\tTransport: baseClient.Transport,\n\t\tJar:       jar, // ⭐ 关键：添加Cookie管理\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[qupanshe] 创建带Cookie管理的session客户端，超时时间: %v\\n\", sessionClient.Timeout)\n\t}\n\n\treturn sessionClient, nil\n}\n\n// getFormhash 从首页获取真实的formhash值\nfunc (p *QupanshePlugin) getFormhash(client *http.Client) (string, error) {\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", BaseURL, nil)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"创建GET请求失败: %w\", err)\n\t}\n\n\tp.setRequestHeaders(req)\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[qupanshe] 请求首页获取formhash: %s\\n\", BaseURL)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"GET请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// 调试：显示从首页获取的cookies\n\tif DebugLog && client.Jar != nil {\n\t\tif u, _ := url.Parse(BaseURL); u != nil {\n\t\t\tcookies := client.Jar.Cookies(u)\n\t\t\tfmt.Printf(\"[qupanshe] 从首页获取到 %d 个cookies:\\n\", len(cookies))\n\t\t\tfor i, cookie := range cookies {\n\t\t\t\tfmt.Printf(\"  Cookie[%d]: %s=%s\\n\", i, cookie.Name, cookie.Value)\n\t\t\t}\n\t\t}\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"首页请求返回状态码: %d\", resp.StatusCode)\n\t}\n\n\t// 处理可能的gzip压缩\n\tvar reader io.Reader = resp.Body\n\tif resp.Header.Get(\"Content-Encoding\") == \"gzip\" {\n\t\tgzipReader, err := gzip.NewReader(resp.Body)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"创建gzip读取器失败: %w\", err)\n\t\t}\n\t\tdefer gzipReader.Close()\n\t\treader = gzipReader\n\t}\n\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(reader)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"解析HTML失败: %w\", err)\n\t}\n\n\t// 查找formhash\n\tformhash := \"\"\n\tinputCount := doc.Find(\"input[name='formhash']\").Length()\n\tif DebugLog {\n\t\tfmt.Printf(\"[qupanshe] 找到input[name='formhash']元素数量: %d\\n\", inputCount)\n\t}\n\n\tdoc.Find(\"input[name='formhash']\").Each(func(i int, s *goquery.Selection) {\n\t\tif value, exists := s.Attr(\"value\"); exists && value != \"\" {\n\t\t\tformhash = value\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[qupanshe] 找到formhash[%d]: %s\\n\", i, value)\n\t\t\t}\n\t\t}\n\t})\n\n\tif formhash == \"\" {\n\t\treturn \"\", fmt.Errorf(\"未找到formhash值\")\n\t}\n\n\treturn formhash, nil\n}\n\n// postSearchRequest 发送POST请求获取搜索结果URL\nfunc (p *QupanshePlugin) postSearchRequest(client *http.Client, keyword, formhash string) (string, error) {\n\t// 添加延时，避免请求过快\n\ttime.Sleep(2 * time.Second)\n\n\t// 构建POST请求\n\tsearchURL := fmt.Sprintf(\"%s/search.php?mod=forum\", BaseURL)\n\tdata := url.Values{}\n\tdata.Set(\"formhash\", formhash)\n\tdata.Set(\"srchtxt\", keyword)\n\tdata.Set(\"searchsubmit\", \"yes\")\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\n\tpostData := data.Encode()\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", searchURL, strings.NewReader(postData))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"创建POST请求失败: %w\", err)\n\t}\n\n\tp.setRequestHeaders(req)\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\n\t// 详细日志：请求信息\n\tif DebugLog {\n\t\tfmt.Printf(\"[qupanshe] POST请求URL: %s\\n\", searchURL)\n\t\tfmt.Printf(\"[qupanshe] POST请求数据: %s\\n\", postData)\n\t\tfmt.Printf(\"[qupanshe] POST请求头:\\n\")\n\t\tfor key, values := range req.Header {\n\t\t\tfor _, value := range values {\n\t\t\t\tfmt.Printf(\"  %s: %s\\n\", key, value)\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 显示将要发送的cookies\n\t\tif client.Jar != nil {\n\t\t\tif u, _ := url.Parse(searchURL); u != nil {\n\t\t\t\tcookies := client.Jar.Cookies(u)\n\t\t\t\tfmt.Printf(\"[qupanshe] POST请求将发送 %d 个cookies:\\n\", len(cookies))\n\t\t\t\tfor i, cookie := range cookies {\n\t\t\t\t\tfmt.Printf(\"  Cookie[%d]: %s=%s\\n\", i, cookie.Name, cookie.Value)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 不自动跟随重定向\n\tclient.CheckRedirect = func(req *http.Request, via []*http.Request) error {\n\t\treturn http.ErrUseLastResponse\n\t}\n\tdefer func() { client.CheckRedirect = nil }()\n\n\t// 带重试机制的请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"POST请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[qupanshe] POST请求响应: status=%d\\n\", resp.StatusCode)\n\t\tfmt.Printf(\"[qupanshe] 响应头: %v\\n\", resp.Header)\n\t}\n\n\t// 从响应头获取Location\n\tlocation := resp.Header.Get(\"Location\")\n\tif DebugLog {\n\t\tfmt.Printf(\"[qupanshe] Location header: %s\\n\", location)\n\t}\n\n\t// 读取响应体用于调试（非重定向状态码时）\n\tif resp.StatusCode != 302 && resp.StatusCode != 301 && DebugLog {\n\t\tbody, readErr := io.ReadAll(resp.Body)\n\t\tif readErr == nil {\n\t\t\tbodyStr := string(body)\n\t\t\tif len(bodyStr) > 1000 {\n\t\t\t\tfmt.Printf(\"[qupanshe] 响应体(前1000字符): %s\\n\", bodyStr[:1000])\n\t\t\t} else {\n\t\t\t\tfmt.Printf(\"[qupanshe] 响应体: %s\\n\", bodyStr)\n\t\t\t}\n\t\t}\n\t}\n\n\tif location == \"\" {\n\t\treturn \"\", fmt.Errorf(\"未获取到重定向URL，状态码: %d\", resp.StatusCode)\n\t}\n\n\t// 将相对路径转换为完整URL\n\tfullURL := BaseURL + \"/\" + strings.TrimPrefix(location, \"/\")\n\n\treturn fullURL, nil\n}\n\n// getSearchResults 获取搜索结果\nfunc (p *QupanshePlugin) getSearchResults(client *http.Client, searchURL, keyword string) ([]model.SearchResult, error) {\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建GET请求失败: %w\", err)\n\t}\n\n\tp.setRequestHeaders(req)\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[qupanshe] GET搜索结果URL: %s\\n\", searchURL)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"GET请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[qupanshe] 搜索结果页请求失败: status=%d\\n\", resp.StatusCode)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"请求返回状态码: %d\", resp.StatusCode)\n\t}\n\n\t// 处理可能的gzip压缩\n\tvar reader io.Reader = resp.Body\n\tif resp.Header.Get(\"Content-Encoding\") == \"gzip\" {\n\t\tgzipReader, err := gzip.NewReader(resp.Body)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"创建gzip读取器失败: %w\", err)\n\t\t}\n\t\tdefer gzipReader.Close()\n\t\treader = gzipReader\n\t}\n\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(reader)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[qupanshe] 解析HTML失败: %v\\n\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"解析HTML失败: %w\", err)\n\t}\n\n\treturn p.extractSearchResults(doc), nil\n}\n\n// extractSearchResults 提取搜索结果\nfunc (p *QupanshePlugin) extractSearchResults(doc *goquery.Document) []model.SearchResult {\n\tvar results []model.SearchResult\n\n\tliCount := doc.Find(\"li.pbw\").Length()\n\tif DebugLog {\n\t\tfmt.Printf(\"[qupanshe] 找到li.pbw元素数量: %d\\n\", liCount)\n\t}\n\n\tdoc.Find(\"li.pbw\").Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchResult(s)\n\t\tif result.Title != \"\" {\n\t\t\tresults = append(results, result)\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[qupanshe] 解析结果[%d]: title=%s, links=%d\\n\", i, result.Title, len(result.Links))\n\t\t\t}\n\t\t} else {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[qupanshe] 解析结果[%d]: 标题为空，跳过\\n\", i)\n\t\t\t}\n\t\t}\n\t})\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[qupanshe] 提取到有效结果数: %d\\n\", len(results))\n\t}\n\n\treturn results\n}\n\n// parseSearchResult 解析单个搜索结果\nfunc (p *QupanshePlugin) parseSearchResult(s *goquery.Selection) model.SearchResult {\n\t// 提取帖子ID\n\tpostID, _ := s.Attr(\"id\")\n\n\t// 提取标题和详情页链接\n\ttitleLink := s.Find(\"h3.xs3 a\").First()\n\ttitleHTML, _ := titleLink.Html()\n\ttitle := p.cleanTitle(titleHTML)\n\tdetailPath, _ := titleLink.Attr(\"href\")\n\n\tvar detailURL string\n\tif detailPath != \"\" {\n\t\tif strings.HasPrefix(detailPath, \"http\") {\n\t\t\tdetailURL = detailPath\n\t\t} else {\n\t\t\tdetailURL = BaseURL + \"/\" + strings.TrimPrefix(detailPath, \"/\")\n\t\t}\n\t}\n\n\t// 提取统计信息（回复数和查看数）\n\tstatsText := s.Find(\"p.xg1\").First().Text()\n\tvar replyCount, viewCount int\n\tp.parseStats(statsText, &replyCount, &viewCount)\n\n\t// 提取内容摘要（第二个p标签）\n\tvar content string\n\ts.Find(\"p\").Each(func(i int, p *goquery.Selection) {\n\t\tif i == 1 { // 第二个p标签是内容摘要\n\t\t\tcontent = strings.TrimSpace(p.Text())\n\t\t}\n\t})\n\n\t// ⭐ 重要：直接从搜索结果页的内容摘要中提取网盘链接\n\tvar links []model.Link\n\n\t// 1. 从HTML中提取<a>标签链接\n\taTagCount := s.Find(\"p\").Eq(1).Find(\"a\").Length()\n\tif DebugLog && aTagCount > 0 {\n\t\tfmt.Printf(\"[qupanshe] [%s] 找到<a>标签数量: %d\\n\", postID, aTagCount)\n\t}\n\ts.Find(\"p\").Eq(1).Find(\"a\").Each(func(i int, a *goquery.Selection) {\n\t\thref, exists := a.Attr(\"href\")\n\t\tif !exists {\n\t\t\treturn\n\t\t}\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[qupanshe] [%s] 检查链接[%d]: %s\\n\", postID, i, href)\n\t\t}\n\t\tlinkType := p.determineLinkType(href)\n\t\tif linkType != \"\" {\n\t\t\tpassword := p.extractPasswordFromContent(content, href)\n\t\t\tlinks = append(links, model.Link{\n\t\t\t\tURL:      href,\n\t\t\t\tType:     linkType,\n\t\t\t\tPassword: password,\n\t\t\t})\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[qupanshe] [%s] 识别到%s链接: %s\\n\", postID, linkType, href)\n\t\t\t}\n\t\t}\n\t})\n\n\t// 2. 从纯文本中提取链接（可能没有<a>标签）\n\tif DebugLog {\n\t\tfmt.Printf(\"[qupanshe] [%s] 从文本提取链接: content长度=%d\\n\", postID, len(content))\n\t}\n\ttextLinks := p.extractLinksFromText(content)\n\tif DebugLog && len(textLinks) > 0 {\n\t\tfmt.Printf(\"[qupanshe] [%s] 从文本提取到链接数: %d\\n\", postID, len(textLinks))\n\t}\n\tlinks = append(links, textLinks...)\n\n\t// 去重\n\tbeforeDedupe := len(links)\n\tlinks = p.deduplicateLinks(links)\n\tif DebugLog && beforeDedupe != len(links) {\n\t\tfmt.Printf(\"[qupanshe] [%s] 链接去重: 去重前=%d, 去重后=%d\\n\", postID, beforeDedupe, len(links))\n\t}\n\n\t// 提取时间、作者、分类信息（最后一个p标签）\n\tvar publishTime, author, category string\n\tlastP := s.Find(\"p\").Last()\n\tspans := lastP.Find(\"span\")\n\tif spans.Length() >= 3 {\n\t\tpublishTime = strings.TrimSpace(spans.Eq(0).Text())\n\t\tauthor = strings.TrimSpace(spans.Eq(1).Find(\"a\").Text())\n\t\tcategory = strings.TrimSpace(spans.Eq(2).Find(\"a\").Text())\n\t}\n\n\t// 转换时间格式\n\tparsedTime := p.parseTime(publishTime)\n\n\t// 构建包含详情页URL的Content\n\tenrichedContent := content\n\tif detailURL != \"\" {\n\t\tenrichedContent = fmt.Sprintf(\"%s | 作者: %s | 分类: %s | 详情: %s\", content, author, category, detailURL)\n\t}\n\n\t// 如果没有找到帖子ID，使用时间戳\n\tif postID == \"\" {\n\t\tpostID = fmt.Sprintf(\"%d\", time.Now().UnixNano())\n\t}\n\n\treturn model.SearchResult{\n\t\tMessageID: fmt.Sprintf(\"%s-%s\", p.Name(), postID),\n\t\tUniqueID:  fmt.Sprintf(\"%s-%s\", p.Name(), postID),\n\t\tTitle:     title,\n\t\tContent:   enrichedContent,\n\t\tDatetime:  parsedTime,\n\t\tLinks:     links, // ⭐ 直接使用从搜索结果页提取的链接\n\t\tChannel:   \"\",    // ⭐ 重要：插件搜索结果Channel必须为空\n\t}\n}\n\n// cleanTitle 清理标题中的HTML标签\nfunc (p *QupanshePlugin) cleanTitle(titleHTML string) string {\n\t// 移除所有HTML标签\n\tre := regexp.MustCompile(`<[^>]*>`)\n\ttitle := re.ReplaceAllString(titleHTML, \"\")\n\n\t// 清理HTML实体\n\ttitle = strings.ReplaceAll(title, \"&nbsp;\", \" \")\n\ttitle = strings.ReplaceAll(title, \"&amp;\", \"&\")\n\ttitle = strings.ReplaceAll(title, \"&lt;\", \"<\")\n\ttitle = strings.ReplaceAll(title, \"&gt;\", \">\")\n\ttitle = strings.ReplaceAll(title, \"&quot;\", \"\\\"\")\n\n\treturn strings.TrimSpace(title)\n}\n\n// determineLinkType 确定链接类型\nfunc (p *QupanshePlugin) determineLinkType(urlStr string) string {\n\tlinkPatterns := map[string]string{\n\t\t`pan\\.quark\\.cn`:   \"quark\",\n\t\t`pan\\.baidu\\.com`:  \"baidu\",\n\t\t`www\\.alipan\\.com`: \"aliyun\",\n\t\t`aliyundrive\\.com`: \"aliyun\",\n\t\t`pan\\.xunlei\\.com`: \"xunlei\",\n\t\t`cloud\\.189\\.cn`:   \"tianyi\",\n\t\t`pan\\.uc\\.cn`:      \"uc\",\n\t\t`www\\.123pan\\.com`: \"123\",\n\t\t`www\\.123684\\.com`: \"123\",\n\t\t`115cdn\\.com`:      \"115\",\n\t\t`115\\.com`:         \"115\",\n\t\t`pan\\.pikpak\\.com`: \"pikpak\",\n\t\t`mypikpak\\.com`:    \"pikpak\",\n\t\t`caiyun\\.139\\.cn`:  \"mobile\",\n\t}\n\n\tfor pattern, linkType := range linkPatterns {\n\t\tmatched, _ := regexp.MatchString(pattern, urlStr)\n\t\tif matched {\n\t\t\treturn linkType\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\n// extractLinksFromText 从文本中提取链接\nfunc (p *QupanshePlugin) extractLinksFromText(text string) []model.Link {\n\tvar links []model.Link\n\n\t// 网盘链接正则模式（支持更宽泛的字符集）\n\tpatterns := []string{\n\t\t`https?://pan\\.quark\\.cn/s/[a-zA-Z0-9_-]+`,\n\t\t`https?://pan\\.baidu\\.com/s/[a-zA-Z0-9_-]+(?:\\?pwd=[a-zA-Z0-9]+)?`, // 支持pwd参数\n\t\t`https?://www\\.alipan\\.com/s/[a-zA-Z0-9_-]+`,\n\t\t`https?://aliyundrive\\.com/s/[a-zA-Z0-9_-]+`,\n\t\t`https?://pan\\.xunlei\\.com/s/[a-zA-Z0-9_-]+`,\n\t\t`https?://cloud\\.189\\.cn/[a-zA-Z0-9_/-]+`, // 天翼云支持多级路径\n\t\t`https?://pan\\.uc\\.cn/s/[a-zA-Z0-9_-]+`,\n\t\t`https?://www\\.123pan\\.com/s/[a-zA-Z0-9_-]+`,\n\t\t`https?://www\\.123684\\.com/s/[a-zA-Z0-9_-]+`,\n\t\t`https?://115cdn\\.com/[a-zA-Z0-9_/-]+`,\n\t\t`https?://115\\.com/[a-zA-Z0-9_/-]+`,\n\t\t`https?://pan\\.pikpak\\.com/s/[a-zA-Z0-9_-]+`,\n\t\t`https?://mypikpak\\.com/s/[a-zA-Z0-9_-]+`,\n\t\t`https?://caiyun\\.139\\.com/[a-zA-Z0-9_/-]+`,\n\t}\n\n\tfor _, pattern := range patterns {\n\t\tre := regexp.MustCompile(pattern)\n\t\tmatches := re.FindAllString(text, -1)\n\n\t\tfor _, match := range matches {\n\t\t\tlinkType := p.determineLinkType(match)\n\t\t\tif linkType != \"\" {\n\t\t\t\t// 从URL参数或周围文本提取密码\n\t\t\t\tpassword := p.extractPasswordFromContent(text, match)\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tURL:      match,\n\t\t\t\t\tType:     linkType,\n\t\t\t\t\tPassword: password,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\treturn links\n}\n\n// extractPasswordFromContent 从内容文本中提取指定链接的密码\nfunc (p *QupanshePlugin) extractPasswordFromContent(content, linkURL string) string {\n\t// 先尝试从URL中提取pwd参数\n\tif parsedURL, err := url.Parse(linkURL); err == nil {\n\t\tif pwd := parsedURL.Query().Get(\"pwd\"); pwd != \"\" {\n\t\t\treturn pwd\n\t\t}\n\t}\n\n\t// 查找链接在内容中的位置\n\tlinkIndex := strings.Index(content, linkURL)\n\tif linkIndex == -1 {\n\t\treturn \"\"\n\t}\n\n\t// 提取链接周围的文本（前20字符，后100字符）\n\tstart := linkIndex - 20\n\tif start < 0 {\n\t\tstart = 0\n\t}\n\tend := linkIndex + len(linkURL) + 100\n\tif end > len(content) {\n\t\tend = len(content)\n\t}\n\n\tsurroundingText := content[start:end]\n\n\t// 查找密码模式\n\tpasswordPatterns := []string{\n\t\t`提取码[：:]\\s*([A-Za-z0-9]+)`,\n\t\t`密码[：:]\\s*([A-Za-z0-9]+)`,\n\t\t`pwd[：:=]\\s*([A-Za-z0-9]+)`,\n\t\t`password[：:=]\\s*([A-Za-z0-9]+)`,\n\t}\n\n\tfor _, pattern := range passwordPatterns {\n\t\tre := regexp.MustCompile(pattern)\n\t\tmatches := re.FindStringSubmatch(surroundingText)\n\t\tif len(matches) > 1 {\n\t\t\treturn matches[1]\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\n// deduplicateLinks 去重链接\nfunc (p *QupanshePlugin) deduplicateLinks(links []model.Link) []model.Link {\n\tlinkMap := make(map[string]model.Link)\n\n\tfor _, link := range links {\n\t\t// 提取和设置密码\n\t\tnormalizedURL, password := p.extractPasswordFromURL(link.URL)\n\n\t\t// 创建带密码信息的新链接\n\t\tnewLink := model.Link{\n\t\t\tURL:      link.URL,\n\t\t\tType:     link.Type,\n\t\t\tPassword: password,\n\t\t}\n\n\t\t// 如果链接本身没有密码但我们找到了密码，使用找到的密码\n\t\tif newLink.Password == \"\" && link.Password != \"\" {\n\t\t\tnewLink.Password = link.Password\n\t\t}\n\n\t\t// 使用标准化URL作为key进行去重\n\t\tif existingLink, exists := linkMap[normalizedURL]; exists {\n\t\t\t// 如果已存在，保留更完整的版本（优先带密码的）\n\t\t\tif newLink.Password != \"\" && existingLink.Password == \"\" {\n\t\t\t\tlinkMap[normalizedURL] = newLink\n\t\t\t}\n\t\t} else {\n\t\t\tlinkMap[normalizedURL] = newLink\n\t\t}\n\t}\n\n\t// 转换为切片\n\tvar result []model.Link\n\tfor _, link := range linkMap {\n\t\tresult = append(result, link)\n\t}\n\n\treturn result\n}\n\n// extractPasswordFromURL 从URL中提取密码并返回标准化URL\nfunc (p *QupanshePlugin) extractPasswordFromURL(rawURL string) (normalizedURL string, password string) {\n\t// 解析URL\n\tparsedURL, err := url.Parse(rawURL)\n\tif err != nil {\n\t\treturn rawURL, \"\"\n\t}\n\n\t// 获取查询参数\n\tquery := parsedURL.Query()\n\n\t// 检查常见的密码参数\n\tpasswordKeys := []string{\"pwd\", \"password\", \"pass\", \"code\"}\n\tfor _, key := range passwordKeys {\n\t\tif val := query.Get(key); val != \"\" {\n\t\t\tpassword = val\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// 构建标准化URL（去除密码参数）\n\tfor _, key := range passwordKeys {\n\t\tquery.Del(key)\n\t}\n\n\tparsedURL.RawQuery = query.Encode()\n\tnormalizedURL = parsedURL.String()\n\n\t// 如果查询参数为空，去掉问号\n\tif parsedURL.RawQuery == \"\" {\n\t\tnormalizedURL = strings.TrimSuffix(normalizedURL, \"?\")\n\t}\n\n\treturn normalizedURL, password\n}\n\n// parseStats 解析统计信息\nfunc (p *QupanshePlugin) parseStats(statsText string, replyCount, viewCount *int) {\n\t// 解析如 \"18 个回复 - 5926 次查看\" 格式\n\tre := regexp.MustCompile(`(\\d+)\\s*个回复\\s*-\\s*(\\d+)\\s*次查看`)\n\tmatches := re.FindStringSubmatch(statsText)\n\tif len(matches) >= 3 {\n\t\tif reply, err := strconv.Atoi(matches[1]); err == nil {\n\t\t\t*replyCount = reply\n\t\t}\n\t\tif view, err := strconv.Atoi(matches[2]); err == nil {\n\t\t\t*viewCount = view\n\t\t}\n\t}\n}\n\n// parseTime 解析时间字符串\nfunc (p *QupanshePlugin) parseTime(timeStr string) time.Time {\n\t// 解析如 \"2024-10-8 20:58\" 格式（注意月和日可能是单数字）\n\ttimeStr = strings.TrimSpace(timeStr)\n\n\tformats := []string{\n\t\t\"2006-1-2 15:04\",\n\t\t\"2006-1-2 15:04:05\",\n\t\t\"2006-01-02 15:04\",\n\t\t\"2006-01-02 15:04:05\",\n\t}\n\n\tfor _, format := range formats {\n\t\tif t, err := time.Parse(format, timeStr); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\n\t// 如果解析失败，返回当前时间\n\treturn time.Now()\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *QupanshePlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tvar lastErr error\n\n\tfor i := 0; i < MaxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 2 * time.Second\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[qupanshe] 重试第%d次，等待%v\\n\", i, backoff)\n\t\t\t}\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil {\n\t\t\t// 检查状态码\n\t\t\tif resp.StatusCode == 503 {\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[qupanshe] 服务器返回503，继续重试\\n\")\n\t\t\t\t}\n\t\t\t\tresp.Body.Close()\n\t\t\t\tlastErr = fmt.Errorf(\"服务器返回503\")\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn resp, nil\n\t\t}\n\n\t\tlastErr = err\n\t}\n\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", MaxRetries, lastErr)\n}\n\n// setRequestHeaders 设置请求头\nfunc (p *QupanshePlugin) setRequestHeaders(req *http.Request) {\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Accept-Encoding\", \"gzip, deflate, br\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n}\n\nfunc init() {\n\tp := NewQupanshePlugin()\n\tplugin.RegisterGlobalPlugin(p)\n}"
  },
  {
    "path": "plugin/qupansou/qupansou.go",
    "content": "package qupansou\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\n// 缓存相关变量\nvar (\n\t// API响应缓存，键为关键词，值为缓存的响应\n\tapiResponseCache = sync.Map{}\n\t\n\t// 最后一次清理缓存的时间\n\tlastCacheCleanTime = time.Now()\n\t\n\t// 缓存有效期（1小时）\n\tcacheTTL = 1 * time.Hour\n)\n\n// 在init函数中注册插件\nfunc init() {\n\t// 使用全局超时时间创建插件实例并注册\n\tplugin.RegisterGlobalPlugin(NewQuPanSouPlugin())\n\t\n\t// 启动缓存清理goroutine\n\tgo startCacheCleaner()\n}\n\n// startCacheCleaner 启动一个定期清理缓存的goroutine\nfunc startCacheCleaner() {\n\t// 每小时清理一次缓存\n\tticker := time.NewTicker(1 * time.Hour)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\t// 清空所有缓存\n\t\tapiResponseCache = sync.Map{}\n\t\tlastCacheCleanTime = time.Now()\n\t}\n}\n\nconst (\n\t// API端点\n\tApiURL = \"https://v.funletu.com/search\"\n\t\n\t// 默认超时时间\n\tDefaultTimeout = 6 * time.Second\n\t\n\t// 默认页大小\n\tDefaultPageSize = 1000\n)\n\n// QuPanSouAsyncPlugin 趣盘搜异步插件\ntype QuPanSouAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\ttimeout time.Duration\n}\n\n// NewQuPanSouPlugin 创建新的趣盘搜异步插件\nfunc NewQuPanSouPlugin() *QuPanSouAsyncPlugin {\n\ttimeout := DefaultTimeout\n\t\n\treturn &QuPanSouAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"qupansou\", 3),\n\t\ttimeout:         timeout,\n\t}\n}\n\n// 确保QuPanSouAsyncPlugin实现了AsyncSearchPlugin接口\nvar _ plugin.AsyncSearchPlugin = (*QuPanSouAsyncPlugin)(nil)\n\n// Name 返回插件名称\nfunc (p *QuPanSouAsyncPlugin) Name() string {\n\treturn \"qupansou\"\n}\n\n// Priority 返回插件优先级\nfunc (p *QuPanSouAsyncPlugin) Priority() int {\n\treturn 3 // 中等优先级\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *QuPanSouAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *QuPanSouAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)\n}\n\n// doSearch 执行具体的搜索逻辑\nfunc (p *QuPanSouAsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 发送API请求\n\titems, err := p.searchAPI(keyword, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"qupansou API error: %w\", err)\n\t}\n\t\n\t// 转换为标准格式\n\tresults := p.convertResults(items)\n\t\n\treturn results, nil\n}\n\n// 缓存响应结构\ntype cachedResponse struct {\n\tresults   []model.SearchResult\n\ttimestamp time.Time\n}\n\n// searchAPI 向API发送请求\nfunc (p *QuPanSouAsyncPlugin) searchAPI(keyword string, client *http.Client) ([]QuPanSouItem, error) {\n\t// 构建请求体\n\treqBody := map[string]interface{}{\n\t\t\"style\": \"get\",\n\t\t\"datasrc\": \"search\",\n\t\t\"query\": map[string]interface{}{\n\t\t\t\"id\": \"\",\n\t\t\t\"datetime\": \"\",\n\t\t\t\"courseid\": 1,\n\t\t\t\"categoryid\": \"\",\n\t\t\t\"filetypeid\": \"\",\n\t\t\t\"filetype\": \"\",\n\t\t\t\"reportid\": \"\",\n\t\t\t\"validid\": \"\",\n\t\t\t\"searchtext\": keyword,\n\t\t},\n\t\t\"page\": map[string]interface{}{\n\t\t\t\"pageSize\": DefaultPageSize,\n\t\t\t\"pageIndex\": 1,\n\t\t},\n\t\t\"order\": map[string]interface{}{\n\t\t\t\"prop\": \"sort\",\n\t\t\t\"order\": \"desc\",\n\t\t},\n\t\t\"message\": \"请求资源列表数据\",\n\t}\n\t\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"marshal request failed: %w\", err)\n\t}\n\t\n\treq, err := http.NewRequest(\"POST\", ApiURL, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create request failed: %w\", err)\n\t}\n\t\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.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\")\n\treq.Header.Set(\"Referer\", \"https://pan.funletu.com/\")\n\t\n\t// 发送请求\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 读取响应体\n\trespBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read response body failed: %w\", err)\n\t}\n\t\n\t// 解析响应\n\tvar apiResp QuPanSouResponse\n\tif err := json.Unmarshal(respBody, &apiResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"decode response failed: %w\", err)\n\t}\n\t\n\t// 检查响应状态\n\tif apiResp.Status != 200 {\n\t\treturn nil, fmt.Errorf(\"API returned error: %s\", apiResp.Message)\n\t}\n\t\n\treturn apiResp.Data, nil\n}\n\n// convertResults 将API响应转换为标准SearchResult格式\nfunc (p *QuPanSouAsyncPlugin) convertResults(items []QuPanSouItem) []model.SearchResult {\n\tresults := make([]model.SearchResult, 0, len(items))\n\t\n\tfor _, item := range items {\n\t\t// 跳过无效的URL\n\t\tif item.URL == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 创建链接\n\t\tlink := model.Link{\n\t\t\tURL:      item.URL,\n\t\t\tType:     p.determineLinkType(item.URL),\n\t\t\tPassword: \"\", // 趣盘搜API不返回密码\n\t\t}\n\t\t\n\t\t// 创建唯一ID\n\t\tuniqueID := fmt.Sprintf(\"qupansou-%d\", item.ID)\n\t\t\n\t\t// 解析时间\n\t\tvar datetime time.Time\n\t\tif item.UpdateTime != \"\" {\n\t\t\t// 尝试解析时间，格式：2025-07-05 00:31:38\n\t\t\tparsedTime, err := time.Parse(\"2006-01-02 15:04:05\", item.UpdateTime)\n\t\t\tif err == nil {\n\t\t\t\tdatetime = parsedTime\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 如果时间解析失败，使用零值\n\t\tif datetime.IsZero() {\n\t\t\tdatetime = time.Time{}\n\t\t}\n\t\t\n\t\t// 清理标题中的HTML标签\n\t\ttitle := cleanHTML(item.Title)\n\t\t\n\t\t// 创建搜索结果\n\t\tresult := model.SearchResult{\n\t\t\tUniqueID:  uniqueID,\n\t\t\tTitle:     title,\n\t\t\tContent:   fmt.Sprintf(\"类别: %s, 文件类型: %s, 大小: %s\", item.Category, item.FileType, item.Size),\n\t\t\tDatetime:  datetime,\n\t\t\tLinks:     []model.Link{link},\n\t\t}\n\t\t\n\t\tresults = append(results, result)\n\t}\n\t\n\treturn results\n}\n\n// determineLinkType 根据URL确定链接类型\nfunc (p *QuPanSouAsyncPlugin) determineLinkType(url string) string {\n\tlowerURL := strings.ToLower(url)\n\t\n\tif strings.Contains(lowerURL, \"pan.baidu.com\") {\n\t\treturn \"baidu\"\n\t} else if strings.Contains(lowerURL, \"aliyundrive.com\") || strings.Contains(lowerURL, \"alipan.com\") {\n\t\treturn \"aliyun\"\n\t} else if strings.Contains(lowerURL, \"pan.quark.cn\") {\n\t\treturn \"quark\"\n\t} else if strings.Contains(lowerURL, \"cloud.189.cn\") {\n\t\treturn \"tianyi\"\n\t} else if strings.Contains(lowerURL, \"pan.xunlei.com\") {\n\t\treturn \"xunlei\"\n\t} else if strings.Contains(lowerURL, \"caiyun.139.com\") || strings.Contains(lowerURL, \"www.caiyun.139.com\") {\n\t\treturn \"mobile\"\n\t} else if strings.Contains(lowerURL, \"115.com\") {\n\t\treturn \"115\"\n\t} else if strings.Contains(lowerURL, \"drive.uc.cn\") {\n\t\treturn \"uc\"\n\t} else if strings.Contains(lowerURL, \"pan.123.com\") || strings.Contains(lowerURL, \"123pan.com\") {\n\t\treturn \"123\"\n\t} else if strings.Contains(lowerURL, \"mypikpak.com\") {\n\t\treturn \"pikpak\"\n\t} else if strings.Contains(lowerURL, \"lanzou\") {\n\t\treturn \"lanzou\"\n\t} else {\n\t\treturn \"others\"\n\t}\n}\n\n// cleanHTML 清理HTML标签\nfunc cleanHTML(html string) string {\n\t// 一次性替换所有常见HTML标签\n\treplacements := map[string]string{\n\t\t\"<em>\": \"\",\n\t\t\"</em>\": \"\",\n\t\t\"<b>\": \"\",\n\t\t\"</b>\": \"\",\n\t\t\"<strong>\": \"\",\n\t\t\"</strong>\": \"\",\n\t\t\"<i>\": \"\",\n\t\t\"</i>\": \"\",\n\t}\n\t\n\tresult := html\n\tfor tag, replacement := range replacements {\n\t\tresult = strings.Replace(result, tag, replacement, -1)\n\t}\n\t\n\t// 移除多余的空格\n\treturn strings.TrimSpace(result)\n}\n\n// QuPanSouResponse API响应结构\ntype QuPanSouResponse struct {\n\tText    string         `json:\"text\"`\n\tData    []QuPanSouItem `json:\"data\"`\n\tTotal   int            `json:\"total\"`\n\tStatus  int            `json:\"status\"`\n\tMessage string         `json:\"message\"`\n}\n\n// QuPanSouItem API响应中的单个结果项\ntype QuPanSouItem struct {\n\tID           int    `json:\"id\"`\n\tTitle        string `json:\"title\"`\n\tFilename     string `json:\"filename\"`\n\tURL          string `json:\"url\"`\n\tLink         string `json:\"link\"`\n\tSearchText   string `json:\"searchtext\"`\n\tExtCode      string `json:\"extcode\"`\n\tUnzipCode    string `json:\"unzipcode\"`\n\tSize         string `json:\"size\"`\n\tCategoryID   int    `json:\"categoryid\"`\n\tCategory     string `json:\"category\"`\n\tCourseID     int    `json:\"courseid\"`\n\tCourse       string `json:\"course\"`\n\tFileTypeID   int    `json:\"filetypeid\"`\n\tFileType     string `json:\"filetype\"`\n\tUpdateTime   string `json:\"updatetime\"`\n\tCreateTime   string `json:\"createtime\"`\n\tViews        int    `json:\"views\"`\n\tViewsHistory int    `json:\"viewshistory\"`\n\tDiff         int    `json:\"diff\"`\n\tViolate      int    `json:\"violate\"`\n\tState        int    `json:\"state\"`\n\tSort         int    `json:\"sort\"`\n\tTop          int    `json:\"top\"`\n\tValid        int    `json:\"valid\"`\n} "
  },
  {
    "path": "plugin/sdso/sdso.go",
    "content": "package sdso\n\nimport (\n\t\"context\"\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\t// 调试日志开关\n\tDebugLog = false\n\t// 默认每种网盘类型获取页数\n\tDefaultPagesPerType = 2\n\t// 最大允许每种网盘类型页数（防止过度请求）\n\tMaxAllowedPagesPerType = 5\n\t// AES解密配置\n\tAESKey = \"4OToScUFOaeVTrHE\"\n\tAESIV  = \"9CLGao1vHKqm17Oz\"\n)\n\n// 支持的网盘类型列表\nvar SupportedCloudTypes = []string{\"baidu\", \"quark\", \"xunlei\", \"ali\"}\n\n// SDSOPlugin SDSO搜索插件\ntype SDSOPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\n// APIResponse SDSO API响应结构\ntype APIResponse struct {\n\tCode int    `json:\"code\"`\n\tMsg  string `json:\"msg\"`\n\tData struct {\n\t\tTotal int        `json:\"total\"`\n\t\tList  []DataItem `json:\"list\"`\n\t} `json:\"data\"`\n}\n\n// DataItem 搜索结果项\ntype DataItem struct {\n\tID          string     `json:\"id\"`\n\tName        string     `json:\"name\"`\n\tURL         string     `json:\"url\"`          // 加密的网盘链接\n\tType        string     `json:\"type\"`\n\tFrom        string     `json:\"from\"`         // 网盘类型: quark/xunlei/aliyun/baidu\n\tContent     *string    `json:\"content\"`\n\tGmtCreate   string     `json:\"gmtCreate\"`\n\tGmtShare    string     `json:\"gmtShare\"`\n\tFileCount   int        `json:\"fileCount\"`\n\tCreatorID   *string    `json:\"creatorId\"`\n\tCreatorName string     `json:\"creatorName\"`\n\tFileInfos   []FileInfo `json:\"fileInfos\"`\n}\n\n// FileInfo 文件信息\ntype FileInfo struct {\n\tCategory      *string `json:\"category\"`\n\tFileExtension *string `json:\"fileExtension\"`\n\tFileID        string  `json:\"fileId\"`\n\tFileName      string  `json:\"fileName\"`\n\tType          *string `json:\"type\"`\n}\n\n// PageResult 页面搜索结果\ntype PageResult struct {\n\tpageNo   int\n\tfromType string\n\tresults  []model.SearchResult\n\terr      error\n}\n\nfunc init() {\n\tp := &SDSOPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"sdso\", 3), // 优先级3 = 普通质量数据源\n\t}\n\tplugin.RegisterGlobalPlugin(p)\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *SDSOPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果（推荐方法）\nfunc (p *SDSOPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实际的搜索实现\nfunc (p *SDSOPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] 开始搜索，关键词: %s\\n\", p.Name(), keyword)\n\t}\n\n\t// 1. 从扩展参数中获取每种网盘类型的页数配置\n\tpagesPerType := DefaultPagesPerType\n\tif ext != nil {\n\t\tif pages, ok := ext[\"pages_per_type\"].(int); ok && pages > 0 {\n\t\t\tpagesPerType = pages\n\t\t\tif pagesPerType > MaxAllowedPagesPerType {\n\t\t\t\tpagesPerType = MaxAllowedPagesPerType\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[%s] 每种网盘类型页数限制在最大值: %d\\n\", p.Name(), MaxAllowedPagesPerType)\n\t\t\t\t}\n\t\t\t}\n\t\t} else if pagesFloat, ok := ext[\"pages_per_type\"].(float64); ok && pagesFloat > 0 {\n\t\t\tpagesPerType = int(pagesFloat)\n\t\t\tif pagesPerType > MaxAllowedPagesPerType {\n\t\t\t\tpagesPerType = MaxAllowedPagesPerType\n\t\t\t}\n\t\t}\n\t\t// 保持向后兼容：如果设置了 pages 参数，则平均分配给各网盘类型\n\t\tif pages, ok := ext[\"pages\"].(int); ok && pages > 0 {\n\t\t\tpagesPerType = pages / len(SupportedCloudTypes)\n\t\t\tif pagesPerType == 0 {\n\t\t\t\tpagesPerType = 1\n\t\t\t}\n\t\t\tif pagesPerType > MaxAllowedPagesPerType {\n\t\t\t\tpagesPerType = MaxAllowedPagesPerType\n\t\t\t}\n\t\t}\n\t}\n\n\ttotalTasks := len(SupportedCloudTypes) * pagesPerType\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] 将分别搜索 %d 种网盘类型，每种 %d 页，总计 %d 个并发任务\\n\", \n\t\t\tp.Name(), len(SupportedCloudTypes), pagesPerType, totalTasks)\n\t}\n\n\t// 2. 并发请求多个网盘类型的多页数据\n\tvar wg sync.WaitGroup\n\tresultsChan := make(chan PageResult, totalTasks)\n\t\n\t// 启动并发任务：遍历网盘类型和页数\n\tfor _, cloudType := range SupportedCloudTypes {\n\t\tfor pageNo := 1; pageNo <= pagesPerType; pageNo++ {\n\t\t\twg.Add(1)\n\t\t\tgo func(fromType string, page int) {\n\t\t\t\tdefer wg.Done()\n\t\t\t\t\n\t\t\t\tresults, err := p.fetchSinglePageWithType(client, keyword, page, fromType)\n\t\t\t\tresultsChan <- PageResult{\n\t\t\t\t\tpageNo:   page,\n\t\t\t\t\tfromType: fromType,\n\t\t\t\t\tresults:  results,\n\t\t\t\t\terr:      err,\n\t\t\t\t}\n\t\t\t}(cloudType, pageNo)\n\t\t}\n\t}\n\n\t// 等待所有任务完成\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultsChan)\n\t}()\n\n\t// 3. 收集所有页面结果\n\tvar allResults []model.SearchResult\n\tsuccessTasks := 0\n\terrorTasks := 0\n\tresultsByType := make(map[string]int) // 统计各网盘类型的结果数\n\t\n\tfor pageResult := range resultsChan {\n\t\tif pageResult.err != nil {\n\t\t\terrorTasks++\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[%s] %s网盘第%d页请求失败: %v\\n\", p.Name(), pageResult.fromType, pageResult.pageNo, pageResult.err)\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\tsuccessTasks++\n\t\tallResults = append(allResults, pageResult.results...)\n\t\tresultsByType[pageResult.fromType] += len(pageResult.results)\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[%s] %s网盘第%d页成功获取 %d 个结果\\n\", p.Name(), pageResult.fromType, pageResult.pageNo, len(pageResult.results))\n\t\t}\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] 分类搜索完成: 成功%d任务, 失败%d任务, 总结果%d个\\n\", \n\t\t\tp.Name(), successTasks, errorTasks, len(allResults))\n\t\tfor cloudType, count := range resultsByType {\n\t\t\tfmt.Printf(\"[%s]   - %s网盘: %d个结果\\n\", p.Name(), cloudType, count)\n\t\t}\n\t}\n\n\t// 4. 如果所有任务都失败，返回错误\n\tif successTasks == 0 {\n\t\treturn nil, fmt.Errorf(\"[%s] 所有搜索任务都失败\", p.Name())\n\t}\n\n\t// 5. 关键词过滤\n\tbeforeFilterCount := len(allResults)\n\tfilteredResults := plugin.FilterResultsByKeyword(allResults, keyword)\n\t\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] 关键词过滤: 过滤前%d项 -> 过滤后%d项\\n\", \n\t\t\tp.Name(), beforeFilterCount, len(filteredResults))\n\t}\n\n\treturn filteredResults, nil\n}\n\n// fetchSinglePageWithType 获取指定网盘类型的单页数据\nfunc (p *SDSOPlugin) fetchSinglePageWithType(client *http.Client, keyword string, pageNo int, fromType string) ([]model.SearchResult, error) {\n\t// 1. 构建搜索URL，添加from参数指定网盘类型\n\tsearchURL := fmt.Sprintf(\"https://sdso.top/api/sd/search?name=%s&pageNo=%d&from=%s\", \n\t\turl.QueryEscape(keyword), pageNo, fromType)\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] 请求%s网盘第%d页: %s\\n\", p.Name(), fromType, pageNo, searchURL)\n\t}\n\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\t// 3. 创建请求对象\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] %s网盘第%d页创建请求失败: %w\", p.Name(), fromType, pageNo, err)\n\t}\n\n\t// 4. 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"https://sdso.top/\")\n\n\t// 5. 发送HTTP请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] %s网盘第%d页请求失败: %w\", p.Name(), fromType, pageNo, err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// 6. 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] %s网盘第%d页返回状态码: %d\", p.Name(), fromType, pageNo, resp.StatusCode)\n\t}\n\n\t// 7. 解析响应\n\tvar apiResp APIResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] %s网盘第%d页JSON解析失败: %w\", p.Name(), fromType, pageNo, err)\n\t}\n\n\t// 8. 检查API响应状态\n\tif apiResp.Code != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] %s网盘第%d页API错误: %s\", p.Name(), fromType, pageNo, apiResp.Msg)\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] %s网盘第%d页获取到 %d 个原始结果\\n\", p.Name(), fromType, pageNo, len(apiResp.Data.List))\n\t}\n\n\t// 9. 转换为标准格式\n\tresults := make([]model.SearchResult, 0, len(apiResp.Data.List))\n\tprocessedCount := 0\n\tskippedCount := 0\n\t\n\tfor i, item := range apiResp.Data.List {\n\t\t// 解密网盘链接\n\t\tdecryptedURL, err := DecryptURL(item.URL)\n\t\tif err != nil {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[%s] %s网盘第%d页第%d项解密失败: %v\\n\", p.Name(), fromType, pageNo, i+1, err)\n\t\t\t}\n\t\t\tskippedCount++\n\t\t\tcontinue\n\t\t}\n\n\t\t// 验证是否为有效的网盘链接\n\t\tif !isValidPanURL(decryptedURL) {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[%s] %s网盘第%d页第%d项无效链接: %s\\n\", p.Name(), fromType, pageNo, i+1, decryptedURL)\n\t\t\t}\n\t\t\tskippedCount++\n\t\t\tcontinue\n\t\t}\n\n\t\t// 映射网盘类型\n\t\tpanType := mapPanType(item.From)\n\t\tif panType == \"others\" {\n\t\t\tskippedCount++\n\t\t\tcontinue\n\t\t}\n\n\t\t// 创建链接对象\n\t\tlink := model.Link{\n\t\t\tType:     panType,\n\t\t\tURL:      decryptedURL,\n\t\t\tPassword: \"\", // SDSO返回的链接通常不包含密码\n\t\t}\n\n\t\t// 解析时间\n\t\tdatetime, err := time.Parse(\"2006-01-02 15:04:05\", item.GmtShare)\n\t\tif err != nil {\n\t\t\tdatetime = time.Now() // 如果解析失败，使用当前时间\n\t\t}\n\n\t\t// 清理标题中的HTML标签\n\t\ttitle := cleanHTMLTags(item.Name)\n\n\t\t// 构建搜索结果，UniqueID包含网盘类型和页码避免重复\n\t\tresult := model.SearchResult{\n\t\t\tUniqueID:  fmt.Sprintf(\"%s-%s-%s-%d\", p.Name(), item.ID, fromType, pageNo),\n\t\t\tTitle:     title,\n\t\t\tContent:   fmt.Sprintf(\"分享者: %s | 文件数量: %d | 网盘类型: %s\", item.CreatorName, item.FileCount, fromType),\n\t\t\tLinks:     []model.Link{link},\n\t\t\tTags:      []string{item.From, item.Type},\n\t\t\tChannel:   \"\", // 插件搜索结果必须为空字符串\n\t\t\tDatetime:  datetime,\n\t\t}\n\n\t\tresults = append(results, result)\n\t\tprocessedCount++\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[%s] %s网盘第%d页处理完成: 原始%d项 -> 有效%d项 -> 跳过%d项\\n\", \n\t\t\tp.Name(), fromType, pageNo, len(apiResp.Data.List), processedCount, skippedCount)\n\t}\n\n\treturn results, nil\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *SDSOPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n\n// DecryptURL 解密SDSO网站返回的加密URL\n// 输入: Base64编码的密文\n// 输出: 解密后的原始网盘链接\nfunc DecryptURL(encryptedURL string) (string, error) {\n\tif encryptedURL == \"\" {\n\t\treturn \"\", fmt.Errorf(\"加密URL不能为空\")\n\t}\n\n\t// Base64解码\n\tciphertext, err := base64.StdEncoding.DecodeString(encryptedURL)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"Base64解码失败: %w\", err)\n\t}\n\n\t// 检查密文长度\n\tif len(ciphertext) == 0 {\n\t\treturn \"\", fmt.Errorf(\"密文长度为0\")\n\t}\n\n\t// 检查密文长度是否为16的倍数\n\tif len(ciphertext)%aes.BlockSize != 0 {\n\t\treturn \"\", fmt.Errorf(\"密文长度不是AES块大小的倍数\")\n\t}\n\n\t// 创建AES块加密器\n\tblock, err := aes.NewCipher([]byte(AESKey))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"创建AES加密器失败: %w\", err)\n\t}\n\n\t// 创建CBC模式解密器\n\tiv := []byte(AESIV)\n\tif len(iv) != aes.BlockSize {\n\t\treturn \"\", fmt.Errorf(\"IV长度不正确: 期望%d，实际%d\", aes.BlockSize, len(iv))\n\t}\n\n\tmode := cipher.NewCBCDecrypter(block, iv)\n\n\t// 解密\n\tplaintext := make([]byte, len(ciphertext))\n\tmode.CryptBlocks(plaintext, ciphertext)\n\n\t// 去除PKCS7填充\n\tunpaddedText, err := removePKCS7Padding(plaintext)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"去除填充失败: %w\", err)\n\t}\n\n\treturn string(unpaddedText), nil\n}\n\n// removePKCS7Padding 去除PKCS7填充\nfunc removePKCS7Padding(data []byte) ([]byte, error) {\n\tif len(data) == 0 {\n\t\treturn nil, fmt.Errorf(\"数据为空\")\n\t}\n\n\t// 获取填充长度\n\tpaddingLen := int(data[len(data)-1])\n\n\t// 验证填充长度\n\tif paddingLen == 0 || paddingLen > len(data) || paddingLen > aes.BlockSize {\n\t\treturn nil, fmt.Errorf(\"无效的填充长度: %d\", paddingLen)\n\t}\n\n\t// 验证填充字节\n\tfor i := len(data) - paddingLen; i < len(data); i++ {\n\t\tif data[i] != byte(paddingLen) {\n\t\t\treturn nil, fmt.Errorf(\"无效的填充字节\")\n\t\t}\n\t}\n\n\t// 返回去除填充后的数据\n\treturn data[:len(data)-paddingLen], nil\n}\n\n// cleanHTMLTags 清理HTML标签\nfunc cleanHTMLTags(text string) string {\n\t// 移除高亮标签 <span style=\"color: red;\">...</span>\n\tre := regexp.MustCompile(`<span[^>]*>(.*?)</span>`)\n\tcleaned := re.ReplaceAllString(text, \"$1\")\n\t\n\t// 移除其他可能的HTML标签\n\tre2 := regexp.MustCompile(`<[^>]*>`)\n\tcleaned = re2.ReplaceAllString(cleaned, \"\")\n\t\n\treturn strings.TrimSpace(cleaned)\n}\n\n// mapPanType 映射网盘类型\nfunc mapPanType(from string) string {\n\tswitch strings.ToLower(from) {\n\tcase \"quark\":\n\t\treturn \"quark\"\n\tcase \"xunlei\":\n\t\treturn \"xunlei\"\n\tcase \"aliyun\", \"ali\":\n\t\treturn \"aliyun\"  // PanSou内部仍使用aliyun标识\n\tcase \"baidu\":\n\t\treturn \"baidu\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n// isValidPanURL 验证是否为有效的网盘链接\nfunc isValidPanURL(url string) bool {\n\tif url == \"\" {\n\t\treturn false\n\t}\n\t\n\t// 检查是否包含网盘域名特征\n\tvalidDomains := []string{\n\t\t\"pan.quark.cn\",\n\t\t\"pan.xunlei.com\",\n\t\t\"aliyundrive.com\",\n\t\t\"alipan.com\",\n\t\t\"pan.baidu.com\",\n\t}\n\t\n\turlLower := strings.ToLower(url)\n\tfor _, domain := range validDomains {\n\t\tif strings.Contains(urlLower, domain) {\n\t\t\treturn true\n\t\t}\n\t}\n\t\n\treturn false\n}\n"
  },
  {
    "path": "plugin/shandian/html结构分析.md",
    "content": "# Shandian网站 (闪电优汐) 搜索结果HTML结构分析\n\n## 网站信息\n\n- **网站名称**: 闪电优汐\n- **主域名**: `1.95.79.193`\n- **备用域名**: `feimaouc.cloud:666`\n- **搜索URL格式**: `http://1.95.79.193/index.php/vod/search/wd/{关键词}.html`\n- **详情页URL格式**: `http://1.95.79.193/index.php/vod/detail/id/{ID}.html`\n- **主要特点**: 闪电系列网盘资源站，提供高清影视资源\n\n## 搜索结果页面结构\n\n搜索结果页面的主要内容位于`.module .module-list .module-items`元素内，每个搜索结果项包含在`.module-search-item`元素中。\n\n```html\n<div class=\"module\">\n    <div class=\"module-list\">\n        <div class=\"module-items\">\n            <div class=\"module-search-item\">\n                <!-- 单个搜索结果 -->\n            </div>\n            <div class=\"module-search-item\">\n                <!-- 单个搜索结果 -->\n            </div>\n        </div>\n    </div>\n</div>\n```\n\n### 单个搜索结果结构\n\n每个搜索结果包含以下主要元素：\n\n#### 1. 封面图片和详情页链接\n\n封面图片和播放按钮位于`.video-cover .module-item-cover .module-item-pic`元素中：\n\n```html\n<div class=\"video-cover\">\n    <div class=\"module-item-cover\">\n        <div class=\"module-item-pic\">\n            <a href=\"/index.php/vod/detail/id/185331.html\" title=\"立刻播放凡人修仙传真人版\">\n                <i class=\"icon-play\"></i>\n            </a>\n            <img class=\"lazy lazyload\" data-src=\"https://pic.youkupic.com/upload/vod/20250727-1/7136e1260ec9eed9d762742fd5191afa.jpg\" src=\"/template/DYXS2/static/picture/loading.png\" alt=\"凡人修仙传真人版\">\n        </div>\n    </div>\n</div>\n```\n\n#### 2. 详情页链接和ID\n\n详情页链接在多个位置出现，格式为`/index.php/vod/detail/id/{ID}.html`，其中`{ID}`是资源的唯一标识符（如`185331`）。\n\n#### 3. 标题和资源类型\n\n标题位于`.video-info-header`元素中：\n\n```html\n<div class=\"video-info-header\">\n    <a class=\"video-serial\" href=\"/index.php/vod/detail/id/185331.html\" title=\"凡人修仙传真人版\">更新至11集</a>\n    <h3><a href=\"/index.php/vod/detail/id/185331.html\" title=\"凡人修仙传真人版\">凡人修仙传真人版</a></h3>\n    <div class=\"video-info-aux\">\n        <a href=\"/index.php/vod/type/id/2.html\" title=\"闪电剧集\" class=\"tag-link\">\n            <span class=\"video-tag-icon\">\n                <i class=\"icon-cate-ds\"></i>\n                闪电剧集\n            </span>\n        </a>\n        <div class=\"tag-link\"><a href=\"/index.php/vod/search/year/2025.html\" target=\"_blank\">2025</a></div>\n        <div class=\"tag-link\"><a href=\"/index.php/vod/search/area/%E5%A4%A7%E9%99%86.html\" target=\"_blank\">大陆</a></div>\n    </div>\n</div>\n```\n\n- 资源类型/质量信息在`.video-serial`元素中（如\"更新至11集\"、\"更新至第153集\"等）\n- 主标题在`h3 a`标签中\n- 分类、年代、地区信息在`.video-info-aux`中\n\n#### 4. 导演和主演信息\n\n导演和主演信息位于`.video-info-main`元素中：\n\n```html\n<div class=\"video-info-main\">\n    <div class=\"video-info-items\">\n        <span class=\"video-info-itemtitle\">导演：</span>\n        <div class=\"video-info-item video-info-actor\">\n            <span class=\"slash\">/</span>\n            <a href=\"/index.php/vod/search/director/%E6%9D%A8%E9%98%B3.html\" target=\"_blank\">杨阳</a>\n            <span class=\"slash\">/</span>\n        </div>\n    </div>\n    <div class=\"video-info-items\">\n        <span class=\"video-info-itemtitle\">主演：</span>\n        <div class=\"video-info-item video-info-actor\">\n            <span class=\"slash\">/</span>\n            <a href=\"/index.php/vod/search/actor/%E6%9D%A8%E6%B4%8B.html\" target=\"_blank\">杨洋</a>\n            <span class=\"slash\">/</span>\n            <a href=\"/index.php/vod/search/actor/%E9%87%91%E6%99%A8.html\" target=\"_blank\">金晨</a>\n            <!-- 更多演员... -->\n        </div>\n    </div>\n    <div class=\"video-info-items\">\n        <span class=\"video-info-itemtitle\">剧情：</span>\n        <div class=\"video-info-item\">该剧改编自忘语的同名小说，讲述了普通的山村穷小子韩立（杨洋 饰）...</div>\n    </div>\n</div>\n```\n\n#### 5. 操作按钮\n\n操作按钮位于`.video-info-footer`元素中：\n\n```html\n<div class=\"video-info-footer\">\n    <a href=\"/index.php/vod/detail/id/185331.html\" class=\"btn-important btn-base\" title=\"立刻播放凡人修仙传真人版\">\n        <i class=\"icon-play\"></i><strong>查看详情</strong>\n    </a>\n    <a href=\"/index.php/vod/detail/id/185331.html\" class=\"btn-aux btn-aux-o btn-base\" title=\"下载凡人修仙传真人版\">\n        <i class=\"icon-download\"></i><strong>下载</strong>\n    </a>\n</div>\n```\n\n## 详情页面结构\n\n详情页面包含更完整的资源信息，特别是下载链接等详细信息。\n\n### 1. 页面标题和基本信息\n\n页面标题和基本信息位于`.box.view-heading`元素中，结构与搜索结果页面类似。\n\n### 2. 下载链接区域\n\n下载链接是该网站的核心功能，位于`#download-list`元素中：\n\n```html\n<div class=\"module\" id=\"download-list\" name=\"download-list\">\n    <div class=\"module-heading\">\n        <h2 class=\"module-title\" title=\"凡人修仙传真人版的影片下载列表\">影片下载</h2>\n        <div class=\"module-tab module-player-tab\">\n            <div class=\"module-tab-items\">\n                <div class=\"module-tab-content\">\n                    <div class=\"module-tab-item downtab-item selected\">\n                        <span data-dropdown-value=\"UC云盘\">UC云盘</span><small>1</small>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n    <div class=\"module-list module-player-list sort-list module-downlist selected\">\n        <div class=\"scroll-box-y\">\n            <div class=\"module-row-one\">\n                <div class=\"module-row-info\">\n                    <a class=\"module-row-text copy\" href=\"javascript:;\" \n                       data-clipboard-text=\"https://drive.uc.cn/s/acee11d5310a4?public=1\"\n                       title=\"复制《凡人修仙传真人版》第1集下载地址\">\n                        <i class=\"icon-video-file\"></i>\n                        <div class=\"module-row-title\">\n                            <h4>凡人修仙传真人版 - 第1集</h4>\n                            <p>https://drive.uc.cn/s/acee11d5310a4?public=1</p>\n                        </div>\n                    </a>\n                    <div class=\"module-row-shortcuts\">\n                        <a class=\"btn-pc btn-down\" href=\"https://drive.uc.cn/s/acee11d5310a4?public=1\"\n                           title=\"下载《凡人修仙传真人版》第1集\">\n                            <i class=\"icon-download\"></i><span>下载</span>\n                        </a>\n                        <a class=\"btn-copyurl copy\" href=\"javascript:;\"\n                           data-clipboard-text=\"https://drive.uc.cn/s/acee11d5310a4?public=1\"\n                           title=\"复制《凡人修仙传真人版》第1集下载地址\">\n                            <i class=\"icon-url\"></i><span class=\"btn-pc\">复制链接</span>\n                        </a>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n```\n\n#### 2.1 网盘类型\n\n网盘类型信息在`.module-tab-item`元素中，通过`data-dropdown-value`属性或文本内容获取（如\"UC云盘\"）。\n\n#### 2.2 下载链接\n\n下载链接有多个位置可以提取：\n- `data-clipboard-text`属性：`https://drive.uc.cn/s/acee11d5310a4?public=1`\n- `.module-row-title p`元素的文本内容\n- `.btn-down`元素的`href`属性\n\n### 3. 相关影片推荐\n\n相关影片推荐位于页面底部，结构类似搜索结果，位于`.module-lines-list .module-items`中。\n\n## 提取逻辑\n\n### 搜索结果页面提取逻辑\n\n1. 定位所有的`.module-search-item`元素\n2. 对于每个元素：\n   - 从`.module-item-pic a`的`href`属性提取详情页链接\n   - 从链接中提取资源ID（如`185331`）\n   - 从`h3 a`提取标题\n   - 从`.video-serial`提取资源类型/质量信息\n   - 从`.video-info-aux`提取分类、年代、地区信息\n   - 从`.video-info-main`提取导演、主演、剧情信息\n   - 从`img`的`data-src`属性提取封面图片URL\n\n### 详情页面提取逻辑\n\n1. 获取资源基本信息：\n   - 标题：`h1.page-title`的文本内容\n   - 又名：`h2.video-subtitle`的`title`属性\n   - 封面图片：`.module-item-pic img`的`data-src`属性\n\n2. 提取下载链接：\n   - 网盘类型：`.module-tab-item span[data-dropdown-value]`的属性值\n   - 下载链接：`data-clipboard-text`属性或`.module-row-title p`的文本内容\n   - 集数信息：`.module-row-title h4`的文本内容\n\n3. 提取分类和详细信息：\n   - 从`.video-info-aux`提取分类、年代、地区\n   - 从`.video-info-main`提取导演、主演、剧情等详细信息\n\n## 与 Labi 网站对比\n\n| 项目 | Shandian (闪电优汐) | Labi (免费的云盘分享平台) |\n|------|---------------------|---------------------------|\n| 域名 | 1.95.79.193 / feimaouc.cloud:666 | xiaocge.fun / duopan.fun |\n| 网站名 | 闪电优汐 | 免费的云盘分享平台 |\n| 分类名 | 闪电电影、闪电剧集、闪电动漫等 | 蜡笔电影、蜡笔剧集、蜡笔动漫等 |\n| 主要网盘 | UC云盘 | 夸克网盘 |\n| HTML结构 | **完全一致** | **完全一致** |\n\n## 注意事项\n\n1. **网盘链接格式**: 主要使用UC云盘，格式为`https://drive.uc.cn/s/{分享码}?public=1`，无需单独的密码\n2. **图片处理**: 封面图片使用了不同的CDN（pic.youkupic.com、img.ffzy888.com等）\n3. **资源分类**: \n   - 闪电电影、闪电剧集、闪电综艺、闪电动漫\n   - 闪电短剧等分类\n4. **延迟加载**: 图片使用了`lazy lazyload`类进行延迟加载\n5. **ID提取**: 从URL中提取ID的正则表达式：`/vod/detail/id/(\\d+)\\.html`\n6. **资源状态**: 通过`.video-serial`可以获取资源状态（如\"更新至11集\"、\"更新至第153集\"等）\n7. **模板系统**: 使用与Labi相同的DYXS2模板系统，HTML结构几乎完全一致"
  },
  {
    "path": "plugin/shandian/shandian.go",
    "content": "package shandian\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\n// 预编译的正则表达式\nvar (\n\t// 从详情页URL中提取ID的正则表达式\n\tdetailIDRegex = regexp.MustCompile(`/vod/detail/id/(\\d+)\\.html`)\n\t\n\t// UC云盘链接的正则表达式\n\tucLinkRegex = regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9a-zA-Z]+(\\?[^\"'\\s]*)?`)\n\t\n\t// 年份提取正则表达式\n\tyearRegex = regexp.MustCompile(`(\\d{4})`)\n\t\n\t// 缓存相关\n\tdetailCache = sync.Map{} // 缓存详情页解析结果\n\tlastCleanupTime = time.Now()\n\tcacheTTL = 1 * time.Hour // 优化为更短的缓存时间\n)\n\nconst (\n\t// 超时时间优化\n\tDefaultTimeout = 8 * time.Second\n\tDetailTimeout  = 6 * time.Second\n\t// 并发数优化\n\tMaxConcurrency = 20\n\t// HTTP连接池配置\n\tMaxIdleConns        = 200\n\tMaxIdleConnsPerHost = 50\n\tMaxConnsPerHost     = 100\n\tIdleConnTimeout     = 90 * time.Second\n)\n\n// 性能统计\nvar (\n\tsearchRequests     int64 = 0\n\tdetailPageRequests int64 = 0\n\tcacheHits          int64 = 0\n\tcacheMisses        int64 = 0\n\ttotalSearchTime    int64 = 0\n\ttotalDetailTime    int64 = 0\n)\n\n// 在init函数中注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewShandianPlugin())\n\t\n\t// 启动缓存清理goroutine\n\tgo startCacheCleaner()\n}\n\n// startCacheCleaner 启动一个定期清理缓存的goroutine\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(30 * time.Minute)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\t// 清空所有缓存\n\t\tdetailCache = sync.Map{}\n\t\tlastCleanupTime = time.Now()\n\t}\n}\n\n// ShandianAsyncPlugin Shandian异步插件\ntype ShandianAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t}\n\treturn &http.Client{Transport: transport, Timeout: DefaultTimeout}\n}\n\n// NewShandianPlugin 创建新的Shandian异步插件\nfunc NewShandianPlugin() *ShandianAsyncPlugin {\n\treturn &ShandianAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"shandian\", 2),\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *ShandianAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *ShandianAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实现具体的搜索逻辑\nfunc (p *ShandianAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 1. 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"http://1.95.79.193/index.php/vod/search/wd/%s.html\", url.QueryEscape(keyword))\n\t\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\t\n\t// 3. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 4. 设置完整的请求头（避免反爬虫）\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Referer\", \"http://1.95.79.193/\")\n\t\n\t// 5. 发送请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 6. 解析搜索结果页面\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 7. 提取搜索结果\n\tvar results []model.SearchResult\n\t\n\tdoc.Find(\".module-search-item\").Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchItem(s, keyword)\n\t\tif result.UniqueID != \"\" {\n\t\t\tresults = append(results, result)\n\t\t}\n\t})\n\t\n\t// 8. 异步获取详情页信息\n\tenhancedResults := p.enhanceWithDetails(client, results)\n\t\n\t// 9. 关键词过滤\n\treturn plugin.FilterResultsByKeyword(enhancedResults, keyword), nil\n}\n\n// parseSearchItem 解析单个搜索结果项\nfunc (p *ShandianAsyncPlugin) parseSearchItem(s *goquery.Selection, keyword string) model.SearchResult {\n\tresult := model.SearchResult{}\n\t\n\t// 提取详情页链接和ID\n\tdetailLink, exists := s.Find(\".module-item-pic a\").First().Attr(\"href\")\n\tif !exists {\n\t\treturn result\n\t}\n\t\n\t// 提取ID\n\tmatches := detailIDRegex.FindStringSubmatch(detailLink)\n\tif len(matches) < 2 {\n\t\treturn result\n\t}\n\t\n\titemID := matches[1]\n\tresult.UniqueID = fmt.Sprintf(\"%s-%s\", p.Name(), itemID)\n\t\n\t// 提取标题\n\ttitleElement := s.Find(\".video-info-header h3 a\")\n\tresult.Title = strings.TrimSpace(titleElement.Text())\n\t\n\t// 提取资源类型/质量\n\tqualityElement := s.Find(\".video-serial\")\n\tquality := strings.TrimSpace(qualityElement.Text())\n\t\n\t// 提取分类信息\n\tvar tags []string\n\ts.Find(\".video-info-aux .tag-link a\").Each(func(i int, tag *goquery.Selection) {\n\t\ttagText := strings.TrimSpace(tag.Text())\n\t\tif tagText != \"\" {\n\t\t\ttags = append(tags, tagText)\n\t\t}\n\t})\n\tresult.Tags = tags\n\t\n\t// 提取导演信息\n\tdirector := \"\"\n\ts.Find(\".video-info-items\").Each(func(i int, item *goquery.Selection) {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\tif strings.Contains(title, \"导演\") {\n\t\t\tdirector = strings.TrimSpace(item.Find(\".video-info-actor a\").Text())\n\t\t}\n\t})\n\t\n\t// 提取主演信息\n\tvar actors []string\n\ts.Find(\".video-info-items\").Each(func(i int, item *goquery.Selection) {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\tif strings.Contains(title, \"主演\") {\n\t\t\titem.Find(\".video-info-actor a\").Each(func(j int, actor *goquery.Selection) {\n\t\t\t\tactorName := strings.TrimSpace(actor.Text())\n\t\t\t\tif actorName != \"\" {\n\t\t\t\t\tactors = append(actors, actorName)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\t\n\t// 提取剧情简介\n\tplotElement := s.Find(\".video-info-items\").FilterFunction(func(i int, item *goquery.Selection) bool {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\treturn strings.Contains(title, \"剧情\")\n\t})\n\tplot := strings.TrimSpace(plotElement.Find(\".video-info-item\").Text())\n\t\n\t// 构建内容描述\n\tvar contentParts []string\n\tif quality != \"\" {\n\t\tcontentParts = append(contentParts, \"【\"+quality+\"】\")\n\t}\n\tif director != \"\" {\n\t\tcontentParts = append(contentParts, \"导演：\"+director)\n\t}\n\tif len(actors) > 0 {\n\t\tactorStr := strings.Join(actors[:min(3, len(actors))], \"、\") // 只显示前3个演员\n\t\tif len(actors) > 3 {\n\t\t\tactorStr += \"等\"\n\t\t}\n\t\tcontentParts = append(contentParts, \"主演：\"+actorStr)\n\t}\n\tif plot != \"\" {\n\t\tcontentParts = append(contentParts, plot)\n\t}\n\t\n\tresult.Content = strings.Join(contentParts, \"\\n\")\n\tresult.Channel = \"\" // 插件搜索结果不设置频道名，只有Telegram频道结果才设置\n\tresult.Datetime = time.Time{} // 使用零值而不是nil，参考jikepan插件标准\n\t\n\treturn result\n}\n\n// enhanceWithDetails 异步获取详情页信息以获取下载链接\nfunc (p *ShandianAsyncPlugin) enhanceWithDetails(client *http.Client, results []model.SearchResult) []model.SearchResult {\n\tvar enhancedResults []model.SearchResult\n\tvar mu sync.Mutex\n\tvar wg sync.WaitGroup\n\t\n\t// 限制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\n\tfor _, result := range results {\n\t\twg.Add(1)\n\t\tgo func(r model.SearchResult) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 从UniqueID提取ID\n\t\t\tparts := strings.Split(r.UniqueID, \"-\")\n\t\t\tif len(parts) < 2 {\n\t\t\t\tmu.Lock()\n\t\t\t\tenhancedResults = append(enhancedResults, r)\n\t\t\t\tmu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\titemID := parts[1]\n\t\t\t\n\t\t\t// 检查缓存\n\t\t\tif cached, ok := detailCache.Load(itemID); ok {\n\t\t\t\tif cachedResult, ok := cached.(model.SearchResult); ok {\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\tenhancedResults = append(enhancedResults, cachedResult)\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 获取详情页链接\n\t\t\tdetailLinks := p.fetchDetailLinks(client, itemID)\n\t\t\tr.Links = detailLinks\n\t\t\t\n\t\t\t// 缓存结果\n\t\t\tdetailCache.Store(itemID, r)\n\t\t\t\n\t\t\tmu.Lock()\n\t\t\tenhancedResults = append(enhancedResults, r)\n\t\t\tmu.Unlock()\n\t\t}(result)\n\t}\n\t\n\twg.Wait()\n\treturn enhancedResults\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *ShandianAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n\n// fetchDetailLinks 获取详情页的下载链接\nfunc (p *ShandianAsyncPlugin) fetchDetailLinks(client *http.Client, itemID string) []model.Link {\n\tdetailURL := fmt.Sprintf(\"http://1.95.79.193/index.php/vod/detail/id/%s.html\", itemID)\n\t\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DetailTimeout)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"http://1.95.79.193/\")\n\t\n\t// 发送请求（带重试）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil\n\t}\n\t\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\tvar links []model.Link\n\t\n\t// 查找下载链接区域\n\tdoc.Find(\"#download-list .module-row-one\").Each(func(i int, s *goquery.Selection) {\n\t\t// 从data-clipboard-text属性提取链接\n\t\tif linkURL, exists := s.Find(\"[data-clipboard-text]\").Attr(\"data-clipboard-text\"); exists {\n\t\t\t// 过滤掉无效链接\n\t\t\tif p.isValidNetworkDriveURL(linkURL) && ucLinkRegex.MatchString(linkURL) {\n\t\t\t\tlink := model.Link{\n\t\t\t\t\tType:     \"uc\",\n\t\t\t\t\tURL:      linkURL,\n\t\t\t\t\tPassword: \"\", // UC云盘通常不需要密码\n\t\t\t\t}\n\t\t\t\tlinks = append(links, link)\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 也检查直接的href属性\n\t\ts.Find(\"a[href]\").Each(func(j int, a *goquery.Selection) {\n\t\t\tif linkURL, exists := a.Attr(\"href\"); exists {\n\t\t\t\t// 过滤掉无效链接\n\t\t\t\tif p.isValidNetworkDriveURL(linkURL) && ucLinkRegex.MatchString(linkURL) {\n\t\t\t\t\t// 避免重复添加\n\t\t\t\t\tisDuplicate := false\n\t\t\t\t\tfor _, existingLink := range links {\n\t\t\t\t\t\tif existingLink.URL == linkURL {\n\t\t\t\t\t\t\tisDuplicate = true\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\tif !isDuplicate {\n\t\t\t\t\t\tlink := model.Link{\n\t\t\t\t\t\t\tType:     \"uc\",\n\t\t\t\t\t\t\tURL:      linkURL,\n\t\t\t\t\t\t\tPassword: \"\",\n\t\t\t\t\t\t}\n\t\t\t\t\t\tlinks = append(links, link)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t})\n\t\n\treturn links\n}\n\n// isValidNetworkDriveURL 检查URL是否为有效的网盘链接\nfunc (p *ShandianAsyncPlugin) isValidNetworkDriveURL(url string) bool {\n\t// 过滤掉明显无效的链接\n\tif strings.Contains(url, \"javascript:\") || \n\t   strings.Contains(url, \"#\") ||\n\t   url == \"\" ||\n\t   !strings.HasPrefix(url, \"http\") {\n\t\treturn false\n\t}\n\t\n\t// 对于shandian插件，只检查UC网盘格式\n\treturn ucLinkRegex.MatchString(url)\n}\n\n// min 返回两个整数中的较小值\nfunc min(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}"
  },
  {
    "path": "plugin/sousou/json结构分析.md",
    "content": "# Sousou API 数据结构分析\n\n## 基本信息\n- **数据源类型**: JSON API (GET 请求)\n- **API URL格式**: `https://sousou.pro/api.php?action=search&q={关键词}&page={页码}&per_size={每页数量}&type={网盘类型}`\n- **数据特点**: 网盘资源聚合搜索，支持多种网盘类型过滤\n\n## API请求参数\n\n| 参数名 | 类型 | 必填 | 说明 | 示例值 |\n|--------|------|------|------|--------|\n| `action` | string | 是 | 操作类型，固定为 `search` | search |\n| `q` | string | 是 | 搜索关键词 | 遮天 |\n| `page` | int | 否 | 页码，从1开始 | 1 |\n| `per_size` | int | 否 | 每页返回数量 | 10 |\n| `type` | string | 否 | 网盘类型过滤，为空表示全部 | QUARK, BDY, ALY, XUNLEI, UC, 115 |\n\n### 网盘类型参数说明\n- `QUARK` - 夸克网盘\n- `BDY` - 百度网盘\n- `ALY` - 阿里云盘\n- `XUNLEI` - 迅雷网盘\n- `UC` - UC网盘\n- `115` - 115网盘\n- 留空 - 搜索所有网盘类型\n\n## API响应结构\n\n### 顶层结构\n```json\n{\n    \"code\": 200,              // 状态码：200表示成功\n    \"msg\": \"请求成功\",        // 响应消息\n    \"data\": {\n        \"total\": 200,         // 总记录数\n        \"per_size\": 10,       // 每页数量\n        \"took\": 62,           // 搜索耗时（毫秒）\n        \"list\": []            // 数据列表数组\n    }\n}\n```\n\n### `data.list`数组中的数据项结构\n```json\n{\n    \"disk_id\": \"bd8623b72cbb\",                    // 网盘分享ID\n    \"disk_name\": \"美漫之黑手遮天-西风啸月.txt\",     // 资源标题/文件名\n    \"disk_pass\": \"\",                              // 提取码（可能为空）\n    \"disk_type\": \"QUARK\",                         // 网盘类型标识\n    \"files\": \"file:美漫之黑手遮天-西风啸月.txt\",    // 文件列表描述\n    \"doc_id\": \"cmhfmcz0l2emoae7mf7amvbsi\",        // 文档ID\n    \"share_user\": \"安心*海豹\",                     // 分享用户（脱敏）\n    \"share_user_id\": \"\",                          // 分享用户ID（可能为空）\n    \"shared_time\": \"2025-10-27 21:38:59\",         // 分享时间\n    \"rel_movie\": \"\",                              // 相关电影（可能为空）\n    \"is_mine\": true,                              // 是否我的分享\n    \"tags\": null,                                 // 标签数组（可能为null或字符串数组）\n    \"link\": \"https://pan.quark.cn/s/bd8623b72cbb\", // 分享链接\n    \"enabled\": true,                              // 是否启用\n    \"weight\": 1,                                  // 权重\n    \"status\": 0                                   // 状态\n}\n```\n\n### 字段说明\n\n#### 核心字段\n- **disk_id**: 网盘分享的唯一标识符，用于去重和构建 UniqueID\n- **disk_name**: 资源标题，通常是文件名或文件夹名\n- **disk_pass**: 提取码/访问密码，可能为空字符串\n- **disk_type**: 网盘类型标识符（API特定格式）\n- **link**: 完整的分享链接URL\n\n#### 扩展信息字段\n- **files**: 文件列表描述，格式多样：\n  - 单文件: `\"file:文件名.txt\"`\n  - 多文件: `\"file:1.mp4\\nfile:2.mp4\\nfolder:文件夹\"`\n  - 包含文件和文件夹的层级结构\n- **tags**: 分类标签，可能为 `null` 或字符串数组，如 `[\"短剧\", \"电视剧\", \"国产剧\"]`\n- **share_user**: 分享用户昵称（已脱敏处理）\n- **shared_time**: 分享时间，格式为 `YYYY-MM-DD HH:MM:SS`\n\n#### 元数据字段\n- **doc_id**: 系统内部文档ID\n- **share_user_id**: 分享用户的系统ID（通常为空）\n- **rel_movie**: 关联的电影信息（通常为空）\n- **is_mine**: 布尔值，标识是否为当前用户的分享\n- **enabled**: 布尔值，标识资源是否启用\n- **weight**: 整数，资源权重\n- **status**: 整数，资源状态码\n\n## 插件所需字段映射\n\n| 源字段 | 目标字段 | 说明 |\n|--------|----------|------|\n| `disk_id` | `UniqueID` | 格式: `sousou-{disk_id}` |\n| `disk_name` | `Title` | 资源标题 |\n| `files` | `Content` | 文件列表描述 |\n| `shared_time` | `Datetime` | 解析为 `time.Time` 格式 |\n| `tags` | `Tags` | 标签数组（需处理 null 情况） |\n| `link` + `disk_pass` + `disk_type` | `Links` | 转换为 Link 数组 |\n| `\"\"` | `Channel` | 插件搜索结果 Channel 为空字符串 |\n\n## 网盘类型映射\n\n### API标识符 -> 系统类型\n\n| API标识 (`disk_type`) | 系统类型 (`Link.Type`) | 域名特征 |\n|----------------------|---------------------|----------|\n| `QUARK` | `quark` | `pan.quark.cn` |\n| `BDY` | `baidu` | `pan.baidu.com` |\n| `ALY` | `aliyun` | `alipan.com`, `aliyundrive.com` |\n| `XUNLEI` | `xunlei` | `pan.xunlei.com` |\n| `UC` | `uc` | `drive.uc.cn` |\n| `115` | `115` | `115.com`, `115cdn.com` |\n| `TIANYI` | `tianyi` | `cloud.189.cn` |\n| `CAIYUN` | `mobile` | `caiyun.139.com` |\n| `123PAN` | `123` | `123pan.com`, `123912.com` |\n| `PIKPAK` | `pikpak` | `mypikpak.com` |\n\n## 数据特征分析\n\n### 1. 时间格式\n- **原始格式**: `\"2025-10-27 21:38:59\"`\n- **解析格式**: `\"2006-01-02 15:04:05\"` (Go time.Parse)\n\n### 2. 标签处理\n```json\n// 情况1: tags 为 null\n\"tags\": null\n\n// 情况2: tags 为字符串数组\n\"tags\": [\"短剧\", \"电视剧\", \"国产剧\"]\n```\n\n### 3. 文件列表格式\n```\n单文件:\n\"file:美漫之黑手遮天-西风啸月.txt\"\n\n多文件:\n\".mp4\nfile:29.mp4\nfile:78.mp4\nfile:59.mp4\nfolder:14.弹指遮天\"\n\n复杂结构:\n\"58.mp4\nfile:90.mp4\nfile:40.mp4\nfolder:10.遮天武神\"\n```\n\n## 插件开发指导\n\n### SearchResult构建示例\n```go\nresult := model.SearchResult{\n    UniqueID: fmt.Sprintf(\"sousou-%s\", item.DiskID),\n    Title:    item.DiskName,\n    Content:  item.Files,\n    Datetime: parseTime(item.SharedTime),\n    Tags:     processTags(item.Tags),\n    Links:    []model.Link{\n        {\n            Type:     convertDiskType(item.DiskType),\n            URL:      item.Link,\n            Password: item.DiskPass,\n        },\n    },\n    Channel:  \"\", // 插件搜索结果Channel为空\n}\n```\n\n### 网盘类型转换函数\n```go\nfunc convertDiskType(diskType string) string {\n    switch diskType {\n    case \"BDY\":\n        return \"baidu\"\n    case \"ALY\":\n        return \"aliyun\"\n    case \"QUARK\":\n        return \"quark\"\n    case \"TIANYI\":\n        return \"tianyi\"\n    case \"UC\":\n        return \"uc\"\n    case \"CAIYUN\":\n        return \"mobile\"\n    case \"115\":\n        return \"115\"\n    case \"XUNLEI\":\n        return \"xunlei\"\n    case \"123PAN\":\n        return \"123\"\n    case \"PIKPAK\":\n        return \"pikpak\"\n    default:\n        return \"others\"\n    }\n}\n```\n\n### 时间解析函数\n```go\nfunc parseTime(timeStr string) time.Time {\n    if timeStr == \"\" {\n        return time.Time{} // 零值\n    }\n    \n    // 格式：2025-10-27 21:38:59\n    parsedTime, err := time.Parse(\"2006-01-02 15:04:05\", timeStr)\n    if err != nil {\n        return time.Time{} // 解析失败返回零值\n    }\n    \n    return parsedTime\n}\n```\n\n### 标签处理函数\n```go\nfunc processTags(tags interface{}) []string {\n    if tags == nil {\n        return nil\n    }\n    \n    // 类型断言为字符串数组\n    if tagArray, ok := tags.([]interface{}); ok {\n        result := make([]string, 0, len(tagArray))\n        for _, tag := range tagArray {\n            if tagStr, ok := tag.(string); ok {\n                result = append(result, tagStr)\n            }\n        }\n        return result\n    }\n    \n    return nil\n}\n```\n\n## 多页请求策略\n\n### 并发分页获取\n```go\nfunc (p *SousouAsyncPlugin) searchAPI(client *http.Client, keyword string) ([]SousouItem, error) {\n    maxPages := 3 // 获取前3页\n    perSize := 30 // 每页30条\n    \n    // 创建结果通道\n    resultChan := make(chan []SousouItem, maxPages)\n    errChan := make(chan error, maxPages)\n    \n    var wg sync.WaitGroup\n    \n    // 并发请求每一页\n    for page := 1; page <= maxPages; page++ {\n        wg.Add(1)\n        \n        go func(pageNum int) {\n            defer wg.Done()\n            \n            url := fmt.Sprintf(\"https://sousou.pro/api.php?action=search&q=%s&page=%d&per_size=%d&type=\",\n                url.QueryEscape(keyword), pageNum, perSize)\n            \n            items, err := p.fetchPage(client, url)\n            if err != nil {\n                errChan <- err\n                return\n            }\n            \n            resultChan <- items\n        }(page)\n    }\n    \n    // 等待所有请求完成\n    go func() {\n        wg.Wait()\n        close(resultChan)\n        close(errChan)\n    }()\n    \n    // 收集结果\n    var allItems []SousouItem\n    for items := range resultChan {\n        allItems = append(allItems, items...)\n    }\n    \n    return allItems, nil\n}\n```\n\n## 与其他插件的差异\n\n| 特性 | sousou | hunhepan | 说明 |\n|------|--------|----------|------|\n| **请求方式** | GET | POST | sousou更简单 |\n| **API数量** | 1个 | 4个 | sousou单一API |\n| **链接格式** | 标准 | 标准 | 都是简单的URL |\n| **时间字段** | 有 | 有 | 格式相同 |\n| **标签处理** | 可能为null | 可能为null | 需要类型检查 |\n| **去重依据** | disk_id | disk_id | 相同逻辑 |\n\n## 注意事项\n\n1. **时间解析**: 时间格式为 `YYYY-MM-DD HH:MM:SS`，需要正确的解析格式\n2. **标签类型**: tags 可能为 `null`，需要进行类型检查和处理\n3. **链接验证**: 确保 link 字段非空再创建 Link 对象\n4. **网盘类型**: disk_type 使用大写标识符，需要转换为系统标准类型\n5. **分页策略**: 可以并发请求多页提高效率\n6. **去重处理**: 使用 disk_id 作为唯一标识进行去重\n7. **空字段处理**: disk_pass、share_user_id、rel_movie 等字段可能为空\n\n## 开发建议\n\n- **优先级设置**: 建议设置为等级3（普通质量数据源）\n- **分页数量**: 建议获取前3页数据，平衡性能和数据量\n- **每页大小**: 建议设置为30条，与其他插件保持一致\n- **超时控制**: 使用 context 控制请求超时（30秒）\n- **错误处理**: 对每个可能失败的解析步骤都要有错误处理\n- **重试机制**: 实现简单的重试机制，提高稳定性\n- **缓存策略**: 设置合理的缓存TTL（建议2小时）\n\n## API响应示例\n\n### 成功响应\n```json\n{\n    \"code\": 200,\n    \"msg\": \"请求成功\",\n    \"data\": {\n        \"total\": 200,\n        \"per_size\": 10,\n        \"took\": 62,\n        \"list\": [\n            {\n                \"disk_id\": \"bd8623b72cbb\",\n                \"disk_name\": \"美漫之黑手遮天-西风啸月.txt\",\n                \"disk_pass\": \"\",\n                \"disk_type\": \"QUARK\",\n                \"files\": \"file:美漫之黑手遮天-西风啸月.txt\",\n                \"doc_id\": \"cmhfmcz0l2emoae7mf7amvbsi\",\n                \"share_user\": \"安心*海豹\",\n                \"share_user_id\": \"\",\n                \"shared_time\": \"2025-10-27 21:38:59\",\n                \"rel_movie\": \"\",\n                \"is_mine\": true,\n                \"tags\": null,\n                \"link\": \"https://pan.quark.cn/s/bd8623b72cbb\",\n                \"enabled\": true,\n                \"weight\": 1,\n                \"status\": 0\n            }\n        ]\n    }\n}\n```\n\n### 错误响应（推测）\n```json\n{\n    \"code\": 400,\n    \"msg\": \"请求失败: 参数错误\",\n    \"data\": null\n}\n```\n\n"
  },
  {
    "path": "plugin/sousou/sousou.go",
    "content": "package sousou\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sync\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\nvar debugEnabled = false\n\nfunc debugLog(format string, args ...interface{}) {\n\tif debugEnabled {\n\t\tlog.Printf(\"[sousou DEBUG] \"+format, args...)\n\t}\n}\n\n// 在init函数中注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewSousouAsyncPlugin())\n}\n\nconst (\n\t// API端点\n\tSousouAPI = \"https://sousou.pro/api.php\"\n\n\t// 默认参数\n\tDefaultPerSize = 30\n\tDefaultMaxPages = 3\n)\n\n// 支持的网盘类型列表\nvar supportedDiskTypes = []string{\n\t\"QUARK\",   // 夸克网盘\n\t\"BDY\",     // 百度网盘\n\t\"ALY\",     // 阿里云盘\n\t\"XUNLEI\",  // 迅雷网盘\n\t\"UC\",      // UC网盘\n\t\"115\",     // 115网盘\n}\n\n// SousouAsyncPlugin Sousou搜索异步插件\ntype SousouAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\n// NewSousouAsyncPlugin 创建新的Sousou搜索异步插件\nfunc NewSousouAsyncPlugin() *SousouAsyncPlugin {\n\treturn &SousouAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"sousou\", 3),\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *SousouAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *SousouAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)\n}\n\n// doSearch 实际的搜索实现 - 并发搜索多种网盘类型\nfunc (p *SousouAsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tdebugLog(\"开始搜索，关键词: %s\", keyword)\n\n\t// 创建结果通道和错误通道\n\tresultChan := make(chan []SousouItem, len(supportedDiskTypes))\n\terrChan := make(chan error, len(supportedDiskTypes))\n\n\t// 创建等待组\n\tvar wg sync.WaitGroup\n\n\t// 并发搜索每种网盘类型\n\tfor _, diskType := range supportedDiskTypes {\n\t\twg.Add(1)\n\n\t\tgo func(dt string) {\n\t\t\tdefer wg.Done()\n\t\t\tdebugLog(\"开始搜索网盘类型: %s\", dt)\n\t\t\t\n\t\t\titems, err := p.searchByType(client, keyword, dt)\n\t\t\tif err != nil {\n\t\t\t\tdebugLog(\"%s 网盘搜索错误: %v\", dt, err)\n\t\t\t\terrChan <- fmt.Errorf(\"%s API error: %w\", dt, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\tdebugLog(\"%s 网盘返回 %d 条结果\", dt, len(items))\n\t\t\tresultChan <- items\n\t\t}(diskType)\n\t}\n\n\t// 启动一个goroutine等待所有请求完成并关闭通道\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultChan)\n\t\tclose(errChan)\n\t}()\n\n\t// 收集结果\n\tvar allItems []SousouItem\n\tvar errors []error\n\n\t// 从通道读取结果\n\tfor items := range resultChan {\n\t\tallItems = append(allItems, items...)\n\t}\n\n\t// 收集错误（不阻止处理）\n\tfor err := range errChan {\n\t\terrors = append(errors, err)\n\t}\n\n\tdebugLog(\"收集到 %d 条原始结果，%d 个错误\", len(allItems), len(errors))\n\n\t// 如果没有获取到任何结果且有错误，则返回第一个错误\n\tif len(allItems) == 0 && len(errors) > 0 {\n\t\treturn nil, errors[0]\n\t}\n\n\t// 去重处理\n\tuniqueItems := p.deduplicateItems(allItems)\n\tdebugLog(\"去重后剩余 %d 条结果\", len(uniqueItems))\n\n\t// 转换为标准格式\n\tresults := p.convertResults(uniqueItems)\n\tdebugLog(\"转换后得到 %d 条最终结果\", len(results))\n\n\t// 关键词过滤\n\tfilteredResults := plugin.FilterResultsByKeyword(results, keyword)\n\tdebugLog(\"过滤后剩余 %d 条结果\", len(filteredResults))\n\n\treturn filteredResults, nil\n}\n\n// searchByType 搜索指定网盘类型\nfunc (p *SousouAsyncPlugin) searchByType(client *http.Client, keyword string, diskType string) ([]SousouItem, error) {\n\t// 创建结果通道和错误通道（用于多页并发）\n\tresultChan := make(chan []SousouItem, DefaultMaxPages)\n\terrChan := make(chan error, DefaultMaxPages)\n\n\t// 创建等待组，用于等待所有页面请求完成\n\tvar wg sync.WaitGroup\n\n\t// 并发请求每一页\n\tfor page := 1; page <= DefaultMaxPages; page++ {\n\t\twg.Add(1)\n\n\t\tgo func(pageNum int) {\n\t\t\tdefer wg.Done()\n\n\t\t\t// 构建请求URL\n\t\t\tapiURL := fmt.Sprintf(\"%s?action=search&q=%s&page=%d&per_size=%d&type=%s\",\n\t\t\t\tSousouAPI,\n\t\t\t\turl.QueryEscape(keyword),\n\t\t\t\tpageNum,\n\t\t\t\tDefaultPerSize,\n\t\t\t\tdiskType,\n\t\t\t)\n\n\t\t\tdebugLog(\"请求URL (page %d, type %s): %s\", pageNum, diskType, apiURL)\n\n\t\t\t// 创建带超时的上下文\n\t\t\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\t\tdefer cancel()\n\n\t\t\t// 创建请求\n\t\t\treq, err := http.NewRequestWithContext(ctx, \"GET\", apiURL, nil)\n\t\t\tif err != nil {\n\t\t\t\tdebugLog(\"创建请求失败 (page %d, type %s): %v\", pageNum, diskType, err)\n\t\t\t\terrChan <- fmt.Errorf(\"create request failed (page %d, type %s): %w\", pageNum, diskType, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 设置请求头\n\t\t\treq.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\")\n\t\t\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\t\t\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\t\t\treq.Header.Set(\"Connection\", \"keep-alive\")\n\t\t\treq.Header.Set(\"Referer\", \"https://sousou.pro/\")\n\n\t\t\t// 发送请求\n\t\t\tresp, err := client.Do(req)\n\t\t\tif err != nil {\n\t\t\t\tdebugLog(\"请求失败 (page %d, type %s): %v\", pageNum, diskType, err)\n\t\t\t\terrChan <- fmt.Errorf(\"request failed (page %d, type %s): %w\", pageNum, diskType, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer resp.Body.Close()\n\n\t\t\tdebugLog(\"收到响应 (page %d, type %s), 状态码: %d\", pageNum, diskType, resp.StatusCode)\n\n\t\t\t// 检查状态码\n\t\t\tif resp.StatusCode != 200 {\n\t\t\t\tdebugLog(\"HTTP错误 (page %d, type %s): %d\", pageNum, diskType, resp.StatusCode)\n\t\t\t\terrChan <- fmt.Errorf(\"HTTP error (page %d, type %s): %d\", pageNum, diskType, resp.StatusCode)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 读取响应体\n\t\t\trespBody, err := io.ReadAll(resp.Body)\n\t\t\tif err != nil {\n\t\t\t\tdebugLog(\"读取响应失败 (page %d, type %s): %v\", pageNum, diskType, err)\n\t\t\t\terrChan <- fmt.Errorf(\"read response body failed (page %d, type %s): %w\", pageNum, diskType, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tdebugLog(\"响应内容 (page %d, type %s, 前500字符): %s\", pageNum, diskType, string(respBody[:min(500, len(respBody))]))\n\n\t\t\t// 解析响应\n\t\t\tvar apiResp SousouResponse\n\t\t\tif err := json.Unmarshal(respBody, &apiResp); err != nil {\n\t\t\t\tdebugLog(\"JSON解析失败 (page %d, type %s): %v\", pageNum, diskType, err)\n\t\t\t\terrChan <- fmt.Errorf(\"decode response failed (page %d, type %s): %w\", pageNum, diskType, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 检查响应状态\n\t\t\tif apiResp.Code != 200 {\n\t\t\t\tdebugLog(\"API返回错误 (page %d, type %s): code=%d, msg=%s\", pageNum, diskType, apiResp.Code, apiResp.Msg)\n\t\t\t\terrChan <- fmt.Errorf(\"API returned error (page %d, type %s): %s\", pageNum, diskType, apiResp.Msg)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tdebugLog(\"成功获取第 %d 页数据 (type %s)，共 %d 条结果\", pageNum, diskType, len(apiResp.Data.List))\n\n\t\t\t// 将结果发送到通道\n\t\t\tresultChan <- apiResp.Data.List\n\t\t}(page)\n\t}\n\n\t// 启动一个goroutine等待所有页面请求完成并关闭通道\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultChan)\n\t\tclose(errChan)\n\t}()\n\n\t// 收集结果\n\tvar allItems []SousouItem\n\tfor items := range resultChan {\n\t\tallItems = append(allItems, items...)\n\t}\n\n\t// 检查是否有错误\n\tvar errors []error\n\tfor err := range errChan {\n\t\terrors = append(errors, err)\n\t}\n\n\t// 如果没有获取到任何结果且有错误，则返回第一个错误\n\tif len(allItems) == 0 && len(errors) > 0 {\n\t\treturn nil, errors[0]\n\t}\n\n\treturn allItems, nil\n}\n\nfunc min(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}\n\n// deduplicateItems 去重处理\nfunc (p *SousouAsyncPlugin) deduplicateItems(items []SousouItem) []SousouItem {\n\t// 使用map进行去重，以disk_id为键\n\tuniqueMap := make(map[string]SousouItem)\n\n\tfor _, item := range items {\n\t\t// 创建唯一键：优先使用DiskID，如果为空则使用Link\n\t\tvar key string\n\t\tif item.DiskID != \"\" {\n\t\t\tkey = item.DiskID\n\t\t} else if item.Link != \"\" {\n\t\t\tkey = item.Link\n\t\t} else {\n\t\t\t// 如果DiskID和Link都为空，则使用DiskName+DiskType作为键\n\t\t\tkey = item.DiskName + \"|\" + item.DiskType\n\t\t}\n\n\t\t// 如果已存在，保留信息更丰富的那个\n\t\tif existing, exists := uniqueMap[key]; exists {\n\t\t\t// 比较文件列表长度和其他信息\n\t\t\texistingScore := len(existing.Files)\n\t\t\tnewScore := len(item.Files)\n\n\t\t\t// 如果新项有密码而现有项没有，增加新项分数\n\t\t\tif existing.DiskPass == \"\" && item.DiskPass != \"\" {\n\t\t\t\tnewScore += 5\n\t\t\t}\n\n\t\t\t// 如果新项有时间而现有项没有，增加新项分数\n\t\t\tif existing.SharedTime == \"\" && item.SharedTime != \"\" {\n\t\t\t\tnewScore += 3\n\t\t\t}\n\n\t\t\t// 如果新项有标签而现有项没有，增加新项分数\n\t\t\tif existing.Tags == nil && item.Tags != nil {\n\t\t\t\tnewScore += 2\n\t\t\t}\n\n\t\t\tif newScore > existingScore {\n\t\t\t\tuniqueMap[key] = item\n\t\t\t}\n\t\t} else {\n\t\t\tuniqueMap[key] = item\n\t\t}\n\t}\n\n\t// 将map转回切片\n\tresult := make([]SousouItem, 0, len(uniqueMap))\n\tfor _, item := range uniqueMap {\n\t\tresult = append(result, item)\n\t}\n\n\treturn result\n}\n\n// convertResults 将API响应转换为标准SearchResult格式\nfunc (p *SousouAsyncPlugin) convertResults(items []SousouItem) []model.SearchResult {\n\tresults := make([]model.SearchResult, 0, len(items))\n\n\tfor i, item := range items {\n\t\t// 跳过无效链接的结果\n\t\tif item.Link == \"\" {\n\t\t\tdebugLog(\"跳过无链接的结果: %s\", item.DiskName)\n\t\t\tcontinue\n\t\t}\n\n\t\t// 创建链接\n\t\tlink := model.Link{\n\t\t\tURL:      item.Link,\n\t\t\tType:     p.convertDiskType(item.DiskType),\n\t\t\tPassword: item.DiskPass,\n\t\t}\n\n\t\t// 创建唯一ID\n\t\tuniqueID := fmt.Sprintf(\"sousou-%s\", item.DiskID)\n\t\tif item.DiskID == \"\" {\n\t\t\t// 使用索引作为后备\n\t\t\tuniqueID = fmt.Sprintf(\"sousou-%d-%d\", time.Now().Unix(), i)\n\t\t}\n\n\t\t// 解析时间\n\t\tvar datetime time.Time\n\t\tif item.SharedTime != \"\" {\n\t\t\t// 尝试解析时间，格式：2025-10-27 21:38:59\n\t\t\tparsedTime, err := time.Parse(\"2006-01-02 15:04:05\", item.SharedTime)\n\t\t\tif err == nil {\n\t\t\t\tdatetime = parsedTime\n\t\t\t} else {\n\t\t\t\tdebugLog(\"时间解析失败: %s, err: %v\", item.SharedTime, err)\n\t\t\t}\n\t\t}\n\n\t\t// 如果时间解析失败，使用零值\n\t\tif datetime.IsZero() {\n\t\t\tdatetime = time.Time{}\n\t\t}\n\n\t\t// 处理标签\n\t\ttags := p.processTags(item.Tags)\n\n\t\t// 创建搜索结果\n\t\tresult := model.SearchResult{\n\t\t\tUniqueID: uniqueID,\n\t\t\tTitle:    item.DiskName,\n\t\t\tContent:  item.Files,\n\t\t\tDatetime: datetime,\n\t\t\tTags:     tags,\n\t\t\tLinks:    []model.Link{link},\n\t\t\tChannel:  \"\", // 插件搜索结果必须为空字符串\n\t\t}\n\n\t\tdebugLog(\"转换结果: ID=%s, Title=%s, Type=%s, Link=%s\", uniqueID, result.Title, link.Type, link.URL)\n\t\tresults = append(results, result)\n\t}\n\n\treturn results\n}\n\n// convertDiskType 将API的网盘类型转换为标准链接类型\nfunc (p *SousouAsyncPlugin) convertDiskType(diskType string) string {\n\tswitch diskType {\n\tcase \"BDY\":\n\t\treturn \"baidu\"\n\tcase \"ALY\":\n\t\treturn \"aliyun\"\n\tcase \"QUARK\":\n\t\treturn \"quark\"\n\tcase \"TIANYI\":\n\t\treturn \"tianyi\"\n\tcase \"UC\":\n\t\treturn \"uc\"\n\tcase \"CAIYUN\":\n\t\treturn \"mobile\"\n\tcase \"115\":\n\t\treturn \"115\"\n\tcase \"XUNLEI\":\n\t\treturn \"xunlei\"\n\tcase \"123PAN\":\n\t\treturn \"123\"\n\tcase \"PIKPAK\":\n\t\treturn \"pikpak\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n// processTags 处理标签字段（可能为null）\nfunc (p *SousouAsyncPlugin) processTags(tags interface{}) []string {\n\tif tags == nil {\n\t\treturn nil\n\t}\n\n\t// 类型断言为字符串数组\n\tif tagArray, ok := tags.([]interface{}); ok {\n\t\tresult := make([]string, 0, len(tagArray))\n\t\tfor _, tag := range tagArray {\n\t\t\tif tagStr, ok := tag.(string); ok && tagStr != \"\" {\n\t\t\t\tresult = append(result, tagStr)\n\t\t\t}\n\t\t}\n\t\tif len(result) > 0 {\n\t\t\treturn result\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// SousouResponse API响应结构\ntype SousouResponse struct {\n\tCode int    `json:\"code\"`\n\tMsg  string `json:\"msg\"`\n\tData struct {\n\t\tTotal   int          `json:\"total\"`\n\t\tPerSize int          `json:\"per_size\"`\n\t\tTook    int          `json:\"took\"`\n\t\tList    []SousouItem `json:\"list\"`\n\t} `json:\"data\"`\n}\n\n// SousouItem API响应中的单个结果项\ntype SousouItem struct {\n\tDiskID      string      `json:\"disk_id\"`\n\tDiskName    string      `json:\"disk_name\"`\n\tDiskPass    string      `json:\"disk_pass\"`\n\tDiskType    string      `json:\"disk_type\"`\n\tFiles       string      `json:\"files\"`\n\tDocID       string      `json:\"doc_id\"`\n\tShareUser   string      `json:\"share_user\"`\n\tShareUserID string      `json:\"share_user_id\"`\n\tSharedTime  string      `json:\"shared_time\"`\n\tRelMovie    string      `json:\"rel_movie\"`\n\tIsMine      bool        `json:\"is_mine\"`\n\tTags        interface{} `json:\"tags\"` // 可能为null或字符串数组\n\tLink        string      `json:\"link\"`\n\tEnabled     bool        `json:\"enabled\"`\n\tWeight      int         `json:\"weight\"`\n\tStatus      int         `json:\"status\"`\n}\n\n"
  },
  {
    "path": "plugin/susu/html结构分析.md",
    "content": "# SuSu网站搜索结果HTML结构分析\n\n## 页面整体结构\n\n搜索结果页面的主要内容位于`.post-1.post-list.post-item-1`元素内，每个搜索结果项包含在`.post-list-item.item-post-style-1`元素中。\n\n```html\n<div class=\"post-1 post-list post-item-1\" id=\"post-list\">\n    <div class=\"hidden-line\">\n        <ul class=\"b2_gap\">\n            <li class=\"post-list-item item-post-style-1\" id=\"item-18892\">\n                <!-- 单个搜索结果 -->\n            </li>\n            <li class=\"post-list-item item-post-style-1\" id=\"item-13859\">\n                <!-- 单个搜索结果 -->\n            </li>\n        </ul>\n    </div>\n</div>\n```\n\n## 单个搜索结果结构\n\n### 1. 帖子ID\n\n帖子ID可以从以下位置提取：\n- 详情页链接：`href=\"https://susuifa.com/18892.html\"`，其中`18892`为帖子ID\n\n### 2. 标题\n\n标题位于`.post-info h2 a`元素中：\n\n```html\n<div class=\"post-info\">\n    <h2><a href=\"https://susuifa.com/18892.html\">瑞克和莫蒂：日漫版 Rick and Morty: The Anime (2024)</a></h2>\n</div>\n```\n\n### 3. 内容描述\n\n内容描述位于`.post-excerpt`元素中：\n\n```html\n<div class=\"post-excerpt\">\n    　　Adult Swim推出《瑞克和莫蒂》衍生剧：日本动画风的《Rick and Morty: The Anime》，佐野隆史（《神之塔》）执导，描述为一个关于\"这个很棒的家庭\"的新故事，独立于主线之外，但会包括《瑞克和莫蒂》的主题和事件。　　佐野隆史此前打造过两部《瑞克和莫蒂》的衍生短片：《瑞克和莫蒂对抗种族灭绝者》和《瑞克和莫蒂外传姐姐遇见上帝》。 打开手机迅雷或者迅雷PC客户端，在搜索框输入&hellip;\n</div>\n```\n\n### 4. 日期时间\n\n日期时间信息位于`.list-footer time.b2timeago`元素中：\n\n```html\n<div class=\"list-footer\">\n    <span><time class=\"b2timeago\" datetime=\"2024-08-16 20:25:28\" itemprop=\"datePublished\">24年8月16日</time></span>\n</div>\n```\n\n### 5. 分类标签\n\n分类标签位于`.post-list-cat-item`元素中：\n\n```html\n<div class=\"post-list-meta-box\">\n    <div class=\"post-list-cat b2-radius\">\n        <a class=\"post-list-cat-item b2-radius\" href=\"https://susuifa.com/rhjj\" style=\"color:#607d8b\">日韩剧集</a>\n    </div>\n</div>\n```"
  },
  {
    "path": "plugin/susu/susu.go",
    "content": "package susu\n\nimport (\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\n// 缓存相关变量\nvar (\n\t// 帖子ID缓存\n\tpostIDCache = sync.Map{}\n\t\n\t// 按钮列表缓存\n\tbuttonListCache = sync.Map{}\n\t\n\t// 按钮详情缓存\n\tbuttonDetailCache = sync.Map{}\n\t\n\t// JWT解析结果缓存\n\tjwtDecodeCache = sync.Map{}\n\t\n\t// 链接类型判断缓存\n\tlinkTypeCache = sync.Map{}\n)\n\n// 常用UA列表\nvar userAgents = []string{\n\t\"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\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36\",\n\t\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0\",\n\t\"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36\",\n}\n\n// 初始化随机数种子\nfunc init() {\n\t// 注册插件\n\tplugin.RegisterGlobalPlugin(NewSusuAsyncPlugin())\n\t\n\t// 启动缓存清理\n\tgo startCacheCleaner()\n\t\n\t// 初始化随机数种子\n\trand.Seed(time.Now().UnixNano())\n}\n\n// startCacheCleaner 定期清理缓存\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(1 * time.Hour)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\t// 清空所有缓存\n\t\tpostIDCache = sync.Map{}\n\t\tbuttonListCache = sync.Map{}\n\t\tbuttonDetailCache = sync.Map{}\n\t\tjwtDecodeCache = sync.Map{}\n\t\tlinkTypeCache = sync.Map{}\n\t}\n}\n\n// getRandomUA 获取随机UA\nfunc getRandomUA() string {\n\treturn userAgents[rand.Intn(len(userAgents))]\n}\n\nconst (\n\t// 搜索API\n\tSearchURL = \"https://susuifa.com/?type=post&s=%s\"\n\t// 获取网盘按钮列表API\n\tButtonListURL = \"https://susuifa.com/wp-json/b2/v1/getDownloadData?post_id=%s&guest=\"\n\t// 获取网盘详情API\n\tButtonDetailURL = \"https://susuifa.com/wp-json/b2/v1/getDownloadPageData?post_id=%s&index=0&i=%d&guest=\"\n\t// 最大重试次数\n\tMaxRetries = 0\n\t// 最大并发数\n\tMaxConcurrency = 100\n)\n\n// SusuAsyncPlugin SuSu网站搜索异步插件\ntype SusuAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\n// NewSusuAsyncPlugin 创建新的SuSu搜索异步插件\nfunc NewSusuAsyncPlugin() *SusuAsyncPlugin {\n\treturn &SusuAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"susu\", 1), // 高优先级\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *SusuAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *SusuAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)\n}\n\n// doSearch 实际的搜索实现\nfunc (p *SusuAsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 构建搜索URL\n\tsearchURL := fmt.Sprintf(SearchURL, url.QueryEscape(keyword))\n\t\n\t// 发送请求\n\treq, err := http.NewRequest(\"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建请求失败: %w\", err)\n\t}\n\t\n\t// 设置请求头\n\treq.Header.Set(\"User-Agent\", getRandomUA())\n\treq.Header.Set(\"Referer\", \"https://susuifa.com/\")\n\t\n\t// 发送请求（带重试）\n\tresp, err := p.doRequestWithRetry(client, req, MaxRetries)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"解析HTML失败: %w\", err)\n\t}\n\t\n\t// 提取搜索结果\n\tvar wg sync.WaitGroup\n\tresultChan := make(chan model.SearchResult, 20)\n\terrorChan := make(chan error, 20)\n\t\n\t// 创建信号量控制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\n\t// 预先收集所有需要处理的项\n\tvar items []*goquery.Selection\n\t\n\t// 将关键词转为小写，用于不区分大小写的比较\n\tlowerKeyword := strings.ToLower(keyword)\n\t\n\t// 将关键词按空格分割，用于支持多关键词搜索\n\tkeywords := strings.Fields(lowerKeyword)\n\t\n\t// 预先过滤不包含关键词的帖子\n\tdoc.Find(\".post-list-item\").Each(func(i int, s *goquery.Selection) {\n\t\t// 提取标题\n\t\ttitle := s.Find(\".post-info h2 a\").Text()\n\t\ttitle = strings.TrimSpace(title)\n\t\tlowerTitle := strings.ToLower(title)\n\t\t\n\t\t// 提取内容描述\n\t\tcontent := s.Find(\".post-excerpt\").Text()\n\t\tcontent = strings.TrimSpace(content)\n\t\t// lowerContent := strings.ToLower(content)\n\t\t\n\t\t// 检查每个关键词是否在标题或内容中\n\t\tmatched := true\n\t\tfor _, kw := range keywords {\n\t\t\t// 对于所有关键词，检查是否在标题或内容中\n\t\t\tif !strings.Contains(lowerTitle, kw) {\n\t\t\t\tmatched = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 只添加匹配的帖子\n\t\tif matched {\n\t\t\titems = append(items, s)\n\t\t}\n\t})\n\t\n\t// 并发处理每个搜索结果项\n\tfor i, s := range items {\n\t\twg.Add(1)\n\t\t\n\t\tgo func(index int, s *goquery.Selection) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 提取帖子ID\n\t\t\tpostID := p.extractPostID(s)\n\t\t\tif postID == \"\" {\n\t\t\t\terrorChan <- fmt.Errorf(\"无法提取帖子ID: index=%d\", index)\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\t// 提取标题\n\t\t\ttitle := s.Find(\".post-info h2 a\").Text()\n\t\t\ttitle = strings.TrimSpace(title)\n\t\t\t\n\t\t\t// 提取内容描述\n\t\t\tcontent := s.Find(\".post-excerpt\").Text()\n\t\t\tcontent = strings.TrimSpace(content)\n\t\t\t\n\t\t\t// 提取日期时间\n\t\t\tdatetimeStr := s.Find(\".list-footer time.b2timeago\").AttrOr(\"datetime\", \"\")\n\t\t\tvar datetime time.Time\n\t\t\tif datetimeStr != \"\" {\n\t\t\t\tparsedTime, err := time.Parse(\"2006-01-02 15:04:05\", datetimeStr)\n\t\t\t\tif err == nil {\n\t\t\t\t\tdatetime = parsedTime\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 提取分类标签\n\t\t\tvar tags []string\n\t\t\ts.Find(\".post-list-cat-item\").Each(func(i int, t *goquery.Selection) {\n\t\t\t\ttag := strings.TrimSpace(t.Text())\n\t\t\t\tif tag != \"\" {\n\t\t\t\t\ttags = append(tags, tag)\n\t\t\t\t}\n\t\t\t})\n\t\t\t\n\t\t\t// 获取网盘链接\n\t\t\tlinks, err := p.getLinks(client, postID)\n\t\t\tif err != nil || len(links) == 0 {\n\t\t\t\t// 如果获取链接失败，仍然返回结果，但没有链接\n\t\t\t\tlinks = []model.Link{}\n\t\t\t}\n\t\t\t\n\t\t\t// 创建搜索结果\n\t\t\tresult := model.SearchResult{\n\t\t\t\tUniqueID:  fmt.Sprintf(\"susu-%s\", postID),\n\t\t\t\tTitle:     title,\n\t\t\t\tContent:   content,\n\t\t\t\tDatetime:  datetime,\n\t\t\t\tLinks:     links,\n\t\t\t\tTags:      tags,\n\t\t\t}\n\t\t\t\n\t\t\tresultChan <- result\n\t\t}(i, s)\n\t}\n\t\n\t// 等待所有goroutine完成\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultChan)\n\t\tclose(errorChan)\n\t}()\n\t\n\t// 收集结果\n\tvar results []model.SearchResult\n\tfor result := range resultChan {\n\t\tresults = append(results, result)\n\t}\n\t\n\t// 由于我们已经在前面过滤了不匹配的帖子，这里不需要再次过滤\n\treturn results, nil\n}\n\n// extractPostID 从搜索结果项中提取帖子ID\nfunc (p *SusuAsyncPlugin) extractPostID(s *goquery.Selection) string {\n\t// 生成缓存键\n\thtml, _ := s.Html()\n\tcacheKey := fmt.Sprintf(\"postid:%x\", md5sum(html))\n\t\n\t// 检查缓存\n\tif cachedID, ok := postIDCache.Load(cacheKey); ok {\n\t\treturn cachedID.(string)\n\t}\n\t\n\t// 方法1：从列表项ID属性提取\n\titemID, exists := s.Attr(\"id\")\n\tif exists && strings.HasPrefix(itemID, \"item-\") {\n\t\tpostID := strings.TrimPrefix(itemID, \"item-\")\n\t\tpostIDCache.Store(cacheKey, postID)\n\t\treturn postID\n\t}\n\t\n\t// 方法2：从详情页链接提取\n\thref, exists := s.Find(\".post-info h2 a\").Attr(\"href\")\n\tif exists {\n\t\tre := regexp.MustCompile(`/(\\d+)\\.html`)\n\t\tmatches := re.FindStringSubmatch(href)\n\t\tif len(matches) > 1 {\n\t\t\tpostID := matches[1]\n\t\t\tpostIDCache.Store(cacheKey, postID)\n\t\t\treturn postID\n\t\t}\n\t}\n\t\n\treturn \"\"\n}\n\n// getLinks 获取网盘链接\nfunc (p *SusuAsyncPlugin) getLinks(client *http.Client, postID string) ([]model.Link, error) {\n\t// 检查缓存\n\tif cachedLinks, ok := buttonListCache.Load(postID); ok {\n\t\treturn cachedLinks.([]model.Link), nil\n\t}\n\t\n\t// 直接并发发送6个请求，而不是先获取按钮列表\n\tconst buttonCount = 6 // 直接假设有8个按钮\n\t\n\t// 创建结果通道\n\tlinkChan := make(chan model.Link, buttonCount)\n\tvar wgLinks sync.WaitGroup\n\t\n\t// 并发获取每个按钮的详情\n\tfor i := 0; i < buttonCount; i++ {\n\t\twgLinks.Add(1)\n\t\t\n\t\tgo func(index int) {\n\t\t\tdefer wgLinks.Done()\n\t\t\t\n\t\t\tlink, err := p.getButtonDetail(client, postID, index)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\tif link.URL != \"\" {\n\t\t\t\tlinkChan <- link\n\t\t\t}\n\t\t}(i)\n\t}\n\t\n\t// 等待所有goroutine完成\n\tgo func() {\n\t\twgLinks.Wait()\n\t\tclose(linkChan)\n\t}()\n\t\n\t// 收集结果\n\tvar links []model.Link\n\tfor link := range linkChan {\n\t\tlinks = append(links, link)\n\t}\n\t\n\t// 缓存结果\n\tbuttonListCache.Store(postID, links)\n\t\n\treturn links, nil\n}\n\n// getButtonDetail 获取按钮详情\nfunc (p *SusuAsyncPlugin) getButtonDetail(client *http.Client, postID string, index int) (model.Link, error) {\n\t// 生成缓存键\n\tcacheKey := fmt.Sprintf(\"%s:%d\", postID, index)\n\t\n\t// 检查缓存\n\tif cachedLink, ok := buttonDetailCache.Load(cacheKey); ok {\n\t\treturn cachedLink.(model.Link), nil\n\t}\n\t\n\t// 构建获取按钮详情的URL\n\tbuttonDetailURL := fmt.Sprintf(ButtonDetailURL, postID, index)\n\t\n\t// 发送请求\n\treq, err := http.NewRequest(\"POST\", buttonDetailURL, nil)\n\tif err != nil {\n\t\treturn model.Link{}, fmt.Errorf(\"创建请求失败: %w\", err)\n\t}\n\t\n\t// 设置请求头\n\treq.Header.Set(\"User-Agent\", getRandomUA())\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Referer\", fmt.Sprintf(\"https://susuifa.com/download?post_id=%s&index=0&i=%d\", postID, index))\n\t\n\t// 发送请求（带重试）\n\tresp, err := p.doRequestWithRetry(client, req, MaxRetries)\n\tif err != nil {\n\t\treturn model.Link{}, fmt.Errorf(\"请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 读取响应体\n\trespBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn model.Link{}, fmt.Errorf(\"读取响应失败: %w\", err)\n\t}\n\t\n\t// 解析响应\n\tvar buttonDetail struct {\n\t\tButton struct {\n\t\t\tName string `json:\"name\"`\n\t\t\tURL  string `json:\"url\"`\n\t\t} `json:\"button\"`\n\t}\n\tif err := json.Unmarshal(respBody, &buttonDetail); err != nil {\n\t\treturn model.Link{}, fmt.Errorf(\"解析响应失败: %w\", err)\n\t}\n\t\n\t// 如果URL为空\n\tif buttonDetail.Button.URL == \"\" {\n\t\treturn model.Link{}, fmt.Errorf(\"按钮URL为空\")\n\t}\n\t\n\t// 解析JWT token获取真实链接\n\trealURL, err := p.decodeJWTURL(buttonDetail.Button.URL)\n\tif err != nil {\n\t\treturn model.Link{}, fmt.Errorf(\"解析JWT失败: %w\", err)\n\t}\n\t\n\t// 创建链接\n\tlink := model.Link{\n\t\tURL:  realURL,\n\t\tType: p.determineLinkType(realURL, buttonDetail.Button.Name),\n\t}\n\t\n\t// 缓存结果\n\tbuttonDetailCache.Store(cacheKey, link)\n\t\n\treturn link, nil\n}\n\n// decodeJWTURL 解析JWT token获取真实链接\nfunc (p *SusuAsyncPlugin) decodeJWTURL(jwtToken string) (string, error) {\n\t// 检查缓存\n\tif cachedURL, ok := jwtDecodeCache.Load(jwtToken); ok {\n\t\treturn cachedURL.(string), nil\n\t}\n\t\n\t// 分割JWT\n\tparts := strings.Split(jwtToken, \".\")\n\tif len(parts) != 3 {\n\t\treturn \"\", fmt.Errorf(\"无效的JWT格式\")\n\t}\n\t\n\t// 解码Payload\n\tpayload, err := base64.RawURLEncoding.DecodeString(parts[1])\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"解码Payload失败: %w\", err)\n\t}\n\t\n\t// 解析JSON\n\tvar payloadData struct {\n\t\tData struct {\n\t\t\tURL string `json:\"url\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(payload, &payloadData); err != nil {\n\t\treturn \"\", fmt.Errorf(\"解析Payload JSON失败: %w\", err)\n\t}\n\t\n\t// 缓存结果\n\tjwtDecodeCache.Store(jwtToken, payloadData.Data.URL)\n\t\n\treturn payloadData.Data.URL, nil\n}\n\n// determineLinkType 根据URL和名称确定链接类型\nfunc (p *SusuAsyncPlugin) determineLinkType(url, name string) string {\n\t// 生成缓存键\n\tcacheKey := fmt.Sprintf(\"%s:%s\", url, name)\n\t\n\t// 检查缓存\n\tif cachedType, ok := linkTypeCache.Load(cacheKey); ok {\n\t\treturn cachedType.(string)\n\t}\n\t\n\tlowerURL := strings.ToLower(url)\n\tlowerName := strings.ToLower(name)\n\t\n\tvar linkType string\n\t\n\t// 根据URL判断\n\tswitch {\n\tcase strings.Contains(lowerURL, \"pan.baidu.com\"):\n\t\tlinkType = \"baidu\"\n\tcase strings.Contains(lowerURL, \"alipan.com\") || strings.Contains(lowerURL, \"aliyundrive.com\"):\n\t\tlinkType = \"aliyun\"\n\tcase strings.Contains(lowerURL, \"pan.xunlei.com\"):\n\t\tlinkType = \"xunlei\"\n\tcase strings.Contains(lowerURL, \"pan.quark.cn\"):\n\t\tlinkType = \"quark\"\n\tcase strings.Contains(lowerURL, \"cloud.189.cn\"):\n\t\tlinkType = \"tianyi\"\n\tcase strings.Contains(lowerURL, \"115.com\"):\n\t\tlinkType = \"115\"\n\tcase strings.Contains(lowerURL, \"drive.uc.cn\"):\n\t\t\tlinkType = \"uc\"\n\tcase strings.Contains(lowerURL, \"caiyun.139.com\"):\n\t\tlinkType = \"mobile\"\n\tcase strings.Contains(lowerURL, \"123pan.com\"):\n\t\tlinkType = \"123\"\n\tcase strings.Contains(lowerURL, \"mypikpak.com\"):\n\t\tlinkType = \"pikpak\"\n\tdefault:\n\t\t// 根据名称判断\n\t\tswitch {\n\t\tcase strings.Contains(lowerName, \"百度\"):\n\t\t\tlinkType = \"baidu\"\n\t\tcase strings.Contains(lowerName, \"阿里\"):\n\t\t\tlinkType = \"aliyun\"\n\t\tcase strings.Contains(lowerName, \"迅雷\"):\n\t\t\tlinkType = \"xunlei\"\n\t\tcase strings.Contains(lowerName, \"夸克\"):\n\t\t\tlinkType = \"quark\"\n\t\tcase strings.Contains(lowerName, \"天翼\"):\n\t\t\tlinkType = \"tianyi\"\n\t\tcase strings.Contains(lowerName, \"115\"):\n\t\t\tlinkType = \"115\"\n\t\tcase strings.Contains(lowerName, \"uc\"):\n\t\t\tlinkType = \"uc\"\n\t\tcase strings.Contains(lowerName, \"移动\") || strings.Contains(lowerName, \"彩云\"):\n\t\t\tlinkType = \"mobile\"\n\t\tcase strings.Contains(lowerName, \"123\"):\n\t\t\tlinkType = \"123\"\n\t\tcase strings.Contains(lowerName, \"pikpak\"):\n\t\t\tlinkType = \"pikpak\"\n\t\tdefault:\n\t\t\tlinkType = \"others\"\n\t\t}\n\t}\n\t\n\t// 缓存结果\n\tlinkTypeCache.Store(cacheKey, linkType)\n\t\n\treturn linkType\n}\n\n// doRequestWithRetry 发送HTTP请求并支持重试\nfunc (p *SusuAsyncPlugin) doRequestWithRetry(client *http.Client, req *http.Request, maxRetries int) (*http.Response, error) {\n\tvar resp *http.Response\n\tvar err error\n\t\n\tfor i := 0; i <= maxRetries; i++ {\n\t\t// 如果不是第一次尝试，等待一段时间\n\t\tif i > 0 {\n\t\t\t// 指数退避算法\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 500 * time.Millisecond\n\t\t\tif backoff > 5*time.Second {\n\t\t\t\tbackoff = 5 * time.Second\n\t\t\t}\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求，避免重用同一个请求对象\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\t// 发送请求\n\t\tresp, err = client.Do(reqClone)\n\t\t\n\t\t// 如果请求成功或者是不可重试的错误，则退出循环\n\t\tif err == nil || !isRetriableError(err) {\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\treturn resp, err\n}\n\n// isRetriableError 判断错误是否可以重试\nfunc isRetriableError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\t\n\t// 判断是否是网络错误或超时错误\n\tif netErr, ok := err.(net.Error); ok {\n\t\treturn netErr.Timeout() || netErr.Temporary()\n\t}\n\t\n\t// 其他可能需要重试的错误类型\n\terrStr := err.Error()\n\treturn strings.Contains(errStr, \"connection refused\") ||\n\t\t   strings.Contains(errStr, \"connection reset\") ||\n\t\t   strings.Contains(errStr, \"EOF\")\n}\n\n// md5sum 计算字符串的MD5值的简化版本\nfunc md5sum(s string) uint32 {\n\th := uint32(0)\n\tfor i := 0; i < len(s); i++ {\n\t\th = h*31 + uint32(s[i])\n\t}\n\treturn h\n} "
  },
  {
    "path": "plugin/susu/susu插件设计文档.md",
    "content": "# SuSu 搜索插件设计文档\n\n## 目录\n\n1. [概述](#概述)\n2. [设计背景](#设计背景)\n3. [总体架构](#总体架构)\n4. [核心组件](#核心组件)\n5. [关键算法](#关键算法)\n6. [性能优化](#性能优化)\n7. [错误处理与容错](#错误处理与容错)\n8. [测试策略](#测试策略)\n9. [部署与集成](#部署与集成)\n10. [未来扩展](#未来扩展)\n11. [附录](#附录)\n\n## 概述\n\n### 1.1 文档目的\n\n本文档详细描述了 SuSu 搜索插件的设计与实现，旨在为开发者提供完整的技术参考。文档涵盖了插件的架构设计、核心组件、关键算法、性能优化策略以及错误处理机制等方面，以确保插件能够高效、稳定地运行。\n\n### 1.2 插件简介\n\nSuSu 搜索插件是 PanSou 网盘搜索系统的一个重要组成部分，专门用于从 SuSu 网站（susuifa.com）搜索并提取网盘资源链接。该插件实现了高效的并发处理、智能缓存机制和可靠的错误处理，能够快速响应用户的搜索请求，并提供准确的搜索结果。\n\n### 1.3 主要特性\n\n- **异步搜索**：基于 BaseAsyncPlugin 实现异步搜索，支持\"尽快响应，持续处理\"的模式\n- **多级缓存**：实现了多层次的缓存系统，减少重复计算和网络请求\n- **并发处理**：采用 goroutine 和信号量控制，实现高效的并发搜索\n- **智能过滤**：在早期阶段就过滤掉不相关的结果，提高处理效率\n- **重试机制**：实现了指数退避的重试策略，提高请求成功率\n- **容错设计**：即使部分请求失败，仍能返回有用的结果\n\n## 设计背景\n\n### 2.1 需求分析\n\nSuSu 网站是一个包含大量网盘资源的平台，用户需要能够快速搜索并获取这些资源的链接。主要需求包括：\n\n1. **高效搜索**：能够快速从 SuSu 网站搜索相关资源\n2. **准确提取**：正确提取网盘链接和提取码\n3. **良好体验**：响应迅速，支持异步处理\n4. **资源节约**：减少不必要的网络请求和计算资源消耗\n5. **稳定可靠**：具备错误处理和重试机制，确保服务稳定性\n\n### 2.2 技术挑战\n\n在实现 SuSu 插件过程中，面临以下技术挑战：\n\n1. **多步骤请求**：获取一个完整的网盘链接需要多次 API 请求\n2. **JWT 解析**：网盘链接被 JWT 加密，需要解析才能获取真实链接\n3. **并发控制**：需要在高效与资源消耗间取得平衡\n4. **反爬虫机制**：需要模拟真实用户行为，避免被网站封禁\n5. **缓存一致性**：确保缓存数据的准确性和时效性\n\n### 2.3 设计目标\n\n基于需求和挑战，制定了以下设计目标：\n\n1. **响应时间**：平均搜索响应时间控制在 3 秒以内\n2. **成功率**：API 请求成功率达到 95% 以上\n3. **资源效率**：减少 50% 以上的不必要网络请求\n4. **可扩展性**：设计良好的接口，便于未来功能扩展\n5. **可维护性**：代码结构清晰，文档完善，便于维护\n\n## 总体架构\n\n### 3.1 架构概览\n\nSuSu 插件采用分层架构设计，主要包含以下几个层次：\n\n```\n┌─────────────────────────┐\n│     SusuAsyncPlugin     │\n└───────────┬─────────────┘\n            │\n┌───────────▼─────────────┐\n│     搜索处理层           │\n│  (doSearch, extractPostID) │\n└───────────┬─────────────┘\n            │\n┌───────────▼─────────────┐\n│     链接获取层           │\n│  (getLinks, getButtonDetail) │\n└───────────┬─────────────┘\n            │\n┌───────────▼─────────────┐\n│     工具支持层           │\n│  (decodeJWTURL, determineLinkType) │\n└───────────┬─────────────┘\n            │\n┌───────────▼─────────────┐\n│     基础设施层           │\n│  (缓存系统, HTTP客户端)   │\n└─────────────────────────┘\n```\n\n**图 3.1 SuSu 插件架构图**\n\n### 3.2 工作流程\n\nSuSu 插件的完整工作流程如下图所示：\n\n```\n┌─────────┐     ┌─────────┐     ┌─────────┐     ┌─────────┐     ┌─────────┐\n│  接收   │     │ 搜索页面 │     │ 提取帖子 │     │ 获取网盘 │     │ 返回结果 │\n│  请求   ├────►│  HTML   ├────►│   信息   ├────►│   链接   ├────►│         │\n└─────────┘     └─────────┘     └─────────┘     └─────────┘     └─────────┘\n     │              │               │               │               │\n     ▼              ▼               ▼               ▼               ▼\n┌─────────┐     ┌─────────┐     ┌─────────┐     ┌─────────┐     ┌─────────┐\n│ 参数处理 │     │ 关键词过滤│     │ 并发处理 │     │ JWT解析  │     │ 缓存结果 │\n└─────────┘     └─────────┘     └─────────┘     └─────────┘     └─────────┘\n```\n\n**图 3.2 SuSu 插件工作流程图**\n\n### 3.3 组件关系\n\n插件各组件之间的关系和数据流向如下图所示：\n\n```\n                          ┌───────────────┐\n                          │  Search()     │\n                          └───────┬───────┘\n                                  │\n                                  ▼\n                          ┌───────────────┐\n                          │  doSearch()   │\n                          └───────┬───────┘\n                                  │\n                 ┌────────────────┴────────────────┐\n                 │                                 │\n                 ▼                                 ▼\n         ┌───────────────┐                 ┌───────────────┐\n         │ extractPostID()│                 │  getLinks()   │\n         └───────────────┘                 └───────┬───────┘\n                                                   │\n                                                   ▼\n                                           ┌───────────────┐\n                                           │getButtonDetail()│\n                                           └───────┬───────┘\n                                                   │\n                                  ┌────────────────┴────────────────┐\n                                  │                                 │\n                                  ▼                                 ▼\n                          ┌───────────────┐                 ┌───────────────┐\n                          │ decodeJWTURL() │                 │determineLinkType()│\n                          └───────────────┘                 └───────────────┘\n```\n\n**图 3.3 SuSu 插件组件关系图**\n\n### 3.4 关键接口\n\n插件实现了 `SearchPlugin` 接口，并基于 `BaseAsyncPlugin` 进行扩展：\n\n```go\n// SearchPlugin 接口\ntype SearchPlugin interface {\n    Name() string\n    Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error)\n    Priority() int\n}\n\n// SusuAsyncPlugin 结构体\ntype SusuAsyncPlugin struct {\n    *plugin.BaseAsyncPlugin\n}\n```\n\n主要方法包括：\n\n- `Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error)`：对外提供的搜索接口\n- `doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error)`：实际的搜索实现\n- `getLinks(client *http.Client, postID string) ([]model.Link, error)`：获取网盘链接\n- `getButtonDetail(client *http.Client, postID string, index int) (model.Link, error)`：获取按钮详情\n- `decodeJWTURL(jwtToken string) (string, error)`：解析JWT获取真实链接 \n\n## 核心组件\n\n### 4.1 缓存系统\n\n缓存系统是 SuSu 插件性能优化的关键部分，采用多级缓存设计，针对不同类型的操作设置独立缓存。\n\n#### 4.1.1 缓存类型\n\n```go\n// 缓存相关变量\nvar (\n    // 帖子ID缓存\n    postIDCache = sync.Map{}\n    \n    // 按钮列表缓存\n    buttonListCache = sync.Map{}\n    \n    // 按钮详情缓存\n    buttonDetailCache = sync.Map{}\n    \n    // JWT解析结果缓存\n    jwtDecodeCache = sync.Map{}\n    \n    // 链接类型判断缓存\n    linkTypeCache = sync.Map{}\n)\n```\n\n每个缓存的作用：\n\n- **postIDCache**：缓存从 HTML 元素中提取的帖子 ID，避免重复提取\n- **buttonListCache**：缓存帖子的网盘按钮列表，减少 API 请求\n- **buttonDetailCache**：缓存按钮详情信息，减少 API 请求\n- **jwtDecodeCache**：缓存 JWT 解码结果，避免重复解码\n- **linkTypeCache**：缓存链接类型判断结果，避免重复判断\n\n#### 4.1.2 缓存管理\n\n缓存系统包含定期清理机制，避免内存泄漏：\n\n```go\n// startCacheCleaner 定期清理缓存\nfunc startCacheCleaner() {\n    ticker := time.NewTicker(1 * time.Hour)\n    defer ticker.Stop()\n    \n    for range ticker.C {\n        // 清空所有缓存\n        postIDCache = sync.Map{}\n        buttonListCache = sync.Map{}\n        buttonDetailCache = sync.Map{}\n        jwtDecodeCache = sync.Map{}\n        linkTypeCache = sync.Map{}\n    }\n}\n```\n\n缓存系统的工作流程如下图所示：\n\n```\n┌───────────┐     ┌───────────┐     ┌───────────┐\n│  操作请求  │     │ 检查缓存  │     │ 返回缓存  │\n│           ├────►│           ├────►│   结果    │\n└───────────┘     └─────┬─────┘     └───────────┘\n                        │ 缓存未命中\n                        ▼\n                  ┌───────────┐     ┌───────────┐\n                  │ 执行实际  │     │ 缓存结果  │\n                  │   操作    ├────►│           │\n                  └─────┬─────┘     └───────────┘\n                        │\n                        ▼\n                  ┌───────────┐\n                  │ 返回操作  │\n                  │   结果    │\n                  └───────────┘\n```\n\n**图 4.1 缓存系统工作流程图**\n\n### 4.2 HTTP 客户端\n\nHTTP 客户端负责与 SuSu 网站的 API 进行通信，包括请求构建、发送和响应处理。\n\n#### 4.2.1 请求头管理\n\n为了模拟真实用户行为，避免被反爬虫机制识别，插件实现了随机 User-Agent 功能：\n\n```go\n// 常用UA列表\nvar userAgents = []string{\n    \"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\",\n    \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36\",\n    \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15\",\n    \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0\",\n    \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36\",\n}\n\n// getRandomUA 获取随机UA\nfunc getRandomUA() string {\n    return userAgents[rand.Intn(len(userAgents))]\n}\n```\n\n请求头设置示例：\n\n```go\n// 设置请求头\nreq.Header.Set(\"User-Agent\", getRandomUA())\nreq.Header.Set(\"Referer\", \"https://susuifa.com/\")\n```\n\n#### 4.2.2 重试机制\n\n为了处理临时网络问题，插件实现了带有指数退避算法的重试机制：\n\n```go\n// doRequestWithRetry 发送HTTP请求并支持重试\nfunc (p *SusuAsyncPlugin) doRequestWithRetry(client *http.Client, req *http.Request, maxRetries int) (*http.Response, error) {\n    var resp *http.Response\n    var err error\n    \n    for i := 0; i <= maxRetries; i++ {\n        // 如果不是第一次尝试，等待一段时间\n        if i > 0 {\n            // 指数退避算法\n            backoff := time.Duration(1<<uint(i-1)) * 500 * time.Millisecond\n            if backoff > 5*time.Second {\n                backoff = 5 * time.Second\n            }\n            time.Sleep(backoff)\n        }\n        \n        // 克隆请求，避免重用同一个请求对象\n        reqClone := req.Clone(req.Context())\n        \n        // 发送请求\n        resp, err = client.Do(reqClone)\n        \n        // 如果请求成功或者是不可重试的错误，则退出循环\n        if err == nil || !isRetriableError(err) {\n            break\n        }\n    }\n    \n    return resp, err\n}\n```\n\n重试机制的工作流程如下图所示：\n\n```\n┌───────────┐     ┌───────────┐     ┌───────────┐\n│  发送请求  │     │ 请求成功? │ 是  │ 返回响应  │\n│           ├────►│           ├────►│           │\n└───────────┘     └─────┬─────┘     └───────────┘\n                        │ 否\n                        ▼\n                  ┌───────────┐     ┌───────────┐\n                  │ 是可重试  │ 否  │ 返回错误  │\n                  │   错误?   ├────►│           │\n                  └─────┬─────┘     └───────────┘\n                        │ 是\n                        ▼\n                  ┌───────────┐     ┌───────────┐\n                  │ 重试次数  │ 是  │ 返回错误  │\n                  │  已满?    ├────►│           │\n                  └─────┬─────┘     └───────────┘\n                        │ 否\n                        ▼\n                  ┌───────────┐\n                  │ 等待退避  │\n                  │   时间    │\n                  └─────┬─────┘\n                        │\n                        ▼\n                  ┌───────────┐\n                  │ 重新发送  │\n                  │   请求    │\n                  └───────────┘\n```\n\n**图 4.2 HTTP 重试机制工作流程图**\n\n### 4.3 并发控制\n\nSuSu 插件使用 goroutine 和信号量实现并发控制，在提高处理效率的同时避免资源过度消耗。\n\n#### 4.3.1 信号量控制\n\n使用带缓冲的通道作为信号量，限制并发数量：\n\n```go\n// 创建信号量控制并发数\nsemaphore := make(chan struct{}, MaxConcurrency)\n\n// 获取信号量\nsemaphore <- struct{}{}\ndefer func() { <-semaphore }()\n```\n\n#### 4.3.2 并发处理模型\n\n使用 goroutine、WaitGroup 和通道实现并发处理：\n\n```go\n// 提取搜索结果\nvar wg sync.WaitGroup\nresultChan := make(chan model.SearchResult, 20)\nerrorChan := make(chan error, 20)\n\n// 并发处理每个搜索结果项\nfor i, s := range items {\n    wg.Add(1)\n    \n    go func(index int, s *goquery.Selection) {\n        defer wg.Done()\n        \n        // 获取信号量\n        semaphore <- struct{}{}\n        defer func() { <-semaphore }()\n        \n        // 处理单个搜索结果\n        // ...\n        \n        resultChan <- result\n    }(i, s)\n}\n\n// 等待所有goroutine完成\ngo func() {\n    wg.Wait()\n    close(resultChan)\n    close(errorChan)\n}()\n\n// 收集结果\nvar results []model.SearchResult\nfor result := range resultChan {\n    results = append(results, result)\n}\n```\n\n并发处理模型的工作流程如下图所示：\n\n```\n┌───────────┐     ┌───────────┐     ┌───────────┐\n│ 搜索结果  │     │ 创建多个  │     │ 等待所有  │\n│   列表    ├────►│ goroutine ├────►│任务完成   │\n└───────────┘     └───────────┘     └─────┬─────┘\n                        │                 │\n                        ▼                 ▼\n                  ┌───────────┐     ┌───────────┐\n                  │ 信号量    │     │ 收集处理  │\n                  │ 控制并发  │     │   结果    │\n                  └───────────┘     └───────────┘\n```\n\n**图 4.3 并发处理模型工作流程图**\n\n### 4.4 早期过滤机制\n\n为了减少不必要的网络请求，SuSu 插件在早期阶段就过滤掉不包含关键词的帖子：\n\n```go\n// 将关键词转为小写，用于不区分大小写的比较\nlowerKeyword := strings.ToLower(keyword)\n\n// 将关键词按空格分割，用于支持多关键词搜索\nkeywords := strings.Fields(lowerKeyword)\n\n// 预先过滤不包含关键词的帖子\ndoc.Find(\".post-list-item\").Each(func(i int, s *goquery.Selection) {\n    // 提取标题\n    title := s.Find(\".post-info h2 a\").Text()\n    title = strings.TrimSpace(title)\n    lowerTitle := strings.ToLower(title)\n    \n    // 检查每个关键词是否在标题中\n    matched := true\n    for _, kw := range keywords {\n        // 对于所有关键词，检查是否在标题中\n        if !strings.Contains(lowerTitle, kw) {\n            matched = false\n            break\n        }\n    }\n    \n    // 只添加匹配的帖子\n    if matched {\n        items = append(items, s)\n    }\n})\n```\n\n早期过滤机制的工作流程如下图所示：\n\n```\n┌───────────┐     ┌───────────┐     ┌───────────┐\n│ 搜索结果  │     │ 提取标题  │     │ 关键词    │\n│   HTML    ├────►│ 和内容    ├────►│ 匹配检查  │\n└───────────┘     └───────────┘     └─────┬─────┘\n                                          │\n                  ┌───────────┐           │\n                  │ 丢弃不    │ 否        │\n                  │ 匹配项    │◄──────────┘\n                  └───────────┘           │\n                                          │ 是\n                                          ▼\n                                    ┌───────────┐\n                                    │ 添加到    │\n                                    │ 处理列表  │\n                                    └───────────┘\n```\n\n**图 4.4 早期过滤机制工作流程图**\n\n## 关键算法\n\n### 5.1 JWT 解析算法\n\nSuSu 网站使用 JWT（JSON Web Token）加密网盘链接，插件需要解析 JWT 才能获取真实链接。\n\n#### 5.1.1 算法描述\n\nJWT 解析算法的步骤如下：\n\n1. 将 JWT 按点（.）分割成三部分：header、payload、signature\n2. 使用 Base64URL 解码 payload 部分\n3. 将解码后的 payload 解析为 JSON 对象\n4. 从 JSON 对象中提取 data.url 字段，即为真实链接\n\n#### 5.1.2 代码实现\n\n```go\n// decodeJWTURL 解析JWT token获取真实链接\nfunc (p *SusuAsyncPlugin) decodeJWTURL(jwtToken string) (string, error) {\n    // 检查缓存\n    if cachedURL, ok := jwtDecodeCache.Load(jwtToken); ok {\n        return cachedURL.(string), nil\n    }\n    \n    // 分割JWT\n    parts := strings.Split(jwtToken, \".\")\n    if len(parts) != 3 {\n        return \"\", fmt.Errorf(\"无效的JWT格式\")\n    }\n    \n    // 解码Payload\n    payload, err := base64.RawURLEncoding.DecodeString(parts[1])\n    if err != nil {\n        return \"\", fmt.Errorf(\"解码Payload失败: %w\", err)\n    }\n    \n    // 解析JSON\n    var payloadData struct {\n        Data struct {\n            URL string `json:\"url\"`\n        } `json:\"data\"`\n    }\n    if err := json.Unmarshal(payload, &payloadData); err != nil {\n        return \"\", fmt.Errorf(\"解析Payload JSON失败: %w\", err)\n    }\n    \n    // 缓存结果\n    jwtDecodeCache.Store(jwtToken, payloadData.Data.URL)\n    \n    return payloadData.Data.URL, nil\n}\n```\n\n#### 5.1.3 算法复杂度\n\n- **时间复杂度**：O(n)，其中 n 是 JWT token 的长度\n- **空间复杂度**：O(n)，需要存储解码后的 payload\n\n#### 5.1.4 优化措施\n\n- 使用缓存存储已解析的结果，避免重复解析\n- 使用 RawURLEncoding 而不是 URLEncoding，避免额外的填充处理\n\n### 5.2 链接类型判断算法\n\n网盘链接类型判断是将提取的 URL 映射到标准网盘类型的过程。\n\n#### 5.2.1 算法描述\n\n链接类型判断算法的步骤如下：\n\n1. 将 URL 和名称转为小写，便于比较\n2. 首先根据 URL 中的域名特征判断链接类型\n3. 如果无法从 URL 判断，则根据名称中的关键词判断\n4. 如果都无法判断，则返回默认类型 \"others\"\n\n#### 5.2.2 代码实现\n\n```go\n// determineLinkType 根据URL和名称确定链接类型\nfunc (p *SusuAsyncPlugin) determineLinkType(url, name string) string {\n    // 生成缓存键\n    cacheKey := fmt.Sprintf(\"%s:%s\", url, name)\n    \n    // 检查缓存\n    if cachedType, ok := linkTypeCache.Load(cacheKey); ok {\n        return cachedType.(string)\n    }\n    \n    lowerURL := strings.ToLower(url)\n    lowerName := strings.ToLower(name)\n    \n    var linkType string\n    \n    // 根据URL判断\n    switch {\n    case strings.Contains(lowerURL, \"pan.baidu.com\"):\n        linkType = \"baidu\"\n    case strings.Contains(lowerURL, \"alipan.com\") || strings.Contains(lowerURL, \"aliyundrive.com\"):\n        linkType = \"aliyun\"\n    case strings.Contains(lowerURL, \"pan.xunlei.com\"):\n        linkType = \"xunlei\"\n    // ... 其他网盘类型判断\n    default:\n        // 根据名称判断\n        switch {\n        case strings.Contains(lowerName, \"百度\"):\n            linkType = \"baidu\"\n        case strings.Contains(lowerName, \"阿里\"):\n            linkType = \"aliyun\"\n        // ... 其他名称判断\n        default:\n            linkType = \"others\"\n        }\n    }\n    \n    // 缓存结果\n    linkTypeCache.Store(cacheKey, linkType)\n    \n    return linkType\n}\n```\n\n#### 5.2.3 算法复杂度\n\n- **时间复杂度**：O(1)，使用 switch-case 和字符串包含判断，复杂度与输入长度无关\n- **空间复杂度**：O(1)，只需存储少量变量\n\n#### 5.2.4 优化措施\n\n- 使用缓存存储已判断的结果，避免重复判断\n- 先根据 URL 判断，再根据名称判断，符合大多数情况下的判断顺序\n- 使用 strings.Contains 而不是正则表达式，提高性能\n\n### 5.3 帖子 ID 提取算法\n\n从搜索结果 HTML 中提取帖子 ID 是获取网盘链接的第一步。\n\n#### 5.3.1 算法描述\n\n帖子 ID 提取算法的步骤如下：\n\n1. 首先尝试从列表项的 ID 属性中提取，格式为 \"item-{ID}\"\n2. 如果无法从 ID 属性提取，则尝试从详情页链接中提取，格式为 \"/{ID}.html\"\n3. 如果都无法提取，则返回空字符串\n\n#### 5.3.2 代码实现\n\n```go\n// extractPostID 从搜索结果项中提取帖子ID\nfunc (p *SusuAsyncPlugin) extractPostID(s *goquery.Selection) string {\n    // 生成缓存键\n    html, _ := s.Html()\n    cacheKey := fmt.Sprintf(\"postid:%x\", md5sum(html))\n    \n    // 检查缓存\n    if cachedID, ok := postIDCache.Load(cacheKey); ok {\n        return cachedID.(string)\n    }\n    \n    // 方法1：从列表项ID属性提取\n    itemID, exists := s.Attr(\"id\")\n    if exists && strings.HasPrefix(itemID, \"item-\") {\n        postID := strings.TrimPrefix(itemID, \"item-\")\n        postIDCache.Store(cacheKey, postID)\n        return postID\n    }\n    \n    // 方法2：从详情页链接提取\n    href, exists := s.Find(\".post-info h2 a\").Attr(\"href\")\n    if exists {\n        re := regexp.MustCompile(`/(\\d+)\\.html`)\n        matches := re.FindStringSubmatch(href)\n        if len(matches) > 1 {\n            postID := matches[1]\n            postIDCache.Store(cacheKey, postID)\n            return postID\n        }\n    }\n    \n    return \"\"\n}\n```\n\n#### 5.3.3 算法复杂度\n\n- **时间复杂度**：O(n)，其中 n 是 HTML 内容的长度\n- **空间复杂度**：O(n)，需要存储 HTML 内容和正则表达式匹配结果\n\n#### 5.3.4 优化措施\n\n- 使用缓存存储已提取的结果，避免重复提取\n- 使用简化版的 md5sum 函数生成缓存键，避免完整 HTML 内容的存储\n- 优先从 ID 属性提取，再从链接提取，符合大多数情况下的提取顺序\n\n### 5.4 指数退避算法\n\n指数退避算法是一种重试策略，随着重试次数的增加，等待时间呈指数增长，避免对服务器造成压力。\n\n#### 5.4.1 算法描述\n\n指数退避算法的步骤如下：\n\n1. 初始等待时间为 500 毫秒\n2. 每次重试后，等待时间翻倍\n3. 设置最大等待时间为 5 秒，避免等待时间过长\n\n#### 5.4.2 代码实现\n\n```go\n// 指数退避算法\nbackoff := time.Duration(1<<uint(i-1)) * 500 * time.Millisecond\nif backoff > 5*time.Second {\n    backoff = 5 * time.Second\n}\ntime.Sleep(backoff)\n```\n\n#### 5.4.3 算法复杂度\n\n- **时间复杂度**：O(1)，计算等待时间的复杂度与输入无关\n- **空间复杂度**：O(1)，只需存储少量变量\n\n#### 5.4.4 优化措施\n\n- 设置最大等待时间，避免等待时间过长\n- 使用位移操作（1<<uint(i-1)）代替幂运算，提高性能 \n\n## 性能优化\n\n### 6.1 缓存优化\n\n缓存是提高插件性能的关键技术，SuSu 插件采用了多级缓存策略，针对不同操作设计了专用缓存。\n\n#### 6.1.1 缓存设计原则\n\n1. **针对性**：根据不同操作类型设计专用缓存\n2. **轻量级**：缓存键设计简洁，避免存储大量数据\n3. **线程安全**：使用 sync.Map 确保并发安全\n4. **生命周期管理**：定期清理缓存，避免内存泄漏\n\n#### 6.1.2 缓存效果分析\n\n下表展示了各类缓存的预期效果：\n\n| 缓存类型 | 缓存命中率 | 性能提升 | 内存占用 |\n|---------|-----------|---------|---------|\n| 帖子ID缓存 | 高 | 中 | 低 |\n| 按钮列表缓存 | 中 | 高 | 中 |\n| 按钮详情缓存 | 中 | 高 | 中 |\n| JWT解析缓存 | 高 | 中 | 低 |\n| 链接类型缓存 | 高 | 低 | 低 |\n\n**表 6.1 缓存效果分析表**\n\n#### 6.1.3 缓存键设计\n\n缓存键设计是缓存系统的重要部分，良好的缓存键设计可以提高缓存命中率，减少内存占用。\n\n- **postIDCache**：使用 HTML 内容的哈希值作为键，避免存储完整 HTML\n- **buttonListCache**：使用帖子 ID 作为键，简单直接\n- **buttonDetailCache**：使用 \"帖子ID:按钮索引\" 作为键，确保唯一性\n- **jwtDecodeCache**：使用完整 JWT token 作为键，确保准确性\n- **linkTypeCache**：使用 \"URL:名称\" 作为键，涵盖判断所需的全部信息\n\n### 6.2 并发优化\n\n并发处理是提高插件吞吐量的重要手段，SuSu 插件在多个环节采用了并发处理。\n\n#### 6.2.1 并发模型选择\n\n插件采用 goroutine + 信号量的并发模型，具有以下优势：\n\n1. **轻量级**：goroutine 比传统线程更轻量，可以创建大量并发任务\n2. **可控性**：通过信号量控制并发数量，避免资源过度消耗\n3. **简洁性**：基于 CSP（通信顺序进程）模型，代码结构清晰\n\n#### 6.2.2 并发点设计\n\n插件在两个关键环节实现了并发处理：\n\n1. **搜索结果处理**：并发处理多个搜索结果项，每个结果项在单独的 goroutine 中处理\n2. **网盘链接获取**：并发获取多个按钮的详情，每个按钮在单独的 goroutine 中处理\n\n#### 6.2.3 并发控制\n\n为了避免并发过度，插件实现了严格的并发控制：\n\n```go\n// 创建信号量控制并发数\nsemaphore := make(chan struct{}, MaxConcurrency)\n\n// 获取信号量\nsemaphore <- struct{}{}\ndefer func() { <-semaphore }()\n```\n\n并发数量通过常量 MaxConcurrency 控制，可以根据实际情况进行调整。\n\n### 6.3 网络优化\n\n网络请求是插件性能的主要瓶颈，SuSu 插件采用了多种技术优化网络性能。\n\n#### 6.3.1 请求头优化\n\n为了模拟真实用户行为，减少被反爬机制拦截的可能性，插件实现了随机 User-Agent 和合理的 Referer 设置：\n\n```go\n// 设置请求头\nreq.Header.Set(\"User-Agent\", getRandomUA())\nreq.Header.Set(\"Referer\", fmt.Sprintf(\"https://susuifa.com/%s.html\", postID))\n```\n\n#### 6.3.2 请求重试\n\n为了处理临时网络问题，插件实现了请求重试机制，提高请求成功率：\n\n```go\n// doRequestWithRetry 发送HTTP请求并支持重试\nfunc (p *SusuAsyncPlugin) doRequestWithRetry(client *http.Client, req *http.Request, maxRetries int) (*http.Response, error) {\n    // ... 重试逻辑 ...\n}\n```\n\n#### 6.3.3 早期过滤\n\n为了减少不必要的网络请求，插件在早期阶段就过滤掉不相关的搜索结果：\n\n```go\n// 预先过滤不包含关键词的帖子\ndoc.Find(\".post-list-item\").Each(func(i int, s *goquery.Selection) {\n    // ... 过滤逻辑 ...\n})\n```\n\n### 6.4 算法优化\n\n算法优化是提高插件性能的重要手段，SuSu 插件在多个算法环节进行了优化。\n\n#### 6.4.1 字符串处理优化\n\n字符串处理是插件中的常见操作，插件采用了高效的字符串处理方法：\n\n1. **使用 strings.Contains 代替正则表达式**：在简单的字符串包含判断中，strings.Contains 比正则表达式更高效\n2. **使用 strings.ToLower 进行不区分大小写的比较**：先转换为小写，再进行比较，避免复杂的正则表达式\n3. **使用 strings.TrimSpace 去除空白字符**：简单高效，避免复杂的正则表达式\n\n#### 6.4.2 哈希函数优化\n\n插件使用简化版的哈希函数生成缓存键，避免完整 MD5 或 SHA 算法的开销：\n\n```go\n// md5sum 计算字符串的MD5值的简化版本\nfunc md5sum(s string) uint32 {\n    h := uint32(0)\n    for i := 0; i < len(s); i++ {\n        h = h*31 + uint32(s[i])\n    }\n    return h\n}\n```\n\n这个简化版哈希函数在性能和冲突概率之间取得了良好的平衡。\n\n## 错误处理与容错\n\n### 7.1 错误处理策略\n\nSuSu 插件采用了全面的错误处理策略，确保即使在出现错误的情况下，插件仍能提供有用的结果。\n\n#### 7.1.1 错误类型分类\n\n插件将错误分为以下几类：\n\n1. **网络错误**：如连接超时、连接重置等\n2. **解析错误**：如 HTML 解析失败、JSON 解析失败等\n3. **业务错误**：如未找到帖子 ID、未找到网盘按钮等\n4. **系统错误**：如内存不足、权限不足等\n\n#### 7.1.2 错误处理原则\n\n插件遵循以下错误处理原则：\n\n1. **早期检查**：在操作开始前检查参数和条件，避免后续错误\n2. **优雅降级**：在出现错误时，尽量返回部分结果，而不是完全失败\n3. **详细日志**：记录错误的详细信息，便于调试和问题排查\n4. **错误包装**：使用 fmt.Errorf 包装错误，保留错误上下文\n\n#### 7.1.3 错误处理示例\n\n```go\n// 获取网盘链接\nlinks, err := p.getLinks(client, postID)\nif err != nil || len(links) == 0 {\n    // 如果获取链接失败，仍然返回结果，但没有链接\n    links = []model.Link{}\n}\n```\n\n### 7.2 重试机制\n\n为了处理临时网络问题，插件实现了重试机制，提高请求成功率。\n\n#### 7.2.1 可重试错误判断\n\n插件实现了 isRetriableError 函数，判断错误是否可以重试：\n\n```go\n// isRetriableError 判断错误是否可以重试\nfunc isRetriableError(err error) bool {\n    if err == nil {\n        return false\n    }\n    \n    // 判断是否是网络错误或超时错误\n    if netErr, ok := err.(net.Error); ok {\n        return netErr.Timeout() || netErr.Temporary()\n    }\n    \n    // 其他可能需要重试的错误类型\n    errStr := err.Error()\n    return strings.Contains(errStr, \"connection refused\") ||\n           strings.Contains(errStr, \"connection reset\") ||\n           strings.Contains(errStr, \"EOF\")\n}\n```\n\n#### 7.2.2 指数退避\n\n为了避免对服务器造成压力，插件使用指数退避算法控制重试间隔：\n\n```go\n// 指数退避算法\nbackoff := time.Duration(1<<uint(i-1)) * 500 * time.Millisecond\nif backoff > 5*time.Second {\n    backoff = 5 * time.Second\n}\ntime.Sleep(backoff)\n```\n\n### 7.3 资源管理\n\n良好的资源管理是确保插件稳定运行的关键，SuSu 插件实现了严格的资源管理。\n\n#### 7.3.1 HTTP 连接管理\n\n插件使用 defer 语句确保 HTTP 响应体被正确关闭，避免资源泄漏：\n\n```go\nresp, err := p.doRequestWithRetry(client, req, MaxRetries)\nif err != nil {\n    return nil, fmt.Errorf(\"请求失败: %w\", err)\n}\ndefer resp.Body.Close()\n```\n\n#### 7.3.2 goroutine 管理\n\n插件使用 WaitGroup 确保所有 goroutine 都能正确退出，避免 goroutine 泄漏：\n\n```go\nvar wg sync.WaitGroup\n// ...\nwg.Add(1)\ngo func() {\n    defer wg.Done()\n    // ...\n}()\n// ...\nwg.Wait()\n```\n\n#### 7.3.3 内存管理\n\n插件通过定期清理缓存，避免内存泄漏：\n\n```go\n// startCacheCleaner 定期清理缓存\nfunc startCacheCleaner() {\n    ticker := time.NewTicker(1 * time.Hour)\n    defer ticker.Stop()\n    \n    for range ticker.C {\n        // 清空所有缓存\n        // ...\n    }\n}\n```\n\n### 7.4 容错设计\n\n容错设计是确保插件在面对各种异常情况时仍能正常工作的关键。\n\n#### 7.4.1 部分结果返回\n\n即使在某些操作失败的情况下，插件仍然会返回部分结果：\n\n```go\n// 获取网盘链接\nlinks, err := p.getLinks(client, postID)\nif err != nil || len(links) == 0 {\n    // 如果获取链接失败，仍然返回结果，但没有链接\n    links = []model.Link{}\n}\n```\n\n#### 7.4.2 多种提取方法\n\n插件实现了多种提取方法，当一种方法失败时，可以尝试其他方法：\n\n```go\n// 方法1：从列表项ID属性提取\nitemID, exists := s.Attr(\"id\")\nif exists && strings.HasPrefix(itemID, \"item-\") {\n    postID := strings.TrimPrefix(itemID, \"item-\")\n    postIDCache.Store(cacheKey, postID)\n    return postID\n}\n\n// 方法2：从详情页链接提取\nhref, exists := s.Find(\".post-info h2 a\").Attr(\"href\")\nif exists {\n    re := regexp.MustCompile(`/(\\d+)\\.html`)\n    matches := re.FindStringSubmatch(href)\n    if len(matches) > 1 {\n        postID := matches[1]\n        postIDCache.Store(cacheKey, postID)\n        return postID\n    }\n}\n```\n\n## 测试策略\n\n### 8.1 测试类型\n\n为了确保 SuSu 插件的质量和稳定性，需要进行多种类型的测试。\n\n#### 8.1.1 单元测试\n\n单元测试主要测试插件的各个组件和函数的功能正确性，包括：\n\n1. **JWT 解析测试**：测试 decodeJWTURL 函数能否正确解析 JWT token\n2. **链接类型判断测试**：测试 determineLinkType 函数能否正确判断链接类型\n3. **帖子 ID 提取测试**：测试 extractPostID 函数能否正确提取帖子 ID\n4. **错误处理测试**：测试各种错误情况下的处理是否正确\n\n#### 8.1.2 集成测试\n\n集成测试主要测试插件的各个组件之间的交互是否正确，包括：\n\n1. **搜索流程测试**：测试完整的搜索流程是否正常工作\n2. **缓存系统测试**：测试缓存系统是否正常工作\n3. **并发处理测试**：测试并发处理是否正常工作\n4. **错误传播测试**：测试错误是否能够正确传播\n\n#### 8.1.3 性能测试\n\n性能测试主要测试插件的性能指标是否满足要求，包括：\n\n1. **响应时间测试**：测试插件的响应时间是否在可接受范围内\n2. **并发性能测试**：测试插件在高并发情况下的性能\n3. **内存占用测试**：测试插件的内存占用是否在可接受范围内\n4. **CPU 占用测试**：测试插件的 CPU 占用是否在可接受范围内\n\n### 8.2 测试用例设计\n\n#### 8.2.1 单元测试用例\n\n以 decodeJWTURL 函数为例，设计以下测试用例：\n\n```go\nfunc TestDecodeJWTURL(t *testing.T) {\n    plugin := NewSusuAsyncPlugin()\n    \n    // 测试用例1：正常的JWT token\n    token1 := \"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczpcL1wvc3VzdWlmYS5jb20iLCJpYXQiOjE3NTMxNzgyMTYsIm5iZiI6MTc1MzE3ODIxNiwiZXhwIjoxNzUzMTc4NTE2LCJkYXRhIjp7InVybCI6Imh0dHBzOlwvXC9jYWl5dW4uMTM5LmNvbVwvbVwvaT8yalFYbXNmc01mRHUzIiwidXNlcl9pZCI6MCwicG9zdF9pZCI6IjE4ODkyIiwiaW5kZXgiOiIwIiwiaSI6IjAifX0.x14hn2sCcNC4WMZ9UAG8a89ldA8eHZ2Qw-dJsWqefog\"\n    expectedURL1 := \"https://caiyun.139.com/m/i?2jQXmsfsMfDu3\"\n    \n    url1, err1 := plugin.decodeJWTURL(token1)\n    if err1 != nil {\n        t.Errorf(\"解析正常JWT token失败: %v\", err1)\n    }\n    if url1 != expectedURL1 {\n        t.Errorf(\"解析结果不匹配，期望: %s, 实际: %s\", expectedURL1, url1)\n    }\n    \n    // 测试用例2：无效的JWT token\n    token2 := \"invalid-token\"\n    _, err2 := plugin.decodeJWTURL(token2)\n    if err2 == nil {\n        t.Error(\"解析无效JWT token应该返回错误，但没有\")\n    }\n    \n    // 测试用例3：缓存命中\n    url3, err3 := plugin.decodeJWTURL(token1)\n    if err3 != nil {\n        t.Errorf(\"缓存命中解析失败: %v\", err3)\n    }\n    if url3 != expectedURL1 {\n        t.Errorf(\"缓存命中解析结果不匹配，期望: %s, 实际: %s\", expectedURL1, url3)\n    }\n}\n```\n\n#### 8.2.2 集成测试用例\n\n以搜索流程为例，设计以下测试用例：\n\n```go\nfunc TestSearch(t *testing.T) {\n    plugin := NewSusuAsyncPlugin()\n    \n    // 测试用例1：正常搜索\n    results1, err1 := plugin.Search(\"测试关键词\", nil)\n    if err1 != nil {\n        t.Errorf(\"正常搜索失败: %v\", err1)\n    }\n    if len(results1) == 0 {\n        t.Error(\"正常搜索应该返回结果，但没有\")\n    }\n    \n    // 测试用例2：空关键词\n    _, err2 := plugin.Search(\"\", nil)\n    if err2 == nil {\n        t.Error(\"空关键词搜索应该返回错误，但没有\")\n    }\n    \n    // 测试用例3：特殊字符关键词\n    results3, err3 := plugin.Search(\"测试!@#$%^&*()\", nil)\n    if err3 != nil {\n        t.Errorf(\"特殊字符关键词搜索失败: %v\", err3)\n    }\n    // 特殊字符关键词可能没有结果，所以不检查结果数量\n}\n```\n\n#### 8.2.3 性能测试用例\n\n以响应时间测试为例，设计以下测试用例：\n\n```go\nfunc BenchmarkSearch(b *testing.B) {\n    plugin := NewSusuAsyncPlugin()\n    \n    b.ResetTimer()\n    for i := 0; i < b.N; i++ {\n        _, _ = plugin.Search(\"测试关键词\", nil)\n    }\n}\n```\n\n### 8.3 测试环境\n\n#### 8.3.1 单元测试环境\n\n单元测试环境应该是隔离的，不依赖外部服务，可以使用 mock 对象模拟外部依赖。\n\n#### 8.3.2 集成测试环境\n\n集成测试环境应该尽可能接近生产环境，但可以使用测试数据和测试服务。\n\n#### 8.3.3 性能测试环境\n\n性能测试环境应该与生产环境配置相同，以获得准确的性能数据。\n\n### 8.4 测试工具\n\n#### 8.4.1 单元测试工具\n\n- **testing 包**：Go 标准库中的测试框架\n- **testify**：提供断言和 mock 功能的测试库\n\n#### 8.4.2 集成测试工具\n\n- **httptest**：Go 标准库中的 HTTP 测试工具\n- **gock**：HTTP 请求 mock 工具\n\n#### 8.4.3 性能测试工具\n\n- **testing.B**：Go 标准库中的基准测试工具\n- **pprof**：Go 标准库中的性能分析工具 \n\n## 部署与集成\n\n### 9.1 部署流程\n\nSuSu 插件作为 PanSou 系统的一部分，其部署流程与整个系统紧密相关。\n\n#### 9.1.1 编译与打包\n\n插件随 PanSou 系统一起编译和打包，不需要单独处理：\n\n```bash\n# 在项目根目录下执行\ngo build -o pansou main.go\n```\n\n#### 9.1.2 配置管理\n\n插件的配置项可以通过环境变量或配置文件设置，主要配置项包括：\n\n- **MaxRetries**：最大重试次数，默认为 2\n- **MaxConcurrency**：最大并发数，默认为 50\n- **CacheCleanInterval**：缓存清理间隔，默认为 1 小时\n\n#### 9.1.3 依赖管理\n\n插件依赖以下外部库：\n\n- **github.com/PuerkitoBio/goquery**：用于 HTML 解析\n- **pansou/model**：系统内部模型定义\n- **pansou/plugin**：插件系统基础库\n- **pansou/util/json**：JSON 处理工具\n\n这些依赖通过 Go 的模块系统管理，确保版本一致性。\n\n### 9.2 系统集成\n\nSuSu 插件通过插件系统与 PanSou 系统集成，实现了松耦合的设计。\n\n#### 9.2.1 插件注册\n\n插件在 init 函数中注册到全局插件管理器：\n\n```go\nfunc init() {\n    // 注册插件\n    plugin.RegisterGlobalPlugin(NewSusuAsyncPlugin())\n    \n    // 启动缓存清理\n    go startCacheCleaner()\n    \n    // 初始化随机数种子\n    rand.Seed(time.Now().UnixNano())\n}\n```\n\n#### 9.2.2 接口实现\n\n插件实现了 SearchPlugin 接口，与系统其他部分进行交互：\n\n```go\n// SearchPlugin 接口\ntype SearchPlugin interface {\n    Name() string\n    Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error)\n    Priority() int\n}\n```\n\n#### 9.2.3 数据流转\n\n插件与系统之间的数据流转如下图所示：\n\n```\n┌───────────┐     ┌───────────┐     ┌───────────┐\n│  用户请求  │     │  API 层   │     │ 服务层    │\n│           ├────►│           ├────►│           │\n└───────────┘     └───────────┘     └─────┬─────┘\n                                          │\n                                          ▼\n                                    ┌───────────┐\n                                    │ 插件管理器 │\n                                    │           │\n                                    └─────┬─────┘\n                                          │\n                  ┌───────────┐           │\n                  │ 其他插件  │◄──────────┘\n                  │           │           │\n                  └───────────┘           ▼\n                                    ┌───────────┐\n                                    │ SuSu 插件  │\n                                    │           │\n                                    └───────────┘\n```\n\n**图 9.1 系统集成数据流图**\n\n### 9.3 监控与运维\n\n为了确保插件的稳定运行，需要进行监控和运维。\n\n#### 9.3.1 日志管理\n\n插件使用系统的日志框架记录关键信息，包括：\n\n- **错误日志**：记录各种错误情况\n- **警告日志**：记录可能影响性能的情况\n- **信息日志**：记录关键操作和状态变化\n- **调试日志**：记录详细的调试信息\n\n#### 9.3.2 性能监控\n\n可以通过以下指标监控插件的性能：\n\n- **响应时间**：插件的搜索响应时间\n- **成功率**：API 请求的成功率\n- **缓存命中率**：各类缓存的命中率\n- **内存占用**：插件的内存占用情况\n- **CPU 占用**：插件的 CPU 占用情况\n\n#### 9.3.3 故障排查\n\n当插件出现问题时，可以通过以下步骤进行排查：\n\n1. **检查日志**：查看错误日志，定位问题\n2. **检查网络**：确认与 SuSu 网站的连接是否正常\n3. **检查资源**：确认系统资源是否充足\n4. **检查配置**：确认配置是否正确\n5. **检查代码**：如果以上都正常，可能是代码问题，需要进行调试"
  },
  {
    "path": "plugin/thepiratebay/html结构分析.md",
    "content": "# ThePirateBay 网站搜索结果HTML结构分析\n\n## 网站概述\n\nThePirateBay (tpirbay.xyz) 是一个专门提供BitTorrent种子资源的搜索网站，**只提供磁力链接**，不提供网盘下载链接。搜索结果以表格形式展示，包含详细的种子信息。\n\n## 搜索URL格式\n\n```\nhttps://tpirbay.xyz/search/{关键词}/{页码}/99/0\n```\n\n示例：\n- 搜索\"rick and morty\"第1页：`https://tpirbay.xyz/search/rick%20and%20morty/1/99/0`\n- 搜索\"rick and morty\"第2页：`https://tpirbay.xyz/search/rick%20and%20morty/2/99/0`\n\n## 页面整体结构\n\n搜索结果页面的主要内容位于`<table id=\"searchResult\">`表格中，每个搜索结果占据一行`<tr>`元素。\n\n```html\n<table id=\"searchResult\">\n    <thead id=\"tableHead\">\n        <!-- 表头 -->\n    </thead>\n    <tr>\n        <!-- 单个搜索结果 -->\n    </tr>\n    <tr class=\"alt\">\n        <!-- 另一个搜索结果（交替样式） -->\n    </tr>\n    <!-- 更多结果... -->\n</table>\n```\n\n## 单个搜索结果结构\n\n每个搜索结果包含4列信息：\n\n### 1. 分类信息（第1列）\n\n分类信息位于`.vertTh`元素中：\n\n```html\n<td class=\"vertTh\">\n    <center>\n        <a href=\"https://tpirbay.xyz/browse/200\" title=\"More from this category\">Video</a><br>\n        (<a href=\"https://tpirbay.xyz/browse/208\" title=\"More from this category\">HD - TV shows</a>)\n    </center>\n</td>\n```\n\n- **主分类**：如`Video`、`Audio`、`Applications`等\n- **子分类**：如`HD - TV shows`、`Movies`、`Music`等\n\n### 2. 种子详情（第2列）\n\n这是主要的信息列，包含多个子元素：\n\n#### 2.1 标题和详情页链接\n\n```html\n<div class=\"detName\">\n    <a href=\"https://tpirbay.xyz/torrent/79983434/Rick_and_Morty_S08E10_1080p_AMZN_WEB-DL_DDP5_1_H_264-BiOMA\" \n       class=\"detLink\" \n       title=\"Details for Rick and Morty S08E10 1080p AMZN WEB-DL DDP5 1 H 264-BiOMA\">\n        Rick and Morty S08E10 1080p AMZN WEB-DL DDP5 1 H 264-BiOMA\n    </a>\n</div>\n```\n\n- **详情页URL格式**：`https://tpirbay.xyz/torrent/{种子ID}/{种子名称}`\n- **种子ID**：如`79983434`\n- **标题**：完整的种子名称\n\n#### 2.2 磁力链接\n\n```html\n<a href=\"magnet:?xt=urn:btih:BA0E267579FA62981795DCC059FB61E1AF5CA429&dn=Rick+and+Morty+S08E10+1080p+AMZN+WEB-DL+DDP5+1+H+264-BiOMA&tr=...\" \n   title=\"Download this torrent using magnet\">\n    <img src=\"https://tpirbay.xyz/static/img/icon-magnet.gif\" alt=\"Magnet link\" height=\"12\" width=\"12\">\n</a>\n```\n\n- **磁力链接**：以`magnet:?xt=urn:btih:`开头的完整磁力链接\n- **识别方式**：通过`href`属性中以`magnet:`开头的链接\n\n#### 2.3 用户信息和种子元数据\n\n```html\n<font class=\"detDesc\">\n    Uploaded 07-28&nbsp;05:35, Size 805.95&nbsp;MiB, ULed by  \n    <a class=\"detDesc\" href=\"https://tpirbay.xyz/user/jajaja/\" title=\"Browse jajaja\">jajaja</a> \n</font>\n```\n\n包含信息：\n- **上传日期**：**有两种格式**\n  - `MM-DD&nbsp;HH:MM` - 最近上传，只有月日时分，无年份\n  - `MM-DD&nbsp;YYYY` - 较早上传，有月日年份，无时分\n- **文件大小**：如`805.95&nbsp;MiB`、`2.3&nbsp;GiB`等\n- **上传者**：用户名和链接\n\n### 3. 种子数（第3列）\n\n```html\n<td align=\"right\">5679</td>\n```\n\n显示当前种子的Seeders数量（做种者数量）。\n\n### 4. 下载数（第4列）\n\n```html\n<td align=\"right\">2609</td>\n```\n\n显示当前种子的Leechers数量（下载者数量）。\n\n## 分页导航\n\n页面底部包含分页链接：\n\n```html\n<td colspan=\"9\" style=\"text-align:center;\">\n    <b>1</b>&nbsp;\n    <a href=\"/search/rick and morty/2/99/0\">2</a>&nbsp;\n    <a href=\"/search/rick and morty/3/99/0\">3</a>&nbsp;\n    <!-- 更多页码... -->\n</td>\n```\n\n## 提取逻辑\n\n### 搜索结果页面提取逻辑\n\n1. **定位搜索结果表格**：`table#searchResult`\n2. **遍历每行结果**：`tr`元素（跳过表头）\n3. **对于每个结果行**：\n   - 提取分类信息：`.vertTh a`元素的文本\n   - 提取标题：`.detName a.detLink`的文本和链接\n   - 从详情链接中提取种子ID：URL路径中的数字部分\n   - 提取磁力链接：查找`href`属性以`magnet:`开头的链接\n   - 提取上传时间：`.detDesc`文本中的时间信息\n   - 提取文件大小：`.detDesc`文本中的Size信息\n   - 提取上传者：`.detDesc a`元素\n   - 提取种子数和下载数：后两列的数字\n\n### 数据字段映射\n\n根据PanSou插件规范，ThePirateBay的数据映射如下：\n\n| 字段 | 来源 | 示例 |\n|------|------|------|\n| `UniqueID` | `thepiratebay-{种子ID}` | `thepiratebay-79983434` |\n| `Title` | `.detName a.detLink`文本 | `Rick and Morty S08E10 1080p AMZN WEB-DL...` |\n| `Content` | 文件大小 + 上传时间组合 | `Size: 805.95 MiB, Uploaded: 07-28 05:35` |\n| `Links` | 磁力链接数组 | `[{type: \"magnet\", url: \"magnet:?xt=...\"}]` |\n| `Tags` | 分类信息数组 | `[\"Video\", \"HD - TV shows\"]` |\n| `Channel` | **必须为空字符串** | `\"\"` |\n| `Datetime` | 上传时间（需解析两种格式） | 解析后的完整时间戳 |\n\n## 注意事项\n\n1. **网络类型**：ThePirateBay只提供磁力链接，链接类型固定为`\"magnet\"`\n2. **时间格式**：上传时间有**两种格式**需要区别处理：\n   - `MM-DD HH:MM` - 最近上传（当年），需要补充当前年份\n   - `MM-DD YYYY` - 历史上传，已包含年份信息\n3. **分页处理**：支持多页搜索，页码从1开始\n4. **交替样式**：表格行可能有`class=\"alt\"`的交替样式，不影响数据提取\n5. **VIP用户标识**：某些结果可能有VIP用户标识，可忽略\n6. **反爬虫**：需要设置合适的User-Agent和请求头\n7. **请求频率**：建议控制请求频率，避免被封禁\n\n## 错误处理\n\n1. **无搜索结果**：当表格中只有表头时，返回空结果\n2. **页面格式变化**：当关键元素无法定位时，记录错误并返回空结果\n3. **磁力链接缺失**：如果某个结果没有磁力链接，跳过该结果\n4. **网络超时**：设置合理的超时时间和重试机制\n\n## 示例代码结构\n\n```go\n// 提取单个搜索结果\nfunc extractTorrentInfo(row *html.Node) model.SearchResult {\n    result := model.SearchResult{\n        UniqueID: fmt.Sprintf(\"thepiratebay-%s\", torrentID),\n        Title:    extractTitle(row),\n        Content:  extractContentInfo(row),\n        Links:    []model.Link{{Type: \"magnet\", URL: magnetURL}},\n        Tags:     extractCategories(row),\n        Channel:  \"\", // 插件搜索结果必须为空\n        Datetime: parseUploadTime(row),\n    }\n    return result\n}\n\n// 解析上传时间的两种格式\nfunc parseUploadTime(timeStr string) time.Time {\n    // 去除&nbsp;\n    timeStr = strings.ReplaceAll(timeStr, \"&nbsp;\", \" \")\n    \n    // 格式1: \"07-28 05:35\" (当年)\n    if matched, _ := regexp.MatchString(`^\\d{2}-\\d{2} \\d{2}:\\d{2}$`, timeStr); matched {\n        currentYear := time.Now().Year()\n        fullTimeStr := fmt.Sprintf(\"%d-%s\", currentYear, timeStr)\n        if t, err := time.Parse(\"2006-01-02 15:04\", fullTimeStr); err == nil {\n            return t\n        }\n    }\n    \n    // 格式2: \"10-30 2023\" (历史)\n    if matched, _ := regexp.MatchString(`^\\d{2}-\\d{2} \\d{4}$`, timeStr); matched {\n        if t, err := time.Parse(\"01-02 2006\", timeStr); err == nil {\n            return t\n        }\n    }\n    \n    // 默认返回当前时间\n    return time.Now()\n}\n```\n\n## 搜索结果质量\n\nThePirateBay作为磁力资源站点，建议设置插件优先级为：\n- **优先级3**（普通质量）：资源丰富但质量参差不齐\n- 或**优先级2**（良好质量）：如果资源质量和时效性较好\n\n## 插件实现建议\n\n1. **并发控制**：避免过高的并发请求\n2. **缓存策略**：磁力链接相对稳定，可以设置较长的缓存时间\n3. **关键词过滤**：使用`plugin.FilterResultsByKeyword`提高结果相关性\n4. **错误重试**：实现重试机制处理网络不稳定问题"
  },
  {
    "path": "plugin/thepiratebay/thepiratebay.go",
    "content": "package thepiratebay\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\n// 常量定义\nconst (\n\t// 搜索URL格式 - 第1页\n\tSearchURL = \"https://thpibay.xyz/search/%s/1/99/0\"\n\t\n\t// 分页搜索URL格式 - 其他页\n\tSearchPageURL = \"https://thpibay.xyz/search/%s/%d/99/0\"\n\t\n\t// 默认超时时间\n\tDefaultTimeout = 10 * time.Second\n\t\n\t// 并发数限制\n\tMaxConcurrency = 200\n\t\n\t// 最大分页数（避免无限请求）\n\tMaxPages = 30\n\t\n\t// HTTP连接池配置 - 针对高并发优化\n\tMaxIdleConns        = 200  // 增加全局空闲连接池大小\n\tMaxIdleConnsPerHost = 80   // 增加每个主机的空闲连接数，提高连接复用\n\tMaxConnsPerHost     = 150  // 增加每个主机的最大连接数，支持高并发\n\tIdleConnTimeout     = 90 * time.Second\n)\n\n// 预编译正则表达式\nvar (\n\t// 磁力链接的正则表达式\n\tmagnetLinkRegex = regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9a-fA-F]{40}[^\"'\\s]*`)\n\t\n\t// 种子ID提取正则表达式\n\ttorrentIDRegex = regexp.MustCompile(`/torrent/(\\d+)/`)\n\t\n\t// 时间解析正则表达式 - 两种格式\n\ttimeFormat1Regex = regexp.MustCompile(`(\\d{2}-\\d{2})\\s+(\\d{2}:\\d{2})`) // MM-DD HH:MM\n\ttimeFormat2Regex = regexp.MustCompile(`(\\d{2}-\\d{2})\\s+(\\d{4})`)      // MM-DD YYYY\n\t\n\t// 文件大小正则表达式\n\tfileSizeRegex = regexp.MustCompile(`Size\\s+([0-9.]+)\\s*(&nbsp;)?\\s*([KMGT]?i?B)`)\n)\n\n// 缓存相关变量\nvar (\n\t// 页面缓存\n\tpageCache = sync.Map{}\n\t\n\t// 最后一次清理缓存的时间  \n\tlastCacheCleanTime = time.Now()\n\t\n\t// 缓存有效期\n\tcacheTTL = 24 * time.Hour\n)\n\n// 缓存的页面响应\ntype pageResponse struct {\n\tResults   []model.SearchResult\n\tTotalPage int\n\tTimestamp time.Time\n}\n\n// ThePirateBayPlugin 海盗湾搜索插件\ntype ThePirateBayPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t\tDisableCompression:  false,\n\t\tWriteBufferSize:     16 * 1024,\n\t\tReadBufferSize:      16 * 1024,\n\t}\n\t\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   DefaultTimeout,\n\t}\n}\n\n// NewThePirateBayPlugin 创建新的海盗湾搜索异步插件\nfunc NewThePirateBayPlugin() *ThePirateBayPlugin {\n\treturn &ThePirateBayPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"thepiratebay\", 3, true), // 跳过Service层过滤\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// 初始化插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewThePirateBayPlugin())\n\t\n\t// 启动缓存清理\n\tgo startCacheCleaner()\n}\n\n// startCacheCleaner 定期清理缓存\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(1 * time.Hour)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\t// 清空页面缓存\n\t\tpageCache = sync.Map{}\n\t\tlastCacheCleanTime = time.Now()\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *ThePirateBayPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *ThePirateBayPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实现具体的搜索逻辑（支持分页）\nfunc (p *ThePirateBayPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 使用优化的客户端\n\tif p.optimizedClient != nil {\n\t\tclient = p.optimizedClient\n\t}\n\t\n\t// 检查是否提供了英文标题参数 - 对英文搜索更友好\n\tsearchKeyword := keyword\n\tif ext != nil {\n\t\tif titleEn, exists := ext[\"title_en\"]; exists {\n\t\t\tif titleEnStr, ok := titleEn.(string); ok && titleEnStr != \"\" {\n\t\t\t\tsearchKeyword = titleEnStr\n\t\t\t}\n\t\t}\n\t}\n\t\n\tencodedKeyword := url.PathEscape(searchKeyword)\n\tallResults := make([]model.SearchResult, 0)\n\t\n\t// 1. 搜索第一页，获取总页数\n\tfirstPageResults, totalPages, err := p.searchPage(client, encodedKeyword, 1)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tallResults = append(allResults, firstPageResults...)\n\t\n\t// 2. 如果有多页，并发搜索其他页面（限制最大页数）\n\tmaxPagesToSearch := totalPages\n\tif maxPagesToSearch > MaxPages {\n\t\tmaxPagesToSearch = MaxPages\n\t}\n\t\n\tif totalPages > 1 && maxPagesToSearch > 1 {\n\t\t// 并发搜索其他页面 - 参考fox4k的并发策略\n\t\tvar wg sync.WaitGroup\n\t\tvar mu sync.Mutex\n\t\t\n\t\t// 使用信号量控制并发数\n\t\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\t\n\t\t// 存储每页结果\n\t\tpageResults := make(map[int][]model.SearchResult)\n\t\t\n\t\tfor page := 2; page <= maxPagesToSearch; page++ {\n\t\t\twg.Add(1)\n\t\t\tgo func(pageNum int) {\n\t\t\t\tdefer wg.Done()\n\t\t\t\t\n\t\t\t\t// 获取信号量\n\t\t\t\tsemaphore <- struct{}{}\n\t\t\t\tdefer func() { <-semaphore }()\n\t\t\t\t\n\t\t\t\tcurrentPageResults, _, err := p.searchPage(client, encodedKeyword, pageNum)\n\t\t\t\tif err == nil && len(currentPageResults) > 0 {\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\tpageResults[pageNum] = currentPageResults\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t}\n\t\t\t}(page)\n\t\t}\n\t\t\n\t\twg.Wait()\n\t\t\n\t\t// 按页码顺序合并所有页面的结果\n\t\tfor page := 2; page <= maxPagesToSearch; page++ {\n\t\t\tif results, exists := pageResults[page]; exists {\n\t\t\t\tallResults = append(allResults, results...)\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 3. 过滤关键词匹配的结果 - 使用处理后的搜索关键词进行过滤\n\t// 注意：标题中的'.'已经被替换为空格，提高匹配准确度\n\tresults := plugin.FilterResultsByKeyword(allResults, searchKeyword)\n\t\n\treturn results, nil\n}\n\n// searchPage 搜索指定页面\nfunc (p *ThePirateBayPlugin) searchPage(client *http.Client, encodedKeyword string, page int) ([]model.SearchResult, int, error) {\n\t// 1. 构建搜索URL\n\tvar searchURL string\n\tif page == 1 {\n\t\tsearchURL = fmt.Sprintf(SearchURL, encodedKeyword)\n\t} else {\n\t\tsearchURL = fmt.Sprintf(SearchPageURL, encodedKeyword, page)\n\t}\n\t\n\t// 2. 检查缓存\n\tcacheKey := fmt.Sprintf(\"%s-page-%d\", encodedKeyword, page)\n\tif cached, ok := pageCache.Load(cacheKey); ok {\n\t\tif cachedResp, ok := cached.(*pageResponse); ok {\n\t\t\tif time.Since(cachedResp.Timestamp) < cacheTTL {\n\t\t\t\treturn cachedResp.Results, cachedResp.TotalPage, nil\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 3. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\t\n\t// 4. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, 0, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 5. 设置完整的请求头 - 参考插件开发指南的最佳实践\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Referer\", \"https://thpibay.xyz/\")\n\t\n\t// 6. 发送HTTP请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, 0, fmt.Errorf(\"[%s] 第%d页搜索请求失败: %w\", p.Name(), page, err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 7. 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, 0, fmt.Errorf(\"[%s] 第%d页请求返回状态码: %d\", p.Name(), page, resp.StatusCode)\n\t}\n\t\n\t// 8. 解析HTML响应\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, 0, fmt.Errorf(\"[%s] 第%d页HTML解析失败: %w\", p.Name(), page, err)\n\t}\n\t\n\t// 9. 解析分页信息（只在第一页解析）\n\ttotalPages := 1\n\tif page == 1 {\n\t\ttotalPages = p.parseTotalPages(doc)\n\t}\n\t\n\t// 10. 提取搜索结果\n\tresults := make([]model.SearchResult, 0)\n\tdoc.Find(\"table#searchResult tr\").Each(func(i int, s *goquery.Selection) {\n\t\t// 跳过表头\n\t\tif s.HasClass(\"header\") {\n\t\t\treturn\n\t\t}\n\t\t\n\t\tresult := p.parseSearchResultItem(s)\n\t\tif result != nil {\n\t\t\tresults = append(results, *result)\n\t\t}\n\t})\n\t\n\t// 11. 缓存结果\n\tcachedResp := &pageResponse{\n\t\tResults:   results,\n\t\tTotalPage: totalPages,\n\t\tTimestamp: time.Now(),\n\t}\n\tpageCache.Store(cacheKey, cachedResp)\n\t\n\treturn results, totalPages, nil\n}\n\n// parseTotalPages 解析总页数\nfunc (p *ThePirateBayPlugin) parseTotalPages(doc *goquery.Document) int {\n\t// 查找分页信息，ThePirateBay的分页在底部\n\t// 格式: <b>1</b> <a href=\"/search/...\">2</a> <a href=\"/search/...\">3</a> ...\n\t\n\tmaxPage := 1\n\tdoc.Find(\"table#searchResult\").Next().Find(\"a\").Each(func(i int, s *goquery.Selection) {\n\t\thref, exists := s.Attr(\"href\")\n\t\tif !exists {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 从URL中提取页码: /search/keyword/PAGE/99/0\n\t\tparts := strings.Split(href, \"/\")\n\t\tif len(parts) >= 4 {\n\t\t\tif pageStr := parts[3]; pageStr != \"\" {\n\t\t\t\tif pageNum, err := strconv.Atoi(pageStr); err == nil && pageNum > maxPage {\n\t\t\t\t\tmaxPage = pageNum\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n\t\n\t// 也检查分页导航区域\n\tdoc.Find(\"td[colspan='9'] a\").Each(func(i int, s *goquery.Selection) {\n\t\tpageText := strings.TrimSpace(s.Text())\n\t\tif pageNum, err := strconv.Atoi(pageText); err == nil && pageNum > maxPage {\n\t\t\tmaxPage = pageNum\n\t\t}\n\t})\n\t\n\t// 限制最大页数，避免过度请求\n\tif maxPage > MaxPages {\n\t\tmaxPage = MaxPages\n\t}\n\t\n\treturn maxPage\n}\n\n// parseSearchResultItem 解析单个搜索结果项\nfunc (p *ThePirateBayPlugin) parseSearchResultItem(s *goquery.Selection) *model.SearchResult {\n\t// 获取详情页链接和标题\n\ttitleElement := s.Find(\".detName a.detLink\").First()\n\tif titleElement.Length() == 0 {\n\t\treturn nil\n\t}\n\t\n\ttitle := strings.TrimSpace(titleElement.Text())\n\tif title == \"\" {\n\t\treturn nil\n\t}\n\t\n\t// 优化标题格式：将'.'替换为空格，便于关键词匹配\n\ttitle = strings.ReplaceAll(title, \".\", \" \")\n\t\n\tdetailURL, exists := titleElement.Attr(\"href\")\n\tif !exists || detailURL == \"\" {\n\t\treturn nil\n\t}\n\t\n\t// 补全URL\n\tif strings.HasPrefix(detailURL, \"/\") {\n\t\tdetailURL = \"https://thpibay.xyz\" + detailURL\n\t}\n\t\n\t// 提取种子ID\n\tmatches := torrentIDRegex.FindStringSubmatch(detailURL)\n\tif len(matches) < 2 {\n\t\treturn nil\n\t}\n\ttorrentID := matches[1]\n\t\n\t// 获取磁力链接\n\tmagnetElement := s.Find(\"a[href^='magnet:']\").First()\n\tmagnetURL, exists := magnetElement.Attr(\"href\")\n\tif !exists || magnetURL == \"\" {\n\t\treturn nil // ThePirateBay只提供磁力链接，没有磁力链接就跳过\n\t}\n\t\n\t// 验证磁力链接格式\n\tif !magnetLinkRegex.MatchString(magnetURL) {\n\t\treturn nil\n\t}\n\t\n\t// 获取分类信息\n\tvar tags []string\n\ts.Find(\".vertTh a\").Each(func(i int, elem *goquery.Selection) {\n\t\ttag := strings.TrimSpace(elem.Text())\n\t\tif tag != \"\" {\n\t\t\ttags = append(tags, tag)\n\t\t}\n\t})\n\t\n\t// 获取种子元数据（文件大小、上传时间、上传者等）\n\tdetDesc := s.Find(\".detDesc\").Text()\n\t\n\t// 解析上传时间\n\tdatetime := p.parseUploadTime(detDesc)\n\t\n\t// 提取文件大小信息\n\tvar content string\n\tif sizeMatch := fileSizeRegex.FindStringSubmatch(detDesc); len(sizeMatch) > 0 {\n\t\tcontent = fmt.Sprintf(\"文件大小: %s%s\", sizeMatch[1], sizeMatch[3])\n\t}\n\t\n\t// 添加其他元数据信息\n\tif content != \"\" {\n\t\tcontent += \", \"\n\t}\n\tcontent += fmt.Sprintf(\"上传信息: %s\", strings.TrimSpace(detDesc))\n\t\n\t// 获取Seeders和Leechers数量\n\tseeders := strings.TrimSpace(s.Find(\"td\").Eq(2).Text())\n\tleechers := strings.TrimSpace(s.Find(\"td\").Eq(3).Text())\n\t\n\tif seeders != \"\" && leechers != \"\" {\n\t\tcontent += fmt.Sprintf(\", Seeders: %s, Leechers: %s\", seeders, leechers)\n\t}\n\t\n\t// 创建磁力链接\n\tmagnetLink := model.Link{\n\t\tType:     \"magnet\",\n\t\tURL:      magnetURL,\n\t\tPassword: \"\", // 磁力链接不需要密码\n\t}\n\t\n\treturn &model.SearchResult{\n\t\tUniqueID: fmt.Sprintf(\"%s-%s\", p.Name(), torrentID),\n\t\tTitle:    title,\n\t\tContent:  content,\n\t\tDatetime: datetime,\n\t\tTags:     tags,\n\t\tLinks:    []model.Link{magnetLink},\n\t\tChannel:  \"\", // 插件搜索结果，Channel必须为空\n\t}\n}\n\n// parseUploadTime 解析上传时间的两种格式\nfunc (p *ThePirateBayPlugin) parseUploadTime(timeStr string) time.Time {\n\t// 去除&nbsp;\n\ttimeStr = strings.ReplaceAll(timeStr, \"&nbsp;\", \" \")\n\t\n\t// 格式1: \"07-28 05:35\" (当年)\n\tif matches := timeFormat1Regex.FindStringSubmatch(timeStr); len(matches) >= 3 {\n\t\tcurrentYear := time.Now().Year()\n\t\tfullTimeStr := fmt.Sprintf(\"%d-%s %s\", currentYear, matches[1], matches[2])\n\t\tif t, err := time.Parse(\"2006-01-02 15:04\", fullTimeStr); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\t\n\t// 格式2: \"10-30 2023\" (历史)\n\tif matches := timeFormat2Regex.FindStringSubmatch(timeStr); len(matches) >= 3 {\n\t\tdateStr := fmt.Sprintf(\"%s-%s\", matches[2], matches[1]) // YYYY-MM-DD\n\t\tif t, err := time.Parse(\"2006-01-02\", dateStr); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\t\n\t// 默认返回当前时间\n\treturn time.Now()\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求 - 参考插件开发指南的最佳实践\nfunc (p *ThePirateBayPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}"
  },
  {
    "path": "plugin/u3c3/u3c3.go",
    "content": "package u3c3\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tBaseURL    = \"https://u3c3u3c3.u3c3u3c3u3c3.com\"\n\tUserAgent  = \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\"\n\tMaxRetries = 3\n\tRetryDelay = 2 * time.Second\n)\n\n// U3c3Plugin U3C3插件\ntype U3c3Plugin struct {\n\t*plugin.BaseAsyncPlugin\n\tdebugMode bool\n\tsearch2   string // 缓存的search2参数\n\tlastSync  time.Time\n}\n\nfunc init() {\n\tp := &U3c3Plugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"u3c3\", 5, true),\n\t\tdebugMode:       false,\n\t}\n\tplugin.RegisterGlobalPlugin(p)\n}\n\n// Search 搜索接口实现\nfunc (p *U3c3Plugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 搜索并返回详细结果\nfunc (p *U3c3Plugin) SearchWithResult(keyword string, ext map[string]interface{}) (*model.PluginSearchResult, error) {\n\tif p.debugMode {\n\t\tlog.Printf(\"[U3C3] 开始搜索: %s\", keyword)\n\t}\n\n\t// 第一步：获取search2参数\n\tsearch2, err := p.getSearch2Parameter()\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[U3C3] 获取search2参数失败: %v\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"获取search2参数失败: %v\", err)\n\t}\n\n\t// 第二步：执行搜索\n\tresults, err := p.doSearch(keyword, search2)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[U3C3] 搜索失败: %v\", err)\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[U3C3] 搜索完成，获得 %d 个结果\", len(results))\n\t}\n\n\t// 应用关键词过滤\n\tfilteredResults := plugin.FilterResultsByKeyword(results, keyword)\n\n\treturn &model.PluginSearchResult{\n\t\tResults:   filteredResults,\n\t\tIsFinal:   true,\n\t\tTimestamp: time.Now(),\n\t\tSource:    p.Name(),\n\t\tMessage:   fmt.Sprintf(\"找到 %d 个结果\", len(filteredResults)),\n\t}, nil\n}\n\n// getSearch2Parameter 获取search2参数\nfunc (p *U3c3Plugin) getSearch2Parameter() (string, error) {\n\t// 如果缓存有效（1小时内），直接返回\n\tif p.search2 != \"\" && time.Since(p.lastSync) < time.Hour {\n\t\treturn p.search2, nil\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[U3C3] 正在获取search2参数...\")\n\t}\n\n\tclient := &http.Client{\n\t\tTimeout: 30 * time.Second,\n\t}\n\n\treq, err := http.NewRequest(\"GET\", BaseURL, nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\n\tvar resp *http.Response\n\tvar lastErr error\n\n\t// 重试机制\n\tfor i := 0; i < MaxRetries; i++ {\n\t\tresp, lastErr = client.Do(req)\n\t\tif lastErr == nil && resp.StatusCode == 200 {\n\t\t\tbreak\n\t\t}\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tif i < MaxRetries-1 {\n\t\t\ttime.Sleep(RetryDelay)\n\t\t}\n\t}\n\n\tif lastErr != nil {\n\t\treturn \"\", lastErr\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn \"\", fmt.Errorf(\"HTTP状态码错误: %d\", resp.StatusCode)\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// 从JavaScript中提取search2参数\n\tsearch2 := p.extractSearch2FromHTML(string(body))\n\tif search2 == \"\" {\n\t\treturn \"\", fmt.Errorf(\"无法从首页提取search2参数\")\n\t}\n\n\t// 缓存参数\n\tp.search2 = search2\n\tp.lastSync = time.Now()\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[U3C3] 获取到search2参数: %s\", search2)\n\t}\n\n\treturn search2, nil\n}\n\n// extractSearch2FromHTML 从HTML中提取search2参数\nfunc (p *U3c3Plugin) extractSearch2FromHTML(html string) string {\n\t// 按行处理，排除注释行\n\tlines := strings.Split(html, \"\\n\")\n\tfor _, line := range lines {\n\t\tline = strings.TrimSpace(line)\n\t\t\n\t\t// 跳过注释行\n\t\tif strings.HasPrefix(line, \"//\") {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 查找包含nmefafej的行\n\t\tif strings.Contains(line, \"nmefafej\") && strings.Contains(line, `\"`) {\n\t\t\t// 使用正则提取引号内的值\n\t\t\tre := regexp.MustCompile(`var\\s+nmefafej\\s*=\\s*\"([^\"]+)\"`)\n\t\t\tmatches := re.FindStringSubmatch(line)\n\t\t\tif len(matches) > 1 && len(matches[1]) > 5 {\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[U3C3] 提取到search2参数: %s (来自行: %s)\", matches[1], line)\n\t\t\t\t}\n\t\t\t\treturn matches[1]\n\t\t\t}\n\t\t\t\n\t\t\t// 备用方案：直接提取引号内容\n\t\t\tstart := strings.Index(line, `\"`)\n\t\t\tif start != -1 {\n\t\t\t\tend := strings.Index(line[start+1:], `\"`)\n\t\t\t\tif end != -1 && end > 5 {\n\t\t\t\t\tcandidate := line[start+1 : start+1+end]\n\t\t\t\t\tif len(candidate) > 5 {\n\t\t\t\t\t\tif p.debugMode {\n\t\t\t\t\t\t\tlog.Printf(\"[U3C3] 备用方案提取search2: %s (来自行: %s)\", candidate, line)\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn candidate\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[U3C3] 未能找到search2参数\")\n\t}\n\treturn \"\"\n}\n\n// doSearch 执行搜索\nfunc (p *U3c3Plugin) doSearch(keyword, search2 string) ([]model.SearchResult, error) {\n\t// 构建搜索URL\n\tencodedKeyword := url.QueryEscape(keyword)\n\tsearchURL := fmt.Sprintf(\"%s/?search2=%s&search=%s\", BaseURL, search2, encodedKeyword)\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[U3C3] 搜索URL: %s\", searchURL)\n\t}\n\n\tclient := &http.Client{\n\t\tTimeout: 30 * time.Second,\n\t}\n\n\treq, err := http.NewRequest(\"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\n\tvar resp *http.Response\n\tvar lastErr error\n\n\t// 重试机制\n\tfor i := 0; i < MaxRetries; i++ {\n\t\tresp, lastErr = client.Do(req)\n\t\tif lastErr == nil && resp.StatusCode == 200 {\n\t\t\tbreak\n\t\t}\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tif i < MaxRetries-1 {\n\t\t\ttime.Sleep(RetryDelay)\n\t\t}\n\t}\n\n\tif lastErr != nil {\n\t\treturn nil, lastErr\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"搜索请求失败，状态码: %d\", resp.StatusCode)\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn p.parseSearchResults(string(body))\n}\n\n// parseSearchResults 解析搜索结果\nfunc (p *U3c3Plugin) parseSearchResults(html string) ([]model.SearchResult, error) {\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar results []model.SearchResult\n\n\t// 查找搜索结果表格行\n\tdoc.Find(\"tbody tr.default\").Each(func(i int, s *goquery.Selection) {\n\t\t// 跳过广告行（通常包含置顶标识）\n\t\ttitleCell := s.Find(\"td:nth-child(2)\")\n\t\ttitleText := titleCell.Text()\n\t\tif strings.Contains(titleText, \"[置顶]\") {\n\t\t\treturn // 跳过置顶广告\n\t\t}\n\n\t\t// 提取标题和详情链接\n\t\ttitleLink := titleCell.Find(\"a\")\n\t\ttitle := strings.TrimSpace(titleLink.Text())\n\t\tif title == \"\" {\n\t\t\treturn // 跳过空标题\n\t\t}\n\n\t\t// 清理标题中的HTML标签和特殊字符\n\t\ttitle = p.cleanTitle(title)\n\n\t\t// 提取详情页链接（可选，用于后续扩展）\n\t\tdetailURL, _ := titleLink.Attr(\"href\")\n\t\tif detailURL != \"\" && !strings.HasPrefix(detailURL, \"http\") {\n\t\t\tdetailURL = BaseURL + detailURL\n\t\t}\n\n\t\t// 提取链接信息\n\t\tlinkCell := s.Find(\"td:nth-child(3)\")\n\t\tvar links []model.Link\n\n\t\t// 磁力链接\n\t\tlinkCell.Find(\"a[href^='magnet:']\").Each(func(j int, link *goquery.Selection) {\n\t\t\thref, exists := link.Attr(\"href\")\n\t\t\tif exists && href != \"\" {\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tURL:  href,\n\t\t\t\t\tType: \"magnet\",\n\t\t\t\t})\n\t\t\t}\n\t\t})\n\n\n\t\t// 提取文件大小\n\t\tsizeText := strings.TrimSpace(s.Find(\"td:nth-child(4)\").Text())\n\n\t\t// 提取上传时间\n\t\tdateText := strings.TrimSpace(s.Find(\"td:nth-child(5)\").Text())\n\n\t\t// 提取分类\n\t\tcategoryText := s.Find(\"td:nth-child(1) a\").AttrOr(\"title\", \"\")\n\n\t\t// 构建内容信息\n\t\tvar contentParts []string\n\t\tif categoryText != \"\" {\n\t\t\tcontentParts = append(contentParts, \"分类: \"+categoryText)\n\t\t}\n\t\tif sizeText != \"\" {\n\t\t\tcontentParts = append(contentParts, \"大小: \"+sizeText)\n\t\t}\n\t\tif dateText != \"\" {\n\t\t\tcontentParts = append(contentParts, \"时间: \"+dateText)\n\t\t}\n\n\t\tcontent := strings.Join(contentParts, \" | \")\n\n\t\t// 生成唯一ID\n\t\tuniqueID := p.generateUniqueID(title, sizeText)\n\n\t\tresult := model.SearchResult{\n\t\t\tTitle:    title,\n\t\t\tContent:  content,\n\t\t\tChannel:  \"\", // 插件搜索结果必须为空\n\t\t\tTags:     []string{\"种子\", \"磁力链接\"},\n\t\t\tDatetime: p.parseDateTime(dateText),\n\t\t\tLinks:    links,\n\t\t\tUniqueID: uniqueID,\n\t\t}\n\n\t\tresults = append(results, result)\n\t})\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[U3C3] 解析到 %d 个搜索结果\", len(results))\n\t}\n\n\treturn results, nil\n}\n\n// cleanTitle 清理标题文本\nfunc (p *U3c3Plugin) cleanTitle(title string) string {\n\t// 移除HTML标签\n\ttitle = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(title, \"\")\n\t// 移除多余的空白字符\n\ttitle = regexp.MustCompile(`\\s+`).ReplaceAllString(title, \" \")\n\t// 移除前后空白\n\ttitle = strings.TrimSpace(title)\n\treturn title\n}\n\n// parseDateTime 解析日期时间\nfunc (p *U3c3Plugin) parseDateTime(dateStr string) time.Time {\n\tif dateStr == \"\" {\n\t\treturn time.Time{}\n\t}\n\n\t// 尝试解析常见的日期格式\n\tformats := []string{\n\t\t\"2006-01-02 15:04:05\",\n\t\t\"2006-01-02\",\n\t\t\"01-02 15:04\",\n\t}\n\n\tfor _, format := range formats {\n\t\tif t, err := time.Parse(format, dateStr); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\n\t// 如果解析失败，返回零值\n\treturn time.Time{}\n}\n\n// generateUniqueID 生成唯一ID\nfunc (p *U3c3Plugin) generateUniqueID(title, size string) string {\n\t// 使用插件名、标题和大小生成唯一ID\n\tsource := fmt.Sprintf(\"%s-%s-%s\", p.Name(), title, size)\n\t// 简单的哈希处理（实际项目中可使用更复杂的哈希算法）\n\thash := 0\n\tfor _, char := range source {\n\t\thash = hash*31 + int(char)\n\t}\n\tif hash < 0 {\n\t\thash = -hash\n\t}\n\treturn fmt.Sprintf(\"u3c3-%d\", hash)\n}"
  },
  {
    "path": "plugin/wanou/json结构分析.md",
    "content": "# Wanou API 数据结构分析\n\n## 基本信息\n- **数据源类型**: JSON API\n- **API URL格式**: `https://woog.nxog.eu.org/api.php/provide/vod?ac=detail&wd={关键词}`\n- **数据特点**: 视频点播(VOD)系统API，提供结构化影视资源数据\n\n## API响应结构\n\n### 顶层结构\n```json\n{\n    \"code\": 1,                    // 状态码：1表示成功\n    \"msg\": \"数据列表\",             // 响应消息\n    \"page\": 1,                    // 当前页码\n    \"pagecount\": 1,               // 总页数\n    \"limit\": 20,                  // 每页限制条数\n    \"total\": 3,                   // 总记录数\n    \"list\": []                    // 数据列表数组\n}\n```\n\n### 单个视频对象结构\n\n#### 核心识别字段\n```json\n{\n    \"vod_id\": 18010,                      // 视频唯一ID\n    \"vod_name\": \"凡人修仙传\",               // 视频标题\n    \"vod_sub\": \"The Immortal Ascension\", // 副标题/英文名\n    \"vod_en\": \"fanrenxiuxianchuan\",      // 英文标识\n    \"type_name\": \"剧集\"                   // 类型名称\n}\n```\n\n#### 内容描述字段\n```json\n{\n    \"vod_actor\": \"杨洋,金晨,汪铎,赵小棠...\",    // 演员列表（逗号分隔）\n    \"vod_director\": \"杨阳\",                   // 导演\n    \"vod_blurb\": \"该剧改编自忘语的同名小说...\", // 剧情简介\n    \"vod_content\": \"<p>该剧改编自...</p>\",    // HTML格式详细描述\n    \"vod_remarks\": \"第11集\",                  // 更新状态/备注\n    \"vod_class\": \"奇幻,古装,内地剧,大陆剧,剧集\", // 分类标签（逗号分隔）\n    \"vod_tag\": \"凡人修仙传,大夫,玄门,修仙...\"   // 关键词标签（逗号分隔）\n}\n```\n\n#### 时间和地区字段\n```json\n{\n    \"vod_year\": \"2025\",                        // 年份\n    \"vod_area\": \"中国大陆\",                     // 地区\n    \"vod_lang\": \"汉语普通话\",                   // 语言\n    \"vod_pubdate\": \"2025-07-27(中国大陆)\",     // 发布日期\n    \"vod_time\": \"2025-07-31 14:36:58\"         // 更新时间\n}\n```\n\n#### 下载链接字段（重要）\n```json\n{\n    \"vod_down_from\": \"bd$$$KG$$$UC\",           // 下载源标识（用$$$分隔）\n    \"vod_down_server\": \"no$$$no$$$no\",        // 服务器标识\n    \"vod_down_note\": \"$$$$$$\",                // 下载备注\n    \"vod_down_url\": \"https://pan.baidu.com/s/13milLJZV5_7DCzGDQu-fcA?pwd=8888$$$https://pan.quark.cn/s/0fe46ed6eefc$$$https://drive.uc.cn/s/d83caf5d4fb74\"\n}\n```\n\n## 下载链接解析规则\n\n### 分隔符规则\n- **多个下载源**: 使用 `$$$` 分隔\n- **对应关系**: `vod_down_from`、`vod_down_url`、`vod_down_note` 按相同位置对应\n\n### 下载源标识映射\n| API标识 | 网盘类型 | 域名示例 |\n|---------|----------|----------|\n| `bd`    | baidu (百度网盘) | `pan.baidu.com` |\n| `KG`    | quark (夸克网盘) | `pan.quark.cn` |\n| `UC`    | uc (UC网盘) | `drive.uc.cn` |\n| `ALY`   | aliyun (阿里云盘) | `aliyundrive.com`, `alipan.com` |\n| `XL`    | xunlei (迅雷网盘) | `pan.xunlei.com` |\n| `TY`    | tianyi (天翼云盘) | `cloud.189.cn` |\n| `115`   | 115 (115网盘) | `115.com` |\n| `MB`    | mobile (移动网盘) | `caiyun.feixin.10086.cn` |\n| `WY`    | weiyun (微云) | `share.weiyun.com` |\n| `LZ`    | lanzou (蓝奏云) | `lanzou.com`, `lanzoui.com` |\n| `JGY`   | jianguoyun (坚果云) | `jianguoyun.com` |\n| `123`   | 123 (123网盘) | `123pan.com` |\n| `PK`    | pikpak (PikPak) | `mypikpak.com` |\n\n### 链接格式示例\n```\n百度网盘: https://pan.baidu.com/s/13milLJZV5_7DCzGDQu-fcA?pwd=8888\n夸克网盘: https://pan.quark.cn/s/0fe46ed6eefc\nUC网盘:   https://drive.uc.cn/s/d83caf5d4fb74\n```\n\n## 插件开发映射关系\n\n### SearchResult字段映射\n```go\n// 基础信息\nUniqueID: fmt.Sprintf(\"wanou-%d\", vod_id)\nTitle:    vod_name\nContent:  构建描述（vod_blurb + 演员导演信息）\nChannel:  \"\"  // 插件搜索结果不设置频道名\nDatetime: 解析vod_time字段\n\n// 分类标签\nTags: 解析vod_class字段（按逗号分割）\n\n// 下载链接\nLinks: 解析vod_down_url和vod_down_from字段\n```\n\n### Link字段映射\n```go\nmodel.Link{\n    Type:     根据vod_down_from映射网盘类型\n    URL:      从vod_down_url解析具体链接\n    Password: 从URL参数中提取密码（如?pwd=8888）\n}\n```\n\n## 支持的网盘类型（16种）\n\n### 主流网盘\n- **baidu (百度网盘)**: `https://pan.baidu.com/s/{分享码}?pwd={密码}`\n- **quark (夸克网盘)**: `https://pan.quark.cn/s/{分享码}`\n- **aliyun (阿里云盘)**: `https://aliyundrive.com/s/{分享码}`, `https://www.alipan.com/s/{分享码}`\n- **uc (UC网盘)**: `https://drive.uc.cn/s/{分享码}`\n- **xunlei (迅雷网盘)**: `https://pan.xunlei.com/s/{分享码}`\n\n### 运营商网盘\n- **tianyi (天翼云盘)**: `https://cloud.189.cn/t/{分享码}`\n- **mobile (移动网盘)**: `https://caiyun.feixin.10086.cn/{分享码}`\n\n### 专业网盘\n- **115 (115网盘)**: `https://115.com/{分享码}`\n- **weiyun (微云)**: `https://share.weiyun.com/{分享码}`\n- **lanzou (蓝奏云)**: `https://lanzou.com/{分享码}`\n- **jianguoyun (坚果云)**: `https://jianguoyun.com/{分享码}`\n- **123 (123网盘)**: `https://123pan.com/s/{分享码}`\n- **pikpak (PikPak)**: `https://mypikpak.com/s/{分享码}`\n\n### 其他协议\n- **magnet (磁力链接)**: `magnet:?xt=urn:btih:{hash}`\n- **ed2k (电驴链接)**: `ed2k://|file|{filename}|{size}|{hash}|/`\n- **others (其他类型)**: 其他不在上述分类中的链接\n\n## 注意事项\n1. **数据格式**: 纯JSON API，无需HTML解析\n2. **分隔符处理**: 多个值使用`$$$`分隔，需要split处理\n3. **密码提取**: 部分百度网盘链接包含`?pwd=`参数\n4. **错误处理**: 检查`code`字段确认API响应状态\n5. **空值处理**: 某些字段可能为空字符串，需要验证\n6. **编码处理**: URL参数需要正确的URL编码处理\n\n## API调用示例\n```\n搜索请求: https://woog.nxog.eu.org/api.php/provide/vod?ac=detail&wd=%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0\n请求方法: GET\n响应格式: application/json\n编码格式: UTF-8\n```"
  },
  {
    "path": "plugin/wanou/wanou.go",
    "content": "package wanou\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\t\"context\"\n\t\"sync/atomic\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\nconst (\n\t// 默认超时时间 - 优化为更短时间\n\tDefaultTimeout = 8 * time.Second\n\n\t// HTTP连接池配置\n\tMaxIdleConns        = 200\n\tMaxIdleConnsPerHost = 50\n\tMaxConnsPerHost     = 100\n\tIdleConnTimeout     = 90 * time.Second\n)\n\n// 性能统计（原子操作）\nvar (\n\tsearchRequests  int64 = 0\n\ttotalSearchTime int64 = 0 // 纳秒\n)\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewWanouPlugin())\n}\n\n// 预编译的正则表达式\nvar (\n\t// 密码提取正则表达式\n\tpasswordRegex = regexp.MustCompile(`\\?pwd=([0-9a-zA-Z]+)`)\n\t\n\t// 常见网盘链接的正则表达式（支持16种类型）\n\tquarkLinkRegex     = regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9a-zA-Z]+`)\n\tucLinkRegex        = regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9a-zA-Z]+(\\?[^\"'\\s]*)?`)\n\tbaiduLinkRegex     = regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9a-zA-Z_\\-]+(\\?pwd=[0-9a-zA-Z]+)?`)\n\taliyunLinkRegex    = regexp.MustCompile(`https?://(www\\.)?(aliyundrive\\.com|alipan\\.com)/s/[0-9a-zA-Z]+`)\n\txunleiLinkRegex    = regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9a-zA-Z_\\-]+(\\?pwd=[0-9a-zA-Z]+)?`)\n\ttianyiLinkRegex    = regexp.MustCompile(`https?://cloud\\.189\\.cn/t/[0-9a-zA-Z]+`)\n\tlink115Regex       = regexp.MustCompile(`https?://115\\.com/s/[0-9a-zA-Z]+`)\n\tmobileLinkRegex    = regexp.MustCompile(`https?://caiyun\\.feixin\\.10086\\.cn/[0-9a-zA-Z]+`)\n\tlink123Regex       = regexp.MustCompile(`https?://123pan\\.com/s/[0-9a-zA-Z]+`)\n\tpikpakLinkRegex    = regexp.MustCompile(`https?://mypikpak\\.com/s/[0-9a-zA-Z]+`)\n\tmagnetLinkRegex    = regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9a-fA-F]{40}`)\n\ted2kLinkRegex      = regexp.MustCompile(`ed2k://\\|file\\|.+\\|\\d+\\|[0-9a-fA-F]{32}\\|/`)\n)\n\n// WanouAsyncPlugin Wanou异步插件\ntype WanouAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t}\n\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   DefaultTimeout,\n\t}\n}\n\n// NewWanouPlugin 创建新的Wanou异步插件\nfunc NewWanouPlugin() *WanouAsyncPlugin {\n\treturn &WanouAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"wanou\", 1),\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Search 同步搜索接口\nfunc (p *WanouAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 带结果统计的搜索接口\nfunc (p *WanouAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 搜索实现\nfunc (p *WanouAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 性能统计\n\tstart := time.Now()\n\tatomic.AddInt64(&searchRequests, 1)\n\tdefer func() {\n\t\tduration := time.Since(start).Nanoseconds()\n\t\tatomic.AddInt64(&totalSearchTime, duration)\n\t}()\n\n\t// 使用优化的客户端\n\tif p.optimizedClient != nil {\n\t\tclient = p.optimizedClient\n\t}\n\n\t// 构建API搜索URL\n\tsearchURL := fmt.Sprintf(\"https://woog.nxog.eu.org/api.php/provide/vod?ac=detail&wd=%s\", url.QueryEscape(keyword))\n\t\n\t// 创建HTTP请求\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\t\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建搜索请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"https://woog.nxog.eu.org/\")\n\treq.Header.Set(\"Cache-Control\", \"no-cache\")\n\t\n\t// 发送请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 解析JSON响应\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 读取响应失败: %w\", p.Name(), err)\n\t}\n\t\n\tvar apiResponse WanouAPIResponse\n\tif err := json.Unmarshal(body, &apiResponse); err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析JSON响应失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 检查API响应状态\n\tif apiResponse.Code != 1 {\n\t\treturn nil, fmt.Errorf(\"[%s] API返回错误: %s\", p.Name(), apiResponse.Msg)\n\t}\n\t\n\t// 解析搜索结果\n\tvar results []model.SearchResult\n\tfor _, item := range apiResponse.List {\n\t\tif result := p.parseAPIItem(item); result.Title != \"\" {\n\t\t\tresults = append(results, result)\n\t\t}\n\t}\n\t\n\treturn results, nil\n}\n\n// WanouAPIResponse API响应结构\ntype WanouAPIResponse struct {\n\tCode      int           `json:\"code\"`\n\tMsg       string        `json:\"msg\"`\n\tPage      int           `json:\"page\"`\n\tPageCount int           `json:\"pagecount\"`\n\tLimit     int           `json:\"limit\"`\n\tTotal     int           `json:\"total\"`\n\tList      []WanouAPIItem `json:\"list\"`\n}\n\n// WanouAPIItem API数据项\ntype WanouAPIItem struct {\n\tVodID       int    `json:\"vod_id\"`\n\tVodName     string `json:\"vod_name\"`\n\tVodActor    string `json:\"vod_actor\"`\n\tVodDirector string `json:\"vod_director\"`\n\tVodDownFrom string `json:\"vod_down_from\"`\n\tVodDownURL  string `json:\"vod_down_url\"`\n\tVodRemarks  string `json:\"vod_remarks\"`\n\tVodPubdate  string `json:\"vod_pubdate\"`\n\tVodArea     string `json:\"vod_area\"`\n\tVodYear     string `json:\"vod_year\"`\n\tVodContent  string `json:\"vod_content\"`\n\tVodPic      string `json:\"vod_pic\"`\n}\n\n// parseAPIItem 解析API数据项\nfunc (p *WanouAsyncPlugin) parseAPIItem(item WanouAPIItem) model.SearchResult {\n\t// 构建唯一ID\n\tuniqueID := fmt.Sprintf(\"%s-%d\", p.Name(), item.VodID)\n\t\n\t// 构建标题\n\ttitle := strings.TrimSpace(item.VodName)\n\tif title == \"\" {\n\t\treturn model.SearchResult{}\n\t}\n\t\n\t// 构建描述\n\tvar contentParts []string\n\tif item.VodActor != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"主演: %s\", item.VodActor))\n\t}\n\tif item.VodDirector != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"导演: %s\", item.VodDirector))\n\t}\n\tif item.VodArea != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"地区: %s\", item.VodArea))\n\t}\n\tif item.VodYear != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"年份: %s\", item.VodYear))\n\t}\n\tif item.VodRemarks != \"\" {\n\t\tcontentParts = append(contentParts, fmt.Sprintf(\"状态: %s\", item.VodRemarks))\n\t}\n\tcontent := strings.Join(contentParts, \" | \")\n\t\n\t// 解析下载链接\n\tlinks := p.parseDownloadLinks(item.VodDownFrom, item.VodDownURL)\n\n\t// 提取封面图片\n\tvar images []string\n\tif item.VodPic != \"\" {\n\t\timages = append(images, item.VodPic)\n\t}\n\n\t// 构建标签\n\tvar tags []string\n\tif item.VodYear != \"\" {\n\t\ttags = append(tags, item.VodYear)\n\t}\n\tif item.VodArea != \"\" {\n\t\ttags = append(tags, item.VodArea)\n\t}\n\n\treturn model.SearchResult{\n\t\tUniqueID: uniqueID,\n\t\tTitle:    title,\n\t\tContent:  content,\n\t\tLinks:    links,\n\t\tTags:     tags,\n\t\tImages:   images,\n\t\tChannel:  \"\", // 插件搜索结果Channel为空\n\t\tDatetime: time.Time{}, // 使用零值而不是nil，参考jikepan插件标准\n\t}\n}\n\n// parseDownloadLinks 解析下载链接\nfunc (p *WanouAsyncPlugin) parseDownloadLinks(vodDownFrom, vodDownURL string) []model.Link {\n\tif vodDownFrom == \"\" || vodDownURL == \"\" {\n\t\treturn nil\n\t}\n\t\n\t// 按$$$分隔\n\tfromParts := strings.Split(vodDownFrom, \"$$$\")\n\turlParts := strings.Split(vodDownURL, \"$$$\")\n\t\n\t// 确保数组长度一致\n\tminLen := len(fromParts)\n\tif len(urlParts) < minLen {\n\t\tminLen = len(urlParts)\n\t}\n\t\n\tvar links []model.Link\n\tfor i := 0; i < minLen; i++ {\n\t\tfromType := strings.TrimSpace(fromParts[i])\n\t\turlStr := strings.TrimSpace(urlParts[i])\n\t\t\n\t\tif urlStr == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 直接确定链接类型（合并验证和类型判断，避免重复正则匹配）\n\t\tlinkType := p.determineLinkTypeOptimized(fromType, urlStr)\n\t\tif linkType == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 提取密码\n\t\tpassword := p.extractPassword(urlStr)\n\t\t\n\t\tlinks = append(links, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      urlStr,\n\t\t\tPassword: password,\n\t\t})\n\t}\n\t\n\treturn links\n}\n\n\n\n\n\n// determineLinkTypeOptimized 优化的链接类型判断（避免重复正则匹配）\nfunc (p *WanouAsyncPlugin) determineLinkTypeOptimized(apiType, url string) string {\n\t// 基本验证（包含原 isValidNetworkDriveURL 的逻辑）\n\tif strings.Contains(url, \"javascript:\") || \n\t   strings.Contains(url, \"#\") ||\n\t   url == \"\" ||\n\t   (!strings.HasPrefix(url, \"http\") && !strings.HasPrefix(url, \"magnet:\") && !strings.HasPrefix(url, \"ed2k:\")) {\n\t\treturn \"\"\n\t}\n\t\n\t// 优先根据API标识快速映射（避免正则匹配）\n\tswitch strings.ToUpper(apiType) {\n\tcase \"BD\":\n\t\tif baiduLinkRegex.MatchString(url) {\n\t\t\treturn \"baidu\"\n\t\t}\n\tcase \"KG\":\n\t\tif quarkLinkRegex.MatchString(url) {\n\t\t\treturn \"quark\"\n\t\t}\n\tcase \"UC\":\n\t\tif ucLinkRegex.MatchString(url) {\n\t\t\treturn \"uc\"\n\t\t}\n\tcase \"ALY\":\n\t\tif aliyunLinkRegex.MatchString(url) {\n\t\t\treturn \"aliyun\"\n\t\t}\n\tcase \"XL\":\n\t\tif xunleiLinkRegex.MatchString(url) {\n\t\t\treturn \"xunlei\"\n\t\t}\n\tcase \"TY\":\n\t\tif tianyiLinkRegex.MatchString(url) {\n\t\t\treturn \"tianyi\"\n\t\t}\n\tcase \"115\":\n\t\tif link115Regex.MatchString(url) {\n\t\t\treturn \"115\"\n\t\t}\n\tcase \"MB\":\n\t\tif mobileLinkRegex.MatchString(url) {\n\t\t\treturn \"mobile\"\n\t\t}\n\tcase \"123\":\n\t\tif link123Regex.MatchString(url) {\n\t\t\treturn \"123\"\n\t\t}\n\tcase \"PIKPAK\":\n\t\tif pikpakLinkRegex.MatchString(url) {\n\t\t\treturn \"pikpak\"\n\t\t}\n\t}\n\t\n\t// 如果API标识匹配失败，回退到URL正则匹配（一次性匹配）\n\tswitch {\n\tcase baiduLinkRegex.MatchString(url):\n\t\treturn \"baidu\"\n\tcase ucLinkRegex.MatchString(url):\n\t\treturn \"uc\"\n\tcase aliyunLinkRegex.MatchString(url):\n\t\treturn \"aliyun\"\n\tcase xunleiLinkRegex.MatchString(url):\n\t\treturn \"xunlei\"\n\tcase tianyiLinkRegex.MatchString(url):\n\t\treturn \"tianyi\"\n\tcase link115Regex.MatchString(url):\n\t\treturn \"115\"\n\tcase mobileLinkRegex.MatchString(url):\n\t\treturn \"mobile\"\n\tcase link123Regex.MatchString(url):\n\t\treturn \"123\"\n\tcase pikpakLinkRegex.MatchString(url):\n\t\treturn \"pikpak\"\n\tcase magnetLinkRegex.MatchString(url):\n\t\treturn \"magnet\"\n\tcase ed2kLinkRegex.MatchString(url):\n\t\treturn \"ed2k\"\n\tcase quarkLinkRegex.MatchString(url):\n\t\treturn \"quark\" \n\tdefault:\n\t\treturn \"\" // 不支持的类型\n\t}\n}\n\n// determineLinkType 根据URL确定链接类型（支持16种类型）\nfunc (p *WanouAsyncPlugin) determineLinkType(url string) string {\n\tswitch {\n\tcase quarkLinkRegex.MatchString(url):\n\t\treturn \"quark\"\n\tcase ucLinkRegex.MatchString(url):\n\t\treturn \"uc\"\n\tcase baiduLinkRegex.MatchString(url):\n\t\treturn \"baidu\"\n\tcase aliyunLinkRegex.MatchString(url):\n\t\treturn \"aliyun\"\n\tcase xunleiLinkRegex.MatchString(url):\n\t\treturn \"xunlei\"\n\tcase tianyiLinkRegex.MatchString(url):\n\t\treturn \"tianyi\"\n\tcase link115Regex.MatchString(url):\n\t\treturn \"115\"\n\tcase mobileLinkRegex.MatchString(url):\n\t\treturn \"mobile\"\n\tcase link123Regex.MatchString(url):\n\t\treturn \"123\"\n\tcase pikpakLinkRegex.MatchString(url):\n\t\treturn \"pikpak\"\n\tcase magnetLinkRegex.MatchString(url):\n\t\treturn \"magnet\"\n\tcase ed2kLinkRegex.MatchString(url):\n\t\treturn \"ed2k\"\n\tdefault:\n\t\treturn \"\" // 不支持的类型返回空字符串\n\t}\n}\n\n// extractPassword 从URL中提取密码\nfunc (p *WanouAsyncPlugin) extractPassword(url string) string {\n\tmatches := passwordRegex.FindStringSubmatch(url)\n\tif len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// doRequestWithRetry 带重试的HTTP请求（优化JSON API的重试策略）\nfunc (p *WanouAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 2  // 对于JSON API减少重试次数\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tresp, err := client.Do(req)\n\t\tif err == nil {\n\t\t\tif resp.StatusCode == http.StatusOK {\n\t\t\t\treturn resp, nil\n\t\t\t}\n\t\t\tresp.Body.Close()\n\t\t\tlastErr = fmt.Errorf(\"HTTP状态码: %d\", resp.StatusCode)\n\t\t} else {\n\t\t\tlastErr = err\n\t\t}\n\t\t\n\t\t// JSON API快速重试：只等待很短时间\n\t\tif i < maxRetries-1 {\n\t\t\ttime.Sleep(100 * time.Millisecond) // 从秒级改为100毫秒\n\t\t}\n\t}\n\t\n\treturn nil, fmt.Errorf(\"[%s] 请求失败，重试%d次后仍失败: %w\", p.Name(), maxRetries, lastErr)\n}\n\n// GetPerformanceStats 获取性能统计信息\nfunc (p *WanouAsyncPlugin) GetPerformanceStats() map[string]interface{} {\n\ttotalRequests := atomic.LoadInt64(&searchRequests)\n\ttotalTime := atomic.LoadInt64(&totalSearchTime)\n\t\n\tvar avgTime float64\n\tif totalRequests > 0 {\n\t\tavgTime = float64(totalTime) / float64(totalRequests) / 1e6 // 转换为毫秒\n\t}\n\t\n\treturn map[string]interface{}{\n\t\t\"search_requests\":    totalRequests,\n\t\t\"avg_search_time_ms\": avgTime,\n\t\t\"total_search_time_ns\": totalTime,\n\t}\n}"
  },
  {
    "path": "plugin/weibo/README.md",
    "content": "# 微博搜索插件 (Weibo)\n\n## 📖 简介\n\nWeibo是PanSou的微博搜索插件，支持多用户登录微博并配置要搜索的微博用户，在搜索时自动聚合所有配置的微博用户发布的资源链接（从微博正文和评论中提取）。\n\n## ✨ 核心特性\n\n- ✅ **多账户支持** - 每个微博账户独立配置，互不干扰\n- ✅ **扫码登录** - 手机微博扫码，自动获取Cookie\n- ✅ **多微博用户** - 每个账户可配置多个要搜索的微博用户\n- ✅ **评论提取** - 自动提取微博正文和评论中的网盘链接\n- ✅ **智能去重** - 多账户配置相同微博用户时自动去重\n- ✅ **负载均衡** - 任务均匀分配，避免单账户限流\n- ✅ **内存缓存** - 用户数据缓存到内存，搜索性能极高\n- ✅ **持久化存储** - Cookie和配置自动保存，重启不丢失\n- ✅ **Web管理界面** - 一站式配置，简单易用\n- ✅ **RESTful API** - 支持程序化调用\n\n## 🚀 快速开始\n\n### 步骤1: 启动服务\n\n```bash\ncd /path/to/pansou\nENABLED_PLUGINS=weibo go run main.go\n\n# 或者编译后运行\ngo build -o pansou main.go\nENABLED_PLUGINS=weibo ./pansou\n```\n\n### 步骤2: 访问管理页面\n\n浏览器打开：\n```\nhttp://localhost:8888/weibo/你的微博用户名\n```\n\n**示例**：\n```\nhttp://localhost:8888/weibo/pansou123\n```\n\n系统会自动：\n1. 根据用户名生成专属64位hash（不可逆）\n2. 重定向到专属管理页面：`http://localhost:8888/weibo/{hash}`\n3. 显示二维码供扫码登录\n\n**📌 提示**：请收藏hash后的URL（包含你的专属hash），方便下次访问。\n\n### 步骤3: 扫码登录\n\n1. 页面会自动显示微博登录二维码\n2. 使用**手机微博APP**扫描二维码\n3. 扫码后系统会**自动检测登录状态**（每2秒检查一次）\n4. 登录成功后自动显示用户信息\n\n### 步骤4: 配置微博用户\n\n在\"微博用户管理\"区域输入要搜索的微博用户ID，**每行一个**：\n\n```\n1234567890\n2345678901\n3456789012\n```\n\n**支持格式**：\n- ✅ 纯用户ID：`1234567890`\n- ✅ 完整URL：`https://weibo.com/u/1234567890`\n\n点击\"**保存配置**\"按钮。\n\n**📌 如何获取微博用户ID？**\n1. 访问目标微博用户主页\n2. 查看URL：`https://weibo.com/u/1234567890`\n3. 其中 `1234567890` 就是用户ID\n\n### 步骤5: 开始搜索\n\n在PanSou主页搜索框输入关键词，系统会**自动搜索所有配置的微博用户**的微博内容！\n\n```bash\n# 通过API搜索\ncurl \"http://localhost:8888/api/search?kw=唐朝诡事录\"\n\n# 只搜索插件（包括weibo）\ncurl \"http://localhost:8888/api/search?kw=唐朝诡事录&src=plugin\"\n```\n\n## 📡 API文档\n\n### 统一接口\n\n所有操作通过统一的POST接口：\n\n```\nPOST /weibo/{hash}\nContent-Type: application/json\n\n{\n  \"action\": \"操作类型\",\n  ...其他参数\n}\n```\n\n### API列表\n\n| Action | 说明 | 需要登录 | 前端调用时机 |\n|--------|------|---------|-------------|\n| `get_status` | 获取状态 | ❌ | 每3秒自动调用 |\n| `refresh_qrcode` | 刷新二维码 | ❌ | 用户点击刷新按钮 |\n| `check_login` | 检查登录状态 | ❌ | 未登录时每2秒调用 |\n| `logout` | 退出登录 | ✅ | 用户点击退出按钮 |\n| `set_users` | 设置微博用户列表 | ✅ | 用户点击保存按钮 |\n| `test_search` | 测试搜索 | ✅ | 用户点击搜索按钮 |\n\n---\n\n### 1️⃣ get_status - 获取账户状态\n\n**作用**：获取当前账户的登录状态、配置的微博用户等信息\n\n**请求**：\n```bash\ncurl -X POST \"http://localhost:8888/weibo/{hash}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"action\": \"get_status\"}'\n```\n\n**成功响应（已登录）**：\n```json\n{\n  \"success\": true,\n  \"message\": \"获取成功\",\n  \"data\": {\n    \"hash\": \"abc123...\",\n    \"logged_in\": true,\n    \"status\": \"active\",\n    \"username_masked\": \"pa****ou\",\n    \"login_time\": \"2025-10-28 12:00:00\",\n    \"expire_time\": \"2026-10-28 12:00:00\",\n    \"expires_in_days\": 365,\n    \"weibo_users\": [\"1234567890\", \"2345678901\"],\n    \"user_count\": 2,\n    \"qrcode_base64\": \"\"\n  }\n}\n```\n\n**成功响应（未登录）**：\n```json\n{\n  \"success\": true,\n  \"message\": \"获取成功\",\n  \"data\": {\n    \"hash\": \"abc123...\",\n    \"logged_in\": false,\n    \"status\": \"pending\",\n    \"username_masked\": \"\",\n    \"weibo_users\": [],\n    \"user_count\": 0,\n    \"qrcode_base64\": \"data:image/png;base64,iVBORw0KGgo...\"\n  }\n}\n```\n\n---\n\n### 2️⃣ refresh_qrcode - 刷新二维码\n\n**作用**：强制生成新的二维码（当二维码过期时）\n\n**请求**：\n```bash\ncurl -X POST \"http://localhost:8888/weibo/{hash}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"action\": \"refresh_qrcode\"}'\n```\n\n**成功响应**：\n```json\n{\n  \"success\": true,\n  \"message\": \"二维码已刷新\",\n  \"data\": {\n    \"qrcode_base64\": \"data:image/png;base64,iVBORw0KGgo...\"\n  }\n}\n```\n\n**说明**：\n- 二维码有效期约2-3分钟\n- 过期后需要点击刷新\n\n---\n\n### 3️⃣ check_login - 检查登录状态\n\n**作用**：检查二维码是否被扫描，登录是否成功（扫码后轮询调用）\n\n**请求**：\n```bash\ncurl -X POST \"http://localhost:8888/weibo/{hash}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"action\": \"check_login\"}'\n```\n\n**响应（等待扫码）**：\n```json\n{\n  \"success\": true,\n  \"message\": \"等待扫码\",\n  \"data\": {\n    \"login_status\": \"waiting\"\n  }\n}\n```\n\n**响应（登录成功）**：\n```json\n{\n  \"success\": true,\n  \"message\": \"登录成功\",\n  \"data\": {\n    \"login_status\": \"success\",\n    \"username_masked\": \"pa****ou\"\n  }\n}\n```\n\n**响应（二维码过期）**：\n```json\n{\n  \"success\": false,\n  \"message\": \"二维码已失效，请刷新\"\n}\n```\n\n**说明**：\n- 前端未登录时每2秒自动调用\n- 登录成功后前端会停止轮询\n- 后端会自动获取完整Cookie并保存\n\n---\n\n### 4️⃣ logout - 退出登录\n\n**作用**：清除Cookie，退出登录状态\n\n**请求**：\n```bash\ncurl -X POST \"http://localhost:8888/weibo/{hash}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"action\": \"logout\"}'\n```\n\n**成功响应**：\n```json\n{\n  \"success\": true,\n  \"message\": \"已退出登录\",\n  \"data\": {\n    \"status\": \"pending\"\n  }\n}\n```\n\n---\n\n### 5️⃣ set_users - 设置微博用户列表\n\n**作用**：配置或更新要搜索的微博用户列表（覆盖式更新）\n\n**请求**：\n```bash\ncurl -X POST \"http://localhost:8888/weibo/{hash}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"action\": \"set_users\",\n    \"users\": [\"1234567890\", \"2345678901\", \"https://weibo.com/u/3456789012\"]\n  }'\n```\n\n**成功响应**：\n```json\n{\n  \"success\": true,\n  \"message\": \"微博用户列表已更新\",\n  \"data\": {\n    \"weibo_users\": [\"1234567890\", \"2345678901\", \"3456789012\"],\n    \"user_count\": 3,\n    \"invalid_users\": []\n  }\n}\n```\n\n**说明**：\n- 自动提取用户ID（支持URL格式）\n- 自动去重\n- 只保存数字格式的用户ID\n\n---\n\n### 6️⃣ test_search - 测试搜索\n\n**作用**：在管理页面测试搜索功能\n\n**请求**：\n```bash\ncurl -X POST \"http://localhost:8888/weibo/{hash}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"action\": \"test_search\",\n    \"keyword\": \"唐朝诡事录\"\n  }'\n```\n\n**参数**：\n- `keyword`（必需）：搜索关键词\n\n**成功响应**：\n```json\n{\n  \"success\": true,\n  \"message\": \"找到 3 条结果\",\n  \"data\": {\n    \"keyword\": \"唐朝诡事录\",\n    \"total_results\": 3,\n    \"users_searched\": [\"1234567890\", \"2345678901\"],\n    \"results\": [\n      {\n        \"unique_id\": \"weibo-1234567890-M_Pqs5eOb\",\n        \"title\": \"唐朝诡事录 全集\",\n        \"content\": \"唐朝诡事录更新至40集...\",\n        \"links\": [\n          {\n            \"type\": \"quark\",\n            \"url\": \"https://pan.quark.cn/s/xxxxx\",\n            \"password\": \"\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\n---\n\n## 🔧 配置说明\n\n### 环境变量（可选）\n\n```bash\n# Hash Salt（推荐自定义，增强安全性）\nexport WEIBO_HASH_SALT=\"your-custom-salt-here\"\n\n# Cookie加密密钥（32字节，推荐自定义）\nexport WEIBO_ENCRYPTION_KEY=\"your-32-byte-key-here!!!!!!!!!!\"\n```\n\n### 代码内配置\n\n在 `weibo.go` 第28-33行修改：\n\n```go\nconst (\n    MaxConcurrentUsers = 10  // 最多同时搜索多少个微博账户\n    MaxConcurrentWeibo = 30  // 最多同时处理多少条微博（获取评论）\n    MaxComments        = 1   // 每条微博最多获取多少条评论\n    DebugLog           = false\n)\n```\n\n**参数说明**：\n\n| 参数 | 默认值 | 说明 | 建议 |\n|------|--------|------|------|\n| `MaxConcurrentUsers` | 10 | 单次搜索最多使用的微博账户数 | 5-10足够 |\n| `MaxConcurrentWeibo` | 30 | 最大并发处理微博数（获取评论） | 20-50 |\n| `MaxComments` | 1 | 每条微博最多获取多少条评论 | 1-3条 |\n| `DebugLog` | false | 是否开启调试日志 | 生产环境false |\n\n## 📂 数据存储\n\n### 存储位置\n\n```\ncache/weibo_users/{hash}.json\n```\n\n### 数据结构\n\n```json\n{\n  \"hash\": \"abc123...\",\n  \"username_masked\": \"pa****ou\",\n  \"cookie\": \"SUB=xxx; SUBP=xxx; ...\",\n  \"status\": \"active\",\n  \"weibo_users\": [\"1234567890\", \"2345678901\", \"3456789012\"],\n  \"created_at\": \"2025-11-19T12:00:00+08:00\",\n  \"login_at\": \"2025-11-19T12:00:00+08:00\",\n  \"expire_at\": \"2026-11-19T12:00:00+08:00\",\n  \"last_access_at\": \"2025-11-19T13:00:00+08:00\"\n}\n```\n\n**字段说明**：\n- `hash`: 账户唯一标识（SHA256，不可逆）\n- `username_masked`: 脱敏用户名（如`pa****ou`）\n- `cookie`: 微博登录Cookie（明文存储，建议配置加密）\n- `status`: 账户状态（`pending`/`active`/`expired`）\n- `weibo_users`: 要搜索的微博用户ID列表\n- `expire_at`: Cookie过期时间\n\n## 🔒 安全特性\n\n### 1. 用户名隐私保护\n\n- ✅ **不存储明文用户名**：只存储SHA256 hash（64位十六进制）\n- ✅ **不可逆**：无法从hash反推用户名\n- ✅ **加盐hash**：支持自定义salt，进一步增强安全性\n\n### 2. Cookie安全\n\n- ⚠️ **当前**：明文存储到JSON（方便调试）\n- ✅ **可选**：通过环境变量配置加密密钥\n- ✅ **建议**：生产环境配置`WEIBO_ENCRYPTION_KEY`\n\n### 3. 自动清理\n\n**定期清理任务**（每24小时）：\n- 删除：状态为`expired`且30天未访问的账户\n- 标记：90天未访问的账户标记为`expired`\n\n## ⚙️ 工作原理\n\n### 搜索流程\n\n```\n用户搜索关键词 \"唐朝诡事录\"\n  ↓\n加载所有active状态的微博账户\n  ↓\n取最近访问的前10个账户（负载均衡）\n  ↓\n为每个账户分配要搜索的微博用户\n  ↓\n并发执行:\n  账户A → 搜索微博用户 1234567890\n  账户B → 搜索微博用户 2345678901\n  账户C → 搜索微博用户 3456789012\n  ↓\n  对每个微博用户:\n    1. 获取前3页微博列表\n    2. 过滤包含关键词的微博\n    3. 提取微博正文中的网盘链接\n    4. 获取第1条评论（可配置）\n    5. 提取评论中的网盘链接\n  ↓\n合并所有账户的搜索结果\n  ↓\n去重（基于微博ID）\n  ↓\n返回最终结果\n```\n\n### 链接提取\n\n**支持的网盘类型**：\n- 夸克网盘：`https://pan.quark.cn/s/xxxxx`\n- 阿里云盘：`https://www.alipan.com/s/xxxxx`\n- 百度网盘：`https://pan.baidu.com/s/xxxxx`\n- 其他常见网盘\n\n**提取位置**：\n1. 微博正文\n2. 微博评论（默认取第1条）\n\n### 负载均衡\n\n```\n账户A配置: [用户1, 用户2, 用户3, 用户4]\n账户B配置: [用户2, 用户3, 用户5, 用户6]\n账户C配置: [用户1, 用户5, 用户7]\n\n去重后要搜索的微博用户:\n  [用户1, 用户2, 用户3, 用户4, 用户5, 用户6, 用户7]\n\n任务分配（轮询）:\n  用户1 → 账户A\n  用户2 → 账户B\n  用户3 → 账户C\n  用户4 → 账户A\n  用户5 → 账户B\n  用户6 → 账户C\n  用户7 → 账户A\n```\n\n## 🎯 使用场景\n\n### 场景1: 追剧更新\n\n配置几个经常分享资源的微博用户，自动获取最新更新的剧集链接。\n\n### 场景2: 资源聚合\n\n配置多个不同领域的资源分享博主，一次搜索聚合所有相关资源。\n\n### 场景3: 团队协作\n\n团队成员各自配置自己的微博账户和关注的资源博主，共享搜索结果。\n\n## 📝 注意事项\n\n1. **Cookie有效期**：微博Cookie约1年有效，过期需要重新登录\n2. **请求限制**：单个账户请求过快可能被限流，建议配置多个账户\n3. **评论获取**：默认只获取每条微博的第1条评论，可通过`MaxComments`调整\n4. **用户ID格式**：必须是纯数字格式（如`1234567890`），不支持个性化域名\n\n## 🔍 故障排查\n\n### 问题1: 搜索无结果\n\n**可能原因**：\n- 配置的微博用户没有发布包含关键词的内容\n- Cookie已过期，需要重新登录\n- 微博用户ID配置错误\n\n**解决方法**：\n1. 检查管理页面的登录状态\n2. 使用\"测试搜索\"功能验证\n3. 确认微博用户ID格式正确\n\n### 问题2: 登录失败\n\n**可能原因**：\n- 二维码已过期\n- 网络问题\n- 微博安全策略限制\n\n**解决方法**：\n1. 点击\"刷新二维码\"重试\n2. 检查网络连接\n3. 尝试更换网络环境\n\n### 问题3: Cookie频繁失效\n\n**可能原因**：\n- 账户在其他设备登录\n- 账户安全策略\n- 请求频率过高\n\n**解决方法**：\n1. 减少请求频率\n2. 配置多个账户分散请求\n3. 检查账户安全设置\n\n## 📚 更多信息\n\n- [PanSou 项目主页](https://github.com/fish2018/pansou)\n- [插件开发指南](../../docs/插件开发指南.md)\n- [常见问题](https://github.com/fish2018/pansou/issues)\n"
  },
  {
    "path": "plugin/weibo/weibo.go",
    "content": "package weibo\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"net/http/cookiejar\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nconst (\n\tMaxConcurrentUsers = 10  // 最多同时搜索多少个微博用户\n\tMaxConcurrentWeibo = 30  // 最多同时处理多少条微博（获取评论）\n\tMaxComments        = 1   // 每条微博最多获取多少条评论\n\tDebugLog           = false\n)\n\nvar StorageDir string\n\n\nconst HTMLTemplate = `<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>PanSou 微博搜索配置</title>\n    <style>\n        * { margin: 0; padding: 0; box-sizing: border-box; }\n        body { \n            font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n            padding: 20px;\n        }\n        .container {\n            max-width: 800px;\n            margin: 0 auto;\n            background: white;\n            border-radius: 16px;\n            box-shadow: 0 20px 60px rgba(0,0,0,0.3);\n            overflow: hidden;\n        }\n        .header {\n            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n            color: white;\n            padding: 30px;\n            text-align: center;\n        }\n        .section {\n            padding: 30px;\n            border-bottom: 1px solid #eee;\n        }\n        .section:last-child { border-bottom: none; }\n        .section-title {\n            font-size: 18px;\n            font-weight: bold;\n            margin-bottom: 15px;\n            color: #333;\n        }\n        .status-box {\n            background: #f8f9fa;\n            padding: 20px;\n            border-radius: 8px;\n            margin-bottom: 15px;\n        }\n        .status-item {\n            display: flex;\n            justify-content: space-between;\n            padding: 8px 0;\n        }\n        .qrcode-container {\n            text-align: center;\n            padding: 20px;\n        }\n        .qrcode-img {\n            max-width: 200px;\n            border: 2px solid #ddd;\n            border-radius: 8px;\n        }\n        .btn {\n            padding: 10px 20px;\n            border: none;\n            border-radius: 6px;\n            cursor: pointer;\n            font-size: 14px;\n            transition: all 0.3s;\n        }\n        .btn-primary {\n            background: #667eea;\n            color: white;\n        }\n        .btn-primary:hover { background: #5568d3; }\n        .btn-danger {\n            background: #f56565;\n            color: white;\n        }\n        .btn-danger:hover { background: #e53e3e; }\n        .btn-secondary {\n            background: #e2e8f0;\n            color: #333;\n        }\n        .btn-secondary:hover { background: #cbd5e0; }\n        textarea {\n            width: 100%;\n            padding: 10px 15px;\n            border: 1px solid #ddd;\n            border-radius: 6px;\n            font-size: 14px;\n            resize: vertical;\n            font-family: monospace;\n        }\n        .test-results {\n            max-height: 300px;\n            overflow-y: auto;\n            background: #f8f9fa;\n            padding: 15px;\n            border-radius: 6px;\n            margin-top: 10px;\n        }\n        .hidden { display: none; }\n        .alert {\n            padding: 12px 15px;\n            border-radius: 6px;\n            margin: 10px 0;\n        }\n        .alert-success {\n            background: #c6f6d5;\n            color: #22543d;\n        }\n        .alert-error {\n            background: #fed7d7;\n            color: #742a2a;\n        }\n    </style>\n</head>\n<body>\n    <div class=\"container\">\n        <div class=\"header\">\n            <h1>🔍 PanSou 微博搜索</h1>\n            <p>配置你的专属搜索服务</p>\n            <p style=\"font-size: 12px; margin-top: 10px; opacity: 0.8;\">\n                🔗 当前地址: <span id=\"current-url\">HASH_PLACEHOLDER</span>\n            </p>\n        </div>\n\n        <div class=\"section\" id=\"login-section\">\n            <div class=\"section-title\">📱 登录状态</div>\n            \n            <div id=\"logged-in-view\" class=\"hidden\">\n                <div class=\"status-box\">\n                    <div class=\"status-item\">\n                        <span>状态</span>\n                        <span><strong style=\"color: #48bb78;\">✅ 已登录</strong></span>\n                    </div>\n                    <div class=\"status-item\">\n                        <span>登录时间</span>\n                        <span id=\"login-time\">-</span>\n                    </div>\n                    <div class=\"status-item\">\n                        <span>有效期</span>\n                        <span id=\"expire-info\">-</span>\n                    </div>\n                </div>\n                <button class=\"btn btn-danger\" onclick=\"logout()\">退出登录</button>\n            </div>\n\n            <div id=\"not-logged-in-view\" class=\"hidden\">\n                <div class=\"qrcode-container\">\n                    <img id=\"qrcode-img\" class=\"qrcode-img\" src=\"\" alt=\"二维码\">\n                    <p style=\"margin-top: 10px; color: #666;\">\n                        请使用手机微博扫描二维码登录\n                    </p>\n                    <p style=\"font-size: 12px; color: #999;\">扫码后自动检测登录状态</p>\n                    <button class=\"btn btn-secondary\" onclick=\"refreshQRCode()\" style=\"margin-top: 10px;\">\n                        刷新二维码\n                    </button>\n                </div>\n            </div>\n        </div>\n\n        <div class=\"section\" id=\"users-section\">\n            <div class=\"section-title\">👤 微博用户管理 (<span id=\"user-count\">0</span> 个)</div>\n            \n            <div id=\"alert-box\"></div>\n            \n            <p style=\"margin-bottom: 10px; color: #666;\">每行一个微博用户ID，保存时自动去重</p>\n            <textarea id=\"users-textarea\" rows=\"10\" placeholder=\"5487050770\n1234567890\n9876543210\"></textarea>\n            \n            <button class=\"btn btn-primary\" onclick=\"saveUsers()\" style=\"margin-top: 10px;\">保存用户配置</button>\n        </div>\n\n        <div class=\"section\" id=\"test-section\">\n            <div class=\"section-title\">🔍 测试搜索(限制返回10条数据)</div>\n            \n            <div style=\"display: flex; gap: 10px;\">\n                <input type=\"text\" id=\"search-keyword\" placeholder=\"输入关键词测试搜索\" style=\"flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 6px;\">\n                <button class=\"btn btn-primary\" onclick=\"testSearch()\">搜索</button>\n            </div>\n\n            <div id=\"search-results\" class=\"test-results hidden\"></div>\n        </div>\n    </div>\n\n    <script>\n        const HASH = 'HASH_PLACEHOLDER';\n        const API_URL = '/weibo/' + HASH;\n        let statusCheckInterval = null;\n        let loginCheckInterval = null;\n\n        window.onload = function() {\n            updateStatus();\n            startStatusPolling();\n        };\n\n        function startStatusPolling() {\n            statusCheckInterval = setInterval(updateStatus, 3000);\n        }\n\n        function startLoginPolling() {\n            if (loginCheckInterval) return;\n            loginCheckInterval = setInterval(checkLogin, 2000);\n        }\n\n        function stopLoginPolling() {\n            if (loginCheckInterval) {\n                clearInterval(loginCheckInterval);\n                loginCheckInterval = null;\n            }\n        }\n\n        async function postAction(action, extraData = {}) {\n            try {\n                const response = await fetch(API_URL, {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ action: action, ...extraData })\n                });\n                return await response.json();\n            } catch (error) {\n                console.error('请求失败:', error);\n                return { success: false, message: '请求失败: ' + error.message };\n            }\n        }\n\n        async function updateStatus() {\n            const result = await postAction('get_status');\n            if (result.success && result.data) {\n                const data = result.data;\n                \n                if (data.logged_in === true && data.status === 'active') {\n                    document.getElementById('logged-in-view').classList.remove('hidden');\n                    document.getElementById('not-logged-in-view').classList.add('hidden');\n                    \n                    document.getElementById('login-time').textContent = data.login_time || '-';\n                    document.getElementById('expire-info').textContent = '剩余 ' + (data.expires_in_days || 0) + ' 天';\n                    \n                    stopLoginPolling();\n                } else {\n                    document.getElementById('logged-in-view').classList.add('hidden');\n                    document.getElementById('not-logged-in-view').classList.remove('hidden');\n                    \n                    if (data.qrcode_base64) {\n                        document.getElementById('qrcode-img').src = data.qrcode_base64;\n                    }\n                    \n                    startLoginPolling();\n                }\n\n                updateUserList(data.user_ids || []);\n            }\n        }\n\n        async function checkLogin() {\n            const result = await postAction('check_login');\n            if (result.success && result.data) {\n                if (result.data.login_status === 'success') {\n                    stopLoginPolling();\n                    showAlert('登录成功！');\n                    updateStatus();\n                }\n            }\n        }\n\n        function updateUserList(userIds) {\n            const textarea = document.getElementById('users-textarea');\n            const count = document.getElementById('user-count');\n            \n            count.textContent = userIds.length;\n            \n            if (document.activeElement !== textarea) {\n                textarea.value = userIds.join('\\n');\n            }\n        }\n\n        function showAlert(message, type = 'success') {\n            const alertBox = document.getElementById('alert-box');\n            alertBox.innerHTML = '<div class=\"alert alert-' + type + '\">' + message + '</div>';\n            setTimeout(() => {\n                alertBox.innerHTML = '';\n            }, 3000);\n        }\n\n        async function refreshQRCode() {\n            const result = await postAction('refresh_qrcode');\n            if (result.success) {\n                showAlert(result.message);\n                updateStatus();\n                startLoginPolling();\n            } else {\n                showAlert(result.message, 'error');\n            }\n        }\n\n        async function logout() {\n            if (!confirm('确定要退出登录吗？')) return;\n            \n            const result = await postAction('logout');\n            if (result.success) {\n                showAlert(result.message);\n                updateStatus();\n            } else {\n                showAlert(result.message, 'error');\n            }\n        }\n\n        async function saveUsers() {\n            const textarea = document.getElementById('users-textarea');\n            const usersText = textarea.value.trim();\n            \n            const userIds = usersText\n                .split('\\n')\n                .map(line => line.trim())\n                .filter(line => line.length > 0);\n            \n            const result = await postAction('set_user_ids', { user_ids: userIds });\n            if (result.success) {\n                showAlert(result.message);\n                updateStatus();\n            } else {\n                showAlert(result.message, 'error');\n            }\n        }\n\n        async function testSearch() {\n            const keyword = document.getElementById('search-keyword').value.trim();\n            \n            if (!keyword) {\n                showAlert('请输入搜索关键词', 'error');\n                return;\n            }\n\n            const resultsDiv = document.getElementById('search-results');\n            resultsDiv.classList.remove('hidden');\n            resultsDiv.innerHTML = '<div>🔍 搜索中...</div>';\n\n            const result = await postAction('test_search', { keyword });\n            \n            if (result.success) {\n                const results = result.data.results || [];\n                \n                if (results.length === 0) {\n                    resultsDiv.innerHTML = '<p style=\"text-align: center; color: #999;\">未找到结果</p>';\n                    return;\n                }\n\n                let html = '<p><strong>找到 ' + result.data.total_results + ' 条结果</strong></p>';\n                results.forEach((item, index) => {\n                    html += '<div style=\"margin: 15px 0; padding: 10px; background: white; border-radius: 6px;\">';\n                    html += '<p><strong>' + (index + 1) + '. ' + item.title + '</strong></p>';\n                    item.links.forEach(link => {\n                        html += '<p style=\"font-size: 12px; color: #666; margin: 5px 0; word-break: break-all;\">';\n                        html += '[' + link.type + '] ' + link.url;\n                        if (link.password) html += ' 密码: ' + link.password;\n                        html += '</p>';\n                    });\n                    html += '</div>';\n                });\n                resultsDiv.innerHTML = html;\n            } else {\n                resultsDiv.innerHTML = '<p style=\"color: red;\">' + result.message + '</p>';\n            }\n        }\n\n        document.getElementById('search-keyword').addEventListener('keypress', function(e) {\n            if (e.key === 'Enter') testSearch();\n        });\n    </script>\n</body>\n</html>`\n\ntype WeiboPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tusers       sync.Map\n\tmu          sync.RWMutex\n\tinitialized bool\n}\n\ntype User struct {\n\tHash         string    `json:\"hash\"`\n\tCookie       string    `json:\"cookie\"`\n\tStatus       string    `json:\"status\"`\n\tUserIDs      []string  `json:\"user_ids\"`\n\tCreatedAt    time.Time `json:\"created_at\"`\n\tLoginAt      time.Time `json:\"login_at\"`\n\tExpireAt     time.Time `json:\"expire_at\"`\n\tLastAccessAt time.Time `json:\"last_access_at\"`\n\tLastRefresh  time.Time `json:\"last_refresh\"` // Cookie上次刷新时间\n\n\tQRCodeCache     []byte    `json:\"-\"`\n\tQRCodeCacheTime time.Time `json:\"-\"`\n\tQrsig           string    `json:\"-\"`\n}\n\ntype UserTask struct {\n\tUserID string\n\tCookie string\n}\n\nfunc init() {\n\tp := &WeiboPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"weibo\", 3),\n\t}\n\n\tplugin.RegisterGlobalPlugin(p)\n}\n\n// Initialize 实现 InitializablePlugin 接口，延迟初始化插件\nfunc (p *WeiboPlugin) Initialize() error {\n\tif p.initialized {\n\t\treturn nil\n\t}\n\n\t// 初始化存储目录路径\n\tcachePath := os.Getenv(\"CACHE_PATH\")\n\tif cachePath == \"\" {\n\t\tcachePath = \"./cache\"\n\t}\n\tStorageDir = filepath.Join(cachePath, \"weibo_users\")\n\n\tif err := os.MkdirAll(StorageDir, 0755); err != nil {\n\t\treturn fmt.Errorf(\"创建存储目录失败: %v\", err)\n\t}\n\n\tp.loadAllUsers()\n\tgo p.startCleanupTask()\n\n\tp.initialized = true\n\treturn nil\n}\n\nfunc (p *WeiboPlugin) RegisterWebRoutes(router *gin.RouterGroup) {\n\tweibo := router.Group(\"/weibo\")\n\tweibo.GET(\"/:param\", p.handleManagePage)\n\tweibo.POST(\"/:param\", p.handleManagePagePOST)\n\t\n\tfmt.Printf(\"[Weibo] Web路由已注册: /weibo/:param\\n\")\n}\n\nfunc (p *WeiboPlugin) SkipServiceFilter() bool {\n\t// 微博插件已经在API层面过滤了关键词，不需要Service层再次过滤\n\treturn true\n}\n\nfunc (p *WeiboPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\nfunc (p *WeiboPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\tif DebugLog {\n\t\tfmt.Printf(\"[Weibo] ========== 开始搜索: %s ==========\\n\", keyword)\n\t}\n\n\tusers := p.getActiveUsers()\n\tif DebugLog {\n\t\tfmt.Printf(\"[Weibo] 找到 %d 个有效用户\\n\", len(users))\n\t}\n\n\tif len(users) == 0 {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Weibo] 没有有效用户，返回空结果\\n\")\n\t\t}\n\t\treturn model.PluginSearchResult{Results: []model.SearchResult{}, IsFinal: true}, nil\n\t}\n\n\tif len(users) > MaxConcurrentUsers {\n\t\tsort.Slice(users, func(i, j int) bool {\n\t\t\treturn users[i].LastAccessAt.After(users[j].LastAccessAt)\n\t\t})\n\t\tusers = users[:MaxConcurrentUsers]\n\t}\n\n\ttasks := p.buildUserTasks(users)\n\tresults := p.executeTasks(tasks, keyword)\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Weibo] 搜索完成，返回 %d 条结果\\n\", len(results))\n\t}\n\n\treturn model.PluginSearchResult{\n\t\tResults: results,\n\t\tIsFinal: true,\n\t\tSource:  \"plugin:weibo\",\n\t}, nil\n}\n\nfunc (p *WeiboPlugin) loadAllUsers() {\n\tfiles, err := ioutil.ReadDir(StorageDir)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tcount := 0\n\tfor _, file := range files {\n\t\tif file.IsDir() || filepath.Ext(file.Name()) != \".json\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tfilePath := filepath.Join(StorageDir, file.Name())\n\t\tdata, err := ioutil.ReadFile(filePath)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar user User\n\t\tif err := json.Unmarshal(data, &user); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tp.users.Store(user.Hash, &user)\n\t\tcount++\n\t}\n\n\tfmt.Printf(\"[Weibo] 已加载 %d 个用户到内存\\n\", count)\n}\n\nfunc (p *WeiboPlugin) getUserByHash(hash string) (*User, bool) {\n\tvalue, ok := p.users.Load(hash)\n\tif !ok {\n\t\treturn nil, false\n\t}\n\treturn value.(*User), true\n}\n\nfunc (p *WeiboPlugin) saveUser(user *User) error {\n\tp.users.Store(user.Hash, user)\n\treturn p.persistUser(user)\n}\n\nfunc (p *WeiboPlugin) persistUser(user *User) error {\n\tfilePath := filepath.Join(StorageDir, user.Hash+\".json\")\n\tdata, err := json.MarshalIndent(user, \"\", \"  \")\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn ioutil.WriteFile(filePath, data, 0644)\n}\n\nfunc (p *WeiboPlugin) deleteUser(hash string) error {\n\tp.users.Delete(hash)\n\tfilePath := filepath.Join(StorageDir, hash+\".json\")\n\treturn os.Remove(filePath)\n}\n\nfunc (p *WeiboPlugin) getActiveUsers() []*User {\n\tvar users []*User\n\n\tp.users.Range(func(key, value interface{}) bool {\n\t\tuser := value.(*User)\n\t\t\n\t\tif user.Status != \"active\" {\n\t\t\treturn true\n\t\t}\n\n\t\tif !user.ExpireAt.IsZero() && time.Now().After(user.ExpireAt) {\n\t\t\tuser.Status = \"expired\"\n\t\t\tuser.Cookie = \"\"\n\t\t\tp.saveUser(user)\n\t\t\treturn true\n\t\t}\n\n\t\tif len(user.UserIDs) == 0 {\n\t\t\treturn true\n\t\t}\n\n\t\tusers = append(users, user)\n\t\treturn true\n\t})\n\n\treturn users\n}\n\nfunc (p *WeiboPlugin) handleManagePage(c *gin.Context) {\n\tparam := c.Param(\"param\")\n\n\tif len(param) == 64 && p.isHexString(param) {\n\t\thtml := strings.ReplaceAll(HTMLTemplate, \"HASH_PLACEHOLDER\", param)\n\t\tc.Data(200, \"text/html; charset=utf-8\", []byte(html))\n\t} else {\n\t\thash := p.generateHash(param)\n\t\tc.Redirect(302, \"/weibo/\"+hash)\n\t}\n}\n\nfunc (p *WeiboPlugin) handleManagePagePOST(c *gin.Context) {\n\thash := c.Param(\"param\")\n\n\tvar reqData map[string]interface{}\n\tif err := c.ShouldBindJSON(&reqData); err != nil {\n\t\trespondError(c, \"无效的请求格式: \"+err.Error())\n\t\treturn\n\t}\n\n\taction, ok := reqData[\"action\"].(string)\n\tif !ok || action == \"\" {\n\t\trespondError(c, \"缺少action字段\")\n\t\treturn\n\t}\n\n\tswitch action {\n\tcase \"get_status\":\n\t\tp.handleGetStatus(c, hash)\n\tcase \"refresh_qrcode\":\n\t\tp.handleRefreshQRCode(c, hash)\n\tcase \"logout\":\n\t\tp.handleLogout(c, hash)\n\tcase \"set_user_ids\":\n\t\tp.handleSetUserIDs(c, hash, reqData)\n\tcase \"test_search\":\n\t\tp.handleTestSearch(c, hash, reqData)\n\tcase \"check_login\":\n\t\tp.handleCheckLogin(c, hash)\n\tdefault:\n\t\trespondError(c, \"未知的操作类型: \"+action)\n\t}\n}\n\nfunc (p *WeiboPlugin) handleGetStatus(c *gin.Context, hash string) {\n\tuser, exists := p.getUserByHash(hash)\n\n\tif !exists {\n\t\tuser = &User{\n\t\t\tHash:         hash,\n\t\t\tStatus:       \"pending\",\n\t\t\tUserIDs:      []string{},\n\t\t\tCreatedAt:    time.Now(),\n\t\t\tLastAccessAt: time.Now(),\n\t\t}\n\t\tp.saveUser(user)\n\t} else {\n\t\tuser.LastAccessAt = time.Now()\n\t\tp.saveUser(user)\n\t}\n\n\tloggedIn := false\n\tif user.Status == \"active\" && user.Cookie != \"\" {\n\t\tloggedIn = true\n\t}\n\t\n\tfmt.Printf(\"[Weibo DEBUG] handleGetStatus - hash: %s, Status: %s, Cookie长度: %d, loggedIn: %v\\n\", \n\t\thash, user.Status, len(user.Cookie), loggedIn)\n\n\tvar qrcodeBase64 string\n\tif !loggedIn {\n\t\tif user.QRCodeCache != nil && time.Since(user.QRCodeCacheTime) < 30*time.Second {\n\t\t\tqrcodeBase64 = \"data:image/png;base64,\" + base64.StdEncoding.EncodeToString(user.QRCodeCache)\n\t\t} else {\n\t\t\tqrcodeBytes, qrsig, err := p.generateQRCodeWithSig()\n\t\t\tif err == nil {\n\t\t\t\tqrcodeBase64 = \"data:image/png;base64,\" + base64.StdEncoding.EncodeToString(qrcodeBytes)\n\t\t\t\tuser.QRCodeCache = qrcodeBytes\n\t\t\t\tuser.QRCodeCacheTime = time.Now()\n\t\t\t\tuser.Qrsig = qrsig\n\t\t\t\tp.saveUser(user)\n\t\t\t}\n\t\t}\n\t}\n\n\texpiresInDays := 0\n\tif !user.ExpireAt.IsZero() {\n\t\texpiresInDays = int(time.Until(user.ExpireAt).Hours() / 24)\n\t\tif expiresInDays < 0 {\n\t\t\texpiresInDays = 0\n\t\t}\n\t}\n\n\tresponseData := gin.H{\n\t\t\"hash\":            hash,\n\t\t\"logged_in\":       loggedIn,\n\t\t\"status\":          user.Status,\n\t\t\"login_time\":      user.LoginAt.Format(\"2006-01-02 15:04:05\"),\n\t\t\"expire_time\":     user.ExpireAt.Format(\"2006-01-02 15:04:05\"),\n\t\t\"expires_in_days\": expiresInDays,\n\t\t\"user_ids\":        user.UserIDs,\n\t\t\"qrcode_base64\":   qrcodeBase64,\n\t}\n\t\n\tfmt.Printf(\"[Weibo DEBUG] handleGetStatus响应 - logged_in: %v, status: %s\\n\", loggedIn, user.Status)\n\t\n\trespondSuccess(c, \"获取成功\", responseData)\n}\n\nfunc (p *WeiboPlugin) handleRefreshQRCode(c *gin.Context, hash string) {\n\tuser, exists := p.getUserByHash(hash)\n\tif !exists {\n\t\trespondError(c, \"用户不存在\")\n\t\treturn\n\t}\n\n\tqrcodeBytes, qrsig, err := p.generateQRCodeWithSig()\n\tif err != nil {\n\t\trespondError(c, \"生成二维码失败: \"+err.Error())\n\t\treturn\n\t}\n\n\tuser.QRCodeCache = qrcodeBytes\n\tuser.QRCodeCacheTime = time.Now()\n\tuser.Qrsig = qrsig\n\tp.saveUser(user)\n\n\tqrcodeBase64 := \"data:image/png;base64,\" + base64.StdEncoding.EncodeToString(qrcodeBytes)\n\n\trespondSuccess(c, \"二维码已刷新\", gin.H{\n\t\t\"qrcode_base64\": qrcodeBase64,\n\t})\n}\n\nfunc (p *WeiboPlugin) handleLogout(c *gin.Context, hash string) {\n\tuser, exists := p.getUserByHash(hash)\n\tif !exists {\n\t\trespondError(c, \"用户不存在\")\n\t\treturn\n\t}\n\n\tuser.Cookie = \"\"\n\tuser.Status = \"pending\"\n\n\tif err := p.saveUser(user); err != nil {\n\t\trespondError(c, \"退出失败\")\n\t\treturn\n\t}\n\n\trespondSuccess(c, \"已退出登录\", gin.H{\n\t\t\"status\": \"pending\",\n\t})\n}\n\nfunc (p *WeiboPlugin) handleCheckLogin(c *gin.Context, hash string) {\n\tuser, exists := p.getUserByHash(hash)\n\tif !exists {\n\t\trespondError(c, \"用户不存在\")\n\t\treturn\n\t}\n\n\tif user.Qrsig == \"\" {\n\t\trespondError(c, \"请先刷新二维码\")\n\t\treturn\n\t}\n\n\tloginResult, err := p.checkQRLoginStatus(user.Qrsig)\n\tif err != nil {\n\t\tfmt.Printf(\"[Weibo] checkQRLoginStatus错误: %v\\n\", err)\n\t\trespondError(c, err.Error())\n\t\treturn\n\t}\n\n\tfmt.Printf(\"[Weibo] checkQRLoginStatus返回状态: %s, Cookie长度: %d\\n\", loginResult.Status, len(loginResult.Cookie))\n\n\tif loginResult.Status == \"success\" {\n\t\tfmt.Printf(\"[Weibo DEBUG] 登录成功! 开始更新用户状态...\\n\")\n\t\t\n\t\tuser.Cookie = loginResult.Cookie\n\t\tuser.Status = \"active\"\n\t\tuser.LoginAt = time.Now()\n\t\tuser.ExpireAt = time.Now().AddDate(0, 0, 30)\n\t\tuser.Qrsig = \"\"\n\t\tuser.QRCodeCache = nil\n\t\t\n\t\tfmt.Printf(\"[Weibo DEBUG] 更新后 - Status: %s, Cookie长度: %d\\n\", user.Status, len(user.Cookie))\n\n\t\t// 保存到内存和文件\n\t\tp.users.Store(hash, user)\n\t\tfmt.Printf(\"[Weibo DEBUG] 已保存到内存\\n\")\n\t\t\n\t\tif err := p.persistUser(user); err != nil {\n\t\t\tfmt.Printf(\"[Weibo DEBUG] 持久化失败: %v\\n\", err)\n\t\t\trespondError(c, \"保存失败: \"+err.Error())\n\t\t\treturn\n\t\t}\n\t\tfmt.Printf(\"[Weibo DEBUG] 已持久化到文件\\n\")\n\n\t\trespondSuccess(c, \"登录成功\", gin.H{\n\t\t\t\"login_status\": \"success\",\n\t\t})\n\t\tfmt.Printf(\"[Weibo DEBUG] 已返回成功响应\\n\")\n\t} else if loginResult.Status == \"waiting\" {\n\t\trespondSuccess(c, \"等待扫码\", gin.H{\n\t\t\t\"login_status\": \"waiting\",\n\t\t})\n\t} else if loginResult.Status == \"expired\" {\n\t\trespondError(c, \"二维码已失效，请刷新\")\n\t} else {\n\t\trespondSuccess(c, \"等待扫码\", gin.H{\n\t\t\t\"login_status\": \"waiting\",\n\t\t})\n\t}\n}\n\nfunc (p *WeiboPlugin) handleSetUserIDs(c *gin.Context, hash string, reqData map[string]interface{}) {\n\tuserIDsInterface, ok := reqData[\"user_ids\"]\n\tif !ok {\n\t\trespondError(c, \"缺少user_ids字段\")\n\t\treturn\n\t}\n\n\tuserIDs := []string{}\n\tif userIDsList, ok := userIDsInterface.([]interface{}); ok {\n\t\tfor _, uid := range userIDsList {\n\t\t\tif uidStr, ok := uid.(string); ok {\n\t\t\t\tuserIDs = append(userIDs, uidStr)\n\t\t\t}\n\t\t}\n\t}\n\n\tuser, exists := p.getUserByHash(hash)\n\tif !exists {\n\t\trespondError(c, \"用户不存在\")\n\t\treturn\n\t}\n\n\tnormalizedUserIDs := []string{}\n\tseen := make(map[string]bool)\n\n\tfor _, uid := range userIDs {\n\t\tuid = strings.TrimSpace(uid)\n\t\tif uid == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !seen[uid] {\n\t\t\tnormalizedUserIDs = append(normalizedUserIDs, uid)\n\t\t\tseen[uid] = true\n\t\t}\n\t}\n\n\tuser.UserIDs = normalizedUserIDs\n\tuser.LastAccessAt = time.Now()\n\n\tif err := p.saveUser(user); err != nil {\n\t\trespondError(c, \"保存失败: \"+err.Error())\n\t\treturn\n\t}\n\n\trespondSuccess(c, \"用户列表已更新\", gin.H{\n\t\t\"user_ids\":   normalizedUserIDs,\n\t\t\"user_count\": len(normalizedUserIDs),\n\t})\n}\n\nfunc (p *WeiboPlugin) handleTestSearch(c *gin.Context, hash string, reqData map[string]interface{}) {\n\tkeyword, ok := reqData[\"keyword\"].(string)\n\tif !ok || keyword == \"\" {\n\t\trespondError(c, \"缺少keyword字段\")\n\t\treturn\n\t}\n\n\tuser, exists := p.getUserByHash(hash)\n\tif !exists || user.Cookie == \"\" {\n\t\trespondError(c, \"请先登录\")\n\t\treturn\n\t}\n\n\tif len(user.UserIDs) == 0 {\n\t\trespondError(c, \"请先配置微博用户ID\")\n\t\treturn\n\t}\n\n\ttasks := []UserTask{}\n\tfor _, uid := range user.UserIDs {\n\t\ttasks = append(tasks, UserTask{\n\t\t\tUserID: uid,\n\t\t\tCookie: user.Cookie,\n\t\t})\n\t}\n\n\tallResults := p.executeTasks(tasks, keyword)\n\n\tmaxResults := 10\n\tif len(allResults) > maxResults {\n\t\tallResults = allResults[:maxResults]\n\t}\n\n\tresults := make([]gin.H, 0, len(allResults))\n\tfor _, r := range allResults {\n\t\tlinks := make([]gin.H, 0, len(r.Links))\n\t\tfor _, link := range r.Links {\n\t\t\tlinks = append(links, gin.H{\n\t\t\t\t\"type\":     link.Type,\n\t\t\t\t\"url\":      link.URL,\n\t\t\t\t\"password\": link.Password,\n\t\t\t})\n\t\t}\n\n\t\tresults = append(results, gin.H{\n\t\t\t\"unique_id\": r.UniqueID,\n\t\t\t\"title\":     r.Title,\n\t\t\t\"links\":     links,\n\t\t})\n\t}\n\n\trespondSuccess(c, fmt.Sprintf(\"找到 %d 条结果\", len(results)), gin.H{\n\t\t\"keyword\":       keyword,\n\t\t\"total_results\": len(results),\n\t\t\"results\":       results,\n\t})\n}\n\nfunc (p *WeiboPlugin) buildUserTasks(users []*User) []UserTask {\n\tuserOwners := make(map[string][]*User)\n\n\tfor _, user := range users {\n\t\tfor _, uid := range user.UserIDs {\n\t\t\tuserOwners[uid] = append(userOwners[uid], user)\n\t\t}\n\t}\n\n\ttasks := []UserTask{}\n\tuserTaskCount := make(map[string]int)\n\n\tfor uid, owners := range userOwners {\n\t\tselectedUser := owners[0]\n\t\tminTasks := userTaskCount[selectedUser.Hash]\n\n\t\tfor _, owner := range owners {\n\t\t\tif count := userTaskCount[owner.Hash]; count < minTasks {\n\t\t\t\tselectedUser = owner\n\t\t\t\tminTasks = count\n\t\t\t}\n\t\t}\n\n\t\t// 检查是否需要刷新Cookie（每小时刷新一次）\n\t\tcookie := selectedUser.Cookie\n\t\tif time.Since(selectedUser.LastRefresh) > time.Hour {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Weibo] Cookie已使用超过1小时，刷新短期令牌...\\n\")\n\t\t\t}\n\t\t\trefreshedCookie := p.refreshCookie(cookie)\n\t\t\tif refreshedCookie != cookie {\n\t\t\t\tselectedUser.Cookie = refreshedCookie\n\t\t\t\tselectedUser.LastRefresh = time.Now()\n\t\t\t\tp.saveUser(selectedUser)\n\t\t\t\tcookie = refreshedCookie\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[Weibo] Cookie刷新成功\\n\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\ttasks = append(tasks, UserTask{\n\t\t\tUserID: uid,\n\t\t\tCookie: cookie,\n\t\t})\n\n\t\tuserTaskCount[selectedUser.Hash]++\n\t}\n\n\treturn tasks\n}\n\nfunc (p *WeiboPlugin) refreshCookie(cookieStr string) string {\n\t// 访问PC端和移动端首页刷新短期令牌（XSRF-TOKEN等）\n\tclient := &http.Client{\n\t\tTimeout: 10 * time.Second,\n\t}\n\t\n\t// 访问PC端首页\n\treqPC, err := http.NewRequest(\"GET\", \"https://weibo.com/\", nil)\n\tif err != nil {\n\t\treturn cookieStr\n\t}\n\treqPC.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\")\n\treqPC.Header.Set(\"Cookie\", cookieStr)\n\t\n\trespPC, err := client.Do(reqPC)\n\tif err != nil {\n\t\treturn cookieStr\n\t}\n\trespPC.Body.Close()\n\t\n\t// 访问移动端首页\n\treqMobile, err := http.NewRequest(\"GET\", \"https://m.weibo.cn/\", nil)\n\tif err != nil {\n\t\treturn cookieStr\n\t}\n\treqMobile.Header.Set(\"User-Agent\", \"Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15\")\n\treqMobile.Header.Set(\"Cookie\", cookieStr)\n\t\n\trespMobile, err := client.Do(reqMobile)\n\tif err != nil {\n\t\treturn cookieStr\n\t}\n\trespMobile.Body.Close()\n\t\n\t// 合并响应中的新Cookie\n\tcookieMap := make(map[string]string)\n\t\n\t// 解析原始Cookie\n\tfor _, item := range strings.Split(cookieStr, \"; \") {\n\t\tif idx := strings.Index(item, \"=\"); idx > 0 {\n\t\t\tkey := item[:idx]\n\t\t\tvalue := item[idx+1:]\n\t\t\tcookieMap[key] = value\n\t\t}\n\t}\n\t\n\t// 更新PC端响应的Cookie\n\tfor _, cookie := range respPC.Cookies() {\n\t\tif cookie.Value != \"\" {\n\t\t\tcookieMap[cookie.Name] = cookie.Value\n\t\t}\n\t}\n\t\n\t// 更新移动端响应的Cookie\n\tfor _, cookie := range respMobile.Cookies() {\n\t\tif cookie.Value != \"\" {\n\t\t\tcookieMap[cookie.Name] = cookie.Value\n\t\t}\n\t}\n\t\n\t// 重新组合Cookie字符串\n\tvar parts []string\n\tfor k, v := range cookieMap {\n\t\tparts = append(parts, fmt.Sprintf(\"%s=%s\", k, v))\n\t}\n\t\n\treturn strings.Join(parts, \"; \")\n}\n\nfunc (p *WeiboPlugin) executeTasks(tasks []UserTask, keyword string) []model.SearchResult {\n\tvar allResults []model.SearchResult\n\tvar mu sync.Mutex\n\tvar wg sync.WaitGroup\n\n\tsemaphore := make(chan struct{}, MaxConcurrentWeibo)\n\n\tfor _, task := range tasks {\n\t\twg.Add(1)\n\t\tgo func(t UserTask) {\n\t\t\tdefer wg.Done()\n\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\n\t\t\tresults := p.searchUserWeibo(t.UserID, t.Cookie, keyword)\n\n\t\t\tmu.Lock()\n\t\t\tallResults = append(allResults, results...)\n\t\t\tmu.Unlock()\n\t\t}(task)\n\t}\n\n\twg.Wait()\n\treturn allResults\n}\n\nfunc (p *WeiboPlugin) searchUserWeibo(uid, cookie, keyword string) []model.SearchResult {\n\tvar results []model.SearchResult\n\tmaxPages := 3\n\n\tclient := &http.Client{\n\t\tTimeout: 30 * time.Second,\n\t}\n\n\tfor page := 1; page <= maxPages; page++ {\n\t\tapiURL := \"https://weibo.com/ajax/profile/searchblog\"\n\t\t\n\t\treq, err := http.NewRequest(\"GET\", apiURL, nil)\n\t\tif err != nil {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Weibo] 创建请求失败: %v\\n\", err)\n\t\t\t}\n\t\t\treturn results\n\t\t}\n\n\t\tq := req.URL.Query()\n\t\tq.Add(\"uid\", uid)\n\t\tq.Add(\"feature\", \"0\")\n\t\tq.Add(\"q\", keyword)\n\t\tq.Add(\"page\", fmt.Sprintf(\"%d\", page))\n\t\treq.URL.RawQuery = q.Encode()\n\n\t\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\")\n\t\treq.Header.Set(\"Referer\", \"https://weibo.com/\")\n\t\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\t\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9\")\n\t\treq.Header.Set(\"Cookie\", cookie)\n\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Weibo] 请求URL: %s\\n\", req.URL.String())\n\t\t\tfmt.Printf(\"[Weibo] Cookie首100字符: %s\\n\", cookie[:min(100, len(cookie))])\n\t\t}\n\n\t\tresp, err := client.Do(req)\n\t\tif err != nil {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Weibo] 请求失败: %v\\n\", err)\n\t\t\t}\n\t\t\treturn results\n\t\t}\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tresp.Body.Close()\n\t\tif err != nil {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Weibo] 读取响应失败: %v\\n\", err)\n\t\t\t}\n\t\t\treturn results\n\t\t}\n\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Weibo] 响应状态码: %d\\n\", resp.StatusCode)\n\t\t\tif len(body) > 0 {\n\t\t\t\tfmt.Printf(\"[Weibo] 响应内容: %s\\n\", string(body)[:min(500, len(body))])\n\t\t\t}\n\t\t}\n\n\t\tif resp.StatusCode != 200 {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Weibo] HTTP状态码错误: %d\\n\", resp.StatusCode)\n\t\t\t}\n\t\t\treturn results\n\t\t}\n\n\t\tvar apiResp map[string]interface{}\n\t\tif err := json.Unmarshal(body, &apiResp); err != nil {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Weibo] JSON解析失败: %v, 原始内容: %s\\n\", err, string(body)[:min(200, len(body))])\n\t\t\t}\n\t\t\treturn results\n\t\t}\n\n\t\t// ok字段判断：支持多种类型（json.Number, float64, int, bool等）\n\t\tokValue := apiResp[\"ok\"]\n\t\tisOK := false\n\t\t\n\t\tif okValue != nil {\n\t\t\tokStr := fmt.Sprintf(\"%v\", okValue)\n\t\t\tisOK = (okStr == \"1\" || okStr == \"true\")\n\t\t}\n\t\t\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Weibo] ok字段: %v (类型:%T), 判断结果: %v\\n\", okValue, okValue, isOK)\n\t\t}\n\t\t\n\t\tif !isOK {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Weibo] API返回失败, msg=%v, 停止搜索\\n\", apiResp[\"msg\"])\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\n\t\tdata, _ := apiResp[\"data\"].(map[string]interface{})\n\t\tif data == nil {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Weibo] data字段为nil\\n\")\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\t\n\t\tlist, _ := data[\"list\"].([]interface{})\n\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Weibo] 第%d页返回%d条微博\\n\", page, len(list))\n\t\t}\n\n\t\tif len(list) == 0 {\n\t\t\tbreak\n\t\t}\n\n\t\t// 并发处理每条微博（获取评论）\n\t\tvar wg sync.WaitGroup\n\t\tvar mu sync.Mutex\n\t\t\n\t\tfor i, item := range list {\n\t\t\titemMap, _ := item.(map[string]interface{})\n\t\t\twg.Add(1)\n\t\t\tgo func(index int, weiboData map[string]interface{}) {\n\t\t\t\tdefer wg.Done()\n\t\t\t\t\n\t\t\t\tresult := p.parseWeibo(weiboData, uid)\n\t\t\t\t\n\t\t\t\t// 获取微博ID用于获取评论\n\t\t\t\tweiboID := \"\"\n\t\t\t\tif idStr, ok := weiboData[\"idstr\"].(string); ok {\n\t\t\t\t\tweiboID = idStr\n\t\t\t\t} else if idNum, ok := weiboData[\"id\"].(float64); ok {\n\t\t\t\t\tweiboID = fmt.Sprintf(\"%.0f\", idNum)\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[Weibo] 微博%d: 标题=%s, 正文链接数=%d\\n\", index+1, result.Title[:min(30, len(result.Title))], len(result.Links))\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// 如果正文没有网盘链接，才获取评论\n\t\t\t\tif len(result.Links) == 0 && weiboID != \"\" {\n\t\t\t\t\tif DebugLog {\n\t\t\t\t\t\tfmt.Printf(\"[Weibo] 正文无链接，获取评论...\\n\")\n\t\t\t\t\t}\n\t\t\t\t\tcomments := p.getComments(weiboID, cookie, MaxComments)\n\t\t\t\t\t\n\t\t\t\t\tcommentLinkCount := 0\n\t\t\t\t\tfor _, comment := range comments {\n\t\t\t\t\t\t// 1. 从评论文本直接提取网盘链接\n\t\t\t\t\t\tcommentLinks := extractNetworkDriveLinks(comment.Text, result.Datetime)\n\t\t\t\t\t\t\n\t\t\t\t\t\t// 2. 从评论中的URLs（已解码的sinaurl）提取网盘链接或抓取页面\n\t\t\t\t\t\tfor _, decodedURL := range comment.URLs {\n\t\t\t\t\t\t\t// 先尝试直接匹配网盘链接\n\t\t\t\t\t\t\tdirectLinks := extractNetworkDriveLinks(decodedURL, result.Datetime)\n\t\t\t\t\t\t\tif len(directLinks) > 0 {\n\t\t\t\t\t\t\t\tcommentLinks = append(commentLinks, directLinks...)\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t// 不是网盘链接，尝试抓取页面内容\n\t\t\t\t\t\t\t\tif DebugLog {\n\t\t\t\t\t\t\t\t\tfmt.Printf(\"[Weibo] 评论链接不是网盘，抓取页面: %s\\n\", decodedURL)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tpageLinks := fetchPageAndExtractLinks(decodedURL, result.Datetime)\n\t\t\t\t\t\t\t\tcommentLinks = append(commentLinks, pageLinks...)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\t\n\t\t\t\t\t\t// 添加到结果\n\t\t\t\t\t\tresult.Links = append(result.Links, commentLinks...)\n\t\t\t\t\t\tcommentLinkCount += len(commentLinks)\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\tif DebugLog {\n\t\t\t\t\t\tfmt.Printf(\"[Weibo] 获取%d条评论, 评论链接数=%d, 总链接数=%d\\n\", len(comments), commentLinkCount, len(result.Links))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tif len(result.Links) > 0 {\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\tresults = append(results, result)\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t\t\n\t\t\t\t\tif DebugLog {\n\t\t\t\t\t\tfmt.Printf(\"[Weibo] ✓ 找到网盘链接: %s, 链接数: %d\\n\", result.Title, len(result.Links))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}(i, itemMap)\n\t\t}\n\t\t\n\t\twg.Wait()\n\n\t\ttime.Sleep(time.Second)\n\t}\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[Weibo] 用户%s搜索完成, 共%d条结果\\n\", uid, len(results))\n\t}\n\treturn results\n}\n\nfunc (p *WeiboPlugin) getComments(weiboID, cookie string, maxComments int) []Comment {\n\tvar comments []Comment\n\tmaxID := 0\n\tmaxIDType := 0\n\t\n\tclient := &http.Client{\n\t\tTimeout: 30 * time.Second,\n\t}\n\t\n\tfor len(comments) < maxComments {\n\t\tapiURL := \"https://m.weibo.cn/comments/hotflow\"\n\t\t\n\t\treq, err := http.NewRequest(\"GET\", apiURL, nil)\n\t\tif err != nil {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Weibo] 创建评论请求失败: %v\\n\", err)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\t\n\t\tq := req.URL.Query()\n\t\tq.Add(\"id\", weiboID)\n\t\tq.Add(\"mid\", weiboID)\n\t\tq.Add(\"max_id\", fmt.Sprintf(\"%d\", maxID))\n\t\tq.Add(\"max_id_type\", fmt.Sprintf(\"%d\", maxIDType))\n\t\treq.URL.RawQuery = q.Encode()\n\t\t\n\t\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15\")\n\t\treq.Header.Set(\"Referer\", \"https://m.weibo.cn/\")\n\t\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\t\treq.Header.Set(\"Cookie\", cookie)\n\t\t\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Weibo] 获取评论: %s, max_id=%d\\n\", weiboID, maxID)\n\t\t}\n\t\t\n\t\tresp, err := client.Do(req)\n\t\tif err != nil {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Weibo] 评论请求失败: %v\\n\", err)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\t\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tresp.Body.Close()\n\t\tif err != nil {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Weibo] 读取评论响应失败: %v\\n\", err)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\t\n\t\tif resp.StatusCode != 200 {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Weibo] 评论API状态码错误: %d\\n\", resp.StatusCode)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\t\n\t\tvar apiResp map[string]interface{}\n\t\tif err := json.Unmarshal(body, &apiResp); err != nil {\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[Weibo] 评论JSON解析失败: %v\\n\", err)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\t\n\t\tdata, _ := apiResp[\"data\"].(map[string]interface{})\n\t\tif data == nil {\n\t\t\tbreak\n\t\t}\n\t\t\n\t\tcommentList, _ := data[\"data\"].([]interface{})\n\t\tif len(commentList) == 0 {\n\t\t\tbreak\n\t\t}\n\t\t\n\t\tfor _, item := range commentList {\n\t\t\tcommentMap, _ := item.(map[string]interface{})\n\t\t\trawText, _ := commentMap[\"text\"].(string)\n\t\t\t\n\t\t\tcleanText := cleanHTML(rawText)\n\t\t\turls := extractURLsFromComment(rawText)\n\t\t\t\n\t\t\tcomments = append(comments, Comment{\n\t\t\t\tText: cleanText,\n\t\t\t\tURLs: urls,\n\t\t\t})\n\t\t\t\n\t\t\tif len(comments) >= maxComments {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\t\n\t\tnewMaxID := 0\n\t\tif maxIDVal, ok := data[\"max_id\"].(float64); ok {\n\t\t\tnewMaxID = int(maxIDVal)\n\t\t}\n\t\t\n\t\tif newMaxID == 0 || newMaxID == maxID {\n\t\t\tbreak\n\t\t}\n\t\t\n\t\tmaxID = newMaxID\n\t\tif maxIDTypeVal, ok := data[\"max_id_type\"].(float64); ok {\n\t\t\tmaxIDType = int(maxIDTypeVal)\n\t\t}\n\t\t\n\t\ttime.Sleep(500 * time.Millisecond)\n\t}\n\t\n\tif DebugLog && len(comments) > 0 {\n\t\tfmt.Printf(\"[Weibo] 获取到%d条评论\\n\", len(comments))\n\t}\n\t\n\treturn comments\n}\n\nfunc extractURLsFromComment(htmlText string) []string {\n\tif htmlText == \"\" {\n\t\treturn []string{}\n\t}\n\t\n\tpattern := regexp.MustCompile(`https://weibo\\.cn/sinaurl\\?u=([^\"&\\s]+)`)\n\tmatches := pattern.FindAllStringSubmatch(htmlText, -1)\n\t\n\tvar urls []string\n\tfor _, match := range matches {\n\t\tif len(match) > 1 {\n\t\t\tdecoded, err := url.QueryUnescape(match[1])\n\t\t\tif err == nil {\n\t\t\t\turls = append(urls, decoded)\n\t\t\t}\n\t\t}\n\t}\n\t\n\treturn urls\n}\n\n// fetchPageAndExtractLinks 抓取页面内容并提取网盘链接\nfunc fetchPageAndExtractLinks(pageURL string, datetime time.Time) []model.Link {\n\tclient := &http.Client{\n\t\tTimeout: 15 * time.Second,\n\t}\n\t\n\treq, err := http.NewRequest(\"GET\", pageURL, nil)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\")\n\t\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil\n\t}\n\t\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\t// 从HTML中提取网盘链接\n\thtmlContent := string(body)\n\treturn extractNetworkDriveLinks(htmlContent, datetime)\n}\n\ntype Comment struct {\n\tText string\n\tURLs []string\n}\n\nfunc (p *WeiboPlugin) parseWeibo(weibo map[string]interface{}, uid string) model.SearchResult {\n\t// 优先使用text_raw，其次使用text\n\ttextRaw, _ := weibo[\"text_raw\"].(string)\n\tif textRaw == \"\" {\n\t\ttextRaw, _ = weibo[\"text\"].(string)\n\t}\n\t\n\t// 检查是否是长文本（需要额外请求获取完整内容）\n\tisLongText := false\n\tif longTextFlag, ok := weibo[\"isLongText\"].(bool); ok && longTextFlag {\n\t\tisLongText = true\n\t}\n\t\n\t// 先获取发布时间\n\tcreatedAt, _ := weibo[\"created_at\"].(string)\n\tpublishTime := time.Now()\n\tif createdAt != \"\" {\n\t\tif t, err := time.Parse(\"Mon Jan 02 15:04:05 -0700 2006\", createdAt); err == nil {\n\t\t\tpublishTime = t\n\t\t}\n\t}\n\t\n\ttext := cleanHTML(textRaw)\n\t\n\tif DebugLog && len(text) > 0 {\n\t\ttruncated := \"\"\n\t\tif isLongText {\n\t\t\ttruncated = \" [长文本-可能被截断]\"\n\t\t}\n\t\tfmt.Printf(\"[Weibo DEBUG] 微博原始文本%s: %s\\n\", truncated, text[:min(200, len(text))])\n\t}\n\n\t// 1. 直接从文本中提取网盘链接\n\tlinks := extractNetworkDriveLinks(text, publishTime)\n\tfmt.Println(links)\n\t\n\t// 2. 处理url_struct字段中的链接（包含所有外部链接，已由微博API解码）\n\tif urlStruct, ok := weibo[\"url_struct\"].([]interface{}); ok && len(urlStruct) > 0 {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[Weibo DEBUG] 发现url_struct字段，包含%d个链接\\n\", len(urlStruct))\n\t\t}\n\t\t\n\t\tfor _, urlItem := range urlStruct {\n\t\t\tif urlMap, ok := urlItem.(map[string]interface{}); ok {\n\t\t\t\tif urlMap[\"url_title\"] != \"网页链接\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tlongURL, _ := urlMap[\"long_url\"].(string)\n\t\t\t\t\n\t\t\t\tif longURL == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tif DebugLog {\n\t\t\t\t\tfmt.Printf(\"[Weibo DEBUG] url_struct中的长链接: %s\\n\", longURL)\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// 先尝试直接匹配网盘链接\n\t\t\t\tdirectLinks := extractNetworkDriveLinks(longURL, publishTime)\n\t\t\t\tif len(directLinks) > 0 {\n\t\t\t\t\tlinks = append(links, directLinks...)\n\t\t\t\t\tif DebugLog {\n\t\t\t\t\t\tfmt.Printf(\"[Weibo DEBUG] url_struct直接匹配到网盘链接: %d个\\n\", len(directLinks))\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// 不是网盘链接，尝试抓取页面内容\n\t\t\t\t\tif DebugLog {\n\t\t\t\t\t\tfmt.Printf(\"[Weibo DEBUG] url_struct链接不是网盘，尝试抓取页面: %s\\n\", longURL)\n\t\t\t\t\t}\n\t\t\t\t\tpageLinks := fetchPageAndExtractLinks(longURL, publishTime)\n\t\t\t\t\tif len(pageLinks) > 0 {\n\t\t\t\t\t\tlinks = append(links, pageLinks...)\n\t\t\t\t\t\tif DebugLog {\n\t\t\t\t\t\t\tfmt.Printf(\"[Weibo DEBUG] 从url_struct页面提取到网盘链接: %d个\\n\", len(pageLinks))\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\tif DebugLog {\n\t\tfmt.Printf(\"[Weibo DEBUG] 最终共提取到%d个网盘链接\\n\", len(links))\n\t}\n\n\ttitle := text\n\tif len(text) > 100 {\n\t\ttitle = text[:100] + \"...\"\n\t}\n\n\t// 获取微博ID，支持多种类型\n\tid := \"\"\n\tif idStr, ok := weibo[\"idstr\"].(string); ok {\n\t\tid = idStr\n\t} else if idStr, ok := weibo[\"id\"].(string); ok {\n\t\tid = idStr\n\t} else if idNum, ok := weibo[\"id\"].(float64); ok {\n\t\tid = fmt.Sprintf(\"%.0f\", idNum)\n\t} else {\n\t\t// 如果以上都失败，尝试转字符串\n\t\tid = fmt.Sprintf(\"%v\", weibo[\"id\"])\n\t}\n\n\treturn model.SearchResult{\n\t\tUniqueID: fmt.Sprintf(\"weibo-%s-%s\", uid, id),\n\t\tChannel:  \"\",\n\t\tDatetime: publishTime,\n\t\tTitle:    title,\n\t\tContent:  text,\n\t\tLinks:    links,\n\t}\n}\n\nfunc extractNetworkDriveLinks(text string, datetime time.Time) []model.Link {\n\tvar links []model.Link\n\tseenURLs := make(map[string]bool) // 用于去重\n\n\tpatterns := map[string]string{\n\t\t\"baidu\":  `https?://pan\\.baidu\\.com/s/[a-zA-Z0-9_-]+(?:\\?pwd=[a-zA-Z0-9]+)?`,\n\t\t\"quark\":  `https?://pan\\.quark\\.cn/s/[a-zA-Z0-9]+(?:\\?pwd=[a-zA-Z0-9]+)?`,\n\t\t\"aliyun\": `https?://www\\.alip?a?n\\.com/s/[a-zA-Z0-9]+(?:\\?[^\\s]*)?|https?://www\\.aliyundrive\\.com/s/[a-zA-Z0-9]+(?:\\?[^\\s]*)?`,\n\t\t\"115\":    `https?://115\\.com/s/[a-zA-Z0-9]+(?:\\?[^\\s]*)?`,\n\t\t\"tianyi\": `https?://cloud\\.189\\.cn/(?:t/|web/share\\?code=)[a-zA-Z0-9]+(?:&?[^\\s]*)?`,\n\t\t\"xunlei\": `https?://pan\\.xunlei\\.com/s/[a-zA-Z0-9_-]+(?:\\?[^\\s]*)?`,\n\t\t\"123\":    `https?://www\\.123pan\\.com/s/[a-zA-Z0-9_-]+(?:\\?[^\\s]*)?`,\n\t\t\"pikpak\": `https?://mypikpak\\.com/s/[a-zA-Z0-9]+(?:\\?[^\\s]*)?`,\n\t}\n\n\tpwdPatterns := []string{\n\t\t`(?:密码|提取码|访问码|pwd|code)[:：\\s]*([a-zA-Z0-9]{4})`,\n\t\t`pwd=([a-zA-Z0-9]{4})`,\n\t}\n\n\tfor linkType, pattern := range patterns {\n\t\tre := regexp.MustCompile(pattern)\n\t\tmatches := re.FindAllString(text, -1)\n\n\t\tfor _, match := range matches {\n\t\t\t// 去重检查\n\t\t\tif seenURLs[match] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseenURLs[match] = true\n\t\t\t\n\t\t\tpassword := \"\"\n\t\t\tstart := strings.Index(text, match)\n\t\t\tif start != -1 {\n\t\t\t\tcontextStart := start - 50\n\t\t\t\tif contextStart < 0 {\n\t\t\t\t\tcontextStart = 0\n\t\t\t\t}\n\t\t\t\tcontextEnd := start + len(match) + 50\n\t\t\t\tif contextEnd > len(text) {\n\t\t\t\t\tcontextEnd = len(text)\n\t\t\t\t}\n\t\t\t\tcontext := text[contextStart:contextEnd]\n\n\t\t\t\tfor _, pwdPattern := range pwdPatterns {\n\t\t\t\t\tpwdRe := regexp.MustCompile(pwdPattern)\n\t\t\t\t\tif pwdMatch := pwdRe.FindStringSubmatch(context); len(pwdMatch) > 1 {\n\t\t\t\t\t\tpassword = pwdMatch[1]\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlinks = append(links, model.Link{\n\t\t\t\tType:     linkType,\n\t\t\t\tURL:      match,\n\t\t\t\tPassword: password,\n\t\t\t\tDatetime: datetime,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn links\n}\n\nfunc min(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}\n\nfunc cleanHTML(html string) string {\n\tre := regexp.MustCompile(`<[^>]+>`)\n\ttext := re.ReplaceAllString(html, \"\")\n\ttext = strings.TrimSpace(text)\n\ttext = regexp.MustCompile(`\\s+`).ReplaceAllString(text, \" \")\n\treturn text\n}\n\ntype LoginResult struct {\n\tStatus  string\n\tCookie  string\n\tMessage string\n}\n\nfunc (p *WeiboPlugin) checkQRLoginStatus(qrsig string) (*LoginResult, error) {\n\t// 参考Python auto.py第80行的check_qrcode_status实现\n\t// URL: https://passport.weibo.com/sso/v2/qrcode/check?entry=sso&qrid={qrid}&callback=STK_{timestamp}\n\t\n\t// 但我们使用qrsig而不是qrid，需要从session cookie中获取qrid\n\t// 实际上Python实现中，qrsig是从get_qrcode的响应中提取的qrid\n\t// 这里我们用qrsig作为qrid\n\t\n\ttimestamp := time.Now().UnixMilli()\n\tcheckURL := fmt.Sprintf(\"https://passport.weibo.com/sso/v2/qrcode/check?entry=sso&qrid=%s&callback=STK_%d\", qrsig, timestamp)\n\t\n\tfmt.Printf(\"[Weibo DEBUG] checkQRLoginStatus调用 - qrsig: %s\\n\", qrsig)\n\tfmt.Printf(\"[Weibo DEBUG] checkURL: %s\\n\", checkURL)\n\t\n\tclient := &http.Client{\n\t\tTimeout: 15 * time.Second,\n\t\tCheckRedirect: func(req *http.Request, via []*http.Request) error {\n\t\t\treturn http.ErrUseLastResponse\n\t\t},\n\t}\n\t\n\treq, err := http.NewRequest(\"GET\", checkURL, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36\")\n\treq.Header.Set(\"Referer\", \"https://weibo.com/\")\n\t\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\t\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\t// 响应可能是JSONP格式: STK_xxx({...}) 或纯JSON格式: {...}\n\tresponseText := string(body)\n\t\n\tfmt.Printf(\"[Weibo DEBUG] 原始响应: %s\\n\", responseText)\n\t\n\t// 提取JSON部分\n\tvar jsonStr string\n\tif strings.HasPrefix(responseText, \"STK_\") {\n\t\t// JSONP格式: STK_xxx({...})\n\t\tstartIdx := strings.Index(responseText, \"({\")\n\t\tendIdx := strings.LastIndex(responseText, \"})\")\n\t\tif startIdx == -1 || endIdx == -1 {\n\t\t\tfmt.Printf(\"[Weibo DEBUG] JSONP格式解析失败\\n\")\n\t\t\treturn &LoginResult{Status: \"waiting\"}, nil\n\t\t}\n\t\tjsonStr = responseText[startIdx+1 : endIdx+1]\n\t} else if strings.HasPrefix(responseText, \"{\") {\n\t\t// 纯JSON格式: {...}\n\t\tjsonStr = responseText\n\t\tfmt.Printf(\"[Weibo DEBUG] 检测到纯JSON格式响应\\n\")\n\t} else {\n\t\tfmt.Printf(\"[Weibo DEBUG] 未知响应格式\\n\")\n\t\treturn &LoginResult{Status: \"waiting\"}, nil\n\t}\n\t\n\tvar result struct {\n\t\tRetcode int    `json:\"retcode\"`\n\t\tMsg     string `json:\"msg\"`\n\t\tData    struct {\n\t\t\tURL string `json:\"url\"`\n\t\t} `json:\"data\"`\n\t}\n\t\n\tif err := json.Unmarshal([]byte(jsonStr), &result); err != nil {\n\t\tfmt.Printf(\"[Weibo DEBUG] JSON解析失败: %v, JSON字符串: %s\\n\", err, jsonStr)\n\t\treturn &LoginResult{Status: \"waiting\"}, nil\n\t}\n\t\n\tfmt.Printf(\"[Weibo DEBUG] 解析后retcode: %d, msg: %s\\n\", result.Retcode, result.Msg)\n\t\n\t// 参考Python auto.py第93-108行的状态码处理\n\t// 20000000: 扫码成功\n\t// 50114001: 等待扫码\n\t// 50114002: 已扫描，等待确认\n\t// 50114004: 二维码已过期\n\t\n\tif result.Retcode == 20000000 {\n\t\t// 登录成功，需要初始化Cookie\n\t\talt := result.Data.URL\n\t\tfmt.Printf(\"[Weibo DEBUG] 登录成功! alt URL: %s\\n\", alt)\n\t\t\n\t\tcookieStr, err := p.initCookieFromAlt(alt)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"[Weibo DEBUG] 初始化Cookie失败: %v\\n\", err)\n\t\t\treturn nil, fmt.Errorf(\"初始化Cookie失败: %v\", err)\n\t\t}\n\t\t\n\t\tfmt.Printf(\"[Weibo DEBUG] Cookie初始化成功, Cookie长度: %d\\n\", len(cookieStr))\n\t\treturn &LoginResult{Status: \"success\", Cookie: cookieStr}, nil\n\t} else if result.Retcode == 50114002 {\n\t\t// 已扫描，等待确认\n\t\tfmt.Printf(\"[Weibo DEBUG] 已扫描，等待确认\\n\")\n\t\treturn &LoginResult{Status: \"waiting\", Message: \"已扫描，请在手机上确认\"}, nil\n\t} else if result.Retcode == 50114004 {\n\t\t// 二维码已过期\n\t\tfmt.Printf(\"[Weibo DEBUG] 二维码已过期\\n\")\n\t\treturn &LoginResult{Status: \"expired\", Message: \"二维码已过期\"}, nil\n\t}\n\t\n\t// 默认状态：等待扫码\n\tfmt.Printf(\"[Weibo DEBUG] 等待扫码中, retcode: %d\\n\", result.Retcode)\n\treturn &LoginResult{Status: \"waiting\", Message: \"等待扫码中\"}, nil\n}\n\nfunc (p *WeiboPlugin) generateQRCodeWithSig() ([]byte, string, error) {\n\t// 参考Python auto.py第46-75行的get_qrcode实现\n\t// 第一步：获取二维码信息（包含api_key和qrid）\n\ttimestamp := time.Now().UnixMilli()\n\tinfoURL := fmt.Sprintf(\"https://passport.weibo.com/sso/v2/qrcode/image?entry=miniblog&size=180&callback=STK_%d\", timestamp)\n\t\n\tclient := &http.Client{Timeout: 15 * time.Second}\n\t\n\treq, err := http.NewRequest(\"GET\", infoURL, nil)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\t\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36\")\n\treq.Header.Set(\"Referer\", \"https://weibo.com/\")\n\t\n\tinfoResp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\tdefer infoResp.Body.Close()\n\t\n\tinfoBody, err := io.ReadAll(infoResp.Body)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\t\n\t// 响应是JSONP格式，提取JSON部分\n\tinfoText := string(infoBody)\n\t\n\t// 提取api_key: 正则 api_key=(.*)\"\n\tapiKeyRegex := regexp.MustCompile(`api_key=([^\"]+)`)\n\tapiKeyMatch := apiKeyRegex.FindStringSubmatch(infoText)\n\tif len(apiKeyMatch) < 2 {\n\t\treturn nil, \"\", fmt.Errorf(\"无法提取api_key\")\n\t}\n\tapiKey := apiKeyMatch[1]\n\t\n\t// 提取qrid: 正则 \"qrid\":\"(.*?)\"\n\tqridRegex := regexp.MustCompile(`\"qrid\":\"([^\"]+)\"`)\n\tqridMatch := qridRegex.FindStringSubmatch(infoText)\n\tif len(qridMatch) < 2 {\n\t\treturn nil, \"\", fmt.Errorf(\"无法提取qrid\")\n\t}\n\tqrid := qridMatch[1]\n\t\n\t// 第二步：使用api_key获取二维码图片\n\tqrImageURL := fmt.Sprintf(\"https://v2.qr.weibo.cn/inf/gen?api_key=%s\", apiKey)\n\t\n\tqrReq, err := http.NewRequest(\"GET\", qrImageURL, nil)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\t\n\tqrReq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36\")\n\tqrReq.Header.Set(\"Referer\", \"https://weibo.com/\")\n\t\n\tqrResp, err := client.Do(qrReq)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\tdefer qrResp.Body.Close()\n\t\n\tqrcodeBytes, err := io.ReadAll(qrResp.Body)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\t\n\t// 返回二维码图片和qrid（用于后续的登录状态检查）\n\treturn qrcodeBytes, qrid, nil\n}\n\nfunc (p *WeiboPlugin) initCookieFromAlt(alt string) (string, error) {\n\t// 参考Python auto.py第118-146行的init_cookie实现\n\t// 访问alt URL获取PC端Cookie，然后访问移动端获取移动端Cookie\n\t\n\tfmt.Printf(\"[Weibo DEBUG] initCookieFromAlt开始 - alt URL: %s\\n\", alt)\n\t\n\tjar, err := cookiejar.New(nil)\n\tif err != nil {\n\t\tfmt.Printf(\"[Weibo DEBUG] 创建cookiejar失败: %v\\n\", err)\n\t\treturn \"\", err\n\t}\n\t\n\tclient := &http.Client{\n\t\tTimeout: 30 * time.Second,\n\t\tJar:     jar,\n\t\tCheckRedirect: func(req *http.Request, via []*http.Request) error {\n\t\t\t// 允许重定向，但保留Cookie\n\t\t\treturn nil\n\t\t},\n\t}\n\t\n\t// 第一步：访问alt URL（允许重定向）\n\treq1, err := http.NewRequest(\"GET\", alt, nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq1.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36\")\n\treq1.Header.Set(\"Referer\", \"https://weibo.com/\")\n\t\n\tresp1, err := client.Do(req1)\n\tif err != nil {\n\t\tfmt.Printf(\"[Weibo DEBUG] 访问alt URL失败: %v\\n\", err)\n\t\treturn \"\", err\n\t}\n\tresp1.Body.Close()\n\tfmt.Printf(\"[Weibo DEBUG] 步骤1完成: 访问alt URL, 状态码: %d\\n\", resp1.StatusCode)\n\t\n\t// 第二步：访问weibo.com首页\n\treq2, err := http.NewRequest(\"GET\", \"https://weibo.com/\", nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq2.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36\")\n\treq2.Header.Set(\"Referer\", \"https://weibo.com/\")\n\t\n\tresp2, err := client.Do(req2)\n\tif err != nil {\n\t\tfmt.Printf(\"[Weibo DEBUG] 访问weibo.com失败: %v\\n\", err)\n\t\treturn \"\", err\n\t}\n\tresp2.Body.Close()\n\tfmt.Printf(\"[Weibo DEBUG] 步骤2完成: 访问weibo.com, 状态码: %d\\n\", resp2.StatusCode)\n\t\n\t// 第三步：访问移动端首页\n\treq3, err := http.NewRequest(\"GET\", \"https://m.weibo.cn/\", nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq3.Header.Set(\"User-Agent\", \"Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148\")\n\treq3.Header.Set(\"Referer\", \"https://m.weibo.cn/\")\n\t\n\tresp3, err := client.Do(req3)\n\tif err != nil {\n\t\tfmt.Printf(\"[Weibo DEBUG] 访问m.weibo.cn失败: %v\\n\", err)\n\t\treturn \"\", err\n\t}\n\tresp3.Body.Close()\n\tfmt.Printf(\"[Weibo DEBUG] 步骤3完成: 访问m.weibo.cn, 状态码: %d\\n\", resp3.StatusCode)\n\t\n\t// 第四步：访问移动端profile页面\n\treq4, err := http.NewRequest(\"GET\", \"https://m.weibo.cn/profile\", nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq4.Header.Set(\"User-Agent\", \"Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148\")\n\treq4.Header.Set(\"Referer\", \"https://m.weibo.cn/\")\n\t\n\tresp4, err := client.Do(req4)\n\tif err != nil {\n\t\tfmt.Printf(\"[Weibo DEBUG] 访问m.weibo.cn/profile失败: %v\\n\", err)\n\t\treturn \"\", err\n\t}\n\tresp4.Body.Close()\n\tfmt.Printf(\"[Weibo DEBUG] 步骤4完成: 访问m.weibo.cn/profile, 状态码: %d\\n\", resp4.StatusCode)\n\t\n\t// 收集所有Cookie\n\tallCookies := make(map[string]string)\n\t\n\t// 从cookie jar中提取所有Cookie\n\tweiboURL, _ := url.Parse(\"https://weibo.com\")\n\tweiboCNURL, _ := url.Parse(\"https://m.weibo.cn\")\n\t\n\tfor _, cookie := range jar.Cookies(weiboURL) {\n\t\tallCookies[cookie.Name] = cookie.Value\n\t}\n\tfor _, cookie := range jar.Cookies(weiboCNURL) {\n\t\tallCookies[cookie.Name] = cookie.Value\n\t}\n\t\n\tfmt.Printf(\"[Weibo DEBUG] 收集到的Cookie字段: %v\\n\", func() []string {\n\t\tkeys := make([]string, 0, len(allCookies))\n\t\tfor k := range allCookies {\n\t\t\tkeys = append(keys, k)\n\t\t}\n\t\treturn keys\n\t}())\n\t\n\t// 检查必需的Cookie字段\n\trequiredFields := []string{\"SUB\", \"SUBP\"}\n\tfor _, field := range requiredFields {\n\t\tif _, exists := allCookies[field]; !exists {\n\t\t\tfmt.Printf(\"[Weibo DEBUG] 缺少必需的Cookie字段: %s\\n\", field)\n\t\t\treturn \"\", fmt.Errorf(\"缺少必需的Cookie字段: %s\", field)\n\t\t} else {\n\t\t\tfmt.Printf(\"[Weibo DEBUG] ✓ 找到必需字段: %s\\n\", field)\n\t\t}\n\t}\n\t\n\t// 构建Cookie字符串\n\tcookieParts := make([]string, 0, len(allCookies))\n\tfor k, v := range allCookies {\n\t\tcookieParts = append(cookieParts, fmt.Sprintf(\"%s=%s\", k, v))\n\t}\n\t\n\tcookieStr := strings.Join(cookieParts, \"; \")\n\tfmt.Printf(\"[Weibo DEBUG] Cookie初始化完成, 总长度: %d, 字段数: %d\\n\", len(cookieStr), len(allCookies))\n\t\n\treturn cookieStr, nil\n}\n\nfunc (p *WeiboPlugin) generateHash(input string) string {\n\tsalt := os.Getenv(\"WEIBO_HASH_SALT\")\n\tif salt == \"\" {\n\t\tsalt = \"pansou_weibo_secret_2025\"\n\t}\n\tdata := input + salt\n\thash := sha256.Sum256([]byte(data))\n\treturn hex.EncodeToString(hash[:])\n}\n\nfunc (p *WeiboPlugin) isHexString(s string) bool {\n\tfor _, c := range s {\n\t\tif !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc respondSuccess(c *gin.Context, message string, data interface{}) {\n\tc.JSON(200, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": message,\n\t\t\"data\":    data,\n\t})\n}\n\nfunc respondError(c *gin.Context, message string) {\n\tc.JSON(200, gin.H{\n\t\t\"success\": false,\n\t\t\"message\": message,\n\t\t\"data\":    nil,\n\t})\n}\n\nfunc (p *WeiboPlugin) startCleanupTask() {\n\tticker := time.NewTicker(24 * time.Hour)\n\tfor range ticker.C {\n\t\tdeleted := p.cleanupExpiredUsers()\n\t\tmarked := p.markInactiveUsers()\n\n\t\tif deleted > 0 || marked > 0 {\n\t\t\tfmt.Printf(\"[Weibo] 清理任务完成: 删除 %d 个过期用户, 标记 %d 个不活跃用户\\n\", deleted, marked)\n\t\t}\n\t}\n}\n\nfunc (p *WeiboPlugin) cleanupExpiredUsers() int {\n\tdeletedCount := 0\n\tnow := time.Now()\n\texpireThreshold := now.AddDate(0, 0, -30)\n\n\tp.users.Range(func(key, value interface{}) bool {\n\t\tuser := value.(*User)\n\t\tif user.Status == \"expired\" && user.LastAccessAt.Before(expireThreshold) {\n\t\t\tif err := p.deleteUser(user.Hash); err == nil {\n\t\t\t\tdeletedCount++\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n\n\treturn deletedCount\n}\n\nfunc (p *WeiboPlugin) markInactiveUsers() int {\n\tmarkedCount := 0\n\tnow := time.Now()\n\tinactiveThreshold := now.AddDate(0, 0, -90)\n\n\tp.users.Range(func(key, value interface{}) bool {\n\t\tuser := value.(*User)\n\t\tif user.LastAccessAt.Before(inactiveThreshold) && user.Status != \"expired\" {\n\t\t\tuser.Status = \"expired\"\n\t\t\tuser.Cookie = \"\"\n\n\t\t\tif err := p.saveUser(user); err == nil {\n\t\t\t\tmarkedCount++\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n\n\treturn markedCount\n}\n"
  },
  {
    "path": "plugin/weibo/微博用户搜索API文档.md",
    "content": "# 微博用户搜索 API 文档\n\n---\n\n## 一、Cookie认证\n\n### 1.1 Cookie字段分类\n\n微博Cookie包含多种类型的字段，有效期各不相同：\n\n| Cookie字段 | 有效期 | 作用 |\n|-----------|--------|------|\n| **SUB** | 30天 | 用户认证令牌（长期） |\n| **SUBP** | 30天 | 用户权限令牌（长期） |\n| **SCF** | 30天 | 安全Cookie标识（长期） |\n| **ALF** | 30天 | 认证过期时间（长期） |\n| **ALC** | 6天 | PC端登录Cookie（中期） |\n| **XSRF-TOKEN** | 2小时 | CSRF防护令牌（短期，需定期刷新） |\n| **WBPSESS** | 2小时 | PC端会话令牌（短期，需定期刷新） |\n| **mweibo_short_token** | 2小时 | 移动端短令牌（短期，需定期刷新） |\n| **SSOLoginState** | 2小时 | 登录状态时间戳（短期） |\n| **_T_WM** | 永久 | 移动端设备标识 |\n| **WEIBOCN_FROM** | 永久 | 来源标识 |\n| **MLOGIN** | 永久 | 移动端登录标识 |\n| **M_WEIBOCN_PARAMS** | 永久 | 移动端参数 |\n\n### 1.2 获取Cookie\n\n#### 方式1: 二维码登录（推荐）\n\n**接口**: `https://passport.weibo.com/sso/v2/qrcode/image`\n\n**流程**:\n1. 获取二维码图片和qrid\n2. 轮询检查扫码状态: `https://passport.weibo.com/sso/v2/qrcode/check?qrid={qrid}`\n3. 扫码成功后获取跳转URL\n4. 访问跳转URL获取PC端Cookie\n5. 使用PC端Cookie访问 `https://m.weibo.cn/` 获取移动端Cookie\n\n**优势**: 自动获取PC端和移动端完整Cookie，包含所有必需字段\n\n#### 方式2: 浏览器手动复制\n\n1. 浏览器登录 [https://m.weibo.cn](https://m.weibo.cn)\n2. 打开开发者工具（F12），移动模式\n3. 切换到 Network（网络）标签\n4. 刷新页面\n5. 查找任意请求的 Request Headers\n6. 复制 Cookie 字段的完整值\n\n**注意**: 浏览器复制的Cookie确保包含移动端字段（`_T_WM`、`mweibo_short_token`等）\n\n### 1.3 Cookie域名设置\n\n不同API需要不同域名的Cookie：\n\n| API | 域名 | 必需Cookie域 |\n|-----|------|-------------|\n| PC搜索API | `weibo.com` | `.weibo.com` |\n| 移动评论API | `m.weibo.cn` | `.weibo.cn` |\n\n**建议**: 将Cookie同时设置到 `.weibo.com` 和 `.weibo.cn` 两个域名\n\n### 1.4 Cookie保活机制\n\n**核心问题**: 短期令牌（2小时有效期）过期后会导致API请求失败\n\n**解决方案**: 定期访问微博首页刷新短期令牌\n\n**刷新方法**:\n1. 每小时访问 `https://weibo.com/` 和 `https://m.weibo.cn/`\n2. 从响应的Set-Cookie中提取更新后的令牌\n3. 更新本地Cookie存储\n\n**刷新的字段**:\n- `XSRF-TOKEN` - 从响应头自动更新\n- `WBPSESS` - 从Set-Cookie更新\n- `mweibo_short_token` - 从Set-Cookie更新\n- `ALC` - 从Set-Cookie更新（延长6天）\n\n**保活效果**: 通过持续刷新，Cookie可保持有效最长30天（长期令牌的有效期）\n\n---\n\n## 二、用户微博搜索API\n\n### 2.1 接口概述\n\n**接口地址**: `https://weibo.com/ajax/profile/searchblog`  \n**请求方法**: `GET`  \n**认证方式**: Cookie 认证（需要登录态）\n\n### 2.2 请求参数\n\n| 参数名 | 类型 | 必填 | 描述 | 示例值 |\n|--------|------|------|------|--------|\n| uid | string | 是 | 用户唯一ID | `\"5487050770\"` |\n| feature | number | 否 | 微博类型筛选<br>• `0` - 全部微博（默认）<br>• `1` - 原创微博 | `0` |\n| q | string | 是 | 搜索关键词 | `\"传说\"` |\n| page | number | 否 | 页码，从1开始 | `1` |\n\n### 2.3 请求头（Headers）\n\n```http\nUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\nReferer: https://weibo.com/\nAccept: application/json, text/plain, */*\nAccept-Language: zh-CN,zh;q=0.9\nCookie: <your_weibo_cookie>\n```\n\n**重要**: Cookie 必须包含有效的登录凭证，否则API会返回401错误。\n\n### 2.4 响应数据结构\n\n#### 成功响应\n\n```json\n{\n  \"ok\": 1,\n  \"data\": {\n    \"list\": [\n      {\n        \"id\": 5230754395068367,\n        \"idstr\": \"5230754395068367\",\n        \"mid\": \"5230754395068367\",\n        \"mblogid\": \"QcU3RlgvR\",\n        \"created_at\": \"Sat Nov 08 17:01:33 +0800 2025\",\n        \"text\": \"微博正文内容（HTML格式）\",\n        \"text_raw\": \"微博正文内容（纯文本）\",\n        \"source\": \"动漫博主\",\n        \"user\": {\n          \"id\": 5487050770,\n          \"idstr\": \"5487050770\",\n          \"screen_name\": \"百特丸maru\",\n          \"profile_image_url\": \"https://tvax1.sinaimg.cn/crop.0.0.1080.1080.50/005Zl6ySly8i4dfx694lrj30u00u040d.jpg\",\n          \"verified\": true,\n          \"verified_type\": 0,\n          \"avatar_large\": \"https://tvax1.sinaimg.cn/...\",\n          \"avatar_hd\": \"https://tvax1.sinaimg.cn/...\",\n          \"following\": true,\n          \"follow_me\": true\n        },\n        \"pic_ids\": [\n          \"005Zl6ySly1i74wjjvwrmj315o0ng7av\"\n        ],\n        \"pic_num\": 1,\n        \"pic_infos\": {\n          \"005Zl6ySly1i74wjjvwrmj315o0ng7av\": {\n            \"thumbnail\": {\n              \"url\": \"https://wx3.sinaimg.cn/wap180/...\",\n              \"width\": 180,\n              \"height\": 101\n            },\n            \"large\": {\n              \"url\": \"https://wx3.sinaimg.cn/orj960/...\",\n              \"width\": 1500,\n              \"height\": 844\n            },\n            \"original\": {\n              \"url\": \"https://wx3.sinaimg.cn/orj1080/...\",\n              \"width\": 1500,\n              \"height\": 844\n            }\n          }\n        },\n        \"reposts_count\": 2,\n        \"comments_count\": 2,\n        \"attitudes_count\": 7,\n        \"isLongText\": false,\n        \"region_name\": \"其他\",\n        \"title_source\": {\n          \"name\": \"银河英雄传说超话\",\n          \"url\": \"https://huati.weibo.com/k/银河英雄传说\",\n          \"image\": \"http://wx2.sinaimg.cn/thumbnail/...\"\n        }\n      }\n    ]\n  }\n}\n```\n\n#### 字段说明\n\n**响应根对象**:\n\n| 字段名 | 类型 | 描述 |\n|--------|------|------|\n| ok | number | 请求状态<br>• `1` - 成功<br>• `0` - 失败 |\n| data | object | 数据对象 |\n| data.list | array | 微博列表 |\n\n**微博对象 (list[])**:\n\n| 字段名 | 类型 | 描述 |\n|--------|------|------|\n| id | number | 微博唯一ID（数字） |\n| idstr | string | 微博唯一ID（字符串） |\n| mid | string | 微博消息ID |\n| mblogid | string | 微博短ID（用于分享链接） |\n| created_at | string | 发布时间（格式：`\"Sat Nov 08 17:01:33 +0800 2025\"`） |\n| text | string | 微博正文（HTML格式，包含表情、链接等标签） |\n| text_raw | string | 微博正文（纯文本） |\n| source | string | 发布来源（如\"动漫博主\"、\"iPhone客户端\"等） |\n| user | object | 用户信息对象 |\n| pic_ids | array | 图片ID列表 |\n| pic_num | number | 图片数量 |\n| pic_infos | object | 图片详细信息（键为pic_id） |\n| reposts_count | number | 转发数 |\n| comments_count | number | 评论数 |\n| attitudes_count | number | 点赞数 |\n| isLongText | boolean | 是否为长文本微博 |\n| region_name | string | 发布地区 |\n| title_source | object | 超话信息（如果来自超话） |\n\n**用户对象 (user)**:\n\n| 字段名 | 类型 | 描述 |\n|--------|------|------|\n| id | number | 用户ID（数字） |\n| idstr | string | 用户ID（字符串） |\n| screen_name | string | 用户昵称 |\n| profile_image_url | string | 头像URL（小图） |\n| avatar_large | string | 头像URL（大图） |\n| avatar_hd | string | 头像URL（高清） |\n| verified | boolean | 是否认证 |\n| verified_type | number | 认证类型<br>• `0` - 个人认证<br>• `2` - 企业认证<br>• `-1` - 未认证 |\n| following | boolean | 当前用户是否关注该用户 |\n| follow_me | boolean | 该用户是否关注当前用户 |\n\n**图片信息对象 (pic_infos)**:\n\n每个图片ID对应的值包含以下尺寸：\n\n| 字段名 | 描述 |\n|--------|------|\n| thumbnail | 缩略图（180px） |\n| bmiddle | 中等尺寸（360px） |\n| large | 大图（960px） |\n| original | 原图（1080px） |\n| largest | 最大尺寸 |\n\n每个尺寸对象包含：\n- `url`: 图片URL\n- `width`: 宽度\n- `height`: 高度\n- `cut_type`: 裁剪类型\n- `type`: 图片类型\n\n**超话信息对象 (title_source)**:\n\n| 字段名 | 类型 | 描述 |\n|--------|------|------|\n| name | string | 超话名称 |\n| url | string | 超话链接 |\n| image | string | 超话图标 |\n\n### 2.5 请求示例\n\n#### Python 示例\n\n```python\nimport requests\n\n# 配置\nurl = \"https://weibo.com/ajax/profile/searchblog\"\nheaders = {\n    \"User-Agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\",\n    \"Referer\": \"https://weibo.com/\",\n    \"Accept\": \"application/json, text/plain, */*\",\n    \"Cookie\": \"your_cookie_here\"  # 需要替换为实际的Cookie\n}\n\nparams = {\n    \"uid\": \"5487050770\",    # 用户ID\n    \"feature\": 0,           # 全部微博\n    \"q\": \"传说\",            # 关键词\n    \"page\": 1               # 页码\n}\n\n# 发送请求\nresponse = requests.get(url, params=params, headers=headers)\ndata = response.json()\n\n# 处理结果\nif data.get(\"ok\") == 1:\n    weibo_list = data.get(\"data\", {}).get(\"list\", [])\n    print(f\"获取到 {len(weibo_list)} 条微博\")\n    \n    for weibo in weibo_list:\n        print(f\"ID: {weibo['id']}\")\n        print(f\"内容: {weibo.get('text_raw', '')}\")\n        print(f\"发布时间: {weibo['created_at']}\")\n        print(f\"点赞数: {weibo['attitudes_count']}\")\n        print(\"-\" * 50)\nelse:\n    print(\"请求失败\")\n```\n\n#### cURL 示例\n\n```bash\ncurl -X GET \\\n  'https://weibo.com/ajax/profile/searchblog?uid=5487050770&feature=0&q=传说&page=1' \\\n  -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' \\\n  -H 'Referer: https://weibo.com/' \\\n  -H 'Accept: application/json, text/plain, */*' \\\n  -H 'Cookie: your_cookie_here'\n```\n\n### 2.6 错误处理\n\n#### 1. 未登录/Cookie失效\n\n```json\n{\n  \"ok\": 0,\n  \"msg\": \"未登录\",\n  \"errno\": \"100005\"\n}\n```\n\n**解决方案**: 更新Cookie为有效的登录凭证\n\n#### 2. 用户不存在\n\n```json\n{\n  \"ok\": 0,\n  \"msg\": \"用户不存在\",\n  \"errno\": \"100003\"\n}\n```\n\n**解决方案**: 检查uid参数是否正确\n\n#### 3. 请求过于频繁\n\n```json\n{\n  \"ok\": 0,\n  \"msg\": \"请求过于频繁，请稍后再试\"\n}\n```\n\n**解决方案**: 添加请求间隔（建议1-3秒）\n\n### 2.7 使用限制\n\n1. **认证要求**: 必须提供有效的Cookie\n2. **频率限制**: 建议每次请求间隔1-3秒，避免触发反爬虫\n3. **分页限制**: 单次最多返回20条微博，需要翻页获取更多\n4. **Cookie有效期**: Cookie会过期，需要定期更新\n\n---\n\n## 三、微博评论API\n\n### 3.1 接口概述\n\n**接口地址**: `https://m.weibo.cn/comments/hotflow`  \n**请求方法**: `GET`  \n**认证方式**: Cookie 认证\n\n### 3.2 请求参数\n\n| 参数名 | 类型 | 必填 | 描述 | 示例值 |\n|--------|------|------|------|--------|\n| id | string | 是 | 微博ID | `\"5230754395068367\"` |\n| mid | string | 是 | 微博MID（与id相同） | `\"5230754395068367\"` |\n| max_id | number | 否 | 分页参数（第一页传0） | `0` |\n| max_id_type | number | 否 | 分页类型（第一页传0） | `0` |\n\n### 3.3 请求头（Headers）\n\n```http\nUser-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15\nReferer: https://m.weibo.cn/\nAccept: application/json, text/plain, */*\nCookie: <your_weibo_cookie>\n```\n\n**注意**: 评论API使用移动端User-Agent，Cookie必须包含移动端字段（`_T_WM`、`mweibo_short_token`等）\n\n### 3.4 响应数据结构\n\n```json\n{\n  \"ok\": 1,\n  \"data\": {\n    \"data\": [\n      {\n        \"id\": 4968123456789,\n        \"text\": \"评论内容（HTML格式）\",\n        \"user\": {\n          \"id\": 1234567890,\n          \"screen_name\": \"评论用户昵称\"\n        },\n        \"created_at\": \"Thu Nov 09 10:30:00 +0800 2025\"\n      }\n    ],\n    \"max_id\": 4968123456788,\n    \"max_id_type\": 0\n  }\n}\n```\n\n#### 字段说明\n\n| 字段名 | 类型 | 描述 |\n|--------|------|------|\n| ok | number | 请求状态（1-成功，0-失败） |\n| data.data | array | 评论列表 |\n| data.max_id | number | 下一页的max_id参数（0表示没有更多） |\n| data.max_id_type | number | 下一页的max_id_type参数 |\n\n**评论对象**:\n\n| 字段名 | 类型 | 描述 |\n|--------|------|------|\n| id | number | 评论ID |\n| text | string | 评论内容（HTML格式，可能包含链接） |\n| user | object | 评论用户信息 |\n| created_at | string | 评论时间 |\n\n### 3.5 请求示例\n\n```python\nimport requests\n\nurl = \"https://m.weibo.cn/comments/hotflow\"\nheaders = {\n    \"User-Agent\": \"Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15\",\n    \"Referer\": \"https://m.weibo.cn/\",\n    \"Accept\": \"application/json, text/plain, */*\",\n    \"Cookie\": \"your_cookie_here\"\n}\n\nparams = {\n    \"id\": \"5230754395068367\",\n    \"mid\": \"5230754395068367\",\n    \"max_id\": 0,\n    \"max_id_type\": 0\n}\n\nresponse = requests.get(url, params=params, headers=headers)\ndata = response.json()\n\nif data.get(\"ok\") == 1:\n    comments = data.get(\"data\", {}).get(\"data\", [])\n    print(f\"获取到 {len(comments)} 条评论\")\n    \n    for comment in comments:\n        print(f\"用户: {comment['user']['screen_name']}\")\n        print(f\"内容: {comment['text']}\")\n        print(\"-\" * 50)\n```\n\n### 3.6 错误处理\n\n#### 1. Cookie失效（retcode=6102）\n\n```json\n{\n  \"ok\": 0,\n  \"retcode\": 6102,\n  \"msg\": \"未登录\"\n}\n```\n\n**原因**: Cookie缺少移动端字段或短期令牌过期  \n**解决方案**: 使用二维码登录获取完整Cookie，或刷新Cookie\n"
  },
  {
    "path": "plugin/wuji/html结构分析.md",
    "content": "# Wuji (无极磁链) HTML结构分析\n\n## 网站信息\n\n- **网站名称**: ØMagnet 无极磁链\n- **基础URL**: https://xcili.net/\n- **功能**: 磁力链接搜索引擎\n- **搜索URL格式**: `/search?q={keyword}`\n\n## 搜索页面结构\n\n### 1. 搜索URL参数说明\n\n```\nhttps://xcili.net/search?q=%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0\n                             ^关键词(URL编码)\n```\n\n**参数说明**:\n- `q`: URL编码的搜索关键词\n\n### 2. 搜索结果容器\n\n```html\n<table class=\"table table-hover file-list\">\n    <tbody>\n        <tr>\n            <!-- 单个搜索结果 -->\n        </tr>\n        <!-- 更多结果... -->\n    </tbody>\n</table>\n```\n\n### 3. 单个搜索结果结构\n\n```html\n<tr>\n    <td>\n        <a href=\"/!k5mO\">\n            【高清剧集网发布 www.DDHDTV.com】<b>凡人修仙传</b>：星海飞驰篇[第103集][国语配音+中文字幕]...\n            <p class=\"sample\"><b>凡人修仙传</b>.A.Mortal's.Journey.2020.E103.2160p.WEB-DL.H264.AAC-ColorWEB.mp4</p>\n        </a>\n    </td>\n    <td class=\"td-size\">2.02GB</td>\n</tr>\n```\n\n**提取要点**:\n- 详情页链接：`td a` 的 `href` 属性（如 `/!k5mO`）\n- 标题：`td a` 的直接文本内容（不包括 `<p class=\"sample\">`）\n- 文件名：`p.sample` 的文本内容\n- 文件大小：`td.td-size` 的文本内容\n\n## 详情页面结构\n\n### 1. 详情页URL格式\n```\nhttps://xcili.net/!k5mO\n                   ^资源ID\n```\n\n### 2. 详情页关键元素\n\n#### 标题区域\n```html\n<h2 class=\"magnet-title\">凡人修仙传156</h2>\n```\n\n#### 磁力链接区域\n```html\n<div class=\"input-group magnet-box\">\n    <input id=\"input-magnet\" class=\"form-control\" type=\"text\" \n           value=\"magnet:?xt=urn:btih:73fb26f819ac2582c56ec9089c85cad4b0d42545&dn=...\" />\n</div>\n```\n\n#### 文件信息区域\n```html\n<dl class=\"dl-horizontal torrent-info col-sm-9\">\n    <dt>种子特征码 :</dt>  \n    <dd>73fb26f819ac2582c56ec9089c85cad4b0d42545</dd>\n    \n    <dt>文件大小 :</dt> \n    <dd>288.6 MB</dd>\n    \n    <dt>发布日期 :</dt>  \n    <dd>2025-08-16 14:51:15</dd>\n</dl>\n```\n\n#### 文件列表区域\n```html\n<table class=\"table table-hover file-list\">\n    <thead>\n        <tr>\n            <th>文件 ( 2 )</th>\n            <th class=\"th-size\">大小</th>\n        </tr>\n    </thead>\n    <tbody>\n        <tr>\n            <td>专属高速VPN介绍.txt</td>\n            <td class=\"td-size\">470 B</td>\n        </tr>\n        <tr>\n            <td>凡人修仙传156.mp4</td>\n            <td class=\"td-size\">288.6 MB</td>\n        </tr>\n    </tbody>\n</table>\n```\n\n## 数据提取要点\n\n### 搜索页面提取信息\n\n1. **基本信息**:\n   - 标题: `tr td a` 的直接文本内容（移除子元素文本）\n   - 详情页链接: `tr td a` 的 `href` 属性\n   - 文件大小: `tr td.td-size` 的文本内容\n   - 文件名预览: `tr td a p.sample` 的文本内容\n\n### 详情页面提取信息\n\n1. **磁力链接**:\n   - 磁力链接: `input#input-magnet` 的 `value` 属性\n\n2. **元数据**:\n   - 标题: `h2.magnet-title` 的文本内容\n   - 种子哈希: `dl.torrent-info` 中 \"种子特征码\" 对应的 `dd` 内容\n   - 文件大小: `dl.torrent-info` 中 \"文件大小\" 对应的 `dd` 内容\n   - 发布日期: `dl.torrent-info` 中 \"发布日期\" 对应的 `dd` 内容\n\n3. **文件列表**:\n   - 文件列表: `table.file-list tbody tr` 中的文件名和大小\n\n### CSS选择器\n\n```css\n/* 搜索页面 */\ntable.file-list tbody tr          /* 搜索结果行 */\ntr td a                          /* 标题链接 */\ntr td a p.sample                 /* 文件名预览 */\ntr td.td-size                    /* 文件大小 */\n\n/* 详情页面 */\nh2.magnet-title                  /* 标题 */\ninput#input-magnet               /* 磁力链接 */\ndl.torrent-info                  /* 元数据信息 */\ntable.file-list tbody tr         /* 文件列表 */\n```\n\n## 广告内容清理\n\n### 需要清理的广告格式\n\n1. **【】格式广告**:\n   - `【高清剧集网发布 www.DDHDTV.com】`\n   - `【不太灵影视 www.3BT0.com】`\n   - `【8i2.fit】名称：`\n\n2. **其他格式**:\n   - 数字+【xxx】格式: `48【孩子你要相信光】`\n\n### 清理规则\n\n```javascript\n// 移除【】及其内容\ntitle = title.replace(/【[^】]*】/g, '');\n\n// 移除数字+【】格式\ntitle = title.replace(/^\\d+【[^】]*】/, '');\n\n// 移除多余空格\ntitle = title.replace(/\\s+/g, ' ').trim();\n```\n\n## 插件流程设计\n\n### 1. 搜索流程\n1. 构造搜索URL: `https://xcili.net/search?q={keyword}`\n2. 解析搜索结果页面，提取基本信息和详情页链接\n3. 对每个结果访问详情页获取磁力链接\n4. 合并信息并返回最终结果\n\n### 2. 详情页处理\n1. 访问详情页URL\n2. 提取磁力链接和详细信息\n3. 解析文件列表\n\n## 请求头要求\n\n建议设置常见的浏览器请求头:\n- User-Agent: 现代浏览器UA\n- Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\n- Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\n\n## 注意事项\n\n1. 需要进行两次请求：搜索页面 + 详情页面\n2. 磁力链接在详情页面，不在搜索页面\n3. 标题需要清理多种格式的广告内容\n4. 文件大小格式多样：B, KB, MB, GB\n5. 详情页链接格式: `/!{resourceId}`\n6. 需要适当的请求间隔避免被限制"
  },
  {
    "path": "plugin/wuji/wuji.go",
    "content": "package wuji\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\n// 常量定义\nconst (\n\t// 基础URL\n\tBaseURL = \"https://xcili.net\"\n\t\n\t// 搜索URL格式：/search?q={keyword}&page={page}\n\tSearchURL = BaseURL + \"/search?q=%s&page=%d\"\n\t\n\t// 默认参数\n\tMaxRetries = 3\n\tTimeoutSeconds = 30\n\t\n\t// 并发控制参数\n\tMaxConcurrency = 10 // 最大并发数\n\tMaxPages = 5        // 最大搜索页数\n)\n\n// 预编译的正则表达式\nvar (\n\t// 磁力链接正则\n\tmagnetLinkRegex = regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9a-fA-F]{40}[^\"'\\s]*`)\n\t\n\t// 磁力链接缓存，键为详情页URL，值为磁力链接\n\tmagnetCache = sync.Map{}\n\tcacheTTL    = 1 * time.Hour // 缓存1小时\n)\n\n// 缓存的磁力链接响应\ntype magnetCacheEntry struct {\n\tMagnetLink string\n\tTimestamp  time.Time\n}\n\n// 常用UA列表\nvar userAgents = []string{\n\t\"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\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\",\n\t\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0\",\n}\n\n// WujiPlugin 无极磁链搜索插件\ntype WujiPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\n// NewWujiPlugin 创建新的无极磁链插件实例\nfunc NewWujiPlugin() *WujiPlugin {\n\treturn &WujiPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"wuji\", 3, true),\n\t}\n}\n\n// Name 返回插件名称\nfunc (p *WujiPlugin) Name() string {\n\treturn \"wuji\"\n}\n\n// DisplayName 返回插件显示名称\nfunc (p *WujiPlugin) DisplayName() string {\n\treturn \"无极磁链\"\n}\n\n// Description 返回插件描述\nfunc (p *WujiPlugin) Description() string {\n\treturn \"ØMagnet 无极磁链 - 磁力链接搜索引擎\"\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *WujiPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *WujiPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实际的搜索实现\nfunc (p *WujiPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 1. 首先搜索第一页\n\tfirstPageResults, err := p.searchPage(client, keyword, 1)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索第一页失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 存储所有结果\n\tvar allResults []model.SearchResult\n\tallResults = append(allResults, firstPageResults...)\n\t\n\t// 2. 并发搜索其他页面（第2页到第5页）\n\tif MaxPages > 1 {\n\t\tvar wg sync.WaitGroup\n\t\tvar mu sync.Mutex\n\t\t\n\t\t// 使用信号量控制并发数\n\t\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\t\n\t\t// 存储每页结果\n\t\tpageResults := make(map[int][]model.SearchResult)\n\t\t\n\t\tfor page := 2; page <= MaxPages; page++ {\n\t\t\twg.Add(1)\n\t\t\tgo func(pageNum int) {\n\t\t\t\tdefer wg.Done()\n\t\t\t\t\n\t\t\t\t// 获取信号量\n\t\t\t\tsemaphore <- struct{}{}\n\t\t\t\tdefer func() { <-semaphore }()\n\t\t\t\t\n\t\t\t\t// 添加小延迟避免过于频繁的请求\n\t\t\t\ttime.Sleep(time.Duration(pageNum%3) * 100 * time.Millisecond)\n\t\t\t\t\n\t\t\t\tcurrentPageResults, err := p.searchPage(client, keyword, pageNum)\n\t\t\t\tif err == nil && len(currentPageResults) > 0 {\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\tpageResults[pageNum] = currentPageResults\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t}\n\t\t\t}(page)\n\t\t}\n\t\t\n\t\twg.Wait()\n\t\t\n\t\t// 按页码顺序合并所有页面的结果\n\t\tfor page := 2; page <= MaxPages; page++ {\n\t\t\tif results, exists := pageResults[page]; exists {\n\t\t\t\tallResults = append(allResults, results...)\n\t\t\t}\n\t\t}\n\t}\n\t\n\t\n\t// 3. 并发获取每个结果的详情页磁力链接\n\tfinalResults := p.enrichWithMagnetLinks(allResults, client)\n\t\n\t// 4. 关键词过滤\n\tsearchKeyword := keyword\n\tif searchParam, ok := ext[\"search\"]; ok {\n\t\tif searchStr, ok := searchParam.(string); ok && searchStr != \"\" {\n\t\t\tsearchKeyword = searchStr\n\t\t}\n\t}\n\t\n\treturn plugin.FilterResultsByKeyword(finalResults, searchKeyword), nil\n}\n\n// searchPage 搜索指定页面\nfunc (p *WujiPlugin) searchPage(client *http.Client, keyword string, page int) ([]model.SearchResult, error) {\n\t// URL编码关键词\n\tencodedKeyword := url.QueryEscape(keyword)\n\tsearchURL := fmt.Sprintf(SearchURL, encodedKeyword, page)\n\t\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), TimeoutSeconds*time.Second)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 设置请求头\n\tp.setRequestHeaders(req)\n\t\n\t// 发送HTTP请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 读取响应体内容\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 读取响应失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body)))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] HTML解析失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 提取搜索结果\n\treturn p.extractSearchResults(doc), nil\n}\n\n// extractSearchResults 提取搜索结果\nfunc (p *WujiPlugin) extractSearchResults(doc *goquery.Document) []model.SearchResult {\n\tvar results []model.SearchResult\n\t\n\t// 查找所有搜索结果\n\tdoc.Find(\"table.file-list tbody tr\").Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchResult(s)\n\t\tif result.Title != \"\" {\n\t\t\tresults = append(results, result)\n\t\t}\n\t})\n\t\n\treturn results\n}\n\n// parseSearchResult 解析单个搜索结果\nfunc (p *WujiPlugin) parseSearchResult(s *goquery.Selection) model.SearchResult {\n\tresult := model.SearchResult{\n\t\tChannel:  \"\", // 插件搜索结果必须为空字符串\n\t\tDatetime: time.Now(),\n\t}\n\t\n\t// 提取标题和详情页链接\n\ttitleCell := s.Find(\"td\").First()\n\ttitleLink := titleCell.Find(\"a\")\n\t\n\t// 详情页链接\n\tdetailPath, exists := titleLink.Attr(\"href\")\n\tif !exists || detailPath == \"\" {\n\t\treturn result\n\t}\n\t\n\t// 构造完整的详情页URL\n\tdetailURL := BaseURL + detailPath\n\t\n\t// 提取标题（排除 p.sample 的内容）\n\ttitleText := titleLink.Clone()\n\ttitleText.Find(\"p.sample\").Remove()\n\ttitle := strings.TrimSpace(titleText.Text())\n\tresult.Title = p.cleanTitle(title)\n\t\n\t// 提取文件名预览\n\tsampleText := strings.TrimSpace(titleLink.Find(\"p.sample\").Text())\n\t\n\t// 提取文件大小\n\tsizeText := strings.TrimSpace(s.Find(\"td.td-size\").Text())\n\t\n\t// 构造内容\n\tvar contentParts []string\n\tif sampleText != \"\" {\n\t\tcontentParts = append(contentParts, \"文件: \"+sampleText)\n\t}\n\tif sizeText != \"\" {\n\t\tcontentParts = append(contentParts, \"大小: \"+sizeText)\n\t}\n\tresult.Content = strings.Join(contentParts, \"\\n\")\n\t\n\t// 暂时将详情页链接作为占位符（后续会被磁力链接替换）\n\tresult.Links = []model.Link{{\n\t\tType: \"detail\",\n\t\tURL:  detailURL,\n\t}}\n\t\n\t// 生成唯一ID\n\tresult.UniqueID = fmt.Sprintf(\"%s-%d\", p.Name(), time.Now().UnixNano())\n\t\n\t// 添加标签\n\tresult.Tags = []string{\"magnet\"}\n\t\n\treturn result\n}\n\n// fetchMagnetLink 获取详情页的磁力链接（带缓存）\nfunc (p *WujiPlugin) fetchMagnetLink(client *http.Client, detailURL string) (string, error) {\n\t// 检查缓存\n\tif cached, ok := magnetCache.Load(detailURL); ok {\n\t\tif entry, ok := cached.(magnetCacheEntry); ok {\n\t\t\tif time.Since(entry.Timestamp) < cacheTTL {\n\t\t\t\t// 缓存命中\n\t\t\t\treturn entry.MagnetLink, nil\n\t\t\t}\n\t\t\t// 缓存过期，删除\n\t\t\tmagnetCache.Delete(detailURL)\n\t\t}\n\t}\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), TimeoutSeconds*time.Second)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"创建详情页请求失败: %w\", err)\n\t}\n\t\n\t// 设置请求头\n\tp.setRequestHeaders(req)\n\t\n\t// 发送HTTP请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"详情页请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn \"\", fmt.Errorf(\"详情页返回状态码: %d\", resp.StatusCode)\n\t}\n\t\n\t// 读取响应体内容\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"读取详情页响应失败: %w\", err)\n\t}\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body)))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"详情页HTML解析失败: %w\", err)\n\t}\n\t\n\t// 提取磁力链接\n\tmagnetInput := doc.Find(\"input#input-magnet\")\n\tif magnetInput.Length() == 0 {\n\t\treturn \"\", fmt.Errorf(\"未找到磁力链接输入框\")\n\t}\n\t\n\tmagnetLink, exists := magnetInput.Attr(\"value\")\n\tif !exists || magnetLink == \"\" {\n\t\treturn \"\", fmt.Errorf(\"磁力链接为空\")\n\t}\n\t\n\t// 存入缓存\n\tmagnetCache.Store(detailURL, magnetCacheEntry{\n\t\tMagnetLink: magnetLink,\n\t\tTimestamp:  time.Now(),\n\t})\n\t\n\treturn magnetLink, nil\n}\n\n// cleanTitle 清理标题中的广告内容\nfunc (p *WujiPlugin) cleanTitle(title string) string {\n\t// 移除【】之间的广告内容\n\ttitle = regexp.MustCompile(`【[^】]*】`).ReplaceAllString(title, \"\")\n\t// 移除数字+【】格式的广告\n\ttitle = regexp.MustCompile(`^\\d+【[^】]*】`).ReplaceAllString(title, \"\")\n\t// 移除[]之间的内容（如有需要）\n\ttitle = regexp.MustCompile(`\\[[^\\]]*\\]`).ReplaceAllString(title, \"\")\n\t// 移除多余的空格\n\ttitle = regexp.MustCompile(`\\s+`).ReplaceAllString(title, \" \")\n\treturn strings.TrimSpace(title)\n}\n\n// setRequestHeaders 设置请求头\nfunc (p *WujiPlugin) setRequestHeaders(req *http.Request) {\n\t// 使用第一个稳定的UA\n\tua := userAgents[0]\n\treq.Header.Set(\"User-Agent\", ua)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"no-cache\")\n\treq.Header.Set(\"Pragma\", \"no-cache\")\n}\n\n// doRequestWithRetry 带重试的HTTP请求\nfunc (p *WujiPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tvar lastErr error\n\t\n\tfor i := 0; i < MaxRetries; i++ {\n\t\tresp, err := client.Do(req)\n\t\tif err == nil {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tlastErr = err\n\t\tif i < MaxRetries-1 {\n\t\t\ttime.Sleep(time.Duration(i+1) * time.Second)\n\t\t}\n\t}\n\t\n\treturn nil, fmt.Errorf(\"请求失败，已重试%d次: %w\", MaxRetries, lastErr)\n}\n\n// enrichWithMagnetLinks 并发获取磁力链接并丰富搜索结果\nfunc (p *WujiPlugin) enrichWithMagnetLinks(results []model.SearchResult, client *http.Client) []model.SearchResult {\n\tif len(results) == 0 {\n\t\treturn results\n\t}\n\t\n\t// 使用信号量控制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\tvar wg sync.WaitGroup\n\tvar mutex sync.Mutex\n\t\n\tenrichedResults := make([]model.SearchResult, len(results))\n\tcopy(enrichedResults, results)\n\t\n\tfor i := range enrichedResults {\n\t\t// 检查是否有详情页链接\n\t\tif len(enrichedResults[i].Links) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\twg.Add(1)\n\t\tgo func(index int) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 获取详情页URL\n\t\t\tdetailURL := enrichedResults[index].Links[0].URL\n\t\t\t\n\t\t\t// 添加适当的间隔避免请求过于频繁\n\t\t\ttime.Sleep(time.Duration(index%5) * 100 * time.Millisecond)\n\t\t\t\n\t\t\t// 请求详情页并解析磁力链接\n\t\t\tmagnetLink, err := p.fetchMagnetLink(client, detailURL)\n\t\t\tif err == nil && magnetLink != \"\" {\n\t\t\t\tmutex.Lock()\n\t\t\t\tenrichedResults[index].Links = []model.Link{{\n\t\t\t\t\tType: \"magnet\",\n\t\t\t\t\tURL:  magnetLink,\n\t\t\t\t}}\n\t\t\t\tmutex.Unlock()\n\t\t\t} else if err != nil {\n\t\t\t\tfmt.Printf(\"[%s] 获取磁力链接失败 [%d]: %v\\n\", p.Name(), index, err)\n\t\t\t}\n\t\t}(i)\n\t}\n\t\n\twg.Wait()\n\t\n\t// 过滤掉没有有效磁力链接的结果\n\tvar validResults []model.SearchResult\n\tfor _, result := range enrichedResults {\n\t\tif len(result.Links) > 0 && result.Links[0].Type == \"magnet\" {\n\t\t\tvalidResults = append(validResults, result)\n\t\t}\n\t}\n\t\n\treturn validResults\n}\n\n// init 注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewWujiPlugin())\n}"
  },
  {
    "path": "plugin/xb6v/xb6v.go",
    "content": "package xb6v\n\nimport (\n\t\"compress/gzip\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tBaseURL        = \"https://www.66ss.org\" // 主域名\n\tBackupURL      = \"https://www.xb6v.com\" // 备用域名\n\tSearchPath     = \"/e/search/1index.php\"  // 搜索端点\n\tUserAgent      = \"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\"\n\tMaxConcurrency = 50 // 详情页最大并发数\n\tMaxResults     = 50 // 最大搜索结果数\n)\n\n// Xb6vPlugin 6v电影插件\ntype Xb6vPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tdebugMode    bool\n\tdetailCache  sync.Map // 缓存详情页结果\n\tcacheTTL     time.Duration\n\tcurrentBase  string // 当前使用的域名\n}\n\n// DetailPageInfo 详情页信息\ntype DetailPageInfo struct {\n\tURL      string    // 详情页URL\n\tDateTime time.Time // 发布日期\n}\n\n// NewXb6vPlugin 创建新的6v电影插件实例\nfunc NewXb6vPlugin() *Xb6vPlugin {\n\t// 检查调试模式\n\tdebugMode := false // 启用调试\n\t\n\tp := &Xb6vPlugin{\n\t\t// 磁力搜索插件：优先级4，跳过Service层过滤\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"xb6v\", 3, true),\n\t\tdebugMode:       debugMode,\n\t\tcacheTTL:        30 * time.Minute,\n\t\tcurrentBase:     BaseURL,\n\t}\n\t\n\t// 设置主缓存键\n\tp.BaseAsyncPlugin.SetMainCacheKey(p.Name())\n\t\n\treturn p\n}\n\n// Name 返回插件名称\nfunc (p *Xb6vPlugin) Name() string {\n\treturn \"xb6v\"\n}\n\n// DisplayName 返回插件显示名称\nfunc (p *Xb6vPlugin) DisplayName() string {\n\treturn \"6v电影\"\n}\n\n// Description 返回插件描述\nfunc (p *Xb6vPlugin) Description() string {\n\treturn \"6v电影 - 磁力链接资源站\"\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *Xb6vPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *Xb6vPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// setRequestHeaders 设置请求头\nfunc (p *Xb6vPlugin) setRequestHeaders(req *http.Request, referer string) {\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Accept-Encoding\", \"gzip, deflate\")\n\treq.Header.Set(\"Cache-Control\", \"no-cache\")\n\treq.Header.Set(\"Pragma\", \"no-cache\")\n\tif referer != \"\" {\n\t\treq.Header.Set(\"Referer\", referer)\n\t}\n}\n\n// doRequest 发送HTTP请求\nfunc (p *Xb6vPlugin) doRequest(client *http.Client, method, url, postData string, referer string) (*http.Response, error) {\n\tvar req *http.Request\n\tvar err error\n\t\n\tif method == \"POST\" && postData != \"\" {\n\t\treq, err = http.NewRequest(\"POST\", url, strings.NewReader(postData))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\t} else {\n\t\treq, err = http.NewRequest(\"GET\", url, nil)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t\n\tp.setRequestHeaders(req, referer)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xb6v] 发送 %s 请求: %s\", method, url)\n\t}\n\t\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xb6v] 请求失败: %v\", err)\n\t\t}\n\t\treturn nil, err\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xb6v] 响应状态: %d\", resp.StatusCode)\n\t}\n\t\n\treturn resp, nil\n}\n\n// searchImpl 实际的搜索实现\nfunc (p *Xb6vPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 先进行URL解码，处理%20等编码\n\tdecodedKeyword, err := url.QueryUnescape(keyword)\n\tif err != nil {\n\t\t// 解码失败，使用原始关键词\n\t\tdecodedKeyword = keyword\n\t}\n\t\n\t// 优化关键词：如果包含空格，只使用空格前的部分\n\toriginalKeyword := decodedKeyword\n\tif spaceIndex := strings.Index(decodedKeyword, \" \"); spaceIndex > 0 {\n\t\tdecodedKeyword = decodedKeyword[:spaceIndex]\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xb6v] 关键词优化: '%s' -> '%s'\", originalKeyword, decodedKeyword)\n\t\t}\n\t}\n\t\n\t// 使用处理后的关键词\n\tkeyword = decodedKeyword\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xb6v] 开始搜索: %s (原始: %s)\", keyword, originalKeyword)\n\t}\n\t\n\t// 第一步：POST搜索请求\n\tsearchURL := p.currentBase + SearchPath\n\tpostData := fmt.Sprintf(\"show=title&tempid=1&tbname=article&mid=1&dopost=search&submit=&keyboard=%s\", url.QueryEscape(keyword))\n\t\n\t// 创建不自动重定向的客户端\n\tnoRedirectClient := &http.Client{\n\t\tCheckRedirect: func(req *http.Request, via []*http.Request) error {\n\t\t\treturn http.ErrUseLastResponse\n\t\t},\n\t}\n\t\n\tresp, err := p.doRequest(noRedirectClient, \"POST\", searchURL, postData, p.currentBase)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"搜索请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xb6v] POST响应状态码: %d\", resp.StatusCode)\n\t}\n\t\n\t// 获取重定向的location\n\tlocation := resp.Header.Get(\"Location\")\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xb6v] Location头: '%s'\", location)\n\t}\n\t\n\t// 如果没有Location头，可能需要从响应体中解析\n\tif location == \"\" {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xb6v] 未找到Location头，尝试解析响应体\")\n\t\t}\n\t\t\n\t\t// 读取响应体看看是否包含重定向信息\n\t\tbodyReader, err := p.getResponseReader(resp)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"读取响应体失败: %w\", err)\n\t\t}\n\t\t\n\t\tbodyBytes, err := io.ReadAll(bodyReader)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"读取响应体失败: %w\", err)\n\t\t}\n\t\t\n\t\tbodyStr := string(bodyBytes)\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xb6v] 响应体长度: %d\", len(bodyStr))\n\t\t\t// 只打印前500个字符避免日志过长\n\t\t\tif len(bodyStr) > 500 {\n\t\t\t\tlog.Printf(\"[Xb6v] 响应体前500字符: %s\", bodyStr[:500])\n\t\t\t} else {\n\t\t\t\tlog.Printf(\"[Xb6v] 响应体内容: %s\", bodyStr)\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 尝试从响应体中提取重定向URL\n\t\t// 可能是JavaScript重定向或meta refresh\n\t\tif strings.Contains(bodyStr, \"location.href\") || strings.Contains(bodyStr, \"window.location\") {\n\t\t\t// JavaScript重定向\n\t\t\tre := regexp.MustCompile(`location\\.href\\s*=\\s*[\"']([^\"']+)[\"']`)\n\t\t\tmatches := re.FindStringSubmatch(bodyStr)\n\t\t\tif len(matches) > 1 {\n\t\t\t\tlocation = matches[1]\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[Xb6v] 从JavaScript中提取到Location: %s\", location)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 尝试查找其他形式的重定向\n\t\tif location == \"\" {\n\t\t\t// 查找可能的URL模式，比如包含searchid的链接\n\t\t\tre := regexp.MustCompile(`(?:href|url)\\s*[=:]\\s*[\"']?([^\"'\\s]*searchid=[^\"'\\s&]+)`)\n\t\t\tmatches := re.FindAllStringSubmatch(bodyStr, -1)\n\t\t\tfor _, match := range matches {\n\t\t\t\tif len(match) > 1 {\n\t\t\t\t\tlocation = match[1]\n\t\t\t\t\tif p.debugMode {\n\t\t\t\t\t\tlog.Printf(\"[Xb6v] 从URL模式中提取到Location: %s\", location)\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 如果还是没找到，尝试查找简单的result/?searchid=格式\n\t\tif location == \"\" {\n\t\t\tre := regexp.MustCompile(`result/\\?searchid=\\d+`)\n\t\t\tmatch := re.FindString(bodyStr)\n\t\t\tif match != \"\" {\n\t\t\t\tlocation = match\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[Xb6v] 从正则匹配中提取到Location: %s\", location)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\tif location == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"未找到搜索结果页面重定向信息\")\n\t\t}\n\t}\n\t\n\t// 构建完整的搜索结果URL\n\t// Location通常是类似 \"result/?searchid=39616\" 的格式，需要加上 /e/search/ 前缀\n\tvar resultURL string\n\tif strings.HasPrefix(location, \"result/\") {\n\t\tresultURL = p.currentBase + \"/e/search/\" + location\n\t} else {\n\t\tresultURL = p.currentBase + \"/\" + strings.TrimPrefix(location, \"/\")\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xb6v] 搜索结果页面: %s\", resultURL)\n\t}\n\t\n\t// 第二步：获取搜索结果页面\n\tresp2, err := p.doRequest(client, \"GET\", resultURL, \"\", p.currentBase)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"获取搜索结果失败: %w\", err)\n\t}\n\tdefer resp2.Body.Close()\n\t\n\tif resp2.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"搜索结果响应状态码异常: %d\", resp2.StatusCode)\n\t}\n\t\n\t// 解析搜索结果页面\n\treader, err := p.getResponseReader(resp2)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\tdoc, err := goquery.NewDocumentFromReader(reader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"解析搜索结果HTML失败: %w\", err)\n\t}\n\t\n\t// 提取搜索结果（详情页链接和日期）\n\tdetailPages := p.extractDetailURLs(doc)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xb6v] 找到 %d 个详情页链接\", len(detailPages))\n\t}\n\t\n\tif len(detailPages) == 0 {\n\t\treturn nil, fmt.Errorf(\"未找到搜索结果\")\n\t}\n\t\n\t// 限制结果数量\n\tif len(detailPages) > MaxResults {\n\t\tdetailPages = detailPages[:MaxResults]\n\t}\n\t\n\t// 并发获取详情页的磁力链接\n\tresults := p.fetchMagnetLinksFromDetails(client, detailPages, keyword)\n\t\n\t// 过滤空结果\n\tvalidResults := p.filterValidResults(results)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xb6v] 去除无链接结果后剩余 %d 个结果\", len(validResults))\n\t}\n\t\n\t// 插件层关键词过滤（必须执行，因为跳过了Service层过滤）\n\tkeywordFilteredResults := plugin.FilterResultsByKeyword(validResults, keyword)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xb6v] 关键词过滤后最终返回 %d 个结果\", len(keywordFilteredResults))\n\t}\n\t\n\treturn keywordFilteredResults, nil\n}\n\n// getResponseReader 获取响应读取器（处理gzip压缩）\nfunc (p *Xb6vPlugin) getResponseReader(resp *http.Response) (io.Reader, error) {\n\tvar reader io.Reader = resp.Body\n\t\n\t// 检查Content-Encoding\n\tcontentEncoding := resp.Header.Get(\"Content-Encoding\")\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xb6v] Content-Encoding: %s\", contentEncoding)\n\t}\n\t\n\t// 如果是gzip压缩，手动解压\n\tif contentEncoding == \"gzip\" {\n\t\tgzReader, err := gzip.NewReader(resp.Body)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"创建gzip reader失败: %w\", err)\n\t\t}\n\t\treader = gzReader\n\t}\n\t\n\treturn reader, nil\n}\n\n// extractDetailURLs 从搜索结果页面提取详情页链接和日期\nfunc (p *Xb6vPlugin) extractDetailURLs(doc *goquery.Document) []DetailPageInfo {\n\tvar detailPages []DetailPageInfo\n\turlMap := make(map[string]bool) // 去重\n\t\n\t// 只从搜索结果区域提取链接，搜索结果在 ul#post_container 中\n\tdoc.Find(\"ul#post_container li.post\").Each(func(i int, li *goquery.Selection) {\n\t\t// 提取详情页链接\n\t\tlinkEl := li.Find(\"a[href*='.html']\")\n\t\tif linkEl.Length() == 0 {\n\t\t\treturn\n\t\t}\n\t\t\n\t\thref, exists := linkEl.Attr(\"href\")\n\t\tif !exists || href == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xb6v] 找到搜索结果链接: %s\", href)\n\t\t}\n\t\t\n\t\t// 检查链接是否符合内容页面格式（分类/子分类/数字.html）\n\t\tif !p.isValidContentURL(href) {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Xb6v] 链接格式无效，跳过: %s\", href)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 构建完整URL\n\t\tvar fullURL string\n\t\tif strings.HasPrefix(href, \"http://\") || strings.HasPrefix(href, \"https://\") {\n\t\t\tfullURL = href\n\t\t} else {\n\t\t\tfullURL = p.currentBase + \"/\" + strings.TrimPrefix(href, \"/\")\n\t\t}\n\t\t\n\t\t// 去重检查\n\t\tif urlMap[fullURL] {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 提取发布日期\n\t\tdateText := strings.TrimSpace(li.Find(\".info .info_date\").Text())\n\t\tvar publishDate time.Time\n\t\t\n\t\tif dateText != \"\" {\n\t\t\t// 解析日期，格式通常是 \"2025-08-17\"\n\t\t\tif parsedDate, err := time.Parse(\"2006-01-02\", dateText); err == nil {\n\t\t\t\tpublishDate = parsedDate\n\t\t\t} else {\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[Xb6v] 日期解析失败: %s, 使用当前时间\", dateText)\n\t\t\t\t}\n\t\t\t\tpublishDate = time.Now()\n\t\t\t}\n\t\t} else {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Xb6v] 未找到日期信息，使用当前时间\")\n\t\t\t}\n\t\t\tpublishDate = time.Now()\n\t\t}\n\t\t\n\t\turlMap[fullURL] = true\n\t\tdetailPages = append(detailPages, DetailPageInfo{\n\t\t\tURL:      fullURL,\n\t\t\tDateTime: publishDate,\n\t\t})\n\t\t\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xb6v] 添加有效链接: %s, 日期: %s\", fullURL, publishDate.Format(\"2006-01-02\"))\n\t\t}\n\t})\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xb6v] 提取到 %d 个有效详情页链接\", len(detailPages))\n\t}\n\t\n\treturn detailPages\n}\n\n// isInSidebar 检查元素是否在侧边栏或不相关区域\nfunc (p *Xb6vPlugin) isInSidebar(s *goquery.Selection) bool {\n\t// 检查父元素是否包含侧边栏相关的class\n\tparent := s.Parent()\n\tfor i := 0; i < 5 && parent.Length() > 0; i++ { // 向上查找5层\n\t\tclass, _ := parent.Attr(\"class\")\n\t\tif strings.Contains(class, \"widget\") || \n\t\t   strings.Contains(class, \"sidebar\") ||\n\t\t   strings.Contains(class, \"box row\") ||\n\t\t   strings.Contains(class, \"related\") ||\n\t\t   strings.Contains(class, \"tagcloud\") {\n\t\t\treturn true\n\t\t}\n\t\tparent = parent.Parent()\n\t}\n\treturn false\n}\n\n// isValidContentURL 检查是否是有效的内容页面URL\nfunc (p *Xb6vPlugin) isValidContentURL(href string) bool {\n\t// 内容页面URL格式通常是：/分类/子分类/数字.html\n\t// 例如：/donghuapian/26525.html 或 /dianshiju/guoju/26608.html\n\tparts := strings.Split(strings.Trim(href, \"/\"), \"/\")\n\tif len(parts) < 2 {\n\t\treturn false\n\t}\n\t\n\t// 最后一部分应该是数字.html格式\n\tlastPart := parts[len(parts)-1]\n\tif !strings.HasSuffix(lastPart, \".html\") {\n\t\treturn false\n\t}\n\t\n\t// 提取数字部分\n\tnameWithoutExt := strings.TrimSuffix(lastPart, \".html\")\n\tif len(nameWithoutExt) == 0 {\n\t\treturn false\n\t}\n\t\n\t// 检查是否包含数字（内容ID）\n\thasNumber := regexp.MustCompile(`\\d+`).MatchString(nameWithoutExt)\n\treturn hasNumber\n}\n\n// cleanTitle 清理标题，移除网站名称等不需要的部分\nfunc (p *Xb6vPlugin) cleanTitle(title string) string {\n\t// 移除常见的网站名称前缀/后缀\n\tcleaners := []string{\n\t\t\"6v电影-新版\",\n\t\t\"6v电影\",\n\t\t\"新版6v\",\n\t\t\"新版6V\",\n\t\t\"6V电影\",\n\t}\n\t\n\tcleaned := title\n\tfor _, cleaner := range cleaners {\n\t\t// 移除前缀（包括可能的空格）\n\t\tif strings.HasPrefix(cleaned, cleaner) {\n\t\t\tcleaned = strings.TrimLeft(cleaned[len(cleaner):], \" \\t　\") // 包括中文空格\n\t\t}\n\t\t\n\t\t// 移除后缀（包括可能的空格）\n\t\tif strings.HasSuffix(cleaned, cleaner) {\n\t\t\tcleaned = strings.TrimRight(cleaned[:len(cleaned)-len(cleaner)], \" \\t　\") // 包括中文空格\n\t\t}\n\t\t\n\t\t// 移除中间的网站名称（用分隔符分隔）\n\t\tparts := strings.Split(cleaned, cleaner)\n\t\tif len(parts) > 1 {\n\t\t\tvar validParts []string\n\t\t\tfor _, part := range parts {\n\t\t\t\tpart = strings.TrimSpace(part)\n\t\t\t\tif part != \"\" {\n\t\t\t\t\tvalidParts = append(validParts, part)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif len(validParts) > 0 {\n\t\t\t\tcleaned = strings.Join(validParts, \" \")\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 清理多余的空格和特殊字符\n\tcleaned = strings.TrimSpace(cleaned)\n\t// 移除多个连续空格\n\tre := regexp.MustCompile(`\\s+`)\n\tcleaned = re.ReplaceAllString(cleaned, \" \")\n\t\n\tif cleaned == \"\" {\n\t\treturn \"未知标题\"\n\t}\n\t\n\treturn cleaned\n}\n\n// fetchMagnetLinksFromDetails 并发从详情页获取磁力链接\nfunc (p *Xb6vPlugin) fetchMagnetLinksFromDetails(client *http.Client, detailPages []DetailPageInfo, keyword string) []model.SearchResult {\n\tvar results []model.SearchResult\n\tvar mu sync.Mutex\n\tvar wg sync.WaitGroup\n\t\n\t// 使用信号量控制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\n\tfor i, detailPage := range detailPages {\n\t\twg.Add(1)\n\t\tgo func(idx int, pageInfo DetailPageInfo) {\n\t\t\tdefer wg.Done()\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 添加延迟避免请求过频\n\t\t\ttime.Sleep(time.Duration(idx*100) * time.Millisecond)\n\t\t\t\n\t\t\tpageResults := p.fetchDetailPageMagnetLinks(client, pageInfo.URL, pageInfo.DateTime)\n\t\t\tif len(pageResults) > 0 {\n\t\t\t\tmu.Lock()\n\t\t\t\tresults = append(results, pageResults...)\n\t\t\t\tmu.Unlock()\n\t\t\t}\n\t\t\t\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Xb6v] 详情页 %d/%d 处理完成，获取到 %d 个结果 (日期: %s)\", \n\t\t\t\t\tidx+1, len(detailPages), len(pageResults), pageInfo.DateTime.Format(\"2006-01-02\"))\n\t\t\t}\n\t\t}(i, detailPage)\n\t}\n\t\n\twg.Wait()\n\treturn results\n}\n\n// fetchDetailPageMagnetLinks 获取单个详情页的磁力链接\nfunc (p *Xb6vPlugin) fetchDetailPageMagnetLinks(client *http.Client, detailURL string, publishDate time.Time) []model.SearchResult {\n\t// 检查缓存\n\tif cached, ok := p.detailCache.Load(detailURL); ok {\n\t\tif results, ok := cached.([]model.SearchResult); ok {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Xb6v] 使用缓存的详情页结果: %s\", detailURL)\n\t\t\t}\n\t\t\treturn results\n\t\t}\n\t}\n\t\n\t// 请求详情页\n\tresp, err := p.doRequest(client, \"GET\", detailURL, \"\", p.currentBase)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xb6v] 获取详情页失败: %v\", err)\n\t\t}\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != http.StatusOK {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xb6v] 详情页响应状态码异常: %d\", resp.StatusCode)\n\t\t}\n\t\treturn nil\n\t}\n\t\n\t// 解析HTML\n\treader, err := p.getResponseReader(resp)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\tdoc, err := goquery.NewDocumentFromReader(reader)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xb6v] 解析详情页HTML失败: %v\", err)\n\t\t}\n\t\treturn nil\n\t}\n\t\n\t// 提取页面信息\n\ttitle := strings.TrimSpace(doc.Find(\"h1\").Text())\n\tif title == \"\" {\n\t\ttitle = \"未知标题\"\n\t}\n\t\n\t// 清理title，移除网站名称\n\ttitle = p.cleanTitle(title)\n\t\n\t// 提取分类信息\n\tcategory := strings.TrimSpace(doc.Find(\".info_category a\").Text())\n\t\n\t// 提取磁力链接\n\tmagnetLinks, linkInfos := p.extractMagnetLinks(doc, title)\n\t\n\tif len(magnetLinks) == 0 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xb6v] 详情页无磁力链接: %s\", detailURL)\n\t\t}\n\t\treturn nil\n\t}\n\t\n\t// 生成多个SearchResult，每个磁力链接一个\n\tvar results []model.SearchResult\n\tfor i, linkInfo := range linkInfos {\n\t\t// 生成唯一的资源ID\n\t\tresourceID := fmt.Sprintf(\"%s-%d\", p.extractResourceID(detailURL), i)\n\t\t\n\t\t// 构建\"主标题-子标题\"格式的标题\n\t\tresultTitle := fmt.Sprintf(\"%s-%s\", title, linkInfo.SubTitle)\n\t\t\n\t\tresult := model.SearchResult{\n\t\t\tTitle:     resultTitle,\n\t\t\tContent:   fmt.Sprintf(\"分类：%s\\n磁力链接：%s\", category, linkInfo.SubTitle),\n\t\t\tChannel:   \"\", // 插件搜索结果必须为空字符串\n\t\t\tMessageID: fmt.Sprintf(\"%s-%s\", p.Name(), resourceID),\n\t\t\tUniqueID:  fmt.Sprintf(\"%s-%s\", p.Name(), resourceID),\n\t\t\tDatetime:  publishDate, // 使用从搜索结果页面提取的真实发布日期\n\t\t\tLinks:     []model.Link{magnetLinks[i]}, // 每个结果只包含一个链接\n\t\t\tTags:      []string{category},\n\t\t}\n\t\t\n\t\tresults = append(results, result)\n\t}\n\t\n\t// 缓存所有结果（使用主标题作为键）\n\tp.detailCache.Store(detailURL, results)\n\t\n\t// 设置缓存过期\n\tgo func() {\n\t\ttime.Sleep(p.cacheTTL)\n\t\tp.detailCache.Delete(detailURL)\n\t}()\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xb6v] 提取到磁力链接: %s, 链接数: %d\", title, len(magnetLinks))\n\t}\n\t\n\treturn results\n}\n\n// MagnetLinkInfo 磁力链接信息（包含标题）\ntype MagnetLinkInfo struct {\n\tURL      string\n\tSubTitle string\n}\n\n// extractMagnetLinks 从详情页提取磁力链接\nfunc (p *Xb6vPlugin) extractMagnetLinks(doc *goquery.Document, mainTitle string) ([]model.Link, []MagnetLinkInfo) {\n\tvar links []model.Link\n\tvar linkInfos []MagnetLinkInfo\n\tlinkMap := make(map[string]bool) // 去重\n\t\n\t// 查找包含\"磁力：\"的表格单元格\n\tdoc.Find(\"td\").Each(func(i int, s *goquery.Selection) {\n\t\ttext := s.Text()\n\t\tif strings.Contains(text, \"磁力：\") {\n\t\t\t// 查找该单元格中的磁力链接\n\t\t\ts.Find(\"a[href^='magnet:']\").Each(func(j int, a *goquery.Selection) {\n\t\t\t\thref, exists := a.Attr(\"href\")\n\t\t\t\tif !exists || href == \"\" {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// 去重\n\t\t\t\tif linkMap[href] {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tlinkMap[href] = true\n\t\t\t\t\n\t\t\t\t// 获取链接子标题\n\t\t\t\tsubTitle := strings.TrimSpace(a.Text())\n\t\t\t\tif subTitle == \"\" {\n\t\t\t\t\tsubTitle = \"磁力链接\"\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tURL:  href,\n\t\t\t\t\tType: \"magnet\",\n\t\t\t\t})\n\t\t\t\t\n\t\t\t\tlinkInfos = append(linkInfos, MagnetLinkInfo{\n\t\t\t\t\tURL:      href,\n\t\t\t\t\tSubTitle: subTitle,\n\t\t\t\t})\n\t\t\t\t\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[Xb6v] 提取磁力链接: %s - %s\", mainTitle, subTitle)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\t\n\t// 如果没有在表格中找到，尝试在整个页面查找\n\tif len(links) == 0 {\n\t\tdoc.Find(\"a[href^='magnet:']\").Each(func(i int, s *goquery.Selection) {\n\t\t\thref, exists := s.Attr(\"href\")\n\t\t\tif !exists || href == \"\" {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\t// 去重\n\t\t\tif linkMap[href] {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlinkMap[href] = true\n\t\t\t\n\t\t\tsubTitle := strings.TrimSpace(s.Text())\n\t\t\tif subTitle == \"\" {\n\t\t\t\tsubTitle = \"磁力链接\"\n\t\t\t}\n\t\t\t\n\t\t\tlinks = append(links, model.Link{\n\t\t\t\tURL:  href,\n\t\t\t\tType: \"magnet\",\n\t\t\t})\n\t\t\t\n\t\t\tlinkInfos = append(linkInfos, MagnetLinkInfo{\n\t\t\t\tURL:      href,\n\t\t\t\tSubTitle: subTitle,\n\t\t\t})\n\t\t\t\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Xb6v] 提取磁力链接: %s - %s\", mainTitle, subTitle)\n\t\t\t}\n\t\t})\n\t}\n\t\n\treturn links, linkInfos\n}\n\n// extractResourceID 从详情页URL提取资源ID\nfunc (p *Xb6vPlugin) extractResourceID(detailURL string) string {\n\t// 从URL中提取ID，如：/dianshiju/guoju/26608.html -> 26608\n\tre := regexp.MustCompile(`/(\\d+)\\.html`)\n\tmatches := re.FindStringSubmatch(detailURL)\n\tif len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\t\n\t// 如果提取失败，使用时间戳\n\treturn fmt.Sprintf(\"%d\", time.Now().UnixNano())\n}\n\n// filterValidResults 过滤有效结果（去掉没有磁力链接的）\nfunc (p *Xb6vPlugin) filterValidResults(results []model.SearchResult) []model.SearchResult {\n\tvar validResults []model.SearchResult\n\t\n\tfor _, result := range results {\n\t\tif len(result.Links) > 0 {\n\t\t\tvalidResults = append(validResults, result)\n\t\t} else if p.debugMode {\n\t\t\tlog.Printf(\"[Xb6v] 忽略无磁力链接结果: %s\", result.Title)\n\t\t}\n\t}\n\t\n\treturn validResults\n}\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewXb6vPlugin())\n}"
  },
  {
    "path": "plugin/xdpan/html结构分析.md",
    "content": "# 兄弟盘 (xiongdipan.com) HTML结构分析\n\n## 网站信息\n\n- **网站名称**: 兄弟盘\n- **域名**: xiongdipan.com\n- **类型**: 百度网盘资源搜索引擎\n- **特点**: 专门搜索百度网盘资源，支持多种文件类型筛选\n\n## 搜索流程\n\n### 搜索URL模式\n\n```\nhttps://xiongdipan.com/search?page={页码}&k={关键词}&s={排序}&t={类型}\n\n示例:\nhttps://xiongdipan.com/search?page=1&k=凡人修仙传\n\n参数说明:\n- page: 页码，从1开始\n- k: 搜索关键词（URL编码）\n- s: 排序方式（可选）\n  - 0: 默认排序\n  - 1: 时间排序  \n  - 2: 完全匹配\n- t: 文件类型（可选）\n  - -1: 全部类别\n  - 1: 视频\n  - 2: 音乐\n  - 3: 图片\n  - 4: 文档\n  - 5: 压缩包\n  - 6: 其他\n  - 7: 文件夹\n```\n\n## 搜索结果页面结构\n\n### 主要容器\n- **页面容器**: `#app`\n- **结果项容器**: `van-row` (每个搜索结果)\n\n### 单个搜索结果结构\n\n每个搜索结果包含在一个 `van-row` 元素中：\n\n```html\n<van-row>\n    <!-- 隐藏的avail值 -->\n    <div style=\"display: none;\">\n        <input name=\"avail\" value=\"f03c5bdc457e067076eef46386379b8cc18af5320b64b369d5df35925a0603bd\">\n    </div>\n    \n    <!-- 详情页链接 -->\n    <a href=\"/s/S1UVAU3m37\" target=\"_blank\">\n        <van-col span=\"8\" offset=\"8\">\n            <van-card thumb=\"/img/folder.png\">\n                <!-- 标题区域 -->\n                <template #title>\n                    <div name=\"content-title\" style=\"font-size:medium;font-weight: 550;padding-top: 5px;\">\n                        <span style='color:red;'>凡人</span><span style='color:red;'>修仙</span><span style='color:red;'>传</span>\n                    </div>\n                </template>\n                \n                <!-- 元信息区域 -->\n                <template #bottom>\n                    <div style=\"padding-bottom: 20px;\">\n                        时间: 2025-10-16 &nbsp;&nbsp;格式:<b>文件夹</b>\n                    </div>\n                </template>\n            </van-card>\n        </van-col>\n    </a>\n    <van-divider></van-divider>\n</van-row>\n```\n\n### 提取要素\n\n1. **详情页链接**: `van-row > a` 的 `href` 属性\n   - 格式: `/s/{资源ID}`\n   - 完整URL: `https://xiongdipan.com/s/{资源ID}`\n\n2. **标题**: `div[name=\"content-title\"]` 的文本内容\n   - 需要提取所有 `span` 标签的文本并拼接\n   - 关键词会被标红显示\n\n3. **分享时间**: `template #bottom` 中 \"时间:\" 后的内容\n   - 格式: `YYYY-MM-DD`\n\n4. **文件格式**: `template #bottom` 中 \"格式:\" 后的 `<b>` 标签内容\n   - 常见值: \"文件夹\", \"视频\", \"文档\" 等\n\n5. **avail值**: 隐藏的 `input[name=\"avail\"]` 的 `value` 属性\n   - 用于后续获取真实下载链接\n\n## 详情页面结构\n\n### 详情页URL模式\n```\nhttps://xiongdipan.com/s/{资源ID}\n\n示例:\nhttps://xiongdipan.com/s/S1UVAU3m37\n```\n\n### 详情页关键信息\n\n```html\n<van-row>\n    <van-col span=\"8\" offset=\"8\">\n        <h3 align=\"center\">凡人修仙传</h3>\n    </van-col>\n</van-row>\n\n<!-- 资源信息 -->\n<van-cell title=\"名称\" value=\"凡人修仙传\"></van-cell>\n<van-cell title=\"类型\">文件夹</van-cell>\n<van-cell title=\"类别\">其他</van-cell>\n<van-cell title=\"分享时间\">2025-10-16</van-cell>\n\n<!-- 重要：密码信息 -->\n<van-cell title=\"密码\">\n    <b style=\"color: red\">1314</b>\n</van-cell>\n\n<!-- 下载按钮（包含真实链接） -->\n<van-goods-action-button type=\"info\" text=\"同意声明,继续访问下载\" @click=\"onDownload();\"></van-goods-action-button>\n```\n\n### JavaScript中的真实链接\n\n在详情页的JavaScript代码中可以找到真实的百度网盘链接：\n\n```javascript\nonDownload() {\n    window.open(\"https://pan.baidu.com/s/15ebI1HYr-BERAnv1A7kOTQ?pwd=1314\", \"target\");\n}\n```\n\n### 提取要素\n\n1. **资源名称**: `van-cell[title=\"名称\"]` 的 `value` 属性\n2. **文件类型**: `van-cell[title=\"类型\"]` 的文本内容\n3. **分享时间**: `van-cell[title=\"分享时间\"]` 的文本内容\n4. **密码**: `van-cell[title=\"密码\"] b` 的文本内容\n5. **百度网盘链接**: JavaScript中 `onDownload()` 函数内的 `window.open()` URL\n\n## CSS选择器总结\n\n| 数据项 | CSS选择器 | 提取方式 |\n|--------|-----------|----------|\n| 搜索结果列表 | `van-row:has(a[href^=\"/s/\"])` | 遍历所有结果项 |\n| 详情页链接 | `van-row > a[href^=\"/s/\"]` | href 属性 |\n| 标题 | `div[name=\"content-title\"]` | 文本内容，拼接所有span |\n| 分享时间 | `template #bottom` 中时间部分 | 正则提取 |\n| 文件格式 | `template #bottom b` | 文本内容 |\n| avail值 | `input[name=\"avail\"]` | value 属性 |\n\n## 详情页选择器\n\n| 数据项 | CSS选择器 | 提取方式 |\n|--------|-----------|----------|\n| 资源名称 | `van-cell[title=\"名称\"]` | value 属性或文本 |\n| 密码 | `van-cell[title=\"密码\"] b` | 文本内容 |\n| 百度网盘链接 | JavaScript代码 | 正则提取onDownload函数中的URL |\n\n## 实现要点\n\n### 1. 两步搜索流程\n1. **搜索页面**: 获取资源列表和详情页链接\n2. **详情页面**: 获取真实的百度网盘链接和密码\n\n### 2. 标题处理\n- 标题由多个 `<span>` 标签组成，需要拼接\n- 关键词会被标红显示，需要保留完整文本\n\n### 3. 密码提取\n- 密码在详情页的 `van-cell[title=\"密码\"]` 中\n- 通常为4位数字，显示为红色\n\n### 4. 链接提取\n- 真实的百度网盘链接在JavaScript的 `onDownload()` 函数中\n- 需要使用正则表达式从JavaScript代码中提取\n- 链接格式: `https://pan.baidu.com/s/{shareId}?pwd={password}`\n\n### 5. 性能优化\n- 只获取第一页结果（根据需求文档）\n- 可以考虑并发获取详情页信息\n- 建议添加请求间隔避免被限制\n\n## 注意事项\n\n1. **仅支持百度网盘**: 该网站专门提供百度网盘资源\n2. **需要访问详情页**: 真实下载链接只在详情页的JavaScript中\n3. **密码提取重要**: 百度网盘链接通常需要提取码\n4. **请求频率控制**: 避免过快请求被网站限制\n5. **JavaScript解析**: 需要从HTML中的JavaScript代码提取真实链接\n\n## 示例数据结构\n\n```json\n{\n  \"title\": \"凡人修仙传\",\n  \"detailUrl\": \"https://xiongdipan.com/s/S1UVAU3m37\",\n  \"shareTime\": \"2025-10-16\",\n  \"fileType\": \"文件夹\",\n  \"password\": \"1314\",\n  \"baiduUrl\": \"https://pan.baidu.com/s/15ebI1HYr-BERAnv1A7kOTQ?pwd=1314\"\n}\n```\n"
  },
  {
    "path": "plugin/xdpan/xdpan.go",
    "content": "package xdpan\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tBaseURL        = \"https://xiongdipan.com\"\n\tUserAgent      = \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\"\n\tMaxConcurrency = 10 // 详情页最大并发数\n\tMaxRetries     = 3\n)\n\nvar (\n\tDebugLog = false // Debug开关，默认关闭\n)\n\n// XdpanPlugin 兄弟盘插件结构\ntype XdpanPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tdetailCache sync.Map // 详情页缓存\n\tcacheTTL    time.Duration\n}\n\n// NewXdpanPlugin 创建兄弟盘插件实例\nfunc NewXdpanPlugin() *XdpanPlugin {\n\treturn &XdpanPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"xdpan\", 3), // 优先级3 = 普通质量数据源\n\t\tcacheTTL:        60 * time.Minute,\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *XdpanPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *XdpanPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实现搜索逻辑\nfunc (p *XdpanPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif DebugLog {\n\t\tfmt.Printf(\"[xdpan] 开始搜索: keyword=%s\\n\", keyword)\n\t}\n\n\t// Step 1: 获取搜索结果页面\n\tsearchResults, err := p.fetchSearchResults(client, keyword)\n\tif err != nil {\n\t\tif DebugLog {\n\t\t\tfmt.Printf(\"[xdpan] 获取搜索结果失败: %v\\n\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"[%s] 获取搜索结果失败: %w\", p.Name(), err)\n\t}\n\tif DebugLog {\n\t\tfmt.Printf(\"[xdpan] 获取搜索结果成功: 结果数=%d\\n\", len(searchResults))\n\t}\n\n\t// Step 2: 并发获取详情页信息（获取真实的百度网盘链接）\n\tp.enrichWithDetailInfo(client, searchResults)\n\n\t// Step 3: 关键词过滤\n\tfilteredResults := plugin.FilterResultsByKeyword(searchResults, keyword)\n\tif DebugLog {\n\t\tfmt.Printf(\"[xdpan] 关键词过滤后: 过滤前=%d, 过滤后=%d\\n\", len(searchResults), len(filteredResults))\n\t}\n\n\treturn filteredResults, nil\n}\n\n// fetchSearchResults 获取搜索结果\nfunc (p *XdpanPlugin) fetchSearchResults(client *http.Client, keyword string) ([]model.SearchResult, error) {\n\t// 构建搜索URL（只获取第一页）\n\tsearchURL := fmt.Sprintf(\"%s/search?page=1&k=%s\", BaseURL, url.QueryEscape(keyword))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建GET请求失败: %w\", err)\n\t}\n\n\tp.setRequestHeaders(req)\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[xdpan] 搜索URL: %s\\n\", searchURL)\n\t}\n\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"GET请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"请求返回状态码: %d\", resp.StatusCode)\n\t}\n\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"解析HTML失败: %w\", err)\n\t}\n\n\treturn p.extractSearchResults(doc), nil\n}\n\n// extractSearchResults 从搜索页面提取结果\nfunc (p *XdpanPlugin) extractSearchResults(doc *goquery.Document) []model.SearchResult {\n\tvar results []model.SearchResult\n\n\t// 查找所有包含详情页链接的van-row元素\n\tdoc.Find(\"van-row\").Each(func(i int, s *goquery.Selection) {\n\t\t// 检查是否包含详情页链接\n\t\tdetailLink := s.Find(\"a[href^='/s/']\")\n\t\tif detailLink.Length() == 0 {\n\t\t\treturn\n\t\t}\n\n\t\tresult := p.parseSearchResult(s)\n\t\tif result.Title != \"\" {\n\t\t\tresults = append(results, result)\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[xdpan] 解析结果[%d]: title=%s, detailUrl=%s\\n\", i, result.Title, result.Content)\n\t\t\t}\n\t\t}\n\t})\n\n\tif DebugLog {\n\t\tfmt.Printf(\"[xdpan] 提取到有效结果数: %d\\n\", len(results))\n\t}\n\n\treturn results\n}\n\n// parseSearchResult 解析单个搜索结果\nfunc (p *XdpanPlugin) parseSearchResult(s *goquery.Selection) model.SearchResult {\n\t// 提取详情页链接\n\tdetailLink := s.Find(\"a[href^='/s/']\")\n\tdetailPath, _ := detailLink.Attr(\"href\")\n\tvar detailURL string\n\tif detailPath != \"\" {\n\t\tdetailURL = BaseURL + detailPath\n\t}\n\n\t// 提取资源ID\n\tresourceID := \"\"\n\tif detailPath != \"\" {\n\t\tparts := strings.Split(detailPath, \"/\")\n\t\tif len(parts) >= 3 {\n\t\t\tresourceID = parts[2]\n\t\t}\n\t}\n\n\t// 提取标题（从content-title div中的所有span标签）\n\tvar titleParts []string\n\ts.Find(\"div[name='content-title'] span\").Each(func(i int, span *goquery.Selection) {\n\t\ttext := strings.TrimSpace(span.Text())\n\t\tif text != \"\" {\n\t\t\ttitleParts = append(titleParts, text)\n\t\t}\n\t})\n\ttitle := strings.Join(titleParts, \"\")\n\n\t// 如果没有找到span标签，尝试直接获取content-title的文本\n\tif title == \"\" {\n\t\ttitle = strings.TrimSpace(s.Find(\"div[name='content-title']\").Text())\n\t}\n\n\t// 提取时间和格式信息\n\tvar shareTime, fileType string\n\tbottomText := s.Find(\"template\").Text()\n\tif bottomText == \"\" {\n\t\t// 如果template不能直接获取文本，尝试其他方式\n\t\tbottomText = s.Find(\"div\").FilterFunction(func(i int, sel *goquery.Selection) bool {\n\t\t\treturn strings.Contains(sel.Text(), \"时间:\")\n\t\t}).Text()\n\t}\n\n\t// 使用正则表达式提取时间和格式\n\ttimeRegex := regexp.MustCompile(`时间:\\s*(\\d{4}-\\d{1,2}-\\d{1,2})`)\n\tif matches := timeRegex.FindStringSubmatch(bottomText); len(matches) > 1 {\n\t\tshareTime = matches[1]\n\t}\n\n\tformatRegex := regexp.MustCompile(`格式:\\s*<b>([^<]+)</b>`)\n\tif matches := formatRegex.FindStringSubmatch(bottomText); len(matches) > 1 {\n\t\tfileType = matches[1]\n\t}\n\n\t// 解析时间\n\tparsedTime := p.parseTime(shareTime)\n\n\t// 构建内容描述\n\tcontent := fmt.Sprintf(\"类型: %s | 分享时间: %s | 详情: %s\", fileType, shareTime, detailURL)\n\n\t// 如果没有找到资源ID，使用时间戳\n\tif resourceID == \"\" {\n\t\tresourceID = fmt.Sprintf(\"%d\", time.Now().UnixNano())\n\t}\n\n\treturn model.SearchResult{\n\t\tMessageID: fmt.Sprintf(\"%s-%s\", p.Name(), resourceID),\n\t\tUniqueID:  fmt.Sprintf(\"%s-%s\", p.Name(), resourceID),\n\t\tTitle:     title,\n\t\tContent:   content,\n\t\tDatetime:  parsedTime,\n\t\tLinks:     []model.Link{}, // 初始为空，后续从详情页获取\n\t\tChannel:   \"\",             // ⭐ 重要：插件搜索结果Channel必须为空\n\t}\n}\n\n// enrichWithDetailInfo 并发获取详情页信息\nfunc (p *XdpanPlugin) enrichWithDetailInfo(client *http.Client, results []model.SearchResult) {\n\tif len(results) == 0 {\n\t\treturn\n\t}\n\n\tvar wg sync.WaitGroup\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\n\tfor i := range results {\n\t\twg.Add(1)\n\t\tgo func(index int) {\n\t\t\tdefer wg.Done()\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\n\t\t\t// 添加延时避免请求过快\n\t\t\ttime.Sleep(time.Duration(index%3) * 200 * time.Millisecond)\n\n\t\t\t// 从Content中提取详情页URL\n\t\t\tdetailURL := p.extractDetailURLFromContent(results[index].Content)\n\t\t\tif detailURL != \"\" {\n\t\t\t\tlinks := p.fetchDetailPageLinks(client, detailURL)\n\t\t\t\tif len(links) > 0 {\n\t\t\t\t\tresults[index].Links = links\n\t\t\t\t\tif DebugLog {\n\t\t\t\t\t\tfmt.Printf(\"[xdpan] 获取详情页链接成功: %s, 链接数: %d\\n\", detailURL, len(links))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}(i)\n\t}\n\n\twg.Wait()\n}\n\n// fetchDetailPageLinks 获取详情页中的百度网盘链接\nfunc (p *XdpanPlugin) fetchDetailPageLinks(client *http.Client, detailURL string) []model.Link {\n\tif detailURL == \"\" {\n\t\treturn []model.Link{}\n\t}\n\n\t// 检查缓存\n\tif cached, ok := p.detailCache.Load(detailURL); ok {\n\t\tif cacheItem, ok := cached.(cacheItem); ok {\n\t\t\tif time.Since(cacheItem.timestamp) < p.cacheTTL {\n\t\t\t\treturn cacheItem.links\n\t\t\t}\n\t\t}\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn []model.Link{}\n\t}\n\n\tp.setRequestHeaders(req)\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn []model.Link{}\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn []model.Link{}\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn []model.Link{}\n\t}\n\n\tlinks := p.extractDetailPageLinks(doc)\n\n\t// 缓存结果\n\tp.detailCache.Store(detailURL, cacheItem{\n\t\tlinks:     links,\n\t\ttimestamp: time.Now(),\n\t})\n\n\treturn links\n}\n\n// extractDetailPageLinks 从详情页提取百度网盘链接\nfunc (p *XdpanPlugin) extractDetailPageLinks(doc *goquery.Document) []model.Link {\n\tvar links []model.Link\n\n\t// 提取密码\n\tpassword := \"\"\n\tdoc.Find(\"van-cell\").Each(func(i int, s *goquery.Selection) {\n\t\ttitle, _ := s.Attr(\"title\")\n\t\tif title == \"密码\" {\n\t\t\tpassword = strings.TrimSpace(s.Find(\"b\").Text())\n\t\t}\n\t})\n\n\t// 从JavaScript代码中提取百度网盘链接\n\tdoc.Find(\"script\").Each(func(i int, s *goquery.Selection) {\n\t\tscriptContent := s.Text()\n\t\t\n\t\t// 查找onDownload函数中的window.open链接\n\t\tre := regexp.MustCompile(`window\\.open\\(\"([^\"]*pan\\.baidu\\.com[^\"]*)\"`)\n\t\tmatches := re.FindStringSubmatch(scriptContent)\n\t\t\n\t\tif len(matches) > 1 {\n\t\t\tbaiduURL := matches[1]\n\t\t\t\n\t\t\t// 如果链接中没有密码参数，但我们从页面中提取到了密码，则添加密码参数\n\t\t\tif !strings.Contains(baiduURL, \"pwd=\") && password != \"\" {\n\t\t\t\tseparator := \"?\"\n\t\t\t\tif strings.Contains(baiduURL, \"?\") {\n\t\t\t\t\tseparator = \"&\"\n\t\t\t\t}\n\t\t\t\tbaiduURL = fmt.Sprintf(\"%s%spwd=%s\", baiduURL, separator, password)\n\t\t\t}\n\t\t\t\n\t\t\tlinks = append(links, model.Link{\n\t\t\t\tURL:      baiduURL,\n\t\t\t\tType:     \"baidu\",\n\t\t\t\tPassword: password,\n\t\t\t})\n\t\t\t\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[xdpan] 提取到百度网盘链接: %s, 密码: %s\\n\", baiduURL, password)\n\t\t\t}\n\t\t}\n\t})\n\n\treturn links\n}\n\n// extractDetailURLFromContent 从Content中提取详情页URL\nfunc (p *XdpanPlugin) extractDetailURLFromContent(content string) string {\n\t// 查找详情URL模式\n\tre := regexp.MustCompile(`详情:\\s*(https?://[^\\s]+)`)\n\tmatches := re.FindStringSubmatch(content)\n\tif len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// parseTime 解析时间字符串\nfunc (p *XdpanPlugin) parseTime(timeStr string) time.Time {\n\ttimeStr = strings.TrimSpace(timeStr)\n\tif timeStr == \"\" {\n\t\treturn time.Now()\n\t}\n\n\tformats := []string{\n\t\t\"2006-1-2\",\n\t\t\"2006-01-02\",\n\t\t\"2006-1-2 15:04\",\n\t\t\"2006-01-02 15:04\",\n\t\t\"2006-1-2 15:04:05\",\n\t\t\"2006-01-02 15:04:05\",\n\t}\n\n\tfor _, format := range formats {\n\t\tif t, err := time.Parse(format, timeStr); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\n\t// 如果解析失败，返回当前时间\n\treturn time.Now()\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *XdpanPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tvar lastErr error\n\n\tfor i := 0; i < MaxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 500 * time.Millisecond\n\t\t\tif DebugLog {\n\t\t\t\tfmt.Printf(\"[xdpan] 重试第%d次，等待%v\\n\", i, backoff)\n\t\t\t}\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", MaxRetries, lastErr)\n}\n\n// setRequestHeaders 设置请求头\nfunc (p *XdpanPlugin) setRequestHeaders(req *http.Request) {\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n}\n\n// cacheItem 缓存项结构\ntype cacheItem struct {\n\tlinks     []model.Link\n\ttimestamp time.Time\n}\n\nfunc init() {\n\tp := NewXdpanPlugin()\n\tplugin.RegisterGlobalPlugin(p)\n}\n"
  },
  {
    "path": "plugin/xdyh/json结构分析.md",
    "content": "# XDYH 搜索API JSON结构分析\n\n## 接口信息\n\n- **接口名称**: XDYH 聚合搜索API\n- **接口地址**: `https://ys.66ds.de/search`\n- **请求方法**: `POST`\n- **Content-Type**: `application/json`\n- **主要特点**: 聚合多个网盘搜索站点，提供统一的JSON API接口\n\n## 请求结构\n\n### 请求体格式\n\n```json\n{\n  \"keyword\": \"关键词\",\n  \"sites\": null,\n  \"max_workers\": 10,\n  \"save_to_file\": false,\n  \"split_links\": true\n}\n```\n\n### 请求参数说明\n\n| 参数名 | 类型 | 必需 | 默认值 | 说明 |\n|--------|------|------|--------|------|\n| `keyword` | string | 是 | - | 搜索关键词 |\n| `sites` | array/null | 否 | null | 指定搜索的站点列表，null表示搜索所有站点 |\n| `max_workers` | int | 否 | 10 | 最大并发工作线程数 |\n| `save_to_file` | bool | 否 | false | 是否保存结果到文件 |\n| `split_links` | bool | 否 | true | 是否拆分链接 |\n\n## 响应结构\n\n### 基本响应格式\n\n```json\n{\n  \"status\": \"success\",\n  \"keyword\": \"搜索关键词\",\n  \"search_timestamp\": \"2025-09-09T09:55:55.091056\",\n  \"summary\": { ... },\n  \"successful_sites\": [ ... ],\n  \"failed_sites\": [ ... ],\n  \"data\": [ ... ],\n  \"performance\": { ... }\n}\n```\n\n### 响应字段详解\n\n#### 1. 基本信息\n\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| `status` | string | 请求状态：\"success\" 或 \"error\" |\n| `keyword` | string | 搜索关键词 |\n| `search_timestamp` | string | 搜索时间戳（ISO 8601格式） |\n\n#### 2. 统计信息 (summary)\n\n```json\n{\n  \"total_sites_searched\": 9,\n  \"successful_sites\": 9,\n  \"failed_sites\": 0,\n  \"total_search_results\": 759,\n  \"total_successful_parses\": 232,\n  \"total_drive_links\": 226,\n  \"unique_links\": 226\n}\n```\n\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| `total_sites_searched` | int | 总搜索站点数 |\n| `successful_sites` | int | 成功搜索的站点数 |\n| `failed_sites` | int | 失败的站点数 |\n| `total_search_results` | int | 总搜索结果数 |\n| `total_successful_parses` | int | 成功解析的结果数 |\n| `total_drive_links` | int | 网盘链接总数 |\n| `unique_links` | int | 去重后的唯一链接数 |\n\n#### 3. 站点信息\n\n```json\n{\n  \"successful_sites\": [\n    \"云桥\",\n    \"寻道云海\", \n    \"易客FM\",\n    \"段聚搜\",\n    \"搜一搜影视\",\n    \"闪电搜\",\n    \"Melost\",\n    \"万阅书屋\",\n    \"Pansoo夸克网盘\"\n  ],\n  \"failed_sites\": []\n}\n```\n\n#### 4. 搜索结果数据 (data)\n\n##### 基础结果格式\n```json\n{\n  \"title\": \"逆仙而上[2025]【更至14】[爱情 古装]\",\n  \"post_date\": \"2025-09-08 12:32:03\",\n  \"drive_links\": [\n    \"https://pan.quark.cn/s/de411fee612b\"\n  ],\n  \"has_links\": true,\n  \"link_count\": 1,\n  \"password\": \"\",\n  \"source_api\": \"yunso\",\n  \"source_site\": \"云桥\"\n}\n```\n\n##### 扩展结果格式（部分结果包含更多字段）\n```json\n{\n  \"title\": \"仙逆\",\n  \"post_date\": \"2025-09-07\",\n  \"drive_links\": [\n    \"https://pan.quark.cn/s/85ef7d3e06b5\"\n  ],\n  \"password\": \"7vs2\",\n  \"has_password\": true,\n  \"has_links\": true,\n  \"link_count\": 1,\n  \"source_site\": \"万阅书屋\",\n  \"file_preview\": \"file:仙逆-hu-077.mp4, file:仙逆-hu-091.mp4\"\n}\n```\n\n##### 数据字段说明\n\n| 字段名 | 类型 | 必需 | 说明 |\n|--------|------|------|------|\n| `title` | string | 是 | 资源标题 |\n| `post_date` | string | 是 | 发布日期（格式：YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD） |\n| `drive_links` | array | 是 | 网盘链接列表 |\n| `has_links` | bool | 是 | 是否包含有效链接 |\n| `link_count` | int | 是 | 链接数量 |\n| `password` | string | 否 | 网盘密码（可能为空） |\n| `has_password` | bool | 否 | 是否有密码 |\n| `source_site` | string | 是 | 来源站点名称 |\n| `source_api` | string | 否 | 来源API标识 |\n| `file_preview` | string | 否 | 文件预览信息（部分结果） |\n\n#### 5. 性能信息 (performance)\n\n```json\n{\n  \"total_search_time\": 1.67,\n  \"sites_searched\": 9,\n  \"avg_time_per_site\": 0.19,\n  \"optimization\": \"asyncio_gather\",\n  \"timestamp\": \"2025-09-09T09:55:55.091451\"\n}\n```\n\n| 字段名 | 类型 | 说明 |\n|--------|------|------|\n| `total_search_time` | float | 总搜索耗时（秒） |\n| `sites_searched` | int | 搜索的站点数量 |\n| `avg_time_per_site` | float | 平均每站点耗时（秒） |\n| `optimization` | string | 优化策略标识 |\n| `timestamp` | string | 性能统计时间戳 |\n\n## 支持的网盘类型\n\n根据API返回的数据分析，支持以下网盘类型：\n\n- **夸克网盘**: `https://pan.quark.cn/s/xxxxxxxx`\n- **UC网盘**: `https://drive.uc.cn/s/xxxxxxxx`\n- **百度网盘**: `https://pan.baidu.com/s/xxxxxxxx`\n- **阿里云盘**: `https://www.alipan.com/s/xxxxxxxx`\n- **天翼云盘**: `https://cloud.189.cn/t/xxxxxxxx`\n- **其他网盘**: 根据实际API返回确定\n\n## 数据来源站点\n\nAPI聚合了以下9个搜索站点：\n\n1. **云桥** - API标识: `yunso`\n2. **寻道云海**\n3. **易客FM** \n4. **段聚搜**\n5. **搜一搜影视**\n6. **闪电搜**\n7. **Melost**\n8. **万阅书屋**\n9. **Pansoo夸克网盘**\n\n## 重要特性\n\n### 1. 聚合搜索 🔍\n- 同时搜索9个不同的资源站点\n- 自动去重和链接整合\n- 统一的数据格式输出\n\n### 2. 异步并发 ⚡\n- 使用 `asyncio_gather` 优化策略\n- 支持自定义并发工作线程数（`max_workers`）\n- 平均每站点搜索时间约0.19秒\n\n### 3. 密码处理 🔐\n- 自动提取网盘链接密码\n- 提供 `has_password` 字段标识\n- 密码信息在 `password` 字段中\n\n### 4. 性能统计 📊\n- 详细的搜索性能数据\n- 成功/失败站点统计\n- 链接数量和去重统计\n\n## 提取逻辑\n\n### 请求构建\n```go\ntype SearchRequest struct {\n    Keyword     string      `json:\"keyword\"`\n    Sites       interface{} `json:\"sites\"`        // null or []string\n    MaxWorkers  int         `json:\"max_workers\"`\n    SaveToFile  bool        `json:\"save_to_file\"`\n    SplitLinks  bool        `json:\"split_links\"`\n}\n```\n\n### 响应解析\n```go\ntype APIResponse struct {\n    Status          string               `json:\"status\"`\n    Keyword         string               `json:\"keyword\"`\n    SearchTimestamp string               `json:\"search_timestamp\"`\n    Summary         Summary              `json:\"summary\"`\n    SuccessfulSites []string             `json:\"successful_sites\"`\n    FailedSites     []string             `json:\"failed_sites\"`\n    Data            []SearchResultItem   `json:\"data\"`\n    Performance     Performance          `json:\"performance\"`\n}\n\ntype SearchResultItem struct {\n    Title        string   `json:\"title\"`\n    PostDate     string   `json:\"post_date\"`\n    DriveLinks   []string `json:\"drive_links\"`\n    HasLinks     bool     `json:\"has_links\"`\n    LinkCount    int      `json:\"link_count\"`\n    Password     string   `json:\"password,omitempty\"`\n    HasPassword  bool     `json:\"has_password,omitempty\"`\n    SourceSite   string   `json:\"source_site\"`\n    SourceAPI    string   `json:\"source_api,omitempty\"`\n    FilePreview  string   `json:\"file_preview,omitempty\"`\n}\n```\n\n### 链接转换\n```go\n// 将API结果转换为标准链接格式\nfunc convertToStandardLinks(items []SearchResultItem) []model.Link {\n    var links []model.Link\n    for _, item := range items {\n        for _, driveLink := range item.DriveLinks {\n            link := model.Link{\n                Type:     determineCloudType(driveLink),\n                URL:      driveLink,\n                Password: item.Password,\n            }\n            links = append(links, link)\n        }\n    }\n    return links\n}\n```\n\n## 错误处理\n\n### 常见错误类型\n1. **网络连接错误**: 请求超时或连接失败\n2. **API服务错误**: 服务端返回非200状态码\n3. **JSON解析错误**: 响应格式不符合预期\n4. **站点访问失败**: 部分源站点无法访问\n\n### 容错机制\n- **部分失败容忍**: 即使部分站点失败，仍返回成功站点的结果\n- **去重处理**: 自动去除重复的网盘链接\n- **数据验证**: 验证链接有效性和格式正确性\n\n## 性能优化建议\n\n1. **并发控制**: 根据服务器性能调整 `max_workers` 参数\n2. **缓存策略**: 对相同关键词实现合理的缓存机制\n3. **超时设置**: 设置适当的HTTP请求超时时间\n4. **重试机制**: 对临时失败的请求实现重试逻辑\n\n## 开发注意事项\n\n1. **优先级设置**: 建议设置为优先级2，聚合搜索质量较高\n2. **Service层过滤**: 使用标准的Service层过滤，不跳过\n3. **链接去重**: API已提供去重功能，插件层面可简化处理\n4. **密码处理**: 正确提取和设置网盘密码字段\n5. **时间格式**: 注意处理不同的时间格式（带时分秒 vs 仅日期）\n"
  },
  {
    "path": "plugin/xdyh/xdyh.go",
    "content": "package xdyh\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n)\n\n// 预编译的常量\nconst (\n\tpluginName = \"xdyh\"\n\tapiURL     = \"https://ys.66ds.de/search\"\n\trefererURL = \"https://ys.66ds.de/\"\n\t\n\t// 超时时间配置\n\tDefaultTimeout = 15 * time.Second  // API聚合搜索需要更长时间\n\t\n\t// 并发数配置\n\tMaxConcurrency = 10\n\t\n\t// HTTP连接池配置\n\tMaxIdleConns        = 100\n\tMaxIdleConnsPerHost = 30\n\tMaxConnsPerHost     = 50\n\tIdleConnTimeout     = 90 * time.Second\n)\n\n// 缓存相关\nvar (\n\tsearchCache     = sync.Map{} // 缓存搜索结果\n\tlastCleanupTime = time.Now()\n\tcacheTTL        = 30 * time.Minute // API搜索结果缓存时间相对较短\n)\n\n// 在init函数中注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewXdyhPlugin())\n\t\n\t// 启动缓存清理goroutine\n\tgo startCacheCleaner()\n}\n\n// startCacheCleaner 启动一个定期清理缓存的goroutine\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(20 * time.Minute)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\t// 清空所有缓存\n\t\tsearchCache = sync.Map{}\n\t\tlastCleanupTime = time.Now()\n\t}\n}\n\n// XdyhAsyncPlugin XDYH异步插件\ntype XdyhAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t\tForceAttemptHTTP2:   true,\n\t}\n\treturn &http.Client{Transport: transport, Timeout: DefaultTimeout}\n}\n\n// NewXdyhPlugin 创建新的XDYH异步插件\nfunc NewXdyhPlugin() *XdyhAsyncPlugin {\n\treturn &XdyhAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, 3), \n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Search 兼容性方法，实际调用SearchWithResult\nfunc (p *XdyhAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *XdyhAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 具体的搜索实现\nfunc (p *XdyhAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 1. 检查缓存\n\tcacheKey := fmt.Sprintf(\"%s_%s\", pluginName, keyword)\n\tif cached, ok := searchCache.Load(cacheKey); ok {\n\t\tif results, ok := cached.([]model.SearchResult); ok {\n\t\t\treturn results, nil\n\t\t}\n\t}\n\t\n\t// 2. 构建请求体\n\trequestBody := SearchRequest{\n\t\tKeyword:    keyword,\n\t\tSites:      nil,  // null表示搜索所有站点\n\t\tMaxWorkers: 10,   // API默认并发数\n\t\tSaveToFile: false,\n\t\tSplitLinks: true,\n\t}\n\t\n\t// 3. JSON序列化\n\tjsonData, err := json.Marshal(requestBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] JSON序列化失败: %w\", pluginName, err)\n\t}\n\t\n\t// 4. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\t\n\t// 5. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", apiURL, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", pluginName, err)\n\t}\n\t\n\t// 6. 设置请求头\n\tp.setRequestHeaders(req)\n\t\n\t// 7. 发送请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", pluginName, err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 8. 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 请求返回状态码: %d\", pluginName, resp.StatusCode)\n\t}\n\t\n\t// 9. 读取响应体\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 读取响应失败: %w\", pluginName, err)\n\t}\n\t\n\t// 10. 解析JSON响应\n\tvar apiResp APIResponse\n\tif err := json.Unmarshal(body, &apiResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] JSON解析失败: %w\", pluginName, err)\n\t}\n\t\n\t// 11. 检查API响应状态\n\tif apiResp.Status != \"success\" {\n\t\treturn nil, fmt.Errorf(\"[%s] API返回错误状态: %s\", pluginName, apiResp.Status)\n\t}\n\t\n\t// 12. 转换为标准格式\n\tresults := p.convertToSearchResults(apiResp, keyword)\n\t\n\t// 13. 缓存结果\n\tif len(results) > 0 {\n\t\tsearchCache.Store(cacheKey, results)\n\t}\n\t\n\t// 14. 关键词过滤\n\treturn plugin.FilterResultsByKeyword(results, keyword), nil\n}\n\n// setRequestHeaders 设置HTTP请求头\nfunc (p *XdyhAsyncPlugin) setRequestHeaders(req *http.Request) {\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Referer\", refererURL)\n\treq.Header.Set(\"Origin\", \"https://ys.66ds.de\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *XdyhAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 500 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n\n// convertToSearchResults 将API响应转换为标准搜索结果\nfunc (p *XdyhAsyncPlugin) convertToSearchResults(apiResp APIResponse, keyword string) []model.SearchResult {\n\tresults := make([]model.SearchResult, 0, len(apiResp.Data))\n\tseenTitles := make(map[string]bool) // 去重用\n\t\n\tfor i, item := range apiResp.Data {\n\t\t// 简单去重处理\n\t\ttitleKey := fmt.Sprintf(\"%s_%s\", item.Title, item.SourceSite)\n\t\tif seenTitles[titleKey] {\n\t\t\tcontinue\n\t\t}\n\t\tseenTitles[titleKey] = true\n\t\t\n\t\t// 转换链接\n\t\tlinks := p.convertDriveLinks(item)\n\t\tif len(links) == 0 {\n\t\t\tcontinue // 跳过没有有效链接的结果\n\t\t}\n\t\t\n\t\t// 解析时间\n\t\tdatetime := p.parseDateTime(item.PostDate)\n\t\t\n\t\t// 构建内容描述\n\t\tcontent := p.buildContentDescription(item)\n\t\t\n\t\t// 提取标签\n\t\ttags := p.extractTags(item.Title, item.SourceSite)\n\t\t\n\t\t// 创建搜索结果\n\t\tresult := model.SearchResult{\n\t\t\tUniqueID:  fmt.Sprintf(\"%s-%d\", pluginName, i),\n\t\t\tTitle:     item.Title,\n\t\t\tContent:   content,\n\t\t\tDatetime:  datetime,\n\t\t\tTags:      tags,\n\t\t\tLinks:     links,\n\t\t\tChannel:   \"\", // 插件搜索结果必须为空字符串\n\t\t}\n\t\t\n\t\tresults = append(results, result)\n\t}\n\t\n\treturn results\n}\n\n// convertDriveLinks 转换网盘链接\nfunc (p *XdyhAsyncPlugin) convertDriveLinks(item SearchResultItem) []model.Link {\n\tlinks := make([]model.Link, 0, len(item.DriveLinks))\n\t\n\tfor _, driveURL := range item.DriveLinks {\n\t\tif driveURL == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 验证链接有效性\n\t\tif !p.isValidURL(driveURL) {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 确定网盘类型\n\t\tlinkType := p.determineCloudType(driveURL)\n\t\t\n\t\t// 创建链接对象\n\t\tlink := model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      driveURL,\n\t\t\tPassword: item.Password, // API已提供密码字段\n\t\t}\n\t\t\n\t\tlinks = append(links, link)\n\t}\n\t\n\treturn links\n}\n\n// parseDateTime 解析日期时间\nfunc (p *XdyhAsyncPlugin) parseDateTime(dateStr string) time.Time {\n\t// 尝试不同的时间格式\n\tformats := []string{\n\t\t\"2006-01-02 15:04:05\",\n\t\t\"2006-01-02\",\n\t\t\"2006/01/02\",\n\t\t\"01/02/2006\",\n\t}\n\t\n\tfor _, format := range formats {\n\t\tif t, err := time.Parse(format, dateStr); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\t\n\t// 解析失败时返回当前时间\n\treturn time.Now()\n}\n\n// buildContentDescription 构建内容描述\nfunc (p *XdyhAsyncPlugin) buildContentDescription(item SearchResultItem) string {\n\tparts := []string{}\n\t\n\t// 来源站点\n\tif item.SourceSite != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(\"来源: %s\", item.SourceSite))\n\t}\n\t\n\t// 链接数量\n\tif item.LinkCount > 0 {\n\t\tparts = append(parts, fmt.Sprintf(\"链接数: %d\", item.LinkCount))\n\t}\n\t\n\t// 密码信息\n\tif item.HasPassword && item.Password != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(\"密码: %s\", item.Password))\n\t}\n\t\n\t// 文件预览\n\tif item.FilePreview != \"\" {\n\t\tpreview := strings.ReplaceAll(item.FilePreview, \"<em>\", \"\")\n\t\tpreview = strings.ReplaceAll(preview, \"</em>\", \"\")\n\t\tif len(preview) > 100 {\n\t\t\tpreview = preview[:100] + \"...\"\n\t\t}\n\t\tparts = append(parts, fmt.Sprintf(\"预览: %s\", preview))\n\t}\n\t\n\treturn strings.Join(parts, \" | \")\n}\n\n// extractTags 提取标签\nfunc (p *XdyhAsyncPlugin) extractTags(title, sourceSite string) []string {\n\ttags := []string{}\n\t\n\t// 添加来源站点作为标签\n\tif sourceSite != \"\" {\n\t\ttags = append(tags, sourceSite)\n\t}\n\t\n\t// 从标题中提取常见标签\n\ttitle = strings.ToLower(title)\n\ttagKeywords := map[string]string{\n\t\t\"4k\":     \"4K\",\n\t\t\"1080p\":  \"1080P\",\n\t\t\"720p\":   \"720P\",\n\t\t\"蓝光\":    \"蓝光\",\n\t\t\"高清\":    \"高清\",\n\t\t\"更新\":    \"更新中\",\n\t\t\"完结\":    \"完结\",\n\t\t\"电影\":    \"电影\",\n\t\t\"剧集\":    \"剧集\",\n\t\t\"动漫\":    \"动漫\",\n\t\t\"综艺\":    \"综艺\",\n\t}\n\t\n\tfor keyword, tag := range tagKeywords {\n\t\tif strings.Contains(title, keyword) {\n\t\t\ttags = append(tags, tag)\n\t\t}\n\t}\n\t\n\treturn tags\n}\n\n// isValidURL 验证URL是否有效\nfunc (p *XdyhAsyncPlugin) isValidURL(urlStr string) bool {\n\tif urlStr == \"\" {\n\t\treturn false\n\t}\n\t\n\t// 检查基本的URL格式\n\tif strings.HasPrefix(urlStr, \"http://\") || strings.HasPrefix(urlStr, \"https://\") {\n\t\t// HTTP/HTTPS链接需要有域名\n\t\tif len(urlStr) <= 8 || urlStr == \"http://\" || urlStr == \"https://\" {\n\t\t\treturn false\n\t\t}\n\t\t// 简单检查是否包含域名\n\t\treturn strings.Contains(urlStr[8:], \".\")\n\t}\n\t\n\treturn false\n}\n\n// determineCloudType 确定网盘类型\nfunc (p *XdyhAsyncPlugin) determineCloudType(url string) string {\n\tswitch {\n\tcase strings.Contains(url, \"pan.quark.cn\"):\n\t\treturn \"quark\"\n\tcase strings.Contains(url, \"drive.uc.cn\"):\n\t\treturn \"uc\"\n\tcase strings.Contains(url, \"pan.baidu.com\"):\n\t\treturn \"baidu\"\n\tcase strings.Contains(url, \"aliyundrive.com\") || strings.Contains(url, \"alipan.com\"):\n\t\treturn \"aliyun\"\n\tcase strings.Contains(url, \"pan.xunlei.com\"):\n\t\treturn \"xunlei\"\n\tcase strings.Contains(url, \"cloud.189.cn\"):\n\t\treturn \"tianyi\"\n\tcase strings.Contains(url, \"115.com\") || strings.Contains(url, \"115cdn.com\"):\n\t\treturn \"115\"\n\tcase strings.Contains(url, \"123pan.com\"):\n\t\treturn \"123\"\n\tcase strings.Contains(url, \"caiyun.139.com\"):\n\t\treturn \"mobile\"\n\tcase strings.Contains(url, \"mypikpak.com\"):\n\t\treturn \"pikpak\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n// API请求结构体\ntype SearchRequest struct {\n\tKeyword    string      `json:\"keyword\"`\n\tSites      interface{} `json:\"sites\"`        // null or []string\n\tMaxWorkers int         `json:\"max_workers\"`\n\tSaveToFile bool        `json:\"save_to_file\"`\n\tSplitLinks bool        `json:\"split_links\"`\n}\n\n// API响应结构体\ntype APIResponse struct {\n\tStatus          string             `json:\"status\"`\n\tKeyword         string             `json:\"keyword\"`\n\tSearchTimestamp string             `json:\"search_timestamp\"`\n\tSummary         Summary            `json:\"summary\"`\n\tSuccessfulSites []string           `json:\"successful_sites\"`\n\tFailedSites     []string           `json:\"failed_sites\"`\n\tData            []SearchResultItem `json:\"data\"`\n\tPerformance     Performance        `json:\"performance\"`\n}\n\ntype Summary struct {\n\tTotalSitesSearched      int `json:\"total_sites_searched\"`\n\tSuccessfulSites         int `json:\"successful_sites\"`\n\tFailedSites             int `json:\"failed_sites\"`\n\tTotalSearchResults      int `json:\"total_search_results\"`\n\tTotalSuccessfulParses   int `json:\"total_successful_parses\"`\n\tTotalDriveLinks         int `json:\"total_drive_links\"`\n\tUniqueLinks             int `json:\"unique_links\"`\n}\n\ntype SearchResultItem struct {\n\tTitle       string   `json:\"title\"`\n\tPostDate    string   `json:\"post_date\"`\n\tDriveLinks  []string `json:\"drive_links\"`\n\tHasLinks    bool     `json:\"has_links\"`\n\tLinkCount   int      `json:\"link_count\"`\n\tPassword    string   `json:\"password,omitempty\"`\n\tHasPassword bool     `json:\"has_password,omitempty\"`\n\tSourceSite  string   `json:\"source_site\"`\n\tSourceAPI   string   `json:\"source_api,omitempty\"`\n\tFilePreview string   `json:\"file_preview,omitempty\"`\n}\n\ntype Performance struct {\n\tTotalSearchTime   float64 `json:\"total_search_time\"`\n\tSitesSearched     int     `json:\"sites_searched\"`\n\tAvgTimePerSite    float64 `json:\"avg_time_per_site\"`\n\tOptimization      string  `json:\"optimization\"`\n\tTimestamp         string  `json:\"timestamp\"`\n}\n"
  },
  {
    "path": "plugin/xiaoji/html结构分析.md",
    "content": "# 小鸡影视 (xiaojitv.com) 搜索结果HTML结构分析\n\n## 网站信息\n\n- **网站名称**: 小鸡影视\n- **域名**: `www.xiaojitv.com`\n- **搜索URL格式**: `https://www.xiaojitv.com/?s={关键词}`\n- **详情页URL格式**: `https://www.xiaojitv.com/{ID}.html`\n- **主要特点**: 影视资源站，提供多种网盘链接，使用base64编码保护真实链接\n\n## HTML结构\n\n### 搜索结果页面结构\n\n搜索结果页面使用poster布局，主要内容位于`.poster-grid`元素内：\n\n```html\n<div class=\"poster-grid\">\n    <article class=\"poster-item excerpt-1\">\n        <!-- 单个搜索结果 -->\n    </article>\n    <article class=\"poster-item excerpt-2\">\n        <!-- 单个搜索结果 -->\n    </article>\n    <!-- 更多搜索结果... -->\n</div>\n```\n\n### 单个搜索结果结构\n\n每个搜索结果包含以下主要元素：\n\n#### 1. 封面图片和详情页链接\n\n```html\n<div class=\"poster-image\">\n    <a class=\"poster-link\" href=\"https://www.xiaojitv.com/656.html\">\n        <img src=\"https://www.xiaojitv.com/wp-content/uploads/2025/09/47a3352110f7e36.webp\" \n             alt=\"凡人修仙传(2020) | 小鸡影视\" \n             class=\"thumb\">\n    </a>\n    <div class=\"poster-top-left\"></div>\n    <div class=\"poster-rating poster-top-right\">\n        <span class=\"rating-score\">7.9</span>\n    </div>\n    <div class=\"poster-category poster-bottom-left\">\n        <a href=\"https://www.xiaojitv.com/dongman\">动漫</a>\n    </div>\n    <div class=\"poster-views\">阅读(<span class=\"ajaxlistpv\" data-id=\"656\"></span>)</div>\n</div>\n```\n\n#### 2. 标题和标签信息\n\n```html\n<div class=\"poster-content\">\n    <h2 class=\"poster-title\">\n        <a href=\"https://www.xiaojitv.com/656.html\" title=\"凡人修仙传(2020) | 小鸡影视\">\n            凡人修仙传(2020)\n        </a>\n    </h2>\n    <div class=\"poster-tags\">\n        <a href=\"https://www.xiaojitv.com/tag/2020年\">2020年</a> / \n        <a href=\"https://www.xiaojitv.com/tag/7-9分\">7.9分</a> / \n        <a href=\"https://www.xiaojitv.com/tag/中国大陆\">中国大陆</a> / \n        <a href=\"https://www.xiaojitv.com/tag/动画\">动画</a> / \n        <a href=\"https://www.xiaojitv.com/tag/奇幻\">奇幻</a>\n    </div>\n</div>\n```\n\n## 详情页面结构\n\n详情页面包含完整的影片信息和网盘下载链接。\n\n### 1. 页面标题和基本信息\n\n```html\n<h1 class=\"article-title\">\n    <a href=\"https://www.xiaojitv.com/656.html\">凡人修仙传(2020)</a>\n</h1>\n```\n\n### 2. 相关资源区域 ⭐ 重要\n\n网盘下载链接位于相关资源区域，这是xiaoji插件的核心提取目标：\n\n```html\n<div class=\"cloud-search-resource-results\" data-post-id=\"656\">\n    <div class=\"cloud-search-resource-header\">\n        <h3>相关资源</h3>\n        <!-- 操作按钮 -->\n    </div>\n    \n    <!-- 资源列表 -->\n    <div class=\"resource-compact-item\">\n        <div class=\"resource-compact-link\">\n            <a href=\"https://www.xiaojitv.com/go.html?url=aHR0cHM6Ly9wYW4ucXVhcmsuY24vcy9kNjQ5MWJmZWQxNmI=\" \n               target=\"_blank\" rel=\"nofollow\">\n                凡人修仙传 2024 4K 持续更新中\n            </a>\n        </div>\n        <div class=\"resource-compact-info\">\n            <span class=\"resource-compact-source\">聚合盘</span>\n        </div>\n    </div>\n    \n    <div class=\"resource-compact-item\">\n        <div class=\"resource-compact-link\">\n            <a href=\"https://www.xiaojitv.com/go.html?url=aHR0cHM6Ly9jbG91ZC4xODkuY24vdC9JYmFVVnpFN1puZXk=\" \n               target=\"_blank\" rel=\"nofollow\">\n                凡人修仙传 2024 4K 持续更新中 txb\n            </a>\n        </div>\n        <div class=\"resource-compact-info\">\n            <span class=\"resource-compact-source\">小愛盘②</span>\n        </div>\n    </div>\n    \n    <!-- 更多资源... -->\n</div>\n```\n\n### 3. Base64编码链接解析 🔑 关键特性\n\nxiaoji网站使用特殊的链接保护机制：\n\n**原始链接格式**：\n```\nhttps://www.xiaojitv.com/go.html?url=aHR0cHM6Ly9wYW4ucXVhcmsuY24vcy9kNjQ5MWJmZWQxNmI=\n```\n\n**提取步骤**：\n1. 提取URL参数中的base64字符串：`aHR0cHM6Ly9wYW4ucXVhcmsuY24vcy9kNjQ5MWJmZWQxNmI=`\n2. 进行base64解码：`https://pan.quark.cn/s/d6491bfed16b`\n3. 得到真实的网盘链接\n\n## 提取逻辑\n\n### 搜索结果页面提取逻辑\n\n1. 定位所有的`article.poster-item`元素\n2. 对于每个元素：\n   - 从`.poster-link`的`href`属性提取详情页链接\n   - 从链接中提取资源ID（正则：`/(\\d+)\\.html`）\n   - 从`.poster-title a`提取标题\n   - 从`.poster-rating .rating-score`提取评分\n   - 从`.poster-category a`提取分类\n   - 从`.poster-image img`的`src`属性提取封面图片URL\n   - 从`.poster-tags a`提取标签信息\n\n### 详情页面提取逻辑\n\n1. 获取资源基本信息：\n   - 标题：`.article-title a`的文本内容\n   - 资源ID：从URL中提取\n\n2. 提取网盘链接 ⭐ 核心逻辑：\n   ```go\n   // 1. 查找所有资源链接\n   doc.Find(\".resource-compact-link a\").Each(func(i int, s *goquery.Selection) {\n       href, exists := s.Attr(\"href\")\n       if !exists {\n           return\n       }\n       \n       var realURL string\n       \n       // 2. 检查链接类型并处理\n       if strings.Contains(href, \"/go.html?url=\") {\n           // Base64编码链接，需要解码\n           parts := strings.Split(href, \"url=\")\n           if len(parts) == 2 {\n               encoded := parts[1]\n               decoded, err := base64.StdEncoding.DecodeString(encoded)\n               if err == nil {\n                   realURL = string(decoded)\n               }\n           }\n       } else if strings.HasPrefix(href, \"http://\") || strings.HasPrefix(href, \"https://\") || \n                 strings.HasPrefix(href, \"magnet:\") || strings.HasPrefix(href, \"ed2k://\") {\n           // 直接链接，无需解码\n           realURL = href\n       }\n       \n       // 3. 处理有效链接\n       if realURL != \"\" {\n           link := model.Link{\n               Type:     determineCloudType(realURL),\n               URL:      realURL,\n               Password: \"\", // xiaoji网站通常无密码\n           }\n           links = append(links, link)\n       }\n   })\n   ```\n\n3. 提取资源描述：\n   - 资源名称：`.resource-compact-link a`的文本内容\n   - 资源来源：`.resource-compact-source`的文本内容\n\n## 支持的网盘类型\n\n根据分析，xiaoji网站支持多种网盘类型：\n\n- **夸克网盘**: `https://pan.quark.cn/s/xxxxx`\n- **天翼云盘**: `https://cloud.189.cn/t/xxxxx`\n- **阿里云盘**: `https://www.alipan.com/s/xxxxx`\n- **百度网盘**: `https://pan.baidu.com/s/xxxxx`\n- **115网盘**: `https://115.com/s/xxxxx`、`https://115cdn.com/s/xxxxx`\n- **城通网盘**: `https://url91.ctfile.com/f/xxxxx` (归类到others)\n- **磁力链接**: `magnet:?xt=urn:btih:xxxxx`\n- **ED2K链接**: `ed2k://xxxxx`\n\n## 重要发现和注意事项\n\n### 1. Base64编码保护 🔐\n\n网站使用base64编码保护真实的网盘链接，这是xiaoji插件的最大特点：\n- 所有网盘链接都经过base64编码\n- 链接格式：`/go.html?url={base64字符串}`\n- 必须解码才能获得真实链接\n\n### 2. 搜索结果布局\n\n使用现代的poster布局，与传统的列表布局不同：\n- 使用CSS Grid布局\n- 每个结果都有封面图片\n- 包含评分和分类信息\n\n### 3. 动态加载\n\n页面可能使用了AJAX动态加载：\n- 某些内容可能需要等待JavaScript执行\n- 建议在请求时设置适当的User-Agent\n\n### 4. 反爬虫措施\n\n网站可能有一定的反爬虫措施：\n- 需要设置完整的浏览器请求头\n- 可能需要处理JavaScript渲染的内容\n\n## 提取字段映射\n\n| 字段 | HTML位置 | 提取方法 |\n|------|----------|----------|\n| 标题 | `.poster-title a` | 文本内容 |\n| 详情页链接 | `.poster-link` | href属性 |\n| 资源ID | 详情页URL | 正则提取 |\n| 封面图片 | `.poster-image img` | src属性 |\n| 评分 | `.rating-score` | 文本内容 |\n| 分类 | `.poster-category a` | 文本内容 |\n| 标签 | `.poster-tags a` | 文本内容数组 |\n| 网盘链接 | `.resource-compact-link a` | href属性（需base64解码） |\n| 资源描述 | `.resource-compact-link a` | 文本内容 |\n| 资源来源 | `.resource-compact-source` | 文本内容 |\n\n## 实现优先级\n\n1. **高优先级**: xiaoji是影视资源站，质量较好，建议设置为优先级2\n2. **Service层过滤**: 使用标准的Service层过滤，不跳过\n3. **缓存策略**: 建议设置合理的缓存时间，避免频繁请求\n\n## 开发注意事项\n\n1. **Base64解码**: 必须实现base64解码逻辑\n2. **网盘类型识别**: 使用系统自带的`determineCloudType`函数\n3. **错误处理**: 处理base64解码失败的情况\n4. **链接去重**: 避免重复的网盘链接\n5. **请求头设置**: 使用完整的浏览器请求头避免被拦截\n"
  },
  {
    "path": "plugin/xiaoji/xiaoji.go",
    "content": "package xiaoji\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\n// 预编译的正则表达式\nvar (\n\t// 从详情页URL中提取ID的正则表达式\n\tdetailIDRegex = regexp.MustCompile(`/(\\d+)\\.html`)\n\t\n\t// go.html链接的正则表达式，用于提取base64编码部分\n\tgoLinkRegex = regexp.MustCompile(`/go\\.html\\?url=([A-Za-z0-9+/]+=*)`)\n\t\n\t// 年份提取正则表达式\n\tyearRegex = regexp.MustCompile(`(\\d{4})`)\n\t\n\t// 缓存相关\n\tdetailCache = sync.Map{} // 缓存详情页解析结果\n\tlastCleanupTime = time.Now()\n\tcacheTTL = 1 * time.Hour\n)\n\nconst (\n\t// 基础配置\n\tpluginName = \"xiaoji\"\n\tbaseURL    = \"https://www.xiaojitv.com\"\n\t\n\t// 超时时间配置\n\tDefaultTimeout = 10 * time.Second\n\tDetailTimeout  = 8 * time.Second\n\t\n\t// 并发数配置\n\tMaxConcurrency = 15\n\t\n\t// HTTP连接池配置\n\tMaxIdleConns        = 100\n\tMaxIdleConnsPerHost = 30\n\tMaxConnsPerHost     = 50\n\tIdleConnTimeout     = 90 * time.Second\n)\n\n// 在init函数中注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewXiaojiPlugin())\n\t\n\t// 启动缓存清理goroutine\n\tgo startCacheCleaner()\n}\n\n// startCacheCleaner 启动一个定期清理缓存的goroutine\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(30 * time.Minute)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\t// 清空所有缓存\n\t\tdetailCache = sync.Map{}\n\t\tlastCleanupTime = time.Now()\n\t}\n}\n\n// XiaojiAsyncPlugin 小鸡影视异步插件\ntype XiaojiAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t\tForceAttemptHTTP2:   true,\n\t}\n\treturn &http.Client{Transport: transport, Timeout: DefaultTimeout}\n}\n\n// NewXiaojiPlugin 创建新的小鸡影视异步插件\nfunc NewXiaojiPlugin() *XiaojiAsyncPlugin {\n\treturn &XiaojiAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, 3), \n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Search 兼容性方法，实际调用SearchWithResult\nfunc (p *XiaojiAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *XiaojiAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 具体的搜索实现\nfunc (p *XiaojiAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 1. 构建搜索URL\n\tencodedKeyword := url.QueryEscape(keyword)\n\tsearchURL := fmt.Sprintf(\"%s/?s=%s\", baseURL, encodedKeyword)\n\t\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\t\n\t// 3. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", pluginName, err)\n\t}\n\t\n\t// 4. 设置请求头\n\tp.setRequestHeaders(req)\n\t\n\t// 5. 发送请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", pluginName, err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 6. 检查状态码\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 请求返回状态码: %d\", pluginName, resp.StatusCode)\n\t}\n\t\n\t// 7. 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] HTML解析失败: %w\", pluginName, err)\n\t}\n\t\n\t// 8. 解析搜索结果\n\tresults := p.parseSearchResults(doc, keyword)\n\t\n\t// 9. 关键词过滤\n\treturn plugin.FilterResultsByKeyword(results, keyword), nil\n}\n\n// setRequestHeaders 设置HTTP请求头，模拟真实浏览器\nfunc (p *XiaojiAsyncPlugin) setRequestHeaders(req *http.Request) {\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", baseURL+\"/\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *XiaojiAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n\n// parseSearchResults 解析搜索结果\nfunc (p *XiaojiAsyncPlugin) parseSearchResults(doc *goquery.Document, keyword string) []model.SearchResult {\n\tresults := make([]model.SearchResult, 0)\n\t\n\t// 查找所有搜索结果项\n\tdoc.Find(\"article.poster-item\").Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchResultItem(s, keyword)\n\t\tif result != nil {\n\t\t\tresults = append(results, *result)\n\t\t}\n\t})\n\t\n\treturn results\n}\n\n// parseSearchResultItem 解析单个搜索结果项\nfunc (p *XiaojiAsyncPlugin) parseSearchResultItem(s *goquery.Selection, keyword string) *model.SearchResult {\n\t// 1. 提取详情页链接\n\tdetailLink, exists := s.Find(\".poster-link\").Attr(\"href\")\n\tif !exists || detailLink == \"\" {\n\t\treturn nil\n\t}\n\t\n\t// 2. 确保链接是绝对路径\n\tif strings.HasPrefix(detailLink, \"/\") {\n\t\tdetailLink = baseURL + detailLink\n\t}\n\t\n\t// 3. 提取资源ID\n\tmatches := detailIDRegex.FindStringSubmatch(detailLink)\n\tif len(matches) < 2 {\n\t\treturn nil\n\t}\n\tresourceID := matches[1]\n\t\n\t// 4. 提取标题\n\ttitle := strings.TrimSpace(s.Find(\".poster-title a\").Text())\n\tif title == \"\" {\n\t\treturn nil\n\t}\n\t\n\t// 5. 提取评分\n\trating := strings.TrimSpace(s.Find(\".rating-score\").Text())\n\t\n\t// 6. 提取分类\n\tcategory := strings.TrimSpace(s.Find(\".poster-category a\").Text())\n\t\n\t// 7. 提取标签\n\tvar tags []string\n\ts.Find(\".poster-tags a\").Each(func(i int, tagSel *goquery.Selection) {\n\t\ttag := strings.TrimSpace(tagSel.Text())\n\t\tif tag != \"\" {\n\t\t\ttags = append(tags, tag)\n\t\t}\n\t})\n\t\n\t// 8. 提取封面图片\n\tcoverImg, _ := s.Find(\".poster-image img\").Attr(\"src\")\n\t\n\t// 9. 构建基础信息\n\tcontent := fmt.Sprintf(\"分类: %s\", category)\n\tif rating != \"\" {\n\t\tcontent += fmt.Sprintf(\" | 评分: %s\", rating)\n\t}\n\tif len(tags) > 0 {\n\t\tcontent += fmt.Sprintf(\" | 标签: %s\", strings.Join(tags, \", \"))\n\t}\n\t\n\t// 10. 获取详情页的下载链接\n\tlinks := p.fetchDetailPageLinks(detailLink)\n\t\n\t// 11. 创建搜索结果\n\tresult := &model.SearchResult{\n\t\tUniqueID:  fmt.Sprintf(\"%s-%s\", pluginName, resourceID),\n\t\tTitle:     title,\n\t\tContent:   content,\n\t\tDatetime:  time.Now(),\n\t\tTags:      tags,\n\t\tLinks:     links,\n\t\tChannel:   \"\", // 插件搜索结果必须为空字符串\n\t}\n\t\n\t// 12. 如果有封面图片，可以添加到额外信息中\n\tif coverImg != \"\" {\n\t\t// 这里可以扩展添加图片信息，当前版本暂不处理\n\t}\n\t\n\treturn result\n}\n\n// fetchDetailPageLinks 获取详情页的下载链接\nfunc (p *XiaojiAsyncPlugin) fetchDetailPageLinks(detailURL string) []model.Link {\n\t// 1. 检查缓存\n\tif cached, ok := detailCache.Load(detailURL); ok {\n\t\tif links, ok := cached.([]model.Link); ok {\n\t\t\treturn links\n\t\t}\n\t}\n\t\n\t// 2. 创建请求\n\tctx, cancel := context.WithTimeout(context.Background(), DetailTimeout)\n\tdefer cancel()\n\t\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\t// 3. 设置请求头\n\tp.setRequestHeaders(req)\n\t\n\t// 4. 发送请求\n\tresp, err := p.doRequestWithRetry(req, p.optimizedClient)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 5. 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\t// 6. 提取下载链接\n\tlinks := p.parseDetailPageLinks(doc)\n\t\n\t// 7. 缓存结果\n\tif len(links) > 0 {\n\t\tdetailCache.Store(detailURL, links)\n\t}\n\t\n\treturn links\n}\n\n// parseDetailPageLinks 解析详情页的下载链接\nfunc (p *XiaojiAsyncPlugin) parseDetailPageLinks(doc *goquery.Document) []model.Link {\n\tlinks := make([]model.Link, 0)\n\tseenLinks := make(map[string]bool) // 用于去重\n\t\n\t// 查找相关资源区域的链接\n\tdoc.Find(\".resource-compact-link a\").Each(func(i int, s *goquery.Selection) {\n\t\thref, exists := s.Attr(\"href\")\n\t\tif !exists {\n\t\t\treturn\n\t\t}\n\t\t\n\t\tvar realURL string\n\t\t\n\t\t// 检查是否为go.html格式的链接（需要base64解码）\n\t\tif strings.Contains(href, \"/go.html?url=\") {\n\t\t\t// 提取并解码真实链接\n\t\t\trealURL = p.decodeGoLink(href)\n\t\t} else if strings.HasPrefix(href, \"http://\") || strings.HasPrefix(href, \"https://\") || strings.HasPrefix(href, \"magnet:\") || strings.HasPrefix(href, \"ed2k://\") {\n\t\t\t// 直接链接（包括磁力链接、网盘链接等）\n\t\t\trealURL = href\n\t\t}\n\t\t\n\t\t// 处理有效链接\n\t\tif p.isValidURL(realURL) && !seenLinks[realURL] {\n\t\t\t// 确定网盘类型\n\t\t\tlinkType := p.determineCloudType(realURL)\n\t\t\t\n\t\t\t// 创建链接对象\n\t\t\tlink := model.Link{\n\t\t\t\tType:     linkType,\n\t\t\t\tURL:      realURL,\n\t\t\t\tPassword: \"\", // xiaoji网站通常无密码\n\t\t\t}\n\t\t\t\n\t\t\tlinks = append(links, link)\n\t\t\tseenLinks[realURL] = true\n\t\t}\n\t})\n\t\n\treturn links\n}\n\n// decodeGoLink 解码go.html链接，提取真实的网盘链接\nfunc (p *XiaojiAsyncPlugin) decodeGoLink(goLink string) string {\n\t// 1. 提取base64编码部分\n\tmatches := goLinkRegex.FindStringSubmatch(goLink)\n\tif len(matches) < 2 {\n\t\treturn \"\"\n\t}\n\t\n\tencoded := matches[1]\n\t\n\t// 2. 清理编码字符串\n\tencoded = strings.TrimSpace(encoded)\n\tif encoded == \"\" {\n\t\treturn \"\"\n\t}\n\t\n\t// 3. Base64解码\n\tdecoded, err := base64.StdEncoding.DecodeString(encoded)\n\tif err != nil {\n\t\t// 尝试处理可能的URL编码问题\n\t\tencoded = strings.ReplaceAll(encoded, \" \", \"+\")\n\t\t// 尝试修复padding问题\n\t\tswitch len(encoded) % 4 {\n\t\tcase 2:\n\t\t\tencoded += \"==\"\n\t\tcase 3:\n\t\t\tencoded += \"=\"\n\t\t}\n\t\tdecoded, err = base64.StdEncoding.DecodeString(encoded)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t}\n\t\n\trealURL := strings.TrimSpace(string(decoded))\n\t\n\t// 4. 验证解码结果是否为有效URL\n\tif p.isValidURL(realURL) {\n\t\treturn realURL\n\t}\n\t\n\treturn \"\"\n}\n\n// isValidURL 验证URL是否有效\nfunc (p *XiaojiAsyncPlugin) isValidURL(urlStr string) bool {\n\tif urlStr == \"\" {\n\t\treturn false\n\t}\n\t\n\t// 检查基本的URL格式\n\tif strings.HasPrefix(urlStr, \"http://\") || strings.HasPrefix(urlStr, \"https://\") {\n\t\t// HTTP/HTTPS链接需要有域名\n\t\tif len(urlStr) <= 8 || urlStr == \"http://\" || urlStr == \"https://\" {\n\t\t\treturn false\n\t\t}\n\t\t// 简单检查是否包含域名\n\t\treturn strings.Contains(urlStr[8:], \".\")\n\t}\n\t\n\t// 磁力链接\n\tif strings.HasPrefix(urlStr, \"magnet:\") {\n\t\treturn len(urlStr) > 7 && strings.Contains(urlStr, \"xt=\")\n\t}\n\t\n\t// ED2K链接\n\tif strings.HasPrefix(urlStr, \"ed2k://\") {\n\t\treturn len(urlStr) > 7\n\t}\n\t\n\treturn false\n}\n\n// determineCloudType 确定网盘类型\nfunc (p *XiaojiAsyncPlugin) determineCloudType(url string) string {\n\tswitch {\n\tcase strings.Contains(url, \"pan.quark.cn\"):\n\t\treturn \"quark\"\n\tcase strings.Contains(url, \"drive.uc.cn\"):\n\t\treturn \"uc\"\n\tcase strings.Contains(url, \"pan.baidu.com\"):\n\t\treturn \"baidu\"\n\tcase strings.Contains(url, \"aliyundrive.com\") || strings.Contains(url, \"alipan.com\"):\n\t\treturn \"aliyun\"\n\tcase strings.Contains(url, \"pan.xunlei.com\"):\n\t\treturn \"xunlei\"\n\tcase strings.Contains(url, \"cloud.189.cn\"):\n\t\treturn \"tianyi\"\n\tcase strings.Contains(url, \"115.com\") || strings.Contains(url, \"115cdn.com\"):\n\t\treturn \"115\"\n\tcase strings.Contains(url, \"123pan.com\"):\n\t\treturn \"123\"\n\tcase strings.Contains(url, \"caiyun.139.com\"):\n\t\treturn \"mobile\"\n\tcase strings.Contains(url, \"mypikpak.com\"):\n\t\treturn \"pikpak\"\n\tcase strings.Contains(url, \"magnet:\"):\n\t\treturn \"magnet\"\n\tcase strings.Contains(url, \"ed2k://\"):\n\t\treturn \"ed2k\"\n\tdefault:\n\t\t// ctfile.com 和其他未知网盘都归类到 others\n\t\treturn \"others\"\n\t}\n}\n"
  },
  {
    "path": "plugin/xiaozhang/html结构分析.md",
    "content": "# xiaozhang插件HTML结构分析\n\n## 网站信息\n- 网站名称：校长影视\n- 主域名：https://xzys.fun\n- 网站类型：影视资源搜索网站\n\n## 1. 搜索页面结构\n\n### 搜索URL格式\n```\nhttps://xzys.fun/search.html?keyword={关键词}\n```\n\n### 页面结构分析\n\n#### 搜索结果容器\n- 主容器：`<div class=\"container tc-main\">`\n- 结果列表：`<div class=\"col-md-9\">`\n- 单个结果：`<div class=\"list-boxes\">`\n\n#### 搜索结果项结构\n```html\n<div class=\"list-boxes\" style=\"position: relative;\">\n    <h2>\n        <div class=\"left_ly\">\n            <a href=\"/subject/9861.html\">\n                <img class=\"image_left\" src=\"https://img9.doubanio.com/view/photo/s/public/p2610801866.webp\" \n                     alt=\"凡人修仙传 [2020][7.9分]\" class=\"img-responsive\" referrerPolicy=\"no-referrer\"/>\n            </a>\n        </div>\n        <a class=\"text_title_p\" href=\"/subject/9861.html\">凡人修仙传 [2020][7.9分][更156]</a>\n    </h2>\n    <p class=\"text_p\">\n        平凡少年韩立出生贫困，为了让家人过上更好的生活，自愿前去七玄门参加入门考核...\n    </p>\n    <div>\n        <div class=\"pull-left\">\n            <div class=\"list-actions\">\n                <span>2025-08-16&nbsp;&nbsp;</span>\n                <i class=\"fa fa-eye like_p\"></i><span>61591</span>\n            </div>\n        </div>\n        <div class=\"pull-right\">\n            <a class=\"btn btn-warning btn-sm pull-right\" href=\"/subject/9861.html\">查看详情</a>\n        </div>\n    </div>\n</div>\n```\n\n#### 字段提取要点\n- **标题**：`a.text_title_p` 的文本内容\n- **详情页链接**：`a.text_title_p` 的 `href` 属性\n- **描述**：`p.text_p` 的文本内容\n- **发布时间**：`.list-actions span:first-child` 的文本\n- **查看次数**：`.list-actions .like_p + span` 的文本\n- **封面图片**：`.left_ly img` 的 `src` 属性\n\n## 2. 详情页面访问流程\n\n### 两步访问机制\n1. **第一步**：访问搜索结果中的详情页链接（如：`/subject/9861.html`）\n2. **第二步**：从响应头的`Location`字段获取真实详情页URL（如：`/article/p/98/9861.html`）\n3. **第三步**：访问真实详情页URL获取下载链接\n\n### 详情页URL构建\n```\n第一步URL：https://xzys.fun + /subject/9861.html\n第二步URL：https://xzys.fun + /article/p/98/9861.html\n```\n\n## 3. 详情页面结构\n\n### 页面基本信息\n- 标题：`<h1 class=\"articl_title\">凡人修仙传 [2020][7.9分][更156]</h1>`\n- 发布时间：`.article-infobox span:first-child`\n- 更新时间：`.d-tag2 .label-success`\n- 类型标签：`.d-tag2 .label-warning`\n\n### 下载链接结构\n```html\n<p><a href=https://pan.quark.cn/s/e4b1762e9b48 target=\"_blank\">\n    <button style=\"width:auto;height:40px;font-weight:bold;background-color:#D85670\" class=\"btn btn-info\">\n        夸克网盘\n    </button>\n</a></p><br/>\n\n<p><a href=https://pan.baidu.com/s/1yFPbKsyeAhXuPBMzh6hk-Q?pwd=v2sa target=\"_blank\">\n    <button style=\"width:auto;height:40px;font-weight:bold;background-color:#009FD4\" class=\"btn btn-info\">\n        百度网盘\n    </button>\n</a> 提取码：v2sa</p><br/>\n```\n\n#### 下载链接提取要点\n- **链接选择器**：`p a[href*=\"pan.\"]` 或 `p a[href*=\"://\"]`\n- **网盘类型判断**：\n  - 夸克网盘：包含 `pan.quark.cn`，按钮颜色 `#D85670`\n  - 百度网盘：包含 `pan.baidu.com`，按钮颜色 `#009FD4`\n  - 其他网盘：根据域名判断\n- **密码提取**：\n  - 在链接所在的 `<p>` 标签后面查找 `提取码：{密码}`\n  - 或者从URL参数中提取 `?pwd={密码}`\n\n## 4. 支持的网盘类型\n\n根据HTML结构分析，网站支持以下网盘：\n- 夸克网盘（pan.quark.cn）\n- 百度网盘（pan.baidu.com）\n- 可能还包括迅雷、阿里等其他网盘\n\n## 5. 特殊处理事项\n\n### 重定向处理\n- 搜索结果中的详情页链接需要进行重定向处理\n- 必须获取Location头信息才能得到真实的详情页URL\n\n### 密码提取\n- 密码可能在URL参数中（如：`?pwd=v2sa`）\n- 也可能在页面文本中（如：`提取码：v2sa`）\n\n### 错误处理\n- 需要处理重定向失败的情况\n- 需要处理详情页无下载链接的情况\n\n## 6. 实现建议\n\n1. **搜索实现**：直接解析搜索页面HTML，提取结果列表\n2. **详情页处理**：实现两步访问机制，先获取重定向，再提取链接\n3. **链接类型识别**：根据域名判断网盘类型\n4. **密码提取**：同时支持URL参数和页面文本两种方式\n5. **并发处理**：对详情页访问进行并发优化"
  },
  {
    "path": "plugin/xiaozhang/xiaozhang.go",
    "content": "package xiaozhang\n\nimport (\n\t\"compress/gzip\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tBaseURL        = \"https://xzys.fun\"\n\tSearchPath     = \"/search.html\"\n\tUserAgent      = \"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\"\n\tMaxConcurrency = 20 // 详情页最大并发数\n\tMaxPages       = 1  // 最大搜索页数（暂时只搜索第一页）\n)\n\n// XiaozhangPlugin 校长影视插件\ntype XiaozhangPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tdebugMode    bool\n\tdetailCache  sync.Map // 缓存详情页结果\n\tcacheTTL     time.Duration\n}\n\n// NewXiaozhangPlugin 创建新的校长影视插件实例\nfunc NewXiaozhangPlugin() *XiaozhangPlugin {\n\t// 检查调试模式\n\tdebugMode := false\n\t\n\tp := &XiaozhangPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"xiaozhang\", 3),\n\t\tdebugMode:       debugMode,\n\t\tcacheTTL:        30 * time.Minute,\n\t}\n\t\n\treturn p\n}\n\n// Name 返回插件名称\nfunc (p *XiaozhangPlugin) Name() string {\n\treturn \"xiaozhang\"\n}\n\n// DisplayName 返回插件显示名称\nfunc (p *XiaozhangPlugin) DisplayName() string {\n\treturn \"校长影视\"\n}\n\n// Description 返回插件描述\nfunc (p *XiaozhangPlugin) Description() string {\n\treturn \"校长影视 - 影视资源搜索\"\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *XiaozhangPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *XiaozhangPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// setRequestHeaders 设置请求头\nfunc (p *XiaozhangPlugin) setRequestHeaders(req *http.Request, referer string) {\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Accept-Encoding\", \"gzip, deflate\")\n\treq.Header.Set(\"Cache-Control\", \"no-cache\")\n\treq.Header.Set(\"Pragma\", \"no-cache\")\n\tif referer != \"\" {\n\t\treq.Header.Set(\"Referer\", referer)\n\t}\n}\n\n// doRequest 发送HTTP请求（带重定向控制）\nfunc (p *XiaozhangPlugin) doRequest(client *http.Client, url string, referer string, followRedirect bool) (*http.Response, error) {\n\t// 创建临时客户端，控制重定向行为\n\ttempClient := &http.Client{\n\t\tTimeout: client.Timeout,\n\t\tTransport: &http.Transport{\n\t\t\tDisableCompression: true, // 禁用自动gzip解压，我们手动处理\n\t\t},\n\t}\n\t\n\tif !followRedirect {\n\t\ttempClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {\n\t\t\treturn http.ErrUseLastResponse\n\t\t}\n\t}\n\t\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\tp.setRequestHeaders(req, referer)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xiaozhang] 发送请求: %s\", url)\n\t}\n\t\n\tresp, err := tempClient.Do(req)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xiaozhang] 请求失败: %v\", err)\n\t\t}\n\t\treturn nil, err\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xiaozhang] 响应状态: %d\", resp.StatusCode)\n\t}\n\t\n\treturn resp, nil\n}\n\n// searchImpl 实际的搜索实现\nfunc (p *XiaozhangPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tsearchURL := fmt.Sprintf(\"%s%s?keyword=%s\", BaseURL, SearchPath, url.QueryEscape(keyword))\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xiaozhang] 开始搜索: %s\", keyword)\n\t\tlog.Printf(\"[Xiaozhang] 搜索URL: %s\", searchURL)\n\t}\n\t\n\t// 发送搜索请求\n\tresp, err := p.doRequest(client, searchURL, BaseURL, true)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"发送搜索请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"搜索响应状态码异常: %d\", resp.StatusCode)\n\t}\n\t\n\t// 处理响应体（可能是gzip压缩的）\n\tvar reader io.Reader = resp.Body\n\t\n\t// 检查Content-Encoding\n\tcontentEncoding := resp.Header.Get(\"Content-Encoding\")\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xiaozhang] Content-Encoding: %s\", contentEncoding)\n\t\tlog.Printf(\"[Xiaozhang] Content-Type: %s\", resp.Header.Get(\"Content-Type\"))\n\t}\n\t\n\t// 如果是gzip压缩，手动解压\n\tif contentEncoding == \"gzip\" {\n\t\tgzReader, err := gzip.NewReader(resp.Body)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"创建gzip reader失败: %w\", err)\n\t\t}\n\t\tdefer gzReader.Close()\n\t\treader = gzReader\n\t}\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(reader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"解析HTML失败: %w\", err)\n\t}\n\t\n\t// 提取搜索结果\n\tresults := p.extractSearchResults(doc, keyword)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xiaozhang] 找到 %d 个搜索结果\", len(results))\n\t}\n\t\n\t// 并发获取详情页链接\n\tresults = p.enrichWithDetailLinks(client, results, keyword)\n\t\n\t// 过滤结果\n\tfilteredResults := plugin.FilterResultsByKeyword(results, keyword)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xiaozhang] 过滤后剩余 %d 个结果\", len(filteredResults))\n\t}\n\t\n\treturn filteredResults, nil\n}\n\n// extractSearchResults 从HTML中提取搜索结果\nfunc (p *XiaozhangPlugin) extractSearchResults(doc *goquery.Document, keyword string) []model.SearchResult {\n\tvar results []model.SearchResult\n\t\n\tif p.debugMode {\n\t\t// 调试：检查页面标题\n\t\tpageTitle := doc.Find(\"title\").Text()\n\t\tlog.Printf(\"[Xiaozhang] 页面标题: %s\", pageTitle)\n\t\t\n\t\t// 调试：检查是否找到list-boxes\n\t\tlistBoxes := doc.Find(\".list-boxes\")\n\t\tlog.Printf(\"[Xiaozhang] 找到 .list-boxes 元素数量: %d\", listBoxes.Length())\n\t\t\n\t\t// 调试：尝试其他可能的选择器\n\t\tif listBoxes.Length() == 0 {\n\t\t\t// 输出页面部分HTML用于调试\n\t\t\tbodyHTML, _ := doc.Find(\"body\").Html()\n\t\t\tif len(bodyHTML) > 500 {\n\t\t\t\tbodyHTML = bodyHTML[:500] + \"...\"\n\t\t\t}\n\t\t\tlog.Printf(\"[Xiaozhang] 页面body前500字符: %s\", bodyHTML)\n\t\t}\n\t}\n\t\n\t// 选择所有搜索结果项\n\tdoc.Find(\".list-boxes\").Each(func(i int, s *goquery.Selection) {\n\t\t// 提取标题和详情页链接\n\t\ttitleElem := s.Find(\"a.text_title_p\")\n\t\ttitle := strings.TrimSpace(titleElem.Text())\n\t\tdetailPath, _ := titleElem.Attr(\"href\")\n\t\t\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xiaozhang] 处理第 %d 个结果: title=%s, path=%s\", i+1, title, detailPath)\n\t\t}\n\t\t\n\t\tif title == \"\" || detailPath == \"\" {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Xiaozhang] 跳过第 %d 个结果：标题或链接为空\", i+1)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 构建完整的详情页URL\n\t\tdetailURL := BaseURL + detailPath\n\t\t\n\t\t// 提取描述\n\t\tcontent := strings.TrimSpace(s.Find(\"p.text_p\").Text())\n\t\t\n\t\t// 提取发布时间\n\t\ttimeText := strings.TrimSpace(s.Find(\".list-actions span\").First().Text())\n\t\ttimeText = strings.ReplaceAll(timeText, \"&nbsp;\", \" \")\n\t\ttimeText = strings.TrimSpace(timeText)\n\t\t\n\t\t// 解析时间（格式：2025-08-16）\n\t\tvar publishTime time.Time\n\t\tif timeText != \"\" {\n\t\t\t// 尝试解析日期\n\t\t\tparsedTime, err := time.Parse(\"2006-01-02\", timeText)\n\t\t\tif err != nil {\n\t\t\t\t// 如果解析失败，使用当前时间\n\t\t\t\tpublishTime = time.Now()\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[Xiaozhang] 解析时间失败: %s, 错误: %v\", timeText, err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tpublishTime = parsedTime\n\t\t\t}\n\t\t} else {\n\t\t\tpublishTime = time.Now()\n\t\t}\n\t\t\n\t\t// 从详情页路径提取ID（如：/subject/9861.html -> 9861）\n\t\tidMatch := regexp.MustCompile(`/subject/(\\d+)\\.html`).FindStringSubmatch(detailPath)\n\t\tresourceID := \"\"\n\t\tif len(idMatch) > 1 {\n\t\t\tresourceID = idMatch[1]\n\t\t} else {\n\t\t\tresourceID = fmt.Sprintf(\"%d\", time.Now().UnixNano())\n\t\t}\n\t\t\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xiaozhang] 提取结果 %d: %s, URL: %s, 时间: %s\", i+1, title, detailURL, timeText)\n\t\t}\n\t\t\n\t\tresult := model.SearchResult{\n\t\t\tTitle:     title,\n\t\t\tContent:   content,\n\t\t\tChannel:   \"\",\n\t\t\tMessageID: fmt.Sprintf(\"%s-%s\", p.Name(), resourceID),\n\t\t\tUniqueID:  fmt.Sprintf(\"%s-%s\", p.Name(), resourceID),\n\t\t\tDatetime:  publishTime,\n\t\t\tLinks:     []model.Link{}, // 稍后填充\n\t\t}\n\t\t\n\t\t// 将详情页URL存储在Tags中供后续使用\n\t\tresult.Tags = []string{detailURL}\n\t\t\n\t\tresults = append(results, result)\n\t})\n\t\n\treturn results\n}\n\n// enrichWithDetailLinks 并发获取详情页的下载链接\nfunc (p *XiaozhangPlugin) enrichWithDetailLinks(client *http.Client, results []model.SearchResult, keyword string) []model.SearchResult {\n\tif len(results) == 0 {\n\t\treturn results\n\t}\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xiaozhang] 开始获取 %d 个详情页的下载链接\", len(results))\n\t}\n\t\n\tvar wg sync.WaitGroup\n\tvar mu sync.Mutex\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\n\tfor i := range results {\n\t\twg.Add(1)\n\t\tgo func(idx int) {\n\t\t\tdefer wg.Done()\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 添加小延迟避免请求过快\n\t\t\ttime.Sleep(time.Duration(idx*50) * time.Millisecond)\n\t\t\t\n\t\t\t// 从Tags中获取详情页URL\n\t\t\tif len(results[idx].Tags) > 0 {\n\t\t\t\tdetailURL := results[idx].Tags[0]\n\t\t\t\tlinks := p.fetchDetailPageLinks(client, detailURL, keyword)\n\t\t\t\t\n\t\t\t\tmu.Lock()\n\t\t\t\tresults[idx].Links = links\n\t\t\t\t// 清空Tags，避免返回给用户\n\t\t\t\tresults[idx].Tags = nil\n\t\t\t\tmu.Unlock()\n\t\t\t\t\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[Xiaozhang] 详情页 %d/%d 获取到 %d 个链接\", idx+1, len(results), len(links))\n\t\t\t\t}\n\t\t\t}\n\t\t}(i)\n\t}\n\t\n\twg.Wait()\n\t\n\treturn results\n}\n\n// fetchDetailPageLinks 获取详情页的下载链接\nfunc (p *XiaozhangPlugin) fetchDetailPageLinks(client *http.Client, detailURL string, keyword string) []model.Link {\n\t// 检查缓存\n\tif cached, ok := p.detailCache.Load(detailURL); ok {\n\t\tif links, ok := cached.([]model.Link); ok {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Xiaozhang] 使用缓存的详情页结果: %s\", detailURL)\n\t\t\t}\n\t\t\treturn links\n\t\t}\n\t}\n\t\n\t// 第一步：获取重定向位置\n\tresp, err := p.doRequest(client, detailURL, BaseURL, false)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xiaozhang] 获取详情页失败: %v\", err)\n\t\t}\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 获取Location头\n\tlocation := resp.Header.Get(\"Location\")\n\tif location == \"\" {\n\t\t// 如果没有重定向，可能直接就是详情页\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn p.extractDetailPageLinks(resp, detailURL)\n\t\t}\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xiaozhang] 未找到重定向位置，状态码: %d\", resp.StatusCode)\n\t\t}\n\t\treturn nil\n\t}\n\t\n\t// 构建真实的详情页URL\n\trealDetailURL := BaseURL + location\n\tif p.debugMode {\n\t\tlog.Printf(\"[Xiaozhang] 重定向到: %s\", realDetailURL)\n\t}\n\t\n\t// 第二步：访问真实的详情页\n\tresp2, err := p.doRequest(client, realDetailURL, detailURL, true)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xiaozhang] 获取真实详情页失败: %v\", err)\n\t\t}\n\t\treturn nil\n\t}\n\tdefer resp2.Body.Close()\n\t\n\tif resp2.StatusCode != http.StatusOK {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xiaozhang] 真实详情页响应状态码异常: %d\", resp2.StatusCode)\n\t\t}\n\t\treturn nil\n\t}\n\t\n\tlinks := p.extractDetailPageLinks(resp2, realDetailURL)\n\t\n\t// 缓存结果\n\tp.detailCache.Store(detailURL, links)\n\t\n\t// 设置缓存过期\n\tgo func() {\n\t\ttime.Sleep(p.cacheTTL)\n\t\tp.detailCache.Delete(detailURL)\n\t}()\n\t\n\treturn links\n}\n\n// extractDetailPageLinks 从详情页响应中提取下载链接\nfunc (p *XiaozhangPlugin) extractDetailPageLinks(resp *http.Response, pageURL string) []model.Link {\n\t// 处理响应体（可能是gzip压缩的）\n\tvar reader io.Reader = resp.Body\n\t\n\t// 检查Content-Encoding\n\tcontentEncoding := resp.Header.Get(\"Content-Encoding\")\n\tif contentEncoding == \"gzip\" {\n\t\tgzReader, err := gzip.NewReader(resp.Body)\n\t\tif err != nil {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Xiaozhang] 创建gzip reader失败: %v\", err)\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t\tdefer gzReader.Close()\n\t\treader = gzReader\n\t}\n\t\n\tdoc, err := goquery.NewDocumentFromReader(reader)\n\tif err != nil {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[Xiaozhang] 解析详情页HTML失败: %v\", err)\n\t\t}\n\t\treturn nil\n\t}\n\t\n\tvar links []model.Link\n\tlinkMap := make(map[string]bool) // 用于去重\n\t\n\t// 查找所有包含下载链接的p标签\n\tdoc.Find(\"p\").Each(func(i int, s *goquery.Selection) {\n\t\t// 查找p标签内的链接\n\t\ts.Find(\"a[href]\").Each(func(j int, a *goquery.Selection) {\n\t\t\thref, exists := a.Attr(\"href\")\n\t\t\tif !exists || href == \"\" {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\t// 过滤非网盘链接\n\t\t\tif !isValidPanLink(href) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\t// 去重\n\t\t\tif linkMap[href] {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlinkMap[href] = true\n\t\t\t\n\t\t\t// 提取密码（可能在p标签的文本中）\n\t\t\tpassword := \"\"\n\t\t\tpText := strings.TrimSpace(s.Text())\n\t\t\t\n\t\t\t// 尝试从文本中提取密码\n\t\t\tif strings.Contains(pText, \"提取码\") || strings.Contains(pText, \"密码\") {\n\t\t\t\tpasswordMatch := regexp.MustCompile(`(?:提取码|密码)[：:]?\\s*([a-zA-Z0-9]+)`).FindStringSubmatch(pText)\n\t\t\t\tif len(passwordMatch) > 1 {\n\t\t\t\t\tpassword = passwordMatch[1]\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 尝试从URL中提取密码\n\t\t\tif password == \"\" && strings.Contains(href, \"pwd=\") {\n\t\t\t\tif u, err := url.Parse(href); err == nil {\n\t\t\t\t\tpassword = u.Query().Get(\"pwd\")\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 判断链接类型\n\t\t\tlinkType := determineLinkType(href)\n\t\t\t\n\t\t\tlink := model.Link{\n\t\t\t\tURL:      href,\n\t\t\t\tType:     linkType,\n\t\t\t\tPassword: password,\n\t\t\t}\n\t\t\t\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[Xiaozhang] 提取链接: %s, 类型: %s, 密码: %s\", href, linkType, password)\n\t\t\t}\n\t\t\t\n\t\t\tlinks = append(links, link)\n\t\t})\n\t})\n\t\n\treturn links\n}\n\n// isValidPanLink 判断是否是有效的网盘链接\nfunc isValidPanLink(url string) bool {\n\tpanPatterns := []string{\n\t\t\"pan.baidu.com\",\n\t\t\"pan.quark.cn\",\n\t\t\"www.aliyundrive.com\",\n\t\t\"www.alipan.com\",\n\t\t\"115.com\",\n\t\t\"cloud.189.cn\",\n\t\t\"pan.xunlei.com\",\n\t\t\"www.123pan.com\",\n\t\t\"www.jianguoyun.com\",\n\t\t\"cowtransfer.com\",\n\t\t\"weidian.com\",\n\t}\n\t\n\tfor _, pattern := range panPatterns {\n\t\tif strings.Contains(url, pattern) {\n\t\t\treturn true\n\t\t}\n\t}\n\t\n\treturn false\n}\n\n// determineLinkType 判断链接类型\nfunc determineLinkType(url string) string {\n\tlinkTypeMap := map[string]string{\n\t\t\"pan.baidu.com\":       \"baidu\",\n\t\t\"pan.quark.cn\":        \"quark\",\n\t\t\"www.aliyundrive.com\": \"aliyun\",\n\t\t\"www.alipan.com\":      \"aliyun\",\n\t\t\"115.com\":             \"115\",\n\t\t\"cloud.189.cn\":        \"tianyi\",\n\t\t\"pan.xunlei.com\":      \"xunlei\",\n\t\t\"www.123pan.com\":      \"123\",\n\t\t\"www.jianguoyun.com\":  \"jianguo\",\n\t\t\"cowtransfer.com\":     \"cowtransfer\",\n\t\t\"weidian.com\":         \"weidian\",\n\t}\n\t\n\tfor pattern, linkType := range linkTypeMap {\n\t\tif strings.Contains(url, pattern) {\n\t\t\treturn linkType\n\t\t}\n\t}\n\t\n\treturn \"other\"\n}\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewXiaozhangPlugin())\n}"
  },
  {
    "path": "plugin/xinjuc/html结构分析.md",
    "content": "# 新剧坊 (xinjuc.com) 网站结构分析\n\n## 网站信息\n\n- **网站名称**: 新剧坊 - 一个网盘资源分享小站\n- **网站URL**: https://www.xinjuc.com\n- **网站类型**: 影视资源网盘分享站\n- **数据源**: HTML页面爬虫\n- **网盘类型**: 仅百度网盘\n\n## 搜索URL格式\n\n```\nhttps://www.xinjuc.com/?s={关键词}\n```\n\n### URL编码\n- 关键词需要进行URL编码\n- 示例: `凡人修仙传` → `%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0`\n\n## 搜索结果页面结构\n\n### 主容器\n\n搜索结果显示在以下结构中：\n\n```html\n<div class=\"list-post\">\n  <div class=\"card\">\n    <div class=\"card-body\">\n      <div class=\"section-header\">\n        <h5>搜索\"凡人修仙传\"的结果...</h5>\n      </div>\n      <div class=\"row-xs post-list\">\n        <!-- 搜索结果项 -->\n      </div>\n    </div>\n  </div>\n</div>\n```\n\n### 单个搜索结果结构\n\n每个搜索结果是一个 `<article class=\"post-item\">` 元素：\n\n```html\n<div class=\"col-4 col-md-3 col-lg-2\">\n  <article class=\"post-item\">\n    <!-- 封面图 -->\n    <div class=\"post-image\">\n      <a href=\"/30839.html\" rel=\"bookmark\" title=\"凡人修仙传 (2020)更至163集-百度网盘1080P高清免费动漫资源\">\n        <img src=\"/wp-content/uploads/2024/09/07044626660.webp\" class=\"post-thumb\" alt=\"...\" title=\"...\">\n        <div class=\"mark\"><span>更至163</span></div>\n      </a>\n    </div>\n    \n    <!-- 标题和更新时间 -->\n    <div class=\"post-body\">\n      <h5 class=\"post-title line-hide-1\">\n        <a href=\"/30839.html\" rel=\"bookmark\">凡人修仙传 (2020)更至163集-百度网盘1080P高清免费动漫资源</a>\n      </h5>\n      <div class=\"post-footer\">\n        <span class=\"time\">2025-04-21 更新</span>\n      </div>\n    </div>\n  </article>\n</div>\n```\n\n### 字段提取规则\n\n| 字段 | CSS选择器 | 说明 |\n|------|-----------|------|\n| **详情链接** | `div.post-image > a[href]` | 相对路径，如 `/30839.html` |\n| **标题** | `h5.post-title a` | 资源完整标题 |\n| **封面图** | `img.post-thumb[src]` | 封面图片URL |\n| **标记** | `div.mark span` | 状态标记，如\"更至163\"、\"1080P\" |\n| **更新时间** | `div.post-footer span.time` | 更新日期 |\n\n## 详情页面结构\n\n### 详情URL格式\n```\nhttps://www.xinjuc.com/{ID}.html\n```\n\n### 主要内容区域\n\n```html\n<div class=\"article-main\">\n  <h1 class=\"article-title\">凡人修仙传 (2020)更至163集-百度网盘1080P高清免费动漫资源</h1>\n  \n  <div class=\"article-meta\">\n    <span class=\"item\"><i class=\"icon icon-time\"></i> 04-21</span>\n    <span class=\"item\"><i class=\"icon icon-fenlei\"></i> <a href=\"/dongman\">动漫</a></span>\n  </div>\n  \n  <div class=\"article-content\">\n    <p><strong>凡人修仙传 (2020)百度云网盘资源下载地址：</strong></p>\n    <p><strong>链接：  <a href=\"https://pan.baidu.com/s/1b5TLAN2s-ss8lDKcswlD2g?pwd=1234\" rel=\"nofollow\">\n      https://pan.baidu.com/s/1b5TLAN2s-ss8lDKcswlD2g?pwd=1234\n    </a></strong></p>\n    <p><strong>提取码：1234</strong></p>\n    \n    <!-- 影视信息 -->\n    <p>导演: 伍镇焯 / 王裕仁<br>\n    编剧: 金增辉 / 李欣雨<br>\n    主演: 钱文青 / 杨天翔 / 杨默 / 歪歪 / 谷江山<br>\n    类型: 动画 / 奇幻 / 武侠<br>\n    制片国家/地区: 中国大陆<br>\n    语言: 汉语普通话<br>\n    首播: 2020-07-25(中国大陆)</p>\n  </div>\n</div>\n```\n\n### 百度盘链接提取\n\n#### 链接格式\n\n1. **完整链接（带pwd参数）**:\n   ```\n   https://pan.baidu.com/s/1b5TLAN2s-ss8lDKcswlD2g?pwd=1234\n   ```\n\n2. **短链接（不带pwd参数）**:\n   ```\n   https://pan.baidu.com/s/1b5TLAN2s-ss8lDKcswlD2g\n   ```\n\n#### 提取码格式\n\n提取码通常出现在以下位置：\n- **在单独的段落中**: `<p><strong>提取码：1234</strong></p>`\n- **在链接URL中**: `?pwd=1234`\n- **在文本描述中**: 使用正则匹配 `提取码[:：]\\s*([a-zA-Z0-9]{4})`\n\n#### 提取策略（优化后）\n\n```go\n// 1. 严格的百度盘链接正则（要求s/后至少10个字符）\nbaiduRegex := regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9a-zA-Z_\\-]{10,}(?:\\?pwd=[0-9a-zA-Z]+)?`)\n\n// 2. 提取码正则\npwdRegex := regexp.MustCompile(`提取码[:：]\\s*([a-zA-Z0-9]{4})`)\n\n// 3. 从URL参数提取提取码\npwdURLRegex := regexp.MustCompile(`\\?pwd=([0-9a-zA-Z]+)`)\n\n// 4. 链接验证（避免误匹配）\nfunc isValidBaiduLink(link string) bool {\n    // 必须是百度盘域名开头\n    if !strings.HasPrefix(link, \"https://pan.baidu.com\") {\n        return false\n    }\n    // 必须包含 /s/ 路径\n    if !strings.Contains(link, \"/s/\") {\n        return false\n    }\n    // 使用正则验证格式\n    return baiduRegex.MatchString(link)\n}\n\n// 5. 链接清理和去重\nbaiduURL = strings.TrimSpace(baiduURL)  // 去除首尾空格\nlinkMap[baiduURL] = true  // 使用map去重\n```\n\n#### 常见问题和解决方案\n\n**问题1：匹配到不完整的链接**\n- ❌ 错误：`https://pan.baidu.com/s/1`\n- ✅ 解决：正则要求s/后至少10个字符\n\n**问题2：匹配到分享链接中的百度盘URL**\n- ❌ 错误：`https://sns.qzone.qq.com/...&summary=...pan.baidu.com/...`\n- ✅ 解决：验证链接必须以 `pan.baidu.com` 开头\n\n**问题3：重复链接（带空格和不带空格）**\n- ❌ 错误：`https://pan.baidu.com/s/xxx` 和 `https://pan.baidu.com/s/xxx `\n- ✅ 解决：使用 `strings.TrimSpace()` 清理\n\n## 数据字段映射\n\n### SearchResult 字段设置\n\n| 源字段 | SearchResult字段 | 说明 |\n|-------|------------------|------|\n| 标题 | Title | 完整资源标题 |\n| 影视信息 | Content | 导演、主演、类型等信息 |\n| 百度盘链接 | Links[0].URL | 完整的百度网盘链接 |\n| 提取码 | Links[0].Password | 4位提取码 |\n| 更新时间 | Datetime | 解析时间字符串 |\n| 分类 | Tags[0] | 如\"动漫\"、\"电影\" |\n| ID | UniqueID | xinjuc-{ID} |\n| 频道 | Channel | 空字符串（插件搜索） |\n\n## 反爬虫策略\n\n### 请求头设置\n\n```go\nreq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...\")\nreq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\")\nreq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\nreq.Header.Set(\"Referer\", \"https://www.xinjuc.com/\")\nreq.Header.Set(\"Connection\", \"keep-alive\")\n```\n\n### 访问控制\n- 建议请求间隔：100-200ms\n- 超时时间：搜索10秒，详情页8秒\n- 重试次数：3次，指数退避\n\n## 性能优化\n\n### 1. HTTP连接池配置\n```go\nMaxIdleConns:        50\nMaxIdleConnsPerHost: 20\nMaxConnsPerHost:     30\nIdleConnTimeout:     90 * time.Second\n```\n\n### 2. 并发控制\n- 最大并发获取详情页：15个goroutine\n- 使用信号量控制并发数量\n\n### 3. 缓存策略\n- 详情页缓存：1小时TTL\n- 定期清理：30分钟清理一次过期缓存\n- 使用 `sync.Map` 实现线程安全缓存\n\n### 4. 超时设置\n- 搜索请求超时：10秒\n- 详情页请求超时：8秒\n- 重试间隔：指数退避（200ms, 400ms, 800ms）\n\n## 插件设计\n\n### 基本信息\n- **插件名称**: xinjuc\n- **优先级**: 2（质量良好的数据源）\n- **Service层过滤**: 启用（标准网盘搜索插件）\n- **缓存TTL**: 1小时\n\n### 搜索流程\n\n```\n1. 构建搜索URL（URL编码关键词）\n   ↓\n2. 发送HTTP请求（带重试）\n   ↓\n3. 解析搜索结果页面（goquery）\n   ↓\n4. 提取基本信息（标题、链接、时间）\n   ↓\n5. 并发获取详情页（最多15个并发）\n   ↓\n6. 从详情页提取百度盘链接和提取码\n   ↓\n7. 构建SearchResult对象\n   ↓\n8. 关键词过滤\n   ↓\n9. 返回结果\n```\n\n### 关键实现细节\n\n#### 1. 百度盘链接提取（精简版）\n\n```go\n// 百度盘链接正则（支持带pwd参数）\nbaiduRegex := regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9a-zA-Z_\\-]+(?:\\?pwd=[0-9a-zA-Z]+)?`)\n\n// 提取所有百度盘链接\nbaiduLinks := baiduRegex.FindAllString(htmlContent, -1)\n```\n\n#### 2. 提取码提取（多种方式）\n\n```go\n// 方式1: 从URL参数提取\nif strings.Contains(baiduURL, \"?pwd=\") {\n    password = extractPwdFromURL(baiduURL)\n}\n\n// 方式2: 从文本中提取\npwdRegex := regexp.MustCompile(`提取码[:：]\\s*([a-zA-Z0-9]{4})`)\nif match := pwdRegex.FindStringSubmatch(htmlContent); len(match) > 1 {\n    password = match[1]\n}\n```\n\n#### 3. Channel字段设置\n\n```go\nresult.Channel = \"\"  // 插件搜索结果必须为空字符串\n```\n\n## 使用示例\n\n### API请求\n```bash\ncurl \"http://localhost:8888/api/search?kw=凡人修仙传&plugins=xinjuc\"\n```\n\n### 预期响应\n```json\n{\n  \"code\": 0,\n  \"message\": \"success\",\n  \"data\": {\n    \"results\": [\n      {\n        \"unique_id\": \"xinjuc-30839\",\n        \"title\": \"凡人修仙传 (2020)更至163集-百度网盘1080P高清免费动漫资源\",\n        \"content\": \"导演: 伍镇焯 / 王裕仁 | 类型: 动画 / 奇幻 / 武侠\",\n        \"datetime\": \"2025-04-21T00:00:00Z\",\n        \"links\": [\n          {\n            \"type\": \"baidu\",\n            \"url\": \"https://pan.baidu.com/s/1b5TLAN2s-ss8lDKcswlD2g?pwd=1234\",\n            \"password\": \"1234\"\n          }\n        ],\n        \"tags\": [\"动漫\", \"更至163\"],\n        \"channel\": \"\"\n      }\n    ]\n  }\n}\n```\n\n## 注意事项\n\n### 优点\n- ✅ **专注影视资源**: 影视资源专业垂直站\n- ✅ **网盘链接质量高**: 仅使用百度网盘，链接稳定\n- ✅ **更新及时**: 资源更新频率较快\n- ✅ **提供提取码**: 自动提取百度盘分享提取码\n- ✅ **详细的影视信息**: 导演、主演、类型等信息完整\n\n### 注意事项\n- ⚠️ **需要访问详情页**: 网盘链接在详情页，需要二次请求\n- ⚠️ **仅百度盘**: 只提供百度网盘资源\n- ⚠️ **需要反爬虫措施**: 设置完整的请求头\n- ⚠️ **建议使用缓存**: 减少重复请求详情页\n\n## 维护建议\n\n1. **定期检查网站结构**: WordPress主题可能更新\n2. **监控成功率**: 检查链接提取成功率\n3. **优化性能**: 根据实际情况调整并发数和超时时间\n4. **缓存策略**: 根据网站更新频率调整缓存TTL\n5. **链接有效性**: 定期检查百度盘链接的有效性\n"
  },
  {
    "path": "plugin/xinjuc/xinjuc.go",
    "content": "package xinjuc\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\n// 预编译的正则表达式\nvar (\n\t// 百度网盘链接正则表达式（网站只有百度盘）\n\t// 要求s/后面至少10个字符，避免匹配到不完整的链接\n\tbaiduLinkRegex = regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9a-zA-Z_\\-]{10,}(?:\\?pwd=[0-9a-zA-Z]+)?`)\n\t\n\t// 提取码正则表达式\n\tpwdRegex = regexp.MustCompile(`提取码[:：]\\s*([a-zA-Z0-9]{4})`)\n\t\n\t// 从URL参数提取密码\n\tpwdURLRegex = regexp.MustCompile(`\\?pwd=([0-9a-zA-Z]+)`)\n\t\n\t// 从详情链接提取ID\n\tdetailIDRegex = regexp.MustCompile(`/(\\d+)\\.html`)\n\t\n\t// 缓存相关\n\tdetailCache     = sync.Map{} // 缓存详情页解析结果\n\tlastCleanupTime = time.Now()\n\tcacheTTL        = 1 * time.Hour\n)\n\nconst (\n\t// 超时时间\n\tDefaultTimeout = 10 * time.Second\n\tDetailTimeout  = 8 * time.Second\n\t\n\t// 并发数\n\tMaxConcurrency = 15\n\t\n\t// HTTP连接池配置\n\tMaxIdleConns        = 50\n\tMaxIdleConnsPerHost = 20\n\tMaxConnsPerHost     = 30\n\tIdleConnTimeout     = 90 * time.Second\n\t\n\t// 网站URL\n\tSiteURL = \"https://www.xinjuc.com\"\n)\n\n// 在init函数中注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewXinjucPlugin())\n\t\n\t// 启动缓存清理goroutine\n\tgo startCacheCleaner()\n}\n\n// startCacheCleaner 启动一个定期清理缓存的goroutine\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(30 * time.Minute)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\t// 清空所有缓存\n\t\tdetailCache = sync.Map{}\n\t\tlastCleanupTime = time.Now()\n\t}\n}\n\n// XinjucPlugin 新剧坊插件\ntype XinjucPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t}\n\treturn &http.Client{Transport: transport, Timeout: DefaultTimeout}\n}\n\n// NewXinjucPlugin 创建新的新剧坊插件\nfunc NewXinjucPlugin() *XinjucPlugin {\n\treturn &XinjucPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"xinjuc\", 2), // 优先级2：质量良好的数据源\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *XinjucPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *XinjucPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实现具体的搜索逻辑\nfunc (p *XinjucPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 1. 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"%s/?s=%s\", SiteURL, url.QueryEscape(keyword))\n\t\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\t\n\t// 3. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 4. 设置完整的请求头（避免反爬虫）\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", SiteURL)\n\t\n\t// 5. 发送请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 6. 解析搜索结果页面\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 7. 提取搜索结果\n\tvar results []model.SearchResult\n\t\n\t// 查找搜索结果列表\n\tpostList := doc.Find(\"div.row-xs.post-list article.post-item\")\n\tif postList.Length() == 0 {\n\t\treturn []model.SearchResult{}, nil // 没有搜索结果\n\t}\n\t\n\t// 8. 解析每个搜索结果项\n\tpostList.Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchItem(s, keyword)\n\t\tif result.UniqueID != \"\" {\n\t\t\tresults = append(results, result)\n\t\t}\n\t})\n\t\n\t// 9. 异步获取详情页信息\n\tenhancedResults := p.enhanceWithDetails(client, results)\n\t\n\t// 10. 关键词过滤\n\treturn plugin.FilterResultsByKeyword(enhancedResults, keyword), nil\n}\n\n// parseSearchItem 解析单个搜索结果项\nfunc (p *XinjucPlugin) parseSearchItem(s *goquery.Selection, keyword string) model.SearchResult {\n\tresult := model.SearchResult{}\n\t\n\t// 提取详情页链接\n\tlinkElem := s.Find(\"div.post-image a\")\n\tif linkElem.Length() == 0 {\n\t\treturn result\n\t}\n\t\n\tdetailLink, exists := linkElem.Attr(\"href\")\n\tif !exists || detailLink == \"\" {\n\t\treturn result\n\t}\n\t\n\t// 处理相对路径\n\tif !strings.HasPrefix(detailLink, \"http\") {\n\t\tif strings.HasPrefix(detailLink, \"/\") {\n\t\t\tdetailLink = SiteURL + detailLink\n\t\t} else {\n\t\t\tdetailLink = SiteURL + \"/\" + detailLink\n\t\t}\n\t}\n\t\n\t// 提取ID\n\tmatches := detailIDRegex.FindStringSubmatch(detailLink)\n\tif len(matches) < 2 {\n\t\treturn result\n\t}\n\titemID := matches[1]\n\tresult.UniqueID = fmt.Sprintf(\"%s-%s\", p.Name(), itemID)\n\t\n\t// 提取标题\n\ttitleElem := s.Find(\"h5.post-title a\")\n\tif titleElem.Length() > 0 {\n\t\tresult.Title = strings.TrimSpace(titleElem.Text())\n\t}\n\t\n\t// 提取标记（如\"更至163\"、\"1080P\"）\n\tmarkElem := s.Find(\"div.mark span\")\n\tif markElem.Length() > 0 {\n\t\tmark := strings.TrimSpace(markElem.Text())\n\t\tif mark != \"\" {\n\t\t\tresult.Tags = []string{mark}\n\t\t}\n\t}\n\t\n\t// 提取更新时间\n\ttimeElem := s.Find(\"div.post-footer span.time\")\n\tif timeElem.Length() > 0 {\n\t\ttimeStr := strings.TrimSpace(timeElem.Text())\n\t\tresult.Datetime = p.parseTime(timeStr)\n\t} else {\n\t\tresult.Datetime = time.Now()\n\t}\n\t\n\tresult.Channel = \"\" // 插件搜索结果必须为空字符串\n\t\n\t// 将详情页链接存储在Content中，后续获取详情\n\tresult.Content = detailLink\n\t\n\treturn result\n}\n\n// parseTime 解析时间字符串\nfunc (p *XinjucPlugin) parseTime(timeStr string) time.Time {\n\t// 时间格式示例: \"2025-04-21 更新\", \"04-21\"\n\ttimeStr = strings.Replace(timeStr, \" 更新\", \"\", -1)\n\ttimeStr = strings.TrimSpace(timeStr)\n\t\n\t// 尝试多种时间格式\n\tformats := []string{\n\t\t\"2006-01-02\",\n\t\t\"01-02\",\n\t\t\"2006-01-02 15:04:05\",\n\t\t\"2006-01-02 15:04\",\n\t}\n\t\n\tfor _, format := range formats {\n\t\tif t, err := time.Parse(format, timeStr); err == nil {\n\t\t\t// 如果只有月-日，补充当前年份\n\t\t\tif format == \"01-02\" {\n\t\t\t\tnow := time.Now()\n\t\t\t\tt = time.Date(now.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)\n\t\t\t}\n\t\t\treturn t\n\t\t}\n\t}\n\t\n\treturn time.Now()\n}\n\n// enhanceWithDetails 异步获取详情页信息\nfunc (p *XinjucPlugin) enhanceWithDetails(client *http.Client, results []model.SearchResult) []model.SearchResult {\n\tvar wg sync.WaitGroup\n\tvar mu sync.Mutex\n\t\n\t// 使用信号量控制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\t\n\tenhancedResults := make([]model.SearchResult, 0, len(results))\n\t\n\tfor _, result := range results {\n\t\twg.Add(1)\n\t\tgo func(r model.SearchResult) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 从缓存或详情页获取链接\n\t\t\tlinks, content := p.getDetailInfo(client, r.Content)\n\t\t\t\n\t\t\t// 更新结果\n\t\t\tr.Links = links\n\t\t\tr.Content = content\n\t\t\t\n\t\t\t// 只添加有链接的结果\n\t\t\tif len(links) > 0 {\n\t\t\t\tmu.Lock()\n\t\t\t\tenhancedResults = append(enhancedResults, r)\n\t\t\t\tmu.Unlock()\n\t\t\t}\n\t\t}(result)\n\t}\n\t\n\twg.Wait()\n\treturn enhancedResults\n}\n\n// getDetailInfo 获取详情页信息（带缓存）\nfunc (p *XinjucPlugin) getDetailInfo(client *http.Client, detailURL string) ([]model.Link, string) {\n\t// 检查缓存\n\tif cached, ok := detailCache.Load(detailURL); ok {\n\t\tcachedData := cached.(DetailCacheData)\n\t\tif time.Since(cachedData.Timestamp) < cacheTTL {\n\t\t\treturn cachedData.Links, cachedData.Content\n\t\t}\n\t}\n\t\n\t// 获取详情页\n\tlinks, content := p.fetchDetailPage(client, detailURL)\n\t\n\t// 存入缓存\n\tif len(links) > 0 {\n\t\tdetailCache.Store(detailURL, DetailCacheData{\n\t\t\tLinks:     links,\n\t\t\tContent:   content,\n\t\t\tTimestamp: time.Now(),\n\t\t})\n\t}\n\t\n\treturn links, content\n}\n\n// DetailCacheData 详情页缓存数据\ntype DetailCacheData struct {\n\tLinks     []model.Link\n\tContent   string\n\tTimestamp time.Time\n}\n\n// fetchDetailPage 获取详情页信息\nfunc (p *XinjucPlugin) fetchDetailPage(client *http.Client, detailURL string) ([]model.Link, string) {\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DetailTimeout)\n\tdefer cancel()\n\t\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn nil, \"\"\n\t}\n\t\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Referer\", SiteURL)\n\t\n\t// 发送请求\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, \"\"\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil, \"\"\n\t}\n\t\n\t// 解析页面\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, \"\"\n\t}\n\t\n\t// 查找文章内容区域\n\tarticleContent := doc.Find(\"div.article-content\")\n\tif articleContent.Length() == 0 {\n\t\treturn nil, \"\"\n\t}\n\t\n\t// 提取百度盘链接（从整个文档中提取）\n\tlinks := p.extractLinksFromDoc(doc)\n\t\n\t// 提取简介（从文章内容中提取）\n\tcontent := p.extractContent(articleContent)\n\t\n\treturn links, content\n}\n\n// extractLinksFromDoc 从整个文档中提取百度盘链接\nfunc (p *XinjucPlugin) extractLinksFromDoc(doc *goquery.Document) []model.Link {\n\tvar links []model.Link\n\tlinkMap := make(map[string]bool) // 去重（使用trim后的URL）\n\t\n\t// 获取整个页面的HTML内容\n\thtmlContent, _ := doc.Html()\n\t\n\t// 提取提取码（多种方式）\n\tpassword := \"\"\n\t\n\t// 方式1: 从文本中提取提取码\n\tif match := pwdRegex.FindStringSubmatch(htmlContent); len(match) > 1 {\n\t\tpassword = match[1]\n\t}\n\t\n\t// 方式2: 使用正则表达式提取所有百度盘链接\n\tbaiduLinks := baiduLinkRegex.FindAllString(htmlContent, -1)\n\tfor _, baiduURL := range baiduLinks {\n\t\t// 清理链接（去除首尾空格）\n\t\tbaiduURL = strings.TrimSpace(baiduURL)\n\t\t\n\t\t// 验证链接有效性\n\t\tif !p.isValidBaiduLink(baiduURL) {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 去重\n\t\tif !linkMap[baiduURL] {\n\t\t\tlinkMap[baiduURL] = true\n\t\t\t\n\t\t\t// 从URL中提取密码（如果有）\n\t\t\turlPassword := password\n\t\t\tif match := pwdURLRegex.FindStringSubmatch(baiduURL); len(match) > 1 {\n\t\t\t\turlPassword = match[1]\n\t\t\t}\n\t\t\t\n\t\t\tlinks = append(links, model.Link{\n\t\t\t\tType:     \"baidu\",\n\t\t\t\tURL:      baiduURL,\n\t\t\t\tPassword: urlPassword,\n\t\t\t})\n\t\t}\n\t}\n\t\n\t// 方式3: 从<a>标签中查找百度盘链接（作为补充）\n\tdoc.Find(\"a\").Each(func(i int, s *goquery.Selection) {\n\t\thref, exists := s.Attr(\"href\")\n\t\tif !exists || href == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 清理链接\n\t\thref = strings.TrimSpace(href)\n\t\t\n\t\t// 必须是纯百度盘域名开头，避免匹配到分享链接中的百度盘URL\n\t\tif !strings.HasPrefix(href, \"http://pan.baidu.com\") && !strings.HasPrefix(href, \"https://pan.baidu.com\") {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 验证链接有效性\n\t\tif !p.isValidBaiduLink(href) {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 去重\n\t\tif !linkMap[href] {\n\t\t\tlinkMap[href] = true\n\t\t\t\n\t\t\t// 从URL中提取密码（如果有）\n\t\t\turlPassword := password\n\t\t\tif match := pwdURLRegex.FindStringSubmatch(href); len(match) > 1 {\n\t\t\t\turlPassword = match[1]\n\t\t\t}\n\t\t\t\n\t\t\tlinks = append(links, model.Link{\n\t\t\t\tType:     \"baidu\",\n\t\t\t\tURL:      href,\n\t\t\t\tPassword: urlPassword,\n\t\t\t})\n\t\t}\n\t})\n\t\n\treturn links\n}\n\n// isValidBaiduLink 验证百度盘链接的有效性\nfunc (p *XinjucPlugin) isValidBaiduLink(link string) bool {\n\t// 必须是百度盘域名\n\tif !strings.HasPrefix(link, \"http://pan.baidu.com\") && !strings.HasPrefix(link, \"https://pan.baidu.com\") {\n\t\treturn false\n\t}\n\t\n\t// 必须包含 /s/ 路径\n\tif !strings.Contains(link, \"/s/\") {\n\t\treturn false\n\t}\n\t\n\t// 使用正则验证格式\n\tif !baiduLinkRegex.MatchString(link) {\n\t\treturn false\n\t}\n\t\n\treturn true\n}\n\n// extractContent 提取简介\nfunc (p *XinjucPlugin) extractContent(articleContent *goquery.Selection) string {\n\t// 提取文本内容\n\tcontent := strings.TrimSpace(articleContent.Text())\n\t\n\t// 清理空白字符\n\tcontent = regexp.MustCompile(`\\s+`).ReplaceAllString(content, \" \")\n\t\n\t// 移除百度盘相关的文本\n\tcontent = regexp.MustCompile(`百度云网盘资源下载地址[:：]?\\s*`).ReplaceAllString(content, \"\")\n\tcontent = regexp.MustCompile(`链接[:：]?\\s*https?://pan\\.baidu\\.com/[^\\s]+`).ReplaceAllString(content, \"\")\n\tcontent = regexp.MustCompile(`提取码[:：]?\\s*[a-zA-Z0-9]{4}`).ReplaceAllString(content, \"\")\n\tcontent = strings.TrimSpace(content)\n\t\n\t// 限制长度\n\tif len(content) > 300 {\n\t\tcontent = content[:300] + \"...\"\n\t}\n\t\n\treturn content\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *XinjucPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n"
  },
  {
    "path": "plugin/xuexizhinan/html结构分析.md",
    "content": "# 4K指南 (xuexizhinan) 网站搜索结果HTML结构分析\n\n## 搜索结果页面结构\n\n搜索结果页面的主要内容位于`.content-wrap .content-layout .row`元素内，每个搜索结果项包含在`.url-card`元素中。\n\n```html\n<div class=\"row\">\n    <div class=\"url-card col-6 col-sm-4 col-md-3 col-lg-4 col-xl-5a\">\n        <div class=\"card-book list-item\">\n            <!-- 单个搜索结果 -->\n        </div>\n    </div>\n    <!-- 更多搜索结果... -->\n</div>\n```\n\n### 单个搜索结果结构\n\n每个搜索结果包含以下主要元素：\n\n#### 1. 电影封面\n\n封面图片位于`.media-content`元素中：\n\n```html\n<div class=\"media media-5x7 p-0 rounded\">\n    <a class=\"media-content\" href=\"https://xuexizhinan.com/book/17694.html\" target=\"_blank\" data-bg=\"url(https://img.alicdn.com/imgextra/i1/2355688876/O1CN017g1yTT2FRGYEB9T3H_!!2355688876.png)\"></a>\n</div>\n```\n\n封面图片URL可以从`data-bg`属性中提取。\n\n#### 2. 详情页链接和ID\n\n详情页链接和ID在多个位置出现，最明显的是在封面图片的链接中：\n\n```html\n<a class=\"media-content\" href=\"https://xuexizhinan.com/book/17694.html\" target=\"_blank\"></a>\n```\n\n详情页链接格式为`https://xuexizhinan.com/book/{ID}.html`，其中`{ID}`是资源的唯一标识符（如`17694`）。\n\n#### 3. 标题\n\n标题在`.list-title`元素中：\n\n```html\n<a href=\"https://xuexizhinan.com/book/17694.html\" target=\"_blank\" class=\"list-title text-md overflowClip_1\">\n    变形金刚\n</a>\n```\n\n#### 4. 资源类型/质量\n\n资源类型或质量信息在`.list-subtitle`元素中：\n\n```html\n<div class=\"list-subtitle text-muted text-xs overflowClip_1\">\n    4K原盘\n</div>\n```\n\n## 详情页面结构\n\n详情页面包含更完整的资源信息，特别是网盘链接和磁力链接等下载信息。\n\n### 1. 页面标题和元数据\n\n页面标题包含了电影名称和资源类型：\n\n```html\n<title>变形金刚1 4K UHD蓝光原盘ISO夸克网盘下载 | 4K指南</title>\n<meta name=\"keywords\" content=\"4K蓝光原盘,1080P,高清电影下载,磁力链接,变形金刚1夸克网盘资源, 变形金刚1磁力链接\" />\n<meta name=\"description\" content=\"提供修复资源+磁力链接。火种源激活的都市混战，点击获取4K资源！\" />\n```\n\n### 2. 封面图片\n\n封面图片位于`.book-cover`元素中：\n\n```html\n<div class=\"book-cover mb-3\">\n    <div class=\"text-center position-relative\">\n        <img class=\"rounded shadow\" src=\"https://img.alicdn.com/imgextra/i1/2355688876/O1CN017g1yTT2FRGYEB9T3H_!!2355688876.png\" alt=\"变形金刚\" title=\"变形金刚\" style=\"max-height: 350px;\">\n    </div>\n</div>\n```\n\n### 3. 资源标题和分类\n\n资源标题和分类标签位于`.book-header`元素中：\n\n```html\n<div class=\"book-header mt-3\">\n    <h1>变形金刚</h1>\n    <div class=\"my-2\">\n        <span class=\"mr-2\"><a href=\"https://xuexizhinan.com/books/dongzuo\" rel=\"tag\">动作</a><i class=\"iconfont icon-wailian text-ss\"></i></span>\n        <span class=\"mr-2\"><a href=\"https://xuexizhinan.com/books/zuixin\" rel=\"tag\">影视</a><i class=\"iconfont icon-wailian text-ss\"></i></span>\n        <span class=\"mr-2\"><a href=\"https://xuexizhinan.com/books/kehuan\" rel=\"tag\">科幻</a><i class=\"iconfont icon-wailian text-ss\"></i></span>\n        <!-- 更多标签 -->\n    </div>\n</div>\n```\n\n### 4. 下载链接区域\n\n下载链接区域位于`.book-info`元素中，包含磁力链接和网盘链接：\n\n```html\n<div class=\"book-info mb-3\">\n    <div class=\"row main card\">\n        <div class=\"col-12 col-lg-9 left my-2\">\n            <p>4K原盘</p>\n            <div>\n                <ul>\n                    <li class=\"my-2\">\n                        <span class=\"mr-3\">磁力链接 :</span>magnet:?xt=urn:btih:17c69c04a26cf2f02a69fb722f973afdcdaf0db4&dn=Transformers.2007.2160p.BluRay.HEVC.TrueHD.7.1.Atmos-TASTED&tr=http%3A%2F%2Ftracker.trackerfix.com%3A80%2Fannounce&tr=udp%3A%2F%2F9.rarbg.me%3A2780&tr=udp%3A%2F%2F9.rarbg.to%3A2880\n                    </li>\n                </ul>\n                <div class=\"site-go mt-3\">\n                    <a target=\"_blank\" href=\"https://pan.quark.cn/s/54afc86c2ced\" class=\"btn btn-arrow rounded-lg\" title=\"夸克网盘\">\n                        <span class=\"b-name\">夸克网盘</span>\n                        <i class=\"iconfont icon-arrow-r\"></i>\n                    </a>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n```\n\n#### 4.1 磁力链接\n\n磁力链接直接出现在`<li>`标签中，格式为`magnet:?xt=urn:btih:...`。\n\n#### 4.2 网盘链接\n\n网盘链接位于`.site-go`元素中，使用`<a>`标签：\n\n```html\n<a target=\"_blank\" href=\"https://pan.quark.cn/s/54afc86c2ced\" class=\"btn btn-arrow rounded-lg\" title=\"夸克网盘\">\n    <span class=\"b-name\">夸克网盘</span>\n    <i class=\"iconfont icon-arrow-r\"></i>\n</a>\n```\n\n网盘链接的URL在`href`属性中，类型可以从`title`属性或`.b-name`元素中获取。\n\n### 5. 资源详情\n\n资源详细介绍位于`.panel-body.single`元素中：\n\n```html\n<div class=\"panel-body single mb-4 \">\n    <p>&#8220;导演: 迈克尔·贝<br />\n    编剧: 罗伯托·奥奇 / 艾里克斯·库兹曼 / 约翰·罗杰斯<br />\n    主演: 希亚·拉博夫 / 梅根·福克斯 / 乔什·杜哈明 / 泰瑞斯·吉布森 / 瑞切尔·泰勒 / 更多...<br />\n    类型: 动作 / 科幻<br />\n    制片国家/地区: 美国<br />\n    语言: 英语 / 西班牙语<br />\n    上映日期: 2007-07-11(中国大陆) / 2007-07-03(美国)<br />\n    片长: 144分钟<br />\n    又名: 变形金刚电影版<br />\n    IMDb: tt0418279&#8221;</p>\n</div>\n```\n\n这部分通常包含了电影的详细信息，如导演、演员、类型、上映日期等。\n\n## 提取逻辑\n\n### 搜索结果页面提取逻辑\n\n1. 定位所有的`.url-card`元素\n2. 对于每个元素：\n   - 提取详情页链接 (`href`属性)\n   - 从链接中提取资源ID\n   - 提取标题文本\n   - 提取资源类型/质量\n   - 记录封面图片URL\n\n### 详情页面提取逻辑\n\n1. 获取资源基本信息：\n   - 标题（`h1`标签）\n   - 分类标签（`.book-header .my-2 a`标签）\n   - 封面图片（`.book-cover img`标签的`src`属性）\n\n2. 提取下载链接：\n   - 磁力链接：从`<li>`标签中提取以`magnet:`开头的文本\n   - 网盘链接：从`.site-go a`标签的`href`属性提取\n   - 网盘类型：从`.site-go a`标签的`title`属性或`.b-name`元素文本提取\n\n3. 提取资源详情：\n   - 从`.panel-body.single`元素中提取文本内容，包括导演、演员、类型等信息\n\n## 注意事项\n\n1. 搜索URL格式：`https://xuexizhinan.com/?post_type=book&s={关键词}`\n2. 详情页URL格式：`https://xuexizhinan.com/book/{ID}.html`\n3. 网站专注于4K超清资源，通常使用夸克网盘\n4. 夸克网盘链接格式：`https://pan.quark.cn/s/{提取码}`，没有单独的密码\n5. 有些资源同时提供磁力链接和网盘链接\n6. 详情页中可能没有明确的发布日期，但可以从电影信息中提取上映日期 "
  },
  {
    "path": "plugin/xuexizhinan/xuexizhinan.go",
    "content": "package xuexizhinan\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\n// 常量定义\nconst (\n\t// 搜索URL\n\tSearchURL = \"https://xuexizhinan.com/?post_type=book&s=%s\"\n\t\n\t// 详情页URL正则表达式\n\tDetailURLPattern = `https://xuexizhinan.com/book/(\\d+)\\.html`\n\t\n\t// 默认超时时间\n\tDefaultTimeout = 10 * time.Second\n\t\n\t// 并发数限制\n\tMaxConcurrency = 8 // 提高并发数以提高性能\n)\n\n// 预编译正则表达式\nvar (\n\tdetailURLRegex  = regexp.MustCompile(DetailURLPattern)\n\tmagnetLinkRegex = regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9a-zA-Z]+`)\n\tdateRegex       = regexp.MustCompile(`上映日期: (\\d{4}-\\d{2}-\\d{2})`)\n)\n\n// 缓存相关变量\nvar (\n\t// 详情页缓存\n\tdetailPageCache = sync.Map{}\n\t\n\t// 最后一次清理缓存的时间\n\tlastCacheCleanTime = time.Now()\n\t\n\t// 缓存有效期\n\tcacheTTL = 24 * time.Hour\n)\n\n// 缓存的详情页响应\ntype detailPageResponse struct {\n\tTitle       string\n\tImageURL    string\n\tMagnetLinks []string\n\tQuarkLinks  []model.Link\n\tTags        []string\n\tContent     string\n\tTimestamp   time.Time\n}\n\n// XuexizhinanPlugin 4K指南搜索插件\ntype XuexizhinanPlugin struct {\n\t*plugin.BaseAsyncPlugin\n}\n\n// NewXuexizhinanPlugin 创建新的4K指南搜索异步插件\nfunc NewXuexizhinanPlugin() *XuexizhinanPlugin {\n\treturn &XuexizhinanPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"xuexizhinan\", 1), // 高优先级\n\t}\n}\n\n// 初始化插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewXuexizhinanPlugin())\n\t\n\t// 启动缓存清理\n\tgo startCacheCleaner()\n}\n\n// startCacheCleaner 定期清理缓存\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(6 * time.Hour)\n\tdefer ticker.Stop()\n\t\n\tfor range ticker.C {\n\t\t// 清空详情页缓存\n\t\tdetailPageCache = sync.Map{}\n\t\tlastCacheCleanTime = time.Now()\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *XuexizhinanPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *XuexizhinanPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)\n}\n\n// doSearch 实际的搜索实现\nfunc (p *XuexizhinanPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 构建搜索URL\n\tsearchURL := fmt.Sprintf(SearchURL, url.QueryEscape(keyword))\n\t\n\t// 发送请求\n\treq, err := http.NewRequest(\"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建请求失败: %w\", err)\n\t}\n\t\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36\")\n\t\n\t// 添加请求超时控制\n\tctx, cancel := context.WithTimeout(req.Context(), DefaultTimeout)\n\tdefer cancel()\n\treq = req.WithContext(ctx)\n\t\n\t// 发送请求\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查响应状态码\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"请求返回状态码: %d\", resp.StatusCode)\n\t}\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"解析HTML失败: %w\", err)\n\t}\n\t\n\t// 提取搜索结果\n\ttype searchItem struct {\n\t\turl   string\n\t\ttitle string\n\t}\n\t\n\t// 将关键词转为小写，用于不区分大小写的比较\n\tlowerKeywords := strings.ToLower(keyword)\n\t// 将关键词按空格分割，用于支持多关键词搜索\n\tkeywords := strings.Fields(lowerKeywords)\n\t\n\t// 存储符合条件的搜索项\n\tvar validItems []searchItem\n\t\n\t// 使用更高效的选择器直接获取所有链接和标题\n\tdoc.Find(\".url-card\").Each(func(i int, s *goquery.Selection) {\n\t\t// 提取标题和链接\n\t\ttitleElem := s.Find(\".list-title\")\n\t\ttitle := strings.TrimSpace(titleElem.Text())\n\t\tlink, exists := titleElem.Attr(\"href\")\n\t\t\n\t\tif !exists || link == \"\" || title == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 标题转小写，用于不区分大小写的比较\n\t\tlowerTitle := strings.ToLower(title)\n\t\t\n\t\t// 检查标题是否包含所有关键词\n\t\tmatched := true\n\t\tfor _, kw := range keywords {\n\t\t\tif !strings.Contains(lowerTitle, kw) {\n\t\t\t\tmatched = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 如果标题包含所有关键词，则添加到有效项中\n\t\tif matched {\n\t\t\tvalidItems = append(validItems, searchItem{url: link, title: title})\n\t\t}\n\t})\n\t\n\t// 如果没有搜索结果，返回空结果\n\tif len(validItems) == 0 {\n\t\treturn nil, nil\n\t}\n\t\n\t// 创建信号量控制并发\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\tvar wg sync.WaitGroup\n\t\n\t// 使用带缓冲的通道减少阻塞\n\tbufferSize := len(validItems) * 2\n\tresultCh := make(chan model.SearchResult, bufferSize)\n\terrorCh := make(chan error, bufferSize)\n\t\n\t// 获取详情页信息\n\tfor _, item := range validItems {\n\t\twg.Add(1)\n\t\tgo func(url string) {\n\t\t\tdefer wg.Done()\n\t\t\t\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\t\t\t\n\t\t\t// 从详情页获取完整信息\n\t\t\tresult, err := p.processDetailPage(client, url)\n\t\t\tif err != nil {\n\t\t\t\terrorCh <- err\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\tif result != nil {\n\t\t\t\tresultCh <- *result\n\t\t\t}\n\t\t}(item.url)\n\t}\n\t\n\t// 等待所有goroutine完成\n\twg.Wait()\n\tclose(resultCh)\n\tclose(errorCh)\n\t\n\t// 收集结果和错误\n\tvar errs []error\n\tfor err := range errorCh {\n\t\terrs = append(errs, err)\n\t}\n\t\n\t// 预分配结果数组容量以提高性能\n\tvar results = make([]model.SearchResult, 0, len(validItems))\n\tfor result := range resultCh {\n\t\tresults = append(results, result)\n\t}\n\t\n\t// 如果有结果，返回结果；如果没有结果，但有错误，返回第一个错误\n\tif len(results) > 0 {\n\t\t// 使用过滤功能过滤结果\n\t\tfilteredResults := plugin.FilterResultsByKeyword(results, keyword)\n\t\t\n\t\treturn filteredResults, nil\n\t} else if len(errs) > 0 {\n\t\treturn nil, errs[0]\n\t}\n\t\n\treturn nil, nil\n}\n\n// processDetailPage 处理详情页，提取网盘链接和资源信息\nfunc (p *XuexizhinanPlugin) processDetailPage(client *http.Client, detailURL string) (*model.SearchResult, error) {\n\t// 检查缓存\n\tif cachedResult, ok := detailPageCache.Load(detailURL); ok {\n\t\tcachedResponse := cachedResult.(detailPageResponse)\n\t\t\n\t\t// 检查缓存是否过期\n\t\tif time.Since(cachedResponse.Timestamp) < cacheTTL {\n\t\t\treturn p.detailResponseToResult(detailURL, cachedResponse), nil\n\t\t}\n\t}\n\t\n\t// 正则匹配提取ID - 使用预编译的正则表达式\n\tmatches := detailURLRegex.FindStringSubmatch(detailURL)\n\tif len(matches) < 2 {\n\t\treturn nil, fmt.Errorf(\"无效的详情页URL格式: %s\", detailURL)\n\t}\n\t\n\t// 发送请求\n\treq, err := http.NewRequest(\"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建请求失败: %w\", err)\n\t}\n\t\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36\")\n\t\n\t// 添加请求超时控制\n\tctx, cancel := context.WithTimeout(req.Context(), DefaultTimeout)\n\tdefer cancel()\n\treq = req.WithContext(ctx)\n\t\n\t// 发送请求\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"请求失败: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 检查响应状态码\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"请求返回状态码: %d\", resp.StatusCode)\n\t}\n\t\n\t// 解析HTML\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"解析HTML失败: %w\", err)\n\t}\n\t\n\t// 提取详情信息\n\tresponse := detailPageResponse{\n\t\tTimestamp: time.Now(),\n\t}\n\t\n\t// 1. 提取标题\n\tresponse.Title = strings.TrimSpace(doc.Find(\".book-header h1\").Text())\n\tif response.Title == \"\" {\n\t\t// 尝试从页面标题获取\n\t\ttitle := doc.Find(\"title\").Text()\n\t\tresponse.Title = strings.TrimSuffix(title, \" | 4K指南\")\n\t\tresponse.Title = strings.TrimSpace(response.Title)\n\t}\n\t\n\t// 2. 提取封面图片\n\tresponse.ImageURL = doc.Find(\".book-cover img\").AttrOr(\"src\", \"\")\n\t\n\t// 3. 提取标签\n\tdoc.Find(\".book-header .my-2 a\").Each(func(i int, s *goquery.Selection) {\n\t\ttag := strings.TrimSpace(s.Text())\n\t\tif tag != \"\" {\n\t\t\tresponse.Tags = append(response.Tags, tag)\n\t\t}\n\t})\n\t\n\t// 4. 提取资源详情\n\tresponse.Content = strings.TrimSpace(doc.Find(\".panel-body.single\").Text())\n\t\n\t// 5. 提取磁力链接和6. 提取夸克网盘链接\n\t// 一次性查找所有可能包含链接的元素，减少DOM遍历\n\tdoc.Find(\"li, .site-go a\").Each(func(i int, s *goquery.Selection) {\n\t\tif s.Is(\"li\") {\n\t\t\t// 提取磁力链接\n\t\t\ttext := s.Text()\n\t\t\tif strings.Contains(text, \"magnet:?xt=urn:btih:\") {\n\t\t\t\t// 使用预编译的正则表达式\n\t\t\t\tmagnetMatch := magnetLinkRegex.FindString(text)\n\t\t\t\tif magnetMatch != \"\" {\n\t\t\t\t\tresponse.MagnetLinks = append(response.MagnetLinks, magnetMatch)\n\t\t\t\t}\n\t\t\t}\n\t\t} else if s.Is(\"a\") {\n\t\t\t// 提取夸克网盘链接\n\t\t\thref := s.AttrOr(\"href\", \"\")\n\t\t\ttitle := s.AttrOr(\"title\", \"\")\n\t\t\tname := s.Find(\".b-name\").Text()\n\t\t\t\n\t\t\tif strings.Contains(href, \"pan.quark.cn\") || strings.Contains(name, \"夸克\") || strings.Contains(title, \"夸克\") {\n\t\t\t\tlink := model.Link{\n\t\t\t\t\tURL:      href,\n\t\t\t\t\tType:     \"quark\",\n\t\t\t\t\tPassword: \"\", // 夸克网盘通常不需要单独的提取码\n\t\t\t\t}\n\t\t\t\tresponse.QuarkLinks = append(response.QuarkLinks, link)\n\t\t\t}\n\t\t}\n\t})\n\t\n\t// 缓存结果\n\tdetailPageCache.Store(detailURL, response)\n\t\n\t// 转换为搜索结果\n\treturn p.detailResponseToResult(detailURL, response), nil\n}\n\n// detailResponseToResult 将详情页响应转换为搜索结果\nfunc (p *XuexizhinanPlugin) detailResponseToResult(detailURL string, response detailPageResponse) *model.SearchResult {\n\tif response.Title == \"\" && len(response.MagnetLinks) == 0 && len(response.QuarkLinks) == 0 {\n\t\treturn nil\n\t}\n\t\n\t// 提取ID - 使用预编译的正则表达式\n\tmatches := detailURLRegex.FindStringSubmatch(detailURL)\n\tid := \"unknown\"\n\tif len(matches) >= 2 {\n\t\tid = matches[1]\n\t}\n\t\n\t// 创建唯一ID\n\tuniqueID := fmt.Sprintf(\"xuexizhinan-%s\", id)\n\t\n\t// 提取日期 - 使用预编译的正则表达式\n\tvar datetime time.Time\n\t// 尝试从内容中提取上映日期\n\tdateMatches := dateRegex.FindStringSubmatch(response.Content)\n\tif len(dateMatches) >= 2 {\n\t\t// 尝试解析日期\n\t\tif t, err := time.Parse(\"2006-01-02\", dateMatches[1]); err == nil {\n\t\t\tdatetime = t\n\t\t}\n\t}\n\t\n\t// 预分配链接数组的容量\n\ttotalLinks := len(response.MagnetLinks) + len(response.QuarkLinks)\n\tlinks := make([]model.Link, 0, totalLinks)\n\t\n\t// 添加磁力链接\n\tfor _, magnetLink := range response.MagnetLinks {\n\t\tlinks = append(links, model.Link{\n\t\t\tType:     \"magnet\",\n\t\t\tURL:      magnetLink,\n\t\t\tPassword: \"\",\n\t\t})\n\t}\n\t\n\t// 添加夸克网盘链接\n\tlinks = append(links, response.QuarkLinks...)\n\t\n\t// 创建搜索结果\n\tresult := &model.SearchResult{\n\t\tUniqueID: uniqueID,\n\t\tTitle:    response.Title,\n\t\tContent:  response.Content,\n\t\tDatetime: datetime,\n\t\tLinks:    links,\n\t\tTags:     response.Tags,\n\t}\n\t\n\treturn result\n} "
  },
  {
    "path": "plugin/xys/html结构分析.md",
    "content": "# XYS（小云搜索）插件HTML结构分析\n\n## API流程概述\n\n### 第一步：获取Token\n- **请求URL**: `https://www.yunso.net/index/user/s?wd={keyword}&mode=undefined&stype=undefined`\n- **方法**: GET\n- **Headers**: \n  - `Referer: https://www.yunso.net/`\n  - `User-Agent: Mozilla/5.0...`\n- **Token提取**: 从返回HTML中匹配 `const DToken = \"42b63a003f80bd5ff0a731fcd2a49fd40aefb5e96a46d546abbf92094da54763\";`\n\n### 第二步：执行搜索\n- **请求URL**: `https://www.yunso.net/api/validate/searchX2`\n- **方法**: POST\n- **URL参数**:\n  - `DToken2={token}`\n  - `requestID=undefined`\n  - `mode=90002`\n  - `stype=undefined`\n  - `scope_content=0`\n  - `wd={keyword}` (URL编码)\n  - `uk=`\n  - `page=1`\n  - `limit=20`\n  - `screen_filetype=`\n- **Headers**:\n  - `Referer: https://www.yunso.net/`\n  - `Content-Type: application/x-www-form-urlencoded`\n\n## 搜索结果结构\n\n### JSON响应格式\n```json\n{\n  \"code\": 0,\n  \"msg\": \"\",\n  \"time\": \"1755998625\",\n  \"data\": \"HTML内容\"\n}\n```\n\n### HTML结构 (在data字段中)\n\n#### 搜索结果项\n```html\n<div class=\"layui-card\" style=\"...\" id=\"{qid}-{timestamp}-{hash}\" data-qid=\"{qid}\">\n  <div class=\"layui-card-header\" style=\"...\">\n    <div style=\"...\">\n      序号、 <span class=\"layui-badge\">24小时内</span>\n      <img src=\"/assets/xyso/icon/filetype_folder.png\" style=\"...\">\n      <a onclick=\"open_sid(this)\" id=\"{qid}-{timestamp}-{hash}\" \n         url=\"{base64_url}\" href=\"{real_url}\" pa=\"{password}\" target=\"_blank\">\n        标题内容\n      </a>\n    </div>\n    <div class=\"responsive-container\">\n      <div><i class=\"layui-icon layui-icon-time\"></i> 2025-08-24 22:56:32</div>\n      <div>按钮组</div>\n    </div>\n  </div>\n  <div class=\"layui-card-body\">\n    <p>\n      <span>所有文件共计: 合计 :N/A</span>\n      <img src=\"/assets/xyso/{type}.png\" alt=\"{platform}\">\n    </p>\n  </div>\n</div>\n```\n\n## 数据提取要点\n\n### 1. 标题提取\n- **选择器**: `.layui-card-header a[onclick=\"open_sid(this)\"]`\n- **内容**: 链接文本内容，可能包含 `@` 等特殊符号需要清理\n\n### 2. 链接提取\n- **属性**: `href` - 真实链接URL\n- **属性**: `url` - Base64编码的URL (备用)\n- **属性**: `pa` - 提取码/密码\n\n### 3. 时间提取\n- **选择器**: `.layui-icon-time` 的父元素或下一个兄弟元素\n- **格式**: `2025-08-24 22:56:32`\n\n### 4. 网盘类型提取\n- **选择器**: `.layui-card-body img[alt]`\n- **类型映射**:\n  - `夸克` → quark\n  - `百度` → baidu\n  - `阿里` → aliyun\n  - 等等\n\n### 5. 结果统计\n- **总数**: 从顶部 `找到相关结果约 <strong>5919</strong> 个` 提取\n\n## 特殊处理\n\n### 1. 标题清理\n- 移除 `@` 符号: `凡@人@修@仙@传` → `凡人修仙传`\n- 移除HTML标签: `<font color='red'>凡人修仙传</font>` → `凡人修仙传`\n\n### 2. 链接处理\n- 优先使用 `href` 属性\n- 如果没有则解码 `url` 属性 (Base64)\n- 提取密码从 `pa` 属性\n\n### 3. 时间解析\n- 格式: `2025-08-24 22:56:32`\n- 转换为标准时间格式\n\n### 4. 网盘识别\n- 根据图片alt属性确定网盘类型\n- 根据URL域名辅助识别"
  },
  {
    "path": "plugin/xys/xys.go",
    "content": "package xys\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\nconst (\n\tPluginName    = \"xys\"\n\tDisplayName   = \"小云搜索\"\n\tDescription   = \"小云搜索 - 阿里云盘、夸克网盘、百度网盘等多网盘搜索引擎\"\n\tBaseURL       = \"https://www.yunso.net\"\n\tTokenPath     = \"/index/user/s\"\n\tSearchPath    = \"/api/validate/searchX2\"\n\tUserAgent     = \"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\"\n\tMaxResults    = 50\n)\n\n// XysPlugin 小云搜索插件\ntype XysPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tdebugMode  bool\n\ttokenCache sync.Map // 缓存token，避免频繁获取\n\tcacheTTL   time.Duration\n}\n\n// TokenCache token缓存结构\ntype TokenCache struct {\n\tToken     string\n\tTimestamp time.Time\n}\n\n// SearchResponse API响应结构\ntype SearchResponse struct {\n\tCode int    `json:\"code\"`\n\tMsg  string `json:\"msg\"`\n\tTime string `json:\"time\"`\n\tData string `json:\"data\"`\n}\n\n// init 注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewXysPlugin())\n}\n\n// NewXysPlugin 创建新的小云搜索插件实例\nfunc NewXysPlugin() *XysPlugin {\n\t// 检查调试模式\n\tdebugMode := false // 生产环境关闭调试\n\n\tp := &XysPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(PluginName, 3), // 标准网盘插件，启用Service层过滤\n\t\tdebugMode:       debugMode,\n\t\tcacheTTL:        30 * time.Minute, // token缓存30分钟\n\t}\n\n\treturn p\n}\n\n// Name 插件名称\nfunc (p *XysPlugin) Name() string {\n\treturn PluginName\n}\n\n// DisplayName 插件显示名称  \nfunc (p *XysPlugin) DisplayName() string {\n\treturn DisplayName\n}\n\n// Description 插件描述\nfunc (p *XysPlugin) Description() string {\n\treturn Description\n}\n\n// Search 搜索接口\nfunc (p *XysPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\treturn p.searchImpl(&http.Client{Timeout: 30 * time.Second}, keyword, ext)\n}\n\n// searchImpl 搜索实现\nfunc (p *XysPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif p.debugMode {\n\t\tlog.Printf(\"[XYS] 开始搜索: %s\", keyword)\n\t}\n\n\t// 第一步：获取token\n\ttoken, err := p.getToken(client, keyword)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"获取token失败: %w\", err)\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[XYS] 获取到token: %s\", token[:10]+\"...\")\n\t}\n\n\t// 第二步：执行搜索\n\tresults, err := p.executeSearch(client, token, keyword)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"执行搜索失败: %w\", err)\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[XYS] 搜索完成，获取到 %d 个结果\", len(results))\n\t}\n\n\treturn results, nil\n}\n\n// getToken 获取搜索token\nfunc (p *XysPlugin) getToken(client *http.Client, keyword string) (string, error) {\n\t// 检查缓存\n\tcacheKey := \"token\"\n\tif cached, found := p.tokenCache.Load(cacheKey); found {\n\t\tif tokenCache, ok := cached.(TokenCache); ok {\n\t\t\t// 检查是否过期\n\t\t\tif time.Since(tokenCache.Timestamp) < p.cacheTTL {\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[XYS] 使用缓存的token\")\n\t\t\t\t}\n\t\t\t\treturn tokenCache.Token, nil\n\t\t\t}\n\t\t}\n\t}\n\n\t// 构建请求URL\n\ttokenURL := fmt.Sprintf(\"%s%s?wd=%s&mode=undefined&stype=undefined\",\n\t\tBaseURL, TokenPath, url.QueryEscape(keyword))\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", tokenURL, nil)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"[%s] 创建token请求失败: %w\", p.Name(), err)\n\t}\n\n\t// 设置完整的请求头\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"[%s] token请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn \"\", fmt.Errorf(\"[%s] token请求HTTP状态错误: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\t// 解析HTML提取token\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"[%s] 解析token页面HTML失败: %w\", p.Name(), err)\n\t}\n\n\t// 查找script标签中的DToken定义\n\tvar token string\n\tdoc.Find(\"script\").Each(func(i int, s *goquery.Selection) {\n\t\tscriptContent := s.Text()\n\t\tif strings.Contains(scriptContent, \"DToken\") {\n\t\t\t// 使用正则表达式提取token\n\t\t\tre := regexp.MustCompile(`const\\s+DToken\\s*=\\s*\"([^\"]+)\"`)\n\t\t\tmatches := re.FindStringSubmatch(scriptContent)\n\t\t\tif len(matches) > 1 {\n\t\t\t\ttoken = matches[1]\n\t\t\t\tif p.debugMode {\n\t\t\t\t\tlog.Printf(\"[XYS] 从script中提取到token: %s\", token[:10]+\"...\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n\n\tif token == \"\" {\n\t\treturn \"\", fmt.Errorf(\"未找到DToken\")\n\t}\n\n\t// 缓存token\n\tp.tokenCache.Store(cacheKey, TokenCache{\n\t\tToken:     token,\n\t\tTimestamp: time.Now(),\n\t})\n\n\treturn token, nil\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *XysPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"[%s] 重试 %d 次后仍然失败: %w\", p.Name(), maxRetries, lastErr)\n}\n\n// executeSearch 执行搜索请求\nfunc (p *XysPlugin) executeSearch(client *http.Client, token, keyword string) ([]model.SearchResult, error) {\n\t// 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"%s%s?DToken2=%s&requestID=undefined&mode=90002&stype=undefined&scope_content=0&wd=%s&uk=&page=1&limit=20&screen_filetype=\",\n\t\tBaseURL, SearchPath, token, url.QueryEscape(keyword))\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建搜索请求失败: %w\", p.Name(), err)\n\t}\n\n\t// 设置完整的请求头\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"application/json, text/plain, */*\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\treq.Header.Set(\"Origin\", BaseURL)\n\treq.Header.Set(\"X-Requested-With\", \"XMLHttpRequest\")\n\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求HTTP状态错误: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\t// 读取响应体\n\trespBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 读取响应体失败: %w\", p.Name(), err)\n\t}\n\n\t// 解析JSON响应\n\tvar searchResp SearchResponse\n\tif err := json.Unmarshal(respBody, &searchResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] JSON解析失败: %w\", p.Name(), err)\n\t}\n\n\tif searchResp.Code != 0 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索API返回错误: %s\", p.Name(), searchResp.Msg)\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[XYS] 搜索API响应成功，data长度: %d\", len(searchResp.Data))\n\t}\n\n\t// 解析HTML内容\n\treturn p.parseSearchResults(searchResp.Data, keyword)\n}\n\n// parseSearchResults 解析搜索结果HTML\nfunc (p *XysPlugin) parseSearchResults(htmlData, keyword string) ([]model.SearchResult, error) {\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索结果HTML失败: %w\", p.Name(), err)\n\t}\n\n\tvar results []model.SearchResult\n\n\t// 查找搜索结果项\n\tdoc.Find(\".layui-card[data-qid]\").Each(func(i int, s *goquery.Selection) {\n\t\tif len(results) >= MaxResults {\n\t\t\treturn\n\t\t}\n\n\t\tresult := p.parseResultItem(s, i+1)\n\t\tif result != nil {\n\t\t\tresults = append(results, *result)\n\t\t}\n\t})\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[XYS] 解析到 %d 个原始结果\", len(results))\n\t}\n\n\t// 关键词过滤（标准网盘插件需要过滤）\n\tfilteredResults := plugin.FilterResultsByKeyword(results, keyword)\n\t\n\tif p.debugMode {\n\t\tlog.Printf(\"[XYS] 关键词过滤后剩余 %d 个结果\", len(filteredResults))\n\t}\n\n\treturn filteredResults, nil\n}\n\n// parseResultItem 解析单个搜索结果项\nfunc (p *XysPlugin) parseResultItem(s *goquery.Selection, index int) *model.SearchResult {\n\t// 提取QID\n\tqid, _ := s.Attr(\"data-qid\")\n\tif qid == \"\" {\n\t\treturn nil\n\t}\n\n\t// 提取标题和链接\n\tlinkEl := s.Find(`a[onclick=\"open_sid(this)\"]`)\n\tif linkEl.Length() == 0 {\n\t\treturn nil\n\t}\n\n\t// 提取标题\n\ttitle := p.cleanTitle(linkEl.Text())\n\tif title == \"\" {\n\t\treturn nil\n\t}\n\n\t// 提取链接URL\n\thref, _ := linkEl.Attr(\"href\")\n\tif href == \"\" {\n\t\t// 尝试从url属性解码\n\t\turlAttr, _ := linkEl.Attr(\"url\")\n\t\tif urlAttr != \"\" {\n\t\t\tif decoded, err := base64.StdEncoding.DecodeString(urlAttr); err == nil {\n\t\t\t\thref = string(decoded)\n\t\t\t}\n\t\t}\n\t}\n\n\tif href == \"\" {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[XYS] 跳过无链接的结果: %s\", title)\n\t\t}\n\t\treturn nil\n\t}\n\n\t// 提取密码\n\tpassword, _ := linkEl.Attr(\"pa\")\n\n\t// 提取时间\n\ttimeStr := strings.TrimSpace(s.Find(\".layui-icon-time\").Parent().Text())\n\tpublishTime := p.parseTime(timeStr)\n\n\t// 提取网盘类型\n\tplatform := p.extractPlatform(s, href)\n\n\t// 构建链接对象\n\tlink := model.Link{\n\t\tType:     platform,\n\t\tURL:      href,\n\t\tPassword: password,\n\t}\n\n\t// 构建结果对象\n\tresult := model.SearchResult{\n\t\tTitle:     title,\n\t\tContent:   fmt.Sprintf(\"来源：%s\", platform),\n\t\tChannel:   \"\", // 插件搜索结果必须为空字符串（按开发指南要求）\n\t\tMessageID: fmt.Sprintf(\"%s-%s-%d\", p.Name(), qid, index),\n\t\tUniqueID:  fmt.Sprintf(\"%s-%s-%d\", p.Name(), qid, index),\n\t\tDatetime:  publishTime,\n\t\tLinks:     []model.Link{link},\n\t\tTags:      []string{platform},\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[XYS] 解析结果: %s (%s)\", title, platform)\n\t}\n\n\treturn &result\n}\n\n// cleanTitle 清理标题\nfunc (p *XysPlugin) cleanTitle(title string) string {\n\tif title == \"\" {\n\t\treturn \"\"\n\t}\n\n\t// 移除HTML标签\n\tre := regexp.MustCompile(`<[^>]*>`)\n\tcleaned := re.ReplaceAllString(title, \"\")\n\n\t// 移除@符号\n\tcleaned = strings.ReplaceAll(cleaned, \"@\", \"\")\n\n\t// 清理多余的空格\n\tcleaned = strings.TrimSpace(cleaned)\n\tre = regexp.MustCompile(`\\s+`)\n\tcleaned = re.ReplaceAllString(cleaned, \" \")\n\n\treturn cleaned\n}\n\n// parseTime 解析时间字符串\nfunc (p *XysPlugin) parseTime(timeStr string) time.Time {\n\t// 清理时间字符串，移除图标等\n\ttimeStr = strings.TrimSpace(timeStr)\n\t\n\t// 查找时间格式 YYYY-MM-DD HH:MM:SS\n\tre := regexp.MustCompile(`(\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}:\\d{2})`)\n\tmatches := re.FindStringSubmatch(timeStr)\n\t\n\tif len(matches) > 1 {\n\t\tif t, err := time.Parse(\"2006-01-02 15:04:05\", matches[1]); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\t\n\t// 如果解析失败，返回当前时间\n\treturn time.Now()\n}\n\n// extractPlatform 提取网盘平台类型（按开发指南标准实现）\nfunc (p *XysPlugin) extractPlatform(s *goquery.Selection, href string) string {\n\treturn determineCloudType(href)\n}\n\n// determineCloudType 根据URL自动识别网盘类型（按开发指南完整列表）\nfunc determineCloudType(url string) string {\n\tswitch {\n\tcase strings.Contains(url, \"pan.quark.cn\"):\n\t\treturn \"quark\"\n\tcase strings.Contains(url, \"drive.uc.cn\"):\n\t\treturn \"uc\"\n\tcase strings.Contains(url, \"pan.baidu.com\"):\n\t\treturn \"baidu\"\n\tcase strings.Contains(url, \"aliyundrive.com\") || strings.Contains(url, \"alipan.com\"):\n\t\treturn \"aliyun\"\n\tcase strings.Contains(url, \"pan.xunlei.com\"):\n\t\treturn \"xunlei\"\n\tcase strings.Contains(url, \"cloud.189.cn\"):\n\t\treturn \"tianyi\"\n\tcase strings.Contains(url, \"caiyun.139.com\"):\n\t\treturn \"mobile\"\n\tcase strings.Contains(url, \"magnet:\"):\n\t\treturn \"magnet\"\n\tcase strings.Contains(url, \"ed2k://\"):\n\t\treturn \"ed2k\"\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n"
  },
  {
    "path": "plugin/yiove/yiove.go",
    "content": "package yiove\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"hash/crc32\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tpluginName      = \"yiove\"\n\tdefaultPriority = 3\n\n\tbaseURL             = \"https://bbs.yiove.com\"\n\tsearchPathFormat    = baseURL + \"/search-%s-1.htm\"\n\trequestTimeout      = 12 * time.Second\n\tdetailTimeout       = 12 * time.Second\n\tretryBaseDelay      = 200 * time.Millisecond\n\tmaxRequestRetries   = 3\n\tsearchResultLimit   = 12\n\tdetailLinkLimit     = 6\n\tdetailWorkerCount   = 6\n\thttpMaxIdleConns    = 64\n\thttpMaxIdlePerHost  = 16\n\thttpMaxConnsPerHost = 32\n)\n\nvar (\n\tlinkPatterns = []struct {\n\t\treg *regexp.Regexp\n\t\ttyp string\n\t}{\n\t\t{regexp.MustCompile(`https?://pan\\.quark\\.cn/(?:s|g)/[0-9A-Za-z]+`), \"quark\"},\n\t\t{regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9A-Za-z\\-_?=&]+`), \"baidu\"},\n\t\t{regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9A-Za-z\\-_?=&]+`), \"xunlei\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?(aliyundrive\\.com|alipan\\.com)/s/[0-9A-Za-z]+`), \"aliyun\"},\n\t\t{regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9A-Za-z]+`), \"uc\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?(123pan\\.com|123pan\\.cn|123684\\.com|123685\\.com|123912\\.com|123592\\.com)/s/[0-9A-Za-z]+`), \"123\"},\n\t\t{regexp.MustCompile(`https?://(?:www\\.)?mypikpak\\.com/s/[0-9A-Za-z]+`), \"pikpak\"},\n\t\t{regexp.MustCompile(`https?://caiyun\\.139\\.com/[^\\s<>\"']+`), \"mobile\"},\n\t\t{regexp.MustCompile(`https?://tianyi\\.cloud/[^\\s<>\"']+`), \"tianyi\"},\n\t\t{regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9A-Za-z]+`), \"magnet\"},\n\t\t{regexp.MustCompile(`ed2k://[^\\s<>\"']+`), \"ed2k\"},\n\t}\n\n\tpasswordPatterns = []*regexp.Regexp{\n\t\tregexp.MustCompile(`提取码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`密码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`pwd\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`code\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t}\n\n\ttextURLRegex  = regexp.MustCompile(`https?://[^\\s<>\"']+`)\n\tthreadIDRegex = regexp.MustCompile(`thread-(\\d+)`)\n)\n\n// YiovePlugin implements YiOVE forum search\ntype YiovePlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tclient *http.Client\n}\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewYiovePlugin())\n}\n\n// NewYiovePlugin creates plugin instance\nfunc NewYiovePlugin() *YiovePlugin {\n\treturn &YiovePlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(pluginName, defaultPriority, true),\n\t\tclient:          newHTTPClient(),\n\t}\n}\n\n// Search compatibility helper\nfunc (p *YiovePlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult entry point\nfunc (p *YiovePlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\nfunc (p *YiovePlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif p.client != nil {\n\t\tclient = p.client\n\t}\n\n\tdebug := false\n\tif ext != nil {\n\t\tswitch v := ext[\"debug\"].(type) {\n\t\tcase bool:\n\t\t\tdebug = v\n\t\tcase string:\n\t\t\tdebug = strings.EqualFold(v, \"true\")\n\t\t}\n\t}\n\n\tsearchKeyword := strings.TrimSpace(keyword)\n\tif searchKeyword == \"\" {\n\t\treturn nil, fmt.Errorf(\"[%s] 关键词不能为空\", p.Name())\n\t}\n\n\tlogDebug(debug, \"[%s] 开始搜索，关键词=%s\", p.Name(), searchKeyword)\n\n\tthreads, err := p.fetchSearchResults(client, searchKeyword, debug)\n\tif err != nil {\n\t\tlogDebug(debug, \"[%s] 搜索阶段报错: %v\", p.Name(), err)\n\t\treturn nil, err\n\t}\n\tlogDebug(debug, \"[%s] 搜索结果数量=%d\", p.Name(), len(threads))\n\tif len(threads) == 0 {\n\t\tlogDebug(debug, \"[%s] 搜索结果为空\", p.Name())\n\t\treturn nil, fmt.Errorf(\"[%s] 未找到相关结果\", p.Name())\n\t}\n\n\tvar (\n\t\twg      sync.WaitGroup\n\t\tsem     = make(chan struct{}, detailWorkerCount)\n\t\tresultM sync.Mutex\n\t\tresults []model.SearchResult\n\t)\n\n\tfor _, thread := range threads {\n\t\tthread := thread\n\t\twg.Add(1)\n\t\tsem <- struct{}{}\n\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tdefer func() { <-sem }()\n\n\t\t\tlogDebug(debug, \"[%s] 准备抓取详情 title=%s url=%s\", p.Name(), thread.Title, thread.URL)\n\n\t\t\tdetail, err := p.fetchDetail(client, thread.URL, debug)\n\t\t\tif err != nil {\n\t\t\t\tlogDebug(debug, \"[%s] 详情页抓取失败 URL=%s err=%v\", p.Name(), thread.URL, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(detail.links) == 0 {\n\t\t\t\tlogDebug(debug, \"[%s] 详情页无链接 URL=%s\", p.Name(), thread.URL)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tlinksWithTitle := applyWorkTitle(detail.links, thread.Title)\n\n\t\t\tresult := model.SearchResult{\n\t\t\t\tUniqueID: buildUniqueID(thread.URL),\n\t\t\t\tTitle:    thread.Title,\n\t\t\t\tContent:  detail.description,\n\t\t\t\tLinks:    limitLinks(linksWithTitle, detailLinkLimit),\n\t\t\t\tTags:     mergeTags(thread.Tags, detail.tags),\n\t\t\t\tChannel:  \"\",\n\t\t\t\tDatetime: detail.datetime,\n\t\t\t}\n\n\t\t\tresultM.Lock()\n\t\t\tresults = append(results, result)\n\t\t\tresultM.Unlock()\n\n\t\t\tlogDebug(debug, \"[%s] 详情抓取成功 URL=%s 链接数=%d\", p.Name(), thread.URL, len(result.Links))\n\t\t}()\n\t}\n\n\twg.Wait()\n\n\tif len(results) == 0 {\n\t\tlogDebug(debug, \"[%s] 所有线程抓取完成但无有效链接\", p.Name())\n\t\treturn nil, fmt.Errorf(\"[%s] 未能抓取到有效网盘链接\", p.Name())\n\t}\n\n\tfiltered := plugin.FilterResultsByKeyword(results, searchKeyword)\n\tlogDebug(debug, \"[%s] 过滤后结果数=%d\", p.Name(), len(filtered))\n\tfor idx, res := range filtered {\n\t\tlinkSummaries := make([]string, 0, len(res.Links))\n\t\tfor _, link := range res.Links {\n\t\t\tlinkSummaries = append(linkSummaries, fmt.Sprintf(\"%s(%s)\", link.Type, link.URL))\n\t\t}\n\t\tlogDebug(\n\t\t\tdebug,\n\t\t\t\"[%s] Result#%d | UID=%s | Title=%s | Links=%d | LinkDetail=%v\",\n\t\t\tp.Name(),\n\t\t\tidx,\n\t\t\tres.UniqueID,\n\t\t\tres.Title,\n\t\t\tlen(res.Links),\n\t\t\tlinkSummaries,\n\t\t)\n\t}\n\n\treturn filtered, nil\n}\n\ntype searchThread struct {\n\tTitle string\n\tURL   string\n\tTags  []string\n}\n\nfunc (p *YiovePlugin) fetchSearchResults(client *http.Client, keyword string, debug bool) ([]searchThread, error) {\n\tsearchURL := fmt.Sprintf(searchPathFormat, encodeKeyword(keyword))\n\tlogDebug(debug, \"[%s] 搜索URL=%s\", p.Name(), searchURL)\n\n\tctx, cancel := context.WithTimeout(context.Background(), requestTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建搜索请求失败: %w\", p.Name(), err)\n\t}\n\tsetHTMLHeaders(req, baseURL)\n\n\tresp, err := p.doRequestWithRetry(req, client, maxRequestRetries)\n\tif err != nil {\n\t\tlogDebug(debug, \"[%s] 搜索请求失败: %v\", p.Name(), err)\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tlogDebug(debug, \"[%s] 搜索返回非200: %d\", p.Name(), resp.StatusCode)\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\tlogDebug(debug, \"[%s] 搜索响应状态: %d\", p.Name(), resp.StatusCode)\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\tlogDebug(debug, \"[%s] 解析搜索页面失败: %v\", p.Name(), err)\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\n\tvar threads []searchThread\n\tdoc.Find(\"ul.threadlist li.thread\").Each(func(_ int, li *goquery.Selection) {\n\t\tif len(threads) >= searchResultLimit {\n\t\t\treturn\n\t\t}\n\n\t\tsubject := li.Find(\".subject a\").First()\n\t\thref, exists := subject.Attr(\"href\")\n\t\tif !exists || strings.TrimSpace(href) == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\ttitle := strings.TrimSpace(subject.Text())\n\t\tif title == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tvar tags []string\n\t\tli.Find(\".subject a.badge\").Each(func(_ int, node *goquery.Selection) {\n\t\t\ttag := strings.TrimSpace(node.Text())\n\t\t\tif tag != \"\" {\n\t\t\t\ttags = append(tags, tag)\n\t\t\t}\n\t\t})\n\n\t\tthreadURL := toAbsoluteURL(href)\n\t\tif threadURL == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tthreads = append(threads, searchThread{\n\t\t\tTitle: title,\n\t\t\tURL:   threadURL,\n\t\t\tTags:  tags,\n\t\t})\n\t\tlogDebug(debug, \"[%s] 解析到线程：title=%s url=%s\", p.Name(), title, threadURL)\n\t})\n\tlogDebug(debug, \"[%s] 解析到线程数量=%d\", p.Name(), len(threads))\n\n\treturn threads, nil\n}\n\ntype detailPayload struct {\n\tlinks       []model.Link\n\ttags        []string\n\tdescription string\n\tdatetime    time.Time\n}\n\nfunc (p *YiovePlugin) fetchDetail(client *http.Client, detailURL string, debug bool) (detailPayload, error) {\n\tlogDebug(debug, \"[%s] 抓取详情 URL=%s\", p.Name(), detailURL)\n\tctx, cancel := context.WithTimeout(context.Background(), detailTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil)\n\tif err != nil {\n\t\treturn detailPayload{}, fmt.Errorf(\"[%s] 创建详情页请求失败: %w\", p.Name(), err)\n\t}\n\tsetHTMLHeaders(req, detailURL)\n\n\tresp, err := p.doRequestWithRetry(req, client, maxRequestRetries)\n\tif err != nil {\n\t\tlogDebug(debug, \"[%s] 详情请求失败: %v\", p.Name(), err)\n\t\treturn detailPayload{}, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tlogDebug(debug, \"[%s] 详情返回非200: %d\", p.Name(), resp.StatusCode)\n\t\treturn detailPayload{}, fmt.Errorf(\"[%s] 详情页返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\tlogDebug(debug, \"[%s] 解析详情页面失败: %v\", p.Name(), err)\n\t\treturn detailPayload{}, fmt.Errorf(\"[%s] 解析详情页失败: %w\", p.Name(), err)\n\t}\n\n\tcontent := doc.Find(\"div.message[isfirst='1']\")\n\tif content.Length() == 0 {\n\t\tcontent = doc.Find(\".message\").First()\n\t}\n\tif content.Length() == 0 {\n\t\tcontent = doc.Selection\n\t}\n\n\tcontent.Find(\"script, style\").Remove()\n\n\tlinks := extractLinks(content)\n\tdescription := strings.TrimSpace(doc.Find(\"meta[name='description']\").AttrOr(\"content\", \"\"))\n\tif description == \"\" {\n\t\tdescription = truncateText(content.Text(), 200)\n\t}\n\tlogDebug(debug, \"[%s] 详情解析完成 URL=%s 链接数=%d\", p.Name(), detailURL, len(links))\n\n\treturn detailPayload{\n\t\tlinks:       links,\n\t\ttags:        collectTags(doc),\n\t\tdescription: description,\n\t\tdatetime:    extractDatetime(doc),\n\t}, nil\n}\n\nfunc logDebug(enabled bool, format string, args ...interface{}) {\n\tif !enabled {\n\t\treturn\n\t}\n\tfmt.Printf(format+\"\\n\", args...)\n}\n\nfunc collectTags(doc *goquery.Document) []string {\n\ttagSet := make(map[string]struct{})\n\n\tdoc.Find(\".breadcrumb a, ol.breadcrumb a\").Each(func(_ int, node *goquery.Selection) {\n\t\ttext := strings.TrimSpace(node.Text())\n\t\tif text == \"\" || strings.Contains(text, \"首页\") {\n\t\t\treturn\n\t\t}\n\t\ttagSet[text] = struct{}{}\n\t})\n\n\tdoc.Find(\"h4 a.badge\").Each(func(_ int, node *goquery.Selection) {\n\t\ttext := strings.TrimSpace(node.Text())\n\t\tif text != \"\" {\n\t\t\ttagSet[text] = struct{}{}\n\t\t}\n\t})\n\n\ttags := make([]string, 0, len(tagSet))\n\tfor tag := range tagSet {\n\t\ttags = append(tags, tag)\n\t}\n\treturn tags\n}\n\nfunc extractDatetime(doc *goquery.Document) time.Time {\n\tdateText := strings.TrimSpace(doc.Find(\".card-thread .date\").First().Text())\n\tif dateText == \"\" {\n\t\treturn time.Now()\n\t}\n\n\tlayouts := []string{\n\t\t\"2006-01-02 15:04\",\n\t\t\"2006/01/02 15:04\",\n\t\t\"2006-01-02\",\n\t\t\"2006/01/02\",\n\t\ttime.RFC3339,\n\t}\n\n\tfor _, layout := range layouts {\n\t\tif t, err := time.ParseInLocation(layout, dateText, time.Local); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\n\treturn time.Now()\n}\n\nfunc extractLinks(selection *goquery.Selection) []model.Link {\n\tvar (\n\t\tresults []model.Link\n\t\tseen    = make(map[string]struct{})\n\t)\n\n\tselection.Find(\"a[href]\").Each(func(_ int, node *goquery.Selection) {\n\t\thref, ok := node.Attr(\"href\")\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\n\t\tlinkType, normalized := classifyLink(href)\n\t\tif linkType == \"\" || normalized == \"\" {\n\t\t\treturn\n\t\t}\n\t\tif _, exists := seen[normalized]; exists {\n\t\t\treturn\n\t\t}\n\n\t\tpassword := extractPassword(node)\n\t\tresults = append(results, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      normalized,\n\t\t\tPassword: password,\n\t\t})\n\t\tseen[normalized] = struct{}{}\n\t})\n\n\ttext := selection.Text()\n\tfor _, loc := range textURLRegex.FindAllStringIndex(text, -1) {\n\t\traw := text[loc[0]:loc[1]]\n\t\tlinkType, normalized := classifyLink(raw)\n\t\tif linkType == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := seen[normalized]; exists {\n\t\t\tcontinue\n\t\t}\n\n\t\tcontext := substring(text, loc[0]-80, loc[1]+80)\n\t\tpassword := matchPassword(context)\n\t\tresults = append(results, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      normalized,\n\t\t\tPassword: password,\n\t\t})\n\t\tseen[normalized] = struct{}{}\n\t}\n\n\treturn results\n}\n\nfunc classifyLink(raw string) (string, string) {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tfor _, pattern := range linkPatterns {\n\t\tif loc := pattern.reg.FindString(raw); loc != \"\" {\n\t\t\treturn pattern.typ, loc\n\t\t}\n\t}\n\treturn \"\", \"\"\n}\n\nfunc extractPassword(node *goquery.Selection) string {\n\tcandidates := []string{node.Text()}\n\n\tif title, ok := node.Attr(\"title\"); ok {\n\t\tcandidates = append(candidates, title)\n\t}\n\n\tif parent := node.Parent(); parent != nil && parent.Length() > 0 {\n\t\tcandidates = append(candidates, parent.Text())\n\t\tif sibling := parent.Next(); sibling.Length() > 0 {\n\t\t\tcandidates = append(candidates, sibling.Text())\n\t\t}\n\t}\n\n\tif next := node.Next(); next.Length() > 0 {\n\t\tcandidates = append(candidates, next.Text())\n\t}\n\n\tfor _, text := range candidates {\n\t\tif pwd := matchPassword(text); pwd != \"\" {\n\t\t\treturn pwd\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc matchPassword(text string) string {\n\ttext = strings.TrimSpace(text)\n\tif text == \"\" {\n\t\treturn \"\"\n\t}\n\tfor _, pattern := range passwordPatterns {\n\t\tif matches := pattern.FindStringSubmatch(text); len(matches) > 1 {\n\t\t\treturn strings.TrimSpace(matches[1])\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc limitLinks(links []model.Link, limit int) []model.Link {\n\tif limit <= 0 || len(links) <= limit {\n\t\treturn links\n\t}\n\treturn links[:limit]\n}\n\nfunc applyWorkTitle(links []model.Link, title string) []model.Link {\n\tif title == \"\" || len(links) == 0 {\n\t\treturn links\n\t}\n\tfor i := range links {\n\t\tlinks[i].WorkTitle = title\n\t}\n\treturn links\n}\n\nfunc mergeTags(a, b []string) []string {\n\ttagSet := make(map[string]struct{})\n\tfor _, tag := range a {\n\t\tif tag = strings.TrimSpace(tag); tag != \"\" {\n\t\t\ttagSet[tag] = struct{}{}\n\t\t}\n\t}\n\tfor _, tag := range b {\n\t\tif tag = strings.TrimSpace(tag); tag != \"\" {\n\t\t\ttagSet[tag] = struct{}{}\n\t\t}\n\t}\n\n\ttags := make([]string, 0, len(tagSet))\n\tfor tag := range tagSet {\n\t\ttags = append(tags, tag)\n\t}\n\treturn tags\n}\n\nfunc encodeKeyword(keyword string) string {\n\tkeyword = strings.TrimSpace(keyword)\n\tif keyword == \"\" {\n\t\treturn \"\"\n\t}\n\tvar builder strings.Builder\n\tfor _, b := range []byte(keyword) {\n\t\tbuilder.WriteByte('_')\n\t\tbuilder.WriteString(strings.ToUpper(fmt.Sprintf(\"%02x\", b)))\n\t}\n\treturn builder.String()\n}\n\nfunc toAbsoluteURL(href string) string {\n\thref = strings.TrimSpace(href)\n\tif href == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(href, \"http\") {\n\t\treturn href\n\t}\n\tif strings.HasPrefix(href, \"//\") {\n\t\treturn \"https:\" + href\n\t}\n\treturn fmt.Sprintf(\"%s/%s\", baseURL, strings.TrimLeft(href, \"./\"))\n}\n\nfunc truncateText(text string, limit int) string {\n\ttext = strings.TrimSpace(text)\n\trunes := []rune(text)\n\tif len(runes) <= limit {\n\t\treturn text\n\t}\n\treturn string(runes[:limit])\n}\n\nfunc substring(text string, start, end int) string {\n\tif start < 0 {\n\t\tstart = 0\n\t}\n\tif end > len(text) {\n\t\tend = len(text)\n\t}\n\treturn text[start:end]\n}\n\nfunc buildUniqueID(detailURL string) string {\n\tif matches := threadIDRegex.FindStringSubmatch(detailURL); len(matches) > 1 {\n\t\treturn fmt.Sprintf(\"%s-%s\", pluginName, matches[1])\n\t}\n\tsum := crc32.ChecksumIEEE([]byte(detailURL))\n\treturn fmt.Sprintf(\"%s-%d\", pluginName, sum)\n}\n\nfunc newHTTPClient() *http.Client {\n\treturn &http.Client{\n\t\tTimeout: requestTimeout,\n\t\tTransport: &http.Transport{\n\t\t\tMaxIdleConns:        httpMaxIdleConns,\n\t\t\tMaxIdleConnsPerHost: httpMaxIdlePerHost,\n\t\t\tMaxConnsPerHost:     httpMaxConnsPerHost,\n\t\t\tIdleConnTimeout:     90 * time.Second,\n\t\t\tTLSHandshakeTimeout: 10 * time.Second,\n\t\t},\n\t}\n}\n\nfunc setHTMLHeaders(req *http.Request, referer string) {\n\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", referer)\n}\n\nfunc (p *YiovePlugin) doRequestWithRetry(req *http.Request, client *http.Client, maxRetries int) (*http.Response, error) {\n\tvar lastErr error\n\n\tfor attempt := 0; attempt < maxRetries; attempt++ {\n\t\tresp, err := client.Do(req.Clone(req.Context()))\n\t\tif err == nil && resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t\tif attempt < maxRetries-1 {\n\t\t\ttime.Sleep(retryBaseDelay * time.Duration(1<<attempt))\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"重试 %d 次后失败: %w\", maxRetries, lastErr)\n}\n"
  },
  {
    "path": "plugin/ypfxw/html结构分析.md",
    "content": "# ypfxw (网盘资源分享网) HTML结构分析\n\n## 网站信息\n- **站点名称**: 网盘资源分享网\n- **域名**: `ypfxw.com`\n- **系统**: Z-Blog (主题 `qk_teat`)\n- **特点**: 资源覆盖影视/动漫/学习等，搜索结果页为图文列表，正文直接放出多个网盘链接（夸克、百度等），常伴随广告段落\n\n## 搜索 / 列表页面\n\n### 1. 搜索 URL\n```\nhttps://ypfxw.com/search.php?q={关键词}\n```\n- 也可通过 `search.php?act=search&q=...` 发起\n- 关键词可直接使用 UTF-8 中文\n\n### 2. DOM 结构\n- 列表容器：`div.list > ul`\n- 列表项：`div.list ul > li`\n\n#### 单项结构\n```html\n<li>\n  <div class=\"img\">\n    <a href=\"https://ypfxw.com/post/103580.html\">\n      <span class=\"img-box\"><img src=\"...\" alt=\"...\"></span>\n    </a>\n  </div>\n  <div class=\"imgr\">\n    <h2><a href=\"https://ypfxw.com/post/103580.html\"><span>标题</span></a></h2>\n    <p>简介/描述，末尾可能带 “链接：https://pan.quark.cn/s/...”</p>\n    <div class=\"info\">\n      <span><a href=\"...\"><i class=\"fa fa-columns\"></i>影视资源</a></span>\n      <span><i class=\"fa fa-clock-o\"></i>2025-11-25</span>\n      <span><i class=\"fa fa-eye\"></i>44</span>\n      <span><i class=\"fa fa-comments\"></i>0</span>\n      <span class=\"tag\">\n        <a href=\"...\">夸克网盘</a>\n        <a href=\"...\">2025</a>\n      </span>\n    </div>\n  </div>\n</li>\n```\n\n#### 需要提取\n- **标题**: `div.imgr h2 a` 文本\n- **详情链接**: 同上 `href`\n- **摘要**: `div.imgr p` 文本（含“名称/描述/链接”）\n- **分类**: `.info span:first-child a` 文本，可入 `Tags`\n- **发布时间**: `.info span i.fa-clock-o` 的父节点文本（格式 `YYYY-MM-DD`）\n- **标签**: `.info span.tag a`\n\n## 详情页\n\n### 1. URL 模式\n```\nhttps://ypfxw.com/post/{文章ID}.html\n```\n- ID 可用于 `UniqueID`\n\n### 2. 关键节点\n- 标题：`.post .title h1`\n- 元信息：`.post .title .info`\n- 正文：`.post .article_content`\n- 标签：`.post span.tag a`\n\n### 3. 下载信息\n- 正文内 `p` 标签通常按照 “链接：URL” 或直接裸露 URL\n- 可能出现多条链接（夸克、百度、群组等），需要根据域名筛选\n- 有些链接是 `<a href=\"URL\">`，也有纯文本 `https://...` 形式\n- 提取码一般紧随链接，例如 `https://pan.baidu.com/... ?pwd=2222`、`提取码：xxxx`、`密码：xxxx`\n\n### 4. 常见网盘域名\n- 夸克：`https://pan.quark.cn/s/...`\n- 百度：`https://pan.baidu.com/s/...`\n- 夸克群：`https://pan.quark.cn/g/...`\n- 其他：视情况扩展（如 `aliyundrive.com`, `123pan.com` 等）\n\n## 提取策略\n1. **列表页**\n   - 请求 `search.php?q=关键字`\n   - 遍历 `div.list ul > li`\n   - 提取标题/链接/摘要/分类/时间/标签\n   - 通过 `/post/{id}.html` 提取唯一 ID\n\n2. **详情页**\n   - 访问 `.article_content`\n   - 解析所有 `<a href>`，按域名判断是否为网盘链接\n   - 额外对正文纯文本使用正则匹配 `https?://...`，以捕获未包裹 `<a>` 的链接\n   - 针对链接周围文本或 `title`、父节点文本匹配提取码关键词（`提取码`/`密码`/`pwd`/`code` 等）\n   - 多链接去重；同一篇返回多个 `model.Link`\n\n3. **时间处理**\n   - 列表页 `span` 文本即 `YYYY-MM-DD`\n   - 详情页元信息含完整时间 `YYYY-MM-DD HH:MM:SS`，可作为 fallback\n\n4. **性能/稳定性**\n   - 采用自定义 `http.Client`（连接池、HTTP/2、TLS 超时）\n   - 搜索/详情请求均加指数退避重试\n   - 详情解析结果加入 TTL 缓存，减少重复访问\n   - 使用信号量控制并发抓取，避免压垮目标站点\n\n## 示例\n1. 搜索 `凡人修仙传` -> 结果项 `https://ypfxw.com/post/103580.html`\n2. 详情页正文出现：\n```\n链接：https://pan.quark.cn/s/08211da2cb83\n```\n3. 输出：\n```\nUniqueID: ypfxw-103580\nTitle: 凡人修仙传 ... 重返天南 ...\nLinks: [{Type:\"quark\", URL:\"https://pan.quark.cn/s/08211da2cb83\", Password:\"\"}]\nTags: [\"影视资源\",\"夸克网盘\",\"2025\",\"免费下载\"]\nDatetime: 2025-11-25\n```\n\n"
  },
  {
    "path": "plugin/ypfxw/ypfxw.go",
    "content": "package ypfxw\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nvar (\n\tarticleIDRegex = regexp.MustCompile(`/post/(\\d+)\\.html`)\n\turlRegex       = regexp.MustCompile(`https?://[^\\s<>\"']+`)\n\n\tlinkPatterns = []struct {\n\t\treg *regexp.Regexp\n\t\ttyp string\n\t}{\n\t\t{regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9A-Za-z]+`), \"quark\"},\n\t\t{regexp.MustCompile(`https?://pan\\.quark\\.cn/g/[0-9A-Za-z]+`), \"quark\"},\n\t\t{regexp.MustCompile(`https?://www\\.aliyundrive\\.com/s/[0-9A-Za-z]+`), \"aliyun\"},\n\t\t{regexp.MustCompile(`https?://www\\.aliyundrive\\.com/drive/folder/[0-9A-Za-z]+`), \"aliyun\"},\n\t\t{regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9A-Za-z\\-_]+`), \"baidu\"},\n\t\t{regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9A-Za-z\\-_]+`), \"xunlei\"},\n\t\t{regexp.MustCompile(`https?://123pan\\.com/s/[0-9A-Za-z]+`), \"123\"},\n\t}\n\n\tpwdPatterns = []*regexp.Regexp{\n\t\tregexp.MustCompile(`提取码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`密码[:：]?\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`pwd\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t\tregexp.MustCompile(`code\\s*[=:：]\\s*([0-9A-Za-z]+)`),\n\t}\n\n\tdetailCache          = sync.Map{}\n\tcacheTTL             = 1 * time.Hour\n\tcacheCleanupInterval = 30 * time.Minute\n)\n\ntype cacheEntry struct {\n\tlinks     []model.Link\n\texpiresAt time.Time\n}\n\nconst (\n\tpluginName            = \"ypfxw\"\n\tdefaultPriority       = 2\n\tsearchTimeout         = 12 * time.Second\n\tdetailTimeout         = 10 * time.Second\n\tmaxConcurrency        = 12\n\tmaxIdleConns          = 64\n\tmaxIdlePerHost        = 16\n\tmaxConnsPerHost       = 32\n\tidleConnLifetime      = 90 * time.Second\n\ttlsHandshakeTimeout   = 10 * time.Second\n\texpectContinueTimeout = 1 * time.Second\n\n\tsearchMaxRetries = 3\n\tdetailMaxRetries = 2\n\tretryBaseDelay   = 200 * time.Millisecond\n)\n\n// YpfxwPlugin 插件实现\ntype YpfxwPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tclient *http.Client\n}\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewYpfxwPlugin())\n\tgo startCacheCleaner()\n}\n\n// NewYpfxwPlugin 构造函数\nfunc NewYpfxwPlugin() *YpfxwPlugin {\n\treturn &YpfxwPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),\n\t\tclient:          newHTTPClient(),\n\t}\n}\n\n// Search 兼容方法\nfunc (p *YpfxwPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 主搜索入口\nfunc (p *YpfxwPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\nfunc newHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:          maxIdleConns,\n\t\tMaxIdleConnsPerHost:   maxIdlePerHost,\n\t\tMaxConnsPerHost:       maxConnsPerHost,\n\t\tIdleConnTimeout:       idleConnLifetime,\n\t\tTLSHandshakeTimeout:   tlsHandshakeTimeout,\n\t\tExpectContinueTimeout: expectContinueTimeout,\n\t\tForceAttemptHTTP2:     true,\n\t}\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   searchTimeout,\n\t}\n}\n\nfunc (p *YpfxwPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif p.client != nil {\n\t\tclient = p.client\n\t}\n\n\tsearchURL := fmt.Sprintf(\"https://ypfxw.com/search.php?q=%s\", url.QueryEscape(keyword))\n\tctx, cancel := context.WithTimeout(context.Background(), searchTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\n\tsetCommonHeaders(req, \"https://ypfxw.com/\")\n\n\tresp, err := p.doRequestWithRetry(req, client, searchMaxRetries)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\n\tvar (\n\t\tresults []model.SearchResult\n\t\twg      sync.WaitGroup\n\t\tmu      sync.Mutex\n\t\tsem     = make(chan struct{}, maxConcurrency)\n\t)\n\n\tdoc.Find(\"div.list ul > li\").Each(func(_ int, item *goquery.Selection) {\n\t\ttitleSel := item.Find(\"div.imgr h2 a\")\n\t\ttitle := strings.TrimSpace(titleSel.Text())\n\t\tdetailURL, ok := titleSel.Attr(\"href\")\n\t\tif !ok || title == \"\" || detailURL == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tarticleID := extractArticleID(detailURL)\n\t\tif articleID == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tsummary := strings.TrimSpace(item.Find(\"div.imgr p\").First().Text())\n\n\t\tcategory := strings.TrimSpace(item.Find(\".info span\").First().Text())\n\t\tvar tags []string\n\t\tif category != \"\" {\n\t\t\ttags = append(tags, strings.TrimSpace(category))\n\t\t}\n\t\titem.Find(\".info span.tag a\").Each(func(_ int, tag *goquery.Selection) {\n\t\t\ttagText := strings.TrimSpace(tag.Text())\n\t\t\tif tagText != \"\" {\n\t\t\t\ttags = append(tags, tagText)\n\t\t\t}\n\t\t})\n\n\t\ttimeText := \"\"\n\t\tif node := item.Find(\".info span i.fa-clock-o\").Parent(); node.Length() > 0 {\n\t\t\ttimeText = strings.TrimSpace(node.Text())\n\t\t}\n\t\tpublishTime := parsePublishTime(timeText)\n\n\t\twg.Add(1)\n\t\tsem <- struct{}{}\n\t\tgo func(title, detailURL, summary string, tags []string, publish time.Time, articleID string) {\n\t\t\tdefer wg.Done()\n\t\t\tdefer func() { <-sem }()\n\n\t\t\tlinks := p.fetchDetailLinks(client, detailURL, articleID)\n\t\t\tif len(links) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tresult := model.SearchResult{\n\t\t\t\tUniqueID: fmt.Sprintf(\"%s-%s\", p.Name(), articleID),\n\t\t\t\tTitle:    title,\n\t\t\t\tContent:  summary,\n\t\t\t\tLinks:    links,\n\t\t\t\tTags:     tags,\n\t\t\t\tChannel:  \"\",\n\t\t\t\tDatetime: publish,\n\t\t\t}\n\n\t\t\tmu.Lock()\n\t\t\tresults = append(results, result)\n\t\t\tmu.Unlock()\n\t\t}(title, detailURL, summary, tags, publishTime, articleID)\n\t})\n\n\twg.Wait()\n\n\treturn plugin.FilterResultsByKeyword(results, keyword), nil\n}\n\nfunc extractArticleID(detailURL string) string {\n\tif matches := articleIDRegex.FindStringSubmatch(detailURL); len(matches) >= 2 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\nfunc parsePublishTime(value string) time.Time {\n\tvalue = strings.TrimSpace(value)\n\tif value == \"\" {\n\t\treturn time.Now()\n\t}\n\n\tlayouts := []string{\n\t\t\"2006-01-02\",\n\t\t\"2006-01-02 15:04:05\",\n\t\ttime.RFC3339,\n\t}\n\n\tfor _, layout := range layouts {\n\t\tif t, err := time.Parse(layout, value); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\n\treturn time.Now()\n}\n\nfunc (p *YpfxwPlugin) fetchDetailLinks(client *http.Client, detailURL, articleID string) []model.Link {\n\tif cached, ok := detailCache.Load(articleID); ok {\n\t\tif entry, valid := cached.(cacheEntry); valid {\n\t\t\tif time.Now().Before(entry.expiresAt) && len(entry.links) > 0 {\n\t\t\t\treturn entry.links\n\t\t\t}\n\t\t\tdetailCache.Delete(articleID)\n\t\t}\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), detailTimeout)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tsetCommonHeaders(req, detailURL)\n\n\tresp, err := p.doRequestWithRetry(req, client, detailMaxRetries)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\tlinks := extractNetDiskLinks(doc)\n\tif len(links) > 0 {\n\t\tdetailCache.Store(articleID, cacheEntry{\n\t\t\tlinks:     links,\n\t\t\texpiresAt: time.Now().Add(cacheTTL),\n\t\t})\n\t}\n\treturn links\n}\n\nfunc extractNetDiskLinks(doc *goquery.Document) []model.Link {\n\tcontainer := doc.Find(\".article_content\")\n\tif container.Length() == 0 {\n\t\treturn nil\n\t}\n\n\tvar (\n\t\tresults []model.Link\n\t\tseen    = make(map[string]struct{})\n\t)\n\n\tcontainer.Find(\"a[href]\").Each(func(_ int, node *goquery.Selection) {\n\t\thref, ok := node.Attr(\"href\")\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\t\thref = strings.TrimSpace(href)\n\t\tif href == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tlinkType, normalized := classifyLink(href)\n\t\tif linkType == \"\" {\n\t\t\treturn\n\t\t}\n\t\tif _, exists := seen[normalized]; exists {\n\t\t\treturn\n\t\t}\n\n\t\tpassword := extractPassword(node)\n\n\t\tresults = append(results, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      normalized,\n\t\t\tPassword: password,\n\t\t})\n\t\tseen[normalized] = struct{}{}\n\t})\n\n\ttext := container.Text()\n\tresults = append(results, extractPlainTextLinks(text, seen)...)\n\n\treturn results\n}\n\nfunc extractPlainTextLinks(text string, seen map[string]struct{}) []model.Link {\n\tvar links []model.Link\n\tindices := urlRegex.FindAllStringIndex(text, -1)\n\tfor _, idx := range indices {\n\t\traw := text[idx[0]:idx[1]]\n\t\tlinkType, normalized := classifyLink(raw)\n\t\tif linkType == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := seen[normalized]; exists {\n\t\t\tcontinue\n\t\t}\n\n\t\tcontext := substringWithBounds(text, idx[0]-80, idx[1]+80)\n\t\tpassword := matchPassword(context)\n\n\t\tlinks = append(links, model.Link{\n\t\t\tType:     linkType,\n\t\t\tURL:      normalized,\n\t\t\tPassword: password,\n\t\t})\n\t\tseen[normalized] = struct{}{}\n\t}\n\treturn links\n}\n\nfunc substringWithBounds(text string, start, end int) string {\n\tif start < 0 {\n\t\tstart = 0\n\t}\n\tif end > len(text) {\n\t\tend = len(text)\n\t}\n\treturn text[start:end]\n}\n\nfunc classifyLink(raw string) (string, string) {\n\tfor _, pattern := range linkPatterns {\n\t\tif loc := pattern.reg.FindString(raw); loc != \"\" {\n\t\t\treturn pattern.typ, loc\n\t\t}\n\t}\n\treturn \"\", \"\"\n}\n\nfunc extractPassword(link *goquery.Selection) string {\n\tcandidates := []string{link.Text()}\n\n\tif title, ok := link.Attr(\"title\"); ok {\n\t\tcandidates = append(candidates, title)\n\t}\n\n\tif parent := link.Parent(); parent != nil && parent.Length() > 0 {\n\t\tcandidates = append(candidates, parent.Text())\n\t\tif next := parent.Next(); next.Length() > 0 {\n\t\t\tcandidates = append(candidates, next.Text())\n\t\t}\n\t}\n\n\tif next := link.Next(); next.Length() > 0 {\n\t\tcandidates = append(candidates, next.Text())\n\t}\n\n\tfor _, text := range candidates {\n\t\tif pwd := matchPassword(text); pwd != \"\" {\n\t\t\treturn pwd\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc matchPassword(text string) string {\n\ttext = strings.TrimSpace(text)\n\tif text == \"\" {\n\t\treturn \"\"\n\t}\n\tfor _, pattern := range pwdPatterns {\n\t\tif matches := pattern.FindStringSubmatch(text); len(matches) >= 2 {\n\t\t\treturn strings.TrimSpace(matches[1])\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc setCommonHeaders(req *http.Request, referer string) {\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", referer)\n}\n\nfunc (p *YpfxwPlugin) doRequestWithRetry(req *http.Request, client *http.Client, maxRetries int) (*http.Response, error) {\n\tvar lastErr error\n\n\tfor attempt := 0; attempt < maxRetries; attempt++ {\n\t\tresp, err := client.Do(req.Clone(req.Context()))\n\t\tif err == nil && resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t\tif attempt < maxRetries-1 {\n\t\t\tbackoff := retryBaseDelay * time.Duration(1<<attempt)\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"重试 %d 次后失败: %w\", maxRetries, lastErr)\n}\n\nfunc startCacheCleaner() {\n\tticker := time.NewTicker(cacheCleanupInterval)\n\tdefer ticker.Stop()\n\n\tfor range ticker.C {\n\t\tnow := time.Now()\n\t\tdetailCache.Range(func(key, value interface{}) bool {\n\t\t\tentry, ok := value.(cacheEntry)\n\t\t\tif !ok || now.After(entry.expiresAt) {\n\t\t\t\tdetailCache.Delete(key)\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "plugin/yuhuage/yuhuage.go",
    "content": "package yuhuage\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n)\n\nconst (\n\tBaseURL           = \"https://www.iyuhuage.fun\"\n\tSearchPath        = \"/search/\"\n\tUserAgent         = \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\"\n\tMaxConcurrency    = 5  // 详情页最大并发数\n\tMaxRetryCount     = 2  // 最大重试次数\n)\n\n// YuhuagePlugin 雨花阁插件\ntype YuhuagePlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tdebugMode    bool\n\tdetailCache  sync.Map // 缓存详情页结果\n\tcacheTTL     time.Duration\n\trateLimited  int32    // 429限流标志位\n}\n\nfunc init() {\n\tp := &YuhuagePlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter(\"yuhuage\", 3, true), \n\t\tdebugMode:       false,\n\t\tcacheTTL:        30 * time.Minute,\n\t}\n\tplugin.RegisterGlobalPlugin(p)\n}\n\n// Search 搜索接口实现\nfunc (p *YuhuagePlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *YuhuagePlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 搜索实现方法\nfunc (p *YuhuagePlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tif p.debugMode {\n\t\tlog.Printf(\"[YUHUAGE] 开始搜索: %s\", keyword)\n\t}\n\n\t// 检查限流状态\n\tif atomic.LoadInt32(&p.rateLimited) == 1 {\n\t\tif p.debugMode {\n\t\t\tlog.Printf(\"[YUHUAGE] 当前处于限流状态，跳过搜索\")\n\t\t}\n\t\treturn nil, fmt.Errorf(\"rate limited\")\n\t}\n\n\t// 构建搜索URL\n\tencodedQuery := url.QueryEscape(keyword)\n\tsearchURL := fmt.Sprintf(\"%s%s%s-%d-time.html\", BaseURL, SearchPath, encodedQuery, 1)\n\t\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\t\n\t// 创建请求对象\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 设置请求头\n\treq.Header.Set(\"User-Agent\", UserAgent)\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\t\n\t// 发送HTTP请求\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode == 429 {\n\t\tatomic.StoreInt32(&p.rateLimited, 1)\n\t\tgo func() {\n\t\t\ttime.Sleep(60 * time.Second)\n\t\t\tatomic.StoreInt32(&p.rateLimited, 0)\n\t\t}()\n\t\treturn nil, fmt.Errorf(\"[%s] 请求被限流\", p.Name())\n\t}\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] HTTP错误: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 读取响应\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 读取响应失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 解析搜索结果\n\tresults, err := p.parseSearchResults(string(body))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[YUHUAGE] 搜索完成，获得 %d 个结果\", len(results))\n\t}\n\n\t// 关键词过滤\n\treturn plugin.FilterResultsByKeyword(results, keyword), nil\n}\n\n// parseSearchResults 解析搜索结果\nfunc (p *YuhuagePlugin) parseSearchResults(html string) ([]model.SearchResult, error) {\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar results []model.SearchResult\n\tvar detailURLs []string\n\n\t// 提取搜索结果\n\tdoc.Find(\".search-item.detail-width\").Each(func(i int, s *goquery.Selection) {\n\t\ttitle := strings.TrimSpace(p.cleanTitle(s.Find(\".item-title h3 a\").Text()))\n\t\tdetailHref, exists := s.Find(\".item-title h3 a\").Attr(\"href\")\n\t\t\n\t\tif !exists || title == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tdetailURL := BaseURL + detailHref\n\t\tdetailURLs = append(detailURLs, detailURL)\n\n\t\t// 提取基本信息\n\t\tcreateTime := strings.TrimSpace(s.Find(\".item-bar span:contains('创建时间') b\").Text())\n\t\tsize := strings.TrimSpace(s.Find(\".item-bar .cpill.blue-pill\").Text())\n\t\tfileCount := strings.TrimSpace(s.Find(\".item-bar .cpill.yellow-pill\").Text())\n\t\thot := strings.TrimSpace(s.Find(\".item-bar span:contains('热度') b\").Text())\n\t\tlastDownload := strings.TrimSpace(s.Find(\".item-bar span:contains('最近下载') b\").Text())\n\n\t\t// 构建内容描述\n\t\tcontent := fmt.Sprintf(\"创建时间: %s | 大小: %s | 文件数: %s | 热度: %s\", \n\t\t\tcreateTime, size, fileCount, hot)\n\t\tif lastDownload != \"\" {\n\t\t\tcontent += fmt.Sprintf(\" | 最近下载: %s\", lastDownload)\n\t\t}\n\n\t\tresult := model.SearchResult{\n\t\t\tTitle:     title,\n\t\t\tContent:   content,\n\t\t\tChannel:   \"\", // 插件搜索结果必须为空字符串\n\t\t\tTags:      []string{\"磁力链接\"},\n\t\t\tDatetime:  p.parseDateTime(createTime),\n\t\t\tUniqueID:  fmt.Sprintf(\"%s-%s\", p.Name(), p.extractHashFromURL(detailURL)),\n\t\t}\n\n\t\tresults = append(results, result)\n\t})\n\n\tif p.debugMode {\n\t\tlog.Printf(\"[YUHUAGE] 解析到 %d 个搜索结果，准备获取详情\", len(results))\n\t}\n\n\t// 同步获取详情页链接\n\tp.fetchDetailsSync(detailURLs, results)\n\n\treturn results, nil\n}\n\n// fetchDetailsSync 同步获取详情页信息\nfunc (p *YuhuagePlugin) fetchDetailsSync(detailURLs []string, results []model.SearchResult) {\n\tif len(detailURLs) == 0 {\n\t\treturn\n\t}\n\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\tvar wg sync.WaitGroup\n\n\tfor i, detailURL := range detailURLs {\n\t\tif i >= len(results) {\n\t\t\tbreak\n\t\t}\n\n\t\twg.Add(1)\n\t\tgo func(url string, result *model.SearchResult) {\n\t\t\tdefer wg.Done()\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\n\t\t\t\t\tlinks := p.fetchDetailLinks(url)\n\t\tif len(links) > 0 {\n\t\t\tresult.Links = links\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[YUHUAGE] 为结果设置了 %d 个链接\", len(links))\n\t\t\t}\n\t\t} else if p.debugMode {\n\t\t\tlog.Printf(\"[YUHUAGE] 详情页没有找到有效链接: %s\", url)\n\t\t}\n\t\t}(detailURL, &results[i])\n\t}\n\n\twg.Wait()\n\tif p.debugMode {\n\t\tlog.Printf(\"[YUHUAGE] 详情页获取完成\")\n\t}\n}\n\n// fetchDetailLinks 获取详情页链接\nfunc (p *YuhuagePlugin) fetchDetailLinks(detailURL string) []model.Link {\n\t// 检查缓存\n\tif cached, exists := p.detailCache.Load(detailURL); exists {\n\t\tif links, ok := cached.([]model.Link); ok {\n\t\t\treturn links\n\t\t}\n\t}\n\n\tclient := &http.Client{Timeout: 15 * time.Second}\n\t\n\tfor retry := 0; retry <= MaxRetryCount; retry++ {\n\t\treq, err := http.NewRequest(\"GET\", detailURL, nil)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\treq.Header.Set(\"User-Agent\", UserAgent)\n\t\treq.Header.Set(\"Referer\", BaseURL+\"/\")\n\t\t\n\t\tresp, err := client.Do(req)\n\t\tif err != nil {\n\t\t\tif retry < MaxRetryCount {\n\t\t\t\ttime.Sleep(time.Duration(retry+1) * time.Second)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\t\n\t\tif resp.StatusCode != 200 {\n\t\t\tresp.Body.Close()\n\t\t\tif retry < MaxRetryCount {\n\t\t\t\ttime.Sleep(time.Duration(retry+1) * time.Second)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\t\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tresp.Body.Close()\n\t\t\n\t\tif err != nil {\n\t\t\tif retry < MaxRetryCount {\n\t\t\t\ttime.Sleep(time.Duration(retry+1) * time.Second)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\t\n\t\tlinks := p.parseDetailLinks(string(body))\n\t\t\n\t\t// 缓存结果\n\t\tif len(links) > 0 {\n\t\t\tp.detailCache.Store(detailURL, links)\n\t\t\t// 设置缓存过期\n\t\t\tgo func() {\n\t\t\t\ttime.Sleep(p.cacheTTL)\n\t\t\t\tp.detailCache.Delete(detailURL)\n\t\t\t}()\n\t\t}\n\t\t\n\t\treturn links\n\t}\n\t\n\treturn nil\n}\n\n// parseDetailLinks 解析详情页链接\nfunc (p *YuhuagePlugin) parseDetailLinks(html string) []model.Link {\n\tvar links []model.Link\n\t\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\tif err != nil {\n\t\treturn links\n\t}\n\t\n\t// 提取磁力链接\n\tdoc.Find(\"a.download[href^='magnet:']\").Each(func(i int, s *goquery.Selection) {\n\t\thref, exists := s.Attr(\"href\")\n\t\tif exists && href != \"\" {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[YUHUAGE] 找到磁力链接: %s\", href)\n\t\t\t}\n\t\t\tlinks = append(links, model.Link{\n\t\t\t\tURL:  href,\n\t\t\t\tType: \"magnet\",\n\t\t\t})\n\t\t}\n\t})\n\t\n\t// 提取迅雷链接\n\tdoc.Find(\"a.download[href^='thunder:']\").Each(func(i int, s *goquery.Selection) {\n\t\thref, exists := s.Attr(\"href\")\n\t\tif exists && href != \"\" {\n\t\t\tif p.debugMode {\n\t\t\t\tlog.Printf(\"[YUHUAGE] 找到迅雷链接: %s\", href)\n\t\t\t}\n\t\t\tlinks = append(links, model.Link{\n\t\t\t\tURL:  href,\n\t\t\t\tType: \"others\",\n\t\t\t})\n\t\t}\n\t})\n\t\n\tif p.debugMode && len(links) > 0 {\n\t\tlog.Printf(\"[YUHUAGE] 从详情页解析到 %d 个链接\", len(links))\n\t}\n\t\n\treturn links\n}\n\n// extractHashFromURL 从URL中提取哈希ID\nfunc (p *YuhuagePlugin) extractHashFromURL(detailURL string) string {\n\tre := regexp.MustCompile(`/hash/(\\d+)\\.html`)\n\tmatches := re.FindStringSubmatch(detailURL)\n\tif len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// cleanTitle 清理标题\nfunc (p *YuhuagePlugin) cleanTitle(title string) string {\n\ttitle = strings.TrimSpace(title)\n\t// 移除HTML标签（如<b>标签）\n\tre := regexp.MustCompile(`<[^>]*>`)\n\ttitle = re.ReplaceAllString(title, \"\")\n\t// 移除多余的空格\n\tre = regexp.MustCompile(`\\s+`)\n\ttitle = re.ReplaceAllString(title, \" \")\n\treturn strings.TrimSpace(title)\n}\n\n// parseDateTime 解析时间字符串\nfunc (p *YuhuagePlugin) parseDateTime(timeStr string) time.Time {\n\tif timeStr == \"\" {\n\t\treturn time.Time{}\n\t}\n\t\n\t// 尝试不同的时间格式\n\tformats := []string{\n\t\t\"2006-01-02 15:04:05\",\n\t\t\"2006-01-02\",\n\t\t\"2006/01/02 15:04:05\",\n\t\t\"2006/01/02\",\n\t}\n\t\n\tfor _, format := range formats {\n\t\tif t, err := time.Parse(format, timeStr); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\t\n\treturn time.Time{}\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *YuhuagePlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}"
  },
  {
    "path": "plugin/yunsou/html结构分析.md",
    "content": "# 云搜影视 (yunsou) 网站搜索结果HTML结构分析\n\n## 网站信息\n\n- **网站名称**: 云搜影视\n- **域名**: `yunsou.xyz`\n- **搜索URL格式**: `https://yunsou.xyz/s/{关键词}.html`\n- **主要特点**: 提供网盘资源搜索，支持夸克、百度、UC、迅雷、阿里云盘等多种网盘\n\n## 搜索源类型\n\n云搜影视提供两种搜索源：\n1. **本地搜** (currentSource=0): 结果直接内嵌在HTML中\n2. **全网搜** (currentSource=1): 通过SSE流式接口获取\n\n本插件实现**本地搜**功能，因为：\n- 结果更稳定可靠\n- 响应速度更快\n- 数据格式规范\n\n## HTML结构\n\n### 搜索结果页面结构\n\n搜索结果直接内嵌在HTML的JavaScript代码中，以JSON格式存储：\n\n```html\n<script type=\"text/javascript\" charset=\"utf-8\">\n    function linkBtn(element) {\n        const index = element.getAttribute('data-index');\n        var jsonData = '[{\"id\":51199,\"source_category_id\":3,\"title\":\"凡人修仙传 真人版 [2025][奇幻 古装 大陆][杨洋 金晨]\",\"is_type\":4,\"code\":null,\"url\":\"https://pan.xunlei.com/s/VOW9WQT6nyFBDjHwYjjGj13YA1?pwd=v2m9\",\"is_time\":0,\"name\":\"凡人修仙传 真人版 [2025][奇幻 古装 大陆][杨洋 金晨]\",\"times\":\"2025-07-27\",\"category\":{\"source_category_id\":3,\"name\":\"电视剧\"}},...]';\n        // ...\n    }\n</script>\n```\n\n### JSON数据结构\n\n每个搜索结果项的JSON结构如下：\n\n```json\n{\n  \"id\": 51199,\n  \"source_category_id\": 3,\n  \"title\": \"凡人修仙传 真人版 [2025][奇幻 古装 大陆][杨洋 金晨]\",\n  \"is_type\": 4,\n  \"code\": null,\n  \"url\": \"https://pan.xunlei.com/s/VOW9WQT6nyFBDjHwYjjGj13YA1?pwd=v2m9\",\n  \"is_time\": 0,\n  \"name\": \"凡人修仙传 真人版 [2025][奇幻 古装 大陆][杨洋 金晨]\",\n  \"times\": \"2025-07-27\",\n  \"category\": {\n    \"source_category_id\": 3,\n    \"name\": \"电视剧\"\n  }\n}\n```\n\n#### JSON字段说明\n\n| 字段 | 类型 | 说明 | 示例 |\n|------|------|------|------|\n| `id` | number | 资源唯一ID | 51199 |\n| `title` | string | 资源标题 | \"凡人修仙传 真人版 [2025][奇幻 古装 大陆][杨洋 金晨]\" |\n| `is_type` | number | 网盘类型标识 | 0=夸克, 1=阿里, 2=百度, 3=UC, 4=迅雷 |\n| `code` | string\\|null | 提取码（可能为null） | \"v2m9\" 或 null |\n| `url` | string | 网盘链接 | \"https://pan.xunlei.com/s/...\" |\n| `times` | string | 发布时间 | \"2025-07-27\" |\n| `category.name` | string | 资源分类 | \"电视剧\", \"动漫\", \"电影\"等 |\n\n#### 网盘类型映射 (is_type)\n\n```\n0 -> 夸克网盘 (quark)\n1 -> 阿里云盘 (aliyun)\n2 -> 百度网盘 (baidu)\n3 -> UC网盘 (uc)\n4 -> 迅雷网盘 (xunlei)\n```\n\n### HTML展示结构\n\n虽然数据在JS中，HTML中也有对应的展示结构：\n\n```html\n<div class=\"list\">\n    <div class=\"item\">\n        <a href=\"javascript:;\" onclick=\"linkBtn(this)\" data-index=\"0\" class=\"title\">\n            凡人修仙传 真人版 [2025][奇幻 古装 大陆][杨洋 金晨]\n        </a>\n        <div class=\"type cate\">分类：电视剧</div>\n        <div class=\"type time\">2025-07-27</div>\n        <div class=\"type\">\n            <span>来源：迅雷网盘</span>\n        </div>\n        <div class=\"btns\">\n            <div class=\"btn\">复制分享</div>\n            <a href=\"/d/51199.html\" class=\"btn\">查看详情</a>\n            <a href=\"javascript:;\" onclick=\"linkBtn(this)\" data-index=\"0\" class=\"btn\">立即访问</a>\n        </div>\n    </div>\n</div>\n```\n\n## 提取逻辑\n\n### 1. 搜索结果提取流程\n\n```\n1. 发送GET请求到搜索URL\n   ├─ URL: https://yunsou.xyz/s/{URL编码的关键词}.html\n   └─ 设置完整的浏览器请求头\n\n2. 解析HTML响应\n   ├─ 查找包含 \"var jsonData = \" 的script标签\n   └─ 提取JSON字符串\n\n3. 清理并解析JSON\n   ├─ 移除控制字符和转义\n   ├─ 解析为结构化数据\n   └─ 处理异常数据\n\n4. 转换为SearchResult格式\n   ├─ 生成UniqueID: \"yunsou-{id}\"\n   ├─ 设置标题、内容、时间\n   ├─ 根据is_type确定网盘类型\n   ├─ 构建Link对象（包含URL和提取码）\n   └─ 添加分类标签\n\n5. 关键词过滤\n   └─ 使用FilterResultsByKeyword过滤结果\n```\n\n### 2. JSON提取正则表达式\n\n```go\n// 提取JSON数据的正则表达式\nvar jsonData = '[...]';\n```\n\n匹配模式：\n- 查找 `var jsonData = '` 开头\n- 提取单引号内的完整JSON字符串\n- 处理转义字符和特殊字符\n\n### 3. 网盘链接处理\n\n#### 提取码处理\n\n提取码可能存在于两个位置：\n1. **JSON中的code字段**: 单独的提取码字段\n2. **URL中的pwd参数**: `?pwd=xxxx` 格式\n\n处理逻辑：\n```go\npassword := \"\"\nif code != nil && *code != \"\" {\n    password = *code\n} else if strings.Contains(url, \"?pwd=\") {\n    // 从URL中提取pwd参数\n    password = extractPwdFromURL(url)\n}\n```\n\n#### 网盘类型转换\n\n```go\nfunc convertNetDiskType(isType int) string {\n    switch isType {\n    case 0:\n        return \"quark\"   // 夸克网盘\n    case 1:\n        return \"aliyun\"  // 阿里云盘\n    case 2:\n        return \"baidu\"   // 百度网盘\n    case 3:\n        return \"uc\"      // UC网盘\n    case 4:\n        return \"xunlei\"  // 迅雷网盘\n    default:\n        return \"others\"\n    }\n}\n```\n\n### 4. 时间格式转换\n\n时间格式为 \"2025-07-27\"，需要解析为 time.Time：\n\n```go\n// 解析时间字符串\nconst timeLayout = \"2006-01-02\"\nparsedTime, err := time.Parse(timeLayout, times)\nif err != nil {\n    parsedTime = time.Now() // 解析失败使用当前时间\n}\n```\n\n### 5. 内容构建\n\n内容字段组合多个信息：\n\n```go\ncontentParts := []string{}\nif category != \"\" {\n    contentParts = append(contentParts, \"【\"+category+\"】\")\n}\n// 可以添加更多描述信息\ncontent := strings.Join(contentParts, \" \")\n```\n\n## 实现要点\n\n### 1. HTTP请求\n\n必须设置完整的浏览器请求头：\n\n```go\nreq.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\")\nreq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\nreq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\nreq.Header.Set(\"Connection\", \"keep-alive\")\nreq.Header.Set(\"Referer\", \"https://yunsou.xyz/\")\n```\n\n### 2. JSON提取\n\n需要处理的特殊情况：\n- 单引号包裹的JSON字符串\n- 转义的双引号 `\\\"`\n- 可能存在的控制字符（`\\x00-\\x1F`, `\\x7F`）\n- Unicode转义序列\n\n清理代码：\n```go\n// 移除控制字符\njsonStr = regexp.MustCompile(`[\\x00-\\x1F\\x7F]`).ReplaceAllString(jsonStr, \"\")\n\n// 处理转义\njsonStr = strings.ReplaceAll(jsonStr, `\\\"`, `\"`)\njsonStr = strings.ReplaceAll(jsonStr, `\\/`, `/`)\n```\n\n### 3. 错误处理\n\n需要处理的错误情况：\n- 网络请求失败\n- HTTP状态码非200\n- 未找到JSON数据\n- JSON解析失败\n- 空结果集\n\n### 4. 数据验证\n\n在添加到结果前验证：\n- UniqueID 不为空\n- Title 不为空\n- URL 是有效的网盘链接\n- Links 数组不为空（系统会自动过滤无链接结果）\n\n## 注意事项\n\n1. **URL编码**: 关键词必须进行URL编码\n2. **中文支持**: 确保正确处理UTF-8编码\n3. **提取码位置**: 优先使用code字段，其次从URL提取\n4. **时间解析**: 处理时间解析失败的情况\n5. **空值处理**: code字段可能为null，需要类型断言\n6. **链接验证**: 确保网盘链接格式正确\n7. **插件规范**: \n   - Channel字段必须为空字符串\n   - Links不能为空（会被系统过滤）\n   - 使用FilterResultsByKeyword进行关键词过滤\n\n## 示例代码片段\n\n### JSON数据提取\n\n```go\nimport (\n    \"regexp\"\n    \"strings\"\n)\n\n// 提取JSON数据\nfunc extractJSONData(htmlContent string) (string, error) {\n    // 查找 var jsonData = '...'\n    pattern := regexp.MustCompile(`var jsonData = '(.+?)';`)\n    matches := pattern.FindStringSubmatch(htmlContent)\n    \n    if len(matches) < 2 {\n        return \"\", fmt.Errorf(\"未找到JSON数据\")\n    }\n    \n    jsonStr := matches[1]\n    \n    // 清理控制字符\n    jsonStr = regexp.MustCompile(`[\\x00-\\x1F\\x7F]`).ReplaceAllString(jsonStr, \"\")\n    \n    // 处理转义字符\n    jsonStr = strings.ReplaceAll(jsonStr, `\\\\/`, `/`)\n    jsonStr = strings.ReplaceAll(jsonStr, `\\\\\"`, `\"`)\n    \n    return jsonStr, nil\n}\n```\n\n### 网盘链接构建\n\n```go\n// 构建Link对象\nfunc buildLink(item YunsouItem) model.Link {\n    link := model.Link{\n        Type: convertNetDiskType(item.IsType),\n        URL:  item.URL,\n    }\n    \n    // 处理提取码\n    if item.Code != nil && *item.Code != \"\" {\n        link.Password = *item.Code\n    } else if strings.Contains(item.URL, \"?pwd=\") {\n        link.Password = extractPwdFromURL(item.URL)\n    }\n    \n    return link\n}\n```\n\n## 优先级建议\n\n根据云搜影视的特点，建议设置优先级为 **2**：\n- ✅ 数据源质量良好，资源较新\n- ✅ 支持多种网盘类型\n- ✅ 响应速度较快\n- ✅ 数据格式规范，易于解析\n- ⚠️ 作为聚合搜索站点，可能有少量失效链接\n\n## 相关链接\n\n- 搜索示例: `https://yunsou.xyz/s/凡人修仙传.html`\n- 详情页示例: `https://yunsou.xyz/d/51199.html` (可选，插件不需要访问)\n\n"
  },
  {
    "path": "plugin/yunsou/yunsou.go",
    "content": "package yunsou\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n)\n\n// 预编译的正则表达式\nvar (\n\t// 提取JSON数据的正则表达式\n\tjsonDataRegex = regexp.MustCompile(`var jsonData = '(.+?)';`)\n\t\n\t// 提取pwd参数的正则表达式\n\tpwdParamRegex = regexp.MustCompile(`[?&]pwd=([0-9a-zA-Z]+)`)\n\t\n\t// 控制字符清理正则\n\tcontrolCharsRegex = regexp.MustCompile(`[\\x00-\\x1F\\x7F]`)\n)\n\n// 常量定义\nconst (\n\t// 插件名称\n\tpluginName = \"yunsou\"\n\t\n\t// 搜索URL模板\n\tsearchURLTemplate = \"https://yunsou.xyz/s/%s.html\"\n\t\n\t// 默认优先级\n\tdefaultPriority = 2\n\t\n\t// 默认超时时间\n\tdefaultTimeout = 30 * time.Second\n\t\n\t// 最大重试次数\n\tmaxRetries = 3\n\t\n\t// 时间格式\n\ttimeLayout = \"2006-01-02\"\n)\n\n// YunsouAsyncPlugin 是云搜影视网站的异步搜索插件实现\ntype YunsouAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// YunsouCategory 分类信息\ntype YunsouCategory struct {\n\tSourceCategoryID int    `json:\"source_category_id\"`\n\tName             string `json:\"name\"`\n}\n\n// YunsouItem 单个搜索结果项\ntype YunsouItem struct {\n\tID               int             `json:\"id\"`\n\tSourceCategoryID int             `json:\"source_category_id\"`\n\tTitle            string          `json:\"title\"`\n\tIsType           int             `json:\"is_type\"`        // 0=夸克, 1=阿里, 2=百度, 3=UC, 4=迅雷\n\tCode             *string         `json:\"code\"`           // 提取码，可能为null\n\tURL              string          `json:\"url\"`\n\tIsTime           int             `json:\"is_time\"`\n\tName             string          `json:\"name\"`\n\tTimes            string          `json:\"times\"`          // 发布时间 \"2025-07-27\"\n\tCategory         YunsouCategory  `json:\"category\"`\n}\n\n// 确保YunsouAsyncPlugin实现了AsyncSearchPlugin接口\nvar _ plugin.AsyncSearchPlugin = (*YunsouAsyncPlugin)(nil)\n\n// init 在包初始化时注册插件\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewYunsouAsyncPlugin())\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        100,\n\t\tMaxIdleConnsPerHost: 20,\n\t\tMaxConnsPerHost:     50,\n\t\tIdleConnTimeout:     90 * time.Second,\n\t\tDisableKeepAlives:   false,\n\t}\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   defaultTimeout,\n\t}\n}\n\n// NewYunsouAsyncPlugin 创建一个新的云搜影视异步插件实例\nfunc NewYunsouAsyncPlugin() *YunsouAsyncPlugin {\n\treturn &YunsouAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Search 执行搜索并返回结果（兼容性方法）\nfunc (p *YunsouAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 执行搜索并返回包含IsFinal标记的结果\nfunc (p *YunsouAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实现具体的搜索逻辑\nfunc (p *YunsouAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 1. 构建搜索URL\n\tsearchURL := fmt.Sprintf(searchURLTemplate, url.QueryEscape(keyword))\n\t\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)\n\tdefer cancel()\n\t\n\t// 3. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 4. 设置完整的请求头（避免反爬虫）\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Referer\", \"https://yunsou.xyz/\")\n\t\n\t// 5. 发送请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\t// 6. 读取响应内容\n\tbodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 读取响应失败: %w\", p.Name(), err)\n\t}\n\t\n\thtmlContent := string(bodyBytes)\n\t\n\t// 7. 提取JSON数据\n\tjsonStr, err := p.extractJSONData(htmlContent)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 提取JSON数据失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 8. 解析JSON数据\n\tvar items []YunsouItem\n\tif err := json.Unmarshal([]byte(jsonStr), &items); err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析JSON失败: %w\", p.Name(), err)\n\t}\n\t\n\t// 9. 转换为标准格式\n\tresults := make([]model.SearchResult, 0, len(items))\n\tfor _, item := range items {\n\t\tresult := p.convertToSearchResult(item)\n\t\tif result.UniqueID != \"\" && len(result.Links) > 0 {\n\t\t\tresults = append(results, result)\n\t\t}\n\t}\n\t\n\t// 10. 关键词过滤\n\treturn plugin.FilterResultsByKeyword(results, keyword), nil\n}\n\n// extractJSONData 从HTML中提取JSON数据\nfunc (p *YunsouAsyncPlugin) extractJSONData(htmlContent string) (string, error) {\n\t// 查找 var jsonData = '...'\n\tmatches := jsonDataRegex.FindStringSubmatch(htmlContent)\n\tif len(matches) < 2 {\n\t\treturn \"\", fmt.Errorf(\"未找到JSON数据\")\n\t}\n\t\n\tjsonStr := matches[1]\n\t\n\t// 清理控制字符\n\tjsonStr = controlCharsRegex.ReplaceAllString(jsonStr, \"\")\n\t\n\t// 处理转义字符\n\tjsonStr = strings.ReplaceAll(jsonStr, `\\/`, `/`)\n\t\n\treturn jsonStr, nil\n}\n\n// convertToSearchResult 将YunsouItem转换为SearchResult\nfunc (p *YunsouAsyncPlugin) convertToSearchResult(item YunsouItem) model.SearchResult {\n\tresult := model.SearchResult{\n\t\tUniqueID: fmt.Sprintf(\"%s-%d\", p.Name(), item.ID),\n\t\tTitle:    item.Title,\n\t\tChannel:  \"\", // 插件搜索结果必须为空字符串\n\t}\n\t\n\t// 解析时间\n\tif item.Times != \"\" {\n\t\tif parsedTime, err := time.Parse(timeLayout, item.Times); err == nil {\n\t\t\tresult.Datetime = parsedTime\n\t\t} else {\n\t\t\tresult.Datetime = time.Now()\n\t\t}\n\t} else {\n\t\tresult.Datetime = time.Now()\n\t}\n\t\n\t// 构建内容描述\n\tvar contentParts []string\n\tif item.Category.Name != \"\" {\n\t\tcontentParts = append(contentParts, \"【\"+item.Category.Name+\"】\")\n\t}\n\tresult.Content = strings.Join(contentParts, \" \")\n\t\n\t// 添加分类标签\n\tif item.Category.Name != \"\" {\n\t\tresult.Tags = []string{item.Category.Name}\n\t}\n\t\n\t// 构建网盘链接\n\tif item.URL != \"\" {\n\t\tlink := model.Link{\n\t\t\tType: p.convertNetDiskType(item.IsType),\n\t\t\tURL:  item.URL,\n\t\t}\n\t\t\n\t\t// 处理提取码\n\t\tif item.Code != nil && *item.Code != \"\" {\n\t\t\tlink.Password = *item.Code\n\t\t} else if strings.Contains(item.URL, \"?pwd=\") {\n\t\t\tlink.Password = p.extractPwdFromURL(item.URL)\n\t\t}\n\t\t\n\t\tresult.Links = []model.Link{link}\n\t}\n\t\n\treturn result\n}\n\n// convertNetDiskType 将is_type转换为网盘类型标识\nfunc (p *YunsouAsyncPlugin) convertNetDiskType(isType int) string {\n\tswitch isType {\n\tcase 0:\n\t\treturn \"quark\" // 夸克网盘\n\tcase 1:\n\t\treturn \"aliyun\" // 阿里云盘\n\tcase 2:\n\t\treturn \"baidu\" // 百度网盘\n\tcase 3:\n\t\treturn \"uc\" // UC网盘\n\tcase 4:\n\t\treturn \"xunlei\" // 迅雷网盘\n\tdefault:\n\t\treturn \"others\"\n\t}\n}\n\n// extractPwdFromURL 从URL中提取pwd参数\nfunc (p *YunsouAsyncPlugin) extractPwdFromURL(urlStr string) string {\n\tmatches := pwdParamRegex.FindStringSubmatch(urlStr)\n\tif len(matches) >= 2 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *YunsouAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避重试\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\t// 克隆请求避免并发问题\n\t\treqClone := req.Clone(req.Context())\n\t\t\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n\n"
  },
  {
    "path": "plugin/zhizhen/json结构分析.md",
    "content": "# Zhizhen HTML 数据结构分析\n\n## 基本信息\n- **数据源类型**: HTML 网页\n- **搜索URL格式**: `https://xiaomi666.fun/index.php/vod/search/wd/{关键词}.html`\n- **详情URL格式**: `https://xiaomi666.fun/index.php/vod/detail/id/{资源ID}.html`\n- **数据特点**: 视频点播(VOD)系统网页，提供HTML格式的影视资源数据\n- **特殊说明**: 使用独立域名，HTML结构与muou插件相同\n\n## HTML 页面结构\n\n### 搜索结果页面 (`.module-search-item`)\n搜索结果页面包含多个搜索项，每个搜索项的HTML结构如下：\n\n```html\n<div class=\"module-search-item\">\n    <div class=\"module-item-pic\">\n        <img data-src=\"https://...\" />\n    </div>\n    <div class=\"video-info-header\">\n        <h3>\n            <a href=\"/index.php/vod/detail/id/12345.html\">资源标题</a>\n        </h3>\n    </div>\n    <div class=\"video-serial\">更新至11集</div>\n    <div class=\"video-info-aux\">\n        <span class=\"tag-link\">\n            <a>分类1</a>\n            <a>分类2</a>\n        </span>\n    </div>\n    <div class=\"video-info-items\">\n        <div>\n            <span class=\"video-info-itemtitle\">导演：</span>\n            <a class=\"video-info-actor\">导演名</a>\n        </div>\n        <div>\n            <span class=\"video-info-itemtitle\">主演：</span>\n            <a class=\"video-info-actor\">演员1</a>\n            <a class=\"video-info-actor\">演员2</a>\n        </div>\n        <div>\n            <span class=\"video-info-itemtitle\">剧情：</span>\n            <span class=\"video-info-item\">剧情简介内容</span>\n        </div>\n    </div>\n</div>\n```\n\n### 详情页面 (`.module-row-one`)\n详情页面包含下载链接区域，每个链接的HTML结构如下：\n\n```html\n<div id=\"download-list\">\n    <div class=\"module-row-one\">\n        <button data-clipboard-text=\"https://pan.quark.cn/s/xxx\">复制链接</button>\n        <a href=\"https://pan.quark.cn/s/xxx\">打开链接</a>\n    </div>\n</div>\n```\n\n## 插件所需字段映射\n\n| 源字段 | 目标字段 | 说明 |\n|--------|----------|------|\n| 详情页URL中的ID | `UniqueID` | 格式: `zhizhen-{id}` |\n| `.video-info-header h3 a` 文本 | `Title` | 资源标题 |\n| 质量、导演、主演、剧情 | `Content` | 组合描述信息 |\n| `.video-info-aux .tag-link a` | `Tags` | 标签数组 |\n| 详情页 `#download-list` 中的链接 | `Links` | 解析为Link数组 |\n| `.module-item-pic > img` 的 `data-src` | `Images` | 封面图片 |\n| `\"\"` | `Channel` | 插件搜索结果Channel为空 |\n| `time.Time{}` | `Datetime` | 使用零值 |\n\n## 下载链接解析\n\n### 链接提取方式\n- **从 `data-clipboard-text` 属性**: 优先从按钮的 `data-clipboard-text` 属性提取链接\n- **从 `href` 属性**: 如果没有 `data-clipboard-text`，则从 `<a>` 标签的 `href` 属性提取\n- **去重处理**: 避免重复添加相同的链接\n\n### 链接类型识别\n通过正则表达式匹配URL来自动识别网盘类型，支持16种网盘类型：\n\n```go\n// 主流网盘\nquark:      https://pan.quark.cn/s/...\nbaidu:      https://pan.baidu.com/s/...?pwd=...\naliyun:     https://aliyundrive.com/s/... 或 https://www.alipan.com/s/...\nuc:         https://drive.uc.cn/s/...\nxunlei:     https://pan.xunlei.com/s/...\n\n// 运营商网盘\ntianyi:     https://cloud.189.cn/t/...\nmobile:     https://caiyun.feixin.10086.cn/...\n\n// 专业网盘\n115:        https://115.com/s/...\nweiyun:     https://share.weiyun.com/...\nlanzou:     https://lanzou.com/... 或其他变体\njianguoyun: https://jianguoyun.com/p/...\n123:        https://123pan.com/s/...\npikpak:     https://mypikpak.com/s/...\n\n// 其他协议\nmagnet:     magnet:?xt=urn:btih:...\ned2k:       ed2k://|file|...|\n```\n\n### 密码提取\n从URL中提取 `?pwd=` 参数作为密码，例如：\n```\nhttps://pan.baidu.com/s/1kOWHnazfGFe6wJ-tin2pNQ?pwd=b2s4\n提取密码: b2s4\n```\n\n## 支持的网盘类型（16种）\n\n### 主流网盘\n- **baidu (百度网盘)**: `https://pan.baidu.com/s/{分享码}?pwd={密码}`\n- **quark (夸克网盘)**: `https://pan.quark.cn/s/{分享码}`\n- **aliyun (阿里云盘)**: `https://aliyundrive.com/s/{分享码}`, `https://www.alipan.com/s/{分享码}`\n- **uc (UC网盘)**: `https://drive.uc.cn/s/{分享码}`\n- **xunlei (迅雷网盘)**: `https://pan.xunlei.com/s/{分享码}`\n\n### 运营商网盘\n- **tianyi (天翼云盘)**: `https://cloud.189.cn/t/{分享码}`\n- **mobile (移动网盘)**: `https://caiyun.feixin.10086.cn/{分享码}`\n\n### 专业网盘\n- **115 (115网盘)**: `https://115.com/s/{分享码}`\n- **weiyun (微云)**: `https://share.weiyun.com/{分享码}`\n- **lanzou (蓝奏云)**: `https://lanzou.com/{分享码}`\n- **jianguoyun (坚果云)**: `https://jianguoyun.com/{分享码}`\n- **123 (123网盘)**: `https://123pan.com/s/{分享码}`\n- **pikpak (PikPak)**: `https://mypikpak.com/s/{分享码}`\n\n### 其他协议\n- **magnet (磁力链接)**: `magnet:?xt=urn:btih:{hash}`\n- **ed2k (电驴链接)**: `ed2k://|file|{filename}|{size}|{hash}|/`\n- **others (其他类型)**: 其他不在上述分类中的链接\n\n## 插件开发指导\n\n### 搜索请求示例\n```go\nsearchURL := fmt.Sprintf(\"https://xiaomi666.fun/index.php/vod/search/wd/%s.html\", url.QueryEscape(keyword))\n```\n\n### 详情页请求示例\n```go\ndetailURL := fmt.Sprintf(\"https://xiaomi666.fun/index.php/vod/detail/id/%s.html\", itemID)\n```\n\n### HTML解析流程\n1. **搜索页面解析**: 使用 goquery 解析搜索结果页面\n2. **提取搜索项**: 遍历 `.module-search-item` 元素\n3. **提取基本信息**: 从搜索项中提取标题、分类、导演、主演等\n4. **异步获取详情**: 并发请求详情页面获取下载链接\n5. **缓存管理**: 使用 sync.Map 缓存详情页结果，TTL为1小时\n\n### SearchResult构建示例\n```go\nresult := model.SearchResult{\n    UniqueID: fmt.Sprintf(\"zhizhen-%s\", itemID),\n    Title:    title,\n    Content:  strings.Join(contentParts, \"\\n\"),\n    Links:    detailLinks,\n    Tags:     tags,\n    Images:   images,\n    Channel:  \"\", // 插件搜索结果Channel为空\n    Datetime: time.Time{}, // 使用零值\n}\n```\n\n### 并发控制\n- **最大并发数**: 20 (MaxConcurrency)\n- **搜索超时**: 8秒 (DefaultTimeout)\n- **详情页超时**: 6秒 (DetailTimeout)\n- **缓存TTL**: 1小时 (cacheTTL)\n\n## 与其他插件的差异\n\n| 特性 | zhizhen | muou | 说明 |\n|------|---------|------|------|\n| **域名** | `xiaomi666.fun` | `666.666291.xyz` | 不同域名 |\n| **数据格式** | HTML | HTML | 都是HTML格式 |\n| **HTML结构** | 相同 | 相同 | 使用相同的CSS选择器 |\n| **并发数** | 20 | 20 | 相同 |\n| **缓存TTL** | 1小时 | 1小时 | 相同 |\n\n## 注意事项\n1. **HTML解析**: 使用 goquery 库进行HTML解析\n2. **异步获取详情**: 搜索结果只包含基本信息，需要异步请求详情页获取下载链接\n3. **并发控制**: 使用信号量限制并发数为20\n4. **缓存管理**: 使用 sync.Map 缓存详情页结果，避免重复请求\n5. **链接验证**: 过滤掉无效链接（如包含`javascript:`、`#`等）\n6. **密码提取**: 从URL中提取 `?pwd=` 参数作为密码\n7. **去重处理**: 避免在详情页中重复添加相同的链接\n\n## 开发建议\n- **参考muou插件**: zhizhen的HTML结构与muou完全相同，可以直接参考muou的实现\n- **关键差异**: 仅需修改域名和插件名称\n- **测试覆盖**: 重点测试多种网盘类型的链接解析和缓存功能\n- **性能优化**: 使用并发请求详情页，提高搜索速度"
  },
  {
    "path": "plugin/zhizhen/zhizhen.go",
    "content": "package zhizhen\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\nconst (\n\t// 默认超时时间 - 优化为更短时间\n\tDefaultTimeout = 8 * time.Second\n\tDetailTimeout  = 6 * time.Second\n\n\t// 并发数限制 - 大幅提高并发数\n\tMaxConcurrency = 20\n\n\t// HTTP连接池配置\n\tMaxIdleConns        = 200\n\tMaxIdleConnsPerHost = 50\n\tMaxConnsPerHost     = 100\n\tIdleConnTimeout     = 90 * time.Second\n\n\t// 缓存TTL - 更短的缓存时间\n\tcacheTTL = 1 * time.Hour\n)\n\n// 性能统计（原子操作）\nvar (\n\tsearchRequests     int64 = 0\n\tdetailPageRequests int64 = 0\n\tcacheHits          int64 = 0\n\tcacheMisses        int64 = 0\n\ttotalSearchTime    int64 = 0 // 纳秒\n\ttotalDetailTime    int64 = 0 // 纳秒\n)\n\nfunc init() {\n\tplugin.RegisterGlobalPlugin(NewZhizhenPlugin())\n}\n\n// 预编译的正则表达式\nvar (\n\t// 从详情页URL中提取ID的正则表达式\n\tdetailIDRegex = regexp.MustCompile(`/vod/detail/id/(\\d+)\\.html`)\n\n\t// 密码提取正则表达式\n\tpasswordRegex = regexp.MustCompile(`\\?pwd=([0-9a-zA-Z]+)`)\n\n\t// 常见网盘链接的正则表达式（支持16种类型）\n\tquarkLinkRegex     = regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[0-9a-zA-Z]+`)\n\tucLinkRegex        = regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[0-9a-zA-Z]+(\\?[^\"'\\s]*)?`)\n\tbaiduLinkRegex     = regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[0-9a-zA-Z_\\-]+(\\?pwd=[0-9a-zA-Z]+)?`)\n\taliyunLinkRegex    = regexp.MustCompile(`https?://(www\\.)?(aliyundrive\\.com|alipan\\.com)/s/[0-9a-zA-Z]+`)\n\txunleiLinkRegex    = regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[0-9a-zA-Z_\\-]+(\\?pwd=[0-9a-zA-Z]+)?`)\n\ttianyiLinkRegex    = regexp.MustCompile(`https?://cloud\\.189\\.cn/t/[0-9a-zA-Z]+`)\n\tlink115Regex       = regexp.MustCompile(`https?://115\\.com/s/[0-9a-zA-Z]+`)\n\tmobileLinkRegex    = regexp.MustCompile(`https?://caiyun\\.feixin\\.10086\\.cn/[0-9a-zA-Z]+`)\n\tweiyunLinkRegex    = regexp.MustCompile(`https?://share\\.weiyun\\.com/[0-9a-zA-Z]+`)\n\tlanzouLinkRegex    = regexp.MustCompile(`https?://(www\\.)?(lanzou[uixys]*|lan[zs]o[ux])\\.(com|net|org)/[0-9a-zA-Z]+`)\n\tjianguoyunLinkRegex = regexp.MustCompile(`https?://(www\\.)?jianguoyun\\.com/p/[0-9a-zA-Z]+`)\n\tlink123Regex       = regexp.MustCompile(`https?://123pan\\.com/s/[0-9a-zA-Z]+`)\n\tpikpakLinkRegex    = regexp.MustCompile(`https?://mypikpak\\.com/s/[0-9a-zA-Z]+`)\n\tmagnetLinkRegex    = regexp.MustCompile(`magnet:\\?xt=urn:btih:[0-9a-fA-F]{40}`)\n\ted2kLinkRegex      = regexp.MustCompile(`ed2k://\\|file\\|.+\\|\\d+\\|[0-9a-fA-F]{32}\\|/`)\n\n\t// 缓存相关\n\tdetailCache = sync.Map{} // 缓存详情页解析结果\n)\n\n// ZhizhenAsyncPlugin Zhizhen异步插件\ntype ZhizhenAsyncPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\toptimizedClient *http.Client\n}\n\n// createOptimizedHTTPClient 创建优化的HTTP客户端\nfunc createOptimizedHTTPClient() *http.Client {\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:        MaxIdleConns,\n\t\tMaxIdleConnsPerHost: MaxIdleConnsPerHost,\n\t\tMaxConnsPerHost:     MaxConnsPerHost,\n\t\tIdleConnTimeout:     IdleConnTimeout,\n\t\tDisableKeepAlives:   false,\n\t}\n\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   DefaultTimeout,\n\t}\n}\n\n// NewZhizhenPlugin 创建新的Zhizhen异步插件\nfunc NewZhizhenPlugin() *ZhizhenAsyncPlugin {\n\treturn &ZhizhenAsyncPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"zhizhen\", 1),\n\t\toptimizedClient: createOptimizedHTTPClient(),\n\t}\n}\n\n// Search 同步搜索接口\nfunc (p *ZhizhenAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\n// SearchWithResult 带结果统计的搜索接口\nfunc (p *ZhizhenAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\n// searchImpl 实现具体的搜索逻辑\nfunc (p *ZhizhenAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 性能统计\n\tstart := time.Now()\n\tatomic.AddInt64(&searchRequests, 1)\n\tdefer func() {\n\t\tduration := time.Since(start).Nanoseconds()\n\t\tatomic.AddInt64(&totalSearchTime, duration)\n\t}()\n\n\t// 使用优化的客户端\n\tif p.optimizedClient != nil {\n\t\tclient = p.optimizedClient\n\t}\n\n\t// 1. 构建搜索URL\n\tsearchURL := fmt.Sprintf(\"https://xiaomi666.fun/index.php/vod/search/wd/%s.html\", url.QueryEscape(keyword))\n\n\t// 2. 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)\n\tdefer cancel()\n\n\t// 3. 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\n\t// 4. 设置完整的请求头（避免反爬虫）\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\treq.Header.Set(\"Cache-Control\", \"max-age=0\")\n\treq.Header.Set(\"Referer\", \"https://xiaomi666.fun/\")\n\n\t// 5. 发送请求（带重试机制）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\n\t// 6. 解析搜索结果页面\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 解析搜索页面失败: %w\", p.Name(), err)\n\t}\n\n\t// 7. 提取搜索结果\n\tvar results []model.SearchResult\n\n\tdoc.Find(\".module-search-item\").Each(func(i int, s *goquery.Selection) {\n\t\tresult := p.parseSearchItem(s, keyword)\n\t\tif result.UniqueID != \"\" {\n\t\t\tresults = append(results, result)\n\t\t}\n\t})\n\n\t// 8. 异步获取详情页信息\n\tenhancedResults := p.enhanceWithDetails(client, results)\n\n\t// 9. 关键词过滤\n\treturn plugin.FilterResultsByKeyword(enhancedResults, keyword), nil\n}\n\n// parseSearchItem 解析单个搜索结果项\nfunc (p *ZhizhenAsyncPlugin) parseSearchItem(s *goquery.Selection, keyword string) model.SearchResult {\n\tresult := model.SearchResult{}\n\n\t// 提取详情页链接和ID (修正：使用正确的选择器)\n\tdetailLink, exists := s.Find(\".video-info-header h3 a\").First().Attr(\"href\")\n\tif !exists {\n\t\treturn result\n\t}\n\n\t// 提取ID\n\tmatches := detailIDRegex.FindStringSubmatch(detailLink)\n\tif len(matches) < 2 {\n\t\treturn result\n\t}\n\n\titemID := matches[1]\n\tresult.UniqueID = fmt.Sprintf(\"%s-%s\", p.Name(), itemID)\n\n\t// 提取标题\n\ttitleElement := s.Find(\".video-info-header h3 a\")\n\tresult.Title = strings.TrimSpace(titleElement.Text())\n\n\t// 提取资源类型/质量\n\tqualityElement := s.Find(\".video-serial\")\n\tquality := strings.TrimSpace(qualityElement.Text())\n\n\t// 提取分类信息\n\tvar tags []string\n\ts.Find(\".video-info-aux .tag-link a\").Each(func(i int, tag *goquery.Selection) {\n\t\ttagText := strings.TrimSpace(tag.Text())\n\t\tif tagText != \"\" {\n\t\t\ttags = append(tags, tagText)\n\t\t}\n\t})\n\tresult.Tags = tags\n\n\t// 提取导演信息\n\tdirector := \"\"\n\ts.Find(\".video-info-items\").Each(func(i int, item *goquery.Selection) {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\tif strings.Contains(title, \"导演\") {\n\t\t\tdirector = strings.TrimSpace(item.Find(\".video-info-actor a\").Text())\n\t\t}\n\t})\n\n\t// 提取主演信息\n\tvar actors []string\n\ts.Find(\".video-info-items\").Each(func(i int, item *goquery.Selection) {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\tif strings.Contains(title, \"主演\") {\n\t\t\titem.Find(\".video-info-actor a\").Each(func(j int, actor *goquery.Selection) {\n\t\t\t\tactorName := strings.TrimSpace(actor.Text())\n\t\t\t\tif actorName != \"\" {\n\t\t\t\t\tactors = append(actors, actorName)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\n\t// 提取剧情简介\n\tplotElement := s.Find(\".video-info-items\").FilterFunction(func(i int, item *goquery.Selection) bool {\n\t\ttitle := strings.TrimSpace(item.Find(\".video-info-itemtitle\").Text())\n\t\treturn strings.Contains(title, \"剧情\")\n\t})\n\tplot := strings.TrimSpace(plotElement.Find(\".video-info-item\").Text())\n\n\t// 提取封面图片 (参考 Pan_mogg.js 的选择器)\n\tvar images []string\n\tif picURL, exists := s.Find(\".module-item-pic > img\").Attr(\"data-src\"); exists && picURL != \"\" {\n\t\timages = append(images, picURL)\n\t}\n\tresult.Images = images\n\n\t// 构建内容描述\n\tvar contentParts []string\n\tif quality != \"\" {\n\t\tcontentParts = append(contentParts, \"【\"+quality+\"】\")\n\t}\n\tif director != \"\" {\n\t\tcontentParts = append(contentParts, \"导演：\"+director)\n\t}\n\tif len(actors) > 0 {\n\t\tactorStr := strings.Join(actors[:min(3, len(actors))], \"、\") // 只显示前3个演员\n\t\tif len(actors) > 3 {\n\t\t\tactorStr += \"等\"\n\t\t}\n\t\tcontentParts = append(contentParts, \"主演：\"+actorStr)\n\t}\n\tif plot != \"\" {\n\t\tcontentParts = append(contentParts, plot)\n\t}\n\n\tresult.Content = strings.Join(contentParts, \"\\n\")\n\tresult.Channel = \"\" // 插件搜索结果不设置频道名，只有Telegram频道结果才设置\n\tresult.Datetime = time.Time{} // 使用零值而不是nil，参考jikepan插件标准\n\n\treturn result\n}\n\n// isValidNetworkDriveURL 检查URL是否为有效的网盘链接\nfunc (p *ZhizhenAsyncPlugin) isValidNetworkDriveURL(url string) bool {\n\t// 过滤掉明显无效的链接\n\tif strings.Contains(url, \"javascript:\") || \n\t   strings.Contains(url, \"#\") ||\n\t   url == \"\" ||\n\t   (!strings.HasPrefix(url, \"http\") && !strings.HasPrefix(url, \"magnet:\") && !strings.HasPrefix(url, \"ed2k:\")) {\n\t\treturn false\n\t}\n\t\n\t// 检查是否匹配任何支持的网盘格式（16种）\n\treturn quarkLinkRegex.MatchString(url) ||\n\t\t   ucLinkRegex.MatchString(url) ||\n\t\t   baiduLinkRegex.MatchString(url) ||\n\t\t   aliyunLinkRegex.MatchString(url) ||\n\t\t   xunleiLinkRegex.MatchString(url) ||\n\t\t   tianyiLinkRegex.MatchString(url) ||\n\t\t   link115Regex.MatchString(url) ||\n\t\t   mobileLinkRegex.MatchString(url) ||\n\t\t   weiyunLinkRegex.MatchString(url) ||\n\t\t   lanzouLinkRegex.MatchString(url) ||\n\t\t   jianguoyunLinkRegex.MatchString(url) ||\n\t\t   link123Regex.MatchString(url) ||\n\t\t   pikpakLinkRegex.MatchString(url) ||\n\t\t   magnetLinkRegex.MatchString(url) ||\n\t\t   ed2kLinkRegex.MatchString(url)\n}\n\n// determineLinkType 根据URL确定链接类型（支持16种类型）\nfunc (p *ZhizhenAsyncPlugin) determineLinkType(url string) string {\n\tswitch {\n\tcase quarkLinkRegex.MatchString(url):\n\t\treturn \"quark\"\n\tcase ucLinkRegex.MatchString(url):\n\t\treturn \"uc\"\n\tcase baiduLinkRegex.MatchString(url):\n\t\treturn \"baidu\"\n\tcase aliyunLinkRegex.MatchString(url):\n\t\treturn \"aliyun\"\n\tcase xunleiLinkRegex.MatchString(url):\n\t\treturn \"xunlei\"\n\tcase tianyiLinkRegex.MatchString(url):\n\t\treturn \"tianyi\"\n\tcase link115Regex.MatchString(url):\n\t\treturn \"115\"\n\tcase mobileLinkRegex.MatchString(url):\n\t\treturn \"mobile\"\n\tcase weiyunLinkRegex.MatchString(url):\n\t\treturn \"weiyun\"\n\tcase lanzouLinkRegex.MatchString(url):\n\t\treturn \"lanzou\"\n\tcase jianguoyunLinkRegex.MatchString(url):\n\t\treturn \"jianguoyun\"\n\tcase link123Regex.MatchString(url):\n\t\treturn \"123\"\n\tcase pikpakLinkRegex.MatchString(url):\n\t\treturn \"pikpak\"\n\tcase magnetLinkRegex.MatchString(url):\n\t\treturn \"magnet\"\n\tcase ed2kLinkRegex.MatchString(url):\n\t\treturn \"ed2k\"\n\tdefault:\n\t\treturn \"\" // 不支持的类型返回空字符串\n\t}\n}\n\n// extractPassword 从URL中提取密码\nfunc (p *ZhizhenAsyncPlugin) extractPassword(url string) string {\n\tmatches := passwordRegex.FindStringSubmatch(url)\n\tif len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\treturn \"\"\n}\n\n// enhanceWithDetails 异步获取详情页信息以获取下载链接\nfunc (p *ZhizhenAsyncPlugin) enhanceWithDetails(client *http.Client, results []model.SearchResult) []model.SearchResult {\n\tvar enhancedResults []model.SearchResult\n\tvar mu sync.Mutex\n\tvar wg sync.WaitGroup\n\n\t// 限制并发数\n\tsemaphore := make(chan struct{}, MaxConcurrency)\n\n\tfor _, result := range results {\n\t\twg.Add(1)\n\t\tgo func(r model.SearchResult) {\n\t\t\tdefer wg.Done()\n\n\t\t\t// 获取信号量\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() { <-semaphore }()\n\n\t\t\t// 从UniqueID提取ID\n\t\t\tparts := strings.Split(r.UniqueID, \"-\")\n\t\t\tif len(parts) < 2 {\n\t\t\t\tmu.Lock()\n\t\t\t\tenhancedResults = append(enhancedResults, r)\n\t\t\t\tmu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\titemID := parts[1]\n\n\t\t\t// 检查缓存\n\t\t\tif cached, ok := detailCache.Load(itemID); ok {\n\t\t\t\tif cachedResult, ok := cached.(model.SearchResult); ok {\n\t\t\t\t\tatomic.AddInt64(&cacheHits, 1)\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\tenhancedResults = append(enhancedResults, cachedResult)\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tatomic.AddInt64(&cacheMisses, 1)\n\n\t\t\t// 获取详情页链接和图片\n\t\t\tdetailLinks, detailImages := p.fetchDetailLinksAndImages(client, itemID)\n\t\t\tr.Links = detailLinks\n\n\t\t\t// 合并图片：优先使用详情页的海报，如果没有则使用搜索结果的图片\n\t\t\tif len(detailImages) > 0 {\n\t\t\t\tr.Images = detailImages\n\t\t\t}\n\n\t\t\t// 缓存结果\n\t\t\tdetailCache.Store(itemID, r)\n\n\t\t\tmu.Lock()\n\t\t\tenhancedResults = append(enhancedResults, r)\n\t\t\tmu.Unlock()\n\t\t}(result)\n\t}\n\n\twg.Wait()\n\treturn enhancedResults\n}\n\n// doRequestWithRetry 带重试机制的HTTP请求\nfunc (p *ZhizhenAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\t// 指数退避\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\n\t\t// 克隆请求\n\t\treqClone := req.Clone(req.Context())\n\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n\n// fetchDetailLinksAndImages 获取详情页的下载链接和图片\nfunc (p *ZhizhenAsyncPlugin) fetchDetailLinksAndImages(client *http.Client, itemID string) ([]model.Link, []string) {\n\t// 性能统计\n\tstart := time.Now()\n\tatomic.AddInt64(&detailPageRequests, 1)\n\tdefer func() {\n\t\tduration := time.Since(start).Nanoseconds()\n\t\tatomic.AddInt64(&totalDetailTime, duration)\n\t}()\n\n\tdetailURL := fmt.Sprintf(\"https://xiaomi666.fun/index.php/vod/detail/id/%s.html\", itemID)\n\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), DetailTimeout)\n\tdefer cancel()\n\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", detailURL, nil)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", \"https://xiaomi666.fun/\")\n\n\t// 发送请求（带重试）\n\tresp, err := p.doRequestWithRetry(req, client)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn nil, nil\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\n\tvar links []model.Link\n\tvar images []string\n\n\t// 提取详情页的海报图片 (参考 Pan_mogg.js 的选择器)\n\tif posterURL, exists := doc.Find(\".mobile-play .lazyload\").Attr(\"data-src\"); exists && posterURL != \"\" {\n\t\timages = append(images, posterURL)\n\t}\n\n\t// 查找下载链接区域\n\tdoc.Find(\"#download-list .module-row-one\").Each(func(i int, s *goquery.Selection) {\n\t\t// 从data-clipboard-text属性提取链接\n\t\tif linkURL, exists := s.Find(\"[data-clipboard-text]\").Attr(\"data-clipboard-text\"); exists {\n\t\t\t// 过滤掉无效链接\n\t\t\tif p.isValidNetworkDriveURL(linkURL) {\n\t\t\t\tif linkType := p.determineLinkType(linkURL); linkType != \"\" {\n\t\t\t\t\tlink := model.Link{\n\t\t\t\t\t\tType:     linkType,\n\t\t\t\t\t\tURL:      linkURL,\n\t\t\t\t\t\tPassword: \"\", // 大部分网盘不需要密码\n\t\t\t\t\t}\n\t\t\t\t\tlinks = append(links, link)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 也检查直接的href属性\n\t\ts.Find(\"a[href]\").Each(func(j int, a *goquery.Selection) {\n\t\t\tif linkURL, exists := a.Attr(\"href\"); exists {\n\t\t\t\t// 过滤掉无效链接\n\t\t\t\tif p.isValidNetworkDriveURL(linkURL) {\n\t\t\t\t\tif linkType := p.determineLinkType(linkURL); linkType != \"\" {\n\t\t\t\t\t\t// 避免重复添加\n\t\t\t\t\t\tisDuplicate := false\n\t\t\t\t\t\tfor _, existingLink := range links {\n\t\t\t\t\t\t\tif existingLink.URL == linkURL {\n\t\t\t\t\t\t\t\tisDuplicate = true\n\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif !isDuplicate {\n\t\t\t\t\t\t\tlink := model.Link{\n\t\t\t\t\t\t\t\tType:     linkType,\n\t\t\t\t\t\t\t\tURL:      linkURL,\n\t\t\t\t\t\t\t\tPassword: \"\",\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tlinks = append(links, link)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t})\n\n\treturn links, images\n}\n\n// fetchDetailLinks 获取详情页的下载链接（兼容性方法，仅返回链接）\nfunc (p *ZhizhenAsyncPlugin) fetchDetailLinks(client *http.Client, itemID string) []model.Link {\n\tlinks, _ := p.fetchDetailLinksAndImages(client, itemID)\n\treturn links\n}\n\n// min 返回两个整数中的较小值\nfunc min(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}\n\n// GetPerformanceStats 获取性能统计信息\nfunc (p *ZhizhenAsyncPlugin) GetPerformanceStats() map[string]interface{} {\n\ttotalSearchRequests := atomic.LoadInt64(&searchRequests)\n\ttotalDetailRequests := atomic.LoadInt64(&detailPageRequests)\n\ttotalCacheHits := atomic.LoadInt64(&cacheHits)\n\ttotalCacheMisses := atomic.LoadInt64(&cacheMisses)\n\ttotalSearchTime := atomic.LoadInt64(&totalSearchTime)\n\ttotalDetailTime := atomic.LoadInt64(&totalDetailTime)\n\n\tvar avgSearchTime, avgDetailTime, cacheHitRate float64\n\tif totalSearchRequests > 0 {\n\t\tavgSearchTime = float64(totalSearchTime) / float64(totalSearchRequests) / 1e6 // 转换为毫秒\n\t}\n\tif totalDetailRequests > 0 {\n\t\tavgDetailTime = float64(totalDetailTime) / float64(totalDetailRequests) / 1e6 // 转换为毫秒\n\t}\n\tif totalCacheHits+totalCacheMisses > 0 {\n\t\tcacheHitRate = float64(totalCacheHits) / float64(totalCacheHits+totalCacheMisses) * 100\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"search_requests\":        totalSearchRequests,\n\t\t\"detail_page_requests\":   totalDetailRequests,\n\t\t\"cache_hits\":            totalCacheHits,\n\t\t\"cache_misses\":          totalCacheMisses,\n\t\t\"cache_hit_rate\":        cacheHitRate,\n\t\t\"avg_search_time_ms\":    avgSearchTime,\n\t\t\"avg_detail_time_ms\":    avgDetailTime,\n\t\t\"total_search_time_ns\":  totalSearchTime,\n\t\t\"total_detail_time_ns\":  totalDetailTime,\n\t}\n}"
  },
  {
    "path": "plugin/zxzj/html结构分析.md",
    "content": "# 在线之家（zxzjhd.com）HTML结构分析\n\n## 网站信息\n\n- **站点名称**：在线之家\n- **主域名**：`https://www.zxzjhd.com`\n- **搜索入口**：`https://www.zxzjhd.com/vodsearch/-------------.html?wd={关键词}&submit=`\n- **详情页格式**：`https://www.zxzjhd.com/detail/{ID}.html`\n- **播放页格式**：`https://www.zxzjhd.com/video/{ID}-{线路ID}-{序号}.html`\n- **资源特征**：站内所有下载入口均聚合到“百度网盘”线路，播放页中的 `player_aaaa` 对象给出真实的网盘链接。\n\n## 搜索结果页面结构\n\n搜索结果页主体位于 `.stui-pannel .stui-vodlist` 内部，每个条目对应一个 `li.col-md-6.col-sm-4.col-xs-3`。\n\n```html\n<ul class=\"stui-vodlist clearfix\">\n  <li class=\"col-md-6 col-sm-4 col-xs-3\">\n    <div class=\"stui-vodlist__box\">\n      <a class=\"stui-vodlist__thumb lazyload\" href=\"/detail/4572.html\"\n         title=\"名侦探柯南：独眼的残像\"\n         data-original=\"https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2922540490.jpg\">\n        <span class=\"play hidden-xs\"></span>\n        <span class=\"pic-text text-right\">已完结</span>\n      </a>\n      <div class=\"stui-vodlist__detail\">\n        <h4 class=\"title text-overflow\">\n          <a href=\"/detail/4572.html\">名侦探柯南：独眼的残像</a>\n        </h4>\n      </div>\n    </div>\n  </li>\n</ul>\n```\n\n需要采集的字段：\n\n- **详情页链接**：`.stui-vodlist__thumb` 的 `href`\n- **唯一ID**：从 `/detail/{id}.html` 中截取 `{id}`\n- **标题**：`.stui-vodlist__detail h4 a` 文本\n- **状态/清晰度**：`.stui-vodlist__thumb .pic-text` 文本\n- **封面**：`data-original` 或 `src` 属性\n\n## 详情页结构\n\n详情页主体位于 `.stui-content` 中，包含影片基础信息、简介以及多个播放线路。\n\n```html\n<div class=\"stui-content\">\n  <div class=\"stui-content__thumb\">\n    <img class=\"lazyload\" data-original=\"https://img1.doubanio.com/...\">\n  </div>\n  <div class=\"stui-content__detail\">\n    <h1 class=\"title\">名侦探柯南：独眼的残像</h1>\n    <p class=\"data\">类型：剧情,动画,悬疑,犯罪 / 地区：日本 / 年份：2025</p>\n    <p class=\"data\">主演：高山南,山崎和佳奈,小山力也...</p>\n    <p class=\"data\">导演：重原克也</p>\n    <p class=\"data\">更新：2025-12-11 12:12:14</p>\n    <p class=\"desc detail\">\n      <span class=\"detail-content\">“我想起来了……”沉睡的记忆...</span>\n    </p>\n  </div>\n</div>\n```\n\n采集重点：\n\n- **标题**：`.stui-content__detail h1.title`\n- **封面**：`.stui-content__thumb img[data-original]`\n- **类型/地区/年份**、**主演**、**导演**、**更新时间**：`p.data` 文本\n- **简介**：`.desc .detail-content` 或 `.detail-sketch` 文本\n\n### 网盘播放列表\n\n每个播放线路由一个 `div.stui-vodlist__head` 与紧随其后的 `ul.stui-content__playlist` 组成。百度网盘线路的标题文字固定为“百度网盘”。\n\n```html\n<div class=\"stui-vodlist__head\">\n  <h3>百度网盘</h3>\n</div>\n<ul class=\"stui-content__playlist clearfix\">\n  <li><a href=\"/video/4572-2-1.html\">1080P</a></li>\n</ul>\n```\n\n解析逻辑：\n\n1. 遍历所有 `.stui-vodlist__head`，筛选文本包含“百度”或“网盘”的块。\n2. 找到其后第一个 `ul.stui-content__playlist`。\n3. 列表中的 `<a>` 提供播放页地址 `/video/{id}-{sid}-{nid}.html` 以及清晰度/集数文本，用于区分 `work_title`。\n\n## 播放页结构（真实网盘链接）\n\n播放页会注入一个 `player_aaaa` 对象，携带真实的网盘地址、线路信息以及影片元数据。\n\n```html\n<script type=\"text/javascript\">\nvar player_aaaa={\n  \"flag\":\"play\",\n  \"encrypt\":3,\n  \"link\":\"/video/4572-1-1.html\",\n  \"url\":\"https:\\/\\/pan.baidu.com\\/s\\/18j_Sf7RJ9qx934WzWTAchw?pwd=zxzj\",\n  \"from\":\"yunpan\",\n  \"note\":\"\",\n  \"id\":\"4572\",\n  \"sid\":2,\n  \"nid\":1,\n  \"vod_data\":{\"vod_name\":\"名侦探柯南：独眼的残像\",\"vod_actor\":\"...\"}\n}\n</script>\n```\n\n解析重点：\n\n- 使用正则匹配 `var player_aaaa = {...}`，并替换 `\\/` 转义后再解析 JSON。\n- **真实网盘链接**：`player_aaaa.url`，当 `encrypt` 字段为 2 或 3 时需要尝试 base64 解码。\n- **网盘平台**：`player_aaaa.from`（此处为 `yunpan`，需根据 URL 再次判断，实际链接为百度）。\n- **集数信息**：`player_aaaa.nid` 或页面上的 `vod_part` 脚本，可与播放列表文本组合为 `work_title`。\n- **密码**：百度链接通常自带 `?pwd=xxxx`，解析查询参数即可得到提取码。\n\n## 提取流程总结\n\n1. 构造搜索 URL，请求搜索页并解析 `.stui-vodlist` 列表，得到每个结果的 `detail/{id}.html`。\n2. 请求详情页，提取基础信息和“百度网盘”线路下所有 `/video/{...}.html` 播放地址。\n3. 逐个访问播放页，解析 `player_aaaa`，得到真实的百度网盘链接及密码。\n4. 根据播放列表文本（如 `1080P`、`第01集`）生成 `work_title`，所有链接类型强制识别为 `baidu`。\n5. 聚合后输出 `model.SearchResult`，其中：\n   - `UniqueID` 可使用 `zxzj-{detailID}`\n   - `Datetime` 使用详情页的“更新”时间\n   - `Content` 组合类型/主演/简介等信息\n   - `Links` 仅包含百度网盘地址及提取码\n\n通过上述步骤，即可从在线之家稳定提取百度网盘资源，满足插件“仅输出百度网盘”的要求。\n"
  },
  {
    "path": "plugin/zxzj/zxzj.go",
    "content": "package zxzj\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/json\"\n)\n\nconst (\n\tbaseURL      = \"https://www.zxzjhd.com\"\n\tsearchPath   = \"/vodsearch/-------------.html\"\n\tmaxResults   = 10\n\tmaxConcurrent = 5\n)\n\ntype ZXZJPlugin struct {\n\t*plugin.BaseAsyncPlugin\n\tclient *http.Client\n}\n\nfunc init() {\n\tp := &ZXZJPlugin{\n\t\tBaseAsyncPlugin: plugin.NewBaseAsyncPlugin(\"zxzj\", 3),\n\t\tclient: &http.Client{\n\t\t\tTimeout: 30 * time.Second,\n\t\t\tTransport: &http.Transport{\n\t\t\t\tMaxIdleConns:        100,\n\t\t\t\tMaxIdleConnsPerHost: 10,\n\t\t\t\tIdleConnTimeout:     90 * time.Second,\n\t\t\t},\n\t\t},\n\t}\n\tplugin.RegisterGlobalPlugin(p)\n}\n\nfunc (p *ZXZJPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tresult, err := p.SearchWithResult(keyword, ext)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Results, nil\n}\n\nfunc (p *ZXZJPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {\n\treturn p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)\n}\n\nfunc (p *ZXZJPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {\n\tsearchURL := fmt.Sprintf(\"%s%s?wd=%s&submit=\", baseURL, searchPath, url.QueryEscape(keyword))\n\t\n\titems, err := p.fetchSearchResults(searchURL)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\tif len(items) == 0 {\n\t\treturn []model.SearchResult{}, nil\n\t}\n\t\n\tif len(items) > maxResults {\n\t\titems = items[:maxResults]\n\t}\n\t\n\tresults := p.processDetailPages(items)\n\t\n\treturn plugin.FilterResultsByKeyword(results, keyword), nil\n}\n\ntype searchItem struct {\n\tID        string\n\tTitle     string\n\tDetailURL string\n}\n\nfunc (p *ZXZJPlugin) fetchSearchResults(searchURL string) ([]searchItem, error) {\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\t\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 创建请求失败: %w\", p.Name(), err)\n\t}\n\t\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Referer\", baseURL)\n\t\n\tresp, err := p.doRequestWithRetry(req, p.client)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] 搜索请求失败: %w\", p.Name(), err)\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil, fmt.Errorf(\"[%s] 请求返回状态码: %d\", p.Name(), resp.StatusCode)\n\t}\n\t\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"[%s] HTML解析失败: %w\", p.Name(), err)\n\t}\n\t\n\tvar items []searchItem\n\tdoc.Find(\"ul.stui-vodlist li\").Each(func(i int, s *goquery.Selection) {\n\t\tlink := s.Find(\".stui-vodlist__detail h4.title a\")\n\t\thref, exists := link.Attr(\"href\")\n\t\tif !exists {\n\t\t\treturn\n\t\t}\n\t\t\n\t\ttitle := strings.TrimSpace(link.Text())\n\t\tif title == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\tre := regexp.MustCompile(`/detail/(\\d+)\\.html`)\n\t\tmatches := re.FindStringSubmatch(href)\n\t\tif len(matches) < 2 {\n\t\t\treturn\n\t\t}\n\t\t\n\t\titems = append(items, searchItem{\n\t\t\tID:        matches[1],\n\t\t\tTitle:     title,\n\t\t\tDetailURL: p.buildAbsURL(href),\n\t\t})\n\t})\n\t\n\treturn items, nil\n}\n\nfunc (p *ZXZJPlugin) processDetailPages(items []searchItem) []model.SearchResult {\n\tvar results []model.SearchResult\n\tvar mu sync.Mutex\n\tvar wg sync.WaitGroup\n\tsem := make(chan struct{}, maxConcurrent)\n\t\n\tfor _, item := range items {\n\t\twg.Add(1)\n\t\tgo func(it searchItem) {\n\t\t\tdefer wg.Done()\n\t\t\tsem <- struct{}{}\n\t\t\tdefer func() { <-sem }()\n\t\t\t\n\t\t\tresult := p.processDetailPage(it)\n\t\t\tif result != nil {\n\t\t\t\tmu.Lock()\n\t\t\t\tresults = append(results, *result)\n\t\t\t\tmu.Unlock()\n\t\t\t}\n\t\t}(item)\n\t}\n\t\n\twg.Wait()\n\treturn results\n}\n\nfunc (p *ZXZJPlugin) processDetailPage(item searchItem) *model.SearchResult {\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\t\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", item.DetailURL, nil)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\tp.setHeaders(req, baseURL)\n\t\n\tresp, err := p.doRequestWithRetry(req, p.client)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil\n\t}\n\t\n\tdoc, err := goquery.NewDocumentFromReader(resp.Body)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\ttitle := strings.TrimSpace(doc.Find(\".stui-content__detail h1.title\").Text())\n\tif title == \"\" {\n\t\ttitle = item.Title\n\t}\n\t\n\tvar description string\n\tvar updateTime time.Time\n\tdoc.Find(\".stui-content__detail p.data\").Each(func(i int, s *goquery.Selection) {\n\t\ttext := strings.TrimSpace(s.Text())\n\t\tif text != \"\" {\n\t\t\tif description != \"\" {\n\t\t\t\tdescription += \"\\n\"\n\t\t\t}\n\t\t\tdescription += text\n\t\t\t\n\t\t\tif updateTime.IsZero() && strings.Contains(text, \"更新\") {\n\t\t\t\tupdateTime = p.parseUpdateTime(text)\n\t\t\t}\n\t\t}\n\t})\n\t\n\tif updateTime.IsZero() {\n\t\tupdateTime = time.Now()\n\t}\n\t\n\tplayLinks := p.extractPlayLinks(doc)\n\tif len(playLinks) == 0 {\n\t\treturn nil\n\t}\n\t\n\tlinks := p.fetchPanLinks(playLinks)\n\tif len(links) == 0 {\n\t\treturn nil\n\t}\n\t\n\treturn &model.SearchResult{\n\t\tUniqueID: fmt.Sprintf(\"%s-%s\", p.Name(), item.ID),\n\t\tTitle:    title,\n\t\tContent:  description,\n\t\tLinks:    links,\n\t\tChannel:  \"\",\n\t\tDatetime: updateTime,\n\t}\n}\n\ntype playLink struct {\n\tURL      string\n\tLabel    string\n\tLineType string\n}\n\nfunc (p *ZXZJPlugin) extractPlayLinks(doc *goquery.Document) []playLink {\n\tvar links []playLink\n\t\n\tdoc.Find(\".stui-vodlist__head\").Each(func(i int, head *goquery.Selection) {\n\t\tlineTitle := strings.TrimSpace(head.Find(\"h3\").Text())\n\t\tif lineTitle == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\tpanType := p.detectPanType(lineTitle)\n\t\tif panType == \"\" {\n\t\t\treturn\n\t\t}\n\t\t\n\t\tplaylist := head.Next()\n\t\tfor playlist.Length() > 0 && !playlist.Is(\"ul.stui-content__playlist\") {\n\t\t\tplaylist = playlist.Next()\n\t\t}\n\t\t\n\t\tif playlist.Length() == 0 {\n\t\t\treturn\n\t\t}\n\t\t\n\t\tplaylist.Find(\"li a\").Each(func(j int, a *goquery.Selection) {\n\t\t\thref, exists := a.Attr(\"href\")\n\t\t\tif !exists {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\tlabel := strings.TrimSpace(a.Text())\n\t\t\tlinks = append(links, playLink{\n\t\t\t\tURL:      p.buildAbsURL(href),\n\t\t\t\tLabel:    label,\n\t\t\t\tLineType: panType,\n\t\t\t})\n\t\t})\n\t})\n\t\n\treturn links\n}\n\nfunc (p *ZXZJPlugin) detectPanType(title string) string {\n\tlower := strings.ToLower(title)\n\t\n\tif strings.Contains(lower, \"百度\") {\n\t\treturn \"baidu\"\n\t}\n\tif strings.Contains(lower, \"夸克\") {\n\t\treturn \"quark\"\n\t}\n\tif strings.Contains(lower, \"迅雷\") {\n\t\treturn \"xunlei\"\n\t}\n\t\n\treturn \"\"\n}\n\nfunc (p *ZXZJPlugin) fetchPanLinks(playLinks []playLink) []model.Link {\n\tvar links []model.Link\n\tvar mu sync.Mutex\n\tvar wg sync.WaitGroup\n\tsem := make(chan struct{}, maxConcurrent)\n\t\n\tfor _, pl := range playLinks {\n\t\twg.Add(1)\n\t\tgo func(playLink playLink) {\n\t\t\tdefer wg.Done()\n\t\t\tsem <- struct{}{}\n\t\t\tdefer func() { <-sem }()\n\t\t\t\n\t\t\tlink := p.fetchSinglePanLink(playLink)\n\t\t\tif link != nil {\n\t\t\t\tmu.Lock()\n\t\t\t\tlinks = append(links, *link)\n\t\t\t\tmu.Unlock()\n\t\t\t}\n\t\t}(pl)\n\t}\n\t\n\twg.Wait()\n\treturn links\n}\n\nfunc (p *ZXZJPlugin) fetchSinglePanLink(pl playLink) *model.Link {\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\t\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", pl.URL, nil)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\tp.setHeaders(req, baseURL)\n\t\n\tresp, err := p.doRequestWithRetry(req, p.client)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer resp.Body.Close()\n\t\n\tif resp.StatusCode != 200 {\n\t\treturn nil\n\t}\n\t\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil\n\t}\n\t\n\tpanURL, password := p.parsePlayerData(body)\n\tif panURL == \"\" {\n\t\treturn nil\n\t}\n\t\n\tcloudType := p.determinePanType(panURL, pl.LineType)\n\tif cloudType == \"\" {\n\t\treturn nil\n\t}\n\t\n\treturn &model.Link{\n\t\tType:     cloudType,\n\t\tURL:      panURL,\n\t\tPassword: password,\n\t}\n}\n\ntype playerData struct {\n\tURL  string `json:\"url\"`\n\tFrom string `json:\"from\"`\n}\n\nfunc (p *ZXZJPlugin) parsePlayerData(body []byte) (string, string) {\n\tre := regexp.MustCompile(`var\\s+player_aaaa\\s*=\\s*(\\{[^;]+\\})`)\n\tmatches := re.FindSubmatch(body)\n\tif len(matches) < 2 {\n\t\treturn \"\", \"\"\n\t}\n\t\n\tvar data playerData\n\tif err := json.Unmarshal(matches[1], &data); err != nil {\n\t\treturn \"\", \"\"\n\t}\n\t\n\tpanURL := strings.TrimSpace(data.URL)\n\tif panURL == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\t\n\tpanURL = strings.ReplaceAll(panURL, `\\/`, `/`)\n\t\n\tpassword := p.extractPassword(panURL)\n\t\n\treturn panURL, password\n}\n\nfunc (p *ZXZJPlugin) extractPassword(panURL string) string {\n\tparsed, err := url.Parse(panURL)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\t\n\tpwd := parsed.Query().Get(\"pwd\")\n\tif pwd != \"\" && len(pwd) == 4 {\n\t\treturn pwd\n\t}\n\t\n\tif strings.Contains(panURL, \"|\") {\n\t\tparts := strings.Split(panURL, \"|\")\n\t\tif len(parts) >= 2 {\n\t\t\tpwd := strings.TrimSpace(parts[1])\n\t\t\tif len(pwd) == 4 {\n\t\t\t\treturn pwd\n\t\t\t}\n\t\t}\n\t}\n\t\n\tpwdRegex := regexp.MustCompile(`pwd=([a-zA-Z0-9]{4})`)\n\tif matches := pwdRegex.FindStringSubmatch(panURL); len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\t\n\treturn \"\"\n}\n\nfunc (p *ZXZJPlugin) determinePanType(panURL, lineType string) string {\n\tlower := strings.ToLower(panURL)\n\t\n\tif strings.Contains(lower, \"pan.baidu.com\") {\n\t\treturn \"baidu\"\n\t}\n\tif strings.Contains(lower, \"pan.quark.cn\") {\n\t\treturn \"quark\"\n\t}\n\tif strings.Contains(lower, \"pan.xunlei.com\") {\n\t\treturn \"xunlei\"\n\t}\n\tif strings.Contains(lower, \"aliyundrive.com\") || strings.Contains(lower, \"alipan.com\") {\n\t\treturn \"aliyun\"\n\t}\n\t\n\tif lineType != \"\" {\n\t\treturn lineType\n\t}\n\t\n\treturn \"\"\n}\n\nfunc (p *ZXZJPlugin) buildAbsURL(path string) string {\n\tif strings.HasPrefix(path, \"http://\") || strings.HasPrefix(path, \"https://\") {\n\t\treturn path\n\t}\n\tif strings.HasPrefix(path, \"//\") {\n\t\treturn \"https:\" + path\n\t}\n\tif !strings.HasPrefix(path, \"/\") {\n\t\tpath = \"/\" + path\n\t}\n\treturn baseURL + path\n}\n\nfunc (p *ZXZJPlugin) setHeaders(req *http.Request, referer string) {\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"zh-CN,zh;q=0.9,en;q=0.8\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Referer\", referer)\n}\n\nfunc (p *ZXZJPlugin) parseUpdateTime(text string) time.Time {\n\tupdateRegex := regexp.MustCompile(`更新[：:]\\s*(\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}:\\d{2}|\\d{4}-\\d{2}-\\d{2})`)\n\tmatches := updateRegex.FindStringSubmatch(text)\n\tif len(matches) < 2 {\n\t\treturn time.Time{}\n\t}\n\t\n\ttimeStr := strings.TrimSpace(matches[1])\n\t\n\tlayouts := []string{\n\t\t\"2006-01-02 15:04:05\",\n\t\t\"2006-01-02\",\n\t}\n\t\n\tfor _, layout := range layouts {\n\t\tif t, err := time.ParseInLocation(layout, timeStr, time.Local); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\t\n\treturn time.Time{}\n}\n\nfunc (p *ZXZJPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {\n\tmaxRetries := 3\n\tvar lastErr error\n\t\n\tfor i := 0; i < maxRetries; i++ {\n\t\tif i > 0 {\n\t\t\tbackoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond\n\t\t\ttime.Sleep(backoff)\n\t\t}\n\t\t\n\t\treqClone := req.Clone(req.Context())\n\t\tresp, err := client.Do(reqClone)\n\t\tif err == nil && resp.StatusCode == 200 {\n\t\t\treturn resp, nil\n\t\t}\n\t\t\n\t\tif resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tlastErr = err\n\t}\n\t\n\treturn nil, fmt.Errorf(\"重试 %d 次后仍然失败: %w\", maxRetries, lastErr)\n}\n"
  },
  {
    "path": "service/cache_integration.go",
    "content": "package service\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util/cache\"\n)\n\n// CacheWriteIntegration 缓存写入集成层\ntype CacheWriteIntegration struct {\n\tbatchManager     *cache.DelayedBatchWriteManager\n\tmainCache        *cache.EnhancedTwoLevelCache\n\tstrategy         cache.CacheWriteStrategy\n\tinitialized      bool\n}\n\n// NewCacheWriteIntegration 创建缓存写入集成\nfunc NewCacheWriteIntegration(mainCache *cache.EnhancedTwoLevelCache) (*CacheWriteIntegration, error) {\n\t// 创建延迟批量写入管理器\n\tbatchManager, err := cache.NewDelayedBatchWriteManager()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建批量写入管理器失败: %v\", err)\n\t}\n\t\n\tintegration := &CacheWriteIntegration{\n\t\tbatchManager: batchManager,\n\t\tmainCache:    mainCache,\n\t}\n\t\n\t// 设置主缓存更新函数\n\tbatchManager.SetMainCacheUpdater(integration.createMainCacheUpdater())\n\t\n\t// 初始化管理器\n\tif err := batchManager.Initialize(); err != nil {\n\t\treturn nil, fmt.Errorf(\"初始化批量写入管理器失败: %v\", err)\n\t}\n\t\n\tintegration.initialized = true\n\t\n\tfmt.Printf(\"[缓存写入集成] 初始化完成\\n\")\n\treturn integration, nil\n}\n\n// createMainCacheUpdater 创建主缓存更新函数\nfunc (c *CacheWriteIntegration) createMainCacheUpdater() func(string, []byte, time.Duration) error {\n\treturn func(key string, data []byte, ttl time.Duration) error {\n\t\t// 调用现有的缓存系统进行实际写入\n\t\treturn c.mainCache.SetBothLevels(key, data, ttl)\n\t}\n}\n\n// HandleCacheWrite 处理缓存写入请求\nfunc (c *CacheWriteIntegration) HandleCacheWrite(key string, results []model.SearchResult, ttl time.Duration, isFinal bool, keyword string, pluginName string) error {\n\tif !c.initialized {\n\t\treturn fmt.Errorf(\"缓存写入集成未初始化\")\n\t}\n\t\n\t// 计算插件优先级\n\tpriority := c.getPluginPriority(pluginName)\n\t\n\t// 计算数据大小（估算）\n\tdataSize := c.estimateDataSize(results)\n\t\n\t// 创建缓存操作\n\toperation := &cache.CacheOperation{\n\t\tKey:        key,\n\t\tData:       results,\n\t\tTTL:        ttl,\n\t\tPluginName: pluginName,\n\t\tKeyword:    keyword,\n\t\tTimestamp:  time.Now(),\n\t\tPriority:   priority,\n\t\tDataSize:   dataSize,\n\t\tIsFinal:    isFinal,\n\t}\n\t\n\t// 调用批量写入管理器处理\n\treturn c.batchManager.HandleCacheOperation(operation)\n}\n\n// getPluginPriority 获取插件优先级\nfunc (c *CacheWriteIntegration) getPluginPriority(pluginName string) int {\n\t// 从插件管理器动态获取真实的优先级\n\tif pluginInstance, exists := plugin.GetPluginByName(pluginName); exists {\n\t\treturn pluginInstance.Priority()\n\t}\n\t\n\t// 如果插件不存在，返回默认等级4（最低优先级）\n\treturn 4\n}\n\n// estimateDataSize 估算数据大小\nfunc (c *CacheWriteIntegration) estimateDataSize(results []model.SearchResult) int {\n\t// 简化估算：每个结果约500字节\n\treturn len(results) * 500\n}\n\n// Shutdown 优雅关闭\nfunc (c *CacheWriteIntegration) Shutdown(timeout time.Duration) error {\n\tif !c.initialized {\n\t\treturn nil\n\t}\n\t\n\treturn c.batchManager.Shutdown(timeout)\n}\n\n// GetStats 获取统计信息\nfunc (c *CacheWriteIntegration) GetStats() interface{} {\n\tif !c.initialized {\n\t\treturn nil\n\t}\n\t\n\treturn c.batchManager.GetStats()\n}\n\n// SetStrategy 设置写入策略\nfunc (c *CacheWriteIntegration) SetStrategy(strategy cache.CacheWriteStrategy) {\n\tc.strategy = strategy\n}\n\n// GetStrategy 获取当前策略\nfunc (c *CacheWriteIntegration) GetStrategy() cache.CacheWriteStrategy {\n\treturn c.strategy\n}"
  },
  {
    "path": "service/search_service.go",
    "content": "package service\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"pansou/config\"\n\t\"pansou/model\"\n\t\"pansou/plugin\"\n\t\"pansou/util\"\n\t\"pansou/util/cache\"\n\t\"pansou/util/pool\"\n)\n\n// normalizeUrl 标准化URL，将URL编码的中文部分解码为中文，用于去重\nfunc normalizeUrl(rawUrl string) string {\n\t// 解码URL中的编码字符\n\tdecoded, err := url.QueryUnescape(rawUrl)\n\tif err != nil {\n\t\t// 如果解码失败，返回原始URL\n\t\treturn rawUrl\n\t}\n\treturn decoded\n}\n\n// 全局缓存写入管理器引用（避免循环依赖）\nvar globalCacheWriteManager *cache.DelayedBatchWriteManager\n\n// SetGlobalCacheWriteManager 设置全局缓存写入管理器\nfunc SetGlobalCacheWriteManager(manager *cache.DelayedBatchWriteManager) {\n\tglobalCacheWriteManager = manager\n}\n\n// GetGlobalCacheWriteManager 获取全局缓存写入管理器\nfunc GetGlobalCacheWriteManager() *cache.DelayedBatchWriteManager {\n\treturn globalCacheWriteManager\n}\n\n// GetEnhancedTwoLevelCache 获取增强版两级缓存实例\nfunc GetEnhancedTwoLevelCache() *cache.EnhancedTwoLevelCache {\n\treturn enhancedTwoLevelCache\n}\n\n// 优先关键词列表\nvar priorityKeywords = []string{\"合集\", \"系列\", \"全\", \"完\", \"最新\", \"附\", \"complete\"}\n\n// extractKeywordFromCacheKey 从缓存键中提取关键词（简化版）\nfunc extractKeywordFromCacheKey(cacheKey string) string {\n\t// 这是一个简化的实现，实际中我们会通过传递来获得关键词\n\t// 为了演示，这里返回简化的显示\n\treturn \"搜索关键词\"\n}\n\n// logAsyncCacheWithKeyword 异步缓存日志输出辅助函数（带关键词）\nfunc logAsyncCacheWithKeyword(keyword, cacheKey string, format string, args ...interface{}) {\n\t// 检查配置开关\n\tif config.AppConfig == nil || !config.AppConfig.AsyncLogEnabled {\n\t\treturn\n\t}\n\t\n\t// 构建显示的关键词信息\n\tdisplayKeyword := keyword\n\tif displayKeyword == \"\" {\n\t\tdisplayKeyword = \"未知\"\n\t}\n\t\n\t// 将缓存键替换为简化版本+关键词\n\tshortKey := cacheKey\n\tif len(cacheKey) > 8 {\n\t\tshortKey = cacheKey[:8] + \"...\"\n\t}\n\t\n\t// 替换格式字符串中的缓存键\n\tenhancedFormat := strings.Replace(format, cacheKey, fmt.Sprintf(\"%s(关键词:%s)\", shortKey, displayKeyword), 1)\n\tfmt.Printf(enhancedFormat, args...)\n}\n\n// 全局缓存实例和缓存是否初始化标志\nvar (\n\tenhancedTwoLevelCache *cache.EnhancedTwoLevelCache\n\tcacheInitialized bool\n)\n\n// 初始化缓存\nfunc init() {\n\tif config.AppConfig != nil && config.AppConfig.CacheEnabled {\n\t\tvar err error\n\t\t// 使用增强版缓存\n\t\tenhancedTwoLevelCache, err = cache.NewEnhancedTwoLevelCache()\n\t\tif err == nil {\n\t\t\tcacheInitialized = true\n\t\t}\n\t}\n}\n\n// mergeSearchResults 智能合并搜索结果，去重并保留最完整的信息\nfunc mergeSearchResults(existing []model.SearchResult, newResults []model.SearchResult) []model.SearchResult {\n\t// 使用map进行去重和合并，以UniqueID作为唯一标识\n\tresultMap := make(map[string]model.SearchResult)\n\t\n\t// 先添加现有结果\n\tfor _, result := range existing {\n\t\tkey := generateResultKey(result)\n\t\tresultMap[key] = result\n\t}\n\t\n\t// 合并新结果，如果UniqueID相同则选择信息更完整的\n\tfor _, newResult := range newResults {\n\t\tkey := generateResultKey(newResult)\n\t\tif existingResult, exists := resultMap[key]; exists {\n\t\t\t// 选择信息更完整的结果\n\t\t\tresultMap[key] = selectBetterResult(existingResult, newResult)\n\t\t} else {\n\t\t\t// 新结果，直接添加\n\t\t\tresultMap[key] = newResult\n\t\t}\n\t}\n\t\n\t// 转换回切片\n\tmerged := make([]model.SearchResult, 0, len(resultMap))\n\tfor _, result := range resultMap {\n\t\tmerged = append(merged, result)\n\t}\n\t\n\t// 按时间排序（最新的在前）\n\tsort.Slice(merged, func(i, j int) bool {\n\t\treturn merged[i].Datetime.After(merged[j].Datetime)\n\t})\n\t\n\treturn merged\n}\n\n// generateResultKey 生成结果的唯一标识键\nfunc generateResultKey(result model.SearchResult) string {\n\t// 使用UniqueID作为主要标识，如果没有则使用MessageID，最后使用标题\n\tif result.UniqueID != \"\" {\n\t\treturn result.UniqueID\n\t}\n\tif result.MessageID != \"\" {\n\t\treturn result.MessageID\n\t}\n\treturn fmt.Sprintf(\"title_%s_%s\", result.Title, result.Channel)\n}\n\n// selectBetterResult 选择信息更完整的结果\nfunc selectBetterResult(existing, new model.SearchResult) model.SearchResult {\n\t// 计算信息完整度得分\n\texistingScore := calculateCompletenessScore(existing)\n\tnewScore := calculateCompletenessScore(new)\n\t\n\tif newScore > existingScore {\n\t\treturn new\n\t}\n\treturn existing\n}\n\n// calculateCompletenessScore 计算结果信息的完整度得分\nfunc calculateCompletenessScore(result model.SearchResult) int {\n\tscore := 0\n\t\n\t// 有UniqueID加分\n\tif result.UniqueID != \"\" {\n\t\tscore += 10\n\t}\n\t\n\t// 有链接信息加分\n\tif len(result.Links) > 0 {\n\t\tscore += 5\n\t\t// 每个链接额外加分\n\t\tscore += len(result.Links)\n\t}\n\t\n\t// 有内容加分\n\tif result.Content != \"\" {\n\t\tscore += 3\n\t}\n\t\n\t// 标题长度加分（更详细的标题）\n\tscore += len(result.Title) / 10\n\t\n\t// 有频道信息加分\n\tif result.Channel != \"\" {\n\t\tscore += 2\n\t}\n\t\n\t// 有标签加分\n\tscore += len(result.Tags)\n\t\n\treturn score\n}\n\n// SearchService 搜索服务\ntype SearchService struct {\n\tpluginManager *plugin.PluginManager\n}\n\n// NewSearchService 创建搜索服务实例并确保缓存可用\nfunc NewSearchService(pluginManager *plugin.PluginManager) *SearchService {\n\t// 检查缓存是否已初始化，如果未初始化则尝试重新初始化\n\tif !cacheInitialized && config.AppConfig != nil && config.AppConfig.CacheEnabled {\n\t\tvar err error\n\t\t// 使用增强版缓存\n\t\tenhancedTwoLevelCache, err = cache.NewEnhancedTwoLevelCache()\n\t\tif err == nil {\n\t\t\tcacheInitialized = true\n\t\t}\n\t}\n\t\n\t// 将主缓存注入到异步插件中\n\tinjectMainCacheToAsyncPlugins(pluginManager, enhancedTwoLevelCache)\n\t\n\t// 确保缓存写入管理器设置了主缓存更新函数\n\tif globalCacheWriteManager != nil && enhancedTwoLevelCache != nil {\n\t\tglobalCacheWriteManager.SetMainCacheUpdater(func(key string, data []byte, ttl time.Duration) error {\n\t\t\treturn enhancedTwoLevelCache.SetBothLevels(key, data, ttl)\n\t\t})\n\t}\n\n\treturn &SearchService{\n\t\tpluginManager: pluginManager,\n\t}\n}\n\n// injectMainCacheToAsyncPlugins 将主缓存系统注入到异步插件中\nfunc injectMainCacheToAsyncPlugins(pluginManager *plugin.PluginManager, mainCache *cache.EnhancedTwoLevelCache) {\n\t// 如果缓存或插件管理器不可用，直接返回\n\tif mainCache == nil || pluginManager == nil {\n\t\treturn\n\t}\n\t\n\t// 设置全局序列化器，确保异步插件与主程序使用相同的序列化格式\n\tserializer := mainCache.GetSerializer()\n\tif serializer != nil {\n\t\tplugin.SetGlobalCacheSerializer(serializer)\n\t}\n\t\n\t// 创建缓存更新函数（支持IsFinal参数）- 接收原始数据并与现有缓存合并\n\tcacheUpdater := func(key string, newResults []model.SearchResult, ttl time.Duration, isFinal bool, keyword string, pluginName string) error {\n\t\t// 优化：如果新结果为空，跳过缓存更新（避免无效操作）\n\t\tif len(newResults) == 0 {\n\t\t\treturn nil\n\t\t}\n\t\t\n\t\t// 获取现有缓存数据进行合并\n\t\tvar finalResults []model.SearchResult\n\t\tif existingData, hit, err := mainCache.Get(key); err == nil && hit {\n\t\t\tvar existingResults []model.SearchResult\n\t\t\tif err := mainCache.GetSerializer().Deserialize(existingData, &existingResults); err == nil {\n\t\t\t\t// 合并新旧结果，去重保留最完整的数据\n\t\t\t\tfinalResults = mergeSearchResults(existingResults, newResults)\n\t\t\t\tif config.AppConfig != nil && config.AppConfig.AsyncLogEnabled {\n\t\t\t\t\tif keyword != \"\" {\n\t\t\t\t\t\tfmt.Printf(\"🔄 [%s:%s] 更新缓存| 原有: %d + 新增: %d = 合并后: %d\\n\", \n\t\t\t\t\t\tpluginName, keyword, len(existingResults), len(newResults), len(finalResults))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// 反序列化失败，使用新结果\n\t\t\t\tfinalResults = newResults\n\t\t\t\t\t\t\tif config.AppConfig != nil && config.AppConfig.AsyncLogEnabled {\n\t\t\t\tdisplayKey := key[:8] + \"...\"\n\t\t\t\tif keyword != \"\" {\n\t\t\t\t\tfmt.Printf(\"[异步插件 %s] 缓存反序列化失败，使用新结果: %s(关键词:%s) | 结果数: %d\\n\", pluginName, displayKey, keyword, len(newResults))\n\t\t\t\t} else {\n\t\t\t\t\tfmt.Printf(\"[异步插件 %s] 缓存反序列化失败，使用新结果: %s | 结果数: %d\\n\", pluginName, key, len(newResults))\n\t\t\t\t}\n\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// 无现有缓存，直接使用新结果\n\t\t\tfinalResults = newResults\n\t\t\t\t\tif config.AppConfig != nil && config.AppConfig.AsyncLogEnabled {\n\t\t\tdisplayKey := key[:8] + \"...\"\n\t\t\tif keyword != \"\" {\n\t\t\t\tfmt.Printf(\"[异步插件 %s] 初始缓存创建: %s(关键词:%s) | 结果数: %d\\n\", pluginName, displayKey, keyword, len(newResults))\n\t\t\t} else {\n\t\t\t\tfmt.Printf(\"[异步插件 %s] 初始缓存创建: %s | 结果数: %d\\n\", pluginName, key, len(newResults))\n\t\t\t}\n\t\t}\n\t\t}\n\t\t\n\t\t// 序列化合并后的结果\n\t\tdata, err := mainCache.GetSerializer().Serialize(finalResults)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"[缓存更新] 序列化失败: %s | 错误: %v\\n\", key, err)\n\t\t\treturn err\n\t\t}\n\t\t\n\t\t// 先更新内存缓存（立即可见）\n\t\tif err := mainCache.SetMemoryOnly(key, data, ttl); err != nil {\n\t\t\treturn fmt.Errorf(\"内存缓存更新失败: %v\", err)\n\t\t}\n\t\t\n\t\t// 使用新的缓存写入管理器处理磁盘写入（智能批处理）\n\t\tif cacheWriteManager := globalCacheWriteManager; cacheWriteManager != nil {\n\t\t\toperation := &cache.CacheOperation{\n\t\t\t\tKey:          key,\n\t\t\t\tData:         finalResults,      // 使用原始数据而不是序列化后的\n\t\t\t\tTTL:          ttl,\n\t\t\t\tIsFinal:      isFinal,\n\t\t\t\tPluginName:   pluginName,\n\t\t\t\tKeyword:      keyword,\n\t\t\t\tPriority:     2,                 // 中等优先级\n\t\t\t\tTimestamp:    time.Now(),\n\t\t\t\tDataSize:     len(data),         // 序列化后的数据大小\n\t\t\t}\n\t\t\t\n\t\t\t// 根据是否为最终结果设置优先级\n\t\t\tif isFinal {\n\t\t\t\toperation.Priority = 1           // 高优先级\n\t\t\t}\n\t\t\t\n\t\t\treturn cacheWriteManager.HandleCacheOperation(operation)\n\t\t}\n\t\t\n\t\t// 兜底：如果缓存写入管理器不可用，使用原有逻辑\n\t\tif isFinal {\n\t\t\treturn mainCache.SetBothLevels(key, data, ttl)\n\t\t} else {\n\t\t\treturn nil // 内存已更新，磁盘稍后批处理\n\t\t}\n\t}\n\t\n\t// 获取所有插件\n\tplugins := pluginManager.GetPlugins()\n\t\n\t// 遍历所有插件，找出异步插件\n\tfor _, p := range plugins {\n\t\t// 检查插件是否实现了SetMainCacheUpdater方法（修复后的签名，增加关键词参数）\n\t\tif asyncPlugin, ok := p.(interface{ SetMainCacheUpdater(func(string, []model.SearchResult, time.Duration, bool, string) error) }); ok {\n\t\t\t// 为每个插件创建专门的缓存更新函数，绑定插件名称\n\t\t\tpluginName := p.Name()\n\t\t\tpluginCacheUpdater := func(key string, newResults []model.SearchResult, ttl time.Duration, isFinal bool, keyword string) error {\n\t\t\t\treturn cacheUpdater(key, newResults, ttl, isFinal, keyword, pluginName)\n\t\t\t}\n\t\t\t// 注入缓存更新函数\n\t\t\tasyncPlugin.SetMainCacheUpdater(pluginCacheUpdater)\n\t\t}\n\t}\n}\n\n// Search 执行搜索\nfunc (s *SearchService) Search(keyword string, channels []string, concurrency int, forceRefresh bool, resultType string, sourceType string, plugins []string, cloudTypes []string, ext map[string]interface{}) (model.SearchResponse, error) {\n\t// 确保ext不为nil\n\tif ext == nil {\n\t\text = make(map[string]interface{})\n\t}\n\t\n\t// 参数预处理\n\t// 源类型标准化\n\tif sourceType == \"\" {\n\t\tsourceType = \"all\"\n\t}\n\n\t// 插件参数规范化处理\n\tif sourceType == \"tg\" {\n\t\t// 对于只搜索Telegram的请求，忽略插件参数\n\t\tplugins = nil\n\t} else if sourceType == \"all\" || sourceType == \"plugin\" {\n\t\t// 检查是否为空列表或只包含空字符串\n\t\tif plugins == nil || len(plugins) == 0 {\n\t\t\tplugins = nil\n\t\t} else {\n\t\t\t// 检查是否有非空元素\n\t\t\thasNonEmpty := false\n\t\t\tfor _, p := range plugins {\n\t\t\t\tif p != \"\" {\n\t\t\t\t\thasNonEmpty = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 如果全是空字符串，视为未指定\n\t\t\tif !hasNonEmpty {\n\t\t\t\tplugins = nil\n\t\t\t} else {\n\t\t\t\t// 检查是否包含所有插件\n\t\t\t\tallPlugins := s.pluginManager.GetPlugins()\n\t\t\t\tallPluginNames := make([]string, 0, len(allPlugins))\n\t\t\t\tfor _, p := range allPlugins {\n\t\t\t\t\tallPluginNames = append(allPluginNames, strings.ToLower(p.Name()))\n\t\t\t\t}\n\n\t\t\t\t// 创建请求的插件名称集合（忽略空字符串）\n\t\t\t\trequestedPlugins := make([]string, 0, len(plugins))\n\t\t\t\tfor _, p := range plugins {\n\t\t\t\t\tif p != \"\" {\n\t\t\t\t\t\trequestedPlugins = append(requestedPlugins, strings.ToLower(p))\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// 如果请求的插件数量与所有插件数量相同，检查是否包含所有插件\n\t\t\t\tif len(requestedPlugins) == len(allPluginNames) {\n\t\t\t\t\t// 创建映射以便快速查找\n\t\t\t\t\tpluginMap := make(map[string]bool)\n\t\t\t\t\tfor _, p := range requestedPlugins {\n\t\t\t\t\t\tpluginMap[p] = true\n\t\t\t\t\t}\n\n\t\t\t\t\t// 检查是否包含所有插件\n\t\t\t\t\tallIncluded := true\n\t\t\t\t\tfor _, name := range allPluginNames {\n\t\t\t\t\t\tif !pluginMap[name] {\n\t\t\t\t\t\t\tallIncluded = false\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// 如果包含所有插件，统一设为nil\n\t\t\t\t\tif allIncluded {\n\t\t\t\t\t\tplugins = nil\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 如果未指定并发数，使用配置中的默认值\n\tif concurrency <= 0 {\n\t\tconcurrency = config.AppConfig.DefaultConcurrency\n\t}\n\n\t// 并行获取TG搜索和插件搜索结果\n\tvar tgResults []model.SearchResult\n\tvar pluginResults []model.SearchResult\n\t\n\tvar wg sync.WaitGroup\n\tvar tgErr, pluginErr error\n\t\n\t// 如果需要搜索TG\n\tif sourceType == \"all\" || sourceType == \"tg\" {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\ttgResults, tgErr = s.searchTG(keyword, channels, forceRefresh)\n\t\t}()\n\t}\n\t// 如果需要搜索插件（且插件功能已启用）\n\tif (sourceType == \"all\" || sourceType == \"plugin\") && config.AppConfig.AsyncPluginEnabled {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\t// 对于插件搜索，我们总是希望获取最新的缓存数据\n\t\t\t// 因此，即使forceRefresh=false，我们也需要确保获取到最新的缓存\n\t\t\tpluginResults, pluginErr = s.searchPlugins(keyword, plugins, forceRefresh, concurrency, ext)\n\t\t}()\n\t}\n\t\n\t// 等待所有搜索完成\n\twg.Wait()\n\t\n\t// 检查错误\n\tif tgErr != nil {\n\t\treturn model.SearchResponse{}, tgErr\n\t}\n\tif pluginErr != nil {\n\t\treturn model.SearchResponse{}, pluginErr\n\t}\n\t\n\t// 合并结果\n\tallResults := mergeSearchResults(tgResults, pluginResults)\n\n\t// 按照优化后的规则排序结果\n\tsortResultsByTimeAndKeywords(allResults)\n\n\t// 过滤结果，只保留有时间的结果或包含优先关键词的结果或高等级插件结果到Results中\n\tfilteredForResults := make([]model.SearchResult, 0, len(allResults))\n\tfor _, result := range allResults {\n\t\tsource := getResultSource(result)\n\t\tpluginLevel := getPluginLevelBySource(source)\n\t\t\n\t\t// 有时间的结果或包含优先关键词的结果或高等级插件(1-2级)结果保留在Results中\n\t\tif !result.Datetime.IsZero() || getKeywordPriority(result.Title) > 0 || pluginLevel <= 2 {\n\t\t\tfilteredForResults = append(filteredForResults, result)\n\t\t}\n\t}\n\n\t// 合并链接按网盘类型分组（使用所有过滤后的结果）\n\tmergedLinks := mergeResultsByType(allResults, keyword, cloudTypes)\n\n\t// 构建响应\n\tvar total int\n\tif resultType == \"merged_by_type\" {\n\t\t// 计算所有类型链接的总数\n\t\ttotal = 0\n\t\tfor _, links := range mergedLinks {\n\t\t\ttotal += len(links)\n\t\t}\n\t} else {\n\t\t// 只计算filteredForResults的数量\n\t\ttotal = len(filteredForResults)\n\t}\n\n\tresponse := model.SearchResponse{\n\t\tTotal:        total,\n\t\tResults:      filteredForResults, // 使用进一步过滤的结果\n\t\tMergedByType: mergedLinks,\n\t}\n\n\t// 根据resultType过滤返回结果\n\treturn filterResponseByType(response, resultType), nil\n}\n\n// filterResponseByType 根据结果类型过滤响应\nfunc filterResponseByType(response model.SearchResponse, resultType string) model.SearchResponse {\n\tswitch resultType {\n\tcase \"merged_by_type\":\n\t\t// 只返回MergedByType，Results设为nil，结合omitempty标签，JSON序列化时会忽略此字段\n\t\treturn model.SearchResponse{\n\t\t\tTotal:        response.Total,\n\t\t\tMergedByType: response.MergedByType,\n\t\t\tResults:      nil,\n\t\t}\n\tcase \"all\":\n\t\treturn response\n\tcase \"results\":\n\t\t// 只返回Results\n\t\treturn model.SearchResponse{\n\t\t\tTotal:   response.Total,\n\t\t\tResults: response.Results,\n\t\t}\n\tdefault:\n\t\t// // 默认返回全部\n\t\t// return response\n\t\treturn model.SearchResponse{\n\t\t\tTotal:        response.Total,\n\t\t\tMergedByType: response.MergedByType,\n\t\t\tResults:      nil,\n\t\t}\n\t}\n}\n\n// 根据时间和关键词排序结果\nfunc sortResultsByTimeAndKeywords(results []model.SearchResult) {\n\t// 1. 计算每个结果的综合得分\n\tscores := make([]ResultScore, len(results))\n\t\n\tfor i, result := range results {\n\t\tsource := getResultSource(result)\n\t\t\n\t\tscores[i] = ResultScore{\n\t\t\tResult:       result,\n\t\t\tTimeScore:    calculateTimeScore(result.Datetime),\n\t\t\tKeywordScore: getKeywordPriority(result.Title),\n\t\t\tPluginScore:  getPluginLevelScore(source),\n\t\t\tTotalScore:   0, // 稍后计算\n\t\t}\n\t\t\n\t\t// 计算综合得分\n\t\tscores[i].TotalScore = scores[i].TimeScore + \n\t\t\t\t\t\t\t  float64(scores[i].KeywordScore) + \n\t\t\t\t\t\t\t  float64(scores[i].PluginScore)\n\t}\n\t\n\t// 2. 按综合得分排序\n\tsort.Slice(scores, func(i, j int) bool {\n\t\treturn scores[i].TotalScore > scores[j].TotalScore\n\t})\n\t\n\t// 3. 更新原数组\n\tfor i, score := range scores {\n\t\tresults[i] = score.Result\n\t}\n}\n\n\n\n\n\n// 获取标题中包含优先关键词的优先级\nfunc getKeywordPriority(title string) int {\n\ttitle = strings.ToLower(title)\n\tfor i, keyword := range priorityKeywords {\n\t\tif strings.Contains(title, keyword) {\n\t\t\t// 返回优先级得分（数组索引越小，优先级越高，最高400分）\n\t\t\treturn (len(priorityKeywords) - i) * 70\n\t\t}\n\t}\n\treturn 0\n}\n\n// 搜索单个频道\nfunc (s *SearchService) searchChannel(keyword string, channel string) ([]model.SearchResult, error) {\n\t// 构建搜索URL\n\turl := util.BuildSearchURL(channel, keyword, \"\")\n\n\t// 使用全局HTTP客户端（已配置代理）\n\tclient := util.GetHTTPClient()\n\n\t// 创建一个带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)\n\tdefer cancel()\n\n\t// 创建请求\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", url, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 发送请求\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\t// 读取响应体\n\tbody, err := ioutil.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 解析响应\n\tresults, _, err := util.ParseSearchResults(string(body), channel)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn results, nil\n}\n\n// 用于从消息内容中提取链接-标题对应关系的函数\nfunc extractLinkTitlePairs(content string) map[string]string {\n\t// 首先尝试使用换行符分割的方法\n\tif strings.Contains(content, \"\\n\") {\n\t\treturn extractLinkTitlePairsWithNewlines(content)\n\t}\n\t\n\t// 如果没有换行符，使用正则表达式直接提取\n\treturn extractLinkTitlePairsWithoutNewlines(content)\n}\n\n// 处理有换行符的情况\nfunc extractLinkTitlePairsWithNewlines(content string) map[string]string {\n\t// 结果映射：链接URL -> 对应标题\n\tlinkTitleMap := make(map[string]string)\n\t\n\t// 按行分割内容\n\tlines := strings.Split(content, \"\\n\")\n\t\n\t// 链接正则表达式\n\tlinkRegex := regexp.MustCompile(`https?://[^\\s\"']+`)\n\t\n\t// 第一遍扫描：识别标题-链接对\n\tvar lastTitle string\n\tvar lastTitleIndex int\n\t\n\tfor i := 0; i < len(lines); i++ {\n\t\tline := strings.TrimSpace(lines[i])\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 检查当前行是否包含链接\n\t\tlinks := linkRegex.FindAllString(line, -1)\n\t\t\n\t\tif len(links) > 0 {\n\t\t\t// 当前行包含链接\n\t\t\t\n\t\t\t// 检查是否是标准链接行（以\"链接：\"、\"地址：\"等开头）\n\t\t\tisStandardLinkLine := isLinkLine(line)\n\t\t\t\n\t\t\tif isStandardLinkLine && lastTitle != \"\" {\n\t\t\t\t// 标准链接行，使用上一个标题\n\t\t\t\tfor _, link := range links {\n\t\t\t\t\tlinkTitleMap[link] = lastTitle\n\t\t\t\t}\n\t\t\t} else if !isStandardLinkLine {\n\t\t\t\t// 非标准链接行，可能是\"标题：链接\"格式\n\t\t\t\ttitleFromLine := extractTitleFromLinkLine(line)\n\t\t\t\tif titleFromLine != \"\" {\n\t\t\t\t\t// 是\"标题：链接\"格式\n\t\t\t\t\tfor _, link := range links {\n\t\t\t\t\t\tlinkTitleMap[link] = titleFromLine\n\t\t\t\t\t}\n\t\t\t\t} else if lastTitle != \"\" {\n\t\t\t\t\t// 其他情况，使用上一个标题\n\t\t\t\t\tfor _, link := range links {\n\t\t\t\t\t\tlinkTitleMap[link] = lastTitle\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// 当前行不包含链接，可能是标题行\n\t\t\t// 检查下一行是否为链接行\n\t\t\tif i+1 < len(lines) {\n\t\t\t\tnextLine := strings.TrimSpace(lines[i+1])\n\t\t\t\tif isLinkLine(nextLine) || linkRegex.MatchString(nextLine) {\n\t\t\t\t\t// 下一行是链接行或包含链接，当前行很可能是标题\n\t\t\t\t\tlastTitle = cleanTitle(line)\n\t\t\t\t\tlastTitleIndex = i\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// 最后一行，也可能是标题\n\t\t\t\tlastTitle = cleanTitle(line)\n\t\t\t\tlastTitleIndex = i\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 第二遍扫描：处理没有匹配到标题的链接\n\t// 为每个链接找到最近的上文标题\n\tfor i := 0; i < len(lines); i++ {\n\t\tline := strings.TrimSpace(lines[i])\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\tlinks := linkRegex.FindAllString(line, -1)\n\t\tif len(links) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\tfor _, link := range links {\n\t\t\tif _, exists := linkTitleMap[link]; !exists {\n\t\t\t\t// 链接没有匹配到标题，尝试找最近的上文标题\n\t\t\t\tnearestTitle := \"\"\n\t\t\t\t\n\t\t\t\t// 向上查找最近的标题行\n\t\t\t\tfor j := i - 1; j >= 0; j-- {\n\t\t\t\t\tif j == lastTitleIndex || (j+1 < len(lines) && \n\t\t\t\t\t\tlinkRegex.MatchString(lines[j+1]) && \n\t\t\t\t\t\t!linkRegex.MatchString(lines[j])) {\n\t\t\t\t\t\tcandidateTitle := cleanTitle(lines[j])\n\t\t\t\t\t\tif candidateTitle != \"\" {\n\t\t\t\t\t\t\tnearestTitle = candidateTitle\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tif nearestTitle != \"\" {\n\t\t\t\t\tlinkTitleMap[link] = nearestTitle\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\treturn linkTitleMap\n}\n\n// 处理没有换行符的情况\nfunc extractLinkTitlePairsWithoutNewlines(content string) map[string]string {\n\t// 结果映射：链接URL -> 对应标题\n\tlinkTitleMap := make(map[string]string)\n\t\n\t// 使用精确的网盘链接正则表达式集合，避免贪婪匹配\n\tlinkPatterns := []*regexp.Regexp{\n\t\tutil.TianyiPanPattern,  // 天翼云盘\n\t\tutil.BaiduPanPattern,   // 百度网盘\n\t\tutil.QuarkPanPattern,   // 夸克网盘\n\t\tutil.AliyunPanPattern,  // 阿里云盘\n\t\tutil.UCPanPattern,      // UC网盘\n\t\tutil.Pan123Pattern,     // 123网盘\n\t\tutil.Pan115Pattern,     // 115网盘\n\t\tutil.XunleiPanPattern,  // 迅雷网盘\n\t}\n\t\n\t// 收集所有链接及其位置\n\ttype linkInfo struct {\n\t\turl string\n\t\tpos int\n\t}\n\tvar allLinks []linkInfo\n\t\n\t// 使用各个精确正则表达式查找链接\n\tfor _, pattern := range linkPatterns {\n\t\tmatches := pattern.FindAllString(content, -1)\n\t\tfor _, match := range matches {\n\t\t\tpos := strings.Index(content, match)\n\t\t\tif pos >= 0 {\n\t\t\t\tallLinks = append(allLinks, linkInfo{url: match, pos: pos})\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 按位置排序\n\tfor i := 0; i < len(allLinks)-1; i++ {\n\t\tfor j := i + 1; j < len(allLinks); j++ {\n\t\t\tif allLinks[i].pos > allLinks[j].pos {\n\t\t\t\tallLinks[i], allLinks[j] = allLinks[j], allLinks[i]\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// URL标准化和去重\n\tuniqueLinks := make(map[string]string) // 标准化URL -> 原始URL\n\tvar links []string\n\t\n\tfor _, linkInfo := range allLinks {\n\t\t// 标准化URL（将URL编码转换为中文）\n\t\tnormalized := normalizeUrl(linkInfo.url)\n\t\t\n\t\t// 如果这个标准化URL还没有见过，则保留\n\t\tif _, exists := uniqueLinks[normalized]; !exists {\n\t\t\tuniqueLinks[normalized] = linkInfo.url\n\t\t\tlinks = append(links, linkInfo.url)\n\t\t}\n\t}\n\t\n\tif len(links) == 0 {\n\t\treturn linkTitleMap\n\t}\n\t\n\t// 使用链接位置分割内容\n\tsegments := make([]string, len(links)+1)\n\tlastPos := 0\n\t\n\t// 查找每个链接的位置，并提取链接前的文本作为段落\n\tfor i, link := range links {\n\t\tidx := strings.Index(content[lastPos:], link)\n\t\tif idx == -1 {\n\t\t\t// 链接在content中不存在，跳过\n\t\t\tcontinue\n\t\t}\n\t\tpos := idx + lastPos\n\t\tif pos > lastPos {\n\t\t\tsegments[i] = content[lastPos:pos]\n\t\t}\n\t\tlastPos = pos + len(link)\n\t}\n\t\n\t// 最后一段\n\tif lastPos < len(content) {\n\t\tsegments[len(links)] = content[lastPos:]\n\t}\n\t\n\t// 从每个段落中提取标题\n\tfor i, link := range links {\n\t\t// 当前链接的标题应该在当前段落的末尾\n\t\tvar title string\n\t\t\n\t\t// 如果是第一个链接\n\t\tif i == 0 {\n\t\t\t// 提取第一个段落作为标题\n\t\t\ttitle = extractTitleBeforeLink(segments[i])\n\t\t} else {\n\t\t\t// 从上一个链接后的文本中提取标题\n\t\t\ttitle = extractTitleBeforeLink(segments[i])\n\t\t}\n\t\t\n\t\t// 如果提取到了标题，保存链接-标题对应关系\n\t\tif title != \"\" {\n\t\t\tlinkTitleMap[link] = title\n\t\t}\n\t}\n\t\n\treturn linkTitleMap\n}\n\n// 从文本中提取链接前的标题\nfunc extractTitleBeforeLink(text string) string {\n\t// 移除可能的链接前缀词\n\ttext = strings.TrimSpace(text)\n\t\n\t// 查找\"链接：\"前的文本作为标题\n\tif idx := strings.Index(text, \"链接：\"); idx > 0 {\n\t\treturn cleanTitle(text[:idx])\n\t}\n\t\n\t// 尝试匹配常见的标题模式\n\ttitlePattern := regexp.MustCompile(`([^链地资网\\s]+?(?:\\([^)]+\\))?(?:\\s*\\d+K)?(?:\\s*臻彩)?(?:\\s*MAX)?(?:\\s*HDR)?(?:\\s*更(?:新)?\\d+集))$`)\n\tmatches := titlePattern.FindStringSubmatch(text)\n\tif len(matches) > 1 {\n\t\treturn cleanTitle(matches[1])\n\t}\n\t\n\treturn cleanTitle(text)\n}\n\n// 判断一行是否为链接行（主要包含链接的行）\nfunc isLinkLine(line string) bool {\n\tlowerLine := strings.ToLower(line)\n\treturn strings.HasPrefix(lowerLine, \"链接：\") || \n\t\t   strings.HasPrefix(lowerLine, \"地址：\") ||\n\t\t   strings.HasPrefix(lowerLine, \"资源地址：\") ||\n\t\t   strings.HasPrefix(lowerLine, \"网盘：\") ||\n\t\t   strings.HasPrefix(lowerLine, \"网盘地址：\") ||\n\t\t   strings.HasPrefix(lowerLine, \"链接:\")\n}\n\n// 从链接行中提取可能的标题\nfunc extractTitleFromLinkLine(line string) string {\n\t// 处理\"标题：链接\"格式\n\tparts := strings.SplitN(line, \"：\", 2)\n\tif len(parts) == 2 && !strings.Contains(parts[0], \"http\") &&\n\t\t!isLinkPrefix(parts[0]) {\n\t\treturn cleanTitle(parts[0])\n\t}\n\t\n\t// 处理\"标题:链接\"格式（半角冒号）\n\tparts = strings.SplitN(line, \":\", 2)\n\tif len(parts) == 2 && !strings.Contains(parts[0], \"http\") &&\n\t\t!isLinkPrefix(parts[0]) {\n\t\treturn cleanTitle(parts[0])\n\t}\n\t\n\treturn \"\"\n}\n\n// 判断是否为链接前缀词（包括网盘名称）\nfunc isLinkPrefix(text string) bool {\n\ttext = strings.ToLower(strings.TrimSpace(text))\n\t\n\t// 标准链接前缀词\n\tif text == \"链接\" || \n\t   text == \"地址\" || \n\t   text == \"资源地址\" || \n\t   text == \"网盘\" || \n\t   text == \"网盘地址\" {\n\t\treturn true\n\t}\n\t\n\t// 网盘名称（防止误将网盘名称当作标题）\n\tcloudDiskNames := []string{\n\t\t// 夸克网盘\n\t\t\"夸克\", \"夸克网盘\", \"quark\", \"夸克云盘\",\n\t\t\n\t\t// 百度网盘\n\t\t\"百度\", \"百度网盘\", \"baidu\", \"百度云\", \"bdwp\", \"bdpan\",\n\t\t\n\t\t// 迅雷网盘\n\t\t\"迅雷\", \"迅雷网盘\", \"xunlei\", \"迅雷云盘\",\n\t\t\n\t\t// 115网盘\n\t\t\"115\", \"115网盘\", \"115云盘\",\n\t\t\n\t\t// 123网盘\n\t\t\"123\", \"123pan\", \"123网盘\", \"123云盘\",\n\t\t\n\t\t// 阿里云盘\n\t\t\"阿里\", \"阿里云\", \"阿里云盘\", \"aliyun\", \"alipan\", \"阿里网盘\",\n\t\t\n\t\t// 天翼云盘\n\t\t\"天翼\", \"天翼云\", \"天翼云盘\", \"tianyi\", \"天翼网盘\",\n\t\t\n\t\t// UC网盘\n\t\t\"uc\", \"uc网盘\", \"uc云盘\",\n\t\t\n\t\t// 移动云盘\n\t\t\"移动\", \"移动云\", \"移动云盘\", \"caiyun\", \"彩云\",\n\t\t\n\t\t// PikPak\n\t\t\"pikpak\", \"pikpak网盘\",\n\t}\n\t\n\tfor _, name := range cloudDiskNames {\n\t\tif text == name {\n\t\t\treturn true\n\t\t}\n\t}\n\t\n\treturn false\n}\n\n// 清理标题文本\nfunc cleanTitle(title string) string {\n\t// 移除常见的无关前缀\n\ttitle = strings.TrimSpace(title)\n\ttitle = strings.TrimPrefix(title, \"名称：\")\n\ttitle = strings.TrimPrefix(title, \"标题：\")\n\ttitle = strings.TrimPrefix(title, \"片名：\")\n\ttitle = strings.TrimPrefix(title, \"名称:\")\n\ttitle = strings.TrimPrefix(title, \"标题:\")\n\ttitle = strings.TrimPrefix(title, \"片名:\")\n\t\n\t// 移除表情符号和特殊字符\n\temojiRegex := regexp.MustCompile(`[\\p{So}\\p{Sk}]`)\n\ttitle = emojiRegex.ReplaceAllString(title, \"\")\n\t\n\treturn strings.TrimSpace(title)\n}\n\n// 判断一行是否为空或只包含空白字符\nfunc isEmpty(line string) bool {\n\treturn strings.TrimSpace(line) == \"\"\n}\n\n// 将搜索结果按网盘类型分组\nfunc mergeResultsByType(results []model.SearchResult, keyword string, cloudTypes []string) model.MergedLinks {\n\t// 创建合并结果的映射\n\tmergedLinks := make(model.MergedLinks, 12) // 预分配容量，假设有12种不同的网盘类型\n\n\t// 用于去重的映射，键为URL\n\tuniqueLinks := make(map[string]model.MergedLink)\n\n\t// 将关键词转为小写，用于不区分大小写的匹配\n\tlowerKeyword := strings.ToLower(keyword)\n\n\t// 遍历所有搜索结果\n\tfor _, result := range results {\n\t\t// 提取消息中的链接-标题对应关系\n\t\tlinkTitleMap := extractLinkTitlePairs(result.Content)\n\t\t\n\t\t// 如果没有从内容中提取到标题，尝试直接从内容中匹配\n\t\tif len(linkTitleMap) == 0 && len(result.Links) > 0 && !strings.Contains(result.Content, \"\\n\") {\n\t\t\t// 这是没有换行符的情况，尝试直接匹配\n\t\t\tcontent := result.Content\n\t\t\t\n\t\t\t// 支持多种网盘链接前缀\n\t\t\tlinkPrefixes := []string{\"天翼链接：\", \"百度链接：\", \"夸克链接：\", \"阿里链接：\", \"UC链接：\", \"115链接：\", \"迅雷链接：\", \"123链接：\", \"链接：\"}\n\t\t\t\n\t\t\tvar parts []string\n\t\t\t\n\t\t\t// 尝试找到匹配的前缀\n\t\t\tfor _, prefix := range linkPrefixes {\n\t\t\t\tif strings.Contains(content, prefix) {\n\t\t\t\t\tparts = strings.Split(content, prefix)\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 如果找到了匹配的前缀并且分割成功\n\t\t\tif len(parts) > 1 && len(result.Links) <= len(parts)-1 {\n\t\t\t\t// 第一部分是第一个标题\n\t\t\t\ttitles := make([]string, 0, len(parts))\n\t\t\t\ttitles = append(titles, cleanTitle(parts[0]))\n\t\t\t\t\n\t\t\t\t// 处理每个包含链接的部分，提取标题\n\t\t\t\tfor i := 1; i < len(parts)-1; i++ {\n\t\t\t\t\tpart := parts[i]\n\t\t\t\t\t// 找到链接的结束位置，使用更通用的分隔符\n\t\t\t\t\tlinkEnd := -1\n\t\t\t\t\tfor j, c := range part {\n\t\t\t\t\t\t// 扩展分隔符列表，包含更多可能的字符\n\t\t\t\t\t\tif c == ' ' || c == '窃' || c == '东' || c == '迎' || c == '千' || c == '我' || c == '恋' || c == '将' || c == '野' || \n\t\t\t\t\t\t   c == '合' || c == '集' || c == '天' || c == '翼' || c == '网' || c == '盘' || c == '(' || c == '（' {\n\t\t\t\t\t\t\tlinkEnd = j\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\tif linkEnd > 0 {\n\t\t\t\t\t\t// 提取标题\n\t\t\t\t\t\ttitle := cleanTitle(part[linkEnd:])\n\t\t\t\t\t\ttitles = append(titles, title)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// 将标题与链接关联\n\t\t\t\tfor i, link := range result.Links {\n\t\t\t\t\tif i < len(titles) {\n\t\t\t\t\t\tlinkTitleMap[link.URL] = titles[i]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\tfor _, link := range result.Links {\n\t\t\t// 优先使用链接的WorkTitle字段，如果为空则回退到传统方式\n\t\t\ttitle := result.Title // 默认使用消息标题\n\t\t\t\n\t\t\tif link.WorkTitle != \"\" {\n\t\t\t\t// 如果链接有WorkTitle字段，优先使用\n\t\t\t\ttitle = link.WorkTitle\n\t\t\t} else {\n\t\t\t\t// 如果没有WorkTitle，使用传统方式从映射中获取该链接对应的标题\n\t\t\t\t// 查找完全匹配的链接\n\t\t\t\tif specificTitle, found := linkTitleMap[link.URL]; found && specificTitle != \"\" {\n\t\t\t\t\ttitle = specificTitle // 如果找到特定标题，则使用它\n\t\t\t\t} else {\n\t\t\t\t\t// 如果没有找到完全匹配的链接，尝试查找前缀匹配的链接\n\t\t\t\t\tfor mappedLink, mappedTitle := range linkTitleMap {\n\t\t\t\t\t\tif strings.HasPrefix(mappedLink, link.URL) {\n\t\t\t\t\t\t\ttitle = mappedTitle\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 检查插件是否需要跳过Service层过滤\n\t\t\tvar skipKeywordFilter bool = false\n\t\t\tif result.UniqueID != \"\" && strings.Contains(result.UniqueID, \"-\") {\n\t\t\t\tparts := strings.SplitN(result.UniqueID, \"-\", 2)\n\t\t\t\tif len(parts) >= 1 {\n\t\t\t\t\tpluginName := parts[0]\n\t\t\t\t\t// 通过插件注册表动态获取过滤设置\n\t\t\t\t\tif pluginInstance, exists := plugin.GetPluginByName(pluginName); exists {\n\t\t\t\t\t\tskipKeywordFilter = pluginInstance.SkipServiceFilter()\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 关键词过滤：现在我们有了准确的链接-标题对应关系，只需检查每个链接的具体标题\n\t\t\tif !skipKeywordFilter && keyword != \"\" {\n\t\t\t\t// 只检查链接的具体标题，无论是TG来源还是插件来源\n\t\t\t\tif !strings.Contains(strings.ToLower(title), lowerKeyword) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 确定数据来源\n\t\t\tvar source string\n\t\t\tif result.Channel != \"\" {\n\t\t\t\t// 来自TG频道\n\t\t\t\tsource = \"tg:\" + result.Channel\n\t\t\t} else if result.UniqueID != \"\" && strings.Contains(result.UniqueID, \"-\") {\n\t\t\t\t// 来自插件：UniqueID格式通常为 \"插件名-ID\"\n\t\t\t\tparts := strings.SplitN(result.UniqueID, \"-\", 2)\n\t\t\t\tif len(parts) >= 1 {\n\t\t\t\t\tsource = \"plugin:\" + parts[0]\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// 无法确定来源，使用默认值\n\t\t\t\tsource = \"unknown\"\n\t\t\t}\n\t\t\t\n\t\t\t// 赋值给Note前，支持多个关键词裁剪\n\t\t\ttitle = util.CutTitleByKeywords(title, []string{\"简介\", \"描述\"})\n\t\t\t\n\t\t\t// 优先使用链接自己的时间，如果没有则使用搜索结果的时间\n\t\t\tlinkDatetime := result.Datetime\n\t\t\tif !link.Datetime.IsZero() {\n\t\t\t\tlinkDatetime = link.Datetime\n\t\t\t}\n\t\t\t\n\t\t\tmergedLink := model.MergedLink{\n\t\t\t\tURL:      link.URL,\n\t\t\t\tPassword: link.Password,\n\t\t\t\tNote:     title, // 使用找到的特定标题\n\t\t\t\tDatetime: linkDatetime,\n\t\t\t\tSource:   source, // 添加数据来源字段\n\t\t\t\tImages:   result.Images, // 添加TG消息中的图片链接\n\t\t\t}\n\n\t\t\t// 检查是否已存在相同URL的链接\n\t\t\tif existingLink, exists := uniqueLinks[link.URL]; exists {\n\t\t\t\t// 如果已存在，只有当当前链接的时间更新时才替换\n\t\t\t\tif mergedLink.Datetime.After(existingLink.Datetime) {\n\t\t\t\t\tuniqueLinks[link.URL] = mergedLink\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// 如果不存在，直接添加\n\t\t\t\tuniqueLinks[link.URL] = mergedLink\n\t\t\t}\n\t\t}\n\t}\n\n\t// 为保持排序顺序，按原始results顺序处理链接，而不是随机遍历map\n\t// 创建一个有序的链接列表，按原始results中的顺序\n\torderedLinks := make([]model.MergedLink, 0, len(uniqueLinks))\n\tlinkTypeMap := make(map[string]string) // URL -> Type的映射\n\t\n\t// 按原始results的顺序收集唯一链接\n\tfor _, result := range results {\n\t\tfor _, link := range result.Links {\n\t\t\tif mergedLink, exists := uniqueLinks[link.URL]; exists {\n\t\t\t\t// 检查是否已经添加过这个链接\n\t\t\t\tfound := false\n\t\t\t\tfor _, existing := range orderedLinks {\n\t\t\t\t\tif existing.URL == link.URL {\n\t\t\t\t\t\tfound = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif !found {\n\t\t\t\t\torderedLinks = append(orderedLinks, mergedLink)\n\t\t\t\t\tlinkTypeMap[link.URL] = link.Type\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 将有序链接按类型分组\n\tfor _, mergedLink := range orderedLinks {\n\t\t// 从预建的映射中获取链接类型\n\t\tlinkType := linkTypeMap[mergedLink.URL]\n\t\tif linkType == \"\" {\n\t\t\tlinkType = \"unknown\"\n\t\t}\n\n\t\t// 添加到对应类型的列表中\n\t\tmergedLinks[linkType] = append(mergedLinks[linkType], mergedLink)\n\t}\n\n\n\t// 如果指定了cloudTypes，则过滤结果\n\tif len(cloudTypes) > 0 {\n\t\t// 创建过滤后的结果映射\n\t\tfilteredLinks := make(model.MergedLinks)\n\t\t\n\t\t// 将cloudTypes转换为map以提高查找性能\n\t\tallowedTypes := make(map[string]bool)\n\t\tfor _, cloudType := range cloudTypes {\n\t\t\tallowedTypes[strings.ToLower(strings.TrimSpace(cloudType))] = true\n\t\t}\n\t\t\n\t\t// 只保留指定类型的链接\n\t\tfor linkType, links := range mergedLinks {\n\t\t\tif allowedTypes[strings.ToLower(linkType)] {\n\t\t\t\tfilteredLinks[linkType] = links\n\t\t\t}\n\t\t}\n\t\t\n\t\treturn filteredLinks\n\t}\n\n\treturn mergedLinks\n}\n\n// searchTG 搜索TG频道\nfunc (s *SearchService) searchTG(keyword string, channels []string, forceRefresh bool) ([]model.SearchResult, error) {\n\t// 生成缓存键\n\tcacheKey := cache.GenerateTGCacheKey(keyword, channels)\n\t\n\t// 如果未启用强制刷新，尝试从缓存获取结果\n\tif !forceRefresh && cacheInitialized && config.AppConfig.CacheEnabled {\n\t\tvar data []byte\n\t\tvar hit bool\n\t\tvar err error\n\t\t\n\t\t// 使用增强版缓存\n\t\tif enhancedTwoLevelCache != nil {\n\t\t\tdata, hit, err = enhancedTwoLevelCache.Get(cacheKey)\n\t\t\t\n\t\t\tif err == nil && hit {\n\t\t\t\tvar results []model.SearchResult\n\t\t\t\tif err := enhancedTwoLevelCache.GetSerializer().Deserialize(data, &results); err == nil {\n\t\t\t\t\t// 直接返回缓存数据，不检查新鲜度\n\t\t\t\t\treturn results, nil\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 缓存未命中或强制刷新，执行实际搜索\n\tvar results []model.SearchResult\n\t\n\t// 使用工作池并行搜索多个频道\n\ttasks := make([]pool.Task, 0, len(channels))\n\t\n\tfor _, channel := range channels {\n\t\tch := channel // 创建副本，避免闭包问题\n\t\ttasks = append(tasks, func() interface{} {\n\t\t\tresults, err := s.searchChannel(keyword, ch)\n\t\t\tif err != nil {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn results\n\t\t})\n\t}\n\t\n\t// 执行搜索任务并获取结果\n\ttaskResults := pool.ExecuteBatchWithTimeout(tasks, len(channels), config.AppConfig.PluginTimeout)\n\t\n\t// 合并所有频道的结果\n\tfor _, result := range taskResults {\n\t\tif result != nil {\n\t\t\tchannelResults := result.([]model.SearchResult)\n\t\t\tresults = append(results, channelResults...)\n\t\t}\n\t}\n\t\n\t// 异步缓存结果\n\tif cacheInitialized && config.AppConfig.CacheEnabled {\n\t\tgo func(res []model.SearchResult) {\n\t\t\tttl := time.Duration(config.AppConfig.CacheTTLMinutes) * time.Minute\n\t\t\t\n\t\t\t// 使用增强版缓存\n\t\t\tif enhancedTwoLevelCache != nil {\n\t\t\t\tdata, err := enhancedTwoLevelCache.GetSerializer().Serialize(res)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tenhancedTwoLevelCache.Set(cacheKey, data, ttl)\n\t\t\t}\n\t\t}(results)\n\t}\n\t\n\treturn results, nil\n}\n\n// searchPlugins 搜索插件\nfunc (s *SearchService) searchPlugins(keyword string, plugins []string, forceRefresh bool, concurrency int, ext map[string]interface{}) ([]model.SearchResult, error) {\n\t// 确保ext不为nil\n\tif ext == nil {\n\t\text = make(map[string]interface{})\n\t}\n\n\t// 关键：将forceRefresh同步到插件ext[\"refresh\"]\n    if forceRefresh {\n        ext[\"refresh\"] = true\n    }\n\t\n\t// 生成缓存键\n\tcacheKey := cache.GeneratePluginCacheKey(keyword, plugins)\n\t\n\t\n\t// 如果未启用强制刷新，尝试从缓存获取结果\n\tif !forceRefresh && cacheInitialized && config.AppConfig.CacheEnabled {\n\t\tvar data []byte\n\t\tvar hit bool\n\t\tvar err error\n\t\t\n\t\t// 使用增强版缓存\n\t\tif enhancedTwoLevelCache != nil {\n\t\t\t\n\t\t\t// 使用Get方法，它会检查磁盘缓存是否有更新\n\t\t\t// 如果磁盘缓存比内存缓存更新，会自动更新内存缓存并返回最新数据\n\t\t\tdata, hit, err = enhancedTwoLevelCache.Get(cacheKey)\n\t\t\t\n\t\t\tif err == nil && hit {\n\t\t\t\tvar results []model.SearchResult\n\t\t\t\tif err := enhancedTwoLevelCache.GetSerializer().Deserialize(data, &results); err == nil {\n\t\t\t\t\t// 返回缓存数据\n\t\t\t\t\tfmt.Printf(\"✅ [%s] 命中缓存 结果数: %d\\n\", keyword,  len(results))\n\t\t\t\t\treturn results, nil\n\t\t\t\t} else {\n\t\t\t\t\tdisplayKey := cacheKey[:8] + \"...\"\n\t\t\t\t\tfmt.Printf(\"[主服务] 缓存反序列化失败: %s(关键词:%s) | 错误: %v\\n\", displayKey, keyword, err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 缓存未命中或强制刷新，执行实际搜索\n\t\n\t// 获取所有可用插件\n\tvar availablePlugins []plugin.AsyncSearchPlugin\n\tif s.pluginManager != nil {\n\t\tallPlugins := s.pluginManager.GetPlugins()\n\t\t\n\t\t// 确保plugins不为nil并且有非空元素\n\t\thasPlugins := plugins != nil && len(plugins) > 0\n\t\thasNonEmptyPlugin := false\n\t\t\n\t\tif hasPlugins {\n\t\t\tfor _, p := range plugins {\n\t\t\t\tif p != \"\" {\n\t\t\t\t\thasNonEmptyPlugin = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 只有当plugins数组包含非空元素时才进行过滤\n\t\tif hasPlugins && hasNonEmptyPlugin {\n\t\t\tpluginMap := make(map[string]bool)\n\t\t\tfor _, p := range plugins {\n\t\t\t\tif p != \"\" { // 忽略空字符串\n\t\t\t\t\tpluginMap[strings.ToLower(p)] = true\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tfor _, p := range allPlugins {\n\t\t\t\tif pluginMap[strings.ToLower(p.Name())] {\n\t\t\t\t\tavailablePlugins = append(availablePlugins, p)\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// 如果plugins为nil、空数组或只包含空字符串，视为未指定，使用所有插件\n\t\t\tavailablePlugins = allPlugins\n\t\t}\n\t}\n\t\n\t// 控制并发数\n\tif concurrency <= 0 {\n\t\t// 使用配置中的默认值\n\t\tconcurrency = config.AppConfig.DefaultConcurrency\n\t}\n\t\n\t// 使用工作池执行并行搜索\n\ttasks := make([]pool.Task, 0, len(availablePlugins))\n\tfor _, p := range availablePlugins {\n\t\tplugin := p // 创建副本，避免闭包问题\n\t\ttasks = append(tasks, func() interface{} {\n\t\t\t// 设置主缓存键和当前关键词\n\t\t\tplugin.SetMainCacheKey(cacheKey)\n\t\t\tplugin.SetCurrentKeyword(keyword)\n\t\t\t\n\t\t\t// 调用异步插件的AsyncSearch方法\n\t\t\tresults, err := plugin.AsyncSearch(keyword, func(client *http.Client, kw string, extParams map[string]interface{}) ([]model.SearchResult, error) {\n\t\t\t\t// 使用插件的Search方法作为搜索函数\n\t\t\t\treturn plugin.Search(kw, extParams)\n\t\t\t}, cacheKey, ext)\n\t\t\t\n\t\t\tif err != nil {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn results\n\t\t})\n\t}\n\t\n\t// 执行搜索任务并获取结果\n\tresults := pool.ExecuteBatchWithTimeout(tasks, concurrency, config.AppConfig.PluginTimeout)\n\t\n\t// 合并所有插件的结果，过滤掉无链接的结果\n\tvar allResults []model.SearchResult\n\tfor _, result := range results {\n\t\tif result != nil {\n\t\t\tpluginResults := result.([]model.SearchResult)\n\t\t\t// 只添加有链接的结果到最终结果中\n\t\t\tfor _, pluginResult := range pluginResults {\n\t\t\t\tif len(pluginResult.Links) > 0 {\n\t\t\t\t\tallResults = append(allResults, pluginResult)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 恢复主程序缓存更新：确保最终合并结果被正确缓存\n\tif cacheInitialized && config.AppConfig.CacheEnabled {\n\t\tgo func(res []model.SearchResult, kw string, key string) {\n\t\t\tttl := time.Duration(config.AppConfig.CacheTTLMinutes) * time.Minute\n\t\t\t\n\t\t\t// 使用增强版缓存，确保与异步插件使用相同的序列化器\n\t\t\tif enhancedTwoLevelCache != nil {\n\t\t\t\tdata, err := enhancedTwoLevelCache.GetSerializer().Serialize(res)\n\t\t\t\tif err != nil {\n\t\t\t\t\tfmt.Printf(\"[主程序] 缓存序列化失败: %s | 错误: %v\\n\", key, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t\n\t\t\t// 主程序最后更新，覆盖可能有问题的异步插件缓存\n\t\t\t// 使用同步方式确保数据写入磁盘\n\t\t\tenhancedTwoLevelCache.SetBothLevels(key, data, ttl)\n\t\t\t\tif config.AppConfig != nil && config.AppConfig.AsyncLogEnabled {\n\t\t\t\t\tfmt.Printf(\"[主程序] 缓存更新完成: %s | 结果数: %d\", \n\t\t\t\t\t\tkey, len(res))\n\t\t\t\t}\n\t\t\t}\n\t\t}(allResults, keyword, cacheKey)\n\t}\n\t\n\treturn allResults, nil\n}\n\n\n\n// GetPluginManager 获取插件管理器\nfunc (s *SearchService) GetPluginManager() *plugin.PluginManager {\n\treturn s.pluginManager\n}\n\n// =============================================================================\n// 轻量级插件优先级排序实现\n// =============================================================================\n\n// ResultScore 搜索结果评分结构\ntype ResultScore struct {\n\tResult       model.SearchResult\n\tTimeScore    float64  // 时间得分\n\tKeywordScore int      // 关键词得分  \n\tPluginScore  int      // 插件等级得分\n\tTotalScore   float64  // 综合得分\n}\n\n// 插件等级缓存\nvar (\n\tpluginLevelCache = sync.Map{} // 插件等级缓存\n)\n\n// getResultSource 从SearchResult推断数据来源\nfunc getResultSource(result model.SearchResult) string {\n\tif result.Channel != \"\" {\n\t\t// 来自TG频道\n\t\treturn \"tg:\" + result.Channel\n\t} else if result.UniqueID != \"\" && strings.Contains(result.UniqueID, \"-\") {\n\t\t// 来自插件：UniqueID格式通常为 \"插件名-ID\"\n\t\tparts := strings.SplitN(result.UniqueID, \"-\", 2)\n\t\tif len(parts) >= 1 {\n\t\t\treturn \"plugin:\" + parts[0]\n\t\t}\n\t}\n\treturn \"unknown\"\n}\n\n// getPluginLevelBySource 根据来源获取插件等级\nfunc getPluginLevelBySource(source string) int {\n\t// 尝试从缓存获取\n\tif level, ok := pluginLevelCache.Load(source); ok {\n\t\treturn level.(int)\n\t}\n\t\n\tparts := strings.Split(source, \":\")\n\tif len(parts) != 2 {\n\t\tpluginLevelCache.Store(source, 3)\n\t\treturn 3 // 默认等级\n\t}\n\t\n\tif parts[0] == \"tg\" {\n\t\tpluginLevelCache.Store(source, 3)\n\t\treturn 3 // TG搜索等同于等级3\n\t}\n\t\n\tif parts[0] == \"plugin\" {\n\t\tlevel := getPluginPriorityByName(parts[1])\n\t\tpluginLevelCache.Store(source, level)\n\t\treturn level\n\t}\n\t\n\tpluginLevelCache.Store(source, 3)\n\treturn 3\n}\n\n// getPluginPriorityByName 根据插件名获取优先级\nfunc getPluginPriorityByName(pluginName string) int {\n\t// 从插件管理器动态获取真实的优先级 (O(1)哈希查找)\n\tif pluginInstance, exists := plugin.GetPluginByName(pluginName); exists {\n\t\treturn pluginInstance.Priority()\n\t}\n\treturn 3 // 默认等级\n}\n\n// getPluginLevelScore 获取插件等级得分\nfunc getPluginLevelScore(source string) int {\n\tlevel := getPluginLevelBySource(source)\n\t\n\tswitch level {\n\tcase 1:\n\t\treturn 1000  // 等级1插件：1000分\n\tcase 2:\n\t\treturn 500   // 等级2插件：500分\n\tcase 3:\n\t\treturn 0     // 等级3插件：0分\n\tcase 4:\n\t\treturn -200  // 等级4插件：-200分\n\tdefault:\n\t\treturn 0     // 默认使用等级3得分\n\t}\n}\n\n// calculateTimeScore 计算时间得分\nfunc calculateTimeScore(datetime time.Time) float64 {\n\tif datetime.IsZero() {\n\t\treturn 0 // 无时间信息得0分\n\t}\n\t\n\tnow := time.Now()\n\tdaysDiff := now.Sub(datetime).Hours() / 24\n\t\n\t// 时间得分：越新得分越高，最大500分（增加时间权重）\n\tswitch {\n\tcase daysDiff <= 1:\n\t\treturn 500  // 1天内\n\tcase daysDiff <= 3:\n\t\treturn 400  // 3天内\n\tcase daysDiff <= 7:\n\t\treturn 300  // 1周内\n\tcase daysDiff <= 30:\n\t\treturn 200  // 1月内\n\tcase daysDiff <= 90:\n\t\treturn 100  // 3月内\n\tcase daysDiff <= 365:\n\t\treturn 50   // 1年内\n\tdefault:\n\t\treturn 20   // 1年以上\n\t}\n}\n\n\n"
  },
  {
    "path": "typescript/package.json",
    "content": "{\n  \"name\": \"@pansou/mcp-server\",\n  \"version\": \"1.0.0\",\n  \"description\": \"MCP server for PanSou netdisk search service\",\n  \"main\": \"dist/index.js\",\n  \"bin\": {\n    \"pansou-mcp-server\": \"dist/index.js\"\n  },\n  \"type\": \"module\",\n  \"scripts\": {\n    \"build\": \"tsc\",\n    \"start\": \"node dist/index.js\",\n    \"dev\": \"ts-node src/index.ts\",\n    \"prepare\": \"npm run build\",\n    \"test\": \"jest\",\n    \"lint\": \"eslint src --ext .ts\",\n    \"lint:fix\": \"eslint src --ext .ts --fix\"\n  },\n  \"keywords\": [\n    \"mcp\",\n    \"model-context-protocol\",\n    \"pansou\",\n    \"netdisk\",\n    \"search\",\n    \"claude\"\n  ],\n  \"license\": \"MIT\",\n  \"engines\": {\n    \"node\": \">=18.0.0\"\n  },\n  \"dependencies\": {\n    \"@modelcontextprotocol/sdk\": \"^1.0.0\",\n    \"axios\": \"^1.6.0\",\n    \"zod\": \"^3.22.0\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^20.0.0\",\n    \"@typescript-eslint/eslint-plugin\": \"^6.0.0\",\n    \"@typescript-eslint/parser\": \"^6.0.0\",\n    \"eslint\": \"^8.0.0\",\n    \"jest\": \"^29.0.0\",\n    \"ts-jest\": \"^29.0.0\",\n    \"ts-node\": \"^10.0.0\",\n    \"typescript\": \"^5.0.0\"\n  },\n  \"files\": [\n    \"dist/**/*\",\n    \"README.md\",\n    \"LICENSE\"\n  ]\n}"
  },
  {
    "path": "typescript/src/index.ts",
    "content": "#!/usr/bin/env node\n\nimport { Server } from '@modelcontextprotocol/sdk/server/index.js';\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';\nimport {\n  CallToolRequestSchema,\n  ErrorCode,\n  ListResourcesRequestSchema,\n  ListToolsRequestSchema,\n  McpError,\n  ReadResourceRequestSchema,\n} from '@modelcontextprotocol/sdk/types.js';\n\nimport { loadConfig } from './utils/config.js';\nimport { HttpClient } from './utils/http-client.js';\nimport { BackendManager } from './utils/backend-manager.js';\nimport { searchTool, executeSearchTool } from './tools/search.js';\nimport { healthTool, executeHealthTool } from './tools/health.js';\nimport { startBackendTool, executeStartBackendTool } from './tools/start-backend.js';\n\n/**\n * PanSou MCP服务器\n */\nclass PanSouMCPServer {\n  private server: Server;\n  private httpClient: HttpClient;\n  private backendManager: BackendManager;\n  private config: any;\n\n  constructor() {\n    this.server = new Server(\n      {\n        name: 'pansou-mcp-server',\n        version: '1.0.0',\n      },\n      {\n        capabilities: {\n          tools: {},\n          resources: {},\n        },\n      }\n    );\n\n    // 加载配置\n    this.config = loadConfig();\n    this.httpClient = new HttpClient(this.config);\n    this.backendManager = new BackendManager(this.config, this.httpClient);\n\n    this.setupHandlers();\n    this.setupProcessHandlers();\n  }\n\n  /**\n   * 设置请求处理器\n   */\n  private setupHandlers(): void {\n    // 工具列表处理器\n    this.server.setRequestHandler(ListToolsRequestSchema, async () => {\n      return {\n        tools: [healthTool, startBackendTool, searchTool],\n      };\n    });\n\n    // 工具调用处理器\n    this.server.setRequestHandler(CallToolRequestSchema, async (request) => {\n      const { name, arguments: args } = request.params;\n\n      // 记录活动，重置空闲计时器\n      this.backendManager.recordActivity();\n\n      try {\n        switch (name) {\n          case 'check_service_health':\n            const healthResult = await executeHealthTool(args, this.httpClient);\n            return {\n              content: [\n                {\n                  type: 'text',\n                  text: healthResult,\n                },\n              ],\n            };\n\n          case 'start_backend':\n            const startResult = await executeStartBackendTool(args, this.httpClient, this.config);\n            return {\n              content: [\n                {\n                  type: 'text',\n                  text: startResult,\n                },\n              ],\n            };\n\n          case 'search_netdisk':\n            const searchResult = await executeSearchTool(args, this.httpClient);\n            return {\n              content: [\n                {\n                  type: 'text',\n                  text: searchResult,\n                },\n              ],\n            };\n\n          default:\n            throw new McpError(\n              ErrorCode.MethodNotFound,\n              `未知工具: ${name}`\n            );\n        }\n      } catch (error) {\n        if (error instanceof McpError) {\n          throw error;\n        }\n\n        throw new McpError(\n          ErrorCode.InternalError,\n          `工具执行失败: ${error instanceof Error ? error.message : String(error)}`\n        );\n      }\n    });\n\n    // 资源列表处理器\n    this.server.setRequestHandler(ListResourcesRequestSchema, async () => {\n      return {\n        resources: [\n          {\n            uri: 'pansou://plugins',\n            name: '可用插件列表',\n            description: '获取当前可用的搜索插件列表',\n            mimeType: 'application/json',\n          },\n          {\n            uri: 'pansou://channels',\n            name: '可用频道列表',\n            description: '获取当前可用的TG频道列表',\n            mimeType: 'application/json',\n          },\n          {\n            uri: 'pansou://cloud-types',\n            name: '支持的网盘类型',\n            description: '获取支持的网盘类型列表',\n            mimeType: 'application/json',\n          },\n        ],\n      };\n    });\n\n    // 资源读取处理器\n    this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {\n      const { uri } = request.params;\n\n      // 记录活动，重置空闲计时器\n      this.backendManager.recordActivity();\n\n      try {\n        switch (uri) {\n          case 'pansou://plugins':\n            return await this.getPluginsResource();\n\n          case 'pansou://channels':\n            return await this.getChannelsResource();\n\n          case 'pansou://cloud-types':\n            return await this.getCloudTypesResource();\n\n          default:\n            throw new McpError(\n              ErrorCode.InvalidRequest,\n              `未知资源URI: ${uri}`\n            );\n        }\n      } catch (error) {\n        if (error instanceof McpError) {\n          throw error;\n        }\n\n        throw new McpError(\n          ErrorCode.InternalError,\n          `资源读取失败: ${error instanceof Error ? error.message : String(error)}`\n        );\n      }\n    });\n  }\n\n  /**\n   * 获取插件资源\n   */\n  private async getPluginsResource() {\n    try {\n      const healthData = await this.httpClient.checkHealth();\n      \n      const plugins = {\n        enabled: healthData.plugins_enabled || false,\n        count: healthData.plugin_count || 0,\n        list: healthData.plugins || [],\n      };\n\n      return {\n        contents: [\n          {\n            uri: 'pansou://plugins',\n            mimeType: 'application/json',\n            text: JSON.stringify(plugins, null, 2),\n          },\n        ],\n      };\n    } catch (error) {\n      throw new McpError(\n        ErrorCode.InternalError,\n        `获取插件信息失败: ${error instanceof Error ? error.message : String(error)}`\n      );\n    }\n  }\n\n  /**\n   * 获取频道资源\n   */\n  private async getChannelsResource() {\n    try {\n      const healthData = await this.httpClient.checkHealth();\n      \n      const channels = {\n        count: healthData.channels_count || 0,\n        list: healthData.channels || [],\n      };\n\n      return {\n        contents: [\n          {\n            uri: 'pansou://channels',\n            mimeType: 'application/json',\n            text: JSON.stringify(channels, null, 2),\n          },\n        ],\n      };\n    } catch (error) {\n      throw new McpError(\n        ErrorCode.InternalError,\n        `获取频道信息失败: ${error instanceof Error ? error.message : String(error)}`\n      );\n    }\n  }\n\n  /**\n   * 获取网盘类型资源\n   */\n  private async getCloudTypesResource() {\n    const cloudTypes = {\n      supported: [\n        'baidu',    // 百度网盘\n        'aliyun',   // 阿里云盘\n        'quark',    // 夸克网盘\n        'tianyi',   // 天翼云盘\n        'uc',       // UC网盘\n        'mobile',   // 移动云盘\n        '115',      // 115网盘\n        'pikpak',   // PikPak\n        'xunlei',   // 迅雷网盘\n        '123',      // 123网盘\n        'magnet',   // 磁力链接\n        'ed2k',     // 电驴链接\n        'others'    // 其他\n      ],\n      description: {\n        'baidu': '百度网盘',\n        'aliyun': '阿里云盘',\n        'quark': '夸克网盘',\n        'tianyi': '天翼云盘',\n        'uc': 'UC网盘',\n        'mobile': '移动云盘',\n        '115': '115网盘',\n        'pikpak': 'PikPak',\n        'xunlei': '迅雷网盘',\n        '123': '123网盘',\n        'magnet': '磁力链接',\n        'ed2k': '电驴链接',\n        'others': '其他网盘'\n      }\n    };\n\n    return {\n      contents: [\n        {\n          uri: 'pansou://cloud-types',\n          mimeType: 'application/json',\n          text: JSON.stringify(cloudTypes, null, 2),\n        },\n      ],\n    };\n  }\n\n  /**\n   * 设置进程处理器\n   */\n  private setupProcessHandlers(): void {\n    // 处理优雅关闭\n    const gracefulShutdown = async (signal: string) => {\n      console.error(`\\n收到 ${signal} 信号，正在优雅关闭...`);\n      \n      if (this.config.autoStartBackend) {\n        // 延迟关闭后端服务\n        this.backendManager.scheduleShutdown();\n      }\n      \n      // 等待一小段时间让MCP客户端处理完当前请求\n      setTimeout(() => {\n        process.exit(0);\n      }, 1000);\n    };\n\n    process.on('SIGINT', () => gracefulShutdown('SIGINT'));\n    process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));\n    \n    // Windows特有的关闭事件\n    if (process.platform === 'win32') {\n      process.on('SIGBREAK', () => gracefulShutdown('SIGBREAK'));\n    }\n  }\n\n  /**\n   * 启动服务器\n   */\n  public async start(): Promise<void> {\n    // 如果启用了自动启动后端服务\n    if (this.config.autoStartBackend) {\n      console.error('检查后端服务状态...');\n      \n      // 在启动阶段启用静默模式，避免输出网络错误信息\n      this.httpClient.setSilentMode(true);\n      \n      const isRunning = await this.backendManager.isBackendRunning();\n      if (!isRunning) {\n        console.error('自动启动后端服务...');\n        const started = await this.backendManager.startBackend();\n        if (!started) {\n          console.error('后端服务启动失败，MCP服务器将继续运行但功能可能受限');\n        }\n      } else {\n        console.error('后端服务已在运行');\n      }\n      \n      // 启动完成后关闭静默模式\n      this.httpClient.setSilentMode(false);\n    }\n\n    const transport = new StdioServerTransport();\n    await this.server.connect(transport);\n    \n    // 输出启动信息到stderr，避免干扰MCP通信\n    console.error('PanSou MCP服务器已启动');\n    console.error(`服务地址: ${this.config.serverUrl}`);\n    console.error(`请求超时: ${this.config.requestTimeout}ms`);\n    console.error(`最大结果数: ${this.config.maxResults}`);\n    console.error(`自动启动后端: ${this.config.autoStartBackend ? '启用' : '禁用'}`);\n    // 空闲监控信息已在BackendManager构造函数中显示\n  }\n}\n\n/**\n * 主函数\n */\nasync function main(): Promise<void> {\n  try {\n    const server = new PanSouMCPServer();\n    await server.start();\n  } catch (error) {\n    console.error('服务器启动失败:', error);\n    process.exit(1);\n  }\n}\n\n// 处理未捕获的异常\nprocess.on('uncaughtException', (error) => {\n  console.error('未捕获的异常:', error);\n  process.exit(1);\n});\n\nprocess.on('unhandledRejection', (reason, promise) => {\n  console.error('未处理的Promise拒绝:', reason);\n  process.exit(1);\n});\n\n// 启动服务器\nif (import.meta.url === `file://${process.argv[1]}` || process.argv[1].endsWith('index.js')) {\n  main();\n}\n\nexport { PanSouMCPServer };"
  },
  {
    "path": "typescript/src/tools/health.ts",
    "content": "import { Tool } from '@modelcontextprotocol/sdk/types.js';\nimport { HttpClient } from '../utils/http-client.js';\n\n/**\n * 健康检查工具定义\n */\nexport const healthTool: Tool = {\n  name: 'check_service_health',\n  description: '检查PanSou服务的健康状态，获取服务信息、可用插件和频道列表',\n  inputSchema: {\n    type: 'object',\n    properties: {},\n    required: []\n  }\n};\n\n/**\n * 执行健康检查工具\n */\nexport async function executeHealthTool(args: unknown, httpClient: HttpClient): Promise<string> {\n  try {\n    // 执行健康检查\n    const healthData = await httpClient.checkHealth();\n    \n    // 格式化返回结果\n    return formatHealthResult(healthData, httpClient.getServerUrl());\n    \n  } catch (error) {\n    if (error instanceof Error) {\n      return formatErrorResult(error.message, httpClient.getServerUrl());\n    }\n    \n    return formatErrorResult(`健康检查失败: ${String(error)}`, httpClient.getServerUrl());\n  }\n}\n\n/**\n * 格式化健康检查结果\n */\nfunction formatHealthResult(healthData: any, serverUrl: string): string {\n  let output = `**PanSou服务健康检查**\\n\\n`;\n  \n  // 服务基本信息\n  output += `**服务地址**: ${serverUrl}\\n`;\n  output += `**服务状态**: ${healthData.status === 'ok' ? '正常' : '异常'}\\n\\n`;\n  \n  // 频道信息\n  output += `**TG频道信息**\\n`;\n  output += `   频道数量: ${healthData.channels_count || 0}\\n`;\n  if (healthData.channels && healthData.channels.length > 0) {\n    output += `   可用频道:\\n`;\n    healthData.channels.forEach((channel: string, index: number) => {\n      output += `      ${index + 1}. ${channel}\\n`;\n    });\n  } else {\n    output += `   未配置频道\\n`;\n  }\n  output += '\\n';\n  \n  // 插件信息\n  output += `**插件信息**\\n`;\n  output += `   插件功能: ${healthData.plugins_enabled ? '已启用' : '已禁用'}\\n`;\n  \n  if (healthData.plugins_enabled) {\n    output += `   插件数量: ${healthData.plugin_count || 0}\\n`;\n    if (healthData.plugins && healthData.plugins.length > 0) {\n      output += `   可用插件:\\n`;\n      \n      // 将插件按行显示，每行最多4个\n      const plugins = healthData.plugins;\n      for (let i = 0; i < plugins.length; i += 4) {\n        const row = plugins.slice(i, i + 4);\n        output += `      ${row.map((plugin: string, idx: number) => `${i + idx + 1}. ${plugin}`).join('  ')}\\n`;\n      }\n    } else {\n      output += `   未发现可用插件\\n`;\n    }\n  } else {\n    output += `   插件功能已禁用\\n`;\n  }\n  \n  output += '\\n';\n  \n  // 功能说明\n  output += `**功能说明**\\n`;\n  output += `   支持搜索多种网盘资源\\n`;\n  output += `   支持TG频道和插件双重搜索\\n`;\n  output += `   支持并发搜索，提升搜索速度\\n`;\n  output += `   支持缓存机制，避免重复请求\\n`;\n  output += `   支持按网盘类型过滤结果\\n`;\n  \n  return output;\n}\n\n/**\n * 格式化错误结果\n */\nfunction formatErrorResult(errorMessage: string, serverUrl: string): string {\n  let output = `**PanSou服务健康检查失败**\\n\\n`;\n  \n  output += `**服务地址**: ${serverUrl}\\n`;\n  output += `**错误信息**: ${errorMessage}\\n\\n`;\n  \n  output += `**可能的解决方案**:\\n`;\n  output += `   1. 检查PanSou服务是否正在运行\\n`;\n  output += `   2. 确认服务地址配置是否正确\\n`;\n  output += `   3. 检查网络连接是否正常\\n`;\n  output += `   4. 查看服务日志获取更多信息\\n\\n`;\n  \n  output += `**配置说明**:\\n`;\n  output += `   可通过环境变量 PANSOU_SERVER_URL 配置服务地址\\n`;\n  output += `   默认地址: http://localhost:8888\\n`;\n  \n  return output;\n}"
  },
  {
    "path": "typescript/src/tools/search.ts",
    "content": "import { Tool } from '@modelcontextprotocol/sdk/types.js';\nimport { z } from 'zod';\nimport { HttpClient, SearchRequest } from '../utils/http-client.js';\nimport { validateCloudTypes, validateSourceType, validateResultType, SUPPORTED_CLOUD_TYPES, SOURCE_TYPES, RESULT_TYPES } from '../utils/config.js';\n\n/**\n * 搜索工具参数验证模式\n */\nconst SearchToolArgsSchema = z.object({\n  keyword: z.string().min(1, '搜索关键词不能为空'),\n  channels: z.array(z.string()).optional().describe('TG频道列表，如: [\"tgsearchers3\", \"xxx\"]'),\n  plugins: z.array(z.string()).optional().describe('插件列表，如: [\"pansearch\", \"panta\"]'),\n  cloud_types: z.array(z.string()).optional().describe(`网盘类型过滤，支持: ${SUPPORTED_CLOUD_TYPES.join(', ')}`),\n  source_type: z.enum(['all', 'tg', 'plugin']).optional().default('all').describe('数据来源类型'),\n  force_refresh: z.boolean().optional().default(false).describe('强制刷新缓存'),\n  result_type: z.enum(['all', 'results', 'merge']).optional().default('merge').describe('结果类型'),\n  concurrency: z.number().int().min(0).optional().describe('并发搜索数量，0表示自动计算'),\n  ext_params: z.record(z.any()).optional().describe('扩展参数，传递给插件的自定义参数')\n});\n\nexport type SearchToolArgs = z.infer<typeof SearchToolArgsSchema>;\n\n/**\n * 搜索工具定义\n */\nexport const searchTool: Tool = {\n  name: 'search_netdisk',\n  description: '搜索网盘资源，支持多种网盘类型和搜索来源。可以搜索电影、电视剧、软件、文档等各类资源。',\n  inputSchema: {\n    type: 'object',\n    properties: {\n      keyword: {\n        type: 'string',\n        description: '搜索关键词，如：\"速度与激情\"、\"Python教程\"、\"Office 2021\"等'\n      },\n      channels: {\n        type: 'array',\n        items: { type: 'string' },\n        description: 'TG频道列表，指定要搜索的Telegram频道。不指定则使用默认配置的频道'\n      },\n      plugins: {\n        type: 'array',\n        items: { type: 'string' },\n        description: '插件列表，指定要使用的搜索插件。不指定则使用所有可用插件'\n      },\n      cloud_types: {\n        type: 'array',\n        items: { \n          type: 'string',\n          enum: [...SUPPORTED_CLOUD_TYPES]\n        },\n        description: `网盘类型过滤，只返回指定类型的网盘链接。支持: ${SUPPORTED_CLOUD_TYPES.join(', ')}`\n      },\n      source_type: {\n        type: 'string',\n        enum: [...SOURCE_TYPES],\n        default: 'all',\n        description: '数据来源类型：all(全部来源)、tg(仅Telegram)、plugin(仅插件)'\n      },\n      force_refresh: {\n        type: 'boolean',\n        default: false,\n        description: '强制刷新缓存，获取最新数据'\n      },\n      result_type: {\n        type: 'string',\n        enum: [...RESULT_TYPES],\n        default: 'merge',\n        description: '结果类型：all(返回所有结果)、results(仅返回results)、merge(仅返回按网盘类型分组的结果)'\n      },\n      concurrency: {\n        type: 'number',\n        description: '并发搜索数量，0或不指定则自动计算'\n      },\n      ext_params: {\n        type: 'object',\n        description: '扩展参数，用于传递给插件的自定义参数，如: {\"title_en\": \"Fast and Furious\", \"is_all\": true}'\n      }\n    },\n    required: ['keyword']\n  }\n};\n\n/**\n * 执行搜索工具\n */\nexport async function executeSearchTool(args: unknown, httpClient: HttpClient): Promise<string> {\n  try {\n    // 参数验证\n    const validatedArgs = SearchToolArgsSchema.parse(args);\n    \n    // 验证网盘类型\n    let cloudTypes: string[] | undefined;\n    if (validatedArgs.cloud_types) {\n      cloudTypes = validateCloudTypes(validatedArgs.cloud_types);\n    }\n    \n    // 验证数据来源类型\n    const sourceType = validateSourceType(validatedArgs.source_type);\n    \n    // 验证结果类型\n    const resultType = validateResultType(validatedArgs.result_type);\n    \n    // 检查后端服务状态\n    const isHealthy = await httpClient.checkHealth();\n    if (!isHealthy) {\n      throw new Error('后端服务未运行，请先启动后端服务。');\n    }\n    \n    // 构建搜索请求\n    const searchRequest: SearchRequest = {\n      kw: validatedArgs.keyword,\n      channels: validatedArgs.channels,\n      plugins: validatedArgs.plugins,\n      cloud_types: cloudTypes as any,\n      src: sourceType,\n      refresh: validatedArgs.force_refresh,\n      res: resultType,\n      conc: validatedArgs.concurrency,\n      ext: validatedArgs.ext_params\n    };\n    \n    // 执行搜索\n    const result = await httpClient.search(searchRequest);\n    \n    // 格式化返回结果\n    return formatSearchResult(result, validatedArgs.keyword, resultType);\n    \n  } catch (error) {\n    if (error instanceof z.ZodError) {\n      const errorMessages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');\n      throw new Error(`参数验证失败: ${errorMessages}`);\n    }\n    \n    if (error instanceof Error) {\n      console.error('搜索过程中发生错误:', {\n        message: error.message,\n        stack: error.stack,\n        name: error.name,\n        timestamp: new Date().toISOString(),\n        originalArgs: args\n      });\n      throw error;\n    }\n    \n    throw new Error(`搜索失败: ${String(error)}`);\n  }\n}\n\n/**\n * 格式化搜索结果\n */\nfunction formatSearchResult(result: any, keyword: string, resultType: string): string {\n  const { total, results, merged_by_type } = result;\n  \n  let output = `搜索关键词: \"${keyword}\"\\n`;\n  output += `找到 ${total} 个结果\\n\\n`;\n  \n  if (resultType === 'merge' && merged_by_type) {\n    // 按网盘类型分组显示\n    output += formatMergedResults(merged_by_type);\n  } else if (resultType === 'results' && results) {\n    // 显示详细结果\n    output += formatDetailedResults(results);\n  } else if (resultType === 'all') {\n    // 显示所有信息\n    if (merged_by_type) {\n      output += \"## 按网盘类型分组\\n\";\n      output += formatMergedResults(merged_by_type);\n    }\n    if (results && results.length > 0) {\n      output += \"\\n## 详细结果\\n\";\n      output += formatDetailedResults(results.slice(0, 10)); // 限制显示前10个详细结果\n    }\n  }\n  \n  return output;\n}\n\n/**\n * 格式化按网盘类型分组的结果\n */\nfunction formatMergedResults(mergedByType: Record<string, any[]>): string {\n  let output = '';\n  \n  const typeNames: Record<string, string> = {\n    'baidu': '百度网盘',\n    'aliyun': '阿里云盘',\n    'quark': '夸克网盘',\n    'tianyi': '天翼云盘',\n    'uc': 'UC网盘',\n    'mobile': '移动云盘',\n    '115': '115网盘',\n    'pikpak': 'PikPak',\n    'xunlei': '迅雷网盘',\n    '123': '123网盘',\n    'magnet': '磁力链接',\n    'ed2k': '电驴链接',\n    'others': '其他'\n  };\n  \n  for (const [type, links] of Object.entries(mergedByType)) {\n    if (links && links.length > 0) {\n      const typeName = typeNames[type] || `${type}`;\n      output += `### ${typeName} (${links.length}个)\\n`;\n      \n      links.slice(0, 5).forEach((link: any, index: number) => {\n        output += `${index + 1}. **${link.note || '未知标题'}**\\n`;\n        output += `   链接: ${link.url}\\n`;\n        if (link.password) {\n          output += `   密码: ${link.password}\\n`;\n        }\n        if (link.source) {\n          output += `   来源: ${link.source}\\n`;\n        }\n        output += `   时间: ${new Date(link.datetime).toLocaleString('zh-CN')}\\n\\n`;\n      });\n      \n      if (links.length > 5) {\n        output += `   ... 还有 ${links.length - 5} 个结果\\n\\n`;\n      }\n    }\n  }\n  \n  return output;\n}\n\n/**\n * 格式化详细结果\n */\nfunction formatDetailedResults(results: any[]): string {\n  let output = '';\n  \n  results.forEach((result: any, index: number) => {\n    output += `### ${index + 1}. ${result.title || '未知标题'}\\n`;\n    output += `频道: ${result.channel}\\n`;\n    output += `时间: ${new Date(result.datetime).toLocaleString('zh-CN')}\\n`;\n    \n    if (result.content && result.content !== result.title) {\n      const content = result.content.length > 200 ? result.content.substring(0, 200) + '...' : result.content;\n      output += `内容: ${content}\\n`;\n    }\n    \n    if (result.tags && result.tags.length > 0) {\n      output += `标签: ${result.tags.join(', ')}\\n`;\n    }\n    \n    if (result.links && result.links.length > 0) {\n      output += `网盘链接:\\n`;\n      result.links.forEach((link: any, linkIndex: number) => {\n        output += `   ${linkIndex + 1}. [${link.type.toUpperCase()}] ${link.url}`;\n        if (link.password) {\n          output += ` (密码: ${link.password})`;\n        }\n        output += '\\n';\n      });\n    }\n    \n    if (result.images && result.images.length > 0) {\n      output += `图片: ${result.images.length}张\\n`;\n    }\n    \n    output += '\\n';\n  });\n  \n  return output;\n}"
  },
  {
    "path": "typescript/src/tools/start-backend.ts",
    "content": "import { Tool } from '@modelcontextprotocol/sdk/types.js';\nimport { BackendManager } from '../utils/backend-manager.js';\nimport { HttpClient } from '../utils/http-client.js';\nimport { Config } from '../utils/config.js';\n\n/**\n * 启动后端服务工具定义\n */\nexport const startBackendTool: Tool = {\n  name: 'start_backend',\n  description: '启动PanSou后端服务。如果后端服务未运行，此工具将启动它并等待服务完全可用。',\n  inputSchema: {\n    type: 'object',\n    properties: {\n      force_restart: {\n        type: 'boolean',\n        description: '是否强制重启后端服务（即使已在运行）',\n        default: false\n      }\n    },\n    additionalProperties: false\n  }\n};\n\n/**\n * 启动后端服务工具参数接口\n */\ninterface StartBackendArgs {\n  force_restart?: boolean;\n}\n\n/**\n * 执行启动后端服务工具\n */\nexport async function executeStartBackendTool(\n  args: unknown, \n  httpClient?: HttpClient, \n  config?: Config\n): Promise<string> {\n  try {\n    // 参数验证\n    const params = args as StartBackendArgs;\n    const forceRestart = params?.force_restart || false;\n\n    console.log('启动后端服务工具被调用');\n    \n    // 如果没有提供依赖项，则创建默认实例\n    if (!config) {\n      const { loadConfig } = await import('../utils/config.js');\n      config = loadConfig();\n    }\n    \n    if (!httpClient) {\n      const { HttpClient } = await import('../utils/http-client.js');\n      httpClient = new HttpClient(config);\n    }\n    \n    // 创建后端管理器\n    const backendManager = new BackendManager(config, httpClient);\n    \n    // 检查当前服务状态\n    httpClient.setSilentMode(true);\n    const isHealthy = await httpClient.testConnection();\n    httpClient.setSilentMode(false);\n    \n    if (isHealthy && !forceRestart) {\n      return JSON.stringify({\n        success: true,\n        message: '后端服务已在运行',\n        status: 'already_running',\n        service_url: config.serverUrl\n      }, null, 2);\n    }\n    \n    if (isHealthy && forceRestart) {\n      console.log('强制重启后端服务...');\n    }\n    \n    console.log('正在启动后端服务...');\n    const started = await backendManager.startBackend();\n    \n    if (!started) {\n      return JSON.stringify({\n        success: false,\n        message: '后端服务启动失败',\n        status: 'start_failed',\n        error: '无法启动后端服务，请检查配置和权限'\n      }, null, 2);\n    }\n    \n    // 等待服务完全启动并进行健康检查\n    console.log('等待服务完全启动...');\n    const maxRetries = 10;\n    let retries = 0;\n    \n    while (retries < maxRetries) {\n      await new Promise(resolve => setTimeout(resolve, 1000)); // 等待1秒\n      const healthy = await httpClient.testConnection();\n      \n      if (healthy) {\n        console.log('后端服务启动成功并通过健康检查');\n        return JSON.stringify({\n          success: true,\n          message: '后端服务启动成功',\n          status: 'started',\n          service_url: config.serverUrl,\n          startup_time: `${retries + 1}秒`\n        }, null, 2);\n      }\n      \n      retries++;\n      console.log(`健康检查重试 ${retries}/${maxRetries}...`);\n    }\n    \n    return JSON.stringify({\n      success: false,\n      message: '后端服务启动超时',\n      status: 'timeout',\n      error: '服务启动后未能通过健康检查，可能需要更多时间或存在配置问题'\n    }, null, 2);\n    \n  } catch (error) {\n    console.error('启动后端服务时发生错误:', error);\n    return JSON.stringify({\n      success: false,\n      message: '启动后端服务时发生错误',\n      status: 'error',\n      error: error instanceof Error ? error.message : String(error)\n    }, null, 2);\n  }\n}"
  },
  {
    "path": "typescript/src/utils/activity-monitor.ts",
    "content": "/**\n * 活动监控器 - 跟踪MCP工具调用活动\n */\nexport class ActivityMonitor {\n  private lastActivityTime: number;\n  private idleTimeout: number;\n  private enableIdleShutdown: boolean;\n  private idleTimer: NodeJS.Timeout | null = null;\n  private onIdleCallback: (() => void) | null = null;\n\n  constructor(idleTimeout: number = 300000, enableIdleShutdown: boolean = true) {\n    this.lastActivityTime = Date.now();\n    this.idleTimeout = idleTimeout;\n    this.enableIdleShutdown = enableIdleShutdown;\n  }\n\n  /**\n   * 记录活动\n   */\n  recordActivity(): void {\n    this.lastActivityTime = Date.now();\n    this.resetIdleTimer();\n  }\n\n  /**\n   * 获取最后活动时间\n   */\n  getLastActivityTime(): number {\n    return this.lastActivityTime;\n  }\n\n  /**\n   * 获取空闲时间（毫秒）\n   */\n  getIdleTime(): number {\n    return Date.now() - this.lastActivityTime;\n  }\n\n  /**\n   * 检查是否空闲超时\n   */\n  isIdleTimeout(): boolean {\n    return this.getIdleTime() >= this.idleTimeout;\n  }\n\n  /**\n   * 设置空闲回调函数\n   */\n  setOnIdleCallback(callback: () => void): void {\n    this.onIdleCallback = callback;\n    this.resetIdleTimer();\n  }\n\n  /**\n   * 重置空闲计时器\n   */\n  private resetIdleTimer(): void {\n    if (!this.enableIdleShutdown || !this.onIdleCallback) {\n      return;\n    }\n\n    // 清除现有计时器\n    if (this.idleTimer) {\n      clearTimeout(this.idleTimer);\n    }\n\n    // 设置新的计时器\n    this.idleTimer = setTimeout(() => {\n      if (this.onIdleCallback) {\n        console.log(`[ActivityMonitor] 检测到空闲超时 (${this.idleTimeout}ms)，触发空闲回调`);\n        this.onIdleCallback();\n      }\n    }, this.idleTimeout);\n  }\n\n  /**\n   * 停止监控\n   */\n  stop(): void {\n    if (this.idleTimer) {\n      clearTimeout(this.idleTimer);\n      this.idleTimer = null;\n    }\n    this.onIdleCallback = null;\n  }\n\n  /**\n   * 更新配置\n   */\n  updateConfig(idleTimeout: number, enableIdleShutdown: boolean): void {\n    this.idleTimeout = idleTimeout;\n    this.enableIdleShutdown = enableIdleShutdown;\n    this.resetIdleTimer();\n  }\n\n  /**\n   * 获取状态信息\n   */\n  getStatus(): {\n    lastActivityTime: number;\n    idleTime: number;\n    idleTimeout: number;\n    enableIdleShutdown: boolean;\n    isIdleTimeout: boolean;\n  } {\n    return {\n      lastActivityTime: this.lastActivityTime,\n      idleTime: this.getIdleTime(),\n      idleTimeout: this.idleTimeout,\n      enableIdleShutdown: this.enableIdleShutdown,\n      isIdleTimeout: this.isIdleTimeout()\n    };\n  }\n}\n\n// 全局活动监控器实例\nlet globalActivityMonitor: ActivityMonitor | null = null;\n\n/**\n * 获取全局活动监控器实例\n */\nexport function getActivityMonitor(): ActivityMonitor {\n  if (!globalActivityMonitor) {\n    throw new Error('活动监控器未初始化，请先调用 initializeActivityMonitor');\n  }\n  return globalActivityMonitor;\n}\n\n/**\n * 初始化全局活动监控器\n */\nexport function initializeActivityMonitor(idleTimeout: number, enableIdleShutdown: boolean): ActivityMonitor {\n  if (globalActivityMonitor) {\n    globalActivityMonitor.stop();\n  }\n  globalActivityMonitor = new ActivityMonitor(idleTimeout, enableIdleShutdown);\n  return globalActivityMonitor;\n}\n\n/**\n * 停止全局活动监控器\n */\nexport function stopActivityMonitor(): void {\n  if (globalActivityMonitor) {\n    globalActivityMonitor.stop();\n    globalActivityMonitor = null;\n  }\n}"
  },
  {
    "path": "typescript/src/utils/backend-manager.ts",
    "content": "import { spawn, ChildProcess } from 'child_process';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { HttpClient } from './http-client.js';\nimport { Config } from './config.js';\nimport { ActivityMonitor } from './activity-monitor.js';\nimport { exec } from 'child_process';\nimport { promisify } from 'util';\n\nconst execAsync = promisify(exec);\n\n/**\n * 后端服务管理器\n * 负责自动启动、停止和监控PanSou Go后端服务\n */\nexport class BackendManager {\n  private process: ChildProcess | null = null;\n  private config: Config;\n  private httpClient: HttpClient;\n  private shutdownTimeout: NodeJS.Timeout | null = null;\n  private isShuttingDown = false;\n  private readonly SHUTDOWN_DELAY = 5000; // 5秒延迟关闭\n  private readonly STARTUP_TIMEOUT = 30000; // 30秒启动超时\n  private readonly HEALTH_CHECK_INTERVAL = 1000; // 1秒健康检查间隔\n  private activityMonitor: ActivityMonitor | null = null;\n\n  constructor(config: Config, httpClient: HttpClient) {\n    this.config = config;\n    this.httpClient = httpClient;\n    \n    // 初始化活动监控器\n    if (this.config.enableIdleShutdown) {\n      this.activityMonitor = new ActivityMonitor(\n        this.config.idleTimeout,\n        this.config.enableIdleShutdown\n      );\n      \n      // 设置空闲监控回调\n      this.activityMonitor.setOnIdleCallback(async () => {\n        console.error('检测到空闲超时，自动关闭后端服务');\n        await this.stopBackend();\n        // 退出整个进程\n        process.exit(0);\n      });\n      console.error(`空闲监控已启用，超时时间: ${this.config.idleTimeout / 1000} 秒`);\n    }\n  }\n\n  /**\n   * 检查后端服务是否正在运行\n   */\n  async isBackendRunning(): Promise<boolean> {\n    try {\n      return await this.httpClient.testConnection();\n    } catch (error) {\n      return false;\n    }\n  }\n\n  /**\n   * 智能检测Docker容器状态\n   */\n  private async detectDockerContainer(): Promise<boolean> {\n    try {\n      // 检查Docker是否可用\n      await execAsync('docker --version');\n      \n      // 检查是否有运行中的pansou容器\n      const { stdout } = await execAsync('docker ps --format \"{{.Names}}\" --filter \"name=pansou\"');\n      const runningContainers = stdout.trim().split('\\n').filter(name => name.includes('pansou'));\n      \n      if (runningContainers.length > 0) {\n        console.error(`检测到运行中的Docker容器: ${runningContainers.join(', ')}`);\n        return true;\n      }\n      \n      return false;\n    } catch (error) {\n      // Docker不可用或没有容器运行\n      return false;\n    }\n  }\n\n  /**\n   * 智能检测部署模式\n   * @returns 'docker' | 'source' | 'unknown'\n   */\n  private async detectDeploymentMode(): Promise<'docker' | 'source' | 'unknown'> {\n    // 1. 首先检查是否有Docker容器运行\n    const hasDockerContainer = await this.detectDockerContainer();\n    if (hasDockerContainer) {\n      return 'docker';\n    }\n    \n    // 2. 检查是否有Go可执行文件\n    const execPath = await this.findGoExecutable();\n    if (execPath) {\n      return 'source';\n    }\n    \n    // 3. 检查服务是否已经在运行（可能是手动启动的）\n    this.httpClient.setSilentMode(true);\n    const isRunning = await this.isBackendRunning();\n    this.httpClient.setSilentMode(false);\n    \n    if (isRunning) {\n      console.error('检测到后端服务已在运行（可能是手动启动）');\n      return 'source'; // 假设是源码模式\n    }\n    \n    return 'unknown';\n  }\n\n  /**\n   * 查找Go可执行文件路径\n   */\n  private async findGoExecutable(): Promise<string | null> {\n    // 优先使用配置中的项目根目录\n    const configProjectRoot = this.config.projectRootPath;\n    \n    const possiblePaths: string[] = [];\n    \n    // 如果配置了项目根目录，直接在该目录下查找\n    if (configProjectRoot) {\n      possiblePaths.push(\n        path.join(configProjectRoot, 'pansou.exe'),\n        path.join(configProjectRoot, 'main.exe')\n      );\n    } else {\n      // 仅在没有配置项目根目录时才使用备用路径\n      possiblePaths.push(\n        // 当前工作目录\n        path.join(process.cwd(), 'pansou.exe'),\n        path.join(process.cwd(), 'main.exe'),\n        // 上级目录（如果MCP在子目录中）\n        path.join(process.cwd(), '..', 'pansou.exe'),\n        path.join(process.cwd(), '..', 'main.exe')\n      );\n    }\n\n    console.error('查找后端可执行文件...');\n    if (configProjectRoot) {\n      console.error(`使用配置的项目根目录: ${configProjectRoot}`);\n    } else {\n      console.error(`当前工作目录: ${process.cwd()}`);\n    }\n    \n    for (const execPath of possiblePaths) {\n      try {\n        await fs.access(execPath);\n        console.error(`找到可执行文件: ${execPath}`);\n        return execPath;\n      } catch {\n        // 静默跳过未找到的路径\n      }\n    }\n\n    console.error('未找到可执行文件');\n    return null;\n  }\n\n  /**\n   * 启动后端服务\n   */\n  async startBackend(): Promise<boolean> {\n    if (this.process) {\n      console.error('后端服务已在运行中');\n      return true;\n    }\n\n    // 智能检测部署模式（如果未明确配置Docker模式）\n    let effectiveDockerMode = this.config.dockerMode;\n    \n    if (!effectiveDockerMode) {\n      console.error('正在智能检测部署模式...');\n      const detectedMode = await this.detectDeploymentMode();\n      \n      switch (detectedMode) {\n        case 'docker':\n          console.error('智能检测：使用Docker部署模式');\n          effectiveDockerMode = true;\n          break;\n        case 'source':\n          console.error('智能检测：使用源码部署模式');\n          effectiveDockerMode = false;\n          break;\n        case 'unknown':\n          console.error('无法检测部署模式，使用默认源码模式');\n          effectiveDockerMode = false;\n          break;\n      }\n    } else {\n      console.error(`使用配置指定的模式: ${effectiveDockerMode ? 'Docker' : '源码'}`);\n    }\n\n    // Docker模式处理\n    if (effectiveDockerMode) {\n      console.error('Docker模式已启用，正在检查后端服务连接...');\n      \n      // Docker模式下进行重试检查，因为容器可能需要时间启动\n      const maxRetries = 3;\n      const retryDelay = 2000; // 2秒\n      \n      this.httpClient.setSilentMode(true);\n      \n      for (let i = 0; i < maxRetries; i++) {\n        const isRunning = await this.isBackendRunning();\n        if (isRunning) {\n          this.httpClient.setSilentMode(false);\n          console.error('Docker模式下后端服务连接成功');\n          return true;\n        }\n        \n        if (i < maxRetries - 1) {\n          console.error(`连接尝试 ${i + 1}/${maxRetries} 失败，${retryDelay/1000}秒后重试...`);\n          await new Promise(resolve => setTimeout(resolve, retryDelay));\n        }\n      }\n      \n      this.httpClient.setSilentMode(false);\n      console.error('Docker模式下后端服务连接失败');\n      console.error('请确保Docker容器正在运行：');\n      console.error('  docker-compose up -d');\n      console.error('或检查Docker容器状态：');\n      console.error('  docker ps');\n      return false;\n    }\n\n    // 源码模式：首先检查是否已有服务在运行\n    this.httpClient.setSilentMode(true);\n    const isRunning = await this.isBackendRunning();\n    this.httpClient.setSilentMode(false);\n    \n    if (isRunning) {\n      console.error('检测到后端服务已在运行');\n      return true;\n    }\n\n    // 查找Go可执行文件\n    const execPath = await this.findGoExecutable();\n    if (!execPath) {\n      console.error('未找到PanSou后端可执行文件');\n      console.error('如果您使用Docker部署，请在MCP配置中设置 DOCKER_MODE=true');\n      console.error('如果您使用源码部署，请确保在项目根目录下存在以下文件之一：');\n      console.error('  - pansou.exe / pansou');\n      console.error('  - main.exe / main');\n      return false;\n    }\n\n    console.error(`启动后端服务: ${execPath}`);\n\n    try {\n      // 启动Go服务\n      this.process = spawn(execPath, [], {\n        cwd: path.dirname(execPath),\n        stdio: ['ignore', 'pipe', 'pipe'],\n        detached: false,\n        windowsHide: true\n      });\n\n      // 监听进程事件\n      this.process.on('error', (error) => {\n        console.error('后端服务启动失败:', error.message);\n        console.error('错误详情:', error);\n        this.process = null;\n      });\n\n      this.process.on('exit', (code, signal) => {\n        if (!this.isShuttingDown) {\n          console.error(`后端服务意外退出 (code: ${code}, signal: ${signal})`);\n        }\n        this.process = null;\n      });\n\n      // 添加进程启动确认\n      console.error(`进程PID: ${this.process.pid}`);\n      console.error(`工作目录: ${path.dirname(execPath)}`);\n      console.error(`启动参数: ${execPath}`);\n      \n      // 给进程一点时间启动\n      await new Promise(resolve => setTimeout(resolve, 1000));\n\n      // 捕获输出（用于调试）\n      if (this.process.stdout) {\n        this.process.stdout.on('data', (data) => {\n          const output = data.toString().trim();\n          if (output) {\n            console.error('Backend stdout:', output);\n          }\n        });\n      }\n\n      if (this.process.stderr) {\n        this.process.stderr.on('data', (data) => {\n          const output = data.toString().trim();\n          if (output) {\n            // 区分错误和正常日志\n            if (output.includes('error') || output.includes('Error') || output.includes('ERROR') || \n                output.includes('panic') || output.includes('fatal') || output.includes('failed')) {\n              console.error('Backend错误:', output);\n            } else {\n              console.error('Backend stderr:', output);\n            }\n          }\n        });\n      }\n\n      // 等待服务启动\n      const started = await this.waitForBackendReady();\n      if (started) {\n        console.error('后端服务启动成功');\n        \n        // 空闲监控已在构造函数中设置\n        \n        return true;\n      } else {\n        console.error('后端服务启动超时');\n        await this.stopBackend();\n        return false;\n      }\n    } catch (error) {\n      console.error('启动后端服务时发生错误:', error);\n      return false;\n    }\n  }\n\n  /**\n   * 等待后端服务就绪\n   */\n  private async waitForBackendReady(): Promise<boolean> {\n    const startTime = Date.now();\n    \n    // 在等待期间启用静默模式，避免输出网络错误\n    const originalSilentMode = this.httpClient.isSilentMode();\n    this.httpClient.setSilentMode(true);\n    \n    try {\n      while (Date.now() - startTime < this.STARTUP_TIMEOUT) {\n        if (await this.isBackendRunning()) {\n          return true;\n        }\n        \n        // 检查进程是否还在运行\n        if (!this.process || this.process.killed) {\n          return false;\n        }\n        \n        // 等待一段时间后重试\n        await new Promise(resolve => setTimeout(resolve, this.HEALTH_CHECK_INTERVAL));\n      }\n      \n      return false;\n    } finally {\n      // 恢复原始静默模式状态\n      this.httpClient.setSilentMode(originalSilentMode);\n    }\n  }\n\n  /**\n   * 停止后端服务\n   */\n  async stopBackend(): Promise<void> {\n    if (!this.process) {\n      return;\n    }\n\n    console.error('正在停止后端服务...');\n    this.isShuttingDown = true;\n\n    try {\n      // 尝试优雅关闭\n      this.process.kill('SIGTERM');\n      \n      // 等待进程退出\n      await new Promise<void>((resolve) => {\n        if (!this.process) {\n          resolve();\n          return;\n        }\n\n        const timeout = setTimeout(() => {\n          // 强制杀死进程\n          if (this.process && !this.process.killed) {\n            console.error('强制终止后端服务');\n            this.process.kill('SIGKILL');\n          }\n          resolve();\n        }, 5000);\n\n        this.process.on('exit', () => {\n          clearTimeout(timeout);\n          resolve();\n        });\n      });\n\n      console.error('后端服务已停止');\n    } catch (error) {\n      console.error('停止后端服务时发生错误:', error);\n    } finally {\n      this.process = null;\n      this.isShuttingDown = false;\n    }\n  }\n\n  /**\n   * 延迟停止后端服务\n   */\n  scheduleShutdown(): void {\n    if (this.shutdownTimeout) {\n      clearTimeout(this.shutdownTimeout);\n    }\n\n    console.error(`将在 ${this.SHUTDOWN_DELAY / 1000} 秒后关闭后端服务`);\n    \n    this.shutdownTimeout = setTimeout(async () => {\n      await this.stopBackend();\n      this.shutdownTimeout = null;\n    }, this.SHUTDOWN_DELAY);\n  }\n\n  /**\n   * 取消计划的关闭\n   */\n  cancelShutdown(): void {\n    if (this.shutdownTimeout) {\n      clearTimeout(this.shutdownTimeout);\n      this.shutdownTimeout = null;\n      console.error('⏸️  取消后端服务关闭计划');\n    }\n  }\n\n  /**\n   * 获取后端服务状态\n   */\n  getStatus(): {\n    processRunning: boolean;\n    serviceReachable: boolean;\n    pid?: number;\n  } {\n    return {\n      processRunning: this.process !== null && !this.process.killed,\n      serviceReachable: false, // 需要异步检查\n      pid: this.process?.pid\n    };\n  }\n\n  /**\n   * 记录活动（重置空闲计时器）\n   */\n  recordActivity(): void {\n    if (this.activityMonitor) {\n      this.activityMonitor.recordActivity();\n    }\n  }\n\n  /**\n   * 获取活动监控状态\n   */\n  getActivityStatus(): any {\n    return this.activityMonitor ? this.activityMonitor.getStatus() : null;\n  }\n\n  /**\n   * 清理资源\n   */\n  async cleanup(): Promise<void> {\n    this.cancelShutdown();\n    if (this.activityMonitor) {\n      this.activityMonitor.stop();\n      this.activityMonitor = null;\n    }\n    await this.stopBackend();\n  }\n}\n\n/**\n * 创建后端管理器实例\n */\nexport function createBackendManager(config: Config, httpClient: HttpClient): BackendManager {\n  return new BackendManager(config, httpClient);\n}"
  },
  {
    "path": "typescript/src/utils/config.ts",
    "content": "import { z } from 'zod';\nimport { ConfigSchema } from './validators.js';\n\nexport type Config = z.infer<typeof ConfigSchema>;\n\n/**\n * 解析逗号分隔的字符串为数组\n */\nfunction parseCommaSeparated(value: string | undefined): string[] {\n  if (!value || value.trim() === '') {\n    return [];\n  }\n  return value.split(',').map(item => item.trim()).filter(item => item.length > 0);\n}\n\n/**\n * 从环境变量加载配置\n */\nexport function loadConfig(): Config {\n  const rawConfig = {\n    serverUrl: process.env.PANSOU_SERVER_URL,\n    requestTimeout: process.env.REQUEST_TIMEOUT ? parseInt(process.env.REQUEST_TIMEOUT) * 1000 : undefined,\n    maxResults: process.env.MAX_RESULTS ? parseInt(process.env.MAX_RESULTS) : undefined,\n    maxConcurrentRequests: process.env.MAX_CONCURRENT_REQUESTS ? parseInt(process.env.MAX_CONCURRENT_REQUESTS) : undefined,\n    enableCache: process.env.ENABLE_CACHE === 'true',\n    defaultChannels: parseCommaSeparated(process.env.DEFAULT_CHANNELS),\n    defaultPlugins: parseCommaSeparated(process.env.DEFAULT_PLUGINS),\n    defaultCloudTypes: parseCommaSeparated(process.env.DEFAULT_CLOUD_TYPES),\n    logLevel: process.env.LOG_LEVEL as 'error' | 'warn' | 'info' | 'debug' | undefined,\n    // 后端服务自动管理配置\n    autoStartBackend: process.env.AUTO_START_BACKEND !== 'false', // 默认为true，除非明确设置为false\n    backendShutdownDelay: process.env.BACKEND_SHUTDOWN_DELAY ? parseInt(process.env.BACKEND_SHUTDOWN_DELAY) : undefined,\n    backendStartupTimeout: process.env.BACKEND_STARTUP_TIMEOUT ? parseInt(process.env.BACKEND_STARTUP_TIMEOUT) : undefined,\n    // 空闲超时配置\n    idleTimeout: process.env.IDLE_TIMEOUT ? parseInt(process.env.IDLE_TIMEOUT) : undefined,\n    enableIdleShutdown: process.env.ENABLE_IDLE_SHUTDOWN !== 'false', // 默认为true，除非明确设置为false\n    // 项目根目录路径\n    projectRootPath: process.env.PROJECT_ROOT_PATH,\n    // Docker部署模式\n    dockerMode: process.env.DOCKER_MODE === 'true'\n  };\n\n  // 移除undefined值，让zod使用默认值\n  const cleanConfig = Object.fromEntries(\n    Object.entries(rawConfig).filter(([_, value]) => value !== undefined)\n  );\n\n  try {\n    return ConfigSchema.parse(cleanConfig);\n  } catch (error) {\n    if (error instanceof z.ZodError) {\n      console.error('配置验证失败:', error.errors);\n      throw new Error(`配置验证失败: ${error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`);\n    }\n    throw error;\n  }\n}\n\n// 从validators模块重新导出类型和验证函数\nexport {\n  SUPPORTED_CLOUD_TYPES,\n  SOURCE_TYPES,\n  RESULT_TYPES,\n  type CloudType,\n  type SourceType,\n  type ResultType,\n  validateCloudTypes,\n  validateSourceType,\n  validateResultType\n} from './validators.js';"
  },
  {
    "path": "typescript/src/utils/http-client.ts",
    "content": "import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';\nimport { Config, validateCloudTypes } from './config.js';\nimport { CloudType, SourceType, ResultType } from './config.js';\n\n/**\n * 搜索请求参数\n */\nexport interface SearchRequest {\n  kw: string;                           // 搜索关键词\n  channels?: string[];                  // 搜索的频道列表\n  conc?: number;                        // 并发搜索数量\n  refresh?: boolean;                    // 强制刷新，不使用缓存\n  res?: ResultType;                     // 结果类型\n  src?: SourceType;                     // 数据来源类型\n  plugins?: string[];                   // 指定搜索的插件列表\n  cloud_types?: CloudType[];            // 指定返回的网盘类型列表\n  ext?: Record<string, any>;            // 扩展参数\n}\n\n/**\n * 网盘链接\n */\nexport interface Link {\n  type: string;\n  url: string;\n  password: string;\n}\n\n/**\n * 搜索结果项\n */\nexport interface SearchResult {\n  message_id: string;\n  unique_id: string;\n  channel: string;\n  datetime: string;\n  title: string;\n  content: string;\n  links: Link[];\n  tags?: string[];\n  images?: string[];\n}\n\n/**\n * 合并后的网盘链接\n */\nexport interface MergedLink {\n  url: string;\n  password: string;\n  note: string;\n  datetime: string;\n  source?: string;\n  images?: string[];\n}\n\n/**\n * 按网盘类型分组的合并链接\n */\nexport type MergedLinks = Record<string, MergedLink[]>;\n\n/**\n * 搜索响应数据\n */\nexport interface SearchResponseData {\n  total: number;\n  results?: SearchResult[];\n  merged_by_type?: MergedLinks;\n}\n\n/**\n * API响应格式\n */\nexport interface ApiResponse<T = any> {\n  code: number;\n  message: string;\n  data?: T;\n}\n\n/**\n * 健康检查响应\n */\nexport interface HealthResponse {\n  status: string;\n  plugins_enabled: boolean;\n  channels: string[];\n  channels_count: number;\n  plugin_count?: number;\n  plugins?: string[];\n}\n\n/**\n * HTTP客户端类\n */\nexport class HttpClient {\n  private client: AxiosInstance;\n  private config: Config;\n  private silentMode: boolean = false;\n\n  constructor(config: Config) {\n    this.config = config;\n    this.client = axios.create({\n      baseURL: config.serverUrl,\n      timeout: config.requestTimeout,\n      headers: {\n        'Content-Type': 'application/json',\n        'User-Agent': 'PanSou-MCP-Server/1.0.0'\n      }\n    });\n\n    // 请求拦截器\n    this.client.interceptors.request.use(\n      (config) => {\n        if (this.config.logLevel === 'debug') {\n          console.log(`[HTTP] 请求: ${config.method?.toUpperCase()} ${config.url}`);\n          if (config.data) {\n            console.log(`[HTTP] 请求数据:`, config.data);\n          }\n        }\n        return config;\n      },\n      (error) => {\n        console.error('[HTTP] 请求错误:', error);\n        return Promise.reject(error);\n      }\n    );\n\n    // 响应拦截器\n    this.client.interceptors.response.use(\n      (response) => {\n        if (this.config.logLevel === 'debug') {\n          console.log(`[HTTP] 响应: ${response.status} ${response.config.url}`);\n        }\n        return response;\n      },\n      (error) => {\n        if (!this.silentMode) {\n          if (error.response) {\n            console.error(`[HTTP] 响应错误: ${error.response.status} ${error.response.statusText}`);\n            if (this.config.logLevel === 'debug') {\n              console.error('[HTTP] 错误详情:', error.response.data);\n            }\n          } else if (error.request) {\n            console.error('[HTTP] 网络错误: 无法连接到服务器');\n          } else {\n            console.error('[HTTP] 请求配置错误:', error.message);\n          }\n        }\n        return Promise.reject(error);\n      }\n    );\n  }\n\n  /**\n   * 搜索网盘资源\n   */\n  async search(params: SearchRequest): Promise<SearchResponseData> {\n    try {\n      // 参数验证\n      if (!params.kw || params.kw.trim() === '') {\n        throw new Error('搜索关键词不能为空');\n      }\n\n      // 设置默认值\n      const requestData: SearchRequest = {\n        kw: params.kw.trim(),\n        channels: params.channels || this.config.defaultChannels,\n        conc: params.conc,\n        refresh: params.refresh || false,\n        res: params.res || 'merge',\n        src: params.src || 'all',\n        plugins: params.plugins || this.config.defaultPlugins,\n        cloud_types: params.cloud_types ? validateCloudTypes(params.cloud_types.map(String)) : this.config.defaultCloudTypes,\n        ext: params.ext || {}\n      };\n\n      // 清理空数组\n      if (requestData.channels && requestData.channels.length === 0) {\n        delete requestData.channels;\n      }\n      if (requestData.plugins && requestData.plugins.length === 0) {\n        delete requestData.plugins;\n      }\n      if (requestData.cloud_types && requestData.cloud_types.length === 0) {\n        delete requestData.cloud_types;\n      }\n\n      const response: AxiosResponse<ApiResponse<SearchResponseData>> = await this.client.post('/api/search', requestData);\n      \n      // 兼容不同版本的响应格式：源码版本使用200，Docker版本使用0\n      if (response.data.code !== 200 && response.data.code !== 0) {\n        throw new Error(response.data.message || '搜索请求失败');\n      }\n\n      if (!response.data.data) {\n        throw new Error('服务器返回数据为空');\n      }\n\n      return response.data.data;\n    } catch (error) {\n      if (axios.isAxiosError(error)) {\n        if (error.response?.data?.message) {\n          throw new Error(`搜索失败: ${error.response.data.message}`);\n        } else if (error.code === 'ECONNREFUSED') {\n          throw new Error(`无法连接到PanSou服务器 (${this.config.serverUrl})。请确保服务器正在运行。`);\n        } else if (error.code === 'ETIMEDOUT') {\n          throw new Error('请求超时，请稍后重试');\n        } else {\n          throw new Error(`网络错误: ${error.message}`);\n        }\n      }\n      throw error;\n    }\n  }\n\n  /**\n   * 检查服务健康状态\n   */\n  async checkHealth(): Promise<HealthResponse> {\n    try {\n      const response: AxiosResponse<HealthResponse> = await this.client.get('/api/health');\n      return response.data;\n    } catch (error) {\n      if (axios.isAxiosError(error)) {\n        if (error.code === 'ECONNREFUSED') {\n          throw new Error(`无法连接到PanSou服务器 (${this.config.serverUrl})。请确保服务器正在运行。`);\n        } else if (error.code === 'ETIMEDOUT') {\n          throw new Error('健康检查超时');\n        } else {\n          throw new Error(`健康检查失败: ${error.message}`);\n        }\n      }\n      throw error;\n    }\n  }\n\n  /**\n   * 测试连接\n   */\n  async testConnection(): Promise<boolean> {\n    try {\n      await this.checkHealth();\n      return true;\n    } catch (error) {\n      return false;\n    }\n  }\n\n  /**\n   * 获取服务器URL\n   */\n  getServerUrl(): string {\n    return this.config.serverUrl;\n  }\n\n  /**\n   * 更新配置\n   */\n  updateConfig(newConfig: Partial<Config>): void {\n    this.config = { ...this.config, ...newConfig };\n    \n    // 更新axios实例配置\n    this.client.defaults.baseURL = this.config.serverUrl;\n    this.client.defaults.timeout = this.config.requestTimeout;\n  }\n\n  /**\n   * 设置静默模式\n   */\n  setSilentMode(silent: boolean): void {\n    this.silentMode = silent;\n  }\n\n  /**\n   * 获取静默模式状态\n   */\n  isSilentMode(): boolean {\n    return this.silentMode;\n  }\n}\n\n/**\n * 创建HTTP客户端实例\n */\nexport function createHttpClient(config: Config): HttpClient {\n  return new HttpClient(config);\n}"
  },
  {
    "path": "typescript/src/utils/validators.ts",
    "content": "import { z } from 'zod';\n\n/**\n * 支持的网盘类型列表\n */\nexport const SUPPORTED_CLOUD_TYPES = [\n  'baidu',    // 百度网盘\n  'aliyun',   // 阿里云盘\n  'quark',    // 夸克网盘\n  'tianyi',   // 天翼云盘\n  'uc',       // UC网盘\n  'mobile',   // 移动云盘\n  '115',      // 115网盘\n  'pikpak',   // PikPak\n  'xunlei',   // 迅雷网盘\n  '123',      // 123网盘\n  'magnet',   // 磁力链接\n  'ed2k',     // 电驴链接\n  'others'    // 其他\n] as const;\n\nexport type CloudType = typeof SUPPORTED_CLOUD_TYPES[number];\n\n/**\n * 支持的数据来源类型\n */\nexport const SOURCE_TYPES = ['all', 'tg', 'plugin'] as const;\nexport type SourceType = typeof SOURCE_TYPES[number];\n\n/**\n * 支持的结果类型\n */\nexport const RESULT_TYPES = ['all', 'results', 'merge'] as const;\nexport type ResultType = typeof RESULT_TYPES[number];\n\n/**\n * 配置验证模式\n */\nexport const ConfigSchema = z.object({\n  serverUrl: z.string().url().default('http://localhost:8888'),\n  requestTimeout: z.number().positive().default(30000),\n  maxResults: z.number().positive().default(100),\n  maxConcurrentRequests: z.number().positive().default(5),\n  enableCache: z.boolean().default(false),\n  defaultChannels: z.array(z.string()).default([]),\n  defaultPlugins: z.array(z.string()).default([]),\n  defaultCloudTypes: z.array(z.enum(['baidu', 'aliyun', 'quark', 'tianyi', 'uc', 'mobile', '115', 'pikpak', 'xunlei', '123', 'magnet', 'ed2k', 'others'])).default([]),\n  logLevel: z.enum(['error', 'warn', 'info', 'debug']).default('info'),\n  // 后端服务自动管理配置\n  autoStartBackend: z.boolean().default(true),\n  backendShutdownDelay: z.number().positive().default(5000),\n  backendStartupTimeout: z.number().positive().default(30000),\n  // 空闲超时配置（毫秒）\n  idleTimeout: z.number().positive().default(300000), // 默认5分钟\n  enableIdleShutdown: z.boolean().default(true),\n  // 项目根目录路径\n  projectRootPath: z.string().optional(),\n  // Docker部署模式（当设置为true时，不会尝试启动本地进程）\n  dockerMode: z.boolean().default(false)\n});\n\n/**\n * 验证网盘类型\n */\nexport function validateCloudTypes(cloudTypes: string[]): CloudType[] {\n  const validTypes: CloudType[] = [];\n  const invalidTypes: string[] = [];\n\n  for (const type of cloudTypes) {\n    if (SUPPORTED_CLOUD_TYPES.includes(type as CloudType)) {\n      validTypes.push(type as CloudType);\n    } else {\n      invalidTypes.push(type);\n    }\n  }\n\n  if (invalidTypes.length > 0) {\n    throw new Error(`不支持的网盘类型: ${invalidTypes.join(', ')}。支持的类型: ${SUPPORTED_CLOUD_TYPES.join(', ')}`);\n  }\n\n  return validTypes;\n}\n\n/**\n * 验证数据来源类型\n */\nexport function validateSourceType(sourceType: string): SourceType {\n  if (!SOURCE_TYPES.includes(sourceType as SourceType)) {\n    throw new Error(`不支持的数据来源类型: ${sourceType}。支持的类型: ${SOURCE_TYPES.join(', ')}`);\n  }\n  return sourceType as SourceType;\n}\n\n/**\n * 验证结果类型\n */\nexport function validateResultType(resultType: string): ResultType {\n  if (!RESULT_TYPES.includes(resultType as ResultType)) {\n    throw new Error(`不支持的结果类型: ${resultType}。支持的类型: ${RESULT_TYPES.join(', ')}`);\n  }\n  return resultType as ResultType;\n}"
  },
  {
    "path": "typescript/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"module\": \"ES2020\",\n    \"lib\": [\"ES2020\"],\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"sourceMap\": true,\n    \"removeComments\": false,\n    \"noImplicitAny\": true,\n    \"strictNullChecks\": true,\n    \"strictFunctionTypes\": true,\n    \"noImplicitThis\": true,\n    \"noImplicitReturns\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"moduleResolution\": \"node\",\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"src/*\"]\n    },\n    \"allowSyntheticDefaultImports\": true,\n    \"experimentalDecorators\": true,\n    \"emitDecoratorMetadata\": true,\n    \"resolveJsonModule\": true\n  },\n  \"include\": [\n    \"src/**/*\"\n  ],\n  \"exclude\": [\n    \"node_modules\",\n    \"dist\",\n    \"**/*.test.ts\",\n    \"**/*.spec.ts\"\n  ]\n}"
  },
  {
    "path": "util/cache/cache_key.go",
    "content": "package cache\n\nimport (\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\n\t\"pansou/plugin\"\n)\n\n// 预计算的哈希值映射\nvar (\n\tchannelHashCache sync.Map // 存储频道列表哈希\n\tpluginHashCache  sync.Map // 存储插件列表哈希\n\t\n\t// 预先计算的常用列表哈希值\n\tprecomputedHashes sync.Map\n\t\n\t// 所有插件名称的哈希值\n\tallPluginsHash string\n\t// 所有频道名称的哈希值\n\tallChannelsHash string\n)\n\n// 初始化预计算的哈希值\nfunc init() {\n\t// 预计算空列表的哈希值\n\tprecomputedHashes.Store(\"empty_channels\", \"all\")\n\t\n\t// 预计算所有插件的哈希值\n\tallPlugins := plugin.GetRegisteredPlugins()\n\tallPluginNames := make([]string, 0, len(allPlugins))\n\tfor _, p := range allPlugins {\n\t\tallPluginNames = append(allPluginNames, p.Name())\n\t}\n\tsort.Strings(allPluginNames)\n\tallPluginsHash = calculateListHash(allPluginNames)\n\tprecomputedHashes.Store(\"all_plugins\", allPluginsHash)\n\t\n\t// 预计算所有频道的哈希值（这里假设有一个全局频道列表）\n\t// 注意：如果没有全局频道列表，可以使用一个默认值\n\tallChannelsHash = \"all\"\n\tprecomputedHashes.Store(\"all_channels\", allChannelsHash)\n}\n\n// GenerateTGCacheKey 为TG搜索生成缓存键\nfunc GenerateTGCacheKey(keyword string, channels []string) string {\n\t// 关键词标准化\n\tnormalizedKeyword := strings.ToLower(strings.TrimSpace(keyword))\n\t\n\t// 获取频道列表哈希\n\tchannelsHash := getChannelsHash(channels)\n\t\n\t// 生成TG搜索特定的缓存键\n\tkeyStr := fmt.Sprintf(\"tg:%s:%s\", normalizedKeyword, channelsHash)\n\thash := md5.Sum([]byte(keyStr))\n\treturn hex.EncodeToString(hash[:])\n}\n\n// GeneratePluginCacheKey 为插件搜索生成缓存键\nfunc GeneratePluginCacheKey(keyword string, plugins []string) string {\n\t// 关键词标准化\n\tnormalizedKeyword := strings.ToLower(strings.TrimSpace(keyword))\n\t\n\t// 获取插件列表哈希\n\tpluginsHash := getPluginsHash(plugins)\n\t\n\t// 生成插件搜索特定的缓存键\n\tkeyStr := fmt.Sprintf(\"plugin:%s:%s\", normalizedKeyword, pluginsHash)\n\thash := md5.Sum([]byte(keyStr))\n\treturn hex.EncodeToString(hash[:])\n}\n\n// GenerateCacheKey 根据所有影响搜索结果的参数生成缓存键\nfunc GenerateCacheKey(keyword string, channels []string, sourceType string, plugins []string) string {\n\t// 关键词标准化\n\tnormalizedKeyword := strings.ToLower(strings.TrimSpace(keyword))\n\t\n\t// 获取频道列表哈希\n\tchannelsHash := getChannelsHash(channels)\n\t\n\t// 源类型处理\n\tif sourceType == \"\" {\n\t\tsourceType = \"all\"\n\t}\n\t\n\t// 插件参数规范化处理\n\tvar pluginsHash string\n\tif sourceType == \"tg\" {\n\t\t// 对于只搜索Telegram的请求，忽略插件参数\n\t\tpluginsHash = \"none\"\n\t} else {\n\t\t// 获取插件列表哈希\n\t\tpluginsHash = getPluginsHash(plugins)\n\t}\n\t\n\t// 生成最终缓存键\n\tkeyStr := fmt.Sprintf(\"%s:%s:%s:%s\", normalizedKeyword, channelsHash, sourceType, pluginsHash)\n\thash := md5.Sum([]byte(keyStr))\n\treturn hex.EncodeToString(hash[:])\n}\n\n// 获取或计算频道哈希\nfunc getChannelsHash(channels []string) string {\n\tif channels == nil || len(channels) == 0 {\n\t\t// 使用预计算的所有频道哈希\n\t\tif hash, ok := precomputedHashes.Load(\"all_channels\"); ok {\n\t\t\treturn hash.(string)\n\t\t}\n\t\treturn allChannelsHash\n\t}\n\t\n\t// 对于小型列表，直接使用字符串连接\n\tif len(channels) < 5 {\n\t\tchannelsCopy := make([]string, len(channels))\n\t\tcopy(channelsCopy, channels)\n\t\tsort.Strings(channelsCopy)\n\t\t\n\t\t// 直接返回排序后的字符串连接\n\t\treturn strings.Join(channelsCopy, \",\")\n\t}\n\t\n\t// 生成排序后的字符串用作键\n\tchannelsCopy := make([]string, len(channels))\n\tcopy(channelsCopy, channels)\n\tsort.Strings(channelsCopy)\n\tkey := strings.Join(channelsCopy, \",\")\n\t\n\t// 尝试从缓存获取\n\tif hash, ok := channelHashCache.Load(key); ok {\n\t\treturn hash.(string)\n\t}\n\t\n\t// 计算哈希\n\thash := calculateListHash(channelsCopy)\n\t\n\t// 存入缓存\n\tchannelHashCache.Store(key, hash)\n\treturn hash\n}\n\n// 获取或计算插件哈希\nfunc getPluginsHash(plugins []string) string {\n\t// 检查是否为空列表\n\tif plugins == nil || len(plugins) == 0 {\n\t\t// 使用预计算的所有插件哈希\n\t\tif hash, ok := precomputedHashes.Load(\"all_plugins\"); ok {\n\t\t\treturn hash.(string)\n\t\t}\n\t\treturn allPluginsHash\n\t}\n\t\n\t// 检查是否有空字符串元素\n\thasNonEmptyPlugin := false\n\tfor _, p := range plugins {\n\t\tif p != \"\" {\n\t\t\thasNonEmptyPlugin = true\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\t// 如果全是空字符串，也视为空列表\n\tif !hasNonEmptyPlugin {\n\t\tif hash, ok := precomputedHashes.Load(\"all_plugins\"); ok {\n\t\t\treturn hash.(string)\n\t\t}\n\t\treturn allPluginsHash\n\t}\n\t\n\t// 对于小型列表，直接使用字符串连接\n\tif len(plugins) < 5 {\n\t\tpluginsCopy := make([]string, 0, len(plugins))\n\t\tfor _, p := range plugins {\n\t\t\tif p != \"\" { // 忽略空字符串\n\t\t\t\tpluginsCopy = append(pluginsCopy, p)\n\t\t\t}\n\t\t}\n\t\tsort.Strings(pluginsCopy)\n\t\t\n\t\t// 直接返回排序后的字符串连接\n\t\treturn strings.Join(pluginsCopy, \",\")\n\t}\n\t\n\t// 生成排序后的字符串用作键，忽略空字符串\n\tpluginsCopy := make([]string, 0, len(plugins))\n\tfor _, p := range plugins {\n\t\tif p != \"\" { // 忽略空字符串\n\t\t\tpluginsCopy = append(pluginsCopy, p)\n\t\t}\n\t}\n\tsort.Strings(pluginsCopy)\n\tkey := strings.Join(pluginsCopy, \",\")\n\t\n\t// 尝试从缓存获取\n\tif hash, ok := pluginHashCache.Load(key); ok {\n\t\treturn hash.(string)\n\t}\n\t\n\t// 计算哈希\n\thash := calculateListHash(pluginsCopy)\n\t\n\t// 存入缓存\n\tpluginHashCache.Store(key, hash)\n\treturn hash\n}\n\n// 计算列表的哈希值\nfunc calculateListHash(items []string) string {\n\th := md5.New()\n\tfor _, item := range items {\n\t\th.Write([]byte(item))\n\t}\n\treturn hex.EncodeToString(h.Sum(nil))\n}\n\n// GenerateCacheKeyV2 根据所有影响搜索结果的参数生成缓存键\n// 为保持向后兼容，保留原函数，但标记为已弃用\nfunc GenerateCacheKeyV2(keyword string, channels []string, sourceType string, plugins []string) string {\n\t// 关键词标准化：去除首尾空格，转为小写\n\tnormalizedKeyword := strings.ToLower(strings.TrimSpace(keyword))\n\t\n\t// 频道处理\n\tvar channelsStr string\n\tif channels != nil && len(channels) > 0 {\n\t\tchannelsCopy := make([]string, len(channels))\n\t\tcopy(channelsCopy, channels)\n\t\tsort.Strings(channelsCopy)\n\t\tchannelsStr = strings.Join(channelsCopy, \",\")\n\t} else {\n\t\tchannelsStr = \"all\"\n\t}\n\t\n\t// 插件处理\n\tvar pluginsStr string\n\tif plugins != nil && len(plugins) > 0 {\n\t\tpluginsCopy := make([]string, len(plugins))\n\t\tcopy(pluginsCopy, plugins)\n\t\tsort.Strings(pluginsCopy)\n\t\tpluginsStr = strings.Join(pluginsCopy, \",\")\n\t} else {\n\t\tpluginsStr = \"all\"\n\t}\n\t\n\t// 源类型处理\n\tif sourceType == \"\" {\n\t\tsourceType = \"all\"\n\t}\n\t\n\t// 生成缓存键字符串\n\tkeyStr := fmt.Sprintf(\"v2:%s:%s:%s:%s\", normalizedKeyword, channelsStr, sourceType, pluginsStr)\n\t\n\t// 计算MD5哈希\n\thash := md5.Sum([]byte(keyStr))\n\treturn hex.EncodeToString(hash[:])\n}\n\n// GenerateCacheKeyLegacy 根据查询和过滤器生成缓存键\n// 为保持向后兼容，保留原函数，但重命名为更清晰的名称\nfunc GenerateCacheKeyLegacy(query string, filters map[string]string) string {\n\t// 如果只需要基于关键词的缓存，不考虑过滤器，调用新函数\n\tif filters == nil || len(filters) == 0 {\n\t\treturn GenerateCacheKey(query, nil, \"\", nil)\n\t}\n\t\n\t// 创建包含查询和所有过滤器的字符串\n\tkeyStr := query\n\n\t// 按字母顺序排序过滤器键，确保相同的过滤器集合总是产生相同的键\n\tvar keys []string\n\tfor k := range filters {\n\t\tkeys = append(keys, k)\n\t}\n\tsort.Strings(keys)\n\n\t// 添加过滤器到键字符串\n\tfor _, k := range keys {\n\t\tkeyStr += \"|\" + k + \"=\" + filters[k]\n\t}\n\n\t// 计算MD5哈希\n\thash := md5.Sum([]byte(keyStr))\n\treturn hex.EncodeToString(hash[:])\n} "
  },
  {
    "path": "util/cache/delayed_batch_write_manager.go",
    "content": "package cache\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"pansou/model\"\n)\n\n// CacheWriteStrategy 缓存写入策略\ntype CacheWriteStrategy string\n\nconst (\n\t// CacheStrategyImmediate 立即写入策略（当前实现）\n\tCacheStrategyImmediate CacheWriteStrategy = \"immediate\"\n\t\n\t// CacheStrategyHybrid 混合智能策略（推荐）\n\tCacheStrategyHybrid    CacheWriteStrategy = \"hybrid\"\n)\n\n// CacheOperation 缓存操作\ntype CacheOperation struct {\n\tKey              string\n\tData             []model.SearchResult\n\tTTL              time.Duration\n\tPluginName       string\n\tKeyword          string\n\tTimestamp        time.Time\n\tPriority         int                // 优先级 (1=highest, 4=lowest)\n\tDataSize         int                // 数据大小（字节）\n\tIsFinal          bool               // 是否为最终结果\n}\n\n// CacheWriteConfig 缓存写入配置\ntype CacheWriteConfig struct {\n\t// 核心策略\n\tStrategy                CacheWriteStrategy `env:\"CACHE_WRITE_STRATEGY\" default:\"hybrid\"`\n\t\n\t// 批量写入参数（自动计算，但可手动覆盖）\n\tMaxBatchInterval        time.Duration      `env:\"BATCH_MAX_INTERVAL\"`        // 0表示自动计算\n\tMaxBatchSize            int                `env:\"BATCH_MAX_SIZE\"`            // 0表示自动计算\n\tMaxBatchDataSize        int                `env:\"BATCH_MAX_DATA_SIZE\"`       // 0表示自动计算\n\t\n\t// 行为参数\n\tHighPriorityRatio       float64            `env:\"HIGH_PRIORITY_RATIO\" default:\"0.3\"`\n\tEnableCompression       bool               // 默认启用操作合并\n\t\n\t// 内部计算参数（运行时动态调整）\n\tidleThresholdCPU        float64            // CPU空闲阈值\n\tidleThresholdDisk       float64            // 磁盘空闲阈值\n\tforceFlushInterval      time.Duration      // 强制刷新间隔\n\tautoTuneInterval        time.Duration      // 调优检查间隔\n\t\n\t// 约束边界（硬编码）\n\tminBatchInterval        time.Duration      // 最小30秒\n\tmaxBatchInterval        time.Duration      // 最大10分钟\n\tminBatchSize            int                // 最小10个\n\tmaxBatchSize            int                // 最大1000个\n}\n\n// Initialize 初始化配置\nfunc (c *CacheWriteConfig) Initialize() error {\n\t// 设置硬编码约束边界\n\tc.minBatchInterval = 30 * time.Second\n\tc.maxBatchInterval = 600 * time.Second  // 10分钟\n\tc.minBatchSize = 10\n\tc.maxBatchSize = 1000\n\t\n\t// 加载环境变量\n\tc.loadFromEnvironment()\n\t\n\t// 自动计算最优参数（除非手动设置）\n\tif c.MaxBatchInterval == 0 {\n\t\tc.MaxBatchInterval = c.calculateOptimalBatchInterval()\n\t}\n\tif c.MaxBatchSize == 0 {\n\t\tc.MaxBatchSize = c.calculateOptimalBatchSize()\n\t}\n\tif c.MaxBatchDataSize == 0 {\n\t\tc.MaxBatchDataSize = c.calculateOptimalDataSize()\n\t}\n\t\n\t// 内部参数自动设置\n\tc.forceFlushInterval = c.MaxBatchInterval * 5  // 5倍批量间隔\n\tc.autoTuneInterval = 300 * time.Second         // 5分钟调优间隔\n\tc.idleThresholdCPU = 0.3                      // CPU空闲阈值\n\tc.idleThresholdDisk = 0.5                     // 磁盘空闲阈值\n\t\n\t// 参数验证和约束\n\treturn c.validateAndConstraint()\n}\n\n// loadFromEnvironment 从环境变量加载配置\nfunc (c *CacheWriteConfig) loadFromEnvironment() {\n\t// 策略配置\n\tif strategy := os.Getenv(\"CACHE_WRITE_STRATEGY\"); strategy != \"\" {\n\t\tc.Strategy = CacheWriteStrategy(strategy)\n\t}\n\t\n\t// 批量写入参数\n\tif interval := os.Getenv(\"BATCH_MAX_INTERVAL\"); interval != \"\" {\n\t\tif d, err := time.ParseDuration(interval); err == nil {\n\t\t\tc.MaxBatchInterval = d\n\t\t}\n\t}\n\t\n\tif size := os.Getenv(\"BATCH_MAX_SIZE\"); size != \"\" {\n\t\tif s, err := strconv.Atoi(size); err == nil {\n\t\t\tc.MaxBatchSize = s\n\t\t}\n\t}\n\t\n\tif dataSize := os.Getenv(\"BATCH_MAX_DATA_SIZE\"); dataSize != \"\" {\n\t\tif ds, err := strconv.Atoi(dataSize); err == nil {\n\t\t\tc.MaxBatchDataSize = ds\n\t\t}\n\t}\n\t\n\t// 行为参数\n\tif ratio := os.Getenv(\"HIGH_PRIORITY_RATIO\"); ratio != \"\" {\n\t\tif r, err := strconv.ParseFloat(ratio, 64); err == nil {\n\t\t\tc.HighPriorityRatio = r\n\t\t}\n\t}\n}\n\n// calculateOptimalBatchInterval 计算最优批量间隔\nfunc (c *CacheWriteConfig) calculateOptimalBatchInterval() time.Duration {\n\t// 基于系统性能动态计算\n\tvar memStats runtime.MemStats\n\truntime.ReadMemStats(&memStats)\n\t\n\t// 简化实现：根据可用内存量调整\n\tavailableMemoryGB := float64(memStats.Sys) / 1024 / 1024 / 1024\n\t\n\tvar interval time.Duration\n\tswitch {\n\tcase availableMemoryGB > 8: // 大内存系统\n\t\tinterval = 45 * time.Second\n\tcase availableMemoryGB > 4: // 中等内存系统\n\t\tinterval = 60 * time.Second\n\tdefault: // 小内存系统\n\t\tinterval = 90 * time.Second\n\t}\n\t\n\t// 应用约束\n\tif interval < c.minBatchInterval {\n\t\tinterval = c.minBatchInterval\n\t}\n\tif interval > c.maxBatchInterval {\n\t\tinterval = c.maxBatchInterval\n\t}\n\t\n\treturn interval\n}\n\n// calculateOptimalBatchSize 计算最优批量大小\nfunc (c *CacheWriteConfig) calculateOptimalBatchSize() int {\n\t// 基于CPU核心数和内存动态计算\n\tnumCPU := runtime.NumCPU()\n\t\n\tvar memStats runtime.MemStats\n\truntime.ReadMemStats(&memStats)\n\tavailableMemoryGB := float64(memStats.Sys) / 1024 / 1024 / 1024\n\t\n\tvar size int\n\tswitch {\n\tcase numCPU >= 8 && availableMemoryGB > 8: // 高性能系统\n\t\tsize = 200\n\tcase numCPU >= 4 && availableMemoryGB > 4: // 中等性能系统\n\t\tsize = 100\n\tdefault: // 低性能系统\n\t\tsize = 50\n\t}\n\t\n\t// 应用约束\n\tif size < c.minBatchSize {\n\t\tsize = c.minBatchSize\n\t}\n\tif size > c.maxBatchSize {\n\t\tsize = c.maxBatchSize\n\t}\n\t\n\treturn size\n}\n\n// calculateOptimalDataSize 计算最优数据大小\nfunc (c *CacheWriteConfig) calculateOptimalDataSize() int {\n\t// 基于可用内存计算\n\tvar memStats runtime.MemStats\n\truntime.ReadMemStats(&memStats)\n\tavailableMemoryGB := float64(memStats.Sys) / 1024 / 1024 / 1024\n\t\n\tvar sizeMB int\n\tswitch {\n\tcase availableMemoryGB > 16: // 大内存系统\n\t\tsizeMB = 20\n\tcase availableMemoryGB > 8: // 中等内存系统\n\t\tsizeMB = 10\n\tdefault: // 小内存系统\n\t\tsizeMB = 5\n\t}\n\t\n\treturn sizeMB * 1024 * 1024 // 转换为字节\n}\n\n// validateAndConstraint 验证和约束配置\nfunc (c *CacheWriteConfig) validateAndConstraint() error {\n\t// 验证配置合理性\n\tif c.MaxBatchInterval < c.minBatchInterval {\n\t\treturn fmt.Errorf(\"批量间隔配置错误: MaxBatchInterval(%v) < MinBatchInterval(%v)\", \n\t\t\tc.MaxBatchInterval, c.minBatchInterval)\n\t}\n\t\n\tif c.MaxBatchSize < c.minBatchSize {\n\t\treturn fmt.Errorf(\"批量大小配置错误: MaxBatchSize(%d) < MinBatchSize(%d)\", \n\t\t\tc.MaxBatchSize, c.minBatchSize)\n\t}\n\t\n\tif c.HighPriorityRatio < 0 || c.HighPriorityRatio > 1 {\n\t\treturn fmt.Errorf(\"高优先级比例配置错误: HighPriorityRatio(%f) 应在 [0,1] 范围内\", \n\t\t\tc.HighPriorityRatio)\n\t}\n\t\n\t// 应用最终约束\n\tif c.MaxBatchInterval > c.maxBatchInterval {\n\t\tc.MaxBatchInterval = c.maxBatchInterval\n\t}\n\tif c.MaxBatchSize > c.maxBatchSize {\n\t\tc.MaxBatchSize = c.maxBatchSize\n\t}\n\t\n\t// 设置默认策略\n\tif c.Strategy != CacheStrategyImmediate && c.Strategy != CacheStrategyHybrid {\n\t\tc.Strategy = CacheStrategyHybrid\n\t}\n\t\n\treturn nil\n}\n\n// DelayedBatchWriteManager 延迟批量写入管理器\ntype DelayedBatchWriteManager struct {\n\tstrategy          CacheWriteStrategy\n\tconfig            *CacheWriteConfig\n\t\n\t// 延迟写入队列\n\twriteQueue        chan *CacheOperation\n\tqueueBuffer       []*CacheOperation\n\tqueueMutex        sync.Mutex\n\t\n\t// 全局缓冲区管理器\n\tglobalBufferManager *GlobalBufferManager\n\t\n\t// 统计信息\n\tstats             *WriteManagerStats\n\t\n\t// 控制通道\n\tshutdownChan      chan struct{}\n\tflushTicker       *time.Ticker\n\t\n\t// 数据压缩（操作合并）\n\toperationMap      map[string]*CacheOperation  // key -> latest operation (去重合并)\n\tmapMutex          sync.RWMutex\n\t\n\t// 主缓存更新函数\n\tmainCacheUpdater  func(string, []byte, time.Duration) error\n\t\n\t// 序列化器\n\tserializer        *GobSerializer\n\t\n\t// 初始化标志\n\tinitialized       int32\n\tinitMutex         sync.Mutex\n}\n\n// WriteManagerStats 写入管理器统计信息\ntype WriteManagerStats struct {\n\t// 基础统计\n\tTotalWrites              int64         // 总写入次数\n\tTotalOperations          int64         // 总操作次数\n\tBatchWrites              int64         // 批量写入次数\n\tImmediateWrites          int64         // 立即写入次数\n\tMergedOperations         int64         // 合并操作次数\n\tFailedWrites             int64         // 失败写入次数\n\tSuccessfulWrites         int64         // 成功写入次数\n\t\n\t// 性能统计\n\tLastFlushTime            time.Time     // 上次刷新时间\n\tLastFlushTrigger         string        // 上次刷新触发原因\n\tLastBatchSize            int           // 上次批量大小\n\tTotalOperationsWritten   int           // 已写入操作总数\n\t\n\t// 时间窗口\n\tWindowStart              time.Time     // 统计窗口开始时间\n\tWindowEnd                time.Time     // 统计窗口结束时间\n\t\n\t// 运行时状态\n\tCurrentQueueSize         int32         // 当前队列大小\n\tCurrentMemoryUsage       int64         // 当前内存使用量\n\tSystemLoadAverage        float64       // 系统负载均值\n}\n\n// NewDelayedBatchWriteManager 创建新的延迟批量写入管理器\nfunc NewDelayedBatchWriteManager() (*DelayedBatchWriteManager, error) {\n\tconfig := &CacheWriteConfig{\n\t\tStrategy:          CacheStrategyHybrid,\n\t\tEnableCompression: true,\n\t}\n\t\n\t// 初始化配置\n\tif err := config.Initialize(); err != nil {\n\t\treturn nil, fmt.Errorf(\"配置初始化失败: %v\", err)\n\t}\n\t\n\t// 创建全局缓冲区管理器\n\tglobalBufferManager := NewGlobalBufferManager(BufferHybrid)\n\t\n\tmanager := &DelayedBatchWriteManager{\n\t\tstrategy:            config.Strategy,\n\t\tconfig:              config,\n\t\twriteQueue:          make(chan *CacheOperation, 1000), // 队列容量1000\n\t\tqueueBuffer:         make([]*CacheOperation, 0, config.MaxBatchSize),\n\t\tglobalBufferManager: globalBufferManager,\n\t\toperationMap:        make(map[string]*CacheOperation),\n\t\tshutdownChan:        make(chan struct{}),\n\t\tstats: &WriteManagerStats{\n\t\t\tWindowStart: time.Now(),\n\t\t},\n\t\tserializer: NewGobSerializer(),\n\t}\n\t\n\treturn manager, nil\n}\n\n// Initialize 初始化管理器\nfunc (m *DelayedBatchWriteManager) Initialize() error {\n\tif !atomic.CompareAndSwapInt32(&m.initialized, 0, 1) {\n\t\treturn nil // 已经初始化\n\t}\n\t\n\tm.initMutex.Lock()\n\tdefer m.initMutex.Unlock()\n\t\n\t// 初始化全局缓冲区管理器\n\tif err := m.globalBufferManager.Initialize(); err != nil {\n\t\treturn fmt.Errorf(\"全局缓冲区管理器初始化失败: %v\", err)\n\t}\n\t\n\t// 启动后台处理goroutine\n\tgo m.backgroundProcessor()\n\t\n\t// 启动定时刷新goroutine\n\tm.flushTicker = time.NewTicker(m.config.MaxBatchInterval)\n\tgo m.timerFlushProcessor()\n\t\n\t// 启动自动调优goroutine\n\tgo m.autoTuningProcessor()\n\t\n\t// 启动全局缓冲区监控\n\tgo m.globalBufferMonitor()\n\t\n\tfmt.Printf(\"缓存写入策略: %s\\n\", m.strategy)\n\treturn nil\n}\n\n// SetMainCacheUpdater 设置主缓存更新函数\nfunc (m *DelayedBatchWriteManager) SetMainCacheUpdater(updater func(string, []byte, time.Duration) error) {\n\tm.mainCacheUpdater = updater\n}\n\n// HandleCacheOperation 处理缓存操作\nfunc (m *DelayedBatchWriteManager) HandleCacheOperation(op *CacheOperation) error {\n\t// 确保管理器已初始化\n\tif err := m.Initialize(); err != nil {\n\t\treturn err\n\t}\n\t\n\t// 关键：无论什么策略，都立即更新内存缓存\n\tif err := m.updateMemoryCache(op); err != nil {\n\t\treturn fmt.Errorf(\"内存缓存更新失败: %v\", err)\n\t}\n\t\n\t// 根据策略处理磁盘写入\n\tif m.strategy == CacheStrategyImmediate {\n\t\treturn m.immediateWriteToDisk(op)\n\t}\n\t\n\t// 使用全局缓冲区管理器进行智能缓冲\n\treturn m.handleWithGlobalBuffer(op)\n}\n\n// handleWithGlobalBuffer 使用全局缓冲区处理操作\nfunc (m *DelayedBatchWriteManager) handleWithGlobalBuffer(op *CacheOperation) error {\n\t// 尝试添加到全局缓冲区\n\tbuffer, shouldFlush, err := m.globalBufferManager.AddOperation(op)\n\tif err != nil {\n\t\t// 全局缓冲区失败，降级到本地队列\n\t\treturn m.enqueueForBatchWrite(op)\n\t}\n\t\n\t// 如果需要刷新缓冲区\n\tif shouldFlush {\n\t\treturn m.flushGlobalBuffer(buffer.ID)\n\t}\n\t\n\treturn nil\n}\n\n// flushGlobalBuffer 刷新全局缓冲区\nfunc (m *DelayedBatchWriteManager) flushGlobalBuffer(bufferID string) error {\n\toperations, err := m.globalBufferManager.FlushBuffer(bufferID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"刷新全局缓冲区失败: %v\", err)\n\t}\n\t\n\tif len(operations) == 0 {\n\t\treturn nil\n\t}\n\t\n\t// 按优先级排序操作\n\tsort.Slice(operations, func(i, j int) bool {\n\t\tif operations[i].Priority != operations[j].Priority {\n\t\t\treturn operations[i].Priority < operations[j].Priority\n\t\t}\n\t\treturn operations[i].Timestamp.Before(operations[j].Timestamp)\n\t})\n\t\n\t// 统计信息更新\n\tatomic.AddInt64(&m.stats.BatchWrites, 1)\n\tatomic.AddInt64(&m.stats.TotalWrites, 1)\n\tm.stats.LastFlushTime = time.Now()\n\tm.stats.LastFlushTrigger = \"全局缓冲区触发\"\n\tm.stats.LastBatchSize = len(operations)\n\t\n\t// 批量写入磁盘\n\terr = m.batchWriteToDisk(operations)\n\tif err != nil {\n\t\tatomic.AddInt64(&m.stats.FailedWrites, 1)\n\t\treturn fmt.Errorf(\"全局缓冲区批量写入失败: %v\", err)\n\t}\n\t\n\t// 📈 成功统计\n\tatomic.AddInt64(&m.stats.SuccessfulWrites, 1)\n\tm.stats.TotalOperationsWritten += len(operations)\n\t\n\treturn nil\n}\n\n// globalBufferMonitor 全局缓冲区监控\nfunc (m *DelayedBatchWriteManager) globalBufferMonitor() {\n\tticker := time.NewTicker(2 * time.Minute) // 每2分钟检查一次\n\tdefer ticker.Stop()\n\t\n\tfor {\n\t\tselect {\n\t\tcase <-ticker.C:\n\t\t\t// 检查是否有过期的缓冲区需要刷新\n\t\t\tm.checkAndFlushExpiredBuffers()\n\t\t\t\n\t\tcase <-m.shutdownChan:\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// checkAndFlushExpiredBuffers 检查并刷新过期缓冲区\nfunc (m *DelayedBatchWriteManager) checkAndFlushExpiredBuffers() {\n\t// 使用原子操作获取需要刷新的缓冲区列表\n\texpiredBuffers := m.globalBufferManager.GetExpiredBuffersForFlush()\n\t\n\tflushedCount := 0\n\tfor _, bufferID := range expiredBuffers {\n\t\tif err := m.flushGlobalBuffer(bufferID); err != nil {\n\t\t\t// 区分错误类型，缓冲区不存在是正常情况\n\t\t\tif isBufferNotExistError(err) {\n\t\t\t\t// 静默处理：缓冲区已被其他线程清理，这是正常的\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// 只有真正的错误才打印警告\n\t\t\tfmt.Printf(\"[全局缓冲区] 刷新缓冲区失败 %s: %v\\n\", bufferID, err)\n\t\t} else {\n\t\t\tflushedCount++\n\t\t}\n\t}\n\t\n\tif flushedCount > 0 {\n\t\tfmt.Printf(\"[全局缓冲区] 刷新完成，处理 %d 个过期缓冲区\\n\", flushedCount)\n\t}\n}\n\n// isBufferNotExistError 检查是否为缓冲区不存在错误\nfunc isBufferNotExistError(err error) bool {\n\treturn err != nil && (\n\t\terr.Error() == \"缓冲区不存在: \"+err.Error()[strings.LastIndex(err.Error(), \": \")+2:] ||\n\t\tstrings.Contains(err.Error(), \"缓冲区不存在\"))\n}\n\n// updateMemoryCache 更新内存缓存（立即执行）\nfunc (m *DelayedBatchWriteManager) updateMemoryCache(op *CacheOperation) error {\n\t// 如果有主缓存更新函数，立即更新内存层\n\tif m.mainCacheUpdater != nil {\n\t\t// 序列化数据\n\t\t_, err := m.serializer.Serialize(op.Data)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"内存缓存数据序列化失败: %v\", err)\n\t\t}\n\t\t\n\t\t// 这里只更新内存，不写磁盘（磁盘由批量写入处理）\n\t\t// 注意：mainCacheUpdater实际上是SetBothLevels，会同时更新内存和磁盘\n\t}\n\treturn nil\n}\n\n// immediateWriteToDisk 立即写入磁盘\nfunc (m *DelayedBatchWriteManager) immediateWriteToDisk(op *CacheOperation) error {\n\tif m.mainCacheUpdater == nil {\n\t\treturn fmt.Errorf(\"主缓存更新函数未设置\")\n\t}\n\t\n\t// 序列化数据\n\tdata, err := m.serializer.Serialize(op.Data)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"数据序列化失败: %v\", err)\n\t}\n\t\n\t// 更新统计\n\tatomic.AddInt64(&m.stats.TotalWrites, 1)\n\tatomic.AddInt64(&m.stats.TotalOperations, 1)\n\tatomic.AddInt64(&m.stats.ImmediateWrites, 1)\n\t\n\treturn m.mainCacheUpdater(op.Key, data, op.TTL)\n}\n\n// enqueueForBatchWrite 加入批量写入队列\nfunc (m *DelayedBatchWriteManager) enqueueForBatchWrite(op *CacheOperation) error {\n\t// 🚀 操作合并优化：相同key的操作只保留最新的\n\tif m.config.EnableCompression {\n\t\tm.mapMutex.Lock()\n\t\texisting, exists := m.operationMap[op.Key]\n\t\tif exists {\n\t\t\t// 合并操作：保留最新数据，累计统计信息\n\t\t\top.DataSize += existing.DataSize\n\t\t\tatomic.AddInt64(&m.stats.MergedOperations, 1)\n\t\t}\n\t\tm.operationMap[op.Key] = op\n\t\tm.mapMutex.Unlock()\n\t}\n\t\n\t// 加入延迟写入队列\n\tselect {\n\tcase m.writeQueue <- op:\n\t\tatomic.AddInt64(&m.stats.TotalOperations, 1)\n\t\tatomic.AddInt32(&m.stats.CurrentQueueSize, 1)\n\t\treturn nil\n\tdefault:\n\t\t// 队列满时，触发紧急刷新\n\t\treturn m.emergencyFlush()\n\t}\n}\n\n// backgroundProcessor 后台处理器\nfunc (m *DelayedBatchWriteManager) backgroundProcessor() {\n\tfor {\n\t\tselect {\n\t\tcase op := <-m.writeQueue:\n\t\t\tm.queueMutex.Lock()\n\t\t\tm.queueBuffer = append(m.queueBuffer, op)\n\t\t\tatomic.AddInt32(&m.stats.CurrentQueueSize, -1)\n\t\t\t\n\t\t\t// 检查是否应该触发批量写入\n\t\t\tif shouldFlush, trigger := m.shouldTriggerBatchWrite(); shouldFlush {\n\t\t\t\tm.executeBatchWrite(trigger)\n\t\t\t}\n\t\t\tm.queueMutex.Unlock()\n\t\t\t\n\t\tcase <-m.shutdownChan:\n\t\t\t// 优雅关闭：处理剩余操作\n\t\t\tm.flushAllPendingData()\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// timerFlushProcessor 定时刷新处理器\nfunc (m *DelayedBatchWriteManager) timerFlushProcessor() {\n\tfor {\n\t\tselect {\n\t\tcase <-m.flushTicker.C:\n\t\t\tm.queueMutex.Lock()\n\t\t\tif len(m.queueBuffer) > 0 {\n\t\t\t\tm.executeBatchWrite(\"定时触发\")\n\t\t\t}\n\t\t\tm.queueMutex.Unlock()\n\t\t\t\n\t\tcase <-m.shutdownChan:\n\t\t\tm.flushTicker.Stop()\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// autoTuningProcessor 自动调优处理器\nfunc (m *DelayedBatchWriteManager) autoTuningProcessor() {\n\tticker := time.NewTicker(m.config.autoTuneInterval)\n\tdefer ticker.Stop()\n\t\n\tfor {\n\t\tselect {\n\t\tcase <-ticker.C:\n\t\t\tm.autoTuneParameters()\n\t\t\t\n\t\tcase <-m.shutdownChan:\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// Shutdown 优雅关闭\nfunc (m *DelayedBatchWriteManager) Shutdown(timeout time.Duration) error {\n\tif !atomic.CompareAndSwapInt32(&m.initialized, 1, 0) {\n\t\treturn nil // 已经关闭\n\t}\n\t\n\t// 正在保存缓存数据（静默）\n\t\n\t// 关闭后台处理器\n\tclose(m.shutdownChan)\n\t\n\t// 等待所有数据保存完成，但有超时保护\n\tctx, cancel := context.WithTimeout(context.Background(), timeout)\n\tdefer cancel()\n\t\n\tdone := make(chan error, 1)\n\tgo func() {\n\t\tvar lastErr error\n\t\t\n\t\t// 第一步：强制刷新全局缓冲区（优先级最高）\n\t\tif err := m.flushAllGlobalBuffers(); err != nil {\n\t\t\tfmt.Printf(\"[数据保护] 全局缓冲区刷新失败: %v\\n\", err)\n\t\t\tlastErr = err\n\t\t} \n\t\t\n\t\t// 第二步：刷新本地队列\n\t\tif err := m.flushAllPendingData(); err != nil {\n\t\t\tfmt.Printf(\"[数据保护] 本地队列刷新失败: %v\\n\", err)\n\t\t\tlastErr = err\n\t\t} \n\t\t\n\t\t// 第三步：关闭全局缓冲区管理器\n\t\tif err := m.globalBufferManager.Shutdown(); err != nil {\n\t\t\tfmt.Printf(\"[数据保护] 全局缓冲区管理器关闭失败: %v\\n\", err)\n\t\t\tlastErr = err\n\t\t} \n\t\t\n\t\tdone <- lastErr\n\t}()\n\t\n\tselect {\n\tcase err := <-done:\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"数据保存失败: %v\", err)\n\t\t}\n\t\t// 缓存数据已安全保存（静默）\n\t\treturn nil\n\tcase <-ctx.Done():\n\t\treturn fmt.Errorf(\"数据保存超时\")\n\t}\n}\n\n// flushAllGlobalBuffers 刷新所有全局缓冲区\nfunc (m *DelayedBatchWriteManager) flushAllGlobalBuffers() error {\n\tallBuffers := m.globalBufferManager.FlushAllBuffers()\n\t\n\tvar lastErr error\n\t\n\tfor bufferID, operations := range allBuffers {\n\t\tif len(operations) > 0 {\n\t\t\tif err := m.batchWriteToDisk(operations); err != nil {\n\t\t\t\tfmt.Printf(\"[全局缓冲区] 缓冲区 %s 刷新失败: %v\\n\", bufferID, err)\n\t\t\t\tlastErr = fmt.Errorf(\"刷新全局缓冲区 %s 失败: %v\", bufferID, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t}\n\t\n\treturn lastErr\n}\n\n// flushAllPendingData 刷新所有待处理数据\nfunc (m *DelayedBatchWriteManager) flushAllPendingData() error {\n\tm.queueMutex.Lock()\n\tdefer m.queueMutex.Unlock()\n\t\n\t// 处理队列缓冲区中的数据\n\tif len(m.queueBuffer) > 0 {\n\t\tif err := m.executeBatchWrite(\"程序关闭\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\t\n\t// 处理操作映射中的数据（如果启用了压缩）\n\tif m.config.EnableCompression && len(m.operationMap) > 0 {\n\t\toperations := m.getCompressedOperations()\n\t\tif len(operations) > 0 {\n\t\t\treturn m.batchWriteToDisk(operations)\n\t\t}\n\t}\n\t\n\treturn nil\n}\n\n// shouldTriggerBatchWrite 检查是否应该触发批量写入\nfunc (m *DelayedBatchWriteManager) shouldTriggerBatchWrite() (bool, string) {\n\tnow := time.Now()\n\t\n\t// 条件1：时间间隔达到阈值\n\tif now.Sub(m.stats.LastFlushTime) >= m.config.MaxBatchInterval {\n\t\treturn true, \"时间间隔触发\"\n\t}\n\t\n\t// 条件2：操作数量达到阈值\n\tif len(m.queueBuffer) >= m.config.MaxBatchSize {\n\t\treturn true, \"数量阈值触发\"\n\t}\n\t\n\t// 条件3：数据大小达到阈值\n\ttotalSize := m.calculateBufferSize()\n\tif totalSize >= m.config.MaxBatchDataSize {\n\t\treturn true, \"大小阈值触发\"\n\t}\n\t\n\t// 条件4：高优先级数据比例达到阈值\n\thighPriorityRatio := m.calculateHighPriorityRatio()\n\tif highPriorityRatio >= m.config.HighPriorityRatio {\n\t\treturn true, \"高优先级触发\"\n\t}\n\t\n\t// 条件5：系统空闲（CPU和磁盘使用率都较低）\n\tif m.isSystemIdle() {\n\t\treturn true, \"系统空闲触发\"\n\t}\n\t\n\t// 条件6：强制刷新间隔（兜底机制）\n\tif now.Sub(m.stats.LastFlushTime) >= m.config.forceFlushInterval {\n\t\treturn true, \"强制刷新触发\"\n\t}\n\t\n\treturn false, \"\"\n}\n\n// calculateBufferSize 计算缓冲区数据大小\nfunc (m *DelayedBatchWriteManager) calculateBufferSize() int {\n\ttotalSize := 0\n\tfor _, op := range m.queueBuffer {\n\t\ttotalSize += op.DataSize\n\t}\n\treturn totalSize\n}\n\n// calculateHighPriorityRatio 计算高优先级数据比例\nfunc (m *DelayedBatchWriteManager) calculateHighPriorityRatio() float64 {\n\tif len(m.queueBuffer) == 0 {\n\t\treturn 0\n\t}\n\t\n\thighPriorityCount := 0\n\tfor _, op := range m.queueBuffer {\n\t\tif op.Priority <= 2 { // 等级1和等级2插件\n\t\t\thighPriorityCount++\n\t\t}\n\t}\n\t\n\treturn float64(highPriorityCount) / float64(len(m.queueBuffer))\n}\n\n// isSystemIdle 检查系统是否空闲\nfunc (m *DelayedBatchWriteManager) isSystemIdle() bool {\n\t// 简化实现：基于CPU使用率\n\tvar memStats runtime.MemStats\n\truntime.ReadMemStats(&memStats)\n\t\n\t// 如果GC频率较低，认为系统相对空闲\n\treturn memStats.NumGC%10 == 0\n}\n\n// executeBatchWrite 执行批量写入\nfunc (m *DelayedBatchWriteManager) executeBatchWrite(trigger string) error {\n\tif len(m.queueBuffer) == 0 {\n\t\treturn nil\n\t}\n\t\n\t// 操作合并：如果启用压缩，使用合并后的操作\n\tvar operations []*CacheOperation\n\tif m.config.EnableCompression {\n\t\toperations = m.getCompressedOperations()\n\t} else {\n\t\toperations = make([]*CacheOperation, len(m.queueBuffer))\n\t\tcopy(operations, m.queueBuffer)\n\t}\n\t\n\tif len(operations) == 0 {\n\t\treturn nil\n\t}\n\t\n\t// 按优先级排序：确保重要数据优先写入\n\tsort.Slice(operations, func(i, j int) bool {\n\t\tif operations[i].Priority != operations[j].Priority {\n\t\t\treturn operations[i].Priority < operations[j].Priority // 数字越小优先级越高\n\t\t}\n\t\treturn operations[i].Timestamp.Before(operations[j].Timestamp)\n\t})\n\t\n\t// 统计信息更新\n\tatomic.AddInt64(&m.stats.BatchWrites, 1)\n\tm.stats.LastFlushTime = time.Now()\n\tm.stats.LastFlushTrigger = trigger\n\tm.stats.LastBatchSize = len(operations)\n\t\n\t// 批量写入磁盘\n\terr := m.batchWriteToDisk(operations)\n\tif err != nil {\n\t\tatomic.AddInt64(&m.stats.FailedWrites, 1)\n\t\treturn fmt.Errorf(\"批量写入失败: %v\", err)\n\t}\n\t\n\t// 清空缓冲区\n\tm.queueBuffer = m.queueBuffer[:0]\n\tif m.config.EnableCompression {\n\t\tm.mapMutex.Lock()\n\t\tm.operationMap = make(map[string]*CacheOperation)\n\t\tm.mapMutex.Unlock()\n\t}\n\t\n\t// 成功统计\n\tatomic.AddInt64(&m.stats.SuccessfulWrites, 1)\n\tatomic.AddInt64(&m.stats.TotalWrites, 1)\n\tm.stats.TotalOperationsWritten += len(operations)\n\t\n\treturn nil\n}\n\n// getCompressedOperations 获取压缩后的操作列表\nfunc (m *DelayedBatchWriteManager) getCompressedOperations() []*CacheOperation {\n\tm.mapMutex.RLock()\n\tdefer m.mapMutex.RUnlock()\n\t\n\toperations := make([]*CacheOperation, 0, len(m.operationMap))\n\tfor _, op := range m.operationMap {\n\t\toperations = append(operations, op)\n\t}\n\t\n\treturn operations\n}\n\n// batchWriteToDisk 批量写入磁盘\nfunc (m *DelayedBatchWriteManager) batchWriteToDisk(operations []*CacheOperation) error {\n\tif m.mainCacheUpdater == nil {\n\t\treturn fmt.Errorf(\"主缓存更新函数未设置\")\n\t}\n\t\n\t// 批量处理所有操作\n\tfor _, op := range operations {\n\t\t// 序列化数据\n\t\tdata, err := m.serializer.Serialize(op.Data)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"数据序列化失败: %v\", err)\n\t\t}\n\t\t\n\t\t// 写入磁盘\n\t\tif err := m.mainCacheUpdater(op.Key, data, op.TTL); err != nil {\n\t\t\treturn fmt.Errorf(\"磁盘写入失败: %v\", err)\n\t\t}\n\t}\n\t\n\treturn nil\n}\n\n// emergencyFlush 紧急刷新\nfunc (m *DelayedBatchWriteManager) emergencyFlush() error {\n\tm.queueMutex.Lock()\n\tdefer m.queueMutex.Unlock()\n\t\n\treturn m.executeBatchWrite(\"紧急刷新\")\n}\n\n// autoTuneParameters 自适应参数调优\nfunc (m *DelayedBatchWriteManager) autoTuneParameters() {\n\t// 完全自动调优，无需配置开关\n\tstats := m.collectRecentStats()\n\t\n\t// 调优批量间隔：基于系统负载动态调整\n\tavgSystemLoad := stats.SystemLoadAverage\n\tswitch {\n\tcase avgSystemLoad > 0.8: // 高负载：延长间隔，减少干扰\n\t\tm.config.MaxBatchInterval = m.minDuration(m.config.MaxBatchInterval*12/10, m.config.maxBatchInterval)\n\tcase avgSystemLoad < 0.3: // 低负载：缩短间隔，及时持久化\n\t\tm.config.MaxBatchInterval = m.maxDuration(m.config.MaxBatchInterval*8/10, m.config.minBatchInterval)\n\t}\n\t\n\t// 调优批量大小：基于写入频率动态调整\n\tqueueSize := int(atomic.LoadInt32(&m.stats.CurrentQueueSize))\n\tswitch {\n\tcase queueSize > 200: // 高频：增大批量，提高效率\n\t\tm.config.MaxBatchSize = m.minInt(m.config.MaxBatchSize*12/10, m.config.maxBatchSize)\n\tcase queueSize < 50:  // 低频：减小批量，降低延迟\n\t\tm.config.MaxBatchSize = m.maxInt(m.config.MaxBatchSize*8/10, m.config.minBatchSize)\n\t}\n}\n\n// collectRecentStats 收集最近的统计数据\nfunc (m *DelayedBatchWriteManager) collectRecentStats() *WriteManagerStats {\n\treturn m.GetWriteManagerStats()\n}\n\n// 辅助函数\nfunc (m *DelayedBatchWriteManager) minDuration(a, b time.Duration) time.Duration {\n\tif a < b { return a }\n\treturn b\n}\n\nfunc (m *DelayedBatchWriteManager) maxDuration(a, b time.Duration) time.Duration {\n\tif a > b { return a }\n\treturn b\n}\n\nfunc (m *DelayedBatchWriteManager) minInt(a, b int) int {\n\tif a < b { return a }\n\treturn b\n}\n\nfunc (m *DelayedBatchWriteManager) maxInt(a, b int) int {\n\tif a > b { return a }\n\treturn b\n}\n\n// GetStats 获取统计信息\nfunc (m *DelayedBatchWriteManager) GetStats() map[string]interface{} {\n\tstats := *m.stats\n\tstats.CurrentQueueSize = atomic.LoadInt32(&m.stats.CurrentQueueSize)\n\tstats.WindowEnd = time.Now()\n\t\n\t// 计算压缩比例\n\tif stats.TotalOperations > 0 {\n\t\tstats.SystemLoadAverage = float64(stats.TotalWrites) / float64(stats.TotalOperations)\n\t}\n\t\n\t// 获取全局缓冲区统计\n\tglobalBufferStats := m.globalBufferManager.GetStats()\n\t\n\t// 合并所有统计信息\n\tcombinedStats := map[string]interface{}{\n\t\t\"write_manager\": &stats,\n\t\t\"global_buffer\": globalBufferStats,\n\t\t\"buffer_info\":   m.globalBufferManager.GetBufferInfo(),\n\t}\n\t\n\treturn combinedStats\n}\n\n// GetWriteManagerStats 获取写入管理器统计（兼容性方法）\nfunc (m *DelayedBatchWriteManager) GetWriteManagerStats() *WriteManagerStats {\n\tstats := *m.stats\n\tstats.CurrentQueueSize = atomic.LoadInt32(&m.stats.CurrentQueueSize)\n\tstats.WindowEnd = time.Now()\n\t\n\t// 计算压缩比例\n\tif stats.TotalOperations > 0 {\n\t\tstats.SystemLoadAverage = float64(stats.TotalWrites) / float64(stats.TotalOperations)\n\t}\n\t\n\treturn &stats\n}"
  },
  {
    "path": "util/cache/disk_cache.go",
    "content": "package cache\n\nimport (\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"time\"\n\t\n\t\"pansou/util/json\"\n)\n\n// 磁盘缓存项元数据\ntype diskCacheMetadata struct {\n\tKey         string    `json:\"key\"`\n\tExpiry      time.Time `json:\"expiry\"`\n\tLastUsed    time.Time `json:\"last_used\"`\n\tSize        int       `json:\"size\"`\n\tLastModified time.Time `json:\"last_modified\"` // 添加最后修改时间字段\n}\n\n// DiskCache 磁盘缓存\ntype DiskCache struct {\n\tpath      string\n\tmaxSizeMB int\n\tmetadata  map[string]*diskCacheMetadata\n\tmutex     sync.RWMutex\n\tcurrSize  int64\n}\n\n// NewDiskCache 创建新的磁盘缓存\nfunc NewDiskCache(path string, maxSizeMB int) (*DiskCache, error) {\n\t// 确保缓存目录存在\n\tif err := os.MkdirAll(path, 0755); err != nil {\n\t\treturn nil, err\n\t}\n\n\tcache := &DiskCache{\n\t\tpath:      path,\n\t\tmaxSizeMB: maxSizeMB,\n\t\tmetadata:  make(map[string]*diskCacheMetadata),\n\t}\n\n\t// 加载现有缓存元数据\n\tcache.loadMetadata()\n\n\t// 启动周期性清理\n\tgo cache.startCleanupTask()\n\n\treturn cache, nil\n}\n\n// 加载元数据\nfunc (c *DiskCache) loadMetadata() {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\t// 遍历缓存目录\n\tfiles, err := ioutil.ReadDir(c.path)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tfor _, file := range files {\n\t\tif file.IsDir() {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 跳过元数据文件\n\t\tif file.Name() == \"metadata.json\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 读取元数据\n\t\tmetadataFile := filepath.Join(c.path, file.Name()+\".meta\")\n\t\tdata, err := ioutil.ReadFile(metadataFile)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar meta diskCacheMetadata\n\t\tif err := json.Unmarshal(data, &meta); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 更新总大小\n\t\tc.currSize += int64(meta.Size)\n\t\t\n\t\t// 存储元数据\n\t\tc.metadata[meta.Key] = &meta\n\t}\n}\n\n// 保存元数据\nfunc (c *DiskCache) saveMetadata(key string, meta *diskCacheMetadata) error {\n\tmetadataFile := filepath.Join(c.path, c.getFilename(key)+\".meta\")\n\tdata, err := json.Marshal(meta)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn ioutil.WriteFile(metadataFile, data, 0644)\n}\n\n// 获取文件名\nfunc (c *DiskCache) getFilename(key string) string {\n\thash := md5.Sum([]byte(key))\n\treturn hex.EncodeToString(hash[:])\n}\n\n// Set 设置缓存\nfunc (c *DiskCache) Set(key string, data []byte, ttl time.Duration) error {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\t// 如果已存在，先减去旧项的大小\n\tif meta, exists := c.metadata[key]; exists {\n\t\tc.currSize -= int64(meta.Size)\n\t\t// 删除旧文件\n\t\tfilename := c.getFilename(key)\n\t\tos.Remove(filepath.Join(c.path, filename))\n\t\tos.Remove(filepath.Join(c.path, filename+\".meta\"))\n\t}\n\n\t// 检查空间\n\tmaxSize := int64(c.maxSizeMB) * 1024 * 1024\n\tif c.currSize+int64(len(data)) > maxSize {\n\t\t// 清理空间\n\t\tc.evictLRU(int64(len(data)))\n\t}\n\n\t// 获取文件名\n\tfilename := c.getFilename(key)\n\tfilePath := filepath.Join(c.path, filename)\n\n\t// 确保目录存在（防止外部删除缓存目录）\n\tif err := os.MkdirAll(c.path, 0755); err != nil {\n\t\treturn fmt.Errorf(\"创建缓存目录失败: %v\", err)\n\t}\n\n\t// 写入文件\n\tif err := ioutil.WriteFile(filePath, data, 0644); err != nil {\n\t\treturn err\n\t}\n\n\t// 创建元数据\n\tnow := time.Now()\n\tmeta := &diskCacheMetadata{\n\t\tKey:         key,\n\t\tExpiry:      now.Add(ttl),\n\t\tLastUsed:    now,\n\t\tLastModified: now, // 设置最后修改时间\n\t\tSize:        len(data),\n\t}\n\n\t// 保存元数据\n\tif err := c.saveMetadata(key, meta); err != nil {\n\t\t// 如果元数据保存失败，删除数据文件\n\t\tos.Remove(filePath)\n\t\treturn err\n\t}\n\n\t// 更新内存中的元数据\n\tc.metadata[key] = meta\n\tc.currSize += int64(len(data))\n\n\treturn nil\n}\n\n// Get 获取缓存\nfunc (c *DiskCache) Get(key string) ([]byte, bool, error) {\n\tc.mutex.RLock()\n\tmeta, exists := c.metadata[key]\n\tc.mutex.RUnlock()\n\n\tif !exists {\n\t\treturn nil, false, nil\n\t}\n\n\t// 检查是否过期\n\tif time.Now().After(meta.Expiry) {\n\t\tc.Delete(key)\n\t\treturn nil, false, nil\n\t}\n\n\t// 获取文件路径\n\tfilePath := filepath.Join(c.path, c.getFilename(key))\n\n\t// 读取文件\n\tdata, err := ioutil.ReadFile(filePath)\n\tif err != nil {\n\t\t// 如果文件不存在，删除元数据\n\t\tif os.IsNotExist(err) {\n\t\t\tc.Delete(key)\n\t\t}\n\t\treturn nil, false, err\n\t}\n\n\t// 更新最后使用时间\n\tc.mutex.Lock()\n\tmeta.LastUsed = time.Now()\n\tc.saveMetadata(key, meta)\n\tc.mutex.Unlock()\n\n\treturn data, true, nil\n}\n\n// Delete 删除缓存\nfunc (c *DiskCache) Delete(key string) error {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\tmeta, exists := c.metadata[key]\n\tif !exists {\n\t\treturn nil\n\t}\n\n\t// 删除文件\n\tfilename := c.getFilename(key)\n\tos.Remove(filepath.Join(c.path, filename))\n\tos.Remove(filepath.Join(c.path, filename+\".meta\"))\n\n\t// 更新元数据\n\tc.currSize -= int64(meta.Size)\n\tdelete(c.metadata, key)\n\n\treturn nil\n}\n\n// Has 检查缓存是否存在\nfunc (c *DiskCache) Has(key string) bool {\n\tc.mutex.RLock()\n\tdefer c.mutex.RUnlock()\n\n\tmeta, exists := c.metadata[key]\n\tif !exists {\n\t\treturn false\n\t}\n\n\t// 检查是否过期\n\tif time.Now().After(meta.Expiry) {\n\t\t// 异步删除过期项\n\t\tgo c.Delete(key)\n\t\treturn false\n\t}\n\n\treturn true\n}\n\n// 清理过期项\nfunc (c *DiskCache) cleanExpired() {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\tnow := time.Now()\n\tfor key, meta := range c.metadata {\n\t\tif now.After(meta.Expiry) {\n\t\t\t// 删除文件\n\t\t\tfilename := c.getFilename(key)\n\t\t\terr := os.Remove(filepath.Join(c.path, filename))\n\t\t\tif err == nil || os.IsNotExist(err) {\n\t\t\t\tos.Remove(filepath.Join(c.path, filename+\".meta\"))\n\t\t\t\tc.currSize -= int64(meta.Size)\n\t\t\t\tdelete(c.metadata, key)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// 驱逐策略 - LRU\nfunc (c *DiskCache) evictLRU(requiredSpace int64) {\n\t// 按最后使用时间排序\n\ttype cacheItem struct {\n\t\tkey      string\n\t\tlastUsed time.Time\n\t\tsize     int\n\t}\n\n\titems := make([]cacheItem, 0, len(c.metadata))\n\tfor k, v := range c.metadata {\n\t\titems = append(items, cacheItem{\n\t\t\tkey:      k,\n\t\t\tlastUsed: v.LastUsed,\n\t\t\tsize:     v.Size,\n\t\t})\n\t}\n\n\t// 按最后使用时间排序\n\t// 使用冒泡排序保持简单\n\tfor i := 0; i < len(items); i++ {\n\t\tfor j := 0; j < len(items)-i-1; j++ {\n\t\t\tif items[j].lastUsed.After(items[j+1].lastUsed) {\n\t\t\t\titems[j], items[j+1] = items[j+1], items[j]\n\t\t\t}\n\t\t}\n\t}\n\n\t// 从最久未使用开始删除，直到有足够空间\n\tmaxSize := int64(c.maxSizeMB) * 1024 * 1024\n\tfor _, item := range items {\n\t\tif c.currSize+requiredSpace <= maxSize {\n\t\t\tbreak\n\t\t}\n\n\t\t// 删除文件\n\t\tfilename := c.getFilename(item.key)\n\t\terr := os.Remove(filepath.Join(c.path, filename))\n\t\tif err == nil || os.IsNotExist(err) {\n\t\t\tos.Remove(filepath.Join(c.path, filename+\".meta\"))\n\t\t\tc.currSize -= int64(item.size)\n\t\t\tdelete(c.metadata, item.key)\n\t\t}\n\t}\n}\n\n// 启动定期清理任务\nfunc (c *DiskCache) startCleanupTask() {\n\tticker := time.NewTicker(10 * time.Minute)\n\tfor range ticker.C {\n\t\tc.cleanExpired()\n\t}\n}\n\n// Clear 清空缓存\nfunc (c *DiskCache) Clear() error {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\t// 删除所有缓存文件\n\tfiles, err := ioutil.ReadDir(c.path)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, file := range files {\n\t\tif file.IsDir() {\n\t\t\tcontinue\n\t\t}\n\n\t\tos.Remove(filepath.Join(c.path, file.Name()))\n\t}\n\n\t// 重置元数据\n\tc.metadata = make(map[string]*diskCacheMetadata)\n\tc.currSize = 0\n\n\treturn nil\n} \n\n// GetLastModified 获取缓存项的最后修改时间\nfunc (c *DiskCache) GetLastModified(key string) (time.Time, bool) {\n\tc.mutex.RLock()\n\tdefer c.mutex.RUnlock()\n\n\tmeta, exists := c.metadata[key]\n\tif !exists {\n\t\treturn time.Time{}, false\n\t}\n\n\treturn meta.LastModified, true\n} "
  },
  {
    "path": "util/cache/enhanced_two_level_cache.go",
    "content": "package cache\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"pansou/config\"\n)\n\n// EnhancedTwoLevelCache 改进的两级缓存\ntype EnhancedTwoLevelCache struct {\n\tmemory     *ShardedMemoryCache\n\tdisk       *ShardedDiskCache\n\tmutex      sync.RWMutex\n\tserializer Serializer\n}\n\n// NewEnhancedTwoLevelCache 创建新的改进两级缓存\nfunc NewEnhancedTwoLevelCache() (*EnhancedTwoLevelCache, error) {\n\t// 内存缓存大小为磁盘缓存的60%\n\tmemCacheMaxItems := 5000\n\tmemCacheSizeMB := config.AppConfig.CacheMaxSizeMB * 3 / 5\n\t\n\tmemCache := NewShardedMemoryCache(memCacheMaxItems, memCacheSizeMB)\n\tmemCache.StartCleanupTask()\n\n\t// 创建优化的分片磁盘缓存，使用动态分片数量\n\tdiskCache, err := NewOptimizedShardedDiskCache(config.AppConfig.CachePath, config.AppConfig.CacheMaxSizeMB)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 创建序列化器\n\tserializer := NewGobSerializer()\n\n\t// 设置内存缓存的磁盘缓存引用，用于LRU淘汰时的备份\n\tmemCache.SetDiskCacheReference(diskCache)\n\n\treturn &EnhancedTwoLevelCache{\n\t\tmemory:     memCache,\n\t\tdisk:       diskCache,\n\t\tserializer: serializer,\n\t}, nil\n}\n\n// Set 设置缓存\nfunc (c *EnhancedTwoLevelCache) Set(key string, data []byte, ttl time.Duration) error {\n\t// 获取当前时间作为最后修改时间\n\tnow := time.Now()\n\t\n\t// 先设置内存缓存（这是快速操作，直接在当前goroutine中执行）\n\tc.memory.SetWithTimestamp(key, data, ttl, now)\n\t\n\t// 异步设置磁盘缓存（这是IO操作，可能较慢）\n\tgo func(k string, d []byte, t time.Duration) {\n\t\t// 使用独立的goroutine写入磁盘，避免阻塞调用者\n\t\t_ = c.disk.Set(k, d, t)\n\t}(key, data, ttl)\n\t\n\treturn nil\n}\n\n// SetMemoryOnly 仅更新内存缓存\nfunc (c *EnhancedTwoLevelCache) SetMemoryOnly(key string, data []byte, ttl time.Duration) error {\n\tnow := time.Now()\n\t\n\t// 只更新内存缓存，不触发磁盘写入\n\tc.memory.SetWithTimestamp(key, data, ttl, now)\n\t\n\treturn nil\n}\n\n// SetBothLevels 更新内存和磁盘缓存\nfunc (c *EnhancedTwoLevelCache) SetBothLevels(key string, data []byte, ttl time.Duration) error {\n\tnow := time.Now()\n\t\n\t// 同步更新内存缓存\n\tc.memory.SetWithTimestamp(key, data, ttl, now)\n\t\n\t// 同步更新磁盘缓存，确保数据立即写入\n\treturn c.disk.Set(key, data, ttl)\n}\n\n// SetWithFinalFlag 根据结果状态选择更新策略\nfunc (c *EnhancedTwoLevelCache) SetWithFinalFlag(key string, data []byte, ttl time.Duration, isFinal bool) error {\n\tif isFinal {\n\t\treturn c.SetBothLevels(key, data, ttl)\n\t} else {\n\t\treturn c.SetMemoryOnly(key, data, ttl)\n\t}\n}\n\n// Get 获取缓存\nfunc (c *EnhancedTwoLevelCache) Get(key string) ([]byte, bool, error) {\n\t\n\t// 检查内存缓存\n\tdata, _, memHit := c.memory.GetWithTimestamp(key)\n\tif memHit {\n\t\treturn data, true, nil\n\t}\n\n    // 尝试从磁盘读取数据\n\tdiskData, diskHit, diskErr := c.disk.Get(key)\n\tif diskErr == nil && diskHit {\n\t\t// 磁盘缓存命中，更新内存缓存\n\t\tdiskLastModified, _ := c.disk.GetLastModified(key)\n\t\tttl := time.Duration(config.AppConfig.CacheTTLMinutes) * time.Minute\n\t\tc.memory.SetWithTimestamp(key, diskData, ttl, diskLastModified)\n\t\treturn diskData, true, nil\n\t}\n\t\n\treturn nil, false, nil\n}\n\n// Delete 删除缓存\nfunc (c *EnhancedTwoLevelCache) Delete(key string) error {\n\t// 从内存缓存删除\n\tc.memory.Delete(key)\n\t\n\t// 从磁盘缓存删除\n\treturn c.disk.Delete(key)\n}\n\n// Clear 清空所有缓存\nfunc (c *EnhancedTwoLevelCache) Clear() error {\n\t// 清空内存缓存\n\tc.memory.Clear()\n\t\n\t// 清空磁盘缓存\n\treturn c.disk.Clear()\n}\n\n// 设置序列化器\nfunc (c *EnhancedTwoLevelCache) SetSerializer(serializer Serializer) {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\tc.serializer = serializer\n}\n\n// 获取序列化器\nfunc (c *EnhancedTwoLevelCache) GetSerializer() Serializer {\n\tc.mutex.RLock()\n\tdefer c.mutex.RUnlock()\n\treturn c.serializer\n}\n\n// FlushMemoryToDisk 将内存缓存中的所有数据刷新到磁盘\nfunc (c *EnhancedTwoLevelCache) FlushMemoryToDisk() error {\n\t// 获取内存缓存中的所有键值对\n\tallItems := c.memory.GetAllItems()\n\t\n\tvar lastErr error\n\t\n\tfor key, item := range allItems {\n\t\t// 同步写入到磁盘缓存\n\t\tif err := c.disk.Set(key, item.Data, item.TTL); err != nil {\n\t\t\tfmt.Printf(\"[内存同步] 同步失败: %s -> %v\\n\", key, err)\n\t\t\tlastErr = err\n\t\t\tcontinue\n\t\t}\n\t}\n\t\n\treturn lastErr\n} "
  },
  {
    "path": "util/cache/global_buffer_manager.go",
    "content": "package cache\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n)\n\n// GlobalBufferStrategy 全局缓冲策略\ntype GlobalBufferStrategy string\n\nconst (\n\t// BufferByKeyword 按关键词缓冲\n\tBufferByKeyword GlobalBufferStrategy = \"keyword\"\n\t\n\t// BufferByPlugin 按插件缓冲\n\tBufferByPlugin GlobalBufferStrategy = \"plugin\"\n\t\n\t// BufferByPattern 按搜索模式缓冲\n\tBufferByPattern GlobalBufferStrategy = \"pattern\"\n\t\n\t// BufferHybrid 混合缓冲策略\n\tBufferHybrid GlobalBufferStrategy = \"hybrid\"\n)\n\n// SearchPattern 搜索模式\ntype SearchPattern struct {\n\tKeywordPattern   string            // 关键词模式\n\tPluginSet        []string          // 插件集合\n\tTimeWindow       time.Duration     // 时间窗口\n\tFrequency        int               // 频率\n\tLastAccessTime   time.Time         // 最后访问时间\n\tMetadata         map[string]interface{} // 元数据\n}\n\n// GlobalBuffer 全局缓冲区\ntype GlobalBuffer struct {\n\t// 基础信息\n\tID               string                   // 缓冲区ID\n\tStrategy         GlobalBufferStrategy     // 缓冲策略\n\tCreatedAt        time.Time               // 创建时间\n\tLastUpdatedAt    time.Time               // 最后更新时间\n\t\n\t// 数据存储\n\tOperations       []*CacheOperation       // 操作列表\n\tKeywordGroups    map[string][]*CacheOperation // 按关键词分组\n\tPluginGroups     map[string][]*CacheOperation // 按插件分组\n\t\n\t// 统计信息\n\tTotalOperations  int64                   // 总操作数\n\tTotalDataSize    int64                   // 总数据大小\n\tCompressRatio    float64                 // 压缩比例\n\t\n\t// 控制参数\n\tMaxOperations    int                     // 最大操作数\n\tMaxDataSize      int64                   // 最大数据大小\n\tMaxAge           time.Duration           // 最大存活时间\n\t\n\tmutex            sync.RWMutex            // 读写锁\n}\n\n// GlobalBufferManager 全局缓冲区管理器\ntype GlobalBufferManager struct {\n\t// 配置\n\tstrategy          GlobalBufferStrategy\n\tmaxBuffers        int                    // 最大缓冲区数量\n\tdefaultBufferSize int                    // 默认缓冲区大小\n\t\n\t// 缓冲区管理\n\tbuffers          map[string]*GlobalBuffer // 缓冲区映射\n\tbuffersMutex     sync.RWMutex            // 缓冲区锁\n\t\n\t// 已移除：搜索模式分析、数据合并器、状态监控\n\t\n\t// 统计信息\n\tstats            *GlobalBufferStats\n\t\n\t// 控制通道\n\tcleanupTicker    *time.Ticker\n\tshutdownChan     chan struct{}\n\t\n\t// 初始化状态\n\tinitialized      int32\n}\n\n// GlobalBufferStats 全局缓冲区统计\ntype GlobalBufferStats struct {\n\t// 缓冲区统计\n\tActiveBuffers        int64     // 活跃缓冲区数量\n\tTotalBuffersCreated  int64     // 总创建缓冲区数量\n\tTotalBuffersDestroyed int64    // 总销毁缓冲区数量\n\t\n\t// 操作统计\n\tTotalOperationsBuffered int64  // 总缓冲操作数\n\tTotalOperationsMerged   int64  // 总合并操作数\n\tTotalDataMerged         int64  // 总合并数据大小\n\t\n\t// 效率统计\n\tAverageCompressionRatio float64 // 平均压缩比例\n\tAverageBufferLifetime   time.Duration // 平均缓冲区生命周期\n\tHitRate                 float64 // 命中率\n\t\n\t// 性能统计\n\tLastCleanupTime     time.Time     // 最后清理时间\n\tCleanupFrequency    time.Duration // 清理频率\n\tMemoryUsage         int64         // 内存使用量\n}\n\n// NewGlobalBufferManager 创建全局缓冲区管理器\nfunc NewGlobalBufferManager(strategy GlobalBufferStrategy) *GlobalBufferManager {\n\t// 高并发优化：静默使用插件策略，避免缓冲区爆炸\n\tif strategy == BufferHybrid {\n\t\tstrategy = BufferByPlugin\n\t}\n\t\n\tmanager := &GlobalBufferManager{\n\t\tstrategy:          strategy,\n\t\tmaxBuffers:        50,  // 最大50个缓冲区\n\t\tdefaultBufferSize: 100, // 默认100个操作\n\t\tbuffers:           make(map[string]*GlobalBuffer),\n\t\tshutdownChan:      make(chan struct{}),\n\t\tstats: &GlobalBufferStats{\n\t\t\tLastCleanupTime: time.Now(),\n\t\t},\n\t}\n\t\n\t// 初始化组件（移除未使用的监控与合并器）\n\t\n\treturn manager\n}\n\n// Initialize 初始化管理器\nfunc (g *GlobalBufferManager) Initialize() error {\n\tif !atomic.CompareAndSwapInt32(&g.initialized, 0, 1) {\n\t\treturn nil // 已经初始化\n\t}\n\t\n\t// 启动定期清理\n\tg.cleanupTicker = time.NewTicker(5 * time.Minute) // 每5分钟清理一次\n\tgo g.cleanupRoutine()\n\t\n\t// 移除状态监控启动（监控已删除）\n\t\n\t// 初始化完成（静默）\n\treturn nil\n}\n\n// AddOperation 添加操作到全局缓冲区\nfunc (g *GlobalBufferManager) AddOperation(op *CacheOperation) (*GlobalBuffer, bool, error) {\n\tif err := g.Initialize(); err != nil {\n\t\treturn nil, false, err\n\t}\n\t\n\t// 根据策略确定缓冲区ID\n\tbufferID := g.determineBufferID(op)\n\t\n\tg.buffersMutex.Lock()\n\tdefer g.buffersMutex.Unlock()\n\t\n\t// 获取或创建缓冲区\n\tbuffer, exists := g.buffers[bufferID]\n\tif !exists {\n\t\tbuffer = g.createNewBuffer(bufferID, op)\n\t\tg.buffers[bufferID] = buffer\n\t\tatomic.AddInt64(&g.stats.TotalBuffersCreated, 1)\n\t\tatomic.AddInt64(&g.stats.ActiveBuffers, 1)\n\t}\n\t\n\t// 添加操作到缓冲区\n\tshouldFlush := g.addOperationToBuffer(buffer, op)\n\t\n\t// 更新统计\n\tatomic.AddInt64(&g.stats.TotalOperationsBuffered, 1)\n\t\n\treturn buffer, shouldFlush, nil\n}\n\n// determineBufferID 确定缓冲区ID\nfunc (g *GlobalBufferManager) determineBufferID(op *CacheOperation) string {\n\tswitch g.strategy {\n\tcase BufferByKeyword:\n\t\treturn fmt.Sprintf(\"keyword_%s\", op.Keyword)\n\t\t\n\tcase BufferByPlugin:\n\t\treturn fmt.Sprintf(\"plugin_%s\", op.PluginName)\n\t\t\n\tcase BufferByPattern:\n\t\t// 已移除模式分析器，退化为按关键词分组\n\t\treturn fmt.Sprintf(\"keyword_%s\", op.Keyword)\n\t\t\n\tcase BufferHybrid:\n\t\t// 混合策略优化：插件+时间窗口（去掉关键词避免高并发爆炸）\n\t\ttimeWindow := op.Timestamp.Truncate(5 * time.Minute) // 5分钟时间窗口\n\t\treturn fmt.Sprintf(\"hybrid_%s_%d\", \n\t\t\top.PluginName, timeWindow.Unix())\n\t\t\t\n\tdefault:\n\t\treturn fmt.Sprintf(\"default_%s\", op.Key)\n\t}\n}\n\n// createNewBuffer 创建新缓冲区\nfunc (g *GlobalBufferManager) createNewBuffer(bufferID string, firstOp *CacheOperation) *GlobalBuffer {\n\tnow := time.Now()\n\t\n\tbuffer := &GlobalBuffer{\n\t\tID:               bufferID,\n\t\tStrategy:         g.strategy,\n\t\tCreatedAt:        now,\n\t\tLastUpdatedAt:    now,\n\t\tOperations:       make([]*CacheOperation, 0, g.defaultBufferSize),\n\t\tKeywordGroups:    make(map[string][]*CacheOperation),\n\t\tPluginGroups:     make(map[string][]*CacheOperation),\n\t\tMaxOperations:    g.defaultBufferSize,\n\t\tMaxDataSize:      int64(g.defaultBufferSize * 1000), // 估算100KB\n\t\tMaxAge:           10 * time.Minute, // 10分钟最大存活时间\n\t}\n\t\n\treturn buffer\n}\n\n// addOperationToBuffer 添加操作到缓冲区\nfunc (g *GlobalBufferManager) addOperationToBuffer(buffer *GlobalBuffer, op *CacheOperation) bool {\n\tbuffer.mutex.Lock()\n\tdefer buffer.mutex.Unlock()\n\t\n\t// 直接追加（已移除数据合并器）\n\tbuffer.Operations = append(buffer.Operations, op)\n\tbuffer.TotalOperations++\n\tbuffer.TotalDataSize += int64(op.DataSize)\n\t\n\t// 按关键词分组\n\tif buffer.KeywordGroups[op.Keyword] == nil {\n\t\tbuffer.KeywordGroups[op.Keyword] = make([]*CacheOperation, 0)\n\t}\n\tbuffer.KeywordGroups[op.Keyword] = append(buffer.KeywordGroups[op.Keyword], op)\n\t\n\t// 按插件分组\n\tif buffer.PluginGroups[op.PluginName] == nil {\n\t\tbuffer.PluginGroups[op.PluginName] = make([]*CacheOperation, 0)\n\t}\n\tbuffer.PluginGroups[op.PluginName] = append(buffer.PluginGroups[op.PluginName], op)\n\t\n\tbuffer.LastUpdatedAt = time.Now()\n\t\n\t// 检查是否应该刷新\n\treturn g.shouldFlushBuffer(buffer)\n}\n\n// shouldFlushBuffer 检查是否应该刷新缓冲区\nfunc (g *GlobalBufferManager) shouldFlushBuffer(buffer *GlobalBuffer) bool {\n\tnow := time.Now()\n\t\n\t// 条件1：操作数量达到阈值\n\tif len(buffer.Operations) >= buffer.MaxOperations {\n\t\treturn true\n\t}\n\t\n\t// 条件2：数据大小达到阈值\n\tif buffer.TotalDataSize >= buffer.MaxDataSize {\n\t\treturn true\n\t}\n\t\n\t// 条件3：缓冲区存活时间过长\n\tif now.Sub(buffer.CreatedAt) >= buffer.MaxAge {\n\t\treturn true\n\t}\n\t\n\t// 条件4：内存压力（基于全局统计）\n\ttotalMemory := atomic.LoadInt64(&g.stats.MemoryUsage)\n\tif totalMemory > 50*1024*1024 { // 50MB内存阈值\n\t\treturn true\n\t}\n\t\n\t// 条件5：高优先级操作比例达到阈值\n\thighPriorityRatio := g.calculateHighPriorityRatio(buffer)\n\tif highPriorityRatio > 0.6 { // 60%高优先级阈值\n\t\treturn true\n\t}\n\t\n\treturn false\n}\n\n// calculateHighPriorityRatio 计算高优先级操作比例\nfunc (g *GlobalBufferManager) calculateHighPriorityRatio(buffer *GlobalBuffer) float64 {\n\tif len(buffer.Operations) == 0 {\n\t\treturn 0\n\t}\n\t\n\thighPriorityCount := 0\n\tfor _, op := range buffer.Operations {\n\t\tif op.Priority <= 2 { // 等级1和等级2插件\n\t\t\thighPriorityCount++\n\t\t}\n\t}\n\t\n\treturn float64(highPriorityCount) / float64(len(buffer.Operations))\n}\n\n// FlushBuffer 刷新指定缓冲区\nfunc (g *GlobalBufferManager) FlushBuffer(bufferID string) ([]*CacheOperation, error) {\n\tg.buffersMutex.Lock()\n\tdefer g.buffersMutex.Unlock()\n\t\n\tbuffer, exists := g.buffers[bufferID]\n\tif !exists {\n\t\treturn nil, fmt.Errorf(\"缓冲区不存在: %s\", bufferID)\n\t}\n\t\n\tbuffer.mutex.Lock()\n\tdefer buffer.mutex.Unlock()\n\t\n\t// 获取所有操作\n\toperations := make([]*CacheOperation, len(buffer.Operations))\n\tcopy(operations, buffer.Operations)\n\t\n\t// 清空缓冲区\n\tbuffer.Operations = buffer.Operations[:0]\n\tbuffer.KeywordGroups = make(map[string][]*CacheOperation)\n\tbuffer.PluginGroups = make(map[string][]*CacheOperation)\n\tbuffer.TotalOperations = 0\n\tbuffer.TotalDataSize = 0\n\t\n\t// 更新压缩比例\n\tif len(operations) > 0 {\n\t\tbuffer.CompressRatio = float64(len(operations)) / float64(buffer.TotalOperations)\n\t}\n\t\n\treturn operations, nil\n}\n\n// FlushAllBuffers 刷新所有缓冲区\nfunc (g *GlobalBufferManager) FlushAllBuffers() map[string][]*CacheOperation {\n\tg.buffersMutex.RLock()\n\tbufferIDs := make([]string, 0, len(g.buffers))\n\tfor id := range g.buffers {\n\t\tbufferIDs = append(bufferIDs, id)\n\t}\n\tg.buffersMutex.RUnlock()\n\t\n\tresult := make(map[string][]*CacheOperation)\n\tfor _, id := range bufferIDs {\n\t\tif ops, err := g.FlushBuffer(id); err == nil && len(ops) > 0 {\n\t\t\tresult[id] = ops\n\t\t}\n\t}\n\t\n\treturn result\n}\n\n// cleanupRoutine 清理例程\nfunc (g *GlobalBufferManager) cleanupRoutine() {\n\tfor {\n\t\tselect {\n\t\tcase <-g.cleanupTicker.C:\n\t\t\tg.performCleanup()\n\t\t\t\n\t\tcase <-g.shutdownChan:\n\t\t\tg.cleanupTicker.Stop()\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// performCleanup 执行清理\nfunc (g *GlobalBufferManager) performCleanup() {\n\tnow := time.Now()\n\t\n\tg.buffersMutex.Lock()\n\tdefer g.buffersMutex.Unlock()\n\t\n\ttoDelete := make([]string, 0)\n\t\n\tfor id, buffer := range g.buffers {\n\t\tbuffer.mutex.RLock()\n\t\t\n\t\t// 清理条件：空缓冲区且超过6分钟未活动（避免与监控冲突）\n\t\tif len(buffer.Operations) == 0 && now.Sub(buffer.LastUpdatedAt) > 6*time.Minute {\n\t\t\ttoDelete = append(toDelete, id)\n\t\t}\n\t\t\n\t\tbuffer.mutex.RUnlock()\n\t}\n\t\n\t// 删除过期缓冲区\n\tfor _, id := range toDelete {\n\t\tdelete(g.buffers, id)\n\t\tatomic.AddInt64(&g.stats.TotalBuffersDestroyed, 1)\n\t\tatomic.AddInt64(&g.stats.ActiveBuffers, -1)\n\t}\n\t\n\t// 更新清理统计\n\tg.stats.LastCleanupTime = now\n\tg.stats.CleanupFrequency = now.Sub(g.stats.LastCleanupTime)\n\t\n\t// 计算内存使用量\n\tg.updateMemoryUsage()\n\t\n}\n\n// updateMemoryUsage 更新内存使用量估算\nfunc (g *GlobalBufferManager) updateMemoryUsage() {\n\ttotalMemory := int64(0)\n\t\n\tfor _, buffer := range g.buffers {\n\t\tbuffer.mutex.RLock()\n\t\ttotalMemory += buffer.TotalDataSize\n\t\tbuffer.mutex.RUnlock()\n\t}\n\t\n\tatomic.StoreInt64(&g.stats.MemoryUsage, totalMemory)\n}\n\n// Shutdown 优雅关闭\nfunc (g *GlobalBufferManager) Shutdown() error {\n\tif !atomic.CompareAndSwapInt32(&g.initialized, 1, 0) {\n\t\treturn nil // 已经关闭\n\t}\n\t\n\t// 停止后台任务\n\tclose(g.shutdownChan)\n\t\n\t// 刷新所有缓冲区\n\tflushedBuffers := g.FlushAllBuffers()\n\ttotalOperations := 0\n\tfor _, ops := range flushedBuffers {\n\t\ttotalOperations += len(ops)\n\t}\n\t\n\t\n\treturn nil\n}\n\n// GetStats 获取统计信息\nfunc (g *GlobalBufferManager) GetStats() *GlobalBufferStats {\n\tstats := *g.stats\n\tstats.ActiveBuffers = atomic.LoadInt64(&g.stats.ActiveBuffers)\n\tstats.MemoryUsage = atomic.LoadInt64(&g.stats.MemoryUsage)\n\t\n\t// 计算平均压缩比例\n\tif stats.TotalOperationsBuffered > 0 {\n\t\tstats.AverageCompressionRatio = float64(stats.TotalOperationsMerged) / float64(stats.TotalOperationsBuffered)\n\t}\n\t\n\t// 计算命中率\n\tif stats.TotalOperationsBuffered > 0 {\n\t\tstats.HitRate = float64(stats.TotalOperationsMerged) / float64(stats.TotalOperationsBuffered)\n\t}\n\t\n\treturn &stats\n}\n\n// GetBufferInfo 获取缓冲区信息\nfunc (g *GlobalBufferManager) GetBufferInfo() map[string]interface{} {\n\tg.buffersMutex.RLock()\n\tdefer g.buffersMutex.RUnlock()\n\t\n\tinfo := make(map[string]interface{})\n\t\n\tfor id, buffer := range g.buffers {\n\t\tbuffer.mutex.RLock()\n\t\tbufferInfo := map[string]interface{}{\n\t\t\t\"id\":               id,\n\t\t\t\"strategy\":         buffer.Strategy,\n\t\t\t\"created_at\":       buffer.CreatedAt,\n\t\t\t\"last_updated_at\":  buffer.LastUpdatedAt,\n\t\t\t\"total_operations\": buffer.TotalOperations,\n\t\t\t\"total_data_size\":  buffer.TotalDataSize,\n\t\t\t\"compress_ratio\":   buffer.CompressRatio,\n\t\t\t\"keyword_groups\":   len(buffer.KeywordGroups),\n\t\t\t\"plugin_groups\":    len(buffer.PluginGroups),\n\t\t}\n\t\tbuffer.mutex.RUnlock()\n\t\t\n\t\tinfo[id] = bufferInfo\n\t}\n\t\n\treturn info\n}\n\n// GetExpiredBuffersForFlush 原子地获取需要刷新的过期缓冲区列表\nfunc (g *GlobalBufferManager) GetExpiredBuffersForFlush() []string {\n\tg.buffersMutex.RLock()\n\tdefer g.buffersMutex.RUnlock()\n\t\n\tnow := time.Now()\n\texpiredBuffers := make([]string, 0, 10) // 预分配容量，减少内存重分配\n\t\n\tfor id, buffer := range g.buffers {\n\t\t// 快速预检查：先检查时间，减少锁竞争\n\t\tif now.Sub(buffer.LastUpdatedAt) <= 4*time.Minute {\n\t\t\tcontinue // 跳过未过期的缓冲区\n\t\t}\n\t\t\n\t\tbuffer.mutex.RLock()\n\t\t// 双重检查：确保在锁保护下再次验证\n\t\tif now.Sub(buffer.LastUpdatedAt) > 4*time.Minute && len(buffer.Operations) > 0 {\n\t\t\texpiredBuffers = append(expiredBuffers, id)\n\t\t}\n\t\tbuffer.mutex.RUnlock()\n\t}\n\t\n\treturn expiredBuffers\n}"
  },
  {
    "path": "util/cache/memory_cache.go",
    "content": "package cache\n\nimport (\n\t\"sync\"\n\t\"time\"\n)\n\n// 简单的内存缓存项\ntype memoryCacheItem struct {\n\tdata         []byte\n\texpiry       time.Time\n\tlastUsed     time.Time\n\tlastModified time.Time // 添加最后修改时间\n\tsize         int\n}\n\n// 内存缓存\ntype MemoryCache struct {\n\titems     map[string]*memoryCacheItem\n\tmutex     sync.RWMutex\n\tmaxItems  int\n\tmaxSize   int64\n\tcurrSize  int64\n}\n\n// 创建新的内存缓存\nfunc NewMemoryCache(maxItems int, maxSizeMB int) *MemoryCache {\n\treturn &MemoryCache{\n\t\titems:    make(map[string]*memoryCacheItem),\n\t\tmaxItems: maxItems,\n\t\tmaxSize:  int64(maxSizeMB) * 1024 * 1024,\n\t}\n}\n\n// 设置缓存\nfunc (c *MemoryCache) Set(key string, data []byte, ttl time.Duration) {\n\tc.SetWithTimestamp(key, data, ttl, time.Now())\n}\n\n// SetWithTimestamp 设置缓存，并指定最后修改时间\nfunc (c *MemoryCache) SetWithTimestamp(key string, data []byte, ttl time.Duration, lastModified time.Time) {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\t// 如果已存在，先减去旧项的大小\n\tif item, exists := c.items[key]; exists {\n\t\tc.currSize -= int64(item.size)\n\t}\n\n\t// 创建新的缓存项\n\tnow := time.Now()\n\titem := &memoryCacheItem{\n\t\tdata:         data,\n\t\texpiry:       now.Add(ttl),\n\t\tlastUsed:     now,\n\t\tlastModified: lastModified,\n\t\tsize:         len(data),\n\t}\n\n\t// 检查是否需要清理空间\n\tif len(c.items) >= c.maxItems || c.currSize+int64(len(data)) > c.maxSize {\n\t\tc.evict()\n\t}\n\n\t// 存储新项\n\tc.items[key] = item\n\tc.currSize += int64(len(data))\n}\n\n// 获取缓存\nfunc (c *MemoryCache) Get(key string) ([]byte, bool) {\n\tc.mutex.RLock()\n\titem, exists := c.items[key]\n\tc.mutex.RUnlock()\n\n\tif !exists {\n\t\treturn nil, false\n\t}\n\n\t// 检查是否过期\n\tif time.Now().After(item.expiry) {\n\t\tc.mutex.Lock()\n\t\tdelete(c.items, key)\n\t\tc.currSize -= int64(item.size)\n\t\tc.mutex.Unlock()\n\t\treturn nil, false\n\t}\n\n\t// 更新最后使用时间\n\tc.mutex.Lock()\n\titem.lastUsed = time.Now()\n\tc.mutex.Unlock()\n\n\treturn item.data, true\n}\n\n// GetWithTimestamp 获取缓存及其最后修改时间\nfunc (c *MemoryCache) GetWithTimestamp(key string) ([]byte, time.Time, bool) {\n\tc.mutex.RLock()\n\titem, exists := c.items[key]\n\tc.mutex.RUnlock()\n\n\tif !exists {\n\t\treturn nil, time.Time{}, false\n\t}\n\n\t// 检查是否过期\n\tif time.Now().After(item.expiry) {\n\t\tc.mutex.Lock()\n\t\tdelete(c.items, key)\n\t\tc.currSize -= int64(item.size)\n\t\tc.mutex.Unlock()\n\t\treturn nil, time.Time{}, false\n\t}\n\n\t// 更新最后使用时间\n\tc.mutex.Lock()\n\titem.lastUsed = time.Now()\n\tc.mutex.Unlock()\n\n\treturn item.data, item.lastModified, true\n}\n\n// GetLastModified 获取缓存项的最后修改时间\nfunc (c *MemoryCache) GetLastModified(key string) (time.Time, bool) {\n\tc.mutex.RLock()\n\tdefer c.mutex.RUnlock()\n\n\titem, exists := c.items[key]\n\tif !exists {\n\t\treturn time.Time{}, false\n\t}\n\n\t// 检查是否过期\n\tif time.Now().After(item.expiry) {\n\t\treturn time.Time{}, false\n\t}\n\n\treturn item.lastModified, true\n}\n\n// 驱逐策略 - LRU\nfunc (c *MemoryCache) evict() {\n\t// 找出最久未使用的项\n\tvar oldestKey string\n\tvar oldestTime time.Time\n\n\t// 初始化为当前时间\n\toldestTime = time.Now()\n\n\tfor k, v := range c.items {\n\t\tif v.lastUsed.Before(oldestTime) {\n\t\t\toldestKey = k\n\t\t\toldestTime = v.lastUsed\n\t\t}\n\t}\n\n\t// 如果找到了最久未使用的项，删除它\n\tif oldestKey != \"\" {\n\t\titem := c.items[oldestKey]\n\t\tc.currSize -= int64(item.size)\n\t\tdelete(c.items, oldestKey)\n\t}\n}\n\n// 清理过期项\nfunc (c *MemoryCache) CleanExpired() {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\tnow := time.Now()\n\tfor k, v := range c.items {\n\t\tif now.After(v.expiry) {\n\t\t\tc.currSize -= int64(v.size)\n\t\t\tdelete(c.items, k)\n\t\t}\n\t}\n}\n\n// 启动定期清理\nfunc (c *MemoryCache) StartCleanupTask() {\n\tticker := time.NewTicker(5 * time.Minute)\n\tgo func() {\n\t\tfor range ticker.C {\n\t\t\tc.CleanExpired()\n\t\t}\n\t}()\n} "
  },
  {
    "path": "util/cache/serializer.go",
    "content": "package cache\n\nimport (\n\t\"bytes\"\n\t\"encoding/gob\"\n\t\"sync\"\n\t\"time\"\n\t\n\t\"pansou/model\"\n)\n\n// 初始化函数，注册model包中的类型到gob\nfunc init() {\n\t// 注册SearchResult类型\n\tgob.Register(model.SearchResult{})\n\t\n\t// 注册SearchResponse类型\n\tgob.Register(model.SearchResponse{})\n\t\n\t// 注册MergedLinks类型\n\tgob.Register(model.MergedLinks{})\n\t\n\t// 注册[]model.SearchResult类型\n\tgob.Register([]model.SearchResult{})\n\t\n\t// 注册map[string][]model.SearchResult类型\n\tgob.Register(map[string][]model.SearchResult{})\n\t\n\t// 注册time.Time类型\n\tgob.Register(time.Time{})\n}\n\n// Serializer 序列化接口\ntype Serializer interface {\n\tSerialize(v interface{}) ([]byte, error)\n\tDeserialize(data []byte, v interface{}) error\n}\n\n// GobSerializer 使用gob进行序列化/反序列化\ntype GobSerializer struct {\n\tbufferPool sync.Pool\n}\n\n// NewGobSerializer 创建新的gob序列化器\nfunc NewGobSerializer() *GobSerializer {\n\treturn &GobSerializer{\n\t\tbufferPool: sync.Pool{\n\t\t\tNew: func() interface{} {\n\t\t\t\treturn new(bytes.Buffer)\n\t\t\t},\n\t\t},\n\t}\n}\n\n// Serialize 序列化数据\nfunc (s *GobSerializer) Serialize(v interface{}) ([]byte, error) {\n\tbuf := s.bufferPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tdefer s.bufferPool.Put(buf)\n\t\n\tenc := gob.NewEncoder(buf)\n\tif err := enc.Encode(v); err != nil {\n\t\treturn nil, err\n\t}\n\t\n\tresult := make([]byte, buf.Len())\n\tcopy(result, buf.Bytes())\n\treturn result, nil\n}\n\n// Deserialize 反序列化数据\nfunc (s *GobSerializer) Deserialize(data []byte, v interface{}) error {\n\tbuf := s.bufferPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tdefer s.bufferPool.Put(buf)\n\t\n\tbuf.Write(data)\n\tdec := gob.NewDecoder(buf)\n\treturn dec.Decode(v)\n}\n\n// JSONSerializer 使用JSON进行序列化/反序列化\n// 为了保持向后兼容性\ntype JSONSerializer struct {\n\tbufferPool *sync.Pool\n}\n\n// NewJSONSerializer 创建新的JSON序列化器\nfunc NewJSONSerializer() *JSONSerializer {\n\treturn &JSONSerializer{\n\t\tbufferPool: &bufferPool, // 使用已有的缓冲区池\n\t}\n}\n\n// Serialize 序列化数据\nfunc (s *JSONSerializer) Serialize(v interface{}) ([]byte, error) {\n\treturn SerializeWithPool(v)\n}\n\n// Deserialize 反序列化数据\nfunc (s *JSONSerializer) Deserialize(data []byte, v interface{}) error {\n\treturn DeserializeWithPool(data, v)\n} "
  },
  {
    "path": "util/cache/sharded_disk_cache.go",
    "content": "package cache\n\nimport (\n\t\"fmt\"\n\t\"hash/fnv\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"sync\"\n\t\"time\"\n)\n\n// ShardedDiskCache 分片磁盘缓存\ntype ShardedDiskCache struct {\n\tbaseDir     string\n\tshardCount  int\n\tshardMask   uint32 // 用于快速取模的掩码\n\tshards      []*DiskCache\n\tmaxSizeMB   int\n\tmutex       sync.RWMutex\n}\n\n// NewShardedDiskCache 创建新的分片磁盘缓存（兼容现有接口）\nfunc NewShardedDiskCache(baseDir string, shardCount, maxSizeMB int) (*ShardedDiskCache, error) {\n\treturn newShardedDiskCacheWithCount(baseDir, shardCount, maxSizeMB)\n}\n\n// NewOptimizedShardedDiskCache 创建优化的分片磁盘缓存（动态分片数）\nfunc NewOptimizedShardedDiskCache(baseDir string, maxSizeMB int) (*ShardedDiskCache, error) {\n\t// 动态确定分片数量：与内存缓存保持一致的策略\n\tshardCount := runtime.NumCPU() * 2\n\tif shardCount < 4 {\n\t\tshardCount = 4\n\t}\n\tif shardCount > 32 { // 磁盘缓存分片数适当限制，避免过多文件夹\n\t\tshardCount = 32\n\t}\n\t\n\t// 确保分片数是2的幂，便于使用掩码进行快速取模\n\tshardCount = nextPowerOfTwoDisk(shardCount)\n\t\n\treturn newShardedDiskCacheWithCount(baseDir, shardCount, maxSizeMB)\n}\n\n// 获取下一个2的幂（磁盘缓存版本）\nfunc nextPowerOfTwoDisk(n int) int {\n\tif n <= 1 {\n\t\treturn 1\n\t}\n\tn--\n\tn |= n >> 1\n\tn |= n >> 2\n\tn |= n >> 4\n\tn |= n >> 8\n\tn |= n >> 16\n\treturn n + 1\n}\n\n// 内部构造函数\nfunc newShardedDiskCacheWithCount(baseDir string, shardCount, maxSizeMB int) (*ShardedDiskCache, error) {\n\t// 确保每个分片的大小合理\n\tshardSize := maxSizeMB / shardCount\n\tif shardSize < 1 {\n\t\tshardSize = 1\n\t}\n\t\n\tcache := &ShardedDiskCache{\n\t\tbaseDir:    baseDir,\n\t\tshardCount: shardCount,\n\t\tshardMask:  uint32(shardCount - 1), // 用于快速取模\n\t\tshards:     make([]*DiskCache, shardCount),\n\t\tmaxSizeMB:  maxSizeMB,\n\t}\n\t\n\t// 初始化每个分片\n\tfor i := 0; i < shardCount; i++ {\n\t\tshardPath := filepath.Join(baseDir, fmt.Sprintf(\"shard_%d\", i))\n\t\tdiskCache, err := NewDiskCache(shardPath, shardSize)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcache.shards[i] = diskCache\n\t}\n\t\n\treturn cache, nil\n}\n\n// 获取键对应的分片\nfunc (c *ShardedDiskCache) getShard(key string) *DiskCache {\n\t// 计算哈希值决定分片\n\th := fnv.New32a()\n\th.Write([]byte(key))\n\tshardIndex := h.Sum32() & c.shardMask // 使用掩码进行快速取模\n\treturn c.shards[shardIndex]\n}\n\n// Set 设置缓存\nfunc (c *ShardedDiskCache) Set(key string, data []byte, ttl time.Duration) error {\n\tshard := c.getShard(key)\n\treturn shard.Set(key, data, ttl)\n}\n\n// Get 获取缓存\nfunc (c *ShardedDiskCache) Get(key string) ([]byte, bool, error) {\n\tshard := c.getShard(key)\n\treturn shard.Get(key)\n}\n\n// Delete 删除缓存\nfunc (c *ShardedDiskCache) Delete(key string) error {\n\tshard := c.getShard(key)\n\treturn shard.Delete(key)\n}\n\n// Has 检查缓存是否存在\nfunc (c *ShardedDiskCache) Has(key string) bool {\n\tshard := c.getShard(key)\n\treturn shard.Has(key)\n}\n\n// Clear 清空所有缓存\nfunc (c *ShardedDiskCache) Clear() error {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\t\n\tvar lastErr error\n\tfor _, shard := range c.shards {\n\t\tif err := shard.Clear(); err != nil {\n\t\t\tlastErr = err\n\t\t}\n\t}\n\t\n\treturn lastErr\n} \n\n// GetLastModified 获取缓存项的最后修改时间\nfunc (c *ShardedDiskCache) GetLastModified(key string) (time.Time, bool) {\n\tshard := c.getShard(key)\n\treturn shard.GetLastModified(key)\n}\n\n// cleanExpired 清理所有分片中的过期项\nfunc (c *ShardedDiskCache) cleanExpired() {\n\t// 并行清理所有分片中的过期项\n\tfor _, shard := range c.shards {\n\t\tgo func(s *DiskCache) {\n\t\t\ts.cleanExpired()\n\t\t}(shard)\n\t}\n}\n\n// CleanExpired 公开的清理方法，符合cleanupTarget接口\nfunc (c *ShardedDiskCache) CleanExpired() {\n\tc.cleanExpired()\n}\n\n// StartCleanupTask 启动定期清理任务（修改为使用单例模式）\nfunc (c *ShardedDiskCache) StartCleanupTask() {\n\t// 使用与内存缓存相同的全局清理系统\n\tregisterForCleanup(c)\n\tstartGlobalCleanupTask()\n}\n\n// GetShards 获取所有分片（用于测试和调试）\nfunc (c *ShardedDiskCache) GetShards() []*DiskCache {\n\treturn c.shards\n}\n\n// GetShardIndex 获取指定键对应的分片索引（用于测试和调试）\nfunc (c *ShardedDiskCache) GetShardIndex(key string) int {\n\th := fnv.New32a()\n\th.Write([]byte(key))\n\tif c.shardMask > 0 {\n\t\treturn int(h.Sum32() & c.shardMask)\n\t} else {\n\t\t// 兼容老版本的模运算\n\t\treturn int(h.Sum32()) % c.shardCount\n\t}\n} "
  },
  {
    "path": "util/cache/sharded_memory_cache.go",
    "content": "package cache\n\nimport (\n\t\"hash/fnv\"\n\t\"runtime\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n)\n\n// 全局清理任务相关变量（单例模式）\nvar (\n\tglobalCleanupTicker *time.Ticker\n\tglobalCleanupOnce   sync.Once\n\tregisteredCaches    []cleanupTarget\n\tcacheRegistryMutex  sync.RWMutex\n)\n\n// 清理目标接口\ntype cleanupTarget interface {\n\tCleanExpired()\n}\n\n// 分片内存缓存项\ntype shardedMemoryCacheItem struct {\n\tdata         []byte\n\texpiry       time.Time\n\tlastUsed     int64 // 使用原子操作的时间戳\n\tlastModified time.Time\n\tsize         int\n}\n\n// 单个分片\ntype memoryCacheShard struct {\n\titems    map[string]*shardedMemoryCacheItem\n\tmutex    sync.RWMutex\n\tcurrSize int64\n}\n\n// 分片内存缓存\ntype ShardedMemoryCache struct {\n\tshards    []*memoryCacheShard\n\tshardMask uint32 // 用于快速取模的掩码\n\tmaxItems  int\n\tmaxSize   int64\n\titemsPerShard int\n\tsizePerShard  int64\n\tdiskCache     *ShardedDiskCache // 磁盘缓存引用\n\tdiskCacheMutex sync.RWMutex     // 磁盘缓存引用的保护锁\n}\n\n// 创建新的分片内存缓存\nfunc NewShardedMemoryCache(maxItems int, maxSizeMB int) *ShardedMemoryCache {\n\t// 动态确定分片数量：基于CPU核心数，但至少4个，最多64个\n\tshardCount := runtime.NumCPU() * 2\n\tif shardCount < 4 {\n\t\tshardCount = 4\n\t}\n\tif shardCount > 64 {\n\t\tshardCount = 64\n\t}\n\t\n\t// 确保分片数是2的幂，便于使用掩码进行快速取模\n\tshardCount = nextPowerOfTwo(shardCount)\n\t\n\ttotalSize := int64(maxSizeMB) * 1024 * 1024\n\titemsPerShard := maxItems / shardCount\n\tsizePerShard := totalSize / int64(shardCount)\n\t\n\tshards := make([]*memoryCacheShard, shardCount)\n\tfor i := 0; i < shardCount; i++ {\n\t\tshards[i] = &memoryCacheShard{\n\t\t\titems: make(map[string]*shardedMemoryCacheItem),\n\t\t}\n\t}\n\t\n\treturn &ShardedMemoryCache{\n\t\tshards:        shards,\n\t\tshardMask:     uint32(shardCount - 1), // 用于快速取模\n\t\tmaxItems:      maxItems,\n\t\tmaxSize:       totalSize,\n\t\titemsPerShard: itemsPerShard,\n\t\tsizePerShard:  sizePerShard,\n\t}\n}\n\n// 获取下一个2的幂\nfunc nextPowerOfTwo(n int) int {\n\tif n <= 1 {\n\t\treturn 1\n\t}\n\tn--\n\tn |= n >> 1\n\tn |= n >> 2\n\tn |= n >> 4\n\tn |= n >> 8\n\tn |= n >> 16\n\treturn n + 1\n}\n\n// 获取分片\nfunc (c *ShardedMemoryCache) getShard(key string) *memoryCacheShard {\n\th := fnv.New32a()\n\th.Write([]byte(key))\n\tshardIndex := h.Sum32() & c.shardMask // 使用掩码进行快速取模\n\treturn c.shards[shardIndex]\n}\n\n// 设置缓存\nfunc (c *ShardedMemoryCache) Set(key string, data []byte, ttl time.Duration) {\n\tc.SetWithTimestamp(key, data, ttl, time.Now())\n}\n\n// SetWithTimestamp 设置缓存，并指定最后修改时间\nfunc (c *ShardedMemoryCache) SetWithTimestamp(key string, data []byte, ttl time.Duration, lastModified time.Time) {\n\tshard := c.getShard(key)\n\tshard.mutex.Lock()\n\tdefer shard.mutex.Unlock()\n\t\n\t// 如果已存在，先减去旧项的大小\n\tif item, exists := shard.items[key]; exists {\n\t\tatomic.AddInt64(&shard.currSize, -int64(item.size))\n\t}\n\t\n\t// 创建新的缓存项\n\tnow := time.Now()\n\titem := &shardedMemoryCacheItem{\n\t\tdata:         data,\n\t\texpiry:       now.Add(ttl),\n\t\tlastUsed:     now.UnixNano(),\n\t\tlastModified: lastModified,\n\t\tsize:         len(data),\n\t}\n\t\n\t// 检查是否需要清理空间\n\tif len(shard.items) >= c.itemsPerShard || shard.currSize+int64(len(data)) > c.sizePerShard {\n\t\tc.evictFromShard(shard)\n\t}\n\t\n\t// 存储新项\n\tshard.items[key] = item\n\tatomic.AddInt64(&shard.currSize, int64(len(data)))\n}\n\n// 获取缓存\nfunc (c *ShardedMemoryCache) Get(key string) ([]byte, bool) {\n\tshard := c.getShard(key)\n\tshard.mutex.RLock()\n\titem, exists := shard.items[key]\n\tshard.mutex.RUnlock()\n\t\n\tif !exists {\n\t\treturn nil, false\n\t}\n\t\n\t// 检查是否过期\n\tif time.Now().After(item.expiry) {\n\t\tshard.mutex.Lock()\n\t\tdelete(shard.items, key)\n\t\tatomic.AddInt64(&shard.currSize, -int64(item.size))\n\t\tshard.mutex.Unlock()\n\t\treturn nil, false\n\t}\n\t\n\t// 原子操作更新最后使用时间，避免额外的锁\n\tatomic.StoreInt64(&item.lastUsed, time.Now().UnixNano())\n\t\n\treturn item.data, true\n}\n\n// GetWithTimestamp 获取缓存及其最后修改时间\nfunc (c *ShardedMemoryCache) GetWithTimestamp(key string) ([]byte, time.Time, bool) {\n\tshard := c.getShard(key)\n\tshard.mutex.RLock()\n\titem, exists := shard.items[key]\n\tshard.mutex.RUnlock()\n\t\n\tif !exists {\n\t\treturn nil, time.Time{}, false\n\t}\n\t\n\t// 检查是否过期\n\tif time.Now().After(item.expiry) {\n\t\tshard.mutex.Lock()\n\t\tdelete(shard.items, key)\n\t\tatomic.AddInt64(&shard.currSize, -int64(item.size))\n\t\tshard.mutex.Unlock()\n\t\treturn nil, time.Time{}, false\n\t}\n\t\n\t// 原子操作更新最后使用时间\n\tatomic.StoreInt64(&item.lastUsed, time.Now().UnixNano())\n\t\n\treturn item.data, item.lastModified, true\n}\n\n// GetLastModified 获取缓存项的最后修改时间\nfunc (c *ShardedMemoryCache) GetLastModified(key string) (time.Time, bool) {\n\tshard := c.getShard(key)\n\tshard.mutex.RLock()\n\tdefer shard.mutex.RUnlock()\n\t\n\titem, exists := shard.items[key]\n\tif !exists {\n\t\treturn time.Time{}, false\n\t}\n\t\n\t// 检查是否过期\n\tif time.Now().After(item.expiry) {\n\t\treturn time.Time{}, false\n\t}\n\t\n\treturn item.lastModified, true\n}\n\n// 从指定分片中驱逐最久未使用的项（带磁盘备份）\nfunc (c *ShardedMemoryCache) evictFromShard(shard *memoryCacheShard) {\n\tvar oldestKey string\n\tvar oldestItem *shardedMemoryCacheItem\n\tvar oldestTime int64 = 9223372036854775807 // int64最大值\n\t\n\tfor k, v := range shard.items {\n\t\tlastUsed := atomic.LoadInt64(&v.lastUsed)\n\t\tif lastUsed < oldestTime {\n\t\t\toldestKey = k\n\t\t\toldestItem = v\n\t\t\toldestTime = lastUsed\n\t\t}\n\t}\n\t\n\t// 如果找到了最久未使用的项，删除它\n\tif oldestKey != \"\" && oldestItem != nil {\n\t\t// 🔥 关键优化：淘汰前检查是否需要刷盘保护\n\t\tdiskCache := c.getDiskCacheReference()\n\t\tif time.Now().Before(oldestItem.expiry) && diskCache != nil {\n\t\t\t// 数据还没过期，异步刷新到磁盘保存\n\t\t\tgo func(key string, data []byte, expiry time.Time) {\n\t\t\t\tttl := time.Until(expiry)\n\t\t\t\tif ttl > 0 {\n\t\t\t\t\tdiskCache.Set(key, data, ttl) // 保持相同TTL\n\t\t\t\t}\n\t\t\t}(oldestKey, oldestItem.data, oldestItem.expiry)\n\t\t}\n\t\t\n\t\t// 从内存中删除\n\t\tatomic.AddInt64(&shard.currSize, -int64(oldestItem.size))\n\t\tdelete(shard.items, oldestKey)\n\t}\n}\n\n// 清理过期项\nfunc (c *ShardedMemoryCache) CleanExpired() {\n\tnow := time.Now()\n\t\n\t// 并行清理所有分片\n\tvar wg sync.WaitGroup\n\tfor _, shard := range c.shards {\n\t\twg.Add(1)\n\t\tgo func(s *memoryCacheShard) {\n\t\t\tdefer wg.Done()\n\t\t\ts.mutex.Lock()\n\t\t\tdefer s.mutex.Unlock()\n\t\t\t\n\t\t\tfor k, v := range s.items {\n\t\t\t\tif now.After(v.expiry) {\n\t\t\t\t\tatomic.AddInt64(&s.currSize, -int64(v.size))\n\t\t\t\t\tdelete(s.items, k)\n\t\t\t\t}\n\t\t\t}\n\t\t}(shard)\n\t}\n\twg.Wait()\n}\n\n// Delete 删除指定键的缓存项\nfunc (c *ShardedMemoryCache) Delete(key string) {\n\tshard := c.getShard(key)\n\tshard.mutex.Lock()\n\tdefer shard.mutex.Unlock()\n\t\n\tif item, exists := shard.items[key]; exists {\n\t\tatomic.AddInt64(&shard.currSize, -int64(item.size))\n\t\tdelete(shard.items, key)\n\t}\n}\n\n// Clear 清空所有缓存项\nfunc (c *ShardedMemoryCache) Clear() {\n\t// 并行清理所有分片\n\tvar wg sync.WaitGroup\n\tfor _, shard := range c.shards {\n\t\twg.Add(1)\n\t\tgo func(s *memoryCacheShard) {\n\t\t\tdefer wg.Done()\n\t\t\ts.mutex.Lock()\n\t\t\tdefer s.mutex.Unlock()\n\t\t\t\n\t\t\ts.items = make(map[string]*shardedMemoryCacheItem)\n\t\t\tatomic.StoreInt64(&s.currSize, 0)\n\t\t}(shard)\n\t}\n\twg.Wait()\n}\n\n// 启动全局清理任务（单例模式）\nfunc startGlobalCleanupTask() {\n\tglobalCleanupOnce.Do(func() {\n\t\tglobalCleanupTicker = time.NewTicker(5 * time.Minute)\n\t\tgo func() {\n\t\t\tfor range globalCleanupTicker.C {\n\t\t\t\tcacheRegistryMutex.RLock()\n\t\t\t\tcaches := make([]cleanupTarget, len(registeredCaches))\n\t\t\t\tcopy(caches, registeredCaches)\n\t\t\t\tcacheRegistryMutex.RUnlock()\n\t\t\t\t\n\t\t\t\t// 并行清理所有注册的缓存\n\t\t\t\tfor _, cache := range caches {\n\t\t\t\t\tgo cache.CleanExpired()\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t})\n}\n\n// 注册缓存到全局清理任务\nfunc registerForCleanup(cache cleanupTarget) {\n\tcacheRegistryMutex.Lock()\n\tdefer cacheRegistryMutex.Unlock()\n\tregisteredCaches = append(registeredCaches, cache)\n}\n\n// 启动定期清理（修改为使用单例模式）\nfunc (c *ShardedMemoryCache) StartCleanupTask() {\n\tregisterForCleanup(c)\n\tstartGlobalCleanupTask()\n}\n\n// SetDiskCacheReference 设置磁盘缓存引用\nfunc (c *ShardedMemoryCache) SetDiskCacheReference(diskCache *ShardedDiskCache) {\n\tc.diskCacheMutex.Lock()\n\tdefer c.diskCacheMutex.Unlock()\n\tc.diskCache = diskCache\n}\n\n// getDiskCacheReference 获取磁盘缓存引用\nfunc (c *ShardedMemoryCache) getDiskCacheReference() *ShardedDiskCache {\n\tc.diskCacheMutex.RLock()\n\tdefer c.diskCacheMutex.RUnlock()\n\treturn c.diskCache\n}\n\n// MemoryCacheItem 内存缓存项结构（用于导出）\ntype MemoryCacheItem struct {\n\tData []byte\n\tTTL  time.Duration\n}\n\n// GetAllItems 获取内存缓存中的所有项\nfunc (c *ShardedMemoryCache) GetAllItems() map[string]*MemoryCacheItem {\n\tresult := make(map[string]*MemoryCacheItem)\n\tnow := time.Now()\n\t\n\t// 遍历所有分片\n\tfor _, shard := range c.shards {\n\t\tshard.mutex.RLock()\n\t\tfor key, item := range shard.items {\n\t\t\t// 检查是否过期\n\t\t\tif !item.expiry.IsZero() && now.After(item.expiry) {\n\t\t\t\tcontinue // 跳过过期项\n\t\t\t}\n\t\t\t\n\t\t\t// 计算剩余TTL\n\t\t\tvar ttl time.Duration\n\t\t\tif !item.expiry.IsZero() {\n\t\t\t\tttl = item.expiry.Sub(now)\n\t\t\t\tif ttl <= 0 {\n\t\t\t\t\tcontinue // 跳过即将过期的项\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tresult[key] = &MemoryCacheItem{\n\t\t\t\tData: item.data,\n\t\t\t\tTTL:  ttl,\n\t\t\t}\n\t\t}\n\t\tshard.mutex.RUnlock()\n\t}\n\t\n\treturn result\n}"
  },
  {
    "path": "util/cache/utils.go",
    "content": "package cache\n\nimport (\n\t\"bytes\"\n\t\"sync\"\n\t\n\t\"pansou/util/json\"\n)\n\n// 缓冲区对象池\nvar bufferPool = sync.Pool{\n\tNew: func() interface{} {\n\t\treturn new(bytes.Buffer)\n\t},\n}\n\n// SerializeWithPool 使用对象池序列化数据\nfunc SerializeWithPool(v interface{}) ([]byte, error) {\n\tbuf := bufferPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tdefer bufferPool.Put(buf)\n\t\n\t// 使用sonic直接编码到缓冲区\n\tencoder := json.API.NewEncoder(buf)\n\tif err := encoder.Encode(v); err != nil {\n\t\treturn nil, err\n\t}\n\t\n\t// 复制结果以避免池化对象被修改\n\tresult := make([]byte, buf.Len())\n\tcopy(result, buf.Bytes())\n\treturn result, nil\n}\n\n// DeserializeWithPool 使用对象池反序列化数据\nfunc DeserializeWithPool(data []byte, v interface{}) error {\n\tbuf := bufferPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tdefer bufferPool.Put(buf)\n\t\n\t// 写入数据到缓冲区\n\tbuf.Write(data)\n\t\n\t// 使用sonic从缓冲区解码\n\tdecoder := json.API.NewDecoder(buf)\n\treturn decoder.Decode(v)\n} "
  },
  {
    "path": "util/compression.go",
    "content": "package util\n\nimport (\n\t\"bytes\"\n\t\"compress/gzip\"\n\t\"io/ioutil\"\n\t\"strings\"\n\t\n\t\"github.com/gin-gonic/gin\"\n\t\"pansou/config\"\n)\n\n// 压缩响应的包装器\ntype gzipResponseWriter struct {\n\tgin.ResponseWriter\n\tgzipWriter *gzip.Writer\n}\n\n// 实现Write接口\nfunc (g *gzipResponseWriter) Write(data []byte) (int, error) {\n\treturn g.gzipWriter.Write(data)\n}\n\n// 实现WriteString接口\nfunc (g *gzipResponseWriter) WriteString(s string) (int, error) {\n\treturn g.gzipWriter.Write([]byte(s))\n}\n\n// 关闭gzip写入器\nfunc (g *gzipResponseWriter) Close() {\n\tg.gzipWriter.Close()\n}\n\n// GzipMiddleware 返回一个Gin中间件，用于压缩HTTP响应\nfunc GzipMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// 如果未启用压缩，直接跳过\n\t\tif !config.AppConfig.EnableCompression {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 检查客户端是否支持gzip\n\t\tif !strings.Contains(c.Request.Header.Get(\"Accept-Encoding\"), \"gzip\") {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 创建一个缓冲响应写入器\n\t\tbuffer := &bytes.Buffer{}\n\t\tblw := &bodyLogWriter{body: buffer, ResponseWriter: c.Writer}\n\t\tc.Writer = blw\n\t\t\n\t\t// 处理请求\n\t\tc.Next()\n\t\t\n\t\t// 获取响应内容\n\t\tresponseData := buffer.Bytes()\n\t\t\n\t\t// 如果响应大小小于最小压缩大小，直接返回原始内容\n\t\tif len(responseData) < config.AppConfig.MinSizeToCompress {\n\t\t\tc.Writer.Write(responseData)\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 设置gzip响应头\n\t\tc.Header(\"Content-Encoding\", \"gzip\")\n\t\tc.Header(\"Vary\", \"Accept-Encoding\")\n\t\t\n\t\t// 创建gzip写入器\n\t\tgz, err := gzip.NewWriterLevel(c.Writer, gzip.BestSpeed)\n\t\tif err != nil {\n\t\t\tc.Writer.Write(responseData)\n\t\t\treturn\n\t\t}\n\t\tdefer gz.Close()\n\t\t\n\t\t// 写入压缩内容\n\t\tgz.Write(responseData)\n\t}\n}\n\n// bodyLogWriter 是一个用于记录响应体的写入器\ntype bodyLogWriter struct {\n\tgin.ResponseWriter\n\tbody *bytes.Buffer\n}\n\n// Write 实现ResponseWriter接口\nfunc (w bodyLogWriter) Write(b []byte) (int, error) {\n\tw.body.Write(b)\n\treturn w.ResponseWriter.Write(b)\n}\n\n// WriteString 实现ResponseWriter接口\nfunc (w bodyLogWriter) WriteString(s string) (int, error) {\n\tw.body.WriteString(s)\n\treturn w.ResponseWriter.WriteString(s)\n}\n\n// CompressData 压缩数据\nfunc CompressData(data []byte) ([]byte, error) {\n\tvar buf bytes.Buffer\n\t\n\t// 创建gzip写入器\n\tgz, err := gzip.NewWriterLevel(&buf, gzip.BestSpeed)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t\n\t// 写入数据\n\tif _, err := gz.Write(data); err != nil {\n\t\treturn nil, err\n\t}\n\t\n\t// 关闭写入器\n\tif err := gz.Close(); err != nil {\n\t\treturn nil, err\n\t}\n\t\n\treturn buf.Bytes(), nil\n}\n\n// DecompressData 解压数据\nfunc DecompressData(data []byte) ([]byte, error) {\n\t// 创建gzip读取器\n\tgz, err := gzip.NewReader(bytes.NewReader(data))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer gz.Close()\n\t\n\t// 读取解压后的数据\n\treturn ioutil.ReadAll(gz)\n} "
  },
  {
    "path": "util/convert.go",
    "content": "package util\n\nimport (\n\t\"strconv\"\n)\n\n// StringToInt 将字符串转换为整数，如果转换失败则返回0\nfunc StringToInt(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\t\n\ti, err := strconv.Atoi(s)\n\tif err != nil {\n\t\treturn 0\n\t}\n\treturn i\n} "
  },
  {
    "path": "util/http_util.go",
    "content": "package util\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"time\"\n\n\t\"golang.org/x/net/proxy\"\n\t\"pansou/config\"\n)\n\n// 全局HTTP客户端\nvar httpClient *http.Client\n\n// InitHTTPClient 初始化HTTP客户端\nfunc InitHTTPClient() {\n\t// 创建传输配置\n\ttransport := &http.Transport{\n\t\t// 启用HTTP/2\n\t\tForceAttemptHTTP2: true,\n\t\t\n\t\t// TLS配置\n\t\tTLSClientConfig: &tls.Config{\n\t\t\tInsecureSkipVerify: false, // 生产环境应设为false\n\t\t},\n\t\t\n\t\t// 连接池优化\n\t\tMaxIdleConns:          100,\n\t\tMaxIdleConnsPerHost:   20,\n\t\tMaxConnsPerHost:       100,\n\t\tIdleConnTimeout:       90 * time.Second,\n\t\tTLSHandshakeTimeout:   10 * time.Second,\n\t\tExpectContinueTimeout: 1 * time.Second,\n\t\t\n\t\t// TCP连接优化\n\t\tDialContext: (&net.Dialer{\n\t\t\tTimeout:   30 * time.Second,\n\t\t\tKeepAlive: 30 * time.Second,\n\t\t\tDualStack: true,\n\t\t}).DialContext,\n\t}\n\n\t// 如果配置了代理，设置代理\n\tif config.AppConfig.UseProxy {\n\t\tproxyURL, err := url.Parse(config.AppConfig.ProxyURL)\n\t\tif err == nil {\n\t\t\t// 根据代理类型设置不同的处理方式\n\t\t\tif proxyURL.Scheme == \"socks5\" {\n\t\t\t\t// 创建SOCKS5代理拨号器\n\t\t\t\tdialer, err := proxy.FromURL(proxyURL, proxy.Direct)\n\t\t\t\tif err == nil {\n\t\t\t\t\ttransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {\n\t\t\t\t\t\treturn dialer.Dial(network, addr)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// HTTP/HTTPS代理\n\t\t\t\ttransport.Proxy = http.ProxyURL(proxyURL)\n\t\t\t}\n\t\t}\n\t}\n\n\t// 创建客户端\n\thttpClient = &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   time.Duration(60) * time.Second,\n\t}\n}\n\n// GetHTTPClient 获取HTTP客户端\nfunc GetHTTPClient() *http.Client {\n\tif httpClient == nil {\n\t\tInitHTTPClient()\n\t}\n\treturn httpClient\n}\n\n// FetchHTML 获取HTML内容\nfunc FetchHTML(targetURL string) (string, error) {\n\t// 使用优化后的HTTP客户端\n\tclient := GetHTTPClient()\n\t\n\t// 创建请求\n\treq, err := http.NewRequest(\"GET\", targetURL, nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\t\n\t// 设置请求头\n\treq.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\")\n\treq.Header.Set(\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\")\n\treq.Header.Set(\"Accept-Language\", \"en-US,en;q=0.5\")\n\treq.Header.Set(\"Connection\", \"keep-alive\")\n\treq.Header.Set(\"Upgrade-Insecure-Requests\", \"1\")\n\t\n\t// 发送请求\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\t\n\t// 读取响应体\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\t\n\treturn string(body), nil\n}\n\n// BuildSearchURL 构建搜索URL\nfunc BuildSearchURL(channel string, keyword string, nextPageParam string) string {\n\tbaseURL := \"https://t.me/s/\" + channel\n\tif keyword != \"\" {\n\t\tbaseURL += \"?q=\" + url.QueryEscape(keyword)\n\t\tif nextPageParam != \"\" {\n\t\t\tbaseURL += \"&\" + nextPageParam\n\t\t}\n\t}\n\treturn baseURL\n} "
  },
  {
    "path": "util/json/json.go",
    "content": "package json\n\nimport (\n\t\"github.com/bytedance/sonic\"\n)\n\n// API是sonic的全局配置实例\nvar API = sonic.ConfigDefault\n\n// 初始化sonic配置\nfunc init() {\n\t// 根据需要配置sonic选项\n\tAPI = sonic.Config{\n\t\tUseNumber:   true,\n\t\tEscapeHTML:  true,\n\t\tSortMapKeys: false, // 生产环境设为false提高性能\n\t}.Froze()\n}\n\n// Marshal 使用sonic序列化对象到JSON\nfunc Marshal(v interface{}) ([]byte, error) {\n\treturn API.Marshal(v)\n}\n\n// Unmarshal 使用sonic反序列化JSON到对象\nfunc Unmarshal(data []byte, v interface{}) error {\n\treturn API.Unmarshal(data, v)\n}\n\n// MarshalString 序列化对象到JSON字符串\nfunc MarshalString(v interface{}) (string, error) {\n\tbytes, err := API.Marshal(v)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(bytes), nil\n}\n\n// UnmarshalString 反序列化JSON字符串到对象\nfunc UnmarshalString(str string, v interface{}) error {\n\treturn API.Unmarshal([]byte(str), v)\n}\n\n// MarshalIndent 序列化对象到格式化的JSON\nfunc MarshalIndent(v interface{}, prefix, indent string) ([]byte, error) {\n\t// 使用sonic的格式化功能\n\treturn API.MarshalIndent(v, prefix, indent)\n} "
  },
  {
    "path": "util/jwt.go",
    "content": "package util\n\nimport (\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n)\n\n// Claims JWT载荷结构\ntype Claims struct {\n\tUsername string `json:\"username\"`\n\tjwt.RegisteredClaims\n}\n\n// GenerateToken 生成JWT token\nfunc GenerateToken(username string, secret string, expiry time.Duration) (string, error) {\n\tif username == \"\" {\n\t\treturn \"\", errors.New(\"username cannot be empty\")\n\t}\n\tif secret == \"\" {\n\t\treturn \"\", errors.New(\"secret cannot be empty\")\n\t}\n\n\texpirationTime := time.Now().Add(expiry)\n\tclaims := &Claims{\n\t\tUsername: username,\n\t\tRegisteredClaims: jwt.RegisteredClaims{\n\t\t\tExpiresAt: jwt.NewNumericDate(expirationTime),\n\t\t\tIssuedAt:  jwt.NewNumericDate(time.Now()),\n\t\t\tIssuer:    \"pansou\",\n\t\t},\n\t}\n\n\ttoken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)\n\treturn token.SignedString([]byte(secret))\n}\n\n// ValidateToken 验证JWT token\nfunc ValidateToken(tokenString string, secret string) (*Claims, error) {\n\tif tokenString == \"\" {\n\t\treturn nil, errors.New(\"token cannot be empty\")\n\t}\n\tif secret == \"\" {\n\t\treturn nil, errors.New(\"secret cannot be empty\")\n\t}\n\n\tclaims := &Claims{}\n\n\ttoken, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {\n\t\t// 验证签名算法\n\t\tif _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {\n\t\t\treturn nil, errors.New(\"unexpected signing method\")\n\t\t}\n\t\treturn []byte(secret), nil\n\t})\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif !token.Valid {\n\t\treturn nil, errors.New(\"invalid token\")\n\t}\n\n\treturn claims, nil\n}\n"
  },
  {
    "path": "util/parser_util.go",
    "content": "package util\n\nimport (\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"pansou/model\"\n)\n\n// normalizeUrl 标准化URL，将URL编码的中文部分解码为中文，用于去重\nfunc normalizeUrl(rawUrl string) string {\n\t// 解码URL中的编码字符\n\tdecoded, err := url.QueryUnescape(rawUrl)\n\tif err != nil {\n\t\t// 如果解码失败，返回原始URL\n\t\treturn rawUrl\n\t}\n\treturn decoded\n}\n\n// isSupportedLink 检查链接是否为支持的网盘链接\nfunc isSupportedLink(url string) bool {\n\tlowerURL := strings.ToLower(url)\n\t\n\t// 检查是否为百度网盘链接\n\tif BaiduPanPattern.MatchString(lowerURL) {\n\t\treturn true\n\t}\n\t\n\t// 检查是否为天翼云盘链接\n\tif TianyiPanPattern.MatchString(lowerURL) {\n\t\treturn true\n\t}\n\t\n\t// 检查是否为UC网盘链接\n\tif UCPanPattern.MatchString(lowerURL) {\n\t\treturn true\n\t}\n\t\n\t// 检查是否为123网盘链接\n\tif Pan123Pattern.MatchString(lowerURL) {\n\t\treturn true\n\t}\n\t\n\t// 检查是否为夸克网盘链接\n\tif QuarkPanPattern.MatchString(lowerURL) {\n\t\treturn true\n\t}\n\t\n\t// 检查是否为迅雷网盘链接\n\tif XunleiPanPattern.MatchString(lowerURL) {\n\t\treturn true\n\t}\n\t\n\t// 检查是否为115网盘链接\n\tif Pan115Pattern.MatchString(lowerURL) {\n\t\treturn true\n\t}\n\t\n\t// 使用通用模式检查其他网盘链接\n\treturn AllPanLinksPattern.MatchString(lowerURL)\n}\n\n// normalizeBaiduPanURL 标准化百度网盘URL，确保链接格式正确并且包含密码参数\nfunc normalizeBaiduPanURL(url string, password string) string {\n\t// 清理URL，确保获取正确的链接部分\n\turl = CleanBaiduPanURL(url)\n\t\n\t// 如果URL已经包含pwd参数，不需要再添加\n\tif strings.Contains(url, \"?pwd=\") {\n\t\treturn url\n\t}\n\t\n\t// 如果有提取到密码，且URL不包含pwd参数，则添加\n\tif password != \"\" {\n\t\t// 确保密码是4位\n\t\tif len(password) > 4 {\n\t\t\tpassword = password[:4]\n\t\t}\n\t\treturn url + \"?pwd=\" + password\n\t}\n\t\n\treturn url\n}\n\n// normalizeTianyiPanURL 标准化天翼云盘URL，确保链接格式正确\nfunc normalizeTianyiPanURL(url string, password string) string {\n\t// 清理URL，确保获取正确的链接部分\n\turl = CleanTianyiPanURL(url)\n\t\n\t// 天翼云盘链接通常不在URL中包含密码参数，所以这里不做处理\n\t// 但是我们确保返回的是干净的链接\n\treturn url\n}\n\n// normalizeUCPanURL 标准化UC网盘URL，确保链接格式正确\nfunc normalizeUCPanURL(url string, password string) string {\n\t// 清理URL，确保获取正确的链接部分\n\turl = CleanUCPanURL(url)\n\t\n\t// UC网盘链接通常使用?public=1参数表示公开分享\n\t// 确保链接格式正确，但不添加密码参数\n\treturn url\n}\n\n// normalize123PanURL 标准化123网盘URL，确保链接格式正确\nfunc normalize123PanURL(url string, password string) string {\n\t// 清理URL，确保获取正确的链接部分\n\turl = Clean123PanURL(url)\n\t\n\t// 123网盘链接通常不在URL中包含密码参数\n\t// 但是我们确保返回的是干净的链接\n\treturn url\n}\n\n// normalize115PanURL 标准化115网盘URL，确保链接格式正确\nfunc normalize115PanURL(url string, password string) string {\n\t// 清理URL，确保获取正确的链接部分，只保留到password=后面4位密码\n\turl = Clean115PanURL(url)\n\t\n\t// 115网盘链接已经在Clean115PanURL中处理了密码部分\n\t// 这里不需要额外添加密码参数\n\treturn url\n}\n\n// ParseSearchResults 解析搜索结果页面\nfunc ParseSearchResults(html string, channel string) ([]model.SearchResult, string, error) {\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\tvar results []model.SearchResult\n\tvar nextPageParam string\n\n\t// 查找消息块\n\tdoc.Find(\".tgme_widget_message_wrap\").Each(func(i int, s *goquery.Selection) {\n\t\tmessageDiv := s.Find(\".tgme_widget_message\")\n\t\t\n\t\t// 提取消息ID\n\t\tdataPost, exists := messageDiv.Attr(\"data-post\")\n\t\tif !exists {\n\t\t\treturn\n\t\t}\n\t\t\n\t\tparts := strings.Split(dataPost, \"/\")\n\t\tif len(parts) != 2 {\n\t\t\treturn\n\t\t}\n\t\t\n\t\tmessageID := parts[1]\n\t\t\n\t\t// 生成全局唯一ID\n\t\tuniqueID := channel + \"_\" + messageID\n\t\t\n\t\t// 提取时间\n\t\ttimeStr, exists := messageDiv.Find(\".tgme_widget_message_date time\").Attr(\"datetime\")\n\t\tif !exists {\n\t\t\treturn\n\t\t}\n\t\t\n\t\tdatetime, err := time.Parse(time.RFC3339, timeStr)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\t\n\t\t// 获取消息文本元素\n\t\tmessageTextElem := messageDiv.Find(\".tgme_widget_message_text\")\n\t\t\n\t\t// 获取消息文本的HTML内容\n\t\tmessageHTML, _ := messageTextElem.Html()\n\t\t\n\t\t// 获取消息的纯文本内容\n\t\tmessageText := messageTextElem.Text()\n\t\t\n\t\t// 提取标题\n\t\ttitle := extractTitle(messageHTML, messageText)\n\t\t\n\t\t// 提取网盘链接 - 使用更精确的方法\n\t\tvar links []model.Link\n\t\tvar foundLinks = make(map[string]bool) // 用于去重\n\t\tvar baiduLinkPasswords = make(map[string]string) // 存储百度链接和对应的密码\n\t\tvar tianyiLinkPasswords = make(map[string]string) // 存储天翼链接和对应的密码\n\t\tvar ucLinkPasswords = make(map[string]string) // 存储UC链接和对应的密码\n\t\tvar pan123LinkPasswords = make(map[string]string) // 存储123网盘链接和对应的密码\n\t\tvar pan115LinkPasswords = make(map[string]string) // 存储115网盘链接和对应的密码\n\t\tvar aliyunLinkPasswords = make(map[string]string) // 存储阿里云盘链接和对应的密码\n\t\t\n\t\t// 1. 从文本内容中提取所有网盘链接和密码\n\t\textractedLinks := ExtractNetDiskLinks(messageText)\n\t\t\n\t\t// 2. 从a标签中提取链接\n\t\tmessageTextElem.Find(\"a\").Each(func(i int, a *goquery.Selection) {\n\t\t\thref, exists := a.Attr(\"href\")\n\t\t\tif !exists {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t\n\t\t\t// 使用更精确的方式匹配网盘链接\n\t\t\tif isSupportedLink(href) {\n\t\t\t\tlinkType := GetLinkType(href)\n\t\t\t\tpassword := ExtractPassword(messageText, href)\n\t\t\t\t\n\t\t\t\t// 如果是百度网盘链接，记录链接和密码的对应关系\n\t\t\t\tif linkType == \"baidu\" {\n\t\t\t\t\t// 提取链接的基本部分（不含密码参数）\n\t\t\t\t\tbaseURL := href\n\t\t\t\t\tif strings.Contains(href, \"?pwd=\") {\n\t\t\t\t\t\tbaseURL = href[:strings.Index(href, \"?pwd=\")]\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\t// 记录密码\n\t\t\t\t\tif password != \"\" {\n\t\t\t\t\t\tbaiduLinkPasswords[baseURL] = password\n\t\t\t\t\t}\n\t\t\t\t} else if linkType == \"tianyi\" {\n\t\t\t\t\t// 如果是天翼云盘链接，记录链接和密码的对应关系\n\t\t\t\t\tbaseURL := CleanTianyiPanURL(href)\n\t\t\t\t\t\n\t\t\t\t\t// 记录密码\n\t\t\t\t\tif password != \"\" {\n\t\t\t\t\t\ttianyiLinkPasswords[baseURL] = password\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// 即使没有密码，也添加到映射中，以便后续处理\n\t\t\t\t\t\tif _, exists := tianyiLinkPasswords[baseURL]; !exists {\n\t\t\t\t\t\t\ttianyiLinkPasswords[baseURL] = \"\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else if linkType == \"uc\" {\n\t\t\t\t\t// 如果是UC网盘链接，记录链接和密码的对应关系\n\t\t\t\t\tbaseURL := CleanUCPanURL(href)\n\t\t\t\t\t\n\t\t\t\t\t// 记录密码\n\t\t\t\t\tif password != \"\" {\n\t\t\t\t\t\tucLinkPasswords[baseURL] = password\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// 即使没有密码，也添加到映射中，以便后续处理\n\t\t\t\t\t\tif _, exists := ucLinkPasswords[baseURL]; !exists {\n\t\t\t\t\t\t\tucLinkPasswords[baseURL] = \"\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else if linkType == \"123\" {\n\t\t\t\t\t// 如果是123网盘链接，记录链接和密码的对应关系\n\t\t\t\t\tbaseURL := Clean123PanURL(href)\n\t\t\t\t\t\n\t\t\t\t\t// 记录密码\n\t\t\t\t\tif password != \"\" {\n\t\t\t\t\t\tpan123LinkPasswords[baseURL] = password\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// 即使没有密码，也添加到映射中，以便后续处理\n\t\t\t\t\t\tif _, exists := pan123LinkPasswords[baseURL]; !exists {\n\t\t\t\t\t\t\tpan123LinkPasswords[baseURL] = \"\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else if linkType == \"115\" {\n\t\t\t\t\t// 如果是115网盘链接，记录链接和密码的对应关系\n\t\t\t\t\tbaseURL := Clean115PanURL(href)\n\t\t\t\t\t\n\t\t\t\t\t// 记录密码\n\t\t\t\t\tif password != \"\" {\n\t\t\t\t\t\tpan115LinkPasswords[baseURL] = password\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// 即使没有密码，也添加到映射中，以便后续处理\n\t\t\t\t\t\tif _, exists := pan115LinkPasswords[baseURL]; !exists {\n\t\t\t\t\t\t\tpan115LinkPasswords[baseURL] = \"\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else if linkType == \"aliyun\" {\n\t\t\t\t\t// 如果是阿里云盘链接，记录链接和密码的对应关系\n\t\t\t\t\tbaseURL := CleanAliyunPanURL(href)\n\t\t\t\t\t\n\t\t\t\t\t// 记录密码\n\t\t\t\t\tif password != \"\" {\n\t\t\t\t\t\taliyunLinkPasswords[baseURL] = password\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// 即使没有密码，也添加到映射中，以便后续处理\n\t\t\t\t\t\tif _, exists := aliyunLinkPasswords[baseURL]; !exists {\n\t\t\t\t\t\t\taliyunLinkPasswords[baseURL] = \"\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// 非特殊处理的网盘链接直接添加\n\t\t\t\t\t// 使用标准化的URL进行去重\n\t\t\t\t\tnormalizedHref := normalizeUrl(href)\n\t\t\t\t\tif !foundLinks[normalizedHref] {\n\t\t\t\t\t\tfoundLinks[normalizedHref] = true\n\t\t\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\t\t\tType:     linkType,\n\t\t\t\t\t\t\tURL:      normalizedHref,  // 使用标准化的URL\n\t\t\t\t\t\t\tPassword: password,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t\t\n\t\t// 3. 处理从文本中提取的链接\n\t\tfor _, linkURL := range extractedLinks {\n\t\t\tlinkType := GetLinkType(linkURL)\n\t\t\tpassword := ExtractPassword(messageText, linkURL)\n\t\t\t\n\t\t\t// 如果是百度网盘链接，记录链接和密码的对应关系\n\t\t\tif linkType == \"baidu\" {\n\t\t\t\t// 提取链接的基本部分（不含密码参数）\n\t\t\t\tbaseURL := linkURL\n\t\t\t\tif strings.Contains(linkURL, \"?pwd=\") {\n\t\t\t\t\tbaseURL = linkURL[:strings.Index(linkURL, \"?pwd=\")]\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// 记录密码\n\t\t\t\tif password != \"\" {\n\t\t\t\t\tbaiduLinkPasswords[baseURL] = password\n\t\t\t\t}\n\t\t\t} else if linkType == \"tianyi\" {\n\t\t\t\t// 如果是天翼云盘链接，记录链接和密码的对应关系\n\t\t\t\tbaseURL := CleanTianyiPanURL(linkURL)\n\t\t\t\t\n\t\t\t\t// 记录密码\n\t\t\t\tif password != \"\" {\n\t\t\t\t\ttianyiLinkPasswords[baseURL] = password\n\t\t\t\t} else {\n\t\t\t\t\t// 即使没有密码，也添加到映射中，以便后续处理\n\t\t\t\t\tif _, exists := tianyiLinkPasswords[baseURL]; !exists {\n\t\t\t\t\t\ttianyiLinkPasswords[baseURL] = \"\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if linkType == \"uc\" {\n\t\t\t\t// 如果是UC网盘链接，记录链接和密码的对应关系\n\t\t\t\tbaseURL := CleanUCPanURL(linkURL)\n\t\t\t\t\n\t\t\t\t// 记录密码\n\t\t\t\tif password != \"\" {\n\t\t\t\t\tucLinkPasswords[baseURL] = password\n\t\t\t\t} else {\n\t\t\t\t\t// 即使没有密码，也添加到映射中，以便后续处理\n\t\t\t\t\tif _, exists := ucLinkPasswords[baseURL]; !exists {\n\t\t\t\t\t\tucLinkPasswords[baseURL] = \"\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if linkType == \"123\" {\n\t\t\t\t// 如果是123网盘链接，记录链接和密码的对应关系\n\t\t\t\tbaseURL := Clean123PanURL(linkURL)\n\t\t\t\t\n\t\t\t\t// 记录密码\n\t\t\t\tif password != \"\" {\n\t\t\t\t\tpan123LinkPasswords[baseURL] = password\n\t\t\t\t} else {\n\t\t\t\t\t// 即使没有密码，也添加到映射中，以便后续处理\n\t\t\t\t\tif _, exists := pan123LinkPasswords[baseURL]; !exists {\n\t\t\t\t\t\tpan123LinkPasswords[baseURL] = \"\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if linkType == \"115\" {\n\t\t\t\t// 如果是115网盘链接，记录链接和密码的对应关系\n\t\t\t\tbaseURL := Clean115PanURL(linkURL)\n\t\t\t\t\n\t\t\t\t// 记录密码\n\t\t\t\tif password != \"\" {\n\t\t\t\t\tpan115LinkPasswords[baseURL] = password\n\t\t\t\t} else {\n\t\t\t\t\t// 即使没有密码，也添加到映射中，以便后续处理\n\t\t\t\t\tif _, exists := pan115LinkPasswords[baseURL]; !exists {\n\t\t\t\t\t\tpan115LinkPasswords[baseURL] = \"\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if linkType == \"aliyun\" {\n\t\t\t\t// 如果是阿里云盘链接，记录链接和密码的对应关系\n\t\t\t\tbaseURL := CleanAliyunPanURL(linkURL)\n\t\t\t\t\n\t\t\t\t// 记录密码\n\t\t\t\tif password != \"\" {\n\t\t\t\t\taliyunLinkPasswords[baseURL] = password\n\t\t\t\t} else {\n\t\t\t\t\t// 即使没有密码，也添加到映射中，以便后续处理\n\t\t\t\t\tif _, exists := aliyunLinkPasswords[baseURL]; !exists {\n\t\t\t\t\t\taliyunLinkPasswords[baseURL] = \"\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// 非特殊处理的网盘链接直接添加\n\t\t\t\t// 使用标准化的URL进行去重\n\t\t\t\tnormalizedLinkURL := normalizeUrl(linkURL)\n\t\t\t\tif !foundLinks[normalizedLinkURL] {\n\t\t\t\t\tfoundLinks[normalizedLinkURL] = true\n\t\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\t\tType:     linkType,\n\t\t\t\t\t\tURL:      normalizedLinkURL,  // 使用标准化的URL\n\t\t\t\t\t\tPassword: password,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 4. 处理百度网盘链接，确保每个链接只有一个版本（带密码的完整版本）\n\t\tfor baseURL, password := range baiduLinkPasswords {\n\t\t\tnormalizedURL := normalizeBaiduPanURL(baseURL, password)\n\t\t\t\n\t\t\t// 确保链接不重复\n\t\t\tif !foundLinks[normalizedURL] {\n\t\t\t\tfoundLinks[normalizedURL] = true\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tType:     \"baidu\",\n\t\t\t\t\tURL:      normalizedURL,\n\t\t\t\t\tPassword: password,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 5. 处理天翼云盘链接，确保每个链接只有一个版本\n\t\tfor baseURL, password := range tianyiLinkPasswords {\n\t\t\tnormalizedURL := normalizeTianyiPanURL(baseURL, password)\n\t\t\t\n\t\t\t// 确保链接不重复\n\t\t\tif !foundLinks[normalizedURL] {\n\t\t\t\tfoundLinks[normalizedURL] = true\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tType:     \"tianyi\",\n\t\t\t\t\tURL:      normalizedURL,\n\t\t\t\t\tPassword: password,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 6. 处理UC网盘链接，确保每个链接只有一个版本\n\t\tfor baseURL, password := range ucLinkPasswords {\n\t\t\tnormalizedURL := normalizeUCPanURL(baseURL, password)\n\t\t\t\n\t\t\t// 确保链接不重复\n\t\t\tif !foundLinks[normalizedURL] {\n\t\t\t\tfoundLinks[normalizedURL] = true\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tType:     \"uc\",\n\t\t\t\t\tURL:      normalizedURL,\n\t\t\t\t\tPassword: password,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 7. 处理123网盘链接，确保每个链接只有一个版本\n\t\tfor baseURL, password := range pan123LinkPasswords {\n\t\t\tnormalizedURL := normalize123PanURL(baseURL, password)\n\t\t\t\n\t\t\t// 确保链接不重复\n\t\t\tif !foundLinks[normalizedURL] {\n\t\t\t\tfoundLinks[normalizedURL] = true\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tType:     \"123\",\n\t\t\t\t\tURL:      normalizedURL,\n\t\t\t\t\tPassword: password,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 8. 处理115网盘链接，确保每个链接只有一个版本\n\t\tfor baseURL, password := range pan115LinkPasswords {\n\t\t\tnormalizedURL := normalize115PanURL(baseURL, password)\n\t\t\t\n\t\t\t// 确保链接不重复\n\t\t\tif !foundLinks[normalizedURL] {\n\t\t\t\tfoundLinks[normalizedURL] = true\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tType:     \"115\",\n\t\t\t\t\tURL:      normalizedURL,\n\t\t\t\t\tPassword: password,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 9. 处理阿里云盘链接，确保每个链接只有一个版本\n\t\tfor baseURL, password := range aliyunLinkPasswords {\n\t\t\tnormalizedURL := CleanAliyunPanURL(baseURL) // 阿里云盘URL通常不包含密码参数\n\t\t\t\n\t\t\t// 确保链接不重复\n\t\t\tif !foundLinks[normalizedURL] {\n\t\t\t\tfoundLinks[normalizedURL] = true\n\t\t\t\tlinks = append(links, model.Link{\n\t\t\t\t\tType:     \"aliyun\",\n\t\t\t\t\tURL:      normalizedURL,\n\t\t\t\t\tPassword: password,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 提取标签\n\t\tvar tags []string\n\t\tmessageTextElem.Find(\"a[href^='?q=%23']\").Each(func(i int, a *goquery.Selection) {\n\t\t\ttag := a.Text()\n\t\t\tif strings.HasPrefix(tag, \"#\") {\n\t\t\t\ttags = append(tags, tag[1:])\n\t\t\t}\n\t\t})\n\t\t\n\t\t// 提取图片链接（只从消息内容区域提取，排除用户头像）\n\t\tvar images []string\n\t\tvar foundImages = make(map[string]bool) // 用于去重\n\t\t\n\t\t// 获取消息气泡区域，排除用户头像区域\n\t\tmessageBubble := messageDiv.Find(\".tgme_widget_message_bubble\")\n\t\t\n\t\t// 1. 从消息内容中的图片包装元素提取图片\n\t\tmessageBubble.Find(\".tgme_widget_message_photo_wrap\").Each(func(i int, photoWrap *goquery.Selection) {\n\t\t\t// 检查style属性中的background-image\n\t\t\tstyle, exists := photoWrap.Attr(\"style\")\n\t\t\tif exists {\n\t\t\t\timageURL := extractImageURLFromStyle(style)\n\t\t\t\tif imageURL != \"\" && !foundImages[imageURL] {\n\t\t\t\t\tfoundImages[imageURL] = true\n\t\t\t\t\timages = append(images, imageURL)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t\t\n\t\t// 2. 从消息内容中的其他可能包含图片的元素提取（排除用户头像）\n\t\tmessageBubble.Find(\"img\").Each(func(i int, img *goquery.Selection) {\n\t\t\tsrc, exists := img.Attr(\"src\")\n\t\t\tif exists && src != \"\" && !foundImages[src] {\n\t\t\t\tfoundImages[src] = true\n\t\t\t\timages = append(images, src)\n\t\t\t}\n\t\t})\n\t\t\n\t\t// 只有包含链接的消息才添加到结果中\n\t\tif len(links) > 0 {\n\t\t\t// 为每个链接提取作品标题\n\t\t\tlinks = extractWorkTitlesForLinks(links, messageText, title)\n\t\t\t\n\t\t\tresults = append(results, model.SearchResult{\n\t\t\t\tMessageID: messageID,\n\t\t\t\tUniqueID:  uniqueID,\n\t\t\t\tChannel:   channel,\n\t\t\t\tDatetime:  datetime,\n\t\t\t\tTitle:     title,\n\t\t\t\tContent:   messageText,\n\t\t\t\tLinks:     links,\n\t\t\t\tTags:      tags,\n\t\t\t\tImages:    images,\n\t\t\t})\n\t\t}\n\t})\n\n\treturn results, nextPageParam, nil\n}\n\n// CutTitleByKeywords 根据关键词进行裁剪，保留最前关键词前的部分\nfunc CutTitleByKeywords(title string, keywords []string) string {\n    minIdx := -1\n    for _, kw := range keywords {\n        if idx := strings.Index(title, kw); idx >= 0 && (minIdx == -1 || idx < minIdx) {\n            minIdx = idx\n        }\n    }\n    if minIdx > 0 {\n        return strings.TrimSpace(title[:minIdx])\n    }\n    return strings.TrimSpace(title)\n}\n\n// extractImageURLFromStyle 从CSS样式字符串中提取background-image的URL\nfunc extractImageURLFromStyle(style string) string {\n\t// 查找background-image:url('...') 或 background-image:url(\"...\")\n\tstartPattern := \"background-image:url('\"\n\tendPattern := \"')\"\n\t\n\tstartIndex := strings.Index(style, startPattern)\n\tif startIndex != -1 {\n\t\tstartIndex += len(startPattern)\n\t\tendIndex := strings.Index(style[startIndex:], endPattern)\n\t\tif endIndex != -1 {\n\t\t\treturn style[startIndex : startIndex+endIndex]\n\t\t}\n\t}\n\t\n\t// 尝试双引号格式\n\tstartPattern = `background-image:url(\"`\n\tendPattern = `\")`\n\t\n\tstartIndex = strings.Index(style, startPattern)\n\tif startIndex != -1 {\n\t\tstartIndex += len(startPattern)\n\t\tendIndex := strings.Index(style[startIndex:], endPattern)\n\t\tif endIndex != -1 {\n\t\t\treturn style[startIndex : startIndex+endIndex]\n\t\t}\n\t}\n\t\n\t// 尝试无引号格式\n\tstartPattern = \"background-image:url(\"\n\tendPattern = \")\"\n\t\n\tstartIndex = strings.Index(style, startPattern)\n\tif startIndex != -1 {\n\t\tstartIndex += len(startPattern)\n\t\tendIndex := strings.Index(style[startIndex:], endPattern)\n\t\tif endIndex != -1 {\n\t\t\turl := style[startIndex : startIndex+endIndex]\n\t\t\t// 移除可能的引号\n\t\t\turl = strings.Trim(url, \"'\\\"\")\n\t\t\treturn url\n\t\t}\n\t}\n\t\n\treturn \"\"\n}\n\n// extractTitle 从消息HTML和文本内容中提取标题\nfunc extractTitle(htmlContent string, textContent string) string {\n\t// 从HTML内容中提取标题\n\tif brIndex := strings.Index(htmlContent, \"<br\"); brIndex > 0 {\n\t\t// 提取<br>前的HTML内容\n\t\tfirstLineHTML := htmlContent[:brIndex]\n\t\t\n\t\t// 创建一个文档来解析这个HTML片段\n\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(\"<div>\" + firstLineHTML + \"</div>\"))\n\t\tif err == nil {\n\t\t\t// 获取解析后的文本\n\t\t\tfirstLine := strings.TrimSpace(doc.Text())\n\t\t\t\n\t\t\t// 如果第一行以\"名称：\"开头，则提取冒号后面的内容作为标题\n\t\t\tif strings.HasPrefix(firstLine, \"名称：\") {\n\t\t\t\treturn strings.TrimSpace(firstLine[len(\"名称：\"):])\n\t\t\t}\n\t\t\t\n\t\t\t// 如果第一行只是标签(以#开头)，尝试从第二行提取\n\t\t\tif strings.HasPrefix(firstLine, \"#\") && !strings.Contains(firstLine, \"名称\") {\n\t\t\t\t// 继续从文本内容提取\n\t\t\t} else {\n\t\t\t\treturn firstLine\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 如果HTML解析失败，则使用纯文本内容\n\tlines := strings.Split(textContent, \"\\n\")\n\tif len(lines) == 0 {\n\t\treturn \"\"\n\t}\n\t\n\t// 第一行通常是标题\n\tfirstLine := strings.TrimSpace(lines[0])\n\t\n\t// 如果第一行只是标签(以#开头且不包含实际内容)，尝试从第二行或\"名称：\"字段提取\n\tif strings.HasPrefix(firstLine, \"#\") {\n\t\t// 检查是否有\"名称：\"字段\n\t\tfor _, line := range lines {\n\t\t\tline = strings.TrimSpace(line)\n\t\t\tif strings.HasPrefix(line, \"名称：\") {\n\t\t\t\treturn strings.TrimSpace(line[len(\"名称：\"):])\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 如果没有\"名称：\"字段，尝试使用第二行\n\t\tif len(lines) > 1 {\n\t\t\tsecondLine := strings.TrimSpace(lines[1])\n\t\t\tif strings.HasPrefix(secondLine, \"名称：\") {\n\t\t\t\treturn strings.TrimSpace(secondLine[len(\"名称：\"):])\n\t\t\t}\n\t\t\t// 如果第二行不是空的且不是标签，使用第二行\n\t\t\tif secondLine != \"\" && !strings.HasPrefix(secondLine, \"#\") {\n\t\t\t\tresult := secondLine\n\t\t\t\tresult = CutTitleByKeywords(result, []string{\"简介\", \"描述\"})\n\t\t\t\treturn result\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 如果第一行以\"名称：\"开头，则提取冒号后面的内容作为标题\n\tif strings.HasPrefix(firstLine, \"名称：\") {\n\t\treturn strings.TrimSpace(firstLine[len(\"名称：\"):])\n\t}\n\t\n\t// 否则直接使用第一行作为标题\n\tresult := firstLine\n\t// 统一裁剪：遇到简介/描述等关键字时，只保留前半部分\n\tresult = CutTitleByKeywords(result, []string{\"简介\", \"描述\"})\n\treturn result\n}\n\n// extractWorkTitlesForLinks 为每个链接提取作品标题\nfunc extractWorkTitlesForLinks(links []model.Link, messageText string, defaultTitle string) []model.Link {\n\tif len(links) == 0 {\n\t\treturn links\n\t}\n\t\n\t// 如果链接数量 <= 4，认为是同一个作品的不同网盘链接\n\tif len(links) <= 4 {\n\t\tfor i := range links {\n\t\t\tlinks[i].WorkTitle = defaultTitle\n\t\t}\n\t\treturn links\n\t}\n\t\n\t// 如果链接数量 > 4，尝试为每个链接匹配具体的作品标题\n\tlines := strings.Split(messageText, \"\\n\")\n\t\n\t// 检测是否是单行格式：\"作品名丨网盘：链接\" 或 \"作品名 网盘：链接\"\n\tif isSingleLineFormat(lines) {\n\t\treturn extractWorkTitlesFromSingleLineFormat(links, lines, defaultTitle)\n\t}\n\t\n\t// 其他格式：尝试通过上下文匹配\n\treturn extractWorkTitlesFromContext(links, messageText, defaultTitle)\n}\n\n// isSingleLineFormat 检测是否是单行格式\nfunc isSingleLineFormat(lines []string) bool {\n\tsingleLineCount := 0\n\tfor _, line := range lines {\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 检测是否包含：\"作品名丨网盘：链接\" 或类似格式\n\t\tif strings.Contains(line, \"丨\") && strings.Contains(line, \"：\") && (strings.Contains(line, \"http://\") || strings.Contains(line, \"https://\")) {\n\t\t\tsingleLineCount++\n\t\t}\n\t}\n\t\n\t// 如果超过一半的行都符合单行格式，则认为是单行格式\n\treturn singleLineCount > len(lines)/3\n}\n\n// extractWorkTitlesFromSingleLineFormat 从单行格式中提取作品标题\nfunc extractWorkTitlesFromSingleLineFormat(links []model.Link, lines []string, defaultTitle string) []model.Link {\n\t// 为每个链接构建URL到作品标题的映射\n\turlToWorkTitle := make(map[string]string)\n\t\n\tfor _, line := range lines {\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// 匹配格式: \"作品名丨网盘名：链接\" 或 \"作品名 网盘名：链接\"\n\t\t// 提取作品名和链接\n\t\tvar workTitle string\n\t\tvar linkURL string\n\t\t\n\t\t// 优先匹配 \"作品名丨网盘：链接\" 格式\n\t\tif strings.Contains(line, \"丨\") {\n\t\t\tparts := strings.Split(line, \"丨\")\n\t\t\tif len(parts) >= 2 {\n\t\t\t\tworkTitle = strings.TrimSpace(parts[0])\n\t\t\t\t// 从第二部分提取链接\n\t\t\t\trestPart := parts[1]\n\t\t\t\tif idx := strings.Index(restPart, \"http\"); idx >= 0 {\n\t\t\t\t\tlinkURL = extractFirstURL(restPart[idx:])\n\t\t\t\t}\n\t\t\t}\n\t\t} else if strings.Contains(line, \"：\") {\n\t\t\t// 匹配 \"作品名 网盘：链接\" 格式\n\t\t\tcolonIdx := strings.Index(line, \"：\")\n\t\t\tif colonIdx > 0 {\n\t\t\t\tbeforeColon := line[:colonIdx]\n\t\t\t\tafterColon := line[colonIdx+len(\"：\"):]\n\t\t\t\t\n\t\t\t\t// 尝试从冒号前提取作品名（去除网盘名）\n\t\t\t\tworkTitle = extractWorkTitleBeforeColon(beforeColon)\n\t\t\t\t\n\t\t\t\t// 从冒号后提取链接\n\t\t\t\tif idx := strings.Index(afterColon, \"http\"); idx >= 0 {\n\t\t\t\t\tlinkURL = extractFirstURL(afterColon[idx:])\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\t// 如果成功提取了作品名和链接，添加到映射\n\t\tif workTitle != \"\" && linkURL != \"\" {\n\t\t\t// 标准化URL用于匹配\n\t\t\tnormalizedURL := normalizeUrl(linkURL)\n\t\t\turlToWorkTitle[normalizedURL] = workTitle\n\t\t}\n\t}\n\t\n\t// 为每个链接设置作品标题\n\tfor i := range links {\n\t\tnormalizedURL := normalizeUrl(links[i].URL)\n\t\tif workTitle, found := urlToWorkTitle[normalizedURL]; found {\n\t\t\tlinks[i].WorkTitle = workTitle\n\t\t} else {\n\t\t\tlinks[i].WorkTitle = defaultTitle\n\t\t}\n\t}\n\t\n\treturn links\n}\n\n// extractFirstURL 从文本中提取第一个URL\nfunc extractFirstURL(text string) string {\n\t// 提取到空格或换行符为止\n\tendIdx := len(text)\n\tif idx := strings.Index(text, \" \"); idx > 0 && idx < endIdx {\n\t\tendIdx = idx\n\t}\n\tif idx := strings.Index(text, \"\\n\"); idx > 0 && idx < endIdx {\n\t\tendIdx = idx\n\t}\n\tif idx := strings.Index(text, \"\\r\"); idx > 0 && idx < endIdx {\n\t\tendIdx = idx\n\t}\n\t\n\treturn strings.TrimSpace(text[:endIdx])\n}\n\n// extractWorkTitleBeforeColon 从冒号前的文本中提取作品名\nfunc extractWorkTitleBeforeColon(text string) string {\n\ttext = strings.TrimSpace(text)\n\t\n\t// 移除常见的网盘名称\n\tnetdiskNames := []string{\n\t\t\"夸克网盘\", \"夸克云盘\", \"夸克\",\n\t\t\"百度网盘\", \"百度云盘\", \"百度云\", \"百度\",\n\t\t\"迅雷网盘\", \"迅雷云盘\", \"迅雷\",\n\t\t\"阿里云盘\", \"阿里网盘\", \"阿里云\", \"阿里\",\n\t\t\"天翼云盘\", \"天翼网盘\", \"天翼云\", \"天翼\",\n\t\t\"UC网盘\", \"UC云盘\", \"UC\",\n\t\t\"移动云盘\", \"移动云\", \"移动\",\n\t\t\"115网盘\", \"115云盘\", \"115\",\n\t\t\"123网盘\", \"123云盘\", \"123\",\n\t\t\"PikPak网盘\", \"PikPak\",\n\t\t\"网盘\", \"云盘\",\n\t}\n\t\n\t// 从右向左移除网盘名称\n\tfor _, name := range netdiskNames {\n\t\tif strings.HasSuffix(text, name) {\n\t\t\ttext = strings.TrimSpace(text[:len(text)-len(name)])\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\treturn text\n}\n\n// extractWorkTitlesFromContext 通过上下文为链接提取作品标题\nfunc extractWorkTitlesFromContext(links []model.Link, messageText string, defaultTitle string) []model.Link {\n\t// 简单实现：如果无法精确匹配，则都使用默认标题\n\tfor i := range links {\n\t\tlinks[i].WorkTitle = defaultTitle\n\t}\n\treturn links\n} "
  },
  {
    "path": "util/pool/object_pool.go",
    "content": "package pool\n\nimport (\n\t\"sync\"\n\n\t\"pansou/model\"\n)\n\n// LinkPool 网盘链接对象池\nvar LinkPool = sync.Pool{\n\tNew: func() interface{} {\n\t\treturn &model.Link{}\n\t},\n}\n\n// SearchResultPool 搜索结果对象池\nvar SearchResultPool = sync.Pool{\n\tNew: func() interface{} {\n\t\treturn &model.SearchResult{\n\t\t\tLinks: make([]model.Link, 0, 4),\n\t\t\tTags:  make([]string, 0, 8),\n\t\t}\n\t},\n}\n\n// MergedLinkPool 合并链接对象池\nvar MergedLinkPool = sync.Pool{\n\tNew: func() interface{} {\n\t\treturn &model.MergedLink{}\n\t},\n}\n\n// GetLink 从对象池获取Link对象\nfunc GetLink() *model.Link {\n\treturn LinkPool.Get().(*model.Link)\n}\n\n// ReleaseLink 释放Link对象回对象池\nfunc ReleaseLink(l *model.Link) {\n\tl.Type = \"\"\n\tl.URL = \"\"\n\tl.Password = \"\"\n\tLinkPool.Put(l)\n}\n\n// GetSearchResult 从对象池获取SearchResult对象\nfunc GetSearchResult() *model.SearchResult {\n\treturn SearchResultPool.Get().(*model.SearchResult)\n}\n\n// ReleaseSearchResult 释放SearchResult对象回对象池\nfunc ReleaseSearchResult(sr *model.SearchResult) {\n\tsr.MessageID = \"\"\n\tsr.Channel = \"\"\n\tsr.Title = \"\"\n\tsr.Content = \"\"\n\tsr.Links = sr.Links[:0]\n\tsr.Tags = sr.Tags[:0]\n\t// 不重置时间，因为会被重新赋值\n\tSearchResultPool.Put(sr)\n}\n\n// GetMergedLink 从对象池获取MergedLink对象\nfunc GetMergedLink() *model.MergedLink {\n\treturn MergedLinkPool.Get().(*model.MergedLink)\n}\n\n// ReleaseMergedLink 释放MergedLink对象回对象池\nfunc ReleaseMergedLink(ml *model.MergedLink) {\n\tml.URL = \"\"\n\tml.Password = \"\"\n\tml.Note = \"\"\n\t// 不重置时间，因为会被重新赋值\n\tMergedLinkPool.Put(ml)\n} "
  },
  {
    "path": "util/pool/worker_pool.go",
    "content": "package pool\n\nimport (\n\t\"context\"\n\t\"sync\"\n\t\"time\"\n)\n\n// Task 表示一个工作任务\ntype Task func() interface{}\n\n// WorkerPool 工作池结构体\ntype WorkerPool struct {\n\tmaxWorkers int\n\ttaskQueue  chan Task\n\tresults    chan interface{}\n\twg         sync.WaitGroup\n\tctx        context.Context\n\tcancel     context.CancelFunc\n}\n\n// NewWorkerPool 创建一个新的工作池\nfunc NewWorkerPool(maxWorkers int) *WorkerPool {\n\tctx, cancel := context.WithCancel(context.Background())\n\t\n\tpool := &WorkerPool{\n\t\tmaxWorkers: maxWorkers,\n\t\ttaskQueue:  make(chan Task, maxWorkers*2), // 任务队列大小为工作者数量的2倍\n\t\tresults:    make(chan interface{}, maxWorkers*2), // 结果队列大小为工作者数量的2倍\n\t\tctx:        ctx,\n\t\tcancel:     cancel,\n\t}\n\t\n\t// 启动工作者\n\tpool.startWorkers()\n\t\n\treturn pool\n}\n\n// NewWorkerPoolWithContext 创建一个带有指定上下文的新工作池\nfunc NewWorkerPoolWithContext(ctx context.Context, maxWorkers int) *WorkerPool {\n\tctx, cancel := context.WithCancel(ctx)\n\t\n\tpool := &WorkerPool{\n\t\tmaxWorkers: maxWorkers,\n\t\ttaskQueue:  make(chan Task, maxWorkers*2), // 任务队列大小为工作者数量的2倍\n\t\tresults:    make(chan interface{}, maxWorkers*2), // 结果队列大小为工作者数量的2倍\n\t\tctx:        ctx,\n\t\tcancel:     cancel,\n\t}\n\t\n\t// 启动工作者\n\tpool.startWorkers()\n\t\n\treturn pool\n}\n\n// startWorkers 启动工作者协程\nfunc (p *WorkerPool) startWorkers() {\n\tfor i := 0; i < p.maxWorkers; i++ {\n\t\tp.wg.Add(1)\n\t\tgo func() {\n\t\t\tdefer p.wg.Done()\n\t\t\t\n\t\t\tfor {\n\t\t\t\tselect {\n\t\t\t\tcase task, ok := <-p.taskQueue:\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\t// 执行任务并发送结果\n\t\t\t\t\tresult := task()\n\t\t\t\t\tp.results <- result\n\t\t\t\t\t\n\t\t\t\tcase <-p.ctx.Done():\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t}\n}\n\n// Submit 提交一个任务到工作池\nfunc (p *WorkerPool) Submit(task Task) {\n\tp.taskQueue <- task\n}\n\n// GetResults 获取所有任务的结果\nfunc (p *WorkerPool) GetResults(count int) []interface{} {\n\tresults := make([]interface{}, 0, count)\n\t\n\t// 收集指定数量的结果\n\tfor i := 0; i < count; i++ {\n\t\tselect {\n\t\tcase result := <-p.results:\n\t\t\tresults = append(results, result)\n\t\tcase <-p.ctx.Done():\n\t\t\t// 上下文取消，返回已收集的结果\n\t\t\treturn results\n\t\t}\n\t}\n\t\n\treturn results\n}\n\n// Close 关闭工作池\nfunc (p *WorkerPool) Close() {\n\t// 取消上下文\n\tp.cancel()\n\t\n\t// 关闭任务队列\n\tclose(p.taskQueue)\n\t\n\t// 等待所有工作者完成\n\tp.wg.Wait()\n\t\n\t// 关闭结果队列\n\tclose(p.results)\n}\n\n// ExecuteBatch 批量执行任务并返回结果\nfunc ExecuteBatch(tasks []Task, maxWorkers int) []interface{} {\n\tif len(tasks) == 0 {\n\t\treturn []interface{}{}\n\t}\n\t\n\t// 如果任务数量少于工作者数量，调整工作者数量\n\tif len(tasks) < maxWorkers {\n\t\tmaxWorkers = len(tasks)\n\t}\n\t\n\t// 创建工作池\n\tpool := NewWorkerPool(maxWorkers)\n\tdefer pool.Close()\n\t\n\t// 提交所有任务\n\tfor _, task := range tasks {\n\t\tpool.Submit(task)\n\t}\n\t\n\t// 获取所有结果\n\treturn pool.GetResults(len(tasks))\n} \n\n// ExecuteBatchWithTimeout 批量执行任务，带有超时控制，并返回结果\nfunc ExecuteBatchWithTimeout(tasks []Task, maxWorkers int, timeout time.Duration) []interface{} {\n\tif len(tasks) == 0 {\n\t\treturn []interface{}{}\n\t}\n\t\n\t// 如果任务数量少于工作者数量，调整工作者数量\n\tif len(tasks) < maxWorkers {\n\t\tmaxWorkers = len(tasks)\n\t}\n\t\n\t// 创建带超时的上下文\n\tctx, cancel := context.WithTimeout(context.Background(), timeout)\n\tdefer cancel()\n\t\n\t// 创建工作池\n\tpool := NewWorkerPoolWithContext(ctx, maxWorkers)\n\tdefer pool.Close()\n\t\n\t// 提交所有任务\n\tfor _, task := range tasks {\n\t\tselect {\n\t\tcase pool.taskQueue <- task:\n\t\t\t// 任务提交成功\n\t\tcase <-ctx.Done():\n\t\t\t// 超时或取消，停止提交更多任务\n\t\t\treturn pool.GetResults(len(tasks))\n\t\t}\n\t}\n\t\n\t// 获取所有结果，GetResults方法会处理超时情况\n\treturn pool.GetResults(len(tasks))\n} "
  },
  {
    "path": "util/regex_util.go",
    "content": "package util\n\nimport (\n\tnetUrl \"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// 通用网盘链接匹配正则表达式 - 修改为更精确的匹配模式\nvar AllPanLinksPattern = regexp.MustCompile(`(?i)(?:(?:magnet:\\?xt=urn:btih:[a-zA-Z0-9]+)|(?:ed2k://\\|file\\|[^|]+\\|\\d+\\|[A-Fa-f0-9]+\\|/?)|(?:https?://(?:(?:[\\w.-]+\\.)?(?:pan\\.(?:baidu|quark)\\.cn|(?:www\\.)?(?:alipan|aliyundrive)\\.com|drive\\.uc\\.cn|cloud\\.189\\.cn|caiyun\\.139\\.com|(?:www\\.)?123(?:684|685|912|pan|592)\\.(?:com|cn)|115\\.com|115cdn\\.com|anxia\\.com|pan\\.xunlei\\.com|mypikpak\\.com))(?:/[^\\s'\"<>()]*)?))`)\n\n// 单独定义各种网盘的链接匹配模式，以便更精确地提取\n// 修改百度网盘链接正则表达式，确保只匹配到链接本身，不包含后面的文本\nvar BaiduPanPattern = regexp.MustCompile(`https?://pan\\.baidu\\.com/s/[a-zA-Z0-9_-]+(?:\\?pwd=[a-zA-Z0-9]{4})?`)\nvar QuarkPanPattern = regexp.MustCompile(`https?://pan\\.quark\\.cn/s/[a-zA-Z0-9]+`)\nvar XunleiPanPattern = regexp.MustCompile(`https?://pan\\.xunlei\\.com/s/[a-zA-Z0-9]+(?:\\?pwd=[a-zA-Z0-9]{4})?(?:#)?`)\n// 添加天翼云盘链接正则表达式 - 精确匹配，支持URL编码的访问码\nvar TianyiPanPattern = regexp.MustCompile(`https?://cloud\\.189\\.cn/t/[a-zA-Z0-9]+(?:%[0-9A-Fa-f]{2})*(?:（[^）]*）)?`)\n// 添加UC网盘链接正则表达式\nvar UCPanPattern = regexp.MustCompile(`https?://drive\\.uc\\.cn/s/[a-zA-Z0-9]+(?:\\?public=\\d)?`)\n// 添加123网盘链接正则表达式\nvar Pan123Pattern = regexp.MustCompile(`https?://(?:www\\.)?123(?:684|865|685|912|pan|592)\\.(?:com|cn)/s/[a-zA-Z0-9_-]+(?:\\?(?:%E6%8F%90%E5%8F%96%E7%A0%81|提取码)[:：][a-zA-Z0-9]+)?`)\n// 添加115网盘链接正则表达式\nvar Pan115Pattern = regexp.MustCompile(`https?://(?:115\\.com|115cdn\\.com|anxia\\.com)/s/[a-zA-Z0-9]+(?:\\?password=[a-zA-Z0-9]{4})?(?:#)?`)\n// 添加阿里云盘链接正则表达式\nvar AliyunPanPattern = regexp.MustCompile(`https?://(?:www\\.)?(?:alipan|aliyundrive)\\.com/s/[a-zA-Z0-9]+`)\n\n// 提取码匹配正则表达式 - 增强提取密码的能力\nvar PasswordPattern = regexp.MustCompile(`(?i)(?:(?:提取|访问|提取密|密)码|pwd)[：:]\\s*([a-zA-Z0-9]{4})(?:[^a-zA-Z0-9]|$)`)\nvar UrlPasswordPattern = regexp.MustCompile(`(?i)[?&]pwd=([a-zA-Z0-9]{4})(?:[^a-zA-Z0-9]|$)`)\n\n// 百度网盘密码专用正则表达式 - 确保只提取4位密码\nvar BaiduPasswordPattern = regexp.MustCompile(`(?i)(?:链接：.*?提取码：|密码：|提取码：|pwd=|pwd:|pwd：)([a-zA-Z0-9]{4})(?:[^a-zA-Z0-9]|$)`)\n\n// GetLinkType 获取链接类型\nfunc GetLinkType(url string) string {\n\turl = strings.ToLower(url)\n\t\n\t// 处理可能带有\"链接：\"前缀的情况\n\tif strings.Contains(url, \"链接：\") || strings.Contains(url, \"链接:\") {\n\t\turl = strings.Split(url, \"链接\")[1]\n\t\tif strings.HasPrefix(url, \"：\") || strings.HasPrefix(url, \":\") {\n\t\t\turl = url[1:]\n\t\t}\n\t\turl = strings.TrimSpace(url)\n\t}\n\t\n\t// 根据关键词判断ed2k链接\n\tif strings.Contains(url, \"ed2k:\") {\n\t\treturn \"ed2k\"\n\t}\n\t\n\tif strings.HasPrefix(url, \"magnet:\") {\n\t\treturn \"magnet\"\n\t}\n\t\n\tif strings.Contains(url, \"pan.baidu.com\") {\n\t\treturn \"baidu\"\n\t}\n\tif strings.Contains(url, \"pan.quark.cn\") {\n\t\treturn \"quark\"\n\t}\n\tif strings.Contains(url, \"alipan.com\") || strings.Contains(url, \"aliyundrive.com\") {\n\t\treturn \"aliyun\"\n\t}\n\tif strings.Contains(url, \"cloud.189.cn\") {\n\t\treturn \"tianyi\"\n\t}\n\tif strings.Contains(url, \"drive.uc.cn\") {\n\t\treturn \"uc\"\n\t}\n\tif strings.Contains(url, \"caiyun.139.com\") {\n\t\treturn \"mobile\"\n\t}\n\tif strings.Contains(url, \"115.com\") || strings.Contains(url, \"115cdn.com\") || strings.Contains(url, \"anxia.com\") {\n\t\treturn \"115\"\n\t}\n\tif strings.Contains(url, \"mypikpak.com\") {\n\t\treturn \"pikpak\"\n\t}\n\tif strings.Contains(url, \"pan.xunlei.com\") {\n\t\treturn \"xunlei\"\n\t}\n\t\n\t// 123网盘有多个域名\n\tif strings.Contains(url, \"123684.com\") || strings.Contains(url, \"123685.com\") || strings.Contains(url, \"123865.com\") || \n\t   strings.Contains(url, \"123912.com\") || strings.Contains(url, \"123pan.com\") || \n\t   strings.Contains(url, \"123pan.cn\") || strings.Contains(url, \"123592.com\") {\n\t\treturn \"123\"\n\t}\n\t\n\treturn \"others\"\n}\n\n// CleanBaiduPanURL 清理百度网盘URL，确保链接格式正确\nfunc CleanBaiduPanURL(url string) string {\n\t// 如果URL包含\"https://pan.baidu.com/s/\"，提取出正确的链接部分\n\tif strings.Contains(url, \"https://pan.baidu.com/s/\") {\n\t\t// 找到链接的起始位置\n\t\tstartIdx := strings.Index(url, \"https://pan.baidu.com/s/\")\n\t\tif startIdx >= 0 {\n\t\t\t// 从起始位置开始提取\n\t\t\turl = url[startIdx:]\n\t\t\t\n\t\t\t// 查找可能的结束标记\n\t\t\tendMarkers := []string{\" \", \"\\n\", \"\\t\", \"，\", \"。\", \"；\", \";\", \"，\", \",\", \"?pwd=\"}\n\t\t\tminEndIdx := len(url)\n\t\t\t\n\t\t\tfor _, marker := range endMarkers {\n\t\t\t\tidx := strings.Index(url, marker)\n\t\t\t\tif idx > 0 && idx < minEndIdx {\n\t\t\t\t\tminEndIdx = idx\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 如果找到了结束标记，截取到结束标记位置\n\t\t\tif minEndIdx < len(url) {\n\t\t\t\turl = url[:minEndIdx]\n\t\t\t}\n\t\t\t\n\t\t\t// 特殊处理pwd参数，确保只保留4位密码\n\t\t\tif strings.Contains(url, \"?pwd=\") {\n\t\t\t\tpwdIdx := strings.Index(url, \"?pwd=\")\n\t\t\t\tif pwdIdx >= 0 && len(url) > pwdIdx+5 { // ?pwd= 有5个字符\n\t\t\t\t\t// 只保留?pwd=后面的4位密码\n\t\t\t\t\tpwdEndIdx := pwdIdx + 9 // ?pwd=xxxx 总共9个字符\n\t\t\t\t\tif pwdEndIdx <= len(url) {\n\t\t\t\t\t\treturn url[:pwdEndIdx]\n\t\t\t\t\t}\n\t\t\t\t\t// 如果剩余字符不足4位，返回所有可用字符\n\t\t\t\t\treturn url\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn url\n}\n\n// CleanTianyiPanURL 清理天翼云盘URL，确保链接格式正确\nfunc CleanTianyiPanURL(url string) string {\n\t// 如果URL包含\"https://cloud.189.cn/t/\"，提取出正确的链接部分\n\tif strings.Contains(url, \"https://cloud.189.cn/t/\") {\n\t\t// 找到链接的起始位置\n\t\tstartIdx := strings.Index(url, \"https://cloud.189.cn/t/\")\n\t\tif startIdx >= 0 {\n\t\t\t// 从起始位置开始提取\n\t\t\turl = url[startIdx:]\n\t\t\t\n\t\t\t// 查找可能的结束标记\n\t\t\tendMarkers := []string{\" \", \"\\n\", \"\\t\", \"，\", \"。\", \"；\", \";\", \"，\", \",\", \"实时\", \"天翼\", \"更多\"}\n\t\t\tminEndIdx := len(url)\n\t\t\t\n\t\t\tfor _, marker := range endMarkers {\n\t\t\t\tidx := strings.Index(url, marker)\n\t\t\t\tif idx > 0 && idx < minEndIdx {\n\t\t\t\t\tminEndIdx = idx\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 如果找到了结束标记，截取到结束标记位置\n\t\t\tif minEndIdx < len(url) {\n\t\t\t\turl = url[:minEndIdx]\n\t\t\t}\n\t\t\t\n\t\t\t// 标准化URL：将URL编码转换为中文，用于去重\n\t\t\tif decoded, err := netUrl.QueryUnescape(url); err == nil {\n\t\t\t\turl = decoded\n\t\t\t}\n\t\t}\n\t}\n\treturn url\n}\n\n// CleanUCPanURL 清理UC网盘URL，确保链接格式正确\nfunc CleanUCPanURL(url string) string {\n\t// 如果URL包含\"https://drive.uc.cn/s/\"，提取出正确的链接部分\n\tif strings.Contains(url, \"https://drive.uc.cn/s/\") {\n\t\t// 找到链接的起始位置\n\t\tstartIdx := strings.Index(url, \"https://drive.uc.cn/s/\")\n\t\tif startIdx >= 0 {\n\t\t\t// 从起始位置开始提取\n\t\t\turl = url[startIdx:]\n\t\t\t\n\t\t\t// 查找可能的结束标记（包括常见的网盘名称，可能出现在链接后面）\n\t\t\tendMarkers := []string{\" \", \"\\n\", \"\\t\", \"，\", \"。\", \"；\", \";\", \"，\", \",\", \"网盘\", \"123\", \"夸克\", \"阿里\", \"百度\"}\n\t\t\tminEndIdx := len(url)\n\t\t\t\n\t\t\tfor _, marker := range endMarkers {\n\t\t\t\tidx := strings.Index(url, marker)\n\t\t\t\tif idx > 0 && idx < minEndIdx {\n\t\t\t\t\tminEndIdx = idx\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 如果找到了结束标记，截取到结束标记位置\n\t\t\tif minEndIdx < len(url) {\n\t\t\t\treturn url[:minEndIdx]\n\t\t\t}\n\t\t\t\n\t\t\t// 处理public参数\n\t\t\tif strings.Contains(url, \"?public=\") {\n\t\t\t\tpublicIdx := strings.Index(url, \"?public=\")\n\t\t\t\tif publicIdx > 0 {\n\t\t\t\t\t// 确保只保留?public=1这样的参数，不包含后面的文本\n\t\t\t\t\tif publicIdx+9 <= len(url) { // ?public=1 总共9个字符\n\t\t\t\t\t\treturn url[:publicIdx+9]\n\t\t\t\t\t}\n\t\t\t\t\treturn url[:publicIdx+8] // 如果参数不完整，至少保留?public=\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn url\n}\n\n// Clean123PanURL 清理123网盘URL，确保链接格式正确\nfunc Clean123PanURL(url string) string {\n\t// 检查是否为123网盘链接\n\tdomains := []string{\"123684.com\", \"123685.com\",\"123865.com\", \"123912.com\", \"123pan.com\", \"123pan.cn\", \"123592.com\"}\n\tisDomain123 := false\n\t\n\tfor _, domain := range domains {\n\t\tif strings.Contains(url, domain+\"/s/\") {\n\t\t\tisDomain123 = true\n\t\t\tbreak\n\t\t}\n\t}\n\t\n\tif isDomain123 {\n\t\t// 确保链接有协议头\n\t\thasProtocol := strings.HasPrefix(url, \"http://\") || strings.HasPrefix(url, \"https://\")\n\t\t\n\t\t// 找到链接的起始位置\n\t\tstartIdx := -1\n\t\tfor _, domain := range domains {\n\t\t\tif idx := strings.Index(url, domain+\"/s/\"); idx >= 0 {\n\t\t\t\tstartIdx = idx\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\t\n\t\tif startIdx >= 0 {\n\t\t\t// 如果链接没有协议头，添加协议头\n\t\t\tif !hasProtocol {\n\t\t\t\t// 提取链接部分\n\t\t\t\tlinkPart := url[startIdx:]\n\t\t\t\t// 添加协议头\n\t\t\t\turl = \"https://\" + linkPart\n\t\t\t} else if startIdx > 0 {\n\t\t\t\t// 如果链接有协议头，但可能包含前缀文本，提取完整URL\n\t\t\t\tprotocolIdx := strings.Index(url, \"://\")\n\t\t\t\tif protocolIdx >= 0 {\n\t\t\t\t\tprotocol := url[:protocolIdx+3]\n\t\t\t\t\turl = protocol + url[startIdx:]\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 保留提取码参数，但需要处理可能的表情符号和其他无关文本\n\t\t\t// 查找可能的结束标记（表情符号、标签标识等）\n\t\t\t// 注意：我们不再将\"提取码\"作为结束标记，因为它是URL的一部分\n\t\t\tendMarkers := []string{\" \", \"\\n\", \"\\t\", \"，\", \"。\", \"；\", \";\", \"，\", \",\", \"📁\", \"🔍\", \"标签\", \"🏷\", \"📎\", \"🔗\", \"📌\", \"📋\", \"📂\", \"🗂️\", \"🔖\", \"📚\", \"📒\", \"📔\", \"📕\", \"📓\", \"📗\", \"📘\", \"📙\", \"📄\", \"📃\", \"📑\", \"🧾\", \"📊\", \"📈\", \"📉\", \"🗒️\", \"🗓️\", \"📆\", \"📅\", \"🗑️\", \"🔒\", \"🔓\", \"🔏\", \"🔐\", \"🔑\", \"🗝️\"}\n\t\t\tminEndIdx := len(url)\n\t\t\t\n\t\t\tfor _, marker := range endMarkers {\n\t\t\t\tidx := strings.Index(url, marker)\n\t\t\t\tif idx > 0 && idx < minEndIdx {\n\t\t\t\t\tminEndIdx = idx\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 如果找到了结束标记，截取到结束标记位置\n\t\t\tif minEndIdx < len(url) {\n\t\t\t\treturn url[:minEndIdx]\n\t\t\t}\n\t\t\t\n\t\t\t// 标准化URL编码的提取码，统一使用非编码形式\n\t\t\tif strings.Contains(url, \"%E6%8F%90%E5%8F%96%E7%A0%81\") {\n\t\t\t\turl = strings.Replace(url, \"%E6%8F%90%E5%8F%96%E7%A0%81\", \"提取码\", 1)\n\t\t\t}\n\t\t}\n\t}\n\treturn url\n}\n\n// Clean115PanURL 清理115网盘URL，确保链接格式正确\nfunc Clean115PanURL(url string) string {\n\t// 检查是否为115网盘链接\n\tif strings.Contains(url, \"115.com/s/\") || strings.Contains(url, \"115cdn.com/s/\") || strings.Contains(url, \"anxia.com/s/\") {\n\t\t// 找到链接的起始位置\n\t\tstartIdx := -1\n\t\tif idx := strings.Index(url, \"115.com/s/\"); idx >= 0 {\n\t\t\tstartIdx = idx\n\t\t} else if idx := strings.Index(url, \"115cdn.com/s/\"); idx >= 0 {\n\t\t\tstartIdx = idx\n\t\t} else if idx := strings.Index(url, \"anxia.com/s/\"); idx >= 0 {\n\t\t\tstartIdx = idx\n\t\t}\n\t\t\n\t\tif startIdx >= 0 {\n\t\t\t// 确保链接有协议头\n\t\t\thasProtocol := strings.HasPrefix(url, \"http://\") || strings.HasPrefix(url, \"https://\")\n\t\t\t\n\t\t\t// 如果链接没有协议头，添加协议头\n\t\t\tif !hasProtocol {\n\t\t\t\t// 提取链接部分\n\t\t\t\tlinkPart := url[startIdx:]\n\t\t\t\t// 添加协议头\n\t\t\t\turl = \"https://\" + linkPart\n\t\t\t} else if startIdx > 0 {\n\t\t\t\t// 如果链接有协议头，但可能包含前缀文本，提取完整URL\n\t\t\t\tprotocolIdx := strings.Index(url, \"://\")\n\t\t\t\tif protocolIdx >= 0 {\n\t\t\t\t\tprotocol := url[:protocolIdx+3]\n\t\t\t\t\turl = protocol + url[startIdx:]\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 如果链接包含password参数，确保只保留到password=xxxx部分（4位密码）\n\t\t\tif strings.Contains(url, \"?password=\") {\n\t\t\t\tpwdIdx := strings.Index(url, \"?password=\")\n\t\t\t\tif pwdIdx > 0 && pwdIdx+14 <= len(url) { // ?password=xxxx 总共14个字符\n\t\t\t\t\t// 截取到密码后面4位\n\t\t\t\t\turl = url[:pwdIdx+14]\n\t\t\t\t\treturn url\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 如果链接包含#，截取到#位置\n\t\t\thashIdx := strings.Index(url, \"#\")\n\t\t\tif hashIdx > 0 {\n\t\t\t\turl = url[:hashIdx]\n\t\t\t\treturn url\n\t\t\t}\n\t\t}\n\t}\n\treturn url\n}\n\n// CleanAliyunPanURL 清理阿里云盘URL，确保链接格式正确\nfunc CleanAliyunPanURL(url string) string {\n\t// 如果URL包含阿里云盘域名，提取出正确的链接部分\n\tif strings.Contains(url, \"alipan.com/s/\") || strings.Contains(url, \"aliyundrive.com/s/\") {\n\t\t// 找到链接的起始位置和域名部分\n\t\tstartIdx := -1\n\t\t\n\t\tif idx := strings.Index(url, \"www.alipan.com/s/\"); idx >= 0 {\n\t\t\tstartIdx = idx\n\t\t} else if idx := strings.Index(url, \"alipan.com/s/\"); idx >= 0 {\n\t\t\tstartIdx = idx\n\t\t} else if idx := strings.Index(url, \"www.aliyundrive.com/s/\"); idx >= 0 {\n\t\t\tstartIdx = idx\n\t\t} else if idx := strings.Index(url, \"aliyundrive.com/s/\"); idx >= 0 {\n\t\t\tstartIdx = idx\n\t\t}\n\t\t\n\t\tif startIdx >= 0 {\n\t\t\t// 确保链接有协议头\n\t\t\thasProtocol := strings.HasPrefix(url, \"http://\") || strings.HasPrefix(url, \"https://\")\n\t\t\t\n\t\t\t// 如果链接没有协议头，添加协议头\n\t\t\tif !hasProtocol {\n\t\t\t\t// 提取链接部分\n\t\t\t\tlinkPart := url[startIdx:]\n\t\t\t\t// 添加协议头\n\t\t\t\turl = \"https://\" + linkPart\n\t\t\t} else if startIdx > 0 {\n\t\t\t\t// 如果链接有协议头，但可能包含前缀文本，提取完整URL\n\t\t\t\tprotocolIdx := strings.Index(url, \"://\")\n\t\t\t\tif protocolIdx >= 0 {\n\t\t\t\t\tprotocol := url[:protocolIdx+3]\n\t\t\t\t\turl = protocol + url[startIdx:]\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 查找可能的结束标记（表情符号、标签标识等）\n\t\t\tendMarkers := []string{\" \", \"\\n\", \"\\t\", \"，\", \"。\", \"；\", \";\", \"，\", \",\", \"📁\", \"🔍\", \"标签\", \"🏷\", \"📎\", \"🔗\", \"📌\", \"📋\", \"📂\", \"🗂️\", \"🔖\", \"📚\", \"📒\", \"📔\", \"📕\", \"📓\", \"📗\", \"📘\", \"📙\", \"📄\", \"📃\", \"📑\", \"🧾\", \"📊\", \"📈\", \"📉\", \"🗒️\", \"🗓️\", \"📆\", \"📅\", \"🗑️\", \"🔒\", \"🔓\", \"🔏\", \"🔐\", \"🔑\", \"🗝️\"}\n\t\t\tminEndIdx := len(url)\n\t\t\t\n\t\t\tfor _, marker := range endMarkers {\n\t\t\t\tidx := strings.Index(url, marker)\n\t\t\t\tif idx > 0 && idx < minEndIdx {\n\t\t\t\t\tminEndIdx = idx\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\t// 如果找到了结束标记，截取到结束标记位置\n\t\t\tif minEndIdx < len(url) {\n\t\t\t\treturn url[:minEndIdx]\n\t\t\t}\n\t\t}\n\t}\n\treturn url\n}\n\n// normalizeAliyunPanURL 标准化阿里云盘URL，确保链接格式正确\nfunc normalizeAliyunPanURL(url string, password string) string {\n\t// 清理URL，确保获取正确的链接部分\n\turl = CleanAliyunPanURL(url)\n\t\n\t// 阿里云盘链接通常不在URL中包含密码参数\n\t// 但是我们确保返回的是干净的链接\n\treturn url\n}\n\n// ExtractPassword 提取链接密码\nfunc ExtractPassword(content, url string) string {\n\t// 特殊处理天翼云盘URL中的访问码\n\tif strings.Contains(url, \"cloud.189.cn\") {\n\t\t// 天翼云盘访问码格式：（访问码：xxxx）或者URL编码形式\n\t\ttianyiPasswordPattern := regexp.MustCompile(`(?:（访问码：|%EF%BC%88%E8%AE%BF%E9%97%AE%E7%A0%81%EF%BC%9A)([a-zA-Z0-9]+)(?:）|%EF%BC%89)`)\n\t\ttianyiMatches := tianyiPasswordPattern.FindStringSubmatch(url)\n\t\tif len(tianyiMatches) > 1 {\n\t\t\treturn tianyiMatches[1]\n\t\t}\n\t}\n\t\n\t// 特殊处理迅雷网盘URL中的pwd参数\n\tif strings.Contains(url, \"pan.xunlei.com\") && strings.Contains(url, \"?pwd=\") {\n\t\tpwdPattern := regexp.MustCompile(`\\?pwd=([a-zA-Z0-9]{4})`)\n\t\tpwdMatches := pwdPattern.FindStringSubmatch(url)\n\t\tif len(pwdMatches) > 1 {\n\t\t\treturn pwdMatches[1]\n\t\t}\n\t}\n\t\n\t// 先从URL中提取密码\n\tmatches := UrlPasswordPattern.FindStringSubmatch(url)\n\tif len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\t\n\t// 特殊处理115网盘URL中的密码\n\tif (strings.Contains(url, \"115.com\") || \n\t\tstrings.Contains(url, \"115cdn.com\") || \n\t\tstrings.Contains(url, \"anxia.com\")) && \n\t\tstrings.Contains(url, \"password=\") {\n\t\t\n\t\t// 尝试从URL中提取密码\n\t\tpasswordPattern := regexp.MustCompile(`password=([a-zA-Z0-9]{4})`)\n\t\tpasswordMatches := passwordPattern.FindStringSubmatch(url)\n\t\tif len(passwordMatches) > 1 {\n\t\t\treturn passwordMatches[1]\n\t\t}\n\t}\n\t\n\t// 特殊处理123网盘URL中的提取码\n\tif (strings.Contains(url, \"123684.com\") || \n\t\tstrings.Contains(url, \"123685.com\") || \n\t\tstrings.Contains(url, \"123865.com\") || \n\t\tstrings.Contains(url, \"123912.com\") || \n\t\tstrings.Contains(url, \"123pan.com\") || \n\t\tstrings.Contains(url, \"123pan.cn\") || \n\t\tstrings.Contains(url, \"123592.com\")) && \n\t\t(strings.Contains(url, \"提取码\") || strings.Contains(url, \"%E6%8F%90%E5%8F%96%E7%A0%81\")) {\n\t\t\n\t\t// 尝试从URL中提取提取码（处理普通文本和URL编码两种情况）\n\t\textractCodePattern := regexp.MustCompile(`(?:提取码|%E6%8F%90%E5%8F%96%E7%A0%81)[:：]([a-zA-Z0-9]+)`)\n\t\tcodeMatches := extractCodePattern.FindStringSubmatch(url)\n\t\tif len(codeMatches) > 1 {\n\t\t\treturn codeMatches[1]\n\t\t}\n\t}\n\t\n\t// 检查123网盘URL中的提取码参数\n\tif (strings.Contains(url, \"123684.com\") || \n\t\tstrings.Contains(url, \"123685.com\") || \n\t\tstrings.Contains(url, \"123865.com\") || \n\t\tstrings.Contains(url, \"123912.com\") || \n\t\tstrings.Contains(url, \"123pan.com\") || \n\t\tstrings.Contains(url, \"123pan.cn\") || \n\t\tstrings.Contains(url, \"123592.com\")) && \n\t\tstrings.Contains(url, \"提取码\") {\n\t\t\n\t\t// 尝试从URL中提取提取码\n\t\tparts := strings.Split(url, \"提取码\")\n\t\tif len(parts) > 1 {\n\t\t\t// 提取码通常跟在冒号后面\n\t\t\tcodeStart := strings.IndexAny(parts[1], \":：\")\n\t\t\tif codeStart >= 0 && codeStart+1 < len(parts[1]) {\n\t\t\t\t// 提取冒号后面的内容，去除空格\n\t\t\t\tcode := strings.TrimSpace(parts[1][codeStart+1:])\n\t\t\t\t\n\t\t\t\t// 如果提取码后面有其他字符（如表情符号、标签等），只取提取码部分\n\t\t\t\t// 增加更多可能的结束标记\n\t\t\t\tendIdx := strings.IndexAny(code, \" \\t\\n\\r，。；;,🏷📁🔍📎🔗📌📋📂🗂️🔖📚📒📔📕📓📗📘📙📄📃📑🧾📊📈📉🗒️🗓️📆��🗑️🔒🔓🔏🔐🔑🗝️\")\n\t\t\t\tif endIdx > 0 {\n\t\t\t\t\tcode = code[:endIdx]\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// 去除可能的空格和其他无关字符\n\t\t\t\tcode = strings.TrimSpace(code)\n\t\t\t\t\n\t\t\t\t// 确保提取码是有效的（通常是4位字母数字）\n\t\t\t\tif len(code) > 0 && len(code) <= 6 && isValidPassword(code) {\n\t\t\t\t\treturn code\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 检查内容中是否包含\"提取码\"字样\n\tif strings.Contains(content, \"提取码\") {\n\t\t// 尝试从内容中提取提取码\n\t\tparts := strings.Split(content, \"提取码\")\n\t\tfor _, part := range parts {\n\t\t\tif len(part) > 0 {\n\t\t\t\t// 提取码通常跟在冒号后面\n\t\t\t\tcodeStart := strings.IndexAny(part, \":：\")\n\t\t\t\tif codeStart >= 0 && codeStart+1 < len(part) {\n\t\t\t\t\t// 提取冒号后面的内容，去除空格\n\t\t\t\t\tcode := strings.TrimSpace(part[codeStart+1:])\n\t\t\t\t\t\n\t\t\t\t\t// 如果提取码后面有其他字符，只取提取码部分\n\t\t\t\t\tendIdx := strings.IndexAny(code, \" \\t\\n\\r，。；;,🏷📁🔍📎🔗📌📋📂🗂️🔖📚📒📔📕📓📗📘📙📄📃📑🧾📊📈📉🗒️🗓️📆📅🗑️🔒🔓🔏🔐🔑🗝️\")\n\t\t\t\t\tif endIdx > 0 {\n\t\t\t\t\t\tcode = code[:endIdx]\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// 如果没有明显的结束标记，假设提取码是4-6位字符\n\t\t\t\t\t\tif len(code) > 6 {\n\t\t\t\t\t\t\t// 检查前4-6位是否是有效的提取码\n\t\t\t\t\t\t\tfor i := 4; i <= 6 && i <= len(code); i++ {\n\t\t\t\t\t\t\t\tif isValidPassword(code[:i]) {\n\t\t\t\t\t\t\t\t\tcode = code[:i]\n\t\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t// 如果没有找到有效的提取码，取前4位\n\t\t\t\t\t\t\tif len(code) > 6 {\n\t\t\t\t\t\t\t\tcode = code[:4]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\t// 去除可能的空格和其他无关字符\n\t\t\t\t\tcode = strings.TrimSpace(code)\n\t\t\t\t\t\n\t\t\t\t\t// 如果提取码不为空且是有效的，返回\n\t\t\t\t\tif code != \"\" && isValidPassword(code) {\n\t\t\t\t\t\treturn code\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 再从内容中提取密码\n\t// 对于百度网盘链接，尝试查找特定格式的密码\n\tif strings.Contains(strings.ToLower(url), \"pan.baidu.com\") {\n\t\t// 尝试匹配百度网盘特定格式的密码\n\t\tbaiduMatches := BaiduPasswordPattern.FindStringSubmatch(content)\n\t\tif len(baiduMatches) > 1 {\n\t\t\treturn baiduMatches[1]\n\t\t}\n\t}\n\t\n\t// 通用密码提取\n\tmatches = PasswordPattern.FindStringSubmatch(content)\n\tif len(matches) > 1 {\n\t\treturn matches[1]\n\t}\n\t\n\treturn \"\"\n}\n\n// isValidPassword 检查提取码是否有效（只包含字母和数字）\nfunc isValidPassword(password string) bool {\n\tfor _, c := range password {\n\t\tif !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ExtractNetDiskLinks 从文本中提取所有网盘链接\nfunc ExtractNetDiskLinks(text string) []string {\n\tvar links []string\n\t\n\t// 提取百度网盘链接\n\tbaiduMatches := BaiduPanPattern.FindAllString(text, -1)\n\tfor _, match := range baiduMatches {\n\t\t// 清理并添加百度网盘链接\n\t\tcleanURL := CleanBaiduPanURL(match)\n\t\t// 确保链接末尾不包含https\n\t\tif strings.HasSuffix(cleanURL, \"https\") {\n\t\t\tcleanURL = cleanURL[:len(cleanURL)-5]\n\t\t}\n\t\tif cleanURL != \"\" {\n\t\t\tlinks = append(links, cleanURL)\n\t\t}\n\t}\n\t\n\t// 提取天翼云盘链接\n\ttianyiMatches := TianyiPanPattern.FindAllString(text, -1)\n\tfor _, match := range tianyiMatches {\n\t\t// 清理并添加天翼云盘链接\n\t\tcleanURL := CleanTianyiPanURL(match)\n\t\t// 确保链接末尾不包含https\n\t\tif strings.HasSuffix(cleanURL, \"https\") {\n\t\t\tcleanURL = cleanURL[:len(cleanURL)-5]\n\t\t}\n\t\tif cleanURL != \"\" {\n\t\t\tlinks = append(links, cleanURL)\n\t\t}\n\t}\n\t\n\t// 提取UC网盘链接\n\tucMatches := UCPanPattern.FindAllString(text, -1)\n\tfor _, match := range ucMatches {\n\t\t// 清理并添加UC网盘链接\n\t\tcleanURL := CleanUCPanURL(match)\n\t\t// 确保链接末尾不包含https\n\t\tif strings.HasSuffix(cleanURL, \"https\") {\n\t\t\tcleanURL = cleanURL[:len(cleanURL)-5]\n\t\t}\n\t\tif cleanURL != \"\" {\n\t\t\tlinks = append(links, cleanURL)\n\t\t}\n\t}\n\t\n\t// 提取123网盘链接\n\tpan123Matches := Pan123Pattern.FindAllString(text, -1)\n\tfor _, match := range pan123Matches {\n\t\t// 清理并添加123网盘链接\n\t\tcleanURL := Clean123PanURL(match)\n\t\t// 确保链接末尾不包含https\n\t\tif strings.HasSuffix(cleanURL, \"https\") {\n\t\t\tcleanURL = cleanURL[:len(cleanURL)-5]\n\t\t}\n\t\tif cleanURL != \"\" {\n\t\t\t// 检查是否已经存在相同的链接（比较完整URL）\n\t\t\tisDuplicate := false\n\t\t\tfor _, existingLink := range links {\n\t\t\t\t// 标准化链接以进行比较（仅移除协议）\n\t\t\t\tnormalizedExisting := normalizeURLForComparison(existingLink)\n\t\t\t\tnormalizedNew := normalizeURLForComparison(cleanURL)\n\t\t\t\t\n\t\t\t\tif normalizedExisting == normalizedNew {\n\t\t\t\t\tisDuplicate = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tif !isDuplicate {\n\t\t\t\tlinks = append(links, cleanURL)\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 提取115网盘链接\n\tpan115Matches := Pan115Pattern.FindAllString(text, -1)\n\tfor _, match := range pan115Matches {\n\t\t// 清理并添加115网盘链接\n\t\tcleanURL := Clean115PanURL(match) // 115网盘链接的清理逻辑与123网盘类似\n\t\t// 确保链接末尾不包含https\n\t\tif strings.HasSuffix(cleanURL, \"https\") {\n\t\t\tcleanURL = cleanURL[:len(cleanURL)-5]\n\t\t}\n\t\tif cleanURL != \"\" {\n\t\t\t// 检查是否已经存在相同的链接（比较完整URL）\n\t\t\tisDuplicate := false\n\t\t\tfor _, existingLink := range links {\n\t\t\t\tnormalizedExisting := normalizeURLForComparison(existingLink)\n\t\t\t\tnormalizedNew := normalizeURLForComparison(cleanURL)\n\t\t\t\t\n\t\t\t\tif normalizedExisting == normalizedNew {\n\t\t\t\t\tisDuplicate = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tif !isDuplicate {\n\t\t\t\tlinks = append(links, cleanURL)\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 提取阿里云盘链接\n\taliyunMatches := AliyunPanPattern.FindAllString(text, -1)\n\tif aliyunMatches != nil {\n\t\tfor _, match := range aliyunMatches {\n\t\t\t// 清理并添加阿里云盘链接\n\t\t\tcleanURL := CleanAliyunPanURL(match)\n\t\t\t// 确保链接末尾不包含https\n\t\t\tif strings.HasSuffix(cleanURL, \"https\") {\n\t\t\t\tcleanURL = cleanURL[:len(cleanURL)-5]\n\t\t\t}\n\t\t\tif cleanURL != \"\" {\n\t\t\t\t// 检查是否已经存在相同的链接\n\t\t\t\tisDuplicate := false\n\t\t\t\tfor _, existingLink := range links {\n\t\t\t\t\tnormalizedExisting := normalizeURLForComparison(existingLink)\n\t\t\t\t\tnormalizedNew := normalizeURLForComparison(cleanURL)\n\t\t\t\t\t\n\t\t\t\t\tif normalizedExisting == normalizedNew {\n\t\t\t\t\t\tisDuplicate = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tif !isDuplicate {\n\t\t\t\t\tlinks = append(links, cleanURL)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 提取夸克网盘链接\n\tquarkLinks := QuarkPanPattern.FindAllString(text, -1)\n\tif quarkLinks != nil {\n\t\tfor _, match := range quarkLinks {\n\t\t\t// 确保链接末尾不包含https\n\t\t\tcleanURL := match\n\t\t\tif strings.HasSuffix(cleanURL, \"https\") {\n\t\t\t\tcleanURL = cleanURL[:len(cleanURL)-5]\n\t\t\t}\n\t\t\t// 检查是否已经存在相同的链接\n\t\t\tisDuplicate := false\n\t\t\tfor _, existingLink := range links {\n\t\t\t\tif strings.Contains(existingLink, cleanURL) || strings.Contains(cleanURL, existingLink) {\n\t\t\t\t\tisDuplicate = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tif !isDuplicate {\n\t\t\t\tlinks = append(links, cleanURL)\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 提取迅雷网盘链接\n\txunleiLinks := XunleiPanPattern.FindAllString(text, -1)\n\tif xunleiLinks != nil {\n\t\tfor _, match := range xunleiLinks {\n\t\t\t// 确保链接末尾不包含https\n\t\t\tcleanURL := match\n\t\t\tif strings.HasSuffix(cleanURL, \"https\") {\n\t\t\t\tcleanURL = cleanURL[:len(cleanURL)-5]\n\t\t\t}\n\t\t\t// 检查是否已经存在相同的链接\n\t\t\tisDuplicate := false\n\t\t\tfor _, existingLink := range links {\n\t\t\t\tif strings.Contains(existingLink, cleanURL) || strings.Contains(cleanURL, existingLink) {\n\t\t\t\t\tisDuplicate = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tif !isDuplicate {\n\t\t\t\tlinks = append(links, cleanURL)\n\t\t\t}\n\t\t}\n\t}\n\t\n\t// 使用通用模式提取其他可能的链接\n\totherLinks := AllPanLinksPattern.FindAllString(text, -1)\n\tif otherLinks != nil {\n\t\t// 过滤掉已经添加过的链接\n\t\tfor _, link := range otherLinks {\n\t\t\t// 确保链接末尾不包含https\n\t\t\tcleanURL := link\n\t\t\tif strings.HasSuffix(cleanURL, \"https\") {\n\t\t\t\tcleanURL = cleanURL[:len(cleanURL)-5]\n\t\t\t}\n\t\t\t// 跳过百度、夸克、迅雷、天翼、UC和123网盘链接，因为已经单独处理过\n\t\t\tif strings.Contains(cleanURL, \"pan.baidu.com\") || \n\t\t\t   strings.Contains(cleanURL, \"pan.quark.cn\") || \n\t\t\t   strings.Contains(cleanURL, \"pan.xunlei.com\") ||\n\t\t\t   strings.Contains(cleanURL, \"cloud.189.cn\") ||\n\t\t\t   strings.Contains(cleanURL, \"drive.uc.cn\") ||\n\t\t\t   strings.Contains(cleanURL, \"123684.com\") ||\n\t\t\t   strings.Contains(cleanURL, \"123685.com\") ||\n\t\t\t   strings.Contains(cleanURL, \"123865.com\") ||\n\t\t\t   strings.Contains(cleanURL, \"123912.com\") ||\n\t\t\t   strings.Contains(cleanURL, \"123pan.com\") ||\n\t\t\t   strings.Contains(cleanURL, \"123pan.cn\") ||\n\t\t\t   strings.Contains(cleanURL, \"123592.com\") {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t\n\t\t\tisDuplicate := false\n\t\t\tfor _, existingLink := range links {\n\t\t\t\tnormalizedExisting := normalizeURLForComparison(existingLink)\n\t\t\t\tnormalizedNew := normalizeURLForComparison(cleanURL)\n\t\t\t\t\n\t\t\t\t// 使用完整URL比较，包括www.前缀\n\t\t\t\tif normalizedExisting == normalizedNew || \n\t\t\t\t   strings.Contains(normalizedExisting, normalizedNew) || \n\t\t\t\t   strings.Contains(normalizedNew, normalizedExisting) {\n\t\t\t\t\tisDuplicate = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tif !isDuplicate {\n\t\t\t\tlinks = append(links, cleanURL)\n\t\t\t}\n\t\t}\n\t}\n\t\n\treturn links\n}\n\n// normalizeURLForComparison 标准化URL以便于比较\n// 移除协议头，标准化提取码，保留完整域名用于比较\nfunc normalizeURLForComparison(url string) string {\n\t// 移除协议头\n\tif idx := strings.Index(url, \"://\"); idx >= 0 {\n\t\turl = url[idx+3:]\n\t}\n\t\n\t// 标准化URL编码的提取码，统一使用非编码形式\n\tif strings.Contains(url, \"%E6%8F%90%E5%8F%96%E7%A0%81\") {\n\t\turl = strings.Replace(url, \"%E6%8F%90%E5%8F%96%E7%A0%81\", \"提取码\", 1)\n\t}\n\t\n\treturn url\n} "
  }
]