// ==UserScript== // @name Bilibili Accelerator // @name:zh-CN Bilibili Accelerator - B站海外播放加速 // @namespace https://github.com/realzza/bilibili-accelerator // @version 0.3.0 // @description Smoother Bilibili playback for overseas viewers. // @description:zh-CN 缓解海外用户看 B 站冷门视频时的卡顿。 // @author realzza // @license MIT // @homepageURL https://github.com/realzza/bilibili-accelerator // @supportURL https://github.com/realzza/bilibili-accelerator/issues // @match https://*.bilibili.com/* // @match https://*.bilibili.tv/* // @run-at document-start // @grant none // ==/UserScript== (function initBiliAcceleratorCore(root, factory) { const core = factory(); if (typeof module === "object" && module.exports) { module.exports = core; } root.BiliAcceleratorCore = core; })(typeof globalThis !== "undefined" ? globalThis : window, function createCore() { "use strict"; const SCHEMA_VERSION = 2; // Healthy UPOS mirrors we are willing to rewrite toward. The first entry is // the default target; the whole list seeds the auto-selection candidate pool. const CDN_HOSTS = Object.freeze([ "upos-sz-mirrorcos.bilivideo.com", "upos-sz-mirrorali.bilivideo.com", "upos-sz-mirrorhw.bilivideo.com", "upos-tf-all-hw.bilivideo.com", "upos-tf-all-tx.bilivideo.com", "upos-hz-mirrorakam.akamaized.net", "upos-sz-mirrorakam.akamaized.net", "upos-sz-mirroraliov.bilivideo.com", "upos-sz-mirrorcosov.bilivideo.com", "upos-sz-mirrorhwov.bilivideo.com" ]); // Panel appearance options. Keys only — the actual accent hexes and dark/light // surface tokens live in the page UI; core just owns the allowed values so a // stored or bridged config can be validated back to a known-good default. const ACCENT_KEYS = Object.freeze([ "bili", // Bilibili blue (default — unchanged for existing users) "teal", "emerald", "violet", "pink", // Little-TV pink "sunset", "graphite" ]); const THEME_MODES = Object.freeze(["system", "light", "dark"]); // Candidates that are safe to auto-probe and rank: non-akamai, non-overseas // mirrors that work well as generic rewrite targets. const CANDIDATE_POOL = Object.freeze([ "upos-sz-mirrorcos.bilivideo.com", "upos-sz-mirrorali.bilivideo.com", "upos-sz-mirrorhw.bilivideo.com", "upos-tf-all-hw.bilivideo.com", "upos-tf-all-tx.bilivideo.com" ]); const DEFAULT_CONFIG = Object.freeze({ enabled: true, lang: "en", // en | zh (UI language) accent: "bili", // panel accent (see ACCENT_KEYS) theme: "system", // system | light | dark surface mode: "bad-only", // bad-only | force | off selection: "auto", // auto | fixed pcdnHost: "upos-sz-mirrorcos.bilivideo.com", candidatePool: CANDIDATE_POOL.slice(), mcdnStrategy: "proxy-all", // proxy-all | proxy-v1 | replace proxyHost: "proxy-tf-all-ws.bilivideo.com", rewriteAkamai: false, portHeuristic: true, // non-default port ⇒ PCDN stallRecovery: true, // live failover on buffering p2pGuard: false, // opt-in WebRTC/PCDN neutralizer maxDepth: 20, schemaVersion: SCHEMA_VERSION }); const MEDIA_PATH_RE = /\.(m4s|mp4|flv|m3u8)(?:$|[?#])/i; const IP_RE = /^(?:\d{1,3}\.){3}\d{1,3}$/; const XY_MCDN_RE = /^xy(?:\d+x){3}\d+xy\.mcdn\.bilivideo\.(?:cn|com|net)$/i; // P2P/PCDN families known from community research (Bilibili-Evolved, MBGTEB): // szbdyd is the legacy scheduler, mountaintoys the 2025 rename, nexusedgeio and // ahdohpiechei are where the upos-*302* redirect hosts land, and mirror14b is a // mirror-named host that actually serves PCDN (its TLS cert is *.bilivideo.cn). const KNOWN_P2P_SUFFIXES = Object.freeze([ ".szbdyd.com", ".mountaintoys.cn", ".nexusedgeio.com", ".ahdohpiechei.com" ]); const KNOWN_P2P_HOSTS = Object.freeze([ "upos-sz-mirror14b.bilivideo.com" ]); function isKnownP2pHost(hostname) { if (KNOWN_P2P_HOSTS.indexOf(hostname) !== -1) { return true; } for (let i = 0; i < KNOWN_P2P_SUFFIXES.length; i += 1) { if (hostname.length > KNOWN_P2P_SUFFIXES[i].length && hostname.indexOf(KNOWN_P2P_SUFFIXES[i]) === hostname.length - KNOWN_P2P_SUFFIXES[i].length) { return true; } } // upos-sz-302ppio / upos-sz-302kodo style hosts answer with an HTTP 302 to a // residential P2P node; the "302" only ever appears in that first label. return hostname.indexOf("upos-") === 0 && hostname.split(".")[0].indexOf("302") !== -1; } // Forward-migrate any stored config (v1 or partial) onto the v2 defaults. function normalizeConfig(config) { const merged = Object.assign({}, DEFAULT_CONFIG, config || {}); if (!Array.isArray(merged.candidatePool) || merged.candidatePool.length === 0) { merged.candidatePool = CANDIDATE_POOL.slice(); } if (merged.mode !== "bad-only" && merged.mode !== "force" && merged.mode !== "off") { merged.mode = DEFAULT_CONFIG.mode; } if (merged.selection !== "auto" && merged.selection !== "fixed") { merged.selection = DEFAULT_CONFIG.selection; } if (merged.lang !== "en" && merged.lang !== "zh") { merged.lang = DEFAULT_CONFIG.lang; } if (ACCENT_KEYS.indexOf(merged.accent) === -1) { merged.accent = DEFAULT_CONFIG.accent; } if (THEME_MODES.indexOf(merged.theme) === -1) { merged.theme = DEFAULT_CONFIG.theme; } merged.schemaVersion = SCHEMA_VERSION; return merged; } function hasBiliMediaSignal(value) { return typeof value === "string" && (value.includes("bilivideo") || value.includes("akamaized.net") || value.includes("szbdyd.com") || value.includes("mountaintoys") || value.includes("nexusedgeio") || value.includes("ahdohpiechei") || value.includes("mcdn.bili") || value.includes("os=mcdn") || value.includes("/upgcxcode/") || value.includes("/v1/resource/")); } function parseUrl(value) { if (!hasBiliMediaSignal(value)) { return null; } try { // Payloads occasionally carry protocol-relative URLs ("//host/path"). const url = new URL(value.slice(0, 2) === "//" ? "https:" + value : value); if (url.protocol !== "http:" && url.protocol !== "https:") { return null; } return url; } catch (_) { return null; } } function isMediaUrl(url) { return MEDIA_PATH_RE.test(url.pathname + url.search) || url.pathname.startsWith("/upgcxcode/") || url.pathname.startsWith("/v1/resource/"); } // Live streams (/live-bvc/ FLV & HLS) are served by a separate CDN tier — the // VOD upos mirrors and the MCDN proxy cannot serve them, so a host swap or // proxy wrap would hard-break live playback. Live PCDN is handled upstream by // filtering the getRoomPlayInfo host list instead (see filterLiveUrlInfo). function isLiveMediaUrl(url) { return url.pathname.indexOf("/live-bvc/") !== -1; } function isMcdnHost(hostname) { return /\.mcdn\.bilivideo\.(?:cn|com|net)$/i.test(hostname); } function isBiliCdnHost(hostname) { return hostname.endsWith(".bilivideo.com") || hostname.endsWith(".bilivideo.cn") || hostname.endsWith(".bilivideo.net") || hostname.endsWith(".akamaized.net"); } function hasNonDefaultPort(url) { // URL drops the port when it matches the protocol default (80/443), so any // remaining port string means a non-standard endpoint — a strong PCDN tell. return url.port !== "" && url.port !== "80" && url.port !== "443"; } function hasMcdnQuery(url) { return url.searchParams.get("os") === "mcdn" || /(?:^|[?&])os=mcdn(?:&|$)/i.test(url.search); } function isOverseasMirror(hostname) { return hostname.includes("mirroraliov") || hostname.includes("mirrorcosov") || hostname.includes("mirrorhwov"); } // Single source of truth for "what is this host, and is it slow for us". // Behavior-based so renamed PCDN families (e.g. *.edge.mountaintoys.cn) are // caught by the port/os=mcdn heuristics without needing a hostname update. function classify(url, rawConfig) { const config = normalizeConfig(rawConfig); const hostname = url.hostname.toLowerCase(); let schedulerSource = null; if (hostname.endsWith(".szbdyd.com")) { schedulerSource = cleanHost(url.searchParams.get("xy_usource") || "") || null; } const ipLike = IP_RE.test(hostname); const xyMcdn = XY_MCDN_RE.test(hostname); const mcdn = isMcdnHost(hostname); const akamai = hostname.endsWith(".akamaized.net"); const portPcdn = config.portHeuristic && hasNonDefaultPort(url); const queryMcdn = hasMcdnQuery(url); const knownP2p = isKnownP2pHost(hostname); const isPcdn = ipLike || xyMcdn || portPcdn || queryMcdn || knownP2p; let kind = "unknown"; if (schedulerSource !== null || hostname.endsWith(".szbdyd.com")) { kind = "scheduler"; } else if (mcdn) { kind = "mcdn"; } else if (isPcdn) { kind = "pcdn"; } else if (akamai) { kind = "akamai"; } else if (hostname.startsWith("upos-") || hostname.endsWith(".bilivideo.com")) { kind = "upos"; } const isSlow = isPcdn || isOverseasMirror(hostname) || (config.rewriteAkamai && akamai); return { host: hostname, port: url.port || "", kind, isPcdn, isMcdn: mcdn, isAkamai: akamai, isSlow, schedulerSource }; } function cleanHost(host) { const trimmed = String(host || "").trim(); return trimmed.replace(/^https?:\/\//i, "").replace(/\/.*$/, ""); } function replaceHost(url, host) { const next = new URL(url.toString()); next.protocol = "https:"; next.host = cleanHost(host); if (!cleanHost(host).includes(":")) { next.port = ""; } return next.toString(); } function proxyUrl(url, config) { const next = new URL("https://" + cleanHost(config.proxyHost) + "/"); next.searchParams.set("url", url.toString()); return next.toString(); } function shouldProxyMcdn(verdict, url, config) { if (!verdict.isMcdn) { return false; } if (config.mcdnStrategy === "proxy-all") { return true; } return config.mcdnStrategy === "proxy-v1" && url.pathname.startsWith("/v1/resource/"); } // The host to rewrite slow UPOS/PCDN URLs toward. In auto mode the runtime // keeps config.pcdnHost pointed at the current best-ranked candidate. function selectTarget(config) { return cleanHost(config.pcdnHost) || CDN_HOSTS[0]; } function rewriteUrlDetail(value, rawConfig) { const config = normalizeConfig(rawConfig); const original = String(value || ""); const url = parseUrl(original); if (!config.enabled || config.mode === "off" || !url || !isMediaUrl(url) || url.hostname === cleanHost(config.proxyHost)) { return { changed: false, original, url: original, reason: "ignored" }; } if (isLiveMediaUrl(url)) { return { changed: false, original, url: original, reason: "live-skip" }; } const verdict = classify(url, config); if (verdict.schedulerSource) { const rewritten = replaceHost(url, verdict.schedulerSource); return { changed: rewritten !== original, original, url: rewritten, reason: "szbdyd-source", targetHost: cleanHost(verdict.schedulerSource) }; } if (shouldProxyMcdn(verdict, url, config)) { const rewritten = proxyUrl(url, config); return { changed: rewritten !== original, original, url: rewritten, reason: "mcdn-proxy", targetHost: cleanHost(config.proxyHost) }; } const force = config.mode === "force"; if (verdict.isSlow || verdict.isMcdn || (force && isBiliCdnHost(url.hostname))) { const target = selectTarget(config); const rewritten = replaceHost(url, target); return { changed: rewritten !== original, original, url: rewritten, reason: verdict.isPcdn ? "pcdn-host" : (verdict.isMcdn ? "mcdn-host" : "cdn-host"), targetHost: target }; } return { changed: false, original, url: original, reason: "ok" }; } function rewriteUrl(value, config) { return rewriteUrlDetail(value, config).url; } // Build host-swapped alternatives of a media URL for DASH backupUrl fan-out. // Returns rewritten URL strings for each candidate host except the current one. function alternativesFor(value, rawConfig, hosts) { const config = normalizeConfig(rawConfig); const url = parseUrl(String(value || "")); if (!url || !isMediaUrl(url) || isLiveMediaUrl(url)) { return []; } const pool = (hosts && hosts.length ? hosts : config.candidatePool) || []; const current = url.hostname.toLowerCase(); const seen = {}; const out = []; pool.forEach(function eachHost(host) { const clean = cleanHost(host).toLowerCase(); if (!clean || clean === current || seen[clean]) { return; } seen[clean] = true; out.push(replaceHost(url, host)); }); return out; } // Convert a transferred byte count over a duration into megabits per second. // Pure so the speed meter's math stays unit-tested. function throughputMbps(bytes, durationMs) { if (!(bytes > 0) || !(durationMs > 0)) { return 0; } return (bytes * 8 / 1e6) / (durationMs / 1000); } // Total length of a set of [start, end] intervals with overlaps merged, so // two segments downloaded in parallel count their shared time only once. function unionDurationMs(intervals) { if (!intervals || !intervals.length) { return 0; } const sorted = intervals.slice().sort(function byStart(a, b) { return a[0] - b[0]; }); let total = 0; let curStart = sorted[0][0]; let curEnd = sorted[0][1]; for (let i = 1; i < sorted.length; i += 1) { const s = sorted[i][0]; const e = sorted[i][1]; if (s > curEnd) { total += curEnd - curStart; curStart = s; curEnd = e; } else if (e > curEnd) { curEnd = e; } } total += curEnd - curStart; return total; } // Aggregate "active" throughput: bytes moved per second of time actually spent // transferring, measured over the trailing `windowMs`. Unlike dividing by // wall-clock, idle gaps between the player's burst downloads don't drag the // rate to zero — this reflects the link's real capacity. Bytes from transfers // straddling the window edge are prorated to the in-window fraction, and the // active time is the union of all transfer intervals (parallel video+audio // segments count their overlap once). transfers: [{ start, end, bytes }] in ms. function aggregateThroughput(transfers, now, windowMs) { if (!transfers || !transfers.length || !(windowMs > 0)) { return 0; } const windowStart = now - windowMs; let bytes = 0; const intervals = []; for (let i = 0; i < transfers.length; i += 1) { const tr = transfers[i]; if (!tr || !(tr.bytes > 0) || !(tr.end > tr.start)) { continue; } const s = Math.max(tr.start, windowStart); const e = Math.min(tr.end, now); if (e <= s) { continue; } bytes += tr.bytes * ((e - s) / (tr.end - tr.start)); intervals.push([s, e]); } return throughputMbps(bytes, unionDurationMs(intervals)); } // Bare host[:port] of a URL, for diagnostics that must never carry the query // string — segment URLs pack the viewer's mid, buvid, IP-derived oi and signed // access tokens there, and the report is meant to be shared. "" on failure. function hostOf(value) { try { return new URL(String(value)).host; } catch (_) { return ""; } } // Pure ranking of probed hosts. samples: [{host, ttfb:number|null, ok:bool}]. // Healthy hosts first (lowest TTFB wins); failures sink to the bottom. function rankHosts(samples) { return (samples || []) .slice() .sort(function compare(a, b) { const aOk = a.ok && typeof a.ttfb === "number"; const bOk = b.ok && typeof b.ttfb === "number"; if (aOk !== bOk) { return aOk ? -1 : 1; } if (aOk && bOk) { return a.ttfb - b.ttfb; } return 0; }) .map(function pickHost(sample) { return cleanHost(sample.host); }); } function rewriteObject(value, rawConfig, state, depth, seen) { const config = normalizeConfig(rawConfig); const tracker = state || { changed: false, rewrites: [] }; const level = depth || 0; const visited = seen || new WeakSet(); if (!config.enabled || value == null || level > config.maxDepth) { return value; } if (typeof value === "string") { const detail = rewriteUrlDetail(value, config); if (detail.changed) { tracker.changed = true; tracker.rewrites.push(detail); } return detail.url; } if (typeof value !== "object") { return value; } if (visited.has(value)) { return value; } visited.add(value); if (Array.isArray(value)) { for (let index = 0; index < value.length; index += 1) { value[index] = rewriteObject(value[index], config, tracker, level + 1, visited); } return value; } for (const key of Object.keys(value)) { value[key] = rewriteObject(value[key], config, tracker, level + 1, visited); } return value; } // Live playurl payloads (getRoomPlayInfo) don't carry full media URLs — each // stream/format/codec entry lists candidate hosts in url_info: [{host, extra}]. // A host like "https://xy…xy.mcdn.bilivideo.cn:486" is a residential PCDN node; // slow for overseas viewers. This decides "is this live host slow" from the // same behavioral signals as classify(), minus the media-path requirement. function isSlowLiveHost(hostValue, extra, rawConfig) { const config = normalizeConfig(rawConfig); const raw = String(hostValue || ""); let url; try { url = new URL(raw.indexOf("://") !== -1 ? raw : "https://" + raw.replace(/^\/\//, "")); } catch (_) { return false; } const hostname = url.hostname.toLowerCase(); if (IP_RE.test(hostname) || XY_MCDN_RE.test(hostname) || isMcdnHost(hostname) || isKnownP2pHost(hostname)) { return true; } if (config.portHeuristic && hasNonDefaultPort(url)) { return true; } return typeof extra === "string" && /(?:^|[?&])os=mcdn(?:&|$)/i.test(extra); } // Drop PCDN/MCDN entries from live url_info host lists, keeping the official // CDN entries the player can fail over to. Never removes the last usable host: // if every entry looks slow, the list is left untouched. Returns rewrite-shaped // entries ({original, url, reason}) so callers can log them like URL rewrites. function filterLiveUrlInfo(payload, rawConfig, depth, seen) { const config = normalizeConfig(rawConfig); const level = depth || 0; const visited = seen || new WeakSet(); const result = { changed: false, rewrites: [] }; if (!config.enabled || config.mode === "off" || payload == null || typeof payload !== "object" || level > config.maxDepth || visited.has(payload)) { return result; } visited.add(payload); const list = payload.url_info; if (Array.isArray(list) && list.length > 1 && list.every(function (item) { return item && typeof item.host === "string"; })) { const kept = list.filter(function (item) { return !isSlowLiveHost(item.host, item.extra, config); }); if (kept.length > 0 && kept.length < list.length) { list.forEach(function (item) { if (kept.indexOf(item) === -1) { result.rewrites.push({ changed: true, original: item.host, url: kept[0].host, reason: "live-pcdn-filter" }); } }); list.length = 0; kept.forEach(function (item) { list.push(item); }); result.changed = true; } } const keys = Array.isArray(payload) ? payload.map(function (_, i) { return i; }) : Object.keys(payload); for (let i = 0; i < keys.length; i += 1) { const child = filterLiveUrlInfo(payload[keys[i]], config, level + 1, visited); if (child.changed) { result.changed = true; result.rewrites = result.rewrites.concat(child.rewrites); } } return result; } function rewriteJsonText(text, rawConfig) { const state = { changed: false, rewrites: [] }; const parsed = JSON.parse(text); rewriteObject(parsed, rawConfig, state); return { changed: state.changed, text: state.changed ? JSON.stringify(parsed) : text, value: parsed, rewrites: state.rewrites }; } return { SCHEMA_VERSION, CDN_HOSTS, CANDIDATE_POOL, ACCENT_KEYS, THEME_MODES, DEFAULT_CONFIG, normalizeConfig, hasMediaSignal: hasBiliMediaSignal, classify, isSlowLiveHost, filterLiveUrlInfo, selectTarget, alternativesFor, throughputMbps, unionDurationMs, aggregateThroughput, hostOf, rankHosts, rewriteJsonText, rewriteObject, rewriteUrl, rewriteUrlDetail }; }); (function installBiliAccelerator(root) { "use strict"; const core = root.BiliAcceleratorCore; if (!core || root.__BILI_ACCELERATOR_INSTALLED__) { return; } root.__BILI_ACCELERATOR_INSTALLED__ = true; const VERSION = "0.3.0"; const STORAGE_KEY = "biliAccelerator.config.v2"; const LEGACY_KEY = "biliAccelerator.config.v1"; const RANK_PREFIX = "biliAccelerator.rank."; const RANK_TTL_MS = 6 * 60 * 60 * 1000; const BUTTON_ID = "bili-accelerator-button"; const PANEL_ID = "bili-accelerator-panel"; const IMMERSED_CLASS = "ba-immersed"; const LIFTED_CLASS = "ba-lifted"; const REVEAL_HOTZONE = 150; const REVEAL_TIMEOUT = 2600; const STALL_GRACE_MS = 2500; const STALL_RETRY_MS = 5000; const PROBE_TIMEOUT_MS = 4000; const nativeJsonParse = JSON.parse; const nativeFetch = root.fetch; const NativeXHR = root.XMLHttpRequest; let immersive = false; let revealTimer = null; let playerObserver = null; let observedContainer = null; let probed = false; let watchedVideo = null; let stallTimer = null; const recovery = { avoidHost: null, clearTimer: null }; const state = { rewrites: [], rewriteCount: 0, lastSource: "", lastMediaHost: null, status: "idle", stalls: 0, recoveries: 0, p2pBlocked: 0, ranking: [], probedAt: null, installedAt: new Date().toISOString() }; // ---- config ------------------------------------------------------------- function loadConfig() { try { const stored = root.localStorage.getItem(STORAGE_KEY) || root.localStorage.getItem(LEGACY_KEY); return core.normalizeConfig(stored ? JSON.parse(stored) : null); } catch (_) { return core.normalizeConfig(); } } let config = loadConfig(); function saveConfig(nextConfig) { config = core.normalizeConfig(nextConfig); try { root.localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); } catch (_) { // storage may be unavailable; in-memory config still applies. } } // ---- panel appearance --------------------------------------------------- // Accent presets keyed by core.ACCENT_KEYS. `hex` is the primary accent, // `strong` the pressed/hover shade, `gradA`/`gradB` the ⚡ toggle gradient. const ACCENT_PRESETS = { bili: { hex: "#00aeec", strong: "#0091cc", gradA: "#00b5f5", gradB: "#0091cc" }, teal: { hex: "#0d9488", strong: "#0b7d73", gradA: "#14b8a6", gradB: "#0b7d73" }, emerald: { hex: "#10b981", strong: "#0e9d6e", gradA: "#25c894", gradB: "#0e9d6e" }, violet: { hex: "#7c5cff", strong: "#6544e0", gradA: "#8f74ff", gradB: "#6544e0" }, pink: { hex: "#fb7299", strong: "#e85d86", gradA: "#ff86ab", gradB: "#e85d86" }, sunset: { hex: "#f97316", strong: "#db5f0c", gradA: "#ff8a3d", gradB: "#db5f0c" }, graphite: { hex: "#46566a", strong: "#33404f", gradA: "#556579", gradB: "#33404f" } }; // Header theme-toggle glyphs; swapped by updateAppearanceControls per resolved theme. const SUN_SVG = ""; const MOON_SVG = ""; // Surface tokens per resolved theme; the accent tokens are layered on top. const SURFACES = { light: { "--ba-surface": "rgba(255,255,255,.97)", "--ba-card": "#ffffff", "--ba-border": "#e5eaf0", "--ba-border-in": "#d5dde5", "--ba-ink": "#17202a", "--ba-ink-strong": "#111827", "--ba-ink-mid": "#46515c", "--ba-ink-soft": "#6b7785", "--ba-ink-faint": "#8a95a1", "--ba-dot-bg": "#eef2f6", "--ba-dot": "#9aa6b2", "--ba-good-bg": "#e6f8ee", "--ba-good": "#19a974", "--ba-warn-bg": "#fff4e0", "--ba-warn": "#e8910c", "--ba-slider-off": "#c9d3dd", "--ba-panel-shadow": "rgba(21,32,43,.24)" }, dark: { "--ba-surface": "rgba(22,26,32,.975)", "--ba-card": "#1c222b", "--ba-border": "#2b323d", "--ba-border-in": "#38414d", "--ba-ink": "#e8edf2", "--ba-ink-strong": "#f4f7fa", "--ba-ink-mid": "#b9c3ce", "--ba-ink-soft": "#93a0ac", "--ba-ink-faint": "#6f7b87", "--ba-dot-bg": "#262d37", "--ba-dot": "#6f7b87", "--ba-good-bg": "rgba(25,169,116,.16)", "--ba-good": "#2ed3a0", "--ba-warn-bg": "rgba(232,145,12,.16)", "--ba-warn": "#f0a838", "--ba-slider-off": "#3a434f", "--ba-panel-shadow": "rgba(0,0,0,.5)" } }; // The speed canvas can't cheaply read CSS vars per frame, so applyTheme caches // the values it needs: accent for the line/fill, card for the endpoint halo. const speedPaint = { accent: "#00aeec", accentRgb: "0,174,236", card: "#ffffff" }; function resolveTheme() { if (config.theme === "light" || config.theme === "dark") { return config.theme; } try { return root.matchMedia && root.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } catch (_) { return "light"; } } function hexToRgb(hex) { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255].join(","); } // Push the resolved accent + surface onto the shadow host as CSS custom // properties; the panel styles read them through var(). Custom properties // inherit across the shadow boundary, so setting them on the host is enough. function applyTheme() { const host = document.getElementById(BUTTON_ID); if (!host) { return; } const accent = ACCENT_PRESETS[config.accent] || ACCENT_PRESETS.bili; const surface = SURFACES[resolveTheme()] || SURFACES.light; Object.keys(surface).forEach(function (name) { host.style.setProperty(name, surface[name]); }); const rgb = hexToRgb(accent.hex); host.style.setProperty("--ba-accent", accent.hex); host.style.setProperty("--ba-accent-strong", accent.strong); host.style.setProperty("--ba-grad-a", accent.gradA); host.style.setProperty("--ba-grad-b", accent.gradB); host.style.setProperty("--ba-accent-shadow", "rgba(" + rgb + ",.42)"); speedPaint.accent = accent.hex; speedPaint.accentRgb = rgb; speedPaint.card = surface["--ba-card"]; drawSpeed(); updateAppearanceControls(); } function watchSystemTheme() { try { const mq = root.matchMedia("(prefers-color-scheme: dark)"); const onChange = function () { if (config.theme === "system") { applyTheme(); } }; if (typeof mq.addEventListener === "function") { mq.addEventListener("change", onChange); } else if (typeof mq.addListener === "function") { mq.addListener(onChange); } } catch (_) { // matchMedia unavailable — system theme just won't live-update. } } // A two-option sliding toggle (the header language + theme pills share it). // Both cells are equal width, so the 50%-wide thumb slides exactly one cell. // Mounts with the active index already set, so it never animates on first // paint — only later taps slide. options: [{ html, value, label }]. function createSegToggle(id, options, activeIndex, onSelect) { const seg = document.createElement("div"); seg.className = "ba-seg"; seg.id = id; seg.dataset.active = String(activeIndex); const thumb = document.createElement("span"); thumb.className = "ba-seg-thumb"; seg.appendChild(thumb); options.forEach(function (opt, i) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "ba-seg-opt"; btn.innerHTML = opt.html; btn.setAttribute("aria-pressed", i === activeIndex ? "true" : "false"); if (opt.label) { btn.title = opt.label; btn.setAttribute("aria-label", opt.label); } btn.addEventListener("click", function () { onSelect(opt.value, i); }); seg.appendChild(btn); }); return seg; } function setSegActive(seg, index) { if (!seg) { return; } seg.dataset.active = String(index); seg.querySelectorAll(".ba-seg-opt").forEach(function (opt, i) { opt.setAttribute("aria-pressed", i === index ? "true" : "false"); }); } function regionKey() { try { return (Intl.DateTimeFormat().resolvedOptions().timeZone || "unknown") + "|" + (root.navigator && root.navigator.language || ""); } catch (_) { return "unknown"; } } function loadRanking() { try { const raw = root.localStorage.getItem(RANK_PREFIX + regionKey()); if (!raw) { return null; } const parsed = JSON.parse(raw); if (!parsed || !Array.isArray(parsed.ranking) || !parsed.at) { return null; } if (Date.now() - parsed.at > RANK_TTL_MS) { return null; } return parsed.ranking; } catch (_) { return null; } } function saveRanking(ranking) { try { root.localStorage.setItem(RANK_PREFIX + regionKey(), JSON.stringify({ ranking, at: Date.now() })); } catch (_) { // best-effort cache only. } } // Apply a learned ranking by pointing the active target at the best host. function applyRanking(ranking) { if (!ranking || !ranking.length || config.selection !== "auto") { return; } state.ranking = ranking; config.pcdnHost = ranking[0]; } // ---- rewrite plumbing --------------------------------------------------- function record(rewrites, source) { if (!rewrites || rewrites.length === 0) { return; } state.lastSource = source; state.rewriteCount += rewrites.length; state.rewrites = state.rewrites.concat(rewrites.map(function mapRewrite(item) { // Keep only bare host + reason — never the full media URL. Segment URLs // carry the viewer's mid, buvid, IP-derived oi and signed tokens, and the // diagnostics report is built to be pasted into public issues. Redacting // here (not just at display) means those tokens never persist in memory. return { at: new Date().toISOString(), source, reason: item.reason, fromHost: core.hostOf(item.original), toHost: core.hostOf(item.url) }; })).slice(-50); if (state.status === "idle") { state.status = "smooth"; } renderStatus(); } function rememberSample(payload) { if (probed || config.selection !== "auto") { return; } const sample = findMediaUrl(payload, 0, new WeakSet()); if (sample) { scheduleProbe(sample); } } function findMediaUrl(value, depth, seen) { if (value == null || depth > config.maxDepth) { return null; } if (typeof value === "string") { return /\.(m4s|mp4|flv|m3u8)(?:$|[?#])/i.test(value) && core.hasMediaSignal(value) ? value : null; } if (typeof value !== "object" || seen.has(value)) { return null; } seen.add(value); const keys = Array.isArray(value) ? value.map(function (_, i) { return i; }) : Object.keys(value); for (let i = 0; i < keys.length; i += 1) { const found = findMediaUrl(value[keys[i]], depth + 1, seen); if (found) { return found; } } return null; } function backupPool() { return state.ranking.length ? state.ranking : config.candidatePool; } // Merge host-swapped alternatives of `base` into entry[key], deduped, max 8. function mergeBackups(entry, key, base) { const alts = core.alternativesFor(base, config, backupPool()); if (!alts.length) { return; } const existing = Array.isArray(entry[key]) ? entry[key] : []; const merged = alts.concat(existing).filter(function uniq(u, i, arr) { return arr.indexOf(u) === i; }); entry[key] = merged.slice(0, 8); } // Add host-swapped alternatives to DASH/durl entries so Bilibili's own // backup-URL failover can recover for free if the primary host stalls. // Payload shapes: data.dash (web player), result.video_info.dash (bangumi), // result.dash, bare dash; durl carries the legacy FLV/MP4 lists. Web-API DASH // uses camelCase (baseUrl/backupUrl); app-style payloads and durl use // snake_case (base_url/backup_url). function enrichBackups(payload) { if (config.selection !== "auto" || !payload || typeof payload !== "object") { return; } const containers = [ payload.data, payload.result, payload.result && payload.result.video_info, payload ]; containers.forEach(function eachContainer(container) { if (!container || typeof container !== "object") { return; } enrichDash(container.dash); enrichDurl(container.durl); }); } function enrichDash(dash) { if (!dash || typeof dash !== "object") { return; } ["video", "audio"].forEach(function eachKind(kind) { const list = dash[kind]; if (!Array.isArray(list)) { return; } list.forEach(function eachEntry(entry) { if (!entry) { return; } if (typeof entry.baseUrl === "string") { mergeBackups(entry, "backupUrl", entry.baseUrl); } if (typeof entry.base_url === "string") { mergeBackups(entry, "backup_url", entry.base_url); } }); }); } function enrichDurl(durl) { if (!Array.isArray(durl)) { return; } durl.forEach(function eachEntry(entry) { if (entry && typeof entry.url === "string") { mergeBackups(entry, "backup_url", entry.url); } }); } function rewritePayload(payload, source) { const tracker = { changed: false, rewrites: [] }; try { const rewritten = core.rewriteObject(payload, config, tracker); enrichBackups(rewritten); record(tracker.rewrites, source); filterLivePcdn(rewritten, source); rememberSample(rewritten); return rewritten; } catch (error) { console.warn("[BiliAccelerator] rewrite failed", error); return payload; } } // Live playurl payloads list candidate hosts (url_info) instead of full URLs; // drop the PCDN/MCDN entries so the live player only ever dials official CDN. function filterLivePcdn(payload, source) { try { const filtered = core.filterLiveUrlInfo(payload, config); if (filtered.changed) { record(filtered.rewrites, source); } } catch (_) { // never let live filtering break payload delivery. } } // Quick check on a response body: does it plausibly carry media URLs? // Broader than "bilivideo" so renamed PCDN payloads aren't skipped. function bodyHasSignal(text) { return typeof text === "string" && (text.indexOf("bilivideo") !== -1 || text.indexOf("mcdn") !== -1 || text.indexOf("upgcxcode") !== -1 || text.indexOf("os=mcdn") !== -1 || text.indexOf("akamaized") !== -1); } // Rewrite a single outgoing request URL (segment fetches, live failover). function rewriteRequestUrl(rawUrl) { if (!core.hasMediaSignal(rawUrl)) { return rawUrl; } try { let host = ""; try { host = new URL(rawUrl, root.location.href).hostname; } catch (_) { host = ""; } // During recovery, force-redirect away from the stalling host even if it // would normally be considered healthy. const cfg = (recovery.avoidHost && host === recovery.avoidHost) ? Object.assign({}, config, { mode: "force" }) : config; const detail = core.rewriteUrlDetail(rawUrl, cfg); // Track which host actually serves the media: the