feat(extension+backend): 插件直接在浏览器里抓 B 站字幕,跳过后端 download_subtitles

之前 B 站字幕优先逻辑放在后端的 BilibiliSubtitleFetcher,需要后端通过 CookieConfigManager
管理 SESSDATA cookie 才能拿 AI 字幕。这次改为:插件在用户浏览器里直接抓字幕,
天然带着用户当前登录态的 cookie;后端只负责把传过来的字幕当作转写缓存。

extension:
- 新增 logic/bilibili-subtitle.ts,调 /x/web-interface/view → /x/player/wbi/v2 → 字幕 URL JSON
  · service worker fetch 走 credentials:'include',借 manifest host_permissions:'*://*/*'
    自动带 .bilibili.com 域 cookie,并绕过 CORS
  · 优先级:人工中文 > AI 中文 > 任意非空
- popup start() 与 background startTask() 在 platform === 'bilibili' 时先调一次抓取,
  结果作为 prefetched_transcript 字段塞到 /api/generate_note payload
- types.ts GenerateRequest 增加 prefetched_transcript 字段

backend:
- VideoRequest 增加可选 prefetched_transcript: dict
- generate_note endpoint 收到时调 _persist_prefetched_transcript() 写到
  NOTE_OUTPUT_DIR/<task_id>_transcript.json;NoteGenerator 的 cache-hit 逻辑天然命中,
  跳过 downloader.download_subtitles 和音频转写,直接走 GPT 总结
- 字幕清洗:去掉空 segment、必要时合成 full_text、language 默认 'zh'

效果:B 站登录用户的视频,从用户点击到 GPT 拿到全文,省掉一次后端 → B 站 API 的来回,
也彻底告别了 backend 那侧的 cookie 配置心智负担。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
huangjianwu
2026-05-07 12:23:16 +08:00
parent d64741628b
commit 406789f834
5 changed files with 187 additions and 0 deletions

View File

@@ -50,6 +50,10 @@ class VideoRequest(BaseModel):
video_understanding: Optional[bool] = False
video_interval: Optional[int] = 0
grid_size: Optional[list] = []
# 客户端(如浏览器插件)已经在用户浏览器里抓到字幕,直接传给后端复用,
# 跳过 download_subtitles 和音频转写。形如:
# {"language": "zh", "full_text": "...", "segments": [{"start","end","text"}, ...]}
prefetched_transcript: Optional[dict] = None
@field_validator("video_url")
def validate_supported_url(cls, v):
@@ -74,6 +78,40 @@ def save_note_to_file(task_id: str, note):
json.dump(asdict(note), f, ensure_ascii=False, indent=2)
def _persist_prefetched_transcript(task_id: str, transcript: dict) -> None:
"""把客户端预取的字幕写到 NoteGenerator 期望的转写缓存文件里。
NoteGenerator.generate 会优先读 <task_id>_transcript.json命中即跳过 download_subtitles
与音频转写流程。要求字段language(可空)/full_text/segments[{start,end,text}]
"""
segments = transcript.get("segments") or []
cleaned_segments = []
for s in segments:
text = (s.get("text") or "").strip()
if not text:
continue
cleaned_segments.append({
"start": float(s.get("start", 0)),
"end": float(s.get("end", 0)),
"text": text,
})
if not cleaned_segments:
raise ValueError("prefetched_transcript 没有可用的 segments")
full_text = transcript.get("full_text") or " ".join(s["text"] for s in cleaned_segments)
payload = {
"language": transcript.get("language") or "zh",
"full_text": full_text,
"segments": cleaned_segments,
}
os.makedirs(NOTE_OUTPUT_DIR, exist_ok=True)
target = os.path.join(NOTE_OUTPUT_DIR, f"{task_id}_transcript.json")
with open(target, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
logger.info(f"已写入客户端预取字幕缓存: {target} ({len(cleaned_segments)} 段)")
def run_note_task(task_id: str, video_url: str, platform: str, quality: DownloadQuality,
link: bool = False, screenshot: bool = False, model_name: str = None, provider_id: str = None,
_format: list = None, style: str = None, extras: str = None, video_understanding: bool = False,
@@ -163,6 +201,13 @@ def generate_note(data: VideoRequest, background_tasks: BackgroundTasks):
# 统一先写入 PENDING表示已进入队列等待串行执行
NoteGenerator()._update_status(task_id, TaskStatus.PENDING)
# 客户端已经抓好字幕的话写到转写缓存文件NoteGenerator 的 cache-hit 逻辑会直接用上
if data.prefetched_transcript:
try:
_persist_prefetched_transcript(task_id, data.prefetched_transcript)
except Exception as e:
logger.warning(f"写入预取字幕失败 (task_id={task_id}): {e}")
background_tasks.add_task(run_note_task, task_id, data.video_url, data.platform, data.quality, data.link,
data.screenshot, data.model_name, data.provider_id, data.format, data.style,
data.extras, data.video_understanding, data.video_interval, data.grid_size)