// ==UserScript== // @name BLSubSum - B站AI字幕总结 // @namespace https://github.com/AfeyGu/BLSubSum // @version 0.3.1 // @description 在B站视频页一键获取AI字幕(无需下载视频),调用 LLM 生成内容总结。支持多P、多字幕轨道、长视频分块总结、历史总结自动保存与恢复、可拖动悬浮按钮。 // @author AfeyGu // @match https://www.bilibili.com/video/* // @match https://www.bilibili.com/festival/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @connect api.bilibili.com // @connect aisubtitle.hdslb.com // @connect * // @icon https://www.bilibili.com/favicon.ico // @license MIT // @run-at document-idle // @tag bilibili // ==/UserScript== /** * BLSubSum —— B站 AI 字幕一键总结 * * 流程(见 docs/B站AI字幕接口与下载_调研报告.md): * ① x/web-interface/view?bvid= → cid(无需登录) * ② x/player/wbi/v2?bvid=&cid= → 字幕轨道列表(需登录,浏览器 cookie 自动携带) * ③ subtitle_url(auth_key 签名) → 字幕 JSON * ④ body[].content 按 ≤gap 秒合并段落 → 讲稿 * ⑤ 讲稿分块 → LLM map-reduce 总结 */ (function () { "use strict"; // ---------------- 配置 ---------------- const CFG_DEFAULTS = { apiBase: "https://api.deepseek.com", // OpenAI 兼容端点 apiKey: "", model: "deepseek-v4-flash", trackPref: "auto", // 字幕轨道偏好:auto / ai-zh / ai-en / ai / any prompt: "你是专业的视频内容分析师。请根据以下视频字幕讲稿总结。无论字幕是什么语言,都必须使用中文总结:" + "\n1. 一句话概括核心主题(使用中文);" + "\n2. 3~6 个要点(每个要点一句话,使用中文);" + "\n3. 值得记住的细节或金句(如有,金句可保留原文并附中文说明)。" + "\n只输出总结内容,不要客套话。", chunkChars: 6000, // 讲稿分块大小 mergeGap: 1.0, // 字幕行间隔 ≤gap 秒合并为一段 historySize: 30, // 历史总结保留条数,0 = 不保存 history: [], // [{key,bvid,p,title,up,part,pagesTotal,lan,model,summary,transcript,ts}] fabPos: null, // FAB 按钮位置 {right,bottom} }; const API = "https://api.bilibili.com"; const cfg = (k) => GM_getValue("blsubsum." + k, CFG_DEFAULTS[k]); const setCfg = (k, v) => GM_setValue("blsubsum." + k, v); GM_registerMenuCommand("⚙️ BLSubSum 设置", openSettings); // ---------------- 小工具 ---------------- const $ = (sel, root = document) => root.querySelector(sel); function gmFetchJSON(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url, timeout: 30000, headers: { Accept: "application/json" }, onload: (r) => { try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(new Error("响应不是 JSON:" + r.responseText.slice(0, 120))); } }, onerror: () => reject(new Error("网络请求失败:" + url)), ontimeout: () => reject(new Error("请求超时:" + url)), }); }); } async function llmChat(messages) { const base = cfg("apiBase").replace(/\/+$/, ""); const res = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "POST", url: base + "/chat/completions", timeout: 300000, headers: { "Content-Type": "application/json", Authorization: "Bearer " + cfg("apiKey"), }, data: JSON.stringify({ model: cfg("model"), messages, temperature: 0.3, }), onload: (r) => { try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(new Error("LLM 响应解析失败:" + r.responseText.slice(0, 200))); } }, onerror: () => reject(new Error("LLM 请求失败,请检查网络 / API 地址")), ontimeout: () => reject(new Error("LLM 请求超时")), }); }); if (res.error) throw new Error("LLM 错误:" + (res.error.message || JSON.stringify(res.error))); const text = res.choices?.[0]?.message?.content; if (!text) throw new Error("LLM 返回为空:" + JSON.stringify(res).slice(0, 200)); return text.trim(); } function log(msg, cls = "") { const box = $("#bls-log"); if (!box) return console.log("[BLSubSum]", msg); const div = document.createElement("div"); div.className = "bls-log-line " + cls; div.textContent = msg; box.appendChild(div); box.scrollTop = box.scrollHeight; } const setTitle = (s) => { const el = $("#bls-title"); if (el) el.textContent = s; }; // ---------------- B站数据获取 ---------------- function getBvid() { const m = location.pathname.match(/\/video\/(BV[0-9A-Za-z]+|av\d+)/); return m ? m[1] : null; } async function resolveVideoInfo() { const bvid = getBvid(); if (!bvid) throw new Error("无法从地址解析 BV 号"); const j = await gmFetchJSON(`${API}/x/web-interface/view?bvid=${bvid}`); if (j.code !== 0) throw new Error(`获取视频信息失败: ${j.code} ${j.message}`); const pages = j.data.pages || []; // 多 P:URL ?p=N(1 基),默认 1 const p = Math.min(Math.max(parseInt(new URLSearchParams(location.search).get("p") || "1", 10), 1), pages.length); const page = pages[p - 1]; return { bvid, title: j.data.title, up: j.data.owner?.name || "", p, pagesTotal: pages.length, cid: page.cid, part: page.part || "", }; } async function listSubtitleTracks(bvid, cid) { const j = await gmFetchJSON(`${API}/x/player/wbi/v2?bvid=${bvid}&cid=${cid}`); if (j.code !== 0) throw new Error(`字幕接口失败: ${j.code} ${j.message}`); const d = j.data || {}; if (d.need_login_subtitle) throw new Error("该视频字幕需要登录:请先登录B站账号后重试"); const subs = (d.subtitle && d.subtitle.subtitles) || []; return subs.filter((s) => s.subtitle_url && s.lan); } function pickTrack(subs) { // 优先 AI 中文 → AI 英文 → 其他 AI → 任意上传字幕 const order = ["ai-zh", "ai-en"]; for (const lan of order) { const hit = subs.find((s) => s.lan === lan); if (hit) return hit; } return subs.find((s) => String(s.lan).startsWith("ai-")) || subs[0]; } // 按设置的字幕轨道偏好挑选;偏好不可用时回退自动顺序 function pickTrackByPref(tracks, pref) { if (pref && pref !== "auto") { const hit = pref === "ai" ? tracks.find((t) => String(t.lan).startsWith("ai-")) : tracks.find((t) => t.lan === pref); if (hit) return hit; log(`⚠️ 字幕轨道偏好 ${pref} 在本视频不可用,已自动选择`); } return pickTrack(tracks); } async function fetchSubtitleBody(track) { const url = track.subtitle_url.startsWith("//") ? "https:" + track.subtitle_url : track.subtitle_url; const j = await gmFetchJSON(url); // auth_key 签名 URL,无需 cookie return j.body || []; } function transcriptFromBody(body, gap) { const paras = []; let cur = []; let prevTo = null; for (const e of body) { if (prevTo !== null && e.from - prevTo > gap) { paras.push(cur.join("")); cur = []; } cur.push(e.content); prevTo = e.to; } if (cur.length) paras.push(cur.join("")); return paras.join("\n\n"); } // ---------------- 总结(map-reduce 分块) ---------------- function splitChunks(text, maxChars) { const paras = text.split("\n\n"); const chunks = []; let cur = []; let len = 0; for (const p of paras) { if (len + p.length > maxChars && cur.length) { chunks.push(cur.join("\n\n")); cur = []; len = 0; } cur.push(p); len += p.length + 2; } if (cur.length) chunks.push(cur.join("\n\n")); return chunks; } async function summarize(transcript, extra, onProgress) { const sysPrompt = `<总结提示词>:\n${cfg("prompt")}`; const extraBlock = extra ? `<补充提示词(最高优先级)>:\n${extra}\n\n` : ""; const chunks = splitChunks(transcript, cfg("chunkChars")); if (chunks.length === 1) { onProgress("调用 LLM 总结…"); return llmChat([ { role: "system", content: sysPrompt }, { role: "user", content: `${extraBlock}<原稿>:\n${chunks[0]}` }, ]); } // 长视频:分块摘要 → 汇总 const partials = []; for (let i = 0; i < chunks.length; i++) { onProgress(`分块总结 ${i + 1}/${chunks.length}…`); const prev = partials.length ? `<前文已总结内容>:\n${partials[partials.length - 1]}\n\n` : ""; const note = `(这是长视频讲稿的第 ${i + 1}/${chunks.length} 段,请先总结该段要点)\n\n`; const s = await llmChat([ { role: "system", content: sysPrompt }, { role: "user", content: `${extraBlock}${prev}${note}<原稿>:\n${chunks[i]}` }, ]); partials.push(s); } onProgress(`汇总 ${chunks.length} 个分块…`); return llmChat([ { role: "system", content: sysPrompt }, { role: "user", content: `${extraBlock}(下面是长视频各部分的分段总结,请整合为一份完整总结)\n\n` + `<分段总结>:\n${partials.join("\n\n---\n\n")}`, }, ]); } // ---------------- UI ---------------- const CSS = ` #bls-fab{position:fixed;right:18px;bottom:88px;z-index:99999;background:#fb7299;color:#fff;border:none; border-radius:20px;padding:8px 14px;font-size:13px;cursor:grab;box-shadow:0 2px 8px rgba(0,0,0,.3); font-family:inherit;user-select:none;touch-action:none} #bls-fab:hover{background:#fc8bab} #bls-fab:active{cursor:grabbing} #bls-panel{position:fixed;right:18px;bottom:130px;z-index:99999;width:420px;max-height:70vh;display:none; flex-direction:column;background:#fff;border-radius:10px;box-shadow:0 4px 24px rgba(0,0,0,.25);font-family:inherit;color:#18191c} #bls-panel.open{display:flex} #bls-head{display:flex;align-items:center;justify-content:space-between;padding:10px 14px; border-bottom:1px solid #e3e5e7;cursor:move} #bls-head b{font-size:14px} #bls-head .bls-x{cursor:pointer;border:none;background:none;font-size:16px;color:#61666d} #bls-body{padding:12px 14px;overflow-y:auto;font-size:13px;line-height:1.6} #bls-subtitle{color:#61666d;margin-bottom:8px;font-size:12px;word-break:break-all} #bls-extra-label{display:block;font-size:12px;color:#61666d;margin-bottom:4px} #bls-extra{width:100%;box-sizing:border-box;height:44px;min-height:44px;max-height:140px;padding:6px 8px; border:1px solid #e3e5e7;border-radius:6px;font-size:13px;font-family:inherit;resize:vertical;margin-bottom:10px} #bls-extra:focus{outline:none;border-color:#fb7299} #bls-run{width:100%;padding:8px 0;border:none;border-radius:6px;background:#fb7299;color:#fff; font-size:14px;cursor:pointer;margin-bottom:10px} #bls-run:disabled{background:#f1f2f3;color:#9499a0;cursor:not-allowed} #bls-log{background:#f6f7f8;border-radius:6px;padding:8px;margin-bottom:10px;max-height:120px; overflow-y:auto;font-size:12px;color:#61666d;display:none} #bls-summary{white-space:pre-wrap;word-break:break-word;background:#fff8f9;border:1px solid #ffe4ea; border-radius:6px;padding:10px;max-height:300px;overflow-y:auto} #bls-actions{display:flex;gap:8px;margin-top:10px} #bls-actions button{flex:1;padding:6px 0;border:1px solid #e3e5e7;border-radius:6px;background:#fff; cursor:pointer;font-size:12px;color:#18191c} #bls-actions button:hover{border-color:#fb7299;color:#fb7299} #bls-mask{position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.4);display:none; align-items:center;justify-content:center} #bls-mask.open{display:flex} #bls-settings{width:460px;background:#fff;border-radius:10px;padding:18px;color:#18191c;font-family:inherit} #bls-settings h3{margin:0 0 12px;font-size:15px} #bls-settings label{display:block;font-size:12px;color:#61666d;margin:10px 0 4px} #bls-settings input,#bls-settings select,#bls-settings textarea{width:100%;box-sizing:border-box;padding:6px 8px; border:1px solid #e3e5e7;border-radius:6px;font-size:13px;font-family:inherit;background:#fff} #bls-settings textarea{height:90px;resize:vertical} #bls-settings .row{display:flex;gap:10px} #bls-settings .hint{font-size:11px;color:#9499a0;margin-top:2px} #bls-settings .btns{display:flex;gap:10px;margin-top:14px;justify-content:flex-end} #bls-settings button{padding:7px 18px;border-radius:6px;border:1px solid #e3e5e7;background:#fff;cursor:pointer} #bls-settings button.primary{background:#fb7299;border-color:#fb7299;color:#fff} #bls-histbtn{width:100%;padding:6px 0;border:1px solid #e3e5e7;border-radius:6px;background:#fff; color:#18191c;font-size:13px;cursor:pointer;margin-bottom:10px;font-family:inherit} #bls-histbtn:hover{border-color:#fb7299;color:#fb7299} #bls-hismask{position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.4);display:none; align-items:center;justify-content:center} #bls-hismask.open{display:flex} #bls-history{width:480px;max-width:92vw;max-height:70vh;display:flex;flex-direction:column;background:#fff; border-radius:10px;padding:14px;color:#18191c;font-family:inherit} #bls-histhead{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px} #bls-histhead b{font-size:14px} #bls-histhead .bls-x{cursor:pointer;border:none;background:none;font-size:13px;color:#61666d} #bls-histhead .bls-x:hover{color:#fb7299} #bls-histlist{overflow-y:auto;font-size:13px} .bls-hist-item{padding:8px 10px;border:1px solid #e3e5e7;border-radius:6px;margin-bottom:6px;cursor:pointer} .bls-hist-item:hover{border-color:#fb7299;background:#fff8f9} .bls-hist-item .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bls-hist-item .m{font-size:11px;color:#9499a0;margin-top:2px} .bls-hist-empty{color:#9499a0;font-size:12px;text-align:center;padding:24px 0} .bls-log-line.err{color:#f25d8e} .bls-log-line.ok{color:#2ac864} `; let panelBuilt = false; let state = { tracks: [], info: null, transcript: "", summary: "" }; function buildUI() { if (panelBuilt) return; panelBuilt = true; const style = document.createElement("style"); style.textContent = CSS; document.head.appendChild(style); const fab = document.createElement("button"); fab.id = "bls-fab"; fab.textContent = "📝 AI总结"; const panel = document.createElement("div"); panel.id = "bls-panel"; panel.innerHTML = `
BLSubSum · AI字幕总结
加载中…
`; document.body.appendChild(fab); document.body.appendChild(panel); // 恢复 FAB 上次拖动的位置 const savedPos = cfg("fabPos"); if (savedPos) { fab.style.right = Math.min(Math.max(savedPos.right || 0, 0), Math.max(window.innerWidth - fab.offsetWidth, 0)) + "px"; fab.style.bottom = Math.min(Math.max(savedPos.bottom || 0, 0), Math.max(window.innerHeight - fab.offsetHeight, 0)) + "px"; } makeFabDraggable(fab); $("#bls-close").addEventListener("click", () => $("#bls-panel").classList.remove("open")); $("#bls-gear").addEventListener("click", openSettings); $("#bls-run").addEventListener("click", runPipeline); $("#bls-copy").addEventListener("click", () => navigator.clipboard.writeText(state.summary).then(() => ($("#bls-copy").textContent = "已复制✓"))); $("#bls-copy-t").addEventListener("click", () => navigator.clipboard.writeText(state.transcript).then(() => ($("#bls-copy-t").textContent = "已复制✓"))); $("#bls-dl").addEventListener("click", downloadTranscript); $("#bls-histbtn").addEventListener("click", openHistory); // 设置弹层 const mask = document.createElement("div"); mask.id = "bls-mask"; mask.innerHTML = `

BLSubSum 设置

填入任意 OpenAI 兼容服务(DeepSeek / SiliconFlow / 中转等)。API Key 保存在本地脚本存储中。历史总结条数填 0 表示不保存。
`; document.body.appendChild(mask); $("#bls-cancel").addEventListener("click", () => mask.classList.remove("open")); $("#bls-save").addEventListener("click", () => { setCfg("apiBase", $("#bls-cfg-api").value.trim() || CFG_DEFAULTS.apiBase); setCfg("apiKey", $("#bls-cfg-key").value.trim()); setCfg("model", $("#bls-cfg-model").value.trim() || CFG_DEFAULTS.model); setCfg("prompt", $("#bls-cfg-prompt").value.trim() || CFG_DEFAULTS.prompt); setCfg("trackPref", $("#bls-cfg-track").value || "auto"); setCfg("chunkChars", Math.max(1000, parseInt($("#bls-cfg-chunk").value, 10) || CFG_DEFAULTS.chunkChars)); setCfg("mergeGap", Math.max(0, parseFloat($("#bls-cfg-gap").value) || CFG_DEFAULTS.mergeGap)); const histN = parseInt($("#bls-cfg-hist").value, 10); setCfg("historySize", isNaN(histN) ? CFG_DEFAULTS.historySize : Math.max(0, histN)); mask.classList.remove("open"); loadTracks(); }); // 历史总结弹层 const hismask = document.createElement("div"); hismask.id = "bls-hismask"; hismask.innerHTML = `
🕘 历史总结
`; document.body.appendChild(hismask); $("#bls-hist-close").addEventListener("click", () => $("#bls-hismask").classList.remove("open")); $("#bls-hist-clear").addEventListener("click", () => { if (!confirm("确定清空全部历史总结?")) return; setCfg("history", []); openHistory(); }); makeDraggable(panel, $("#bls-head")); } function makeDraggable(el, handle) { let sx, sy, ox, oy, dragging = false; handle.addEventListener("mousedown", (e) => { dragging = true; el.style.top = "auto"; // 面板可能被 placePanelNearFab 以 top 定位,拖动时改回 bottom sx = e.clientX; sy = e.clientY; ox = el.getBoundingClientRect().right - window.innerWidth; // 以 right 定位 oy = window.innerHeight - el.getBoundingClientRect().bottom; e.preventDefault(); }); document.addEventListener("mousemove", (e) => { if (!dragging) return; const right = Math.min(Math.max(window.innerWidth - e.clientX - ox, 0), window.innerWidth - 100); const bottom = Math.min(Math.max(window.innerHeight - e.clientY - oy, 0), window.innerHeight - 60); el.style.right = right + "px"; el.style.bottom = bottom + "px"; }); document.addEventListener("mouseup", () => (dragging = false)); } // FAB 拖动:位移 ≥5px 视为拖动(并记忆位置),否则视为点击 function makeFabDraggable(fab) { let pid = null, sx, sy, sr, sb, moved = false; fab.addEventListener("pointerdown", (e) => { if (pid !== null) return; pid = e.pointerId; fab.setPointerCapture(pid); sx = e.clientX; sy = e.clientY; const r = fab.getBoundingClientRect(); sr = window.innerWidth - r.right; sb = window.innerHeight - r.bottom; moved = false; }); fab.addEventListener("pointermove", (e) => { if (pid === null || e.pointerId !== pid) return; const dx = e.clientX - sx, dy = e.clientY - sy; if (!moved && Math.hypot(dx, dy) < 5) return; moved = true; const right = Math.min(Math.max(sr - dx, 0), window.innerWidth - fab.offsetWidth); const bottom = Math.min(Math.max(sb - dy, 0), window.innerHeight - fab.offsetHeight); fab.style.right = right + "px"; fab.style.bottom = bottom + "px"; }); const finish = (e) => { if (pid === null || e.pointerId !== pid) return; pid = null; if (moved) { setCfg("fabPos", { right: parseInt(fab.style.right, 10) || 0, bottom: parseInt(fab.style.bottom, 10) || 0 }); } else { togglePanel(); } }; fab.addEventListener("pointerup", finish); fab.addEventListener("pointercancel", () => (pid = null)); } function togglePanel() { const panel = $("#bls-panel"); panel.classList.toggle("open"); if (panel.classList.contains("open")) { placePanelNearFab(); if (!state.tracks.length) loadTracks(); } } // 面板打开时贴着 FAB 摆放(优先上方,放不下则下方) function placePanelNearFab() { const fab = $("#bls-fab"); const panel = $("#bls-panel"); if (!fab || !panel) return; const fr = fab.getBoundingClientRect(); panel.style.right = Math.min(Math.max(window.innerWidth - fr.right, 0), window.innerWidth - panel.offsetWidth) + "px"; if (fr.top - panel.offsetHeight - 16 > 0) { panel.style.top = "auto"; panel.style.bottom = window.innerHeight - fr.top + 8 + "px"; } else { panel.style.bottom = "auto"; panel.style.top = Math.min(fr.bottom + 8, Math.max(window.innerHeight - panel.offsetHeight - 10, 0)) + "px"; } } // ---------------- 历史总结 ---------------- const historyKey = (bvid, p) => bvid + "#P" + p; function loadHistory() { try { const list = GM_getValue("blsubsum.history", []); return Array.isArray(list) ? list : []; } catch (e) { return []; } } function saveHistory(entry) { const max = Math.max(0, parseInt(cfg("historySize"), 10) || 0); if (!max) return; const list = loadHistory().filter((it) => it.key !== entry.key); list.unshift(entry); setCfg("history", list.slice(0, max)); } const findHistory = (bvid, p) => loadHistory().find((it) => it.key === historyKey(bvid, p)) || null; function showSummaryBox(text) { const box = $("#bls-summary"); box.textContent = text; box.style.display = "block"; $("#bls-actions").style.display = "flex"; $("#bls-copy").textContent = "复制总结"; $("#bls-copy-t").textContent = "复制讲稿"; } // 面板打开/切换分P后,若当前视频(BV+分P)有历史总结则自动恢复 function tryRestoreSummary() { if (!state.info) return false; const hit = findHistory(state.info.bvid, state.info.p); if (!hit) return false; state.summary = hit.summary || ""; state.transcript = hit.transcript || ""; showSummaryBox(state.summary); const logBox = $("#bls-log"); logBox.innerHTML = ""; logBox.style.display = "block"; log(`🕘 已恢复历史总结(保存于 ${new Date(hit.ts).toLocaleString()}),可复制/下载,也可重新总结`, "ok"); return true; } function openHistory() { const list = loadHistory(); const box = $("#bls-histlist"); box.innerHTML = ""; if (!list.length) { box.innerHTML = `
暂无历史总结
完成一次总结后会自动保存到这里
`; } else { for (const it of list) { const item = document.createElement("div"); item.className = "bls-hist-item"; const t = document.createElement("div"); t.className = "t"; t.textContent = `${it.title}${it.pagesTotal > 1 ? ` (P${it.p}${it.part ? " " + it.part : ""})` : ""}`; const m = document.createElement("div"); m.className = "m"; m.textContent = `${it.bvid} · ${it.up || "?"} · ${new Date(it.ts).toLocaleString()} · ${(it.summary || "").length} 字`; item.append(t, m); item.addEventListener("click", () => applyHistoryItem(it)); box.appendChild(item); } } $("#bls-hismask").classList.add("open"); } function applyHistoryItem(it) { $("#bls-hismask").classList.remove("open"); const curBvid = getBvid(); const curP = parseInt(new URLSearchParams(location.search).get("p") || "1", 10) || 1; if (curBvid !== it.bvid || curP !== it.p) { // 其他视频/分P:直接跳转,打开面板后会自动恢复 location.href = `https://www.bilibili.com/video/${it.bvid}${it.p > 1 ? "?p=" + it.p : ""}`; return; } state.summary = it.summary || ""; state.transcript = it.transcript || ""; if (!state.info) { state.info = { bvid: it.bvid, title: it.title, up: it.up, p: it.p, pagesTotal: it.pagesTotal || 1, part: it.part || "" }; const sub = $("#bls-subtitle"); if (sub) sub.textContent = `${it.title}${it.pagesTotal > 1 ? ` (P${it.p} ${it.part})` : ""} · UP: ${it.up}`; } showSummaryBox(state.summary); const logBox = $("#bls-log"); logBox.innerHTML = ""; logBox.style.display = "block"; log(`🕘 已载入历史总结(${new Date(it.ts).toLocaleString()})`, "ok"); } function openSettings() { $("#bls-cfg-api").value = cfg("apiBase"); $("#bls-cfg-key").value = cfg("apiKey"); $("#bls-cfg-model").value = cfg("model"); $("#bls-cfg-prompt").value = cfg("prompt"); $("#bls-cfg-track").value = cfg("trackPref"); $("#bls-cfg-chunk").value = cfg("chunkChars"); $("#bls-cfg-gap").value = cfg("mergeGap"); $("#bls-cfg-hist").value = cfg("historySize"); $("#bls-mask").classList.add("open"); } // ---------------- 主流程 ---------------- async function loadTracks() { const sub = $("#bls-subtitle"); $("#bls-log").style.display = "none"; $("#bls-summary").style.display = "none"; $("#bls-actions").style.display = "none"; sub.textContent = "加载中…"; try { const info = await resolveVideoInfo(); state.info = info; const title = `${info.title}${info.pagesTotal > 1 ? ` (P${info.p} ${info.part})` : ""}`; sub.textContent = `${title} · UP: ${info.up}`; tryRestoreSummary(); const tracks = await listSubtitleTracks(info.bvid, info.cid); state.tracks = tracks; if (!tracks.length) { sub.textContent = title + "\n⚠️ 未找到任何字幕轨道(该视频可能未生成AI字幕,或未登录)"; return; } sub.textContent += ` · 字幕轨道: ${tracks .map((t) => `${t.lan_doc || t.lan}${t.lan.startsWith("ai-") ? "(AI)" : ""}`) .join("、")}`; } catch (e) { sub.textContent = "❌ " + e.message; } } async function runPipeline() { const run = $("#bls-run"); run.disabled = true; $("#bls-log").innerHTML = ""; $("#bls-log").style.display = "block"; $("#bls-summary").style.display = "none"; $("#bls-actions").style.display = "none"; try { if (!state.tracks.length) { log("字幕轨道未加载,请先等轨道列表加载完成"); return; } if (!state.info) { log("解析视频信息…"); state.info = await resolveVideoInfo(); } const track = pickTrackByPref(state.tracks, cfg("trackPref")); log(`使用字幕轨道 ${track.lan}(${track.lan_doc || ""})…`); const body = await fetchSubtitleBody(track); log(`共 ${body.length} 条字幕,合并段落…`); state.transcript = transcriptFromBody(body, cfg("mergeGap")); log(`讲稿 ${state.transcript.length} 字,开始总结…`); const extra = $("#bls-extra").value.trim(); if (extra) log(`已附加补充提示词(最高优先级,${extra.length} 字)`); state.summary = await summarize(state.transcript, extra, (m) => log(m)); log("✅ 总结完成", "ok"); showSummaryBox(state.summary); saveHistory({ key: historyKey(state.info.bvid, state.info.p), bvid: state.info.bvid, p: state.info.p, title: state.info.title, up: state.info.up, part: state.info.part, pagesTotal: state.info.pagesTotal, lan: track.lan, model: cfg("model"), summary: state.summary, transcript: state.transcript, ts: Date.now(), }); log("🕘 已存入历史总结", "ok"); } catch (e) { log("❌ " + e.message, "err"); } finally { run.disabled = false; } } function downloadTranscript() { if (!state.info) return; const safe = state.info.title.replace(/[\\/:*?"<>|]/g, "_").slice(0, 60); const blob = new Blob( [`【${state.info.title}】\nUP: ${state.info.up}\n\n===== 总结 =====\n\n${state.summary}\n\n===== 讲稿 =====\n\n${state.transcript}\n`], { type: "text/plain;charset=utf-8" } ); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `${safe}_P${state.info.p}_BLSubSum.txt`; a.click(); URL.revokeObjectURL(a.href); } // ---------------- SPA 路由感知 ---------------- // B站视频页是 SPA,切换视频时重置状态重新加载轨道 let lastUrl = location.href; setInterval(() => { if (location.href === lastUrl) return; lastUrl = location.href; state = { tracks: [], info: null, transcript: "", summary: "" }; if ($("#bls-panel")?.classList.contains("open")) loadTracks(); }, 800); // ---------------- 启动 ---------------- function boot() { if (!getBvid()) return; buildUI(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot); } else { boot(); } })();