
Bilibili Subtitle
- 1.4k installs
- 14 repo stars
- Updated June 9, 2026
- hamsterider-m/bilibili-subtitle
bilibili-subtitle is an agent skill that 从 bilibili 视频提取字幕、转录无字幕视频、生成结构化摘要。触发条件:bilibili url (bilibili.com)、bv id (bv1xxx)、或"提取b站字幕"等请求。.
About
bilibili-subtitle is an agent skill from hamsterider-m/bilibili-subtitle that 从 bilibili 视频提取字幕、转录无字幕视频、生成结构化摘要。触发条件:bilibili url (bilibili.com)、bv id (bv1xxx)、或"提取b站字幕"等请求。. # Bilibili 字幕提取工具 从 Bilibili 视频提取字幕,支持 AI 字幕检测和 ASR 转录回退。 ## Quick Reference | 任务 | 命令 | |------|------| | 前置检查 | `pixi run python -m bilibili_subtitle --check` | | 基本提取 | `pixi run python -m bilibili_subtitle "BV1234567890"` | | 快速模式 | `pixi run python -m bilibili_subtitle "URL" --skip-proofread --skip-summary` | | JSON 输出 | `pixi run python -m Developers invoke bilibili-subtitle during build/integrations work for generative media tasks. The skill documents triggers, prerequisites, and step-by-step workflows grounded in SKILL.md. Compatible with Claude Code, Cursor, and Codex agent runtimes that load marketplace skills. Review the Security Audits panel on this listing before installing in production environments. Category Generative Media with ai vertical focus supports repeatable agent-guided delivery.
- 从 Bilibili 视频提取字幕,支持 AI 字幕检测和 ASR 转录回退。
- | 前置检查 | `pixi run python -m bilibili_subtitle --check` |
- | 基本提取 | `pixi run python -m bilibili_subtitle "BV1234567890"` |
- | 快速模式 | `pixi run python -m bilibili_subtitle "URL" --skip-proofread --skip-summary` |
- | JSON 输出 | `pixi run python -m bilibili_subtitle "URL" --json-output` |
Bilibili Subtitle by the numbers
- 1,355 all-time installs (skills.sh)
- Ranked #196 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
bilibili-subtitle capabilities & compatibility
- Capabilities
- 从 bilibili 视频提取字幕,支持 ai 字幕检测和 asr 转录回退。 · | 前置检查 | `pixi run python m bilibili_subtitle · | 基本提取 | `pixi run python m bilibili_subtitle " · | 快速模式 | `pixi run python m bilibili_subtitle " · | json 输出 | `pixi run python m bilibili_subtitl
- Use cases
- orchestration
What bilibili-subtitle says it does
┌─────────────────────────────────────────────────────────┐
│ Layer 0: Preflight Check (前置检查) │
│ --check → 验证 BBDown/API Keys/登录状态 │
npx skills add https://github.com/hamsterider-m/bilibili-subtitle --skill bilibili-subtitleAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.4k |
|---|---|
| repo stars | ★ 14 |
| Security audit | 1 / 3 scanners passed |
| Last updated | June 9, 2026 |
| Repository | hamsterider-m/bilibili-subtitle ↗ |
What it does
从 Bilibili 视频提取字幕、转录无字幕视频、生成结构化摘要。触发条件:Bilibili URL (bilibili.com)、BV ID (BV1xxx)、或"提取B站字幕"等请求。
Who is it for?
Developers working on generative media during build tasks.
Skip if: Tasks outside Generative Media scope described in SKILL.md.
When should I use this skill?
从 Bilibili 视频提取字幕、转录无字幕视频、生成结构化摘要。触发条件:Bilibili URL (bilibili.com)、BV ID (BV1xxx)、或"提取B站字幕"等请求。
What you get
Completed generative media workflow aligned with SKILL.md steps.
- SRT subtitle file
- Markdown transcript
- JSON structured output
By the numbers
- Supports 4 primary output formats: SRT, VTT, Markdown transcript, and JSON
- Uses BBDown, ffmpeg, and pixi-managed Python dependencies
Files
Bilibili 字幕提取工具
从 Bilibili 视频提取字幕,支持 AI 字幕检测和 ASR 转录回退。
Quick Reference
| 任务 | 命令 |
|---|---|
| 前置检查 | pixi run python -m bilibili_subtitle --check |
| 基本提取 | pixi run python -m bilibili_subtitle "BV1234567890" |
| 快速模式 | pixi run python -m bilibili_subtitle "URL" --skip-proofread --skip-summary |
| JSON 输出 | pixi run python -m bilibili_subtitle "URL" --json-output |
| 双语输出 | pixi run python -m bilibili_subtitle "URL" --output-lang zh+en |
执行层级
┌─────────────────────────────────────────────────────────┐
│ Layer 0: Preflight Check (前置检查) │
│ --check → 验证 BBDown/API Keys/登录状态 │
├─────────────────────────────────────────────────────────┤
│ Layer 1: Video Detection (视频检测) │
│ URL → BBDown → 字幕存在性检测 │
├─────────────────────────────────────────────────────────┤
│ Layer 2: Content Extraction (内容提取) │
│ 有字幕 → 加载 SRT → 校对 │
│ 无字幕 → 下载音频 → ASR 转录 → 校对 │
├─────────────────────────────────────────────────────────┤
│ Layer 3: Enhancement (增强处理) │
│ 校对(ANTHROPIC_API_KEY) → 摘要(ANTHROPIC_API_KEY) │
└─────────────────────────────────────────────────────────┘前置条件检查
必须先运行,验证环境配置:
pixi run python -m bilibili_subtitle --check输出示例:
✅ BBDown: Installed (1.6.3)
✅ BBDown Auth: Logged in
✅ ffmpeg: Installed
⚠️ ANTHROPIC_API_KEY: Not set (required for proofreading)
⚠️ DASHSCOPE_API_KEY: Not set (required for ASR)
✅ Python Dependencies: All installedJSON 格式输出(便于父 skill 解析):
pixi run python -m bilibili_subtitle --check --check-json错误分级
| Code | Level | 说明 | 修复建议 |
|---|---|---|---|
| E001 | FATAL | BBDown 未安装 | ./install.sh |
| E002 | FATAL | BBDown 未登录 | BBDown login |
| E003 | RECOVERABLE | 下载失败 | 检查 URL/网络 |
| E004 | RECOVERABLE | 无字幕 | 自动触发 ASR |
| E005 | FATAL | ASR 未配置 | 设置 DASHSCOPE_API_KEY |
| E006 | RECOVERABLE | AI 功能未配置 | 使用 --skip-* 或设置 API Key |
| E007 | FATAL | ffmpeg 未安装 | pixi install |
| E008 | FATAL | URL 无效 | 提供正确的 BV/URL |
| E010 | FATAL | 视频不存在 | 检查视频是否删除/私有 |
退出码:
0- 成功1- 致命错误(FATAL)2- 可恢复错误(RECOVERABLE,部分完成)3- 部分成功(有警告)
作为子 Skill 的调用契约
标准调用
pixi run python -m bilibili_subtitle "<URL>" \
-o /tmp/bilibili_output \
--skip-summary \
--json-output成功判定
1. 退出码为 0 或 2 2. 输出目录存在 *.transcript.md
JSON 输出结构
{
"exit_code": 0,
"success": true,
"output": {
"video_id": "BV1xxx",
"title": "视频标题",
"files": {
"transcript": "/path/to/xxx.transcript.md",
"srt": "/path/to/xxx.srt",
"vtt": "/path/to/xxx.vtt",
"summary_json": null
}
},
"warnings": [],
"errors": []
}父 Skill 集成示例
from bilibili_subtitle import build_cli_command, ExitCode
cmd = build_cli_command(
"BV1xxx",
output_dir="/tmp/output",
skip_proofread=True,
skip_summary=True,
)
# ['pixi', 'run', 'python', '-m', 'bilibili_subtitle', 'BV1xxx', ...]输出文件
output/
├── {title}.srt # SRT 字幕
├── {title}.vtt # VTT 字幕
├── {title}.transcript.md # Markdown 逐字稿
├── {title}.summary.json # 结构化摘要 (可选)
└── {title}.summary.md # 摘要 Markdown (可选)API Keys
| Key | Provider | 用途 | 必需性 |
|---|---|---|---|
ANTHROPIC_API_KEY | Anthropic | 校对/摘要 | 推荐 |
DASHSCOPE_API_KEY | 阿里云 | ASR 转录 | 无字幕时必需 |
安装
cd ~/.agents/skills/bilibili-subtitle
./install.sh
BBDown login # 扫码登录详细文档
- references/preflight.md - 前置检查详解
- references/errors.md - 错误处理指南
- references/contract.md - 子 Skill 契约
- references/agents.md - AI Agent 配置
---
版本: v0.2.0
.venv/
.pixi/
pixi.lock
.cache/
output/
*.egg-info/
**/__pycache__/
.pytest_cache/
*.pyc
*.egg-info/
"""
Bilibili Subtitle Extraction Skill.
Extract subtitles from Bilibili videos with ASR fallback for videos without subtitles.
"""
from __future__ import annotations
__version__ = "0.1.0"
__all__ = [
"BBDownClient",
"BBDownError",
"VideoInfo",
"SubtitleInfo",
"detect_subtitles",
"VideoMetadata",
"parse_bilibili_ref",
"VideoRef",
"SkillError",
"ErrorLevel",
"Remediation",
"PreflightReport",
"run_preflight",
"ExecutionResult",
"SubtitleOutput",
"ExitCode",
"build_cli_command",
]
from .bbdown_client import BBDownClient, BBDownError, SubtitleInfo, VideoInfo
from .contract import (
ExitCode,
ExecutionResult,
SubtitleOutput,
build_cli_command,
)
from .detector import VideoMetadata, detect_subtitles
from .errors import ErrorLevel, Remediation, SkillError
from .preflight import PreflightReport, run_preflight
from .url_parser import VideoRef, parse_bilibili_ref
"""
CLI entry point for bilibili-subtitle skill.
Usage:
pixi run python -m bilibili_subtitle "BV1234567890"
pixi run python -m bilibili_subtitle --check
pixi run python -m bilibili_subtitle "URL" --skip-proofread --skip-summary
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import re
from .contract import ExitCode, ExecutionResult, SubtitleOutput
from .errors import (
ASRConfigError,
BBDownAuthError,
BBDownDownloadError,
FFmpegNotFoundError,
InvalidURLError,
NoSubtitleError,
SkillError,
VideoNotFoundError,
exit_code_for_error,
)
from .preflight import run_preflight
from .url_parser import parse_bilibili_ref
_WINDOWS_ILLEGAL_RE = re.compile(r'[/\\:*?"<>|]')
_CONTROL_CHAR_RE = re.compile(r'[\x00-\x1f]')
def _sanitize_filename(name: str) -> str:
"""Remove Windows-illegal characters and control chars from a filename."""
name = _CONTROL_CHAR_RE.sub("", name)
name = _WINDOWS_ILLEGAL_RE.sub("_", name)
name = name.strip().strip("_").strip()
return name or "untitled"
def create_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="bilibili_subtitle",
description="Extract Bilibili subtitles with ASR fallback",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s "BV1234567890"
%(prog)s "https://www.bilibili.com/video/BV1234567890"
%(prog)s "BV1234567890" --skip-proofread --skip-summary
%(prog)s --check
""",
)
parser.add_argument("url", nargs="?", help="Bilibili URL or BV ID")
parser.add_argument(
"-o", "--output-dir", default="./output", help="Output directory"
)
parser.add_argument("--output-lang", choices=["zh", "en", "zh+en"], default="zh")
parser.add_argument(
"--skip-proofread", action="store_true", help="Skip AI proofreading"
)
parser.add_argument(
"--skip-summary", action="store_true", help="Skip AI summarization"
)
parser.add_argument("--cache-dir", default="./.cache", help="Cache directory")
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
parser.add_argument("--check", action="store_true", help="Run preflight checks")
parser.add_argument("--check-json", action="store_true", help="Preflight as JSON")
parser.add_argument(
"--skip-auth-check", action="store_true", help="Skip auth check"
)
parser.add_argument("--json-output", action="store_true", help="Output as JSON")
parser.add_argument("--version", action="version", version="%(prog)s 0.2.0")
return parser
def run_extraction(
url: str,
output_dir: Path,
*,
output_lang: str = "zh",
skip_proofread: bool = False,
skip_summary: bool = False,
cache_dir: Path = Path("./.cache"),
verbose: bool = False,
) -> ExecutionResult:
warnings: list[str] = []
errors: list[dict] = []
try:
ref = parse_bilibili_ref(url)
video_id = ref.video_id or "unknown"
canonical_url = ref.canonical_url or ref.input_value
except Exception:
raise InvalidURLError(url)
cache_dir.mkdir(parents=True, exist_ok=True)
output_dir.mkdir(parents=True, exist_ok=True)
from .bbdown_client import BBDownClient
from .segment import Segment
from .subtitle_loader import load_segments_from_subtitle_file, check_title_relevance
client = BBDownClient()
if verbose:
print(f"[INFO] Processing: {video_id}")
print(f"[INFO] Output directory: {output_dir}")
try:
info = client.get_video_info(canonical_url, cache_dir)
except Exception as e:
if "login" in str(e).lower() or "auth" in str(e).lower():
raise BBDownAuthError(str(e))
if "not found" in str(e).lower() or "不存在" in str(e).lower():
raise VideoNotFoundError(video_id or "unknown")
raise BBDownDownloadError(canonical_url, str(e))
if verbose:
print(f"[INFO] Title: {info.title}")
print(f"[INFO] Has subtitle: {info.subtitle_info.has_subtitle}")
segments: list[Segment] = []
if info.subtitle_files:
max_crosstalk_retries = 2
for attempt in range(max_crosstalk_retries + 1):
sub_file = info.subtitle_files[0]
if verbose:
print(f"[INFO] Loading subtitle: {sub_file.name}")
load_result = load_segments_from_subtitle_file(sub_file, title=info.title)
segments = load_result.segments
if load_result.relevant:
break
# Crosstalk detected — subtitle may belong to a different video
if attempt < max_crosstalk_retries:
warnings.append(
f"Crosstalk suspected (attempt {attempt + 1}), re-downloading..."
)
if verbose:
print(f"[WARN] Subtitle may not match title, retrying ({attempt + 1}/{max_crosstalk_retries})")
# Delete stale file and re-fetch
sub_file.unlink(missing_ok=True)
try:
info = client.get_video_info(canonical_url, cache_dir)
except Exception:
break # Can't retry, use what we have
if not info.subtitle_files:
break
else:
warnings.append(
"Subtitle content may not match video title (crosstalk); proceeding anyway"
)
elif not info.subtitle_info.has_subtitle:
warnings.append("No subtitles found, attempting ASR transcription")
import shutil
if not shutil.which("ffmpeg"):
raise FFmpegNotFoundError()
from .audio_extractor import extract_audio
from .agents.transcribe_agent import TranscribeAgent
try:
audio_path = extract_audio(canonical_url, cache_dir)
if verbose:
print(f"[INFO] Audio extracted: {audio_path}")
transcriber = TranscribeAgent(mode="qwen")
result = transcriber.transcribe(str(audio_path))
segments = result.segments
if audio_path.exists():
audio_path.unlink()
except Exception as e:
if "DASHSCOPE_API_KEY" in str(e):
raise ASRConfigError()
raise
if not segments:
raise NoSubtitleError(video_id)
if not skip_proofread:
import os
if not os.environ.get("ANTHROPIC_API_KEY"):
warnings.append("ANTHROPIC_API_KEY not set, skipping proofreading")
else:
if verbose:
print("[INFO] Proofreading...")
from .agents.proofread_agent import ProofreadAgent
proofer = ProofreadAgent()
try:
segments = proofer.proofread_segments(segments)
except Exception as e:
warnings.append(f"Proofreading failed: {e}")
from .renderers.srt import render_srt
from .renderers.vtt import render_vtt
from .renderers.markdown import render_transcript_markdown
srt_content = render_srt(segments)
vtt_content = render_vtt(segments)
md_content = render_transcript_markdown(segments, title=info.title)
lang_suffix = "" if output_lang == "zh" else f".{output_lang}"
safe_title = _sanitize_filename(info.title or video_id)
srt_path = output_dir / f"{safe_title}{lang_suffix}.srt"
vtt_path = output_dir / f"{safe_title}{lang_suffix}.vtt"
md_path = output_dir / f"{safe_title}.transcript.md"
srt_path.write_text(srt_content, encoding="utf-8")
vtt_path.write_text(vtt_content, encoding="utf-8")
md_path.write_text(md_content, encoding="utf-8")
if verbose:
print(f"[INFO] Generated: {srt_path.name}")
print(f"[INFO] Generated: {md_path.name}")
summary_json_path = None
summary_md_path = None
if not skip_summary:
import os
if not os.environ.get("ANTHROPIC_API_KEY"):
warnings.append("ANTHROPIC_API_KEY not set, skipping summarization")
else:
if verbose:
print("[INFO] Summarizing...")
from .agents.summarize_agent import SummarizeAgent
summarizer = SummarizeAgent()
try:
result = summarizer.summarize(segments, title=info.title)
summary_json_path = output_dir / f"{safe_title}.summary.json"
summary_md_path = output_dir / f"{safe_title}.summary.md"
summary_json_path.write_text(
json.dumps(result.summary, ensure_ascii=False, indent=2),
encoding="utf-8",
)
summary_md_path.write_text(result.raw_text or "", encoding="utf-8")
except Exception as e:
warnings.append(f"Summarization failed: {e}")
output = SubtitleOutput(
video_id=video_id,
title=info.title,
transcript_md=md_path,
srt_file=srt_path,
vtt_file=vtt_path,
summary_json=summary_json_path,
summary_md=summary_md_path,
)
return ExecutionResult(
exit_code=ExitCode.SUCCESS if not errors else ExitCode.PARTIAL_SUCCESS,
output=output,
errors=errors,
warnings=warnings,
metadata={"url": canonical_url},
)
def main() -> int:
parser = create_parser()
args = parser.parse_args()
if args.check or args.check_json:
report = run_preflight(include_auth=not args.skip_auth_check)
if args.check_json:
print(report.to_json())
else:
report.print_report()
return 0 if report.can_proceed else 1
if not args.url:
parser.error("URL is required (unless using --check)")
output_dir = Path(args.output_dir)
cache_dir = Path(args.cache_dir)
try:
result = run_extraction(
args.url,
output_dir,
output_lang=args.output_lang,
skip_proofread=args.skip_proofread,
skip_summary=args.skip_summary,
cache_dir=cache_dir,
verbose=args.verbose,
)
if args.json_output:
print(result.to_json())
else:
if result.output:
print(
f"✅ {result.output.video_id}: {result.output.title or 'No title'}"
)
if result.output.transcript_md:
print(f" Transcript: {result.output.transcript_md}")
for w in result.warnings:
print(f"⚠️ {w}")
return result.exit_code.value
except SkillError as e:
if args.json_output:
print(
json.dumps(
{
"exit_code": exit_code_for_error(e),
"error": e.to_json(),
},
indent=2,
)
)
else:
print(str(e), file=sys.stderr)
return exit_code_for_error(e)
except Exception as e:
if args.json_output:
print(
json.dumps(
{
"exit_code": 1,
"error": {"code": "E999", "message": str(e)},
},
indent=2,
)
)
else:
print(f"❌ Unexpected error: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Any, Literal
from ..segment import Segment
Mode = Literal["noop", "anthropic"]
def diff_segments(before: list[Segment], after: list[Segment]) -> list[dict[str, Any]]:
if len(before) != len(after):
raise ValueError("diff_segments requires equal-length segment lists.")
changes: list[dict[str, Any]] = []
for i, (b, a) in enumerate(zip(before, after, strict=True)):
if (b.start_ms, b.end_ms) != (a.start_ms, a.end_ms):
raise ValueError("diff_segments requires timestamps to be unchanged.")
if b.text != a.text:
changes.append({"index": i, "before": b.text, "after": a.text})
return changes
def _extract_json_array(text: str) -> Any:
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Tolerate code fences / extra prose.
start = text.find("[")
end = text.rfind("]")
if start == -1 or end == -1 or end <= start:
raise ValueError("Model output did not contain a JSON array.")
return json.loads(text[start : end + 1])
@dataclass(frozen=True, slots=True)
class ProofreadResult:
segments: list[Segment]
changes: list[dict[str, Any]]
class ProofreadAgent:
def __init__(
self,
*,
mode: Mode = "anthropic",
model: str = "claude-3-5-sonnet-latest",
api_key: str | None = None,
) -> None:
self._mode = mode
self._model = model
self._api_key = api_key
def proofread_segments(self, segments: list[Segment]) -> list[Segment]:
return self.proofread(segments).segments
def proofread(self, segments: list[Segment]) -> ProofreadResult:
if self._mode == "noop":
return ProofreadResult(segments=segments, changes=[])
api_key = self._api_key or os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise RuntimeError("Missing ANTHROPIC_API_KEY (or pass api_key=...).")
try:
from anthropic import Anthropic # type: ignore[import-not-found]
except Exception as e: # pragma: no cover
raise RuntimeError("anthropic package is required for proofreading. Install: pip install anthropic") from e
client = Anthropic(api_key=api_key)
payload = [
{"index": i, "start_ms": s.start_ms, "end_ms": s.end_ms, "text": s.text}
for i, s in enumerate(segments)
]
system = (
"You are a subtitle proofreader for Chinese content.\n"
"Rules:\n"
"- Do NOT change start_ms/end_ms.\n"
"- Only fix typos, punctuation, spacing, and obvious ASR errors.\n"
"- Keep proper nouns consistent.\n"
"- Do NOT add or remove segments.\n"
"- Output ONLY a JSON array, each item: {\"index\": number, \"text\": string}.\n"
)
user = (
"Proofread these subtitle segments. Return corrected text per index as JSON array.\n\n"
f"{json.dumps(payload, ensure_ascii=False)}"
)
msg = client.messages.create(
model=self._model,
max_tokens=4096,
system=system,
messages=[{"role": "user", "content": user}],
)
content = ""
for block in msg.content:
if getattr(block, "type", None) == "text":
content += block.text
items = _extract_json_array(content)
if not isinstance(items, list):
raise ValueError("Model output was not a JSON array.")
corrected_text_by_index: dict[int, str] = {}
for item in items:
if not isinstance(item, dict):
continue
idx = item.get("index")
text = item.get("text")
if isinstance(idx, int) and isinstance(text, str):
corrected_text_by_index[idx] = text
out: list[Segment] = []
for i, seg in enumerate(segments):
text = corrected_text_by_index.get(i, seg.text)
out.append(Segment(start_ms=seg.start_ms, end_ms=seg.end_ms, text=text))
return ProofreadResult(segments=out, changes=diff_segments(segments, out))
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from ..segment import Segment
Mode = Literal["noop", "anthropic"]
def default_summary() -> dict[str, Any]:
return {
"key_points": [],
"outline": [],
"entities": [],
"timestamps": [],
}
def load_summary_schema(schema_path: str | Path = Path("schemas/summary_schema.json")) -> dict[str, Any]:
data = Path(schema_path).read_text(encoding="utf-8")
return json.loads(data)
@dataclass(frozen=True, slots=True)
class SummarizeResult:
summary: dict[str, Any]
raw_text: str | None = None
class SummarizeAgent:
def __init__(
self,
*,
mode: Mode = "anthropic",
model: str = "claude-3-5-sonnet-latest",
api_key: str | None = None,
) -> None:
self._mode = mode
self._model = model
self._api_key = api_key
def summarize(self, segments: list[Segment], *, title: str | None = None) -> SummarizeResult:
if self._mode == "noop":
return SummarizeResult(summary=default_summary(), raw_text=None)
api_key = self._api_key or os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise RuntimeError("Missing ANTHROPIC_API_KEY (or pass api_key=...).")
try:
from anthropic import Anthropic # type: ignore[import-not-found]
except Exception as e: # pragma: no cover
raise RuntimeError("anthropic package is required for summarization. Install: pip install anthropic") from e
client = Anthropic(api_key=api_key)
schema = load_summary_schema()
transcript = [
{"index": i, "start_ms": s.start_ms, "end_ms": s.end_ms, "text": s.text}
for i, s in enumerate(segments)
]
system = (
"You summarize transcripts into a structured JSON object.\n"
"Return ONLY valid JSON matching the provided JSON Schema.\n"
"Include timestamp references using {start_ms,end_ms,segment_indices[]}.\n"
)
user = {
"title": title,
"schema": schema,
"transcript": transcript,
}
msg = client.messages.create(
model=self._model,
max_tokens=4096,
system=system,
messages=[{"role": "user", "content": json.dumps(user, ensure_ascii=False)}],
)
content = ""
for block in msg.content:
if getattr(block, "type", None) == "text":
content += block.text
text = content.strip()
try:
summary = json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or end <= start:
raise ValueError("Model output did not contain a JSON object.")
summary = json.loads(text[start : end + 1])
if not isinstance(summary, dict):
raise ValueError("Model output was not a JSON object.")
return SummarizeResult(summary=summary, raw_text=text)
from __future__ import annotations
import os
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from ..segment import Segment
Mode = Literal["noop", "openai", "qwen"]
@dataclass(frozen=True, slots=True)
class TranscribeResult:
segments: list[Segment]
raw: Any | None = None
class TranscribeAgent:
def __init__(
self,
*,
mode: Mode = "qwen",
model: str = "qwen3-asr-flash",
api_key: str | None = None,
) -> None:
self._mode = mode
self._model = model
self._api_key = api_key
def transcribe(self, audio_path: str) -> TranscribeResult:
if self._mode == "noop":
return TranscribeResult(segments=[], raw=None)
elif self._mode == "qwen":
return self._transcribe_qwen(audio_path)
else:
return self._transcribe_openai(audio_path)
def _transcribe_qwen(self, audio_path: str) -> TranscribeResult:
import dashscope
api_key = self._api_key or os.environ.get("DASHSCOPE_API_KEY")
if not api_key:
raise RuntimeError("Missing DASHSCOPE_API_KEY")
dashscope.api_key = api_key
# Convert to wav if needed
wav_path = self._ensure_wav(audio_path)
try:
text = self._call_asr(wav_path)
finally:
if wav_path != audio_path and Path(wav_path).exists():
Path(wav_path).unlink()
# Create a single segment for the audio
if text.strip():
from ..chunker import probe_duration_ms
duration_ms = probe_duration_ms(audio_path)
segments = [Segment(start_ms=0, end_ms=duration_ms, text=text.strip())]
else:
segments = []
return TranscribeResult(segments=segments, raw={"text": text})
def _ensure_wav(self, audio_path: str) -> str:
"""Convert audio to wav format if needed."""
path = Path(audio_path)
if path.suffix.lower() == ".wav":
return audio_path
wav_path = path.with_suffix(".wav")
cmd = [
"ffmpeg", "-y", "-v", "error",
"-i", str(path),
"-ar", "16000", "-ac", "1",
str(wav_path)
]
subprocess.run(cmd, check=True)
return str(wav_path)
def _call_asr(self, wav_path: str) -> str:
"""Call Qwen ASR API."""
from dashscope import MultiModalConversation
messages = [
{"role": "system", "content": [{"text": ""}]},
{"role": "user", "content": [{"audio": wav_path}]}
]
response = MultiModalConversation.call(
model=self._model,
messages=messages,
result_format="message",
asr_options={"language": "zh", "enable_itn": True}
)
if response.status_code != 200:
raise RuntimeError(f"ASR failed: {response.message}")
choice = response.output.choices[0]
content = choice.message.content[0]
return content.get("text", "")
def _transcribe_openai(self, audio_path: str) -> TranscribeResult:
api_key = self._api_key or os.environ.get("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("Missing OPENAI_API_KEY")
from openai import OpenAI
client = OpenAI(api_key=api_key)
with open(audio_path, "rb") as f:
resp = client.audio.transcriptions.create(
model="whisper-1",
file=f,
response_format="verbose_json",
timestamp_granularities=["segment"],
)
segments: list[Segment] = []
for seg in getattr(resp, "segments", []) or []:
start_s = getattr(seg, "start", None)
end_s = getattr(seg, "end", None)
text = getattr(seg, "text", "") or ""
if start_s is None or end_s is None:
continue
segments.append(Segment(
start_ms=int(round(float(start_s) * 1000)),
end_ms=int(round(float(end_s) * 1000)),
text=" ".join(str(text).split()),
))
return TranscribeResult(segments=segments, raw=resp)
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Any, Literal
from ..segment import Segment
Mode = Literal["noop", "anthropic"]
def _extract_json_array(text: str) -> Any:
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
start = text.find("[")
end = text.rfind("]")
if start == -1 or end == -1 or end <= start:
raise ValueError("Model output did not contain a JSON array.")
return json.loads(text[start : end + 1])
@dataclass(frozen=True, slots=True)
class TranslateResult:
segments: list[Segment]
raw_text: str | None = None
class TranslateAgent:
def __init__(
self,
*,
mode: Mode = "anthropic",
model: str = "claude-3-5-sonnet-latest",
api_key: str | None = None,
) -> None:
self._mode = mode
self._model = model
self._api_key = api_key
def translate_segments(self, segments: list[Segment]) -> list[Segment]:
return self.translate(segments).segments
def translate(self, segments: list[Segment]) -> TranslateResult:
if self._mode == "noop":
return TranslateResult(segments=segments, raw_text=None)
api_key = self._api_key or os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise RuntimeError("Missing ANTHROPIC_API_KEY (or pass api_key=...).")
try:
from anthropic import Anthropic # type: ignore[import-not-found]
except Exception as e: # pragma: no cover
raise RuntimeError("anthropic package is required for translation. Install: pip install anthropic") from e
client = Anthropic(api_key=api_key)
payload = [
{"index": i, "start_ms": s.start_ms, "end_ms": s.end_ms, "text": s.text}
for i, s in enumerate(segments)
]
system = (
"Translate Chinese subtitles to natural English.\n"
"Rules:\n"
"- Do NOT change timestamps.\n"
"- Do NOT add or remove segments.\n"
"- Output ONLY a JSON array: {\"index\": number, \"text\": string}.\n"
)
user = "Translate these segments to English:\n\n" + json.dumps(payload, ensure_ascii=False)
msg = client.messages.create(
model=self._model,
max_tokens=4096,
system=system,
messages=[{"role": "user", "content": user}],
)
content = ""
for block in msg.content:
if getattr(block, "type", None) == "text":
content += block.text
items = _extract_json_array(content)
if not isinstance(items, list):
raise ValueError("Model output was not a JSON array.")
translated_by_index: dict[int, str] = {}
for item in items:
if not isinstance(item, dict):
continue
idx = item.get("index")
text = item.get("text")
if isinstance(idx, int) and isinstance(text, str):
translated_by_index[idx] = text
out: list[Segment] = []
for i, seg in enumerate(segments):
text = translated_by_index.get(i, seg.text)
out.append(Segment(start_ms=seg.start_ms, end_ms=seg.end_ms, text=text))
return TranslateResult(segments=out, raw_text=content.strip())
from __future__ import annotations
from pathlib import Path
from .bbdown_client import BBDownClient
from .url_parser import parse_bilibili_ref
def extract_audio(
url_or_id: str,
output_dir: str | Path,
) -> Path:
ref = parse_bilibili_ref(url_or_id)
url = ref.canonical_url or ref.input_value
output_dir = Path(output_dir)
client = BBDownClient()
return client.download_audio(url, output_dir)
from __future__ import annotations
import logging
import re
import shutil
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
logger = logging.getLogger(__name__)
# --- Compiled regexes for subtitle info extraction (Fix 5) ---
_SUBTITLE_LINE_RE = re.compile(
r"下载字幕|download.*subtitle|saving subtitle|字幕下载", re.IGNORECASE
)
_AI_MARKER_RE = re.compile(
r"ai[_\-]|AI识别|auto.?generated|asr|自动识别", re.IGNORECASE
)
_LANG_RE = re.compile(
r"\b(zh-hans|zh-hant|zh|en|ja|ko)\b", re.IGNORECASE
)
_LANG_NORMALIZE: dict[str, str] = {
"zh-hans": "zh",
"zh-hant": "zh-hant",
}
# Errors that should NOT be retried
_FATAL_PATTERNS = re.compile(
r"login|auth|cookie|not found|不存在|404|权限", re.IGNORECASE
)
@dataclass(frozen=True, slots=True)
class SubtitleInfo:
has_subtitle: bool
has_ai_subtitle: bool
languages: list[str]
@dataclass(frozen=True, slots=True)
class VideoInfo:
video_id: str
title: str | None
subtitle_info: SubtitleInfo
subtitle_files: list[Path]
class BBDownError(Exception):
pass
class BBDownClient:
def __init__(self) -> None:
self._bbdown = self._find_bbdown()
def _find_bbdown(self) -> str:
path = shutil.which("BBDown")
if path:
return path
local = Path(__file__).parent.parent / "BBDown"
if local.exists():
return str(local)
raise BBDownError(
"BBDown not found. Download from: https://github.com/nilaoda/BBDown/releases"
)
def _base_args(self) -> list[str]:
return [self._bbdown]
def _run(
self,
args: list[str],
*,
check: bool = True,
max_retries: int = 3,
retry_delay: float = 1.0,
timeout: int = 120,
) -> subprocess.CompletedProcess[str]:
"""Run BBDown with retry + timeout (Fix 1)."""
last_exc: Exception | None = None
for attempt in range(max_retries):
try:
result = subprocess.run(
args,
capture_output=True,
text=True,
check=False,
timeout=timeout,
)
# If check requested and non-zero, see if it's fatal
if check and result.returncode != 0:
combined = result.stdout + result.stderr
if _FATAL_PATTERNS.search(combined):
raise BBDownError(f"BBDown failed (non-retryable): {result.stderr}")
# Retryable error
last_exc = BBDownError(f"BBDown failed (rc={result.returncode}): {result.stderr}")
logger.warning(
"BBDown attempt %d/%d failed (rc=%d), retrying in %.1fs",
attempt + 1, max_retries, result.returncode, retry_delay * (2 ** attempt),
)
time.sleep(retry_delay * (2 ** attempt))
continue
return result
except subprocess.TimeoutExpired:
last_exc = BBDownError(f"BBDown timed out after {timeout}s")
logger.warning(
"BBDown attempt %d/%d timed out, retrying in %.1fs",
attempt + 1, max_retries, retry_delay * (2 ** attempt),
)
time.sleep(retry_delay * (2 ** attempt))
continue
except BBDownError:
raise
except Exception as e:
raise BBDownError(f"BBDown failed: {e}") from e
raise last_exc or BBDownError("BBDown failed after retries")
def get_video_info(
self, url: str, work_dir: Path, *, lang: str | None = "zh-Hans"
) -> VideoInfo:
"""Download subtitles and return video info (Fix 4, 7)."""
work_dir.mkdir(parents=True, exist_ok=True)
video_id = self._extract_video_id(url)
existing_files = set(work_dir.glob(f"{video_id}*.srt")) | set(
work_dir.glob(f"{video_id}*.vtt")
)
args = self._base_args() + [
"--sub-only",
"--skip-ai",
"false",
"-F",
video_id,
"--work-dir",
str(work_dir),
]
if lang is not None:
args += ["--select-lang", lang]
args.append(url)
result = self._run(args, check=False)
output = result.stdout + result.stderr
new_files = sorted(
(set(work_dir.glob(f"{video_id}*.srt")) | set(work_dir.glob(f"{video_id}*.vtt")))
- existing_files
)
# Fix 7: raise on non-zero exit when no files were produced
if result.returncode != 0 and not new_files:
logger.error("BBDown exited %d with no subtitle files", result.returncode)
raise BBDownError(
f"BBDown failed (rc={result.returncode}): {output[-500:]}"
)
title = self._extract_title(output)
subtitle_info = self._extract_subtitle_info(output)
return VideoInfo(
video_id=video_id,
title=title,
subtitle_info=subtitle_info,
subtitle_files=new_files,
)
def _extract_video_id(self, url: str) -> str:
bv_match = re.search(r"(BV[0-9A-Za-z]{10})", url)
if bv_match:
return bv_match.group(1)
av_match = re.search(r"av(\d+)", url, re.IGNORECASE)
if av_match:
return f"av{av_match.group(1)}"
return "unknown"
def _extract_title(self, output: str) -> str | None:
for line in output.splitlines():
cleaned = re.sub(r"^\[[^\]]+\]\s*-\s*", "", line).strip()
match = re.search(r"(?:视频标题|标题|Title)\s*[::]\s*(.+)", cleaned)
if match:
return match.group(1).strip()
return None
def _extract_subtitle_info(self, output: str) -> SubtitleInfo:
"""Parse BBDown output for subtitle metadata (Fix 5)."""
has_subtitle = False
has_ai_subtitle = False
languages: list[str] = []
for line in output.splitlines():
if not _SUBTITLE_LINE_RE.search(line):
continue
has_subtitle = True
if _AI_MARKER_RE.search(line):
has_ai_subtitle = True
lang_match = _LANG_RE.search(line)
if lang_match:
raw = lang_match.group(1).lower()
normalized = _LANG_NORMALIZE.get(raw, raw)
if normalized not in languages:
languages.append(normalized)
return SubtitleInfo(
has_subtitle=has_subtitle,
has_ai_subtitle=has_ai_subtitle,
languages=languages,
)
def download_audio(self, url: str, work_dir: Path) -> Path:
work_dir.mkdir(parents=True, exist_ok=True)
video_id = self._extract_video_id(url)
args = self._base_args() + [
"--audio-only",
"-F",
video_id,
"--work-dir",
str(work_dir),
url,
]
self._run(args)
audio_files = list(work_dir.glob(f"{video_id}.*"))
audio_exts = (".m4a", ".aac", ".mp3", ".flac", ".wav")
for f in audio_files:
if f.suffix.lower() in audio_exts:
return f
all_audio = [f for f in work_dir.iterdir() if f.suffix.lower() in audio_exts]
if all_audio:
return sorted(all_audio, key=lambda p: p.stat().st_mtime, reverse=True)[0]
raise BBDownError("Audio download produced no output.")
from __future__ import annotations
import json
import re
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from .segment import Segment
def _safe_name(value: str) -> str:
value = value.strip() or "unknown"
return re.sub(r"[^A-Za-z0-9._-]+", "_", value)
@dataclass(frozen=True, slots=True)
class CachedSegments:
video_id: str
segments: list[Segment]
class Cache:
def __init__(self, cache_dir: str | Path) -> None:
self._dir = Path(cache_dir)
self._dir.mkdir(parents=True, exist_ok=True)
def _path(self, video_id: str, name: str) -> Path:
return self._dir / f"{_safe_name(video_id)}.{_safe_name(name)}.json"
def load_segments(self, video_id: str, name: str) -> list[Segment] | None:
path = self._path(video_id, name)
if not path.exists():
return None
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, list):
return None
out: list[Segment] = []
for item in data:
if not isinstance(item, dict):
continue
out.append(Segment(start_ms=item["start_ms"], end_ms=item["end_ms"], text=item["text"]))
return out
def save_segments(self, video_id: str, name: str, segments: list[Segment]) -> Path:
path = self._path(video_id, name)
data: list[dict[str, Any]] = [asdict(s) for s in segments]
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
return path
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True, slots=True)
class AudioChunk:
start_ms: int
end_ms: int
path: Path
def probe_duration_ms(input_path: str | Path) -> int:
input_path = Path(input_path)
cmd = [
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"json",
str(input_path),
]
try:
out = subprocess.check_output(cmd, text=True)
except FileNotFoundError as e: # pragma: no cover
raise RuntimeError("ffprobe not found. Install ffmpeg.") from e
data = json.loads(out)
dur = float(data["format"]["duration"])
return int(round(dur * 1000))
def chunk_audio_ffmpeg(
input_path: str | Path,
output_dir: str | Path,
*,
chunk_seconds: int = 60,
overlap_seconds: int = 2,
) -> list[AudioChunk]:
input_path = Path(input_path)
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
duration_ms = probe_duration_ms(input_path)
chunk_ms = chunk_seconds * 1000
overlap_ms = overlap_seconds * 1000
if chunk_ms <= 0:
raise ValueError("chunk_seconds must be > 0.")
if overlap_ms < 0 or overlap_ms >= chunk_ms:
raise ValueError("overlap_seconds must be >= 0 and < chunk_seconds.")
step_ms = chunk_ms - overlap_ms
chunks: list[AudioChunk] = []
start_ms = 0
idx = 0
while start_ms < duration_ms:
end_ms = min(duration_ms, start_ms + chunk_ms)
out_path = output_dir / f"chunk_{idx:04d}_{start_ms}_{end_ms}{input_path.suffix}"
cmd = [
"ffmpeg",
"-y",
"-v",
"error",
"-ss",
f"{start_ms / 1000:.3f}",
"-t",
f"{(end_ms - start_ms) / 1000:.3f}",
"-i",
str(input_path),
"-c",
"copy",
str(out_path),
]
try:
subprocess.check_call(cmd)
except FileNotFoundError as e: # pragma: no cover
raise RuntimeError("ffmpeg not found. Install ffmpeg.") from e
chunks.append(AudioChunk(start_ms=start_ms, end_ms=end_ms, path=out_path))
idx += 1
start_ms += step_ms
return chunks
"""
Sub-skill invocation contract.
Standardized interface for parent skills to invoke this skill.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Literal
class ExitCode(Enum):
SUCCESS = 0
FATAL_ERROR = 1
RECOVERABLE_ERROR = 2
PARTIAL_SUCCESS = 3
@dataclass
class SubtitleOutput:
video_id: str
title: str | None = None
transcript_md: Path | None = None
srt_file: Path | None = None
vtt_file: Path | None = None
summary_json: Path | None = None
summary_md: Path | None = None
def to_dict(self) -> dict[str, Any]:
return {
"video_id": self.video_id,
"title": self.title,
"files": {
"transcript": str(self.transcript_md) if self.transcript_md else None,
"srt": str(self.srt_file) if self.srt_file else None,
"vtt": str(self.vtt_file) if self.vtt_file else None,
"summary_json": str(self.summary_json) if self.summary_json else None,
"summary_md": str(self.summary_md) if self.summary_md else None,
},
}
@dataclass
class ExecutionResult:
exit_code: ExitCode
output: SubtitleOutput | None = None
errors: list[dict[str, Any]] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
@property
def success(self) -> bool:
return self.exit_code in (ExitCode.SUCCESS, ExitCode.PARTIAL_SUCCESS)
def to_dict(self) -> dict[str, Any]:
return {
"exit_code": self.exit_code.value,
"success": self.success,
"output": self.output.to_dict() if self.output else None,
"errors": self.errors,
"warnings": self.warnings,
"metadata": self.metadata,
}
def to_json(self) -> str:
return json.dumps(self.to_dict(), indent=2, ensure_ascii=False)
def write_manifest(self, path: Path) -> None:
path.write_text(self.to_json(), encoding="utf-8")
def build_cli_command(
url_or_id: str,
output_dir: str | Path,
*,
skip_proofread: bool = False,
skip_summary: bool = True,
output_lang: Literal["zh", "en", "zh+en"] = "zh",
cache_dir: str | Path | None = None,
verbose: bool = False,
) -> list[str]:
cmd = [
"pixi",
"run",
"python",
"-m",
"bilibili_subtitle",
url_or_id,
"-o",
str(output_dir),
"--output-lang",
output_lang,
]
if skip_proofread:
cmd.append("--skip-proofread")
if skip_summary:
cmd.append("--skip-summary")
if cache_dir:
cmd.extend(["--cache-dir", str(cache_dir)])
if verbose:
cmd.append("-v")
return cmd
def parse_execution_result(
output_dir: Path, exit_code: int, stderr: str = ""
) -> ExecutionResult:
errors: list[dict[str, Any]] = []
warnings: list[str] = []
video_id = _extract_video_id_from_dir(output_dir)
srt_files = list(output_dir.glob("*.srt"))
vtt_files = list(output_dir.glob("*.vtt"))
transcript_files = list(output_dir.glob("*.transcript.md"))
summary_json_files = list(output_dir.glob("*.summary.json"))
summary_md_files = list(output_dir.glob("*.summary.md"))
has_transcript = bool(transcript_files)
has_subtitle = bool(srt_files)
result_exit_code = (
ExitCode(exit_code) if exit_code in (0, 1, 2, 3) else ExitCode.FATAL_ERROR
)
if stderr:
parsed_errors = _parse_stderr(stderr)
errors.extend(parsed_errors)
if not has_transcript and not has_subtitle:
if exit_code == 0:
result_exit_code = ExitCode.PARTIAL_SUCCESS
warnings.append("No transcript or subtitle files generated")
else:
result_exit_code = ExitCode.FATAL_ERROR
output = None
if video_id:
output = SubtitleOutput(
video_id=video_id,
transcript_md=transcript_files[0] if transcript_files else None,
srt_file=srt_files[0] if srt_files else None,
vtt_file=vtt_files[0] if vtt_files else None,
summary_json=summary_json_files[0] if summary_json_files else None,
summary_md=summary_md_files[0] if summary_md_files else None,
)
return ExecutionResult(
exit_code=result_exit_code,
output=output,
errors=errors,
warnings=warnings,
metadata={
"output_dir": str(output_dir),
},
)
def _extract_video_id_from_dir(output_dir: Path) -> str | None:
for f in output_dir.iterdir():
if f.suffix in (".srt", ".md", ".json", ".vtt"):
name = f.stem
if name.endswith(".transcript"):
name = name[:-10]
if name.endswith(".summary"):
name = name[:-7]
if name.endswith((".zh", ".en")):
name = name[:-3]
if name.startswith("BV") or name.startswith("av"):
return name
return None
def _parse_stderr(stderr: str) -> list[dict[str, Any]]:
errors: list[dict[str, Any]] = []
for line in stderr.splitlines():
line = line.strip()
if not line:
continue
if "error" in line.lower() or "failed" in line.lower():
errors.append(
{
"type": "error",
"message": line,
}
)
elif "warning" in line.lower():
errors.append(
{
"type": "warning",
"message": line,
}
)
return errors
CONTRACT_VERSION = "1.0.0"
REQUIRED_OUTPUTS = ["*.transcript.md"]
OPTIONAL_OUTPUTS = ["*.srt", "*.vtt", "*.summary.json", "*.summary.md"]
from __future__ import annotations
import re
from ..segment import Segment
_TIME_RE = re.compile(
r"(?P<sh>\d{2}):(?P<sm>\d{2}):(?P<ss>\d{2}),(?P<sms>\d{3})\s*-->\s*"
r"(?P<eh>\d{2}):(?P<em>\d{2}):(?P<es>\d{2}),(?P<ems>\d{3})"
)
def _to_ms(h: str, m: str, s: str, ms: str) -> int:
return (int(h) * 3600 + int(m) * 60 + int(s)) * 1000 + int(ms)
def srt_to_segments(srt_text: str) -> list[Segment]:
blocks = re.split(r"\r?\n\r?\n+", srt_text.strip())
segments: list[Segment] = []
for block in blocks:
lines = [ln.strip("\r") for ln in block.splitlines() if ln.strip("\r").strip()]
if len(lines) < 2:
continue
# Optional numeric index line.
time_line = lines[1] if lines[0].isdigit() else lines[0]
m = _TIME_RE.search(time_line)
if not m:
continue
start_ms = _to_ms(m["sh"], m["sm"], m["ss"], m["sms"])
end_ms = _to_ms(m["eh"], m["em"], m["es"], m["ems"])
text_lines = lines[2:] if lines[0].isdigit() else lines[1:]
text = " ".join(" ".join(text_lines).replace("\n", " ").split())
segments.append(Segment(start_ms=start_ms, end_ms=end_ms, text=text))
return segments
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from .bbdown_client import BBDownClient
from .url_parser import VideoRef, parse_bilibili_ref
@dataclass(frozen=True, slots=True)
class VideoMetadata:
video_id: str
title: str | None
has_subtitle: bool
has_ai_subtitle: bool
subtitle_files: list[Path]
def detect_subtitles(
url_or_id: str,
output_dir: str | Path,
) -> tuple[VideoRef, VideoMetadata]:
ref = parse_bilibili_ref(url_or_id)
url = ref.canonical_url or ref.input_value
output_dir = Path(output_dir)
client = BBDownClient()
info = client.get_video_info(url, output_dir)
meta = VideoMetadata(
video_id=info.video_id or ref.video_id or "unknown",
title=info.title,
has_subtitle=info.subtitle_info.has_subtitle,
has_ai_subtitle=info.subtitle_info.has_ai_subtitle,
subtitle_files=info.subtitle_files,
)
return ref, meta
"""
Error taxonomy with severity levels and remediation hints.
Level 1: FATAL - Cannot proceed, requires user action
Level 2: RECOVERABLE - Can continue with degraded functionality
Level 3: WARNING - Non-critical, proceed with caution
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Literal
class ErrorLevel(Enum):
FATAL = "fatal"
RECOVERABLE = "recoverable"
WARNING = "warning"
@dataclass(frozen=True, slots=True)
class Remediation:
hint: str
command: str | None = None
doc_url: str | None = None
@dataclass
class SkillError(Exception):
code: str
level: ErrorLevel
message: str
remediation: Remediation | None = None
def __str__(self) -> str:
parts = [f"[{self.code}] {self.message}"]
if self.remediation:
parts.append(f" Hint: {self.remediation.hint}")
if self.remediation.command:
parts.append(f" Run: {self.remediation.command}")
if self.remediation.doc_url:
parts.append(f" See: {self.remediation.doc_url}")
return "\n".join(parts)
def to_json(self) -> dict:
return {
"code": self.code,
"level": self.level.value,
"message": self.message,
"remediation": {
"hint": self.remediation.hint,
"command": self.remediation.command,
"doc_url": self.remediation.doc_url,
}
if self.remediation
else None,
}
class BBDownNotFoundError(SkillError):
def __init__(self) -> None:
super().__init__(
code="E001",
level=ErrorLevel.FATAL,
message="BBDown not found in PATH",
remediation=Remediation(
hint="Install BBDown from GitHub releases",
command="./install.sh",
doc_url="https://github.com/nilaoda/BBDown/releases",
),
)
class BBDownAuthError(SkillError):
def __init__(self, details: str = "") -> None:
super().__init__(
code="E002",
level=ErrorLevel.FATAL,
message=f"BBDown authentication required{': ' + details if details else ''}",
remediation=Remediation(
hint="Login to Bilibili via BBDown",
command="BBDown login",
),
)
class BBDownDownloadError(SkillError):
def __init__(self, url: str, reason: str = "") -> None:
super().__init__(
code="E003",
level=ErrorLevel.RECOVERABLE,
message=f"Failed to download: {url}{': ' + reason if reason else ''}",
remediation=Remediation(
hint="Check URL validity or network connection",
),
)
class NoSubtitleError(SkillError):
def __init__(self, video_id: str) -> None:
super().__init__(
code="E004",
level=ErrorLevel.RECOVERABLE,
message=f"Video {video_id} has no subtitles",
remediation=Remediation(
hint="ASR transcription will be attempted if DASHSCOPE_API_KEY is set",
),
)
class ASRConfigError(SkillError):
def __init__(self) -> None:
super().__init__(
code="E005",
level=ErrorLevel.FATAL,
message="DASHSCOPE_API_KEY not configured",
remediation=Remediation(
hint="Set DASHSCOPE_API_KEY for ASR transcription",
command='export DASHSCOPE_API_KEY="your-key"',
),
)
class AnthropicConfigError(SkillError):
def __init__(self, feature: str = "proofreading") -> None:
super().__init__(
code="E006",
level=ErrorLevel.RECOVERABLE,
message=f"ANTHROPIC_API_KEY not configured, {feature} skipped",
remediation=Remediation(
hint="Set ANTHROPIC_API_KEY or use --skip-proofread --skip-summary",
command='export ANTHROPIC_API_KEY="your-key"',
),
)
class FFmpegNotFoundError(SkillError):
def __init__(self) -> None:
super().__init__(
code="E007",
level=ErrorLevel.FATAL,
message="ffmpeg not found",
remediation=Remediation(
hint="ffmpeg is required for audio conversion",
command="pixi install",
),
)
class InvalidURLError(SkillError):
def __init__(self, url: str) -> None:
super().__init__(
code="E008",
level=ErrorLevel.FATAL,
message=f"Invalid Bilibili URL or BV ID: {url}",
remediation=Remediation(
hint="Provide a valid URL (e.g., https://www.bilibili.com/video/BVxxx) or BV ID",
),
)
class OutputWriteError(SkillError):
def __init__(self, path: str, reason: str = "") -> None:
super().__init__(
code="E009",
level=ErrorLevel.FATAL,
message=f"Cannot write to {path}{': ' + reason if reason else ''}",
remediation=Remediation(
hint="Check directory permissions or use -o to specify a different output directory",
),
)
class VideoNotFoundError(SkillError):
def __init__(self, video_id: str) -> None:
super().__init__(
code="E010",
level=ErrorLevel.FATAL,
message=f"Video not found or unavailable: {video_id}",
remediation=Remediation(
hint="The video may be deleted, private, or region-restricted",
),
)
class RateLimitError(SkillError):
def __init__(self, retry_after: int = 60) -> None:
super().__init__(
code="E011",
level=ErrorLevel.RECOVERABLE,
message=f"Rate limited, retry after {retry_after}s",
remediation=Remediation(
hint="Wait and retry, or reduce request frequency",
),
)
class SubtitleContentError(SkillError):
def __init__(self, reason: str = "empty segments") -> None:
super().__init__(
code="E012",
level=ErrorLevel.RECOVERABLE,
message=f"Subtitle content issue: {reason}",
remediation=Remediation(
hint="Retry download or try a different subtitle language",
),
)
def exit_code_for_error(error: SkillError) -> int:
return {
ErrorLevel.FATAL: 1,
ErrorLevel.RECOVERABLE: 2,
ErrorLevel.WARNING: 0,
}[error.level]
from __future__ import annotations
def normalize_lang(lang: str) -> str:
raw = (lang or "").strip()
if not raw:
return raw
lowered = raw.lower()
aliases = {
"zh-cn": "zh",
"zh_cn": "zh",
"zh-hans": "zh-Hans",
"zh-hans-cn": "zh-Hans",
"zh-hans_cn": "zh-Hans",
"zh-sg": "zh",
"zh_tw": "zh-Hant",
"zh-tw": "zh-Hant",
"zh-hant": "zh-Hant",
}
if lowered in aliases:
return aliases[lowered]
if lowered == "ai-zh":
return "ai-zh"
# Preserve original casing for non-aliases; most yt-dlp lang IDs are case-sensitive-ish.
return raw
from __future__ import annotations
import difflib
from dataclasses import dataclass
from .segment import Segment
@dataclass(frozen=True, slots=True)
class ChunkTranscript:
chunk_start_ms: int
segments: list[Segment]
def validate_strictly_increasing(segments: list[Segment]) -> None:
prev_end = -1
for seg in segments:
if seg.start_ms < prev_end:
raise ValueError("Non-monotonic timestamps: segment overlaps previous segment.")
prev_end = seg.end_ms
def _similarity(a: str, b: str) -> float:
return difflib.SequenceMatcher(None, a, b).ratio()
def _shift_segments(segments: list[Segment], offset_ms: int) -> list[Segment]:
if offset_ms == 0:
return segments
return [
Segment(start_ms=s.start_ms + offset_ms, end_ms=s.end_ms + offset_ms, text=s.text) for s in segments
]
def merge_chunk_transcripts(
transcripts: list[ChunkTranscript],
*,
overlap_ms: int = 2000,
similarity_threshold: float = 0.8,
) -> list[Segment]:
if not transcripts:
return []
transcripts = sorted(transcripts, key=lambda t: t.chunk_start_ms)
merged: list[Segment] = []
for t in transcripts:
shifted = _shift_segments(t.segments, t.chunk_start_ms)
if not merged:
merged.extend(shifted)
continue
overlap_start = t.chunk_start_ms
overlap_end = overlap_start + max(0, overlap_ms)
candidates = [s for s in merged if s.end_ms > overlap_start and s.start_ms < overlap_end]
def is_duplicate(seg: Segment) -> bool:
if seg.start_ms >= overlap_end:
return False
for cand in candidates:
time_overlaps = max(seg.start_ms, cand.start_ms) < min(seg.end_ms, cand.end_ms)
if not time_overlaps:
continue
if _similarity(seg.text, cand.text) >= similarity_threshold:
return True
return False
for seg in shifted:
if is_duplicate(seg):
continue
merged.append(seg)
merged.sort(key=lambda s: (s.start_ms, s.end_ms))
# Enforce strict monotonicity by clamping any overlap forward.
fixed: list[Segment] = []
prev_end = 0
for seg in merged:
start_ms = max(seg.start_ms, prev_end)
if start_ms >= seg.end_ms:
continue
if start_ms != seg.start_ms:
seg = Segment(start_ms=start_ms, end_ms=seg.end_ms, text=seg.text)
fixed.append(seg)
prev_end = seg.end_ms
validate_strictly_increasing(fixed)
return fixed
"""
Preflight checks for skill execution.
Usage:
pixi run python -m bilibili_subtitle --check
pixi run python -m bilibili_subtitle --check --json
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any
class CheckStatus(Enum):
OK = "ok"
WARNING = "warning"
ERROR = "error"
SKIPPED = "skipped"
@dataclass
class CheckResult:
name: str
status: CheckStatus
message: str
details: dict[str, Any] = field(default_factory=dict)
remediation: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"status": self.status.value,
"message": self.message,
"details": self.details,
"remediation": self.remediation,
}
@dataclass
class PreflightReport:
results: list[CheckResult]
@property
def has_errors(self) -> bool:
return any(r.status == CheckStatus.ERROR for r in self.results)
@property
def has_warnings(self) -> bool:
return any(r.status == CheckStatus.WARNING for r in self.results)
@property
def can_proceed(self) -> bool:
return not self.has_errors
def to_dict(self) -> dict[str, Any]:
return {
"checks": [r.to_dict() for r in self.results],
"summary": {
"total": len(self.results),
"ok": sum(1 for r in self.results if r.status == CheckStatus.OK),
"warnings": sum(
1 for r in self.results if r.status == CheckStatus.WARNING
),
"errors": sum(1 for r in self.results if r.status == CheckStatus.ERROR),
"can_proceed": self.can_proceed,
},
}
def to_json(self) -> str:
return json.dumps(self.to_dict(), indent=2, ensure_ascii=False)
def print_report(self) -> None:
for r in self.results:
icon = {
CheckStatus.OK: "✅",
CheckStatus.WARNING: "⚠️",
CheckStatus.ERROR: "❌",
CheckStatus.SKIPPED: "⏭️",
}[r.status]
print(f"{icon} {r.name}: {r.message}")
if r.remediation:
print(f" → {r.remediation}")
def check_bbdown() -> CheckResult:
bbdown_path = shutil.which("BBDown")
if bbdown_path:
try:
result = subprocess.run(
["BBDown", "--help"],
capture_output=True,
text=True,
timeout=5,
)
version_line = result.stdout.split("\n")[0] if result.stdout else "unknown"
version = version_line.split()[-1] if version_line else "unknown"
return CheckResult(
name="BBDown",
status=CheckStatus.OK,
message=f"Installed ({version})",
details={"path": bbdown_path, "version": version},
)
except Exception as e:
return CheckResult(
name="BBDown",
status=CheckStatus.WARNING,
message=f"Installed but check failed: {e}",
details={"path": bbdown_path},
)
return CheckResult(
name="BBDown",
status=CheckStatus.ERROR,
message="Not found in PATH",
remediation="Run: ./install.sh",
)
def check_bbdown_auth() -> CheckResult:
bbdown_data = Path.home() / "BBDown.data"
if bbdown_data.exists():
try:
content = bbdown_data.read_text()
has_sessdata = "SESSDATA" in content
if has_sessdata:
return CheckResult(
name="BBDown Auth",
status=CheckStatus.OK,
message="Logged in",
details={"cookie_file": str(bbdown_data)},
)
return CheckResult(
name="BBDown Auth",
status=CheckStatus.ERROR,
message="Cookie file exists but no SESSDATA",
remediation="Run: BBDown login",
)
except Exception as e:
return CheckResult(
name="BBDown Auth",
status=CheckStatus.WARNING,
message=f"Cannot read cookie file: {e}",
remediation="Run: BBDown login",
)
return CheckResult(
name="BBDown Auth",
status=CheckStatus.ERROR,
message="Not logged in",
remediation="Run: BBDown login",
)
def check_ffmpeg() -> CheckResult:
ffmpeg_path = shutil.which("ffmpeg")
if ffmpeg_path:
try:
result = subprocess.run(
["ffmpeg", "-version"],
capture_output=True,
text=True,
timeout=5,
)
version_line = result.stdout.split("\n")[0] if result.stdout else "unknown"
return CheckResult(
name="ffmpeg",
status=CheckStatus.OK,
message="Installed",
details={
"path": ffmpeg_path,
"version": version_line.split()[2]
if len(version_line.split()) > 2
else "unknown",
},
)
except Exception:
return CheckResult(
name="ffmpeg",
status=CheckStatus.WARNING,
message="Installed but version check failed",
details={"path": ffmpeg_path},
)
return CheckResult(
name="ffmpeg",
status=CheckStatus.ERROR,
message="Not found",
remediation="Run: pixi install",
)
def check_anthropic_key() -> CheckResult:
key = os.environ.get("ANTHROPIC_API_KEY")
if key:
masked = key[:8] + "..." + key[-4:] if len(key) > 12 else "***"
return CheckResult(
name="ANTHROPIC_API_KEY",
status=CheckStatus.OK,
message="Configured",
details={"key_hint": masked},
)
return CheckResult(
name="ANTHROPIC_API_KEY",
status=CheckStatus.WARNING,
message="Not set (required for proofreading/summarization)",
remediation='export ANTHROPIC_API_KEY="your-key"',
)
def check_dashscope_key() -> CheckResult:
key = os.environ.get("DASHSCOPE_API_KEY")
if key:
masked = key[:8] + "..." + key[-4:] if len(key) > 12 else "***"
return CheckResult(
name="DASHSCOPE_API_KEY",
status=CheckStatus.OK,
message="Configured",
details={"key_hint": masked},
)
return CheckResult(
name="DASHSCOPE_API_KEY",
status=CheckStatus.WARNING,
message="Not set (required for ASR transcription of videos without subtitles)",
remediation='export DASHSCOPE_API_KEY="your-key"',
)
def check_python_deps() -> CheckResult:
missing: list[str] = []
try:
import anthropic # noqa: F401
except ImportError:
missing.append("anthropic")
try:
import dashscope # noqa: F401
except ImportError:
missing.append("dashscope")
if not missing:
return CheckResult(
name="Python Dependencies",
status=CheckStatus.OK,
message="All installed",
)
return CheckResult(
name="Python Dependencies",
status=CheckStatus.WARNING,
message=f"Missing: {', '.join(missing)}",
remediation="Run: pixi run pip install -e .[claude,transcribe]",
)
def run_preflight(*, include_auth: bool = True) -> PreflightReport:
checks = [
check_bbdown(),
check_ffmpeg(),
check_anthropic_key(),
check_dashscope_key(),
check_python_deps(),
]
if include_auth:
checks.insert(1, check_bbdown_auth())
return PreflightReport(results=checks)
def main() -> int:
import argparse
parser = argparse.ArgumentParser(description="Preflight checks")
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument("--skip-auth", action="store_true", help="Skip auth check")
args = parser.parse_args()
report = run_preflight(include_auth=not args.skip_auth)
if args.json:
print(report.to_json())
else:
report.print_report()
print()
if report.can_proceed:
print("✅ Ready to proceed")
else:
print("❌ Fix errors before proceeding")
return 0 if report.can_proceed else 1
if __name__ == "__main__":
exit(main())
"""Time formatting utilities for subtitle renderers."""
from __future__ import annotations
def format_timestamp_srt(ms: int) -> str:
"""Format milliseconds to SRT timestamp format: HH:MM:SS,mmm"""
total_seconds = ms // 1000
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
milliseconds = ms % 1000
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"
def format_timestamp_vtt(ms: int) -> str:
"""Format milliseconds to VTT timestamp format: HH:MM:SS.mmm"""
total_seconds = ms // 1000
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
milliseconds = ms % 1000
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{milliseconds:03d}"
from __future__ import annotations
from ..segment import Segment
from ._time import format_timestamp_vtt
def render_transcript_markdown(
segments_zh: list[Segment],
*,
segments_en: list[Segment] | None = None,
title: str | None = None,
) -> str:
if segments_en is not None and len(segments_en) != len(segments_zh):
raise ValueError("Bilingual output requires equal-length zh/en segment lists.")
lines: list[str] = []
if title:
lines.append(f"# {title}")
lines.append("")
for i, zh in enumerate(segments_zh):
start = format_timestamp_vtt(zh.start_ms)
end = format_timestamp_vtt(zh.end_ms)
if segments_en is not None:
en = segments_en[i]
if (en.start_ms, en.end_ms) != (zh.start_ms, zh.end_ms):
raise ValueError("Bilingual zh/en segments must share timestamps.")
text = f"{zh.text}\n\n{en.text}"
else:
text = zh.text
lines.append(f"## {start} - {end}")
lines.append(text)
lines.append("")
return "\n".join(lines).rstrip("\n") + "\n"
from __future__ import annotations
from ..segment import Segment
from ._time import format_timestamp_srt
def render_srt(segments_zh: list[Segment], *, segments_en: list[Segment] | None = None) -> str:
if segments_en is not None and len(segments_en) != len(segments_zh):
raise ValueError("Bilingual output requires equal-length zh/en segment lists.")
lines: list[str] = []
for idx, zh in enumerate(segments_zh, start=1):
if segments_en is not None:
en = segments_en[idx - 1]
if (en.start_ms, en.end_ms) != (zh.start_ms, zh.end_ms):
raise ValueError("Bilingual zh/en segments must share timestamps.")
text = f"{zh.text}\n{en.text}"
else:
text = zh.text
lines.extend(
[
str(idx),
f"{format_timestamp_srt(zh.start_ms)} --> {format_timestamp_srt(zh.end_ms)}",
text,
"",
]
)
return "\n".join(lines).rstrip("\n") + "\n"
from __future__ import annotations
from ..segment import Segment
from ._time import format_timestamp_vtt
def render_vtt(segments_zh: list[Segment], *, segments_en: list[Segment] | None = None) -> str:
if segments_en is not None and len(segments_en) != len(segments_zh):
raise ValueError("Bilingual output requires equal-length zh/en segment lists.")
lines: list[str] = ["WEBVTT", ""]
for i, zh in enumerate(segments_zh):
if segments_en is not None:
en = segments_en[i]
if (en.start_ms, en.end_ms) != (zh.start_ms, zh.end_ms):
raise ValueError("Bilingual zh/en segments must share timestamps.")
text = f"{zh.text}\n{en.text}"
else:
text = zh.text
lines.extend(
[
f"{format_timestamp_vtt(zh.start_ms)} --> {format_timestamp_vtt(zh.end_ms)}",
text,
"",
]
)
return "\n".join(lines).rstrip("\n") + "\n"
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Segment:
start_ms: int
end_ms: int
text: str
def __post_init__(self) -> None:
if not isinstance(self.start_ms, int) or not isinstance(self.end_ms, int):
raise TypeError("start_ms/end_ms must be ints (milliseconds).")
if self.start_ms < 0:
raise ValueError("start_ms must be >= 0.")
if self.start_ms >= self.end_ms:
raise ValueError("start_ms must be < end_ms.")
if not isinstance(self.text, str):
raise TypeError("text must be a string.")
if not self.text.strip():
raise ValueError("text must be non-empty after stripping.")
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from .converters.srt_converter import srt_to_segments
from .errors import SubtitleContentError
from .segment import Segment
logger = logging.getLogger(__name__)
# VTT timestamp pattern: 00:01:23.456 (dot before ms)
_VTT_TS_RE = re.compile(r"(\d{2}:\d{2}:\d{2})\.(\d{3})")
def _normalize_vtt_timestamps(text: str) -> str:
"""Convert VTT timestamps (dot separator) to SRT format (comma separator).
Only touches timestamp lines — preserves dots in subtitle text content.
"""
lines = text.splitlines(keepends=True)
out: list[str] = []
for line in lines:
# A timestamp line contains --> and HH:MM:SS.mmm patterns
if "-->" in line and _VTT_TS_RE.search(line):
line = _VTT_TS_RE.sub(r"\1,\2", line)
out.append(line)
return "".join(out)
def check_title_relevance(segments: list[Segment], title: str | None) -> bool:
"""Check if subtitle content is relevant to the video title.
Uses character bigram matching for CJK titles and word matching for Latin titles.
Returns True (relevant) if any title token appears in the subtitle text,
or if the check cannot be performed (title too short / None).
"""
if not title or len(title) <= 2:
return True # Can't check, assume relevant
subtitle_text = " ".join(seg.text for seg in segments)
if not subtitle_text.strip():
return True # Empty text handled elsewhere
# Detect if title is primarily CJK
cjk_chars = sum(1 for c in title if "\u4e00" <= c <= "\u9fff")
if cjk_chars > len(title) * 0.3:
# CJK: generate character bigrams
clean = re.sub(r"\s+", "", title)
bigrams = {clean[i : i + 2] for i in range(len(clean) - 1)}
return any(bg in subtitle_text for bg in bigrams)
else:
# Latin/mixed: split by whitespace, filter short words
words = {w.lower() for w in title.split() if len(w) >= 3}
if not words:
return True
text_lower = subtitle_text.lower()
return any(w in text_lower for w in words)
@dataclass
class LoadResult:
"""Result of loading subtitles, with optional relevance warning."""
segments: list[Segment]
relevant: bool = True
def load_segments_from_subtitle_file(
path: str | Path, *, title: str | None = None
) -> LoadResult:
"""Load subtitle segments from SRT or VTT file.
Returns LoadResult with segments and a relevance flag.
Raises SubtitleContentError if the file produces zero segments.
"""
path = Path(path)
ext = path.suffix.lower().lstrip(".")
text = path.read_text(encoding="utf-8", errors="replace")
if ext == "vtt":
# Strip WEBVTT header, then normalize timestamps for SRT parser
text = _normalize_vtt_timestamps(text)
segments = srt_to_segments(text)
elif ext == "srt":
segments = srt_to_segments(text)
else:
raise ValueError(f"Unsupported subtitle extension: .{ext}")
if not segments:
raise SubtitleContentError("parsed subtitle file contains no segments")
relevant = check_title_relevance(segments, title)
if not relevant:
logger.warning(
"Subtitle content may not match video title %r", title
)
return LoadResult(segments=segments, relevant=relevant)
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Literal
from urllib.parse import urlparse
_BV_RE = re.compile(r"\b(BV[0-9A-Za-z]{10})\b")
_AV_RE = re.compile(r"\bav(\d+)\b", re.IGNORECASE)
VideoIdType = Literal["BV", "av", "unknown"]
@dataclass(frozen=True, slots=True)
class VideoRef:
id_type: VideoIdType
video_id: str | None
input_value: str
canonical_url: str | None
def parse_bilibili_ref(value: str) -> VideoRef:
value = (value or "").strip()
if not value:
raise ValueError("Empty input.")
# Raw IDs.
bv_match = _BV_RE.search(value)
if bv_match:
bv = bv_match.group(1)
return VideoRef(
id_type="BV",
video_id=bv,
input_value=value,
canonical_url=f"https://www.bilibili.com/video/{bv}/",
)
av_match = _AV_RE.search(value)
if av_match:
avid = f"av{av_match.group(1)}"
return VideoRef(
id_type="av",
video_id=avid,
input_value=value,
canonical_url=f"https://www.bilibili.com/video/{avid}/",
)
# URLs.
parsed = urlparse(value)
if parsed.scheme in {"http", "https"} and parsed.netloc:
# We might still be able to parse BV/av from the path.
bv_match = _BV_RE.search(parsed.path)
if bv_match:
bv = bv_match.group(1)
return VideoRef(
id_type="BV",
video_id=bv,
input_value=value,
canonical_url=f"https://www.bilibili.com/video/{bv}/",
)
av_match = _AV_RE.search(parsed.path)
if av_match:
avid = f"av{av_match.group(1)}"
return VideoRef(
id_type="av",
video_id=avid,
input_value=value,
canonical_url=f"https://www.bilibili.com/video/{avid}/",
)
return VideoRef(
id_type="unknown",
video_id=None,
input_value=value,
canonical_url=value,
)
return VideoRef(id_type="unknown", video_id=None, input_value=value, canonical_url=None)
#!/bin/bash
# bilibili-subtitle Skill Installer
set -e
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PIXI_MANIFEST="$SKILL_DIR/pixi.toml"
cd "$SKILL_DIR"
SKIP_PYTHON_INSTALL="${INSTALL_SKIP_PYTHON:-}"
BBDOWN_DRY_RUN="${BBDOWN_DRY_RUN:-}"
BBDOWN_FORCE_INSTALL="${BBDOWN_FORCE_INSTALL:-}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} Bilibili 字幕提取工具 安装程序${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# 1. 检查 pixi / 安装 Python 依赖
if [ -z "$SKIP_PYTHON_INSTALL" ]; then
echo -e "${YELLOW}[1/4] 检查 pixi...${NC}"
if ! command -v pixi &> /dev/null; then
echo -e "${RED}❌ 未找到 pixi,请先安装 pixi${NC}"
echo "安装方式:"
echo " curl -fsSL https://pixi.sh/install.sh | bash"
echo "安装完成后重新运行:"
echo " ./install.sh"
exit 1
fi
echo -e "${GREEN}✅ pixi 已安装${NC}"
# 2. 初始化 pixi 环境
echo ""
echo -e "${YELLOW}[2/4] 初始化 pixi 环境...${NC}"
if [ ! -f "$PIXI_MANIFEST" ]; then
echo -e "${RED}❌ 未找到 pixi.toml,请确认安装目录正确${NC}"
exit 1
fi
pixi install
echo -e "${GREEN}✅ pixi 环境就绪${NC}"
# 3. 安装 Python 依赖
echo ""
echo -e "${YELLOW}[3/4] 安装 Python 依赖...${NC}"
pixi run python -m pip install --upgrade pip -q
pixi run python -m pip install -e "$SKILL_DIR[claude,transcribe]" -q
pixi run python -m pip install dashscope -q
echo -e "${GREEN}✅ Python 依赖安装完成${NC}"
else
echo -e "${YELLOW}[1/4] 跳过 pixi/Python 安装 (INSTALL_SKIP_PYTHON=1)${NC}"
fi
# 4. 检查外部工具
echo ""
echo -e "${YELLOW}[4/4] 检查外部工具...${NC}"
# 检查 BBDown(总是检查 nightly 更新)
BBDOWN_OS="${BBDOWN_OS:-$(uname -s)}"
BBDOWN_ARCH="${BBDOWN_ARCH:-$(uname -m)}"
case "$BBDOWN_OS" in
Linux*) BBDOWN_OS="linux" ;;
Darwin*) BBDOWN_OS="osx" ;;
MINGW*|MSYS*|CYGWIN*|Windows_NT*) BBDOWN_OS="win" ;;
*) echo -e "${RED}❌ 无法识别操作系统: $BBDOWN_OS${NC}"; exit 1 ;;
esac
case "$BBDOWN_ARCH" in
x86_64|amd64) BBDOWN_ARCH="x64" ;;
arm64|aarch64) BBDOWN_ARCH="arm64" ;;
*) echo -e "${RED}❌ 无法识别架构: $BBDOWN_ARCH${NC}"; exit 1 ;;
esac
if ! command -v gh &> /dev/null; then
echo -e "${RED}❌ 需要 gh CLI 来下载 BBDown nightly build${NC}"
echo "安装方式: https://cli.github.com/"
exit 1
fi
BBDOWN_ARTIFACT="BBDown_${BBDOWN_OS}-${BBDOWN_ARCH}"
BBDOWN_BIN="$HOME/.local/bin"
BBDOWN_TMP="/tmp/bbdown-dl"
mkdir -p "$BBDOWN_BIN" "$BBDOWN_TMP"
if [ -n "$BBDOWN_DRY_RUN" ]; then
echo "BBDOWN_ARTIFACT=$BBDOWN_ARTIFACT"
else
echo "正在检查 BBDown nightly 更新..."
BBDOWN_RUN_ID=$(gh run list -R nilaoda/BBDown -b master -s success --limit 1 --json databaseId -q '.[0].databaseId')
if [ -z "$BBDOWN_RUN_ID" ]; then
echo -e "${RED}❌ 无法获取 BBDown 最新构建${NC}"
exit 1
fi
rm -rf "$BBDOWN_TMP"/*
if gh run download "$BBDOWN_RUN_ID" -R nilaoda/BBDown --name "$BBDOWN_ARTIFACT" -D "$BBDOWN_TMP"; then
# 解压到临时目录
BBDOWN_EXTRACT="/tmp/bbdown-extract"
rm -rf "$BBDOWN_EXTRACT"
mkdir -p "$BBDOWN_EXTRACT"
BBDOWN_ZIP=$(find "$BBDOWN_TMP" -name '*.zip' | head -1)
if [ -n "$BBDOWN_ZIP" ]; then
unzip -q -o "$BBDOWN_ZIP" -d "$BBDOWN_EXTRACT"
else
cp "$BBDOWN_TMP"/BBDown "$BBDOWN_EXTRACT/" 2>/dev/null || cp "$BBDOWN_TMP"/BBDown* "$BBDOWN_EXTRACT/"
fi
NEW_BIN="$BBDOWN_EXTRACT/BBDown"
OLD_BIN="$BBDOWN_BIN/BBDown"
if [ -f "$OLD_BIN" ]; then
OLD_MD5=$(md5sum "$OLD_BIN" 2>/dev/null | cut -d' ' -f1)
NEW_MD5=$(md5sum "$NEW_BIN" 2>/dev/null | cut -d' ' -f1)
if [ "$OLD_MD5" = "$NEW_MD5" ]; then
echo -e "${GREEN}✅ BBDown 已是最新 (build #${BBDOWN_RUN_ID})${NC}"
else
cp "$NEW_BIN" "$OLD_BIN"
chmod +x "$OLD_BIN"
echo -e "${GREEN}✅ BBDown 已更新到 nightly build #${BBDOWN_RUN_ID}${NC}"
fi
else
cp "$NEW_BIN" "$OLD_BIN"
chmod +x "$OLD_BIN"
echo -e "${GREEN}✅ BBDown (nightly build #${BBDOWN_RUN_ID}) 安装完成${NC}"
if [[ ":$PATH:" != *":$BBDOWN_BIN:"* ]]; then
echo -e "${YELLOW}⚠️ 请将 $BBDOWN_BIN 添加到 PATH${NC}"
fi
fi
rm -rf "$BBDOWN_TMP" "$BBDOWN_EXTRACT"
else
echo -e "${RED}❌ BBDown 下载失败${NC}"
echo "请确认 gh 已登录: gh auth status"
exit 1
fi
fi
# 检查 ffmpeg
if [ -z "$SKIP_PYTHON_INSTALL" ]; then
if pixi run ffmpeg -version &> /dev/null; then
FFMPEG_VERSION=$(pixi run ffmpeg -version 2>/dev/null | head -n1 | awk '{print $3}' || echo "unknown")
echo -e "${GREEN}✅ ffmpeg 已安装 ($FFMPEG_VERSION)${NC}"
else
echo -e "${YELLOW}⚠️ ffmpeg 未安装(pixi 环境内未找到)${NC}"
echo "请检查 pixi 环境或重新运行:"
echo " pixi install"
fi
else
if command -v ffmpeg &> /dev/null; then
FFMPEG_VERSION=$(ffmpeg -version 2>/dev/null | head -n1 | awk '{print $3}' || echo "unknown")
echo -e "${GREEN}✅ ffmpeg 已安装 ($FFMPEG_VERSION)${NC}"
else
echo -e "${YELLOW}⚠️ ffmpeg 未安装${NC}"
fi
fi
# 5. 配置指导
echo ""
echo -e "${YELLOW}[5/5] 配置指导${NC}"
echo ""
echo -e "${BLUE}🔑 API Keys 配置${NC}"
echo ""
echo "请设置以下环境变量(添加到 ~/.zshrc 或 ~/.bashrc):"
echo ""
echo -e "${GREEN}# Anthropic API (校对/翻译/摘要)${NC}"
echo "export ANTHROPIC_API_KEY=\"your-api-key\""
echo ""
echo -e "${GREEN}# DashScope API (ASR 转录,仅无字幕时需要)${NC}"
echo "export DASHSCOPE_API_KEY=\"your-api-key\""
echo ""
echo -e "${BLUE}🔐 BBDown 认证${NC}"
echo ""
echo "首次使用前,请运行:"
echo -e "${GREEN} BBDown login${NC}"
echo "扫描二维码完成登录,Cookie 保存在 BBDown.data"
echo ""
echo -e "${BLUE}🧪 安装后自检${NC}"
echo ""
echo "建议运行:"
echo -e "${GREEN} pixi run python -m bilibili_subtitle --help${NC}"
echo -e "${GREEN} pixi run python -m bilibili_subtitle \"BV1xx411c7mD\" --skip-proofread --skip-summary -o ./output${NC}"
echo ""
# 最终检查
echo ""
echo -e "${BLUE}========================================${NC}"
echo -e "${GREEN}✅ 安装完成!${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
echo "📦 安装位置:$SKILL_DIR"
echo ""
echo "🚀 使用示例:"
echo -e " ${GREEN}pixi run python -m bilibili_subtitle \"BV1234567890\" --skip-proofread --skip-summary${NC}"
echo ""
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"edit": "allow",
"bash": "allow",
"skill": "allow",
"webfetch": "allow",
"doom_loop": "allow",
"external_directory": "allow"
}
}
[workspace]
name = "bilibili-subtitle"
version = "0.2.0"
description = "Extract Bilibili subtitles or transcribe audio to generate transcripts."
channels = ["conda-forge"]
platforms = ["linux-64", "osx-arm64", "osx-64", "win-64"]
[dependencies]
python = "3.11.*"
pip = "*"
ffmpeg = "*"Bilibili Subtitle Extraction Skill - Solution Design
Generated by all-plan collaborative design process
Overview
Goal: Build a Claude Code skill that reliably extracts Bilibili subtitles (supporting both with/without subtitle paths) and generates structured transcripts with summaries.
Readiness Score: 88/100
Generated: 2026-01-22
---
Requirements Summary
Problem Statement
创建一个 Claude Code skill,用于提取 B站视频字幕并生成总结。支持两种路径: 1. 有 AI 字幕的视频:直接提取、校对、输出逐字稿 2. 无字幕视频:抓取音频、多模态模型转写、生成带时间轴字幕
Scope
In Scope:
- B站视频字幕提取 (BV/av 号)
- 音频转写 (无字幕视频)
- 字幕校对与优化
- 内容摘要生成
- SRT/VTT/Markdown 输出
- 双语输出支持 (zh+en 单文件)
Out of Scope:
- 批量爬取
- 付费/会员专属内容破解
- 实时直播字幕
Success Criteria
- [ ] Path A: 成功提取有字幕视频,输出 SRT/VTT 匹配 golden files
- [ ] Path B: 成功转写无字幕视频,生成严格递增时间戳的字幕
- [ ] 摘要输出符合 JSON schema,包含时间戳引用
Constraints
- 必须遵守 B站平台政策
- 用户提供认证 (cookies),不自动破解
- 优雅处理速率限制
- 使用 Claude Agent SDK 构建转写管道
Assumptions
- 用户可提供有效的 B站 cookies (如需)
- 目标视频可通过 yt-dlp 访问
- Claude API 可用于转写和摘要
---
Architecture
Approach
采用双路径架构,根据视频是否有字幕自动选择处理路径:
┌──────────────────────────────────────────────────────────────┐
│ bilibili-subtitle skill │
├──────────────────────────────────────────────────────────────┤
│ Entry: SKILL.md + python -m bilibili_subtitle │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │URL Parser│──▶│SubtitleDetector │──▶│Path Selector │ │
│ │(BV/av ID)│ │(yt-dlp metadata)│ │ │ │
│ └──────────┘ └─────────────────┘ └────────┬────────┘ │
│ │ │
│ ┌────────────────────────┼────────┐ │
│ ▼ ▼ │ │
│ ┌────────────┐ ┌────────────┐ │ │
│ │ Path A │ │ Path B │ │ │
│ │ Subtitle │ │ Audio │ │ │
│ │ Extract │ │ Transcribe│ │ │
│ └─────┬──────┘ └─────┬──────┘ │ │
│ │ │ │ │
│ └───────────┬───────────┘ │ │
│ ▼ │ │
│ ┌────────────────┐ │ │
│ │ Proofreader │ │ │
│ │ (Claude) │ │ │
│ └───────┬────────┘ │ │
│ ▼ │ │
│ ┌────────────────┐ │ │
│ │ Summarizer │ │ │
│ │ (Claude) │ │ │
│ └───────┬────────┘ │ │
│ ▼ │ │
│ ┌────────────────┐ │ │
│ │ Output │ │ │
│ │ SRT/VTT/MD │ │ │
│ └────────────────┘ │ │
└──────────────────────────────────────────────────────────────┘Key Components
- URL Parser: 解析 BV/av 号,提取视频 ID
- SubtitleDetector: 使用 yt-dlp 检测可用字幕轨道
- Path A (SubtitleExtractor): 下载并转换现有字幕
- Path B (AudioTranscriber): 音频提取 + Claude Agent SDK 转写
- Proofreader: Claude 校对专有名词、错别字
- Summarizer: 生成结构化摘要
- Renderer: 输出 SRT/VTT/Markdown 格式
Core Data Model
@dataclass
class Segment:
start_ms: int
end_ms: int
text: str
# Invariants:
# - start_ms < end_ms
# - start_ms >= 0
# - text is non-empty after stripSubtitleDetector Decision Table
| Condition | Action |
|---|---|
yt-dlp finds ai-zh subtitle | Path A (AI subtitle) |
yt-dlp finds zh subtitle | Path A (official subtitle) |
yt-dlp finds zh-Hans subtitle | Path A (simplified Chinese) |
| yt-dlp finds any subtitle | Path A (fallback) |
| No subtitle found | Path B (audio transcription) |
Priority: ai-zh > zh > zh-Hans > any > Path B
---
Implementation Plan
Step 1: URL Parsing + Metadata Extraction
- Actions:
- 实现 BV/av 号解析器
- 集成 yt-dlp
extract_info(download=False) - 定义
Segment数据模型 - 实现字幕轨道检测逻辑
- Deliverables:
url_parser.py,segment.py,detector.py - Dependencies: yt-dlp 安装
Step 2: Subtitle Discovery & Converters
- Actions:
- 实现 yt-dlp 字幕下载 (
--write-subs --sub-lang zh,ai-zh) - 实现 JSON → Segment 转换器
- 实现 ASS → Segment 转换器
- 添加字幕语言映射层
- Deliverables:
subtitle_downloader.py,converters/json_converter.py,converters/ass_converter.py - Dependencies: Step 1
Step 3: Path A - Proofreading Agent
- Actions:
- 使用 Claude Agent SDK 创建 ProofreadAgent
- 定义校对 prompt (仅修改文本,保留时间戳)
- 实现 diff 输出用于审计
- Deliverables:
agents/proofread_agent.py - Dependencies: Step 2, Claude Agent SDK
Step 4: Path B - Audio Transcription Pipeline
- Actions:
- 实现 yt-dlp 音频提取 (
--extract-audio --audio-format m4a) - 实现 ffmpeg 分块 (60s + 2s overlap)
- 创建 TranscribeAgent (Claude Agent SDK)
- 实现重叠感知合并算法
- 验证时间戳单调性
- Deliverables:
audio_extractor.py,chunker.py,agents/transcribe_agent.py,merger.py - Dependencies: Step 1, ffmpeg, Claude Agent SDK
Step 5: Output Renderers
- Actions:
- 实现 Segment[] → SRT 渲染器
- 实现 Segment[] → VTT 渲染器
- 实现 Segment[] → Markdown 逐字稿渲染器
- 支持双语输出 (zh+en 单文件格式)
- Deliverables:
renderers/srt.py,renderers/vtt.py,renderers/markdown.py - Dependencies: Step 3, Step 4
Step 6: Summarization Agent
- Actions:
- 创建 SummarizeAgent (Claude Agent SDK)
- 定义 JSON schema:
{key_points[], outline[], entities[], timestamps[]} - 实现 summary.md + summary.json 输出
- Deliverables:
agents/summarize_agent.py,schemas/summary_schema.json - Dependencies: Step 5
Step 7: CLI & Skill Integration
- Actions:
- 实现 CLI 入口 (
python -m bilibili_subtitle) - 创建 SKILL.md (Claude Code skill 定义)
- 实现缓存/恢复机制
- 添加 cookies 支持
- Deliverables:
__main__.py,SKILL.md,cache.py - Dependencies: Step 6
Step 8: Testing Suite
- Actions:
- 单元测试: converters, renderers, merge algorithm
- 契约测试: agent JSON schema 验证
- 集成测试: recorded fixtures
- E2E 测试: behind
--run-e2eflag - Deliverables:
tests/directory - Dependencies: Step 7
---
Technical Considerations
- 字幕语言 ID 映射: B站/yt-dlp 实际输出的语言 ID 可能与预期不同,需实现映射层并在
--verbose模式下记录可用轨道 - 双语输出格式:
zh+en模式生成单个双语字幕文件,每条字幕同时显示中英文 - Path B 合并算法: 使用 Levenshtein 相似度 (阈值 0.8) 检测重叠,保留 chunk N 版本,调整 chunk N+1 起始时间
- 时间戳单调性: 强制递增时间戳,允许间隙,仅在重叠区域去重
- 摘要时间戳引用: 格式
{start_ms, end_ms, segment_indices[]} - 缓存安全: 不缓存 cookies,仅缓存派生产物;URL 中的 token 需脱敏
---
Risk Management
| Risk | Impact | Likelihood | Mitigation |
|---|---|---|---|
| B站 API/格式变化 | High | Medium | yt-dlp 优先,fallback 策略,清晰诊断信息 |
| 多模态转写可用性/限制 | Medium | Medium | 分块处理,缓存,可恢复运行 |
| 时间戳漂移/合并伪影 | Medium | Medium | 重叠策略 + 验证 + 问题区域重处理 |
| 速率限制 | Medium | High | 指数退避,max_workers=2,元数据缓存 |
| TOS/政策风险 | High | Low | 无批量爬取,仅用户提供认证 |
| Secrets 泄露 | High | Low | 不记录 cookies,安全存储 |
---
Acceptance Criteria
- [ ] Path A: 输出 SRT/VTT 匹配 golden files (时间戳保留且有效)
- [ ] Path B: 生成严格递增时间戳的 SRT/VTT,跨 chunk 偏移正确
- [ ] 摘要输出符合 schema,引用逐字稿中的时间戳/章节
---
Design Contributors
| CLI | Key Contributions |
|---|---|
| Claude | 架构图设计,校对环节强调,多种摘要格式 |
| Codex | 分块策略 (60s+2s),Segment 数据模型,测试策略,合并算法细节 |
| Gemini | (未配置) |
| OpenCode | (超时) |
---
Appendix
Clarification Summary
Readiness Score: 88/100
Dimensions:
- Problem Clarity: 24/30 ✓ (新功能,明确业务价值)
- Functional Scope: 20/25 ✓ (多个相关组件)
- Success Criteria: 18/20 ✓ (自动化测试)
- Constraints: 14/15 ✓ (平台限制/合规)
- Priority/MVP: 9/10 ✓ (完整功能,单次发布)CLI Interface
bilibili-subtitle <URL> [OPTIONS]
--output-dir DIR Output directory (default: ./output)
--output-lang LANG zh|en|zh+en (default: zh)
--cookies-from-browser BROWSER
--cookies-file FILE
--skip-proofread Skip Claude proofreading
--skip-summary Skip summarization
--cache-dir DIR Cache directory for resume
--verbose Show available subtitle tracksOutput Files Structure
output/
├── {video_id}.zh.srt # 中文字幕
├── {video_id}.zh.vtt # 中文字幕 (VTT)
├── {video_id}.zh+en.srt # 双语字幕 (if --output-lang zh+en)
├── {video_id}.transcript.md # 逐字稿
├── {video_id}.summary.md # 摘要 (Markdown)
└── {video_id}.summary.json # 摘要 (JSON)Alternative Approaches Considered
1. Whisper 本地转写: 考虑使用 OpenAI Whisper 本地模型,但选择 Claude Agent SDK 以保持一致性和更好的中文支持 2. B站官方 API: 考虑直接调用 B站 player API,但选择 yt-dlp 优先以获得更好的稳定性和社区支持 3. 实时流式转写: 考虑流式处理长视频,但选择分块方式以简化实现和错误恢复
[project]
name = "bilibili-subtitle"
version = "0.1.0"
description = "Extract Bilibili subtitles or transcribe audio to generate transcripts."
requires-python = ">=3.11"
dependencies = [
"yt-dlp>=2025.1.15",
]
[project.optional-dependencies]
claude = [
"anthropic>=0.45.0",
]
transcribe = [
"dashscope>=1.20.0",
"openai>=1.0.0",
]
dev = [
"jsonschema>=4.20.0",
"pytest>=8.0.0",
]
[tool.pytest.ini_options]
addopts = "-q"
testpaths = ["tests"]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
[tool.setuptools.packages.find]
where = ["."]
include = ["bilibili_subtitle*"]
exclude = ["plans*", "tests*"]
bilibili-subtitle
Bilibili 字幕提取 Skill,支持:
- 优先下载原生字幕/AI 字幕
- 无字幕时自动走音频下载 + ASR 转录
- 输出 SRT / VTT / Markdown transcript / 结构化摘要
该仓库可独立使用,也可作为上层编排器(如 anything-to-notebooklm)的子 Skill。
Quick Start
# Claude Code
# git clone https://github.com/HamsteRider-m/bilibili-subtitle.git ~/.claude/skills/bilibili-subtitle
# Codex/Agents
git clone https://github.com/HamsteRider-m/bilibili-subtitle.git ~/.agents/skills/bilibili-subtitle
cd ~/.agents/skills/bilibili-subtitle
# 一键安装(pixi + Python 依赖 + BBDown/ffmpeg 检查)
./install.sh首次使用前:
BBDown login安装后自检:
pixi run python -m bilibili_subtitle --help
pixi run python -m bilibili_subtitle "BV1xx411c7mD" --skip-proofread --skip-summary -o ./output依赖说明
| 依赖 | 用途 | 是否必须 |
|---|---|---|
| pixi | 固定 Python/工具环境 | 是 |
| BBDown | B站元信息/字幕/音频抓取 | 是 |
| ffmpeg | 音频格式转换(ASR 路径) | 是 |
DASHSCOPE_API_KEY | 无字幕视频时的 ASR | 条件必须 |
ANTHROPIC_API_KEY | 校对与摘要 | 可选 |
说明:
- 如果不需要 LLM 校对/摘要,可加
--skip-proofread --skip-summary - 如果视频本身有字幕,可不配置
DASHSCOPE_API_KEY
CLI 用法
pixi run python -m bilibili_subtitle "URL_OR_BVID" [options]常用参数:
-o, --output-dir输出目录(默认./output)--output-langzh/en/zh+en--skip-proofread跳过校对--skip-summary跳过摘要--cache-dir缓存目录(默认./.cache)-v, --verbose打印详细日志
输出文件
{video_id}.zh.srt{video_id}.zh.vtt{video_id}.transcript.md{video_id}.summary.json(未跳过摘要时){video_id}.summary.md(未跳过摘要时)
作为子 Skill 被调用(集成契约)
推荐父 Skill 按以下契约调用:
- 输入:Bilibili URL 或 BV ID
- 命令:
pixi run python -m bilibili_subtitle "<url-or-bv>" -o /tmp --skip-summary - 成功条件:退出码
0且输出目录存在*.transcript.md - 主产物:
{video_id}.transcript.md
建议父 Skill:
- 把本 Skill 当作可选能力(没装就降级,不要中断全流程)
- 只依赖命令与输出文件,不耦合内部 Python 模块
常见问题
command not found: BBDown- 重新执行
./install.sh - 或手动安装:
https://github.com/nilaoda/BBDown/releases Missing DASHSCOPE_API_KEY- 仅在无字幕视频且需要转录时出现
Missing ANTHROPIC_API_KEY- 设置 Key,或使用
--skip-proofread --skip-summary
License
MIT
AI Agent Configuration
Overview
This skill uses AI agents for optional enhancement features:
1. ProofreadAgent - Fix ASR errors, punctuation 2. SummarizeAgent - Generate structured summaries 3. TranscribeAgent - ASR for videos without subtitles
API Keys
| Agent | Key | Environment Variable | Required For |
|---|---|---|---|
| ProofreadAgent | Anthropic | ANTHROPIC_API_KEY | --skip-proofread=false |
| SummarizeAgent | Anthropic | ANTHROPIC_API_KEY | --skip-summary=false |
| TranscribeAgent (Qwen) | DashScope | DASHSCOPE_API_KEY | ASR fallback |
| TranscribeAgent (OpenAI) | OpenAI | OPENAI_API_KEY | ASR fallback (alternative) |
Agent Details
ProofreadAgent
Model: Claude 3.5 Sonnet Purpose: Fix typos, punctuation, ASR errors in subtitles Input: List of segments with timestamps Output: Corrected segments (timestamps unchanged)
from bilibili_subtitle.agents.proofread_agent import ProofreadAgent
agent = ProofreadAgent(model="claude-3-5-sonnet-latest")
result = agent.proofread(segments)
print(result.changes) # List of corrections madeSkip: --skip-proofread
SummarizeAgent
Model: Claude 3.5 Sonnet Purpose: Generate structured summary with key points, outline, entities Input: Transcript segments Output: JSON summary matching schemas/summary_schema.json
from bilibili_subtitle.agents.summarize_agent import SummarizeAgent
agent = SummarizeAgent()
result = agent.summarize(segments, title="Video Title")
print(result.summary["key_points"])Skip: --skip-summary
TranscribeAgent
Modes:
qwen(default) - Uses Qwen ASR via DashScopeopenai- Uses OpenAI Whisper
from bilibili_subtitle.agents.transcribe_agent import TranscribeAgent
agent = TranscribeAgent(mode="qwen", model="qwen3-asr-flash")
result = agent.transcribe("audio.wav")
print(len(result.segments))Configuration
Environment Variables
# Required for proofreading/summarization
export ANTHROPIC_API_KEY="sk-ant-..."
# Required for ASR (when video has no subtitles)
export DASHSCOPE_API_KEY="sk-..."
# Alternative ASR backend
export OPENAI_API_KEY="sk-..."Cost Optimization
| Use Case | Recommended Flags |
|---|---|
| Bulk extraction, no AI | --skip-proofread --skip-summary |
| Quick check | --skip-summary |
| Full processing | (default) |
Error Handling
Agents raise RuntimeError when API key is missing:
try:
result = agent.proofread(segments)
except RuntimeError as e:
if "ANTHROPIC_API_KEY" in str(e):
# Skip or prompt user
passCustom Models
# Use different Claude model
agent = ProofreadAgent(model="claude-3-opus-latest")
# Use different Qwen ASR model
agent = TranscribeAgent(mode="qwen", model="qwen-asr-large")Sub-Skill Invocation Contract
Version: 1.0.0
Standard Invocation
pixi run python -m bilibili_subtitle "<URL>" \
-o /tmp/output \
--skip-summary \
--json-outputExit Codes
| Code | Meaning | Description |
|---|---|---|
| 0 | SUCCESS | All outputs generated successfully |
| 1 | FATAL_ERROR | Cannot proceed, fix required |
| 2 | RECOVERABLE_ERROR | Partial success, some outputs missing |
| 3 | PARTIAL_SUCCESS | Success with warnings |
Success Criteria
1. Exit code is 0 or 2 2. Output directory contains *.transcript.md
Output Schema
{
"exit_code": 0,
"success": true,
"output": {
"video_id": "BV1xxx",
"title": "视频标题",
"files": {
"transcript": "/path/to/xxx.transcript.md",
"srt": "/path/to/xxx.srt",
"vtt": "/path/to/xxx.vtt",
"summary_json": null,
"summary_md": null
}
},
"warnings": ["ANTHROPIC_API_KEY not set"],
"errors": [],
"metadata": {
"url": "https://..."
}
}Required Outputs
*.transcript.md- Markdown transcript (always generated on success)
Optional Outputs
*.srt- SRT subtitles*.vtt- VTT subtitles*.summary.json- Structured summary*.summary.md- Summary markdown
Parent Skill Integration
Python API
from bilibili_subtitle import build_cli_command, run_preflight
# Build command
cmd = build_cli_command(
"BV1xxx",
output_dir="/tmp/output",
skip_proofread=True,
skip_summary=True,
)
# ['pixi', 'run', 'python', '-m', 'bilibili_subtitle', 'BV1xxx', ...]
# Check preflight
report = run_preflight(include_auth=True)
if not report.can_proceed:
# Handle errors
passShell Integration
# Run with JSON output
result=$(pixi run python -m bilibili_subtitle "$URL" --json-output -o /tmp/out)
# Parse exit code
exit_code=$?
# Check success
if [ $exit_code -eq 0 ] || [ $exit_code -eq 2 ]; then
# Find transcript
transcript=$(find /tmp/out -name "*.transcript.md" | head -1)
echo "Transcript: $transcript"
fiBatch Processing Pattern
import json
import subprocess
from pathlib import Path
def process_videos(urls: list[str], output_base: Path) -> dict:
results = {}
for url in urls:
output_dir = output_base / url.split("/")[-1]
result = subprocess.run(
["pixi", "run", "python", "-m", "bilibili_subtitle",
url, "-o", str(output_dir), "--json-output", "--skip-summary"],
capture_output=True, text=True,
)
results[url] = json.loads(result.stdout)
return resultsError Handling Guide
Error Levels
| Level | Meaning | Exit Code | Action |
|---|---|---|---|
| FATAL | Cannot proceed | 1 | Fix before retry |
| RECOVERABLE | Degraded functionality | 2 | Proceed with warnings |
| WARNING | Non-critical | 0 | Log and continue |
Error Codes
FATAL Errors (Exit 1)
| Code | Error | Cause | Solution |
|---|---|---|---|
| E001 | BBDownNotFoundError | BBDown not in PATH | Run ./install.sh |
| E002 | BBDownAuthError | Not logged in to Bilibili | Run BBDown login |
| E005 | ASRConfigError | DASHSCOPE_API_KEY not set | Export API key |
| E007 | FFmpegNotFoundError | ffmpeg not installed | Run pixi install |
| E008 | InvalidURLError | Invalid BV/URL format | Provide correct URL |
| E010 | VideoNotFoundError | Video deleted/private | Check video availability |
RECOVERABLE Errors (Exit 2)
| Code | Error | Cause | Solution |
|---|---|---|---|
| E003 | BBDownDownloadError | Network/URL issue | Retry or check URL |
| E004 | NoSubtitleError | Video has no subtitles | ASR will be attempted |
| E006 | AnthropicConfigError | ANTHROPIC_API_KEY not set | Use --skip-* or set key |
JSON Error Output
When using --json-output, errors are returned as:
{
"exit_code": 1,
"error": {
"code": "E002",
"level": "fatal",
"message": "BBDown authentication required",
"remediation": {
"hint": "Login to Bilibili via BBDown",
"command": "BBDown login",
"doc_url": null
}
}
}Programmatic Handling
from bilibili_subtitle.errors import SkillError, exit_code_for_error
try:
result = run_extraction(url, output_dir)
except SkillError as e:
print(f"[{e.code}] {e.message}")
if e.remediation:
print(f" → {e.remediation.hint}")
exit(exit_code_for_error(e))Retry Strategy
| Error Level | Retry? | Wait Time |
|---|---|---|
| FATAL | No | N/A |
| RECOVERABLE | Yes | 5-30s exponential backoff |
| WARNING | N/A | N/A |
Preflight Check Guide
Overview
Preflight checks validate the execution environment before running the main extraction workflow.
Usage
Basic Check
pixi run python -m bilibili_subtitle --checkJSON Output (for parent skills)
pixi run python -m bilibili_subtitle --check --check-jsonOutput:
{
"checks": [
{"name": "BBDown", "status": "ok", "message": "Installed (1.6.3)"},
{"name": "BBDown Auth", "status": "ok", "message": "Logged in"},
{"name": "ffmpeg", "status": "ok", "message": "Installed"},
{"name": "ANTHROPIC_API_KEY", "status": "warning", "message": "Not set"},
{"name": "DASHSCOPE_API_KEY", "status": "warning", "message": "Not set"}
],
"summary": {
"can_proceed": true
}
}Check Items
| Check | Status Impact | Required For |
|---|---|---|
| BBDown | ERROR if missing | All operations |
| BBDown Auth | ERROR if not logged in | Video info, subtitle download |
| ffmpeg | ERROR if missing | ASR transcription |
| ANTHROPIC_API_KEY | WARNING if missing | Proofreading, summarization |
| DASHSCOPE_API_KEY | WARNING if missing | ASR transcription |
Integration Pattern
Parent skills should run preflight checks with --check-json and parse the result:
import subprocess
import json
result = subprocess.run(
["pixi", "run", "python", "-m", "bilibili_subtitle", "--check", "--check-json"],
capture_output=True,
text=True,
)
report = json.loads(result.stdout)
if not report["summary"]["can_proceed"]:
# Handle fatal errors
for check in report["checks"]:
if check["status"] == "error":
print(f"Fatal: {check['name']} - {check['remediation']}")
exit(1)
# Check for optional features
has_asr = any(
c["name"] == "DASHSCOPE_API_KEY" and c["status"] == "ok"
for c in report["checks"]
){
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Bilibili Subtitle Summary Schema",
"type": "object",
"additionalProperties": false,
"required": ["key_points", "outline", "entities", "timestamps"],
"properties": {
"key_points": {
"type": "array",
"items": { "type": "string" }
},
"outline": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["title", "start_ms", "end_ms"],
"properties": {
"title": { "type": "string" },
"start_ms": { "type": "integer", "minimum": 0 },
"end_ms": { "type": "integer", "minimum": 0 }
}
}
},
"entities": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "type"],
"properties": {
"name": { "type": "string" },
"type": { "type": "string" },
"description": { "type": "string" }
}
}
},
"timestamps": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["start_ms", "end_ms", "segment_indices"],
"properties": {
"start_ms": { "type": "integer", "minimum": 0 },
"end_ms": { "type": "integer", "minimum": 0 },
"segment_indices": {
"type": "array",
"items": { "type": "integer", "minimum": 0 }
},
"note": { "type": "string" }
}
}
}
}
}
"""Tests for bbdown_client.py — Fix 1 (retry/timeout), Fix 5 (regex), Fix 7 (error propagation)."""
from __future__ import annotations
import subprocess
from unittest.mock import patch
import pytest
from bilibili_subtitle.bbdown_client import (
BBDownClient,
BBDownError,
_SUBTITLE_LINE_RE,
_AI_MARKER_RE,
_LANG_RE,
_LANG_NORMALIZE,
)
# ── Fix 5: Regex tests ──
class TestSubtitleLineRegex:
@pytest.mark.parametrize("line", [
"下载字幕 zh-Hans", "Download subtitle for BV123",
"Saving subtitle file...", "字幕下载完成",
])
def test_matches(self, line: str):
assert _SUBTITLE_LINE_RE.search(line)
def test_no_match(self):
assert _SUBTITLE_LINE_RE.search("downloading video") is None
class TestAIMarkerRegex:
@pytest.mark.parametrize("line", [
"ai-zh subtitle", "AI识别字幕", "auto-generated captions",
"asr transcription", "自动识别", "ai_en",
])
def test_matches(self, line: str):
assert _AI_MARKER_RE.search(line)
def test_no_match(self):
assert _AI_MARKER_RE.search("human translated") is None
class TestLangRegex:
@pytest.mark.parametrize("text,expected", [
("zh-Hans", "zh"), ("zh-Hant", "zh-hant"),
("en", "en"), ("ja", "ja"), ("ko", "ko"),
])
def test_normalize(self, text: str, expected: str):
m = _LANG_RE.search(text)
assert m
raw = m.group(1).lower()
assert _LANG_NORMALIZE.get(raw, raw) == expected
# ── Fix 1: Retry + timeout ──
def _make_client() -> BBDownClient:
with patch.object(BBDownClient, "_find_bbdown", return_value="/usr/bin/BBDown"):
return BBDownClient()
@patch("bilibili_subtitle.bbdown_client.time.sleep")
@patch("bilibili_subtitle.bbdown_client.subprocess.run")
def test_succeeds_first_try(mock_run, mock_sleep):
mock_run.return_value = subprocess.CompletedProcess([], 0, "ok", "")
assert _make_client()._run(["x"]).returncode == 0
mock_sleep.assert_not_called()
@patch("bilibili_subtitle.bbdown_client.time.sleep")
@patch("bilibili_subtitle.bbdown_client.subprocess.run")
def test_retries_transient(mock_run, mock_sleep):
mock_run.side_effect = [
subprocess.CompletedProcess([], 1, "", "network error"),
subprocess.CompletedProcess([], 0, "ok", ""),
]
assert _make_client()._run(["x"], retry_delay=0.01).returncode == 0
assert mock_run.call_count == 2
@patch("bilibili_subtitle.bbdown_client.time.sleep")
@patch("bilibili_subtitle.bbdown_client.subprocess.run")
def test_no_retry_fatal(mock_run, mock_sleep):
mock_run.return_value = subprocess.CompletedProcess([], 1, "", "login required auth")
with pytest.raises(BBDownError, match="non-retryable"):
_make_client()._run(["x"])
assert mock_run.call_count == 1
@patch("bilibili_subtitle.bbdown_client.time.sleep")
@patch("bilibili_subtitle.bbdown_client.subprocess.run")
def test_retries_timeout(mock_run, mock_sleep):
mock_run.side_effect = [
subprocess.TimeoutExpired("x", 120),
subprocess.CompletedProcess([], 0, "ok", ""),
]
assert _make_client()._run(["x"], retry_delay=0.01).returncode == 0
@patch("bilibili_subtitle.bbdown_client.time.sleep")
@patch("bilibili_subtitle.bbdown_client.subprocess.run")
def test_exhausts_retries(mock_run, mock_sleep):
mock_run.return_value = subprocess.CompletedProcess([], 1, "", "transient")
with pytest.raises(BBDownError):
_make_client()._run(["x"], max_retries=2, retry_delay=0.01)
assert mock_run.call_count == 2
# ── Fix 5: _extract_subtitle_info ──
@patch("bilibili_subtitle.bbdown_client.time.sleep")
def test_extract_subtitle_info_ai_zh(mock_sleep):
client = _make_client()
info = client._extract_subtitle_info("下载字幕 ai-zh\n其他行")
assert info.has_subtitle is True
assert info.has_ai_subtitle is True
assert "zh" in info.languages
@patch("bilibili_subtitle.bbdown_client.time.sleep")
def test_extract_subtitle_info_no_subtitle(mock_sleep):
client = _make_client()
info = client._extract_subtitle_info("视频标题: test\n完成")
assert info.has_subtitle is False
assert info.languages == []
from bilibili_subtitle.cache import Cache
from bilibili_subtitle.segment import Segment
def test_cache_roundtrip(tmp_path) -> None:
cache = Cache(tmp_path)
segs = [Segment(0, 1000, "a")]
cache.save_segments("BV1xxx", "segments.zh", segs)
loaded = cache.load_segments("BV1xxx", "segments.zh")
assert loaded == segs
import subprocess
import sys
def test_cli_help_runs() -> None:
proc = subprocess.run(
[sys.executable, "-m", "bilibili_subtitle", "--help"],
capture_output=True,
text=True,
check=True,
)
output = proc.stdout + proc.stderr
assert "usage: bilibili_subtitle" in output
assert "Extract Bilibili subtitles" in output
from bilibili_subtitle.converters.srt_converter import srt_to_segments
def test_srt_to_segments_basic() -> None:
srt = "1\n00:00:00,000 --> 00:00:01,000\nhello\n\n2\n00:00:01,000 --> 00:00:02,000\nworld\n"
segments = srt_to_segments(srt)
assert [(s.start_ms, s.end_ms, s.text) for s in segments] == [
(0, 1000, "hello"),
(1000, 2000, "world"),
]
from bilibili_subtitle.bbdown_client import SubtitleInfo, VideoInfo
def test_video_info_with_subtitle() -> None:
info = VideoInfo(
video_id="BV1234567890",
title="Test Video",
subtitle_info=SubtitleInfo(has_subtitle=True, has_ai_subtitle=True, languages=["zh"]),
subtitle_files=[],
)
assert info.subtitle_info.has_subtitle is True
assert info.subtitle_info.has_ai_subtitle is True
def test_video_info_without_subtitle() -> None:
info = VideoInfo(
video_id="BV1234567890",
title="Test Video",
subtitle_info=SubtitleInfo(has_subtitle=False, has_ai_subtitle=False, languages=[]),
subtitle_files=[],
)
assert info.subtitle_info.has_subtitle is False
assert info.subtitle_info.has_ai_subtitle is False
import pytest
from bilibili_subtitle.merger import ChunkTranscript, merge_chunk_transcripts, validate_strictly_increasing
from bilibili_subtitle.segment import Segment
def test_merge_deduplicates_overlap_by_text_similarity() -> None:
# Chunk0: 0-60s, Chunk1 starts at 58s (2s overlap)
c0 = ChunkTranscript(
chunk_start_ms=0,
segments=[
Segment(57_500, 58_500, "hello"),
Segment(58_500, 59_500, "overlap text"),
],
)
c1 = ChunkTranscript(
chunk_start_ms=58_000,
segments=[
Segment(0, 1000, "overlap text"), # duplicate in overlap window
Segment(1000, 2000, "next"),
],
)
merged = merge_chunk_transcripts([c0, c1], overlap_ms=2000, similarity_threshold=0.8)
assert [s.text for s in merged] == ["hello", "overlap text", "next"]
validate_strictly_increasing(merged)
def test_validate_strictly_increasing_raises() -> None:
with pytest.raises(ValueError):
validate_strictly_increasing([Segment(0, 1000, "a"), Segment(900, 2000, "b")])
from bilibili_subtitle.agents.proofread_agent import ProofreadAgent, diff_segments
from bilibili_subtitle.segment import Segment
def test_proofread_agent_noop() -> None:
agent = ProofreadAgent(mode="noop")
segments = [Segment(0, 1000, "a"), Segment(1000, 2000, "b")]
out = agent.proofread_segments(segments)
assert out == segments
def test_diff_segments_reports_changes() -> None:
before = [Segment(0, 1000, "a"), Segment(1000, 2000, "b")]
after = [Segment(0, 1000, "a"), Segment(1000, 2000, "B")]
diff = diff_segments(before, after)
assert diff == [{"index": 1, "before": "b", "after": "B"}]
from bilibili_subtitle.renderers.srt import render_srt
from bilibili_subtitle.segment import Segment
def test_render_srt_basic() -> None:
s = render_srt([Segment(0, 1000, "a"), Segment(1000, 2000, "b")])
assert "1\n00:00:00,000 --> 00:00:01,000\na\n\n2\n00:00:01,000 --> 00:00:02,000\nb\n" in s
def test_render_srt_bilingual() -> None:
zh = [Segment(0, 1000, "你好")]
en = [Segment(0, 1000, "Hello")]
s = render_srt(zh, segments_en=en)
assert "你好\nHello" in s
from bilibili_subtitle.renderers.vtt import render_vtt
from bilibili_subtitle.segment import Segment
def test_render_vtt_basic() -> None:
s = render_vtt([Segment(0, 1000, "a")])
assert s.startswith("WEBVTT")
assert "00:00:00.000 --> 00:00:01.000" in s
import pytest
from bilibili_subtitle.segment import Segment
def test_segment_valid() -> None:
Segment(start_ms=0, end_ms=1000, text="hello")
@pytest.mark.parametrize(
("start_ms", "end_ms", "text"),
[
(-1, 10, "a"),
(10, 10, "a"),
(11, 10, "a"),
(0, 10, " "),
],
)
def test_segment_invalid(start_ms: int, end_ms: int, text: str) -> None:
with pytest.raises((ValueError, TypeError)):
Segment(start_ms=start_ms, end_ms=end_ms, text=text)
"""Tests for subtitle_loader.py — Fix 2 (relevance), Fix 6 (VTT)."""
from __future__ import annotations
import pytest
from bilibili_subtitle.segment import Segment
from bilibili_subtitle.subtitle_loader import (
LoadResult,
check_title_relevance,
load_segments_from_subtitle_file,
_normalize_vtt_timestamps,
)
from bilibili_subtitle.errors import SubtitleContentError
def _segs(text: str) -> list[Segment]:
return [Segment(start_ms=0, end_ms=1000, text=text)]
# ── Fix 2: check_title_relevance ──
def test_relevance_cjk_match():
assert check_title_relevance(_segs("今天讲量子力学的基础"), "量子力学入门") is True
def test_relevance_cjk_no_match():
assert check_title_relevance(_segs("今天做红烧肉"), "量子力学入门") is False
def test_relevance_latin_match():
assert check_title_relevance(
_segs("introduction to quantum physics"), "Quantum Physics 101"
) is True
def test_relevance_latin_no_match():
assert check_title_relevance(
_segs("cooking recipe for pasta"), "Quantum Physics 101"
) is False
def test_relevance_short_title_skipped():
assert check_title_relevance(_segs("anything"), "ab") is True
def test_relevance_none_title():
assert check_title_relevance(_segs("anything"), None) is True
# ── Fix 6: VTT timestamp normalization ──
def test_vtt_timestamps_converted():
vtt = "00:01:23.456 --> 00:01:25.789\nHello world"
result = _normalize_vtt_timestamps(vtt)
assert "00:01:23,456 --> 00:01:25,789" in result
def test_vtt_preserves_text_dots():
vtt = "00:01:23.456 --> 00:01:25.789\nVersion 3.14 is out"
result = _normalize_vtt_timestamps(vtt)
assert "3.14" in result
assert "00:01:23,456" in result
# ── File loading ──
_SRT = "1\n00:00:01,000 --> 00:00:03,000\n量子力学很有趣\n\n2\n00:00:04,000 --> 00:00:06,000\n今天学习\n"
_VTT = "WEBVTT\n\n1\n00:00:01.000 --> 00:00:03.000\n量子力学很有趣\n\n2\n00:00:04.000 --> 00:00:06.000\n今天学习\n"
def test_load_srt(tmp_path):
p = tmp_path / "test.srt"
p.write_text(_SRT, encoding="utf-8")
r = load_segments_from_subtitle_file(p, title="量子力学入门")
assert len(r.segments) == 2
assert r.relevant is True
def test_load_vtt(tmp_path):
p = tmp_path / "test.vtt"
p.write_text(_VTT, encoding="utf-8")
r = load_segments_from_subtitle_file(p, title="量子力学入门")
assert len(r.segments) == 2
assert r.relevant is True
def test_load_irrelevant(tmp_path):
p = tmp_path / "test.srt"
p.write_text(_SRT, encoding="utf-8")
r = load_segments_from_subtitle_file(p, title="How to cook pasta at home")
assert r.relevant is False
assert len(r.segments) == 2 # still returns segments
def test_load_empty_raises(tmp_path):
p = tmp_path / "test.srt"
p.write_text("no valid blocks here\n", encoding="utf-8")
with pytest.raises(SubtitleContentError):
load_segments_from_subtitle_file(p)
def test_unsupported_ext(tmp_path):
p = tmp_path / "test.ass"
p.write_text("content", encoding="utf-8")
with pytest.raises(ValueError, match="Unsupported"):
load_segments_from_subtitle_file(p)
import json
from pathlib import Path
import jsonschema
from bilibili_subtitle.agents.summarize_agent import SummarizeAgent
from bilibili_subtitle.segment import Segment
def test_noop_summary_conforms_to_schema() -> None:
schema = json.loads(Path("schemas/summary_schema.json").read_text(encoding="utf-8"))
agent = SummarizeAgent(mode="noop")
result = agent.summarize([Segment(0, 1000, "a")], title="t")
jsonschema.validate(result.summary, schema)
from bilibili_subtitle.agents.translate_agent import TranslateAgent
from bilibili_subtitle.segment import Segment
def test_translate_agent_noop() -> None:
agent = TranslateAgent(mode="noop")
segments = [Segment(0, 1000, "你好")]
out = agent.translate_segments(segments)
assert out == segments
Related skills
Forks & variants (1)
Bilibili Subtitle has 1 known copy in the catalog totaling 1.4k installs. They canonicalize to this original listing.
- hamsterider-m - 1.4k installs
How it compares
Choose bilibili-subtitle over generic video downloaders when you need subtitle-first extraction with ASR fallback and agent-ready Markdown or JSON.
FAQ
What does bilibili-subtitle do?
从 Bilibili 视频提取字幕、转录无字幕视频、生成结构化摘要。触发条件:Bilibili URL (bilibili.com)、BV ID (BV1xxx)、或"提取B站字幕"等请求。
When should I use bilibili-subtitle?
During build integrations work for generative media.
Is bilibili-subtitle safe to install?
Review the Security Audits panel on this listing before production use.