From ad35442d9e410f5cfb362f15f4744c7df98d4786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AI=E4=BA=A7=E5=93=81=E9=BB=84=E5=8F=94?= Date: Sun, 30 Aug 2026 17:29:59 +0800 Subject: [PATCH] Harden WeChat installation and diagnostics --- .github/workflows/validate.yml | 24 +++ README.md | 29 +++- SKILL.md | 85 +++++----- bootstrap.sh | 141 ++++++++--------- install.sh | 169 ++++++++++++++------ scripts/sph_resolver.py | 151 +++++++++++++++--- scripts/transcript.py | 109 ++++++++++--- tests/test_wechat_reliability.py | 261 +++++++++++++++++++++++++++++++ 8 files changed, 754 insertions(+), 215 deletions(-) create mode 100644 .github/workflows/validate.yml create mode 100644 tests/test_wechat_reliability.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..8f3611f --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,24 @@ +name: Validate skill + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + tests: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Check shell syntax + run: bash -n install.sh bootstrap.sh + - name: Compile Python entrypoints + run: python -m py_compile scripts/sph_resolver.py scripts/transcript.py scripts/platform_extractor.py + - name: Run regression tests + run: python -m unittest discover -s tests -v diff --git a/README.md b/README.md index c115f55..1a85c7a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -把视频和播客转成可引用的逐字稿。贴链接就能跑,全程在你电脑上,不需要 API Key。 +把视频和播客转成可引用的逐字稿。ASR 转录在你电脑上运行,不需要 API Key;链接解析和首次模型下载需要联网。 - **视频**:B 站 / 抖音 / 小红书 / YouTube / 微信视频号 - **播客**:小宇宙 / 喜马拉雅 / Apple Podcasts → 自动分开说话人 @@ -14,7 +14,7 @@ ## 贴链接,你拿到什么 -转写在你电脑上跑。B 站 / 抖音 / 小红书 / YouTube 给你带小标题的整理稿。播客再多一份能压字幕的 SRT。 +转写在你电脑上跑。B 站 / 抖音 / 小红书 / YouTube 给你带小标题的整理稿。播客再多一份能压字幕的 SRT。微信视频号首次使用需要在本机打开腾讯元宝并扫码一次,不需要手工复制 Cookie。 视频号可以选三种交付。三种都是**他怎么说的**,不会改写成概述。 @@ -82,7 +82,14 @@ macOS 复制这一行到终端,回车,跟着提示走: bash <(curl -fsSL https://raw.githubusercontent.com/Backtthefuture/video-transcript/main/bootstrap.sh) ``` -它会:把 Skill 装到 `~/.claude/skills/video-transcript/`,检查 ffmpeg,安装 `yt-dlp` / Playwright / Chromium,装 FunASR,并带上视频号配套的 `video-download`。 +它会:把 Skill 装到 `~/.claude/skills/video-transcript/`,检查 ffmpeg,安装 `yt-dlp` / Playwright / Chromium,装 FunASR,并把配套的 `video-download` 放在同一个 skills 根目录。重复执行会从 GitHub main 更新程序文件,同时保留 `.env`、`outputs/` 和本地词表。 + +要安装到其他 Agent 目录,可显式指定目标,避免机器上出现多份互相遮蔽的副本: + +```bash +VIDEO_TRANSCRIPT_TARGET="$HOME/.agents/skills/video-transcript" \ + bash <(curl -fsSL https://raw.githubusercontent.com/Backtthefuture/video-transcript/main/bootstrap.sh) +``` 装完后在 Claude Code / Codex 里就可以: @@ -154,15 +161,18 @@ python3 ~/.claude/skills/video-transcript/scripts/transcript.py "<同一输入>"
微信视频号怎么解析 -公共 Worker 已失效。`install.sh` 第 7 步会引导用微信扫一次码,复用腾讯元宝登录态走官方接口,不导出 Cookie。 +公开安装不再使用 `public-worker`:该地址当前需要额外服务器凭据,普通用户会收到 HTTP 401。`install.sh` 第 7 步会引导微信扫码一次,之后复用本机腾讯元宝登录态;不会把 Cookie 发给第三方 Worker。 之后自己维护: ```bash python3 ~/.claude/skills/video-transcript/scripts/sph_resolver.py --login python3 ~/.claude/skills/video-transcript/scripts/sph_resolver.py --check +python3 ~/.claude/skills/video-transcript/scripts/transcript.py --doctor-live "<公开视频号链接>" ``` +`--check` 只验证认证,不代表任意链接都能拿到视频流。`--doctor-live` 才会验证“认证 → 分享链接解析 → 视频详情 → 媒体流”,且不会下载或转录。 + 只下载、不转录,请直接用 [`video-download`](https://github.com/Backtthefuture/video-download)。
@@ -215,6 +225,7 @@ python3 ~/.claude/skills/video-transcript/scripts/sph_resolver.py --check | `--keep-video` | 额外留一份 MP4(截图PDF 需要) | | `--force` | 忽略缓存,整条链路重跑 | | `--doctor` | 检查依赖,缺什么说什么 | +| `--doctor-live <视频号链接>` | 检查依赖并真实验证视频号解析,不下载/转录 | --- @@ -228,9 +239,13 @@ python3 ~/.claude/skills/video-transcript/scripts/transcript.py --doctor |---|---| | `--doctor` 报缺依赖 | 重跑 `bash ~/.claude/skills/video-transcript/install.sh` | | funasr 未安装 | `pip install funasr torchaudio` | -| 首次很慢 / 联网失败 | 视频模型约 234MB,播客模型约 1GB,下完以后离线 | +| 首次很慢 / 联网失败 | 视频模型约 234MB,播客模型约 1GB;模型可离线复用,网络链接仍需联网 | | 抖音 / 小红书抓不到 | 平台改版常见,看 [FALLBACK.md](FALLBACK.md) | | 视频号找不到 `video-download` | 重跑 `install.sh`,或 `npx skills add Backtthefuture/video-download` | +| `WECHAT_AUTH_REQUIRED` / `WECHAT_AUTH_EXPIRED` | 运行 `scripts/sph_resolver.py --login`,扫码后重试 | +| `WECHAT_PARSE_EMPTY` / `WECHAT_PARSE_TOKEN_MISSING` | 登录已通过,但该链接没有可用解析结果;检查链接/内容权限,必要时上传 MP4/MOV | +| `WECHAT_FEED_FAILED` / `WECHAT_STREAM_EMPTY` | 视频详情阶段没有媒体流;保留完整错误码后提 Issue,或改传本地文件 | +| 公共 Worker 返回 401 / 1042 | 不要继续请求隐私授权;公开发行只用 `yuanbao-login` | | B 站 yt-dlp 报 412 | 会自动改走无头浏览器,可忽略 | | 抖音图文笔记 | 只支持视频,不支持图文 | | Chromium 找不到 / 下载失败 | `python3 -m playwright install chromium`,国内网络可设代理后重试 | @@ -242,9 +257,9 @@ python3 ~/.claude/skills/video-transcript/scripts/transcript.py --doctor ## 隐私 -音视频只在你电脑上处理,不上传到第三方,也不需要 API Key。可选热词写在本地 `.env`(已加入 `.gitignore`)。 +ASR 推理和逐字稿文件在你电脑上处理,不上传到第三方,也不需要 API Key。处理网络链接时,分享链接及必要请求会发送给原平台;视频号默认只访问腾讯域名,并复用本机元宝登录态。可选热词写在本地 `.env`(已加入 `.gitignore`)。 -第一次使用需要联网下载模型(ModelScope),之后全离线。 +第一次使用需要联网下载模型(ModelScope),之后模型可离线复用;处理网络链接时仍需访问对应平台。 --- diff --git a/SKILL.md b/SKILL.md index 206ed5a..b3e13f9 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,26 +1,17 @@ --- -name: 视频文案提取 -description: | - 视频/播客逐字稿提取专家(FunASR 本地转录,无需 API Key)。视频走 SenseVoice-Small(CPU 高速,自带标点);播客/访谈走 paraformer + CAM++ **说话人分离**链路,输出「说话人区块版」逐字稿(主持人/嘉宾自动识别 + 补标点 + 语义分段)。支持 微信视频号 / 抖音 / 小红书 / B站 / YouTube / 小宇宙播客 / 本地视频音频。 - **微信视频号三种交付**(规则全文见 skills/weixin-layout.md):默认或「逐字稿/逐字稿版本」=对话里发口语逐字稿,不排PDF;「文字PDF」(旧称文字版本)=同一份口语稿排成Kami羊皮纸长文;「截图PDF」(旧称截图版本)=同一份口语稿+视频关键帧。视频号正文永远是「他怎么说的」(补标点/说话人/专有名词),禁止改写成导读/概述/Takeaways。 - **B站/抖音/小红书/YouTube 核心交付仍为「整理优化版」**(补标点+合并碎句+修正识别错误+语义化小标题+对照表),原始逐字稿仅作内部素材与对照存档,不向用户全文展示。全程在用户电脑后台运行(headless),不弹窗、不要求登录视频网站,离线零成本。 - 触发场景: - - 用户说"出文案"、"视频文案"、"提取文案"、"文案提取"、"视频文案提取" - - 用户说"出逐字稿"、"提取逐字稿"、"转文字"、"视频转文字"、"逐字稿版本" - - 用户说"文字PDF"、"文字版本"、"截图PDF"、"截图版本"(视频号排版) - - 用户说"听写视频"、"视频字幕" - - 用户说"主持稿"、"出主持稿" - - 用户说"播客转文字"、"播客逐字稿"、"区分说话人"、"说话人分离" - - 用户使用 /video-transcript 命令 - - **用户贴一个微信视频号链接(weixin.qq.com/sph / channels.weixin.qq.com)→ 直接转录;无额外说明则默认对话交付逐字稿,不排PDF** - - **用户贴一个视频链接(抖音/小红书/B站/YouTube)→ 直接开始转录,不询问意图** - - **用户贴一个播客/音频链接(小宇宙 xiaoyuzhoufm.com/episode/、喜马拉雅 ximalaya.com/sound/、Apple Podcasts)→ 自动走说话人分离链路** - - **用户贴微博/知乎/西瓜视频/AcFun 等其他链接 → 也直接试转录(yt-dlp 兜底),不要先反问** - - 用户给本地视频/音频文件路径(mp4/mp3/m4a/wav 等)→ 直接转录 - - 只有用户明确说"只下载"、"保存MP4"、"下载视频不用转录"时,才走纯下载流程 - - 已知不支持:Spotify(DRM)、快手 — 脚本会打印具体原因和替代做法,照着转达即可 +name: video-transcript +description: > + 视频/播客逐字稿提取 Skill。使用 FunASR 在本机转录(无需 API Key);视频用 + SenseVoice-Small,播客/访谈用 paraformer + CAM++ 区分主持人与嘉宾。支持微信视频号、 + 抖音、小红书、B站、YouTube、小宇宙及本地音视频。用户说“出文案/提取文案/出逐字稿/ + 转文字/视频字幕/主持稿/播客转文字/区分说话人”,粘贴上述平台链接,或提供本地媒体文件时使用。 + 视频号默认在对话中交付口语逐字稿;“文字PDF”生成同稿文字版;“截图PDF”加入关键帧, + 正文不得改写成导读。其他视频平台默认交付整理优化版;播客交付说话人区块版。用户明确说 + “只下载/保存MP4”时只下载。ASR 在本机运行,但链接解析需要联网;视频号首次使用需在本机 + 扫码登录腾讯元宝。 allowed-tools: Read, Write, Edit, Bash, Glob, Grep -user-invocable: true +metadata: + display-name: 视频文案提取 --- # 视频文案提取专家 @@ -33,20 +24,22 @@ user-invocable: true ## 阶段 0 · 定位 skill 根目录(第一件事) ```bash -VT_HOME="$( - for d in "$HOME/.workbuddy/skills/video-transcript" \ - "$HOME/.agents/skills/video-transcript" \ - "$HOME/.Codex/skills/video-transcript" \ - "$HOME/.codex/skills/video-transcript" \ - "$HOME/.claude/skills/video-transcript" \ - "$(pwd)/.Codex/skills/video-transcript" \ - "$(pwd)/.claude/skills/video-transcript" \ - "$(pwd)/skills/video-transcript" \ - "$HOME/.Codex/plugins/video-transcript/video-transcript" \ - "$HOME/.claude/plugins/video-transcript/video-transcript"; do - [ -f "$d/SKILL.md" ] && echo "$d" && break - done -)" +if [ -z "${VT_HOME:-}" ]; then + VT_HOME="$( + for d in "$HOME/.workbuddy/skills/video-transcript" \ + "$HOME/.agents/skills/video-transcript" \ + "$HOME/.Codex/skills/video-transcript" \ + "$HOME/.codex/skills/video-transcript" \ + "$HOME/.claude/skills/video-transcript" \ + "$(pwd)/.Codex/skills/video-transcript" \ + "$(pwd)/.claude/skills/video-transcript" \ + "$(pwd)/skills/video-transcript" \ + "$HOME/.Codex/plugins/video-transcript/video-transcript" \ + "$HOME/.claude/plugins/video-transcript/video-transcript"; do + [ -f "$d/SKILL.md" ] && echo "$d" && break + done + )" +fi export VT_HOME echo "VT_HOME=$VT_HOME" ``` @@ -114,7 +107,13 @@ B 站 / 抖音 / 小红书 / YouTube / 播客不受影响,继续走后面的原 "$VT_PY" "$VT_HOME/scripts/transcript.py" --doctor ``` -有 ✗ 项就跑 `bash "$VT_HOME/install.sh"`。全 ✓ 才进入阶段 3。 +有 ✗ 项就跑 `bash "$VT_HOME/install.sh"`。核心依赖没有 ✗ 就可以处理本地文件和其他平台。 + +`--doctor` 只检查依赖和视频号认证,不会冒充真实链路验收。需要验证视频号时,用一个可公开测试的分享链接: + +```bash +"$VT_PY" "$VT_HOME/scripts/transcript.py" --doctor-live "<公开视频号链接>" +``` ## 阶段 3 · 一条命令跑完下载+转录+预整理 @@ -159,6 +158,13 @@ stderr 会先打 📊 评估表。**立刻复述给用户**(标题/时长/预估 **若是微信视频号:到这里停,去 [`skills/weixin-layout.md`](skills/weixin-layout.md)。** 不要进入阶段 4,不要跑 `make_optimized.py`。 +视频号失败时按错误码处理,不要把隐私同意误说成技术鉴权: + +- `WECHAT_AUTH_REQUIRED` / `WECHAT_AUTH_EXPIRED`:让用户在本机运行 `sph_resolver.py --login`,扫码后重试。 +- `WECHAT_PARSE_EMPTY` / `WECHAT_PARSE_TOKEN_MISSING`:登录已通过,但该分享链接没有得到可用解析结果;说明可能是链接、内容权限或页面接口变化。 +- `WECHAT_FEED_FAILED` / `WECHAT_STREAM_EMPTY`:已经进入视频详情阶段,但没有媒体流;可请用户上传本地 MP4/MOV 继续。 +- **不要自动改用或请求授权使用 `public-worker`**。该服务当前需要额外服务器凭据,不是公开兜底。 + ## 阶段 4 · 你(agent)必须做的事:只出 patch,不要重写全文 > 本阶段只给 B 站 / 抖音 / 小红书 / YouTube 等非视频号视频。视频号看 [skills/weixin-layout.md](skills/weixin-layout.md)。 @@ -279,7 +285,9 @@ agent 拿到播客 `*_逐字稿.md` 后:**直接在对话里输出全文**(或 | 抖音图文笔记 | 提示仅支持视频 | | 平台前端改版 | 看 `$VT_HOME/FALLBACK.md` | | 视频号缺登录态 | `"$VT_PY" "$VT_HOME/scripts/sph_resolver.py" --login` | -| 视频号公共 Worker 1042 | 已默认走元宝 HTTP,无需处理 | +| `WECHAT_AUTH_REQUIRED` / `WECHAT_AUTH_EXPIRED` | 在本机运行 `sph_resolver.py --login`,扫码后重试 | +| `WECHAT_PARSE_EMPTY` / `WECHAT_STREAM_EMPTY` | 登录不等于链接可解析;保留错误码,可让用户上传本地 MP4/MOV | +| 视频号公共 Worker 401 / 1042 | 不再作为公开兜底;使用 `yuanbao-login` | | 要保留 MP4 | 给脚本加 `--keep-video`,或走 `video-download` | 视频号解析默认 `yuanbao-login`。`sph_resolver.py` 先抽 Cookie 走 HTTP,失败才开一次浏览器。 @@ -299,6 +307,7 @@ agent 拿到播客 `*_逐字稿.md` 后:**直接在对话里输出全文**(或 | `--no-save` | 不落盘 | | `--output-dir` | 改保存路径 | | `--doctor` | 体检 | +| `--doctor-live <视频号链接>` | 在体检基础上验证认证→解析→媒体流,不下载/转录 | | `--force` / `--no-cache` | 忽略同 URL 缓存 | | `--keep-video` | 额外保存 MP4(视频号截图PDF 必加) | | `--no-daemon` | 不使用常驻模型 | @@ -316,5 +325,5 @@ agent 拿到播客 `*_逐字稿.md` 后:**直接在对话里输出全文**(或 - 时间戳是段落级,用于章节定位 - 预估耗时:`时长/8 + 15s`(直链音频 + 已预热模型) - 热词:`$VT_HOME/.env` 里 `FUNASR_HOTWORD=词1 词2` -- 全程离线转录,不需要 API Key +- ASR 转录在本地运行,不需要 API Key;链接解析和首次模型下载需要联网 - 微信视频号三种交付见 [skills/weixin-layout.md](skills/weixin-layout.md):默认对话逐字稿;文字PDF / 截图PDF 用 Kami 羊皮纸长文;文件名用视频原标题 diff --git a/bootstrap.sh b/bootstrap.sh index a0929ce..6d1f8cb 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -6,13 +6,13 @@ # # 流程: 拉 skill 文件 → 跑 install.sh(装系统依赖 + FunASR 转录引擎) # -# 兜底顺序: npx skills add → git clone → tarball +# 拉取顺序: git clone 暂存 → GitHub tarball;更新时保留本机配置和产物 -set -e +set -euo pipefail REPO="Backtthefuture/video-transcript" SKILL="video-transcript" -TARGET="$HOME/.claude/skills/$SKILL" +TARGET="${VIDEO_TRANSCRIPT_TARGET:-$HOME/.claude/skills/$SKILL}" C_BOLD='\033[1m'; C_GREEN='\033[32m'; C_YELLOW='\033[33m'; C_RED='\033[31m'; C_BLUE='\033[34m'; C_GRAY='\033[90m'; C_RESET='\033[0m' say() { printf "${C_BLUE}▸${C_RESET} %s\n" "$1"; } @@ -29,33 +29,16 @@ register_codex() { return 0 fi mkdir -p "$codex_home/prompts" - cat > "$codex_home/prompts/video-transcript.md" <<'PROMPT_EOF' -You are a video transcript extractor. The user provides a video URL -(B站 / 抖音 / 小红书 / YouTube / 微信视频号) or a local file path as $ARGUMENTS. - -If the URL is a WeChat Channels link (weixin.qq.com/sph or channels.weixin.qq.com), -read ~/.claude/skills/video-transcript/skills/weixin-layout.md and follow those -three modes. Default (no extra words, or 逐字稿): polished spoken transcript in -chat, no PDF. 文字PDF / 文字版本: same transcript as Kami parchment PDF. -截图PDF / 截图版本: same transcript + video keyframes; add --keep-video. -Never rewrite 视频号 body as 导读 / overview / Takeaways. - -Otherwise run this command, streaming both stderr and stdout: - - python3 ~/.claude/skills/video-transcript/scripts/transcript.py "$ARGUMENTS" - -Behavior contract: -1. The script prints a 📊 评估表 to stderr early on. As soon as you see it, - tell the user the title, duration, segment count, and 预估耗时 — so they - know how long to wait. -2. When the script finishes, the FULL markdown transcript is on stdout, - starting with a `# ` heading. You MUST display the entire transcript - verbatim — every section with its `[MM:SS - MM:SS]` header. Do NOT summarize. - Do NOT only say "saved to xxx.md". -3. End your reply with one short line noting the saved .md path. - -If the script fails with missing dependencies, suggest the user run: - bash ~/.claude/skills/video-transcript/install.sh + cat > "$codex_home/prompts/video-transcript.md" <<PROMPT_EOF +Use the video-transcript skill at: + + $TARGET/SKILL.md + +Read that SKILL.md before acting and follow its current platform routing, +privacy boundaries, error handling, and output contract. Do not reproduce an +older workflow from this prompt. The user's request is: + + \$ARGUMENTS PROMPT_EOF ok "已注册 Codex 命令 → $codex_home/prompts/video-transcript.md" printf " ${C_GRAY} 在 Codex 里可用 /video-transcript <URL> 触发${C_RESET}\n" @@ -66,72 +49,76 @@ printf "${C_BOLD} 🎬 video-transcript skill 安装引导${C_RESET}\n" bar echo "" -# ── 已安装则提示 ───────────────────────────────────────── -if [ -d "$TARGET" ]; then - warn "已检测到 $TARGET 存在" - printf " 覆盖重装? [y/N]: " - read -r yn < /dev/tty || yn="" - case "$yn" in - [Yy]*) say "覆盖中,删除旧目录..."; rm -rf "$TARGET" ;; - *) say "保留现有 skill,只跑 install.sh 重新配置" - bash "$TARGET/install.sh" - echo "" - register_codex - exit 0 ;; - esac -fi - -mkdir -p "$HOME/.claude/skills" +case "$TARGET" in + ""|/|"$HOME"|"$HOME/") + err "拒绝使用过宽的安装目标: $TARGET" + exit 1 + ;; + /*/video-transcript) + ;; + *) + err "安装目标必须是以 /video-transcript 结尾的绝对路径: $TARGET" + exit 1 + ;; +esac + +TARGET_PARENT=$(dirname "$TARGET") +mkdir -p "$TARGET_PARENT" +STAGE_ROOT=$(mktemp -d) +STAGE_TARGET="$STAGE_ROOT/video-transcript" +cleanup_stage(){ rm -rf "$STAGE_ROOT"; } +trap cleanup_stage EXIT -# ── 三档兜底拉 skill 文件 ──────────────────────────────── fetched="" - -# 档 1: npx skills add(独立 skill 仓库,根目录即 SKILL.md) -if [ -z "$fetched" ] && command -v npx >/dev/null 2>&1; then - say "用 npx skills add 拉 skill..." - if npx -y skills add "$REPO" -a claude-code -g -y 2>&1; then - if [ -d "$TARGET" ] && [ -f "$TARGET/SKILL.md" ]; then - fetched="npx" - ok "通过 npx skills 拉到 $TARGET" - fi - fi - [ -z "$fetched" ] && warn "npx skills 失败,尝试下一档..." -fi - -# 档 2: git clone(仓库根目录即 skill,直接拉全量) -if [ -z "$fetched" ] && command -v git >/dev/null 2>&1; then - say "用 git clone 拉 skill..." - if git clone --depth=1 "https://github.com/$REPO.git" "$TARGET" 2>&1; then - rm -rf "$TARGET/.git" +if command -v git >/dev/null 2>&1; then + say "暂存拉取 GitHub main..." + if git clone --depth=1 "https://github.com/$REPO.git" "$STAGE_TARGET" 2>&1; then fetched="git" - ok "通过 git clone 拉到 $TARGET" fi - [ -z "$fetched" ] && warn "git 失败,尝试下一档..." fi -# 档 3: tarball(终极兜底,无需 git/node) if [ -z "$fetched" ]; then - say "用 tarball 下载..." - TMP=$(mktemp -d) - if curl -fsSL "https://github.com/$REPO/archive/refs/heads/main.tar.gz" | tar xz -C "$TMP" 2>&1; then - SUBDIR=$(find "$TMP" -maxdepth 2 -type f -name SKILL.md -exec dirname {} \; | head -1) + say "git 不可用或拉取失败,改用 GitHub tarball..." + if curl -fsSL "https://github.com/$REPO/archive/refs/heads/main.tar.gz" | tar xz -C "$STAGE_ROOT" 2>&1; then + SKILL_FILE=$(find "$STAGE_ROOT" -maxdepth 2 -type f -name SKILL.md -print -quit) + SUBDIR="${SKILL_FILE:+$(dirname "$SKILL_FILE")}" if [ -n "$SUBDIR" ] && [ -d "$SUBDIR" ]; then - mv "$SUBDIR" "$TARGET" - rm -rf "$TMP" + STAGE_TARGET="$SUBDIR" fetched="tarball" - ok "通过 tarball 拉到 $TARGET" fi fi fi -if [ -z "$fetched" ]; then - err "三种方式都失败了!" +if [ -z "$fetched" ] || [ ! -f "$STAGE_TARGET/SKILL.md" ]; then + err "GitHub main 拉取失败!" err "请手动 git clone 后跑: bash <skill-dir>/install.sh" err " git clone https://github.com/$REPO ~/Downloads/video-transcript" err " bash ~/Downloads/video-transcript/install.sh" exit 1 fi +if ! command -v rsync >/dev/null 2>&1; then + err "缺少 rsync,无法安全保留已有 .env 与 outputs/" + exit 1 +fi + +if [ -d "$TARGET" ]; then + say "检测到已有安装,更新程序文件并保留 .env、outputs 和本地词表..." +else + say "安装到 $TARGET ..." + mkdir -p "$TARGET" +fi + +rsync -a \ + --exclude='.git/' \ + --exclude='.env' \ + --exclude='.env.local' \ + --exclude='outputs/' \ + --exclude='__pycache__/' \ + --exclude='.podcast_glossary.json' \ + "$STAGE_TARGET/" "$TARGET/" +ok "程序文件已同步到 $TARGET" + echo "" say "进入安装向导(装系统依赖 + FunASR 转录引擎)..." echo "" diff --git a/install.sh b/install.sh index 6ea5f2a..1ad44f3 100755 --- a/install.sh +++ b/install.sh @@ -2,10 +2,11 @@ # video-transcript skill 一键安装向导(macOS) # 用法:bash ~/.claude/skills/video-transcript/install.sh -set -e +set -euo pipefail SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="$SKILL_DIR/.env" +PYTHON_BIN="${VT_PY:-python3}" C_RESET='\033[0m' C_BOLD='\033[1m' @@ -22,6 +23,10 @@ warn(){ printf " ${C_YELLOW}⚠${C_RESET} %s\n" "$1"; } err() { printf " ${C_RED}✗${C_RESET} %s\n" "$1"; } info(){ printf " ${C_BLUE}ℹ${C_RESET} %s\n" "$1"; } step(){ printf "\n${C_BOLD}[%s/%s] %s${C_RESET}\n" "$1" "$2" "$3"; } +has_tty(){ + [ "${VIDEO_TRANSCRIPT_NONINTERACTIVE:-0}" != "1" ] && + { : </dev/tty; } 2>/dev/null +} # ── 仅支持 macOS ─────────────────────────────────────── if [[ "$(uname)" != "Darwin" ]]; then @@ -34,7 +39,7 @@ bar printf "${C_BOLD} 🎬 视频逐字稿 Skill 安装向导${C_RESET}\n" sep echo " 把 B 站/抖音/小红书/YouTube/视频号、小宇宙播客转成逐字稿" -echo " 全程在你电脑后台跑,不弹窗、不要登录视频网站" +echo " 转录在你电脑本地跑;链接解析需要联网;视频号首次使用需扫码登录腾讯元宝" echo "" echo " 接下来 7 步,大约 6-12 分钟:" echo " [1/7] 检查/安装 ffmpeg(视频处理)" @@ -46,18 +51,23 @@ echo " [6/7] 安装配套 skill video-download(微信视频号必需)" echo " [7/7] 微信视频号元宝登录态(扫码一次,免 Cookie 解析)" bar echo "" -read -r -p " 按回车继续 / Ctrl+C 取消..." _ < /dev/tty || true +if has_tty; then + read -r -p " 按回车继续 / Ctrl+C 取消..." _ < /dev/tty +fi # ── Step 1: ffmpeg ───────────────────────────────────── step 1 7 "检查 ffmpeg" if command -v ffmpeg >/dev/null 2>&1; then - ok "ffmpeg 已装: $(ffmpeg -version 2>/dev/null | head -1 | awk '{print $3}')" + FFMPEG_VER=$(ffmpeg -version 2>/dev/null | sed -n '1{s/^ffmpeg version \([^ ]*\).*/\1/;p;}') + ok "ffmpeg 已装: ${FFMPEG_VER:-unknown}" else warn "ffmpeg 未装,需要 Homebrew 帮忙" if ! command -v brew >/dev/null 2>&1; then warn "也没装 Homebrew,先帮你装它(macOS 标配工具)" info "下一步会让你输入 Mac 开机密码(看不到字符是正常的)" - read -r -p " 按回车继续..." _ < /dev/tty || true + if has_tty; then + read -r -p " 按回车继续..." _ < /dev/tty + fi /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" # 把 brew 加进当前 shell PATH if [[ -x /opt/homebrew/bin/brew ]]; then @@ -73,9 +83,9 @@ fi # ── Step 2: Python 3 ──────────────────────────────────── step 2 7 "检查 Python 3" -if command -v python3 >/dev/null 2>&1; then - PY_VER=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")') - PY_OK=$(python3 -c 'import sys; print(1 if sys.version_info >= (3,8) else 0)') +if command -v "$PYTHON_BIN" >/dev/null 2>&1 || [ -x "$PYTHON_BIN" ]; then + PY_VER=$("$PYTHON_BIN" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")') + PY_OK=$("$PYTHON_BIN" -c 'import sys; print(1 if sys.version_info >= (3,8) else 0)') if [[ "$PY_OK" == "1" ]]; then ok "Python $PY_VER" else @@ -89,36 +99,37 @@ fi # ── Step 3: pip 装 yt-dlp + playwright ───────────────── step 3 7 "安装 Python 工具" -if python3 -m pip install --help 2>/dev/null | grep -q -- '--break-system-packages'; then - PIP_FLAGS="--break-system-packages --quiet" +if "$PYTHON_BIN" -m pip install --help 2>/dev/null | grep -q -- '--break-system-packages'; then + PIP_FLAGS=(--break-system-packages --quiet) else - PIP_FLAGS="--user --quiet" + PIP_FLAGS=(--user --quiet) fi info "yt-dlp ..." -python3 -m pip install $PIP_FLAGS --upgrade yt-dlp +"$PYTHON_BIN" -m pip install "${PIP_FLAGS[@]}" --upgrade yt-dlp ok "yt-dlp" info "playwright ..." -python3 -m pip install $PIP_FLAGS --upgrade playwright +"$PYTHON_BIN" -m pip install "${PIP_FLAGS[@]}" --upgrade playwright ok "playwright" # ── Step 4: chromium ──────────────────────────────────── step 4 7 "下载 Chromium(playwright 用的浏览器引擎, ~300MB)" info "国内网络可能稍慢,大概 1-3 分钟..." -python3 -m playwright install chromium +"$PYTHON_BIN" -m playwright install chromium ok "Chromium 装好" # ── Step 5: funasr 转录引擎 ──────────────────────────── step 5 7 "安装 FunASR 转录引擎(SenseVoice-Small,约 234M)" sep info "安装 funasr + torchaudio(纯本地转录,不需要 API Key)..." -python3 -m pip install $PIP_FLAGS --upgrade funasr torchaudio +"$PYTHON_BIN" -m pip install "${PIP_FLAGS[@]}" --upgrade funasr torchaudio ok "funasr 装好" info "视频转录模型 SenseVoice-Small(234M)首次转录时自动下载" info "播客说话人分离模型 paraformer/CAM++/VAD/punc(约 1GB)首次转播客时自动下载" -# 写入最小 .env(可选热词) -cat > "$ENV_FILE" <<EOF +# 首次安装才创建 .env;升级/重跑安装器必须保留用户热词与私有配置。 +if [ ! -f "$ENV_FILE" ]; then + cat > "$ENV_FILE" <<EOF # video-transcript skill 配置 # 由 install.sh 生成于 $(date '+%Y-%m-%d %H:%M:%S') @@ -126,42 +137,90 @@ cat > "$ENV_FILE" <<EOF # 例:FUNASR_HOTWORD=玉伯 优麦 YouMind WorkBuddy Codex # FUNASR_HOTWORD= EOF -chmod 600 "$ENV_FILE" -ok "已写入 $ENV_FILE (chmod 600,只有你能读)" + chmod 600 "$ENV_FILE" + ok "已创建 $ENV_FILE (chmod 600,只有你能读)" +else + chmod 600 "$ENV_FILE" + ok "保留现有 $ENV_FILE" +fi # ── Step 6: 配套 skill video-download(微信视频号必需) ── step 6 7 "安装配套 skill video-download(微信视频号下载)" sep -VD_TARGET="$HOME/.claude/skills/video-download" +VD_TARGET="${VIDEO_DOWNLOAD_HOME:-$(dirname "$SKILL_DIR")/video-download}" +case "$VD_TARGET" in + /*/video-download) + ;; + *) + err "VIDEO_DOWNLOAD_HOME 必须是以 /video-download 结尾的绝对路径: $VD_TARGET" + exit 1 + ;; +esac if [ -d "$VD_TARGET" ] && [ -f "$VD_TARGET/scripts/download_video.py" ]; then ok "video-download 已存在: $VD_TARGET" else info "拉取 video-download skill(抖音/小红书/B站/YouTube/微信视频号 → 本地 MP4)..." - if command -v npx >/dev/null 2>&1 && npx -y skills add Backtthefuture/video-download -a claude-code -g -y 2>&1; then - : - elif git clone --depth=1 https://github.com/Backtthefuture/video-download.git "$VD_TARGET" 2>&1; then - rm -rf "$VD_TARGET/.git" + mkdir -p "$(dirname "$VD_TARGET")" + VD_STAGE_ROOT=$(mktemp -d) + VD_STAGE="$VD_STAGE_ROOT/video-download" + if command -v git >/dev/null 2>&1 && git clone --depth=1 https://github.com/Backtthefuture/video-download.git "$VD_STAGE" 2>&1; then + VD_SOURCE="$VD_STAGE" + elif curl -fsSL https://github.com/Backtthefuture/video-download/archive/refs/heads/main.tar.gz | tar xz -C "$VD_STAGE_ROOT" 2>&1; then + VD_SKILL_FILE=$(find "$VD_STAGE_ROOT" -maxdepth 2 -type f -name SKILL.md -print -quit) + VD_SOURCE="${VD_SKILL_FILE:+$(dirname "$VD_SKILL_FILE")}" else - warn "video-download 拉取失败,可稍后手动安装(仅影响微信视频号转录)" + VD_SOURCE="" + fi + if [ -n "${VD_SOURCE:-}" ] && [ -f "$VD_SOURCE/scripts/download_video.py" ]; then + mkdir -p "$VD_TARGET" + if command -v rsync >/dev/null 2>&1; then + rsync -a --exclude='.git/' --exclude='.env' "$VD_SOURCE/" "$VD_TARGET/" + else + warn "缺少 rsync,无法安全更新已有的 video-download 目录" + fi fi + rm -rf "$VD_STAGE_ROOT" if [ -d "$VD_TARGET" ] && [ -f "$VD_TARGET/scripts/download_video.py" ]; then - ok "video-download 就绪" + ok "video-download 就绪: $VD_TARGET" + else + warn "video-download 拉取失败,可稍后手动安装(仅影响 --keep-video / 只下载)" fi fi -# 写 video-download 的最小 .env(默认 public-worker,无需 Cookie;失效时自动回退元宝登录态) +# video-transcript 的公开发行默认只走本机元宝登录态。 +# 公共 Worker 当前需要额外服务器凭据,不能再作为新用户默认路线。 if [ -d "$VD_TARGET" ]; then VD_ENV="$VD_TARGET/.env" if [ ! -f "$VD_ENV" ]; then cat > "$VD_ENV" <<'EOF' # video-download skill 配置 -# public-worker: 走公共 Worker 解析视频号,无需本机 Cookie(链接会发给第三方);失效时自动回退元宝登录态 +# yuanbao-login: 复用本机元宝登录态,链接只发给腾讯官方域名 # cookie: 用本机元宝 Cookie,隐私更好,需配置 SPH_COOKIE/YUANBAO_COOKIE -# 元宝登录态: 见 install.sh Step 7 / sph_resolver.py --login(扫码一次,官方接口解析) -WECHAT_RESOLVER=public-worker +# 元宝登录态: 见 install.sh Step 7 / sph_resolver.py --login(扫码一次) +WECHAT_RESOLVER=yuanbao-login EOF chmod 600 "$VD_ENV" - ok "已写入 $VD_ENV (WECHAT_RESOLVER=public-worker)" + ok "已写入 $VD_ENV (WECHAT_RESOLVER=yuanbao-login)" + elif grep -Eq '^[[:space:]]*WECHAT_RESOLVER=public-worker[[:space:]]*$' "$VD_ENV"; then + "$PYTHON_BIN" - "$VD_ENV" <<'PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +lines = path.read_text(encoding="utf-8").splitlines() +updated = [] +for line in lines: + if line.strip() == "WECHAT_RESOLVER=public-worker": + updated.append("WECHAT_RESOLVER=yuanbao-login") + else: + updated.append(line) +path.write_text("\n".join(updated) + "\n", encoding="utf-8") +PY + chmod 600 "$VD_ENV" + warn "检测到旧的 public-worker 默认值,已只迁移为 yuanbao-login;其他配置保持不变" + else + chmod 600 "$VD_ENV" + ok "保留现有 $VD_ENV" fi fi @@ -171,18 +230,18 @@ sep SPH_SCRIPT="$SKILL_DIR/scripts/sph_resolver.py" if [ ! -f "$SPH_SCRIPT" ]; then warn "缺少 sph_resolver.py,跳过元宝登录态配置" - warn "视频号解析将依赖公共 Worker(已失效)或手动配置 SPH_COOKIE" + warn "视频号链接暂不可用;本地文件及其他平台不受影响" else - # 探测可用的 python(优先 WorkBuddy venv) - VENV_PY="/Users/superhuang/.workbuddy/binaries/python/envs/default/bin/python" - RESOLVER_PY="$python3" + # 探测可用的 python(优先当前安装解释器,其次 WorkBuddy venv) + VENV_PY="$HOME/.workbuddy/binaries/python/envs/default/bin/python" + RESOLVER_PY="$PYTHON_BIN" if [ -x "$VENV_PY" ] && "$VENV_PY" -c "import playwright" 2>/dev/null; then RESOLVER_PY="$VENV_PY" - elif python3 -c "import playwright" 2>/dev/null; then - RESOLVER_PY="python3" + elif "$PYTHON_BIN" -c "import playwright" 2>/dev/null; then + RESOLVER_PY="$PYTHON_BIN" else warn "当前 python 环境没有 playwright,无法弹出扫码;可用 venv python 手动执行:" - warn " python3 $SPH_SCRIPT --login" + warn " $PYTHON_BIN $SPH_SCRIPT --login" RESOLVER_PY="" fi @@ -192,18 +251,23 @@ else ok "元宝登录态已存在,无需重新扫码" else echo "" - info "即将弹出腾讯元宝官方登录页,请用微信扫码登录(只需这一次)" - info "登录后 Skill 会把登录态保存到 ~/.workbuddy/credentials/,之后解析视频号免扫码" - echo "" - read -r -p " 按回车弹出扫码窗口 / 输入 s 跳过(以后手动运行 --login)..." CHOICE < /dev/tty || true - if [[ "${CHOICE:-}" != "s" && "${CHOICE:-}" != "S" ]]; then - if "$RESOLVER_PY" "$SPH_SCRIPT" --login; then - ok "元宝登录态已建立,视频号解析免 Cookie 可用" + if has_tty; then + info "视频号首次使用需弹出腾讯元宝登录页,请用微信扫码一次" + info "只保存本机登录态,不把 Cookie 发给第三方 Worker" + echo "" + read -r -p " 按回车弹出扫码窗口 / 输入 s 跳过(以后手动运行 --login)..." CHOICE < /dev/tty + if [[ "${CHOICE:-}" != "s" && "${CHOICE:-}" != "S" ]]; then + if "$RESOLVER_PY" "$SPH_SCRIPT" --login; then + ok "元宝登录态已建立,视频号认证可用" + else + warn "扫码登录未完成,可稍后手动运行: $RESOLVER_PY $SPH_SCRIPT --login" + fi else - warn "扫码登录未完成,可稍后手动运行: python3 $SPH_SCRIPT --login" + warn "已跳过,以后需要视频号时运行: $RESOLVER_PY $SPH_SCRIPT --login" fi else - warn "已跳过,以后需要时手动运行: python3 $SPH_SCRIPT --login" + warn "当前是非交互安装,未自动打开扫码窗口" + warn "以后需要视频号时运行: $RESOLVER_PY $SPH_SCRIPT --login" fi fi fi @@ -214,24 +278,25 @@ echo "" bar printf "${C_BOLD} ✅ 安装完成,跑一次自检...${C_RESET}\n" sep -python3 "$SKILL_DIR/scripts/transcript.py" --doctor +"$PYTHON_BIN" "$SKILL_DIR/scripts/transcript.py" --doctor echo "" bar -printf "${C_BOLD} 🎉 一切就绪!${C_RESET}\n" +printf "${C_BOLD} 🎉 核心转录环境安装完成${C_RESET}\n" sep cat <<EOF 试一下: 在 Claude Code 里输入: /video-transcript <视频URL> 或终端直接跑: - python3 $SKILL_DIR/scripts/transcript.py <URL> + $PYTHON_BIN $SKILL_DIR/scripts/transcript.py <URL> 逐字稿默认存到: $SKILL_DIR/outputs/ 微信视频号: - 已建立元宝登录态的话,直接粘贴链接即可转录,免 Cookie 免扫码 - 登录态过期时,运行: python3 $SKILL_DIR/scripts/sph_resolver.py --login + 视频号首次使用需建立本机元宝登录态 + 登录/续期: $PYTHON_BIN $SKILL_DIR/scripts/sph_resolver.py --login + 真实链路验收: $PYTHON_BIN $SKILL_DIR/scripts/transcript.py --doctor-live <公开视频号链接> 常见问题: cat $SKILL_DIR/README.md EOF diff --git a/scripts/sph_resolver.py b/scripts/sph_resolver.py index e4a3856..994095c 100644 --- a/scripts/sph_resolver.py +++ b/scripts/sph_resolver.py @@ -43,6 +43,28 @@ def log(msg): print(msg, file=sys.stderr) +class WechatResolverError(RuntimeError): + """带稳定错误码和阶段的视频号解析错误。""" + + def __init__(self, code, stage, message): + self.code = code + self.stage = stage + self.message = message + super().__init__(f"[{code}] {message}") + + def as_dict(self): + return { + "ok": False, + "code": self.code, + "stage": self.stage, + "error": self.message, + } + + +def resolver_error(code, stage, message): + raise WechatResolverError(code, stage, message) + + LOGIN_JS = r""" () => { const webApi = window.$webApi; @@ -226,12 +248,18 @@ def http_parse_share_url(share_url, cookie, user_id=""): if status != 200: raise RuntimeError(f"get_parse_result 失败: HTTP {status}") if parsed.get("code") not in (None, 0): - raise RuntimeError( - f"元宝解析失败: {parsed.get('msg') or parsed.get('message') or parsed.get('code')}" + resolver_error( + "WECHAT_PARSE_FAILED", + "parse", + f"元宝解析失败: {parsed.get('msg') or parsed.get('message') or parsed.get('code')}", ) data = parsed.get("data") or {} if not data.get("playable_url") and not data.get("wx_export_id"): - raise RuntimeError("元宝解析未返回 playable_url 或 wx_export_id") + resolver_error( + "WECHAT_PARSE_EMPTY", + "parse", + "元宝已响应,但没有返回 playable_url 或 wx_export_id;可能是链接失效、内容权限受限或页面接口发生变化", + ) return data @@ -348,21 +376,41 @@ def profile_from_feed(share_url, feed, resolver="yuanbao-http"): def resolve_via_http(share_url, state): cookie = cookie_header_from_state(state) if not cookie: - raise RuntimeError("登录态里没有可用 Cookie") + resolver_error( + "WECHAT_AUTH_STATE_INVALID", + "auth", + "元宝登录态里没有可用 Cookie,请重新执行 sph_resolver.py --login", + ) if "hy_token" not in cookie and "hy_user" not in cookie: - raise RuntimeError("登录态缺少 hy_token/hy_user") + resolver_error( + "WECHAT_AUTH_STATE_INVALID", + "auth", + "元宝登录态缺少必要字段,请重新执行 sph_resolver.py --login", + ) user_info = http_get_userinfo(cookie) user_id = _pick_user_id(user_info) parsed = http_parse_share_url(share_url, cookie, user_id) token, eid = token_eid_from_parse(parsed) if not token or not eid: - raise RuntimeError("playable_url 缺少 token/eid") + resolver_error( + "WECHAT_PARSE_TOKEN_MISSING", + "parse", + "元宝返回结果缺少 token/eid,无法继续请求视频详情", + ) feed, err = fetch_feed_info(token, eid) if err or not feed: - raise RuntimeError(f"get_feed_info 失败: {err}") + resolver_error( + "WECHAT_FEED_FAILED", + "feed", + f"视频号详情请求失败: {err or '空响应'}", + ) profile = profile_from_feed(share_url, feed, resolver="yuanbao-http") if not profile.get("direct_url"): - raise RuntimeError("视频号详情没有返回可下载视频流") + resolver_error( + "WECHAT_STREAM_EMPTY", + "stream", + "视频号详情已返回,但没有可下载视频流;可能是内容权限、链接状态或接口字段变化", + ) return profile @@ -422,18 +470,36 @@ async def run_browser_session(headless, storage_state, share_url=None, login_onl def check_login_state(): state = load_state() + if not state: + return { + "ready": True, + "loggedIn": False, + "via": "local", + "authOnly": True, + "code": "WECHAT_AUTH_REQUIRED", + "message": "未找到元宝登录态,首次使用视频号需执行 sph_resolver.py --login", + } cookie = cookie_header_from_state(state) if cookie: try: info = http_get_userinfo(cookie) user_id = _pick_user_id(info) if user_id: - return {"ready": True, "loggedIn": True, "via": "http", "userId": user_id} + return { + "ready": True, + "loggedIn": True, + "via": "http", + "authOnly": True, + "userId": user_id, + } except Exception as exc: log(f"[INFO] HTTP 登录检查失败,回退浏览器: {exc}") result = asyncio.run(run_browser_session(True, state, login_only=True)) login = result.get("login") or {} login["via"] = "browser" + login["authOnly"] = True + if not login.get("loggedIn"): + login.setdefault("code", "WECHAT_AUTH_EXPIRED") return login @@ -460,23 +526,41 @@ def resolve_via_browser(share_url, state): except Exception: pass if not login.get("loggedIn"): - raise RuntimeError("元宝登录态已失效,需要重新执行 sph_resolver.py --login 扫码") + resolver_error( + "WECHAT_AUTH_EXPIRED", + "auth", + "元宝登录态已失效,需要重新执行 sph_resolver.py --login 扫码", + ) parsed_wrap = result.get("parse") or {} if not parsed_wrap.get("ok"): - raise RuntimeError( - f"解析失败: {parsed_wrap.get('code')} {(parsed_wrap.get('body') or '')[:200]}" + resolver_error( + "WECHAT_PARSE_FAILED", + "parse", + f"元宝浏览器解析失败: {parsed_wrap.get('code')} {(parsed_wrap.get('body') or '')[:200]}", ) parsed = json.loads(parsed_wrap["body"]) data = parsed.get("data") or parsed token, eid = token_eid_from_parse(data) if not token or not eid: - raise RuntimeError("playable_url 缺少 token/eid") + resolver_error( + "WECHAT_PARSE_TOKEN_MISSING", + "parse", + "元宝返回结果缺少 token/eid,无法继续请求视频详情", + ) feed, err = fetch_feed_info(token, eid) if err or not feed: - raise RuntimeError(f"get_feed_info 失败: {err}") + resolver_error( + "WECHAT_FEED_FAILED", + "feed", + f"视频号详情请求失败: {err or '空响应'}", + ) profile = profile_from_feed(share_url, feed, resolver="yuanbao-browser") if not profile.get("direct_url"): - raise RuntimeError("视频号详情没有返回可下载视频流") + resolver_error( + "WECHAT_STREAM_EMPTY", + "stream", + "视频号详情已返回,但没有可下载视频流;可能是内容权限、链接状态或接口字段变化", + ) return profile @@ -484,15 +568,36 @@ def resolve_wechat(share_url, prefer_http=True): """解析视频号分享链接,返回含 direct_url/title/duration 的 profile。""" state = load_state() if not state: - raise RuntimeError("无登录态,先执行 --login 扫码登录") + resolver_error( + "WECHAT_AUTH_REQUIRED", + "auth", + "未找到元宝登录态,先执行 sph_resolver.py --login 扫码登录", + ) + http_error = None if prefer_http: try: profile = resolve_via_http(share_url, state) log("[OK] 视频号 HTTP 解析成功") return profile except Exception as exc: + http_error = exc log(f"[WARN] HTTP 解析失败({exc}),回退单次浏览器会话") - return resolve_via_browser(share_url, state) + try: + return resolve_via_browser(share_url, state) + except WechatResolverError: + raise + except Exception as browser_exc: + if isinstance(http_error, WechatResolverError): + raise WechatResolverError( + http_error.code, + http_error.stage, + f"{http_error.message};浏览器兜底也失败: {browser_exc}", + ) from browser_exc + resolver_error( + "WECHAT_BROWSER_FALLBACK_FAILED", + "browser", + f"元宝浏览器兜底失败: {browser_exc}", + ) def parse_share_url(share_url): @@ -519,12 +624,22 @@ def main(): if args[0] == "--login": return 0 if do_login() else 1 - share_url = args[0] + probe_mode = args[0] == "--probe" + if probe_mode and len(args) < 2: + print("用法: sph_resolver.py --probe <视频号分享链接>", file=sys.stderr) + return 2 + share_url = args[1] if probe_mode else args[0] try: profile = resolve_wechat(share_url) except Exception as exc: log(f"[错误] {exc}") + if isinstance(exc, WechatResolverError): + print(json.dumps(exc.as_dict(), ensure_ascii=False)) + else: + print(json.dumps({"ok": False, "code": "WECHAT_UNKNOWN", "stage": "unknown", "error": str(exc)}, ensure_ascii=False)) return 1 + if probe_mode: + profile = {"ok": True, **profile} print(json.dumps(profile, ensure_ascii=False)) return 0 diff --git a/scripts/transcript.py b/scripts/transcript.py index 0eb87fe..cb67749 100644 --- a/scripts/transcript.py +++ b/scripts/transcript.py @@ -119,15 +119,25 @@ def platform_zh_name(platform): def find_video_download_script(): - candidates = [ + candidates = [] + explicit_home = os.getenv("VIDEO_DOWNLOAD_HOME") + if explicit_home: + candidates.append(os.path.join(os.path.expanduser(explicit_home), "scripts", "download_video.py")) + # 优先使用和当前 video-transcript 同一安装根下的配套副本,避免命中其他运行时的旧版本。 + candidates.extend([ + os.path.join(os.path.dirname(SKILL_DIR), "video-download", "scripts", "download_video.py"), os.path.join(os.path.expanduser("~"), ".workbuddy", "skills", "video-download", "scripts", "download_video.py"), os.path.join(os.path.expanduser("~"), ".agents", "skills", "video-download", "scripts", "download_video.py"), os.path.join(os.path.expanduser("~"), ".Codex", "skills", "video-download", "scripts", "download_video.py"), os.path.join(os.path.expanduser("~"), ".codex", "skills", "video-download", "scripts", "download_video.py"), os.path.join(os.path.expanduser("~"), ".claude", "skills", "video-download", "scripts", "download_video.py"), - os.path.join(os.path.dirname(SKILL_DIR), "video-download", "scripts", "download_video.py"), - ] + ]) + seen = set() for path in candidates: + path = os.path.abspath(path) + if path in seen: + continue + seen.add(path) if os.path.exists(path): return path return None @@ -137,7 +147,7 @@ def _run_video_download_json(args, timeout=900): script = find_video_download_script() if not script: raise RuntimeError("找不到 video-download/scripts/download_video.py") - cmd = ["python3", script] + args + ["--json"] + cmd = [sys.executable, script] + args + ["--json"] r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) if r.returncode != 0: err = "" @@ -155,9 +165,10 @@ def _run_video_download_json(args, timeout=900): def download_via_video_download(url): args = [url] - resolver = os.getenv("VIDEO_DOWNLOAD_WECHAT_RESOLVER") - if resolver: - args += ["--wechat-resolver", resolver] + # video-transcript 的公开发行默认只走本机元宝登录态。旧安装里的 + # WECHAT_RESOLVER=public-worker 不能覆盖这里,除非用户显式设置本变量。 + resolver = (os.getenv("VIDEO_DOWNLOAD_WECHAT_RESOLVER") or "yuanbao-login").strip() + args += ["--wechat-resolver", resolver or "yuanbao-login"] data = _run_video_download_json(args, timeout=1200) path = data.get("path") if not path or not os.path.exists(path): @@ -1174,7 +1185,7 @@ def run(input_path, title=None, output_dir=None, save_md=True, use_cache=True, k print(json.dumps(outputs, ensure_ascii=False), file=sys.stderr) -def doctor(): +def doctor(live_wechat_url=None): print("=" * 55) print(" 🩺 video-transcript 体检") print("=" * 55) @@ -1197,17 +1208,34 @@ def doctor(): else: print(" ⚠ yt-dlp 未安装(YouTube 会受影响)") try: - from playwright.sync_api import sync_playwright - with sync_playwright() as p: - exe = p.chromium.executable_path - if exe and os.path.exists(exe): - print(" ✓ playwright + chromium") - else: - print(" ✗ chromium 没装") - issues.append("python3 -m playwright install chromium") + import playwright # noqa: F401 + # Playwright 在部分 Python 3.13 环境里,即使正常 stop 也会向父进程 + # 泄漏 asyncio 的 TargetClosed 警告;放进短命子进程做路径探测可隔离该噪音。 + browser_probe = subprocess.run( + [ + sys.executable, + "-c", + ( + "import os; from playwright.sync_api import sync_playwright; " + "p=sync_playwright().start(); path=p.chromium.executable_path; " + "print('1' if path and os.path.exists(path) else '0'); p.stop()" + ), + ], + capture_output=True, + text=True, + timeout=20, + ) + if browser_probe.returncode == 0 and browser_probe.stdout.strip().endswith("1"): + print(" ✓ playwright + chromium") + else: + print(" ✗ chromium 没装或无法启动探测") + issues.append(f"{sys.executable} -m playwright install chromium") except ImportError: print(" ✗ playwright 未安装") issues.append("pip install playwright") + except (subprocess.TimeoutExpired, OSError) as exc: + print(f" ✗ playwright 探测失败: {exc}") + issues.append(f"{sys.executable} -m playwright install chromium") try: import funasr print(f" ✓ funasr({funasr.__version__})") @@ -1226,11 +1254,41 @@ def doctor(): print(f" ✓ video-download: {find_video_download_script()}") else: print(" ⚠ video-download 未安装(仅影响 --keep-video / 回退下载)") - state = os.path.expanduser("~/.workbuddy/credentials/yuanbao_state.json") - if os.path.exists(state): - print(f" ✓ 元宝登录态: {state}") + try: + from sph_resolver import check_login_state, resolve_wechat + auth = check_login_state() + except Exception as exc: + auth = {"loggedIn": False, "code": "WECHAT_AUTH_CHECK_FAILED", "message": str(exc)} + resolve_wechat = None + if auth.get("loggedIn"): + print(f" ✓ 视频号元宝认证可用({auth.get('via') or 'unknown'};仅认证检查)") + else: + code = auth.get("code") or "WECHAT_AUTH_REQUIRED" + print(f" ⚠ 视频号元宝认证未就绪: {code}") + print(" 需要视频号时运行: python3 scripts/sph_resolver.py --login") + + wechat_live_ok = False + if live_wechat_url: + if detect_platform(live_wechat_url) != "wechat_channels": + print(" ✗ --doctor-live 只接受微信视频号分享链接") + issues.append("换用 weixin.qq.com/sph 或 channels.weixin.qq.com 链接") + elif not auth.get("loggedIn") or resolve_wechat is None: + print(" ✗ 视频号真实解析未执行: 元宝认证未就绪") + issues.append("先执行 sph_resolver.py --login") + else: + try: + profile = resolve_wechat(live_wechat_url) + if not profile.get("direct_url"): + raise RuntimeError("解析结果没有媒体流") + title = (profile.get("title") or "未命名视频")[:50] + duration = int(profile.get("duration") or 0) + print(f" ✓ 视频号真实解析: {title} ({duration}s)") + wechat_live_ok = True + except Exception as exc: + print(f" ✗ 视频号真实解析失败: {exc}") + issues.append("视频号端到端解析失败") else: - print(" ⚠ 元宝登录态不存在(视频号需 sph_resolver.py --login)") + print(" ⚠ 视频号端到端未验证(可用 --doctor-live <公开测试链接>)") try: from asr_daemon import ping info = ping(timeout=1) @@ -1248,7 +1306,10 @@ def doctor(): for x in issues: print(f" - {x}") return 1 - print(" ✅ 全部就绪") + if live_wechat_url and wechat_live_ok: + print(" ✅ 全部就绪(含视频号真实解析)") + else: + print(" ✅ 核心转录依赖就绪;视频号端到端状态见上方") return 0 @@ -1259,6 +1320,8 @@ def main(): parser.add_argument("--no-save", dest="save_md", action="store_false") parser.add_argument("--output-dir", default=None) parser.add_argument("--doctor", action="store_true") + parser.add_argument("--doctor-live", metavar="WECHAT_URL", + help="体检并用一个公开视频号链接验证认证→解析→媒体流") parser.add_argument("--no-cache", dest="use_cache", action="store_false") parser.add_argument("--force", action="store_true", help="忽略缓存,强制重跑") parser.add_argument("--keep-video", action="store_true", help="额外保存完整 MP4") @@ -1273,8 +1336,8 @@ def main(): help="转录后保留临时 wav(默认清理,1 小时单集约 115MB)") parser.set_defaults(save_md=True, use_cache=True, use_daemon=True) args = parser.parse_args() - if args.doctor: - sys.exit(doctor()) + if args.doctor or args.doctor_live: + sys.exit(doctor(args.doctor_live)) if not args.input: parser.error("缺少 input 参数") if args.speakers or args.reformat or (is_url(args.input) and is_podcast_platform(args.input)): diff --git a/tests/test_wechat_reliability.py b/tests/test_wechat_reliability.py new file mode 100644 index 0000000..be21a9c --- /dev/null +++ b/tests/test_wechat_reliability.py @@ -0,0 +1,261 @@ +import io +import json +import os +import shlex +import shutil +import subprocess +import sys +import tempfile +import textwrap +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +import sph_resolver # noqa: E402 +import transcript # noqa: E402 + + +class WechatResolverErrorTests(unittest.TestCase): + def test_missing_state_is_auth_required_without_browser(self): + with patch.object(sph_resolver, "load_state", return_value=None), patch.object( + sph_resolver.asyncio, "run", side_effect=AssertionError("browser should not run") + ): + status = sph_resolver.check_login_state() + + self.assertFalse(status["loggedIn"]) + self.assertEqual(status["code"], "WECHAT_AUTH_REQUIRED") + self.assertTrue(status["authOnly"]) + + def test_resolve_without_state_has_stable_code(self): + with patch.object(sph_resolver, "load_state", return_value=None): + with self.assertRaises(sph_resolver.WechatResolverError) as caught: + sph_resolver.resolve_wechat("https://weixin.qq.com/sph/test") + + self.assertEqual(caught.exception.code, "WECHAT_AUTH_REQUIRED") + self.assertEqual(caught.exception.stage, "auth") + + def test_empty_parse_result_is_not_reported_as_login_failure(self): + with patch.object( + sph_resolver, + "_http_json", + return_value=({"code": 0, "data": {}}, 200), + ): + with self.assertRaises(sph_resolver.WechatResolverError) as caught: + sph_resolver.http_parse_share_url( + "https://weixin.qq.com/sph/test", "hy_token=local-only" + ) + + self.assertEqual(caught.exception.code, "WECHAT_PARSE_EMPTY") + self.assertEqual(caught.exception.stage, "parse") + + def test_empty_media_stream_has_stream_stage(self): + state = { + "cookies": [ + {"domain": ".yuanbao.tencent.com", "name": "hy_token", "value": "local-only"} + ] + } + parsed = {"playable_url": "https://example.invalid/?token=t&eid=e"} + empty_feed = {"data": {"feedInfo": {}, "authorInfo": {}}} + with patch.object(sph_resolver, "http_get_userinfo", return_value={"id": "u"}), patch.object( + sph_resolver, "http_parse_share_url", return_value=parsed + ), patch.object(sph_resolver, "fetch_feed_info", return_value=(empty_feed, None)): + with self.assertRaises(sph_resolver.WechatResolverError) as caught: + sph_resolver.resolve_via_http("https://weixin.qq.com/sph/test", state) + + self.assertEqual(caught.exception.code, "WECHAT_STREAM_EMPTY") + self.assertEqual(caught.exception.stage, "stream") + + def test_browser_crash_does_not_hide_http_stage(self): + state = {"cookies": [{"name": "hy_token", "value": "local-only"}]} + parse_error = sph_resolver.WechatResolverError( + "WECHAT_PARSE_EMPTY", "parse", "empty parse response" + ) + with patch.object(sph_resolver, "load_state", return_value=state), patch.object( + sph_resolver, "resolve_via_http", side_effect=parse_error + ), patch.object( + sph_resolver, "resolve_via_browser", side_effect=RuntimeError("chromium unavailable") + ): + with self.assertRaises(sph_resolver.WechatResolverError) as caught: + sph_resolver.resolve_wechat("https://weixin.qq.com/sph/test") + + self.assertEqual(caught.exception.code, "WECHAT_PARSE_EMPTY") + self.assertEqual(caught.exception.stage, "parse") + self.assertIn("浏览器兜底也失败", caught.exception.message) + + def test_probe_failure_emits_machine_readable_json(self): + error = sph_resolver.WechatResolverError( + "WECHAT_PARSE_EMPTY", "parse", "no media profile" + ) + stdout = io.StringIO() + stderr = io.StringIO() + with patch.object(sys, "argv", ["sph_resolver.py", "--probe", "https://weixin.qq.com/sph/test"]), patch.object( + sph_resolver, "resolve_wechat", side_effect=error + ), redirect_stdout(stdout), redirect_stderr(stderr): + exit_code = sph_resolver.main() + + payload = json.loads(stdout.getvalue()) + self.assertEqual(exit_code, 1) + self.assertEqual(payload["code"], "WECHAT_PARSE_EMPTY") + self.assertEqual(payload["stage"], "parse") + + +class VideoDownloadBridgeTests(unittest.TestCase): + def test_default_bridge_forces_yuanbao_login(self): + with tempfile.NamedTemporaryFile() as media, patch.dict( + os.environ, {"VIDEO_DOWNLOAD_WECHAT_RESOLVER": ""} + ), patch.object( + transcript, + "_run_video_download_json", + return_value={"ok": True, "path": media.name, "title": "test"}, + ) as run_download: + path, title = transcript.download_via_video_download( + "https://weixin.qq.com/sph/test" + ) + + args = run_download.call_args.args[0] + self.assertEqual(path, media.name) + self.assertEqual(title, "test") + self.assertEqual(args[-2:], ["--wechat-resolver", "yuanbao-login"]) + + def test_bridge_preserves_explicit_resolver_override(self): + with tempfile.NamedTemporaryFile() as media, patch.dict( + os.environ, {"VIDEO_DOWNLOAD_WECHAT_RESOLVER": "cookie"} + ), patch.object( + transcript, + "_run_video_download_json", + return_value={"ok": True, "path": media.name, "title": "test"}, + ) as run_download: + transcript.download_via_video_download("https://weixin.qq.com/sph/test") + + args = run_download.call_args.args[0] + self.assertEqual(args[-2:], ["--wechat-resolver", "cookie"]) + + def test_bridge_uses_current_python_interpreter(self): + completed = SimpleNamespace(returncode=0, stdout='{"ok": true}', stderr="") + with patch.object(transcript, "find_video_download_script", return_value=__file__), patch.object( + subprocess, "run", return_value=completed + ) as run_process: + transcript._run_video_download_json(["https://example.invalid/video"]) + + command = run_process.call_args.args[0] + self.assertEqual(command[0], sys.executable) + + +class InstallerInvariantTests(unittest.TestCase): + def test_install_defaults_to_first_party_login_and_is_portable(self): + installer = (ROOT / "install.sh").read_text(encoding="utf-8") + self.assertIn("WECHAT_RESOLVER=yuanbao-login", installer) + self.assertNotIn("/Users/superhuang", installer) + self.assertIn('VD_TARGET="${VIDEO_DOWNLOAD_HOME:-$(dirname "$SKILL_DIR")/video-download}"', installer) + self.assertIn("rsync -a --exclude='.git/' --exclude='.env'", installer) + + def test_bootstrap_preserves_user_state_on_update(self): + bootstrap = (ROOT / "bootstrap.sh").read_text(encoding="utf-8") + self.assertNotIn('rm -rf "$TARGET"', bootstrap) + self.assertIn("--exclude='.env'", bootstrap) + self.assertIn("--exclude='outputs/'", bootstrap) + self.assertIn("VIDEO_TRANSCRIPT_TARGET", bootstrap) + self.assertIn("/*/video-transcript)", bootstrap) + + def test_noninteractive_reinstall_preserves_env_and_migrates_worker(self): + with tempfile.TemporaryDirectory() as tmp: + sandbox = Path(tmp) + skill = sandbox / "video-transcript" + shutil.copytree( + ROOT, + skill, + ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc"), + ) + transcript_env = skill / ".env" + transcript_env.write_text("FUNASR_HOTWORD=must-stay\n", encoding="utf-8") + + video_download = sandbox / "video-download" + (video_download / "scripts").mkdir(parents=True) + (video_download / "scripts" / "download_video.py").write_text( + "# smoke-test placeholder\n", encoding="utf-8" + ) + video_download_env = video_download / ".env" + video_download_env.write_text( + "WECHAT_RESOLVER=public-worker\nKEEP_ME=yes\n", encoding="utf-8" + ) + + fake_bin = sandbox / "bin" + fake_bin.mkdir() + fake_python = fake_bin / "python-stub" + real_python = shlex.quote(sys.executable) + fake_python.write_text( + textwrap.dedent( + f"""\ + #!/usr/bin/env bash + set -e + case "${{1:-}}" in + -c) + case "${{2:-}}" in + *sys.version_info.major*) echo '3.12.0' ;; + *'print(1 if sys.version_info'*) echo '1' ;; + *'import playwright'*) exit 0 ;; + *) exec {real_python} "$@" ;; + esac + ;; + -m) exit 0 ;; + -) exec {real_python} "$@" ;; + *sph_resolver.py) + printf '%s\n' '{{"loggedIn": true, "via": "stub"}}' + ;; + *transcript.py) exit 0 ;; + *) exec {real_python} "$@" ;; + esac + """ + ), + encoding="utf-8", + ) + fake_python.chmod(0o755) + (fake_bin / "uname").write_text( + "#!/usr/bin/env bash\necho Darwin\n", encoding="utf-8" + ) + (fake_bin / "ffmpeg").write_text( + "#!/usr/bin/env bash\necho 'ffmpeg version 7.0-smoke'\n", + encoding="utf-8", + ) + (fake_bin / "uname").chmod(0o755) + (fake_bin / "ffmpeg").chmod(0o755) + + env = os.environ.copy() + env.update( + { + "HOME": str(sandbox / "home"), + "PATH": f"{fake_bin}:/usr/bin:/bin", + "VT_PY": str(fake_python), + "VIDEO_DOWNLOAD_HOME": str(video_download), + "VIDEO_TRANSCRIPT_NONINTERACTIVE": "1", + } + ) + result = subprocess.run( + ["/bin/bash", str(skill / "install.sh")], + capture_output=True, + text=True, + timeout=30, + env=env, + ) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual( + transcript_env.read_text(encoding="utf-8"), + "FUNASR_HOTWORD=must-stay\n", + ) + migrated = video_download_env.read_text(encoding="utf-8") + self.assertIn("WECHAT_RESOLVER=yuanbao-login", migrated) + self.assertIn("KEEP_ME=yes", migrated) + self.assertNotIn("WECHAT_RESOLVER=public-worker", migrated) + self.assertIn("核心转录环境安装完成", result.stdout) + + +if __name__ == "__main__": + unittest.main()