Files

659 lines
24 KiB
JavaScript
Raw Permalink 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.
// 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;
}
});