626 lines
22 KiB
JavaScript
626 lines
22 KiB
JavaScript
// 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 = 24 * 3600 * 1000; // 未授权试用1天
|
||
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");
|
||
let trialStart = Number(st.trialStart) || 0;
|
||
if (!trialStart) {
|
||
trialStart = Date.now();
|
||
await chrome.storage.local.set({ trialStart: trialStart });
|
||
}
|
||
const trialEnd = trialStart + TRIAL_MS;
|
||
const leftMs = Math.max(0, trialEnd - Date.now());
|
||
|
||
return {
|
||
authorized: authorized || offline, // offline 时视为放行
|
||
offline: offline,
|
||
realAuthorized: authorized,
|
||
expire: expire,
|
||
machine: machine,
|
||
trialStart: trialStart,
|
||
trialEnd: trialEnd,
|
||
trialLeftMs: leftMs
|
||
};
|
||
}
|
||
|
||
// 卡密激活: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 && lic.trialLeftMs <= 0) {
|
||
sendResponse({ ok: false, error: "免费试用已到期,自动评论已锁定。购买卡密激活后即可继续使用(下载视频不受影响)" });
|
||
return;
|
||
}
|
||
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);
|
||
sendResponse({ ok: true, stats: { total: s.total, today: s.today, week: s.week } });
|
||
})();
|
||
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;
|
||
}
|
||
});
|