// ==UserScript== // @name B站视频观看次数统计 // @namespace http://tampermonkey.net/ // @version 1.4 // @description 每次刷新/跳转进入视频页时观看次数+1,用 🔁 显示在视频信息栏(播放量/弹幕那一栏),数据存 IndexedDB。静默运行,不做实时监听,性能零负担 // @author Anonymity // @match *://www.bilibili.com/video/* // @grant GM_registerMenuCommand // @run-at document-end // ==/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", times: num, uid: "UP主UID", name: "UP主名", lastTime: 时间戳 } async function increment(bv, meta) { 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; } 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(() => []); } // ====================== 计数核心 ====================== 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]; } } if (!name) { const n = document.querySelector('.up-name'); if (n) name = n.textContent.trim(); } return { uid, name }; } // ====================== 30s 确认窗口计数 ====================== // 兜底方案:检测到新BV后不立即计数,进入 CONFIRM_MS 确认窗口。 // 窗口内一切重复触发(页面初始化/路由跳转/切P回调同时到达)都被合并; // 30s 后复查仍是同一 BV(确实停留在该视频)才 +1。 const CONFIRM_MS = 30000; let countedBv = null; // 本会话已计费的 BV:不再重计(切P/SPA回看都安全) 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 === countedBv) { render(bv); return; } // 本会话已计过:只补显示,不重计 if (bv === pendingBv) return; // 确认窗口内:合并瞬时重复触发 // 新 BV:取消旧窗口,开启 30s 确认 cancelConfirm(); pendingBv = bv; confirmTimer = setTimeout(async () => { const still = getBv(); const target = pendingBv; confirmTimer = null; pendingBv = null; if (still !== target) return; // 已跳走:放弃本次计数 countedBv = target; try { const times = await increment(target, getUpInfo()); timesCache.set(target, times); } 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() { 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'; panel.style.cssText = ` 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; `; let html = '
| ${h} | `).join('') + '|||
|---|---|---|---|
| ${g.uid === '未知' ? '未知' : escapeHtml(g.uid)} | ` + `${name} | ` + `${g.times} | ` + `${lastStr} | ` + `