v1.7.0: 新增私信评论区用户(wbi签名+永久按mid去重+2~4分钟间隔)、安装统计上报、有新版本提示

This commit is contained in:
personal82555 committed 2026-10-13 18:38:58 +08:00
1 parent 38b90c1448
commit 0cce6cc2bb
5 files changed
+344 -9

No files matched your search

+243 -1
View File
@@ -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));
}