From 153589549de22090f1554f562f8cdc838757caa8 Mon Sep 17 00:00:00 2001 From: Harold <8866033@gmail.com> Date: Fri, 24 Apr 2026 16:23:34 +0800 Subject: [PATCH] docs: add jinpai spider design --- .../specs/2026-04-24-jinpai-spider-design.md | 342 ++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 py/docs/superpowers/specs/2026-04-24-jinpai-spider-design.md diff --git a/py/docs/superpowers/specs/2026-04-24-jinpai-spider-design.md b/py/docs/superpowers/specs/2026-04-24-jinpai-spider-design.md new file mode 100644 index 0000000..de80b99 --- /dev/null +++ b/py/docs/superpowers/specs/2026-04-24-jinpai-spider-design.md @@ -0,0 +1,342 @@ +# 金牌 Python 爬虫设计 + +## 目标 + +在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的金牌爬虫,行为参考用户提供的 JS 版本,覆盖以下能力: + +- 首页分类与筛选 +- 首页推荐 +- 分类浏览 +- 搜索 +- 详情解析 +- 播放解析 + +实现形式遵循当前仓库的单文件 Spider 约定和离线单测约定,不引入新的公共基类。 + +## 范围 + +本次实现包含: + +- 新增独立脚本 `py/金牌.py` +- 新增独立测试 `py/tests/test_金牌.py` +- 实现带签名头的匿名 API 请求 +- 动态拉取分类与筛选 +- 首页返回热门推荐 +- 分类支持类型、剧情、地区、语言、年份、排序筛选 +- 搜索支持分页与空关键词保护 +- 详情组装基础元数据与单线路剧集 +- 播放接口返回多清晰度直链列表 + +本次实现不包含: + +- 修改 `base/` 公共层 +- 引入新的第三方依赖 +- 真实联网集成测试 +- 站点探活、容灾切换或缓存 +- 参考 JS 的 HTTP 路由包装层 + +## 方案选择 + +采用“单文件 Spider + helper + 单测”的直接适配方案,而不是抽象新的签名 API 基类。 + +原因如下: + +- 当前仓库绝大多数站点都以单文件 Spider 交付,新增公共基类会放大本次范围 +- 参考实现已经清晰给出了接口路径、签名算法和字段映射,直接适配风险最低 +- 当前需求重点是完整还原站点能力,而不是为未知后续站点提前抽象 + +## 模块边界 + +新增 `py/金牌.py`,只在模块内部维护站点逻辑,不修改 `base/`。 + +对外实现以下接口: + +- `init` +- `getName` +- `homeContent` +- `homeVideoContent` +- `categoryContent` +- `searchContent` +- `detailContent` +- `playerContent` + +模块内部拆分以下 helper: + +- `_obj_to_form` + - 将请求参数按 `k=v&...` 拼接,忽略空值 +- `_signed_headers` + - 根据请求参数、`APP_KEY` 和时间戳生成签名头 +- `_fetch_json` + - 统一发起 GET 请求、校验状态码并解析 JSON +- `_map_vod` + - 统一列表项字段映射 +- `_build_filters` + - 请求分类接口与筛选接口并组装首页筛选结构 +- `_page_result` + - 统一分类和搜索的分页返回结构 +- `_build_play_id` + - 将主视频 ID 与分集 ID 组合成短播放 ID +- `_parse_play_id` + - 解析 `主ID@子ID` 形式的播放 ID + +## 站点配置与签名策略 + +固定配置来自参考实现: + +- 站点名:`金牌` +- 主站:`https://m.jiabaide.cn` +- 默认 UA:移动端 Chrome UA +- `Referer`:`${host}/` +- `APP_KEY`:`cb808529bae6b6be45ecfab29a4889bc` + +签名规则保持与参考实现一致: + +1. 取当前毫秒时间戳作为 `t` +2. 将请求参数与 `key=APP_KEY`、`t` 合并 +3. 按 `k=v&...` 形式拼接 +4. 先对拼接字符串做 `MD5` +5. 再对 `MD5` 结果做 `SHA1` +6. 将 `t` 和 `sign` 放入请求头 + +请求策略: + +- 接口请求统一使用 `GET` +- 参数通过 query string 传递 +- 请求头统一带 `User-Agent`、`Referer`、`t`、`sign` +- 若响应状态码不是 `2xx`,或业务 `code` 不是成功值,则返回空结果而非抛出未处理异常 + +## 分类与首页设计 + +`homeContent` 负责同时返回分类、筛选和首页推荐。 + +分类来源: + +- `/api/mw-movie/anonymous/get/filer/type` + +筛选来源: + +- `/api/mw-movie/anonymous/v1/get/filer/list` + +首页推荐来源: + +- `/api/mw-movie/anonymous/home/hotSearch` + +筛选字段映射遵循参考实现: + +- `typeList -> key=type, name=类型` +- `plotList -> key=class, name=剧情` +- `districtList -> key=area, name=地区` +- `languageList -> key=lang, name=语言` +- `yearList -> key=year, name=年份` +- `serialList -> key=by, name=排序` + +排序选项固定为: + +- 最近更新 -> `1` +- 添加时间 -> `2` +- 人气高低 -> `3` +- 评分高低 -> `4` + +每个筛选项都默认插入“全部”。 + +`homeVideoContent` 保持仓库现有习惯,返回: + +- `{"list": []}` + +## 列表与搜索设计 + +分类接口使用: + +- `/api/mw-movie/anonymous/video/list` + +请求参数如下: + +- `type1` + - 当前分类 ID +- `pageNum` + - 当前页码 +- `pageSize` + - 固定 `30` +- `sort` + - 由筛选项 `by` 映射,默认 `1` +- `sortBy` + - 固定 `1` +- `type` + - 子类型筛选 +- `v_class` + - 剧情筛选 +- `area` + - 地区筛选 +- `lang` + - 语言筛选 +- `year` + - 年份筛选 + +搜索接口使用: + +- `/api/mw-movie/anonymous/video/searchByWordPageable` + +请求参数如下: + +- `keyword` +- `pageNum` +- `pageSize` + +返回结构遵循当前仓库约定: + +- `page` +- `limit` +- `total` +- `list` + +其中: + +- `limit` 固定为 `30` +- 默认不返回 `pagecount` +- 空关键词搜索直接返回空列表 + +## 列表字段映射 + +列表页与搜索页的单项统一映射为: + +- `vod_id` + - `vodId` +- `vod_name` + - `vodName` +- `vod_pic` + - `vodPic` +- `vod_remarks` + - `vodRemarks` 与 `vodDoubanScore` 以 `_` 连接,空值自动忽略 +- `vod_year` + - 优先从 `vodPubdate` 提取年份 +- `type_id` + - `typeId` +- `type_name` + - `typeName` + +这样首页推荐、分类和搜索的列表结构保持一致。 + +## 详情设计 + +详情接口使用: + +- `/api/mw-movie/anonymous/video/detail` + +请求参数: + +- `id` + +详情返回只保留当前仓库需要的核心字段: + +- `vod_id` +- `vod_name` +- `vod_pic` +- `type_name` +- `vod_remarks` +- `vod_year` +- `vod_area` +- `vod_lang` +- `vod_director` +- `vod_actor` +- `vod_content` +- `vod_play_from` +- `vod_play_url` + +剧集列表来自 `episodeList`,每一集的播放 ID 设计为: + +- `@` + +播放线路先收敛为单条: + +- `金牌线路` + +这样可以兼容仓库现有以 `vod_play_from` / `vod_play_url` 为核心的详情结构,同时避免把上游清晰度层级提前展开到详情页。 + +## 播放设计 + +播放接口使用: + +- `/api/mw-movie/anonymous/v2/video/episode/url` + +请求参数如下: + +- `clientType=3` +- `id` + - 主视频 ID +- `nid` + - 分集 ID + +播放页逻辑: + +- 从 `主ID@子ID` 解析出 `id` 和 `nid` +- 请求上游接口拿到 `list` +- 将上游返回的多清晰度地址整理为播放器可消费的结果 + +播放器返回结构: + +- `parse: 0` +- `playUrl: ""` +- `url` + - 优先返回首个可用地址 +- `header` + - 至少包含移动端 `User-Agent` + +不额外引入 `pagecount`、`urls` 或其他参考 JS 的扩展字段,优先保持与当前 Python 仓库播放器返回习惯一致。 + +同时,为了兼容仓库中部分站点会保留扩展信息,模块内部仍会保留原始清晰度列表的整理逻辑;若调用方仅消费 `url`,则使用首个可用地址即可。 + +异常策略: + +- 播放 ID 不是 `主ID@子ID` 格式时,返回空 URL +- 上游返回空列表时,返回空 URL + +## 错误处理 + +所有公开接口都以“尽量返回空结果”作为失败策略: + +- `homeContent` + - 失败时返回空分类、空筛选、空推荐 +- `categoryContent` + - 失败时返回 `page/limit/total/list` 的空结构 +- `searchContent` + - 失败时返回空分页结果 +- `detailContent` + - 失败时返回 `{"list": []}` +- `playerContent` + - 失败时返回空 URL + +不向上抛出未处理异常,保持与现有仓库 Spider 的容错风格一致。 + +## 测试设计 + +新增 `py/tests/test_金牌.py`,全部通过 mock 网络层验证离线行为。 + +至少覆盖以下测试: + +1. `_signed_headers` 会写入 `t` 和正确的 `sign` +2. `homeContent` 能组合分类、筛选和首页推荐 +3. `categoryContent` 能把 `by/class/area/lang/year/type` 正确映射到请求参数 +4. `searchContent` 对空关键词直接返回空列表,对正常结果正确映射 +5. `detailContent` 能输出基础元数据、单线路名与 `主ID@子ID` 剧集串 +6. `playerContent` 能拆分播放 ID、请求上游并返回首个可用播放地址 +7. 公开接口在上游异常或空数据时返回约定的空结构 + +测试原则: + +- 不依赖真实网络 +- 尽量验证最小行为单元 +- 对签名和请求参数做精确断言,避免只测结果不测协议 + +## 风险与约束 + +主要风险如下: + +- 上游匿名接口的业务 `code` 语义可能并不完全稳定,需要测试中明确覆盖成功与失败分支 +- 参考 JS 的 `play` 返回了多清晰度数组,而仓库 Python Spider 通常以单个 `url` 为主,需要在实现中做兼容收敛 +- 详情中的 `vodClass`、`vodYear`、`vodArea` 等字段可能出现缺失,需要统一空值兜底 + +对应约束如下: + +- 优先保证仓库消费兼容性,而不是 1:1 复刻 JS 返回结构 +- 不额外改动公共播放器约定 +- 所有行为变化都必须由离线测试先定义再实现