Files
BiliNote/BillNote_extension/src/logic/bilibili-subtitle.ts
huangjianwu 406789f834 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>
2026-05-07 12:23:16 +08:00

126 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 在浏览器里直接调 B 站 player API 抓字幕。
// 因为 manifest host_permissions: '*://*/*' 覆盖 api.bilibili.comservice worker 里的
// fetch 会自动带 .bilibili.com 域下的用户 cookie并且绕过 CORS——AI 字幕需要登录态,
// 这等于用用户当前浏览器的登录身份代替了 backend 那边的 SESSDATA 配置。
//
// 与 backend/app/downloaders/bilibili_subtitle.py 的 BilibiliSubtitleFetcher 行为对齐。
const UA
= 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
export interface PrefetchedTranscript {
language: string
full_text: string
segments: Array<{ start: number, end: number, text: string }>
source: 'bilibili_extension'
}
interface SubtitleEntry {
lan?: string
ai_type?: number
subtitle_url?: string
}
function extractBvid(url: string): string | null {
const m = url.match(/BV([0-9A-Za-z]+)/)
return m ? `BV${m[1]}` : null
}
async function jsonGet<T>(url: string): Promise<T | null> {
try {
const res = await fetch(url, {
credentials: 'include',
headers: { 'User-Agent': UA, 'Referer': 'https://www.bilibili.com' },
})
if (!res.ok)
return null
return await res.json() as T
}
catch (e) {
console.warn('[bilinote] B 站 API 请求失败:', url, e)
return null
}
}
async function getCid(bvid: string): Promise<number | null> {
const data = await jsonGet<{ code: number, data?: { cid?: number } }>(
`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`,
)
if (!data || data.code !== 0)
return null
return data.data?.cid ?? null
}
async function listSubtitles(bvid: string, cid: number): Promise<SubtitleEntry[]> {
const data = await jsonGet<{
code: number
data?: { subtitle?: { subtitles?: SubtitleEntry[] } }
}>(`https://api.bilibili.com/x/player/wbi/v2?bvid=${bvid}&cid=${cid}`)
if (!data || data.code !== 0)
return []
return data.data?.subtitle?.subtitles ?? []
}
function pickSubtitle(subtitles: SubtitleEntry[]): SubtitleEntry | null {
if (!subtitles.length)
return null
const isZh = (s: SubtitleEntry) => {
const lan = (s.lan || '').toLowerCase()
return lan.startsWith('zh') || lan === 'ai-zh'
}
// 优先级:人工中文 > AI 中文 > 任意非空
return (
subtitles.find(s => isZh(s) && !s.ai_type)
|| subtitles.find(s => isZh(s))
|| subtitles[0]
)
}
function normalizeUrl(url: string): string {
return url.startsWith('//') ? `https:${url}` : url
}
interface SubtitleBody {
body?: Array<{ from?: number, to?: number, content?: string }>
}
export async function fetchBilibiliSubtitle(videoUrl: string): Promise<PrefetchedTranscript | null> {
const bvid = extractBvid(videoUrl)
if (!bvid)
return null
const cid = await getCid(bvid)
if (!cid)
return null
const subtitles = await listSubtitles(bvid, cid)
const track = pickSubtitle(subtitles)
if (!track?.subtitle_url) {
console.info(`[bilinote] B 站 ${bvid} 没找到可用字幕轨(可能未登录或视频无字幕)`)
return null
}
const sub = await jsonGet<SubtitleBody>(normalizeUrl(track.subtitle_url))
const body = sub?.body || []
const segments: PrefetchedTranscript['segments'] = []
for (const item of body) {
const text = (item.content || '').trim()
if (!text)
continue
segments.push({
start: Number(item.from || 0),
end: Number(item.to || 0),
text,
})
}
if (!segments.length)
return null
return {
language: track.lan || 'zh',
full_text: segments.map(s => s.text).join(' '),
segments,
source: 'bilibili_extension',
}
}