// ==UserScript== // @name B站关注UP主更新统计 v1.1 // @namespace https://docs.scriptcat.org/ // @version 1.1.0 // @description 只读统计:遍历你的完整关注列表,按最近一次公开投稿距今的年限分档(1年/2年/3年/5年以上),找出长期不更新的 UP 主。不做任何取关操作。 // @author ROY + WORKBUDDY // @match https://space.bilibili.com/*/relation/follow* // @grant none // @noframes // @license MIT // ==/UserScript== (function () { 'use strict'; /** * B站关注UP主更新统计 v1.0 * * 工作流程: * 1. 从当前页 URL(或 nav 接口)拿到自己的 mid; * 2. /x/relation/followings 分页拉取完整关注列表(最多 1000 位,接口限制); * 3. /x/space/wbi/arc/search(WBI 签名)逐个查询 UP 最近一个公开视频的发布时间; * 4. 按距今分档:活跃(<1年) / 1~2年 / 2~3年 / 3~5年 / 5年以上 / 无公开视频; * 汇总展示 ≥1年、≥2年、≥3年、≥5年 四个累计维度的人数和名单。 * * 边界: * - 全程只读查询,不做取关、不写入任何数据; * - 仅统计公开投稿视频,动态/直播/充电专属内容不计入“更新”; * - 触发风控或连续失败会自动提前停止,已统计部分保留。 * * v1.1 风控对抗与可观测性: * 1. 正确识别 HTTP 412/403/429 与 code -352/-412/-799 为风控,不再误判为普通失败; * 2. 单个 UP 查询失败自动重试一次(间隔 2~3.5s),排除瞬时抖动; * 3. 自适应降速:失败后查询间隔自动放大(上限 2s),成功后恢复; * 4. 批次休息:每连续查询 20 位强制停 3~5 秒; * 5. 失败 UP 记录具体原因并在面板展示;新增「接口自检」按钮用于排查。 */ const PANEL_ID = 'tm-bili-upstat-panel-v10'; const FLOAT_ID = 'tm-bili-upstat-float-v10'; const runtime = { running: false, stopRequested: false, statusTimer: null, total: 0, checked: 0, failed: [], buckets: null, // 见 emptyBuckets() }; function emptyBuckets() { return { b1: [], // 1~2 年未更新 b2: [], // 2~3 年 b3: [], // 3~5 年 b5: [], // 5 年以上 novideo: [], // 无公开视频 active: 0, // 1 年内有更新(只计数,不列名单) }; } const BUCKET_META = [ { key: 'b1', label: '1~2 年未更新', color: '#e6a23c' }, { key: 'b2', label: '2~3 年未更新', color: '#f7892b' }, { key: 'b3', label: '3~5 年未更新', color: '#f85a54' }, { key: 'b5', label: '5 年以上未更新', color: '#c02428' }, { key: 'novideo', label: '无公开视频(从未投稿或已清空)', color: '#909399' }, ]; function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function clampNumber(value, fallback, min, max) { const n = Number(value); if (!Number.isFinite(n)) return fallback; return Math.min(max, Math.max(min, n)); } function randomDelay(min, max) { const lo = Math.min(min, max); const hi = Math.max(min, max); return Math.floor(lo + Math.random() * Math.max(0, hi - lo)); } /* ================================================================ * MD5(WBI 签名 w_rid 需要,浏览器 SubtleCrypto 不支持 MD5) * ================================================================ */ function md5(string) { function rotateLeft(lValue, iShiftBits) { return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits)); } function addUnsigned(lX, lY) { const lX4 = lX & 0x40000000; const lY4 = lY & 0x40000000; const lX8 = lX & 0x80000000; const lY8 = lY & 0x80000000; const lResult = (lX & 0x3FFFFFFF) + (lY & 0x3FFFFFFF); if (lX4 & lY4) return lResult ^ 0x80000000 ^ lX8 ^ lY8; if (lX4 | lY4) { if (lResult & 0x40000000) return lResult ^ 0xC0000000 ^ lX8 ^ lY8; return lResult ^ 0x40000000 ^ lX8 ^ lY8; } return lResult ^ lX8 ^ lY8; } function fF(x, y, z) { return (x & y) | (~x & z); } function fG(x, y, z) { return (x & z) | (y & ~z); } function fH(x, y, z) { return x ^ y ^ z; } function fI(x, y, z) { return y ^ (x | ~z); } function FF(a, b, c, d, x, s, ac) { a = addUnsigned(a, addUnsigned(addUnsigned(fF(b, c, d), x), ac)); return addUnsigned(rotateLeft(a, s), b); } function GG(a, b, c, d, x, s, ac) { a = addUnsigned(a, addUnsigned(addUnsigned(fG(b, c, d), x), ac)); return addUnsigned(rotateLeft(a, s), b); } function HH(a, b, c, d, x, s, ac) { a = addUnsigned(a, addUnsigned(addUnsigned(fH(b, c, d), x), ac)); return addUnsigned(rotateLeft(a, s), b); } function II(a, b, c, d, x, s, ac) { a = addUnsigned(a, addUnsigned(addUnsigned(fI(b, c, d), x), ac)); return addUnsigned(rotateLeft(a, s), b); } function convertToWordArray(str) { let lWordCount; const lMessageLength = str.length; const lNumberOfWordsTemp1 = lMessageLength + 8; const lNumberOfWordsTemp2 = (lNumberOfWordsTemp1 - (lNumberOfWordsTemp1 % 64)) / 64; const lNumberOfWords = (lNumberOfWordsTemp2 + 1) * 16; const lWordArray = new Array(lNumberOfWords - 1); let lBytePosition = 0; let lByteCount = 0; while (lByteCount < lMessageLength) { lWordCount = (lByteCount - (lByteCount % 4)) / 4; lBytePosition = (lByteCount % 4) * 8; lWordArray[lWordCount] = lWordArray[lWordCount] | (str.charCodeAt(lByteCount) << lBytePosition); lByteCount += 1; } lWordCount = (lByteCount - (lByteCount % 4)) / 4; lBytePosition = (lByteCount % 4) * 8; lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80 << lBytePosition); lWordArray[lNumberOfWords - 2] = lMessageLength << 3; lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29; return lWordArray; } function wordToHex(lValue) { let wordToHexValue = ''; for (let lCount = 0; lCount <= 3; lCount += 1) { const lByte = (lValue >>> (lCount * 8)) & 255; const temp = '0' + lByte.toString(16); wordToHexValue += temp.substr(temp.length - 2, 2); } return wordToHexValue; } function utf8Encode(str) { return unescape(encodeURIComponent(str)); } const S11 = 7, S12 = 12, S13 = 17, S14 = 22; const S21 = 5, S22 = 9, S23 = 14, S24 = 20; const S31 = 4, S32 = 11, S33 = 16, S34 = 23; const S41 = 6, S42 = 10, S43 = 15, S44 = 21; const x = convertToWordArray(utf8Encode(string)); let a = 0x67452301; let b = 0xEFCDAB89; let c = 0x98BADCFE; let d = 0x10325476; for (let k = 0; k < x.length; k += 16) { const AA = a, BB = b, CC = c, DD = d; a = FF(a, b, c, d, x[k + 0], S11, 0xD76AA478); d = FF(d, a, b, c, x[k + 1], S12, 0xE8C7B756); c = FF(c, d, a, b, x[k + 2], S13, 0x242070DB); b = FF(b, c, d, a, x[k + 3], S14, 0xC1BDCEEE); a = FF(a, b, c, d, x[k + 4], S11, 0xF57C0FAF); d = FF(d, a, b, c, x[k + 5], S12, 0x4787C62A); c = FF(c, d, a, b, x[k + 6], S13, 0xA8304613); b = FF(b, c, d, a, x[k + 7], S14, 0xFD469501); a = FF(a, b, c, d, x[k + 8], S11, 0x698098D8); d = FF(d, a, b, c, x[k + 9], S12, 0x8B44F7AF); c = FF(c, d, a, b, x[k + 10], S13, 0xFFFF5BB1); b = FF(b, c, d, a, x[k + 11], S14, 0x895CD7BE); a = FF(a, b, c, d, x[k + 12], S11, 0x6B901122); d = FF(d, a, b, c, x[k + 13], S12, 0xFD987193); c = FF(c, d, a, b, x[k + 14], S13, 0xA679438E); b = FF(b, c, d, a, x[k + 15], S14, 0x49B40821); a = GG(a, b, c, d, x[k + 1], S21, 0xF61E2562); d = GG(d, a, b, c, x[k + 6], S22, 0xC040B340); c = GG(c, d, a, b, x[k + 11], S23, 0x265E5A51); b = GG(b, c, d, a, x[k + 0], S24, 0xE9B6C7AA); a = GG(a, b, c, d, x[k + 5], S21, 0xD62F105D); d = GG(d, a, b, c, x[k + 10], S22, 0x2441453); c = GG(c, d, a, b, x[k + 15], S23, 0xD8A1E681); b = GG(b, c, d, a, x[k + 4], S24, 0xE7D3FBC8); a = GG(a, b, c, d, x[k + 9], S21, 0x21E1CDE6); d = GG(d, a, b, c, x[k + 14], S22, 0xC33707D6); c = GG(c, d, a, b, x[k + 3], S23, 0xF4D50D87); b = GG(b, c, d, a, x[k + 8], S24, 0x455A14ED); a = GG(a, b, c, d, x[k + 13], S21, 0xA9E3E905); d = GG(d, a, b, c, x[k + 2], S22, 0xFCEFA3F8); c = GG(c, d, a, b, x[k + 7], S23, 0x676F02D9); b = GG(b, c, d, a, x[k + 12], S24, 0x8D2A4C8A); a = HH(a, b, c, d, x[k + 5], S31, 0xFFFA3942); d = HH(d, a, b, c, x[k + 8], S32, 0x8771F681); c = HH(c, d, a, b, x[k + 11], S33, 0x6D9D6122); b = HH(b, c, d, a, x[k + 14], S34, 0xFDE5380C); a = HH(a, b, c, d, x[k + 1], S31, 0xA4BEEA44); d = HH(d, a, b, c, x[k + 4], S32, 0x4BDECFA9); c = HH(c, d, a, b, x[k + 7], S33, 0xF6BB4B60); b = HH(b, c, d, a, x[k + 10], S34, 0xBEBFBC70); a = HH(a, b, c, d, x[k + 13], S31, 0x289B7EC6); d = HH(d, a, b, c, x[k + 0], S32, 0xEAA127FA); c = HH(c, d, a, b, x[k + 3], S33, 0xD4EF3085); b = HH(b, c, d, a, x[k + 6], S34, 0x4881D05); a = HH(a, b, c, d, x[k + 9], S31, 0xD9D4D039); d = HH(d, a, b, c, x[k + 12], S32, 0xE6DB99E5); c = HH(c, d, a, b, x[k + 15], S33, 0x1FA27CF8); b = HH(b, c, d, a, x[k + 2], S34, 0xC4AC5665); a = II(a, b, c, d, x[k + 0], S41, 0xF4292244); d = II(d, a, b, c, x[k + 7], S42, 0x432AFF97); c = II(c, d, a, b, x[k + 14], S43, 0xAB9423A7); b = II(b, c, d, a, x[k + 5], S44, 0xFC93A039); a = II(a, b, c, d, x[k + 12], S41, 0x655B59C3); d = II(d, a, b, c, x[k + 3], S42, 0x8F0CCC92); c = II(c, d, a, b, x[k + 10], S43, 0xFFEFF47D); b = II(b, c, d, a, x[k + 1], S44, 0x85845DD1); a = II(a, b, c, d, x[k + 8], S41, 0x6FA87E4F); d = II(d, a, b, c, x[k + 15], S42, 0xFE2CE6E0); c = II(c, d, a, b, x[k + 6], S43, 0xA3014314); b = II(b, c, d, a, x[k + 13], S44, 0x4E0811A1); a = II(a, b, c, d, x[k + 4], S41, 0xF7537E82); d = II(d, a, b, c, x[k + 11], S42, 0xBD3AF235); c = II(c, d, a, b, x[k + 2], S43, 0x2AD7D2BB); b = II(b, c, d, a, x[k + 9], S44, 0xEB86D391); a = addUnsigned(a, AA); b = addUnsigned(b, BB); c = addUnsigned(c, CC); d = addUnsigned(d, DD); } return (wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d)).toLowerCase(); } /* ================================================================ * WBI 签名 + 数据查询 * ================================================================ */ const WBI_MIXIN_TAB = [ 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, ]; let wbiMixinKeyCache = ''; async function fetchApiJson(url) { const resp = await fetch(url, { credentials: 'include' }); let json = null; try { json = await resp.json(); } catch (_) { /* 412 等响应可能没有 JSON body */ } if (!resp.ok) { const err = new Error(`HTTP ${resp.status}${json?.message ? `:${json.message}` : ''}`); err.httpStatus = resp.status; if ([403, 412, 429].includes(resp.status)) err.risk = true; if (json && typeof json.code === 'number') err.code = json.code; throw err; } if (json === null) throw new Error('响应不是有效 JSON'); return json; } async function getWbiMixinKey() { if (wbiMixinKeyCache) return wbiMixinKeyCache; const nav = await fetchApiJson('https://api.bilibili.com/x/web-interface/nav'); const imgUrl = nav?.data?.wbi_img?.img_url || ''; const subUrl = nav?.data?.wbi_img?.sub_url || ''; const imgKey = imgUrl.split('/').pop().split('.')[0]; const subKey = subUrl.split('/').pop().split('.')[0]; const raw = imgKey + subKey; let mixin = ''; for (const idx of WBI_MIXIN_TAB) mixin += raw[idx]; wbiMixinKeyCache = mixin.slice(0, 32); return wbiMixinKeyCache; } async function buildWbiQuery(params) { const mixinKey = await getWbiMixinKey(); const full = Object.assign({}, params, { wts: Math.floor(Date.now() / 1000) }); const query = Object.keys(full) .sort() .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(String(full[k]).replace(/[!'()*]/g, ''))}`) .join('&'); return `${query}&w_rid=${md5(query + mixinKey)}`; } function getPageMid() { const m = location.pathname.match(/^\/(\d+)\/relation\/follow/); return m ? m[1] : ''; } async function getScanVmid() { const fromUrl = getPageMid(); if (fromUrl) return fromUrl; const nav = await fetchApiJson('https://api.bilibili.com/x/web-interface/nav'); if (nav.code === 0 && nav.data?.mid) return String(nav.data.mid); throw new Error('无法确定账号 mid,请确认已登录 B 站。'); } async function fetchAllFollowings(vmid, onProgress) { const list = []; const seen = new Set(); const ps = 50; for (let pn = 1; pn <= 20; pn += 1) { if (runtime.stopRequested) break; const url = `https://api.bilibili.com/x/relation/followings?vmid=${vmid}&pn=${pn}&ps=${ps}&order_type=attention`; const json = await fetchApiJson(url); if (json.code === -101) throw new Error('未登录或登录已失效,请先登录 B 站后再统计。'); if (json.code !== 0) throw new Error(`获取关注列表失败:${json.message || `code ${json.code}`}`); const items = json.data?.list || []; for (const it of items) { if (it?.mid && !seen.has(it.mid)) { seen.add(it.mid); list.push({ mid: it.mid, uname: it.uname || `UID:${it.mid}` }); } } const total = json.data?.total || 0; onProgress?.(list.length, total); if (items.length < ps || list.length >= total) break; await sleep(randomDelay(200, 400)); } return list; } async function fetchLatestVideoInfo(mid) { const query = await buildWbiQuery({ mid, ps: 1, pn: 1, order: 'pubdate' }); const json = await fetchApiJson(`https://api.bilibili.com/x/space/wbi/arc/search?${query}`); if (json.code === 0) { const vlist = json.data?.list?.vlist || []; if (vlist.length === 0) return { ok: true, hasVideo: false }; return { ok: true, hasVideo: true, created: Number(vlist[0].created) * 1000, title: vlist[0].title || '', }; } if ([-352, -412, -799].includes(json.code)) { return { ok: false, risk: true, message: `触发风控 code ${json.code}` }; } return { ok: false, message: json.message || `code ${json.code}` }; } /* ================================================================ * 分档 + 展示 * ================================================================ */ const YEAR_MS = 365.25 * 86400000; function classify(created) { const years = (Date.now() - created) / YEAR_MS; if (years < 1) return 'active'; if (years < 2) return 'b1'; if (years < 3) return 'b2'; if (years < 5) return 'b3'; return 'b5'; } function formatDate(ts) { const d = new Date(ts); const mm = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); return `${d.getFullYear()}-${mm}-${dd}`; } function formatYears(ts) { return `${((Date.now() - ts) / YEAR_MS).toFixed(1)} 年`; } function ensureStyle() { if (document.querySelector('#tm-bili-upstat-style-v10')) return; const style = document.createElement('style'); style.id = 'tm-bili-upstat-style-v10'; style.textContent = ` #${PANEL_ID} { box-sizing: border-box; width: 100%; margin: 12px 0; padding: 12px; border: 1px solid #e3e5e7; border-radius: 10px; background: #ffffff; color: #18191c; font-size: 13px; line-height: 1.6; font-family: Arial, "Microsoft YaHei", sans-serif; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06); } #${PANEL_ID}.tm-fixed-fallback { position: fixed; right: 18px; bottom: 18px; width: 420px; max-height: 80vh; overflow: auto; z-index: 999998; } #${PANEL_ID} * { box-sizing: border-box; } #${PANEL_ID} .tm-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; } #${PANEL_ID} .tm-title { font-weight: 700; font-size: 14px; margin-bottom: 4px; } #${PANEL_ID} .tm-desc { color: #61666d; font-size: 12px; } #${PANEL_ID} button { border: 1px solid #c9ccd0; border-radius: 6px; background: #fff; color: #18191c; padding: 6px 10px; cursor: pointer; font-size: 13px; } #${PANEL_ID} button:hover { background: #f6f7f8; } #${PANEL_ID} button.tm-primary { border-color: #00aeec; background: #00aeec; color: #ffffff; } #${PANEL_ID} button.tm-stop { border-color: #f85a54; background: #fff0ef; color: #d93832; } #${PANEL_ID} button[disabled] { opacity: 0.55; cursor: not-allowed; } #${PANEL_ID} input[type="number"] { width: 70px; padding: 4px 6px; border: 1px solid #c9ccd0; border-radius: 5px; } #${PANEL_ID} label { display: inline-flex; align-items: center; gap: 4px; color: #61666d; font-size: 12px; } #${PANEL_ID} .tm-status-line { margin-top: 8px; padding: 8px; border-radius: 6px; background: #f6f7f8; color: #61666d; white-space: pre-wrap; } #${PANEL_ID} .tm-summary { margin-top: 10px; display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 6px; } #${PANEL_ID} .tm-summary-card { border: 1px solid #ecedf0; border-radius: 8px; padding: 8px; text-align: center; background: #fafbfc; } #${PANEL_ID} .tm-summary-num { font-size: 20px; font-weight: 700; } #${PANEL_ID} .tm-summary-label { font-size: 12px; color: #61666d; } #${PANEL_ID} details.tm-bucket { margin-top: 8px; border: 1px solid #ecedf0; border-radius: 8px; overflow: hidden; } #${PANEL_ID} details.tm-bucket > summary { cursor: pointer; padding: 8px 10px; font-weight: 600; background: #fafbfc; user-select: none; } #${PANEL_ID} .tm-bucket-list { max-height: 220px; overflow: auto; padding: 4px 10px 8px 10px; } #${PANEL_ID} .tm-up-row { padding: 3px 0; border-bottom: 1px dashed #ecedf0; font-size: 12.5px; } #${PANEL_ID} .tm-up-row a { color: #00aeec; text-decoration: none; font-weight: 600; } #${PANEL_ID} .tm-up-row a:hover { text-decoration: underline; } #${PANEL_ID} .tm-up-info { color: #9499a0; font-size: 12px; } #${FLOAT_ID} { position: fixed; left: 50%; top: 90px; transform: translateX(-50%); min-width: 320px; max-width: 620px; z-index: 999999; padding: 12px 16px; border-radius: 10px; background: rgba(24, 25, 28, 0.92); color: #fff; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); font-size: 13.5px; line-height: 1.7; white-space: pre-wrap; font-family: Arial, "Microsoft YaHei", sans-serif; } `; document.head.appendChild(style); } function setStatusLine(text) { const el = document.querySelector('#tm-upstat-status'); if (el) el.textContent = text; } function showFloat(message, autoCloseMs = 0) { let float = document.querySelector(`#${FLOAT_ID}`); if (!float) { float = document.createElement('div'); float.id = FLOAT_ID; document.body.appendChild(float); } float.textContent = message; float.style.display = 'block'; setStatusLine(message); if (runtime.statusTimer) { clearTimeout(runtime.statusTimer); runtime.statusTimer = null; } if (autoCloseMs > 0) { runtime.statusTimer = setTimeout(() => { const f = document.querySelector(`#${FLOAT_ID}`); if (f) f.style.display = 'none'; }, autoCloseMs); } } function makeUpRow(up) { const row = document.createElement('div'); row.className = 'tm-up-row'; const link = document.createElement('a'); link.href = `https://space.bilibili.com/${up.mid}`; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.textContent = up.uname; row.appendChild(link); const info = document.createElement('span'); info.className = 'tm-up-info'; info.textContent = up.hasVideo ? ` 最后更新 ${formatDate(up.created)}(${formatYears(up.created)})` : ' 无公开视频'; row.appendChild(info); return row; } function renderResults(final) { const box = document.querySelector('#tm-upstat-result'); if (!box || !runtime.buckets) return; box.innerHTML = ''; const b = runtime.buckets; const ge1 = b.b1.length + b.b2.length + b.b3.length + b.b5.length; const ge2 = b.b2.length + b.b3.length + b.b5.length; const ge3 = b.b3.length + b.b5.length; const ge5 = b.b5.length; // 四个累计维度的统计卡片 const summary = document.createElement('div'); summary.className = 'tm-summary'; const cards = [ { num: runtime.checked, label: final ? '已统计 UP 数' : '已统计 / 总数', color: '#18191c', suffix: final ? '' : ` / ${runtime.total}` }, { num: ge1, label: '1 年以上未更新', color: '#e6a23c' }, { num: ge2, label: '2 年以上未更新', color: '#f7892b' }, { num: ge3, label: '3 年以上未更新', color: '#f85a54' }, { num: ge5, label: '5 年以上未更新', color: '#c02428' }, { num: b.novideo.length, label: '无公开视频', color: '#909399' }, ]; for (const c of cards) { const card = document.createElement('div'); card.className = 'tm-summary-card'; const num = document.createElement('div'); num.className = 'tm-summary-num'; num.style.color = c.color; num.textContent = String(c.num) + (c.suffix || ''); const label = document.createElement('div'); label.className = 'tm-summary-label'; label.textContent = c.label; card.appendChild(num); card.appendChild(label); summary.appendChild(card); } box.appendChild(summary); // 分档名单(可折叠) for (const meta of BUCKET_META) { const list = b[meta.key]; const details = document.createElement('details'); details.className = 'tm-bucket'; if (final && list.length > 0 && list.length <= 30) details.open = true; const sum = document.createElement('summary'); sum.textContent = `${meta.label}:${list.length} 位`; sum.style.color = meta.color; details.appendChild(sum); const wrap = document.createElement('div'); wrap.className = 'tm-bucket-list'; if (!list.length) { const empty = document.createElement('div'); empty.className = 'tm-up-info'; empty.textContent = '(无)'; wrap.appendChild(empty); } else { for (const up of list) wrap.appendChild(makeUpRow(up)); } details.appendChild(wrap); box.appendChild(details); } if (runtime.failed.length) { const failBox = document.createElement('div'); failBox.className = 'tm-status-line'; failBox.textContent = `查询失败 ${runtime.failed.length} 位(已跳过):\n` + runtime.failed.slice(0, 10).map(f => `${f.uname}(${f.reason || '未知原因'})`).join('\n') + (runtime.failed.length > 10 ? `\n… 等共 ${runtime.failed.length} 位` : ''); box.appendChild(failBox); } if (final) { const tip = document.createElement('div'); tip.className = 'tm-status-line'; tip.style.marginTop = '8px'; tip.textContent = '提示:本脚本只做统计,不会取关。点 UP 名字可打开空间核对;确认要清理时,可在空间里点"已关注"取关,或回关注页搜索后处理。'; box.appendChild(tip); } } function updateButtons() { const startBtn = document.querySelector('#tm-upstat-start'); const stopBtn = document.querySelector('#tm-upstat-stop'); if (startBtn) startBtn.disabled = runtime.running; if (stopBtn) stopBtn.disabled = !runtime.running; } /* ================================================================ * 主流程 * ================================================================ */ async function runStat() { if (runtime.running) return; const delayMs = clampNumber(document.querySelector('#tm-upstat-delay')?.value, 300, 0, 5000); runtime.running = true; runtime.stopRequested = false; runtime.total = 0; runtime.checked = 0; runtime.failed = []; runtime.buckets = emptyBuckets(); updateButtons(); renderResults(false); try { showFloat('开始统计:正在确认登录状态并拉取关注列表…(全程只读,不会取关)', 3000); const vmid = await getScanVmid(); const followings = await fetchAllFollowings(vmid, (got, total) => { showFloat(`正在拉取关注列表:${got} / ${total || '?'}`, 0); }); if (runtime.stopRequested) throw new Error('__stopped__'); if (!followings.length) { showFloat('关注列表为空,统计结束。', 5000); return; } runtime.total = followings.length; let errorStreak = 0; let endedEarly = ''; let currentDelay = delayMs; let sinceBatchPause = 0; // 单个 UP 查询:非风控失败时等 2~3.5s 自动重试一次,排除瞬时抖动 const queryWithRetry = async (mid) => { try { return await fetchLatestVideoInfo(mid); } catch (err) { if (err?.risk) throw err; await sleep(randomDelay(2000, 3500)); return await fetchLatestVideoInfo(mid); } }; for (let i = 0; i < followings.length; i += 1) { if (runtime.stopRequested) { endedEarly = '已手动停止,以下为已统计部分的结果。'; break; } const up = followings[i]; try { const info = await queryWithRetry(up.mid); runtime.checked += 1; if (!info.ok) { errorStreak += 1; if (info.risk) { runtime.failed.push({ ...up, reason: info.message }); endedEarly = `${info.message}(B 站风控拦截)。已提前停止,建议把查询间隔调大到 500ms 以上、过 10 分钟再试。`; break; } runtime.failed.push({ ...up, reason: info.message || '未知错误' }); } else { errorStreak = 0; currentDelay = delayMs; if (!info.hasVideo) { runtime.buckets.novideo.push({ ...up, hasVideo: false, created: 0 }); } else { const bucket = classify(info.created); if (bucket === 'active') { runtime.buckets.active += 1; } else { runtime.buckets[bucket].push({ ...up, hasVideo: true, created: info.created }); runtime.buckets[bucket].sort((x, y) => x.created - y.created); } } } } catch (err) { runtime.checked += 1; errorStreak += 1; const reason = err?.risk ? `${err.message}(风控拦截)` : (err?.message || '网络异常'); runtime.failed.push({ ...up, reason }); if (err?.risk) { endedEarly = `${reason}。已提前停止,建议把查询间隔调大到 500ms 以上、过 10 分钟再试。`; break; } } if (errorStreak >= 5) { const lastReason = runtime.failed.slice(-5).map(f => f.reason).find(Boolean) || '未知'; endedEarly = `连续多次查询失败(最近原因:${lastReason}),已提前停止。`; break; } // 自适应降速:一旦失败就把节奏放慢,成功后恢复 if (errorStreak > 0) { currentDelay = Math.min(2000, Math.max(currentDelay * 1.6, 400)); } if (i % 5 === 0 || i === followings.length - 1) { const stale = runtime.buckets.b1.length + runtime.buckets.b2.length + runtime.buckets.b3.length + runtime.buckets.b5.length + runtime.buckets.novideo.length; showFloat(`统计中:${i + 1} / ${followings.length},1 年以上未更新 ${stale} 位`, 0); renderResults(false); } // 批次休息:每连续查 20 位强制停 3~5 秒 sinceBatchPause += 1; if (sinceBatchPause >= 20) { sinceBatchPause = 0; showFloat(`已连续查询 20 位,休息 3~5 秒防风控…(${i + 1} / ${followings.length})`, 0); await sleep(randomDelay(3000, 5000)); } else if (currentDelay > 0) { await sleep(randomDelay(currentDelay * 0.7, currentDelay * 1.3)); } } renderResults(true); const b = runtime.buckets; const ge1 = b.b1.length + b.b2.length + b.b3.length + b.b5.length; showFloat( (endedEarly || '统计完成。') + `\n共 ${runtime.checked} 位:1 年以上未更新 ${ge1} 位(含 5 年以上 ${b.b5.length} 位),无公开视频 ${b.novideo.length} 位。` + `\n分档名单见页面面板,点名字可打开空间核对。`, 0 ); } catch (err) { if (err?.message === '__stopped__') { renderResults(true); showFloat('已手动停止。', 4000); } else { showFloat(`统计异常:${err?.message || err}`, 10000); } } finally { runtime.running = false; updateButtons(); } } async function copyResults() { const b = runtime.buckets; if (!b || runtime.checked === 0) { showFloat('还没有统计结果,请先点击"开始统计"。', 3000); return; } const fmtUp = up => up.hasVideo ? `${up.uname} (UID:${up.mid}) 最后更新 ${formatDate(up.created)} https://space.bilibili.com/${up.mid}` : `${up.uname} (UID:${up.mid}) 无公开视频 https://space.bilibili.com/${up.mid}`; const ge1 = b.b1.length + b.b2.length + b.b3.length + b.b5.length; const ge2 = b.b2.length + b.b3.length + b.b5.length; const ge3 = b.b3.length + b.b5.length; const ge5 = b.b5.length; let text = `【B站关注 UP 主更新统计】统计时间 ${formatDate(Date.now())},共 ${runtime.checked} 位\n`; text += `1 年以上未更新:${ge1} 位 | 2 年以上:${ge2} 位 | 3 年以上:${ge3} 位 | 5 年以上:${ge5} 位 | 无公开视频:${b.novideo.length} 位\n`; for (const meta of BUCKET_META) { const list = b[meta.key]; text += `\n■ ${meta.label}(${list.length} 位)\n`; text += list.length ? list.map(fmtUp).join('\n') + '\n' : '(无)\n'; } try { await navigator.clipboard.writeText(text); showFloat('统计结果已复制到剪贴板。', 4000); } catch (_) { showFloat('复制失败,请手动选择结果文本复制。', 5000); } } // 接口自检:逐步验证 登录态 → WBI 密钥 → 投稿查询接口,把原始返回展示出来用于排查 async function selfTest() { if (runtime.running) { showFloat('统计运行中,请先停止再自检。', 3000); return; } try { showFloat('自检 1/3:确认登录状态…', 0); const vmid = await getScanVmid(); showFloat(`自检 2/3:登录 OK(mid=${vmid}),正在获取 WBI 密钥并签名…`, 0); const mixinKey = await getWbiMixinKey(); showFloat(`自检 3/3:WBI 密钥 OK(${mixinKey.slice(0, 8)}…),正在查询你自己的最近投稿…`, 0); const query = await buildWbiQuery({ mid: vmid, ps: 1, pn: 1, order: 'pubdate' }); const resp = await fetch(`https://api.bilibili.com/x/space/wbi/arc/search?${query}`, { credentials: 'include' }); const text = await resp.text(); let json = null; try { json = JSON.parse(text); } catch (_) { /* 保留原始文本 */ } const codeText = json ? `code=${json.code},message=${json.message || '(无)'}` : `非 JSON 响应:${text.slice(0, 80)}`; let verdict; if (resp.ok && json?.code === 0) { verdict = `接口正常!你的公开投稿数:${json.data?.page?.count ?? '?'}。可以点开始统计。`; } else if (resp.status === 412) { verdict = 'HTTP 412 = 请求被风控拦截。请把间隔调大到 500ms+,过 10~30 分钟再试;仍不行就换个网络环境。'; } else if (json?.code === -352 || json?.code === -799) { verdict = `code ${json.code} = 风控/请求过频。请放慢节奏稍后重试。`; } else if (json?.code === -101) { verdict = 'code -101 = 未登录。请先在 B 站登录。'; } else { verdict = '接口返回异常,可把这段自检信息发出来排查。'; } showFloat(`自检结果:HTTP ${resp.status},${codeText}\n${verdict}`, 0); } catch (err) { showFloat(`自检异常:${err?.message || err}`, 0); } } /* ================================================================ * 面板挂载 * ================================================================ */ function createPanel() { if (document.querySelector(`#${PANEL_ID}`)) return; ensureStyle(); const panel = document.createElement('div'); panel.id = PANEL_ID; panel.innerHTML = `