v1.6.6: 使用说明+官网页链接、下载改夸克网盘、评论3字下限、AI读取视频真实内容、模拟真人逐字评论
This commit is contained in:
commit
38b90c1448
12 files changed
+2614
No files matched your search
@@ -0,0 +1,3 @@
|
||||
__test_harness.html
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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 归一化
|
||||
+585
@@ -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;
|
||||
}
|
||||
});
|
||||
+46
@@ -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); }
|
||||
+964
@@ -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 = '<div class="biliKwTitle">💬 B站评论助手</div><div class="biliKwSteps"></div>';
|
||||
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[^>]*>([^<]+)<\/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站评论区是 <bili-comments> 嵌套 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" });
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 574 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { width: 100%; min-height: 100vh; font-family: "PingFang SC","Microsoft YaHei","Noto Sans CJK SC",sans-serif; font-size: 13px; color: #222; }
|
||||
header { background: #00A1D6; color: #fff; padding: 10px 12px; font-weight: bold; font-size: 14px; }
|
||||
.promo { display:flex; align-items:center; justify-content:space-between; gap:8px;
|
||||
background: linear-gradient(90deg,#00A1D6,#FB7299); color:#fff; padding: 8px 12px; font-size: 13px; }
|
||||
.promo a { color:#fff; font-weight:bold; text-decoration:underline; cursor:pointer; }
|
||||
.sec { padding: 10px 12px; border-bottom: 1px solid #eee; }
|
||||
.lbl { font-size: 11px; color: #888; margin-bottom: 5px; }
|
||||
input[type=text], input[type=password], input[type=number], select {
|
||||
width: 100%; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; background:#fff; }
|
||||
.row { display: flex; gap: 6px; margin-top: 6px; align-items: center; }
|
||||
.row .unit { font-size: 12px; white-space: nowrap; }
|
||||
.row input[type=number] { width: 62px; }
|
||||
button { padding: 6px 10px; border: 1px solid #d9d9d9; background: #fff; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||
button.primary { background: #00A1D6; color: #fff; border-color: #00A1D6; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
#status { font-size: 12px; color: #0a7; padding: 0 12px 8px; min-height: 16px; }
|
||||
#modeWarn { display:none; font-size: 11px; color: #e67e22; margin-top: 4px; }
|
||||
#btnStart { width: 100%; height: 42px; font-size: 16px; font-weight: bold; margin-top: 10px; }
|
||||
#btnStopRun { height: 42px; font-size: 15px; font-weight: bold; color:#fff; background:#444; border-color:#444; flex: 1; }
|
||||
#runTimer { font-size: 13px; font-weight: bold; color: #FB7299; white-space: nowrap; min-width: 90px; text-align: right; }
|
||||
.statRow { display:flex; gap:6px; align-items:center; }
|
||||
.stat { flex:1; text-align:center; background:#f7f7f9; border-radius:6px; padding:5px 2px; }
|
||||
.stat .n { font-size:16px; font-weight:bold; color:#00A1D6; }
|
||||
.stat .k { font-size:10px; color:#999; }
|
||||
.done { padding: 6px 10px; border-bottom: 1px solid #f2f2f2; cursor: pointer; }
|
||||
.done:hover { background: #f3faff; }
|
||||
.done .dt { display: block; font-size: 13px; color: #161823; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
text-decoration: none; font-weight: 500; }
|
||||
.done .dt::before { content: "✓ "; color: #0a7; font-weight: bold; }
|
||||
.done .dt:hover { color: #00A1D6; text-decoration: underline; }
|
||||
.done .dc { font-size: 12px; color: #4a9d6a; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.done .dm { font-size: 10px; color: #aaa; margin-top: 1px; }
|
||||
#logBox { max-height: 220px; min-height: 64px; overflow-y: auto; background:#14161f; color:#c5fff2;
|
||||
font-family: Consolas, monospace; font-size: 12px; padding: 8px 10px; border-radius: 6px; line-height: 1.6; }
|
||||
#logBox .step { color: #ffd76e; }
|
||||
#logBox .ok { color: #8ef7b7; }
|
||||
#logBox .fail { color: #ff7b72; }
|
||||
.foot { font-size: 11px; color: #aaa; padding: 8px 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>B站评论助手</header>
|
||||
|
||||
<div id="licSec" style="padding:8px 12px;border-bottom:1px solid #eee;background:#fafbfc">
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span id="licIcon" style="font-size:14px">⏳</span>
|
||||
<span id="licText" style="font-size:13px;font-weight:bold;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">授权检查中…</span>
|
||||
<a id="licBuy" style="color:#e91e63;font-weight:bold;text-decoration:underline;cursor:pointer;white-space:nowrap">购买授权 →</a>
|
||||
</div>
|
||||
<div id="licSub" style="font-size:11px;color:#888;margin-top:3px"></div>
|
||||
<div id="licNotice" style="display:none;font-size:12px;color:#7a5c00;background:#fff8e1;border:1px solid #ffe082;border-radius:4px;padding:4px 8px;margin-top:6px;line-height:1.5"></div>
|
||||
<div id="licActRow" style="display:none;margin-top:6px;align-items:center;gap:6px">
|
||||
<input type="text" id="cardKey" placeholder="已有卡密?粘贴到这里" style="flex:1">
|
||||
<button id="btnActivate">激活</button>
|
||||
<span id="actMsg" style="font-size:12px"></span>
|
||||
</div>
|
||||
<div id="licMachine" style="font-size:10px;color:#bbb;margin-top:4px;cursor:pointer" title="点击复制机器码">机器码:<span id="machineIdText">…</span>(点击复制)</div>
|
||||
</div>
|
||||
|
||||
<div class="promo">
|
||||
<span>🤖 没有模型?注册即送额度</span>
|
||||
<a id="promoLink">ai.88531.cn 注册 →</a>
|
||||
</div>
|
||||
|
||||
<div class="sec">
|
||||
<div class="lbl">📊 评论记录</div>
|
||||
<div class="statRow">
|
||||
<div class="stat"><div class="n" id="stTotal">0</div><div class="k">总数</div></div>
|
||||
<div class="stat"><div class="n" id="stWeek">0</div><div class="k">周</div></div>
|
||||
<div class="stat"><div class="n" id="stToday">0</div><div class="k">今日</div></div>
|
||||
<button id="btnClearHist" title="清空评论记录">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec">
|
||||
<div class="lbl">LLM 配置(自动保存,仅 AI 模式需要)</div>
|
||||
<input type="text" id="baseUrl" placeholder="https://ai.88531.cn/v1">
|
||||
<input type="password" id="apiKey" placeholder="API Key" style="margin-top:4px">
|
||||
<input type="text" id="model" placeholder="模型名,默认 mimo-v2.6-flash" style="margin-top:4px">
|
||||
<div class="row">
|
||||
<button id="testCfg">测试连接</button>
|
||||
<span id="testMsg" style="font-size:12px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec">
|
||||
<div class="lbl">搜索与发布</div>
|
||||
<input type="text" id="keyword" placeholder="搜索关键词,如:效率软件">
|
||||
<div class="row">
|
||||
<span class="unit">排序</span>
|
||||
<select id="order">
|
||||
<option value="hot">最热门(播放量优先)</option>
|
||||
<option value="totalrank">综合排序</option>
|
||||
<option value="click">最多播放</option>
|
||||
<option value="dm">最多弹幕</option>
|
||||
<option value="stow">最多收藏</option>
|
||||
<option value="pubdate">最新发布</option>
|
||||
</select>
|
||||
<span class="unit">评论方式</span>
|
||||
<select id="mode">
|
||||
<option value="tpl">固定模板</option>
|
||||
<option value="ai">AI生成</option>
|
||||
<option value="custom">自定义</option>
|
||||
</select>
|
||||
</div>
|
||||
<textarea id="customText" rows="5" placeholder="自定义评论:每行一条,每行至少3个字(3~200字、不含链接),每条视频随机取一条" style="display:none;width:100%;min-height:110px;margin-top:6px;padding:8px 10px;border:1px solid #ccc;border-radius:4px;font-size:13px;line-height:1.6;resize:vertical;box-sizing:border-box"></textarea>
|
||||
<div class="row">
|
||||
<span class="unit">发送方式</span>
|
||||
<select id="sendMode" style="flex:1">
|
||||
<option value="human">模拟真人(打开视频页逐字输入)</option>
|
||||
<option value="api">快速(不跳页直接发送)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="unit">分区</span>
|
||||
<select id="tid" style="flex:1">
|
||||
<option value="0">全部分区</option>
|
||||
<option value="188">科技</option>
|
||||
<option value="36">知识</option>
|
||||
<option value="160">生活</option>
|
||||
<option value="4">游戏</option>
|
||||
<option value="181">影视</option>
|
||||
<option value="177">纪录片</option>
|
||||
<option value="23">电影</option>
|
||||
<option value="11">电视剧</option>
|
||||
<option value="3">音乐</option>
|
||||
<option value="129">舞蹈</option>
|
||||
<option value="1">动画</option>
|
||||
<option value="168">国创</option>
|
||||
<option value="5">娱乐</option>
|
||||
<option value="119">鬼畜</option>
|
||||
<option value="155">时尚</option>
|
||||
<option value="211">美食</option>
|
||||
<option value="217">动物圈</option>
|
||||
<option value="223">汽车</option>
|
||||
<option value="234">运动</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="unit">地区</span>
|
||||
<select id="region" style="flex:1">
|
||||
<option value="">全部地区</option>
|
||||
<optgroup label="直辖市">
|
||||
<option value="北京市">北京市</option>
|
||||
<option value="上海市">上海市</option>
|
||||
<option value="天津市">天津市</option>
|
||||
<option value="重庆市">重庆市</option>
|
||||
</optgroup>
|
||||
<optgroup label="省份/自治区">
|
||||
<option value="河北省">河北省</option>
|
||||
<option value="山西省">山西省</option>
|
||||
<option value="辽宁省">辽宁省</option>
|
||||
<option value="吉林省">吉林省</option>
|
||||
<option value="黑龙江省">黑龙江省</option>
|
||||
<option value="江苏省">江苏省</option>
|
||||
<option value="浙江省">浙江省</option>
|
||||
<option value="安徽省">安徽省</option>
|
||||
<option value="福建省">福建省</option>
|
||||
<option value="江西省">江西省</option>
|
||||
<option value="山东省">山东省</option>
|
||||
<option value="河南省">河南省</option>
|
||||
<option value="湖北省">湖北省</option>
|
||||
<option value="湖南省">湖南省</option>
|
||||
<option value="广东省">广东省</option>
|
||||
<option value="海南省">海南省</option>
|
||||
<option value="四川省">四川省</option>
|
||||
<option value="贵州省">贵州省</option>
|
||||
<option value="云南省">云南省</option>
|
||||
<option value="陕西省">陕西省</option>
|
||||
<option value="甘肃省">甘肃省</option>
|
||||
<option value="青海省">青海省</option>
|
||||
<option value="台湾省">台湾省</option>
|
||||
<option value="内蒙古自治区">内蒙古自治区</option>
|
||||
<option value="广西壮族自治区">广西壮族自治区</option>
|
||||
<option value="西藏自治区">西藏自治区</option>
|
||||
<option value="宁夏回族自治区">宁夏回族自治区</option>
|
||||
<option value="新疆维吾尔自治区">新疆维吾尔自治区</option>
|
||||
</optgroup>
|
||||
<optgroup label="特别行政区">
|
||||
<option value="香港特别行政区">香港</option>
|
||||
<option value="澳门特别行政区">澳门</option>
|
||||
</optgroup>
|
||||
<optgroup label="热门城市">
|
||||
<option value="广州">广州</option>
|
||||
<option value="深圳">深圳</option>
|
||||
<option value="杭州">杭州</option>
|
||||
<option value="成都">成都</option>
|
||||
<option value="武汉">武汉</option>
|
||||
<option value="南京">南京</option>
|
||||
<option value="苏州">苏州</option>
|
||||
<option value="西安">西安</option>
|
||||
<option value="郑州">郑州</option>
|
||||
<option value="长沙">长沙</option>
|
||||
<option value="青岛">青岛</option>
|
||||
<option value="大连">大连</option>
|
||||
<option value="厦门">厦门</option>
|
||||
<option value="宁波">宁波</option>
|
||||
<option value="无锡">无锡</option>
|
||||
<option value="佛山">佛山</option>
|
||||
<option value="东莞">东莞</option>
|
||||
<option value="合肥">合肥</option>
|
||||
<option value="福州">福州</option>
|
||||
<option value="济南">济南</option>
|
||||
<option value="昆明">昆明</option>
|
||||
<option value="沈阳">沈阳</option>
|
||||
<option value="哈尔滨">哈尔滨</option>
|
||||
<option value="长春">长春</option>
|
||||
<option value="南宁">南宁</option>
|
||||
<option value="贵阳">贵阳</option>
|
||||
<option value="太原">太原</option>
|
||||
<option value="南昌">南昌</option>
|
||||
<option value="石家庄">石家庄</option>
|
||||
<option value="兰州">兰州</option>
|
||||
<option value="乌鲁木齐">乌鲁木齐</option>
|
||||
<option value="海口">海口</option>
|
||||
<option value="呼和浩特">呼和浩特</option>
|
||||
<option value="银川">银川</option>
|
||||
<option value="西宁">西宁</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div id="modeWarn">⚠ 选了 AI 生成但还没填模型配置,将自动改用固定模板</div>
|
||||
<div class="row">
|
||||
<span class="unit">最多</span>
|
||||
<input type="number" id="maxN" value="3" min="1" max="20">
|
||||
<span class="unit">条视频,每条间隔 1~2 分钟随机</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="unit">播放量 ≥</span>
|
||||
<input type="number" id="minPlay" value="0" min="0">
|
||||
<span class="unit">次(0 = 不过滤)</span>
|
||||
</div>
|
||||
<button id="btnStart" class="primary">▶ 开始执行</button>
|
||||
<div class="row" style="display:none" id="stopRow">
|
||||
<button id="btnStopRun">⏹ 停止</button>
|
||||
<span id="runTimer"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
|
||||
<div class="sec">
|
||||
<div class="lbl">✅ 本次评论成功的视频(实时更新)</div>
|
||||
<div id="doneList" style="max-height:240px;overflow-y:auto"></div>
|
||||
</div>
|
||||
|
||||
<div class="sec">
|
||||
<div class="lbl">📋 执行日志(实时滚动,确认正在运行就看这里)</div>
|
||||
<div id="logBox"></div>
|
||||
</div>
|
||||
|
||||
<div class="sec">
|
||||
<div class="lbl">⬇ 下载当前B站页面视频(先切到视频页,保存到 下载/B站视频/)</div>
|
||||
<div class="row">
|
||||
<span class="unit">清晰度</span>
|
||||
<select id="dlQuality">
|
||||
<option value="80">最高可用(登录后1080P)</option>
|
||||
<option value="64">720P</option>
|
||||
<option value="32">480P</option>
|
||||
<option value="16">360P</option>
|
||||
</select>
|
||||
<button id="btnDownload">⬇ 下载当前视频</button>
|
||||
<span id="dlMsg" style="font-size:12px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec">
|
||||
<button id="btnLatest" style="width:100%;height:46px;font-size:16px;font-weight:bold;color:#fff;border:none;border-radius:12px;cursor:pointer;background:linear-gradient(135deg,#00A1D6,#FB7299);box-shadow:0 6px 18px rgba(0,161,214,.28)">
|
||||
⬇ 获取最新版本 · 使用说明
|
||||
</button>
|
||||
<div style="font-size:11px;color:#999;text-align:center;margin-top:5px">点击在新标签页打开官网 dl.88531.cn/bili.html</div>
|
||||
</div>
|
||||
|
||||
<div class="sec">
|
||||
<details>
|
||||
<summary style="cursor:pointer;font-size:13px;font-weight:bold;color:#00A1D6">📖 使用说明(点开看功能与用法)</summary>
|
||||
<div style="font-size:12px;color:#555;line-height:1.85;margin-top:8px">
|
||||
<b style="color:#222">三步上手</b><br>
|
||||
① 先登录B站网页版 → 点工具栏图标打开本面板<br>
|
||||
② 填关键词(可选分区/地区/最热门排序/最多几条),选评论方式 → 点「▶ 开始执行」<br>
|
||||
③ 想让评论更贴合视频就配 AI 模型(API地址+模型名+密钥,点测试连接验证)
|
||||
<div style="border-top:1px dashed #ddd;margin:9px 0"></div>
|
||||
<b style="color:#222">三种评论方式</b><br>
|
||||
· 固定模板:不用配置,装上就能用<br>
|
||||
· 自定义:自己写,一行一条随机取,每行 3 字以上<br>
|
||||
· AI 生成:先读视频的标签/简介/弹幕/热评,再写针对性评论
|
||||
<div style="border-top:1px dashed #ddd;margin:9px 0"></div>
|
||||
<b style="color:#222">核心优势</b><br>
|
||||
· 模拟真人:自动打开视频页→滚到评论区→逐字输入→点发布<br>
|
||||
· 防风控:每条间隔 1~2 分钟随机,可随时停止<br>
|
||||
· 不重复:评过的视频自动跳过<br>
|
||||
· 数据本地:评论内容和记录都只存在你浏览器里<br>
|
||||
· 下载视频:不受授权限制,最高 1080P
|
||||
<div style="border-top:1px dashed #ddd;margin:9px 0"></div>
|
||||
<b style="color:#222">注意事项</b><br>
|
||||
· 每天建议不超过 10 条,合理控制频率避免账号风控<br>
|
||||
· 评论不含外链/广告,触发验证码或当日上限会自动停止<br>
|
||||
· 新版本下载、安装步骤、常见问题都在官网页
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="foot">填关键词 → 点「▶ 开始执行」,其余全自动;页面右上角浮层显示每一步</div>
|
||||
|
||||
<script src="sidepanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+623
@@ -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 += ' <a href="#" class="annLink" data-url="' + String(a.link).replace(/"/g, """) + '" style="color:#00A1D6">详情 →</a>';
|
||||
return html;
|
||||
}).join("<br>");
|
||||
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();
|
||||
})();
|
||||
Reference in new issue
Block a user