1258 lines
58 KiB
JavaScript
1258 lines
58 KiB
JavaScript
// 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" });
|
||
})();
|