Skill: poltawa/subtitle-extractor (version 1.0.0)

--- name: Subtitle-Extractor author: poltawa version: 1.0.0 description: "Subtitle extractor for Bilibili, YouTube, Xiaohongshu, Douyin, and local files. Extracts native subtitles or Whisper transcription in original format. Agent handles dependency checking, file naming, and content analysis." # 🔒 Security Declaration # Downloads subtitles/audio from video platforms via yt-dlp. # Cookie files are read locally only, never transmitted externally. # No config files written. No secrets stored. No telemetry. metadata: openclaw: requires: bins: ["yt-dlp"] behavior: networkAccess: indirect description: "Subtitle extractor for Bilibili, YouTube, Xiaohongshu, Douyin, and local files. Extracts native subtitles or Whisper transcription in original format. Agent handles dependency checking, file naming, and content analysis." --- # Subtitle Extractor Skill Extracts subtitles from video platforms in their native format. Supports Bilibili, YouTube, Xiaohongshu, Douyin, and local video files. **Scope of this skill:** subtitle extraction only. Summarization, analysis, Q&A — all handled by the agent based on the user's actual request. --- ## What It Does 1. Detect platform from URL 2. Extract native subtitles via yt-dlp (or Whisper transcription when no native subtitles exist) 3. Output: raw subtitle file path + video title/author 4. Agent saves subtitle to `outputs/` and processes per user request --- ## Dependencies Agent must verify dependencies before calling the script. If any are missing, inform the user with the relevant install command. ### yt-dlp — Required (always) ```bash # Check yt-dlp --version # Install pip install yt-dlp # all platforms (recommended) brew install yt-dlp # macOS Homebrew winget install yt-dlp.yt-dlp # Windows WinGet scoop install yt-dlp # Windows Scoop conda install -c conda-forge yt-dlp # Conda environments # Upgrade existing install pip install -U yt-dlp ``` ### ffmpeg — Required only for Whisper transcription > Only needed for Xiaohongshu, Douyin, local files, or Path B (Whisper transcription). ```bash # Check ffmpeg -version # Install brew install ffmpeg # macOS Homebrew winget install Gyan.FFmpeg # Windows WinGet choco install ffmpeg # Windows Chocolatey scoop install ffmpeg # Windows Scoop apt install ffmpeg # Ubuntu / Debian dnf install ffmpeg # Fedora / RHEL (may need RPM Fusion) pacman -S ffmpeg # Arch Linux snap install ffmpeg # Ubuntu Snap ``` > **Windows users:** restart the terminal after installation for PATH to take effect. > If `winget` is unavailable, download from [ffmpeg.org](https://ffmpeg.org/download.html) and add the `bin/` directory to system PATH. ### faster-whisper — Required only for transcription platforms > Only needed for Xiaohongshu, Douyin, local files, or Path B (Whisper transcription). ```bash # Check python3 -c "from faster_whisper import WhisperModel; print('ok')" # Install pip install faster-whisper # Configure model size (default: base) export VIDEO_SUMMARY_WHISPER_MODEL=base # tiny | base | small | medium | large ``` **Note:** Model files are downloaded automatically on first transcription run (~150MB for base). This may take a minute depending on network speed. **China network note:** If auto-download fails (HuggingFace blocked), see [Whisper model download failed](#whisper-model-download-failed) in Troubleshooting. **Transcription time estimate (CPU, faster-whisper):** | Video Duration | tiny | base | small | medium | |---------------|------|------|-------|--------| | 5 min | ~10s | ~20s | ~40s | ~80s | | 15 min | ~30s | ~60s | ~2m | ~4m | | 30 min | ~60s | ~2m | ~5m | ~10m | GPU accelerates transcription 5–15×. First run downloads the model (~150MB for base). --- ## Cookie Configuration ### Bilibili — Cookie Required Bilibili requires a cookie file for all requests. The script auto-discovers cookie files **in the skill directory only** (same folder as `subtitle-extractor.py` and `SKILL.md`): Any `.txt` file whose name contains `bilibili` will be picked up automatically — including the browser extension's default export format `www.bilibili.com_netscape_<timestamp>.txt`. **Place your cookie file in the skill directory.** The agent does not need to locate or pass it manually (see Step 1b). ### Xiaohongshu / Douyin — Manual ```bash video-summary "https://www.xiaohongshu.com/explore/xxxxx" --cookies cookies.txt # or export VIDEO_SUMMARY_COOKIES=/path/to/cookies.txt ``` **How to export cookies:** 1. Install browser extension: "Cookie Editor（https://cookieeditor.org/）" 2. Log in to the platform 3. Export cookies to a `.txt` file（Netscape format） --- ## Agent Workflow ### EXECUTION ORDER — NON-NEGOTIABLE Steps 1–4 in this skill MUST be completed in full before addressing any user request. The subtitle file MUST be saved to disk (Step 4) before the agent proceeds to summarization, translation, analysis, or any other task the user has asked for. Treat Steps 1–4 as mandatory prerequisites, not optional helpers. Do not skip any step even if the user's final output format (e.g. a markdown file) appears to make it unnecessary. ### Step 1 — Check Dependencies ```bash yt-dlp --version ``` If the user requests Whisper transcription (keywords: "whisper转录" / "用whisper" / "transcribe" / "转录" / "语音转文字"), or the platform is Xiaohongshu, Douyin, or a local file, also check: ```bash ffmpeg -version python3 -c "from faster_whisper import WhisperModel; print('ok')" ``` If anything is missing, stop and tell the user which dependency to install (see Dependencies section). ### Step 1b — Bilibili Cookie The script auto-discovers any `.txt` file containing "bilibili" in the skill directory. Do not search for or pass the cookie file yourself. Only act if the script exits with: - `未找到 Bilibili Cookie 文件` → tell the user to place a cookie file in the skill directory - `Bilibili 412 错误：Cookie 已过期` → tell the user to re-export To export: install "Cookie Editor (https://cookieeditor.org/)", log in to Bilibili, export Netscape format → place in skill directory → retry. ### Step 2 — Extract Subtitles Determine which path applies, then execute it completely before moving to Step 3. --- **Path A — Native subtitles** Use when: Bilibili or YouTube URL, and the user has not mentioned any transcription keyword. Tell the user: "正在提取字幕..." ```bash python subtitle-extractor.py "<url>" # auto-detect language python subtitle-extractor.py "<url>" --lang zh-CN # force language ``` Parse the JSON from stdout. You now have all four fields needed for Step 3: | Field | Value | |---|---| | `title` | from this JSON | | `author` | from this JSON | | `platform` | from this JSON | | `subtitle_file` | from this JSON | If the script exits non-zero: read stderr, report the error to the user, stop. --- **Path B — Whisper transcription** Use when: user mentions any transcription keyword, OR platform is Xiaohongshu or Douyin. > Transcription keyword takes priority over phrasing like "提取字幕" or "字幕原文" — those describe the desired output, not the method. **Call 1 — Download audio** (skip for local files, go to Call 2 directly) Tell the user: "正在下载音频，请稍候..." ```bash python subtitle-extractor.py "<url>" --step download-audio ``` Parse the JSON from stdout and record these values: | Field | Value | |---|---| | `title` | from this JSON | | `author` | from this JSON | | `platform` | from this JSON | | `audio_file` | from this JSON — input for Call 2 | If the script exits non-zero: read stderr, report the error to the user, stop. Tell the user: "音频下载完成，开始 Whisper 转录（模型: base），请稍候..." **Call 2 — Transcribe** For URL input, use the `audio_file` recorded from Call 1: ```bash python subtitle-extractor.py "<audio_file>" --step transcribe ``` For local file input (set `title` = filename, `author` = "local"): ```bash python subtitle-extractor.py "<local_file_path>" --step transcribe ``` Parse the JSON from stdout and record: | Field | Value | |---|---| | `subtitle_file` | from this JSON | Tell the user: "转录完成！" If the script exits non-zero: - Read stderr, report the error to the user, stop - If stderr contains `Whisper 模型下载失败`: show the full error message verbatim — it contains the exact download directory and manual steps --- **Failure rule:** Do not run yt-dlp, ffmpeg, or Whisper commands manually. Do not retry with different flags unless the error message explicitly says to. ### Step 3 — Confirm Data Verify you have collected all four values from the script outputs in Step 2: | Field | Path A source | Path B source | |---|---|---| | `title` | script JSON | Call 1 JSON (or filename for local) | | `author` | script JSON | Call 1 JSON (or "local") | | `subtitle_file` | script JSON | Call 2 JSON | Note: non-ASCII characters in JSON output appear as `\uXXXX` escapes — standard JSON parsing produces the correct decoded strings. ### Step 4 — Save Subtitle to Outputs (REQUIRED — DO NOT SKIP) Before answering the user, save the subtitle file to the session outputs directory. **Naming rule: `{title前8字}_{author}.{原格式扩展名}`** Steps: 1. Take `title`, keep the first **8 characters** (Chinese and English each count as 1) 2. Replace unsafe filesystem characters `/ \ : * ? " < > |` and spaces with `_` 3. Apply the same sanitization to `author` 4. Use the extension from `subtitle_file` path (`.srt` or `.vtt`) 5. Save to `{outputs_dir}/{safe_title8}_{safe_author}.{ext}` ### Step 5 — Process and Respond Read the subtitle file content and respond to the user's original request — summarize, analyze, translate, answer questions, etc. The subtitle content is in SRT or VTT format with timestamps; LLMs handle both directly. ## Platform Notes | Platform | Method | Notes | |----------|--------|-------| | **YouTube** | yt-dlp native CC + auto-generated | Best support, usually no cookies needed | | **Bilibili** | yt-dlp native CC | Auto-discovers cookies; zh-CN → ai-zh fallback; 412 error handling | | **Xiaohongshu** | Whisper transcription | No native subtitles; requires ffmpeg + whisper | | **Douyin** | Whisper transcription | No native subtitles; requires ffmpeg + whisper | | **Local files** | Whisper transcription | mp4, mkv, webm, mp3, etc. | ### Supported URL Formats **YouTube:** `youtube.com/watch?v=...` · `youtu.be/...` **Bilibili:** `bilibili.com/video/BV...` · `bilibili.com/video/av...` · `b23.tv/...` (short link) **Xiaohongshu:** `xiaohongshu.com/explore/...` · `xhslink.com/...` (short link) **Douyin:** `douyin.com/video/...` · `v.douyin.com/...` (short link) --- ## Script Reference ``` Usage: python subtitle-extractor.py <url|file> [options] Steps (--step): download-audio Download audio from URL → {"audio_file", "title", "author", "platform"} transcribe Transcribe local audio/video file → {"subtitle_file"} (none) Extract native subtitles (default path) → {"title", "author", "platform", "subtitle_file"} Options: --step <name> Pipeline step to run: download-audio | transcribe --lang <code> Subtitle language code, default path only (default: auto) --cookies <file> Cookie file for restricted content --help Show help Environment Variables: VIDEO_SUMMARY_COOKIES Path to cookies file VIDEO_SUMMARY_WHISPER_MODEL Whisper model size (default: base) ``` --- ## Troubleshooting ### "yt-dlp: command not found" ```bash pip install yt-dlp ``` ### "No subtitles found" - The video may not have CC subtitles — use Path B (`--step download-audio` then `--step transcribe`) to force Whisper - For Xiaohongshu/Douyin, transcription is always required (no native subtitles) - Try `--lang` to specify a different language code ### Bilibili 412 Precondition Failed Cookie expired. Re-export: 1. Log in to Bilibili in browser 2. Use "Cookie Editor（https://cookieeditor.org/）" extension 3. Export（Netscape format）→ place in skill directory → retry ### Bilibili: no zh-CN subtitle found The script automatically falls back to `ai-zh`. If both fail, it lists all available subtitle codes. Use `--lang <code>` to specify one. ### "Whisper not installed" ```bash pip install faster-whisper ``` ### Whisper model download failed The script tries `hf-mirror.com` then `huggingface.co`. If both fail (common in China), the script will print exact steps. **Show the error message to the user verbatim** — it contains the exact directory path and download URL. Manual download (browser accessible in China): 1. Open: `https://modelscope.cn/models/pkufool/faster-whisper-base/files` 2. Download these 5 files: `config.json` `model.bin` `tokenizer.json` `vocabulary.json` `preprocessor_config.json` 3. Create the directory shown in the error message and place all 5 files there 4. Re-run the script — it auto-detects the local model, no download needed For other model sizes (tiny/small/medium/large), change `faster-whisper-base` to `faster-whisper-{size}` in the ModelScope URL. ### "ffmpeg not found" (during transcription) See ffmpeg install commands in the Dependencies section above. ### Video too long for Whisper Use a smaller model: ```bash export VIDEO_SUMMARY_WHISPER_MODEL=tiny ``` --- *Extract subtitles. Let the agent think.*

path: subtitle-extractor.py
content: #!/usr/bin/env python3
"""
subtitle-extractor.py — Subtitle Extractor
Extracts native subtitles or Whisper transcription from video platforms.
Outputs raw subtitle file path + basic metadata; agent handles naming/analysis (see SKILL.md).

Usage: python subtitle-extractor.py <url|file> [--lang <code>] [--transcribe] [--cookies <file>]
"""

import argparse
import io
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

# Force UTF-8 for stdout and stderr on all platforms.
# On Windows the default is GBK/cp936, which cannot encode many Unicode characters.
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')


# ANSI colors — auto-disabled when stderr is not a tty (e.g. captured by agent)
def _c(code):
    return f'\033[{code}m' if sys.stderr.isatty() else ''

RED    = _c('0;31'); GREEN  = _c('0;32'); YELLOW = _c('1;33')
BLUE   = _c('0;34'); CYAN   = _c('0;36'); NC     = _c('0')


def eprint(*args, **kwargs):
    """Print to stderr (status / error messages — not captured by agent)."""
    print(*args, file=sys.stderr, **kwargs)


# ---------------------------------------------------------------------------

def detect_platform(url):
    if re.search(r'youtube\.com|youtu\.be', url):         return 'youtube'
    if re.search(r'bilibili\.com|b23\.tv', url):          return 'bilibili'
    if re.search(r'xiaohongshu\.com|xhslink\.com', url):  return 'xiaohongshu'
    if re.search(r'douyin\.com', url):                    return 'douyin'
    if Path(url).is_file():                               return 'local'
    return 'unknown'


# ---------------------------------------------------------------------------

def find_bilibili_cookies(cookie_file):
    """
    If cookie_file is already a valid path, return it.
    Otherwise scan the skill directory for any .txt file whose name contains 'bilibili'.
    Returns absolute path string, or empty string if not found.
    """
    if cookie_file and Path(cookie_file).is_file():
        return str(Path(cookie_file).resolve())

    skill_dir = Path(__file__).parent.resolve()

    # Prefer exact name first, then scan all .txt files for 'bilibili' in the filename
    candidate = skill_dir / 'bilibili_cookies.txt'
    if candidate.is_file():
        eprint(f'{CYAN}找到 Bilibili Cookie: {candidate}{NC}')
        return str(candidate.resolve())

    for f in sorted(skill_dir.glob('*.txt')):
        if 'bilibili' in f.name.lower():
            eprint(f'{CYAN}找到 Bilibili Cookie: {f}{NC}')
            return str(f.resolve())

    return ''


# ---------------------------------------------------------------------------

def _check_faster_whisper():
    """Raise RuntimeError if faster-whisper is not importable."""
    try:
        import faster_whisper  # noqa: F401
    except ImportError:
        raise RuntimeError('faster-whisper 未安装。请运行: pip install faster-whisper')


def _fmt_srt_time(seconds):
    h  = int(seconds // 3600)
    m  = int((seconds % 3600) // 60)
    s  = int(seconds % 60)
    ms = int(round((seconds % 1) * 1000))
    return f'{h:02d}:{m:02d}:{s:02d},{ms:03d}'


def _segments_to_srt(segments):
    """Convert faster-whisper segment iterator to SRT format string."""
    lines = []
    for i, seg in enumerate(segments, 1):
        lines.append(str(i))
        lines.append(f'{_fmt_srt_time(seg.start)} --> {_fmt_srt_time(seg.end)}')
        lines.append(seg.text.strip())
        lines.append('')
    return '\n'.join(lines)


# ---------------------------------------------------------------------------

def get_metadata(url, cookie_file):
    """Fetch title and uploader via yt-dlp --print. Returns (title, author)."""
    cmd = ['yt-dlp']
    if cookie_file:
        cmd += ['--cookies', cookie_file]
    cmd += ['--print', '%(title)s', '--print', '%(uploader)s', '--skip-download', url]

    result = subprocess.run(cmd, capture_output=True, encoding='utf-8', errors='replace')
    lines = result.stdout.strip().splitlines()

    title  = lines[0].strip() if len(lines) > 0 else ''
    author = lines[1].strip() if len(lines) > 1 else ''

    return (title  if title  and title  != 'NA' else 'Unknown',
            author if author and author != 'NA' else 'Unknown')


# ---------------------------------------------------------------------------

def _find_sub_file(directory):
    """Return first subtitle file found in directory tree, or None."""
    for ext in ('*.srt', '*.vtt', '*.ass'):
        matches = list(Path(directory).rglob(ext))
        if matches:
            return matches[0]
    return None


def extract_bilibili_subtitles(url, lang, cookie_file):
    """
    Extract Bilibili subtitles with language fallback (zh-CN → ai-zh).
    Returns subtitle text, or empty string if not found.
    Raises RuntimeError on 412 (cookie expired).
    """
    base_cmd = ['yt-dlp']
    if cookie_file:
        base_cmd += ['--cookies', cookie_file]

    lang_priority = []
    if lang != 'auto':
        lang_priority.append(lang)
    lang_priority += ['zh-CN', 'ai-zh']

    for try_lang in lang_priority:
        eprint(f'{BLUE}尝试字幕语言: {try_lang}{NC}')

        with tempfile.TemporaryDirectory() as tmp_dir:
            result = subprocess.run(
                base_cmd + [
                    '--write-sub', '--write-auto-sub',
                    '--sub-lang', try_lang,
                    '--skip-download',
                    '-P', tmp_dir,
                    url,
                ],
                capture_output=True, encoding='utf-8', errors='replace'
            )

            if '412' in result.stderr or 'Precondition Failed' in result.stderr:
                raise RuntimeError(
                    'Bilibili 412 错误：Cookie 已过期。\n'
                    '请重新导出 → 命名为 bilibili_cookies.txt → 放于脚本目录或 ~/Downloads'
                )

            sub_file = _find_sub_file(tmp_dir)
            if sub_file:
                eprint(f'{GREEN}找到字幕: {try_lang}{NC}')
                return sub_file.read_text(encoding='utf-8', errors='replace')

    # List available subtitles for user reference
    eprint(f'{YELLOW}未找到中文字幕，可用字幕列表：{NC}')
    result = subprocess.run(
        base_cmd + ['--list-subs', '--skip-download', url],
        capture_output=True, encoding='utf-8', errors='replace'
    )
    for line in (result.stdout + result.stderr).splitlines():
        if not line.startswith('[debug]'):
            eprint(line)
    eprint(f'{YELLOW}请使用 --lang <语言代码> 指定字幕{NC}')
    return ''


def extract_subtitles(url, lang, cookie_file):
    """Generic subtitle extraction via yt-dlp (YouTube, etc.)."""
    cmd = ['yt-dlp']
    if cookie_file:
        cmd += ['--cookies', cookie_file]
    if lang != 'auto':
        cmd += ['--sub-lang', lang]

    eprint(f'{BLUE}正在提取字幕...{NC}')

    with tempfile.TemporaryDirectory() as tmp_dir:
        subprocess.run(
            cmd + ['--write-sub', '--write-auto-sub', '--skip-download', '-P', tmp_dir, url],
            capture_output=True, encoding='utf-8', errors='replace'
        )
        sub_file = _find_sub_file(tmp_dir)
        if sub_file:
            return sub_file.read_text(encoding='utf-8', errors='replace')

    eprint(f'{YELLOW}未找到字幕{NC}')
    return ''


# ---------------------------------------------------------------------------

def _resolve_model_path(model_size):
    """
    Resolve Whisper model path.
    Priority:
      1. VIDEO_SUMMARY_WHISPER_MODEL env var — if it points to an existing directory, use as local path
      2. {skill_dir}/models/whisper-{model_size}/ — local model placed next to the script
      3. Fall through to HuggingFace / mirror download
    Returns either an absolute path string (local) or the model_size string (for download).
    """
    env_val = os.environ.get('VIDEO_SUMMARY_WHISPER_MODEL', '')
    if env_val:
        p = Path(env_val)
        if p.is_dir() and (p / 'model.bin').is_file():
            eprint(f'{CYAN}使用本地模型（环境变量）: {p}{NC}')
            return str(p)
        if not p.is_dir():
            # treat as model size name, not a path
            return env_val

    skill_dir = Path(__file__).parent.resolve()
    local_model = skill_dir / 'models' / f'whisper-{model_size}'
    if local_model.is_dir() and (local_model / 'model.bin').is_file():
        eprint(f'{CYAN}使用本地模型: {local_model}{NC}')
        return str(local_model)

    return model_size  # fall back to download


def _download_via_modelscope(model_size, target_dir):
    """
    Try to download faster-whisper model from ModelScope (fastest in China).
    Returns True if model is ready at target_dir, False if modelscope is not installed.
    Raises RuntimeError on download failure.
    """
    try:
        from modelscope.hub.snapshot_download import snapshot_download  # noqa
    except ImportError:
        return False

    eprint(f'{BLUE}正在从 ModelScope 下载模型 faster-whisper-{model_size}（国内推荐，速度更快）...{NC}')
    target_dir.mkdir(parents=True, exist_ok=True)
    try:
        snapshot_download(
            f'pkufool/faster-whisper-{model_size}',
            local_dir=str(target_dir),
            ignore_file_pattern=['.gitattributes', 'README.md', '*.md'],
        )
        eprint(f'{GREEN}ModelScope 下载完成{NC}')
        return True
    except Exception as e:
        eprint(f'{YELLOW}ModelScope 下载失败: {e}，将尝试其他镜像...{NC}')
        return False


def _whisper_transcribe(audio_path, model_size):
    """Transcribe audio with faster-whisper. Returns SRT text."""
    from faster_whisper import WhisperModel

    model_path = _resolve_model_path(model_size)
    is_local = Path(model_path).is_dir()

    if is_local:
        eprint(f'{BLUE}正在加载本地 Whisper 模型: {model_path}（可能需要数秒）...{NC}')
        model = WhisperModel(model_path, device='cpu', compute_type='int8')
    else:
        skill_dir = Path(__file__).parent.resolve()
        local_model_dir = skill_dir / 'models' / f'whisper-{model_path}'

        # Force-enable HuggingFace Hub progress bars (disabled by default in non-tty)
        os.environ['HF_HUB_DISABLE_PROGRESS_BARS'] = '0'

        # Priority 1: ModelScope (most reliable in China, install: pip install modelscope)
        if _download_via_modelscope(model_path, local_model_dir):
            eprint(f'{BLUE}正在加载模型...{NC}')
            model = WhisperModel(str(local_model_dir), device='cpu', compute_type='int8')
        else:
            # Priority 2: HuggingFace mirrors
            mirrors = [
                'https://hf-mirror.com',
                'https://huggingface.co',
            ]
            model = None
            for mirror in mirrors:
                os.environ['HF_ENDPOINT'] = mirror
                try:
                    eprint(f'{BLUE}正在从 {mirror} 下载 Whisper 模型 ({model_path})，'
                           f'约 150MB，请耐心等待（如多次卡住建议 pip install modelscope）...{NC}')
                    model = WhisperModel(model_path, device='cpu', compute_type='int8')
                    eprint(f'{GREEN}模型加载完成{NC}')
                    break
                except Exception as e:
                    err_str = str(e)
                    if ('ConnectError' in err_str or 'LocalEntryNotFoundError' in type(e).__name__
                            or 'ConnectionError' in err_str or 'WinError 10054' in err_str
                            or 'RemoteDisconnected' in err_str):
                        eprint(f'{YELLOW}镜像 {mirror} 连接失败，尝试下一个...{NC}')
                        continue
                    raise  # non-network error, re-raise immediately

            if model is None:
                raise RuntimeError(
                    f'Whisper 模型下载失败（所有网络镜像均无法连接）。\n\n'
                    f'推荐方案（国内最快）：\n'
                    f'  pip install modelscope\n'
                    f'  然后重新运行脚本，将自动从 ModelScope 下载\n\n'
                    f'或手动下载后放置到：\n'
                    f'  {local_model_dir}\n\n'
                    f'手动下载步骤：\n'
                    f'  1. 用浏览器打开（国内可访问）：\n'
                    f'     https://modelscope.cn/models/pkufool/faster-whisper-{model_path}/files\n'
                    f'  2. 下载以下 5 个文件：\n'
                    f'     config.json  model.bin  tokenizer.json\n'
                    f'     vocabulary.json  preprocessor_config.json\n'
                    f'  3. 创建目录并放入：\n'
                    f'     {local_model_dir}\\\n'
                    f'  4. 重新运行脚本'
                )

    eprint(f'{BLUE}正在转写音频，CPU 模式下请耐心等待（base 模型 3 分钟视频约需 10~20 秒）...{NC}')
    segments, info = model.transcribe(audio_path)
    eprint(f'{BLUE}检测到语言: {info.language}，开始生成字幕...{NC}')
    srt = _segments_to_srt(segments)
    eprint(f'{GREEN}转写完成{NC}')
    return srt


def transcribe_video(url, cookie_file, model):
    """Download audio from URL and transcribe with faster-whisper."""
    cmd = ['yt-dlp']
    if cookie_file:
        cmd += ['--cookies', cookie_file]

    eprint(f'{BLUE}正在下载音频（yt-dlp），下载进度如下：{NC}')

    with tempfile.TemporaryDirectory() as tmp_dir:
        audio_template = str(Path(tmp_dir) / 'audio.%(ext)s')
        # Stream yt-dlp stderr so download progress is visible to the agent
        result = subprocess.run(
            cmd + ['-x', '--audio-format', 'mp3', '-o', audio_template, url],
            stdout=subprocess.PIPE,
            stderr=sys.stderr,
            encoding='utf-8', errors='replace'
        )
        if result.returncode != 0:
            raise RuntimeError('yt-dlp 音频下载失败（见上方错误输出）')

        audio_files = list(Path(tmp_dir).glob('audio.*'))
        if not audio_files:
            raise RuntimeError('yt-dlp 未生成音频文件')

        eprint(f'{GREEN}音频下载完成，开始 Whisper 转写（模型: {model}）...{NC}')
        return _whisper_transcribe(str(audio_files[0]), model)


def transcribe_local(file_path, model):
    """Transcribe a local video/audio file with faster-whisper."""
    eprint(f'{BLUE}正在转写本地文件（模型: {model}）...{NC}')
    return _whisper_transcribe(file_path, model)


# ---------------------------------------------------------------------------
# Step handlers
# ---------------------------------------------------------------------------

def run_download_audio(args, model):
    """
    --step download-audio
    Downloads audio from a URL to a persistent temp file.
    Stdout: {"audio_file": "...", "title": "...", "author": "...", "platform": "..."}
    """
    url      = args.input
    platform = detect_platform(url)
    eprint(f'{GREEN}Platform: {platform}{NC}')

    if platform == 'local':
        eprint(f'{RED}Error: --step download-audio 不支持本地文件，请直接使用 --step transcribe{NC}')
        sys.exit(1)

    # --- Cookie setup ---
    cookie_file = args.cookies
    if platform == 'bilibili':
        cookie_file = find_bilibili_cookies(cookie_file)
        if not cookie_file:
            eprint(f'{RED}Error: 未找到 Bilibili Cookie 文件{NC}')
            eprint(f'{YELLOW}请导出 Cookie 并命名为 bilibili_cookies.txt，放于脚本目录{NC}')
            sys.exit(1)

    # --- Metadata ---
    title, author = get_metadata(url, cookie_file)

    # --- Download audio to persistent temp file ---
    pid            = os.getpid()
    audio_template = str(Path(tempfile.gettempdir()) / f'video-summary-audio-{pid}.%(ext)s')

    cmd = ['yt-dlp']
    if cookie_file:
        cmd += ['--cookies', cookie_file]
    cmd += ['-x', '--audio-format', 'mp3', '-o', audio_template, url]

    eprint(f'{BLUE}正在下载音频（yt-dlp），下载进度如下：{NC}')
    result = subprocess.run(
        cmd,
        stdout=subprocess.PIPE,
        stderr=sys.stderr,
        encoding='utf-8', errors='replace'
    )
    if result.returncode != 0:
        eprint(f'{RED}Error: yt-dlp 音频下载失败（见上方错误输出）{NC}')
        sys.exit(1)

    audio_files = list(Path(tempfile.gettempdir()).glob(f'video-summary-audio-{pid}.*'))
    if not audio_files:
        eprint(f'{RED}Error: yt-dlp 未生成音频文件{NC}')
        sys.exit(1)

    output = {
        'audio_file': str(audio_files[0]),
        'title':      title,
        'author':     author,
        'platform':   platform,
    }
    print(json.dumps(output, ensure_ascii=True))
    eprint(f'{GREEN}音频下载完成{NC}')


def run_transcribe(args, model):
    """
    --step transcribe
    Transcribes a local audio/video file with faster-whisper.
    Input: local file path (URL_OR_FILE positional arg).
    Stdout: {"subtitle_file": "..."}
    """
    audio_path = args.input

    if not Path(audio_path).is_file():
        eprint(f'{RED}Error: 文件不存在: {audio_path}{NC}')
        sys.exit(1)

    try:
        _check_faster_whisper()
        srt = _whisper_transcribe(audio_path, model)
    except RuntimeError as e:
        eprint(f'{RED}Error: {e}{NC}')
        sys.exit(1)

    sub_path = Path(tempfile.gettempdir()) / f'video-summary-{os.getpid()}.srt'
    sub_path.write_text(srt, encoding='utf-8')

    output = {'subtitle_file': str(sub_path)}
    print(json.dumps(output, ensure_ascii=True))
    eprint(f'{GREEN}转写完成{NC}')


def run_native_subtitle(args, model):
    """
    Default path (no --step): extract native subtitles via yt-dlp.
    Falls back to Whisper if no subtitles found.
    Stdout: {"title": "...", "author": "...", "platform": "...", "subtitle_file": "..."}
    """
    url      = args.input
    lang     = args.lang
    platform = detect_platform(url)
    eprint(f'{GREEN}Platform: {platform}{NC}')

    # --- Cookie setup ---
    cookie_file = args.cookies
    if platform == 'bilibili':
        cookie_file = find_bilibili_cookies(cookie_file)
        if not cookie_file:
            eprint(f'{RED}Error: 未找到 Bilibili Cookie 文件{NC}')
            eprint(f'{YELLOW}请导出 Cookie 并命名为 bilibili_cookies.txt，放于脚本目录{NC}')
            sys.exit(1)

    # --- Metadata ---
    if platform != 'local':
        title, author = get_metadata(url, cookie_file)
    else:
        title  = Path(url).name
        author = 'local'

    # --- Extract subtitles ---
    transcript = ''
    try:
        if platform in ('xiaohongshu', 'douyin', 'local'):
            _check_faster_whisper()
            if platform == 'local':
                transcript = transcribe_local(url, model)
            else:
                transcript = transcribe_video(url, cookie_file, model)

        elif platform == 'bilibili':
            transcript = extract_bilibili_subtitles(url, lang, cookie_file)
            if not transcript:
                eprint(f'{YELLOW}字幕提取失败，回退到 Whisper 转写...{NC}')
                _check_faster_whisper()
                transcript = transcribe_video(url, cookie_file, model)

        else:
            transcript = extract_subtitles(url, lang, cookie_file)
            if not transcript:
                eprint(f'{YELLOW}未找到字幕，回退到 Whisper 转写...{NC}')
                _check_faster_whisper()
                transcript = transcribe_video(url, cookie_file, model)

    except RuntimeError as e:
        eprint(f'{RED}Error: {e}{NC}')
        sys.exit(1)

    if not transcript:
        eprint(f'{RED}Error: 无法提取字幕{NC}')
        sys.exit(1)

    # --- Detect subtitle format ---
    first_line = transcript.splitlines()[0] if transcript.splitlines() else ''
    sub_ext = 'vtt' if first_line.startswith('WEBVTT') else 'srt'

    sub_path = Path(tempfile.gettempdir()) / f'video-summary-{os.getpid()}.{sub_ext}'
    sub_path.write_text(transcript, encoding='utf-8')

    output = {
        'title':         title,
        'author':        author,
        'platform':      platform,
        'subtitle_file': str(sub_path),
    }
    print(json.dumps(output, ensure_ascii=True))
    eprint(f'{GREEN}Done!{NC}')


# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(
        prog='subtitle-extractor',
        description='Subtitle extractor for Bilibili, YouTube, Xiaohongshu, Douyin, local files.'
    )
    parser.add_argument('input', metavar='URL_OR_FILE', help='Video URL or local file path')
    parser.add_argument('--step',    choices=['download-audio', 'transcribe'],
                        help='Run a single pipeline step (see SKILL.md)')
    parser.add_argument('--lang',    default='auto',
                        help='Subtitle language code, default path only (default: auto)')
    parser.add_argument('--cookies', default=os.environ.get('VIDEO_SUMMARY_COOKIES', ''),
                        help='Cookie file path (or set VIDEO_SUMMARY_COOKIES env var)')
    args = parser.parse_args()

    model = os.environ.get('VIDEO_SUMMARY_WHISPER_MODEL', 'base')

    if args.step == 'download-audio':
        run_download_audio(args, model)
    elif args.step == 'transcribe':
        run_transcribe(args, model)
    else:
        run_native_subtitle(args, model)


if __name__ == '__main__':
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)