diff --git a/py/docs/superpowers/specs/2026-04-25-aaz-music-design.md b/py/docs/superpowers/specs/2026-04-25-aaz-music-design.md new file mode 100644 index 0000000..32aa117 --- /dev/null +++ b/py/docs/superpowers/specs/2026-04-25-aaz-music-design.md @@ -0,0 +1,369 @@ +# AAZ音乐 Python 爬虫设计 + +## 目标 + +在当前 Python 仓库中新增一个符合 `base.spider.Spider` 接口的 AAZ 音乐爬虫,行为参考用户提供的 OmniBox 脚本,但交付物遵循仓库现有的单文件 Spider 结构、短 ID 约定和 `unittest` 测试方式。 + +首版仅覆盖仓库最常用的五个能力: + +- 首页 +- 分类 +- 搜索 +- 详情 +- 播放 + +## 范围 + +本次实现包含: + +- 新增独立脚本 `py/AAZ音乐.py` +- 新增独立测试 `py/tests/test_AAZ音乐.py` +- 首页固定分类与推荐歌曲解析 +- 分类页对歌曲、歌手、歌单、专辑、MV 的列表解析 +- 搜索页的混合结果解析 +- 单曲详情解析 +- 歌手、歌单、专辑、MV 详情页解析 +- 单曲试听直链解析 +- 针对短 ID、空结果、详情播放串和播放失败回退增加离线单测 + +本次实现不包含: + +- 下载链接抓取和返回 +- 歌词链接抓取和返回 +- 修改 `py/base/` 公共层 +- 引入新的第三方依赖 +- 真实联网集成测试 +- 复杂反爬绕过、代理、缓存和重试策略 +- 对 MV 独立播放链路做额外兼容 + +## 方案选择 + +采用“按仓库 Python Spider 形态重写站点逻辑”的方案,而不是直接移植 OmniBox 脚本的数据模型。 + +原因如下: + +- 当前仓库消费的是 Python Spider 接口,而不是 OmniBox 风格的异步 handler +- 仓库现有测试和调用都围绕 `homeContent/categoryContent/searchContent/detailContent/playerContent` +- 参考脚本中的下载、歌词和扩展字段不在本次范围内,直接照搬会增加无效复杂度 +- 使用短 ID 和离线 fixture 更符合当前仓库的可维护性与可测试性 + +## 模块边界 + +新增模块 `py/AAZ音乐.py` 只负责 AAZ 站点逻辑,不修改 `py/base/`。 + +模块对外实现以下接口: + +- `init` +- `getName` +- `homeContent` +- `homeVideoContent` +- `categoryContent` +- `detailContent` +- `searchContent` +- `playerContent` + +模块内部使用 helper 收敛 URL、ID 编解码和页面解析逻辑: + +- `_build_url` + - 补全站点绝对地址 +- `_fetch_html` + - 统一 GET 页面并返回文本 +- `_post_play_api` + - 统一 POST `/js/play.php` 并解析 JSON +- `_load_html` + - 将 HTML 转成可 XPath 的文档对象 +- `_clean_text` + - 规范化文本并移除多余空白 +- `_extract_song_id` + - 从 `/m/.html` 链接提取单曲 ID +- `_encode_vod_id` + - 将站点链接编码为仓库短 `vod_id` +- `_decode_vod_id` + - 将短 `vod_id` 还原为站点详情路径 +- `_parse_song_cards` + - 解析歌曲列表 +- `_parse_folder_cards` + - 解析歌手、歌单、专辑、MV 封面卡片 +- `_parse_folder_tracks` + - 从目录型详情页提取歌曲播放列表 +- `_parse_song_detail` + - 提取单曲详情元信息 +- `_build_empty_result` + - 返回统一的空分页结构 + +## 站点配置 + +固定站点配置如下: + +- 站点名:`AAZ音乐` +- 根地址:`https://www.aaz.cx` +- 默认请求头包含桌面浏览器 `User-Agent` +- 默认请求头带 `Referer: https://www.aaz.cx/` + +固定首页分类如下: + +- `new -> 新歌榜` +- `top -> TOP榜单` +- `singer -> 歌手` +- `playtype -> 歌单` +- `album -> 专辑` +- `mv -> 高清MV` + +分类路径映射如下: + +- `new -> /list/new.html` +- `top -> /list/top.html` +- `singer -> /singerlist/index/index/index/index.html` +- `playtype -> /playtype/index.html` +- `album -> /albumlist/index.html` +- `mv -> /mvlist/index.html` + +## ID 设计 + +统一使用仓库短 ID,不在列表或搜索结果中暴露完整 URL。 + +`vod_id` 编码规则: + +- `song:` +- `singer:` +- `playlist:` +- `album:` +- `mv:` + +目录型资源与链接前缀映射如下: + +- `/s/xxx` -> `singer:xxx` +- `/p/xxx` -> `playlist:xxx` +- `/a/xxx` -> `album:xxx` +- `/v/xxx` -> `mv:xxx` + +单曲资源与链接映射如下: + +- `/m/.html` -> `song:` + +`play_id` 首版只保留: + +- `song:` + +内部路径还原规则: + +- `song:` -> `/m/.html` +- `singer:` -> `/s/` +- `playlist:` -> `/p/` +- `album:` -> `/a/` +- `mv:` -> `/v/` + +## 首页设计 + +`homeContent` 返回: + +- `class` +- `list` + +首页策略如下: + +- 请求 `/list/new.html` +- 解析页面中的歌曲列表项 +- 将歌曲链接统一编码为 `song:` +- 以 `vod_id` 去重 +- 返回固定分类和首页歌曲列表 + +首页列表项字段统一为: + +- `vod_id` +- `vod_name` +- `vod_pic` +- `vod_remarks` + +`homeVideoContent` 直接复用 `homeContent(False)` 的 `list`。 + +首版不返回 `filters`,因为当前站点分类依赖固定入口,不需要额外筛选面板。 + +## 分类设计 + +`categoryContent` 支持以下分类: + +- `new` +- `top` +- `singer` +- `playtype` +- `album` +- `mv` + +返回结构遵循仓库当前约定: + +- `page` +- `limit` +- `total` +- `list` + +不返回 `pagecount`。 + +各分类策略如下: + +### `new` 与 `top` + +- 请求对应榜单页面 +- 解析歌曲列表 +- 结果项编码为 `song:` +- `vod_name` 使用站点卡片标题 +- 若卡片存在 MV 标记,可写入 `vod_remarks` + +### `singer` + +- 请求歌手列表页 +- 解析 `/s/` 链接的封面卡片 +- 编码为 `singer:` + +### `playtype` + +- 请求歌单列表页 +- 解析 `/p/` 链接的封面卡片 +- 编码为 `playlist:` + +### `album` + +- 请求专辑列表页 +- 解析 `/a/` 链接的封面卡片 +- 编码为 `album:` + +### `mv` + +- 请求 MV 列表页 +- 解析 `/v/` 链接的封面卡片 +- 编码为 `mv:` + +首版不实现翻页抓取;所有分类都按单页结果返回,`page` 仍回显请求值,`limit` 和 `total` 取当前页解析条数。 + +## 搜索设计 + +`searchContent(keyword, quick, pg)` 请求 `/so/{keyword}.html`。 + +搜索策略如下: + +- 空关键字直接返回空分页结构 +- 请求搜索结果页 +- 统一扫描结果区域中的站点链接 +- 根据链接前缀映射为 `song/singer/playlist/album/mv` 五类短 ID +- 对结果按 `vod_id` 去重 + +搜索结果字段统一为: + +- `vod_id` +- `vod_name` +- `vod_pic` +- `vod_remarks` + +搜索结果允许混合类型存在,不对类型做二次拆分。 + +## 详情设计 + +`detailContent` 根据 `vod_id` 分为“单曲详情”和“目录型详情”两条链路。 + +### 单曲详情 + +当 `vod_id` 为 `song:` 时: + +- 请求 `/m/.html` +- 提取歌名、歌手、专辑、封面、时长和简介 +- `vod_name` 使用解析到的歌名 +- `vod_pic` 优先使用详情页封面 +- `vod_remarks` 组合歌手、专辑和时长 +- `vod_play_from` 固定为 `AAZ音乐` +- `vod_play_url` 生成为 `播放$song:` + +单曲详情不暴露下载地址、慢速下载地址和歌词地址。 + +### 目录型详情 + +当 `vod_id` 为 `singer/playlist/album/mv` 之一时: + +- 根据短 ID 还原对应详情页 URL +- 提取页面标题、封面和简介 +- 从页面歌曲列表中解析 `/m/.html` +- 生成 `歌曲名$song:` 的播放串 +- `vod_play_from` 固定为 `AAZ音乐` + +目录型详情只保证歌曲列表和歌曲试听,不承诺 MV 独立播放能力。即使详情类型为 `mv:`,只要页面能列出歌曲,就按目录型播放列表处理。 + +若详情页没有有效歌曲列表,则返回只有基础元信息、播放串为空的单对象详情。 + +## 播放设计 + +`playerContent(flag, id, vipFlags)` 只处理 `song:`。 + +播放流程如下: + +1. 解析 `id` 中的 `song_id` +2. POST `https://www.aaz.cx/js/play.php` +3. 请求体使用 `id=&type=music` +4. 请求头补充表单提交和 `X-Requested-With` +5. 从 JSON 中提取 `url` +6. 若成功,返回 `parse=0` 的直链播放结果 +7. 若失败,返回 `parse=0` 且 `url=""` + +返回结构至少包含: + +- `parse` +- `url` +- `header` + +`header` 固定返回: + +- `User-Agent` +- `Referer: https://www.aaz.cx/` + +首版不接入下载接口,也不把播放失败回退到下载地址。 + +## 错误处理 + +统一遵循仓库现有的宽松容错策略: + +- 页面请求失败时返回空字符串或空结果,不抛出到调用层 +- 解析缺失字段时回退为空字符串 +- 搜索空关键字返回空分页结构 +- 未知 `vod_id` 或非法 `play_id` 返回空详情或空播放地址 + +空分页结构统一为: + +- `{"page": 1, "limit": 0, "total": 0, "list": []}` + +## 测试设计 + +新增 `py/tests/test_AAZ音乐.py`,使用 `unittest` 与 `unittest.mock`。 + +测试覆盖以下行为: + +- `homeContent` + - 返回 6 个固定分类 + - 首页歌曲卡片正确映射为 `song:` +- `homeVideoContent` + - 直接复用首页 `list` +- `categoryContent` + - `new/top` 正确解析歌曲 + - `singer/playtype/album/mv` 正确解析目录型短 ID + - 未知分类返回空分页结构 +- `searchContent` + - 混合结果正确映射为五类短 ID + - 空关键字返回空分页结构 +- `detailContent` + - `song:` 生成单条播放串 + - `singer/playlist/album/mv` 详情页能生成歌曲播放列表 + - 无效 `vod_id` 返回空 `list` +- `playerContent` + - `/js/play.php` 返回 JSON 时正确映射直链 + - 非法 `play_id` 或空 `url` 时返回空播放地址 + +fixture 采用内嵌 HTML/JSON 文本,不依赖真实网络。 + +## 验证计划 + +实现阶段先执行最小受影响测试,再执行模块完整测试。 + +预期验证命令为: + +```bash +cd py && python -m unittest tests.test_AAZ音乐 -v +``` + +本次工作完成后不新增更大范围的套件验证要求,因为实现不会修改公共层。