Files
bili-auto-comment/content.js
T

1258 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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; }
}
// ============ 私信(web_im send_msg,带 wbi 签名) ============
// B站私信接口:POST https://api.vc.bilibili.com/web_im/v1/web_im/send_msg
// 鉴权 wbi(w_rid + wts)+ csrf;只支持 msg_type=1(文字)/2(图)/5(撤回)
// 关键风控:21047「对方主动回复或关注你前,最多发送1条」→ 必须按 mid 永久去重
const MIXIN_INDEX = [46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35,27,43,5,49,33,9,42,19,29,28,14,39,12,38,41,13,37,48,7,16,24,55,40,61,26,17,0,1,60,51,30,4,22,25,54,21,56,59,6,63,57,62,11,36,20,34,44,52];
/* MD5 (RFC 1321),私信 w_rid 需要;下面用标准实现,测试向量见下方 selftest */
function md5(str) {
function rl(n, c) { return (n << c) | (n >>> (32 - c)); }
function au(x, y) { var l = (x & 0xFFFF) + (y & 0xFFFF); var m = (x >> 16) + (y >> 16) + (l >> 16); return (m << 16) | (l & 0xFFFF); }
function cmn(q, a, b, x, s, t) { return au(rl(au(au(a, q), au(x, t)), s), b); }
function ff(a, b, c, d, x, s, t) { return cmn((b & c) | (~b & d), a, b, x, s, t); }
function gg(a, b, c, d, x, s, t) { return cmn((b & d) | (c & ~d), a, b, x, s, t); }
function hh(a, b, c, d, x, s, t) { return cmn(b ^ c ^ d, a, b, x, s, t); }
function ii(a, b, c, d, x, s, t) { return cmn(c ^ (b | ~d), a, b, x, s, t); }
function utf8Bytes(s) {
var out = [], i, c;
for (i = 0; i < s.length; i++) {
c = s.charCodeAt(i);
if (c < 0x80) out.push(c);
else if (c < 0x800) { out.push(0xC0 | (c >> 6), 0x80 | (c & 0x3F)); }
else if (c >= 0xD800 && c <= 0xDBFF && i + 1 < s.length) {
var c2 = s.charCodeAt(++i);
var cp = 0x10000 + ((c - 0xD800) << 10) + (c2 - 0xDC00);
out.push(0xF0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3F), 0x80 | ((cp >> 6) & 0x3F), 0x80 | (cp & 0x3F));
} else { out.push(0xE0 | (c >> 12), 0x80 | ((c >> 6) & 0x3F), 0x80 | (c & 0x3F)); }
}
return out;
}
function toBlocks(s) {
var b = utf8Bytes(s), n = b.length;
var nb = ((n + 8) >> 6) + 1, blks = new Array(nb * 16).fill(0);
for (var i = 0; i < n; i++) blks[i >> 2] |= b[i] << ((i % 4) * 8);
blks[i >> 2] |= 0x80 << ((i % 4) * 8);
blks[nb * 16 - 2] = n * 8;
return blks;
}
var x = toBlocks(str), a = 1732584193, b = -271733879, c = -1732584194, d = 271733878;
for (var i = 0; i < x.length; i += 16) {
var oa = a, ob = b, oc = c, od = d;
a = ff(a, b, c, d, x[i], 7, -680876936); d = ff(d, a, b, c, x[i + 1], 12, -389564586); c = ff(c, d, a, b, x[i + 2], 17, 606105819); b = ff(b, c, d, a, x[i + 3], 22, -1044525330);
a = ff(a, b, c, d, x[i + 4], 7, -176418897); d = ff(d, a, b, c, x[i + 5], 12, 1200080426); c = ff(c, d, a, b, x[i + 6], 17, -1473231341); b = ff(b, c, d, a, x[i + 7], 22, -45705983);
a = ff(a, b, c, d, x[i + 8], 7, 1770035416); d = ff(d, a, b, c, x[i + 9], 12, -1958414417); c = ff(c, d, a, b, x[i + 10], 17, -42063); b = ff(b, c, d, a, x[i + 11], 22, -1990404162);
a = ff(a, b, c, d, x[i + 12], 7, 1804603682); d = ff(d, a, b, c, x[i + 13], 12, -40341101); c = ff(c, d, a, b, x[i + 14], 17, -1502002290); b = ff(b, c, d, a, x[i + 15], 22, 1236535329);
a = gg(a, b, c, d, x[i + 1], 5, -165796510); d = gg(d, a, b, c, x[i + 6], 9, -1069501632); c = gg(c, d, a, b, x[i + 11], 14, 643717713); b = gg(b, c, d, a, x[i], 20, -373897302);
a = gg(a, b, c, d, x[i + 5], 5, -701558691); d = gg(d, a, b, c, x[i + 10], 9, 38016083); c = gg(c, d, a, b, x[i + 15], 14, -660478335); b = gg(b, c, d, a, x[i + 4], 20, -405537848);
a = gg(a, b, c, d, x[i + 9], 5, 568446438); d = gg(d, a, b, c, x[i + 14], 9, -1019803690); c = gg(c, d, a, b, x[i + 3], 14, -187363961); b = gg(b, c, d, a, x[i + 8], 20, 1163531501);
a = gg(a, b, c, d, x[i + 13], 5, -1444681467); d = gg(d, a, b, c, x[i + 2], 9, -51403784); c = gg(c, d, a, b, x[i + 7], 14, 1735328473); b = gg(b, c, d, a, x[i + 12], 20, -1926607734);
a = hh(a, b, c, d, x[i + 5], 4, -378558); d = hh(d, a, b, c, x[i + 8], 11, -2022574463); c = hh(c, d, a, b, x[i + 11], 16, 1839030562); b = hh(b, c, d, a, x[i + 14], 23, -35309556);
a = hh(a, b, c, d, x[i + 1], 4, -1530992060); d = hh(d, a, b, c, x[i + 4], 11, 1272893353); c = hh(c, d, a, b, x[i + 7], 16, -155497632); b = hh(b, c, d, a, x[i + 10], 23, -1094730640);
a = hh(a, b, c, d, x[i + 13], 4, 681279174); d = hh(d, a, b, c, x[i], 11, -358537222); c = hh(c, d, a, b, x[i + 3], 16, -722521979); b = hh(b, c, d, a, x[i + 6], 23, 76029189);
a = hh(a, b, c, d, x[i + 9], 4, -640364487); d = hh(d, a, b, c, x[i + 12], 11, -421815835); c = hh(c, d, a, b, x[i + 15], 16, 530742520); b = hh(b, c, d, a, x[i + 2], 23, -995338651);
a = ii(a, b, c, d, x[i], 6, -198630844); d = ii(d, a, b, c, x[i + 7], 10, 1126891415); c = ii(c, d, a, b, x[i + 14], 15, -1416354905); b = ii(b, c, d, a, x[i + 5], 21, -57434055);
a = ii(a, b, c, d, x[i + 12], 6, 1700485571); d = ii(d, a, b, c, x[i + 3], 10, -1894986606); c = ii(c, d, a, b, x[i + 10], 15, -1051523); b = ii(b, c, d, a, x[i + 1], 21, -2054922799);
a = ii(a, b, c, d, x[i + 8], 6, 1873313359); d = ii(d, a, b, c, x[i + 15], 10, -30611744); c = ii(c, d, a, b, x[i + 6], 15, -1560198380); b = ii(b, c, d, a, x[i + 13], 21, 1309151649);
a = ii(a, b, c, d, x[i + 4], 6, -145523070); d = ii(d, a, b, c, x[i + 11], 10, -1120210379); c = ii(c, d, a, b, x[i + 2], 15, 718787259); b = ii(b, c, d, a, x[i + 9], 21, -343485551);
a = au(a, oa); b = au(b, ob); c = au(c, oc); d = au(d, od);
}
function hx(n) { var s = "", k; for (k = 0; k < 4; k++) s += "0123456789abcdef".charAt((n >> (k * 8 + 4)) & 0x0F) + "0123456789abcdef".charAt((n >> (k * 8)) & 0x0F); return s; }
return hx(a) + hx(b) + hx(c) + hx(d);
}
let WBI_CACHE = null;
async function getWbiMixin() {
if (WBI_CACHE) return WBI_CACHE;
const data = await apiJSON("https://api.bilibili.com/x/web-interface/nav");
const wi = data && data.data && data.data.wbi_img;
if (!wi || !wi.img_url || !wi.sub_url) throw new Error("拿不到 wbi 密钥(未登录或接口异常)");
const k1 = String(wi.img_url).split("/").pop().split(".")[0];
const k2 = String(wi.sub_url).split("/").pop().split(".")[0];
const raw = k1 + k2;
let mixin = "";
for (const i of MIXIN_INDEX) mixin += raw[i] || "";
WBI_CACHE = mixin.slice(0, 32);
return WBI_CACHE;
}
async function signParams(params) {
const mixin = await getWbiMixin();
const p = Object.assign({}, params);
p.wts = String(Math.floor(Date.now() / 1000));
const clean = {};
for (const k of Object.keys(p).sort()) clean[k] = String(p[k]).replace(/[!'()*]/g, "");
const q = Object.keys(clean).map(k => k + "=" + encodeURIComponent(clean[k])).join("&");
const w_rid = md5(q + mixin);
return { q: q + "&w_rid=" + w_rid, w_rid: w_rid };
}
async function getMyMid() {
const data = await apiJSON("https://api.bilibili.com/x/web-interface/nav");
const mid = data && data.data && data.data.mid;
return Number(mid) || 0;
}
// 已发过私信的 mid(永久去重:B站 21047 限制同一陌生人只能发 1 条)
async function dmSentSet() {
try {
const c = await chrome.storage.local.get("dmSentMids");
const arr = c.dmSentMids;
return new Set(Array.isArray(arr) ? arr : []);
} catch (e) { return new Set(); }
}
async function dmMarkSent(mid) {
try {
const c = await chrome.storage.local.get("dmSentMids");
const arr = Array.isArray(c.dmSentMids) ? c.dmSentMids : [];
const s = String(mid);
if (!arr.includes(s)) { arr.push(s); if (arr.length > 5000) arr.splice(0, arr.length - 5000); }
await chrome.storage.local.set({ dmSentMids: arr });
} catch (e) { /* 忽略 */ }
}
// 取视频评论区用户 mid(去重、排除自己、排除已发过)
async function getDmTargets(aid, limit, sentSet, myMid) {
let mids = [];
try {
const data = await apiJSON("https://api.bilibili.com/x/v2/reply/main?oid=" + aid + "&type=1&ps=20&mode=3");
const replies = (data && data.data && (data.data.replies || [])) || [];
const hot = (data && data.data && (data.data.top_replies || [])) || [];
const all = hot.concat(replies);
for (const r of all) {
const mid = Number(r && r.member && r.member.mid);
if (!mid || mid === myMid) continue;
const name = String((r.member && r.member.uname) || "");
if (sentSet.has(String(mid))) continue;
if (!mids.some(x => x.mid === mid)) mids.push({ mid: mid, name: name });
if (mids.length >= limit) break;
}
} catch (e) { /* 接口不可用则返回空 */ }
return mids;
}
const DM_CODE_HINT = {
"-101": "账号未登录或登录已过期",
"-400": "请求错误(参数或签名问题)",
"21015": "需要绑定手机号才能发私信",
"21020": "发私信频率过快,已停止",
"21026": "不能给自己发私信",
"21046": "发私信频率过高,24小时后再发(已停止)",
"21047": "对方未回复/未关注你前,最多只能发 1 条(该用户已跳过)",
"25003": "对方隐私设置,无法发送",
"25005": "你已拉黑对方"
};
async function sendDm(mid, text) {
const csrf = getCookie("bili_jct");
if (!csrf) return { code: -999, message: "未读取到 bili_jct(登录态)" };
const myMid = await getMyMid();
if (!myMid) return { code: -998, message: "拿不到自己的 mid(未登录)" };
const ts = Math.floor(Date.now() / 1000);
const devId = (typeof crypto !== "undefined" && crypto.randomUUID)
? crypto.randomUUID()
: "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0, v = c === "x" ? r : (r & 0x3 | 0x8); return v.toString(16);
});
const params = {
"msg[sender_uid]": String(myMid),
"msg[receiver_id]": String(mid),
"msg[receiver_type]": "1",
"msg[msg_type]": "1",
"msg[msg_status]": "0",
"msg[dev_id]": devId,
"msg[timestamp]": String(ts),
"msg[new_face_version]": "1",
"msg[content]": JSON.stringify({ content: text }),
"csrf_token": csrf,
"csrf": csrf,
"mobi_app": "web",
"build": "0"
};
const signed = await signParams(params);
const body = new URLSearchParams(signed.q.split("&").reduce((acc, kv) => {
const i = kv.indexOf("="); acc[kv.slice(0, i)] = decodeURIComponent(kv.slice(i + 1)); return acc;
}, {})).toString();
const data = await apiJSON("https://api.vc.bilibili.com/web_im/v1/web_im/send_msg?" + signed.q, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Referer": "https://message.bilibili.com/",
"Origin": "https://message.bilibili.com"
},
body: body
});
const code = data && data.code;
return { code: code, message: DM_CODE_HINT[String(code)] || (data && data.message) || "" };
}
// ============ 关键词搜索 ============
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;
let avgGapSec = 0;
if (task && task.firstSentAt && task.lastSentAt && (task.sent || 0) >= 2) {
avgGapSec = Math.round((task.lastSentAt - task.firstSentAt) / ((task.sent || 1) - 1) / 1000);
}
relay({ type: "finished", ok: !!ok, reason: reason || "", done: task ? (task.sent || 0) : 0, failed: task ? (task.failed || 0) : 0, avgGapSec: avgGapSec });
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;
}
}
// ============ 私信执行步:对当前视频的评论区用户发送(独立频率、永久按 mid 去重) ============
// B站风控:21047 同一陌生人未回复/未关注前最多只能发 1 条;21046 频繁发会被限 24 小时
// 所以默认间隔 2~4 分钟、每视频最多 3 人、同一 mid 永不重复
const DM_GAP_MIN = 2 * 60 * 1000, DM_GAP_MAX = 4 * 60 * 1000;
// 私信内容:优先用面板「私信内容」框(每行一条随机取);
// 留空则复用本次评论内容(与界面「留空则复用上方评论内容」一致),再不行才用模板
function pickDmText(cfg, video, commentText) {
const raw = String(cfg.dmText || "").split("\n").map(s => s.trim()).filter(Boolean);
if (raw.length) {
const valid = [], 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) {
step("❌ 私信内容全部不合规(每行需3~200字、不含外链、不能全是重复字),已改用评论模板", "fail");
} else {
return { text: valid[Math.floor(Math.random() * valid.length)], source: "私信自定义" };
}
}
if (commentText && verifyMessage(commentText).ok) {
return { text: String(commentText).trim(), source: "复用评论" };
}
return { text: pickTemplate(video.title, cfg.keyword), source: "模板" };
}
async function runDmStep(task, video) {
const cfg = task.config;
const limit = Math.max(1, Math.min(5, Number(cfg.dmCount) || 1));
try {
step("准备私信:抓取评论区用户…");
const myMid = await getMyMid();
if (!myMid) { step("⚠️ 未登录,跳过私信", "fail"); return; }
const sentSet = await dmSentSet();
const targets = await getDmTargets(video.aid, limit, sentSet, myMid);
if (!targets.length) { step("本视频无可私信对象(都已发过或无评论)"); return; }
step("评论区可私信 " + targets.length + " 人");
for (let i = 0; i < targets.length; i++) {
if (stopRequested()) { step("已手动停止,私信中断", "fail"); return; }
const t = targets[i];
const dm = pickDmText(cfg, video, text);
const text = dm.text;
const v = verifyMessage(text);
if (!v.ok) { step("❌ 私信内容不合规:" + v.reason, "fail"); return; }
step("私信[" + dm.source + "] @" + t.name + "(" + (i + 1) + "/" + targets.length + "):" + text.slice(0, 24) + (text.length > 24 ? "…" : ""));
const res = await sendDm(t.mid, text);
if (res.code === 0) {
task.dmSent = (task.dmSent || 0) + 1;
await dmMarkSent(t.mid);
relay({ type: "dmDone", mid: t.mid, name: t.name });
step("✅ 私信已发送:@" + t.name, "ok");
} else {
step("❌ 私信失败 @" + t.name + ":" + res.message, "fail");
// 频率类致命错误 → 直接停,避免把号搞废
if (["21020", "21046", "-101", "-999"].indexOf(String(res.code)) >= 0) {
step("触发私信风控,已停止后续私信", "fail");
return;
}
}
if (i < targets.length - 1) {
const g = DM_GAP_MIN + Math.random() * (DM_GAP_MAX - DM_GAP_MIN);
step("等待 " + Math.round(g / 1000) + " 秒后发下一条私信…");
await interruptibleWait(g);
}
}
} catch (e) {
step("私信出错:" + (e && e.message || e), "fail");
}
}
// 视频页内处理一条 → 等待 → 跳下一个(导航后由新页面的脚本续跑)
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: "模板" };
// 发送内容三选一:comment=只评论 / dm=只发私信不发评论 / both=评论+私信
const what = ["comment", "dm", "both"].indexOf(cfg.sendWhat) >= 0 ? cfg.sendWhat : "comment";
const doComment = (what === "comment" || what === "both");
const doDm = (what === "dm" || what === "both");
if (doComment) {
// 试用额度检查:发到额度就自动停,不用等用户发现
if (cfg.trialQuota != null && (task.sent || 0) >= cfg.trialQuota) {
finish(task, true, "试用期 " + cfg.trialQuota + " 条额度已用完,购买卡密激活后不限量");
return;
}
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");
if (!task.firstSentAt) task.firstSentAt = Date.now();
task.lastSentAt = Date.now();
await recordOne(task, video, text, true);
} else {
step("❌ 失败:" + r.error, "fail");
await recordOne(task, video, text, false);
}
} else {
step("本条不发评论(发送内容=只发私信)");
}
// 私信:对本视频评论区里发过评论的人发送;风控更严,间隔更长
let dmBefore = task.dmSent || 0; // 本视频发送前的累计数,用于算增量
if (doDm && !stopRequested()) {
await runDmStep(task, video);
}
// 只发私信模式下也要记为「已处理」,否则下次会重复进同一视频
// 注意按「本视频的增量」判断,不能用累计数(否则前面发过就会误记后面没发的)
if (!doComment && (task.dmSent || 0) > dmBefore) {
await recordOne(task, video, text, true);
}
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) + (task.dmSent ? ",私信 " + task.dmSent + " 条" : ""));
} 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" });
})();