// ==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 = `