```
├── .dockerignore (100 tokens)
├── .github/
├── workflows/
├── docker_ci.yml (400 tokens)
├── .gitignore
├── Dockerfile (700 tokens)
├── LICENSE (omitted)
├── README.md (3.7k tokens)
├── api/
├── auth_handler.go (400 tokens)
├── check_handler.go (200 tokens)
├── filter.go (700 tokens)
├── handler.go (1200 tokens)
├── middleware.go (600 tokens)
├── router.go (400 tokens)
├── config/
├── config.go (2.7k tokens)
├── docker-compose.yml (600 tokens)
├── docs/
├── æÂÂä»¶å¼ÂÃ¥ÂÂæÂÂÃ¥ÂÂ.md (6.1k tokens)
├── ç³»ç»Âå¼ÂÃ¥ÂÂ设计æÂÂæ¡£.md (5.7k tokens)
├── go.mod (300 tokens)
├── go.sum (2.5k tokens)
├── main.go (2.1k tokens)
├── model/
├── check.go (200 tokens)
├── plugin_result.go (100 tokens)
├── request.go (200 tokens)
├── response.go (500 tokens)
├── plugin/
├── ahhhhfs/
├── ahhhhfs.go (2.7k tokens)
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (900 tokens)
├── aikanzy/
├── aikanzy.go (2.8k tokens)
├── alupan/
├── alupan.go (2k tokens)
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (800 tokens)
├── ash/
├── ash.go (1500 tokens)
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (700 tokens)
├── bixin/
├── bixin.go (2.7k tokens)
├── jsonç»ÂæÂÂÃ¥ÂÂæÂÂ.md (3.3k tokens)
├── cldi/
├── cldi.go (1700 tokens)
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (600 tokens)
├── clmao/
├── clmao.go (1800 tokens)
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (600 tokens)
├── clxiong/
├── clxiong.go (3.1k tokens)
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (800 tokens)
├── cyg/
├── cyg.go (2.4k tokens)
├── daishudj/
├── daishudj.go (2.3k tokens)
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (500 tokens)
├── ddys/
├── ddys.go (2.8k tokens)
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1100 tokens)
├── discourse/
├── discourse.go (2.9k tokens)
├── jsonç»ÂæÂÂÃ¥ÂÂæÂÂ.md (2.3k tokens)
├── djgou/
├── djgou.go (2.2k tokens)
├── duanjuw/
├── duanjuw.go (2.6k tokens)
├── duoduo/
├── duoduo.go (3.5k tokens)
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (500 tokens)
├── dyyj/
├── dyyj.go (6.2k tokens)
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (700 tokens)
├── dyyjpro/
├── dyyjpro.go (2.2k tokens)
├── erxiao/
├── erxiao.go (2.9k tokens)
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (800 tokens)
├── feikuai/
├── feikuai.go (2.1k tokens)
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (2.9k tokens)
├── jsonç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1700 tokens)
├── fox4k/
├── fox4k.go (5.7k tokens)
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1900 tokens)
├── gaoqing888/
├── gaoqing888.go (1800 tokens)
├── gying/
├── README.md (2.2k tokens)
├── gying.go (18.7k tokens)
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (2.1k tokens)
├── haisou/
├── haisou.go (2.8k tokens)
├── jsonç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1400 tokens)
├── hdmoli/
├── hdmoli.go (3.5k tokens)
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (900 tokens)
├── hdr4k/
├── hdr4k.go (3.2k tokens)
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (900 tokens)
├── 设计æÂÂæ¡£.md (3.6k tokens)
├── huban/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (800 tokens)
├── huban.go (3.6k tokens)
├── jsonç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1900 tokens)
├── hunhepan/
├── hunhepan.go (2.4k tokens)
├── javdb/
├── javdb.go (5k tokens)
├── jikepan/
├── jikepan.go (1000 tokens)
├── jsnoteclub/
├── jsnoteclub.go (3k tokens)
├── jupansou/
├── jupansou.go (1100 tokens)
├── jutoushe/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (800 tokens)
├── jutoushe.go (1700 tokens)
├── kkmao/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (700 tokens)
├── kkmao.go (1900 tokens)
├── kkv/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (800 tokens)
├── kkv.go (2.1k tokens)
├── labi/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1600 tokens)
├── labi.go (2.5k tokens)
├── leijing/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (800 tokens)
├── leijing.go (2.1k tokens)
├── libvio/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (800 tokens)
├── libvio.go (3k tokens)
├── lingjisp/
├── lingjisp.go (2.6k tokens)
├── lou1/
├── lou1.go (2.8k tokens)
├── meitizy/
├── jsonç»ÂæÂÂÃ¥ÂÂæÂÂ.md (600 tokens)
├── meitizy.go (1900 tokens)
├── melost/
├── melost.go (2000 tokens)
├── miaoso/
├── jsonç»ÂæÂÂÃ¥ÂÂæÂÂ.md (900 tokens)
├── miaoso.go (1700 tokens)
├── mikuclub/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (300 tokens)
├── jsonç»ÂæÂÂÃ¥ÂÂæÂÂ.md (500 tokens)
├── mikuclub.go (2.6k tokens)
├── mizixing/
├── mizixing.go (2.6k tokens)
├── muou/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (800 tokens)
├── muou.go (3.4k tokens)
├── nsgame/
├── jsonç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1400 tokens)
├── nsgame.go (1400 tokens)
├── nyaa/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1300 tokens)
├── nyaa.go (1500 tokens)
├── ouge/
├── jsonç»ÂæÂÂÃ¥ÂÂæÂÂ.md (900 tokens)
├── ouge.go (2.3k tokens)
├── pan666/
├── pan666.go (3.3k tokens)
├── panlian/
├── panlian.go (8.5k tokens)
├── pansearch/
├── pansearch.go (4.9k tokens)
├── pansearch_test.go (400 tokens)
├── panta/
├── panta.go (6k tokens)
├── pantaæÂÂ件设计æÂÂæ¡£.md (1900 tokens)
├── panwiki/
├── panwiki.go (7.6k tokens)
├── panyq/
├── panyq.go (6.9k tokens)
├── panzun/
├── panzun.go (2.6k tokens)
├── pianku/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (2.5k tokens)
├── pianku.go (2.6k tokens)
├── pioz/
├── 1.txt (10.5k tokens)
├── plugin.go (5.9k tokens)
├── qingying/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (600 tokens)
├── qingying.go (2.3k tokens)
├── qiwei/
├── qiwei.go (4.3k tokens)
├── qqpd/
├── README.md (1800 tokens)
├── qqpd.go (12.8k tokens)
├── quark4k/
├── jsonç»ÂæÂÂÃ¥ÂÂæÂÂ.md (3.5k tokens)
├── quark4k.go (3k tokens)
├── quarksoo/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1400 tokens)
├── quarksoo.go (1200 tokens)
├── quarktv/
├── quarktv.go (2.1k tokens)
├── qupanshe/
├── qupanshe.go (4.1k tokens)
├── qupansou/
├── qupansou.go (1600 tokens)
├── sdso/
├── sdso.go (2.5k tokens)
├── shandian/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1600 tokens)
├── shandian.go (2.3k tokens)
├── sousou/
├── jsonç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1700 tokens)
├── sousou.go (2.3k tokens)
├── susu/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (300 tokens)
├── susu.go (2.7k tokens)
├── susuæÂÂ件设计æÂÂæ¡£.md (5.7k tokens)
├── thepiratebay/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1100 tokens)
├── thepiratebay.go (2.5k tokens)
├── u3c3/
├── u3c3.go (1800 tokens)
├── wanou/
├── jsonç»ÂæÂÂÃ¥ÂÂæÂÂ.md (900 tokens)
├── wanou.go (2.5k tokens)
├── weibo/
├── README.md (1800 tokens)
├── weibo.go (10.9k tokens)
├── å¾®åÂÂç¨æÂ·æÂÂç´¢APIæÂÂæ¡£.md (2.1k tokens)
├── wuji/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (800 tokens)
├── wuji.go (2.2k tokens)
├── xb6v/
├── xb6v.go (3.8k tokens)
├── xdpan/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (900 tokens)
├── xdpan.go (2.2k tokens)
├── xdyh/
├── jsonç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1300 tokens)
├── xdyh.go (2.4k tokens)
├── xiaoji/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1300 tokens)
├── xiaoji.go (2.3k tokens)
├── xiaozhang/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (600 tokens)
├── xiaozhang.go (2.6k tokens)
├── xinjuc/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1400 tokens)
├── xinjuc.go (2.6k tokens)
├── xuexizhinan/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1000 tokens)
├── xuexizhinan.go (1900 tokens)
├── xys/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (500 tokens)
├── xys.go (2.3k tokens)
├── yiove/
├── yiove.go (3.3k tokens)
├── ypfxw/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (600 tokens)
├── ypfxw.go (2.3k tokens)
├── yuhuage/
├── yuhuage.go (2000 tokens)
├── yulinshufa/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (500 tokens)
├── yulinshufa.go (4.2k tokens)
├── yunso/
├── yunso.go (2.4k tokens)
├── yunsou/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1300 tokens)
├── yunsou.go (1600 tokens)
├── zhizhen/
├── jsonç»ÂæÂÂÃ¥ÂÂæÂÂ.md (1100 tokens)
├── zhizhen.go (3.5k tokens)
├── zxzj/
├── htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md (700 tokens)
├── zxzj.go (2.3k tokens)
├── service/
├── cache_integration.go (600 tokens)
├── check_mobile_crypto.go (600 tokens)
├── check_service.go (10k tokens)
├── search_service.go (7.4k tokens)
├── util/
├── cache/
├── cache_key.go (1300 tokens)
├── delayed_batch_write_manager.go (4.8k tokens)
├── disk_cache.go (1400 tokens)
├── enhanced_two_level_cache.go (700 tokens)
├── global_buffer_manager.go (2.5k tokens)
├── memory_cache.go (700 tokens)
├── serializer.go (400 tokens)
├── sharded_disk_cache.go (800 tokens)
├── sharded_memory_cache.go (1700 tokens)
├── utils.go (200 tokens)
├── compression.go (500 tokens)
├── convert.go
├── http_util.go (700 tokens)
├── json/
├── json.go (200 tokens)
├── jwt.go (300 tokens)
├── parser_util.go (4.1k tokens)
├── pool/
├── object_pool.go (300 tokens)
├── worker_pool.go (700 tokens)
├── regex_util.go (5.2k tokens)
```
## /.dockerignore
```dockerignore path="/.dockerignore"
# Git相关
.git
.gitignore
.github
# 文档和其他非必要文件
README.md
docs/
*.md
LICENSE
# 开发和测试相关
*_test.go
*.test
*.out
*.prof
# 构建产物
pansou
pansou_*
*.exe
*.exe~
*.dll
*.so
*.dylib
# 缓存和临时文件
.DS_Store
cache/
tmp/
.idea/
.vscode/
# 其他
Dockerfile
.dockerignore
```
## /.github/workflows/docker_ci.yml
```yml path="/.github/workflows/docker_ci.yml"
name: 构建并发布Docker镜像
on:
push:
branches:
- "main"
paths-ignore:
- "README.md"
- "docs/**"
pull_request:
branches:
- "main"
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: 检出代码
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 设置QEMU
uses: docker/setup-qemu-action@v3
- name: 设置Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-flags: --debug
- name: 登录到GitHub容器注册表
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.DOCKER }}
- name: 提取Docker元数据
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/pansou
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: 构建并推送Docker镜像
uses: docker/build-push-action@v5
with:
context: .
# 这是关键修改点
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILD_DATE=${{ github.event.repository.updated_at }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta.outputs.version }}
```
## /.gitignore
```gitignore path="/.gitignore"
.DS_Store
cache
```
## /Dockerfile
``` path="/Dockerfile"
# 构建阶段
# 使用 --platform=$BUILDPLATFORM 确保构建器始终在运行 Actions 的机器的原生架构上运行 (通常是 linux/amd64)
# $BUILDPLATFORM 是 buildx 自动提供的变量
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder
# 安装构建依赖
RUN apk add --no-cache git ca-certificates tzdata
# 设置工作目录
WORKDIR /app
# 复制依赖文件
COPY go.mod go.sum ./
# 下载依赖
RUN go mod download
# 复制源代码
COPY . .
# 构建参数
ARG VERSION=dev
ARG BUILD_DATE=unknown
ARG VCS_REF=unknown
# 这是 buildx 自动传入的目标平台架构参数,例如 amd64, arm64
ARG TARGETARCH
# 构建应用
# Go 语言原生支持交叉编译,这里会根据传入的 TARGETARCH 编译出对应平台的可执行文件
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w -extldflags '-static'" -o pansou .
# 运行阶段
# 这一阶段会根据 buildx 的 --platform 参数选择正确的基础镜像 (例如 linux/arm64 会拉取 arm64/alpine)
FROM alpine:3.19
# 添加运行时依赖
RUN apk add --no-cache ca-certificates tzdata
# 创建缓存目录
RUN mkdir -p /app/cache
# 从构建阶段复制可执行文件
# buildx 会智能地从对应平台的 builder 中复制正确的可执行文件
COPY --from=builder /app/pansou /app/pansou
# 设置工作目录
WORKDIR /app
# 暴露端口
EXPOSE 8888
# 设置环境变量
# ENABLED_PLUGINS: 必须指定启用的插件,多个插件用逗号分隔
# AUTH_ENABLED: 认证功能默认关闭,可通过环境变量启用
ENV CACHE_PATH=/app/cache \
CACHE_ENABLED=true \
TZ=Asia/Shanghai \
ASYNC_PLUGIN_ENABLED=true \
ASYNC_RESPONSE_TIMEOUT=4 \
ASYNC_MAX_BACKGROUND_WORKERS=20 \
ASYNC_MAX_BACKGROUND_TASKS=100 \
ASYNC_CACHE_TTL_HOURS=1 \
CHANNELS=tgsearchers6,Aliyun_4K_Movies,bdbdndn11,yunpanx,bsbdbfjfjff,yp123pan,sbsbsnsqq,yunpanxunlei,tianyifc,BaiduCloudDisk,txtyzy,peccxinpd,gotopan,PanjClub,kkxlzy,baicaoZY,MCPH01,MCPH02,MCPH03,bdwpzhpd,ysxb48,jdjdn1111,yggpan,MCPH086,zaihuayun,Q66Share,ucwpzy,shareAliyun,alyp_1,dianyingshare,Quark_Movies,XiangxiuNBB,ydypzyfx,ucquark,xx123pan,yingshifenxiang123,zyfb123,tyypzhpd,tianyirigeng,cloudtianyi,hdhhd21,Lsp115,oneonefivewpfx,qixingzhenren,taoxgzy,Channel_Shares_115,tyysypzypd,vip115hot,wp123zy,yunpan139,yunpan189,yunpanuc,yydf_hzl,leoziyuan,pikpakpan,Q_dongman,yoyokuakeduanju,TG654TG,WFYSFX02,QukanMovie,yeqingjie_GJG666,movielover8888_film3,Baidu_netdisk,D_wusun,FLMdongtianfudi,KaiPanshare,QQZYDAPP,rjyxfx,PikPak_Share_Channel,btzhi,newproductsourcing,cctv1211,duan_ju,QuarkFree,yunpanNB,kkdj001,xxzlzn,pxyunpanxunlei,jxwpzy,kuakedongman,liangxingzhinan,xiangnikanj,solidsexydoll,guoman4K,zdqxm,kduanju,cilidianying,CBduanju,SharePanFilms,dzsgx,BooksRealm,Oscar_4Kmovies,douerpan,baidu_yppan,Q_jilupian,Netdisk_Movies,yunpanquark,ammmziyuan,ciliziyuanku,cili8888,jzmm_123pan \
ENABLED_PLUGINS=labi,zhizhen,shandian,duoduo,muou,wanou,hunhepan,jikepan,panwiki,pansearch,panta,qupansou,hdr4k,pan666,susu,thepiratebay,xuexizhinan,panyq,ouge,huban,cyg,erxiao,miaoso,fox4k,pianku,clmao,wuji,cldi,xiaozhang,libvio,leijing,xb6v,xys,ddys,hdmoli,yuhuage,u3c3,javdb,clxiong,jutoushe,sdso,xiaoji,xdyh,haisou,bixin,djgou,nyaa,xinjuc,aikanzy,qupanshe,xdpan,discourse,yunsou,qqpd,ahhhhfs,nsgame,gying,quark4k,quarksoo,sousou,ash \
AUTH_ENABLED=false \
AUTH_TOKEN_EXPIRY=24
# 构建参数
ARG VERSION=dev
ARG BUILD_DATE=unknown
ARG VCS_REF=unknown
# 添加镜像标签
LABEL org.opencontainers.image.title="PanSou" \
org.opencontainers.image.description="高性能网盘资源搜索API服务" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.revision="${VCS_REF}" \
org.opencontainers.image.url="https://github.com/fish2018/pansou" \
org.opencontainers.image.source="https://github.com/fish2018/pansou" \
maintainer="fish2018"
# 运行应用
CMD ["/app/pansou"]
```
## /README.md
# PanSou 网盘搜索API
PanSou是一个高性能的网盘资源搜索API服务,支持TG搜索和自定义插件搜索。系统设计以性能和可扩展性为核心,支持并发搜索、结果智能排序和网盘类型分类。
[//]: # (MCP服务文档: [MCP-SERVICE.md](docs/MCP-SERVICE.md))
## 特性([详见系统设计文档](docs/%E7%B3%BB%E7%BB%9F%E5%BC%80%E5%8F%91%E8%AE%BE%E8%AE%A1%E6%96%87%E6%A1%A3.md))
- **高性能搜索**:并发执行多个TG频道及异步插件搜索,显著提升搜索速度;工作池设计,高效管理并发任务
- **网盘类型分类**:自动识别多种网盘链接,按类型归类展示
- **智能排序**:基于插件等级、时间新鲜度和优先关键词的多维度综合排序算法
- **异步插件系统**:支持通过插件扩展搜索来源,支持"尽快响应,持续处理"的异步搜索模式,解决了某些搜索源响应时间长的问题。详情参考[**插件开发指南**](docs/插件开发指南.md)
- **二级缓存**:分片内存+分片磁盘缓存机制,大幅提升重复查询速度和并发性能
## 支持的网盘类型
百度网盘 (`baidu`)、阿里云盘 (`aliyun`)、夸克网盘 (`quark`)、光鸭云盘 (`guangya`)、天翼云盘 (`tianyi`)、UC网盘 (`uc`)、移动云盘 (`mobile`)、115网盘 (`115`)、PikPak (`pikpak`)、迅雷网盘 (`xunlei`)、123网盘 (`123`)、磁力链接 (`magnet`)、电驴链接 (`ed2k`)、其他 (`others`)
## 快速开始
在 Github 上先[](https://github.com/fish2018/pansou/fork)
本项目,并点上 Star !!!
### 使用Docker部署
[qqpd搜索插件文档](plugin/qqpd/README.md)
[gying搜索插件文档](plugin/gying/README.md)
[weibo搜索插件文档](plugin/weibo/README.md)
[常见问题总结](https://github.com/fish2018/pansou/issues/46)
[TG/QQ频道/插件/微博](https://github.com/fish2018/pansou/issues/4)
#### **1、前后端集成版**
##### 直接使用Docker命令
一键启动,开箱即用
```
docker run -d --name pansou -p 80:80 ghcr.io/fish2018/pansou-web
```
##### 使用Docker Compose(推荐)
```
# 下载配置文件
curl -o docker-compose.yml https://raw.githubusercontent.com/fish2018/pansou-web/refs/heads/main/docker-compose.yml
# 启动服务
docker-compose up -d
# 查看日志
docker-compose logs -f
```
#### **2、纯后端API版**
##### 直接使用Docker命令
```bash
docker run -d --name pansou -p 8888:8888 ghcr.io/fish2018/pansou:latest
```
##### 使用Docker Compose(推荐)
```bash
# 下载配置文件
curl -o docker-compose.yml https://raw.githubusercontent.com/fish2018/pansou/refs/heads/main/docker-compose.yml
# 启动服务
docker-compose up -d
# 访问服务
http://localhost:8888
```
### 从源码安装
#### 环境要求
- Go 1.18+
- 可选:SOCKS5代理(用于访问受限地区的Telegram站点)
1. 克隆仓库
```bash
git clone https://github.com/fish2018/pansou.git
cd pansou
```
2. 配置环境变量(可选)
#### 基础配置
| 环境变量 | 描述 | 默认值 | 说明 |
|----------|------|--------|------|
| **PORT** | 服务端口 | `8888` | 修改服务监听端口 |
| **PROXY** | SOCKS5代理 | 无 | 如:`PROXY=socks5://127.0.0.1:1080` |
| **HTTPS_PROXY/HTTP_PROXY** | HTTPS/HTTP代理 | 无 | 如:`HTTPS_PROXY=http://127.0.0.1:1080`,`HTTP_PROXY=http://127.0.0.1:1080` |
| **CHANNELS** | 默认搜索的TG频道 | `tgsearchers3` | 多个频道用逗号分隔 |
| **ENABLED_PLUGINS** | 指定启用插件,多个插件用逗号分隔 | 无 | 必须显式指定 |
#### 认证配置(可选)
PanSou支持可选的安全认证功能,默认关闭。开启后,所有API接口(除登录接口外)都需要提供有效的JWT Token。详见[认证系统设计文档](docs/认证系统设计.md)。
| 环境变量 | 描述 | 默认值 | 说明 |
|----------|------|--------|------|
| **AUTH_ENABLED** | 是否启用认证 | `false` | 设置为`true`启用认证功能 |
| **AUTH_USERS** | 用户账号配置 | 无 | 格式:`user1:pass1,user2:pass2` |
| **AUTH_TOKEN_EXPIRY** | Token有效期(小时) | `24` | JWT Token的有效时长 |
| **AUTH_JWT_SECRET** | JWT签名密钥 | 自动生成 | 用于签名Token,建议手动设置 |
**认证配置示例:**
```bash
# 启用认证并配置单个用户
docker run -d --name pansou -p 8888:8888 \
-e AUTH_ENABLED=true \
-e AUTH_USERS=admin:admin123 \
-e AUTH_TOKEN_EXPIRY=24 \
ghcr.io/fish2018/pansou:latest
# 配置多个用户
docker run -d --name pansou -p 8888:8888 \
-e AUTH_ENABLED=true \
-e AUTH_USERS=admin:pass123,user1:pass456,user2:pass789 \
ghcr.io/fish2018/pansou:latest
```
**认证API接口:**
- `POST /api/auth/login` - 用户登录,获取Token
- `POST /api/auth/verify` - 验证Token有效性
- `POST /api/auth/logout` - 退出登录(客户端删除Token)
**使用Token调用API:**
```bash
# 1. 登录获取Token
curl -X POST http://localhost:8888/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
# 响应:{"token":"eyJhbGc...","expires_at":1234567890,"username":"admin"}
# 2. 使用Token调用搜索API
curl -X POST http://localhost:8888/api/search \
-H "Authorization: Bearer eyJhbGc..." \
-H "Content-Type: application/json" \
-d '{"kw":"速度与激情"}'
```
#### 高级配置(默认值即可)
<details>
<summary>点击展开高级配置选项(通常不需要修改)</summary>
| 环境变量 | 描述 | 默认值 |
|----------|------|--------|
| CONCURRENCY | 并发搜索数 | 自动计算 |
| CACHE_TTL | 缓存有效期(分钟) | `60` |
| CACHE_MAX_SIZE | 最大缓存大小(MB) | `100` |
| PLUGIN_TIMEOUT | 插件超时时间(秒) | `30` |
| ASYNC_RESPONSE_TIMEOUT | 快速响应超时(秒) | `4` |
| ASYNC_LOG_ENABLED | 异步插件详细日志 | `true` |
| CACHE_PATH | 缓存文件路径 | `./cache` |
| SHARD_COUNT | 缓存分片数量 | `8` |
| CACHE_WRITE_STRATEGY | 缓存写入策略(immediate/hybrid) | `hybrid` |
| ENABLE_COMPRESSION | 是否启用压缩 | `false` |
| MIN_SIZE_TO_COMPRESS | 最小压缩阈值(字节) | `1024` |
| GC_PERCENT | Go GC触发百分比 | `50` |
| ASYNC_MAX_BACKGROUND_WORKERS | 最大后台工作者数量 | CPU核心数×5 |
| ASYNC_MAX_BACKGROUND_TASKS | 最大后台任务数量 | 工作者数×5 |
| ASYNC_CACHE_TTL_HOURS | 异步缓存有效期(小时) | `1` |
| ASYNC_PLUGIN_ENABLED | 异步插件是否启用 | `true` |
| HTTP_READ_TIMEOUT | HTTP读取超时(秒) | 自动计算 |
| HTTP_WRITE_TIMEOUT | HTTP写入超时(秒) | 自动计算 |
| HTTP_IDLE_TIMEOUT | HTTP空闲超时(秒) | `120` |
| HTTP_MAX_CONNS | HTTP最大连接数 | 自动计算 |
</details>
3. 构建
```linux
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -extldflags '-static'" -o pansou .
```
4. 运行
```bash
./pansou
```
### 其他配置参考
<details>
<summary>点击展开 supervisor 配置参考</summary>
```
[program:pansou]
environment=PORT=8888,CHANNELS="tgsearchers4,Aliyun_4K_Movies,bdbdndn11,yunpanx,bsbdbfjfjff,yp123pan,sbsbsnsqq,yunpanxunlei,tianyifc,BaiduCloudDisk,txtyzy,peccxinpd,gotopan,PanjClub,kkxlzy,baicaoZY,MCPH01,bdwpzhpd,ysxb48,jdjdn1111,yggpan,MCPH086,zaihuayun,Q66Share,Oscar_4Kmovies,ucwpzy,shareAliyun,alyp_1,dianyingshare,Quark_Movies,XiangxiuNBB,ydypzyfx,ucquark,xx123pan,yingshifenxiang123,zyfb123,tyypzhpd,tianyirigeng,cloudtianyi,hdhhd21,Lsp115,oneonefivewpfx,qixingzhenren,taoxgzy,Channel_Shares_115,tyysypzypd,vip115hot,wp123zy,yunpan139,yunpan189,yunpanuc,yydf_hzl,leoziyuan,pikpakpan,Q_dongman,yoyokuakeduanju",ENABLED_PLUGINS="labi,zhizhen,shandian,duoduo,muou"
command=/home/work/pansou/pansou
directory=/home/work/pansou
autostart=true
autorestart=true
startsecs=5
startretries=3
exitcodes=0
stopwaitsecs=10
stopasgroup=true
killasgroup=true
```
</details>
<details>
<summary>点击展开 nginx 配置参考</summary>
```
server {
listen 80;
server_name pansou.252035.xyz;
# 将 HTTP 重定向到 HTTPS
return 301 https://$host$request_uri;
}
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=60r/m;
server {
listen 443 ssl http2;
server_name pansou.252035.xyz;
access_log /home/work/logs/pansou.log;
# 证书和密钥路径
ssl_certificate /etc/letsencrypt/live/252035.xyz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/252035.xyz/privkey.pem;
# 增强 SSL 安全性
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH;
ssl_prefer_server_ciphers on;
# 后端代理,应用限流
location / {
# 应用限流规则
limit_req zone=api_limit burst=10 nodelay;
# 当超过限制时返回 429 状态码
limit_req_status 429;
proxy_pass http://127.0.0.1:8888;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
</details>
## API文档
### 认证说明
当启用认证功能(`AUTH_ENABLED=true`)时,除登录和健康检测接口外的所有API接口都需要提供有效的JWT Token。
**请求头格式**:
```
Authorization: Bearer <your-jwt-token>
```
**获取Token**:
1. 调用登录接口获取Token(详见下方[认证API](#认证API))
2. 在后续所有API请求的Header中添加`Authorization: Bearer <token>`
3. Token过期后需要重新登录获取新Token
**示例**:
```bash
# 未启用认证时
curl -X POST http://localhost:8888/api/search \
-H "Content-Type: application/json" \
-d '{"kw":"速度与激情"}'
# 启用认证时
curl -X POST http://localhost:8888/api/search \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJhbGc..." \
-d '{"kw":"速度与激情"}'
```
### 认证API
#### 用户登录
获取JWT Token用于后续API调用。
**接口地址**:`/api/auth/login`
**请求方法**:`POST`
**Content-Type**:`application/json`
**是否需要认证**:否
**请求参数**:
| 参数名 | 类型 | 必填 | 描述 |
|--------|------|------|------|
| username | string | 是 | 用户名 |
| password | string | 是 | 密码 |
**请求示例**:
```bash
curl -X POST http://localhost:8888/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
```
**成功响应**:
```json
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_at": 1234567890,
"username": "admin"
}
```
**错误响应**:
```json
{
"error": "用户名或密码错误"
}
```
#### 验证Token
验证当前Token是否有效。
**接口地址**:`/api/auth/verify`
**请求方法**:`POST`
**是否需要认证**:是
**请求示例**:
```bash
curl -X POST http://localhost:8888/api/auth/verify \
-H "Authorization: Bearer eyJhbGc..."
```
**成功响应**:
```json
{
"valid": true,
"username": "admin"
}
```
#### 退出登录
退出当前登录(客户端删除Token即可)。
**接口地址**:`/api/auth/logout`
**请求方法**:`POST`
**是否需要认证**:否
**请求示例**:
```bash
curl -X POST http://localhost:8888/api/auth/logout
```
**成功响应**:
```json
{
"message": "退出成功"
}
```
### 搜索API
搜索网盘资源。
**接口地址**:`/api/search`
**请求方法**:`POST` 或 `GET`
**Content-Type**:`application/json`(POST方法)
**是否需要认证**:取决于`AUTH_ENABLED`配置
**POST请求参数**:
| 参数名 | 类型 | 必填 | 描述 |
|--------|------|------|------|
| kw | string | 是 | 搜索关键词 |
| channels | string[] | 否 | 搜索的频道列表,不提供则使用默认配置 |
| conc | number | 否 | 并发搜索数量,不提供则自动设置为频道数+插件数+10 |
| refresh | boolean | 否 | 强制刷新,不使用缓存,便于调试和获取最新数据 |
| res | string | 否 | 结果类型:all(返回所有结果)、results(仅返回results)、merge(仅返回merged_by_type),默认为merge |
| src | string | 否 | 数据来源类型:all(默认,全部来源)、tg(仅Telegram)、plugin(仅插件) |
| plugins | string[] | 否 | 指定搜索的插件列表,不指定则搜索全部插件 |
| cloud_types | string[] | 否 | 指定返回的网盘类型列表,支持:baidu、aliyun、quark、guangya、tianyi、uc、mobile、115、pikpak、xunlei、123、magnet、ed2k,不指定则返回所有类型 |
| ext | object | 否 | 扩展参数,用于传递给插件的自定义参数,如{"title_en":"English Title", "is_all":true} |
| filter | object | 否 | 过滤配置,用于过滤返回结果。格式:{"include":["关键词1","关键词2"],"exclude":["排除词1","排除词2"]}。include为包含关键词列表(OR关系),exclude为排除关键词列表(OR关系) |
**GET请求参数**:
| 参数名 | 类型 | 必填 | 描述 |
|--------|------|------|------|
| kw | string | 是 | 搜索关键词 |
| channels | string | 否 | 搜索的频道列表,使用英文逗号分隔多个频道,不提供则使用默认配置 |
| conc | number | 否 | 并发搜索数量,不提供则自动设置为频道数+插件数+10 |
| refresh | boolean | 否 | 强制刷新,设置为"true"表示不使用缓存 |
| res | string | 否 | 结果类型:all(返回所有结果)、results(仅返回results)、merge(仅返回merged_by_type),默认为merge |
| src | string | 否 | 数据来源类型:all(默认,全部来源)、tg(仅Telegram)、plugin(仅插件) |
| plugins | string | 否 | 指定搜索的插件列表,使用英文逗号分隔多个插件名,不指定则搜索全部插件 |
| cloud_types | string | 否 | 指定返回的网盘类型列表,使用英文逗号分隔多个类型,支持:baidu、aliyun、quark、guangya、tianyi、uc、mobile、115、pikpak、xunlei、123、magnet、ed2k,不指定则返回所有类型 |
| ext | string | 否 | JSON格式的扩展参数,用于传递给插件的自定义参数,如{"title_en":"English Title", "is_all":true} |
| filter | string | 否 | JSON格式的过滤配置,用于过滤返回结果。格式:{"include":["关键词1","关键词2"],"exclude":["排除词1","排除词2"]} |
**POST请求示例**:
```bash
# 未启用认证
curl -X POST http://localhost:8888/api/search \
-H "Content-Type: application/json" \
-d '{
"kw": "速度与激情",
"channels": ["tgsearchers3", "xxx"],
"conc": 2,
"refresh": true,
"res": "merge",
"src": "all",
"plugins": ["jikepan"],
"cloud_types": ["baidu", "quark"],
"ext": {
"title_en": "Fast and Furious",
"is_all": true
}
}'
# 启用认证时(需要添加Authorization头)
curl -X POST http://localhost:8888/api/search \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
-d '{
"kw": "速度与激情",
"res": "merge"
}'
# 使用过滤器(只返回包含“合集”或“全集”,且不包含“预告”或“花絮”的结果)
curl -X POST http://localhost:8888/api/search \
-H "Content-Type: application/json" \
-d '{
"kw": "唐朝诡事录",
"filter": {
"include": ["合集", "全集"],
"exclude": ["预告", "花絮"]
}
}'
```
**GET请求示例**:
```bash
# 未启用认证
curl "http://localhost:8888/api/search?kw=速度与激情&res=merge&src=tg"
# 启用认证时(需要添加Authorization头)
curl "http://localhost:8888/api/search?kw=速度与激情&res=merge" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
# 使用过滤器(GET方式需要URL编码JSON)
curl "http://localhost:8888/api/search?kw=唐朝诡事录&filter=%7B%22include%22%3A%5B%22合集%22%2C%22全集%22%5D%2C%22exclude%22%3A%5B%22预告%22%5D%7D"
```
**成功响应**:
```json
{
"total": 15,
"results": [
{
"message_id": "12345",
"unique_id": "channel-12345",
"channel": "tgsearchers3",
"datetime": "2023-06-10T14:23:45Z",
"title": "速度与激情全集1-10",
"content": "速度与激情系列全集,1080P高清...",
"links": [
{
"type": "baidu",
"url": "https://pan.baidu.com/s/1abcdef",
"password": "1234",
"datetime": "2023-06-10T14:23:45Z",
"work_title": "速度与激情全集1-10"
}
],
"tags": ["电影", "合集"],
"images": [
"https://cdn1.cdn-telegram.org/file/xxx.jpg"
]
},
// 更多结果...
],
"merged_by_type": {
"baidu": [
{
"url": "https://pan.baidu.com/s/1abcdef",
"password": "1234",
"note": "速度与激情全集1-10",
"datetime": "2023-06-10T14:23:45Z",
"source": "tg:频道名称",
"images": [
"https://cdn1.cdn-telegram.org/file/xxx.jpg"
]
},
// 更多百度网盘链接...
],
"quark": [
{
"url": "https://pan.quark.cn/s/xxxx",
"password": "",
"note": "凡人修仙传",
"datetime": "2023-06-10T15:30:22Z",
"source": "plugin:插件名",
"images": []
}
],
"aliyun": [
// 阿里云盘链接...
]
// 更多网盘类型...
}
}
```
**字段说明**:
**SearchResult对象**:
- `message_id`: 消息ID
- `unique_id`: 全局唯一标识符
- `channel`: 来源频道名称
- `datetime`: 消息发布时间
- `title`: 消息标题
- `content`: 消息内容
- `links`: 网盘链接数组
- `tags`: 标签数组(可选)
- `images`: TG消息中的图片链接数组(可选)
**Link对象**:
- `type`: 网盘类型(baidu、quark、aliyun等)
- `url`: 网盘链接地址
- `password`: 提取码/密码
- `datetime`: 链接更新时间(可选)
- `work_title`: 作品标题(可选)
- 用于区分同一消息中多个作品的链接
- 当一条消息包含≤4个链接时,所有链接使用相同的work_title
- 当一条消息包含>4个链接时,系统会智能识别每个链接对应的作品标题
**MergedLink对象**:
- `url`: 网盘链接地址
- `password`: 提取码/密码
- `note`: 资源说明/标题
- `datetime`: 链接更新时间
- `source`: 数据来源标识
- `tg:频道名称`: 来自Telegram频道
- `plugin:插件名`: 来自指定插件
- `unknown`: 未知来源
- `images`: TG消息中的图片链接数组(可选)
- 仅在来源为Telegram频道且消息包含图片时出现
**错误响应**:
```json
// 参数错误
{
"code": 400,
"message": "关键词不能为空"
}
// 未授权(启用认证但未提供Token)
{
"error": "未授权:缺少认证令牌",
"code": "AUTH_TOKEN_MISSING"
}
// Token无效或过期
{
"error": "未授权:令牌无效或已过期",
"code": "AUTH_TOKEN_INVALID"
}
```
### 链接检测API
检测指定网盘分享链接当前是否有效,适合前端结果页按需做可见项检测,也支持批量调试和服务端缓存复用。
**接口地址**:`/api/check/links`
**请求方法**:`POST`
**Content-Type**:`application/json`
**是否需要认证**:取决于`AUTH_ENABLED`配置
**请求参数**:
| 参数名 | 类型 | 必填 | 描述 |
|--------|------|------|------|
| items | object[] | 是 | 待检测链接数组,至少提供一项 |
| items[].disk_type | string | 是 | 网盘类型,支持:baidu、aliyun、quark、tianyi、uc、mobile、115、xunlei、123 |
| items[].url | string | 是 | 完整分享链接 |
| items[].password | string | 否 | 提取码/密码,未拼接在链接中时可传 |
| proxy_url | string | 否 | 本次检测请求使用的代理地址,位于请求根节点,支持 `http://`、`https://`、`socks5://`、`socks5h://` |
| proxy | string | 否 | `proxy_url` 的兼容别名,位于请求根节点;同时传入时以 `proxy_url` 为准 |
| view_token | string | 否 | 视图标识,用于区分当前前端检测批次 |
**代理行为说明**:
- `proxy_url`/`proxy` 只影响当前 `/api/check/links` 请求,不会修改服务进程的全局代理配置。
- 单次批量检测共用同一个代理;如需轮换出口IP,可由调用方在不同请求中传入不同代理。
- 未传 `proxy_url`/`proxy` 时,检测服务沿用启动时的全局HTTP客户端配置。
- 使用代理时,检测缓存会按代理地址隔离,避免不同出口IP复用同一检测结果。
- 代理地址格式非法或协议不支持时返回 `400`,不会静默降级为直连。
**请求示例**:
```bash
# 未启用认证
curl -X POST http://localhost:8888/api/check/links \
-H "Content-Type: application/json" \
-d '{
"items": [
{
"disk_type": "quark",
"url": "https://pan.quark.cn/s/abcdefg",
"password": "1234"
},
{
"disk_type": "xunlei",
"url": "https://pan.xunlei.com/s/abcdefg?pwd=1234"
},
{
"disk_type": "115",
"url": "https://115cdn.com/s/abcdefg?password=1234"
}
],
"view_token": "quark-1710000000000"
}'
# 指定本次检测使用代理,避免固定出口IP频繁触发风控
curl -X POST http://localhost:8888/api/check/links \
-H "Content-Type: application/json" \
-d '{
"items": [
{
"disk_type": "quark",
"url": "https://pan.quark.cn/s/abcdefg"
}
],
"proxy_url": "socks5://127.0.0.1:1080"
}'
# 启用认证时
curl -X POST http://localhost:8888/api/check/links \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJhbGc..." \
-d '{
"items": [
{
"disk_type": "baidu",
"url": "https://pan.baidu.com/s/1abcdef?pwd=1234"
}
]
}'
```
**成功响应**:
```json
{
"results": [
{
"disk_type": "quark",
"url": "https://pan.quark.cn/s/abcdefg",
"normalized_url": "https://pan.quark.cn/s/abcdefg?pwd=1234",
"state": "ok",
"cache_hit": false,
"checked_at": 1710000000000,
"expires_at": 1710086400000,
"summary": "链接有效"
},
{
"disk_type": "xunlei",
"url": "https://pan.xunlei.com/s/abcdefg?pwd=1234",
"normalized_url": "https://pan.xunlei.com/s/abcdefg?pwd=1234",
"state": "bad",
"cache_hit": true,
"checked_at": 1710000100000,
"expires_at": 1710021700000,
"summary": "链接失效"
}
]
}
```
**状态说明**:
- `ok`:链接有效
- `bad`:链接失效
- `locked`:需要提取码或密码错误
- `unsupported`:当前平台暂不支持检测
- `uncertain`:检测失败或结果不确定
**字段说明**:
- `results`: 检测结果数组
- `results[].disk_type`: 网盘类型
- `results[].url`: 原始传入链接
- `results[].normalized_url`: 规范化后的链接
- `results[].state`: 检测状态
- `results[].cache_hit`: 是否命中服务端检测缓存
- `results[].checked_at`: 最近一次检测时间戳(毫秒)
- `results[].expires_at`: 当前缓存过期时间戳(毫秒)
- `results[].summary`: 状态说明文本
**错误响应**:
```json
// 请求参数无效
{
"code": 400,
"message": "无效的检测请求: Key: 'CheckRequest.Items' Error:Field validation for 'Items' failed on the 'required' tag"
}
// items 为空
{
"code": 400,
"message": "items不能为空"
}
// 代理参数无效
{
"code": 400,
"message": "无效的代理参数: 不支持的代理协议: ftp"
}
// 未授权(启用认证但未提供Token)
{
"error": "未授权:缺少认证令牌",
"code": "AUTH_TOKEN_MISSING"
}
```
### 健康检查
检查API服务是否正常运行。
**接口地址**:`/api/health`
**请求方法**:`GET`
**是否需要认证**:否(公开接口)
**请求示例**:
```bash
curl http://localhost:8888/api/health
```
**成功响应**:
```json
{
"status": "ok",
"auth_enabled": true,
"plugins_enabled": true,
"plugin_count": 16,
"plugins": [
"pansearch",
"panta",
"qupansou",
"hunhepan",
"jikepan",
"pan666",
"panyq",
"susu",
"xuexizhinan",
"hdr4k",
"labi",
"shandian",
"duoduo",
"muou",
"wanou",
"ouge",
"zhizhen",
"huban"
],
"channels_count": 1,
"channels": [
"tgsearchers3"
]
}
```
**字段说明**:
- `status`: 服务状态,"ok"表示正常
- `auth_enabled`: 是否启用认证功能
- `plugins_enabled`: 是否启用插件
- `plugin_count`: 已启用的插件数量
- `plugins`: 已启用的插件列表
- `channels_count`: 配置的频道数量
- `channels`: 配置的频道列表
## 📄 许可证
本项目采用 MIT 许可证。详情请见 [LICENSE](LICENSE) 文件。
## ⭐ Star 历史
[](https://star-history.com/#fish2018/pansou&Date)
## /api/auth_handler.go
```go path="/api/auth_handler.go"
package api
import (
"time"
"github.com/gin-gonic/gin"
"pansou/config"
"pansou/util"
)
// LoginRequest 登录请求结构
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// LoginResponse 登录响应结构
type LoginResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expires_at"`
Username string `json:"username"`
}
// LoginHandler 处理用户登录
func LoginHandler(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "参数错误:用户名和密码不能为空"})
return
}
// 验证认证系统是否启用
if !config.AppConfig.AuthEnabled {
c.JSON(403, gin.H{"error": "认证功能未启用"})
return
}
// 验证用户配置是否存在
if config.AppConfig.AuthUsers == nil || len(config.AppConfig.AuthUsers) == 0 {
c.JSON(500, gin.H{"error": "认证系统未正确配置"})
return
}
// 验证用户名和密码
storedPassword, exists := config.AppConfig.AuthUsers[req.Username]
if !exists || storedPassword != req.Password {
c.JSON(401, gin.H{"error": "用户名或密码错误"})
return
}
// 生成JWT token
token, err := util.GenerateToken(
req.Username,
config.AppConfig.AuthJWTSecret,
config.AppConfig.AuthTokenExpiry,
)
if err != nil {
c.JSON(500, gin.H{"error": "生成令牌失败"})
return
}
// 返回token和过期时间
expiresAt := time.Now().Add(config.AppConfig.AuthTokenExpiry).Unix()
c.JSON(200, LoginResponse{
Token: token,
ExpiresAt: expiresAt,
Username: req.Username,
})
}
// VerifyHandler 验证token有效性
func VerifyHandler(c *gin.Context) {
// 如果未启用认证,直接返回有效
if !config.AppConfig.AuthEnabled {
c.JSON(200, gin.H{
"valid": true,
"message": "认证功能未启用",
})
return
}
// 如果能到达这里,说明中间件已经验证通过
username, exists := c.Get("username")
if !exists {
c.JSON(401, gin.H{"error": "未授权"})
return
}
c.JSON(200, gin.H{
"valid": true,
"username": username,
})
}
// LogoutHandler 退出登录(客户端删除token即可)
func LogoutHandler(c *gin.Context) {
// JWT是无状态的,服务端不需要处理注销
// 客户端删除存储的token即可
c.JSON(200, gin.H{"message": "退出成功"})
}
```
## /api/check_handler.go
```go path="/api/check_handler.go"
package api
import (
"net/http"
"strings"
"sync"
"github.com/gin-gonic/gin"
"pansou/model"
"pansou/service"
)
var (
checkService *service.CheckService
checkServiceOnce sync.Once
)
func getCheckService() *service.CheckService {
checkServiceOnce.Do(func() {
checkService = service.NewCheckService()
})
return checkService
}
func CheckHandler(c *gin.Context) {
var req model.CheckRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(400, "无效的检测请求: "+err.Error()))
return
}
if len(req.Items) == 0 {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(400, "items不能为空"))
return
}
proxyURL := strings.TrimSpace(req.ProxyURL)
if proxyURL == "" {
proxyURL = strings.TrimSpace(req.Proxy)
}
response, err := getCheckService().CheckWithProxy(req.Items, proxyURL)
if err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(400, "无效的代理参数: "+err.Error()))
return
}
c.JSON(http.StatusOK, response)
}
```
## /api/filter.go
```go path="/api/filter.go"
package api
import (
"pansou/model"
"strings"
)
// applyResultFilter 应用过滤器到搜索响应
func applyResultFilter(response model.SearchResponse, filter *model.FilterConfig, resultType string) model.SearchResponse {
if filter == nil || (len(filter.Include) == 0 && len(filter.Exclude) == 0) {
return response
}
// 预处理关键词(转小写)
includeKeywords := make([]string, len(filter.Include))
for i, kw := range filter.Include {
includeKeywords[i] = strings.ToLower(kw)
}
excludeKeywords := make([]string, len(filter.Exclude))
for i, kw := range filter.Exclude {
excludeKeywords[i] = strings.ToLower(kw)
}
// 根据结果类型决定过滤策略
if resultType == "merged_by_type" || resultType == "" {
// 过滤 merged_by_type 的 note 字段
response.MergedByType = filterMergedByType(response.MergedByType, includeKeywords, excludeKeywords)
// 重新计算 total
total := 0
for _, links := range response.MergedByType {
total += len(links)
}
response.Total = total
} else if resultType == "all" || resultType == "results" {
// 过滤 results 的 title 和 links 的 work_title
response.Results = filterResults(response.Results, includeKeywords, excludeKeywords)
response.Total = len(response.Results)
// 如果是 all 类型,也需要过滤 merged_by_type
if resultType == "all" {
response.MergedByType = filterMergedByType(response.MergedByType, includeKeywords, excludeKeywords)
}
}
return response
}
// filterMergedByType 过滤 merged_by_type 中的链接
func filterMergedByType(mergedLinks model.MergedLinks, includeKeywords, excludeKeywords []string) model.MergedLinks {
if mergedLinks == nil {
return nil
}
filtered := make(model.MergedLinks)
for linkType, links := range mergedLinks {
filteredLinks := make([]model.MergedLink, 0)
for _, link := range links {
if matchFilter(link.Note, includeKeywords, excludeKeywords) {
filteredLinks = append(filteredLinks, link)
}
}
// 只添加非空的类型
if len(filteredLinks) > 0 {
filtered[linkType] = filteredLinks
}
}
return filtered
}
// filterResults 过滤 results 数组
func filterResults(results []model.SearchResult, includeKeywords, excludeKeywords []string) []model.SearchResult {
if results == nil {
return nil
}
filtered := make([]model.SearchResult, 0)
for _, result := range results {
// 先检查 title 是否匹配
if !matchFilter(result.Title, includeKeywords, excludeKeywords) {
continue
}
// title 匹配后,过滤 links 中的 work_title
filteredLinks := make([]model.Link, 0)
for _, link := range result.Links {
// 如果 link 有 work_title,检查它;否则使用 result.Title
checkText := link.WorkTitle
if checkText == "" {
checkText = result.Title
}
if matchFilter(checkText, includeKeywords, excludeKeywords) {
filteredLinks = append(filteredLinks, link)
}
}
// 只有有链接的结果才添加
if len(filteredLinks) > 0 {
result.Links = filteredLinks
filtered = append(filtered, result)
}
}
return filtered
}
// matchFilter 检查文本是否匹配过滤条件
func matchFilter(text string, includeKeywords, excludeKeywords []string) bool {
lowerText := strings.ToLower(text)
// 检查 exclude(任一匹配则排除)
for _, kw := range excludeKeywords {
if strings.Contains(lowerText, kw) {
return false
}
}
// 检查 include(如果有 include 列表,必须至少匹配一个)
if len(includeKeywords) > 0 {
matched := false
for _, kw := range includeKeywords {
if strings.Contains(lowerText, kw) {
matched = true
break
}
}
if !matched {
return false
}
}
return true
}
```
## /api/handler.go
```go path="/api/handler.go"
package api
import (
// "fmt"
"net/http"
// "os"
"github.com/gin-gonic/gin"
"pansou/config"
"pansou/model"
"pansou/service"
jsonutil "pansou/util/json"
"pansou/util"
"strings"
)
// 保存搜索服务的实例
var searchService *service.SearchService
// SetSearchService 设置搜索服务实例
func SetSearchService(service *service.SearchService) {
searchService = service
}
// SearchHandler 搜索处理函数
func SearchHandler(c *gin.Context) {
var req model.SearchRequest
var err error
// 根据请求方法不同处理参数
if c.Request.Method == http.MethodGet {
// GET方式:从URL参数获取
// 获取keyword,必填参数
keyword := c.Query("kw")
// 处理channels参数,支持逗号分隔
channelsStr := c.Query("channels")
var channels []string
// 只有当参数非空时才处理
if channelsStr != "" && channelsStr != " " {
parts := strings.Split(channelsStr, ",")
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
channels = append(channels, trimmed)
}
}
}
// 处理并发数
concurrency := 0
concStr := c.Query("conc")
if concStr != "" && concStr != " " {
concurrency = util.StringToInt(concStr)
}
// 处理强制刷新
forceRefresh := false
refreshStr := c.Query("refresh")
if refreshStr != "" && refreshStr != " " && refreshStr == "true" {
forceRefresh = true
}
// 处理结果类型和来源类型
resultType := c.Query("res")
if resultType == "" || resultType == " " {
resultType = "merge" // 直接设置为默认值merge
}
sourceType := c.Query("src")
if sourceType == "" || sourceType == " " {
sourceType = "all" // 直接设置为默认值all
}
// 处理plugins参数,支持逗号分隔
var plugins []string
// 检查请求中是否存在plugins参数
if c.Request.URL.Query().Has("plugins") {
pluginsStr := c.Query("plugins")
// 判断参数是否非空
if pluginsStr != "" && pluginsStr != " " {
parts := strings.Split(pluginsStr, ",")
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
plugins = append(plugins, trimmed)
}
}
}
} else {
// 如果请求中不存在plugins参数,设置为nil
plugins = nil
}
// 处理cloud_types参数,支持逗号分隔
var cloudTypes []string
// 检查请求中是否存在cloud_types参数
if c.Request.URL.Query().Has("cloud_types") {
cloudTypesStr := c.Query("cloud_types")
// 判断参数是否非空
if cloudTypesStr != "" && cloudTypesStr != " " {
parts := strings.Split(cloudTypesStr, ",")
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
cloudTypes = append(cloudTypes, trimmed)
}
}
}
} else {
// 如果请求中不存在cloud_types参数,设置为nil
cloudTypes = nil
}
// 处理ext参数,JSON格式
var ext map[string]interface{}
extStr := c.Query("ext")
if extStr != "" && extStr != " " {
// 处理特殊情况:ext={}
if extStr == "{}" {
ext = make(map[string]interface{})
} else {
if err := jsonutil.Unmarshal([]byte(extStr), &ext); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(400, "无效的ext参数格式: "+err.Error()))
return
}
}
}
// 确保ext不为nil
if ext == nil {
ext = make(map[string]interface{})
}
// 处理filter参数,JSON格式
var filter *model.FilterConfig
filterStr := c.Query("filter")
if filterStr != "" && filterStr != " " {
filter = &model.FilterConfig{}
if err := jsonutil.Unmarshal([]byte(filterStr), filter); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(400, "无效的filter参数格式: "+err.Error()))
return
}
}
req = model.SearchRequest{
Keyword: keyword,
Channels: channels,
Concurrency: concurrency,
ForceRefresh: forceRefresh,
ResultType: resultType,
SourceType: sourceType,
Plugins: plugins,
CloudTypes: cloudTypes, // 添加cloud_types到请求中
Ext: ext,
Filter: filter,
}
} else {
// POST方式:从请求体获取
data, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(400, "读取请求数据失败: "+err.Error()))
return
}
if err := jsonutil.Unmarshal(data, &req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(400, "无效的请求参数: "+err.Error()))
return
}
}
// 检查并设置默认值
if len(req.Channels) == 0 {
req.Channels = config.AppConfig.DefaultChannels
}
// 如果未指定结果类型,默认返回merge并转换为merged_by_type
if req.ResultType == "" {
req.ResultType = "merged_by_type"
} else if req.ResultType == "merge" {
// 将merge转换为merged_by_type,以兼容内部处理
req.ResultType = "merged_by_type"
}
// 如果未指定数据来源类型,默认为全部
if req.SourceType == "" {
req.SourceType = "all"
}
// 参数互斥逻辑:当src=tg时忽略plugins参数,当src=plugin时忽略channels参数
if req.SourceType == "tg" {
req.Plugins = nil // 忽略plugins参数
} else if req.SourceType == "plugin" {
req.Channels = nil // 忽略channels参数
} else if req.SourceType == "all" {
// 对于all类型,如果plugins为空或不存在,统一设为nil
if req.Plugins == nil || len(req.Plugins) == 0 {
req.Plugins = nil
}
}
// 可选:启用调试输出(生产环境建议注释掉)
// fmt.Printf("🔧 [调试] 搜索参数: keyword=%s, channels=%v, concurrency=%d, refresh=%v, resultType=%s, sourceType=%s, plugins=%v, cloudTypes=%v, ext=%v\n",
// req.Keyword, req.Channels, req.Concurrency, req.ForceRefresh, req.ResultType, req.SourceType, req.Plugins, req.CloudTypes, req.Ext)
// 执行搜索
result, err := searchService.Search(req.Keyword, req.Channels, req.Concurrency, req.ForceRefresh, req.ResultType, req.SourceType, req.Plugins, req.CloudTypes, req.Ext)
if err != nil {
response := model.NewErrorResponse(500, "搜索失败: "+err.Error())
jsonData, _ := jsonutil.Marshal(response)
c.Data(http.StatusInternalServerError, "application/json", jsonData)
return
}
// 应用过滤器
if req.Filter != nil {
result = applyResultFilter(result, req.Filter, req.ResultType)
}
// 包装SearchResponse到标准响应格式中
response := model.NewSuccessResponse(result)
jsonData, _ := jsonutil.Marshal(response)
c.Data(http.StatusOK, "application/json", jsonData)
}
```
## /api/middleware.go
```go path="/api/middleware.go"
package api
import (
"fmt"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"pansou/config"
"pansou/util"
)
// CORSMiddleware 跨域中间件
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
// LoggerMiddleware 日志中间件
func LoggerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 开始时间
startTime := time.Now()
// 处理请求
c.Next()
// 结束时间
endTime := time.Now()
// 执行时间
latencyTime := endTime.Sub(startTime)
// 请求方式
reqMethod := c.Request.Method
// 请求路由
reqURI := c.Request.RequestURI
// 对于搜索API,尝试解码关键词以便更好地显示
displayURI := reqURI
if strings.Contains(reqURI, "/api/search") && strings.Contains(reqURI, "kw=") {
if parsedURL, err := url.Parse(reqURI); err == nil {
if keyword := parsedURL.Query().Get("kw"); keyword != "" {
if decodedKeyword, err := url.QueryUnescape(keyword); err == nil {
// 替换原始URI中的编码关键词为解码后的关键词
displayURI = strings.Replace(reqURI, "kw="+keyword, "kw="+decodedKeyword, 1)
}
}
}
}
// 状态码
statusCode := c.Writer.Status()
// 请求IP
clientIP := c.ClientIP()
// 日志格式
gin.DefaultWriter.Write([]byte(
fmt.Sprintf("| %s | %s | %s | %d | %s\n",
clientIP, reqMethod, displayURI, statusCode, latencyTime.String())))
}
}
// AuthMiddleware JWT认证中间件
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 如果未启用认证,直接放行
if !config.AppConfig.AuthEnabled {
c.Next()
return
}
// 定义公开接口(不需要认证)
publicPaths := []string{
"/api/auth/login",
"/api/auth/logout",
"/api/health", // 健康检查接口可选择是否需要认证
}
// 检查当前路径是否是公开接口
path := c.Request.URL.Path
for _, p := range publicPaths {
if strings.HasPrefix(path, p) {
c.Next()
return
}
}
// 获取Authorization头
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(401, gin.H{
"error": "未授权:缺少认证令牌",
"code": "AUTH_TOKEN_MISSING",
})
c.Abort()
return
}
// 解析Bearer token
const bearerPrefix = "Bearer "
if !strings.HasPrefix(authHeader, bearerPrefix) {
c.JSON(401, gin.H{
"error": "未授权:令牌格式错误",
"code": "AUTH_TOKEN_INVALID_FORMAT",
})
c.Abort()
return
}
tokenString := strings.TrimPrefix(authHeader, bearerPrefix)
// 验证token
claims, err := util.ValidateToken(tokenString, config.AppConfig.AuthJWTSecret)
if err != nil {
c.JSON(401, gin.H{
"error": "未授权:令牌无效或已过期",
"code": "AUTH_TOKEN_INVALID",
})
c.Abort()
return
}
// 将用户信息存入上下文,供后续处理使用
c.Set("username", claims.Username)
c.Next()
}
}
```
## /api/router.go
```go path="/api/router.go"
package api
import (
"github.com/gin-gonic/gin"
"pansou/config"
"pansou/plugin"
"pansou/service"
"pansou/util"
)
// SetupRouter 设置路由
func SetupRouter(searchService *service.SearchService) *gin.Engine {
// 设置搜索服务
SetSearchService(searchService)
// 设置为生产模式
gin.SetMode(gin.ReleaseMode)
// 创建默认路由
r := gin.Default()
// 添加中间件
r.Use(CORSMiddleware())
r.Use(LoggerMiddleware())
r.Use(util.GzipMiddleware()) // 添加压缩中间件
r.Use(AuthMiddleware()) // 添加认证中间件
// 定义API路由组
api := r.Group("/api")
{
// 认证接口(不需要认证,由中间件公开路径处理)
auth := api.Group("/auth")
{
auth.POST("/login", LoginHandler)
auth.POST("/verify", VerifyHandler)
auth.POST("/logout", LogoutHandler)
}
// 搜索接口 - 支持POST和GET两种方式
api.POST("/search", SearchHandler)
api.GET("/search", SearchHandler) // 添加GET方式支持
api.POST("/check/links", CheckHandler)
// 健康检查接口
api.GET("/health", func(c *gin.Context) {
// 根据配置决定是否返回插件信息
pluginCount := 0
pluginNames := []string{}
pluginsEnabled := config.AppConfig.AsyncPluginEnabled
if pluginsEnabled && searchService != nil && searchService.GetPluginManager() != nil {
plugins := searchService.GetPluginManager().GetPlugins()
pluginCount = len(plugins)
for _, p := range plugins {
pluginNames = append(pluginNames, p.Name())
}
}
// 获取频道信息
channels := config.AppConfig.DefaultChannels
channelsCount := len(channels)
response := gin.H{
"status": "ok",
"auth_enabled": config.AppConfig.AuthEnabled, // 添加认证状态
"plugins_enabled": pluginsEnabled,
"channels": channels,
"channels_count": channelsCount,
}
// 只有当插件启用时才返回插件相关信息
if pluginsEnabled {
response["plugin_count"] = pluginCount
response["plugins"] = pluginNames
}
c.JSON(200, response)
})
}
// 注册插件的Web路由(如果插件实现了PluginWithWebHandler接口)
// 只有当插件功能启用且插件在启用列表中时才注册路由
if config.AppConfig.AsyncPluginEnabled && searchService != nil && searchService.GetPluginManager() != nil {
enabledPlugins := searchService.GetPluginManager().GetPlugins()
for _, p := range enabledPlugins {
if webPlugin, ok := p.(plugin.PluginWithWebHandler); ok {
webPlugin.RegisterWebRoutes(r.Group(""))
}
}
}
return r
}
```
## /config/config.go
```go path="/config/config.go"
package config
import (
"os"
"path/filepath"
"runtime"
"runtime/debug"
"strconv"
"strings"
"time"
)
// Config 应用配置结构
type Config struct {
DefaultChannels []string
DefaultConcurrency int
Port string
ProxyURL string
UseProxy bool
HTTPProxyURL string
HTTPSProxyURL string
// 缓存相关配置
CacheEnabled bool
CachePath string
CacheMaxSizeMB int
CacheTTLMinutes int
// 压缩相关配置
EnableCompression bool
MinSizeToCompress int // 最小压缩大小(字节)
// GC相关配置
GCPercent int // GC触发阈值百分比
OptimizeMemory bool // 是否启用内存优化
// 插件相关配置
PluginTimeoutSeconds int // 插件超时时间(秒)
PluginTimeout time.Duration // 插件超时时间(Duration)
// 异步插件相关配置
AsyncPluginEnabled bool // 是否启用异步插件
EnabledPlugins []string // 启用的具体插件列表(空表示启用所有)
AsyncResponseTimeout int // 响应超时时间(秒)
AsyncResponseTimeoutDur time.Duration // 响应超时时间(Duration)
AsyncMaxBackgroundWorkers int // 最大后台工作者数量
AsyncMaxBackgroundTasks int // 最大后台任务数量
AsyncCacheTTLHours int // 异步缓存有效期(小时)
AsyncLogEnabled bool // 是否启用异步插件详细日志
// HTTP服务器配置
HTTPReadTimeout time.Duration // 读取超时
HTTPWriteTimeout time.Duration // 写入超时
HTTPIdleTimeout time.Duration // 空闲超时
HTTPMaxConns int // 最大连接数
// 认证相关配置
AuthEnabled bool // 是否启用认证
AuthUsers map[string]string // 用户名:密码映射
AuthTokenExpiry time.Duration // Token有效期
AuthJWTSecret string // JWT签名密钥
}
// 全局配置实例
var AppConfig *Config
// 初始化配置
func Init() {
proxyURL := getProxyURL()
pluginTimeoutSeconds := getPluginTimeout()
asyncResponseTimeoutSeconds := getAsyncResponseTimeout()
AppConfig = &Config{
DefaultChannels: getDefaultChannels(),
DefaultConcurrency: getDefaultConcurrency(),
Port: getPort(),
ProxyURL: proxyURL,
UseProxy: proxyURL != "",
HTTPProxyURL: getHTTPProxyURL(),
HTTPSProxyURL: getHTTPSProxyURL(),
// 缓存相关配置
CacheEnabled: getCacheEnabled(),
CachePath: getCachePath(),
CacheMaxSizeMB: getCacheMaxSize(),
CacheTTLMinutes: getCacheTTL(),
// 压缩相关配置
EnableCompression: getEnableCompression(),
MinSizeToCompress: getMinSizeToCompress(),
// GC相关配置
GCPercent: getGCPercent(),
OptimizeMemory: getOptimizeMemory(),
// 插件相关配置
PluginTimeoutSeconds: pluginTimeoutSeconds,
PluginTimeout: time.Duration(pluginTimeoutSeconds) * time.Second,
// 异步插件相关配置
AsyncPluginEnabled: getAsyncPluginEnabled(),
EnabledPlugins: getEnabledPlugins(),
AsyncResponseTimeout: asyncResponseTimeoutSeconds,
AsyncResponseTimeoutDur: time.Duration(asyncResponseTimeoutSeconds) * time.Second,
AsyncMaxBackgroundWorkers: getAsyncMaxBackgroundWorkers(),
AsyncMaxBackgroundTasks: getAsyncMaxBackgroundTasks(),
AsyncCacheTTLHours: getAsyncCacheTTLHours(),
AsyncLogEnabled: getAsyncLogEnabled(),
// HTTP服务器配置
HTTPReadTimeout: getHTTPReadTimeout(),
HTTPWriteTimeout: getHTTPWriteTimeout(),
HTTPIdleTimeout: getHTTPIdleTimeout(),
HTTPMaxConns: getHTTPMaxConns(),
// 认证相关配置
AuthEnabled: getAuthEnabled(),
AuthUsers: getAuthUsers(),
AuthTokenExpiry: getAuthTokenExpiry(),
AuthJWTSecret: getAuthJWTSecret(),
}
// 应用GC配置
applyGCSettings()
}
// 从环境变量获取默认频道列表,如果未设置则使用默认值
func getDefaultChannels() []string {
channelsEnv := os.Getenv("CHANNELS")
if channelsEnv == "" {
return []string{"tgsearchers6"}
}
return strings.Split(channelsEnv, ",")
}
// 从环境变量获取默认并发数,如果未设置则使用基于环境变量的简单计算
func getDefaultConcurrency() int {
concurrencyEnv := os.Getenv("CONCURRENCY")
if concurrencyEnv != "" {
concurrency, err := strconv.Atoi(concurrencyEnv)
if err == nil && concurrency > 0 {
return concurrency
}
}
// 环境变量未设置或无效,使用基于环境变量的简单计算
// 计算频道数
channelCount := len(getDefaultChannels())
// 估计插件数(从环境变量或默认值,实际在应用启动后会根据真实插件数调整)
pluginCountEnv := os.Getenv("PLUGIN_COUNT")
pluginCount := 0
if pluginCountEnv != "" {
count, err := strconv.Atoi(pluginCountEnv)
if err == nil && count > 0 {
pluginCount = count
}
}
// 如果没有指定插件数,默认使用7个(当前已知的插件数)
if pluginCount == 0 {
pluginCount = 7
}
// 计算并发数 = 频道数 + 插件数 + 10
concurrency := channelCount + pluginCount + 10
if concurrency < 1 {
concurrency = 1 // 确保至少为1
}
return concurrency
}
// 更新默认并发数(根据实际插件数或0调用)
// pluginCount: 如果插件被禁用则为0,否则为实际插件数
func UpdateDefaultConcurrency(pluginCount int) {
if AppConfig == nil {
return
}
// 只有当未通过环境变量指定并发数时才进行调整
concurrencyEnv := os.Getenv("CONCURRENCY")
if concurrencyEnv != "" {
return
}
// 计算频道数
channelCount := len(AppConfig.DefaultChannels)
// 计算并发数 = 频道数 + 插件数(插件禁用时为0)+ 10
concurrency := channelCount + pluginCount + 10
if concurrency < 1 {
concurrency = 1 // 确保至少为1
}
// 更新配置
AppConfig.DefaultConcurrency = concurrency
}
// 从环境变量获取服务端口,如果未设置则使用默认值
func getPort() string {
port := os.Getenv("PORT")
if port == "" {
return "8888"
}
return port
}
func getProxyURL() string {
return os.Getenv("PROXY")
}
func getHTTPProxyURL() string {
if proxyURL := os.Getenv("HTTP_PROXY"); proxyURL != "" {
return proxyURL
}
return os.Getenv("http_proxy")
}
func getHTTPSProxyURL() string {
if proxyURL := os.Getenv("HTTPS_PROXY"); proxyURL != "" {
return proxyURL
}
return os.Getenv("https_proxy")
}
// 从环境变量获取是否启用缓存,如果未设置则默认启用
func getCacheEnabled() bool {
enabled := os.Getenv("CACHE_ENABLED")
if enabled == "" {
return true
}
return enabled != "false" && enabled != "0"
}
// 从环境变量获取缓存路径,如果未设置则使用默认路径
func getCachePath() string {
path := os.Getenv("CACHE_PATH")
if path == "" {
// 默认在当前目录下创建cache文件夹
defaultPath, err := filepath.Abs("./cache")
if err != nil {
return "./cache"
}
return defaultPath
}
return path
}
// 从环境变量获取缓存最大大小(MB),如果未设置则使用默认值
func getCacheMaxSize() int {
sizeEnv := os.Getenv("CACHE_MAX_SIZE")
if sizeEnv == "" {
return 100 // 默认100MB
}
size, err := strconv.Atoi(sizeEnv)
if err != nil || size <= 0 {
return 100
}
return size
}
// 从环境变量获取缓存TTL(分钟),如果未设置则使用默认值
func getCacheTTL() int {
ttlEnv := os.Getenv("CACHE_TTL")
if ttlEnv == "" {
return 60 // 默认60分钟
}
ttl, err := strconv.Atoi(ttlEnv)
if err != nil || ttl <= 0 {
return 60
}
return ttl
}
// 从环境变量获取是否启用压缩,如果未设置则默认禁用
func getEnableCompression() bool {
enabled := os.Getenv("ENABLE_COMPRESSION")
if enabled == "" {
return false // 默认禁用,因为通常由Nginx等处理
}
return enabled == "true" || enabled == "1"
}
// 从环境变量获取最小压缩大小,如果未设置则使用默认值
func getMinSizeToCompress() int {
sizeEnv := os.Getenv("MIN_SIZE_TO_COMPRESS")
if sizeEnv == "" {
return 1024 // 默认1KB
}
size, err := strconv.Atoi(sizeEnv)
if err != nil || size <= 0 {
return 1024
}
return size
}
// 从环境变量获取GC百分比,如果未设置则使用默认值
func getGCPercent() int {
percentEnv := os.Getenv("GC_PERCENT")
if percentEnv == "" {
return 50 // 默认50% - 优化内存管理,更频繁的GC避免内存暴涨
}
percent, err := strconv.Atoi(percentEnv)
if err != nil || percent <= 0 {
return 50 // 错误时也使用优化后的默认值
}
return percent
}
// 从环境变量获取是否优化内存,如果未设置则默认启用
func getOptimizeMemory() bool {
enabled := os.Getenv("OPTIMIZE_MEMORY")
if enabled == "" {
return true // 默认启用
}
return enabled != "false" && enabled != "0"
}
// 从环境变量获取插件超时时间(秒),如果未设置则使用默认值
func getPluginTimeout() int {
timeoutEnv := os.Getenv("PLUGIN_TIMEOUT")
if timeoutEnv == "" {
return 30 // 默认30秒
}
timeout, err := strconv.Atoi(timeoutEnv)
if err != nil || timeout <= 0 {
return 30
}
return timeout
}
// 从环境变量获取是否启用异步插件,如果未设置则默认启用
func getAsyncPluginEnabled() bool {
enabled := os.Getenv("ASYNC_PLUGIN_ENABLED")
if enabled == "" {
return true // 默认启用
}
return enabled != "false" && enabled != "0"
}
// 从环境变量获取启用的插件列表
// 返回nil表示未设置环境变量(不启用任何插件)
// 返回[]string{}表示设置为空(不启用任何插件)
// 返回具体列表表示启用指定插件
func getEnabledPlugins() []string {
plugins, exists := os.LookupEnv("ENABLED_PLUGINS")
if !exists {
// 未设置环境变量时返回nil,表示不启用任何插件
return nil
}
if plugins == "" {
// 设置为空字符串,也表示不启用任何插件
return []string{}
}
// 按逗号分割插件名
result := make([]string, 0)
for _, plugin := range strings.Split(plugins, ",") {
plugin = strings.TrimSpace(plugin)
if plugin != "" {
result = append(result, plugin)
}
}
return result
}
// 从环境变量获取异步响应超时时间(秒),如果未设置则使用默认值
func getAsyncResponseTimeout() int {
timeoutEnv := os.Getenv("ASYNC_RESPONSE_TIMEOUT")
if timeoutEnv == "" {
return 4 // 默认4秒
}
timeout, err := strconv.Atoi(timeoutEnv)
if err != nil || timeout <= 0 {
return 4
}
return timeout
}
// 从环境变量获取最大后台工作者数量,如果未设置则自动计算
func getAsyncMaxBackgroundWorkers() int {
sizeEnv := os.Getenv("ASYNC_MAX_BACKGROUND_WORKERS")
if sizeEnv != "" {
size, err := strconv.Atoi(sizeEnv)
if err == nil && size > 0 {
return size
}
}
// 自动计算:根据CPU核心数计算
// 每个CPU核心分配5个工作者,最小20个
cpuCount := runtime.NumCPU()
workers := cpuCount * 5
// 确保至少有20个工作者
if workers < 20 {
workers = 20
}
return workers
}
// 从环境变量获取最大后台任务数量,如果未设置则自动计算
func getAsyncMaxBackgroundTasks() int {
sizeEnv := os.Getenv("ASYNC_MAX_BACKGROUND_TASKS")
if sizeEnv != "" {
size, err := strconv.Atoi(sizeEnv)
if err == nil && size > 0 {
return size
}
}
// 自动计算:工作者数量的5倍,最小100个
workers := getAsyncMaxBackgroundWorkers()
tasks := workers * 5
// 确保至少有100个任务
if tasks < 100 {
tasks = 100
}
return tasks
}
// 从环境变量获取异步缓存有效期(小时),如果未设置则使用默认值
func getAsyncCacheTTLHours() int {
ttlEnv := os.Getenv("ASYNC_CACHE_TTL_HOURS")
if ttlEnv == "" {
return 1 // 默认1小时
}
ttl, err := strconv.Atoi(ttlEnv)
if err != nil || ttl <= 0 {
return 1
}
return ttl
}
// 从环境变量获取HTTP读取超时,如果未设置则自动计算
func getHTTPReadTimeout() time.Duration {
timeoutEnv := os.Getenv("HTTP_READ_TIMEOUT")
if timeoutEnv != "" {
timeout, err := strconv.Atoi(timeoutEnv)
if err == nil && timeout > 0 {
return time.Duration(timeout) * time.Second
}
}
// 自动计算:默认30秒,异步模式下根据异步响应超时调整
timeout := 30 * time.Second
// 如果启用了异步插件,确保读取超时足够长
if getAsyncPluginEnabled() {
// 读取超时应该至少是异步响应超时的3倍,确保有足够时间完成异步操作
asyncTimeoutSecs := getAsyncResponseTimeout()
asyncTimeoutExtended := time.Duration(asyncTimeoutSecs * 3) * time.Second
if asyncTimeoutExtended > timeout {
timeout = asyncTimeoutExtended
}
}
return timeout
}
// 从环境变量获取HTTP写入超时,如果未设置则自动计算
func getHTTPWriteTimeout() time.Duration {
timeoutEnv := os.Getenv("HTTP_WRITE_TIMEOUT")
if timeoutEnv != "" {
timeout, err := strconv.Atoi(timeoutEnv)
if err == nil && timeout > 0 {
return time.Duration(timeout) * time.Second
}
}
// 自动计算:默认60秒,但根据插件超时和异步处理时间调整
timeout := 60 * time.Second
// 如果启用了异步插件,确保写入超时足够长
pluginTimeoutSecs := getPluginTimeout()
// 计算1.5倍的插件超时时间(使用整数运算:乘以3再除以2)
pluginTimeoutExtended := time.Duration(pluginTimeoutSecs * 3 / 2) * time.Second
if pluginTimeoutExtended > timeout {
timeout = pluginTimeoutExtended
}
return timeout
}
// 从环境变量获取HTTP空闲超时,如果未设置则自动计算
func getHTTPIdleTimeout() time.Duration {
timeoutEnv := os.Getenv("HTTP_IDLE_TIMEOUT")
if timeoutEnv != "" {
timeout, err := strconv.Atoi(timeoutEnv)
if err == nil && timeout > 0 {
return time.Duration(timeout) * time.Second
}
}
// 自动计算:默认120秒,考虑到保持连接的效益
return 120 * time.Second
}
// 从环境变量获取HTTP最大连接数,如果未设置则自动计算
func getHTTPMaxConns() int {
maxConnsEnv := os.Getenv("HTTP_MAX_CONNS")
if maxConnsEnv != "" {
maxConns, err := strconv.Atoi(maxConnsEnv)
if err == nil && maxConns > 0 {
return maxConns
}
}
// 自动计算:根据CPU核心数计算
// 每个CPU核心分配200个连接,最小1000个
cpuCount := runtime.NumCPU()
maxConns := cpuCount * 200
// 确保至少有1000个连接
if maxConns < 1000 {
maxConns = 1000
}
return maxConns
}
// 从环境变量获取异步插件日志开关,如果未设置则使用默认值
func getAsyncLogEnabled() bool {
logEnv := os.Getenv("ASYNC_LOG_ENABLED")
if logEnv == "" {
return true // 默认启用日志
}
enabled, err := strconv.ParseBool(logEnv)
if err != nil {
return true // 解析失败时默认启用
}
return enabled
}
// 从环境变量获取认证开关,如果未设置则默认关闭
func getAuthEnabled() bool {
enabled := os.Getenv("AUTH_ENABLED")
return enabled == "true" || enabled == "1"
}
// 从环境变量获取用户配置,格式:user1:pass1,user2:pass2
func getAuthUsers() map[string]string {
usersEnv := os.Getenv("AUTH_USERS")
if usersEnv == "" {
return nil
}
users := make(map[string]string)
pairs := strings.Split(usersEnv, ",")
for _, pair := range pairs {
parts := strings.SplitN(pair, ":", 2)
if len(parts) == 2 {
username := strings.TrimSpace(parts[0])
password := strings.TrimSpace(parts[1])
if username != "" && password != "" {
users[username] = password
}
}
}
return users
}
// 从环境变量获取Token有效期(小时),如果未设置则使用默认值
func getAuthTokenExpiry() time.Duration {
expiryEnv := os.Getenv("AUTH_TOKEN_EXPIRY")
if expiryEnv == "" {
return 24 * time.Hour // 默认24小时
}
expiry, err := strconv.Atoi(expiryEnv)
if err != nil || expiry <= 0 {
return 24 * time.Hour
}
return time.Duration(expiry) * time.Hour
}
// 从环境变量获取JWT密钥,如果未设置则生成随机密钥
func getAuthJWTSecret() string {
secret := os.Getenv("AUTH_JWT_SECRET")
if secret == "" {
// 生成随机密钥(32字节)
import_crypto := "crypto/rand"
import_encoding := "encoding/base64"
_ = import_crypto
_ = import_encoding
// 注意:实际使用时应该使用crypto/rand生成随机密钥
// 这里为了简化,使用时间戳作为临时密钥
secret = "pansou-default-secret-" + strconv.FormatInt(time.Now().Unix(), 10)
}
return secret
}
// 应用GC设置
func applyGCSettings() {
// 设置GC百分比
debug.SetGCPercent(AppConfig.GCPercent)
// 如果启用内存优化
if AppConfig.OptimizeMemory {
// 释放操作系统内存
debug.FreeOSMemory()
}
}
```
## /docker-compose.yml
```yml path="/docker-compose.yml"
version: '3.8'
services:
pansou:
image: ghcr.io/fish2018/pansou:latest
container_name: pansou
restart: unless-stopped
ports:
- "8888:8888"
environment:
- PORT=8888
- CHANNELS=tgsearchers6,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,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,Q_dianying,domgmingapk,dianying4k,q_dianshiju,tgbokee,ucshare,godupan,gokuapan,gimy115,WFYSFX03,peccxin,Movie888035,xlwpzy,zyywpzy,wydwpzy,gimy100,ucshare,gimy115iso
# 必须指定启用的插件,多个插件用逗号分隔
- ENABLED_PLUGINS=hunhepan,jikepan,panwiki,pansearch,panta,qupansou,hdr4k,pan666,susu,thepiratebay,wanou,xuexizhinan,panyq,zhizhen,labi,muou,ouge,shandian,duoduo,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,weibo,feikuai,kkmao,alupan,ypfxw,mikuclub,daishudj,dyyj,meitizy,jsnoteclub,mizixing,lou1,yiove,zxzj,qingying,kkv,yulinshufa
- CACHE_ENABLED=true
- CACHE_PATH=/app/cache
- CACHE_MAX_SIZE=100
- CACHE_TTL=60
- ASYNC_PLUGIN_ENABLED=true
- ASYNC_RESPONSE_TIMEOUT=4
- ASYNC_MAX_BACKGROUND_WORKERS=20
- ASYNC_MAX_BACKGROUND_TASKS=100
- ASYNC_CACHE_TTL_HOURS=1
# 认证配置(可选)
# - AUTH_ENABLED=true
# - AUTH_USERS=admin:admin123,user:pass456
# - AUTH_TOKEN_EXPIRY=24
# - AUTH_JWT_SECRET=your-secret-key-here
# 如果需要代理,取消下面的注释并设置代理地址
# - PROXY=socks5://proxy:7897
volumes:
- pansou-cache:/app/cache
networks:
- pansou-network
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8888/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# 如果需要代理,取消下面的注释
# proxy:
# image: ghcr.io/snail007/goproxy:latest
# container_name: pansou-proxy
# restart: unless-stopped
# command: /proxy socks -p :7897
# networks:
# - pansou-network
volumes:
pansou-cache:
name: pansou-cache
networks:
pansou-network:
name: pansou-network
```
## /docs/æÂÂä»¶å¼ÂÃ¥ÂÂæÂÂÃ¥ÂÂ.md
# PanSou 插件开发指南
## 概述
PanSou 采用异步插件架构,支持通过插件扩展搜索来源。插件系统基于 Go 接口设计,提供高性能的并发搜索能力和智能缓存机制。
## 系统架构
### 核心组件
- **插件管理器 (PluginManager)**: 管理所有插件的注册和调度
- **异步插件 (AsyncSearchPlugin)**: 实现异步搜索接口的插件
- **基础插件 (BaseAsyncPlugin)**: 提供通用功能的基础结构
- **工作池**: 管理并发请求和资源限制
- **缓存系统**: 二级缓存提供高性能数据存储
### 异步处理机制
1. **双级超时控制**:
- 短超时 (4秒): 确保快速响应用户
- 长超时 (30秒): 允许完整数据处理
2. **渐进式结果返回**:
- `isFinal=false`: 部分结果,继续后台处理
- `isFinal=true`: 完整结果,停止处理
3. **智能缓存更新**:
- 实时更新主缓存 (内存+磁盘)
- 结果合并去重
- 用户无感知数据更新
## 插件接口规范
### AsyncSearchPlugin 接口
```go
type AsyncSearchPlugin interface {
// Name 返回插件名称 (必须唯一)
Name() string
// Priority 返回插件优先级 (1-4,数字越小优先级越高,影响搜索结果排序)
Priority() int
// AsyncSearch 异步搜索方法 (核心方法)
AsyncSearch(keyword string, searchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error), mainCacheKey string, ext map[string]interface{}) ([]model.SearchResult, error)
// SetMainCacheKey 设置主缓存键 (由系统调用)
SetMainCacheKey(key string)
// SetCurrentKeyword 设置当前搜索关键词 (用于日志显示)
SetCurrentKeyword(keyword string)
// Search 同步搜索方法 (兼容性方法)
Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error)
// SkipServiceFilter 返回是否跳过Service层的关键词过滤 (新增功能)
// 对于磁力搜索等需要宽泛结果的插件,应返回true
SkipServiceFilter() bool
}
```
### 参数说明
- **keyword**: 搜索关键词
- **searchFunc**: HTTP搜索函数,处理实际的网络请求
- **mainCacheKey**: 主缓存键,用于缓存管理
- **ext**: 扩展参数,支持自定义搜索选项
### Service层过滤控制 (新功能)
PanSou支持插件级别的Service层过滤控制,允许插件自主决定是否在Service层进行关键词过滤:
#### 过滤机制说明
1. **插件层过滤**: 在插件内部使用 `FilterResultsByKeyword()` 进行精确过滤
2. **Service层过滤**: 在 `search_service.go` 的 `mergeResultsByType()` 中进行二次过滤
3. **双层过滤问题**: 某些插件(如磁力搜索)需要更宽泛的搜索结果,二次过滤会误删有效结果
#### 适用场景
**应该跳过Service层过滤的插件类型**:
- ✅ **磁力搜索插件**: 如 thepiratebay,标题格式特殊(点号分隔),需要宽泛匹配
- ✅ **国外资源插件**: 英文资源标题与中文关键词匹配度低
- ✅ **特殊格式插件**: 标题包含大量符号或编码,标准过滤可能失效
- ✅ **聚合搜索插件**: 需要保留所有相关结果供用户筛选
**应该保持Service层过滤的插件类型**:
- ⚠️ **网盘搜索插件**: 标准中文资源,过滤有助于提高精确度
- ⚠️ **API接口插件**: 结构化数据,关键词匹配准确
- ⚠️ **论坛爬取插件**: 标题格式标准,过滤效果良好
## 插件优先级系统
### 优先级等级
PanSou 采用4级插件优先级系统,直接影响搜索结果的排序权重:
| 等级 | 得分 | 适用场景 | 示例插件 |
|------|------|----------|----------|
| **等级1** | **1000分** | 高质量、稳定可靠的数据源 | panta, zhizhen, labi |
| **等级2** | **500分** | 质量良好、响应稳定的数据源 | huban, shandian, duoduo |
| **等级3** | **0分** | 普通质量的数据源 | pansearch, hunhepan, pan666 |
| **等级4** | **-200分** | 质量较低或不稳定的数据源 | - |
### 排序算法影响
插件优先级在PanSou的多维度排序算法中占据主导地位:
```
总得分 = 插件得分(1000/500/0/-200) + 时间得分(最高500) + 关键词得分(最高420)
```
**权重分配**:
- 🥇 **插件等级**: ~52% (主导因素)
- 🥈 **关键词匹配**: ~22% (重要因素)
- 🥉 **时间新鲜度**: ~26% (重要因素)
**实际效果**:
- 等级1插件的结果通常排在前列
- 即使是较旧的等级1插件结果,也会优于新的等级3插件结果
- 包含优先关键词的等级2插件可能超越等级1插件
### 如何选择优先级
在开发新插件时,应根据以下标准选择合适的优先级:
#### 选择等级1的条件
- ✅ 数据源质量极高,很少出现无效链接
- ✅ 服务稳定性好,响应时间短
- ✅ 数据更新频率高,内容新颖
- ✅ 链接有效性高(>90%)
#### 选择等级2的条件
- ✅ 数据源质量良好,偶有无效链接
- ✅ 服务相对稳定,响应时间适中
- ✅ 数据更新较为及时
- ✅ 链接有效性中等(70-90%)
#### 选择等级3的条件
- ⚠️ 数据源质量一般,存在一定比例无效链接
- ⚠️ 服务稳定性一般,可能偶有超时
- ⚠️ 数据更新不够及时
- ⚠️ 链接有效性较低(50-70%)
#### 选择等级4的条件
- ❌ 数据源质量较差,大量无效链接
- ❌ 服务不稳定,经常超时或失败
- ❌ 数据更新缓慢或过时
- ❌ 链接有效性很低(<50%)
### 启动时显示
系统启动时会按优先级排序显示所有已加载的插件:
```
已加载插件:
- panta (优先级: 1)
- zhizhen (优先级: 1)
- labi (优先级: 1)
- huban (优先级: 2)
- duoduo (优先级: 2)
- pansearch (优先级: 3)
- hunhepan (优先级: 3)
```
## 开发新插件
### 1. 基础结构
```go
package myplugin
import (
"context"
"io"
"net/http"
"time"
"pansou/model"
"pansou/plugin"
"pansou/util/json" // 使用项目统一的高性能JSON工具
)
type MyPlugin struct {
*plugin.BaseAsyncPlugin
}
func init() {
p := &MyPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin", 3), // 优先级3 = 普通质量数据源
}
plugin.RegisterGlobalPlugin(p)
}
// 对于需要跳过Service层过滤的插件(如磁力搜索插件)
func init() {
p := &MyMagnetPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("mymagnet", 4, true), // 跳过Service层过滤
}
plugin.RegisterGlobalPlugin(p)
}
// Search 执行搜索并返回结果(兼容性方法)
func (p *MyPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
result, err := p.SearchWithResult(keyword, ext)
if err != nil {
return nil, err
}
return result.Results, nil
}
// SearchWithResult 执行搜索并返回包含IsFinal标记的结果(推荐方法)
func (p *MyPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)
}
```
### 2. 实现搜索逻辑(⭐ 推荐实现模式)
```go
func (p *MyPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
// 1. 构建请求URL
searchURL := fmt.Sprintf("https://api.example.com/search?q=%s", url.QueryEscape(keyword))
// 2. 处理扩展参数
if titleEn, ok := ext["title_en"].(string); ok && titleEn != "" {
searchURL += "&title_en=" + url.QueryEscape(titleEn)
}
// 3. 创建带超时的上下文 ⭐ 重要:避免请求超时
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 4. 创建请求对象 ⭐ 重要:使用context控制超时
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err)
}
// 5. 设置完整请求头 ⭐ 重要:避免反爬虫检测
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Referer", "https://api.example.com/")
// 6. 发送HTTP请求(带重试机制)⭐ 重要:提高稳定性
resp, err := p.doRequestWithRetry(req, client)
if err != nil {
return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err)
}
defer resp.Body.Close()
// 7. 检查状态码
if resp.StatusCode != 200 {
return nil, fmt.Errorf("[%s] 请求返回状态码: %d", p.Name(), resp.StatusCode)
}
// 8. 解析响应
var apiResp APIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("[%s] JSON解析失败: %w", p.Name(), err)
}
// 9. 转换为标准格式
results := make([]model.SearchResult, 0, len(apiResp.Data))
for _, item := range apiResp.Data {
result := model.SearchResult{
UniqueID: fmt.Sprintf("%s-%s", p.Name(), item.ID),
Title: item.Title,
Content: item.Description,
Datetime: item.CreateTime,
Tags: item.Tags,
Links: convertLinks(item.Links), // 转换链接格式
}
results = append(results, result)
}
// 10. 关键词过滤
return plugin.FilterResultsByKeyword(results, keyword), nil
}
// doRequestWithRetry 带重试机制的HTTP请求 ⭐ 重要:提高稳定性
func (p *MyPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {
maxRetries := 3
var lastErr error
for i := 0; i < maxRetries; i++ {
if i > 0 {
// 指数退避重试
backoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond
time.Sleep(backoff)
}
// 克隆请求避免并发问题
reqClone := req.Clone(req.Context())
resp, err := client.Do(reqClone)
if err == nil && resp.StatusCode == 200 {
return resp, nil
}
if resp != nil {
resp.Body.Close()
}
lastErr = err
}
return nil, fmt.Errorf("重试 %d 次后仍然失败: %w", maxRetries, lastErr)
}
```
### 3. 链接转换与 work_title 字段
#### Link 结构定义
```go
type Link struct {
Type string `json:"type"` // 网盘类型
URL string `json:"url"` // 链接地址
Password string `json:"password"` // 提取码/密码
Datetime time.Time `json:"datetime,omitempty"` // 链接更新时间(可选)
WorkTitle string `json:"work_title,omitempty"` // 作品标题(重要:用于区分同一消息中多个作品的链接)
}
```
#### work_title 字段详解
**字段作用**:
- 用于区分**同一条消息/结果中包含的多个不同作品**的链接
- 特别适用于论坛帖子、TG频道消息等一次性发布多部影视资源的场景
**使用场景示例**:
```
📺 TG频道消息示例:
【今日更新】多部热门剧集
1. 凡人修仙传 第30集
夸克:https://pan.quark.cn/s/abc123
2. 唐朝诡事录 第20集
夸克:https://pan.quark.cn/s/def456
3. 庆余年2 全集
百度:https://pan.baidu.com/s/xyz789?pwd=abcd
```
**不使用 work_title 的问题**:
- 所有链接的标题都是 "【今日更新】多部热门剧集"
- 用户无法区分哪个链接对应哪部剧集
**使用 work_title 后的效果**:
```go
links := []model.Link{
{
Type: "quark",
URL: "https://pan.quark.cn/s/abc123",
WorkTitle: "凡人修仙传 第30集", // 独立作品标题
},
{
Type: "quark",
URL: "https://pan.quark.cn/s/def456",
WorkTitle: "唐朝诡事录 第20集", // 独立作品标题
},
{
Type: "baidu",
URL: "https://pan.baidu.com/s/xyz789?pwd=abcd",
Password: "abcd",
WorkTitle: "庆余年2 全集", // 独立作品标题
},
}
```
**PanSou系统的智能处理**:
PanSou 会根据消息中的链接数量自动决定是否提取 work_title:
1. **链接数量 ≤ 4**:所有链接使用相同的 work_title(即消息标题)
```go
// 示例:一条消息只包含同一部剧的不同网盘链接
// 消息标题:"凡人修仙传 第30集"
// 链接1(夸克)、链接2(百度) → work_title 都是 "凡人修仙传 第30集"
```
2. **链接数量 > 4**:系统智能识别每个链接对应的作品标题
```go
// 示例:一条消息包含5个不同作品的链接
// 系统会分析消息文本,为每个链接提取独立的 work_title
```
**插件实现 work_title 的两种方式**:
**方式1: 依赖系统自动提取**(适用于TG频道、论坛等)
```go
// 直接返回链接,系统会自动调用 extractWorkTitlesForLinks 进行处理
links := []model.Link{
{Type: "quark", URL: "https://pan.quark.cn/s/abc123"},
{Type: "baidu", URL: "https://pan.baidu.com/s/xyz789"},
}
// PanSou会根据消息文本自动为每个链接提取work_title
```
**方式2: 插件手动设置**(适用于API插件、磁力搜索等)
```go
// 插件直接设置 work_title(如feikuai、thepiratebay等)
links := []model.Link{
{
Type: "magnet",
URL: magnetURL,
WorkTitle: buildWorkTitle(keyword, fileName), // 插件自己构建
Datetime: publishedTime,
},
}
```
**插件开发建议**:
- **网盘API插件**: 如果API直接返回单一作品,可以不设置 work_title(留空)
- **磁力搜索插件**: 建议设置 work_title,特别是文件名不含中文时需要拼接关键词
- **爬虫插件**: 如果能从页面提取每个链接的独立标题,建议设置 work_title
#### 支持的网盘类型
PanSou系统支持以下网盘类型的自动识别(完整列表):
| 网盘类型 | 类型标识 | 域名特征 | 说明 |
|---------|---------|----------|------|
| **夸克网盘** | `quark` | `pan.quark.cn` | 主流网盘 |
| **UC网盘** | `uc` | `drive.uc.cn` | 主流网盘 |
| **百度网盘** | `baidu` | `pan.baidu.com` | 主流网盘 |
| **阿里云盘** | `aliyun` | `aliyundrive.com`, `alipan.com` | 主流网盘 |
| **光鸭云盘** | `guangya` | `guangyapan.com` | 主流网盘 |
| **迅雷网盘** | `xunlei` | `pan.xunlei.com` | 主流网盘 |
| **天翼云盘** | `tianyi` | `cloud.189.cn` | 主流网盘 |
| **115网盘** | `115` | `115.com`,`115cdn.com`,`anxia.com` | 主流网盘 |
| **123网盘** | `123` | `123pan.com`,`123684.com`,`123685.com`,`123912.com`,`123pan.cn`,`123592.com` | 主流网盘 |
| **移动云盘** | `mobile` | `caiyun.139.com` | 其他网盘 |
| **PikPak** | `pikpak` | `mypikpak.com` | 其他网盘 |
| **磁力链接** | `magnet` | `magnet:?xt=urn:btih:` | 磁力链接 |
| **ED2K链接** | `ed2k` | `ed2k://` | 磁力链接 |
```go
func convertLinks(apiLinks []APILink) []model.Link {
links := make([]model.Link, 0, len(apiLinks))
for _, apiLink := range apiLinks {
link := model.Link{
Type: determineCloudType(apiLink.URL), // 自动识别网盘类型
URL: apiLink.URL,
Password: apiLink.Password,
}
links = append(links, link)
}
return links
}
func determineCloudType(url string) string {
switch {
case strings.Contains(url, "pan.quark.cn"):
return "quark"
case strings.Contains(url, "drive.uc.cn"):
return "uc"
case strings.Contains(url, "pan.baidu.com"):
return "baidu"
case strings.Contains(url, "aliyundrive.com") || strings.Contains(url, "alipan.com"):
return "aliyun"
case strings.Contains(url, "guangyapan.com"):
return "guangya"
case strings.Contains(url, "pan.xunlei.com"):
return "xunlei"
case strings.Contains(url, "cloud.189.cn"):
return "tianyi"
case strings.Contains(url, "115.com") || strings.Contains(url, "115cdn.com") || strings.Contains(url, "anxia.com"):
return "115"
case strings.Contains(url, "123684.com") || strings.Contains(url, "123685.com") ||
strings.Contains(url, "123912.com") || strings.Contains(url, "123pan.com") ||
strings.Contains(url, "123pan.cn") || strings.Contains(url, "123592.com"):
return "123"
case strings.Contains(url, "caiyun.139.com"):
return "mobile"
case strings.Contains(url, "mypikpak.com"):
return "pikpak"
case strings.Contains(url, "magnet:"):
return "magnet"
case strings.Contains(url, "ed2k://"):
return "ed2k"
default:
return "others"
}
}
// 使用示例
func convertAPILinks(apiLinks []APILink) []model.Link {
links := make([]model.Link, 0, len(apiLinks))
for _, apiLink := range apiLinks {
// 自动识别网盘类型
cloudType := determineCloudType(apiLink.URL)
// 只添加识别成功的链接
if cloudType != "others" || strings.HasPrefix(apiLink.URL, "http") {
link := model.Link{
Type: cloudType,
URL: apiLink.URL,
Password: apiLink.Password,
}
links = append(links, link)
}
}
return links
}
```
## 高级特性
### 1. 插件Web路由注册(自定义HTTP接口)
#### 概述
PanSou 支持插件注册自定义的 HTTP 路由,用于实现插件专属的管理页面、配置接口或其他Web功能。
**典型应用场景**:
- 插件配置管理界面(如 QQPD 的用户登录和频道管理)
- 插件数据查询接口
- 插件状态监控页面
- OAuth回调接口
#### 接口定义
```go
// PluginWithWebHandler 支持Web路由的插件接口
// 插件可以选择实现此接口来注册自定义的HTTP路由
type PluginWithWebHandler interface {
AsyncSearchPlugin // 继承搜索插件接口
// RegisterWebRoutes 注册Web路由
// router: gin的路由组,插件可以在此注册自己的路由
RegisterWebRoutes(router *gin.RouterGroup)
}
```
#### 实现步骤
**步骤1: 插件结构实现接口**
```go
package myplugin
import (
"github.com/gin-gonic/gin"
"pansou/plugin"
"pansou/model"
)
type MyPlugin struct {
*plugin.BaseAsyncPlugin
// ... 其他字段
}
// 确保插件实现了 PluginWithWebHandler 接口
var _ plugin.PluginWithWebHandler = (*MyPlugin)(nil)
```
**步骤2: 实现 RegisterWebRoutes 方法**
```go
// RegisterWebRoutes 注册Web路由
func (p *MyPlugin) RegisterWebRoutes(router *gin.RouterGroup) {
// 创建插件专属的路由组
myGroup := router.Group("/myplugin")
// 注册GET路由
myGroup.GET("/status", p.handleGetStatus)
// 注册POST路由
myGroup.POST("/config", p.handleUpdateConfig)
// 支持动态路径参数
myGroup.GET("/:id", p.handleGetByID)
myGroup.POST("/:id/action", p.handleAction)
}
```
**步骤3: 实现路由处理函数**
```go
// handleGetStatus 获取插件状态
func (p *MyPlugin) handleGetStatus(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"plugin": p.Name(),
"version": "1.0.0",
})
}
// handleUpdateConfig 更新插件配置
func (p *MyPlugin) handleUpdateConfig(c *gin.Context) {
var config map[string]interface{}
if err := c.BindJSON(&config); err != nil {
c.JSON(400, gin.H{"error": "Invalid JSON"})
return
}
// 处理配置更新逻辑
// ...
c.JSON(200, gin.H{
"success": true,
"message": "配置已更新",
})
}
// handleGetByID 根据ID获取数据
func (p *MyPlugin) handleGetByID(c *gin.Context) {
id := c.Param("id")
// 根据ID查询数据
// ...
c.JSON(200, gin.H{
"id": id,
"data": "...",
})
}
```
#### 实际案例: QQPD 插件
QQPD 插件实现了完整的用户管理和频道配置功能:
```go
// RegisterWebRoutes 注册Web路由
func (p *QQPDPlugin) RegisterWebRoutes(router *gin.RouterGroup) {
qqpd := router.Group("/qqpd")
// GET /:param - 显示管理页面(HTML)
qqpd.GET("/:param", p.handleManagePage)
// POST /:param - 处理管理操作(JSON API)
qqpd.POST("/:param", p.handleManagePagePOST)
}
// handleManagePage 渲染管理页面
func (p *QQPDPlugin) handleManagePage(c *gin.Context) {
param := c.Param("param")
// 生成用户专属的管理页面
html := strings.ReplaceAll(HTMLTemplate, "HASH_PLACEHOLDER", param)
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(200, html)
}
// handleManagePagePOST 处理管理操作
func (p *QQPDPlugin) handleManagePagePOST(c *gin.Context) {
param := c.Param("param")
var req struct {
Action string `json:"action"`
Channels []string `json:"channels,omitempty"`
Keyword string `json:"keyword,omitempty"`
}
if err := c.BindJSON(&req); err != nil {
respondError(c, "无效的请求格式")
return
}
// 根据不同的 action 执行不同的操作
switch req.Action {
case "get_status":
p.handleGetStatus(c, param)
case "set_channels":
p.handleSetChannels(c, param, req.Channels)
case "test_search":
p.handleTestSearch(c, param, req.Keyword)
case "logout":
p.handleLogout(c, param)
default:
respondError(c, "未知的操作")
}
}
```
#### 实际案例: Gying 插件
```go
// RegisterWebRoutes 注册Web路由
func (p *GyingPlugin) RegisterWebRoutes(router *gin.RouterGroup) {
gying := router.Group("/gying")
gying.GET("/:param", p.handleManagePage)
gying.POST("/:param", p.handleManagePagePOST)
}
```
#### 路由访问示例
插件注册的路由可以通过以下方式访问:
```bash
# QQPD 插件管理页面
GET http://localhost:8888/qqpd/user123
# QQPD 插件配置接口
POST http://localhost:8888/qqpd/user123
Content-Type: application/json
{
"action": "set_channels",
"channels": ["pd97631607", "kuake12345"]
}
# 自定义插件接口
GET http://localhost:8888/myplugin/status
POST http://localhost:8888/myplugin/config
GET http://localhost:8888/myplugin/resource123
```
#### 系统集成
PanSou 在启动时会自动扫描并注册所有实现了 `PluginWithWebHandler` 接口的插件路由:
```go
// api/router.go 中的自动注册逻辑
func SetupRouter(searchService *service.SearchService) *gin.Engine {
r := gin.Default()
// ... 其他路由配置 ...
// 注册插件的Web路由(如果插件实现了PluginWithWebHandler接口)
allPlugins := plugin.GetRegisteredPlugins()
for _, p := range allPlugins {
if webPlugin, ok := p.(plugin.PluginWithWebHandler); ok {
webPlugin.RegisterWebRoutes(r.Group(""))
}
}
return r
}
```
#### 开发建议
1. **路由命名规范**: 使用插件名作为路由前缀,避免与其他插件冲突
```go
// ✅ 推荐
router.Group("/myplugin")
// ❌ 避免
router.Group("/config") // 可能与其他插件冲突
```
2. **安全考虑**:
- 对敏感操作进行身份验证
- 验证用户输入,防止注入攻击
- 使用哈希或加密保护敏感参数
3. **错误处理**: 统一错误响应格式
```go
func respondError(c *gin.Context, message string) {
c.JSON(400, gin.H{
"success": false,
"message": message,
})
}
func respondSuccess(c *gin.Context, message string, data interface{}) {
c.JSON(200, gin.H{
"success": true,
"message": message,
"data": data,
})
}
```
4. **HTML模板**: 可以内嵌HTML模板提供管理界面
```go
const HTMLTemplate = `<!DOCTYPE html>
<html>
<head>
<title>插件管理</title>
</head>
<body>
<h1>{{ .PluginName }} 管理界面</h1>
<!-- ... -->
</body>
</html>`
```
5. **可选实现**: Web路由是**可选功能**,只有需要自定义HTTP接口的插件才需要实现
### 2. Service层过滤控制详解
#### 构造函数选择
```go
// 标准插件构造函数(默认启用Service层过滤)
func NewStandardPlugin() *StandardPlugin {
return &StandardPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("standard", 3), // 默认skipServiceFilter=false
}
}
// 磁力搜索插件构造函数(跳过Service层过滤)
func NewMagnetPlugin() *MagnetPlugin {
return &MagnetPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("magnet", 4, true), // skipServiceFilter=true
}
}
```
#### 实际应用示例
**ThePirateBay插件示例**:
```go
// thepiratebay插件的实际实现
func NewThePirateBayPlugin() *ThePirateBayPlugin {
return &ThePirateBayPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("thepiratebay", 4, true), // 跳过Service层过滤
optimizedClient: createOptimizedHTTPClient(),
}
}
func (p *ThePirateBayPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
// 支持英文搜索优化
searchKeyword := keyword
if ext != nil {
if titleEn, exists := ext["title_en"]; exists {
if titleEnStr, ok := titleEn.(string); ok && titleEnStr != "" {
searchKeyword = titleEnStr
}
}
}
// 获取搜索结果
allResults := p.fetchAllResults(client, searchKeyword)
// 标题格式优化:将'.'替换为空格,提高关键词匹配准确度
for i := range allResults {
allResults[i].Title = strings.ReplaceAll(allResults[i].Title, ".", " ")
}
// 插件层过滤(使用处理后的搜索关键词)
filteredResults := plugin.FilterResultsByKeyword(allResults, searchKeyword)
return filteredResults, nil
// 注意:Service层会通过SkipServiceFilter()方法跳过二次过滤
}
```
#### 过滤策略对比
| 过滤类型 | 标准插件 | 磁力搜索插件 |
|----------|----------|--------------|
| **插件层过滤** | ✅ 使用原始关键词 | ✅ 使用searchKeyword(支持title_en) |
| **Service层过滤** | ✅ 再次过滤 | ❌ 跳过过滤 |
| **结果特点** | 精确匹配 | 宽泛搜索 |
| **适用场景** | 中文网盘资源 | 英文磁力资源 |
#### 动态过滤检测机制
Service层通过以下机制动态判断是否需要过滤:
```go
// service/search_service.go 中的实现
func mergeResultsByType(...) {
// 检查插件是否需要跳过Service层过滤
var skipKeywordFilter bool = false
if result.UniqueID != "" && strings.Contains(result.UniqueID, "-") {
parts := strings.SplitN(result.UniqueID, "-", 2)
if len(parts) >= 1 {
pluginName := parts[0]
// 通过插件注册表动态获取过滤设置
if pluginInstance, exists := plugin.GetPluginByName(pluginName); exists {
skipKeywordFilter = pluginInstance.SkipServiceFilter()
}
}
}
// 根据插件设置决定是否过滤
if !skipKeywordFilter && keyword != "" && !strings.Contains(strings.ToLower(title), lowerKeyword) {
continue // 过滤掉不匹配的结果
}
}
```
### 2. 扩展参数处理
```go
// 支持的扩展参数示例
ext := map[string]interface{}{
"title_en": "English Title", // 英文标题
"is_all": true, // 全量搜索标志
"year": 2023, // 年份限制
"type": "movie", // 内容类型
}
// 在插件中处理
func (p *MyPlugin) handleExtParams(ext map[string]interface{}) searchOptions {
opts := searchOptions{}
if titleEn, ok := ext["title_en"].(string); ok {
opts.TitleEn = titleEn
}
if isAll, ok := ext["is_all"].(bool); ok {
opts.IsAll = isAll
}
return opts
}
```
### 2. 缓存策略
```go
// 设置缓存TTL
p.SetCacheTTL(2 * time.Hour)
// 手动缓存更新
p.UpdateMainCache(cacheKey, results, ttl, true, keyword)
```
### 3. 错误处理
```go
func (p *MyPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
// 网络错误处理
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("[%s] 网络请求失败: %w", p.Name(), err)
}
// HTTP状态码检查
if resp.StatusCode != 200 {
return nil, fmt.Errorf("[%s] HTTP错误: %d", p.Name(), resp.StatusCode)
}
// JSON解析错误 - 推荐使用项目统一的JSON工具
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("[%s] 读取响应失败: %w", p.Name(), err)
}
var apiResp APIResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("[%s] JSON解析失败: %w", p.Name(), err)
}
// 业务逻辑错误
if apiResp.Code != 0 {
return nil, fmt.Errorf("[%s] API错误: %s", p.Name(), apiResp.Message)
}
return results, nil
}
```
## 性能优化
### 1. HTTP客户端优化
```go
// 使用连接池
client := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
```
### 2. 内存优化
```go
// 预分配切片容量
results := make([]model.SearchResult, 0, expectedCount)
// 及时释放大对象
defer func() {
apiResp = APIResponse{}
}()
```
### 3. 并发控制
```go
// 使用插件内置的工作池,避免创建过多goroutine
// BaseAsyncPlugin 已经提供了工作池管理
```
## 测试和调试
### 1. 单元测试
```go
func TestMyPlugin_Search(t *testing.T) {
plugin := &MyPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("test", 3),
}
results, err := plugin.Search("测试关键词", nil)
assert.NoError(t, err)
assert.NotEmpty(t, results)
}
```
### 2. 集成测试
```bash
# 使用API测试插件
curl "http://localhost:8888/api/search?kw=测试&plugins=myplugin"
```
### 3. 性能测试
```bash
# 使用压力测试脚本
python3 stress_test.py
```
## 部署和配置
### 1. 插件注册
确保在 `init()` 函数中注册插件:
```go
func init() {
p := &MyPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin", 3),
}
plugin.RegisterGlobalPlugin(p)
}
```
### 2. 环境配置
```bash
# 异步插件配置
export ASYNC_PLUGIN_ENABLED=true
export ASYNC_RESPONSE_TIMEOUT=4
export ASYNC_MAX_BACKGROUND_WORKERS=40
export ASYNC_MAX_BACKGROUND_TASKS=200
```
### 3. 生产部署注意事项
1. **资源限制**: 根据服务器配置调整工作池大小
2. **监控告警**: 监控插件响应时间和错误率
3. **日志管理**: 合理设置日志级别,避免日志过多
4. **缓存配置**: 根据数据更新频率调整缓存TTL
## 现有插件参考
### 标准网盘搜索插件
- **jikepan** - 标准网盘插件,启用Service层过滤
- **pan666** - 标准网盘插件,启用Service层过滤
- **hunhepan** - 标准网盘插件,启用Service层过滤
- **pansearch** - 标准网盘插件,启用Service层过滤
- **qupansou** - 标准网盘插件,启用Service层过滤
- **panta** - 高质量网盘插件,启用Service层过滤
### 特殊搜索插件
- **thepiratebay** - 磁力搜索插件,跳过Service层过滤,支持title_en参数,标题格式化处理
## 插件开发最佳实践 ⭐
### 核心原则
1. **命名规范**: 插件名使用小写字母和数字
2. **优先级设置**: 1-2为高优先级,3为标准,4-5为低优先级
3. **关键词过滤**: 使用 `FilterResultsByKeyword` 提高结果相关性
4. **缓存友好**: 合理设置缓存TTL,避免频繁请求
5. **资源清理**: 及时关闭连接和释放资源
6. **过滤策略**: 根据插件类型选择合适的Service层过滤策略
### 必须实现的优化点
#### 1. Service层过滤策略选择 ⭐ 新功能
```go
// ✅ 磁力搜索插件 - 跳过Service层过滤
func NewMagnetSearchPlugin() *MagnetSearchPlugin {
return &MagnetSearchPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("magnet", 4, true), // skipServiceFilter=true
}
}
// ✅ 标准网盘插件 - 启用Service层过滤
func NewPanSearchPlugin() *PanSearchPlugin {
return &PanSearchPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("pansearch", 3), // 默认skipServiceFilter=false
}
}
```
**选择指南**:
- **跳过过滤** (`true`): 磁力搜索、英文资源、特殊格式标题、聚合搜索
- **启用过滤** (`false`): 网盘搜索、中文资源、API接口、标准格式标题
**注意事项**:
- 跳过Service层过滤的插件**必须**在插件内部进行`FilterResultsByKeyword`过滤
- 插件层过滤使用的关键词应与实际搜索关键词一致(支持`title_en`等参数)
- 标题格式化处理应在过滤之前进行(如将`"."` 替换为`" "`)
#### 2. SearchResult字段设置规范 ⭐ 重要
```go
// ✅ 正确的SearchResult设置
result := model.SearchResult{
UniqueID: fmt.Sprintf("%s-%s", p.Name(), itemID), // 插件名-资源ID
Title: title, // 资源标题
Content: description, // 资源描述
Links: downloadLinks, // 下载链接列表
Tags: tags, // 分类标签
Channel: "", // ⭐ 重要:插件搜索结果必须为空字符串
Datetime: time.Now(), // 发布时间
}
// ❌ 错误的Channel设置
result.Channel = p.Name() // 不要设置为插件名!
```
**Channel字段使用规则**:
- **插件搜索结果**: `Channel` 必须为空字符串 `""`
- **Telegram频道**: `Channel` 才设置为频道名称
- **目的**: 区分搜索来源,便于前端展示和后端统计
**Links字段处理规则** ⭐ 重要:
- **必须有链接**: 系统会自动过滤掉 `Links` 为空或长度为0的结果
- **链接质量**: 确保返回的链接都是有效的网盘链接,避免返回无效链接
- **链接验证**: 建议使用 `isValidNetworkDriveURL()` 函数预先验证链接有效性
#### 2. HTTP请求最佳实践 ⭐ 重要
```go
// ✅ 正确的请求实现
func (p *MyPlugin) makeRequest(url string, client *http.Client) (*http.Response, error) {
// 使用context控制超时
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 创建请求
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
// 设置完整的请求头(避免反爬虫)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Referer", "https://example.com/")
// 使用重试机制
return p.doRequestWithRetry(req, client)
}
// ❌ 错误的简单实现
func (p *MyPlugin) badRequest(url string, client *http.Client) (*http.Response, error) {
return client.Get(url) // 没有超时控制、没有请求头、没有重试
}
```
#### 2. 实现高级搜索接口 ⭐ 推荐
```go
// ✅ 推荐:实现两个方法
func (p *MyPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
result, err := p.SearchWithResult(keyword, ext)
if err != nil {
return nil, err
}
return result.Results, nil
}
func (p *MyPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)
}
```
#### 3. 错误处理增强 ⭐ 重要
```go
// ✅ 详细的错误信息
if resp.StatusCode != 200 {
return nil, fmt.Errorf("[%s] 请求失败,状态码: %d", p.Name(), resp.StatusCode)
}
// ✅ 包装外部错误
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("[%s] JSON解析失败: %w", p.Name(), err)
}
```
#### 4. 重试机制模板 ⭐ 复制可用
```go
func (p *MyPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {
maxRetries := 3
var lastErr error
for i := 0; i < maxRetries; i++ {
if i > 0 {
backoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond
time.Sleep(backoff)
}
reqClone := req.Clone(req.Context())
resp, err := client.Do(reqClone)
if err == nil && resp.StatusCode == 200 {
return resp, nil
}
if resp != nil {
resp.Body.Close()
}
lastErr = err
}
return nil, fmt.Errorf("重试 %d 次后仍然失败: %w", maxRetries, lastErr)
}
```
#### 5. 请求头模板 ⭐ 复制可用
```go
// HTML页面请求头
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Upgrade-Insecure-Requests", "1")
req.Header.Set("Cache-Control", "max-age=0")
req.Header.Set("Referer", "https://example.com/")
// JSON API请求头
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Referer", "https://example.com/")
```
### 常见问题避免
1. **不要使用 `client.Get(url)`** - 缺少超时控制和请求头
2. **不要忘记设置 User-Agent** - 很多网站会阻止空UA请求
3. **不要忘记错误上下文** - 使用 `fmt.Errorf("[%s] 错误描述: %w", p.Name(), err)`
4. **不要忘记关闭响应体** - `defer resp.Body.Close()`
5. **不要在循环中创建大量goroutine** - 使用信号量控制并发数
6. **Service层过滤常见问题**:
- ❌ **跳过Service层过滤但不在插件内过滤** - 会返回大量无关结果
- ❌ **磁力搜索插件使用默认构造函数** - 会被Service层误过滤
- ❌ **过滤关键词不一致** - 插件用`title_en`搜索但用原`keyword`过滤
- ❌ **标题格式化在过滤之后** - 格式化不会改善过滤效果
## /docs/ç³»ç»Âå¼ÂÃ¥ÂÂ设计æÂÂæ¡£.md
# PanSou 网盘搜索系统开发设计文档
## 📋 文档目录
- [1. 项目概述](#1-项目概述)
- [2. 系统架构设计](#2-系统架构设计)
- [3. 异步插件系统](#3-异步插件系统)
- [4. 二级缓存系统](#4-二级缓存系统)
- [5. 核心组件实现](#5-核心组件实现)
- [6. 智能排序算法详解](#6-智能排序算法详解)
- [7. API接口设计](#7-api接口设计)
- [8. 认证系统设计](#8-认证系统设计)
- [9. 插件开发框架](#9-插件开发框架)
- [10. 性能优化实现](#10-性能优化实现)
- [11. 技术选型说明](#11-技术选型说明)
---
## 1. 项目概述
### 1.1 项目定位
PanSou是一个高性能的网盘资源搜索API服务,支持TG搜索和自定义插件搜索。系统采用异步插件架构,具备二级缓存机制和并发控制能力,在MacBook Pro 8GB上能够支持500用户并发访问。
### 1.2 核心特性
- **异步插件系统**: 双级超时控制(4秒/30秒),渐进式结果返回
- **二级缓存系统**: 分片内存缓存+分片磁盘缓存,GOB序列化
- **工作池管理**: 基于`util/pool`的并发控制
- **智能结果合并**: `mergeSearchResults`函数实现去重合并
- **多维度排序**: 插件等级+时间新鲜度+优先关键词综合评分
- **多网盘类型支持**: 自动识别12种网盘类型
---
## 2. 系统架构设计
### 2.1 整体架构流程
```mermaid
graph TB
A[用户请求] --> B[API Gateway<br/>Gin Handler]
B --> C[参数解析与验证<br/>GET/POST处理]
C --> D[参数预处理<br/>规范化处理]
D --> E[SearchService<br/>主搜索服务]
E --> F{源类型判断<br/>sourceType}
F -->|TG| G[并行TG搜索]
F -->|Plugin| H[并行插件搜索]
F -->|All| I[TG+插件并行搜索]
I --> G
I --> H
%% TG搜索分支
G --> G1[生成TG缓存键<br/>GenerateTGCacheKey]
G1 --> G2{强制刷新?<br/>forceRefresh}
G2 -->|否| G3[检查二级缓存<br/>EnhancedTwoLevelCache]
G2 -->|是| G6[跳过缓存检查]
G3 --> G4{缓存命中?}
G4 -->|是| G5[缓存反序列化<br/>直接返回结果]
G4 -->|否| G6[执行TG频道搜索<br/>多频道并行]
G6 --> G7[HTML解析<br/>链接提取]
G7 --> G8[结果标准化]
G8 --> G9[更新缓存<br/>SetBothLevels]
%% 插件搜索分支 - 详细的异步处理
H --> H1[生成插件缓存键<br/>GeneratePluginCacheKey]
H1 --> H2{强制刷新?<br/>forceRefresh}
H2 -->|否| H3[检查二级缓存<br/>EnhancedTwoLevelCache]
H2 -->|是| H6[跳过缓存检查]
H3 --> H4{缓存命中?}
H4 -->|是| H5[缓存反序列化<br/>直接返回结果]
H4 -->|否| H6[插件管理器调度<br/>PluginManager]
%% 异步插件详细流程
H6 --> H7[异步插件初始化<br/>SetMainCacheKey]
H7 --> H8[工作池任务提交<br/>WorkerPool]
%% 双级超时机制的并行处理
H8 --> H9{异步并行处理}
%% 快速响应分支 (4秒)
H9 --> H10[短超时处理<br/>4秒快速响应]
H10 --> H11[HTTP请求<br/>短超时模式]
H11 --> H12[部分结果解析<br/>快速过滤]
H12 --> H13[部分结果缓存<br/>isFinal=false]
H13 --> H14[立即返回<br/>部分结果给用户]
%% 持续处理分支 (30秒)
H9 --> H15[长超时后台处理<br/>最长30秒持续]
H15 --> H16[HTTP请求<br/>长超时模式]
H16 --> H17[完整结果解析<br/>深度过滤]
H17 --> H18[结果去重合并<br/>最终处理]
H18 --> H19[完整结果缓存<br/>isFinal=true]
H19 --> H20[主缓存异步更新<br/>DelayedBatchWrite]
%% 结果合并处理
G5 --> J[结果合并<br/>mergeSearchResults]
G9 --> J
H5 --> J
H14 --> J
J --> K[智能排序算法<br/>时间+关键词+插件等级]
K --> L[结果过滤<br/>高质量结果筛选]
L --> M[网盘类型分组<br/>mergeResultsByType]
M --> N{结果类型<br/>resultType}
N -->|merged_by_type| O[返回分组结果]
N -->|results| P[返回原始结果]
N -->|all| Q[返回完整结果]
O --> R[JSON响应]
P --> R
Q --> R
R --> S[用户]
%% 后台持续更新(不影响用户响应)
H20 --> T[后台缓存完善<br/>下次请求更完整]
T -.-> U[持续优化<br/>用户体验]
%% 缓存系统
subgraph Cache[二级缓存系统]
CA[分片内存缓存<br/>LRU + 原子操作]
CB[分片磁盘缓存<br/>GOB序列化]
CC[智能缓存写入管理器<br/>DelayedBatchWriteManager]
CD[全局缓冲区管理器<br/>BufferByPlugin策略]
end
G3 -.-> CA
H3 -.-> CA
CA -.-> CB
G9 -.-> CC
H13 -.-> CC
H20 -.-> CC
CC -.-> CD
%% 样式定义
classDef cacheNode fill:#e1f5fe,stroke:#01579b,stroke-width:2px
classDef pluginNode fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
classDef searchNode fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px
classDef fastResponse fill:#fff3e0,stroke:#e65100,stroke-width:2px
classDef slowProcess fill:#fce4ec,stroke:#880e4f,stroke-width:2px
classDef processNode fill:#f5f5f5,stroke:#424242,stroke-width:2px
class G3,H3,G5,H5,G9,H13,H20,CA,CB,CC,CD cacheNode
class H6,H7,H8 pluginNode
class G6,G7,G8 searchNode
class H10,H11,H12,H13,H14 fastResponse
class H15,H16,H17,H18,H19,H20,T slowProcess
class D,J,K,L,M processNode
```
### 2.2 异步插件工作流程
```mermaid
sequenceDiagram
participant U as 用户
participant API as API Handler
participant S as SearchService
participant SP as searchPlugins函数
participant C as 二级缓存系统
participant PM as PluginManager
participant P as AsyncPlugin
participant WP as WorkerPool
participant BWM as BatchWriteManager
participant EXT as 外部API
%% 请求处理阶段
U->>API: 🔍 搜索请求 (kw=关键词)
API->>API: 参数解析与验证
API->>API: 参数预处理规范化
API->>S: Search(req.Keyword, ...)
%% 并行搜索启动
Note over S: 🚀 并行启动TG和插件搜索
S->>SP: searchPlugins(keyword, plugins, ...)
%% 缓存检查阶段
SP->>SP: 生成插件缓存键
SP->>SP: 检查forceRefresh标志
alt forceRefresh = false
SP->>C: 🔍 Get(cacheKey)
alt 缓存命中
C-->>SP: ✅ 返回缓存数据
SP->>SP: 反序列化结果
SP-->>S: 🎯 返回缓存结果 (<10ms)
S-->>U: ⚡ 极速响应
else 缓存未命中
Note over SP: 🚨 执行异步插件搜索
SP->>PM: 获取可用插件列表
SP->>PM: 过滤指定插件
end
else forceRefresh = true
Note over SP: 🔄 跳过缓存,强制搜索
SP->>PM: 获取可用插件列表
SP->>PM: 过滤指定插件
end
%% 异步搜索初始化
PM->>P: 🎯 设置关键词和缓存键
P->>P: SetMainCacheKey(cacheKey)
P->>P: SetCurrentKeyword(keyword)
P->>P: 注入缓存更新函数
%% 🚀 异步插件的精髓:双级超时并行机制
Note over P,EXT: 🔥 异步插件精髓:快速响应 + 持续处理
P->>WP: 🚀 提交异步任务到工作池
%% 快速响应路径 (4秒)
par 🚀 快速响应路径 (4秒)
Note over WP,EXT: ⚡ 第一阶段:快速响应用户
WP->>EXT: HTTP请求 (短超时 4秒)
EXT-->>WP: 部分响应数据
WP->>P: 🔍 解析部分结果
P->>P: 快速过滤和标准化
P->>P: 📝 记录日志: 初始缓存创建
%% 部分结果立即缓存和返回
P->>BWM: 🗄️ 异步缓存更新 (isFinal=false)
Note over BWM: 部分结果缓存,不等待写入完成
P-->>SP: 📤 部分结果立即返回
SP-->>S: 🎯 部分结果 (isFinal=false)
S->>S: 与TG结果合并
S-->>U: ⚡ 快速响应 (~4秒)
and 🔄 持续处理路径 (最长30秒)
Note over WP,EXT: 🔄 第二阶段:后台持续完善
WP->>EXT: 继续HTTP请求 (长超时 30秒)
EXT-->>WP: 完整响应数据
WP->>P: 🔍 解析完整结果
P->>P: 深度过滤和去重
P->>P: 结果质量评估
P->>P: 📝 记录日志: 缓存更新完成
%% 完整结果的主缓存更新
P->>BWM: 🗄️ 主缓存更新 (isFinal=true)
Note over BWM: 完整结果写入,高优先级
BWM->>BWM: 🧠 智能缓存写入策略
BWM->>BWM: 🗂️ 全局缓冲区管理
BWM->>C: 📀 批量写入磁盘缓存
Note over C: 🎯 下次同样请求将获得完整结果
end
%% 缓存系统内部处理
C->>C: ⚡ 立即更新内存缓存
C->>C: 📀 延迟批量更新磁盘缓存
C->>C: 🧹 自动清理过期缓存
%% 持续优化标注
Note over U,EXT: 💡 异步插件核心价值
Note over U,EXT: ✅ 用户获得快速响应 (4秒内)
Note over U,EXT: ✅ 系统持续完善结果 (30秒内)
Note over U,EXT: ✅ 下次访问获得完整数据 (<100ms)
Note over U,EXT: 🔄 完美平衡:速度 vs 完整性
```
### 2.3 核心组件
#### 2.3.1 HTTP服务层 (`api/`)
- **router.go**: 路由配置
- **handler.go**: 请求处理逻辑
- **middleware.go**: 中间件(日志、CORS等)
#### 2.3.2 搜索服务层 (`service/`)
- **search_service.go**: 核心搜索逻辑,结果合并
#### 2.3.3 插件系统层 (`plugin/`)
- **plugin.go**: 插件接口定义
- **baseasyncplugin.go**: 异步插件基类
- **各插件目录**: jikepan、pan666、hunhepan等
#### 2.3.4 工具层 (`util/`)
- **cache/**: 二级缓存系统实现
- **pool/**: 工作池实现
- **其他工具**: HTTP客户端、解析工具等
---
## 3. 异步插件系统
### 3.1 设计理念
异步插件系统解决传统同步搜索响应慢的问题,采用"尽快响应,持续处理"策略:
- **4秒短超时**: 快速返回部分结果(`isFinal=false`)
- **30秒长超时**: 后台继续处理,获得完整结果(`isFinal=true`)
- **主动缓存更新**: 完整结果自动更新主缓存,下次访问更快
### 3.2 插件接口实现
基于`plugin/plugin.go`的实际接口:
```go
type AsyncSearchPlugin interface {
Name() string
Priority() int
AsyncSearch(keyword string, searchFunc func(*http.Client, string, map[string]interface{}) ([]model.SearchResult, error),
mainCacheKey string, ext map[string]interface{}) ([]model.SearchResult, error)
SetMainCacheKey(key string)
SetCurrentKeyword(keyword string)
Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error)
}
```
### 3.3 基础插件类
`plugin/baseasyncplugin.go`提供通用功能:
```go
type BaseAsyncPlugin struct {
name string
priority int
cacheTTL time.Duration
mainCacheKey string
currentKeyword string // 用于日志显示
httpClient *http.Client
mainCacheUpdater func(string, []model.SearchResult, time.Duration, bool, string) error
}
```
### 3.4 已实现插件列表
当前系统包含以下插件(基于`main.go`的导入):
- **hdr4k**
- **hunhepan**
- **jikepan**
- **pan666**
- **pansearch**
- **panta**
- **qupansou**
- **susu**
- **panyq**
- **xuexizhinan**
### 3.5 插件注册机制
```go
// 全局插件注册表(plugin/plugin.go)
var globalRegistry = make(map[string]AsyncSearchPlugin)
// 插件通过init()函数自动注册
func init() {
p := &MyPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin", 3),
}
plugin.RegisterGlobalPlugin(p)
}
```
---
## 4. 二级缓存系统
### 4.1 实现架构
基于`util/cache/`目录的实际实现:
- **enhanced_two_level_cache.go**: 二级缓存主入口
- **sharded_memory_cache.go**: 分片内存缓存(LRU+原子操作)
- **sharded_disk_cache.go**: 分片磁盘缓存
- **serializer.go**: GOB序列化器
- **cache_key.go**: 缓存键生成和管理
### 4.2 分片缓存设计
#### 4.2.1 内存缓存分片
```go
// 基于CPU核心数的动态分片
type ShardedMemoryCache struct {
shards []*MemoryCacheShard
shardMask uint32
}
// 每个分片独立锁,减少竞争
type MemoryCacheShard struct {
data map[string]*CacheItem
lock sync.RWMutex
}
```
#### 4.2.2 磁盘缓存分片
```go
// 磁盘缓存同样采用分片设计
type ShardedDiskCache struct {
shards []*DiskCacheShard
shardMask uint32
basePath string
}
```
### 4.3 缓存读写策略
#### 4.3.1 读取流程
1. **内存优先**: 先检查分片内存缓存
2. **磁盘回源**: 内存未命中时读取磁盘缓存
3. **异步加载**: 磁盘命中后异步加载到内存
#### 4.3.2 写入流程
1. **智能写入策略**: 立即更新内存缓存,延迟批量写入磁盘
2. **DelayedBatchWriteManager**: 智能缓存写入管理器,支持immediate和hybrid两种策略
3. **原子操作**: 内存缓存使用原子操作
4. **GOB序列化**: 磁盘存储使用GOB格式
5. **数据安全保障**: 程序终止时自动保存所有待写入数据,防止数据丢失
### 4.4 缓存键策略
`cache_key.go`实现了智能缓存键生成:
```go
// TG搜索和插件搜索使用不同的缓存键前缀
func GenerateTGCacheKey(keyword string, channels []string) string
func GeneratePluginCacheKey(keyword string, plugins []string) string
```
**优势**:
- 独立更新:TG和插件缓存互不影响
- 提高命中率:精确的键匹配
- 并发安全:分片设计减少锁竞争
### 4.5 序列化性能
使用GOB序列化(`serializer.go`)的实际优势:
- **性能**: 比JSON序列化快约30%
- **体积**: 比JSON小约20%
- **兼容**: Go原生支持,无外部依赖
---
## 5. 核心组件实现
### 5.1 工作池系统 (`util/pool/`)
#### 5.1.1 worker_pool.go 实现
- **批量任务处理**: `ExecuteBatchWithTimeout`方法
- **超时控制**: 支持任务级别的超时设置
- **并发限制**: 控制最大工作者数量
#### 5.1.2 object_pool.go 实现
- **对象复用**: 减少内存分配和GC压力
- **线程安全**: 支持并发访问
### 5.2 HTTP服务配置
#### 5.2.1 服务器优化(基于config/config.go)
```go
// 自动计算HTTP连接数,防止资源耗尽
func getHTTPMaxConns() int {
cpuCount := runtime.NumCPU()
maxConns := cpuCount * 25 // 保守配置
if maxConns < 100 {
maxConns = 100
}
if maxConns > 500 {
maxConns = 500 // 限制最大值
}
return maxConns
}
```
#### 5.2.2 连接池配置(基于util/http_util.go)
```go
// HTTP客户端优化配置
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
}
```
### 5.3 结果处理系统
#### 5.3.1 智能排序算法(service/search_service.go)
PanSou 采用多维度综合评分排序算法,确保高质量结果优先展示:
**评分公式**:
```
总得分 = 插件得分(1000/500/0/-200) + 时间得分(最高500) + 关键词得分(最高420)
```
**权重分配**:
- 🥇 **插件等级**: ~52% (主导因素) - 等级1(1000分) > 等级2(500分) > 等级3(0分)
- 🥈 **关键词匹配**: ~22% (重要因素) - "合集"(420分) > "系列"(350分) > "全"(280分)
- 🥉 **时间新鲜度**: ~26% (重要因素) - 1天内(500分) > 3天内(400分) > 1周内(300分)
**关键优化**:
- **缓存性能**: 跳过空结果和重复数据的缓存更新,减少70%无效操作
- **排序稳定性**: 修复map遍历随机性问题,确保merged_by_type保持排序
- **插件管理**: 启动时按优先级排序显示已加载插件,便于监控
#### 5.3.2 结果合并(mergeSearchResults函数)
- **去重合并**: 基于UniqueID去重
- **完整性选择**: 选择更完整的结果保留
- **增量更新**: 新结果与缓存结果智能合并
### 5.4 网盘类型识别
支持自动识别的网盘类型(共12种):
- 百度网盘、阿里云盘、夸克网盘、天翼云盘
- UC网盘、移动云盘、115网盘、PikPak
- 迅雷网盘、123网盘、磁力链接、电驴链接
---
## 6. 智能排序算法详解
### 6.1 算法概述
PanSou 搜索引擎采用多维度综合评分排序算法,确保用户能够优先看到最相关、最新、最高质量的搜索结果。
#### 6.1.1 核心设计理念
1. **质量优先**:高等级插件的结果优先展示
2. **时效性重要**:新发布的资源获得更高权重
3. **相关性保证**:关键词匹配度影响排序
4. **用户体验**:最终排序结果保持稳定性
#### 6.1.2 排序流程
```mermaid
graph TD
A[搜索请求] --> B[获取搜索结果 allResults]
B --> C[sortResultsByTimeAndKeywords]
C --> D[为每个结果计算得分]
D --> E[时间得分<br/>最高500分]
D --> F[关键词得分<br/>最高420分]
D --> G[插件得分<br/>等级1=1000分<br/>等级2=500分<br/>等级3=0分]
E --> H[总得分 = 时间得分 + 关键词得分 + 插件得分]
F --> H
G --> H
H --> I[按总得分降序排序]
I --> J[mergeResultsByType]
J --> K[按原始顺序收集唯一链接<br/>保持排序不被破坏]
K --> L[按类型分组<br/>生成merged_by_type]
L --> M[返回最终结果]
```
### 6.2 评分算法详解
#### 6.2.1 核心公式
```
总得分 = 时间得分 + 关键词得分 + 插件得分
```
#### 6.2.2 时间得分 (Time Score)
时间得分反映资源的新鲜度,**最高 500 分**:
| 时间范围 | 得分 | 说明 |
|---------|------|------|
| ≤ 1天 | 500 | 最新资源,最高优先级 |
| ≤ 3天 | 400 | 非常新的资源 |
| ≤ 1周 | 300 | 较新资源 |
| ≤ 1月 | 200 | 相对较新 |
| ≤ 3月 | 100 | 中等新鲜度 |
| ≤ 1年 | 50 | 较旧资源 |
| > 1年 | 20 | 旧资源 |
| 无日期 | 0 | 未知时间 |
#### 6.2.3 关键词得分 (Keyword Score)
关键词得分基于搜索词在标题中的匹配情况,**最高 420 分**:
| 优先关键词 | 得分 | 说明 |
|-----------|------|------|
| "合集" | 420 | 最高优先级 |
| "系列" | 350 | 高优先级 |
| "全" | 280 | 中高优先级 |
| "完" | 210 | 中等优先级 |
| "最新" | 140 | 较低优先级 |
| "附" | 70 | 低优先级 |
| 无匹配 | 0 | 无加分 |
#### 6.2.4 插件得分 (Plugin Score)
插件得分基于数据源的质量等级,体现资源可靠性:
| 插件等级 | 得分 | 说明 |
|---------|------|------|
| 等级1 | 1000 | 顶级数据源 |
| 等级2 | 500 | 优质数据源 |
| 等级3 | 0 | 普通数据源 |
| 等级4 | -200 | 低质量数据源 |
### 6.3 权重分析与实际效果
#### 6.3.1 权重分配
| 维度 | 最高分值 | 权重占比 | 影响说明 |
|------|---------|---------|----------|
| 插件等级 | 1000 | ~52% | **主导因素**,决定基础排序 |
| 关键词匹配 | 420 | ~22% | **重要因素**,优先关键词显著加分 |
| 时间新鲜度 | 500 | ~26% | **重要因素**,同等级内排序关键 |
#### 6.3.2 实际排序示例
| 场景 | 插件等级 | 时间 | 关键词 | 总分 | 排序 |
|------|---------|------|--------|------|------|
| 等级1 + 1天内 + "合集" | 1000 | 500 | 420 | **1920** | 🥇 第1 |
| 等级1 + 1天内 + "系列" | 1000 | 500 | 350 | **1850** | 🥈 第2 |
| 等级1 + 1月内 + "合集" | 1000 | 200 | 420 | **1620** | 🥉 第3 |
| 等级2 + 1天内 + "合集" | 500 | 500 | 420 | **1420** | 第4 |
| 等级1 + 1天内 + 无关键词 | 1000 | 500 | 0 | **1500** | 第5 |
---
## 7. API接口设计
### 7.1 核心接口实现(基于api/handler.go)
#### 7.1.1 搜索接口
```
POST /api/search
GET /api/search
```
**核心参数**:
- `kw`: 搜索关键词(必填)
- `channels`: TG频道列表
- `plugins`: 插件列表
- `cloud_types`: 网盘类型过滤
- `ext`: 扩展参数(JSON格式)
- `refresh`: 强制刷新缓存
- `res`: 返回格式(merge/all/results)
- `src`: 数据源(all/tg/plugin)
#### 7.1.2 健康检查接口
```
GET /api/health
```
返回系统状态和已注册插件信息。
#### 7.1.3 链接检测接口
```
POST /api/check/links
```
检测指定网盘分享链接当前是否有效,支持批量检测和服务端缓存复用。
**核心参数**:
- `items`: 待检测链接数组,至少提供一项
- `items[].disk_type`: 网盘类型,支持 `baidu`、`aliyun`、`quark`、`tianyi`、`uc`、`mobile`、`115`、`xunlei`、`123`
- `items[].url`: 完整分享链接
- `items[].password`: 提取码/密码,未拼接在链接中时可传
- `proxy_url`: 本次检测请求使用的代理地址,支持 `http://`、`https://`、`socks5://`、`socks5h://`
- `proxy`: `proxy_url` 的兼容别名,同时传入时以 `proxy_url` 为准
- `view_token`: 视图标识,用于区分前端检测批次
**代理行为**:
- 请求级代理只影响当前 `/api/check/links` 调用,不修改进程全局代理。
- 单次批量检测共用同一个代理;需要轮换出口IP时,由调用方在不同请求中传入不同代理。
- 使用代理时,检测缓存按代理地址隔离,避免不同出口IP复用同一检测结果。
- 代理地址非法或协议不支持时返回 `400`。
### 6.2 中间件系统(api/middleware.go)
- **日志中间件**: 记录请求响应,支持URL解码显示
- **CORS中间件**: 跨域请求支持
- **错误处理**: 统一错误响应格式
### 6.3 扩展参数系统
通过`ext`参数支持插件特定选项:
```json
{
"title_en": "English Title",
"is_all": true,
"year": 2023
}
```
---
## 8. 认证系统设计
### 8.1 系统概述
PanSou认证系统是一个可选的安全访问控制模块,基于JWT(JSON Web Token)标准实现。该系统设计目标是在不影响现有用户的前提下,为需要私有部署的用户提供灵活的认证功能。
#### 8.1.1 核心特性
- **可选性**: 默认关闭,通过环境变量`AUTH_ENABLED`启用
- **无状态**: 基于JWT,无需session存储
- **标准化**: 采用RFC 7519 JWT标准
- **灵活性**: 支持多用户配置
- **安全性**: Token自动过期,防止长期有效性风险
### 8.2 认证架构
#### 8.2.1 认证流程
```mermaid
sequenceDiagram
participant U as 用户
participant F as 前端
participant M as 认证中间件
participant A as 认证接口
participant S as 搜索服务
Note over U,S: 初始访问阶段
U->>F: 访问应用
F->>F: 检查localStorage中的token
alt token不存在或无效
F->>U: 显示登录窗口
U->>F: 输入账号密码
F->>A: POST /api/auth/login
A->>A: 验证账号密码
A->>A: 生成JWT Token
A-->>F: 返回Token
F->>F: 存储Token到localStorage
F->>U: 关闭登录窗口
end
Note over U,S: API调用阶段
U->>F: 发起搜索请求
F->>F: axios拦截器添加Authorization头
F->>M: GET/POST /api/search + Token
M->>M: 验证Token有效性
alt Token有效
M->>S: 转发请求
S-->>M: 返回搜索结果
M-->>F: 返回响应
F-->>U: 显示结果
else Token无效/过期
M-->>F: 返回401 Unauthorized
F->>F: 响应拦截器捕获401
F->>U: 显示登录窗口
end
```
#### 8.2.2 组件架构
```
┌─────────────────────────────────────────────────────────────┐
│ 前端层 (Vue 3) │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ LoginDialog │ │ HTTP拦截器 │ │ Token管理工具 │ │
│ │ 登录组件 │ │ 自动添加Token │ │ LocalStorage │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↕ HTTP (Authorization: Bearer)
┌─────────────────────────────────────────────────────────────┐
│ 后端层 (Go + Gin) │
├─────────────────────────────────────────────────────────────┤
│ ┌──────────────────────────────────────────────────────┐ │
│ │ AuthMiddleware 认证中间件 │ │
│ │ • 检查AUTH_ENABLED配置 │ │
│ │ • 排除公开接口(/api/auth/login, /api/health) │ │
│ │ • 验证JWT Token有效性 │ │
│ │ • 提取用户信息到Context │ │
│ └──────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────┐ │
│ │ 认证接口 │ │ JWT工具 │ │ 配置管理 │ │
│ │ /auth/login │ │ util/jwt.go │ │ config/config.go │ │
│ │ /auth/verify│ │ GenerateToken│ │ AuthEnabled │ │
│ │ /auth/logout│ │ ValidateToken│ │ AuthUsers │ │
│ └─────────────┘ └─────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 8.3 后端实现细节
#### 8.3.1 配置模块 (config/config.go)
```go
type Config struct {
// ... 现有配置 ...
// 认证相关配置
AuthEnabled bool // 是否启用认证
AuthUsers map[string]string // 用户名:密码哈希映射
AuthTokenExpiry time.Duration // Token有效期
AuthJWTSecret string // JWT签名密钥
}
// 从环境变量读取认证配置
func getAuthEnabled() bool {
enabled := os.Getenv("AUTH_ENABLED")
return enabled == "true" || enabled == "1"
}
func getAuthUsers() map[string]string {
usersEnv := os.Getenv("AUTH_USERS")
if usersEnv == "" {
return nil
}
users := make(map[string]string)
pairs := strings.Split(usersEnv, ",")
for _, pair := range pairs {
parts := strings.SplitN(pair, ":", 2)
if len(parts) == 2 {
username := strings.TrimSpace(parts[0])
password := strings.TrimSpace(parts[1])
// 实际使用时应该对密码进行哈希处理
users[username] = password
}
}
return users
}
```
#### 8.3.2 JWT工具模块 (util/jwt.go)
```go
package util
import (
"errors"
"github.com/golang-jwt/jwt/v5"
"time"
)
// Claims JWT载荷结构
type Claims struct {
Username string `json:"username"`
jwt.RegisteredClaims
}
// GenerateToken 生成JWT token
func GenerateToken(username string, secret string, expiry time.Duration) (string, error) {
expirationTime := time.Now().Add(expiry)
claims := &Claims{
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "pansou",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
// ValidateToken 验证JWT token
func ValidateToken(tokenString string, secret string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
```
#### 8.3.3 认证中间件 (api/middleware.go)
```go
// AuthMiddleware JWT认证中间件
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 如果未启用认证,直接放行
if !config.AppConfig.AuthEnabled {
c.Next()
return
}
// 定义公开接口(不需要认证)
publicPaths := []string{
"/api/auth/login",
"/api/auth/verify",
"/api/auth/logout",
"/api/health", // 可选:健康检查是否需要认证
}
// 检查当前路径是否是公开接口
path := c.Request.URL.Path
for _, p := range publicPaths {
if strings.HasPrefix(path, p) {
c.Next()
return
}
}
// 获取Authorization头
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(401, gin.H{
"error": "未授权:缺少认证令牌",
"code": "AUTH_TOKEN_MISSING",
})
c.Abort()
return
}
// 解析Bearer token
const bearerPrefix = "Bearer "
if !strings.HasPrefix(authHeader, bearerPrefix) {
c.JSON(401, gin.H{
"error": "未授权:令牌格式错误",
"code": "AUTH_TOKEN_INVALID_FORMAT",
})
c.Abort()
return
}
tokenString := strings.TrimPrefix(authHeader, bearerPrefix)
// 验证token
claims, err := util.ValidateToken(tokenString, config.AppConfig.AuthJWTSecret)
if err != nil {
c.JSON(401, gin.H{
"error": "未授权:令牌无效或已过期",
"code": "AUTH_TOKEN_INVALID",
})
c.Abort()
return
}
// 将用户信息存入上下文,供后续处理使用
c.Set("username", claims.Username)
c.Next()
}
}
```
#### 8.3.4 认证接口 (api/auth_handler.go)
```go
package api
import (
"github.com/gin-gonic/gin"
"pansou/config"
"pansou/util"
"time"
)
// LoginRequest 登录请求结构
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// LoginResponse 登录响应结构
type LoginResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expires_at"`
Username string `json:"username"`
}
// LoginHandler 处理用户登录
func LoginHandler(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
// 验证用户名和密码
if config.AppConfig.AuthUsers == nil {
c.JSON(500, gin.H{"error": "认证系统未正确配置"})
return
}
storedPassword, exists := config.AppConfig.AuthUsers[req.Username]
if !exists || storedPassword != req.Password {
c.JSON(401, gin.H{"error": "用户名或密码错误"})
return
}
// 生成JWT token
token, err := util.GenerateToken(
req.Username,
config.AppConfig.AuthJWTSecret,
config.AppConfig.AuthTokenExpiry,
)
if err != nil {
c.JSON(500, gin.H{"error": "生成令牌失败"})
return
}
// 返回token和过期时间
expiresAt := time.Now().Add(config.AppConfig.AuthTokenExpiry).Unix()
c.JSON(200, LoginResponse{
Token: token,
ExpiresAt: expiresAt,
Username: req.Username,
})
}
// VerifyHandler 验证token有效性
func VerifyHandler(c *gin.Context) {
// 如果能到达这里,说明中间件已经验证通过
username, exists := c.Get("username")
if !exists {
c.JSON(401, gin.H{"error": "未授权"})
return
}
c.JSON(200, gin.H{
"valid": true,
"username": username,
})
}
// LogoutHandler 退出登录(客户端删除token即可)
func LogoutHandler(c *gin.Context) {
c.JSON(200, gin.H{"message": "退出成功"})
}
```
### 8.4 前端实现细节
#### 8.4.1 API模块扩展 (src/api/index.ts)
```typescript
// 登录接口
export interface LoginParams {
username: string;
password: string;
}
export interface LoginResponse {
token: string;
expires_at: number;
username: string;
}
export const login = async (params: LoginParams): Promise<LoginResponse> => {
const response = await api.post<LoginResponse>('/auth/login', params);
return response.data;
};
// 验证token
export const verifyToken = async (): Promise<boolean> => {
try {
await api.post('/auth/verify');
return true;
} catch {
return false;
}
};
// 退出登录
export const logout = async (): Promise<void> => {
try {
await api.post('/auth/logout');
} finally {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_username');
}
};
```
#### 8.4.2 HTTP拦截器配置
```typescript
// 请求拦截器 - 自动添加token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器 - 处理401
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// 清除token
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_username');
// 触发显示登录窗口
window.dispatchEvent(new CustomEvent('auth:required'));
}
return Promise.reject(error);
}
);
```
### 8.5 API文档组件集成
在 `ApiDocs.vue` 组件中,需要确保在线调试功能自动携带token:
```typescript
// 生成请求预览时包含Authorization头
const generateSearchRequest = () => {
const token = localStorage.getItem('auth_token');
let headers = 'Content-Type: application/json\n';
if (token) {
headers += `Authorization: Bearer ${token}\n`;
}
if (searchMethod.value === 'POST') {
return `POST /api/search
${headers}
${JSON.stringify(payload, null, 2)}`;
}
// ... GET请求类似处理
};
```
### 8.6 健康检查接口扩展
`/api/health` 接口需要返回认证状态信息:
```go
func HealthHandler(c *gin.Context) {
// ... 现有逻辑 ...
response := gin.H{
"status": "ok",
"auth_enabled": config.AppConfig.AuthEnabled, // 新增
"plugins_enabled": pluginsEnabled,
"plugin_count": pluginCount,
"plugins": pluginNames,
"channels": channels,
"channels_count": channelsCount,
}
c.JSON(200, response)
}
```
### 8.7 环境变量配置
| 变量名 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `AUTH_ENABLED` | boolean | `false` | 是否启用认证功能 |
| `AUTH_USERS` | string | - | 用户配置,格式:`user1:pass1,user2:pass2` |
| `AUTH_TOKEN_EXPIRY` | int | `24` | Token有效期(小时) |
| `AUTH_JWT_SECRET` | string | 随机生成 | JWT签名密钥 |
### 8.8 安全考虑
1. **密码存储**: 生产环境应使用bcrypt等算法对密码进行哈希
2. **HTTPS传输**: 生产环境必须使用HTTPS保护token传输
3. **Token过期**: 合理设置token有效期,避免长期有效
4. **限流保护**: 对登录接口实施限流,防止暴力破解
5. **密钥管理**: JWT_SECRET应随机生成并妥善保管
### 8.9 性能影响
- **未启用认证**: 零性能影响,中间件直接放行
- **启用认证**: 每个请求增加约0.1-0.5ms的token验证时间
- **并发性能**: JWT无状态特性,对高并发无影响
- **缓存友好**: 认证不影响现有缓存机制
---
## 9. 插件开发框架
### 9.1 基础开发模板
```go
package myplugin
import (
"net/http"
"pansou/model"
"pansou/plugin"
)
type MyPlugin struct {
*plugin.BaseAsyncPlugin
}
func init() {
p := &MyPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("myplugin", 3),
}
plugin.RegisterGlobalPlugin(p)
}
func (p *MyPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
return p.AsyncSearch(keyword, p.searchImpl, p.GetMainCacheKey(), ext)
}
func (p *MyPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
// 实现具体搜索逻辑
// 1. 构建请求URL
// 2. 发送HTTP请求
// 3. 解析响应数据
// 4. 转换为标准格式
// 5. 关键词过滤
return plugin.FilterResultsByKeyword(results, keyword), nil
}
```
### 8.2 插件注册流程
1. **自动注册**: 通过`init()`函数自动注册到全局注册表
2. **管理器加载**: `PluginManager`统一管理所有插件
3. **导入触发**: 在`main.go`中通过空导入触发注册
### 8.3 开发最佳实践
- **命名规范**: 插件名使用小写字母
- **优先级设置**: 1-5,数字越小优先级越高
- **错误处理**: 详细错误信息,便于调试
- **资源管理**: 及时释放HTTP连接
---
## 10. 性能优化实现
### 10.1 环境配置优化
基于实际性能测试结果的配置方案:
#### 10.1.1 macOS优化配置
```bash
export HTTP_MAX_CONNS=200
export ASYNC_MAX_BACKGROUND_WORKERS=15
export ASYNC_MAX_BACKGROUND_TASKS=75
export CONCURRENCY=30
```
#### 9.1.2 服务器优化配置
```bash
export HTTP_MAX_CONNS=500
export ASYNC_MAX_BACKGROUND_WORKERS=40
export ASYNC_MAX_BACKGROUND_TASKS=200
export CONCURRENCY=50
```
### 9.2 日志控制系统
基于`config.go`的日志控制:
```bash
export ASYNC_LOG_ENABLED=false # 控制异步插件详细日志
```
异步插件缓存更新日志可通过环境变量开关,避免生产环境日志过多。
---
## 11. 技术选型说明
### 11.1 Go语言优势
- **并发支持**: 原生goroutine,适合高并发场景
- **性能优秀**: 编译型语言,接近C的性能
- **部署简单**: 单一可执行文件,无外部依赖
- **标准库丰富**: HTTP、JSON、并发原语完备
### 10.2 GIN框架选择
- **高性能**: 路由和中间件处理效率高
- **简洁易用**: API设计简洁,学习成本低
- **中间件生态**: 丰富的中间件支持
- **社区活跃**: 文档完善,问题解决快
### 10.3 GOB序列化选择
- **性能优势**: 比JSON快约30%
- **体积优势**: 比JSON小约20%
- **Go原生**: 无需第三方依赖
- **类型安全**: 保持Go类型信息
### 10.4 Sonic JSON库选择
- **高性能**: 比标准库encoding/json快3-5倍
- **统一处理**: 全局统一JSON序列化/反序列化
- **兼容性好**: 完全兼容标准JSON格式
- **内存优化**: 更高效的内存使用
### 10.5 无数据库架构
- **简化部署**: 无需数据库安装配置
- **降低复杂度**: 减少组件依赖
- **提升性能**: 避免数据库IO瓶颈
- **易于扩展**: 无状态设计,支持水平扩展
## /go.mod
```mod path="/go.mod"
module pansou
go 1.24.1
toolchain go1.24.9
require (
github.com/Advik-B/cloudscraper v0.0.0-20250623142001-d5e0e43555db
github.com/PuerkitoBio/goquery v1.8.1
github.com/bytedance/sonic v1.14.0
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.0
go.etcd.io/bbolt v1.4.0
golang.org/x/net v0.41.0
)
require (
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/robertkrimen/otto v0.5.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/sourcemap.v1 v1.0.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
```
## /go.sum
```sum path="/go.sum"
github.com/Advik-B/cloudscraper v0.0.0-20250623142001-d5e0e43555db h1:r1hesdkYWgm4Bf7abv6UsIUlrCdFxRdKy+DuVypOpw4=
github.com/Advik-B/cloudscraper v0.0.0-20250623142001-d5e0e43555db/go.mod h1:X4xeBaRgq6YCNFrPNd/AXnzGLWq2c46oJfIBh0iLOpI=
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0=
github.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
```
## /main.go
```go path="/main.go"
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"runtime"
"sort"
"strings"
"syscall"
"time"
"golang.org/x/net/netutil"
"pansou/api"
"pansou/config"
"pansou/plugin"
"pansou/service"
"pansou/util"
"pansou/util/cache"
// 以下是插件的空导入,用于触发各插件的init函数,实现自动注册
// 添加新插件时,只需在此处添加对应的导入语句即可
_ "pansou/plugin/ahhhhfs"
_ "pansou/plugin/aikanzy"
_ "pansou/plugin/alupan"
_ "pansou/plugin/ash"
_ "pansou/plugin/bixin"
_ "pansou/plugin/cldi"
_ "pansou/plugin/clmao"
_ "pansou/plugin/clxiong"
_ "pansou/plugin/cyg"
_ "pansou/plugin/daishudj"
_ "pansou/plugin/ddys"
_ "pansou/plugin/discourse"
_ "pansou/plugin/djgou"
_ "pansou/plugin/duoduo"
_ "pansou/plugin/dyyj"
_ "pansou/plugin/dyyjpro"
_ "pansou/plugin/erxiao"
_ "pansou/plugin/feikuai"
_ "pansou/plugin/fox4k"
_ "pansou/plugin/gaoqing888"
_ "pansou/plugin/gying"
_ "pansou/plugin/haisou"
_ "pansou/plugin/hdmoli"
_ "pansou/plugin/hdr4k"
_ "pansou/plugin/huban"
_ "pansou/plugin/hunhepan"
_ "pansou/plugin/javdb"
_ "pansou/plugin/jikepan"
_ "pansou/plugin/jsnoteclub"
_ "pansou/plugin/jutoushe"
_ "pansou/plugin/kkmao"
_ "pansou/plugin/kkv"
_ "pansou/plugin/labi"
_ "pansou/plugin/leijing"
_ "pansou/plugin/libvio"
_ "pansou/plugin/lou1"
_ "pansou/plugin/meitizy"
_ "pansou/plugin/miaoso"
_ "pansou/plugin/mikuclub"
_ "pansou/plugin/mizixing"
_ "pansou/plugin/muou"
_ "pansou/plugin/nsgame"
_ "pansou/plugin/nyaa"
_ "pansou/plugin/ouge"
_ "pansou/plugin/pan666"
_ "pansou/plugin/panlian"
_ "pansou/plugin/pansearch"
_ "pansou/plugin/panta"
_ "pansou/plugin/panwiki"
_ "pansou/plugin/panyq"
_ "pansou/plugin/pianku"
_ "pansou/plugin/qingying"
_ "pansou/plugin/qiwei"
_ "pansou/plugin/qqpd"
_ "pansou/plugin/quark4k"
_ "pansou/plugin/quarksoo"
_ "pansou/plugin/qupanshe"
_ "pansou/plugin/qupansou"
_ "pansou/plugin/sdso"
_ "pansou/plugin/shandian"
_ "pansou/plugin/sousou"
_ "pansou/plugin/susu"
_ "pansou/plugin/thepiratebay"
_ "pansou/plugin/u3c3"
_ "pansou/plugin/wanou"
_ "pansou/plugin/weibo"
_ "pansou/plugin/wuji"
_ "pansou/plugin/xb6v"
_ "pansou/plugin/xdpan"
_ "pansou/plugin/xdyh"
_ "pansou/plugin/xiaoji"
_ "pansou/plugin/xiaozhang"
_ "pansou/plugin/xinjuc"
_ "pansou/plugin/xuexizhinan"
_ "pansou/plugin/xys"
_ "pansou/plugin/yiove"
_ "pansou/plugin/ypfxw"
_ "pansou/plugin/yuhuage"
_ "pansou/plugin/yulinshufa"
_ "pansou/plugin/yunsou"
_ "pansou/plugin/melost"
_ "pansou/plugin/zhizhen"
_ "pansou/plugin/zxzj"
_ "pansou/plugin/duanjuw"
_ "pansou/plugin/jupansou"
_ "pansou/plugin/lingjisp"
_ "pansou/plugin/panzun"
_ "pansou/plugin/quarktv"
_ "pansou/plugin/yunso"
)
// 全局缓存写入管理器
var globalCacheWriteManager *cache.DelayedBatchWriteManager
func main() {
// 初始化应用
initApp()
// 启动服务器
startServer()
}
// initApp 初始化应用程序
func initApp() {
// 初始化配置
config.Init()
// 初始化HTTP客户端
util.InitHTTPClient()
// 初始化缓存写入管理器
var err error
globalCacheWriteManager, err = cache.NewDelayedBatchWriteManager()
if err != nil {
log.Fatalf("缓存写入管理器创建失败: %v", err)
}
if err := globalCacheWriteManager.Initialize(); err != nil {
log.Fatalf("缓存写入管理器初始化失败: %v", err)
}
// 将缓存写入管理器注入到service包
service.SetGlobalCacheWriteManager(globalCacheWriteManager)
// 延迟设置主缓存更新函数,确保service初始化完成
go func() {
// 等待一小段时间确保service包完全初始化
time.Sleep(100 * time.Millisecond)
if mainCache := service.GetEnhancedTwoLevelCache(); mainCache != nil {
globalCacheWriteManager.SetMainCacheUpdater(func(key string, data []byte, ttl time.Duration) error {
return mainCache.SetBothLevels(key, data, ttl)
})
}
}()
// 确保异步插件系统初始化
plugin.InitAsyncPluginSystem()
}
// startServer 启动Web服务器
func startServer() {
// 初始化插件管理器
pluginManager := plugin.NewPluginManager()
// 注册全局插件(根据配置过滤)
if config.AppConfig.AsyncPluginEnabled {
pluginManager.RegisterGlobalPluginsWithFilter(config.AppConfig.EnabledPlugins)
}
// 更新默认并发数(如果插件被禁用则使用0)
pluginCount := 0
if config.AppConfig.AsyncPluginEnabled {
pluginCount = len(pluginManager.GetPlugins())
}
config.UpdateDefaultConcurrency(pluginCount)
// 初始化搜索服务
searchService := service.NewSearchService(pluginManager)
// 设置路由
router := api.SetupRouter(searchService)
// 获取端口配置
port := config.AppConfig.Port
// 输出服务信息
printServiceInfo(port, pluginManager)
// 创建HTTP服务器
srv := &http.Server{
Addr: ":" + port,
Handler: router,
ReadTimeout: config.AppConfig.HTTPReadTimeout,
WriteTimeout: config.AppConfig.HTTPWriteTimeout,
IdleTimeout: config.AppConfig.HTTPIdleTimeout,
}
// 创建通道来接收操作系统信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// 在单独的goroutine中启动服务器
go func() {
// 如果设置了最大连接数,使用限制监听器
if config.AppConfig.HTTPMaxConns > 0 {
// 创建监听器
listener, err := net.Listen("tcp", srv.Addr)
if err != nil {
log.Fatalf("创建监听器失败: %v", err)
}
// 创建限制连接数的监听器
limitListener := netutil.LimitListener(listener, config.AppConfig.HTTPMaxConns)
// 使用限制监听器启动服务器
if err := srv.Serve(limitListener); err != nil && err != http.ErrServerClosed {
log.Fatalf("启动服务器失败: %v", err)
}
} else {
// 使用默认方式启动服务器(不限制连接数)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("启动服务器失败: %v", err)
}
}
}()
// 等待中断信号
<-quit
fmt.Println("正在关闭服务器...")
// 优先保存缓存数据到磁盘(数据安全第一)
// 增加关闭超时时间,确保数据有足够时间保存
shutdownTimeout := 10 * time.Second
if globalCacheWriteManager != nil {
if err := globalCacheWriteManager.Shutdown(shutdownTimeout); err != nil {
log.Printf("缓存数据保存失败: %v", err)
}
}
// 额外确保内存缓存也被保存(双重保障)
if mainCache := service.GetEnhancedTwoLevelCache(); mainCache != nil {
if err := mainCache.FlushMemoryToDisk(); err != nil {
log.Printf("内存缓存同步失败: %v", err)
}
}
// 设置关闭超时时间
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// 优雅关闭服务器
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("服务器关闭异常: %v", err)
}
fmt.Println("服务器已安全关闭")
}
// printServiceInfo 打印服务信息
func printServiceInfo(port string, pluginManager *plugin.PluginManager) {
// 启动服务器
fmt.Printf("服务器启动在 http://localhost:%s\n", port)
// 输出代理信息
hasProxy := false
if config.AppConfig.ProxyURL != "" {
proxyType := "代理"
if strings.HasPrefix(config.AppConfig.ProxyURL, "socks5://") {
proxyType = "SOCKS5代理"
} else if strings.HasPrefix(config.AppConfig.ProxyURL, "http://") {
proxyType = "HTTP代理"
} else if strings.HasPrefix(config.AppConfig.ProxyURL, "https://") {
proxyType = "HTTPS代理"
}
fmt.Printf("使用%s (PROXY): %s\n", proxyType, config.AppConfig.ProxyURL)
hasProxy = true
}
if config.AppConfig.HTTPProxyURL != "" {
fmt.Printf("使用HTTP代理 (HTTP_PROXY/http_proxy): %s\n", config.AppConfig.HTTPProxyURL)
hasProxy = true
}
if config.AppConfig.HTTPSProxyURL != "" {
fmt.Printf("使用HTTPS代理 (HTTPS_PROXY/https_proxy): %s\n", config.AppConfig.HTTPSProxyURL)
hasProxy = true
}
if !hasProxy {
fmt.Println("未使用代理")
}
// 输出并发信息
if os.Getenv("CONCURRENCY") != "" {
fmt.Printf("默认并发数: %d (由环境变量CONCURRENCY指定)\n", config.AppConfig.DefaultConcurrency)
} else {
channelCount := len(config.AppConfig.DefaultChannels)
pluginCount := 0
// 只有插件启用时才计算插件数
if config.AppConfig.AsyncPluginEnabled && pluginManager != nil {
pluginCount = len(pluginManager.GetPlugins())
}
fmt.Printf("默认并发数: %d (= 频道数%d + 插件数%d + 10)\n",
config.AppConfig.DefaultConcurrency, channelCount, pluginCount)
}
// 输出缓存信息
if config.AppConfig.CacheEnabled {
fmt.Printf("缓存已启用: 路径=%s, 最大大小=%dMB, TTL=%d分钟\n",
config.AppConfig.CachePath,
config.AppConfig.CacheMaxSizeMB,
config.AppConfig.CacheTTLMinutes)
} else {
fmt.Println("缓存已禁用")
}
// 输出压缩信息
if config.AppConfig.EnableCompression {
fmt.Printf("响应压缩已启用: 最小压缩大小=%d字节\n",
config.AppConfig.MinSizeToCompress)
}
// 输出GC配置信息
fmt.Printf("GC配置: 触发阈值=%d%%, 内存优化=%v\n",
config.AppConfig.GCPercent,
config.AppConfig.OptimizeMemory)
// 输出HTTP服务器配置信息
readTimeoutMsg := ""
if os.Getenv("HTTP_READ_TIMEOUT") != "" {
readTimeoutMsg = "(由环境变量指定)"
} else {
readTimeoutMsg = "(自动计算)"
}
writeTimeoutMsg := ""
if os.Getenv("HTTP_WRITE_TIMEOUT") != "" {
writeTimeoutMsg = "(由环境变量指定)"
} else {
writeTimeoutMsg = "(自动计算)"
}
maxConnsMsg := ""
if os.Getenv("HTTP_MAX_CONNS") != "" {
maxConnsMsg = "(由环境变量指定)"
} else {
cpuCount := runtime.NumCPU()
maxConnsMsg = fmt.Sprintf("(自动计算: CPU核心数%d × 200)", cpuCount)
}
fmt.Printf("HTTP服务器配置: 读取超时=%v %s, 写入超时=%v %s, 空闲超时=%v, 最大连接数=%d %s\n",
config.AppConfig.HTTPReadTimeout, readTimeoutMsg,
config.AppConfig.HTTPWriteTimeout, writeTimeoutMsg,
config.AppConfig.HTTPIdleTimeout,
config.AppConfig.HTTPMaxConns, maxConnsMsg)
// 输出异步插件配置信息
if config.AppConfig.AsyncPluginEnabled {
// 检查工作者数量是否由环境变量指定
workersMsg := ""
if os.Getenv("ASYNC_MAX_BACKGROUND_WORKERS") != "" {
workersMsg = "(由环境变量指定)"
} else {
cpuCount := runtime.NumCPU()
workersMsg = fmt.Sprintf("(自动计算: CPU核心数%d × 5)", cpuCount)
}
// 检查任务数量是否由环境变量指定
tasksMsg := ""
if os.Getenv("ASYNC_MAX_BACKGROUND_TASKS") != "" {
tasksMsg = "(由环境变量指定)"
} else {
tasksMsg = "(自动计算: 工作者数量 × 5)"
}
fmt.Printf("异步插件已启用: 响应超时=%d秒, 最大工作者=%d %s, 最大任务=%d %s, 缓存TTL=%d小时\n",
config.AppConfig.AsyncResponseTimeout,
config.AppConfig.AsyncMaxBackgroundWorkers, workersMsg,
config.AppConfig.AsyncMaxBackgroundTasks, tasksMsg,
config.AppConfig.AsyncCacheTTLHours)
} else {
fmt.Println("异步插件已禁用")
}
// 只有当插件功能启用时才输出插件信息
if config.AppConfig.AsyncPluginEnabled {
plugins := pluginManager.GetPlugins()
if len(plugins) > 0 {
// 根据新逻辑,只有指定了具体插件才会加载插件
fmt.Printf("已启用指定插件 (%d个):\n", len(plugins))
// 按优先级排序(优先级数字越小越靠前)
sort.Slice(plugins, func(i, j int) bool {
// 优先级相同时按名称排序
if plugins[i].Priority() == plugins[j].Priority() {
return plugins[i].Name() < plugins[j].Name()
}
return plugins[i].Priority() < plugins[j].Priority()
})
for _, p := range plugins {
fmt.Printf(" - %s (优先级: %d)\n", p.Name(), p.Priority())
}
} else {
// 区分不同的情况
if config.AppConfig.EnabledPlugins == nil {
fmt.Println("未设置插件列表 (ENABLED_PLUGINS),未加载任何插件")
} else if len(config.AppConfig.EnabledPlugins) > 0 {
fmt.Printf("未找到指定的插件: %s\n", strings.Join(config.AppConfig.EnabledPlugins, ", "))
} else {
fmt.Println("插件列表为空 (ENABLED_PLUGINS=\"\"),未加载任何插件")
}
}
}
}
```
## /model/check.go
```go path="/model/check.go"
package model
type CheckItem struct {
DiskType string `json:"disk_type" binding:"required"`
URL string `json:"url" binding:"required"`
Password string `json:"password,omitempty"`
}
type CheckRequest struct {
Items []CheckItem `json:"items" binding:"required"`
ViewToken string `json:"view_token,omitempty"`
Proxy string `json:"proxy,omitempty"`
ProxyURL string `json:"proxy_url,omitempty"`
}
type CheckResult struct {
DiskType string `json:"disk_type"`
URL string `json:"url"`
NormalizedURL string `json:"normalized_url,omitempty"`
State string `json:"state"`
CacheHit bool `json:"cache_hit"`
CheckedAt int64 `json:"checked_at"`
ExpiresAt int64 `json:"expires_at"`
Summary string `json:"summary,omitempty"`
}
type CheckResponse struct {
Results []CheckResult `json:"results"`
}
```
## /model/plugin_result.go
```go path="/model/plugin_result.go"
package model
import (
"time"
)
// PluginSearchResult 插件搜索结果
type PluginSearchResult struct {
Results []SearchResult `json:"results"` // 搜索结果
IsFinal bool `json:"is_final"` // 是否为最终完整结果
Timestamp time.Time `json:"timestamp"` // 结果时间戳
Source string `json:"source"` // 插件来源
Message string `json:"message"` // 状态描述(可选)
}
// IsEmpty 检查结果是否为空
func (p *PluginSearchResult) IsEmpty() bool {
return len(p.Results) == 0
}
// Count 返回结果数量
func (p *PluginSearchResult) Count() int {
return len(p.Results)
}
// GetResults 获取搜索结果列表
func (p *PluginSearchResult) GetResults() []SearchResult {
if p.Results == nil {
return []SearchResult{}
}
return p.Results
}
```
## /model/request.go
```go path="/model/request.go"
package model
// FilterConfig 过滤配置
type FilterConfig struct {
Include []string `json:"include,omitempty"` // 包含关键词列表(OR关系)
Exclude []string `json:"exclude,omitempty"` // 排除关键词列表(AND关系)
}
// SearchRequest 搜索请求参数
type SearchRequest struct {
Keyword string `json:"kw" binding:"required"` // 搜索关键词
Channels []string `json:"channels"` // 搜索的频道列表
Concurrency int `json:"conc"` // 并发搜索数量
ForceRefresh bool `json:"refresh"` // 强制刷新,不使用缓存
ResultType string `json:"res"` // 结果类型:all(返回所有结果)、results(仅返回results)、merge(仅返回merged_by_type)
SourceType string `json:"src"` // 数据来源类型:all(默认,全部来源)、tg(仅Telegram)、plugin(仅插件)
Plugins []string `json:"plugins"` // 指定搜索的插件列表,不指定则搜索全部插件
Ext map[string]interface{} `json:"ext"` // 扩展参数,用于传递给插件的自定义参数
CloudTypes []string `json:"cloud_types"` // 指定返回的网盘类型列表,不指定则返回所有类型
Filter *FilterConfig `json:"filter,omitempty"` // 过滤配置,用于过滤返回结果
}
```
## /model/response.go
```go path="/model/response.go"
package model
import "time"
// Link 网盘链接
type Link struct {
Type string `json:"type" sonic:"type"`
URL string `json:"url" sonic:"url"`
Password string `json:"password" sonic:"password"`
Datetime time.Time `json:"datetime,omitempty" sonic:"datetime,omitempty"` // 链接更新时间(可选)
WorkTitle string `json:"work_title,omitempty" sonic:"work_title,omitempty"` // 作品标题(用于区分同一消息中多个作品的链接)
}
// SearchResult 搜索结果
type SearchResult struct {
MessageID string `json:"message_id" sonic:"message_id"`
UniqueID string `json:"unique_id" sonic:"unique_id"` // 全局唯一ID
Channel string `json:"channel" sonic:"channel"`
Datetime time.Time `json:"datetime" sonic:"datetime"`
Title string `json:"title" sonic:"title"`
Content string `json:"content" sonic:"content"`
Links []Link `json:"links" sonic:"links"`
Tags []string `json:"tags,omitempty" sonic:"tags,omitempty"`
Images []string `json:"images,omitempty" sonic:"images,omitempty"` // TG消息中的图片链接
}
// MergedLink 合并后的网盘链接
type MergedLink struct {
URL string `json:"url" sonic:"url"`
Password string `json:"password" sonic:"password"`
Note string `json:"note" sonic:"note"`
Datetime time.Time `json:"datetime" sonic:"datetime"`
Source string `json:"source,omitempty" sonic:"source,omitempty"` // 数据来源:tg:频道名 或 plugin:插件名
Images []string `json:"images,omitempty" sonic:"images,omitempty"` // TG消息中的图片链接
}
// MergedLinks 按网盘类型分组的合并链接
type MergedLinks map[string][]MergedLink
// SearchResponse 搜索响应
type SearchResponse struct {
Total int `json:"total" sonic:"total"`
Results []SearchResult `json:"results,omitempty" sonic:"results,omitempty"`
MergedByType MergedLinks `json:"merged_by_type,omitempty" sonic:"merged_by_type,omitempty"`
}
// Response API通用响应
type Response struct {
Code int `json:"code" sonic:"code"`
Message string `json:"message" sonic:"message"`
Data interface{} `json:"data,omitempty" sonic:"data,omitempty"`
}
// NewSuccessResponse 创建成功响应
func NewSuccessResponse(data interface{}) Response {
return Response{
Code: 0,
Message: "success",
Data: data,
}
}
// NewErrorResponse 创建错误响应
func NewErrorResponse(code int, message string) Response {
return Response{
Code: code,
Message: message,
}
}
```
## /plugin/ahhhhfs/ahhhhfs.go
```go path="/plugin/ahhhhfs/ahhhhfs.go"
package ahhhhfs
import (
"context"
"fmt"
"net/http"
"net/url"
"pansou/model"
"pansou/plugin"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/PuerkitoBio/goquery"
)
// 预编译的正则表达式
var (
// 从详情页URL中提取文章ID的正则表达式
articleIDRegex = regexp.MustCompile(`/(\d+)/?$`)
// 常见网盘链接的正则表达式
quarkLinkRegex = regexp.MustCompile(`https?://pan\.quark\.cn/s/[0-9a-zA-Z]+`)
baiduLinkRegex = regexp.MustCompile(`https?://pan\.baidu\.com/s/[0-9a-zA-Z_\-]+`)
aliyunLinkRegex = regexp.MustCompile(`https?://(www\.)?(aliyundrive\.com|alipan\.com)/s/[0-9a-zA-Z]+`)
ucLinkRegex = regexp.MustCompile(`https?://drive\.uc\.cn/s/[0-9a-zA-Z]+`)
xunleiLinkRegex = regexp.MustCompile(`https?://pan\.xunlei\.com/s/[0-9a-zA-Z_\-]+`)
tianyiLinkRegex = regexp.MustCompile(`https?://cloud\.189\.cn/(t|web)/[0-9a-zA-Z]+`)
link115Regex = regexp.MustCompile(`https?://115\.com/s/[0-9a-zA-Z]+`)
link123Regex = regexp.MustCompile(`https?://123pan\.com/s/[0-9a-zA-Z]+`)
pikpakLinkRegex = regexp.MustCompile(`https?://mypikpak\.com/s/[0-9a-zA-Z]+`)
// 提取码匹配模式
pwdPatterns = []*regexp.Regexp{
regexp.MustCompile(`提取码[::]\s*([0-9a-zA-Z]+)`),
regexp.MustCompile(`密码[::]\s*([0-9a-zA-Z]+)`),
regexp.MustCompile(`pwd[=::]\s*([0-9a-zA-Z]+)`),
regexp.MustCompile(`code[=::]\s*([0-9a-zA-Z]+)`),
}
// 缓存相关
detailCache = sync.Map{} // 缓存详情页解析结果
lastCleanupTime = time.Now()
cacheTTL = 1 * time.Hour
)
const (
// 插件名称
pluginName = "ahhhhfs"
// 优先级
defaultPriority = 2
// 超时时间
DefaultTimeout = 10 * time.Second
DetailTimeout = 8 * time.Second
// 并发数限制
MaxConcurrency = 15
// HTTP连接池配置
MaxIdleConns = 100
MaxIdleConnsPerHost = 30
MaxConnsPerHost = 50
IdleConnTimeout = 90 * time.Second
)
// 性能统计
var (
searchRequests int64 = 0
detailPageRequests int64 = 0
cacheHits int64 = 0
cacheMisses int64 = 0
)
// AhhhhfsAsyncPlugin ahhhhfs异步插件
type AhhhhfsAsyncPlugin struct {
*plugin.BaseAsyncPlugin
optimizedClient *http.Client
}
// 在init函数中注册插件
func init() {
plugin.RegisterGlobalPlugin(NewAhhhhfsPlugin())
// 启动缓存清理goroutine
go startCacheCleaner()
}
// startCacheCleaner 启动一个定期清理缓存的goroutine
func startCacheCleaner() {
ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop()
for range ticker.C {
// 清空所有缓存
detailCache = sync.Map{}
lastCleanupTime = time.Now()
}
}
// createOptimizedHTTPClient 创建优化的HTTP客户端
func createOptimizedHTTPClient() *http.Client {
transport := &http.Transport{
MaxIdleConns: MaxIdleConns,
MaxIdleConnsPerHost: MaxIdleConnsPerHost,
MaxConnsPerHost: MaxConnsPerHost,
IdleConnTimeout: IdleConnTimeout,
DisableKeepAlives: false,
}
return &http.Client{
Transport: transport,
Timeout: DefaultTimeout,
}
}
// NewAhhhhfsPlugin 创建新的ahhhhfs异步插件
func NewAhhhhfsPlugin() *AhhhhfsAsyncPlugin {
return &AhhhhfsAsyncPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),
optimizedClient: createOptimizedHTTPClient(),
}
}
// Search 执行搜索并返回结果(兼容性方法)
func (p *AhhhhfsAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
result, err := p.SearchWithResult(keyword, ext)
if err != nil {
return nil, err
}
return result.Results, nil
}
// SearchWithResult 执行搜索并返回包含IsFinal标记的结果
func (p *AhhhhfsAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)
}
// searchImpl 实现具体的搜索逻辑
func (p *AhhhhfsAsyncPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
// 性能统计
start := time.Now()
atomic.AddInt64(&searchRequests, 1)
defer func() {
fmt.Printf("[%s] 搜索耗时: %v\n", p.Name(), time.Since(start))
}()
// 使用优化的客户端
if p.optimizedClient != nil {
client = p.optimizedClient
}
// 1. 构建搜索URL
searchURL := fmt.Sprintf("https://www.ahhhhfs.com/?cat=&s=%s", url.QueryEscape(keyword))
// 2. 创建带超时的上下文
ctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancel()
// 3. 创建请求
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err)
}
// 4. 设置完整的请求头(避免反爬虫)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Upgrade-Insecure-Requests", "1")
req.Header.Set("Cache-Control", "max-age=0")
req.Header.Set("Referer", "https://www.ahhhhfs.com/")
// 5. 发送请求(带重试机制)
resp, err := p.doRequestWithRetry(req, client)
if err != nil {
return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("[%s] 搜索请求返回状态码: %d", p.Name(), resp.StatusCode)
}
// 6. 解析搜索结果页面
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("[%s] 解析搜索页面失败: %w", p.Name(), err)
}
// 7. 提取搜索结果
var results []model.SearchResult
var wg sync.WaitGroup
var mu sync.Mutex
semaphore := make(chan struct{}, MaxConcurrency)
doc.Find("article.post-item.item-list").Each(func(i int, s *goquery.Selection) {
// 解析基本信息
titleElem := s.Find(".entry-title a")
title := strings.TrimSpace(titleElem.Text())
if title == "" {
title = strings.TrimSpace(titleElem.AttrOr("title", ""))
}
detailURL, exists := titleElem.Attr("href")
if !exists || detailURL == "" || title == "" {
return
}
// 提取文章ID
articleID := p.extractArticleID(detailURL)
if articleID == "" {
return
}
// 提取分类标签
var tags []string
s.Find(".entry-cat-dot a").Each(func(j int, tag *goquery.Selection) {
tagText := strings.TrimSpace(tag.Text())
if tagText != "" {
tags = append(tags, tagText)
}
})
// 提取描述
content := strings.TrimSpace(s.Find(".entry-desc").Text())
// 提取时间
datetime := ""
timeElem := s.Find(".entry-meta .meta-date time")
if dt, exists := timeElem.Attr("datetime"); exists {
datetime = dt
} else {
datetime = strings.TrimSpace(timeElem.Text())
}
// 解析时间
publishTime := p.parseDateTime(datetime)
// 异步获取详情页的网盘链接
wg.Add(1)
semaphore <- struct{}{} // 获取信号量
go func(title, detailURL, articleID, content string, tags []string, publishTime time.Time) {
defer wg.Done()
defer func() { <-semaphore }() // 释放信号量
// 获取网盘链接
links := p.fetchDetailLinks(client, detailURL, articleID)
if len(links) > 0 {
result := model.SearchResult{
UniqueID: fmt.Sprintf("%s-%s", p.Name(), articleID),
Title: title,
Content: content,
Links: links,
Tags: tags,
Channel: "", // 插件搜索结果 Channel 必须为空
Datetime: publishTime,
}
mu.Lock()
results = append(results, result)
mu.Unlock()
}
}(title, detailURL, articleID, content, tags, publishTime)
})
// 等待所有详情页请求完成
wg.Wait()
fmt.Printf("[%s] 搜索结果: %d 条\n", p.Name(), len(results))
// 关键词过滤
return plugin.FilterResultsByKeyword(results, keyword), nil
}
// extractArticleID 从URL中提取文章ID
func (p *AhhhhfsAsyncPlugin) extractArticleID(detailURL string) string {
matches := articleIDRegex.FindStringSubmatch(detailURL)
if len(matches) >= 2 {
return matches[1]
}
return ""
}
// parseDateTime 解析时间字符串
func (p *AhhhhfsAsyncPlugin) parseDateTime(datetime string) time.Time {
datetime = strings.TrimSpace(datetime)
// 尝试解析 ISO 格式
if t, err := time.Parse(time.RFC3339, datetime); err == nil {
return t
}
// 尝试解析标准日期格式
layouts := []string{
"2006-01-02",
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
"2006-01-02T15:04:05Z07:00",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, datetime); err == nil {
return t
}
}
// 处理相对时间(如"1 周前"、"2 天前")
now := time.Now()
if strings.Contains(datetime, "小时前") || strings.Contains(datetime, "hours ago") {
// 简单处理,返回当天
return now
}
if strings.Contains(datetime, "天前") || strings.Contains(datetime, "days ago") {
// 简单处理,返回近期
return now.AddDate(0, 0, -7)
}
if strings.Contains(datetime, "周前") || strings.Contains(datetime, "weeks ago") {
// 简单处理,返回一个月前
return now.AddDate(0, -1, 0)
}
// 默认返回当前时间
return now
}
// fetchDetailLinks 获取详情页的网盘链接
func (p *AhhhhfsAsyncPlugin) fetchDetailLinks(client *http.Client, detailURL, articleID string) []model.Link {
atomic.AddInt64(&detailPageRequests, 1)
// 检查缓存
if cached, ok := detailCache.Load(articleID); ok {
atomic.AddInt64(&cacheHits, 1)
return cached.([]model.Link)
}
atomic.AddInt64(&cacheMisses, 1)
// 创建带超时的上下文
ctx, cancel := context.WithTimeout(context.Background(), DetailTimeout)
defer cancel()
// 创建请求
req, err := http.NewRequestWithContext(ctx, "GET", detailURL, nil)
if err != nil {
fmt.Printf("[%s] 创建详情页请求失败: %v\n", p.Name(), err)
return nil
}
// 设置请求头
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Referer", "https://www.ahhhhfs.com/")
// 发送请求
resp, err := client.Do(req)
if err != nil {
fmt.Printf("[%s] 详情页请求失败: %v\n", p.Name(), err)
return nil
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
fmt.Printf("[%s] 详情页返回状态码: %d\n", p.Name(), resp.StatusCode)
return nil
}
// 解析详情页
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
fmt.Printf("[%s] 解析详情页失败: %v\n", p.Name(), err)
return nil
}
// 提取网盘链接
links := p.extractNetDiskLinks(doc)
// 缓存结果
if len(links) > 0 {
detailCache.Store(articleID, links)
}
return links
}
// extractNetDiskLinks 从详情页提取网盘链接
func (p *AhhhhfsAsyncPlugin) extractNetDiskLinks(doc *goquery.Document) []model.Link {
var links []model.Link
linkMap := make(map[string]model.Link) // 用于去重
// 在文章内容中查找所有链接
doc.Find(".post-content a").Each(func(i int, s *goquery.Selection) {
href, exists := s.Attr("href")
if !exists || href == "" {
return
}
// 判断是否为网盘链接
cloudType := p.determineCloudType(href)
if cloudType == "others" {
return
}
// 提取提取码
password := p.extractPassword(s, href)
// 添加到结果(去重)
if _, exists := linkMap[href]; !exists {
link := model.Link{
Type: cloudType,
URL: href,
Password: password,
}
linkMap[href] = link
links = append(links, link)
}
})
return links
}
// determineCloudType 判断链接类型
func (p *AhhhhfsAsyncPlugin) determineCloudType(url string) string {
switch {
case strings.Contains(url, "pan.quark.cn"):
return "quark"
case strings.Contains(url, "drive.uc.cn"):
return "uc"
case strings.Contains(url, "pan.baidu.com"):
return "baidu"
case strings.Contains(url, "aliyundrive.com") || strings.Contains(url, "alipan.com"):
return "aliyun"
case strings.Contains(url, "pan.xunlei.com"):
return "xunlei"
case strings.Contains(url, "cloud.189.cn"):
return "tianyi"
case strings.Contains(url, "115.com"):
return "115"
case strings.Contains(url, "123pan.com"):
return "123"
case strings.Contains(url, "mypikpak.com"):
return "pikpak"
default:
return "others"
}
}
// extractPassword 提取提取码
func (p *AhhhhfsAsyncPlugin) extractPassword(linkElem *goquery.Selection, url string) string {
// 1. 从链接的 title 属性中提取
if title, exists := linkElem.Attr("title"); exists {
for _, pattern := range pwdPatterns {
if matches := pattern.FindStringSubmatch(title); len(matches) >= 2 {
return matches[1]
}
}
}
// 2. 从链接文本中提取
linkText := linkElem.Text()
for _, pattern := range pwdPatterns {
if matches := pattern.FindStringSubmatch(linkText); len(matches) >= 2 {
return matches[1]
}
}
// 3. 从链接后面的兄弟节点或父节点的文本中提取
parent := linkElem.Parent()
parentText := parent.Text()
// 获取链接在父元素文本中的位置
linkIndex := strings.Index(parentText, linkText)
if linkIndex >= 0 {
// 获取链接后面的文本
afterText := parentText[linkIndex+len(linkText):]
for _, pattern := range pwdPatterns {
if matches := pattern.FindStringSubmatch(afterText); len(matches) >= 2 {
return matches[1]
}
}
}
// 4. 从 URL 参数中提取
if strings.Contains(url, "pwd=") {
parts := strings.Split(url, "pwd=")
if len(parts) >= 2 {
pwd := parts[1]
// 只取密码部分(去除其他参数)
if idx := strings.IndexAny(pwd, "&?#"); idx >= 0 {
pwd = pwd[:idx]
}
return pwd
}
}
return ""
}
// doRequestWithRetry 带重试机制的HTTP请求
func (p *AhhhhfsAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {
maxRetries := 3
var lastErr error
for i := 0; i < maxRetries; i++ {
if i > 0 {
// 指数退避重试
backoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond
time.Sleep(backoff)
}
// 克隆请求避免并发问题
reqClone := req.Clone(req.Context())
resp, err := client.Do(reqClone)
if err == nil && resp.StatusCode == 200 {
return resp, nil
}
if resp != nil {
resp.Body.Close()
}
lastErr = err
}
return nil, fmt.Errorf("重试 %d 次后仍然失败: %w", maxRetries, lastErr)
}
```
## /plugin/ahhhhfs/htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md
# ahhhhfs (A姐分享) HTML结构分析
## 网站信息
- **网站名称**: ahhhhfs (A姐分享)
- **域名**: www.ahhhhfs.com
- **类型**: 资源分享网站(WordPress 站点)
- **特点**: 分享各类学习资源、软件、教程等
## 搜索页面结构
### 1. 搜索URL模式
```
https://www.ahhhhfs.com/search/{关键词}
或
https://www.ahhhhfs.com/?s={关键词}
示例:
https://www.ahhhhfs.com/search/小红书
https://www.ahhhhfs.com/?s=小红书
参数说明:
- 关键词: 直接使用中文或URL编码都可以
```
### 2. 搜索结果容器
- **父容器**: `.row` (结果列表容器)
- **结果项**: `<article class="post-item item-list">` (每个搜索结果)
### 3. 单个搜索结果结构
#### 标题区域 (.entry-title)
```html
<h2 class="entry-title">
<a target="_blank" href="https://www.ahhhhfs.com/76567/"
title="AI小红书虚拟电商全链路实战课:从选品到变现的AI爆款打法">
AI小红书虚拟电商全链路实战课:从选品到变现的AI爆款打法
</a>
</h2>
提取要素:
- 标题: a 的文本内容或 title 属性
- 详情页链接: a 的 href 属性
```
#### 分类标签 (.entry-cat-dot)
```html
<div class="entry-cat-dot">
<a href="https://www.ahhhhfs.com/recourse/%e7%9f%ad%e8%a7%86%e9%a2%91/">短视频</a>
<a href="https://www.ahhhhfs.com/recourse/">资源</a>
</div>
提取要素:
- 分类: 所有 a 标签的文本内容
```
#### 描述区域 (.entry-desc)
```html
<div class="entry-desc">
AI小红书虚拟电商全链路实战课程概览 《AI小红书虚拟电商5.0实战课》是一门聚焦AI与小红书生态融合的系统课程,围绕AI赋能选品、创作、运营与变现四大环节展开...
</div>
提取要素:
- 描述: div 的文本内容
```
#### 元数据栏 (.entry-meta)
```html
<div class="entry-meta">
<span class="meta-date">
<i class="far fa-clock me-1"></i>
<time class="pub-date" datetime="2025-10-18T13:43:10+08:00">1 周前</time>
</span>
<span class="meta-likes d-none d-md-inline-block"><i class="far fa-heart me-1"></i>0</span>
<span class="meta-fav d-none d-md-inline-block"><i class="far fa-star me-1"></i>1</span>
</div>
提取要素:
- 发布时间: time 标签的 datetime 属性或文本内容
```
## 详情页面结构
### 1. 详情页URL模式
```
https://www.ahhhhfs.com/{文章ID}/
示例:
https://www.ahhhhfs.com/76567/
```
### 2. 下载链接位置
下载链接在文章正文内容中 `.post-content` 里面,通常在文章末尾部分。
#### 下载链接格式示例
```html
<p>
学习地址:
<a title="..."
href="https://pan.quark.cn/s/c16a5ae18ea0"
target="_blank"
rel="nofollow noopener noreferrer">夸克</a>
</p>
或者
<p>
下载地址:
<a href="https://pan.baidu.com/s/xxxxx"
target="_blank"
rel="nofollow noopener noreferrer">百度网盘</a>
提取码: xxxx
</p>
或者多个网盘链接:
<p>
阿里云盘:<a href="...">链接</a><br>
夸克网盘:<a href="...">链接</a><br>
百度网盘:<a href="...">链接</a> 提取码: xxxx
</p>
提取要素:
- 网盘链接: .post-content 中包含网盘域名的 a 标签的 href 属性
- 提取码/密码: 链接附近的文本内容,可能包含 "提取码"、"密码"、"pwd" 等关键词
```
## CSS选择器总结
| 数据项 | CSS选择器 | 提取方式 |
|--------|-----------|----------|
| 搜索结果列表 | `article.post-item.item-list` | 遍历所有结果项 |
| 标题 | `.entry-title a` | 文本内容或 title 属性 |
| 详情页链接 | `.entry-title a` | href 属性 |
| 分类标签 | `.entry-cat-dot a` | 所有 a 标签的文本内容 |
| 描述 | `.entry-desc` | 文本内容 |
| 发布时间 | `.entry-meta .meta-date time` | datetime 属性或文本内容 |
| 文章内容 | `.post-content` | HTML 内容 |
| 网盘链接 | `.post-content a[href*="pan"]` 或匹配网盘域名 | href 属性 |
## 实现要点
### 1. 支持的网盘类型
- 夸克网盘: `pan.quark.cn`
- 阿里云盘: `aliyundrive.com`, `alipan.com`
- 百度网盘: `pan.baidu.com`
- UC网盘: `drive.uc.cn`
- 迅雷网盘: `pan.xunlei.com`
- 天翼云盘: `cloud.189.cn`
- 115网盘: `115.com`
- 123网盘: `123pan.com`
### 2. 提取码识别
提取码可能出现在以下位置:
- 链接后面的文本: `提取码: xxxx` 或 `密码: xxxx`
- 链接的 title 属性中
- `<br>` 标签分隔的下一行
- 括号内: `(提取码: xxxx)`
常见关键词:
- 提取码
- 密码
- pwd
- code
- 取码
### 3. 链接提取策略
1. 先从搜索结果页获取文章列表
2. 访问每篇文章的详情页
3. 在详情页的 `.post-content` 中查找包含网盘域名的链接
4. 提取链接和相应的提取码
5. 如果文章没有网盘链接,则跳过
### 4. 时间格式处理
- 相对时间: "1 周前"、"2 天前" 需要转换为具体日期
- 绝对时间: "2025-10-18" 可以直接使用
- datetime 属性: "2025-10-18T13:43:10+08:00" 标准ISO格式
### 5. 去重标识
- 使用文章ID作为唯一标识: 从详情页URL中提取 `/76567/`
## 注意事项
1. **搜索结果可能为空**: 如果关键词没有匹配结果,页面会显示"没有找到相关内容"
2. **分页**: 搜索结果可能有多页,但通常只抓取第一页即可
3. **网盘链接位置不固定**: 链接可能在文章开头、中间或结尾,需要遍历整个 `.post-content`
4. **广告干扰**: 页面包含广告,需要准确定位到实际内容区域
5. **需要访问详情页**: 搜索结果页不包含下载链接,必须访问详情页才能获取
6. **请求频率**: 需要访问详情页,建议控制请求频率避免被封
## 示例数据流
```
1. 搜索请求: https://www.ahhhhfs.com/search/小红书
↓
2. 解析搜索结果页,提取文章列表
- 标题: "AI小红书虚拟电商全链路实战课:从选品到变现的AI爆款打法"
- 详情页URL: https://www.ahhhhfs.com/76567/
- 分类: ["短视频", "资源"]
- 发布时间: 2025-10-18
↓
3. 访问详情页: https://www.ahhhhfs.com/76567/
↓
4. 解析详情页 .post-content,提取网盘链接
- 夸克网盘: https://pan.quark.cn/s/c16a5ae18ea0
- 提取码: (如果有)
↓
5. 构建最终结果
- UniqueID: ahhhhfs-76567
- Title: "AI小红书虚拟电商全链路实战课:从选品到变现的AI爆款打法"
- Content: 文章描述
- Links: [{Type: "quark", URL: "...", Password: ""}]
- Tags: ["短视频", "资源"]
- Datetime: 2025-10-18T13:43:10+08:00
```
## /plugin/aikanzy/aikanzy.go
```go path="/plugin/aikanzy/aikanzy.go"
package aikanzy
import (
"context"
"fmt"
"net/http"
"net/url"
"pansou/model"
"pansou/plugin"
"regexp"
"strings"
"sync"
"time"
"github.com/PuerkitoBio/goquery"
)
// 预编译的正则表达式
var (
// 夸克网盘链接
quarkLinkRegex = regexp.MustCompile(`https?://pan\.quark\.cn/s/[0-9a-zA-Z]+`)
// UC网盘链接
ucLinkRegex = regexp.MustCompile(`https?://drive\.uc\.cn/s/[0-9a-zA-Z]+(\?[^"'\s]*)?`)
// 百度网盘链接
baiduLinkRegex = regexp.MustCompile(`https?://pan\.baidu\.com/s/[0-9a-zA-Z_-]+`)
// 迅雷网盘链接
xunleiLinkRegex = regexp.MustCompile(`https?://pan\.xunlei\.com/s/[0-9a-zA-Z_-]+`)
// 从URL中提取文章ID
articleIDRegex = regexp.MustCompile(`/([a-z]+)/(\d+)\.html`)
// 提取阅读数
viewCountRegex = regexp.MustCompile(`(\d+)\s*阅读`)
)
// 常量定义
const (
// 插件名称
pluginName = "aikanzy"
// 搜索URL模板
searchURLTemplate = "https://www.aikanzy.com/search?word=%s&molds=article"
// 默认优先级
defaultPriority = 3
// 默认超时时间(秒)
defaultTimeout = 15
// 详情页超时时间(秒)
detailTimeout = 8
// 最大重试次数
maxRetries = 3
// 详情页并发数
detailConcurrency = 15
// 指数退避基数(毫秒)
backoffBase = 200
)
// AikanzyAsyncPlugin 是AikanZY网站的异步搜索插件实现
type AikanzyAsyncPlugin struct {
*plugin.BaseAsyncPlugin
optimizedClient *http.Client
}
// 确保AikanzyAsyncPlugin实现了AsyncSearchPlugin接口
var _ plugin.AsyncSearchPlugin = (*AikanzyAsyncPlugin)(nil)
// 在包初始化时注册插件
func init() {
plugin.RegisterGlobalPlugin(NewAikanzyAsyncPlugin())
}
// createOptimizedHTTPClient 创建优化的HTTP客户端
func createOptimizedHTTPClient() *http.Client {
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
DisableKeepAlives: false,
}
return &http.Client{
Transport: transport,
Timeout: defaultTimeout * time.Second,
}
}
// NewAikanzyAsyncPlugin 创建一个新的AikanZY异步插件实例
func NewAikanzyAsyncPlugin() *AikanzyAsyncPlugin {
return &AikanzyAsyncPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("aikanzy", defaultPriority),
optimizedClient: createOptimizedHTTPClient(),
}
}
// Name 返回插件名称
func (p *AikanzyAsyncPlugin) Name() string {
return pluginName
}
// Priority 返回插件优先级
func (p *AikanzyAsyncPlugin) Priority() int {
return defaultPriority
}
// Search 执行搜索并返回结果(兼容性方法)
func (p *AikanzyAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
result, err := p.SearchWithResult(keyword, ext)
if err != nil {
return nil, err
}
return result.Results, nil
}
// SearchWithResult 执行搜索并返回包含IsFinal标记的结果
func (p *AikanzyAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)
}
// doSearch 执行具体的搜索逻辑
func (p *AikanzyAsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
// 使用优化的客户端
if p.optimizedClient != nil {
client = p.optimizedClient
}
// 对关键词进行URL编码
encodedKeyword := url.QueryEscape(keyword)
// 构建搜索URL
searchURL := fmt.Sprintf(searchURLTemplate, encodedKeyword)
// 创建一个带有超时的上下文
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout*time.Second)
defer cancel()
// 创建请求
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err)
}
// 设置完整的请求头(避免反爬虫)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Referer", "https://www.aikanzy.com/")
req.Header.Set("Upgrade-Insecure-Requests", "1")
req.Header.Set("Cache-Control", "max-age=0")
// 使用带重试的请求方法发送HTTP请求
resp, err := p.doRequestWithRetry(req, client)
if err != nil {
return nil, fmt.Errorf("[%s] 请求搜索页面失败: %w", p.Name(), err)
}
defer resp.Body.Close()
// 检查状态码
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("[%s] 请求搜索页面失败,状态码: %d", p.Name(), resp.StatusCode)
}
// 使用goquery解析HTML
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("[%s] 解析HTML失败: %w", p.Name(), err)
}
// 解析搜索结果列表
articleItems := p.parseArticleList(doc)
if len(articleItems) == 0 {
return []model.SearchResult{}, nil
}
// 并发抓取详情页获取网盘链接
results := p.fetchDetailsWithLinks(articleItems, client, keyword)
// 使用过滤功能过滤结果
filteredResults := plugin.FilterResultsByKeyword(results, keyword)
return filteredResults, nil
}
// ArticleItem 文章基本信息
type ArticleItem struct {
ID string
Title string
DetailURL string
Category string
PublishDate string
ViewCount int
Summary string
ImageURL string
}
// parseArticleList 解析文章列表
func (p *AikanzyAsyncPlugin) parseArticleList(doc *goquery.Document) []ArticleItem {
var items []ArticleItem
// 查找所有文章项
doc.Find("article.post-list.contt.blockimg").Each(func(i int, s *goquery.Selection) {
// 提取详情页链接
detailLink := s.Find("a[href]").First()
detailURL, exists := detailLink.Attr("href")
if !exists || detailURL == "" {
return
}
// 提取文章ID
articleID := p.extractArticleID(detailURL)
if articleID == "" {
return
}
// 提取标题
title := strings.TrimSpace(s.Find("header.entry-header span.entry-title a").Text())
// 移除标题中的HTML标签(如<b>)
title = p.cleanHTMLTags(title)
if title == "" {
return
}
// 提取分类
category := strings.TrimSpace(s.Find("div.entry-meta > a").First().Text())
// 提取发布日期
publishDate := strings.TrimSpace(s.Find("time").First().Text())
// 提取阅读数
metaText := s.Find("div.entry-meta").Text()
viewCount := p.extractViewCount(metaText)
// 提取摘要
summary := strings.TrimSpace(s.Find("div.entry-summary.ss p").Text())
summary = p.cleanHTMLTags(summary)
// 提取缩略图
imageURL, _ := s.Find("img.block-fea").Attr("data-src")
items = append(items, ArticleItem{
ID: articleID,
Title: title,
DetailURL: detailURL,
Category: category,
PublishDate: publishDate,
ViewCount: viewCount,
Summary: summary,
ImageURL: imageURL,
})
})
return items
}
// fetchDetailsWithLinks 并发抓取详情页获取网盘链接
func (p *AikanzyAsyncPlugin) fetchDetailsWithLinks(items []ArticleItem, client *http.Client, keyword string) []model.SearchResult {
// 创建结果通道和等待组
resultChan := make(chan model.SearchResult, len(items))
var wg sync.WaitGroup
// 创建信号量控制并发数
semaphore := make(chan struct{}, detailConcurrency)
// 并发处理每个文章项
for _, item := range items {
wg.Add(1)
go func(item ArticleItem) {
defer wg.Done()
// 获取信号量
semaphore <- struct{}{}
defer func() { <-semaphore }()
// 抓取详情页
links := p.fetchDetailPageLinks(item.DetailURL, client)
// 只有包含链接的结果才添加
if len(links) > 0 {
// 解析发布时间
publishTime := p.parsePublishTime(item.PublishDate)
// 组装内容
var contentParts []string
if item.Summary != "" {
contentParts = append(contentParts, item.Summary)
}
if item.Category != "" {
contentParts = append(contentParts, item.Category)
}
if item.PublishDate != "" {
contentParts = append(contentParts, item.PublishDate)
}
if item.ViewCount > 0 {
contentParts = append(contentParts, fmt.Sprintf("%d阅读", item.ViewCount))
}
content := strings.Join(contentParts, " | ")
// 组装标签
var tags []string
if item.Category != "" {
tags = append(tags, item.Category)
}
result := model.SearchResult{
UniqueID: fmt.Sprintf("aikanzy-%s", item.ID),
Title: item.Title,
Content: content,
Links: links,
Tags: tags,
Channel: "", // 插件搜索结果Channel为空
Datetime: publishTime,
}
resultChan <- result
}
}(item)
}
// 等待所有goroutine完成
go func() {
wg.Wait()
close(resultChan)
}()
// 收集所有结果
var results []model.SearchResult
for result := range resultChan {
results = append(results, result)
}
return results
}
// fetchDetailPageLinks 抓取详情页的网盘链接
func (p *AikanzyAsyncPlugin) fetchDetailPageLinks(detailURL string, client *http.Client) []model.Link {
// 创建带超时的上下文
ctx, cancel := context.WithTimeout(context.Background(), detailTimeout*time.Second)
defer cancel()
// 创建请求
req, err := http.NewRequestWithContext(ctx, "GET", detailURL, nil)
if err != nil {
return nil
}
// 设置请求头
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Referer", "https://www.aikanzy.com/")
req.Header.Set("Upgrade-Insecure-Requests", "1")
// 发送请求(带重试)
resp, err := p.doRequestWithRetry(req, client)
if err != nil {
return nil
}
defer resp.Body.Close()
// 检查状态码
if resp.StatusCode != http.StatusOK {
return nil
}
// 解析HTML
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil
}
// 提取网盘链接
return p.extractNetDiskLinks(doc)
}
// extractNetDiskLinks 从详情页提取网盘链接
func (p *AikanzyAsyncPlugin) extractNetDiskLinks(doc *goquery.Document) []model.Link {
var links []model.Link
foundURLs := make(map[string]bool) // 用于去重
// 方法1: 从<a>标签的href属性提取
doc.Find("a[href*='pan.quark.cn'], a[href*='drive.uc.cn'], a[href*='pan.baidu.com'], a[href*='pan.xunlei.com']").Each(func(i int, s *goquery.Selection) {
href, exists := s.Attr("href")
if !exists || href == "" {
return
}
// 去重
if foundURLs[href] {
return
}
foundURLs[href] = true
// 确定链接类型
linkType := p.determineLinkType(href)
if linkType == "" {
return
}
links = append(links, model.Link{
Type: linkType,
URL: href,
Password: p.extractPassword(href),
})
})
// 方法2: 从页面HTML文本中提取(正则表达式)
if len(links) == 0 {
html, _ := doc.Html()
// 提取夸克网盘链接
quarkLinks := quarkLinkRegex.FindAllString(html, -1)
for _, link := range quarkLinks {
if !foundURLs[link] {
foundURLs[link] = true
links = append(links, model.Link{
Type: "quark",
URL: link,
Password: p.extractPassword(link),
})
}
}
// 提取UC网盘链接
ucLinks := ucLinkRegex.FindAllString(html, -1)
for _, link := range ucLinks {
if !foundURLs[link] {
foundURLs[link] = true
links = append(links, model.Link{
Type: "uc",
URL: link,
Password: p.extractPassword(link),
})
}
}
// 提取百度网盘链接
baiduLinks := baiduLinkRegex.FindAllString(html, -1)
for _, link := range baiduLinks {
if !foundURLs[link] {
foundURLs[link] = true
links = append(links, model.Link{
Type: "baidu",
URL: link,
Password: p.extractPassword(link),
})
}
}
// 提取迅雷网盘链接
xunleiLinks := xunleiLinkRegex.FindAllString(html, -1)
for _, link := range xunleiLinks {
if !foundURLs[link] {
foundURLs[link] = true
links = append(links, model.Link{
Type: "xunlei",
URL: link,
Password: p.extractPassword(link),
})
}
}
}
return links
}
// determineLinkType 根据URL确定链接类型
func (p *AikanzyAsyncPlugin) determineLinkType(urlStr string) string {
lowerURL := strings.ToLower(urlStr)
switch {
case strings.Contains(lowerURL, "pan.quark.cn"):
return "quark"
case strings.Contains(lowerURL, "drive.uc.cn"):
return "uc"
case strings.Contains(lowerURL, "pan.baidu.com"):
return "baidu"
case strings.Contains(lowerURL, "pan.xunlei.com"):
return "xunlei"
default:
return ""
}
}
// extractArticleID 从URL中提取文章ID
func (p *AikanzyAsyncPlugin) extractArticleID(urlStr string) string {
matches := articleIDRegex.FindStringSubmatch(urlStr)
if len(matches) >= 3 {
return matches[2] // 返回数字ID
}
return ""
}
// extractViewCount 提取阅读数
func (p *AikanzyAsyncPlugin) extractViewCount(text string) int {
matches := viewCountRegex.FindStringSubmatch(text)
if len(matches) >= 2 {
var count int
fmt.Sscanf(matches[1], "%d", &count)
return count
}
return 0
}
// cleanHTMLTags 清除HTML标签
func (p *AikanzyAsyncPlugin) cleanHTMLTags(text string) string {
// 移除<b>标签
text = regexp.MustCompile(`<b[^>]*>`).ReplaceAllString(text, "")
text = regexp.MustCompile(`</b>`).ReplaceAllString(text, "")
// 移除其他常见HTML标签
text = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(text, "")
return strings.TrimSpace(text)
}
// parsePublishTime 解析发布时间
func (p *AikanzyAsyncPlugin) parsePublishTime(dateStr string) time.Time {
dateStr = strings.TrimSpace(dateStr)
if dateStr == "" {
return time.Time{}
}
// 尝试多种日期格式
formats := []string{
"2006-01-02",
"2006-01-02 15:04:05",
"2006-01-02T15:04:05Z",
"2006-01-02T15:04:05+08:00",
"2006-01-02T15:04:05-07:00",
}
for _, format := range formats {
if t, err := time.Parse(format, dateStr); err == nil {
return t
}
}
// 如果以上格式都不匹配,尝试使用time.RFC3339格式(处理<time>标签的datetime属性)
if t, err := time.Parse(time.RFC3339, dateStr); err == nil {
return t
}
return time.Time{}
}
// extractPassword 从网盘链接中提取密码
func (p *AikanzyAsyncPlugin) extractPassword(urlStr string) string {
// 从URL中提取pwd=后面的四位密码(不包含#)
pwdRegex := regexp.MustCompile(`pwd=([^#&]{4})`)
matches := pwdRegex.FindStringSubmatch(urlStr)
if len(matches) >= 2 {
return matches[1]
}
return ""
}
// doRequestWithRetry 发送HTTP请求,带重试机制
func (p *AikanzyAsyncPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {
var resp *http.Response
var err error
for retry := 0; retry <= maxRetries; retry++ {
if retry > 0 {
// 指数退避
backoffTime := time.Duration(1<<uint(retry-1)) * backoffBase * time.Millisecond
time.Sleep(backoffTime)
// 克隆请求
req = req.Clone(req.Context())
}
resp, err = client.Do(req)
if err == nil && resp.StatusCode == 200 {
return resp, nil
}
if resp != nil {
resp.Body.Close()
}
}
return nil, fmt.Errorf("重试 %d 次后仍然失败: %w", maxRetries, err)
}
```
## /plugin/alupan/alupan.go
```go path="/plugin/alupan/alupan.go"
package alupan
import (
"context"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/PuerkitoBio/goquery"
"pansou/model"
"pansou/plugin"
)
var (
articleIDRegex = regexp.MustCompile(`\?p=(\d+)`)
linkPatterns = []struct {
reg *regexp.Regexp
typ string
}{
{regexp.MustCompile(`https?://pan\.quark\.cn/s/[0-9A-Za-z]+`), "quark"},
{regexp.MustCompile(`https?://www\.aliyundrive\.com/s/[0-9A-Za-z]+`), "aliyun"},
{regexp.MustCompile(`https?://www\.aliyundrive\.com/drive/folder/[0-9A-Za-z]+`), "aliyun"},
}
pwdPatterns = []*regexp.Regexp{
regexp.MustCompile(`提取码[::]?\s*([0-9A-Za-z]+)`),
regexp.MustCompile(`密码[::]?\s*([0-9A-Za-z]+)`),
regexp.MustCompile(`pwd\s*[=::]\s*([0-9A-Za-z]+)`),
regexp.MustCompile(`code\s*[=::]\s*([0-9A-Za-z]+)`),
}
detailCache = sync.Map{}
cacheTTL = 1 * time.Hour
cacheCleanupInterval = 30 * time.Minute
)
type cacheEntry struct {
links []model.Link
expiresAt time.Time
}
const (
pluginName = "alupan"
defaultPriority = 2
searchTimeout = 12 * time.Second
detailTimeout = 10 * time.Second
maxConcurrency = 12
maxIdleConns = 64
maxIdlePerHost = 16
maxConnsPerHost = 32
idleConnLifetime = 90 * time.Second
tlsHandshakeTimeout = 10 * time.Second
expectContinueTimeout = 1 * time.Second
searchMaxRetries = 3
detailMaxRetries = 2
retryBaseDelay = 200 * time.Millisecond
)
// AlupanPlugin 搜索插件
type AlupanPlugin struct {
*plugin.BaseAsyncPlugin
client *http.Client
}
func init() {
plugin.RegisterGlobalPlugin(NewAlupanPlugin())
go startCacheCleaner()
}
// NewAlupanPlugin 创建插件
func NewAlupanPlugin() *AlupanPlugin {
return &AlupanPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin(pluginName, defaultPriority),
client: newHTTPClient(),
}
}
// Search 兼容方法
func (p *AlupanPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
result, err := p.SearchWithResult(keyword, ext)
if err != nil {
return nil, err
}
return result.Results, nil
}
// SearchWithResult 主搜索方法
func (p *AlupanPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)
}
func newHTTPClient() *http.Client {
transport := &http.Transport{
MaxIdleConns: maxIdleConns,
MaxIdleConnsPerHost: maxIdlePerHost,
MaxConnsPerHost: maxConnsPerHost,
IdleConnTimeout: idleConnLifetime,
TLSHandshakeTimeout: tlsHandshakeTimeout,
ExpectContinueTimeout: expectContinueTimeout,
ForceAttemptHTTP2: true,
}
return &http.Client{
Transport: transport,
Timeout: searchTimeout,
}
}
func (p *AlupanPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
if p.client != nil {
client = p.client
}
searchURL := fmt.Sprintf("https://www.aliupan.com/?s=%s", url.QueryEscape(keyword))
ctx, cancel := context.WithTimeout(context.Background(), searchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
if err != nil {
return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err)
}
setCommonHeaders(req, "https://www.aliupan.com/")
resp, err := p.doRequestWithRetry(req, client, searchMaxRetries)
if err != nil {
return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("[%s] 搜索返回状态码: %d", p.Name(), resp.StatusCode)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("[%s] 解析搜索页面失败: %w", p.Name(), err)
}
var (
results []model.SearchResult
wg sync.WaitGroup
mu sync.Mutex
sem = make(chan struct{}, maxConcurrency)
)
doc.Find("article.excerpt").Each(func(_ int, item *goquery.Selection) {
titleSel := item.Find("header h2 a")
title := strings.TrimSpace(titleSel.Text())
detailURL, ok := titleSel.Attr("href")
if !ok || title == "" || detailURL == "" {
return
}
articleID := extractArticleID(detailURL)
if articleID == "" {
return
}
category := strings.TrimSpace(item.Find("header .label").First().Text())
var tags []string
if category != "" {
tags = append(tags, category)
}
summary := strings.TrimSpace(item.Find("p.note").Text())
timeText := strings.TrimSpace(item.Find("p .icon-time").Parent().Text())
publishTime := parsePublishTime(timeText)
wg.Add(1)
sem <- struct{}{}
go func(title, detailURL, summary string, tags []string, publish time.Time, articleID string) {
defer wg.Done()
defer func() { <-sem }()
links := p.fetchDetailLinks(client, detailURL, articleID)
if len(links) == 0 {
return
}
result := model.SearchResult{
UniqueID: fmt.Sprintf("%s-%s", p.Name(), articleID),
Title: title,
Content: summary,
Links: links,
Tags: tags,
Channel: "",
Datetime: publish,
}
mu.Lock()
results = append(results, result)
mu.Unlock()
}(title, detailURL, summary, tags, publishTime, articleID)
})
wg.Wait()
return plugin.FilterResultsByKeyword(results, keyword), nil
}
func extractArticleID(detailURL string) string {
if matches := articleIDRegex.FindStringSubmatch(detailURL); len(matches) >= 2 {
return matches[1]
}
return ""
}
func parsePublishTime(value string) time.Time {
value = strings.TrimSpace(value)
if value == "" {
return time.Now()
}
if idx := strings.Index(value, "("); idx >= 0 && strings.HasSuffix(value, ")") {
value = value[idx+1 : len(value)-1]
value = strings.TrimSpace(value)
}
layouts := []string{
"2006-01-02",
"2006/01/02",
"2006年01月02日",
time.RFC3339,
}
for _, layout := range layouts {
if t, err := time.Parse(layout, value); err == nil {
return t
}
}
return time.Now()
}
func (p *AlupanPlugin) fetchDetailLinks(client *http.Client, detailURL, articleID string) []model.Link {
if cached, ok := detailCache.Load(articleID); ok {
if entry, valid := cached.(cacheEntry); valid {
if time.Now().Before(entry.expiresAt) && len(entry.links) > 0 {
return entry.links
}
detailCache.Delete(articleID)
}
}
ctx, cancel := context.WithTimeout(context.Background(), detailTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil)
if err != nil {
return nil
}
setCommonHeaders(req, detailURL)
resp, err := p.doRequestWithRetry(req, client, detailMaxRetries)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil
}
links := extractNetDiskLinks(doc)
if len(links) > 0 {
detailCache.Store(articleID, cacheEntry{
links: links,
expiresAt: time.Now().Add(cacheTTL),
})
}
return links
}
func extractNetDiskLinks(doc *goquery.Document) []model.Link {
var (
results []model.Link
seen = make(map[string]struct{})
)
doc.Find(".article-content a[href]").Each(func(_ int, node *goquery.Selection) {
href, exists := node.Attr("href")
if !exists {
return
}
href = strings.TrimSpace(href)
if href == "" {
return
}
linkType, normalized := classifyLink(href)
if linkType == "" {
return
}
if _, found := seen[normalized]; found {
return
}
password := extractPassword(node)
results = append(results, model.Link{
Type: linkType,
URL: normalized,
Password: password,
})
seen[normalized] = struct{}{}
})
return results
}
func classifyLink(raw string) (string, string) {
for _, pattern := range linkPatterns {
if loc := pattern.reg.FindString(raw); loc != "" {
return pattern.typ, loc
}
}
return "", ""
}
func extractPassword(link *goquery.Selection) string {
candidates := []string{
link.Text(),
}
if title, ok := link.Attr("title"); ok {
candidates = append(candidates, title)
}
if parent := link.Parent(); parent != nil && parent.Length() > 0 {
candidates = append(candidates, parent.Text())
if next := parent.Next(); next.Length() > 0 {
candidates = append(candidates, next.Text())
}
}
if next := link.Next(); next.Length() > 0 {
candidates = append(candidates, next.Text())
}
for _, text := range candidates {
if pwd := matchPassword(text); pwd != "" {
return pwd
}
}
return ""
}
func matchPassword(text string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
for _, pattern := range pwdPatterns {
if matches := pattern.FindStringSubmatch(text); len(matches) >= 2 {
return strings.TrimSpace(matches[1])
}
}
return ""
}
func setCommonHeaders(req *http.Request, referer string) {
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Referer", referer)
}
func (p *AlupanPlugin) doRequestWithRetry(req *http.Request, client *http.Client, maxRetries int) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
resp, err := client.Do(req.Clone(req.Context()))
if err == nil && resp.StatusCode == http.StatusOK {
return resp, nil
}
if resp != nil {
resp.Body.Close()
}
lastErr = err
if attempt < maxRetries-1 {
backoff := retryBaseDelay * time.Duration(1<<attempt)
time.Sleep(backoff)
}
}
return nil, fmt.Errorf("重试 %d 次后失败: %w", maxRetries, lastErr)
}
func startCacheCleaner() {
ticker := time.NewTicker(cacheCleanupInterval)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
detailCache.Range(func(key, value interface{}) bool {
entry, ok := value.(cacheEntry)
if !ok || now.After(entry.expiresAt) {
detailCache.Delete(key)
}
return true
})
}
}
```
## /plugin/alupan/htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md
# alupan (阿里U盘) HTML结构分析
## 网站信息
- **站点名称**: 阿里U盘
- **域名**: `www.aliupan.com`
- **类型**: 影视/图书等资源聚合站(WordPress D8 主题)
- **特点**: 搜索结果页按文章列表展示,详情页正文直接给出阿里云盘/夸克网盘链接,文章数量大、分类细
## 搜索/列表页
### 1. 请求入口
```
https://www.aliupan.com/?s={关键词}
```
- 关键词直接 UTF-8;无需额外参数
- 返回 WordPress 搜索结果页(带 `archive-header`)
### 2. 结果容器
- 外层:`section.container > .content-wrap > .content`
- 列表项:`article.excerpt`(常见类名 `excerpt-titletype`)
### 3. 单条记录
```html
<article class="excerpt excerpt-titletype">
<div class="focus">
<a href="https://www.aliupan.com/?p=7078" class="thumbnail">
<img src="..." alt="[阿里云盘][夸克网盘]《遮天》(2023年)" />
</a>
</div>
<header>
<a class="label label-important" href="https://www.aliupan.com/?cat=19">中国内地电视剧<i class="label-arrow"></i></a>
<h2>
<a href="https://www.aliupan.com/?p=7078" title="...">[阿里云盘][夸克网盘]《遮天》(2023年)</a>
</h2>
</header>
<p>
<span class="muted"><i class="icon-user"></i><a href="...">阿里U盘</a></span>
<span class="muted"><i class="icon-time"></i> 1年前 (2024-07-27)</span>
<span class="muted"><i class="icon-eye-open"></i> 745浏览</span>
<span class="muted"><i class="icon-comment"></i><a href="...">0评论</a></span>
</p>
<p class="note">……摘要文本……</p>
</article>
```
#### 需要提取的字段
- **标题**: `h2 a` 文本
- **详情链接**: `h2 a[href]`
- **分类**: `.label.label-important` 文本(可作为 `Tags` 之一)
- **发布日期**: `p > span .icon-time` 所在 `<span>`,格式通常为 `1年前 (2024-07-27)`;取括号内日期
- **摘要**: `p.note`
- **封面**: `div.focus img[src]`(仅用于调试,不需要在结果中返回)
### 4. 分页
- 搜索页默认返回全部匹配列表,可根据需要继续解析分页链接(一般抓取第一页即可)。
## 详情页
### 1. URL 规则
```
https://www.aliupan.com/?p={文章ID}
```
- `文章ID` 来自列表页 URL,可直接作为唯一标识。
### 2. 主体定位
- 标题:`.article-header .article-title a`
- 元信息:`.meta`(含分类、作者、时间、阅读)
- 正文:`article.article-content`
### 3. 下载链接形态
正文中使用普通段落给出下载地址:
```html
<p>阿里云盘丨遮天:<a href="https://www.aliyundrive.com/s/xxxx" target="_blank" rel="nofollow">https://www.aliyundrive.com/s/xxxx</a></p>
<p>夸克网盘丨遮天:<a href="https://pan.quark.cn/s/5ad996dc0725" target="_blank" rel="noreferrer noopener nofollow">https://pan.quark.cn/s/5ad996dc0725</a></p>
```
- 个别文章会出现“待补”等文字;只返回真正包含链接的 `<a>`。
- 可能同文提供多个链接(夸克 / 阿里云盘 / 其他),需要全部收集。
- 提取码通常写在同一段落文本里,形如 `提取码:xxxx`、`密码:xxxx` 等。
### 4. 支持的网盘域名
- **阿里云盘**: `https://www.aliyundrive.com/s/`、`https://www.aliyundrive.com/drive/folder/`
- **夸克网盘**: `https://pan.quark.cn/s/`
- 可根据站点实际扩展(如出现 `pan.baidu.com` 等)
## CSS 选择器速览
| 数据项 | 选择器/规则 |
|--------|-------------|
| 列表项 | `article.excerpt` |
| 标题 & 链接 | `article.excerpt h2 a` |
| 分类标签 | `article.excerpt header .label` |
| 摘要 | `article.excerpt p.note` |
| 发布时间 | `article.excerpt p .icon-time` 所在 `<span>`;取括号中的日期 |
| 正文容器 | `article.article-content` |
| 网盘链接 | `.article-content a[href*="pan.quark.cn"]`、`a[href*="aliyundrive.com"]` 等 |
## 提取策略
1. **搜索页**
- 构建 `https://www.aliupan.com/?s=keyword`,使用浏览器 UA、防爬 Header。
- 解析 `article.excerpt`,抓取基本元信息。
- 由 `?p={id}` 提取 ID,构建唯一键 `alupan-{id}`。
2. **详情页**
- 访问正文 `.article-content`。
- 遍历所有 `<a>`,通过域名判断网盘类型。
- 在链接文本或父级文本中搜索提取码关键词(`提取码/密码/pwd/code`)。
- 多个链接去重(同地址只保留一次)。
3. **时间解析**
- 优先解析括号内日期(`YYYY-MM-DD`)。
- 若无括号,只能是 `YYYY-MM-DD` 或 `YYYY年MM月DD日`,按常见格式匹配;失败则用当前时间。
4. **性能优化建议**
- 统一使用定制 `http.Client`(连接池 + TLS/Expect 超时 + HTTP/2)。
- 搜索与详情请求加入指数退避重试(至少 2~3 次)。
- 对详情解析结果加 TTL 缓存(例如 1 小时),避免重复抓取。
- 使用信号量控制同时抓取的详情页数量,推荐 10~15。
## 示例数据流
```
1. 请求 https://www.aliupan.com/?s=遮天
2. 列表项:
- 标题: [阿里云盘][夸克网盘]《遮天》(2023年)
- 分类: 中国内地电视剧
- 日期: 1年前 (2024-07-27)
- 摘要: 阿里云盘丨遮天:待补 夸克网盘丨遮天:https://pan.quark.cn/...
- 详情: https://www.aliupan.com/?p=7078
3. 详情解析:
- `https://pan.quark.cn/s/5ad996dc0725`
4. 构建结果:
UniqueID: alupan-7078
Title: [阿里云盘][夸克网盘]《遮天》(2023年)
Links: [{Type:"quark", URL:"https://pan.quark.cn/s/5ad996dc0725", Password:""}]
Tags: ["中国内地电视剧"]
Datetime: 2024-07-27T00:00:00+08:00
```
## 注意事项
1. **摘要中的裸链**:虽然摘要有时包含 URL,但仍应以详情页数据为准。
2. **缺失链接**:如果正文中没有有效网盘链接(例如“待补”),忽略该文章。
3. **多链接**:同一篇可能同时提供阿里云盘与夸克链接,均需返回。
4. **缓存**:文章更新较频繁,建议缓存加入 TTL,并定时清理。
5. **编码**:站点内容大量中文,解析时确保使用 UTF-8。
## /plugin/ash/ash.go
```go path="/plugin/ash/ash.go"
package ash
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"pansou/model"
"pansou/plugin"
"pansou/util/json"
)
type AshPlugin struct {
*plugin.BaseAsyncPlugin
}
const (
// 错误的夸克域名
wrongQuarkDomain = "pan.qualk.cn"
// 正确的夸克域名
correctQuarkDomain = "pan.quark.cn"
)
var (
// 提取JSON数据的正则表达式(预编译)
jsonDataRegex = regexp.MustCompile(`var jsonData = '(\[.*?\])';`)
// 控制字符清理正则(预编译)
controlCharRegex = regexp.MustCompile(`[\x00-\x1F\x7F]`)
)
// AshResult 表示ASH搜索结果的数据结构
type AshResult struct {
ID int `json:"id"`
SourceCategoryID int `json:"source_category_id"`
Title string `json:"title"`
IsType int `json:"is_type"`
Code interface{} `json:"code"` // 可能是null或string
URL string `json:"url"`
IsTime int `json:"is_time"`
Name string `json:"name"`
Times string `json:"times"`
Category interface{} `json:"category"` // 可能是null或string
}
func init() {
p := &AshPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPlugin("ash", 2), // 优先级2,质量良好的影视资源
}
plugin.RegisterGlobalPlugin(p)
}
// Search 执行搜索并返回结果
func (p *AshPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
result, err := p.SearchWithResult(keyword, ext)
if err != nil {
return nil, err
}
return result.Results, nil
}
// SearchWithResult 执行搜索并返回包含IsFinal标记的结果
func (p *AshPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)
}
// searchImpl 实际的搜索实现(优化版本)
func (p *AshPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
// 构建搜索URL
searchURL := fmt.Sprintf("https://so.allsharehub.com/s/%s.html", url.QueryEscape(keyword))
// 创建带超时的上下文(减少超时时间,提高响应速度)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// 创建请求
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err)
}
// 设置请求头
p.setRequestHeaders(req)
// 发送请求(优化重试)
resp, err := p.doRequestWithRetry(req, client)
if err != nil {
return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err)
}
defer resp.Body.Close()
// 检查状态码
if resp.StatusCode != 200 {
return nil, fmt.Errorf("[%s] 请求返回状态码: %d", p.Name(), resp.StatusCode)
}
// 读取响应(使用有限制的读取,避免读取过大内容)
// ASH页面通常不会太大,限制在2MB以内
limitReader := io.LimitReader(resp.Body, 2*1024*1024)
body, err := io.ReadAll(limitReader)
if err != nil {
return nil, fmt.Errorf("[%s] 读取响应失败: %w", p.Name(), err)
}
// 从HTML中提取JSON数据(直接传递字节,避免字符串转换)
results, err := p.extractResultsFromBytes(body)
if err != nil {
return nil, fmt.Errorf("[%s] 提取搜索结果失败: %w", p.Name(), err)
}
// 关键词过滤
filtered := plugin.FilterResultsByKeyword(results, keyword)
return filtered, nil
}
// extractResultsFromBytes 从字节数组中提取搜索结果(优化版本,避免字符串转换)
func (p *AshPlugin) extractResultsFromBytes(data []byte) ([]model.SearchResult, error) {
// 直接在字节数组中查找JSON数据(避免转换为字符串)
html := string(data) // 只转换一次
// 查找JSON数据
matches := jsonDataRegex.FindStringSubmatch(html)
if len(matches) < 2 {
return []model.SearchResult{}, nil // 没有找到数据,返回空结果
}
// 提取JSON字符串
jsonStr := matches[1]
// 清理JSON字符串(批量操作,减少内存分配)
if strings.Contains(jsonStr, "\\/") {
jsonStr = strings.ReplaceAll(jsonStr, "\\/", "/")
}
jsonStr = controlCharRegex.ReplaceAllString(jsonStr, "")
// 解析JSON - 使用高性能的sonic库
var ashResults []AshResult
if err := json.Unmarshal([]byte(jsonStr), &ashResults); err != nil {
return nil, fmt.Errorf("JSON解析失败: %w", err)
}
// 如果没有结果,直接返回
if len(ashResults) == 0 {
return []model.SearchResult{}, nil
}
// 预分配切片容量,避免动态扩容
results := make([]model.SearchResult, 0, len(ashResults))
// 批量处理所有结果
for i := range ashResults {
item := &ashResults[i]
// 提前检查URL是否有效,避免无效处理
if item.URL == "" {
continue
}
// 处理网盘链接
panURL := p.fixPanURL(item.URL)
if panURL == "" {
continue
}
// 确定网盘类型(内联优化)
var panType string
switch item.IsType {
case 0:
panType = "quark"
case 2:
panType = "baidu"
case 3:
panType = "uc"
case 4:
panType = "xunlei"
default:
panType = "quark"
}
// 处理提取码
var password string
if item.Code != nil {
if codeStr, ok := item.Code.(string); ok && codeStr != "" {
password = codeStr
}
}
// 解析时间
var datetime time.Time
if item.Times != "" {
if parsedTime, err := time.Parse("2006-01-02", item.Times); err == nil {
datetime = parsedTime
} else {
datetime = time.Now()
}
} else {
datetime = time.Now()
}
// 获取标签
var tags []string
if item.SourceCategoryID > 0 && item.SourceCategoryID <= 6 {
categoryNames := [...]string{"短剧", "电影", "电视剧", "动漫", "综艺", "充电视频"}
tags = []string{categoryNames[item.SourceCategoryID-1]}
}
// 构建搜索结果
results = append(results, model.SearchResult{
UniqueID: fmt.Sprintf("%s-%d", p.Name(), item.ID),
Title: item.Title,
Content: item.Name,
Datetime: datetime,
Channel: "",
Links: []model.Link{{
Type: panType,
URL: panURL,
Password: password,
}},
Tags: tags,
})
}
return results, nil
}
// fixPanURL 修复网盘链接 - 关键功能!(优化版本)
func (p *AshPlugin) fixPanURL(url string) string {
// 快速检查是否为有效的HTTP/HTTPS链接
if len(url) < 8 { // 最短的URL: http://a
return ""
}
// 验证链接协议(使用更快的检查方式)
if url[0] != 'h' || (url[4] != ':' && url[5] != ':') {
return ""
}
// 只在包含错误域名时才进行替换,避免不必要的字符串操作
if strings.Contains(url, wrongQuarkDomain) {
return strings.Replace(url, wrongQuarkDomain, correctQuarkDomain, 1)
}
return url
}
// setRequestHeaders 设置请求头
func (p *AshPlugin) setRequestHeaders(req *http.Request) {
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Upgrade-Insecure-Requests", "1")
req.Header.Set("Cache-Control", "max-age=0")
req.Header.Set("Referer", "https://so.allsharehub.com/")
}
// doRequestWithRetry 带重试机制的HTTP请求(优化版本)
func (p *AshPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {
maxRetries := 2 // 减少重试次数,提高响应速度
var lastErr error
for i := 0; i < maxRetries; i++ {
if i > 0 {
// 更短的退避时间
backoff := time.Duration(100<<uint(i-1)) * time.Millisecond
time.Sleep(backoff)
}
// 克隆请求(只在重试时克隆)
var reqToUse *http.Request
if i == 0 {
reqToUse = req
} else {
reqToUse = req.Clone(req.Context())
}
resp, err := client.Do(reqToUse)
// 成功返回
if err == nil && resp.StatusCode == 200 {
return resp, nil
}
// 清理响应
if resp != nil {
resp.Body.Close()
}
lastErr = err
// 如果是上下文取消或超时,不再重试
if req.Context().Err() != nil {
break
}
}
if lastErr != nil {
return nil, fmt.Errorf("重试 %d 次后仍然失败: %w", maxRetries, lastErr)
}
return nil, fmt.Errorf("重试 %d 次后仍然失败", maxRetries)
}
```
## /plugin/ash/htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md
# ASH搜剧助手 HTML结构分析
## 网站信息
- **网站名称**: ASH搜剧助手
- **域名**: so.allsharehub.com
- **类型**: 影视资源搜索引擎
- **特点**: 专门搜索影视剧资源,主要提供夸克网盘链接
- **搜索模式**: 本地搜索(从网站数据库查询,不使用全网搜)
## 搜索页面结构
### 1. 搜索URL模式
```
https://so.allsharehub.com/s/[关键词].html
示例:
https://so.allsharehub.com/s/%E4%BB%99%E9%80%86.html
参数说明:
- 关键词: URL编码的搜索关键词
- 支持分页: /s/[关键词]-[页码].html
- 支持分类: /s/[关键词]-[页码]-[分类ID].html
```
### 2. 数据提取方式
#### JavaScript数据源(唯一方式)
搜索结果嵌入在页面JavaScript变量中(本地搜索数据):
```javascript
var 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}]';
```
**注意**:
- 只使用本地搜索数据(currentSource === 0)
- 不需要处理全网搜的SSE流式数据(currentSource === 1)
### 3. 数据字段说明
| 字段 | 类型 | 说明 | 示例 |
|------|------|------|------|
| `id` | number | 资源ID | 987 |
| `source_category_id` | number | 分类ID | 0 |
| `title` | string | 资源标题 | "仙逆剧场版神临之战4K完整版" |
| `is_type` | number | 网盘类型 (0=夸克) | 0 |
| `code` | string/null | 提取码 | null 或 "1234" |
| `url` | string | 网盘链接 | "https://pan.qualk.cn/s/095628b04e6c" |
| `is_time` | number | 时间标记 | 0 |
| `name` | string | 资源名称 | "仙逆剧场版神临之战4K完整版" |
| `times` | string | 发布时间 | "2025-08-31" |
| `category` | string/null | 分类 | null |
### 4. HTML结构(备用方式)
#### 搜索结果容器
- **父容器**: `.listBox .left .box .list`
- **结果项**: `.item` (每个搜索结果)
#### 单个搜索结果结构
```html
<div class="item">
<!-- 标题 -->
<a href="javascript:;" onclick="linkBtn(this)" data-index="0" class="title">
仙逆剧场版神临之战4K完整版
</a>
<!-- 发布时间 -->
<div class="type time">2025-08-31</div>
<!-- 来源 -->
<div class="type">
<span>来源:夸克网盘</span>
</div>
<!-- 操作按钮 -->
<div class="btns">
<div class="btn" @click.stop="copyText(...)">
<i class="iconfont icon-fenxiang1"></i>复制分享
</div>
<a href="/d/987.html" class="btn">
<i class="iconfont icon-fangwen"></i>查看详情
</a>
<a href="javascript:;" onclick="linkBtn(this)" data-index="0" class="btn">
立即访问
</a>
</div>
</div>
```
## 重要实现要点
### 1. 网盘链接转换 ⭐ 非常重要
页面返回的链接使用错误的域名,必须进行转换:
```
原始链接: https://pan.qualk.cn/s/095628b04e6c
正确链接: https://pan.quark.cn/s/095628b04e6c
转换规则: 将 "pan.qualk.cn" 替换为 "pan.quark.cn"
```
### 2. 数据提取正则表达式
```go
// 提取JSON数据
jsonDataRegex := regexp.MustCompile(`var jsonData = '(\[.*?\])';`)
// 清理JSON中的控制字符
jsonData = strings.ReplaceAll(jsonData, "\\/", "/")
jsonData = regexp.MustCompile(`[\x00-\x1F\x7F]`).ReplaceAllString(jsonData, "")
```
### 3. 网盘类型映射
```go
is_type 值映射:
0 -> "quark" (夸克网盘)
2 -> "baidu" (百度网盘)
3 -> "uc" (UC网盘)
4 -> "xunlei" (迅雷网盘)
```
### 4. 时间格式
- 格式: `YYYY-MM-DD`
- 需要转换为标准时间格式: `time.Parse("2006-01-02", timeStr)`
### 5. 分类信息
页面支持按分类筛选:
- 0: 全部
- 1: 短剧
- 2: 电影
- 3: 电视剧
- 4: 动漫
- 5: 综艺
- 6: 充电视频
## CSS选择器总结
| 数据项 | CSS选择器 | 提取方式 |
|--------|-----------|----------|
| 搜索结果列表 | `.listBox .left .box .list .item` | 遍历所有结果项 |
| 标题 | `.item .title` | 文本内容 |
| 发布时间 | `.item .type.time` | 文本内容 |
| 来源类型 | `.item .type span` | 文本内容 |
| 详情页链接 | `.item a[href^="/d/"]` | href 属性 |
## 优先级建议
- **优先级**: 2-3 (质量良好的影视资源搜索)
- **跳过Service层过滤**: false (标准中文资源,保持过滤)
- **缓存TTL**: 2小时
## 搜索策略
1. 优先使用JavaScript变量提取数据(更快、更准确)
2. 如果JavaScript解析失败,回退到HTML解析
3. 必须对所有链接进行域名转换(pan.qualk.cn -> pan.quark.cn)
4. 只返回包含有效网盘链接的结果
## /plugin/bixin/bixin.go
```go path="/plugin/bixin/bixin.go"
package bixin
import (
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
"pansou/model"
"pansou/plugin"
"pansou/util/json"
)
// 在init函数中注册插件
func init() {
// 注册插件
plugin.RegisterGlobalPlugin(NewBixinAsyncPlugin())
}
const (
// API基础URL
BaseURL = "https://www.bixbiy.com/api/discussions"
// 默认参数
PageSize = 50 // 符合API实际返回数量
MaxRetries = 2
)
// 常用UA列表
var userAgents = []string{
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36",
}
// BixinAsyncPlugin bixin网盘搜索异步插件
type BixinAsyncPlugin struct {
*plugin.BaseAsyncPlugin
retries int
}
// NewBixinAsyncPlugin 创建新的bixin异步插件
func NewBixinAsyncPlugin() *BixinAsyncPlugin {
return &BixinAsyncPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("bixin", 3, true), // 跳过Service层过滤
retries: MaxRetries,
}
}
// Search 执行搜索并返回结果(兼容性方法)
func (p *BixinAsyncPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
result, err := p.SearchWithResult(keyword, ext)
if err != nil {
return nil, err
}
return result.Results, nil
}
// SearchWithResult 执行搜索并返回包含IsFinal标记的结果
func (p *BixinAsyncPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.doSearch, p.MainCacheKey, ext)
}
// doSearch 实际的搜索实现
func (p *BixinAsyncPlugin) doSearch(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
// 初始化随机数种子
rand.Seed(time.Now().UnixNano())
// 只并发请求2个页面(0-1页)
allResults, _, err := p.fetchBatch(client, keyword, 0, 2)
if err != nil {
return nil, err
}
// 去重
uniqueResults := p.deduplicateResults(allResults)
// 使用过滤功能过滤结果
filteredResults := plugin.FilterResultsByKeyword(uniqueResults, keyword)
return filteredResults, nil
}
// fetchBatch 获取一批页面的数据
func (p *BixinAsyncPlugin) fetchBatch(client *http.Client, keyword string, startOffset, pageCount int) ([]model.SearchResult, bool, error) {
var wg sync.WaitGroup
resultChan := make(chan struct{
offset int
results []model.SearchResult
hasMore bool
err error
}, pageCount)
// 并发请求多个页面,但每个请求之间添加随机延迟
for i := 0; i < pageCount; i++ {
offset := (startOffset + i) * PageSize
wg.Add(1)
go func(offset int, index int) {
defer wg.Done()
// 第一个请求立即执行,后续请求添加随机延迟
if index > 0 {
// 随机等待0-1秒
randomDelay := time.Duration(100 + rand.Intn(900)) * time.Millisecond
time.Sleep(randomDelay)
}
// 请求特定页面
results, hasMore, err := p.fetchPage(client, keyword, offset)
resultChan <- struct{
offset int
results []model.SearchResult
hasMore bool
err error
}{
offset: offset,
results: results,
hasMore: hasMore,
err: err,
}
}(offset, i)
}
// 等待所有请求完成
go func() {
wg.Wait()
close(resultChan)
}()
// 收集结果
var allResults []model.SearchResult
hasMore := false
for result := range resultChan {
if result.err != nil {
return nil, false, result.err
}
allResults = append(allResults, result.results...)
hasMore = hasMore || result.hasMore
}
return allResults, hasMore, nil
}
// deduplicateResults 去除重复结果
func (p *BixinAsyncPlugin) deduplicateResults(results []model.SearchResult) []model.SearchResult {
seen := make(map[string]bool)
unique := make([]model.SearchResult, 0, len(results))
for _, result := range results {
if !seen[result.UniqueID] {
seen[result.UniqueID] = true
unique = append(unique, result)
}
}
// 按时间降序排序
sort.Slice(unique, func(i, j int) bool {
return unique[i].Datetime.After(unique[j].Datetime)
})
return unique
}
// fetchPage 获取指定页的搜索结果
func (p *BixinAsyncPlugin) fetchPage(client *http.Client, keyword string, offset int) ([]model.SearchResult, bool, error) {
// 构建API URL
apiURL := fmt.Sprintf("%s?filter[q]=%s&include=mostRelevantPost&page[offset]=%d&page[limit]=%d",
BaseURL, url.QueryEscape(keyword), offset, PageSize)
// 创建请求
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, false, fmt.Errorf("创建请求失败: %w", err)
}
// 设置请求头
req.Header.Set("User-Agent", getRandomUA())
req.Header.Set("X-Forwarded-For", generateRandomIP())
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Sec-Fetch-Dest", "empty")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Site", "same-origin")
var resp *http.Response
var responseBody []byte
// 重试逻辑
for i := 0; i <= p.retries; i++ {
// 发送请求
resp, err = client.Do(req)
if err != nil {
if i == p.retries {
return nil, false, fmt.Errorf("请求失败: %w", err)
}
time.Sleep(500 * time.Millisecond)
continue
}
defer resp.Body.Close()
// 读取响应体
responseBody, err = io.ReadAll(resp.Body)
if err != nil {
if i == p.retries {
return nil, false, fmt.Errorf("读取响应失败: %w", err)
}
time.Sleep(500 * time.Millisecond)
continue
}
// 状态码检查
if resp.StatusCode != http.StatusOK {
if i == p.retries {
return nil, false, fmt.Errorf("API返回非200状态码: %d", resp.StatusCode)
}
time.Sleep(500 * time.Millisecond)
continue
}
// 请求成功,跳出重试循环
break
}
// 解析响应
var apiResp BixinResponse
if err := json.Unmarshal(responseBody, &apiResp); err != nil {
return nil, false, fmt.Errorf("解析响应失败: %w", err)
}
// 处理结果
results := make([]model.SearchResult, 0, len(apiResp.Data))
postMap := make(map[string]BixinPost)
// 创建帖子ID到帖子内容的映射
for _, post := range apiResp.Included {
postMap[post.ID] = post
}
// 遍历搜索结果
for _, discussion := range apiResp.Data {
// 获取相关帖子
postID := discussion.Relationships.MostRelevantPost.Data.ID
post, ok := postMap[postID]
if !ok {
continue
}
// 清理HTML内容
cleanedHTML := cleanHTML(post.Attributes.ContentHTML)
// 提取链接(只处理移动云盘)
links := extractMobileLinksFromText(cleanedHTML)
// 如果没有找到链接,跳过该结果
if len(links) == 0 {
continue
}
// 解析时间
createdTime, err := time.Parse(time.RFC3339, discussion.Attributes.CreatedAt)
if err != nil {
createdTime = time.Now() // 如果解析失败,使用当前时间
}
// 创建唯一ID:插件名-帖子ID
uniqueID := fmt.Sprintf("bixin-%s", discussion.ID)
// 创建搜索结果
result := model.SearchResult{
UniqueID: uniqueID,
Title: discussion.Attributes.Title,
Content: cleanedHTML, // 使用清理后的HTML作为内容
Datetime: createdTime,
Links: links,
Channel: "", // 插件搜索结果Channel为空
}
results = append(results, result)
}
// 判断是否有更多结果
hasMore := apiResp.Links.Next != ""
return results, hasMore, nil
}
// 生成随机IP
func generateRandomIP() string {
return fmt.Sprintf("%d.%d.%d.%d",
rand.Intn(223)+1, // 避免0和255
rand.Intn(255),
rand.Intn(255),
rand.Intn(254)+1) // 避免0
}
// 获取随机UA
func getRandomUA() string {
return userAgents[rand.Intn(len(userAgents))]
}
// 清理HTML内容(参考pan666的cleanHTML函数)
func cleanHTML(html string) string {
// 移除<br>标签
html = strings.ReplaceAll(html, "<br>", "\n")
html = strings.ReplaceAll(html, "<br/>", "\n")
html = strings.ReplaceAll(html, "<br />", "\n")
// 移除其他HTML标签
var result strings.Builder
inTag := false
for _, r := range html {
if r == '<' {
inTag = true
continue
}
if r == '>' {
inTag = false
continue
}
if !inTag {
result.WriteRune(r)
}
}
// 处理HTML实体
output := result.String()
output = strings.ReplaceAll(output, "&", "&")
output = strings.ReplaceAll(output, "<", "<")
output = strings.ReplaceAll(output, ">", ">")
output = strings.ReplaceAll(output, """, "\"")
output = strings.ReplaceAll(output, "'", "'")
output = strings.ReplaceAll(output, "'", "'")
output = strings.ReplaceAll(output, " ", " ")
// 处理多行空白
lines := strings.Split(output, "\n")
var cleanedLines []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
cleanedLines = append(cleanedLines, trimmed)
}
}
return strings.Join(cleanedLines, "\n")
}
// 从文本提取移动云盘链接(bixin专用)
func extractMobileLinksFromText(content string) []model.Link {
var allLinks []model.Link
lines := strings.Split(content, "\n")
// 收集所有可能的链接信息
var linkInfos []struct {
link model.Link
position int
category string
}
// 收集所有可能的密码信息
var passwordInfos []struct {
keyword string
position int
password string
}
// 第一遍:查找所有的链接和密码
for i, line := range lines {
line = strings.TrimSpace(line)
// 只检查移动云盘(bixin只支持移动云盘)
if strings.Contains(line, "caiyun.139.com") {
url := extractURLFromText(line)
if url != "" {
linkInfos = append(linkInfos, struct {
link model.Link
position int
category string
}{
link: model.Link{URL: url, Type: "mobile"},
position: i,
category: "mobile",
})
}
}
// 检查密码/访问码(移动云盘主要使用访问码)
passwordKeywords := []string{"访问码", "密码"}
for _, keyword := range passwordKeywords {
if strings.Contains(line, keyword) {
// 寻找冒号后面的内容
colonPos := strings.Index(line, ":")
if colonPos == -1 {
colonPos = strings.Index(line, ":")
}
if colonPos != -1 && colonPos+1 < len(line) {
password := strings.TrimSpace(line[colonPos+1:])
// 如果密码长度超过10个字符,可能不是密码
if len(password) <= 10 {
passwordInfos = append(passwordInfos, struct {
keyword string
position int
password string
}{
keyword: keyword,
position: i,
password: password,
})
}
}
}
}
}
// 第二遍:将密码与链接匹配
for i := range linkInfos {
// 检查链接自身是否包含密码
password := extractPasswordFromURL(linkInfos[i].link.URL)
if password != "" {
linkInfos[i].link.Password = password
continue
}
// 查找最近的密码
minDistance := 1000000
var closestPassword string
for _, pwInfo := range passwordInfos {
// 移动云盘匹配访问码或密码
match := false
if linkInfos[i].category == "mobile" && (pwInfo.keyword == "访问码" || pwInfo.keyword == "密码") {
match = true
}
if match {
distance := abs(pwInfo.position - linkInfos[i].position)
if distance < minDistance {
minDistance = distance
closestPassword = pwInfo.password
}
}
}
// 只有当距离较近时才认为是匹配的密码
if minDistance <= 3 {
linkInfos[i].link.Password = closestPassword
}
}
// 收集所有有效链接
for _, info := range linkInfos {
allLinks = append(allLinks, info.link)
}
return allLinks
}
// 从文本中提取URL
func extractURLFromText(text string) string {
// 查找URL的起始位置
urlPrefixes := []string{"http://", "https://"}
start := -1
for _, prefix := range urlPrefixes {
pos := strings.Index(text, prefix)
if pos != -1 {
start = pos
break
}
}
if start == -1 {
return ""
}
// 查找URL的结束位置
end := len(text)
endChars := []string{" ", "\t", "\n", "\"", "'", "<", ">", ")", "]", "}", ",", ";"}
for _, char := range endChars {
pos := strings.Index(text[start:], char)
if pos != -1 && start+pos < end {
end = start + pos
}
}
return text[start:end]
}
// 从URL中提取密码
func extractPasswordFromURL(url string) string {
// 查找密码参数
pwdParams := []string{"pwd=", "password=", "passcode=", "code="}
for _, param := range pwdParams {
pos := strings.Index(url, param)
if pos != -1 {
start := pos + len(param)
end := len(url)
// 查找参数结束位置
for i := start; i < len(url); i++ {
if url[i] == '&' || url[i] == '#' {
end = i
break
}
}
if start < end {
return url[start:end]
}
}
}
return ""
}
// 绝对值函数
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// BixinResponse API响应结构
type BixinResponse struct {
Links struct {
First string `json:"first"`
Next string `json:"next,omitempty"`
} `json:"links"`
Data []BixinDiscussion `json:"data"`
Included []BixinPost `json:"included"`
}
// BixinDiscussion 讨论信息
type BixinDiscussion struct {
Type string `json:"type"`
ID string `json:"id"`
Attributes struct {
Title string `json:"title"`
Slug string `json:"slug"`
CommentCount int `json:"commentCount"`
CreatedAt string `json:"createdAt"`
LastPostedAt string `json:"lastPostedAt"`
LastPostNumber int `json:"lastPostNumber"`
IsApproved bool `json:"isApproved"`
} `json:"attributes"`
Relationships struct {
MostRelevantPost struct {
Data struct {
Type string `json:"type"`
ID string `json:"id"`
} `json:"data"`
} `json:"mostRelevantPost"`
} `json:"relationships"`
}
// BixinPost 帖子内容
type BixinPost struct {
Type string `json:"type"`
ID string `json:"id"`
Attributes struct {
Number int `json:"number"`
CreatedAt string `json:"createdAt"`
ContentType string `json:"contentType"`
ContentHTML string `json:"contentHtml"`
} `json:"attributes"`
}
```
## /plugin/bixin/jsonç»ÂæÂÂÃ¥ÂÂæÂÂ.md
# Bixin API 数据结构分析
## 基本信息
- **数据源类型**: JSON API
- **API URL格式**: `https://www.bixbiy.com/api/discussions?filter[q]={关键词}&page[limit]=3&include=mostRelevantPost`
- **请求方法**: `GET`
- **Content-Type**: `application/json`
- **Referer**: `https://www.bixbiy.com/`
- **特殊说明**: 该网站**只提供移动云盘(mobile)链接**,域名固定为`caiyun.139.com`,需要从HTML内容中解析网盘链接和密码
## API响应结构
### 顶层结构
```json
{
"links": {
"first": "https://www.bixbiy.com/api/discussions?filter%5Bq%5D=%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0+&page%5Blimit%5D=3&include=mostRelevantPost",
"next": "https://www.bixbiy.com/api/discussions?filter%5Bq%5D=%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0+&page%5Blimit%5D=3&page%5Boffset%5D=3&include=mostRelevantPost"
},
"data": [
// 讨论帖子数组
],
"included": [
// 相关回复内容数组
]
}
```
### `data`数组中的讨论帖子结构
```json
{
"type": "discussions",
"id": "5754",
"attributes": {
"title": "凡人修仙传(2025)更新至第8集",
"slug": "5754",
"commentCount": 1,
"participantCount": 1,
"createdAt": "2025-07-29T15:31:19+00:00",
"lastPostedAt": "2025-07-29T15:31:19+00:00",
"lastPostNumber": 1,
"canReply": false,
"canRename": false,
"canDelete": false,
"canHide": false,
"isApproved": true,
"canTag": false,
"isSticky": false,
"canSticky": false,
"isStickiest": false,
"isTagSticky": false,
"canStickiest": false,
"canTagSticky": false,
"subscription": null,
"isLocked": false,
"canLock": false
},
"relationships": {
"mostRelevantPost": {
"data": {
"type": "posts",
"id": "6187"
}
}
}
}
```
### `included`数组中的回复内容结构
```json
{
"type": "posts",
"id": "6187",
"attributes": {
"number": 1,
"createdAt": "2025-07-29T15:31:19+00:00",
"contentType": "comment",
"contentHtml": "<p>凡人修仙传(2025)更新至第8集:<a href=\"https://caiyun.139.com/w/i/2oRhbuZoZbFpi\" rel=\"ugc nofollow\">https://caiyun.139.com/w/i/2oRhbuZoZbFpi</a></p>",
"renderFailed": false,
"canEdit": false,
"canDelete": false,
"canHide": false,
"mentionedByCount": 0,
"canFlag": false,
"isApproved": true,
"canApprove": false,
"canLike": false,
"likesCount": 0
}
}
```
## 插件所需字段映射
| 源字段 | 目标字段 | 说明 |
|--------|----------|------|
| `data[].id` | `UniqueID` | 格式: `bixin-{discussion_id}` |
| `data[].attributes.title` | `Title` | 讨论标题 |
| `data[].attributes.createdAt` | `Datetime` | 创建时间 |
| `included[].attributes.contentHtml` | `Content` | HTML内容,需要解析提取网盘链接 |
| `""` | `Channel` | 插件搜索结果Channel为空 |
| `[]` | `Tags` | 标签数组(从标题或内容中提取) |
| 解析的网盘链接 | `Links` | 从HTML内容中提取的网盘链接 |
## 网盘链接解析
### HTML内容特点
- **格式**: 包含HTML标签的文本内容,需要清理HTML标签获取纯文本
- **链接**: 以`<a href="...">`标签形式存在,但更多是纯文本格式
- **示例**:
- HTML格式: `<a href="https://caiyun.139.com/w/i/2oRhbuZoZbFpi" rel="ugc nofollow">https://caiyun.139.com/w/i/2oRhbuZoZbFpi</a>`
- 纯文本格式: `https://caiyun.139.com/w/i/2oRhbuZoZbFpi`
### 支持的网盘类型(bixin专用)
| 网盘类型 | 域名特征 | 示例链接 | 密码关键词 |
|---------|----------|----------|------------|
| **移动云盘** | `caiyun.139.com` | `https://caiyun.139.com/w/i/2oRhbuZoZbFpi` | 访问码、密码 |
**重要说明**: bixin插件**只支持移动云盘**,所有链接都是`caiyun.139.com`域名,不需要处理其他网盘类型。
### 链接解析策略(bixin专用)
1. **HTML清理**: 移除HTML标签,保留纯文本内容
2. **链接提取**: 从纯文本中提取**移动云盘链接**(只处理`caiyun.139.com`)
3. **密码匹配**: 匹配"访问码"或"密码"关键词
4. **位置关联**: 密码通常出现在链接附近的行中
## 插件开发指导
### 请求示例
```go
searchURL := fmt.Sprintf("https://www.bixbiy.com/api/discussions?filter[q]=%s&page[limit]=3&include=mostRelevantPost", url.QueryEscape(keyword))
```
### 请求头设置(参考pan666实现)
```go
req.Header.Set("User-Agent", getRandomUA()) // 使用随机UA避免反爬虫
req.Header.Set("X-Forwarded-For", generateRandomIP()) // 随机IP
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Sec-Fetch-Dest", "empty")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Site", "same-origin")
```
### SearchResult构建示例
```go
result := model.SearchResult{
UniqueID: fmt.Sprintf("bixin-%s", discussion.ID),
Title: discussion.Attributes.Title,
Content: extractTextFromHTML(post.Attributes.ContentHTML),
Links: extractLinksFromHTML(post.Attributes.ContentHTML),
Tags: extractTagsFromTitle(discussion.Attributes.Title),
Channel: "", // 插件搜索结果Channel为空
Datetime: parseTime(discussion.Attributes.CreatedAt),
}
```
### HTML内容解析函数(参考pan666实现)
```go
// 清理HTML内容(参考pan666的cleanHTML函数)
func (p *BixinAsyncPlugin) cleanHTML(html string) string {
// 移除<br>标签
html = strings.ReplaceAll(html, "<br>", "\n")
html = strings.ReplaceAll(html, "<br/>", "\n")
html = strings.ReplaceAll(html, "<br />", "\n")
// 移除其他HTML标签
var result strings.Builder
inTag := false
for _, r := range html {
if r == '<' {
inTag = true
continue
}
if r == '>' {
inTag = false
continue
}
if !inTag {
result.WriteRune(r)
}
}
// 处理HTML实体
output := result.String()
output = strings.ReplaceAll(output, "&", "&")
output = strings.ReplaceAll(output, "<", "<")
output = strings.ReplaceAll(output, ">", ">")
output = strings.ReplaceAll(output, """, "\"")
output = strings.ReplaceAll(output, "'", "'")
output = strings.ReplaceAll(output, "'", "'")
output = strings.ReplaceAll(output, " ", " ")
// 处理多行空白
lines := strings.Split(output, "\n")
var cleanedLines []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
cleanedLines = append(cleanedLines, trimmed)
}
}
return strings.Join(cleanedLines, "\n")
}
// 从文本中提取链接(参考pan666的extractLinksFromText函数)
func (p *BixinAsyncPlugin) extractLinksFromText(content string) []model.Link {
var allLinks []model.Link
lines := strings.Split(content, "\n")
// 收集所有可能的链接信息
var linkInfos []struct {
link model.Link
position int
category string
}
// 收集所有可能的密码信息
var passwordInfos []struct {
keyword string
position int
password string
}
// 第一遍:查找所有的链接和密码
for i, line := range lines {
line = strings.TrimSpace(line)
// 只检查移动云盘(bixin只支持移动云盘)
if strings.Contains(line, "caiyun.139.com") {
url := p.extractURLFromText(line)
if url != "" {
linkInfos = append(linkInfos, struct {
link model.Link
position int
category string
}{
link: model.Link{URL: url, Type: "mobile"},
position: i,
category: "mobile",
})
}
}
// 检查密码/访问码(移动云盘主要使用访问码)
passwordKeywords := []string{"访问码", "密码"}
for _, keyword := range passwordKeywords {
if strings.Contains(line, keyword) {
// 寻找冒号后面的内容
colonPos := strings.Index(line, ":")
if colonPos == -1 {
colonPos = strings.Index(line, ":")
}
if colonPos != -1 && colonPos+1 < len(line) {
password := strings.TrimSpace(line[colonPos+1:])
// 如果密码长度超过10个字符,可能不是密码
if len(password) <= 10 {
passwordInfos = append(passwordInfos, struct {
keyword string
position int
password string
}{
keyword: keyword,
position: i,
password: password,
})
}
}
}
}
}
// 第二遍:将密码与链接匹配
for i := range linkInfos {
// 检查链接自身是否包含密码
password := p.extractPasswordFromURL(linkInfos[i].link.URL)
if password != "" {
linkInfos[i].link.Password = password
continue
}
// 查找最近的密码
minDistance := 1000000
var closestPassword string
for _, pwInfo := range passwordInfos {
// 移动云盘匹配访问码或密码
match := false
if linkInfos[i].category == "mobile" && (pwInfo.keyword == "访问码" || pwInfo.keyword == "密码") {
match = true
}
if match {
distance := abs(pwInfo.position - linkInfos[i].position)
if distance < minDistance {
minDistance = distance
closestPassword = pwInfo.password
}
}
}
// 只有当距离较近时才认为是匹配的密码
if minDistance <= 3 {
linkInfos[i].link.Password = closestPassword
}
}
// 收集所有有效链接
for _, info := range linkInfos {
allLinks = append(allLinks, info.link)
}
return allLinks
}
```
### 辅助函数(参考pan666实现)
```go
// 从文本中提取URL
func (p *BixinAsyncPlugin) extractURLFromText(text string) string {
// 查找URL的起始位置
urlPrefixes := []string{"http://", "https://"}
start := -1
for _, prefix := range urlPrefixes {
pos := strings.Index(text, prefix)
if pos != -1 {
start = pos
break
}
}
if start == -1 {
return ""
}
// 查找URL的结束位置
end := len(text)
endChars := []string{" ", "\t", "\n", "\"", "'", "<", ">", ")", "]", "}", ",", ";"}
for _, char := range endChars {
pos := strings.Index(text[start:], char)
if pos != -1 && start+pos < end {
end = start + pos
}
}
return text[start:end]
}
// 从URL中提取密码
func (p *BixinAsyncPlugin) extractPasswordFromURL(url string) string {
// 查找密码参数
pwdParams := []string{"pwd=", "password=", "passcode=", "code="}
for _, param := range pwdParams {
pos := strings.Index(url, param)
if pos != -1 {
start := pos + len(param)
end := len(url)
// 查找参数结束位置
for i := start; i < len(url); i++ {
if url[i] == '&' || url[i] == '#' {
end = i
break
}
}
if start < end {
return url[start:end]
}
}
}
return ""
}
// 绝对值函数
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// 生成随机UA
func getRandomUA() string {
userAgents := []string{
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36",
}
return userAgents[rand.Intn(len(userAgents))]
}
// 生成随机IP
func generateRandomIP() string {
return fmt.Sprintf("%d.%d.%d.%d",
rand.Intn(223)+1, // 避免0和255
rand.Intn(255),
rand.Intn(255),
rand.Intn(254)+1) // 避免0
}
```
### 时间解析函数
```go
func (p *BixinAsyncPlugin) parseTime(timeStr string) time.Time {
// 解析ISO 8601格式时间
t, err := time.Parse("2006-01-02T15:04:05Z07:00", timeStr)
if err != nil {
return time.Now()
}
return t
}
```
## 数据结构定义
### API响应结构体
```go
type BixinAPIResponse struct {
Links BixinLinks `json:"links"`
Data []BixinDiscussion `json:"data"`
Included []BixinPost `json:"included"`
}
type BixinLinks struct {
First string `json:"first"`
Next string `json:"next"`
}
type BixinDiscussion struct {
Type string `json:"type"`
ID string `json:"id"`
Attributes BixinDiscussionAttributes `json:"attributes"`
Relationships BixinRelationships `json:"relationships"`
}
type BixinDiscussionAttributes struct {
Title string `json:"title"`
Slug string `json:"slug"`
CommentCount int `json:"commentCount"`
ParticipantCount int `json:"participantCount"`
CreatedAt string `json:"createdAt"`
LastPostedAt string `json:"lastPostedAt"`
LastPostNumber int `json:"lastPostNumber"`
IsApproved bool `json:"isApproved"`
IsLocked bool `json:"isLocked"`
}
type BixinRelationships struct {
MostRelevantPost BixinPostRef `json:"mostRelevantPost"`
}
type BixinPostRef struct {
Data BixinPostData `json:"data"`
}
type BixinPostData struct {
Type string `json:"type"`
ID string `json:"id"`
}
type BixinPost struct {
Type string `json:"type"`
ID string `json:"id"`
Attributes BixinPostAttributes `json:"attributes"`
}
type BixinPostAttributes struct {
Number int `json:"number"`
CreatedAt string `json:"createdAt"`
ContentType string `json:"contentType"`
ContentHTML string `json:"contentHtml"`
RenderFailed bool `json:"renderFailed"`
IsApproved bool `json:"isApproved"`
LikesCount int `json:"likesCount"`
}
```
## 特殊处理逻辑
### 1. 讨论与回复关联
- 通过`relationships.mostRelevantPost.data.id`关联讨论和回复
- 需要在`included`数组中查找对应的回复内容
- 一个讨论可能对应多个回复,需要处理所有相关回复
### 2. HTML内容清理
- 移除HTML标签获取纯文本内容
- 解码HTML实体(如`<`、`>`等)
- 提取链接时保留原始URL
### 3. 链接验证
- 验证链接是否为有效的网盘链接
- 过滤掉无效链接(如`javascript:`、`#`等)
- 提取链接中的密码信息
### 4. 标签提取
- 从讨论标题中提取关键词作为标签
- 可以基于内容类型、年份等信息生成标签
- 支持中文和英文标签
## 与pan666插件的相似性
| 特性 | bixin | pan666 | 说明 |
|------|-------|--------|------|
| **数据源** | 论坛讨论API | 论坛讨论API | 使用相同的论坛系统 |
| **API结构** | 相同 | 相同 | JSON结构完全一致 |
| **链接解析** | 文本解析 | 文本解析 | 都需要从HTML清理后的文本中提取 |
| **主要网盘** | 移动云盘 | 移动云盘 | 都主要提供移动云盘链接 |
| **密码匹配** | 位置关联 | 位置关联 | 使用相同的密码匹配策略 |
| **过滤策略** | 跳过Service层过滤 | 跳过Service层过滤 | 都使用`NewBaseAsyncPluginWithFilter` |
## 与其他插件的差异
| 特性 | bixin/pan666 | 其他插件 | 说明 |
|------|-------------|----------|------|
| **数据源** | 论坛讨论API | 网盘搜索API | 需要解析HTML内容 |
| **链接格式** | 纯文本格式 | 直接URL字符串 | 需要从文本中提取 |
| **内容结构** | 讨论+回复 | 直接资源信息 | 需要关联处理 |
| **链接验证** | 必需 | 可选 | 论坛可能包含无效链接 |
| **过滤策略** | 跳过Service层过滤 | 启用Service层过滤 | 论坛内容需要宽泛搜索 |
## 注意事项
1. **HTML解析**: 需要正确处理HTML标签和实体,参考pan666的cleanHTML函数
2. **链接提取**: 主要从纯文本中提取链接,而非HTML标签
3. **内容关联**: 需要将讨论和回复内容正确关联
4. **链接验证**: 论坛内容可能包含无效链接,需要过滤
5. **时间解析**: 使用ISO 8601格式解析时间
6. **错误处理**: API可能返回空数据或格式错误
7. **反爬虫**: 使用随机UA和IP避免反爬虫检测
8. **密码匹配**: 使用位置关联策略匹配密码和链接
## 开发建议
- **优先级设置**: 建议设置为优先级3,数据质量一般
- **Service层过滤**: 跳过Service层过滤,使用`NewBaseAsyncPluginWithFilter("bixin", 3, true)`
- **HTML处理**: 重点处理HTML内容的解析和清理,参考pan666实现
- **链接提取**: 实现robust的链接提取和验证机制,**只处理移动云盘**(caiyun.139.com)
- **缓存策略**: 建议使用较短的缓存TTL,论坛内容更新频繁
- **错误日志**: 详细记录HTML解析和链接提取的错误信息
- **基于pan666**: 可以直接基于pan666插件进行修改,主要更改API URL和插件名称
## API调用示例
### 搜索请求示例
```bash
curl "https://www.bixbiy.com/api/discussions?filter[q]=凡人修仙传&page[limit]=3&include=mostRelevantPost" \
-H "Referer: https://www.bixbiy.com/" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
```
### 完整流程示例
1. **发送搜索请求**: 获取讨论列表和回复内容
2. **解析讨论数据**: 提取标题、时间等基本信息
3. **关联回复内容**: 通过ID关联讨论和回复
4. **清理HTML内容**: 移除HTML标签,获取纯文本
5. **提取网盘链接**: 从纯文本中提取**移动云盘链接**(只处理caiyun.139.com)
6. **匹配密码**: 使用位置关联策略匹配密码和链接
7. **验证链接有效性**: 过滤无效链接
8. **构建搜索结果**: 转换为PanSou标准格式
9. **返回结果**: 包含标题、内容、链接等信息
### 插件实现建议
```go
// 基于pan666插件进行修改
func NewBixinAsyncPlugin() *BixinAsyncPlugin {
return &BixinAsyncPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("bixin", 3, true), // 跳过Service层过滤
retries: MaxRetries,
}
}
// 主要修改点:
// 1. 更改API URL: "https://www.bixbiy.com/api/discussions"
// 2. 更改插件名称: "bixin"
// 3. 简化链接提取:只处理移动云盘(caiyun.139.com)
// 4. 简化密码匹配:只匹配"访问码"和"密码"关键词
// 5. 保持相同的HTML解析逻辑
```
## /plugin/cldi/cldi.go
```go path="/plugin/cldi/cldi.go"
package cldi
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/PuerkitoBio/goquery"
"pansou/model"
"pansou/plugin"
)
type CldiPlugin struct {
*plugin.BaseAsyncPlugin
}
const (
// 并发数限制
MaxConcurrency = 10
// 最大搜索页数
MaxPages = 5
)
var (
// 广告清理正则表达式
adRegex = regexp.MustCompile(`【[^】]*】`)
// 文件大小和名称分离正则
fileSizeRegex = regexp.MustCompile(`^(.+?) <span class="lightColor">([^<]+)</span>$`)
// 各种数字提取正则
numberRegex = regexp.MustCompile(`\d+`)
)
func init() {
p := &CldiPlugin{
BaseAsyncPlugin: plugin.NewBaseAsyncPluginWithFilter("cldi", 3, true), // 磁力搜索插件,跳过Service层过滤
}
plugin.RegisterGlobalPlugin(p)
}
// Search 执行搜索并返回结果
func (p *CldiPlugin) Search(keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
result, err := p.SearchWithResult(keyword, ext)
if err != nil {
return nil, err
}
return result.Results, nil
}
// SearchWithResult 执行搜索并返回包含IsFinal标记的结果
func (p *CldiPlugin) SearchWithResult(keyword string, ext map[string]interface{}) (model.PluginSearchResult, error) {
return p.AsyncSearchWithResult(keyword, p.searchImpl, p.MainCacheKey, ext)
}
// searchImpl 实际的搜索实现
func (p *CldiPlugin) searchImpl(client *http.Client, keyword string, ext map[string]interface{}) ([]model.SearchResult, error) {
// 1. 首先搜索第一页
firstPageResults, err := p.searchPage(client, keyword, 1)
if err != nil {
return nil, fmt.Errorf("[%s] 搜索第一页失败: %w", p.Name(), err)
}
// 存储所有结果
var allResults []model.SearchResult
allResults = append(allResults, firstPageResults...)
// 2. 并发搜索其他页面(第2页到第5页)
if MaxPages > 1 {
var wg sync.WaitGroup
var mu sync.Mutex
// 使用信号量控制并发数
semaphore := make(chan struct{}, MaxConcurrency)
// 存储每页结果
pageResults := make(map[int][]model.SearchResult)
for page := 2; page <= MaxPages; page++ {
wg.Add(1)
go func(pageNum int) {
defer wg.Done()
// 获取信号量
semaphore <- struct{}{}
defer func() { <-semaphore }()
// 添加小延迟避免过于频繁的请求
time.Sleep(time.Duration(pageNum%3) * 100 * time.Millisecond)
currentPageResults, err := p.searchPage(client, keyword, pageNum)
if err == nil && len(currentPageResults) > 0 {
mu.Lock()
pageResults[pageNum] = currentPageResults
mu.Unlock()
}
}(page)
}
wg.Wait()
// 按页码顺序合并所有页面的结果
for page := 2; page <= MaxPages; page++ {
if results, exists := pageResults[page]; exists {
allResults = append(allResults, results...)
}
}
}
// 3. 关键词过滤
return plugin.FilterResultsByKeyword(allResults, keyword), nil
}
// searchPage 搜索指定页面
func (p *CldiPlugin) searchPage(client *http.Client, keyword string, page int) ([]model.SearchResult, error) {
// 构建搜索URL (分类=0全部, 排序=2按添加时间)
searchURL := fmt.Sprintf("https://wvmzbxki.1122132.xyz/search-%s-0-2-%d.html", url.QueryEscape(keyword), page)
// 创建带超时的上下文
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 创建请求
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return nil, fmt.Errorf("[%s] 创建请求失败: %w", p.Name(), err)
}
// 设置请求头
p.setRequestHeaders(req)
// 发送请求
resp, err := p.doRequestWithRetry(req, client)
if err != nil {
return nil, fmt.Errorf("[%s] 搜索请求失败: %w", p.Name(), err)
}
defer resp.Body.Close()
// 检查状态码
if resp.StatusCode != 200 {
return nil, fmt.Errorf("[%s] 请求返回状态码: %d", p.Name(), resp.StatusCode)
}
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("[%s] 读取响应失败: %w", p.Name(), err)
}
// 解析HTML
doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body)))
if err != nil {
return nil, fmt.Errorf("[%s] HTML解析失败: %w", p.Name(), err)
}
// 提取搜索结果
return p.extractSearchResults(doc), nil
}
// setRequestHeaders 设置请求头
func (p *CldiPlugin) setRequestHeaders(req *http.Request) {
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
req.Header.Set("Referer", "https://wvmzbxki.1122132.xyz/")
}
// doRequestWithRetry 带重试机制的HTTP请求
func (p *CldiPlugin) doRequestWithRetry(req *http.Request, client *http.Client) (*http.Response, error) {
maxRetries := 3
var lastErr error
for i := 0; i < maxRetries; i++ {
if i > 0 {
// 指数退避重试
backoff := time.Duration(1<<uint(i-1)) * 200 * time.Millisecond
time.Sleep(backoff)
}
// 克隆请求
reqClone := req.Clone(req.Context())
resp, err := client.Do(reqClone)
if err == nil && resp.StatusCode == 200 {
return resp, nil
}
if resp != nil {
resp.Body.Close()
}
lastErr = err
}
return nil, fmt.Errorf("重试 %d 次后仍然失败: %w", maxRetries, lastErr)
}
// extractSearchResults 提取搜索结果
func (p *CldiPlugin) extractSearchResults(doc *goquery.Document) []model.SearchResult {
var results []model.SearchResult
// 查找所有搜索结果
doc.Find(".tbox .ssbox").Each(func(i int, s *goquery.Selection) {
result := p.parseSearchResult(s)
if result.Title != "" && len(result.Links) > 0 {
results = append(results, result)
}
})
return results
}
// parseSearchResult 解析单个搜索结果
func (p *CldiPlugin) parseSearchResult(s *goquery.Selection) model.SearchResult {
result := model.SearchResult{
Channel: "", // 插件搜索结果必须为空字符串
Datetime: time.Now(),
}
// 提取标题和分类
titleSection := s.Find(".title h3")
// 提取分类
category := strings.TrimSpace(titleSection.Find("span").First().Text())
if category != "" {
result.Tags = []string{p.mapCategory(category)}
}
// 提取标题
titleLink := titleSection.Find("a")
title := strings.TrimSpace(titleLink.Text())
result.Title = p.cleanTitle(title)
// 提取磁力链接和元数据
p.extractMagnetInfo(s, &result)
// 提取文件列表作为内容
p.extractFileList(s, &result)
// 生成唯一ID
result.UniqueID = fmt.Sprintf("%s-%d", p.Name(), time.Now().UnixNano())
return result
}
// extractMagnetInfo 提取磁力链接和元数据
func (p *CldiPlugin) extractMagnetInfo(s *goquery.Selection, result *model.SearchResult) {
sbar := s.Find(".sbar")
// 提取磁力链接
magnetLink, exists := sbar.Find("a[href^='magnet:']").Attr("href")
if exists && magnetLink != "" {
result.Links = []model.Link{{
Type: "magnet",
URL: magnetLink,
}}
}
// 提取添加时间
sbar.Find("span").Each(func(i int, span *goquery.Selection) {
text := span.Text()
if strings.Contains(text, "添加时间:") {
timeStr := strings.TrimSpace(span.Find("b").Text())
if timeStr != "" {
if parsedTime, err := time.Parse("2006-01-02", timeStr); err == nil {
result.Datetime = parsedTime
}
}
}
})
}
// extractFileList 提取文件列表
func (p *CldiPlugin) extractFileList(s *goquery.Selection, result *model.SearchResult) {
var fileList []string
s.Find(".slist ul li").Each(func(i int, li *goquery.Selection) {
// 获取原始HTML以解析文件名和大小
html, _ := li.Html()
// 使用正则表达式分离文件名和大小
if matches := fileSizeRegex.FindStringSubmatch(html); len(matches) == 3 {
fileName := strings.TrimSpace(matches[1])
fileSize := strings.TrimSpace(matches[2])
if fileName != "" && fileSize != "" {
fileList = append(fileList, fmt.Sprintf("%s (%s)", fileName, fileSize))
}
} else {
// 回退方案:直接使用文本内容
text := strings.TrimSpace(li.Text())
if text != "" {
fileList = append(fileList, text)
}
}
})
if len(fileList) > 0 {
result.Content = strings.Join(fileList, "\n")
}
}
// mapCategory 映射分类
func (p *CldiPlugin) mapCategory(category string) string {
// 移除方括号
category = strings.Trim(category, "[]")
switch category {
case "影视":
return "影视"
case "音乐":
return "音乐"
case "图像":
return "图像"
case "文档书籍":
return "文档"
case "压缩文件":
return "压缩包"
case "安装包":
return "软件"
case "其他":
return "其他"
default:
return "其他"
}
}
// cleanTitle 清理标题中的广告内容
func (p *CldiPlugin) cleanTitle(title string) string {
// 移除【】内的广告内容
cleaned := adRegex.ReplaceAllString(title, "")
// 清理多余的空格
cleaned = strings.TrimSpace(cleaned)
cleaned = regexp.MustCompile(`\s+`).ReplaceAllString(cleaned, " ")
return cleaned
}
```
## /plugin/cldi/htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md
# CLDI (磁力帝) HTML结构分析
## 网站信息
- **网站名称**: 磁力帝
- **域名**: cldcld.cc (通过动态域名访问)
- **类型**: 磁力搜索引擎
- **特点**: 专门搜索BT种子和磁力链接
## 搜索页面结构
### 1. 搜索URL模式
```
https://[域名]/search-[关键词]-[分类]-[排序]-[页码].html
示例:
https://wvmzbxki.1122132.xyz/search-%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0-0-2-1.html
参数说明:
- 关键词: URL编码的搜索关键词
- 分类: 0=全部, 1=影视, 2=音乐, 3=图像, 4=文档书籍, 5=压缩文件, 6=安装包, 7=其他
- 排序: 0=相关程度, 1=文件大小, 2=添加时间, 3=热度, 4=最近访问
- 页码: 从1开始
```
### 2. 搜索结果容器
- **父容器**: `.tbox`
- **结果项**: `.ssbox` (每个搜索结果)
### 3. 单个搜索结果结构
#### 标题区域 (.title)
```html
<div class="title">
<h3>
<span>[影视]</span>
<a href="/hash/186e709110410a995f1a4bece816d70c5986a5d5.html" target="_blank">
【不太灵影视 www.2BT0.com】<span class="red">凡人修仙传</span>[60帧率版本][全30集][国语配音+中文字幕].2025.2160p.WEB-DL.H265.60FPS.AAC-DeePTV
</a>
</h3>
</div>
提取要素:
- 分类: span 文本内容 (如 "[影视]")
- 详情页链接: a 的 href 属性 (用于构造磁力链接)
- 标题: a 的文本内容 (需要去掉广告标记)
```
#### 文件列表 (.slist)
```html
<div class="slist">
<ul>
<li>凡人修仙传.The.Immortal.Ascension.S01E08.2025.2160p.WEB-DL.H265.60FPS.AAC-DeePTV.mp4 <span class="lightColor">2.7 GB</span></li>
<li>凡人修仙传.The.Immortal.Ascension.S01E01.2025.2160p.WEB-DL.H265.60FPS.AAC-DeePTV.mp4 <span class="lightColor">2.4 GB</span></li>
<!-- 更多文件... -->
</ul>
</div>
提取要素:
- 文件名: li 文本内容 (去掉 后的内容)
- 文件大小: span.lightColor 文本内容
```
#### 元数据栏 (.sbar)
```html
<div class="sbar">
<span><a href="magnet:?xt=urn:btih:186E709110410A995F1A4BECE816D70C5986A5D5" target="_blank">[磁力链接]</a></span>
<span>添加时间:<b>2025-08-19</b></span>
<span>大小:<b class="cpill yellow-pill">54.3 GB</b></span>
<span>最近下载:<b>2025-08-20</b></span>
<span>热度:<b>73</b></span>
</div>
提取要素:
- 磁力链接: a[href^="magnet:"] 的 href 属性
- 添加时间: "添加时间:" 后的 b 标签文本
- 总大小: "大小:" 后的 b 标签文本
- 最近下载: "最近下载:" 后的 b 标签文本
- 热度: "热度:" 后的 b 标签文本
```
## CSS选择器总结
| 数据项 | CSS选择器 | 提取方式 |
|--------|-----------|----------|
| 搜索结果列表 | `.tbox .ssbox` | 遍历所有结果项 |
| 分类标签 | `.title h3 span` | 文本内容,去掉 `[]` |
| 标题 | `.title h3 a` | 文本内容,需要清理广告 |
| 详情页链接 | `.title h3 a` | href 属性 |
| 文件列表 | `.slist ul li` | 文本内容,分割文件名和大小 |
| 磁力链接 | `.sbar a[href^="magnet:"]` | href 属性 |
| 添加时间 | `.sbar span:contains("添加时间:") b` | 文本内容 |
| 总大小 | `.sbar span:contains("大小:") b` | 文本内容 |
| 热度 | `.sbar span:contains("热度:") b` | 文本内容 |
## 实现要点
### 1. 标题清理
- 需要移除 `【xxx】` 格式的广告标记
- 示例: `【不太灵影视 www.2BT0.com】凡人修仙传[...]` → `凡人修仙传[...]`
### 2. 分类映射
```
[影视] → 影视
[音乐] → 音乐
[图像] → 图像
[文档书籍] → 文档
[压缩文件] → 压缩包
[安装包] → 软件
[其他] → 其他
```
### 3. 文件列表解析
- 每个 li 包含: `文件名 <span class="lightColor">大小</span>`
- 需要分离文件名和大小信息
### 4. 时间格式
- 格式: `YYYY-MM-DD`
- 需要转换为标准时间格式
### 5. 磁力链接处理
- 直接从搜索页提取,无需访问详情页
- 链接格式: `magnet:?xt=urn:btih:[HASH]`
## 搜索参数
- 支持中文关键词 (需要URL编码)
- 默认使用全部分类 (0) 和按添加时间排序 (2)
- 支持分页 (从第1页开始)
## /plugin/clmao/htmlç»ÂæÂÂÃ¥ÂÂæÂÂ.md
# Clmao (磁力猫) HTML结构分析
## 网站信息
- **网站名称**: 磁力猫 - 磁力搜索引擎
- **基础URL**: https://www.8800492.xyz/
- **功能**: BT种子磁力链接搜索
- **搜索URL格式**: `/search-{keyword}-{category}-{sort}-{page}.html`
## 搜索页面结构
### 1. 搜索URL参数说明
```
https://www.8800492.xyz/search-%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0-0-0-1.html
^关键词(URL编码) ^分类 ^排序 ^页码
```
**参数说明**:
- `keyword`: URL编码的搜索关键词
- `category`: 分类筛选 (0=全部, 1=影视, 2=音乐, 3=图像, 4=文档书籍, 5=压缩文件, 6=安装包, 7=其他)
- `sort`: 排序方式 (0=相关程度, 1=文件大小, 2=添加时间, 3=热度, 4=最近访问)
- `page`: 页码 (从1开始)
### 2. 搜索结果容器
```html
<div class="tbox">
<div class="ssbox">
<!-- 单个搜索结果 -->
</div>
<!-- 更多结果... -->
</div>
```
### 3. 单个搜索结果结构
#### 标题区域
```html
<div class="title">
<h3>
<span>[影视]</span> <!-- 分类标签 -->
<a href="/hash/a6cfa78f3c36e78c7f6342ff12de9590a25db441.html" target="_blank">
19<span class="red">凡人修仙传</span>20<span class="red">凡人修仙传</span>21天龙八部...
</a>
</h3>
</div>
```
#### 文件列表区域
```html
<div class="slist">
<ul>
<li>rw.mp4 <span class="lightColor">145.5 MB</span></li>
<!-- 更多文件... -->
</ul>
</div>
```
#### 信息栏区域
```html
<div class="sbar">
<span><a href="magnet:?xt=urn:btih:A6CFA78F3C36E78C7F6342FF12DE9590A25DB441" target="_blank">[磁力链接]</a></span>
<span>添加时间:<b>2022-06-28</b></span>
<span>大小:<b class="cpill yellow-pill">145.5 MB</b></span>
<span>最近下载:<b>2025-08-19</b></span>
<span>热度:<b>2348</b></span>
</div>
```
### 4. 分页区域
```html
<div class="pager">
<span>共61页</span>
<a href="#">上一页</a>
<span>1</span> <!-- 当前页 -->
<a href="/search-%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0-0-0-2.html">2</a>
<!-- 更多页码... -->
<a href="/search-%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0-0-0-2.html">下一页</a>
</div>
```
## 数据提取要点
### 需要提取的信息
1. **搜索结果基本信息**:
- 标题: `.title h3 a` 的文本内容
- 分类: `.title h3 span` 的文本内容
- 详情页链接: `.title h3 a` 的 `href` 属性
2. **磁力链接信息**:
- 磁力链接: `.sbar a[href^="magnet:"]` 的 `href` 属性
- 文件大小: `.sbar .cpill` 的文本内容
- 添加时间: `.sbar` 中 "添加时间:" 后的 `<b>` 标签内容
- 热度: `.sbar` 中 "热度:" 后的 `<b>` 标签内容
3. **文件列表**:
- 文件名和大小: `.slist ul li` 的文本内容
### CSS选择器
```css
/* 搜索结果容器 */
.tbox .ssbox
/* 标题和分类 */
.title h3 span /* 分类 */
.title h3 a /* 标题和详情链接 */
/* 磁力链接 */
.sbar a[href^="magnet:"]
/* 文件信息 */
.slist ul li
/* 元数据 */
.sbar span b /* 时间、大小、热度等 */
```
## 特殊处理
### 1. 关键词高亮
搜索关键词在结果中用 `<span class="red">` 标签高亮显示
### 2. 文件大小格式
文件大小格式多样: `145.5 MB`、`854.2 MB`、`41.5 GB` 等
### 3. 磁力链接格式
标准磁力链接格式: `magnet:?xt=urn:btih:{40位哈希值}`
### 4. 分类映射
- [影视] → movie/video
- [音乐] → music
- [图像] → image
- [文档书籍] → document
- [压缩文件] → archive
- [安装包] → software
- [其他] → others
## 请求头要求
建议设置常见的浏览器请求头:
- User-Agent: 现代浏览器UA
- Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
- Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
## 注意事项
1. 网站可能有反爬虫机制,需要适当的请求间隔
2. 搜索关键词需要进行URL编码
3. 磁力链接是直接可用的,无需额外处理
4. 部分结果可能包含大量无关文件,需要进行过滤
5. 网站域名可能会变更,需要支持域名更新
The content has been capped at 50000 tokens. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.