From f99824ee40eeebf77ed818a1dbfbb81055893d0b Mon Sep 17 00:00:00 2001 From: Harold <8866033@gmail.com> Date: Sun, 19 Apr 2026 19:57:05 +0800 Subject: [PATCH] docs: add cupfox spider design --- .../specs/2026-04-19-cupfox-spider-design.md | 336 ++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 py/docs/superpowers/specs/2026-04-19-cupfox-spider-design.md diff --git a/py/docs/superpowers/specs/2026-04-19-cupfox-spider-design.md b/py/docs/superpowers/specs/2026-04-19-cupfox-spider-design.md new file mode 100644 index 0000000..ef85e7a --- /dev/null +++ b/py/docs/superpowers/specs/2026-04-19-cupfox-spider-design.md @@ -0,0 +1,336 @@ +# 茶杯狐 Python 爬虫设计 + +## 目标 + +在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的茶杯狐站点爬虫,完整覆盖以下能力: + +- 首页分类 +- 首页推荐 +- 分类列表 +- 搜索 +- 详情解析 +- 播放解析 +- 搜索/详情/播放页面的人机验证绕过 + +实现基于站点 HTML 页面和 `foxplay` 接口,不引入服务端路由层,不修改 `base/` 公共层,并保持当前仓库的短 ID 和结果结构约定。 + +## 范围 + +本次实现包含: + +- 新增独立脚本,文件名为 `茶杯狐.py` +- 新增对应单元测试 `tests/test_茶杯狐.py` +- 使用单一站点主域:`https://www.cupfox.ai` +- 支持 `home/homeVideo/category/search/detail/player` 主链路 +- 实现命中验证页后的 Cookie 合并、`token` 提取和 `/robot.php` 过盾请求 +- 实现 `foxplay/api.php` 的两种播放链接解密 +- 过滤 404 占位、403 和明显不可用链接,并在失败时回退 `parse=1` + +本次实现不包含: + +- 多域名探活与自动切换 +- 浏览器自动化、JS 引擎执行和验证码识别 +- 站外搜索源聚合和非站内播放链路修复 +- 登录态管理和本地缓存持久化 + +## 方案选择 + +采用仓库现有的“单站点单文件 + 单测”方案: + +- 对外保持 `Spider` 接口兼容 +- 对内拆分为短 ID 处理、请求与过盾、卡片解析、详情解析、播放解密几个 helper +- 只在站点文件内部维护逻辑,不修改 `base/` + +不直接照搬参考 JS 路由层的原因是: + +- 当前仓库只消费 Spider 接口,不消费 Node 路由插件 +- Python 版已有稳定的 HTML 爬虫实现风格,应延续既有结构 +- 本次重点是把茶杯狐能力转成可测试的 Python Spider,而不是保留一层额外运行时 + +## 模块边界 + +新增脚本内部职责拆分如下: + +- `init` + - 初始化主域、请求头、分页大小和字符映射表 +- `homeContent` + - 返回分类列表和静态筛选空结构 +- `homeVideoContent` + - 请求首页并解析推荐卡片,去重后截断 +- `categoryContent` + - 请求分类页,复用卡片解析并返回页码、总量估算和列表 +- `searchContent` + - 请求搜索页,复用卡片解析并支持 `quick` +- `detailContent` + - 请求详情页,提取元数据和多线路播放列表 +- `playerContent` + - 请求播放页、提取 `player_aaaa`、调用 `foxplay/api.php`、解密直链并决定是否回退解析 +- 私有辅助函数 + - URL 补全与短 ID 编解码 + - HTML 请求与过盾流程 + - Cookie 合并 + - `token` 提取和过盾加密 + - `player_aaaa` 解析 + - 两套播放解密 + - 占位/禁播链接识别 + +## ID 与 URL 设计 + +遵循当前仓库“短 ID,不暴露完整 URL”的约定。 + +详情页短 ID: + +- 页面链接 `/movie/.html` +- 存储为 `detail/` + +播放页短 ID: + +- 页面链接 `/play/.html` +- 存储为 `play/` + +编码与解码原则: + +- 只接受站内标准详情/播放路径 +- 解析失败时返回空字符串,调用侧直接回空结果或回退 +- 内部统一通过 `urljoin` 补全相对路径 + +## Host 与请求策略 + +本次只实现单域: + +- `https://www.cupfox.ai` + +统一请求头至少包含: + +- `User-Agent` +- `Referer` +- `Accept` +- `Accept-Language` + +请求原则: + +- HTML 请求超时固定为 15 秒 +- 普通页面请求走 `self.fetch` / `self.post` +- 页面请求默认不自动依赖持久会话,而是在单次过盾流程里显式维护 Cookie 字典 +- 命中验证时只处理当前请求,不做全局 Cookie 缓存 +- 请求失败时返回空结果或播放页回退,不抛出未处理异常 + +## 过盾设计 + +页面请求统一走 `_request_with_firewall()`: + +1. 首次请求目标 URL,收集响应文本和 `set-cookie` +2. 如果页面不包含 `人机验证` 或 `verifyBox`,直接返回 HTML +3. 如果命中验证,从 HTML 中提取 `var token = encrypt("...")` +4. 对当前 URL 和原始 token 分别执行参考实现中的字符位移 + 随机填充 + Base64 加密 +5. 向 `/robot.php` 发起表单 POST,请求头带上已收集 Cookie +6. 合并验证接口返回的 Cookie +7. 带最新 Cookie 二次请求原页面 +8. 若二次请求仍是验证页,则按失败处理 + +该流程只用于 HTML 页面: + +- 首页 +- 分类页 +- 搜索页 +- 详情页 +- 播放页 + +`foxplay/api.php` 不走过盾流程。 + +## 分类、首页与搜索设计 + +### 分类 + +分类从首页导航 `nav.bm-item-list a` 动态解析,提取: + +- `type_id` +- `type_name` + +只保留 `/type/.html` 结构的导航链接。 + +### 首页推荐 + +首页推荐从 `.mobile-main .panel .movie-list-item` 提取,按详情 URL 去重后返回前 20 条。 + +### 分类列表 + +分类页路径: + +- `/type/-.html` + +每张卡片提取: + +- 链接:`a[href]` +- 标题:`a[title]` +- 封面:`.Lazy[data-original]` +- 备注:`.movie-item-note`,为空时退回 `.movie-item-score` + +### 搜索 + +搜索页路径: + +- `/search/-------------.html` + +搜索结果按 `.vod-search-list .box` 解析,字段提取规则与分类卡片一致,但标题优先取 `.movie-title`。 + +返回策略: + +- `limit` 固定为 20 +- 列表和搜索返回 `page`、`total`、`limit`、`list` +- 不返回 `pagecount` +- 当页结果为空时 `total` 为 0 +- 当页结果非空时,`total` 至少保证大于等于 `page * len(list)`,避免上层立即判定无后续分页 + +## 详情页设计 + +详情页解析以下字段: + +- `vod_id` +- `vod_name` +- `vod_pic` +- `vod_content` +- `vod_year` +- `vod_director` +- `vod_actor` +- `vod_play_from` +- `vod_play_url` + +解析策略: + +- 标题取 `h1.movie-title` +- 海报取 `.poster img` +- 简介取 `.summary.detailsTxt` 的纯文本,并移除展开按钮文本 +- 年份从 `.scroll-content a` 中提取四位数字 +- 导演和演员从 `.info-data` 中按标签文案识别并拼接 + +播放列表策略: + +- 线路名从 `.play_source_tab .swiper-slide` 提取 +- 每个 `.play_list_box` 对应一组线路 +- 集数条目从 `.content_playlist li a` 提取为 `名称$play/` +- 最终按仓库约定拼接成 `vod_play_from` 和 `vod_play_url` + +如果线路数和列表数不一致: + +- 优先保留已有剧集列表 +- 缺失的线路名回退为 `线路` + +## 播放解析设计 + +### 播放页提取 + +`playerContent` 先还原播放页 URL,请求 HTML,再从脚本中提取: + +- `player_aaaa.url` +- `player_aaaa.from` +- `player_aaaa.server` + +如果找不到 `player_aaaa.url`,直接回退: + +- `parse = 1` +- `url = 播放页 URL` +- `header` 带 `User-Agent` 和当前页 `Referer` + +### `foxplay/api.php` + +当 `player_aaaa.url` 存在时: + +1. 以 `vid=` POST 到 `/foxplay/api.php` +2. 若接口返回 `data.url`,按 `urlmode` 解密: + - `1` 走 `Decode1.sign` + - `2` 走 `decode2` + - 其他值直接使用原始 `url` +3. 对解密结果做占位链接识别 + +### 解密规则 + +`Decode1.sign` 对应参考实现的三段式处理: + +- 自定义异或解码 +- Base64 还原映射表 +- 按明文/密文字母映射恢复真实路径 + +`decode2` 对应参考实现的字典反向位移逻辑: + +- Base64 解码 +- 每三个字符取中间位 +- 按字典回退 3 位 + +### 占位链接识别 + +以下情况视为不可用直链: + +- 空字符串 +- 非 `http(s)` / `//` / `magnet:` 链接 +- 指向 `404.mp4` +- 参数含 `code=403` +- 包含 `forbidden` + +### 播放返回策略 + +如果解密得到可用直链: + +- 返回 `parse = 0` +- `url` 为真实地址 +- `header.Referer` 指向 `muiplayer.php?vid=...` + +如果 API 无直链、解密失败或命中占位链接: + +- 返回 `parse = 1` +- `url` 为原播放页 +- `header.Referer` 指向原播放页 + +## 错误处理 + +所有对外方法在站点异常时返回兼容结构,不抛出未处理异常: + +- `homeContent` 返回空分类 +- `homeVideoContent` 返回空列表 +- `categoryContent` 返回当前页和空列表 +- `searchContent` 返回当前页和空列表 +- `detailContent` 返回空 `list` +- `playerContent` 返回原播放页的 `parse=1` + +内部异常处理原则: + +- 私有 helper 可抛出 `ValueError` +- 对外接口统一兜底 +- JSON 解析、Base64 解码和映射解密失败时返回空字符串,不中断整个请求 + +## 测试设计 + +测试采用 `unittest` 和 `unittest.mock`,避免真实网络访问。 + +首批覆盖: + +- 短 ID 编解码 +- Cookie 合并和请求头拼装 +- 验证页 `token` 提取和过盾二次请求 +- 首页分类解析 +- 首页推荐去重 +- 分类列表解析 +- 搜索结果解析和空关键词分支 +- 详情页元数据和多线路播放列表拼装 +- `Decode1` 与 `decode2` 的解密行为 +- `playerContent` 的直链成功分支 +- `playerContent` 的占位链接回退分支 +- `playerContent` 的缺失 `player_aaaa` 回退分支 + +测试粒度要求: + +- 先验证最小私有 helper +- 再验证对外接口方法 +- 只 mock 网络层,不 mock 纯解析函数 + +## 验收标准 + +满足以下条件视为完成: + +- 新增 `茶杯狐.py`,接口与仓库现有 Spider 一致 +- 新增 `tests/test_茶杯狐.py` +- 列表和搜索结果使用短详情 ID +- 详情页播放列表使用短播放 ID +- 命中验证页时能完成两次请求和 Cookie 合并 +- `playerContent` 能在可用时返回直链,在不可用时稳定回退 +- 相关测试通过,且不引入 `pagecount` 到列表/搜索返回结构