// ==UserScript== // @name Bilibili Immediate Comments Preload // @name:zh-TW Bilibili 留言區提前預載 // @namespace https://github.com/jellycat/bilibili-watchitlater-quickdigest // @version 0.1.0 // @description Preload Bilibili video comments on page load so the comment section is ready before you scroll to it. // @description:zh-TW 在 Bilibili 影片頁一開啟時就提前載入留言區,避免每次都要先往下捲再等留言動態出現。 // @author jellycat // @match https://www.bilibili.com/video/* // @run-at document-start // @grant none // @noframes // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/574431/Bilibili%20Immediate%20Comments%20Preload.user.js // @updateURL https://update.greasyfork.icu/scripts/574431/Bilibili%20Immediate%20Comments%20Preload.meta.js // ==/UserScript== (() => { 'use strict'; const DEBUG_KEY = '__BILI_COMMENT_PRELOAD_USERSCRIPT__'; const DISABLE_AUTOBOOT_KEY = '__BILI_COMMENT_PRELOAD_DISABLE_AUTOBOOT__'; const NAV_EVENT = 'bili-comment-preload:urlchange'; const NAV_HOOK_FLAG = '__biliCommentPreloadNavHookInstalled__'; const HOST_ATTEMPTS = new WeakMap(); const MAX_ATTEMPTS_PER_HOST = 3; const MIN_RETRY_INTERVAL_MS = 1_500; const WAIT_TIMEOUT_MS = 15_000; const POLL_INTERVAL_MS = 250; const RETRY_DELAYS_MS = [0, 400, 1_200, 3_000]; let navigationToken = 0; function isVideoPage(url = globalThis.location?.href ?? '') { return /^https:\/\/www\.bilibili\.com\/video\/[^/?#]+/i.test(String(url)); } function getCommentHost(root = document) { return root.querySelector('#commentapp > bili-comments'); } function hasTruthyAttribute(element, name) { if (!element?.hasAttribute?.(name)) { return false; } const value = element.getAttribute(name); return value !== 'false'; } function normalizeOptionalValue(value) { return value == null || value === '' ? undefined : value; } function splitDataParams(host) { const raw = host?.getAttribute?.('data-params') ?? ''; const [type = '', oid = ''] = raw.split(','); return { type, oid }; } function hasLoadedThreads(host) { const shadowRoot = host?.shadowRoot; return Boolean(shadowRoot?.querySelector('bili-comment-thread-renderer')); } function extractReloadOptions(host) { if (!host) { return null; } const { type, oid } = splitDataParams(host); if (!type || !oid) { return null; } const options = { oid, type, lazyLoad: false, spmPrefix: host.getAttribute('spm-prefix') ?? '', contentFeatures: { videoTime: !hasTruthyAttribute(host, 'disable-video-time') }, fixedCommentBox: host.getAttribute('fixed-comment-box') !== 'false', disableUpActions: hasTruthyAttribute(host, 'disable-up-actions') }; const optionalMap = { mode: 'mode', seekRpid: 'seek-id', maxViewLimit: 'max-view-limit', cmFromTrackId: 'cm-from-track-id' }; for (const [optionKey, attributeName] of Object.entries(optionalMap)) { const value = normalizeOptionalValue(host.getAttribute(attributeName)); if (value !== undefined) { options[optionKey] = value; } } return options; } function markPreloadAttempt(host) { const previous = HOST_ATTEMPTS.get(host) ?? { count: 0, lastAttemptAt: 0 }; const next = { count: previous.count + 1, lastAttemptAt: Date.now() }; HOST_ATTEMPTS.set(host, next); return next; } function canAttemptPreload(host) { const state = HOST_ATTEMPTS.get(host) ?? { count: 0, lastAttemptAt: 0 }; return state.count < MAX_ATTEMPTS_PER_HOST && Date.now() - state.lastAttemptAt >= MIN_RETRY_INTERVAL_MS; } function preloadHost(host, reason = 'unknown') { if (!host || typeof host.reload !== 'function' || hasLoadedThreads(host) || !canAttemptPreload(host)) { return false; } const reloadOptions = extractReloadOptions(host); if (!reloadOptions) { return false; } host.removeAttribute('lazy-load'); try { host.lazyLoad = false; } catch { // Ignore assignment failures on readonly descriptors. } markPreloadAttempt(host); try { host.reload(reloadOptions); host.dataset.biliCommentPreloaded = reason; return true; } catch (error) { console.warn('[bili-comment-preload] reload failed:', error); return false; } } function waitForUpgradedHost({ timeoutMs = WAIT_TIMEOUT_MS, token } = {}) { return new Promise((resolve) => { const deadline = Date.now() + timeoutMs; let pollTimer = null; let timeoutTimer = null; let observer = null; function cleanup() { if (pollTimer) { clearInterval(pollTimer); } if (timeoutTimer) { clearTimeout(timeoutTimer); } observer?.disconnect(); } function finish(host) { cleanup(); resolve(host ?? null); } function isStale() { return token != null && token !== navigationToken; } function probe() { if (isStale()) { finish(null); return; } const host = getCommentHost(); if (host && typeof host.reload === 'function') { finish(host); return; } if (Date.now() >= deadline) { finish(host ?? null); } } if (document.documentElement) { observer = new MutationObserver(probe); observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true }); } pollTimer = setInterval(probe, POLL_INTERVAL_MS); timeoutTimer = setTimeout(() => finish(getCommentHost()), timeoutMs); if (globalThis.customElements?.whenDefined) { globalThis.customElements.whenDefined('bili-comments').then(probe).catch(() => {}); } document.addEventListener('DOMContentLoaded', probe, { once: true }); globalThis.addEventListener('load', probe, { once: true }); probe(); }); } async function runPreloadPass(reason = 'manual', token = navigationToken) { if (!isVideoPage()) { return false; } const host = await waitForUpgradedHost({ token }); if (!host || token !== navigationToken) { return false; } if (hasLoadedThreads(host)) { return false; } return preloadHost(host, reason); } function schedulePreload(reason = 'schedule') { if (!isVideoPage()) { return; } const token = ++navigationToken; RETRY_DELAYS_MS.forEach((delayMs, index) => { globalThis.setTimeout(() => { if (token !== navigationToken) { return; } runPreloadPass(`${reason}:${index}`, token).catch((error) => { console.warn('[bili-comment-preload] scheduled preload failed:', error); }); }, delayMs); }); } function dispatchUrlChange(kind) { globalThis.dispatchEvent(new CustomEvent(NAV_EVENT, { detail: { kind, href: globalThis.location?.href ?? '' } })); } function installNavigationHooks() { if (globalThis[NAV_HOOK_FLAG]) { return; } globalThis[NAV_HOOK_FLAG] = true; for (const methodName of ['pushState', 'replaceState']) { const original = history[methodName]; if (typeof original !== 'function') { continue; } history[methodName] = function patchedHistoryMethod(...args) { const result = original.apply(this, args); queueMicrotask(() => dispatchUrlChange(methodName)); return result; }; } globalThis.addEventListener(NAV_EVENT, () => schedulePreload('urlchange')); globalThis.addEventListener('popstate', () => dispatchUrlChange('popstate')); globalThis.addEventListener('hashchange', () => dispatchUrlChange('hashchange')); } function boot() { installNavigationHooks(); schedulePreload('boot'); document.addEventListener('DOMContentLoaded', () => schedulePreload('domcontentloaded'), { once: true }); globalThis.addEventListener('load', () => schedulePreload('load'), { once: true }); } const api = { isVideoPage, getCommentHost, splitDataParams, hasLoadedThreads, extractReloadOptions, preloadHost, waitForUpgradedHost, runPreloadPass, schedulePreload, boot }; globalThis[DEBUG_KEY] = api; if (!globalThis[DISABLE_AUTOBOOT_KEY]) { boot(); } })();