diff --git a/README.md b/README.md index 5f48de23..07d36252 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ## 核心特性 -- **多平台支持**:YouTube、Bilibili、抖音、小红书、小宇宙播客、Apple Podcast,工厂模式自动匹配下载器 +- **多平台支持**:YouTube、Bilibili、抖音、小红书、微信视频号(经 MediaResolverAPI)、小宇宙播客、Apple Podcast,工厂模式自动匹配下载器 - **双引擎转录**:CapsWriter-Offline(通用转录)+ FunASR(说话人识别) - **智能文本处理**:LLM 自动校对 ASR 错误、专有名词纠错、按说话人采样+置信度降级的说话人推断、内容总结 - **处理深度可控**:`processing_options` 开关按任务控制是否校对/总结,分层缓存产物只增不减,重复请求自动复用已有层 @@ -28,7 +28,7 @@ - [funasr_spk_server:funasr server 对应暴露 api,支持音视频转写,分角色,自动合并相同人物的话。](https://github.com/zj1123581321/funasr_spk_server) - [CapsWriter-Offline:CapsWriter 的离线版,一个好用的 PC 端的语音输入工具,支持热词、LLM处理。](https://github.com/HaujetZhao/CapsWriter-Offline) - [youtube_download_api:YouTube 视频下载服务,作为 yt-dlp 的可选替代后端。](https://github.com/zj1123581321/youtube_download_api)(可选) -- MediaResolverAPI:短视频 URL → 无水印直链 + 元数据的集中解析服务,可选地接管抖音/小红书解析(可选,见[使用指南](docs/guides/media_resolver.md))。 +- MediaResolverAPI:短视频/视频号 URL → 无水印直链 + 元数据的集中解析服务,可选地接管抖音/小红书/视频号解析(可选,见[使用指南](docs/guides/media_resolver.md))。 - OpenAI 兼容的 API,比如 Deepseek,量大管饱。 --- @@ -55,7 +55,7 @@ uv sync # 配置服务 cp config/config.example.jsonc config/config.jsonc # 编辑 config.jsonc,填写 api.auth_token、tikhub.api_key 等 -# 可选:抖音/小红书改走 MediaResolverAPI 集中解析,设 +# 可选:抖音/小红书/视频号改走 MediaResolverAPI 集中解析,设 # downloaders.use_media_resolver=true 并配置 media_resolver 段 # (使用指南:docs/guides/media_resolver.md) diff --git a/docs/guides/media_resolver.md b/docs/guides/media_resolver.md index 314b5d60..5d92058b 100644 --- a/docs/guides/media_resolver.md +++ b/docs/guides/media_resolver.md @@ -1,15 +1,16 @@ -# MediaResolverAPI 集成指南(抖音 / 小红书解析) +# MediaResolverAPI 集成指南(抖音 / 小红书 / 微信视频号解析) -> 适用版本:v1(接管 **抖音 + 小红书**)。其它平台(B 站 / YouTube / 小宇宙)不受影响,仍走原生下载器。 +> 适用版本:v1(接管 **抖音 + 小红书 + 微信视频号**)。其它平台(B 站 / YouTube / 小宇宙)不受影响,仍走原生下载器。 ## 这是什么 [MediaResolverAPI](https://github.com/) 是一个独立的「短视频 URL → 无水印直链 + 元数据」解析服务, -内置 TikHub 多端点降级 + Cobalt 兜底。本项目可选地把**抖音 / 小红书的解析**外包给它,从而: +内置 TikHub 多端点降级 + Cobalt 兜底。本项目可选地把**抖音 / 小红书 / 微信视频号的解析**外包给它,从而: - 把易碎的 TikHub 解析逻辑集中到专用服务,本仓库退化为「下载 + 转录 + LLM」; - 抖音改为下载**完整 mp4 再由 CapsWriter 提取音轨**(而非旧版直接抓 `music.play_url` 的 mp3)—— - 对套用热门 BGM 模板的口播视频,提取的是**视频自带人声**而非背景乐,转录更准。 + 对套用热门 BGM 模板的口播视频,提取的是**视频自带人声**而非背景乐,转录更准; +- 支持微信视频号(`https://weixin.qq.com/sph/`)链接转录,通过 MediaResolverAPI 的流式代理端点边解密边拉取。 > ⚠️ **行为变更**:开启后抖音下载体积由 mp3 增大为 mp4。长视频可能撞 `storage.max_download_size_mb` > 上限,或 CapsWriter 一次性入内存的限制。短视频无影响。 @@ -19,16 +20,22 @@ | 你的情况 | 建议 | |---------|------| | 抖音/小红书解析经常失败、想集中维护解析逻辑 | ✅ 启用 | +| 需要转录微信视频号链接 | ✅ 启用(视频号必须依赖 MediaResolverAPI) | | 已部署 MediaResolverAPI 服务并有 API Key | ✅ 启用 | | 只转录 B 站/YouTube/小宇宙 | 无需启用(默认 off,不影响) | -| 没有 MediaResolverAPI 服务 | 保持 off,继续用内置 TikHub 直连 | +| 没有 MediaResolverAPI 服务 | 保持 off,继续用内置 TikHub 直连(视频号不可用) | 默认 **关闭**。开关打开前请确认 MediaResolverAPI 服务可达。 ## 前置条件 1. 一个可访问的 MediaResolverAPI 服务(自建或他人提供),拿到 `base_url` 与 `X-API-Key`。 -2. 服务健康检查: +2. 微信视频号转录前提: + - 必须设置 `downloaders.use_media_resolver: true`(无原生下载器兜底); + - 若配置了 `security.download_url_allowlist` 安全下载白名单,MediaResolverAPI 服务域名/IP 须在允许列表中; + - 视频号 `video_url` 指向 resolver 自己的流式代理端点(`/api/stream/wechat_channels/{sph_code}`),下载时下载器会自动携带 `X-API-Key` 鉴权头(第三方 CDN 直链则不会携带); + - resolver 流式端点受上游并发限制,若单进程并发超限会返回 429(Too Many Requests)。 +3. 服务健康检查: ```bash curl http://:/health @@ -58,21 +65,21 @@ > **Docker 注意**:容器内访问宿主机服务用 `host.docker.internal`,不要写 `localhost` / `127.0.0.1`。 -配置改完无需改代码——`factory` 会在 `use_media_resolver=true` 时自动把抖音/小红书路由到 +配置改完无需改代码——`factory` 会在 `use_media_resolver=true` 时自动把抖音/小红书/视频号路由到 `MediaResolverDownloader`,并跳过旧的 `DouyinDownloader` / `XiaohongshuDownloader`。 ## 使用 -开关打开后,正常提交抖音/小红书链接即可,无需任何额外参数: +开关打开后,正常提交抖音/小红书/视频号链接即可,无需任何额外参数: ```bash curl -X POST http://localhost:8000/api/transcribe \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ - -d '{"url": "https://v.douyin.com/xxxxxxx/"}' + -d '{"url": "https://weixin.qq.com/sph/AOzokRxWHz"}' ``` -支持的链接形态:`douyin.com` / `v.douyin.com` 短链 / `xiaohongshu.com` / `xhslink.com` 短链。 +支持的链接形态:`douyin.com` / `v.douyin.com` 短链 / `xiaohongshu.com` / `xhslink.com` 短链 / `weixin.qq.com/sph/` 视频号链接。 ## 错误与提示对照 @@ -86,6 +93,7 @@ curl -X POST http://localhost:8000/api/transcribe \ | 图文/已删除/私密等无视频内容 | 该内容无可转录视频 | 否 | | 全部解析源失败 | 解析失败,稍后再试 | 否 | | 服务端错误(HTTP 5xx) | 解析服务异常 | 是 | +| 流式端点并发超限(HTTP 429) | 解析服务繁忙,稍后再试 | 是 | > 注:当前 MediaResolverAPI 的 `error` 仅返回文案、无结构化 `error.code`,因此「图文/删除」类终态 > 可能被笼统归为「解析失败,稍后再试」。若你维护该服务,建议为终态返回 `error.code` 以便精确区分。 @@ -98,7 +106,7 @@ MediaResolverAPI 返回的视频直链在下载前会经过 **SSRF 校验**(`u ## 回退 若启用后遇到问题,把 `downloaders.use_media_resolver` 改回 `false` 即可立即回到内置 TikHub 直连下载器, -无需回滚代码(旧下载器在迁移期保留)。 +无需回滚代码(旧下载器在迁移期保留;视频号链接在未启用时将回退至通用下载器)。 ## 故障排查 @@ -106,6 +114,8 @@ MediaResolverAPI 返回的视频直链在下载前会经过 **SSRF 校验**(`u |------|------| | 提示「鉴权失败」 | 检查 `media_resolver.api_key`;用 `curl -H "X-API-Key: "` 直接打 `/api/resolve` 验证 | | 提示「解析服务暂不可用」 | 检查 `base_url` 可达性、`/health`、Docker 内是否误用 localhost | +| 视频号下载失败(401) | 确认下载请求发往 resolver 域名并携带了 `X-API-Key` | +| 视频号下载失败(429) | MediaResolverAPI 流式并发超限,稍后重试或调整 resolver 服务并发能力 | | 抖音下载撞大小上限 | 调高 `storage.max_download_size_mb`,或对长视频暂时关闭开关 | | 想确认走了哪个下载器 | 看日志 `为URL创建下载器: ..., 类型: MediaResolverDownloader` | @@ -113,3 +123,4 @@ MediaResolverAPI 返回的视频直链在下载前会经过 **SSRF 校验**(`u - 设计与实现决策记录:[docs/designs/media-resolver-integration.md](../designs/media-resolver-integration.md) - 后续 v2 规划(多平台/观测/CDN 兜底):见仓库 `TODOS.md` + diff --git a/docs/sessions/260902-wechat-channels/reviews/01-wechat-channels-verdict.md b/docs/sessions/260902-wechat-channels/reviews/01-wechat-channels-verdict.md new file mode 100644 index 00000000..0a783b23 --- /dev/null +++ b/docs/sessions/260902-wechat-channels/reviews/01-wechat-channels-verdict.md @@ -0,0 +1,75 @@ +# 视频号(wechat_channels)接入独立审查 + +- **审查对象**:`ea71bf3f9167faa732b25928bb959116aac8c4f4..dedd376e6def0ea04b5b9f8ca89168abd4c8e6ad`(H0 冻结,7 commit) +- **risk-tier**:personal(P1 = 数据丢失 / 静默出错 / 崩溃) +- **本轮新证据**:① `urlparse` netloc 矩阵(大小写/端口/userinfo/尾点/IPv6/无 scheme);② `ocr-review` status=`reviewed`(minimax);③ H0 `factory.py` 实例生命周期;④ `urlparse` 抛异常面。同一份 diff 复读不算新证据,以上均是生成结论前新跑的。 + +## 结论 + +**pass**。无 P1;无阻塞合并的 P2。OCR 5 条均为测试/可维护性意见,本仓判定不升级。 + +## Findings + +无。 + +## 锁定决策(6 条) + +1. **平台字符串 `wechat_channels`**:符合。H0 `git grep` 生产路径的平台标识均为 `wechat_channels`(`url_parser.py:84,293`、`views.py:611`、`history.html:921`)。`shipinhao` 零命中。存量 `config.wechat` / `wechat_webhook` 是企微通知通道,不是视频平台标识。 +2. **`X-API-Key` 仅 netloc 相等时携带**:符合。`media_resolver.py:222-229` 用 `urlparse(...).netloc.lower()` 精确相等,且 `_resolver_netloc` 为空则不带头。基类 `base.py:224` 走 `headers=self.download_headers or None`。测试锁 CDN 不带、resolver 流式端点带(`TestConditionalDownloadHeaders`)。 +3. **复用 `download_headers` + 403 重下也设头**:符合。首次 `download_file:238` 与 force_refresh 后 `download_file:269` 都调用 `_prepare_download_headers`;重解析语义(反查 → force_refresh → SSRF 校验 → 再下,失败抛 `DownloadFailedError`)相对 base 未改。测试 `test_reresolve_applies_correct_headers_on_retry` 锁重下带 key。 +4. **不新增配置项;flag off 落 GenericDownloader**:符合。`config/` 无 diff。`test_downloader_factory.py`:flag on → resolver,flag off + sph URL → `GenericDownloader`。 +5. **sph 不进 `SHORT_URL_DOMAINS`**:符合。`url_parser.py:90-95` 仍只有 `b23.tv` / `youtu.be` / `v.douyin.com` / `xhslink.com`;sph 走 `PATTERNS['wechat_channels']`。 +6. **静态资产变更递增 `CACHE_NAME`**:符合。base `vta-static-v4` → H0 `vta-static-v5`;`tests/unit/web/test_frontend_auth.py` 断言同步。 + +## 本轮新角度(4 条) + +### 1. netloc 相等判定绕过面 + +查了,无发现(泄露方向干净;漏带头不是静默成功)。 + +对 `_prepare_download_headers` 同一谓词跑矩阵: + +| 形态 | 结果 | +|---|---| +| 大小写 / 尾斜杠 / `#fragment` | 匹配,该带头 | +| `https://host:8000` vs `http://host:8000`(同 netloc) | 匹配(spec 比的是 netloc 不是 scheme) | +| 无 scheme 的 `host:port` | `_resolver_netloc=""` → 永不带头 | +| 缺省端口 vs 显式 `:80`/`:443`、尾点、userinfo、IPv6 字面量不同写法 | 字符串不等 → 不带头 | +| CDN `cdn.example.com` vs resolver netloc | 从未匹配 | + +泄露(不该带却带了):矩阵无此格;只有 CDN 的 netloc 真等于 resolver 才会带,那就是同一主机。 +漏带(该带没带):owner 配置畸形或上游 stream URL 与 `base_url` 形态不一致 → 视频号 401/下载失败,不是静默错结果。按 review-discipline,可信配置畸形 ≤P2,且与 spec「netloc 相等」字面一致;要求规范化会反着 spec。接受不修。 + +### 2. `_prepare_download_headers` try/except pass 与静默出错 + +查了,无发现(不构成 P1 静默出错)。 + +`except Exception: pass` 之后恒执行 `self.download_headers = {}`(fail-closed)。`urlparse` 对 str 几乎不抛;会抛的是非 str / 非法 IPv6。下载 URL 已经 `validate_url_safe`。缺 key 的视频号流式端点 → 401 → `DownloadFailedError`,不会「下到文件却没鉴权还当成功」。CDN 路径空头是正确行为。OCR 建议补 debug 日志:可维护性 P3,接受不修。 + +### 3. 实例级 `download_headers` 串扰 + +查了,无发现(真实使用方式下不会跨任务共享实例)。 + +`create_downloader`(`factory.py:46`)每次 `MediaResolverDownloader()` 新实例,无进程级单例。每次 `download_file` 开头重设 headers;403 重下对 `fresh_url` 再设一次。顺序复用同一实例(先视频号后 CDN)会被第二次 `_prepare` 清掉 key。并发意见在 personal 档 ≤P2,且当前工厂不会让两任务共享同一实例。 + +### 4. 熵增 + +查了,无熵 +1。新增 `_prepare_download_headers` 有两个调用点(首次 + 重下),不是转发-only、不是无第二消费者。`_resolver_netloc` / `_resolver_api_key` 是 `__init__` 一次缓存,不是第二事实源。无新配置项、无新文件、无单实现接口。 + +## OCR 前置 + +- `ocr-review` status=`reviewed`(primary minimax);verifier 两条腿不可用,5 条均为 `unverified`。 +- 工具标注 → 本仓判定(P1 两问:真实使用会触发吗?后果能否接受?): + +| # | 工具 | 本仓 | 两问 | 处置 | +|---|---|---|---|---| +| 1 | except pass 无日志 / low | P3 | 触发:几乎不。后果:空头 + 401,非静默错 | 接受不修 | +| 2 | 测试 `headers == {key}` 过严 / medium | 不成立 | 测试形态,不是生产缺陷 | 不采纳 | +| 3 | 测试写入 `_resolver_netloc` 绕过 `__init__` / high | 不成立 | 生产仍走 `urlparse(base_url)`;测试缺口不导致运行时漏带头 | 不采纳 | +| 4 | 403 测试靠 `HTTPError` 注释不准 / medium | 不成立 | 重下路径另有 `force_refresh` 断言 | 不采纳 | +| 5 | 重下缺「fresh netloc 不同」用例 / medium | P3 测试缺口 | 不触发生产静默错 | 接受不修 | + +## Backlog(不占本卡结论) + +- `can_handle` 仍用 `domain in url` 子串(与抖音/小红书同一存量模式),非本 diff 引入。 +- OCR #1/#5:except 无日志、测试未覆盖「重下换 netloc」;P3,接受不修。 diff --git a/retro/acceptance-log.jsonl b/retro/acceptance-log.jsonl index 9132b45e..33b73058 100644 --- a/retro/acceptance-log.jsonl +++ b/retro/acceptance-log.jsonl @@ -11,3 +11,4 @@ {"ts":"2026-08-24T22:55:34+08:00","dispatch_id":"dlg-20260824-143605-1d04d3","task_id":"VideoTranscriptAPI-20260824-05","repo":"VideoTranscriptAPI","executor":"codex","model":"gpt-5.6-luna","effort":"xhigh","runtime":"systemd","task_type":"review","complexity":"S","rounds":1,"wall_clock_min":9,"diff_lines":198,"commits":["7ed9f7460e01c56e35b04a00e872865bd6730145","712055c14adaafc0c54c2e52157cf95113d5db5d"],"commits_resolution":"partial","outcome":"accepted","scope_discipline":3,"taste":2,"note":"R1 verdict pass-with-backlog: docs/sessions/260824-summary-trunc/reviews/summary-truncation-r1-verdict.md (commit 7ed9f74 on PR #65 branch); 0 P1/P2; 降层三问+熵增+旁路排查齐全; 全量 pytest exit 0","report_directory":"20260824-143610-big-codex-VideoTranscriptAPI","decisions_ref":"PR #65"} {"ts":"2026-08-24T22:57:20+08:00","dispatch_id":"dlg-20260824-133558-fd0c2f","task_id":"VideoTranscriptAPI-20260824-04","repo":"VideoTranscriptAPI","executor":"cursor","model":"composer-2.5","effort":"unknown","runtime":"systemd","task_type":"backend-logic","complexity":"L","rounds":1,"wall_clock_min":10,"diff_lines":533,"commits":["5fd47cad624acedddfaf0f697250e79fb99f9845"],"commits_resolution":"partial","outcome":"accepted","scope_discipline":3,"taste":2,"note":"主脑独立验收: 全量 pytest 绿(exit 0); 红验 base 红; R1 verdict pass-with-backlog: docs/sessions/260824-summary-trunc/reviews/summary-truncation-r1-verdict.md; 实现提交 5fd47cad624acedddfaf0f697250e79fb99f9845","report_directory":"20260824-133602-big-cursor-VideoTranscriptAPI","decisions_ref":"PR #65"} {"ts":"2026-08-24T23:18:07+08:00","dispatch_id":"dlg-20260824-145748-1737de","task_id":"VideoTranscriptAPI-20260824-06","repo":"VideoTranscriptAPI","executor":"grok","model":"grok-4.6","effort":"unknown","runtime":"systemd","task_type":"review","complexity":"S","rounds":1,"wall_clock_min":17,"diff_lines":142,"commits":["e344a8ddea8c631735e0b6decc0cafef89fb2ce8"],"commits_resolution":"partial","outcome":"accepted","scope_discipline":3,"taste":3,"note":"R2 对抗视角 verdict pass-with-backlog: docs/sessions/260824-summary-trunc/reviews/summary-truncation-r2-verdict.md (commit e344a8ddea8c631735e0b6decc0cafef89fb2ce8); 0 P1, 2 P2(其一转修复轮), 3 注入红验, 全量 pytest exit 0","report_directory":"20260824-145758-big-grok-VideoTranscriptAPI","decisions_ref":"PR #65"} +{"ts":"2026-09-02T03:05:50+08:00","dispatch_id":"dlg-20260901-181636-fe8dd0","task_id":"VideoTranscriptAPI-20260902-01","repo":"VideoTranscriptAPI","executor":"agy","model":"gemini-3.7-flash-high","effort":"unknown","runtime":"systemd","task_type":"backend-logic","complexity":"S","rounds":2,"wall_clock_min":17,"diff_lines":233,"commits":["dedd376e6def0ea04b5b9f8ca89168abd4c8e6ad","dd6098b7678b50e3486a738c8f6f56d9dcc798f8","dda22e7eec9273e547edcb5d9d7a708fdfe9b099","76678f93b6b7d75381f4e24e270dc7d58c8632ce","541061b8b66ed911cc6ad94f2df54ff06f0c31ae","c840bbc42572cca49bf2861fdc0de0ad66250823","4228f5397845ae840c26280c6a7b2f9415594a9e"],"commits_resolution":"resolved","outcome":"accepted","scope_discipline":3,"taste":1,"note":"视频号接入,head dedd376e6def0ea04b5b9f8ca89168abd4c8e6ad:agy 实现 6 commit + 1 轮修复(swjs 缓存版本回退方向反了,根因拆卡漏列前端鉴权测试的硬编码版本断言,scope-miss)。主脑验收:diff 逐行审 + base 红验抽查 7 条全红 + 全量单测绿;OCR reviewed 无 P1;cursor 独立 review pass 无 P1,收敛。","report_directory":"20260901-181642-quick-agy-VideoTranscriptAPI","rework_origin":"scope-miss"} diff --git a/src/video_transcript_api/api/routes/tasks.py b/src/video_transcript_api/api/routes/tasks.py index 95d87a74..190362bd 100644 --- a/src/video_transcript_api/api/routes/tasks.py +++ b/src/video_transcript_api/api/routes/tasks.py @@ -358,6 +358,8 @@ async def transcribe_video( title = "小红书内容转录" elif "douyin.com" in display_url: title = "抖音视频转录" + elif "weixin.qq.com" in display_url: + title = "视频号视频转录" notification_router = get_notification_router() notification_router.send_view_link( diff --git a/src/video_transcript_api/api/routes/views.py b/src/video_transcript_api/api/routes/views.py index 051df14b..c5387c32 100644 --- a/src/video_transcript_api/api/routes/views.py +++ b/src/video_transcript_api/api/routes/views.py @@ -608,6 +608,7 @@ def generate_download_filename(title: str, platform: str, content_type: str) -> "xiaohongshu": "小红书", "xiaoyuzhou": "小宇宙", "apple_podcast": "Apple播客", + "wechat_channels": "视频号", "generic": "自定义", } diff --git a/src/video_transcript_api/downloaders/media_resolver.py b/src/video_transcript_api/downloaders/media_resolver.py index 9f368545..81a6e62c 100644 --- a/src/video_transcript_api/downloaders/media_resolver.py +++ b/src/video_transcript_api/downloaders/media_resolver.py @@ -1,10 +1,11 @@ -"""MediaResolverDownloader:把抖音/小红书解析外包给 MediaResolverAPI。 +"""MediaResolverDownloader:把抖音/小红书/视频号解析外包给 MediaResolverAPI。 -v1 收窄范围:只接管抖音 + 小红书(不抽基类、不加平台)。本下载器: +接管平台:抖音 + 小红书 + 微信视频号。本下载器: - 用**归一化 url** 作缓存 key(绕开"先有 video_id 才能查"的鸡蛋悖论), 一次 resolve 同时喂 `_fetch_metadata` 与 `_fetch_download_info`(FORK1-A)。 -- resolver 只返回 video_url 视频直链,下载仍走基类 `download_file()`。 +- resolver 返回 video_url 视频直链(视频号为流式代理端点),下载仍走基类 `download_file()`。 - 对 resolver 返回的直链下载前做 SSRF 校验(P0-2)。 +- 下载 resolver 流式端点时携带 X-API-Key 头,第三方 CDN 直链不携带该头。 - 下载遇 403/失效 → force_refresh 重解析再下一次(P0-3 / FORK4-A),仍失败抛错。 - 无字幕:`get_subtitle` 返回 None。 @@ -25,24 +26,29 @@ logger = setup_logger("media_resolver_downloader") -# v1 接管的平台域名(抖音 + 小红书) +# 接管的平台域名(抖音 + 小红书 + 微信视频号) _SUPPORTED_DOMAINS = ( "douyin.com", "v.douyin.com", "xiaohongshu.com", "xhslink.com", + "weixin.qq.com", ) class MediaResolverDownloader(BaseDownloader): - """通过 MediaResolverAPI 解析抖音/小红书的下载器。""" + """通过 MediaResolverAPI 解析抖音/小红书/微信视频号的下载器。""" def __init__(self): super().__init__() mr = self.config.get("media_resolver", {}) or {} + base_url = mr.get("base_url", "") + api_key = mr.get("api_key", "") + self._resolver_netloc = (urlparse(base_url).netloc or "").lower() + self._resolver_api_key = api_key self.client = MediaResolverClient( - base_url=mr.get("base_url", ""), - api_key=mr.get("api_key", ""), + base_url=base_url, + api_key=api_key, timeout=mr.get("timeout", 30), max_retries=mr.get("max_retries", 2), ) @@ -55,7 +61,7 @@ def __init__(self): # 路由 # ------------------------------------------------------------------ # def can_handle(self, url: str) -> bool: - """v1 仅接管抖音/小红书。""" + """接管抖音/小红书/微信视频号。""" if not url: return False return any(domain in url for domain in _SUPPORTED_DOMAINS) @@ -71,9 +77,9 @@ def extract_video_id(self, url: str) -> str: return str(data["video_id"]) import re - m = re.search(r"(?:video|note|explore|item)/([A-Za-z0-9]+)", url) or re.search( - r"/(\d{6,})", url - ) + m = re.search( + r"(?:video|note|explore|item|sph)/([A-Za-z0-9_-]+)", url + ) or re.search(r"/(\d{6,})", url) return m.group(1) if m else self._normalize_url(url) # ------------------------------------------------------------------ # @@ -207,6 +213,21 @@ def get_subtitle(self, url): # ------------------------------------------------------------------ # # 下载:403/失效 → force_refresh 重解析再下一次(P0-3 / FORK4-A) # ------------------------------------------------------------------ # + def _prepare_download_headers(self, download_url: str) -> None: + """根据下载 URL netloc 条件设置 download_headers。 + + 当下载 URL 的 netloc 与 resolver 的 base_url netloc 相等且 api_key 非空时, + 携带 X-API-Key 头(如视频号流式代理端点);否则置空(避免向第三方 CDN 泄露密钥)。 + """ + try: + target_netloc = (urlparse(download_url).netloc or "").lower() + if self._resolver_netloc and target_netloc == self._resolver_netloc and self._resolver_api_key: + self.download_headers = {"X-API-Key": self._resolver_api_key} + return + except Exception: + pass + self.download_headers = {} + def download_file(self, url, filename, max_retries: int = 3): """覆写下载:常规下载失败后,对 resolver 直链做一次 force_refresh 重解析重下。 @@ -214,6 +235,7 @@ def download_file(self, url, filename, max_retries: int = 3): 反查该 video_url 对应的页面 url,force_refresh 重解析得到新直链再下一次; 仍失败则抛 DownloadFailedError(爆炸半径仅限本类)。 """ + self._prepare_download_headers(url) local_file = super().download_file(url, filename, max_retries=max_retries) if local_file: return local_file @@ -244,6 +266,7 @@ def download_file(self, url, filename, max_retries: int = 3): except URLValidationError as e: raise DownloadFailedError(f"重解析直链不安全: {e}") + self._prepare_download_headers(fresh_url) local_file = super().download_file(fresh_url, filename, max_retries=max_retries) if local_file: return local_file diff --git a/src/video_transcript_api/utils/url_parser.py b/src/video_transcript_api/utils/url_parser.py index 9b268854..0e1213f2 100644 --- a/src/video_transcript_api/utils/url_parser.py +++ b/src/video_transcript_api/utils/url_parser.py @@ -22,7 +22,7 @@ class ParsedURL: 解析后的 URL 信息 Attributes: - platform: 平台名称 (youtube/bilibili/douyin/xiaohongshu/xiaoyuzhou/apple_podcast/generic) + platform: 平台名称 (youtube/bilibili/douyin/xiaohongshu/xiaoyuzhou/apple_podcast/wechat_channels/generic) video_id: 视频ID (唯一标识) normalized_url: 规范化的URL(长链接格式) is_short_url: 是否为短链接 @@ -81,6 +81,9 @@ class URLParser: r'xiaohongshu\.com/(?:explore|discovery/item|items)/(\w+)', # 主域名 r'xhslink\.com/(\w+)', # 短链接(需要解析) ], + 'wechat_channels': [ + r'weixin\.qq\.com/sph/([A-Za-z0-9_-]+)', # 微信视频号 + ], } # 短链接域名映射 @@ -286,6 +289,8 @@ def extract_platform(self, url: str) -> str: return 'apple_podcast' elif 'xiaohongshu.com' in url_lower or 'xhslink.com' in url_lower: return 'xiaohongshu' + elif 'weixin.qq.com' in url_lower: + return 'wechat_channels' return 'generic' diff --git a/src/web/static/history.html b/src/web/static/history.html index 9815a979..ddb7ea6d 100644 --- a/src/web/static/history.html +++ b/src/web/static/history.html @@ -918,6 +918,7 @@

Webhook 别名(保存在本地浏览器)

xiaoyuzhou: '🎵 小宇宙', xiaohongshu: '📕 小红书', douyin: '🎭 抖音', + wechat_channels: '📹 视频号', }; // ===================== Auth ===================== diff --git a/src/web/static/index.html b/src/web/static/index.html index 12523944..61db3b97 100644 --- a/src/web/static/index.html +++ b/src/web/static/index.html @@ -11,7 +11,7 @@ 视频转录 - VideoTranscriptAPI - + diff --git a/src/web/static/js/app.js b/src/web/static/js/app.js index 9f147586..d0744ee1 100644 --- a/src/web/static/js/app.js +++ b/src/web/static/js/app.js @@ -276,7 +276,8 @@ class URLExtractor { const videoDomains = [ 'youtube.com', 'youtu.be', 'bilibili.com', 'b23.tv', 'xiaohongshu.com', 'xhslink.com', 'douyin.com', 'v.douyin.com', - 'xiaoyuzhoufm.com', 'tiktok.com', 'vm.tiktok.com' + 'xiaoyuzhoufm.com', 'tiktok.com', 'vm.tiktok.com', + 'weixin.qq.com' ]; if (videoDomains.some(domain => url.includes(domain))) { @@ -495,6 +496,8 @@ class TaskHistoryManager { return '抖音视频'; } else if (hostname.includes('xiaoyuzhoufm.com')) { return '小宇宙播客'; + } else if (hostname.includes('weixin.qq.com')) { + return '视频号视频'; } else { return '视频内容'; } diff --git a/src/web/static/manifest.webmanifest b/src/web/static/manifest.webmanifest index cf63807c..1315ff9f 100644 --- a/src/web/static/manifest.webmanifest +++ b/src/web/static/manifest.webmanifest @@ -1,7 +1,7 @@ { "name": "VideoTranscriptAPI 视频转录", "short_name": "视频转录", - "description": "提交视频转录任务,支持 YouTube、Bilibili、抖音、小红书、小宇宙等多平台", + "description": "提交视频转录任务,支持 YouTube、Bilibili、抖音、小红书、微信视频号、小宇宙等多平台", "id": "/add_task_by_web", "start_url": "/add_task_by_web", "scope": "/", diff --git a/src/web/static/sw.js b/src/web/static/sw.js index 053e4fde..866a4460 100644 --- a/src/web/static/sw.js +++ b/src/web/static/sw.js @@ -21,7 +21,7 @@ */ // Bump this version whenever sw.js, icons or the manifest change. -const CACHE_NAME = 'vta-static-v4'; +const CACHE_NAME = 'vta-static-v5'; const CACHE_PREFIX = 'vta-static-'; // Shared authentication scripts are explicitly installed and refreshed with diff --git a/tests/unit/test_downloader_factory.py b/tests/unit/test_downloader_factory.py index 7c615b3b..0178fc6e 100644 --- a/tests/unit/test_downloader_factory.py +++ b/tests/unit/test_downloader_factory.py @@ -170,6 +170,7 @@ def resolver_off(self, monkeypatch): "https://v.douyin.com/abc/", "https://www.xiaohongshu.com/explore/abc", "https://xhslink.com/abc", + "https://weixin.qq.com/sph/AOzokRxWHz", ]) def test_flag_on_routes_to_resolver(self, resolver_on, url): downloader = create_downloader(url) @@ -180,6 +181,7 @@ def test_flag_on_routes_to_resolver(self, resolver_on, url): @pytest.mark.parametrize("url,expected", [ ("https://www.douyin.com/video/7123", DouyinDownloader), ("https://www.xiaohongshu.com/explore/abc", XiaohongshuDownloader), + ("https://weixin.qq.com/sph/AOzokRxWHz", GenericDownloader), ]) def test_flag_off_routes_to_legacy(self, resolver_off, url, expected): downloader = create_downloader(url) diff --git a/tests/unit/test_media_resolver_downloader.py b/tests/unit/test_media_resolver_downloader.py index 2fff891d..152794cc 100644 --- a/tests/unit/test_media_resolver_downloader.py +++ b/tests/unit/test_media_resolver_downloader.py @@ -3,6 +3,7 @@ Console output English only. """ +from unittest.mock import Mock import pytest from video_transcript_api.downloaders.base import BaseDownloader @@ -26,8 +27,10 @@ def resolve(self, url, translate=False, force_refresh=False): return item -def make_downloader(responses): +def make_downloader(responses, base_url="http://resolver.local:8000", api_key="secret-key"): dl = MediaResolverDownloader() + dl._resolver_netloc = "resolver.local:8000" + dl._resolver_api_key = api_key dl.client = FakeClient(responses) return dl @@ -43,6 +46,17 @@ def make_downloader(responses): "provider": "tikhub", } +WECHAT_DATA = { + "platform": "wechat_channels", + "video_id": "AOzokRxWHz", + "title": "视频号测试", + "author_name": "bob", + "description": "desc", + "duration": 45.0, + "video_url": "http://resolver.local:8000/api/stream/wechat_channels/AOzokRxWHz", + "provider": "tikhub", +} + # --------------------------------------------------------------------------- # # routing @@ -54,6 +68,7 @@ class TestCanHandle: "https://v.douyin.com/abc/", "https://www.xiaohongshu.com/explore/abc", "https://xhslink.com/abc", + "https://weixin.qq.com/sph/AOzokRxWHz", ]) def test_supported(self, url): assert make_downloader([DOUYIN_DATA]).can_handle(url) is True @@ -217,3 +232,100 @@ def test_unknown_url_raises_without_reresolve(self, monkeypatch): dl.download_file("https://cdn.example.com/unknown.mp4", "x.mp4") # no resolve happened (url not in reverse map) assert len(dl.client.calls) == 0 + + +# --------------------------------------------------------------------------- # +# extract_video_id +# --------------------------------------------------------------------------- # + +class TestExtractVideoId: + def test_extract_video_id_wechat_channels(self): + dl = make_downloader([]) + assert dl.extract_video_id("https://weixin.qq.com/sph/AOzokRxWHz") == "AOzokRxWHz" + + +# --------------------------------------------------------------------------- # +# conditional download headers (X-API-Key only for resolver domain) +# --------------------------------------------------------------------------- # + +class TestConditionalDownloadHeaders: + def test_resolver_domain_includes_api_key_header(self, monkeypatch): + monkeypatch.setattr(BaseDownloader, "_validate_media_file", lambda self, path: True) + dl = make_downloader([WECHAT_DATA], api_key="my-secret-key") + di = dl.get_download_info("https://weixin.qq.com/sph/AOzokRxWHz") + + captured_headers = [] + + def fake_get(url, headers=None, stream=True, timeout=60): + captured_headers.append((url, headers)) + mock_resp = Mock() + mock_resp.raise_for_status.return_value = None + mock_resp.headers = {"Content-Length": "100"} + mock_resp.iter_content.return_value = [b"fake_mp4_bytes"] + return mock_resp + + import requests + monkeypatch.setattr(requests, "get", fake_get) + out = dl.download_file(di.download_url, di.filename) + assert out is not None + assert len(captured_headers) == 1 + url, headers = captured_headers[0] + assert url == "http://resolver.local:8000/api/stream/wechat_channels/AOzokRxWHz" + assert headers == {"X-API-Key": "my-secret-key"} + + def test_cdn_domain_does_not_include_api_key_header(self, monkeypatch): + monkeypatch.setattr(BaseDownloader, "_validate_media_file", lambda self, path: True) + dl = make_downloader([DOUYIN_DATA], api_key="my-secret-key") + di = dl.get_download_info("https://www.douyin.com/video/7123") + + captured_headers = [] + + def fake_get(url, headers=None, stream=True, timeout=60): + captured_headers.append((url, headers)) + mock_resp = Mock() + mock_resp.raise_for_status.return_value = None + mock_resp.headers = {"Content-Length": "100"} + mock_resp.iter_content.return_value = [b"fake_mp4_bytes"] + return mock_resp + + import requests + monkeypatch.setattr(requests, "get", fake_get) + out = dl.download_file(di.download_url, di.filename) + assert out is not None + assert len(captured_headers) == 1 + url, headers = captured_headers[0] + assert url == "https://cdn.example.com/v/7123.mp4" + assert headers is None or "X-API-Key" not in headers + + def test_reresolve_applies_correct_headers_on_retry(self, monkeypatch): + monkeypatch.setattr(BaseDownloader, "_validate_media_file", lambda self, path: True) + fresh_stream = dict( + WECHAT_DATA, + video_url="http://resolver.local:8000/api/stream/wechat_channels/AOzokRxWHz?retry=1", + ) + dl = make_downloader([WECHAT_DATA, fresh_stream], api_key="my-secret-key") + di = dl.get_download_info("https://weixin.qq.com/sph/AOzokRxWHz") + + captured_headers = [] + attempt = {"count": 0} + + def fake_get(url, headers=None, stream=True, timeout=60): + attempt["count"] += 1 + captured_headers.append((url, headers)) + if attempt["count"] == 1: + import requests + raise requests.exceptions.HTTPError("403 Forbidden") + mock_resp = Mock() + mock_resp.raise_for_status.return_value = None + mock_resp.headers = {"Content-Length": "100"} + mock_resp.iter_content.return_value = [b"fake_mp4_bytes"] + return mock_resp + + import requests + monkeypatch.setattr(requests, "get", fake_get) + out = dl.download_file(di.download_url, di.filename, max_retries=1) + assert out is not None + assert len(captured_headers) >= 2 + fresh_call_headers = [h for u, h in captured_headers if "retry=1" in u] + assert fresh_call_headers + assert fresh_call_headers[0] == {"X-API-Key": "my-secret-key"} diff --git a/tests/unit/test_url_parser.py b/tests/unit/test_url_parser.py index 77613012..a3b744d0 100644 --- a/tests/unit/test_url_parser.py +++ b/tests/unit/test_url_parser.py @@ -132,6 +132,16 @@ def test_apple_podcast_show_url(self): assert result.platform == "apple_podcast" assert result.video_id == "1434243584" + def test_wechat_channels_url(self): + """Test WeChat Channels URL""" + parser = URLParser() + result = parser.parse("https://weixin.qq.com/sph/AOzokRxWHz") + + assert result.platform == "wechat_channels" + assert result.video_id == "AOzokRxWHz" + assert not result.is_short_url + assert result.original_url == "https://weixin.qq.com/sph/AOzokRxWHz" + def test_generic_url(self): """Test generic URL (no platform matched)""" parser = URLParser() @@ -229,6 +239,9 @@ def test_extract_platform_function(self): platform = extract_platform("https://www.bilibili.com/video/BV1test") assert platform == "bilibili" + platform = extract_platform("https://weixin.qq.com/sph/AOzokRxWHz") + assert platform == "wechat_channels" + platform = extract_platform("https://example.com/video/123") assert platform == "generic" diff --git a/tests/unit/web/test_frontend_auth.py b/tests/unit/web/test_frontend_auth.py index b99eb2fb..b322e8bc 100644 --- a/tests/unit/web/test_frontend_auth.py +++ b/tests/unit/web/test_frontend_auth.py @@ -217,7 +217,7 @@ def test_transcript_missing_shared_scripts_fails_closed_and_uses_safe_status_upd def test_service_worker_versions_and_precaches_shared_auth_scripts(): source = SW_JS.read_text(encoding="utf-8") - assert "const CACHE_NAME = 'vta-static-v4';" in source + assert "const CACHE_NAME = 'vta-static-v5';" in source assert "'/static/js/auth-storage.js'" in source assert "'/static/js/transcript-protected-action.js'" in source assert "self.addEventListener('install'" in source