Bilibili 视频观看历史记录 优化版

记录并提示 Bilibili 已访问 / 已观看 视频(兼容 2025 年 B 站与最新 Chrome)

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Bilibili 视频观看历史记录 优化版
// @namespace    Bilibili-video-History-2025
// @version      1.4.6
// @description  记录并提示 Bilibili 已访问 / 已观看 视频(兼容 2025 年 B 站与最新 Chrome)
// @author       DreamNya + ITLinya (优化)
// @match        https://www.bilibili.com/*
// @match        https://space.bilibili.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_addStyle
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

/*
核心思想:
1. 优先使用页面 video 元素的事件(timeupdate / ended / play)保存观看信息(节流)。
2. 使用 beforeunload / visibilitychange 做最后一次保存(兼容直接关页的情况)。
3. 使用 MutationObserver 侦测单页路由和推荐区变化(避免频繁全页轮询)。
4. 用原生 DOM 操作为主,避免与 B 站和其它脚本冲突。
5. 存储格式兼容旧版:[type, watchTimeString, percentString, timeStamp, title]
*/

(function () {
    'use strict';

    /* ---------- 配置 ---------- */
    const SHOW_PROGRESS_BAR = true;       // 是否在缩略图上显示小进度条
    const SAVE_THROTTLE_SECONDS = 10;     // timeupdate 节流保存间隔(秒)
    const TAG_SCAN_INTERVAL = 3000;       // 在列表页面扫描插入标签的节流(毫秒)
    const MAX_TAG_LIVE_SECONDS = 600;     // 卡片标签最长存活(秒),防止页面长期堆积
    const CSS_ZINDEX = 99999;
    const FULLSCREEN_CHECK_INTERVAL = 800; // 检测网页全屏状态的轮询间隔(毫秒)

    /* ---------- 样式 ---------- */
    GM_addStyle(`
    /* 封面左上角标签:绝对定位覆盖在预览图上,不再挤压布局 */
    .BvH-tag{
        position:absolute;
        top:4px;
        left:4px;
        padding:0 6px;
        height:20px;
        line-height:20px;
        border-radius:4px;
        color:#fff;
        font-size:12px;
        background:rgba(122,134,234,0.85);
        z-index:${CSS_ZINDEX};
        box-sizing:border-box;
    }
    .BvH-tag-small{
        padding:0 4px;
        height:18px;
        line-height:18px;
        font-size:10px;
    }
    .BvH-progress-bar{
        position:absolute;
        left:0;
        bottom:0;
        height:4px;
        border-bottom-left-radius:inherit;
        border-bottom-right-radius:inherit;
        background:#ff3636;
        z-index:${CSS_ZINDEX};
    }
    .BvH-fixed-view{
        position:fixed;
        bottom:15px;
        left:15px;
        text-align:center;
        border-left:6px solid #2196F3;
        background-color:#aeffff;
        font-family:Segoe UI,Helvetica,Arial;
        font-weight:700;
        z-index:${CSS_ZINDEX+1};
    }
    `);

    /* ---------- 工具函数 ---------- */
    const nowTimeStr = () => {
        const d = new Date();
        const pad = (n) => (n < 10 ? '0' + n : '' + n);
        return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
    };

    const secToTimeStr = (sec) => {
        if (!isFinite(sec) || sec <= 0) return '00:00';
        sec = Math.round(sec);
        const h = Math.floor(sec / 3600); sec %= 3600;
        const m = Math.floor(sec / 60); const s = sec % 60;
        return (h > 0 ? String(h).padStart(2, '0') + ':' : '') + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
    };

    const percentStr = (cur, total) => {
        if (!isFinite(cur) || !isFinite(total) || total === 0) return '';
        return Math.round((cur / total) * 100) + '%';
    };

    // 解析当前页 BV/av 与 p(返回 "BVxxxxx" 或 "av123" + 可带 ?p=n)
    function parseVideoIdFromUrl(url) {
        try {
            const u = new URL(url, location.origin);
            const path = u.pathname;
            const match = path.match(/(BV[0-9A-Za-z]+|av\d+)/);
            if (match) {
                const base = match[1];
                const p = u.searchParams.get('p');
                return p ? `${base}?p=${p}` : base;
            }
            const searchAll = u.href;
            const m2 = searchAll.match(/(BV[0-9A-Za-z]+|av\d+)(\?p=\d+)?/);
            if (m2) return m2[0];
        } catch (e) {}
        return null;
    }

    // 尝试从 __INITIAL_STATE__ 获取(优先)
    function getVideoKeyFallback() {
        try {
            if (typeof window.__INITIAL_STATE__ !== 'undefined' && window.__INITIAL_STATE__.bvid) {
                const bvid = window.__INITIAL_STATE__.bvid;
                const p = (window.__INITIAL_STATE__.p && window.__INITIAL_STATE__.p > 1) ? `?p=${window.__INITIAL_STATE__.p}` : '';
                return bvid + p;
            }
        } catch (e) {}
        const meta = document.querySelector('meta[property="og:url"], meta[name="og:url"]');
        if (meta && meta.getAttribute('content')) {
            const parsed = parseVideoIdFromUrl(meta.getAttribute('content'));
            if (parsed) return parsed;
        }
        return parseVideoIdFromUrl(location.href);
    }

    // 存储 key -> value 数组(兼容旧格式)
    function saveRecord(key, arr) {
        try {
            GM_setValue(key, arr);
        } catch (e) {
            try { localStorage.setItem('BvH_' + key, JSON.stringify(arr)); } catch (e2) {}
        }
    }
    function readRecord(key) {
        try {
            const v = GM_getValue(key);
            if (v !== undefined) return v;
        } catch (e) {}
        try {
            const raw = localStorage.getItem('BvH_' + key);
            if (raw) return JSON.parse(raw);
        } catch (e) {}
        return null;
    }
    function deleteRecord(key) {
        try { GM_deleteValue(key); } catch (e) {}
        try { localStorage.removeItem('BvH_' + key); } catch (e) {}
    }

    // 统一:根据基础 key(BVxxx 或 BVxxx?p=2)拿记录,同时兼容 GM_listValues 里带 ?p= 的实际 key
    function getRecordForBaseKey(baseKey) {
        let key = baseKey;
        let rec = readRecord(key);
        if (!rec && typeof GM_listValues === 'function') {
            try {
                const allKeys = GM_listValues();
                const baseId = key.split('?')[0].toLowerCase();
                const alt = allKeys.find(k => k.split('?')[0].toLowerCase() === baseId);
                if (alt) {
                    key = alt;
                    rec = readRecord(alt);
                }
            } catch (e) {}
        }
        return { key, rec };
    }

    /* ---------- 全屏状态检测 & 左下角提示隐藏 ---------- */

    function isInAnyFullscreenMode() {
        // 1. 浏览器原生全屏(F11 / 播放器全屏按钮)
        if (document.fullscreenElement) return true;

        // 2. 播放器自身的网页全屏 / 全屏模式 class
        if (document.querySelector(
            '#bilibiliPlayer.mode-webfullscreen,' +
            '#bilibiliPlayer.mode-fullscreen,' +
            '#bilibili-player.mode-webfullscreen,' +
            '#bilibili-player.mode-fullscreen,' +
            '#bilibili-player.mode-webscreen'
        )) {
            return true;
        }

        return false;
    }

    function updateFixedViewVisibility() {
        const el = document.getElementById('BvH_fixed_view');
        if (!el) return;
        if (isInAnyFullscreenMode()) {
            el.style.display = 'none';
        } else {
            el.style.display = '';
        }
    }

    document.addEventListener('fullscreenchange', updateFixedViewVisibility);
    setInterval(updateFixedViewVisibility, FULLSCREEN_CHECK_INTERVAL);

    /* ---------- 视频页面:记录播放进度逻辑 ---------- */
    let saveThrottleTimer = null;
    function setupVideoRecording() {
        const video = document.querySelector('video');
        if (!video) return null;

        let key = getVideoKeyFallback();
        if (!key) return null;

        const titleFallback =
            document.title ||
            document.querySelector('meta[property="og:title"]')?.content ||
            '';

        const existing = readRecord(key);

        function showFixedView(record) {
            const id = 'BvH_fixed_view';
            let el = document.getElementById(id);
            if (el) el.remove();
            if (!record) return;
            const [type, watchTime, pct, timestamp] = record;

            const html = document.createElement('div');
            html.id = id;
            html.className = 'BvH-fixed-view';
            html.title = `${key}\n左键单击跳转至上次观看进度\n右键单击删除记录`;
            const day = (timestamp || '').split(' ')[0] || '';
            const time = (timestamp || '').split(' ')[1] || '';
            html.innerHTML =
                `<p style="margin:6px 10px 4px 10px">${type}${watchTime ? '<br/>' + watchTime + ' (' + (pct || '') + ')' : ''}</p>` +
                `<p style="margin:0 10px 6px 10px;font-size:12px">${day}<br/>${time}</p>`;
            document.body.appendChild(html);

            html.addEventListener('click', () => {
                if (!watchTime) return;
                const parts = watchTime.split(':').reverse().map((it, idx) => Number(it) * Math.pow(60, idx));
                const t = parts.reduce((a, b) => a + b, 0);
                if (isFinite(t) && t > 0) {
                    video.currentTime = t;
                    video.play().catch(() => {});
                }
            });
            html.addEventListener('contextmenu', (ev) => {
                ev.preventDefault();
                deleteRecord(key);
                html.remove();
            });

            updateFixedViewVisibility();
        }

        if (existing) {
            setTimeout(() => showFixedView(existing), 800);
        }

        function doSave(final = false) {
            const cur = video.currentTime;
            const dur = video.duration;
            if (!isFinite(dur) || dur <= 0) {
                saveRecord(key, ['已访问', '', '', nowTimeStr(), titleFallback]);
                return;
            }
            const watchTime = secToTimeStr(cur);
            const pct = percentStr(cur, dur);
            const record = ['已观看', watchTime, pct, nowTimeStr(), titleFallback];
            saveRecord(key, record);
            if (final) {
                // no-op
            }
            showFixedView(record);
        }

        let lastSavedAt = 0;
        const throttleSave = (final = false) => {
            const now = Date.now();
            if (final || (now - lastSavedAt) / 1000 >= SAVE_THROTTLE_SECONDS) {
                doSave(final);
                lastSavedAt = now;
            } else {
                if (saveThrottleTimer) clearTimeout(saveThrottleTimer);
                const waitMs = (SAVE_THROTTLE_SECONDS - ((now - lastSavedAt) / 1000)) * 1000 + 50;
                saveThrottleTimer = setTimeout(() => {
                    doSave();
                    lastSavedAt = Date.now();
                    saveThrottleTimer = null;
                }, waitMs);
            }
        };

        const onPlay = () => { throttleSave(false); };
        const onTimeUpdate = () => { throttleSave(false); };
        const onEnded = () => { throttleSave(true); };
        video.addEventListener('play', onPlay);
        video.addEventListener('timeupdate', onTimeUpdate);
        video.addEventListener('ended', onEnded);

        const onVisibility = () => {
            if (document.visibilityState === 'hidden') {
                throttleSave(true);
            }
        };
        const onBeforeUnload = () => { throttleSave(true); };

        document.addEventListener('visibilitychange', onVisibility);
        window.addEventListener('beforeunload', onBeforeUnload);

        return () => {
            video.removeEventListener('play', onPlay);
            video.removeEventListener('timeupdate', onTimeUpdate);
            video.removeEventListener('ended', onEnded);
            document.removeEventListener('visibilitychange', onVisibility);
            window.removeEventListener('beforeunload', onBeforeUnload);
            if (saveThrottleTimer) {
                clearTimeout(saveThrottleTimer);
                saveThrottleTimer = null;
            }
        };
    }

    /* ---------- 列表/首页/UP 空间:标注缩略图的小标签逻辑 ---------- */
    const BV_REGEX = /((BV|bv)[0-9A-Za-z]+(\?p=\d+)?)|(av\d+(\?p=\d+)?)/i;

    function createTagElement(text, title, small = false) {
        const d = document.createElement('div');
        d.className = 'BvH-tag' + (small ? ' BvH-tag-small' : '');
        d.title = title || '';
        d.innerHTML = text;
        return d;
    }

    // 在一个块元素上(可以是 <a>、<li>、<div>)插入标签和进度条
    function tryInsertTagOnLink(rootEl, text, title, percent, small) {
        if (!rootEl || !(rootEl instanceof Element)) return;

        const oldTag = rootEl.querySelector('.BvH-tag');
        if (oldTag) {
            if (oldTag.innerText === text) return;
            oldTag.remove();
        }
        const oldBar = rootEl.querySelector('.BvH-progress-bar');
        if (oldBar) oldBar.remove();

        const img = rootEl.querySelector('img, .bili-awesome-img, .lazy-img, .cover-image');
        if (!img || !img.parentElement) return;

        const container = img.parentElement;
        const computedPos = window.getComputedStyle(container).position;
        if (computedPos === 'static' || !computedPos) {
            container.style.position = 'relative';
        }

        const tag = createTagElement(text, title, small);
        tag.style.position = 'absolute';
        tag.style.top = '4px';
        tag.style.left = '4px';

        container.appendChild(tag);

        if (percent && SHOW_PROGRESS_BAR) {
            const bar = document.createElement('div');
            bar.className = 'BvH-progress-bar';
            const num = Number((percent || '').replace('%', ''));
            const width = Math.max(3, isFinite(num) ? num : 0) + '%';
            bar.style.width = width;
            container.appendChild(bar);
        }

        setTimeout(() => {
            if (tag && tag.parentElement) tag.remove();
        }, MAX_TAG_LIVE_SECONDS * 1000);
    }

    let scanning = false;
    function scanAndTag() {
        if (scanning) return;
        scanning = true;
        try {
            /* —— 1. 常规:扫描所有 a[href] 里的 BV/av 视频链接 —— */
            const links = Array.from(document.querySelectorAll('a[href*="BV"], a[href*="bv"], a[href*="av"]'));
            links.forEach(a => {
                try {
                    if (!a) return;

                    // 如果在顶栏 / 头部区域:
                    // 只有带缩略图(img 等)的链接才参与打标签(用于“历史 / 收藏”下拉中的视频卡片)
                    const inHeader =
                        a.closest('header') ||
                        a.closest('#biliMainHeader') ||
                        a.closest('.bili-header') ||
                        a.closest('.bili-header__bar') ||
                        a.closest('.international-header') ||
                        a.closest('.bili-app-header');
                    if (inHeader) {
                        const thumb = a.querySelector('img, .bili-awesome-img, .lazy-img, .cover-image');
                        if (!thumb) return; // 顶栏纯文本按钮直接跳过
                    }

                    const href = a.getAttribute('href');
                    if (!href) return;
                    const m = href.match(BV_REGEX);
                    if (!m) return;

                    let base = m[0].replace(/\?p=1$/, '');
                    const { key, rec } = getRecordForBaseKey(base);
                    if (!rec) return;

                    const [type, watchTime, pct, timestamp] = rec;
                    const text = key.includes('?p=') ? '已记录 多P' : `${type}${pct ? pct : ''}`;
                    const title = `${key}\n${timestamp || ''}\n${watchTime || ''}`;

                    const imgEl = a.querySelector('img');
                    const small = imgEl ? (imgEl.naturalWidth > 0 && imgEl.naturalWidth < 83) : false;

                    tryInsertTagOnLink(a, text, title, pct, small);
                } catch (e) {
                    // ignore single link
                }
            });

            /* —— 2. 合集 / 播放列表左侧的条目(通常带 data-bvid) —— */
            const playlistItems = Array.from(document.querySelectorAll(
                '[data-bvid], [data-bv-id], [data-bv]'
            ));

            playlistItems.forEach(item => {
                try {
                    if (!item || item.querySelector('.BvH-tag')) return;

                    const bvid =
                        item.getAttribute('data-bvid') ||
                        item.getAttribute('data-bv-id') ||
                        item.getAttribute('data-bv');
                    if (!bvid) return;

                    const { key, rec } = getRecordForBaseKey(bvid);
                    if (!rec) return;

                    const [type, watchTime, pct, timestamp] = rec;
                    const text = `${type}${pct ? pct : ''}`;
                    const title = `${key}\n${timestamp || ''}\n${watchTime || ''}`;

                    const imgEl = item.querySelector('img');
                    const small = imgEl ? (imgEl.naturalWidth > 0 && imgEl.naturalWidth < 83) : false;

                    tryInsertTagOnLink(item, text, title, pct, small);
                } catch (e) {
                    // ignore
                }
            });
        } finally {
            scanning = false;
            setTimeout(scanAndTag, TAG_SCAN_INTERVAL);
        }
    }

    /* ---------- 单页路由变化处理 ---------- */
    let currentRouteKey = null;
    let currentVideoUnbind = null;

    function handleRoute() {
        const key = location.pathname + location.search;
        if (key === currentRouteKey) return;

        if (typeof currentVideoUnbind === 'function') {
            try { currentVideoUnbind(); } catch (e) {}
            currentVideoUnbind = null;
        }
        currentRouteKey = key;

        const vid = getVideoKeyFallback();
        if (!vid) return;

        // 兼容视频元素晚挂载:短时间重试几次
        let retry = 0;
        const maxRetry = 10;
        const trySetup = () => {
            const unbind = setupVideoRecording();
            if (unbind) {
                currentVideoUnbind = unbind;
            } else if (retry++ < maxRetry) {
                setTimeout(trySetup, 300);
            }
        };
        trySetup();
    }

    const routeObserver = new MutationObserver(() => {
        if (routeObserver._timer) clearTimeout(routeObserver._timer);
        routeObserver._timer = setTimeout(() => {
            handleRoute();
        }, 120);
    });

    routeObserver.observe(document.documentElement || document.body, { childList: true, subtree: true });

    handleRoute();
    setTimeout(scanAndTag, 500);

    /* ---------- 提示用户脚本已加载(简洁) ---------- */
    (function floatingLoadHint() {
        const id = 'BvH_loaded_hint';
        if (document.getElementById(id)) return;
        const el = document.createElement('div');
        el.id = id;
        el.style.position = 'fixed';
        el.style.right = '12px';
        el.style.bottom = '12px';
        el.style.padding = '6px 10px';
        el.style.background = 'rgba(0,0,0,0.6)';
        el.style.color = '#fff';
        el.style.borderRadius = '6px';
        el.style.fontSize = '12px';
        el.style.zIndex = CSS_ZINDEX + 2;
        el.textContent = 'BvH: 观看记录脚本已加载';
        document.body.appendChild(el);
        setTimeout(() => el.remove(), 3500);
    })();

})();