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)
-把视频和播客转成可引用的逐字稿。贴链接就能跑,全程在你电脑上,不需要 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" < 触发${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 /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/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" < "$ENV_FILE" < "$ENV_FILE" </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 <
或终端直接跑:
- python3 $SKILL_DIR/scripts/transcript.py
+ $PYTHON_BIN $SKILL_DIR/scripts/transcript.py
逐字稿默认存到: $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()