// ==UserScript== // @name B站视频观看次数统计 // @namespace http://tampermonkey.net/ // @version 1.8 // @description 每次刷新/跳转进入视频页时观看次数+1,用 🔁 显示在视频信息栏(播放量/弹幕那一栏),数据存 IndexedDB。静默运行,不做实时监听,性能零负担 // @author Anonymity // @match *://www.bilibili.com/video/* // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @run-at document-end // @license MIT // ==/UserScript== (function () { 'use strict'; const DB_NAME = 'BiliViewCountDB'; const STORE = 'views'; // 记录格式 { id: "BV1xxx", times: num } const ITEM_ID = 'bili-view-count-item'; // ====================== IndexedDB ====================== function openDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = () => { req.result.createObjectStore(STORE, { keyPath: 'id' }); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } // 记录格式 { id: "BV1xxx", title, times: num, uid: "UP主UID", name: "UP主名", lastTime: 时间戳 } async function increment(bv, meta, title) { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite'); const getReq = tx.objectStore(STORE).get(bv); getReq.onsuccess = () => { const rec = getReq.result || { id: bv, times: 0 }; rec.times += 1; rec.lastTime = Date.now(); if (meta) { if (meta.uid) rec.uid = meta.uid; if (meta.name) rec.name = meta.name; } if (title) rec.title = title; tx.objectStore(STORE).put(rec); resolve(rec.times); }; getReq.onerror = () => reject(getReq.error); }); } // 读取全部记录(数组) function getAllRecords() { return openDB().then(db => new Promise(resolve => { try { const req = db.transaction(STORE, 'readonly').objectStore(STORE).getAll(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => resolve([]); } catch (e) { resolve([]); } })).catch(() => []); } // 只补录 UP 主信息/标题(不改 times / lastTime) async function updateMeta(bv, meta) { if (!meta || (!meta.uid && !meta.name && !meta.title)) return false; try { const db = await openDB(); return await new Promise(resolve => { const tx = db.transaction(STORE, 'readwrite'); const g = tx.objectStore(STORE).get(bv); g.onsuccess = () => { const rec = g.result; if (!rec) { resolve(false); return; } if (meta.uid) rec.uid = meta.uid; if (meta.name) rec.name = meta.name; if (meta.title) rec.title = meta.title; tx.objectStore(STORE).put(rec); tx.oncomplete = () => resolve(true); }; g.onerror = () => resolve(false); }); } catch (e) { return false; } } // 按 BV 反查 UP 主(B站 view API,绕过 DOM/CORS) function gmGet(url) { return new Promise(resolve => { GM_xmlhttpRequest({ method: 'GET', url, timeout: 8000, onload: r => resolve(r.status === 200 ? r.responseText : null), onerror: () => resolve(null), ontimeout: () => resolve(null) }); }); } async function fetchUpMetaByApi(bv) { try { const text = await gmGet('https://api.bilibili.com/x/web-interface/view?bvid=' + bv); if (!text) return null; const json = JSON.parse(text); const d = json && json.code === 0 && json.data; if (!d) return null; const meta = {}; if (d.owner && d.owner.mid) meta.uid = String(d.owner.mid); if (d.owner && d.owner.name) meta.name = String(d.owner.name).trim(); if (d.title) meta.title = String(d.title).trim(); return meta.uid || meta.name || meta.title ? meta : null; } catch (e) { return null; } } // ====================== 计数核心 ====================== function getBv() { const m = location.pathname.match(/\/video\/(BV[0-9A-Za-z]+)/); return m ? m[1] : null; } const timesCache = new Map(); // bv -> times,用于 DOM 重建后恢复显示 // 从视频页读取 UP 主信息 function getUpInfo() { let uid = null, name = ''; // 只取 UP 主信息区(视频作者区),避开页头登录账号自己的 space 链接 const box = document.querySelector('.up-info-container'); if (box) { const link = box.querySelector('a[href*="space.bilibili.com"]'); if (link) { const m = (link.getAttribute('href') || '').match(/space\.bilibili\.com\/(\d+)/); if (m) uid = m[1]; } const nameEl = box.querySelector('.up-name'); if (nameEl) name = nameEl.textContent.trim(); } // 兜底:容器没渲染时,只用 up-avatar 专用类(该 class 只属于 UP 主头像,不会命中页头) if (!uid) { const a = document.querySelector('.up-avatar[href*="space.bilibili.com"]'); if (a) { const m = (a.getAttribute('href') || '').match(/space\.bilibili\.com\/(\d+)/); if (m) uid = m[1]; } } // 第三层:排除页头(登录账号自己的入口)后,正文区第一个 space 链接 if (!uid) { const headerLinks = new Set(document.querySelectorAll('.bili-header a[href*="space.bilibili.com"], header a[href*="space.bilibili.com"]')); for (const a of document.querySelectorAll('a[href*="space.bilibili.com"]')) { if (headerLinks.has(a)) continue; const m = (a.getAttribute('href') || '').match(/space\.bilibili\.com\/(\d+)/); if (m) { uid = m[1]; break; } } } if (!name) { const n = document.querySelector('.up-name'); if (n) name = n.textContent.trim(); } return { uid, name }; } // 从 document.title 取视频标题(B站格式:视频标题_哔哩哔哩_bilibili) function getVideoTitle() { const t = (document.title || '').split('_哔哩哔哩')[0].trim(); return t && t !== location.hostname ? t : ''; } // ====================== 30s 确认窗口计数 ====================== // 兜底方案:检测到新BV后不立即计数,进入 CONFIRM_MS 确认窗口, // 30s 后复查仍是该视频才 +1;窗口内重复触发被合并。 // 防重复的关键:只记住「最近一次导航的BV」。BV没变(切P/参数变化)= 同一次观看,不重计; // BV变了(含列表循环跳回旧视频)= 新一次进入,重新走确认窗口。 const CONFIRM_MS = 30000; let lastNavBv = null; // 最近一次导航到的 BV let pendingBv = null; // 正在确认窗口内的 BV let confirmTimer = null; function cancelConfirm() { if (confirmTimer) { clearTimeout(confirmTimer); confirmTimer = null; } pendingBv = null; } // 所有 URL 相关入口(初始化 / pushState / replaceState / popstate)统一走这里 function handleNav() { const bv = getBv(); if (!bv) { cancelConfirm(); return; } // 离开视频页:放弃未确认的计数 if (bv === pendingBv) return; // 确认窗口内:合并瞬时重复触发 if (bv === lastNavBv) { render(bv); return; } // BV 没变:切P/改参,不重计,只补显示 // 跳到了与最近一次不同的 BV:取消旧窗口,开启新的 30s 确认 cancelConfirm(); lastNavBv = bv; pendingBv = bv; confirmTimer = setTimeout(async () => { confirmTimer = null; if (pendingBv !== bv) return; // 窗口内又导航走了:放弃 pendingBv = null; try { const meta = getUpInfo(); const title = getVideoTitle(); const times = await increment(bv, meta, title); timesCache.set(bv, times); // 兜底:DOM 没抓到 UP 主/标题 → API 反查补录(不影响计数结果) if (!meta.uid || !meta.name || !title) { fetchUpMetaByApi(bv).then(apiMeta => { if (apiMeta) updateMeta(bv, apiMeta); }); } } catch (e) { /* IndexedDB 失败静默 */ } startRenderLoop(); }, CONFIRM_MS); } // ====================== 显示 ====================== // 返回 true 表示已成功显示,轮询可停止 function render(bv) { if (!bv) return false; const times = timesCache.get(bv); if (times === undefined) return false; const bar = document.querySelector('.video-info-detail-list'); if (!bar) return false; let el = document.getElementById(ITEM_ID); if (!el || !el.isConnected) { el = document.createElement('div'); el.id = ITEM_ID; el.className = 'item'; el.title = '本机观看次数'; el.style.cssText = 'display:flex;align-items:center;gap:4px;font-size:13px;color:inherit;white-space:nowrap;cursor:default;'; el.innerHTML = '🔁'; bar.appendChild(el); } const textEl = el.querySelector('.times-text'); if (textEl.textContent !== String(times)) { textEl.textContent = times; } return true; } // 低频轮询:1秒1次,成功显示即停,最多尝试60次;之后完全零开销(不用 MutationObserver) let renderTimer = null; function startRenderLoop() { if (renderTimer) return; let attempts = 0; renderTimer = setInterval(() => { attempts++; const done = render(getBv()); if (done || attempts >= 60) { clearInterval(renderTimer); renderTimer = null; } }, 1000); } // ====================== 路由监听 ====================== // 仅在跳转瞬间触发一次轻量函数(正则+查表),无任何持续监听 const _push = history.pushState.bind(history); history.pushState = function (...args) { _push(...args); setTimeout(handleNav, 0); }; const _replace = history.replaceState.bind(history); history.replaceState = function (...args) { _replace(...args); setTimeout(handleNav, 0); }; window.addEventListener('popstate', () => setTimeout(handleNav, 0)); // ====================== 观看统计表格(GM菜单) ====================== function escapeHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function showStatsPanel() { // 面板样式放独立 style 里,用 ID 作用域避免被 B 站全局样式(如 h3 黑色)污染 const CSS_ID = 'bili-view-stats-style'; let styleEl = document.getElementById(CSS_ID); if (!styleEl) { styleEl = document.createElement('style'); styleEl.id = CSS_ID; styleEl.textContent = ` #bili-view-stats-panel{position:fixed;top:80px;right:24px;width:460px;max-height:70vh;overflow-y:auto;background:rgba(16,16,22,0.92);backdrop-filter:blur(14px);color:#fff;padding:16px;border-radius:14px;z-index:100002;box-shadow:0 6px 24px rgba(0,0,0,0.5);font-size:13px;} #bili-view-stats-panel h3{margin:0;font-size:15px;color:#fff;} #bili-view-stats-panel .head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,0.15);} #bili-view-stats-panel .head h3{border:none;padding:0;margin:0;} #bili-view-stats-panel .theme-btn{flex-shrink:0;padding:3px 10px;font-size:13px;line-height:1.4;background:rgba(255,255,255,0.12);color:inherit;border:1px solid rgba(255,255,255,0.25);border-radius:6px;cursor:pointer;} #bili-view-stats-panel table{width:100%;border-collapse:collapse;} #bili-view-stats-panel th{text-align:left;padding:6px 8px;color:#ff69b4;border-bottom:1px solid rgba(255,255,255,0.2);font-weight:600;} #bili-view-stats-panel td{padding:6px 8px;color:inherit;} #bili-view-stats-panel .up-link{color:#6cb8ff;text-decoration:none;} #bili-view-stats-panel .up-link:hover{text-decoration:underline;} #bili-view-stats-panel .col-times{font-weight:bold;color:#ffd700;cursor:pointer;user-select:none;white-space:nowrap;} #bili-view-stats-panel .col-times:hover{text-decoration:underline;} #bili-view-stats-panel .unknown{opacity:0.5;} #bili-view-stats-panel .meta{margin-top:10px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.15);font-size:12px;opacity:0.75;} #bili-view-stats-panel .btn{display:block;width:100%;margin-top:12px;padding:7px;background:rgba(255,255,255,0.14);border:none;border-radius:6px;color:inherit;cursor:pointer;} #bili-view-stats-panel .detail{margin-top:10px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.15);} #bili-view-stats-panel .detail-title{font-size:12px;font-weight:bold;margin-bottom:6px;color:inherit;} #bili-view-stats-panel .detail-row{display:flex;justify-content:space-between;gap:10px;padding:3px 0;font-size:12px;opacity:0.92;color:inherit;} #bili-view-stats-panel .detail-row .d-time{opacity:0.6;white-space:nowrap;} /* 浅色主题 */ #bili-view-stats-panel.stats-light{background:rgba(255,255,255,0.96);color:#18191c;box-shadow:0 6px 24px rgba(0,0,0,0.25);} #bili-view-stats-panel.stats-light h3{color:#18191c;} #bili-view-stats-panel.stats-light .head{border-bottom:1px solid rgba(0,0,0,0.15);} #bili-view-stats-panel.stats-light .theme-btn{background:rgba(0,0,0,0.06);border-color:rgba(0,0,0,0.2);} #bili-view-stats-panel.stats-light th{color:#d63384;border-bottom:1px solid rgba(0,0,0,0.2);} #bili-view-stats-panel.stats-light .up-link{color:#0a7de0;} #bili-view-stats-panel.stats-light .col-times{color:#b8860b;} #bili-view-stats-panel.stats-light .meta{border-top:1px solid rgba(0,0,0,0.15);} #bili-view-stats-panel.stats-light .btn{background:rgba(0,0,0,0.07);} #bili-view-stats-panel.stats-light .detail{border-top:1px solid rgba(0,0,0,0.15);} #bili-view-stats-panel .back-btn{display:inline-block;margin-bottom:10px;padding:4px 12px;font-size:12px;background:rgba(255,255,255,0.12);color:inherit;border:1px solid rgba(255,255,255,0.25);border-radius:6px;cursor:pointer;} #bili-view-stats-panel .back-btn:hover{opacity:0.85;} #bili-view-stats-panel .v-item{padding:6px 2px;border-bottom:1px solid rgba(255,255,255,0.08);} #bili-view-stats-panel .v-item:last-child{border-bottom:none;} #bili-view-stats-panel .v-title{display:block;color:inherit;text-decoration:none;font-size:13px;line-height:1.5;} #bili-view-stats-panel .v-title:hover{color:#6cb8ff;text-decoration:underline;} #bili-view-stats-panel .v-sub{font-size:11px;opacity:0.6;margin-top:1px;} #bili-view-stats-panel.stats-light .back-btn{background:rgba(0,0,0,0.06);border-color:rgba(0,0,0,0.2);} #bili-view-stats-panel.stats-light .v-item{border-bottom:1px solid rgba(0,0,0,0.08);} #bili-view-stats-panel.stats-light .v-title:hover{color:#0a7de0;} `; document.head.appendChild(styleEl); } getAllRecords().then(records => { // 按 UP 主 UID 聚合:总观看数、最近观看时间 const groups = {}; records.forEach(r => { const key = r.uid || '未知'; const g = groups[key] || (groups[key] = { uid: key, name: '', times: 0, last: 0 }); g.times += r.times; g.last = Math.max(g.last, r.lastTime || 0); if (!g.name && r.name) g.name = r.name; }); const rows = Object.values(groups).sort((a, b) => b.times - a.times); let old = document.getElementById('bili-view-stats-panel'); if (old) old.remove(); const panel = document.createElement('div'); panel.id = 'bili-view-stats-panel'; let body = ''; if (!rows.length) { body = '
暂无观看记录,先看几个视频吧
'; } else { const trs = rows.map(g => { const lastStr = g.last ? new Date(g.last).toLocaleString('zh-CN', { hour12: false }) : '—'; const isUnknown = g.uid === '未知'; const uidTd = isUnknown ? '未知' : escapeHtml(g.uid); const nameTd = !g.name ? '未知' : (isUnknown ? `${escapeHtml(g.name)}` : `${escapeHtml(g.name)}`); const timesTd = isUnknown ? `${g.times}` : `${g.times}`; return `${uidTd}${nameTd}${timesTd}${lastStr}`; }).join(''); const totalTime = records.reduce((s, r) => s + r.times, 0); body = ` ${trs}
UID用户名总观看数最近观看
共 ${rows.length} 位UP主 / ${records.length} 个视频 / 累计观看 ${totalTime} 次 · 点击「总观看数」查看该 UP 的视频明细
`; } panel.innerHTML = `

📊 UP主观看统计

${body}
`; document.body.appendChild(panel); const pageMain = panel.querySelector('#bili-stats-page-main'); const pageDetail = panel.querySelector('#bili-stats-page-detail'); // 黑白主题切换(☀️ 深色面板 → 点击转浅色;🌙 浅色 → 点击回深色) const themeBtn = panel.querySelector('.theme-btn'); themeBtn.onclick = () => { const light = panel.classList.toggle('stats-light'); themeBtn.textContent = light ? '🌙' : '☀️'; }; // 「总观看数」→ 切换到该 UP 的视频明细页(含标题,按次数倒序) function showDetailPage(uid, name) { const list = records .filter(r => (r.uid || '未知') === uid) .sort((a, b) => (b.times - a.times) || ((b.lastTime || 0) - (a.lastTime || 0))); if (!list.length) return; pageDetail.innerHTML = `
▸ ${escapeHtml(name || uid)}(UID ${escapeHtml(uid)})· 共 ${list.length} 个视频
${list.map(v => { const lastStr = v.lastTime ? new Date(v.lastTime).toLocaleString('zh-CN', { hour12: false }) : '—'; return `
${escapeHtml(v.title || v.id)}
${escapeHtml(v.id)} · 🔁 ${v.times} 次 · 最近观看 ${lastStr}
`; }).join('')}`; pageMain.style.display = 'none'; pageDetail.style.display = 'block'; pageDetail.querySelector('#bili-detail-back').onclick = () => { pageDetail.style.display = 'none'; pageMain.style.display = 'block'; }; } pageMain.querySelectorAll('.col-times[data-uid]').forEach(td => { td.onclick = () => showDetailPage(td.dataset.uid, td.dataset.name); }); panel.querySelector('#bili-view-stats-close').onclick = () => panel.remove(); }); } // ====================== 批量补全 UP 主信息(GM菜单) ====================== function showMiniMsg(text) { let el = document.getElementById('bili-view-mini-msg'); if (!el) { el = document.createElement('div'); el.id = 'bili-view-mini-msg'; el.style.cssText = 'position:fixed;top:24px;left:50%;transform:translateX(-50%);background:rgba(16,16,22,0.92);color:#fff;padding:10px 22px;border-radius:10px;font-size:13px;z-index:100003;box-shadow:0 6px 24px rgba(0,0,0,0.5);pointer-events:none;opacity:0;transition:opacity 0.25s;white-space:nowrap;'; document.body.appendChild(el); } el.textContent = text; el.style.opacity = '1'; clearTimeout(el._t); el._t = setTimeout(() => { el.style.opacity = '0'; }, 3000); } async function backfillUnknownUps() { showMiniMsg('🔄 正在补全 UP 主 / 视频标题…'); let records = []; try { records = await getAllRecords(); } catch (e) {} const missing = records.filter(r => !r.uid || !r.title); if (!missing.length) { showMiniMsg('✅ 没有需要补全的记录'); return; } let ok = 0, fail = 0; for (const r of missing) { const meta = await fetchUpMetaByApi(r.id); if (meta && (await updateMeta(r.id, meta))) ok++; else fail++; await new Promise(s => setTimeout(s, 200)); // 轻微限速,避免触发风控 } showMiniMsg(`✅ 补全完成:成功 ${ok},失败 ${fail}(共 ${missing.length})`); if (document.getElementById('bili-view-stats-panel')) showStatsPanel(); // 面板开着则刷新 } // ====================== 暴露给其他脚本(1.js) ====================== const _global = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; // 返回 Promise<{ BV1xxx: times, ... }> _global.__biliViewCountGetAll = async function () { try { const db = await openDB(); return await new Promise((resolve) => { const tx = db.transaction(STORE, 'readonly'); const req = tx.objectStore(STORE).getAll(); req.onsuccess = () => { const map = {}; req.result.forEach(r => { map[r.id] = r.times; }); resolve(map); }; req.onerror = () => resolve({}); }); } catch (e) { return {}; } }; _global.__biliViewCountLoaded = true; // ====================== 初始化 ====================== handleNav(); GM_registerMenuCommand('观看统计', showStatsPanel); GM_registerMenuCommand('补全视频信息(UP主/标题)', backfillUnknownUps); })();