// ==UserScript== // @name B站UP主更新频率计算 // @namespace https://github.com/dsh/up-freq // @version 1.0.0 // @description 在B站UP主空间/主页/投稿页右侧边栏嵌入统计面板,拦截动态与投稿接口响应,根据 pub_time/pub_ts 与 created 计算该UP主的更新频率:平均/中位间隔、每周更新次数、更新规律性、常发时段与下次更新预测。 // @author Ace // @match https://space.bilibili.com/* // @run-at document-start // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant unsafeWindow // @noframes // @license MIT // ==/UserScript== (function () { 'use strict'; /* ========================================================================== * 配置 * ========================================================================== */ // 数据来源标记:同一个 mid 可能同时从动态页和投稿页拿到数据 var SRC_DYNAMIC = 'DYNAMIC_TYPE_AV'; // 动态接口(响应.json),字段 pub_time / pub_ts var SRC_VIDEO = 'VIDEO'; // 投稿接口(响应2.json),字段 created var CFG = { // 参与统计的动态类型。默认只统计视频稿件。 // 想统计专栏/图文/纯文字,可加 'DYNAMIC_TYPE_ARTICLE' / 'DYNAMIC_TYPE_DRAW' / 'DYNAMIC_TYPE_WORD' ONLY_TYPES: [SRC_DYNAMIC, SRC_VIDEO], // 置顶动态是否计入(置顶动态时间通常很老,会让间隔严重失真,默认剔除) INCLUDE_PINNED: false, // 单条间隔超过该天数视为“断更”,不参与中位数/规律性计算(0 表示不做上限过滤) MAX_GAP_DAYS: 180, // 参与统计的动态条数上限(按时间取最近 N 条) MAX_SAMPLES: 200, // 每个 UP 独立缓存的键前缀 CACHE_PREFIX: 'bili_up_freq_cache_v1_', // 缓存有效期(毫秒) CACHE_TTL: 1000 * 60 * 60 * 24 * 7, // 调试用:固定“当前时间”,便于测试相对时间解析 DEBUG_NOW: null }; // 面板用的主题色:优先用 B 站/BewlyCat 的 CSS 变量,缺失时按主题回退 var THEME = { bg: 'var(--bg2, #ffffff)', bg2: 'var(--bg3, #f6f7f8)', line: 'var(--line_regular, #e3e5e7)', text: 'var(--text1, #18191c)', sub: 'var(--text3, #9499a0)', accent: 'var(--brand_blue, #00a1d6)', ok: 'var(--bili-green, #2ac864)' }; var W = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; /* ========================================================================== * 日期解析 * -------------------------------------------------------------------------- * 接口 module_author 里同时给出: * pub_time : 人读的相对时间,如 "昨天 17:17"、"10月15日"、"3小时前" * pub_ts : 秒级时间戳字符串,如 "1792401446",跨年也不会歧义 * 脚本优先用 pub_ts;只有在 pub_ts 缺失/非法时才回退解析 pub_time 文本。 * ========================================================================== */ var WEEK = { '日': 0, '天': 0, '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6 }; function nowMs() { return CFG.DEBUG_NOW != null ? CFG.DEBUG_NOW : Date.now(); } function atTime(baseMs, hh, mm) { var d = new Date(baseMs); d.setHours(hh || 0, mm || 0, 0, 0); return d.getTime(); } function addDays(ms, n) { var d = new Date(ms); d.setDate(d.getDate() + n); return d.getTime(); } function parseRelativeTime(str, baseMs) { var s = String(str || '').replace(/\s+/g, ' ').trim(); if (!s) return null; var m; // 绝对日期:2024-05-01 / 2024/05/01 / 2024年5月1日 [12:30] m = s.match(/(\d{4})\s*[-/年]\s*(\d{1,2})\s*[-/月]\s*(\d{1,2})\s*日?(?:\s*(\d{1,2}):(\d{2}))?/); if (m) { var full = new Date(+m[1], +m[2] - 1, +m[3], m[4] ? +m[4] : 0, m[5] ? +m[5] : 0, 0, 0).getTime(); return isNaN(full) ? null : full; } // 纯时刻:"17:17" → 今天 17:17 m = s.match(/^(\d{1,2}):(\d{2})$/); if (m) return atTime(baseMs, +m[1], +m[2]); // 今天 / 昨天 / 前天 [HH:MM] m = s.match(/^(今天|昨天|前天)(?:\s*(\d{1,2}):(\d{2}))?$/); if (m) { var off = m[1] === '今天' ? 0 : m[1] === '昨天' ? -1 : -2; var day = addDays(baseMs, off); return m[2] ? atTime(day, +m[2], +m[3]) : atTime(day, 0, 0); } // N 秒/分钟/小时/天/周/月/年 前 m = s.match(/^(\d+)\s*(秒|分钟|分|小时|天|周|个月|月|年)前$/); if (m) { var n = +m[1]; var mul = { '秒': 1000, '分钟': 60000, '分': 60000, '小时': 3600000, '天': 86400000, '周': 604800000, '个月': 2592000000, '月': 2592000000, '年': 31536000000 }[m[2]]; return baseMs - n * mul; } // 星期几 [HH:MM],B站 3~10 天前会显示成 "星期五 12:30" m = s.match(/^(?:星期|周)([一二三四五六日天])(?:\s*(\d{1,2}):(\d{2}))?$/); if (m) { var target = WEEK[m[1]]; var cur = new Date(baseMs); var back = (cur.getDay() - target + 7) % 7; if (back === 0) back = 7; // 显示“星期五”时不可能是今天 var wday = addDays(atTime(baseMs, 0, 0), -back); return m[2] ? atTime(wday, +m[2], +m[3]) : wday; } // 月日:"10月15日" / "10月15日 12:30" / "1月5号" m = s.match(/^(\d{1,2})\s*月\s*(\d{1,2})\s*[日号](?:\s*(\d{1,2}):(\d{2}))?$/); if (m) { var year = new Date(baseMs).getFullYear(); var y = year; var ts = new Date(y, +m[1] - 1, +m[2], m[3] ? +m[3] : 0, m[4] ? +m[4] : 0, 0, 0).getTime(); // 12 月里看到 "1月5日" 其实是明年;1 月里看到 "12月30日" 其实是去年 if (ts > baseMs + 86400000) { ts = new Date(y - 1, +m[1] - 1, +m[2], m[3] ? +m[3] : 0, m[4] ? +m[4] : 0, 0, 0).getTime(); } return ts; } return null; } // 单条动态 → 毫秒时间戳(优先 pub_ts) function itemTime(item, baseMs) { if (!item) return null; var author = (item.modules && item.modules.module_author) || {}; var raw = author.pub_ts != null && author.pub_ts !== '' ? author.pub_ts : item.pub_ts; if (raw != null && raw !== '') { var n = Number(raw); if (isFinite(n) && n > 0) return n > 1e12 ? n : n * 1000; // 10 位=秒,13 位=毫秒 } return parseRelativeTime(author.pub_time || item.pub_time || '', baseMs); } /* ========================================================================== * 格式化 * ========================================================================== */ function fmtDuration(ms) { if (ms == null || !isFinite(ms) || ms < 0) return '-'; if (ms < 60000) return Math.round(ms / 1000) + ' 秒'; var d = ms / 86400000; if (d < 1) return (ms / 3600000).toFixed(1) + ' 小时'; if (d < 30) return (d < 10 ? d.toFixed(1) : Math.round(d)) + ' 天'; if (d < 365) return (d / 30.4375).toFixed(1) + ' 个月'; return (d / 365.25).toFixed(2) + ' 年'; } function fmtDate(ms) { var d = new Date(ms); function p(n) { return (n < 10 ? '0' : '') + n; } return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); } function median(arr) { if (!arr.length) return NaN; var a = arr.slice().sort(function (x, y) { return x - y; }); var mid = a.length >> 1; return a.length % 2 ? a[mid] : (a[mid - 1] + a[mid]) / 2; } function describeFrequency(perWeek) { if (!isFinite(perWeek) || perWeek <= 0) return '数据不足'; if (perWeek >= 6) return '日更及以上'; if (perWeek >= 3.5) return '高频更新'; if (perWeek >= 1.5) return '稳定周更'; if (perWeek >= 0.9) return '周更'; if (perWeek >= 0.45) return '半月更'; if (perWeek >= 0.2) return '月更'; return '低频更新'; } /* ========================================================================== * 统计核心 * ========================================================================== */ function analyze(input, baseMs) { var entries = (input || []).slice().sort(function (a, b) { return b.ts - a.ts; }); var res = { samples: entries.length, first: null, last: null, span: NaN, avgGap: NaN, medianGap: NaN, minGap: NaN, maxGap: NaN, perWeek: NaN, perMonth: NaN, regularity: NaN, activeHours: [], activeWeekdays: [], gaps: [], entries: entries }; if (entries.length < 2) return res; res.first = entries[entries.length - 1].ts; res.last = entries[0].ts; res.span = res.last - res.first; if (res.span <= 0) return res; var gaps = []; for (var i = 1; i < entries.length; i++) { var g = entries[i - 1].ts - entries[i].ts; if (g > 0) gaps.push(g); } if (!gaps.length) return res; var cap = CFG.MAX_GAP_DAYS > 0 ? CFG.MAX_GAP_DAYS * 86400000 : Infinity; var normal = gaps.filter(function (x) { return x <= cap; }); if (!normal.length) normal = gaps; res.gaps = gaps; res.avgGap = gaps.reduce(function (s, v) { return s + v; }, 0) / gaps.length; res.medianGap = median(normal); res.minGap = Math.min.apply(null, normal); res.maxGap = Math.max.apply(null, normal); // 更新频率按首末跨度计算(而不是按样本数量),样本少时更接近真实节奏 res.perWeek = (entries.length - 1) / (res.span / (7 * 86400000)); res.perMonth = (entries.length - 1) / (res.span / (30.4375 * 86400000)); // 规律性 = 1 - 变异系数,越接近 1 越准时 if (normal.length > 1) { var mean = normal.reduce(function (s, v) { return s + v; }, 0) / normal.length; var sd = Math.sqrt(normal.reduce(function (s, v) { return s + (v - mean) * (v - mean); }, 0) / normal.length); res.regularity = mean > 0 ? Math.max(0, 1 - sd / mean) : NaN; } // 发布时段分布 var hours = []; for (var hi = 0; hi < 24; hi++) hours.push(0); var days = [0, 0, 0, 0, 0, 0, 0]; entries.forEach(function (e) { var d = new Date(e.ts); hours[d.getHours()]++; days[d.getDay()]++; }); res.activeHours = hours; res.activeWeekdays = days; return res; } // 把动态 feed 的 items 压缩成轻量记录(避免把整份原始 JSON 塞进存储) function slimItems(items, baseMs) { var out = []; (items || []).forEach(function (item) { var author = (item.modules && item.modules.module_author) || {}; var ts = itemTime(item, baseMs); if (ts == null || !isFinite(ts)) return; // 投稿类动态会带上稿件信息,取 bvid/aid 作为规范键, // 这样同一支视频无论是从动态接口还是投稿接口来,都只算一次 var major = (item.modules && item.modules.module_dynamic && item.modules.module_dynamic.major) || {}; var arc = major.archive || {}; var canon = arc.bvid || (arc.aid ? 'av' + arc.aid : ''); out.push({ id: String(canon || item.id_str || item.id || ts), ts: ts, type: item.type || '', top: !!author.is_top, text: author.pub_time || '', title: arc.title || '' }); }); return out; } // 把投稿接口 (arc/search) 的 vlist 压缩成同样的轻量记录 // vlist 里没有 modules/module_author,时间戳字段是 created(秒) function slimVideos(vlist) { var out = []; (vlist || []).forEach(function (v) { var ts = videoTime(v); if (ts == null || !isFinite(ts)) return; out.push({ // 与 slimItems 使用同一套规范键(bvid 优先),才能跨来源去重 id: String(v.bvid || (v.aid ? 'av' + v.aid : ts)), ts: ts, type: SRC_VIDEO, // 统一标记成视频来源 top: false, // 投稿列表没有置顶概念 text: '', title: v.title || '' }); }); return out; } // 投稿条目时间:created 是秒级时间戳;个别接口也会给 pubdate function videoTime(v) { if (!v) return null; var raw = v.created != null ? v.created : v.pubdate; if (raw == null || raw === '') return null; var n = Number(raw); if (!isFinite(n) || n <= 0) return null; return n > 1e12 ? n : n * 1000; } // 按规范键去重合并,时间降序。 // 同一支视频可能同时来自动态接口和投稿接口,此时保留信息更全的字段。 function mergeItems(a, b) { var map = {}; var out = []; function push(it) { if (!it || !it.id) return; var cur = map[it.id]; if (!cur) { map[it.id] = it; out.push(it); return; } // 已存在:补齐缺失字段,保留更完整的文本/标题 if (!cur.title && it.title) cur.title = it.title; if (!cur.text && it.text) cur.text = it.text; // 时间以更早(更接近真实发布)的为准也无意义,保持先到的即可 } (b || []).forEach(push); (a || []).forEach(push); out.sort(function (x, y) { return (y.ts || 0) - (x.ts || 0); }); return out.slice(0, 500); } /* ========================================================================== * 存储(GM_* 优先,回退 localStorage) * ========================================================================== */ var store = { get: function (k, d) { try { if (typeof GM_getValue === 'function') { var v = GM_getValue(k, undefined); return v === undefined ? d : v; } var s = localStorage.getItem(k); return s == null ? d : JSON.parse(s); } catch (e) { return d; } }, set: function (k, v) { try { if (typeof GM_setValue === 'function') { GM_setValue(k, v); return; } localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* 隐私模式等情况忽略 */ } }, del: function (k) { try { if (typeof GM_deleteValue === 'function') { GM_deleteValue(k); return; } localStorage.removeItem(k); } catch (e) { /* ignore */ } } }; function loadCache(mid) { var c = store.get(CFG.CACHE_PREFIX + mid, null); if (!c || !c.savedAt || Date.now() - c.savedAt > CFG.CACHE_TTL) return null; return c; } function saveCache(mid, items, extra) { var old = loadCache(mid) || {}; store.set(CFG.CACHE_PREFIX + mid, { savedAt: Date.now(), items: mergeItems(old.items, items), name: (extra && extra.name) || old.name || '', face: (extra && extra.face) || old.face || '' }); } /* ========================================================================== * 网络拦截:hook fetch / XMLHttpRequest,只旁路读取响应,不改变页面行为 * -------------------------------------------------------------------------- * 两种数据源: * 1) 动态页 /x/polymer/web-dynamic/v1/feed/space?host_mid=… → data.items[] * 时间字段:modules.module_author.pub_ts / pub_time * 2) 主页/投稿 /x/space/wbi/arc/search?mid=… → data.list.vlist[] * 时间字段:created(秒级时间戳) * 两个接口都需要 wbi 签名,无法在脚本里稳定构造,因此统一走“旁路截获页面请求”。 * ========================================================================== */ var HOOKED = false; var live = {}; // mid -> 轻量记录数组(两种来源混在一起,按 id 去重) var activeMid = ''; // 最近一次截获到的 UP mid function isFeedApi(url) { return typeof url === 'string' && url.indexOf('/x/polymer/web-dynamic/v1/feed/') !== -1 && url.indexOf('host_mid=') !== -1; } function isVideoApi(url) { return typeof url === 'string' && url.indexOf('/x/space/wbi/arc/search') !== -1; } // 任一被关心的接口 function isWatchedApi(url) { return isFeedApi(url) || isVideoApi(url); } function midFromUrl(url) { var s = String(url); var m = s.match(/[?&]host_mid=(\d+)/) || s.match(/[?&]mid=(\d+)/); return m ? m[1] : ''; } // 两个接口分流解析 function onResponse(url, text) { if (!isWatchedApi(url)) return; var json; try { json = JSON.parse(text); } catch (e) { return; } if (!json || json.code !== 0 || !json.data) return; var mid = midFromUrl(url); if (!mid) return; var added = []; if (isFeedApi(url)) { var items = json.data.items; if (!items || !items.length) return; var first = items[0]; var author = (first && first.modules && first.modules.module_author) || {}; added = slimItems(items, nowMs()); saveCache(mid, added, { name: author.name, face: author.face }); } else { var list = json.data.list; var vlist = (list && list.vlist) || []; if (!vlist.length) return; added = slimVideos(vlist); saveCache(mid, added, {}); } if (!added.length) return; live[mid] = mergeItems(live[mid], added); activeMid = mid; notifyUpdate(); } function hookFetch() { if (!W || !W.fetch || W.fetch.__biliFreqHooked) return; var orig = W.fetch; var wrapped = function (input, init) { var url = typeof input === 'string' ? input : (input && input.url) || ''; var p = orig.apply(this, arguments); if (isWatchedApi(url)) { try { p.then(function (res) { if (!res || !res.clone) return res; res.clone().text().then(function (t) { onResponse(url, t); }, function () {}); return res; }, function () {}); } catch (e) { /* ignore */ } } return p; }; wrapped.__biliFreqHooked = true; W.fetch = wrapped; } function hookXHR() { if (!W || !W.XMLHttpRequest || W.XMLHttpRequest.prototype.__biliFreqHooked) return; var proto = W.XMLHttpRequest.prototype; var origOpen = proto.open; var origSend = proto.send; proto.open = function (method, url) { try { this.__biliFreqUrl = url; } catch (e) { /* ignore */ } return origOpen.apply(this, arguments); }; proto.send = function () { var xhr = this; if (isWatchedApi(xhr.__biliFreqUrl)) { xhr.addEventListener('load', function () { try { var t = ''; if (xhr.responseType === '' || xhr.responseType === 'text') t = xhr.responseText; else if (xhr.responseType === 'json' && xhr.response) t = JSON.stringify(xhr.response); if (t) onResponse(xhr.__biliFreqUrl, t); } catch (e) { /* ignore */ } }); } return origSend.apply(this, arguments); }; proto.__biliFreqHooked = true; } function installHooks() { if (HOOKED) return; HOOKED = true; hookFetch(); hookXHR(); } /* ========================================================================== * 面板 UI * -------------------------------------------------------------------------- * 位置:.space-dynamic__right 右栏,作为第一个 .dynamic-aside-section 插入。 *
*
*
侧边导航(全部/视频/…) *
动态列表(无限滚动) *
← 面板插在这里 *
(认证 / 公告 / 个人资料 / 预约) * .space-dynamic 是固定的三栏 flex 布局,右栏已有原生卡片, * 顺排插入不会遮挡动态列表,也不依赖 fixed 定位。 * 右栏 DOM 由 Vue 渲染(动态切换 tab 时会重建),因此用 MutationObserver 重新挂载。 * ========================================================================== */ var PANEL_ID = 'bili-up-freq-panel'; var el = {}; var mounted = false; function injectStyle() { if (document.getElementById('bili-up-freq-style')) return; var css = [ /* 容器:放在 .dynamic-aside-section 里,视觉对齐右栏原生卡片 */ '#' + PANEL_ID + '{box-sizing:border-box;padding:12px;border-radius:8px;', 'background:' + THEME.bg + ';color:' + THEME.text + ';', 'font:12px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;}', '#' + PANEL_ID + ' *{box-sizing:border-box;}', '.buf-head{display:flex;align-items:center;gap:6px;margin-bottom:8px;}', '.buf-head__title{flex:1;min-width:0;font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}', '.buf-head__title small{display:block;font-weight:400;color:' + THEME.sub + ';font-size:11px;}', '.buf-btn{border:1px solid ' + THEME.line + ';background:transparent;color:' + THEME.sub + ';border-radius:6px;', 'padding:1px 6px;cursor:pointer;font-size:11px;line-height:1.6;font-family:inherit;transition:.15s;flex:none;}', '.buf-btn:hover{border-color:' + THEME.accent + ';color:' + THEME.accent + ';}', /* 主指标 */ '.buf-hero{display:flex;align-items:baseline;gap:5px;}', '.buf-hero b{font-size:26px;line-height:1.1;font-weight:700;color:' + THEME.accent + ';}', '.buf-hero span{color:' + THEME.sub + ';font-size:11px;}', '.buf-tagline{color:' + THEME.sub + ';font-size:11px;line-height:1.7;margin-bottom:10px;}', /* 指标网格 */ '.buf-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px 8px;margin-bottom:10px;}', '.buf-cell{background:' + THEME.bg2 + ';border-radius:6px;padding:5px 7px;min-width:0;}', '.buf-cell .k{display:block;color:' + THEME.sub + ';font-size:10px;}', '.buf-cell .v{font-size:13px;font-weight:600;}', /* 区块标题 */ '.buf-sec{color:' + THEME.sub + ';font-size:10px;letter-spacing:.4px;margin:10px 0 5px;}', /* 条形图 */ '.buf-bar{display:flex;align-items:center;gap:6px;margin:3px 0;font-size:11px;}', '.buf-bar .lbl{width:32px;flex:none;color:' + THEME.sub + ';}', '.buf-bar .track{flex:1;height:7px;background:' + THEME.bg2 + ';border-radius:4px;overflow:hidden;}', '.buf-bar .fill{height:100%;background:' + THEME.accent + ';border-radius:4px;opacity:.85;}', '.buf-bar .num{width:44px;flex:none;text-align:right;color:' + THEME.sub + ';}', /* 列表 */ '.buf-list{max-height:156px;overflow:auto;margin-top:4px;border-top:1px solid ' + THEME.line + ';}', '.buf-list::-webkit-scrollbar{width:4px;}', '.buf-list::-webkit-scrollbar-thumb{background:' + THEME.line + ';border-radius:2px;}', '.buf-row{display:flex;gap:6px;padding:4px 0;border-bottom:1px solid ' + THEME.line + ';font-size:11px;align-items:center;}', '.buf-row:last-child{border-bottom:none;}', '.buf-row .ago{flex:none;color:' + THEME.accent + ';}', '.buf-row .txt{color:' + THEME.sub + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}', /* 空态 / 提示 */ '.buf-empty{color:' + THEME.sub + ';font-size:11px;line-height:1.7;background:' + THEME.bg2 + ';border-radius:6px;padding:8px 10px;white-space:pre-line;}', '.buf-hint{color:' + THEME.sub + ';font-size:11px;margin-top:6px;opacity:.85;}', /* 底部按钮 */ '.buf-foot{display:flex;gap:6px;margin-top:10px;}', '.buf-foot .buf-btn{flex:1;text-align:center;padding:3px 0;}' ].join(''); var style = document.createElement('style'); style.id = 'bili-up-freq-style'; style.textContent = css; (document.head || document.documentElement).appendChild(style); } function h(tag, props, children) { var node = document.createElement(tag); if (props) Object.keys(props).forEach(function (k) { if (k === 'style') node.setAttribute('style', props[k]); else if (k === 'text') node.textContent = props[k]; else if (k.slice(0, 2) === 'on') node.addEventListener(k.slice(2), props[k]); else node.setAttribute(k, props[k]); }); (children || []).forEach(function (c) { if (c == null) return; node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); }); return node; } // 当前页面 URL 中的 UP mid(space.bilibili.com/{mid}/dynamic 或 ?mid=) function midFromLocation() { var m = location.pathname.match(/^\/(\d{3,})/); if (m) return m[1]; try { return new URLSearchParams(location.search).get('mid') || ''; } catch (e) { return ''; } } function resolveMid() { return midFromLocation() || activeMid || ''; } /* ---------- 挂载点 ---------- * 两种页面布局,同一个面板都要挂到右栏最前面: * * A) 动态页 main.space-main.route_dynamic * div.space-dynamic > .space-dynamic__right * └ .dynamic-aside-section(认证/公告/个人资料/预约) * * B) 主页/投稿页 main.space-main.route_home * div.space-home > .aside * └ .home-aside-section(认证/充电/公告/直播间/个人资料/预约) * * 两个右栏都是固定的侧栏列,顺排插入即可,不需要 fixed 定位。 * 节点由 Vue 渲染,切 tab 会重建,靠 MutationObserver 兜底重挂。 */ // 返回 { column, sectionClass };不同路由对应不同右栏与卡片 class function findAsideTarget() { // 动态页优先 var dyn = document.querySelector('.space-dynamic__right'); if (dyn) return { column: dyn, sectionClass: 'dynamic-aside-section' }; // 主页 / 投稿页 var home = document.querySelector('.space-home .aside'); if (home) return { column: home, sectionClass: 'home-aside-section' }; // 兜底:更通用的侧栏容器 var aside = document.querySelector('main.space-main .aside'); if (aside) return { column: aside, sectionClass: 'home-aside-section' }; return null; } // 右栏里第一个原生卡片,用于把面板排在最前面 function findFirstSection(column, sectionClass) { var kids = column.children; for (var i = 0; i < kids.length; i++) { if (kids[i].classList && kids[i].classList.contains(sectionClass)) return kids[i]; } return null; } function buildPanel() { injectStyle(); var body = h('div', { class: 'buf-body' }); var title = h('div', { class: 'buf-head__title', text: '更新频率' }, [h('small', { text: '等待数据…' })]); var panel = h('div', { id: PANEL_ID }, [ h('div', { class: 'buf-head' }, [ title, h('button', { class: 'buf-btn', title: '补拉更多数据', text: '↻', onclick: function () { refresh(true); } }) ]), body ]); el.panel = panel; el.title = title; el.body = body; return panel; } function mountPanel() { var target = findAsideTarget(); if (!target) return false; injectStyle(); var column = target.column; var sectionClass = target.sectionClass; var panel = document.getElementById(PANEL_ID); var wrap = panel ? panel.parentNode : null; var alreadyFirst = wrap && wrap.parentNode === column && column.firstElementChild === wrap && wrap.classList.contains(sectionClass); if (panel && alreadyFirst) { mounted = true; el.panel = panel; el.title = panel.querySelector('.buf-head__title'); el.body = panel.querySelector('.buf-body'); return true; } if (!panel) { panel = buildPanel(); } else { // Vue 重建/换页后重新取回内部引用 el.panel = panel; el.title = panel.querySelector('.buf-head__title'); el.body = panel.querySelector('.buf-body'); } // 用站点原生的卡片容器包裹,才能继承右栏的上下间距 var newWrap = h('div', { class: sectionClass }, [panel]); var firstSection = findFirstSection(column, sectionClass); if (firstSection) column.insertBefore(newWrap, firstSection); else column.insertBefore(newWrap, column.firstChild); mounted = true; renderCurrent(); return true; } function ensureMounted() { if (mounted && document.getElementById(PANEL_ID)) return true; return mountPanel(); } /* ---------- 面板内容 ---------- */ function renderEmpty() { if (!el.body) return; el.body.innerHTML = ''; el.body.appendChild(h('div', { class: 'buf-empty', text: '正在等待数据…\n在首页/投稿页滚动视频列表,或在动态页向下滚动,脚本会截获接口返回并统计。' })); } function cell(k, v) { return h('div', { class: 'buf-cell' }, [ h('span', { class: 'k', text: k }), h('span', { class: 'v', text: v }) ]); } function bar(label, ratio, text) { var pct = Math.max(0, Math.min(100, Math.round(ratio * 100))); return h('div', { class: 'buf-bar' }, [ h('span', { class: 'lbl', text: label }), h('div', { class: 'track' }, [h('div', { class: 'fill', style: 'width:' + pct + '%' })]), h('span', { class: 'num', text: text }) ]); } function renderResult(res, meta, hint) { if (!el.body || !el.title) return; var name = meta.name || '更新频率'; el.title.innerHTML = ''; el.title.appendChild(document.createTextNode(name)); el.body.innerHTML = ''; if (!res || res.samples < 2) { el.title.appendChild(h('small', { text: res && res.samples ? '样本 ' + res.samples + ' 条' : '等待数据' })); el.body.appendChild(h('div', { class: 'buf-empty', text: '已获取 ' + (res ? res.samples : 0) + ' 条记录,至少需要 2 条才能计算间隔。\n在首页/投稿页滚动视频列表,或在动态页向下滚动即可补足。' })); return; } // 标题下方:MID + 样本构成 var parts = []; if (meta.mid) parts.push('MID ' + meta.mid); parts.push(res.samples + ' 条样本'); if (meta.videoCount && meta.dynamicCount) { parts.push('投稿 ' + meta.videoCount + ' / 动态 ' + meta.dynamicCount); } el.title.appendChild(h('small', { text: parts.join(' · ') })); // 主指标 el.body.appendChild(h('div', { class: 'buf-hero' }, [ h('b', { text: res.perWeek.toFixed(1) }), h('span', { text: '次 / 周' }) ])); var tag = describeFrequency(res.perWeek) + ' · 约 ' + (res.perMonth >= 1 ? res.perMonth.toFixed(1) + ' 次/月' : (res.perMonth * 12).toFixed(1) + ' 次/年') + ' · 平均每 ' + fmtDuration(res.avgGap) + ' 一次'; if (isFinite(res.medianGap) && res.medianGap > 0) { var nextTs = res.last + res.medianGap; var diff = nextTs - nowMs(); tag += '\n预计下次:' + fmtDate(nextTs).slice(0, 10) + (diff >= 0 ? '(还有 ' + fmtDuration(diff) + ')' : '(已超期 ' + fmtDuration(-diff) + ')'); } el.body.appendChild(h('div', { class: 'buf-tagline', text: tag })); // 指标网格 el.body.appendChild(h('div', { class: 'buf-grid' }, [ cell('平均间隔', fmtDuration(res.avgGap)), cell('中位间隔', fmtDuration(res.medianGap)), cell('最短间隔', fmtDuration(res.minGap)), cell('最长间隔', fmtDuration(res.maxGap)), cell('时间跨度', fmtDuration(res.span)), cell('统计条数', String(res.samples)) ])); // 规律性 if (isFinite(res.regularity)) { el.body.appendChild(h('div', { class: 'buf-sec', text: '更新规律性' })); el.body.appendChild(bar('稳定', res.regularity, Math.round(res.regularity * 100) + '%')); } // 发布时段 if (res.activeHours && res.activeHours.some(function (v) { return v > 0; })) { el.body.appendChild(h('div', { class: 'buf-sec', text: '常发时段' })); var maxH = Math.max.apply(null, res.activeHours); for (var hh = 0; hh < 24; hh++) { if (!res.activeHours[hh]) continue; el.body.appendChild(bar((hh < 10 ? '0' + hh : hh) + '时', res.activeHours[hh] / maxH, res.activeHours[hh] + ' 条')); } } // 每周分布 if (res.activeWeekdays && res.activeWeekdays.some(function (v) { return v > 0; })) { el.body.appendChild(h('div', { class: 'buf-sec', text: '每周分布' })); var names = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; var maxD = Math.max.apply(null, res.activeWeekdays); for (var d = 0; d < 7; d++) { el.body.appendChild(bar(names[d], res.activeWeekdays[d] / maxD, res.activeWeekdays[d] + ' 条')); } } // 最近更新 el.body.appendChild(h('div', { class: 'buf-sec', text: '最近更新' })); var list = h('div', { class: 'buf-list' }); res.entries.slice(0, 30).forEach(function (e) { var diff = nowMs() - e.ts; var ago = diff >= 0 ? fmtDuration(diff) + '前' : '时间异常'; // 视频条目带标题,动态条目带原始 pub_time 文本 var detail = e.title || e.text || ''; list.appendChild(h('div', { class: 'buf-row' }, [ h('span', { class: 'ago', text: ago }), h('span', { class: 'txt', text: fmtDate(e.ts) + (detail ? '(' + detail + ')' : '') }) ])); }); el.body.appendChild(list); if (hint) el.body.appendChild(h('div', { class: 'buf-hint', text: hint })); el.body.appendChild(h('div', { class: 'buf-foot' }, [ h('button', { class: 'buf-btn', text: '清空缓存', onclick: function () { clearCache(meta.mid); } }), h('button', { class: 'buf-btn', text: '复制统计', onclick: function () { copyResult(res); } }) ])); } function copyResult(res) { var lines = [ '更新频率:' + res.perWeek.toFixed(2) + ' 次/周(' + describeFrequency(res.perWeek) + ')', '平均间隔:' + fmtDuration(res.avgGap), '中位间隔:' + fmtDuration(res.medianGap), '最短/最长:' + fmtDuration(res.minGap) + ' / ' + fmtDuration(res.maxGap), '规律性:' + (isFinite(res.regularity) ? Math.round(res.regularity * 100) + '%' : '-'), '样本:' + res.samples + ' 条,跨度 ' + fmtDuration(res.span) ]; var text = lines.join('\n'); var done = function () { flash('已复制'); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, function () { fallbackCopy(text); }); } else { fallbackCopy(text); } } function fallbackCopy(text) { var ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) { /* ignore */ } ta.remove(); flash('已复制'); } function flash(msg) { var tip = h('div', { style: 'position:fixed;z-index:1000001;right:20px;bottom:20px;padding:8px 14px;background:' + THEME.accent + ';color:#fff;border-radius:8px;font-weight:600;font-size:12px;', text: msg }); document.body.appendChild(tip); setTimeout(function () { tip.remove(); }, 1600); } function clearCache(mid) { if (!mid) return; store.del(CFG.CACHE_PREFIX + mid); live[mid] = []; flash('缓存已清空'); renderEmpty(); } /* ========================================================================== * 汇总与刷新 * ========================================================================== */ function computeFor(mid) { var cache = loadCache(mid) || {}; var list = mergeItems(cache.items, live[mid]); var entries = list.filter(function (it) { if (!CFG.INCLUDE_PINNED && it.top) return false; if (CFG.ONLY_TYPES.length && CFG.ONLY_TYPES.indexOf(it.type) === -1) return false; return it.ts > 0; }).sort(function (a, b) { return b.ts - a.ts; }).slice(0, CFG.MAX_SAMPLES); // 数据来源统计,用于在面板上标注样本来自哪里 var videoCount = 0; var dynamicCount = 0; entries.forEach(function (e) { if (e.type === SRC_VIDEO) videoCount++; else dynamicCount++; }); // 昵称/头像:接口不一定给,优先取页面上的 var dom = readDomMeta(); return { res: analyze(entries, nowMs()), meta: { mid: mid, name: cache.name || dom.name || '', face: cache.face || dom.face || '', videoCount: videoCount, dynamicCount: dynamicCount } }; } // 从页头读昵称与头像(空间各页都有 .upinfo / .nickname) function readDomMeta() { var name = ''; var face = ''; try { var n = document.querySelector('.upinfo-detail .nickname, .upinfo .nickname'); if (n) name = (n.textContent || '').trim(); var img = document.querySelector('.upinfo-avatar img, .space-avatar img'); if (img) { var src = img.getAttribute('src') || ''; // 去掉尺寸后缀,取原图,避免 @128w_128h 这种 face = src.replace(/@[^/]*$/, ''); if (face.indexOf('//') === 0) face = 'https:' + face; } } catch (e) { /* ignore */ } return { name: name, face: face }; } function renderCurrent(hint) { var mid = resolveMid(); if (!mid || !el.body) return; var out = computeFor(mid); renderResult(out.res, out.meta, hint || ''); } function notifyUpdate() { if (!ensureMounted()) return; renderCurrent(); } function refresh(manual) { var mid = resolveMid(); if (!mid) { if (manual) flash('未识别到当前 UP 的 MID'); return; } renderCurrent(); if (!manual) return; var cur = computeFor(mid); if (cur.res.samples >= 2) { // 已有足够样本,顺手补拉投稿列表;失败静默 fetchVideos(mid).catch(function () {}); flash('已刷新'); return; } // 样本不足:尽力主动补数据(未登录/无签名时接口会返回 412,属正常) fetchVideos(mid).catch(function () { flash('接口需签名/登录态,请滚动列表加载'); }); } // 主动拉投稿列表(主页/投稿页用的接口)。 // 该接口需要 wbi 签名,脚本不构造签名,而是在页面上下文里调用 fetch, // 若页面已有 cookie 且风控放行则成功,否则静默失败,仍靠截获兜底。 function fetchVideos(mid) { var url = 'https://api.bilibili.com/x/space/wbi/arc/search?mid=' + encodeURIComponent(mid) + '&order=pubdate&ps=25&pn=1&index=1&order_avoided=true&platform=web&web_location=333.1387'; var f = (W && W.fetch) ? W.fetch : window.fetch; return f.call(W, url, { credentials: 'include' }).then(function (r) { return r.text(); }).then(function (t) { var json; try { json = JSON.parse(t); } catch (e) { throw new Error('not json'); } if (!json || json.code !== 0) throw new Error('code ' + (json && json.code)); return json; }); } /* ========================================================================== * 启动 * ========================================================================== */ function boot() { installHooks(); // 右栏是 Vue 渲染的,且切 tab 会重建节点 —— 用 MutationObserver 兜底重挂 function watchDom() { if (typeof MutationObserver !== 'function') return; var pending = false; var mo = new MutationObserver(function () { if (pending) return; pending = true; setTimeout(function () { pending = false; if (!document.getElementById(PANEL_ID)) { mounted = false; if (resolveMid()) mountPanel(); } }, 120); }); mo.observe(document.documentElement, { childList: true, subtree: true }); } function start() { if (resolveMid()) mountPanel(); watchDom(); } if (document.body) start(); else document.addEventListener('DOMContentLoaded', start, { once: true }); // SPA 路由切换(空间站内切换 UP 不会整页刷新) var lastHref = location.href; setInterval(function () { if (location.href === lastHref) return; lastHref = location.href; mounted = false; if (resolveMid()) mountPanel(); }, 1000); } boot(); // 调试入口:控制台可用 __BiliUpFreq.analyze(...) 等 try { W.__BiliUpFreq = { analyze: analyze, parseRelativeTime: parseRelativeTime, itemTime: itemTime, videoTime: videoTime, slimVideos: slimVideos, computeFor: computeFor, mountPanel: mountPanel, findAsideTarget: findAsideTarget, config: CFG }; } catch (e) { /* ignore */ } })();