v1.6.6: 使用说明+官网页链接、下载改夸克网盘、评论3字下限、AI读取视频真实内容、模拟真人逐字评论
This commit is contained in:
commit
38b90c1448
12 files changed
+2614
No files matched your search
+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" });
|
||||
})();
|
||||
Reference in new issue
Block a user