diff --git a/background.js b/background.js index cbd0764..d4c16bb 100644 --- a/background.js +++ b/background.js @@ -29,6 +29,41 @@ async function ensureDownloadHeadersRule() { } ensureDownloadHeadersRule(); +// ============ 安装统计(CardAuth installations:只上报机器码+版本,不碰B站账号) ============ +// 用 check-update 接口登记(服务端会写 installations 表并返回 install_id), +// 顺带拿到 latest_version → 面板可提示「有新版本」 +let installReported = false; +async function reportInstall(force) { + if (installReported && !force) return; + try { + const machine = await getMachineId(); + const ver = chrome.runtime.getManifest().version; + const ua = (typeof navigator !== "undefined" && navigator.userAgent) || ""; + const m = ua.match(/(Chrome|Edg)\/[\d.]+/); + const r = await authFetch("/public/installations/check-update", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Api-Key": CARD_API_KEY }, + body: JSON.stringify({ + project_id: PROJECT_ID, + machine_id: machine, + client_version: ver, + device_info: (m ? m[0] : "chrome-extension") + }) + }, 8000); + if (r && r.code === 200 && r.data) { + installReported = true; + await chrome.storage.local.set({ + updateAvailable: !!r.data.update_available, + latestVersion: String(r.data.latest_version || "") + }); + } + } catch (e) { /* 统计失败不影响任何功能,也不阻塞用户 */ } +} +if (chrome.runtime.onInstalled) { + chrome.runtime.onInstalled.addListener(() => reportInstall(true)); +} +reportInstall(false); // SW 启动即登记一次(同一浏览器只记一条) + // ============ 授权(CardAuth: auth.88531.cn,项目=B站评论插件) ============ const AUTH_BASE = "https://auth.88531.cn/api"; @@ -517,7 +552,12 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { const license = await checkLicense(); let announcements = []; try { announcements = await getAnnouncements(); } catch (e) { announcements = []; } - sendResponse({ ok: true, license: license, announcements: announcements }); + const st = await chrome.storage.local.get(["updateAvailable", "latestVersion"]); + reportInstall(false); // 面板打开时补一次登记(SW 冷启动后 onInstalled 不会再触发) + sendResponse({ + ok: true, license: license, announcements: announcements, + update: { available: !!st.updateAvailable, latest: String(st.latestVersion || ""), current: chrome.runtime.getManifest().version } + }); })(); return true; } diff --git a/content.js b/content.js index d176ae1..04c92c6 100644 --- a/content.js +++ b/content.js @@ -136,6 +136,194 @@ } 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) { @@ -686,6 +874,55 @@ } } + // ============ 私信执行步:对当前视频的评论区用户发送(独立频率、永久按 mid 去重) ============ + // B站风控:21047 同一陌生人未回复/未关注前最多只能发 1 条;21046 频繁发会被限 24 小时 + // 所以默认间隔 2~4 分钟、每视频最多 3 人、同一 mid 永不重复 + const DM_GAP_MIN = 2 * 60 * 1000, DM_GAP_MAX = 4 * 60 * 1000; + + 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 text = video.dmText || pickTemplate(video.title, cfg.keyword); + const v = verifyMessage(text); + if (!v.ok) { step("❌ 私信内容不合规:" + v.reason, "fail"); return; } + step("私信 @" + 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; @@ -713,6 +950,11 @@ await recordOne(task, video, text, false); } + // 私信(可选,评论后对本视频评论区用户发送;风控更严,间隔更长) + if (cfg.dmEnabled && !stopRequested()) { + await runDmStep(task, video); + } + task.index += 1; if (task.index < task.queue.length && !stopRequested()) { @@ -726,7 +968,7 @@ location.href = next.url; return; // 本页生命周期结束 } - finish(task, true, "执行完成:成功 " + (task.sent || 0) + " / 失败 " + (task.failed || 0)); + finish(task, true, "执行完成:评论成功 " + (task.sent || 0) + " / 失败 " + (task.failed || 0) + (task.dmSent ? ",私信 " + task.dmSent + " 条" : "")); } catch (e) { finish(task, false, "执行出错:" + (e && e.message || e)); } diff --git a/manifest.json b/manifest.json index 0934824..6793cbe 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "B站评论助手", - "version": "1.6.6", + "version": "1.7.0", "description": "B站网页版关键词评论助手:按搜索关键词找视频,默认模拟真人(打开视频页、滚到评论区、逐字输入、点发布),也可快速API直发;支持下载当前页视频;带防重复记录、可中断执行与页面实时进度浮层。", "permissions": ["storage", "tabs", "scripting", "sidePanel", "downloads", "declarativeNetRequest"], "host_permissions": [ diff --git a/sidepanel.html b/sidepanel.html index a7a2245..b7734ac 100644 --- a/sidepanel.html +++ b/sidepanel.html @@ -54,6 +54,7 @@ 购买授权 →
+ +
+ + +
+