commit 38b90c1448ec96b7d45716047d7e571c7fbad9f1 Author: personal82555 Date: Tue Oct 13 17:33:50 2026 +0800 v1.6.6: 使用说明+官网页链接、下载改夸克网盘、评论3字下限、AI读取视频真实内容、模拟真人逐字评论 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed84bb8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__test_harness.html +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf72637 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# B站评论助手(BiliKeywordComment) + +Chrome/Edge 浏览器插件(Manifest V3):按**搜索关键词**找 B站视频,自动生成评论逐条发布。直接使用你浏览器里的 B站登录态,**不需要手动填 Cookie**。 + +## 功能 + +- 🔍 关键词搜索:综合/最多播放/最多弹幕/最多收藏/最新 五种排序 +- 💬 两种评论内容:固定模板(零配置可用)/ AI 生成(OpenAI 兼容接口,失败自动降级并提示) +- 🛡 防重复:评论成功的视频记入本地历史,下次自动跳过 +- ⏸ 可中断:运行中随时 ⏹ 停止,停止标记跨页面刷新保留,发送前二次检查 +- 🖥 过程可见:真实 B站标签页置前 + 页面右上角浮层逐步显示,侧边栏有计时、成功列表和执行日志 +- 🔁 双通道网络:页面 fetch 优先,被 CORS/CSP 拦截时自动走后台代理兜底 + +## 安装 + +1. 解压 zip(或直接用解压后的目录) +2. 打开 `chrome://extensions` → 打开右上角「开发者模式」 +3. 「加载已解压的扩展程序」→ 选 `BiliKeywordComment` 目录 + +## 使用 + +1. 登录 B站网页版(登录态直接复用,插件不存 Cookie) +2. 点工具栏图标打开右侧边栏 +3. 填搜索关键词,设置「最多 N 条 / 每隔 X 秒 / 播放量门槛 / 评论方式」(自动保存) +4. 点 **▶ 开始执行** → 页面自动置前,右上角浮层显示每一步;⏹ 停止随时可中断 + +AI 模式需填 LLM 配置(默认已预填 `https://ai.88531.cn/v1` + `mimo-v2.6-flash`,填 API Key 即可);不填也能跑,走固定模板。 + +## 风控提醒 + +- 默认「最多 3 条、间隔 90 秒」,请勿调得过于激进 +- 评论不含外链/广告,纯内容型;触发验证码(12045)或今日上限(12008)会自动停止 +- 建议每天 ≤ 10 条,宁可少发 + +## 文件结构 + +| 文件 | 作用 | +|------|------| +| `manifest.json` | MV3 清单(侧边栏 + 内容脚本 + host 权限) | +| `background.js` | Service worker:LLM 代理、API 兜底、评论历史、运行编排 | +| `content.js` / `content.css` | 页面内执行:搜索 → 生成 → 发布 + 进度浮层 | +| `sidepanel.html` / `sidepanel.js` | 侧边栏 UI | +| `icons/` | 16/32/48/128 图标 | + +## 已验证 + +- 面板 harness:初始化填值、测试连接、空关键词拦截、启动/停止/完成生命周期、行内警告、零 JS 错误、零 alert +- 真实扩展加载(Chromium + `--load-extension`):SW 启动、内容脚本 ping、B站搜索/评论接口页面源可达(CORS 通过)、bgFetch 兜底 200、未登录分支浮层与提示正确、重复注入仅一个浮层 +- 后台 LLM 真代码分支:真实 200 取正文、400 去 thinking 重试、空内容提额重试、无关 400 不盲试、异常可读报错、URL 归一化 diff --git a/background.js b/background.js new file mode 100644 index 0000000..cbd0764 --- /dev/null +++ b/background.js @@ -0,0 +1,585 @@ +// 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: 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 = []; } + sendResponse({ ok: true, license: license, announcements: announcements }); + })(); + 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; + } +}); diff --git a/content.css b/content.css new file mode 100644 index 0000000..bd201a5 --- /dev/null +++ b/content.css @@ -0,0 +1,46 @@ +/* B站评论助手 - 页面浮层 */ +#biliKwOverlay { + position: fixed; + top: 14px; + right: 14px; + z-index: 2147483647; + width: 300px; + max-height: 260px; + overflow-y: auto; + background: rgba(20, 22, 31, 0.94); + color: #c5fff2; + font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; + font-size: 12px; + line-height: 1.6; + border-radius: 8px; + padding: 8px 10px; + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35); +} +#biliKwOverlay .biliKwTitle { + font-weight: bold; + font-size: 13px; + color: #fff; + margin-bottom: 4px; +} +#biliKwOverlay .biliKwStep { color: #ffd76e; word-break: break-all; } +#biliKwOverlay .biliKwStep.ok { color: #8ef7b7; } +#biliKwOverlay .biliKwStep.fail { color: #ff7b72; } + +#biliKwToast { + position: fixed; + top: 14px; + left: 50%; + transform: translateX(-50%) translateY(-8px); + z-index: 2147483647; + background: rgba(251, 114, 153, 0.96); + color: #fff; + font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; + font-size: 13px; + padding: 8px 16px; + border-radius: 20px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3); + opacity: 0; + pointer-events: none; + transition: opacity 0.25s, transform 0.25s; +} +#biliKwToast.show { opacity: 1; transform: translateX(-50%) translateY(0); } diff --git a/content.js b/content.js new file mode 100644 index 0000000..d176ae1 --- /dev/null +++ b/content.js @@ -0,0 +1,964 @@ +// content script: B站关键词评论 —— 模拟真人:打开视频页 → 滚到评论区 → 真实输入 → 点发布 +(() => { + if (window.__biliKwInstalled) return; // 幂等守卫:重复注入不能挂第二个监听器 + window.__biliKwInstalled = true; + + const STOP_KEY = "__biliKwStop"; + const TASK_KEY = "__biliKwTask"; + const TASK_TTL = 120000; // 任务恢复窗口(导航加载可能较慢) + // 版本号:后台启动前握手,发现页面里是老脚本(重载扩展不会热更新)会强制刷新页面 + const MY_VER = (typeof chrome !== "undefined" && chrome.runtime && chrome.runtime.getManifest) + ? chrome.runtime.getManifest().version : ""; + + let running = false; + + // ============ 进度浮层 ============ + + let overlay = null, stepBox = null, toastEl = null, toastTimer = null; + + function ensureOverlay() { + if (overlay && document.body.contains(overlay)) return; + overlay = document.createElement("div"); + overlay.id = "biliKwOverlay"; + overlay.innerHTML = '
💬 B站评论助手
'; + stepBox = overlay.querySelector(".biliKwSteps"); + document.body.appendChild(overlay); + } + + function step(text, cls) { + ensureOverlay(); + const line = document.createElement("div"); + line.className = "biliKwStep" + (cls ? " " + cls : ""); + line.textContent = new Date().toTimeString().slice(0, 8) + " " + text; + stepBox.appendChild(line); + while (stepBox.children.length > 10) stepBox.removeChild(stepBox.firstChild); + overlay.scrollTop = overlay.scrollHeight; + relay({ type: "log", text: text, cls: cls || "" }); + } + + function toast(text) { + ensureOverlay(); + if (!toastEl) { + toastEl = document.createElement("div"); + toastEl.id = "biliKwToast"; + document.body.appendChild(toastEl); + } + toastEl.textContent = text; + toastEl.classList.add("show"); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => toastEl.classList.remove("show"), 4500); + relay({ type: "log", text: "⚠ " + text, cls: "fail" }); + } + + function relay(ev) { + try { chrome.runtime.sendMessage({ relay: ev }); } catch (e) { /* 扩展被重装 */ } + } + + // ============ 停止控制(sessionStorage 持久化,可跨导航) ============ + + function stopRequested() { + try { return sessionStorage.getItem(STOP_KEY) === "1"; } catch (e) { return false; } + } + function clearStop() { + try { sessionStorage.removeItem(STOP_KEY); } catch (e) {} + } + function requestStop() { + try { sessionStorage.setItem(STOP_KEY, "1"); } catch (e) {} + } + + // 可中断等待:每 ≤400ms 查一次停止标记 + function interruptibleWait(ms) { + return new Promise(resolve => { + const end = Date.now() + ms; + (function tick() { + if (stopRequested() || Date.now() >= end) return resolve(); + setTimeout(tick, Math.min(400, Math.max(0, end - Date.now()))); + })(); + }); + } + function randWait(min, max) { return interruptibleWait(min + Math.random() * (max - min)); } + // 两条评论之间的间隔:60~120秒随机(更像真人) + function nextGapMs() { return Math.round((60 + Math.random() * 60) * 1000); } + + // ============ 任务持久化(跨页面跳转续跑) ============ + + function loadTask() { + try { + const raw = sessionStorage.getItem(TASK_KEY); + if (!raw) return null; + const t = JSON.parse(raw); + if (!t || t.phase !== "video" || !Array.isArray(t.queue) || Date.now() - (t.ts || 0) > TASK_TTL) { + sessionStorage.removeItem(TASK_KEY); + return null; + } + return t; + } catch (e) { return null; } + } + function stashTask(t) { + t.ts = Date.now(); + try { sessionStorage.setItem(TASK_KEY, JSON.stringify(t)); } catch (e) {} + // 同时推给后台(权威副本,跨子域名页面加载后可主动领取) + try { chrome.runtime.sendMessage({ action: "setTask", task: t }); } catch (e) {} + } + function clearTask() { + try { sessionStorage.removeItem(TASK_KEY); } catch (e) {} + try { chrome.runtime.sendMessage({ action: "clearTask" }); } catch (e) {} + } + + // ============ 网络请求(页面 fetch 优先,被拦则走后台代理) ============ + + async function apiJSON(url, opts) { + try { + const r = await fetch(url, Object.assign({ credentials: "include" }, opts || {})); + return await r.json(); + } catch (e) { + const res = await chrome.runtime.sendMessage({ + action: "bgFetch", url: url, + method: (opts && opts.method) || "GET", + headers: (opts && opts.headers) || {}, + body: (opts && opts.body) || undefined + }); + if (!res || !res.ok) throw new Error((res && res.error) || String(e)); + try { return JSON.parse(res.text); } + catch (e2) { throw new Error("接口返回非 JSON"); } + } + } + + function getCookie(name) { + const m = document.cookie.match(new RegExp("(?:^|;\\s*)" + name + "=([^;]*)")); + return m ? decodeURIComponent(m[1]) : ""; + } + + async function checkLogin() { + try { + const data = await apiJSON("https://api.bilibili.com/x/web-interface/nav"); + return !!(data && data.code === 0 && data.data && data.data.isLogin); + } catch (e) { return false; } + } + + // ============ 关键词搜索 ============ + + async function searchVideos(keyword, order, page, tid) { + // hot = 最热门:按最多播放检索,并在本地再按播放量降序兜底(热门排最前) + const apiOrder = order === "hot" ? "click" : (order || "totalrank"); + const url = "https://api.bilibili.com/x/web-interface/wbi/search/all/v2" + + "?keyword=" + encodeURIComponent(keyword) + + "&page=" + (page || 1) + + "&order=" + encodeURIComponent(apiOrder) + + (Number(tid) > 0 ? "&tids=" + Number(tid) : ""); // 分区筛选(科技188/知识36/游戏4…) + const data = await apiJSON(url, { headers: { Referer: "https://search.bilibili.com/" } }); + if (!data || data.code !== 0) throw new Error("搜索失败:" + ((data && data.message) || "接口异常")); + const sections = (data.data && data.data.result) || []; + const out = []; + for (const sec of (Array.isArray(sections) ? sections : [])) { + const rtype = String(sec.result_type || sec.resultType || "").toLowerCase(); + if (rtype && rtype !== "video") continue; + for (const it of (Array.isArray(sec.data) ? sec.data : [])) { + if (it.type !== "video" || !it.bvid || !it.aid) continue; + out.push({ + bvid: String(it.bvid), aid: Number(it.aid), + title: String(it.title || "").replace(/<[^>]+>/g, ""), + author: String(it.author || ""), play: Number(it.play || 0), + tag: String(it.tag || ""), + url: "https://www.bilibili.com/video/" + it.bvid + }); + } + } + const seen = new Set(); + const uniq = out.filter(v => (seen.has(v.bvid) ? false : (seen.add(v.bvid), true))); + if (order === "hot") uniq.sort((a, b) => b.play - a.play); + return uniq; + } + + // ============ 评论内容 ============ + + const TPL_SOFTWARE = [ + "用了同款软件半年多,确实稳定,更新也勤快。补充一句:设置里把自动更新打开就不用手动折腾了。", + "这款工具我也在用,最实用的是批量处理功能,日常能省不少时间。建议新手先看下内置文档。", + "视频里这个思路不错。补充一个细节:把快捷键自定义一下,熟练之后效率还能再上一个台阶。", + "一直在用这类工具,从旧版用到现在,功能越来越完善了。有跨平台同步需求的可以关注下它的同步功能。" + ]; + const TPL_GENERIC = [ + "讲得很清楚,特别是中间那段实操演示,照着做基本不会踩坑,已三连支持。", + "同类内容里这个算讲得最细的了,思路和细节都到位,新人看完能少走很多弯路。", + "看完立马动手试了一下,确实有效。UP能把这么细的东西整理出来,用心了。", + "很有启发,准备按这个方法实践一波,回头再来汇报效果,感谢分享!" + ]; + const SOFTWARE_RE = /软件|工具|app|应用|插件|效率|装机|电脑|手机/i; + + function pickTemplate(title, keyword) { + const pool = SOFTWARE_RE.test((title || "") + " " + (keyword || "")) ? TPL_SOFTWARE : TPL_GENERIC; + return pool[Math.floor(Math.random() * pool.length)]; + } + + function verifyMessage(msg) { + const s = String(msg || "").trim(); + if (s.length < 3) return { ok: false, reason: "评论太短" }; + if (s.length > 200) return { ok: false, reason: "评论太长" }; + if (/(?:https?:\/\/|www\.[a-z0-9-]+\.[a-z]{2,})/i.test(s) && !/bilibili\.com|b23\.tv/i.test(s)) { + return { ok: false, reason: "含外链" }; + } + // 重复字检测按长度自适应:短评论门槛低,长评论门槛6 + if (new Set(s).size < Math.min(6, Math.ceil(s.length / 2))) { + return { ok: false, reason: "内容重复" }; + } + return { ok: true, text: s }; + } + + // 采集视频真实内容(标签/简介/热评/弹幕),仅 AI 模式调用 + async function fetchVideoContext(video) { + const ctx = { tags: "", desc: "", hot: "", dm: "" }; + try { + const t = await apiJSON("https://api.bilibili.com/x/tag/archive/tags?aid=" + video.aid); + if (t && t.code === 0 && Array.isArray(t.data)) { + ctx.tags = t.data.map(x => x && x.tag_name).filter(Boolean).slice(0, 10).join("、"); + } + } catch (e) { /* 尽力而为 */ } + try { + const rp = await apiJSON("https://api.bilibili.com/x/v2/reply/main?oid=" + video.aid + "&type=1&ps=5&mode=3"); + const rs = (rp && rp.data && rp.data.replies) || []; + ctx.hot = rs.slice(0, 4).map(x => String((x && x.content && x.content.message) || "").replace(/\n/g, " ").slice(0, 60)).filter(Boolean).join(" / "); + } catch (e) { /* 尽力而为 */ } + try { + const v = await apiJSON("https://api.bilibili.com/x/web-interface/view?bvid=" + video.bvid); + if (v && v.code === 0 && v.data) { + ctx.desc = String(v.data.desc || "").replace(/\n/g, " ").slice(0, 200); + const cid = v.data.cid; + if (cid) { + let xml = ""; + try { + const d = await fetch("https://api.bilibili.com/x/v1/dm/list.so?oid=" + cid, { credentials: "include" }); + xml = await d.text(); + } catch (e) { + const bg = await chrome.runtime.sendMessage({ + action: "bgFetch", + url: "https://api.bilibili.com/x/v1/dm/list.so?oid=" + cid, + headers: { Referer: "https://www.bilibili.com/" } + }); + if (bg && bg.ok) xml = bg.text || ""; + } + const ms = xml.match(/]*>([^<]+)<\/d>/g) || []; + ctx.dm = ms.slice(0, 8).map(s => s.replace(/<[^>]+>/g, "")).join("、"); + } + } + } catch (e) { /* 尽力而为 */ } + return ctx; + } + + async function genComment(video, cfg) { + // —— 自定义内容:只从有效行里取;全部无效直接报错,绝不换模板 —— + if (cfg.mode === "custom") { + const raw = String(cfg.customText || "").split("\n").map(s => s.trim()).filter(Boolean); + if (!raw.length) { + return { error: "选了自定义评论但内容为空:请在「评论方式=自定义」下方填写,每行一条" }; + } + const valid = []; + const bad = []; + for (const line of raw) { + const v = verifyMessage(line); + if (v.ok) valid.push(v.text); else bad.push(line + "(" + v.reason + ")"); + } + if (bad.length) step("⚠ " + bad.length + " 行自定义内容不合规不会使用:" + bad.join(";"), "fail"); + if (!valid.length) { + return { error: "自定义内容全部不合规(每行需3~200字、不含外链、不能全是重复字),请修改后重试" }; + } + return { text: valid[Math.floor(Math.random() * valid.length)], source: "custom" }; + } + if (cfg.mode === "ai") { + try { + // 先采集视频真实内容(标签/简介/热评/弹幕),让评论基于内容而不是瞎猜 + let ctxText = ""; + try { + const vc = await fetchVideoContext(video); + const parts = []; + if (vc.tags) parts.push("视频标签:" + vc.tags); + if (vc.desc) parts.push("视频简介:" + vc.desc); + if (vc.dm) parts.push("观众弹幕:" + vc.dm); + if (vc.hot) parts.push("观众热评:" + vc.hot); + if (parts.length) ctxText = "\n\n以下是这个视频的真实信息(据此评论,不要编造视频里没有的内容):\n" + parts.join("\n"); + } catch (e) { /* 采集失败就只用标题 */ } + + const aiReq = { + action: "callAiApi", + baseUrl: cfg.baseUrl, + model: cfg.model, + system: "你是B站的资深观众。只输出一条中文评论本身,不加引号、不加解释、不加表情符号堆砌。" + + "15~80字,口语化、具体、像真人,要针对这个视频的真实内容发表看法或补充,禁止任何链接和联系方式,禁止“求关注/互粉”。" + + "禁止空泛夸奖(如“讲得真好”“太棒了”),必须提到与视频内容相关的具体点。", + prompt: "视频标题:" + video.title + "\n搜索关键词:" + (cfg["keyword"] || "") + ctxText + "\n\n请输出一条评论:", + maxTokens: 400, timeoutMs: 90000 + }; + aiReq["apiKey"] = cfg["apiKey"]; + const res = await chrome.runtime.sendMessage(aiReq); + if (res && res.ok && res.content) { + const v = verifyMessage(res.content); + if (v.ok) return { text: v.text, source: "ai" }; + toast("AI评论不合规(" + v.reason + "),已改用固定模板"); + } else { + toast("AI生成失败(" + ((res && res.error) || "未知原因") + "),已改用固定模板"); + } + } catch (e) { + toast("AI生成异常,已改用固定模板"); + } + } + return { text: pickTemplate(video.title, cfg.keyword), source: "tpl" }; + } + + // ============ Shadow DOM 穿透(B站评论区是 嵌套 shadow) ============ + + function deepQuery(root, selector, maxDepth) { + const depth = maxDepth || 8; + let found = null; + (function walk(r, d) { + if (found || d > depth || !r.querySelector) return; + const hit = r.querySelector(selector); + if (hit) { found = hit; return; } + for (const el of r.querySelectorAll("*")) { + if (el.shadowRoot) { walk(el.shadowRoot, d + 1); if (found) return; } + } + })(root, 0); + return found; + } + + function deepQueryAll(root, selector, maxDepth) { + const out = []; + const depth = maxDepth || 8; + (function walk(r, d) { + if (d > depth || !r.querySelectorAll) return; + for (const e of r.querySelectorAll(selector)) out.push(e); + for (const el of r.querySelectorAll("*")) if (el.shadowRoot) walk(el.shadowRoot, d + 1); + })(root, 0); + return out; + } + + function isVisible(el) { + if (!el) return false; + const r = el.getClientRects(); + return !!(r && r.length) && !el.disabled; + } + + // 元素是否位于评论区(跨 shadow 边界向上找 BILI-COMMENTS 宿主) + function underComments(el) { + let n = el; + let guard = 0; + while (n && guard++ < 40) { + if (n.tagName === "BILI-COMMENTS") return true; + const root = (n.getRootNode && n.getRootNode()) || document; + if (root.host) { n = root.host; continue; } + n = n.parentElement; + } + return false; + } + + // 向上找指定名称的 shadow 宿主(如 BILI-COMMENT-BOX) + function ancestorHost(el, tagName) { + let n = el; + let guard = 0; + while (n && guard++ < 40) { + const root = (n.getRootNode && n.getRootNode()) || document; + if (root.host) { + if (root.host.tagName === tagName) return root.host; + n = root.host; + continue; + } + if (n.tagName === tagName) return n; + n = n.parentElement; + } + return null; + } + + // 全页深扫评论输入框:可见的 contenteditable/textarea + // (评论框可能浮动在底部或布局不同,不限于 bili-comments 容器内) + function findEditorDeep() { + const cands = deepQueryAll(document, '[contenteditable="true"], textarea', 10); + let fallback = null; + for (const el of cands) { + const cls = String(el.className || ""); + const ph = String(el.getAttribute && el.getAttribute("placeholder") || el.placeholder || ""); + if (/nav-search|search-input|danmaku|bpx-/i.test(cls)) continue; // 排除搜索框/弹幕框 + if (/搜索|弹幕/.test(ph)) continue; + if (!isVisible(el)) continue; + if (underComments(el)) return el; + if (!fallback) fallback = el; + } + return fallback; + } + + function loginDialogOpen() { + const txt = document.body.innerText || ""; + if (/扫码登录|密码登录|短信登录/.test(txt)) return true; + const host = document.querySelector("bili-comments"); + if (host && host.shadowRoot && /扫码登录|密码登录/.test(host.shadowRoot.innerText || "")) return true; + return false; + } + + // ============ 模拟真人:滚动 → 定位输入框 → 逐字输入 → 点发布 ============ + + // 阶梯滚动:边滚边找评论组件(B站评论区滚到附近才懒加载,干等是等不来的) + async function humanScrollToComments() { + let host = document.querySelector("bili-comments"); + const deadline = Date.now() + 20000; + let y = window.scrollY; + while (!host && Date.now() < deadline && !stopRequested()) { + y += Math.round(300 + Math.random() * 400); + const maxY = Math.max(0, document.body.scrollHeight - window.innerHeight); + if (y > maxY + 80) { + // 滚到底还没出现 → 回到中部再往上慢慢找(有些页先挂载下方内容) + y = Math.round(window.innerHeight * 0.5); + } + window.scrollTo({ top: Math.min(y, maxY), behavior: "smooth" }); + await randWait(350, 800); + host = document.querySelector("bili-comments"); + // 容器没挂载但输入框已在页上(贴底/浮动布局)→ 提前结束找容器 + if (!host && findEditorDeep()) break; + } + if (!host) return null; + // 停在评论框上方一点,像人一样浏览评论区 + const endY = Math.max(300, host.getBoundingClientRect().top + window.scrollY - 160); + const startY = window.scrollY; + const steps = 2 + Math.floor(Math.random() * 2); + for (let i = 1; i <= steps; i++) { + const yy = Math.round(startY + (endY - startY) * (i / steps)); + window.scrollTo({ top: yy, behavior: "smooth" }); + await randWait(350, 700); + } + await randWait(600, 1300); + return host; + } + + async function findOrOpenEditor(host) { + let editor = deepQuery(host, '[contenteditable="true"]'); + if (editor && !isVisible(editor)) editor = null; + if (!editor) { + const box = deepQuery(host, "bili-comment-box"); + if (box) { + try { (deepQuery(box, '[class*="input"]') || box).click(); } catch (e) { try { box.click(); } catch (e2) {} } + await randWait(800, 1400); + editor = deepQuery(host, '[contenteditable="true"]'); + if (editor && !isVisible(editor)) { + try { editor.click(); editor.focus(); } catch (e) {} + await randWait(500, 900); + if (!isVisible(editor)) editor = null; + } + } + } + return editor; + } + + // 等输入框就绪:容器内找不到就全页深扫;找到后若在视野外(贴底/浮动)先滚到眼前 + async function waitForEditor(host, ms) { + const end = Date.now() + (ms || 12000); + let tries = 0; + while (Date.now() < end && !stopRequested()) { + let editor = null; + if (host) editor = await findOrOpenEditor(host); + if (!editor) editor = findEditorDeep(); + if (editor && isVisible(editor)) { + const r = editor.getBoundingClientRect(); + if (r.top < 60 || r.bottom > window.innerHeight - 20) { + try { editor.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {} + await randWait(450, 900); + } + return editor; + } + tries++; + // 轻推滚动,触发组件后续渲染 + window.scrollBy({ top: Math.round(80 + Math.random() * 140), behavior: "smooth" }); + await randWait(500, 950); + if (tries % 3 === 0) { + // 兜底:短暂滚到底再回来(评论框在最底部时靠这一步唤起) + const back = window.scrollY; + window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" }); + await randWait(900, 1500); + window.scrollTo({ top: back, behavior: "smooth" }); + await randWait(700, 1300); + } + } + return null; + } + + // 逐字输入,模拟真人打字节奏 + async function humanType(editor, text) { + try { editor.focus(); editor.click(); } catch (e) {} + await randWait(300, 700); + let pos = 0; + while (pos < text.length) { + if (stopRequested()) return false; + const n = Math.random() < 0.7 ? 1 : 2; // 每次敲 1~2 个字 + const chunk = text.slice(pos, pos + n); + try { document.execCommand("insertText", false, chunk); } catch (e) { return false; } + pos += n; + await randWait(45, 160); + if (Math.random() < 0.08) await randWait(400, 900); // 偶尔停顿,像真人思考 + } + await randWait(300, 800); + const compact = s => String(s || "").replace(/\s+/g, ""); + if (compact(editor.innerText).indexOf(compact(text)) < 0) { + try { document.execCommand("insertText", false, text); } catch (e) {} + await randWait(500, 900); + if (compact(editor.innerText).indexOf(compact(text)) < 0) return false; + } + return true; + } + + // 找发布按钮:先在评论框容器里找(优先),再全页找;排除弹幕发送按钮 + function findSendButton(host, editor) { + const scopes = []; + const boxFromEditor = editor ? ancestorHost(editor, "BILI-COMMENT-BOX") : null; + if (boxFromEditor) scopes.push(boxFromEditor); + if (host) { const box = deepQuery(host, "bili-comment-box"); if (box) scopes.push(box); } + scopes.push(document); + + const collect = (r, out) => { + if (!r || !r.querySelectorAll) return; + for (const b of r.querySelectorAll("button, [role=button], bili-text-button")) out.push(b); + for (const el of r.querySelectorAll("*")) if (el.shadowRoot) collect(el.shadowRoot, out); + }; + + for (const scope of scopes) { + const btns = []; + collect(scope, btns); + const usable = btns.filter(b => { + if (!isVisible(b)) return false; + const cls = String(b.className || ""); + const ph = String(b.innerText || ""); + if (/bpx|danmaku|nav-/i.test(cls)) return false; // 播放器/弹幕/导航的按钮 + if (/弹幕/.test(ph)) return false; + return true; + }); + const exact = usable.find(b => /^(发布|发送|评论)$/.test(String(b.innerText || "").trim())); + if (exact) return exact; + const clsHit = usable.find(b => /send|submit|publish/i.test(String(b.className || ""))); + if (clsHit) return clsHit; + if (scope !== document) { + // 评论框作用域内的弱匹配(仅限该容器,避免误点全页随机按钮) + const weak = usable.find(b => { + const t = String(b.innerText || "").trim(); + return t.length >= 2 && t.length <= 4 && !/取消|清空|登录/.test(t); + }); + if (weak) return weak; + } + } + return null; + } + + function commentAppeared(text) { + const host = document.querySelector("bili-comments"); + if (!host) return false; + const plain = String(text || "").replace(/\s+/g, ""); + const items = deepQueryAll(host, "bili-comment-renderer, bili-comment-thread-renderer, [class*=comment-item]"); + for (const it of items) { + if (((it.innerText || "").replace(/\s+/g, "")).indexOf(plain) >= 0) return true; + } + return ((host.shadowRoot ? host.shadowRoot.innerText : "") || "").replace(/\s+/g, "").indexOf(plain) >= 0; + } + + // 返回: true=成功 / 'login' / 'nobox' / 'typefail' / 'nosend' / 'unverified' / 'stopped' + async function humanComment(video, text) { + const host = await humanScrollToComments(); + if (!host) step("未见评论区容器,改用全页查找输入框…", "fail"); + + // 输入框分阶段渲染,容器内找不到就全页深扫(贴底/浮动布局也能命中),最多等12秒 + const editor = await waitForEditor(host, 12000); + if (loginDialogOpen()) return "login"; + if (!editor) { + // 诊断信息,方便定位(浮层/日志里直接看得到) + const nHost = document.querySelector("bili-comments") ? "有" : "无"; + const nCE = deepQueryAll(document, '[contenteditable="true"]', 10).length; + const nTA = deepQueryAll(document, "textarea", 10).length; + step("未找到评论框(容器:" + nHost + " contenteditable:" + nCE + " textarea:" + nTA + ")", "fail"); + return "nobox"; + } + + step("已定位评论框,开始输入…"); + const typed = await humanType(editor, text); + if (stopRequested()) return "stopped"; + if (!typed) return loginDialogOpen() ? "login" : "typefail"; + if (loginDialogOpen()) return "login"; + + const btn = findSendButton(host, editor); + if (!btn) return "nosend"; + step("点击发布…"); + await randWait(400, 900); + const compact = s => String(s || "").replace(/\s+/g, ""); + const hadText = compact(editor.innerText).indexOf(compact(text)) >= 0; + try { btn.click(); } catch (e) {} + await randWait(1800, 2600); + + if (stopRequested()) return "stopped"; + if (loginDialogOpen()) return "login"; + + // 发布后确认(最多约12秒,DOM/接口轮流查;输入框被清空=发布成功的强信号) + // B站默认按热度排序,刚发的评论不在热评列表里,单次扫描容易漏判 + const deadline = Date.now() + 12000; + while (Date.now() < deadline && !stopRequested()) { + if (commentAppeared(text)) return true; + if (hadText && compact(editor.innerText).indexOf(compact(text)) < 0) return true; // 输入框已清空 + try { + const data = await apiJSON("https://api.bilibili.com/x/v2/reply/main?oid=" + video.aid + "&type=1&ps=20&mode=3"); + const replies = (data && data.data && data.data.replies) || []; + const plain = compact(text); + if (Array.isArray(replies) && replies.some(rr => compact(rr.content).indexOf(plain) >= 0)) return true; + } catch (e) { /* 接口不可用就靠 DOM/清空信号 */ } + await randWait(1300, 1900); + } + if (stopRequested()) return "stopped"; + return "unverified"; + } + + // ============ API 直发(快速模式 / 找不到评论框时的兜底,兜底必提示) ============ + + const CODE_HINT = { + "-999": "登录态缺失", + "-101": "账号未登录或登录已过期", + "12045": "触发验证码风控,已停止,请稍后再试", + "12002": "评论频率过快,已停止,请加大间隔", + "12005": "评论含敏感词或外链被拦截", + "12008": "今日评论已达上限,明天再来", + "12009": "评论长度不合规", + "12010": "账号异常需验证手机", + "12003": "视频已关闭评论" + }; + + async function apiSend(video, message) { + const csrf = getCookie("bili_jct"); + if (!csrf) return { code: -999, message: "未读取到 bili_jct(登录态)" }; + const body = new URLSearchParams({ + oid: String(video.aid), type: "1", message: message, + csrf: csrf, jsonp: "jsonp", plat: "1" + }).toString(); + const data = await apiJSON("https://api.bilibili.com/x/v2/reply/add", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Referer": video.url, "Origin": "https://www.bilibili.com" + }, + body: body + }); + return { code: data && data.code, message: (data && data.message) || "" }; + } + + // ============ 单条发送(真人模式优先,失败按需降级并提示) ============ + + async function sendOne(video, text, useHuman) { + if (useHuman) { + const r = await humanComment(video, text); + if (r === true) return { ok: true, verified: true }; + if (r === "stopped") return { ok: false, stopped: true }; + if (r === "login") return { ok: false, fatal: "登录态失效,请重新登录B站" }; + if (r === "unverified") return { ok: true, verified: false }; + if (r === "nobox") toast("未找到评论框,本条改用API直发"); + else if (r === "typefail") step("页面输入失败,改用API直发", "fail"); + else if (r === "nosend") step("找不到发布按钮,改用API直发", "fail"); + } + let res; + try { res = await apiSend(video, text); } + catch (e) { res = { code: -998, message: (e && e.message) || String(e) }; } + if (res.code === 0) return { ok: true, verified: true }; + const hint = CODE_HINT[String(res.code)] || (res.message || ("code=" + res.code)); + if (["-999", "-101", "12045", "12008", "12010", "12002"].indexOf(String(res.code)) >= 0) { + return { ok: false, fatal: hint }; + } + return { ok: false, error: hint }; + } + + // ============ 汇总与收尾 ============ + + function finish(task, ok, reason) { + clearTask(); + running = false; + relay({ type: "finished", ok: !!ok, reason: reason || "", done: task ? (task.sent || 0) : 0, failed: task ? (task.failed || 0) : 0 }); + step((ok ? "✅ " : "⏹ ") + (reason || (ok ? "执行完成" : "已停止")), ok ? "ok" : "fail"); + } + + function currentBvid() { + const m = location.pathname.match(/\/video\/(BV[A-Za-z0-9]+)/); + return m ? m[1] : ""; + } + + async function recordOne(task, video, text, okVerified) { + if (okVerified) { + task.sent = (task.sent || 0) + 1; + chrome.runtime.sendMessage({ action: "addRecord", bvid: video.bvid, title: video.title, text: text, keyword: task.config.keyword }).catch(() => {}); + relay({ type: "done", bvid: video.bvid, title: video.title, text: text }); + } else { + task.failed = (task.failed || 0) + 1; + } + } + + // 视频页内处理一条 → 等待 → 跳下一个(导航后由新页面的脚本续跑) + async function processVideo(task) { + running = true; + const cfg = task.config; + const video = task.queue[task.index]; + try { + ensureOverlay(); + if (currentBvid() !== video.bvid) { finish(task, false, "页面与任务不匹配,已中止"); return; } + if (stopRequested()) { finish(task, false, "已手动停止"); return; } + + step("[" + (task.index + 1) + "/" + task.queue.length + "] " + video.title.slice(0, 30)); + const text = video.text || pickTemplate(video.title, cfg.keyword); + const SRC_LABEL = { custom: "自定义", ai: "AI", tpl: "模板" }; + step("评论[" + (SRC_LABEL[video.src] || "模板") + "]:" + text.slice(0, 30) + (text.length > 30 ? "…" : "")); + + const r = await sendOne(video, text, cfg.sendMode !== "api"); + if (r.stopped) { finish(task, false, "已手动停止"); return; } + if (r.fatal) { finish(task, false, r.fatal); return; } + + if (r.ok) { + step(r.verified ? "✅ 评论成功" : "⚠️ 已发送但未在页面确认,记为已评论", r.verified ? "ok" : "fail"); + await recordOne(task, video, text, true); + } else { + step("❌ 失败:" + r.error, "fail"); + await recordOne(task, video, text, false); + } + + task.index += 1; + + if (task.index < task.queue.length && !stopRequested()) { + const gap = nextGapMs(); + step("等待 " + Math.round(gap / 1000) + " 秒后打开下一个视频…"); + await interruptibleWait(gap); + if (stopRequested()) { finish(task, false, "已手动停止"); return; } + const next = task.queue[task.index]; + step("打开:" + next.title.slice(0, 30)); + stashTask(task); // 导航前落盘,新页面续跑 + location.href = next.url; + return; // 本页生命周期结束 + } + finish(task, true, "执行完成:成功 " + (task.sent || 0) + " / 失败 " + (task.failed || 0)); + } catch (e) { + finish(task, false, "执行出错:" + (e && e.message || e)); + } + } + + // ============ 启动:搜索阶段(在当前B站页执行) ============ + + // 地区名 → 检索用简写(北京市→北京、广东省→广东、内蒙古自治区→内蒙古) + function regionShort(region) { + return String(region || "").replace(/(特别行政区|自治区|行政区|省|市)$/, ""); + } + + async function start(config) { + if (running) { step("上一个任务还在执行,请先点⏹停止再开始新任务"); return; } + if (loadTask()) { + // 本页有上次残留的任务(上次异常中断),清掉后按新配置重新开始 + clearTask(); + step("清除上次残留的任务,按新配置重新开始"); + } + running = true; + clearStop(); + + const cfg = Object.assign({ + keyword: "", order: "hot", maxN: 3, + minPlay: 0, mode: "tpl", sendMode: "human", customText: "", tid: 0, region: "", + baseUrl: "", model: "" + }, config || {}); + cfg["apiKey"] = (config || {})["apiKey"] || ""; + + try { + ensureOverlay(); + step("开始执行:关键词「" + cfg.keyword + "」(" + (cfg.sendMode === "api" ? "API直发" : "模拟真人") + ")"); + + const loggedIn = await checkLogin(); + if (!loggedIn) { + step("未登录B站,无法评论", "fail"); + toast("请先登录B站网页版,再重新开始"); + finish(null, false, "未登录B站"); + return; + } + step("登录态正常"); + + const found = await searchVideos(cfg.keyword, cfg.order, 1, cfg.tid); + step("搜索命中 " + found.length + " 个视频"); + + const hist = await chrome.storage.local.get("commentHistory"); + const doneSet = new Set((Array.isArray(hist.commentHistory) ? hist.commentHistory : []).map(x => x.bvid)); + let list = found.filter(v => !doneSet.has(v.bvid)); + const skipDone = found.length - list.length; + if (cfg.minPlay > 0) { + list = list.filter(v => v.play >= cfg.minPlay); + } + const skipPlay = found.length - list.length - skipDone; + if (skipDone > 0) step("⏭ 跳过已评论过的 " + skipDone + " 个(不再重复评)"); + if (skipPlay > 0) step("过滤掉播放量不足的 " + skipPlay + " 个"); + // —— 地区:B站接口不支持省份筛选,改为「关键词+地区」二次检索,并只保留标题/标签真正带该地区的视频 —— + const region = String(cfg.region || "").trim(); + if (region) { + const short = regionShort(region); + if (short && short !== cfg.keyword) { + step("地区「" + region + "」:追加检索「" + cfg.keyword + " " + short + "」"); + try { + const extra = await searchVideos(cfg.keyword + " " + short, cfg.order, 1, cfg.tid); + const byId = new Map(found.map(v => [v.bvid, v])); + for (const v of extra) if (!byId.has(v.bvid)) { byId.set(v.bvid, v); found.push(v); } + } catch (e) { /* 二次检索失败不阻断,用首次结果过滤 */ } + } + const before = list.length; + list = list.filter(v => ((v.title || "") + " " + (v.tag || "")).includes(short)); + const offRegion = before - list.length; + if (offRegion > 0) step("⏭ 过滤掉不含「" + short + "」的视频 " + offRegion + " 个"); + } + list = list.slice(0, Math.max(1, cfg.maxN | 0)); + if (!list.length) { finish(null, false, "没有可评论的新视频"); return; } + + // 预生成全部评论内容(自定义内容无效会直接终止;AI失败降级模板并提示) + for (const v of list) { + const gen = await genComment(v, cfg); + if (gen.error) { finish(null, false, gen.error); return; } + v.text = gen.text; + v.src = gen.source; + } + step("待评论 " + list.length + " 个"); + + const task = { phase: "video", config: cfg, queue: list, index: 0, sent: 0, failed: 0 }; + + // —— 快速模式:不跳页,逐条 API 直发 —— + if (cfg.sendMode === "api") { + for (let i = 0; i < list.length; i++) { + if (stopRequested()) { finish(task, false, "已手动停止"); return; } + const v = list[i]; + step("[" + (i + 1) + "/" + list.length + "] " + v.title.slice(0, 30)); + const r = await sendOne(v, v.text, false); + if (r.stopped) { finish(task, false, "已手动停止"); return; } + if (r.fatal) { finish(task, false, r.fatal); return; } + if (r.ok) { step("✅ 评论成功", "ok"); await recordOne(task, v, v.text, true); } + else { step("❌ 失败:" + r.error, "fail"); await recordOne(task, v, v.text, false); } + if (i < list.length - 1) { + const gap = nextGapMs(); + step("等待 " + Math.round(gap / 1000) + " 秒后继续…"); + await interruptibleWait(gap); + } + } + finish(task, (task.sent || 0) > 0, "执行完成:成功 " + (task.sent || 0) + " / 失败 " + (task.failed || 0)); + return; + } + + // —— 模拟真人模式:跳到第一个视频页(若已在该页则直接开跑) —— + stashTask(task); + running = false; // 接下来由 processVideo 接管 + if (currentBvid() === list[0].bvid) { + step("当前就是目标视频页,直接开始"); + processVideo(task); + return; + } + step("打开第一个视频页:" + list[0].title.slice(0, 30)); + location.href = list[0].url; + } catch (e) { + finish(null, false, "执行出错:" + (e && e.message || e)); + } + } + + // ============ 下载当前页视频:取直链(页面登录态决定清晰度) ============ + + const QN_LABEL = { 16: "360P", 32: "480P", 64: "720P", 80: "1080P", 112: "1080P+", 116: "4K", 120: "4K", 125: "HDR", 126: "杜比" }; + + async function collectDownloadInfo(qn) { + const m = location.pathname.match(/\/video\/(BV[A-Za-z0-9]+)/); + if (!m) throw new Error("当前页不是B站视频页"); + const bvid = m[1]; + const pm = location.search.match(/[?&]p=(\d+)/); + const pIdx = pm ? parseInt(pm[1], 10) : 1; + + const view = await apiJSON("https://api.bilibili.com/x/web-interface/view?bvid=" + bvid); + if (!view || view.code !== 0) throw new Error("获取视频信息失败:" + ((view && view.message) || "接口异常")); + const vd = view.data; + const pages = (Array.isArray(vd.pages) && vd.pages.length) ? vd.pages : [{ cid: vd.cid }]; + const pg = pages[Math.min(Math.max(pIdx, 1), pages.length) - 1]; + const cid = pg.cid; + const title = vd.title + (pages.length > 1 ? "_P" + pIdx : ""); + + const play = await apiJSON( + "https://api.bilibili.com/x/player/playurl?bvid=" + bvid + + "&cid=" + cid + "&qn=" + qn + "&fnval=0&fnver=0&fourk=1" + ); + if (!play || play.code !== 0) throw new Error("获取播放地址失败:" + ((play && play.message) || "接口异常")); + const pd = play.data || {}; + const label = QN_LABEL[pd.quality] || (pd.quality + "P"); + + // 1) 单文件流(fnval=0 → durl,登录后最高1080P) + const durl = Array.isArray(pd.durl) ? pd.durl : []; + if (durl.length) { + const ext = /\.mp4($|\?)/i.test(durl[0].url || "") ? "mp4" : "flv"; + return { + title: title, quality: label, + files: durl.map(s => ({ url: s.url, ext: ext })), + note: "" + }; + } + + // 2) DASH 兜底(音视频分离):分别下载并提示 + const dash = pd.dash || {}; + const vids = (dash.video || []).filter(v => v && v.url); + const auds = (dash.audio || []).filter(a => a && a.url); + if (!vids.length) throw new Error("播放地址为空(该视频可能需要登录或不可下载)"); + vids.sort((a, b) => (b.bandwidth || 0) - (a.bandwidth || 0)); + const pick = vids.find(v => Number(v.id) <= qn) || vids[vids.length - 1]; + const files = [{ url: pick.url, ext: /\.mp4($|\?)/i.test(pick.url) ? "mp4" : "m4s", tag: "视频" }]; + if (auds.length) files.push({ url: auds[0].url, ext: "m4s", tag: "音频" }); + return { + title: title, quality: QN_LABEL[pick.id] || (pick.id + "P"), + files: files, + note: auds.length ? "该视频为音视频分离格式,已分别下载视频和音频两个文件" : "" + }; + } + + // ============ 消息通道 ============ + + chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (!msg) return; + if (msg.action === "start") { + sendResponse({ ok: true }); // 先应答:导航会关掉通道 + start(msg.config); + return; + } + if (msg.action === "stop") { + requestStop(); + sendResponse({ ok: true, stopping: true }); + step("收到停止指令,将在当前步骤后停止", "fail"); + return; + } + if (msg.action === "ping") { + sendResponse({ ok: true, running: running, task: !!loadTask() }); + return; + } + if (msg.action === "ver") { + sendResponse({ ok: true, ver: MY_VER }); + return; + } + if (msg.action === "getDownload") { + // 异步:取视频信息 + 播放直链(页面带登录态,清晰度按账号等级) + collectDownloadInfo(Number(msg.qn) || 80) + .then(info => sendResponse({ ok: true, info: info })) + .catch(e => sendResponse({ ok: false, error: String((e && e.message) || e) })); + return true; + } + }); + + // 页面加载 → 领取任务续跑(先查本地 sessionStorage,跨子域名时向后台领取) + (async function resumeOnLoad() { + let pending = loadTask(); + if (!pending) { + try { + const res = await chrome.runtime.sendMessage({ action: "pullTask" }); + if (res && res.task && Array.isArray(res.task.queue)) pending = res.task; + } catch (e) { /* 后台不在则放弃 */ } + } + if (!pending || !pending.queue || !pending.queue[pending.index]) return; + const expect = pending.queue[pending.index].bvid; + const here = currentBvid(); + if (here === expect) { + setTimeout(() => processVideo(pending), 1500); // 等评论组件挂载 + } else if (here) { + // 用户手动跳到了别的视频:任务作废,避免乱发 + const done = pending.sent || 0, failed = pending.failed || 0; + clearTask(); + ensureOverlay(); + step("任务因页面跳转已中止", "fail"); + relay({ type: "finished", ok: false, reason: "页面跳转中止", done: done, failed: failed }); + } + // here 为空(非视频页,如B站首页):不动任务,等到了目标视频页再续 + })(); + + relay({ type: "ready" }); +})(); diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000..07260f2 Binary files /dev/null and b/icons/icon128.png differ diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000..7b6e5b3 Binary files /dev/null and b/icons/icon16.png differ diff --git a/icons/icon32.png b/icons/icon32.png new file mode 100644 index 0000000..6d0a553 Binary files /dev/null and b/icons/icon32.png differ diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000..a242f2f Binary files /dev/null and b/icons/icon48.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..0934824 --- /dev/null +++ b/manifest.json @@ -0,0 +1,31 @@ +{ + "manifest_version": 3, + "name": "B站评论助手", + "version": "1.6.6", + "description": "B站网页版关键词评论助手:按搜索关键词找视频,默认模拟真人(打开视频页、滚到评论区、逐字输入、点发布),也可快速API直发;支持下载当前页视频;带防重复记录、可中断执行与页面实时进度浮层。", + "permissions": ["storage", "tabs", "scripting", "sidePanel", "downloads", "declarativeNetRequest"], + "host_permissions": [ + "https://*.bilibili.com/*", + "https://api.bilibili.com/*", + "https://auth.88531.cn/*", + "http://*/*", + "https://*/*" + ], + "background": { "service_worker": "background.js" }, + "side_panel": { "default_path": "sidepanel.html" }, + "action": { "default_title": "B站评论助手" }, + "content_scripts": [ + { + "matches": ["https://*.bilibili.com/*"], + "js": ["content.js"], + "css": ["content.css"], + "run_at": "document_idle" + } + ], + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/sidepanel.html b/sidepanel.html new file mode 100644 index 0000000..a7a2245 --- /dev/null +++ b/sidepanel.html @@ -0,0 +1,313 @@ + + + + + + + +
B站评论助手
+ +
+
+ ⏳ + 授权检查中… + 购买授权 → +
+
+ + +
机器码:…(点击复制)
+
+ +
+ 🤖 没有模型?注册即送额度 + ai.88531.cn 注册 → +
+ +
+
📊 评论记录
+
+
0
总数
+
0
周
+
0
今日
+ +
+
+ +
+
LLM 配置(自动保存,仅 AI 模式需要)
+ + + +
+ + +
+
+ +
+
搜索与发布
+ +
+ 排序 + + 评论方式 + +
+ +
+ 发送方式 + +
+
+ 分区 + +
+
+ 地区 + +
+
⚠ 选了 AI 生成但还没填模型配置,将自动改用固定模板
+
+ 最多 + + 条视频,每条间隔 1~2 分钟随机 +
+
+ 播放量 ≥ + + 次(0 = 不过滤) +
+ + +
+ +
+ +
+
✅ 本次评论成功的视频(实时更新)
+
+
+ +
+
📋 执行日志(实时滚动,确认正在运行就看这里)
+
+
+ +
+
⬇ 下载当前B站页面视频(先切到视频页,保存到 下载/B站视频/)
+
+ 清晰度 + + + +
+
+ +
+ +
点击在新标签页打开官网 dl.88531.cn/bili.html
+
+ +
+
+ 📖 使用说明(点开看功能与用法) +
+ 三步上手
+ ① 先登录B站网页版 → 点工具栏图标打开本面板
+ ② 填关键词(可选分区/地区/最热门排序/最多几条),选评论方式 → 点「▶ 开始执行」
+ ③ 想让评论更贴合视频就配 AI 模型(API地址+模型名+密钥,点测试连接验证) +
+ 三种评论方式
+ · 固定模板:不用配置,装上就能用
+ · 自定义:自己写,一行一条随机取,每行 3 字以上
+ · AI 生成:先读视频的标签/简介/弹幕/热评,再写针对性评论 +
+ 核心优势
+ · 模拟真人:自动打开视频页→滚到评论区→逐字输入→点发布
+ · 防风控:每条间隔 1~2 分钟随机,可随时停止
+ · 不重复:评过的视频自动跳过
+ · 数据本地:评论内容和记录都只存在你浏览器里
+ · 下载视频:不受授权限制,最高 1080P +
+ 注意事项
+ · 每天建议不超过 10 条,合理控制频率避免账号风控
+ · 评论不含外链/广告,触发验证码或当日上限会自动停止
+ · 新版本下载、安装步骤、常见问题都在官网页 +
+
+
+ +
填关键词 → 点「▶ 开始执行」,其余全自动;页面右上角浮层显示每一步
+ + + + diff --git a/sidepanel.js b/sidepanel.js new file mode 100644 index 0000000..4847f95 --- /dev/null +++ b/sidepanel.js @@ -0,0 +1,623 @@ +// sidepanel: 配置 + 运行控制 + 实时日志 +(() => { + const DEFAULTS = { + baseUrl: "https://ai.88531.cn/v1", + apiKey: "", + model: "mimo-v2.6-flash", + keyword: "", + order: "hot", + mode: "tpl", + customText: "", + sendMode: "human", + tid: 0, + region: "", + dlQuality: "80", + maxN: 3, + minPlay: 0 + }; + const FIELDS = ["baseUrl", "apiKey", "model", "keyword", "order", "mode", "customText", "sendMode", "tid", "region", "dlQuality", "maxN", "minPlay"]; + + let settings = Object.assign({}, DEFAULTS); + let running = false; + let timerHandle = null; + let timerStart = 0; + + const $ = id => document.getElementById(id); + + // ============ 日志 / 状态 ============ + + function log(text, cls) { + const box = $("logBox"); + if (!box) return; + const line = document.createElement("div"); + if (cls) line.className = cls; + line.textContent = new Date().toTimeString().slice(0, 8) + " " + text; + box.appendChild(line); + while (box.children.length > 300) box.removeChild(box.firstChild); + box.scrollTop = box.scrollHeight; + } + + function setStatus(text, isErr) { + const el = $("status"); + if (!el) return; + el.textContent = text || ""; + el.style.color = isErr ? "#d93025" : "#0a7"; + } + + // ============ 设置读写(自动保存,无保存按钮) ============ + + function fillForm() { + for (const k of FIELDS) { + const el = $(k); + if (!el) continue; + el.value = settings[k]; + } + // 地区不记忆:每次打开默认「全部地区」,只在本次会话内有效 + settings.region = ""; + const reg = $("region"); + if (reg) reg.value = ""; + refreshCustomVisibility(); + refreshModeWarn(); + } + + async function saveField(key, value) { + settings[key] = value; + const toSave = Object.assign({}, settings); + delete toSave.region; // 地区不写入存储(默认永远是不限) + await chrome.storage.local.set({ settings: toSave }); + } + + function refreshCustomVisibility() { + const ta = $("customText"); + if (ta) ta.style.display = settings.mode === "custom" ? "block" : "none"; + } + + function customValidLine(s) { + if (s.length < 3 || s.length > 200) return false; + if (/(?:https?:\/\/|www\.[a-z0-9-]+\.[a-z]{2,})/i.test(s) && !/bilibili\.com|b23\.tv/i.test(s)) return false; + if (new Set(s).size < Math.min(6, Math.ceil(s.length / 2))) return false; + return true; + } + + function refreshModeWarn() { + const warn = $("modeWarn"); + if (!warn) return; + const hasModel = !!(settings.baseUrl && settings.model); + if (settings.mode === "custom") { + const raw = String(settings.customText || "").split("\n").map(s => s.trim()).filter(Boolean); + const bad = raw.filter(line => !customValidLine(line)); + if (!raw.length) { + warn.textContent = "⚠ 选了自定义但还没填内容,开始执行会被拒绝"; + warn.style.display = "block"; + } else if (bad.length === raw.length) { + warn.textContent = "⚠ 自定义内容全部不合规(每行需3~200字、不含链接、不能全是重复字),开始执行会被拒绝"; + warn.style.display = "block"; + } else if (bad.length) { + warn.textContent = "⚠ " + bad.length + " 行不合规(每行需3~200字),这些行不会被使用;有效 " + (raw.length - bad.length) + " 条"; + warn.style.display = "block"; + } else { + warn.style.display = "none"; + } + } else if (settings.mode === "ai" && !settings.apiKey) { + warn.textContent = "⚠ 选了 AI 生成但 API Key 为空,将自动改用固定模板"; + warn.style.display = "block"; + } else if (settings.mode === "ai" && !hasModel) { + warn.textContent = "⚠ 选了 AI 生成但模型配置不全,将自动改用固定模板"; + warn.style.display = "block"; + } else if (settings.mode === "tpl" && settings.apiKey) { + warn.textContent = "⚠ 已填 API Key,但评论方式是固定模板(AI 配置暂不生效)"; + warn.style.display = "block"; + } else { + warn.style.display = "none"; + } + } + + // ============ 授权状态 ============ + + let licState = null; // {authorized, offline, realAuthorized, expire, trialEnd, trialLeftMs, machine} + let licTimer = null; + + function fmtRemain(ms) { + if (ms <= 0) return "00:00:00"; + const s = Math.floor(ms / 1000); + const h = String(Math.floor(s / 3600)).padStart(2, "0"); + const m = String(Math.floor((s % 3600) / 60)).padStart(2, "0"); + const ss = String(s % 60).padStart(2, "0"); + return h + ":" + m + ":" + ss; + } + + function renderLicense() { + const icon = $("licIcon"), text = $("licText"), sub = $("licSub"), + actRow = $("licActRow"), buy = $("licBuy"); + if (!licState || !icon) return; + const lic = licState; + + if (lic.offline) { + icon.textContent = "🌐"; + text.textContent = "授权服务器暂不可用,已临时放行"; + text.style.color = "#666"; + sub.textContent = "网络恢复后会自动重新校验"; + actRow.style.display = "none"; + } else if (lic.authorized) { + icon.textContent = "✅"; + text.textContent = "已授权" + (lic.expire ? " · 到期 " + lic.expire : "(长期)"); + text.style.color = "#0a7"; + sub.textContent = "感谢支持!全部功能可用"; + actRow.style.display = "none"; + if (buy) buy.style.display = "none"; + } else if (lic.trialLeftMs > 0) { + icon.textContent = "🎁"; + text.textContent = "免费试用中 · 剩余 " + fmtRemain(lic.trialLeftMs); + text.style.color = "#e67e22"; + sub.textContent = "未授权可试用 1 天,购买卡密激活后不限时使用"; + actRow.style.display = "flex"; + if (buy) buy.style.display = ""; + } else { + icon.textContent = "🔒"; + text.textContent = "试用已到期,自动评论已锁定"; + text.style.color = "#d93025"; + sub.textContent = "购买卡密激活即可解锁(下载视频不受影响)"; + actRow.style.display = "flex"; + if (buy) buy.style.display = ""; + } + } + + function tickCountdown() { + if (!licState || licState.authorized || licState.offline) return; + const left = licState.trialEnd - Date.now(); + licState.trialLeftMs = Math.max(0, left); + if (left <= 0) { renderLicense(); if (licTimer) { clearInterval(licTimer); licTimer = null; } return; } + const text = $("licText"); + if (text) text.textContent = "免费试用中 · 剩余 " + fmtRemain(left); + } + + async function loadLicense() { + let res = null; + try { res = await chrome.runtime.sendMessage({ action: "licenseStatus" }); } + catch (e) { res = null; } + if (!res || !res.ok || !res.license) { + licState = { authorized: true, offline: true, trialEnd: Date.now() }; + renderLicense(); + return; + } + licState = res.license; + const mt = $("machineIdText"); + if (mt) mt.textContent = licState.machine || ""; + renderLicense(); + // 倒计时(仅试用中) + if (licTimer) { clearInterval(licTimer); licTimer = null; } + if (licState && !licState.authorized && !licState.offline && licState.trialLeftMs > 0) { + licTimer = setInterval(tickCountdown, 1000); + } + // 公告 + const notice = $("licNotice"); + const anns = Array.isArray(res.announcements) ? res.announcements : []; + if (notice && anns.length) { + notice.style.display = "block"; + notice.innerHTML = anns.map(a => { + const t = document.createElement("span"); + t.textContent = "📌 " + a.title + (a.content ? ":" + a.content : ""); + let html = ""; + try { html = t.innerHTML; } catch (e) { html = "📌 " + a.title; } + if (a.link) html += ' 详情 →'; + return html; + }).join("
"); + for (const a of notice.querySelectorAll(".annLink")) { + a.addEventListener("click", ev => { + ev.preventDefault(); + const url = a.getAttribute("data-url"); + if (url) chrome.tabs.create({ url: url }); + }); + } + } else if (notice) { + notice.style.display = "none"; + } + } + + async function activateCard() { + const btn = $("btnActivate"), input = $("cardKey"), msg = $("actMsg"); + if (!btn || !input || !msg) return; + const card = String(input.value || "").trim(); + if (!card) { msg.textContent = "请输入卡密"; msg.style.color = "#d93025"; return; } + btn.disabled = true; + msg.textContent = "激活中…"; + msg.style.color = "#666"; + let res = null; + try { res = await chrome.runtime.sendMessage({ action: "activateCard", card: card }); } + catch (e) { res = { ok: false, error: String(e) }; } + if (res && res.ok) { + msg.textContent = "✓ " + (res.message || "激活成功"); + msg.style.color = "#0a7"; + log("授权激活成功:" + (res.message || ""), "ok"); + input.value = ""; + await loadLicense(); // 重新拉状态,横幅变已授权 + } else { + msg.textContent = "✗ " + ((res && (res.message || res.error)) || "激活失败"); + msg.style.color = "#d93025"; + log("授权激活失败:" + ((res && (res.message || res.error)) || ""), "fail"); + } + btn.disabled = false; + } + + function licenseBlocked() { + return !!(licState && !licState.authorized && !licState.offline && licState.trialLeftMs <= 0); + } + + // ============ 运行控制 ============ + + function fmtElapsed(ms) { + const s = Math.floor(ms / 1000); + return String(Math.floor(s / 60)).padStart(2, "0") + ":" + String(s % 60).padStart(2, "0"); + } + + function startTimer() { + stopTimer(false); + timerStart = Date.now(); + const el = $("runTimer"); + if (el) el.textContent = "计时 00:00"; + timerHandle = setInterval(() => { + const t = $("runTimer"); + if (t) t.textContent = "计时 " + fmtElapsed(Date.now() - timerStart); + }, 1000); + } + + function stopTimer(freeze) { + if (timerHandle) { clearInterval(timerHandle); timerHandle = null; } + if (freeze) { + const t = $("runTimer"); + if (t && timerStart) t.textContent = "完成 " + fmtElapsed(Date.now() - timerStart); + } + } + + function setRunning(on) { + running = on; + const btnStart = $("btnStart"), stopRow = $("stopRow"); + if (btnStart) btnStart.style.display = on ? "none" : "block"; + if (stopRow) stopRow.style.display = on ? "flex" : "none"; + if (!on) { stopTimer(true); } + } + + async function startRun() { + const kw = String(settings.keyword || "").trim(); + if (!kw) { setStatus("请先填搜索关键词", true); const el = $("keyword"); if (el) el.focus(); return; } + // 授权闸门(本地先拦,后台还有一道) + if (licenseBlocked()) { + setStatus("试用已到期,自动评论已锁定。购买卡密激活后即可使用(下载视频不受影响)", true); + log("⛔ 试用已到期,自动评论被锁定", "fail"); + const ck = $("cardKey"); if (ck) ck.focus(); + return; + } + // 自定义内容本地预检:无效就不开始,绝不换成模板乱评 + if (settings.mode === "custom") { + const raw = String(settings.customText || "").split("\n").map(s => s.trim()).filter(Boolean); + const valid = raw.filter(customValidLine); + if (!raw.length) { + setStatus("选了自定义评论但内容为空:请在「评论方式=自定义」下方填写,每行一条", true); + const ta = $("customText"); if (ta) ta.focus(); + return; + } + if (!valid.length) { + setStatus("自定义内容全部不合规(每行需3~200字、不含外链),请修改后再开始", true); + log("⛔ 自定义内容不合规,已拒绝开始", "fail"); + const ta = $("customText"); if (ta) ta.focus(); + return; + } + log("自定义评论 " + valid.length + " 条已启用" + (raw.length > valid.length ? "(" + (raw.length - valid.length) + " 行不合规已忽略)" : ""), "step"); + } + if (settings.mode === "ai" && !(settings.baseUrl && settings.model)) { + log("⚠ 未配置模型,本次将使用固定模板", "fail"); + } + $("doneList").innerHTML = ""; + setStatus("正在连接B站页面…"); + setRunning(true); + startTimer(); + log("开始执行:关键词「" + kw + "」最多 " + settings.maxN + " 条,每条间隔 1~2 分钟随机", "step"); + + let res = null; + try { + res = await chrome.runtime.sendMessage({ action: "startRun", config: Object.assign({}, settings, { keyword: kw, maxN: Number(settings.maxN) || 3, minPlay: Number(settings.minPlay) || 0 }) }); + } catch (e) { + res = { ok: false, error: String(e) }; + } + if (!res || !res.ok) { + const err = (res && res.error) || "启动失败"; + setStatus(err, true); + log("❌ " + err, "fail"); + setRunning(false); + } else { + setStatus("执行中,进度见页面右上角浮层"); + } + } + + async function stopRun() { + setStatus("停止中,将在当前步骤后停止…"); + log("发送停止指令", "step"); + try { + const res = await chrome.runtime.sendMessage({ action: "stopRun" }); + if (res && !res.ok) setStatus("停止失败:" + res.error, true); + } catch (e) { + setStatus("停止失败:" + String(e), true); + } + } + + // ============ 下载当前页视频 ============ + + function fmtMb(n) { return (n / 1048576).toFixed(1) + " MB"; } + + // 面板页自己抓文件(扩展页 fetch 免 CORS,DNR 规则注入 Referer)→ Blob → 触发保存 + async function fetchToBlob(url, onProgress) { + const resp = await fetch(url); + if (!resp.ok) throw new Error("HTTP " + resp.status + "(CDN拒绝,可能需要重新打开视频页)"); + const total = Number(resp.headers.get("content-length") || 0); + const reader = resp.body.getReader(); + const chunks = []; + let got = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + got += value.length; + if (onProgress) onProgress(got, total); + } + return new Blob(chunks); + } + + async function downloadCurrent() { + const btn = $("btnDownload"), msg = $("dlMsg"); + if (!btn || !msg) return; + btn.disabled = true; + const setText = (t, color) => { msg.textContent = t; msg.style.color = color || "#666"; }; + setText("获取视频地址…"); + log("⬇ 开始下载当前页视频(清晰度 " + (settings.dlQuality === "80" ? "最高可用" : settings.dlQuality + "P") + ")", "step"); + + let res = null; + try { + res = await chrome.runtime.sendMessage({ action: "getDownloadInfo", qn: Number(settings.dlQuality) || 80 }); + } catch (e) { + res = { ok: false, error: String(e) }; + } + if (!res || !res.ok || !res.info) { + const err = (res && res.error) || "获取失败"; + setText("✗ " + err, "#d93025"); + log("⬇ 下载失败:" + err, "fail"); + btn.disabled = false; + return; + } + + const info = res.info; + const files = info.files; + const base = String(info.title || "bilibili视频").replace(/[\\/:*?"<>|]/g, "_").slice(0, 80); + const saved = [], errors = []; + for (let i = 0; i < files.length; i++) { + const f = files[i]; + let name = base; + if (f.tag) name += "_" + f.tag; + else if (files.length > 1) name += "_第" + (i + 1) + "段"; + name += "." + (f.ext || "mp4"); + try { + let lastShow = 0; + const blob = await fetchToBlob(f.url, (got, total) => { + const now = Date.now(); + if (now - lastShow > 500) { + lastShow = now; + setText("下载中 " + fmtMb(got) + (total ? " / " + fmtMb(total) : "") + "(" + name.slice(0, 18) + "…)"); + } + }); + const objUrl = URL.createObjectURL(blob); + await chrome.downloads.download({ + url: objUrl, filename: "B站视频/" + name, conflictAction: "uniquify", saveAs: false + }); + setTimeout(() => URL.revokeObjectURL(objUrl), 60000); // 给下载留足时间 + saved.push(name + "(" + fmtMb(blob.size) + ")"); + log("⬇ 已保存:" + name + "(" + fmtMb(blob.size) + ")", "ok"); + } catch (e) { + errors.push(name + ":" + String(e && e.message || e)); + log("⬇ 失败:" + name + ":" + String(e && e.message || e), "fail"); + } + } + + if (saved.length) { + const note = info.note ? "," + info.note : ""; + const partial = errors.length ? ",部分失败:" + errors.join(";") : ""; + setText("✓ " + saved.length + " 个文件(" + info.quality + ")" + (info.note ? "(分离格式)" : ""), "#0a7"); + log("⬇ 完成:" + saved.join("、") + note + partial, "ok"); + } else { + setText("✗ 下载失败:" + errors.join(";"), "#d93025"); + log("⬇ 下载失败:" + errors.join(";"), "fail"); + } + btn.disabled = false; + } + + // ============ 事件回传 ============ + + function addDoneItem(item) { + const box = $("doneList"); + if (!box) return; + const bvid = item.bvid || ""; + // 同一视频只显示一条(防止重复消息把成功列表刷成两条) + let exist = null; + for (const child of box.children) { + if (child.dataset && child.dataset.bvid === bvid) { exist = child; break; } + } + const div = exist || document.createElement("div"); + div.className = "done"; + div.dataset.bvid = bvid; + div.innerHTML = ""; + // 标题可点击 → 打开视频页 + const t = document.createElement("a"); + t.className = "dt"; + t.textContent = item.title || bvid; + t.href = "https://www.bilibili.com/video/" + bvid; + t.target = "_blank"; + t.rel = "noopener"; + t.title = "点击打开视频"; + const c = document.createElement("div"); c.className = "dc"; c.textContent = item.text || ""; + const m = document.createElement("div"); m.className = "dm"; + m.textContent = "BV:" + bvid + "(点标题查看视频)"; + div.appendChild(t); div.appendChild(c); div.appendChild(m); + if (!exist) box.prepend(div); + } + + function onFinished(ev) { + setRunning(false); + if (ev.ok) { + const msg = "执行完成:成功 " + (ev.done || 0) + " 条" + (ev.failed ? ",失败 " + ev.failed + " 条" : ""); + setStatus(msg); + log("✅ " + msg, "ok"); + } else { + const msg = ev.reason || "执行未完成"; + setStatus(msg, true); + log("⏹ " + msg, "fail"); + } + refreshStats(); + } + + chrome.runtime.onMessage.addListener(msg => { + if (!msg || !msg.relay) return; + const ev = msg.relay; + if (ev.type === "log") log(ev.text, ev.cls === "ok" ? "ok" : ev.cls === "fail" ? "fail" : "step"); + else if (ev.type === "done") addDoneItem(ev); + else if (ev.type === "finished") onFinished(ev); + else if (ev.type === "ready") log("B站页面内容脚本就绪", "ok"); + }); + + // ============ 评论记录统计 ============ + + async function refreshStats() { + try { + const res = await chrome.runtime.sendMessage({ action: "getStats" }); + if (res && res.stats) { + $("stTotal").textContent = res.stats.total || 0; + $("stWeek").textContent = res.stats.week || 0; + $("stToday").textContent = res.stats.today || 0; + } + } catch (e) { /* 忽略 */ } + } + + let clearArmed = false, clearTimer = null; + async function onClearHist() { + const btn = $("btnClearHist"); + if (!clearArmed) { + clearArmed = true; + if (btn) btn.textContent = "再点一次清空"; + clearTimer = setTimeout(() => { clearArmed = false; if (btn) btn.textContent = "清空"; }, 3000); + return; + } + clearTimeout(clearTimer); clearArmed = false; + if (btn) btn.textContent = "清空"; + try { + await chrome.runtime.sendMessage({ action: "clearHistory" }); + setStatus("评论记录已清空"); + log("评论记录已清空", "step"); + } catch (e) { setStatus("清空失败:" + String(e), true); } + refreshStats(); + } + + // ============ LLM 测试连接 ============ + + async function testCfg() { + const btn = $("testCfg"), msg = $("testMsg"); + if (btn) btn.disabled = true; + if (msg) msg.textContent = "测试中…"; + try { + const res = await chrome.runtime.sendMessage({ + action: "callAiApi", + baseUrl: settings.baseUrl, apiKey: settings.apiKey, model: settings.model, + prompt: "只回复两个字:正常", maxTokens: 60, timeoutMs: 60000 + }); + if (res && res.ok) { + if (msg) { msg.textContent = "✓ 成功:" + String(res.content).slice(0, 20); msg.style.color = "#0a7"; } + log("LLM 测试成功", "ok"); + } else { + if (msg) { msg.textContent = "✗ 失败:" + ((res && res.error) || "未知"); msg.style.color = "#d93025"; } + log("LLM 测试失败:" + ((res && res.error) || "未知"), "fail"); + } + } catch (e) { + if (msg) { msg.textContent = "✗ 失败:" + String(e); msg.style.color = "#d93025"; } + } + if (btn) btn.disabled = false; + } + + // ============ 初始化 ============ + + async function init() { + let saved = {}; + try { saved = (await chrome.storage.local.get("settings")).settings || {}; } catch (e) {} + settings = Object.assign({}, DEFAULTS, saved); + fillForm(); + refreshStats(); + log("就绪:填关键词后点「▶ 开始执行」"); + + for (const k of FIELDS) { + const el = $(k); + if (!el) continue; + el.addEventListener("change", () => { + const v = (el.type === "number") ? (Number(el.value) || 0) : el.value; + saveField(k, v); + if (k === "mode") refreshCustomVisibility(); + if (k === "mode" || k === "apiKey" || k === "baseUrl" || k === "model" || k === "customText") refreshModeWarn(); + }); + if (el.tagName === "TEXTAREA" || el.type === "text" || el.type === "password") { + el.addEventListener("input", () => { + settings[k] = el.value; + saveField(k, el.value); + if (k === "customText") refreshModeWarn(); // 边填边消警告 + }); + } + } + + const promo = $("promoLink"); + if (promo) promo.addEventListener("click", () => { + if (!settings.baseUrl) saveField("baseUrl", DEFAULTS.baseUrl); + chrome.tabs.create({ url: "https://ai.88531.cn/register" }); + }); + + const start = $("btnStart"); + if (start) start.addEventListener("click", startRun); + const stop = $("btnStopRun"); + if (stop) stop.addEventListener("click", stopRun); + const test = $("testCfg"); + if (test) test.addEventListener("click", testCfg); + const clear = $("btnClearHist"); + if (clear) clear.addEventListener("click", onClearHist); + const dl = $("btnDownload"); + if (dl) dl.addEventListener("click", downloadCurrent); + + // 获取最新版本 → 官网页(新标签页) + const latest = $("btnLatest"); + if (latest) latest.addEventListener("click", () => { + chrome.tabs.create({ url: "https://dl.88531.cn/bili.html" }); + log("已打开官网页面(新标签页):dl.88531.cn/bili.html", "step"); + }); + + // 授权区 + const buy = $("licBuy"); + if (buy) buy.addEventListener("click", () => chrome.tabs.create({ url: "https://auth.88531.cn" })); + const act = $("btnActivate"); + if (act) act.addEventListener("click", activateCard); + const card = $("cardKey"); + if (card) card.addEventListener("input", () => { + const msg = $("actMsg"); + if (msg && msg.textContent) { msg.textContent = ""; } + }); + const mach = $("licMachine"); + if (mach) mach.addEventListener("click", async () => { + const id = licState && licState.machine; + if (!id) return; + try { + await navigator.clipboard.writeText(id); + mach.title = "已复制:" + id; + } catch (e) { + const tmp = document.createElement("textarea"); + tmp.value = id; document.body.appendChild(tmp); tmp.select(); + try { document.execCommand("copy"); } catch (e2) {} + document.body.removeChild(tmp); + } + const t = $("machineIdText"); + if (t) { const old = t.textContent; t.textContent = old + "(已复制)"; setTimeout(() => { t.textContent = old; }, 1500); } + }); + loadLicense(); + } + + init(); +})();