// background service worker (MV3): 侧边栏行为 + LLM代理 + API兜底 + 评论历史 + 运行编排 // 点工具栏图标 → 打开侧边栏(不要设 default_popup,会被 popup 抢走) if (chrome.sidePanel && chrome.sidePanel.setPanelBehavior) { chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {}); } // B站 CDN(bilivideo.com)要求 Referer 才给视频文件,而 chrome.downloads 不能自定义 // Referer(Unsafe header)→ 用 declarativeNetRequest 给这些请求自动注入 Referer async function ensureDownloadHeadersRule() { try { const existing = await chrome.declarativeNetRequest.getDynamicRules(); const removeRuleIds = existing.filter(r => r.id === 1001).map(r => r.id); await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: removeRuleIds, addRules: [{ id: 1001, priority: 1, action: { type: "modifyHeaders", requestHeaders: [{ header: "Referer", operation: "set", value: "https://www.bilibili.com/" }] }, condition: { requestDomains: ["bilivideo.com", "hdslb.com"] } }] }); } catch (e) { /* 权限或浏览器版本不支持时忽略,下载会403并有报错提示 */ } } ensureDownloadHeadersRule(); // ============ 安装统计(CardAuth installations:只上报机器码+版本,不碰B站账号) ============ // 用 check-update 接口登记(服务端会写 installations 表并返回 install_id), // 顺带拿到 latest_version → 面板可提示「有新版本」 let installReported = false; async function reportInstall(force) { if (installReported && !force) return; try { const machine = await getMachineId(); const ver = chrome.runtime.getManifest().version; const ua = (typeof navigator !== "undefined" && navigator.userAgent) || ""; const m = ua.match(/(Chrome|Edg)\/[\d.]+/); const r = await authFetch("/public/installations/check-update", { method: "POST", headers: { "Content-Type": "application/json", "X-Api-Key": CARD_API_KEY }, body: JSON.stringify({ project_id: PROJECT_ID, machine_id: machine, client_version: ver, device_info: (m ? m[0] : "chrome-extension") }) }, 8000); if (r && r.code === 200 && r.data) { installReported = true; await chrome.storage.local.set({ updateAvailable: !!r.data.update_available, latestVersion: String(r.data.latest_version || "") }); } } catch (e) { /* 统计失败不影响任何功能,也不阻塞用户 */ } } if (chrome.runtime.onInstalled) { chrome.runtime.onInstalled.addListener(() => reportInstall(true)); } reportInstall(false); // SW 启动即登记一次(同一浏览器只记一条) // ============ 授权(CardAuth: auth.88531.cn,项目=B站评论插件) ============ const AUTH_BASE = "https://auth.88531.cn/api"; const PROJECT_NAME = "B站评论插件"; const PROJECT_ID = 4; const TRIAL_MS = 72 * 3600 * 1000; // 未授权试用3天 const TRIAL_COMMENT_LIMIT = 30; // 试用期内最多30条评论,用完或到期任一先到即止 const CARD_API_KEY = "ak_ab99ed40b2296d6d2db06a4b5692b987"; async function getMachineId() { const c = await chrome.storage.local.get("machineId"); if (c.machineId && /^\d{10,16}$/.test(String(c.machineId))) return String(c.machineId); let id = ""; for (let i = 0; i < 13; i++) { id += (i === 0 ? String(1 + Math.floor(Math.random() * 9)) : String(Math.floor(Math.random() * 10))); } await chrome.storage.local.set({ machineId: id }); return id; } async function authFetch(path, opts, timeoutMs) { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), timeoutMs || 8000); try { const resp = await fetch(AUTH_BASE + path, Object.assign({ signal: ctl.signal }, opts || {})); return await resp.json(); } finally { clearTimeout(timer); } } // 授权状态:机器码在 CardAuth 是否有本项目的有效授权;无授权则看本地1天试用 // 授权服务器不可用时 fail-open(放行),绝不因服务器故障锁死用户 async function checkLicense() { const machine = await getMachineId(); let authorized = false; let expire = ""; let offline = false; try { const r = await authFetch("/public/authorizations/query?bot_qq=" + machine); if (r && r.code === 200 && r.data && Array.isArray(r.data.list)) { // 只认本项目的授权(其它项目的卡不能解锁本插件) const mine = r.data.list.filter(it => it && it.project_name === PROJECT_NAME && it.status === "active" && !it.is_expired); if (mine.length) { authorized = true; const times = mine.map(it => it.expire_time).filter(Boolean).sort(); expire = times.length ? times[times.length - 1] : ""; } } else if (r && r.code === 200 && r.data && r.data.has_valid_auth === false) { authorized = false; } else if (r && r.code === 200) { // 形状异常时按 fail-safe 处理为未授权(有试用兑底),但网络通 authorized = false; } else { offline = true; } } catch (e) { offline = true; // 网络/超时 → 放行 } const st = await chrome.storage.local.get(["trialStart", "trialComments"]); let trialStart = Number(st.trialStart) || 0; if (!trialStart) { trialStart = Date.now(); await chrome.storage.local.set({ trialStart: trialStart }); } const trialEnd = trialStart + TRIAL_MS; const now = Date.now(); const leftMs = Math.max(0, trialEnd - now); const usedComments = Number(st.trialComments) || 0; const leftComments = Math.max(0, TRIAL_COMMENT_LIMIT - usedComments); // 到期后24小时内购买享8折(折扣窗口从试用到期时刻起算) const discountEnd = trialEnd + 24 * 3600 * 1000; const inDiscount = leftMs <= 0 && now < discountEnd; const discountLeftMs = inDiscount ? discountEnd - now : 0; return { authorized: authorized || offline, // offline 时视为放行 offline: offline, realAuthorized: authorized, expire: expire, machine: machine, trialStart: trialStart, trialEnd: trialEnd, trialLeftMs: leftMs, trialCommentsUsed: usedComments, trialCommentsLimit: TRIAL_COMMENT_LIMIT, trialCommentsLeft: leftComments, inDiscount: inDiscount, discountLeftMs: discountLeftMs }; } // 卡密激活:verify 首次调用即绑定机器码(客户端式激活) async function activateCard(cardKey) { const machine = await getMachineId(); const r = await authFetch("/public/verify", { method: "POST", headers: { "Content-Type": "application/json", "X-Api-Key": CARD_API_KEY }, body: JSON.stringify({ card_key: String(cardKey || "").trim(), machine_id: machine, client_version: chrome.runtime.getManifest().version }) }, 12000); const d = (r && r.data) || {}; if (r && r.code === 200 && d.valid) { return { ok: true, message: d.message || "激活成功", days: d.duration_days || 0 }; } return { ok: false, message: (d && d.message) || (r && r.message) || "激活失败" }; } // 公告:只取本项目发布的(后台公告管理里选「B站评论插件」项目) async function getAnnouncements() { const r = await authFetch("/public/announcements", {}, 8000); const list = (r && r.code === 200 && r.data && Array.isArray(r.data.list)) ? r.data.list : []; return list .filter(a => a && Number(a.project_id) === PROJECT_ID) .sort((a, b) => (b.is_top || 0) - (a.is_top || 0)) .slice(0, 3) .map(a => ({ title: a.title || "", content: a.content || "", link: a.link_url || "" })); } // ============ LLM(OpenAI 兼容接口) ============ // 归一化 Base URL:去尾斜杠;已带 /chat/completions 直接用;路径无 /vN 段则补 /v1 function normalizeChatUrl(raw) { let s = String(raw || "").trim().replace(/\/+$/, ""); if (!s) return ""; if (/\/chat\/completions$/.test(s)) return s; if (!/\/v\d+(\/|$)/.test(s)) s += "/v1"; return s + "/chat/completions"; } // 从各种返回形状取正文(绝不回退 reasoning_content) function extractContent(data) { if (!data) return ""; const c = data.choices && data.choices[0]; if (c) { const m = c.message; if (m) { if (typeof m.content === "string" && m.content.trim()) return m.content; if (Array.isArray(m.content)) { const t = m.content.map(p => (p && (p.text || p.content)) || "").join(""); if (t.trim()) return t; } } if (typeof c.text === "string" && c.text.trim()) return c.text; } if (typeof data.output_text === "string" && data.output_text.trim()) return data.output_text; if (typeof data.output === "string" && data.output.trim()) return data.output; return ""; } function buildBody(p) { const body = { model: p.model, messages: [ { role: "system", content: p.system || "你只输出结果文本本身,不做任何解释。" }, { role: "user", content: p.prompt } ], temperature: typeof p.temperature === "number" ? p.temperature : 1.0, max_tokens: p.maxTokens || 800, presence_penalty: 0.3, frequency_penalty: 0.4, thinking: { type: "disabled" } }; return body; } async function doChat(url, key, body, timeoutMs) { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), timeoutMs); try { const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer " + (key || "") }, body: JSON.stringify(body), signal: ctl.signal }); const text = await resp.text(); let data = null; try { data = JSON.parse(text); } catch (e) { data = null; } return { resp, data, text }; } finally { clearTimeout(timer); } } // 400/422 修复阶梯:最多一次重试 function repairBody(body, data, status) { const msg = (((data || {}).error || {}).message || "").toString(); const lower = msg.toLowerCase(); if (/thinking|unknown|unrecognized|extra|invalid|not allowed|parameter|不支持|参数/.test(lower)) { if (body.thinking) { const nb = Object.assign({}, body); delete nb.thinking; return { body: nb, reason: "去掉 thinking 字段" }; } if (body.max_tokens && /max_tokens|max_completion_tokens/.test(lower)) { const nb = Object.assign({}, body); nb.max_completion_tokens = nb.max_tokens; delete nb.max_tokens; return { body: nb, reason: "max_tokens 改名 max_completion_tokens" }; } } return null; } async function handleCallAiApi(p, sendResponse) { const url = normalizeChatUrl(p.baseUrl); if (!url || !p.model) { sendResponse({ ok: false, error: "模型未配置(缺 Base URL 或模型名)" }); return; } const timeoutMs = p.timeoutMs || 90000; const key = p.apiKey || ""; let body = buildBody(p); let r; try { r = await doChat(url, key, body, timeoutMs); } catch (e) { sendResponse({ ok: false, error: String(e && e.message || e).includes("abort") ? "请求超时(" + Math.round(timeoutMs / 1000) + "秒)" : String(e) }); return; } if (r.resp.status === 400 || r.resp.status === 422) { const fixed = repairBody(body, r.data, r.resp.status); if (fixed) { try { r = await doChat(url, key, fixed.body, timeoutMs); } catch (e) { sendResponse({ ok: false, error: "请求超时(" + Math.round(timeoutMs / 1000) + "秒)" }); return; } } } if (!r.resp.ok) { const msg = (r.data && r.data.error && r.data.error.message) || ("HTTP " + r.resp.status); sendResponse({ ok: false, error: String(msg) }); return; } let content = extractContent(r.data); const finish = r.data && r.data.choices && r.data.choices[0] && r.data.choices[0].finish_reason; const hasReasoning = !!(r.data && r.data.choices && r.data.choices[0] && r.data.choices[0].message && r.data.choices[0].message.reasoning_content); if (!content && (finish === "length" || hasReasoning)) { // 思考模型吃掉了 max_tokens → 提额重试一次 const nb = Object.assign({}, body, { max_tokens: Math.max(4096, (body.max_tokens || 800) * 4) }); try { const r2 = await doChat(url, key, nb, timeoutMs); if (r2.resp.ok) content = extractContent(r2.data); } catch (e) { /* 保持 content 为空走下方报错 */ } } if (!content) { sendResponse({ ok: false, error: "模型只返回了思考过程,没有返回正文(可提高 max_tokens 或换非思考型模型)" }); return; } sendResponse({ ok: true, content: content.trim() }); } // ============ API 兜底代理(页面 fetch 被 CORS/CSP 拦时走这里) ============ async function handleBgFetch(p, sendResponse) { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), p.timeoutMs || 20000); try { const resp = await fetch(p.url, { method: p.method || "GET", headers: p.headers || {}, body: p.body, credentials: "include", signal: ctl.signal }); const text = await resp.text(); sendResponse({ ok: true, status: resp.status, text }); } catch (e) { const aborted = String(e && e.name) === "AbortError"; sendResponse({ ok: false, error: aborted ? "请求超时(" + Math.round((p.timeoutMs || 20000) / 1000) + "秒)" : String(e) }); } finally { clearTimeout(timer); } } // ============ 评论历史(去重 + 统计) ============ async function getHistory() { const c = await chrome.storage.local.get("commentHistory"); const h = c.commentHistory; return Array.isArray(h) ? h : []; } async function addRecord(rec) { const h = await getHistory(); if (h.some(x => x.bvid === rec.bvid)) return h; h.unshift({ bvid: rec.bvid, title: rec.title || "", text: rec.text || "", keyword: rec.keyword || "", ts: rec.ts || Date.now() }); if (h.length > 3000) h.length = 3000; await chrome.storage.local.set({ commentHistory: h }); return h; } function statsOf(h) { const now = Date.now(); const DAY = 86400000; const d = { total: h.length, today: 0, week: 0, list: h }; for (const r of h) { const age = now - (r.ts || 0); if (age <= DAY) d.today++; if (age <= 7 * DAY) d.week++; } return d; } // ============ 运行编排:任务存后台内存(跨子域名页面共享),页面加载后主动领任务 ============ let ACTIVE_TASK = null; // 当前未完成任务(唯一权威副本) function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } function waitForTabComplete(tabId, timeoutMs) { return new Promise(resolve => { let done = false; const onUpd = (id, info) => { if (id === tabId && info.status === "complete" && !done) { done = true; try { chrome.tabs.onUpdated.removeListener(onUpd); } catch (e) {} resolve(true); } }; chrome.tabs.onUpdated.addListener(onUpd); setTimeout(() => { if (!done) { done = true; try { chrome.tabs.onUpdated.removeListener(onUpd); } catch (e) {} resolve(false); } }, timeoutMs || 10000); }); } // 内容脚本版本(老脚本没有 ver 处理会抛错 → 返回空串) async function contentVersion(tabId) { try { const r = await chrome.tabs.sendMessage(tabId, { action: "ver" }); return (r && r.ver) || ""; } catch (e) { return ""; } } // 版本握手后确保页面里是最新脚本(不一致则刷新页面),返回是否就绪 async function ensureFreshContent(tabId) { const want = chrome.runtime.getManifest().version; let ver = await contentVersion(tabId); if (ver === want) return true; await chrome.tabs.reload(tabId); await waitForTabComplete(tabId, 10000); for (let i = 0; i < 12 && ver !== want; i++) { await sleep(500); ver = await contentVersion(tabId); } return ver === want; } async function findBiliTab() { const tabs = await chrome.tabs.query({}); // 优先匹配任务当前目标视频所在的标签页(停止指令要发到正在执行的那一页) if (ACTIVE_TASK && Array.isArray(ACTIVE_TASK.queue) && ACTIVE_TASK.queue[ACTIVE_TASK.index]) { const bvid = ACTIVE_TASK.queue[ACTIVE_TASK.index].bvid; const hit = tabs.find(t => t.url && t.url.indexOf(bvid) >= 0); if (hit) return hit; } return tabs.find(t => t.url && /^https:\/\/[^/]*bilibili\.com\//.test(t.url)) || null; } async function ensureContent(tabId) { // 自愈注入:manifest 的 content_scripts 只在页面加载后存在,新装扩展的旧标签页没有 try { await chrome.scripting.executeScript({ target: { tabId }, files: ["content.js"] }); } catch (e) { /* 已存在或页面不允许注入 */ } } async function sendWithRetry(tabId, msg, tries) { let lastErr = null; for (let i = 0; i < (tries || 10); i++) { try { return await chrome.tabs.sendMessage(tabId, msg); } catch (e) { lastErr = e; await sleep(500); } } throw lastErr || new Error("消息发送失败"); } async function handleStartRun(p, sendResponse) { try { // 授权闸门:未授权时同时检查试用时长与试用条数(下载视频不走这里,不受影响) const lic = await checkLicense(); if (!lic.authorized) { if (lic.trialLeftMs <= 0) { sendResponse({ ok: false, error: "免费试用已到期,自动评论已锁定。购买卡密激活后即可继续使用(下载视频不受影响)" }); return; } if (lic.trialCommentsLeft <= 0) { sendResponse({ ok: false, error: "试用期 " + lic.trialCommentsLimit + " 条评论已用完,购买卡密激活后不限量(下载视频不受影响)" }); return; } // 试用中:把剩余额度写进任务配置,内容脚本发到额度会自动停 p.config = p.config || {}; p.config.trialQuota = lic.trialCommentsLeft; if (Number(p.config.maxN) > lic.trialCommentsLeft) { p.config.maxN = lic.trialCommentsLeft; } } ACTIVE_TASK = null; // 本次点「开始」= 全新任务,作废残留的旧任务(旧任务会带着预生成的旧文案继续跑) let tab = await findBiliTab(); if (!tab) { tab = await chrome.tabs.create({ url: "https://search.bilibili.com/all?keyword=" + encodeURIComponent(p.config.keyword || "") }); await sleep(1800); // 等页面加载出内容脚本 } else { await chrome.tabs.update(tab.id, { active: true }); } await ensureContent(tab.id); // 版本握手:重载扩展不会热更新已打开页面里的老脚本 → 不一致先刷新页面换新脚本 const fresh = await ensureFreshContent(tab.id); if (!fresh) { sendResponse({ ok: false, error: "B站页面脚本未就绪,请手动刷新该标签页后重试" }); return; } const res = await sendWithRetry(tab.id, { action: "start", config: p.config }); sendResponse({ ok: true, tabId: tab.id, res: res || null }); } catch (e) { sendResponse({ ok: false, error: "无法连接B站页面:" + String(e && e.message || e) + "(请刷新B站标签页后重试)" }); } } async function handleStopRun(p, sendResponse) { try { const tab = await findBiliTab(); if (!tab) { sendResponse({ ok: false, error: "没找到B站标签页" }); return; } await ensureContent(tab.id); const res = await sendWithRetry(tab.id, { action: "stop" }, 4); sendResponse({ ok: true, res: res || null }); } catch (e) { sendResponse({ ok: false, error: String(e && e.message || e) }); } } // ============ 下载当前页视频(取直链,实际抓取与保存在侧边栏页面完成) ============ async function handleGetDownloadInfo(p, sendResponse) { try { const tabs = await chrome.tabs.query({}); const videoTabs = tabs.filter(t => t.url && /bilibili\.com\/video\//.test(t.url)); if (!videoTabs.length) { sendResponse({ ok: false, error: "请先打开一个B站视频页,再点下载" }); return; } // 优先当前活动的视频页 videoTabs.sort((a, b) => (b.active ? 1 : 0) - (a.active ? 1 : 0)); const tab = videoTabs[0]; await ensureContent(tab.id); const fresh = await ensureFreshContent(tab.id); if (!fresh) { sendResponse({ ok: false, error: "B站页面脚本未就绪,请刷新该视频页后重试" }); return; } const r = await sendWithRetry(tab.id, { action: "getDownload", qn: Number(p.qn) || 80 }); if (!r || !r.ok || !r.info) { sendResponse({ ok: false, error: (r && r.error) || "页面无响应" }); return; } const info = r.info; if (!Array.isArray(info.files) || !info.files.length) { sendResponse({ ok: false, error: "没有可下载的地址" }); return; } sendResponse({ ok: true, info: info }); } catch (e) { sendResponse({ ok: false, error: String(e && e.message || e) }); } } // ============ 消息路由 ============ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (!msg) return; // 内容脚本的进度事件:侧边栏自身也会直接收到(runtime 消息发给除发送者外的所有扩展上下文), // 这里不再转发,否则面板收到两份 → 成功列表/日志显示重复 if (msg.relay) { sendResponse({ ok: true }); return; } if (msg.action === "callAiApi") { handleCallAiApi(msg, sendResponse); return true; } if (msg.action === "bgFetch") { handleBgFetch(msg, sendResponse); return true; } if (msg.action === "licenseStatus") { (async () => { const license = await checkLicense(); let announcements = []; try { announcements = await getAnnouncements(); } catch (e) { announcements = []; } const st = await chrome.storage.local.get(["updateAvailable", "latestVersion"]); reportInstall(false); // 面板打开时补一次登记(SW 冷启动后 onInstalled 不会再触发) sendResponse({ ok: true, license: license, announcements: announcements, update: { available: !!st.updateAvailable, latest: String(st.latestVersion || ""), current: chrome.runtime.getManifest().version } }); })(); return true; } if (msg.action === "activateCard") { (async () => { try { sendResponse(await activateCard(msg.card)); } catch (e) { sendResponse({ ok: false, error: String(e && e.message || e) }); } })(); return true; } if (msg.action === "setTask") { ACTIVE_TASK = msg.task || null; sendResponse({ ok: true }); return; } if (msg.action === "pullTask") { // 只给新鲜任务(120秒内更新过),防止关掉标签页后残留任务在别的页面乱续 if (ACTIVE_TASK && Date.now() - (ACTIVE_TASK.ts || 0) <= 120000) { sendResponse({ task: ACTIVE_TASK }); } else { ACTIVE_TASK = null; sendResponse({ task: null }); } return; } if (msg.action === "clearTask") { ACTIVE_TASK = null; sendResponse({ ok: true }); return; } if (msg.action === "startRun") { handleStartRun(msg, sendResponse); return true; } if (msg.action === "stopRun") { handleStopRun(msg, sendResponse); return true; } if (msg.action === "getDownloadInfo") { handleGetDownloadInfo(msg, sendResponse); return true; } if (msg.action === "addRecord") { (async () => { const h = await addRecord({ bvid: msg.bvid, title: msg.title, text: msg.text, keyword: msg.keyword, ts: msg.ts }); const s = statsOf(h); // 试用计数:每成功发一条评论消耗一次额度(仅未授权时计数) let trialCommentsLeft = null; const lic = await checkLicense(); if (!lic.realAuthorized && !lic.offline) { const used = lic.trialCommentsUsed + 1; await chrome.storage.local.set({ trialComments: used }); trialCommentsLeft = Math.max(0, lic.trialCommentsLimit - used); } sendResponse({ ok: true, stats: { total: s.total, today: s.today, week: s.week }, trialCommentsLeft: trialCommentsLeft }); })(); return true; } if (msg.action === "getStats") { (async () => { const s = statsOf(await getHistory()); sendResponse({ stats: { total: s.total, today: s.today, week: s.week }, list: s.list.slice(0, 100) }); })(); return true; } if (msg.action === "clearHistory") { (async () => { await chrome.storage.local.set({ commentHistory: [] }); sendResponse({ ok: true, stats: { total: 0, today: 0, week: 0 } }); })(); return true; } });