Last active
May 9, 2026 17:21
-
-
Save Dobby233Liu/832bf82e34ed63f50d42d9ed23638125 to your computer and use it in GitHub Desktop.
Annonate Emoticons: Adds emoticon and decoration card name hover hints to bilibili
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Annonate Emoticons | |
| // @namespace https://dobby233liu.github.io | |
| // @version v1.3.38 | |
| // @description Adds emoticon and decoration card name hover hints to bilibili | |
| // @author Liu Wenyuan | |
| // @match *://*.bilibili.com/* | |
| // @exclude *://message.bilibili.com/pages/nav/header_sync* | |
| // @exclude *://message.bilibili.com/pages/nav/index_new_pc_sync* | |
| // @exclude *://s1.hdslb.com/bfs/seed/jinkela/short/cols/* | |
| // @icon https://i0.hdslb.com/bfs/garb/126ae16648d5634fe0be1265478fd6722d848841.png | |
| // @require https://unpkg.com/arrive@2.5.2/minified/arrive.min.js#sha256-tIcpmxEDTbj4LvjrVQOMki7ASpQFVc5GwOuiN/1Y5Ew= | |
| // @require https://unpkg.com/js-cookie@3.0.5/dist/js.cookie.min.js#sha256-WCzAhd2P6gRJF9Hv3oOOd+hFJi/QJbv+Azn4CGB8gfY= | |
| // @require https://unpkg.com/adler-32@1.3.1/adler32.js#sha256-8kZc7b2Qaunn8QStsKOKRNQhhK6l/eKw64hP6y3JnnA= | |
| // @run-at document-start | |
| // @grant GM_getValue | |
| // @grant GM_setValue | |
| // @grant GM_addStyle | |
| // @connect api.bilibili.com | |
| // @updateURL https://gist.githubusercontent.com/Dobby233Liu/832bf82e34ed63f50d42d9ed23638125/raw/annonate-emoicons.user.js | |
| // @downloadURL https://gist.githubusercontent.com/Dobby233Liu/832bf82e34ed63f50d42d9ed23638125/raw/annonate-emoicons.user.js | |
| // @supportURL https://gist.github.com/Dobby233Liu/832bf82e34ed63f50d42d9ed23638125#comments | |
| // ==/UserScript== | |
| "use strict"; | |
| // NOTE: move @grant GM_notification below for releases | |
| // @grant GM_notification | |
| /* global Arrive */ | |
| /* global Cookies */ | |
| /* global ADLER32 */ | |
| function stringifyConsoleArgs(...args) { | |
| function stringifyArg(i) { | |
| if (i instanceof Error) { | |
| return i.name + (i.message != "" ? `: ${i.message}` : ""); | |
| } | |
| return String(i); | |
| } | |
| return [...args].map(stringifyArg).join(" "); | |
| } | |
| const getTaggedConsole = (function() { | |
| const ALERT_ON_ERROR = GM_getValue?.("alertOnError") ?? false; | |
| const MSG_TRUNCATE_TO_LENGTH = 200; | |
| class TaggedConsole { | |
| _formatTag = (tag) => `[AE]<${tag}>`; | |
| #tag; | |
| constructor(tag) { | |
| this.#tag = this._formatTag(tag); | |
| } | |
| log = (...args) => console.log(this.#tag, ...args); | |
| warn = (...args) => console.warn(this.#tag, ...args); | |
| debug = (...args) => console.debug(this.#tag, ...args); | |
| trace = (...args) => console.trace(this.#tag, ...args); | |
| error(...args) { | |
| const ret = console.error(this.#tag, ...args); | |
| try { | |
| if (!ALERT_ON_ERROR) return ret; | |
| const msg2 = stringifyConsoleArgs(this.#tag, ...args); | |
| const msg = `=== ERROR (${Date.now()}) - check console ===\n` | |
| + msg2.substring(0, MSG_TRUNCATE_TO_LENGTH) | |
| + (msg2.length > MSG_TRUNCATE_TO_LENGTH ? "..." : ""); | |
| try { | |
| // not very urgent so no timeout thingy | |
| (requestIdleCallback ?? requestAnimationFrame)(() => alert(msg)); | |
| } catch (err) { | |
| alert(msg); | |
| } | |
| } catch (err) { | |
| console.error(this._formatTag("TaggedConsole/error"), err); | |
| } | |
| return ret; | |
| } | |
| } | |
| const consoles = new Map(); | |
| return function getTaggedConsole(tag) { | |
| if (!consoles.has(tag)) { | |
| consoles.set(tag, new TaggedConsole(tag)); | |
| } | |
| return consoles.get(tag); | |
| } | |
| })(); | |
| const { arriveInShadowRootOf, addStyleInShadowRootOf } = (function({ | |
| addRetroactively = false, console = console | |
| } = {}) { | |
| // NOTE: Both functions don't immediately apply to existing shadow roots due to technical limitations | |
| // (+ I have small brain) | |
| const listenersByElem = new Map(); | |
| // TODO: allow selectors for `tag`? | |
| function _addListenersToShadowRoot(tag, shadowRoot, listeners=undefined) { | |
| const _listeners = listeners ?? listenersByElem.get(tag.toUpperCase()); | |
| if (!_listeners) return; | |
| const arrive = HTMLElement.prototype.arrive.bind(shadowRoot); | |
| for (const [selector, options, listener] of _listeners.values()) { | |
| if (options) { | |
| arrive(selector, options, listener); | |
| } else { | |
| arrive(selector, listener); | |
| } | |
| } | |
| } | |
| const stylesByElem = new Map(); | |
| function _addStylesToShadowRoot(tag, shadowRoot, styles=undefined) { | |
| const _styles = styles ?? stylesByElem.get(tag.toUpperCase()); | |
| if (!_styles) return; | |
| for (const css of _styles) { | |
| // no clue if GM_addStyle works here | |
| const styleElem = shadowRoot.appendChild(document.createElement("style")); | |
| styleElem.innerHTML = css; | |
| } | |
| } | |
| // probably kind of memory expensive but it's the best way I can think of | |
| const shadowRootStore = addRetroactively ? new Map() : null; | |
| /* global WeakRef */ | |
| // no proper polyfill out there so ... | |
| class NotExactlyWeakRef { | |
| constructor(target) { | |
| this.target = target; | |
| // TODO: listen to DOMNodeInserted/DOMNodeRemoved/etc. to call _invalidateIfNecessary; | |
| // we're dealing with outdated browsers anyways | |
| } | |
| set target(target) { | |
| if (this.target instanceof Node) { | |
| throw new DOMException("NotSupportedError", "NotExactlyWeakRef cannot store anything other than Nodes"); | |
| } | |
| this._target = target; | |
| this._invalidateIfNecessary(); | |
| } | |
| // TODO: (low priority) this is not regularly checked | |
| _invalidateIfNecessary() { | |
| if (this.target instanceof Node) { | |
| if (!this.target.isConnected) { | |
| this.target = undefined; | |
| } | |
| } | |
| if (this.target instanceof ShadowRoot) { | |
| if (!this.target.host || !this.target.host.isConnected) { | |
| this.target = undefined; | |
| } | |
| } | |
| } | |
| deref() { | |
| this._invalidateIfNecessary(); | |
| return this.target; | |
| } | |
| } | |
| const useNotWeakRef = typeof WeakRef !== "function"; | |
| if (addRetroactively && useNotWeakRef) { | |
| console.warn("WeakRef not available, downgrading to NotExactlyWeakRef!! Expect bad memory usage"); | |
| } | |
| function hookIn(obj, funcName, newFunc) { | |
| const origFunc = obj[funcName]; | |
| const hookInvocationFunc = function(...args) { | |
| return newFunc.bind(this)(origFunc.bind(this), ...args); | |
| }; | |
| return (obj[funcName] = hookInvocationFunc); | |
| } | |
| hookIn(HTMLElement.prototype, "attachShadow", function(orig, options, ...etc) { | |
| /*if (this.tagName.toLowerCase() == "bili-comment-user-sailing-card") { | |
| console.trace("creating bili-comment-user-sailing-card"); | |
| }*/ | |
| const ret = orig(options, ...etc); | |
| // not going to access ret here, we've no reason to fiddle if SR is closed | |
| if (this.shadowRoot) { | |
| const tag = this.tagName.toUpperCase(); | |
| _addListenersToShadowRoot(tag, this.shadowRoot); | |
| _addStylesToShadowRoot(tag, this.shadowRoot); | |
| if (addRetroactively) { | |
| if (!shadowRootStore.has(tag)) { | |
| shadowRootStore.set(tag, new Set()); | |
| } | |
| shadowRootStore.get(tag).add( | |
| new (!useNotWeakRef ? WeakRef : NotExactlyWeakRef)(this.shadowRoot)); | |
| } | |
| } | |
| return ret; | |
| }); | |
| function _addRetroactivelyHelper(tag, func, data) { | |
| if (!addRetroactively || !shadowRootStore.has(tag)) return; | |
| const roots = shadowRootStore.get(tag); | |
| for (const shadowRootRef of roots) { | |
| const shadowRoot = shadowRootRef.deref(); | |
| if (shadowRoot) { | |
| func(tag, shadowRoot, [data]); | |
| } else { | |
| roots.delete(shadowRootRef); | |
| } | |
| } | |
| } | |
| function arriveInShadowRootOf(_tag, selector, ...args) { | |
| const tag = _tag.toUpperCase(); | |
| let options, listener; | |
| if (args.length >= 2) { | |
| [options, listener] = args; | |
| } else { | |
| [listener] = args; | |
| } | |
| if (!listenersByElem.has(tag)) { | |
| listenersByElem.set(tag, new Set()); | |
| } | |
| const listeners = listenersByElem.get(tag); | |
| const data = [selector, options, listener]; | |
| if (!listeners.has(data)) { | |
| listeners.add(data); | |
| _addRetroactivelyHelper(tag, _addListenersToShadowRoot, data); | |
| } | |
| return listener; | |
| } | |
| function addStyleInShadowRootOf(_tag, css) { | |
| const tag = _tag.toUpperCase(); | |
| if (!stylesByElem.has(tag)) { | |
| stylesByElem.set(tag, new Set()); | |
| } | |
| const styles = stylesByElem.get(tag); | |
| if (!styles.has(css)) { | |
| styles.add(css); | |
| _addRetroactivelyHelper(tag, _addStylesToShadowRoot, css); | |
| } | |
| } | |
| return { arriveInShadowRootOf, addStyleInShadowRootOf }; | |
| })({ | |
| console: getTaggedConsole("init_arriveInShadowRootOf"), | |
| addRetroactively: false // we don't need this yet | |
| }); | |
| const { throttledFetch, ThrottledRequestCancelledError } = (function({ | |
| getTaggedConsole = (tag) => console, | |
| maxConcurrentRequests, queueSize, | |
| minimumGracePeriod, maximumGracePeriod, | |
| requestTimeout, processQueueIdleCbTimeout, | |
| concealSecrets = (str, tokens) => str | |
| } = {}) { | |
| // this was vibe coded but has been heavily rewritten since then | |
| function randomRange(i, j) { | |
| const min = Math.min(i, j), max = Math.max(i, j); | |
| return min + Math.random() * (max - min); | |
| } | |
| function wait(t) { | |
| return new Promise((resolve, _) => setTimeout(resolve, t)); | |
| } | |
| function _concealSecrets(str, tokens) { | |
| tokens = tokens | |
| .filter((i) => i !== null && typeof i !== "undefined") | |
| .map((i) => String(i)) | |
| .sort((a, b) => b.length - a.length); | |
| for (const token of tokens) { | |
| str = str.replaceAll(token, "[SECRET]"); | |
| } | |
| str = concealSecrets(str, tokens); | |
| return str; | |
| } | |
| const requestQueue = []; | |
| let activeRequests = 0; | |
| async function processQueue() { | |
| if (requestQueue.length != 0 || activeRequests != 0) { | |
| const con = getTaggedConsole("throttledFetch/processQueue"); | |
| con.debug("Queue length =", requestQueue.length, "activeRequests =", activeRequests); | |
| } | |
| while (activeRequests < maxConcurrentRequests && requestQueue.length > 0) { | |
| requestQueue.shift().perform(); | |
| await wait(Math.floor(randomRange(minimumGracePeriod, maximumGracePeriod))); | |
| } | |
| } | |
| const PROCESS_QUEUE_CB_TYPE_ANIMFRAME = 0; | |
| const PROCESS_QUEUE_CB_TYPE_IDLECB = 1; | |
| let processQueueCbId = null; | |
| async function _runProcessQueue() { | |
| try { | |
| await processQueue(); | |
| } catch (err) { | |
| const con = getTaggedConsole("throttledFetch/scheduleProcessQueue"); | |
| con.error("Failed to process queue:", err); | |
| } finally { | |
| processQueueCbId = null; | |
| } | |
| } | |
| function scheduleProcessQueue() { | |
| if (processQueueCbId) return; | |
| if (typeof requestIdleCallback === "function") { | |
| processQueueCbId = [ | |
| PROCESS_QUEUE_CB_TYPE_IDLECB, | |
| requestIdleCallback(_runProcessQueue, { timeout: processQueueIdleCbTimeout })]; | |
| } else { | |
| processQueueCbId = [ | |
| PROCESS_QUEUE_CB_TYPE_ANIMFRAME, | |
| requestAnimationFrame(_runProcessQueue)]; | |
| } | |
| } | |
| function cancelProcessQueueCb() { | |
| if (!processQueueCbId) return; | |
| if (processQueueCbId[0] == PROCESS_QUEUE_CB_TYPE_ANIMFRAME) { | |
| cancelAnimationFrame(processQueueCbId[1]); | |
| } else if (processQueueCbId[0] == PROCESS_QUEUE_CB_TYPE_IDLECB) { | |
| cancelIdleCallback(processQueueCbId[1]); | |
| } | |
| processQueueCbId = null; | |
| } | |
| window.addEventListener("pagehide", (ev) => { | |
| if (ev.persisted) return; // TODO: ? | |
| cancelProcessQueueCb(); | |
| const requestQueueCopy = requestQueue.slice(); | |
| requestQueue.length = 0; | |
| for (const req of requestQueueCopy) { | |
| req.abort(new ThrottledRequestCancelledError("Current page is unloading")); | |
| } | |
| }); | |
| class ThrottledRequestCancelledError extends Error {} | |
| // TODO: dedupe by request contents (using a stupid Map, obj as key and Promise as value)? maybe? | |
| function throttledFetch(url, options) { | |
| const controller = new AbortController(); | |
| async function perform(resolve, reject) { | |
| controller.signal.throwIfAborted(); | |
| activeRequests++; | |
| const con = getTaggedConsole("throttledFetch/perform"); | |
| const thisReqIdentifier = `[${activeRequests}-${Date.now()}]`; | |
| const conceal = (url) => _concealSecrets(url, options?.concealedSecrets ?? []); | |
| con.debug(thisReqIdentifier, "Requesting:", conceal(url)); | |
| try { | |
| const res = await fetch(url, { | |
| ...options, | |
| // TODO: polyfill??? | |
| signal: AbortSignal.any([controller.signal, AbortSignal.timeout(requestTimeout)]) | |
| }); | |
| con.debug(thisReqIdentifier, "Got response:", res.status, conceal(res.url)); | |
| resolve(res); | |
| } finally { | |
| activeRequests--; | |
| if (activeRequests < 0) { | |
| con.warn("activeRequests underflow"); | |
| activeRequests = 0; | |
| } | |
| scheduleProcessQueue(); | |
| } | |
| } | |
| return new Promise(function _throttledFetch(resolve, reject) { | |
| const _perform = () => perform(resolve, reject).catch(reject); | |
| if (activeRequests < maxConcurrentRequests) { | |
| _perform(); | |
| } else if (requestQueue.length >= queueSize) { | |
| // TODO: reconsider | |
| reject(new ThrottledRequestCancelledError("Request queue is full")); | |
| } else { | |
| requestQueue.push({ | |
| perform: _perform, | |
| abort: controller.abort.bind(controller) | |
| }); | |
| } | |
| }); | |
| } | |
| return { throttledFetch, ThrottledRequestCancelledError }; | |
| })({ | |
| getTaggedConsole, | |
| maxConcurrentRequests: 3, | |
| // TODO: having second thoughts on queue size, maybe add multiple queues? | |
| queueSize: 30, | |
| minimumGracePeriod: 100, | |
| maximumGracePeriod: 250, | |
| requestTimeout: 10000, | |
| processQueueIdleCbTimeout: 100 | |
| }); | |
| const { extractBfsImgInfo, extractBfsImgId } = (function() { | |
| function extractBfsImgInfo(url) { | |
| const START = "/bfs/"; | |
| if (url.pathname.substring(0, START.length) != START) { | |
| return; | |
| } | |
| const info = { | |
| server: url.origin + START, | |
| id: url.pathname.substring(START.length), | |
| origFormat: null, | |
| params: null | |
| }; | |
| const paramStartIndex = info.id.lastIndexOf("@"); | |
| if (paramStartIndex >= 0) { | |
| info.params = info.id.substring(paramStartIndex + 1); | |
| info.id = info.id.substring(0, paramStartIndex); | |
| } | |
| const extStartIndex = info.id.lastIndexOf("."); | |
| if (extStartIndex >= 0) { | |
| info.origFormat = info.id.substring(extStartIndex); | |
| if (info.id) { | |
| info.id = info.id.substring(0, extStartIndex); | |
| } | |
| } | |
| return info; | |
| } | |
| function extractBfsImgId(url) { | |
| return extractBfsImgInfo(url)?.id ?? (url.origin + url.pathname); // w/e | |
| } | |
| return { extractBfsImgInfo, extractBfsImgId }; | |
| })(); | |
| const { requestInfoHelper, isFailedInfo } = (function() { | |
| let knownIds = {}; | |
| let knownIdsCleanMemo = ""; | |
| // LRU-ish cache (might be too much) | |
| // TODO: allow each data type to have different expire times? | |
| const KNOWN_IDS_EXPIRE_TIME = 1 * 60 * 60 * 1000; | |
| const FAILED_IDS_EXPIRE_TIME = 5 * 1000; | |
| const KNOWN_IDS_MAX_RETENTION_COUNT = 30; | |
| const KNOWN_IDS_REF_COUNT_LOAD_DECAY_FACTOR = 0.99; // TODO: temporary | |
| const KNOWN_IDS_REF_COUNT_REF_DECAY_FACTOR = 0.95; | |
| const KNOWN_IDS_TS_PENALTY_WEIGHT = 5 * 60 * 1000; | |
| const KNOWN_IDS_STORAGE_KEY = "knownUids"; // legacy | |
| const KNOWN_IDS_FORCE_NO_STORAGE = false; | |
| const REQUEST_INFO_FORCE_OFFLINE = false; | |
| // This returning true does not mean internet is reachable, but we don't really care | |
| // about the specifics for now | |
| function isOnline() { | |
| return navigator.onLine && !REQUEST_INFO_FORCE_OFFLINE; | |
| } | |
| function _cleanKnownIds(newKnownIds) { | |
| const con = getTaggedConsole("_cleanKnownIds"); | |
| const cleanMemo = []; | |
| function logCleanMemo(...msg) { | |
| con.log(...msg); | |
| cleanMemo.push(stringifyConsoleArgs(...msg)); | |
| } | |
| // if we know we're offline, we want to keep as much data in storage as possible | |
| if (isOnline()) { | |
| const now = Date.now(); | |
| // TODO: is this useful to have anymore | |
| let expiredEntries = 0; | |
| for (const [id, info] of Object.entries(newKnownIds)) { | |
| if (!("ts" in info)) { | |
| info.ts = info.timestamp | |
| ?? (info.lastAccess ?? info.lastAccessTimestamp) // fallback | |
| ?? (now - KNOWN_IDS_EXPIRE_TIME/2); | |
| delete info.timestamp; | |
| } | |
| if ((now - info.ts) > (info.failed ? FAILED_IDS_EXPIRE_TIME : KNOWN_IDS_EXPIRE_TIME)) { | |
| delete newKnownIds[id]; | |
| expiredEntries++; | |
| } | |
| } | |
| if (expiredEntries > 0) { | |
| logCleanMemo("Deleting expired entries:", expiredEntries); | |
| } | |
| function _getLats(info) { | |
| if (!("lastAccess" in info) && "lastAccessTimestamp" in info) { | |
| info.lastAccess = info.lastAccessTimestamp; | |
| delete info.lastAccessTimestamp; | |
| } | |
| return info.lastAccess ?? info.ts ?? (now - 1000); | |
| } | |
| const sortedByRefCount = Object.entries(newKnownIds).sort(([_, a], [__, b]) => { | |
| const scoreA = (a.refs ?? a.refCount ?? 1) + (now - _getLats(a)) / KNOWN_IDS_TS_PENALTY_WEIGHT; | |
| const scoreB = (b.refs ?? b.refCount ?? 1) + (now - _getLats(b)) / KNOWN_IDS_TS_PENALTY_WEIGHT; | |
| return scoreB - scoreA; | |
| }); | |
| if (sortedByRefCount.length > KNOWN_IDS_MAX_RETENTION_COUNT) { | |
| const shearedEntries = sortedByRefCount.length - KNOWN_IDS_MAX_RETENTION_COUNT; | |
| for (const [id, _] of sortedByRefCount.slice(KNOWN_IDS_MAX_RETENTION_COUNT)) { | |
| delete newKnownIds[id]; | |
| } | |
| logCleanMemo("Shearing less used entries:", shearedEntries); | |
| } | |
| } | |
| for (const info of Object.values(newKnownIds)) { | |
| info.refs = Math.ceil((info.refs ?? info.refCount ?? 1) * KNOWN_IDS_REF_COUNT_LOAD_DECAY_FACTOR); | |
| delete info.refCount; | |
| } | |
| return [newKnownIds, cleanMemo.join("\n")]; | |
| } | |
| function showKnownIdsCleanMemo(title) { | |
| if (!GM_getValue?.("showKnownIdsCleanMemo")) return; | |
| if (typeof GM_notification !== "function" || knownIdsCleanMemo == "") { | |
| return; | |
| } | |
| GM_notification({ | |
| title: `[AE] ${title}`, | |
| text: knownIdsCleanMemo, | |
| tag: "knownIdsCleanMemo", | |
| silent: true, | |
| timeout: 5000 | |
| }); | |
| } | |
| async function GM_getValue_async(key, defaultValue) { | |
| if (typeof GM !== "undefined") return await GM.getValue(key, defaultValue); | |
| if (typeof GM_getValue !== "undefined") return GM_getValue(key, defaultValue); | |
| return null; // TODO: ? | |
| } | |
| async function _loadKnownIds() { // marked async so it generates a Promise | |
| let newKnownIds = knownIds; | |
| const storedKnownIds = !KNOWN_IDS_FORCE_NO_STORAGE ? | |
| await GM_getValue_async(KNOWN_IDS_STORAGE_KEY, null) : null; | |
| if (storedKnownIds !== null) { | |
| newKnownIds = Object.assign({}, storedKnownIds, newKnownIds); // TODO: ? | |
| } | |
| knownIdsCleanMemo = ""; | |
| [newKnownIds, knownIdsCleanMemo] = _cleanKnownIds(newKnownIds); | |
| knownIds = newKnownIds; | |
| return knownIds; | |
| } | |
| // we have singletons at home | |
| let loadKnownIdsPromise = null; | |
| function loadKnownIds() { // callers should treat this as async | |
| if (!loadKnownIdsPromise) { | |
| loadKnownIdsPromise = _loadKnownIds().finally(() => { | |
| loadKnownIdsPromise = null; | |
| }); | |
| } else { | |
| getTaggedConsole("loadKnownIds") | |
| .debug("Caller will wait for the last load call to complete"); | |
| } | |
| return loadKnownIdsPromise; | |
| } | |
| /*async function GM_setValue_async(key, value) { | |
| if (typeof GM !== "undefined") return await GM.setValue(key, value); | |
| if (typeof GM_setValue !== "undefined") return GM_setValue(key, value); | |
| }*/ | |
| let savingKnownIds = 0; | |
| function saveKnownIds(localCopy) { | |
| const con = getTaggedConsole("saveKnownIds"); | |
| if (KNOWN_IDS_FORCE_NO_STORAGE || typeof GM_setValue !== "function") { | |
| con.warn("Storage disabled, knownIds will only remain in memory"); | |
| return false; | |
| } | |
| if (localCopy !== knownIds) { | |
| con.warn("localCopy !== knownIds (shouldn't happen if called correctly)"); | |
| } | |
| if (savingKnownIds > 0) { | |
| con.debug("Already working on it (what to do? idk)"); | |
| //return false; | |
| } | |
| savingKnownIds++; | |
| GM_setValue(KNOWN_IDS_STORAGE_KEY, knownIds); | |
| try { | |
| showKnownIdsCleanMemo("knownIds saved"); | |
| } catch (err) { | |
| con.warn(err); | |
| void(err); | |
| } | |
| savingKnownIds--; | |
| if (savingKnownIds < 0) { | |
| con.warn("savingKnownIds underflow"); | |
| savingKnownIds = 0; | |
| } | |
| return true; | |
| } | |
| // might cause some extra race conditions | |
| /*async function refreshKnownIds() { | |
| try { | |
| //await | |
| saveKnownIds(await loadKnownIds()); | |
| } catch (err) { | |
| getTaggedConsole("refreshKnownIds").error(err); | |
| } | |
| } | |
| refreshKnownIds(); | |
| setInterval(refreshKnownIds, Math.min(KNOWN_IDS_EXPIRE_TIME, FAILED_IDS_EXPIRE_TIME));*/ | |
| let saveKnownIdsTimeout = null; | |
| function scheduleSaveKnownIds() { // TODO: can this be an idle callback | |
| clearTimeout(saveKnownIdsTimeout); | |
| saveKnownIdsTimeout = setTimeout(() => { | |
| saveKnownIdsTimeout = null; | |
| saveKnownIds(knownIds); | |
| }, 150); | |
| return saveKnownIdsTimeout; | |
| } | |
| window.addEventListener("pagehide", function saveKnownIdsOnPagehide(ev) { | |
| if (ev.persisted) return; // TODO: ? | |
| if (saveKnownIdsTimeout) { | |
| const con = getTaggedConsole("saveKnownIdsOnPagehide"); | |
| con.warn("saveKnownIdsTimeout did not trigger in time!! Extreme corner case, attempting to save right now"); | |
| if (savingKnownIds > 0) { | |
| con.warn("savingKnownIds > 0. Should exit here maybe?"); | |
| } | |
| clearTimeout(saveKnownIdsTimeout); | |
| saveKnownIdsTimeout = null; | |
| saveKnownIds(knownIds); | |
| } | |
| }); | |
| function addKnownIdInfo(id, obj) { | |
| if (!("ts" in obj)) obj.ts = Date.now(); | |
| if (!("refs" in obj)) obj.refs = 0; | |
| return (knownIds[id] = obj); | |
| } | |
| function getKnownIdInfo(id, updateRefCount=true, save=true) { | |
| const info = knownIds[id]; | |
| if (!info) return info; | |
| if (updateRefCount) { | |
| info.refs = Math.ceil((info.refs ?? info.refCount ?? 1) * KNOWN_IDS_REF_COUNT_REF_DECAY_FACTOR) + 1; | |
| delete info.refCount; | |
| info.lastAccess = Date.now(); delete info.lastAccessTimestamp; | |
| if (save) scheduleSaveKnownIds(); | |
| } | |
| return info; | |
| } | |
| // TODO: decide if passing through errors is favorable behavior | |
| const PASS_THROUGH_REQ_ERRORS = false; | |
| const PASS_THROUGH_REQ_ERRORS_ON_CACHE_MISS = false; | |
| // reset data versions to 0 after bumping this | |
| const INFO_VERSION = 0; | |
| const INFO_VERSION_BSHIFT = 8; | |
| const requestInfoPromises = {}; | |
| async function requestInfoHelper(func, ver, typeDisplayName, id, ...args) { | |
| const con = getTaggedConsole("requestInfoHelper"); | |
| await loadKnownIds(); | |
| const oldInfo = getKnownIdInfo(id, false); | |
| if (oldInfo && !("ver" in oldInfo) && "version" in oldInfo) { | |
| oldInfo.ver = oldInfo.version; | |
| delete oldInfo.version; | |
| } | |
| const currentVersion = oldInfo?.ver; | |
| if (ver >= (1 << INFO_VERSION_BSHIFT)) { | |
| con.warn("Datatype", typeDisplayName, "ver", ver, ">=", 1 << INFO_VERSION_BSHIFT); | |
| } | |
| const writtenVersion = (INFO_VERSION << INFO_VERSION_BSHIFT) + ver; | |
| // even if info is outdated it may not get re-requested, so make sure to not change field meanings | |
| // heavily (or prepare to add a bunch of compat code) unless if absolutely necessary | |
| const structureOutdated = currentVersion != writtenVersion; | |
| if (oldInfo && structureOutdated) { | |
| con.debug(id, "has outdated structure;", currentVersion, "!=", writtenVersion); | |
| } | |
| async function _performRequest() { | |
| let result = { failed: true }, err; | |
| try { | |
| result = await func(...args); | |
| } catch (_err) { | |
| err = _err; | |
| // TODO: is cancellation a failure? | |
| if (err instanceof ThrottledRequestCancelledError) result = null; | |
| if (!PASS_THROUGH_REQ_ERRORS && !PASS_THROUGH_REQ_ERRORS_ON_CACHE_MISS) { | |
| con.error(`While fetching info of ${typeDisplayName} ${id}:`, err); | |
| } | |
| } | |
| if (result) { | |
| result.ver = writtenVersion; | |
| addKnownIdInfo(id, result); | |
| } | |
| if (PASS_THROUGH_REQ_ERRORS && err) { | |
| throw new TypeError(`Failed to fetch info of ${typeDisplayName} ${id}`, { cause: err }); | |
| } | |
| return result && !isFailedInfo(result); | |
| } | |
| function _onRequestEnd() { | |
| if (!requestInfoPromises[id]) { | |
| getTaggedConsole("requestInfoHelper/_onRequestEnd").warn( | |
| "requestInfoPromise for", id, "is already null"); | |
| } | |
| delete requestInfoPromises[id]; | |
| } | |
| // TODO: better retrying support | |
| async function performRequest(retryingForOffline=false) { | |
| const isCurrentlyOnline = isOnline(); | |
| let requested = false, err; | |
| if (isCurrentlyOnline) { | |
| if (!requestInfoPromises[id]) { | |
| requestInfoPromises[id] = _performRequest().finally(_onRequestEnd); | |
| } | |
| try { requested = await requestInfoPromises[id]; } | |
| catch (_err) { err = _err; } | |
| } | |
| if (!requested) { | |
| con.warn("Did not/could not (re-)request info of", id, "when we should have"); | |
| // obvious problem: we're checking for this too soon | |
| // at least the logic is here for now | |
| if (!retryingForOffline && !isCurrentlyOnline && isOnline()) { | |
| con.warn("We were offline and are now online, retrying"); | |
| return await performRequest(true); | |
| } | |
| } | |
| if (PASS_THROUGH_REQ_ERRORS && err) throw err; | |
| } | |
| let err; | |
| if (!oldInfo || structureOutdated) { | |
| try { await performRequest(); } | |
| catch (_err) { err = _err; } | |
| } | |
| const info = getKnownIdInfo(id) ?? null; // TODO: why nullish? | |
| if (PASS_THROUGH_REQ_ERRORS && err && (!PASS_THROUGH_REQ_ERRORS_ON_CACHE_MISS || !info)) { | |
| throw err; | |
| } | |
| return info; | |
| } | |
| function isFailedInfo(info) { | |
| return !info || info.failed; | |
| } | |
| return { requestInfoHelper, isFailedInfo }; | |
| })(); | |
| const { reqUidInfo, isUserDeleted, reqGarbSuitItemInfo, reqGarbDlcActInfo } = (function() { | |
| function makeEndpointUrl(endpoint, front="https://api.bilibili.com/x") { | |
| return new URL(front + endpoint); | |
| } | |
| function getEndpointId(url) { | |
| return url.origin + url.pathname; | |
| } | |
| // generally cookies seem to be passed | |
| const DEFAULT_FETCH_OPTIONS = { | |
| credentials: "include" | |
| }; | |
| let showCsrfTokenExistenceWarning = true; | |
| function getCsrfToken() { | |
| const csrfToken = Cookies.get("bili_jct"); | |
| if (!csrfToken) { | |
| if (showCsrfTokenExistenceWarning) { | |
| getTaggedConsole("requestInfoHelper/getCsrfToken").debug("bili_jct doesn't exist, not logged in?"); | |
| showCsrfTokenExistenceWarning = false; | |
| } | |
| } else { | |
| showCsrfTokenExistenceWarning = true; | |
| } | |
| return csrfToken; | |
| } | |
| function throwIfResponseNotOk(res) { | |
| if (!res.ok) { | |
| // one may call res.json() later on (hopefully) | |
| throw new TypeError(`Got response with status: ${res.status} (${res.statusText})`, { cause: res.clone() }); | |
| } | |
| return res; | |
| } | |
| class BilibiliApiError extends Error { | |
| constructor(response, extraMessage, endpoint) { // response: the json object | |
| super(undefined, { cause: response }); | |
| this.extraMessage = extraMessage; | |
| // for cases where we request multiple endpoints in one func | |
| // getEndpointId(endpointUrl) | |
| this.endpoint = endpoint; | |
| } | |
| get message() { | |
| return (this.extraMessage ? `${this.extraMessage}\n` : "") | |
| + `Got response${this.endpoint ? ` from ${this.endpoint}` : ""} with status: ` | |
| + `${this.cause.message} (${this.cause.code})\n` | |
| + JSON.stringify(this.cause); // TODO: is this necessary? | |
| } | |
| static isOk(response) { return response.code >= 0; } | |
| static isSuccessful(response) { return response.code == 0; } | |
| static throwIfNotOk(response, ...args) { | |
| if (!this.isOk(response)) throw new this(response, ...args); | |
| return response; | |
| } | |
| static throwIfNotSuccessful(response, ...args) { | |
| if (!this.isSuccessful(response)) throw new this(response, ...args); | |
| return response; | |
| } | |
| } | |
| const NUMERIC_REGEX = /^[0-9]+$/; | |
| function checkIfNumeric(id, dataType) { | |
| if ((typeof id !== "number" || isNaN(id)) && !(typeof id === "string" && id.match(NUMERIC_REGEX))) { | |
| getTaggedConsole("requestInfoHelper/checkIfNumeric").warn("id", id, "of dataType", dataType, "is not numeric"); | |
| // TODO: throw error? | |
| return false; | |
| } | |
| return true; | |
| } | |
| const INFO_UID_VERSION = 1; | |
| const DELETED_ACCOUNT_NAME = "账号已注销"; | |
| async function _reqUidInfo(uid) { | |
| const endpointUrl = makeEndpointUrl("/web-interface/card"); | |
| endpointUrl.searchParams.set("mid", uid); | |
| const res = throwIfResponseNotOk(await throttledFetch(endpointUrl.href, DEFAULT_FETCH_OPTIONS)); | |
| const content = BilibiliApiError.throwIfNotOk(await res.json()); | |
| const name = content.data?.card?.name; | |
| if (!name) throw new BilibiliApiError(content, "Missing name"); | |
| return { name: name == DELETED_ACCOUNT_NAME ? "" : name.trim() }; | |
| } | |
| function reqUidInfo(uid) { | |
| checkIfNumeric(uid, "uid"); | |
| return requestInfoHelper( | |
| _reqUidInfo, INFO_UID_VERSION, | |
| "user", String(uid), | |
| uid); | |
| } | |
| function isUserDeleted(info) { | |
| return info.name == DELETED_ACCOUNT_NAME || info.name == ""; | |
| } | |
| const INFO_GARB_SUIT_ITEM_VERSION = 2; | |
| async function _reqGarbSuitItemInfo(itemId, partType, isDiy, vmid) { | |
| const endpointUrl = makeEndpointUrl("/garb/v2/user/suit/benefit"); | |
| // this API will work without a csrf token if not logged on | |
| const csrfToken = getCsrfToken(); | |
| if (csrfToken) endpointUrl.searchParams.set("csrf", csrfToken); | |
| endpointUrl.searchParams.set("is_diy", isDiy); | |
| endpointUrl.searchParams.set("item_id", itemId); | |
| endpointUrl.searchParams.set("part", partType); | |
| // idk if vmid is necessary when is_diy is false | |
| // the only thing is seems to do in that case is changing how data is sorted | |
| if (isDiy != "0") { | |
| endpointUrl.searchParams.set("vmid", vmid); | |
| } | |
| const res = throwIfResponseNotOk(await throttledFetch(endpointUrl.href, { | |
| ...DEFAULT_FETCH_OPTIONS, | |
| concealedSecrets: [csrfToken] | |
| })); | |
| const content = BilibiliApiError.throwIfNotOk(await res.json()); | |
| if (content.data === null) { | |
| // "很遗憾,当前装扮暂时无法查看,去看看其他装扮吧~" or "empty-rights", see id 5887 | |
| // appears for nonexistent items too | |
| // TODO: maybe I also can just store "" as name here | |
| return { unavailable: true }; | |
| } | |
| if (!content.data?.name) throw new BilibiliApiError(content, "Missing name"); | |
| let itemName; | |
| // suit item names seem internal, the mall page doesn't show them at least, so don't present them for now | |
| // (I don't need to bump data version for this I think) | |
| /*const items = content.data.suit_items?.[partType]; | |
| if (items) { // see item id 29 for a case where suit items don't exist | |
| const itemsById = Object.fromEntries(items.map(x => [x.item_id, x])); | |
| const item = itemsById[itemId]; | |
| if (!item) { | |
| throw new BilibiliApiError(content, `No item with id ${itemId}`); | |
| } | |
| itemName = item.name; | |
| }*/ | |
| return { suiteName: content.data.name.trim(), name: itemName?.trim() }; | |
| } | |
| function reqGarbSuitItemInfo(itemId, part, isDiy, vmid) { | |
| const dataType = "garb_suit"; | |
| checkIfNumeric(itemId, dataType); | |
| return requestInfoHelper( | |
| _reqGarbSuitItemInfo, INFO_GARB_SUIT_ITEM_VERSION, | |
| "personalized suit item", dataType + "_" + itemId, // fake id for cache | |
| itemId, part, isDiy, vmid); | |
| } | |
| const INFO_DLC_ACT_VERSION = 2; | |
| function _parseMedalInfo(dataJson) { | |
| if (!dataJson) return; | |
| const data = JSON.parse(dataJson); | |
| const levels = data.map(i => i.level).sort(); | |
| if (levels.length <= 0) return; | |
| if (levels[0] < 1) { | |
| throw new RangeError("1st level is smaller than 1, but we assume Lv1 is the first!", { cause: data }); | |
| } | |
| const imgUrlHashesByLvl = new Array(levels[levels.length - 1]); | |
| for (const medal of data) { | |
| if (!medal.scene_image) continue; | |
| const destIndex = medal.level - 1; | |
| if (imgUrlHashesByLvl[destIndex]) { | |
| const con = getTaggedConsole("_reqGarbDlcActInfo/_parseMedalInfo"); | |
| con.warn("Already have hashes for level", medal.level); | |
| } | |
| const hashes = imgUrlHashesByLvl[destIndex] = []; | |
| for (const img of new Set(Object.values(medal.scene_image))) { | |
| hashes.push(ADLER32.str(extractBfsImgId(new URL(img)))); | |
| } | |
| } | |
| return imgUrlHashesByLvl; | |
| } | |
| async function _reqGarbDlcActInfo(id) { | |
| const endpointUrl = makeEndpointUrl("/vas/dlc_act/act/basic"); | |
| endpointUrl.searchParams.set("act_id", id); | |
| // this API will work without a csrf token if not logged on (iirc) | |
| const csrfToken = getCsrfToken(); | |
| if (csrfToken) endpointUrl.searchParams.set("csrf", csrfToken); | |
| const res = throwIfResponseNotOk(await throttledFetch(endpointUrl.href, { | |
| ...DEFAULT_FETCH_OPTIONS, | |
| concealedSecrets: [csrfToken] | |
| })); | |
| const content = BilibiliApiError.throwIfNotOk(await res.json()); | |
| if (!content.data?.act_title) throw new BilibiliApiError(content, "Missing act_title"); | |
| let medals; | |
| try { | |
| medals = _parseMedalInfo(content.data.collector_medal_info); | |
| } catch (err) { | |
| const con = getTaggedConsole("_reqGarbDlcActInfo"); | |
| con.warn("Failed to parse medal info:", err); | |
| } | |
| return { name: content.data.act_title.trim(), medals } | |
| } | |
| function reqGarbDlcActInfo(id) { | |
| const dataType = "dlc_act"; | |
| checkIfNumeric(id, dataType); | |
| return requestInfoHelper( | |
| _reqGarbDlcActInfo, INFO_DLC_ACT_VERSION, | |
| "digital collection campaign", dataType + "_" + id, | |
| id); | |
| } | |
| return { reqUidInfo, isUserDeleted, reqGarbSuitItemInfo, reqGarbDlcActInfo }; | |
| })(); | |
| const translateEmoticonName = (function() { | |
| const UP_EMOTE_REGEX = /(?<=\[)(UPOWER|UP)_(\d+)(?=_.+?\])/; | |
| const PREFIX_TL_TABLE = { | |
| /* | |
| example: | |
| - https://member.bilibili.com/mall/upower-pay/rights?mid=9736159 | |
| <- page has note about upower-exclusive emotes, calls them "专属表情" | |
| - https://member.bilibili.com/mall/upower-pay/rights?mid=66796740 | |
| * https://t.bilibili.com/1157733942460153857 <- usage | |
| - https://member.bilibili.com/mall/upower-pay/rights?mid=3546796468996650 | |
| * Screenshots\2026-02\msedge_Y6JSO1GvNS.png <- UPOWER prefix can be seen in liverooms | |
| - https://member.bilibili.com/mall/upower-pay/rights?mid=451758 | |
| * https://t.bilibili.com/1159938244279795713 <- calls them "充电表情包" | |
| -> 充电表情包_ / 充电专属_ / 充电表情_ | |
| */ | |
| ["UPOWER"]: "充电表情包", | |
| /* | |
| example: | |
| - https://live.bilibili.com/1710489335 | |
| * Screenshots\2026-02\msedge_Pr7tg0uwph.png, msedge_8mSUiYmcjA.png, msedge_mHAs7kZPOV.png | |
| (see below) | |
| * https://t.bilibili.com/1160683528480882694 <- **usage outside of liveroom** | |
| (UP prefix is not seen in liverooms) | |
| * https://live.bilibili.com/p/html/live-app-guard-info/index.html?uid=3546796468996650 | |
| <- page has note about sailor-exclusive emotes | |
| - https://live.bilibili.com/510 | |
| * Screenshots\2026-02\msedge_Jhx06KxPZ4.png, msedge_a2pHdoU8Hi.png, msedge_ePMTkjsdVg.png | |
| <- emote picker with various tier restrictions shown | |
| "粉丝团"-exclusive (loosely the second-lowest tier) emotes are unlocked only once for common users | |
| -> 直播表情包_ / 直播间表情_ / 粉丝团专属_ / 大航海专属_ | |
| - https://live.bilibili.com/21379697 | |
| * https://api.live.bilibili.com/xlive/web-ucenter/v2/emoticon/GetEmoticons?platform=pc&room_id=21379697 | |
| -> site-wide set: UP主大表情 / 房间表情(系统); room-wide set: 房间专属表情 | |
| room-wide set in 21379697 has no tier limits, and some emote names overlap with the site-wide set | |
| (that being the example in this case as I actually opened it and checked) | |
| */ | |
| ["UP"]: "直播间表情包" | |
| // please ask me for the aforementioned screenshots if you need them | |
| }; | |
| return async function translateEmoticonName(name) { | |
| const match = name.match(UP_EMOTE_REGEX); | |
| const uid = match?.[2]; | |
| if (!uid) return name; | |
| let userInfo; | |
| try { | |
| userInfo = await reqUidInfo(uid); | |
| } catch (err) { | |
| const con = getTaggedConsole("translateEmoticonName"); | |
| con.error("reqUidInfo failed for", name, err, uid); | |
| } | |
| let replacement = `${PREFIX_TL_TABLE[match[1]] ?? match[1]}_`; | |
| if (!userInfo?.name) { // omitting isFailedInfo check for now | |
| replacement += uid + "(未能查询)"; | |
| } else if (!isUserDeleted(userInfo)) { | |
| replacement += `${userInfo.name}(${uid})`; | |
| } else { | |
| replacement += uid; | |
| } | |
| return name.replace(UP_EMOTE_REGEX, replacement); | |
| } | |
| })(); | |
| const makeDecoCardTooltip = (function() { | |
| const CSS_URL_EXTRACT_REGEX = /(?<=.*\b)url\((?<q>["'])?(.+?)\k<q>\)(?=\B)/; | |
| function extractDecoCardImgs(elem) { | |
| const con = getTaggedConsole("extractDecoCardImgs"); | |
| const imgs = new Set(Array.from(elem.querySelectorAll("img")) | |
| .map(i => i.src).filter(i => !!i) | |
| .map(i => extractBfsImgId(new URL(i)))); | |
| if (elem.parentElement?.classList.contains("dyn-decoration-card")) { | |
| for (const child of elem.children) { // only check one level | |
| // dawg I'm not going to Array.from the classList | |
| let componentIsBackgroundImg = false; | |
| for (const cls of child.classList) { | |
| if (cls.startsWith("_backgroundImg_")) { | |
| componentIsBackgroundImg = true; | |
| break; | |
| } | |
| } | |
| if (!componentIsBackgroundImg) continue; | |
| // TODO: ? | |
| let bimg = child.style.getPropertyValue?.("background-image") ?? child.style.backgroundImage; | |
| if (bimg == "") { | |
| con.warn("Calling getComputedStyle on", child); | |
| bimg = window.getComputedStyle(child).backgroundImage; | |
| } | |
| if (bimg == "") { | |
| con.warn("No background-image found on", child); | |
| continue; | |
| } | |
| const bimgMatches = bimg.match(CSS_URL_EXTRACT_REGEX); | |
| if (!bimgMatches) { | |
| con.warn("background-image contains no urls", child, bimg); | |
| continue; | |
| } | |
| imgs.add(extractBfsImgId(new URL(bimgMatches[2]))); | |
| } | |
| } | |
| return imgs; | |
| } | |
| function calcSetIntersectionCount(a, b) { | |
| if (typeof a.intersection !== "undefined") { // es2026 or something | |
| return a.intersection(b).size; | |
| } | |
| let count = 0; | |
| const [small, large] = a.size < b.size ? [a, b] : [b, a]; | |
| for (const item of small) { | |
| if (large.has(item)) { | |
| count++; | |
| } | |
| } | |
| return count; | |
| } | |
| function calcSetUnionCount(a, b) { | |
| if (typeof a.union !== "undefined") { // es2026 or something | |
| return a.union(b).size; | |
| } | |
| return new Set([...a, ...b]).size; | |
| } | |
| function findMedalLevelByImgs(medals, imgs) { | |
| if (imgs.size == 1) { | |
| // awesome "fast path" | |
| const imgHash = ADLER32.str(imgs.values().next().value); | |
| for (let level = medals.length; level >= 1; level--) { // prefer highest matching level | |
| const medalHashes = medals[level - 1]; | |
| if (medalHashes.includes(imgHash)) return level; | |
| } | |
| } else if (imgs.size > 0) { | |
| if (false) { | |
| // TODO: verify if this is better | |
| const imgHashes = Array.from(imgs).map(i => ADLER32.str(i)); | |
| for (let level = medals.length; level >= 1; level--) { | |
| const medalHashes = medals[level - 1]; | |
| if (imgHashes.some(hash => hash in medalHashes)) return level; | |
| } | |
| } else { | |
| const imgHashes = new Set(Array.from(imgs).map(i => ADLER32.str(i))); | |
| const reverseJaccardIndex = Array.from( | |
| medals.map((hashes, level) => { | |
| const hashesSet = new Set(hashes ?? []); | |
| const intersection = calcSetIntersectionCount(hashesSet, imgHashes); | |
| const union = calcSetUnionCount(hashesSet, imgHashes); | |
| // level, invertedIndex | |
| return [1 + level, 1 - ((intersection == 0 || union == 0) ? 0 : (intersection/union))]; | |
| }) | |
| ).sort((a, b) => b[1] - a[1]); // then sort it reversed (this is why index is inverted) | |
| if (reverseJaccardIndex.length > 0) { | |
| return reverseJaccardIndex[reverseJaccardIndex.length - 1][0]; | |
| } | |
| } | |
| } | |
| } | |
| // This totally makes sense. (Damn md5 hashes) | |
| const GUARD_T3 = "舰长", GUARD_T2 = "提督", GUARD_T1 = "总督"; | |
| const GUARD_ORNAMENT_IMG_ID_TO_TIER = { | |
| "garb/item/7605b10f0bae26fdc95e359b7ef11e5359783560": GUARD_T3, | |
| "garb/item/22c143523cbd71f5b03de64f8c0a1e429541ebe6": GUARD_T2, | |
| "garb/item/85f9dced6dd1525b0f7f2b5a54990fed21ade1e5": GUARD_T1 | |
| }; | |
| // Might help: https://s1.hdslb.com/bfs/seed/ogv/garb-component/garb-asset-equipment.umd.js | |
| return async function makeDecoCardTooltip(elem) { | |
| const con = getTaggedConsole("makeDecoCardTooltip"); | |
| const urlWarnIntro = "Unrecognized decoration card URL:"; | |
| const url = new URL(elem.href); | |
| switch (url.hostname + url.pathname) { | |
| case "www.bilibili.com/h5/mall/equity-link/collect-home": { | |
| const itemId = url.searchParams.get("item_id"), | |
| part = url.searchParams.get("part"), | |
| isDiy = url.searchParams.get("isdiy") ?? "0", | |
| vmid = url.searchParams.get("vmid") ?? "2"; | |
| if (!itemId || !part) { | |
| con.warn(urlWarnIntro, "parameters incomplete", elem, url.href); | |
| break; | |
| } | |
| let info; | |
| try { | |
| info = await reqGarbSuitItemInfo(itemId, part, isDiy, vmid); | |
| } catch (err) { | |
| con.error("reqGarbSuitItemInfo failed:", err, [itemId, part, isDiy, vmid]); | |
| } | |
| if (isFailedInfo(info)) { | |
| break; | |
| } | |
| if (info.unavailable) { | |
| return "【已下架装扮】"; | |
| } | |
| /* | |
| if (typeof info.name !== "string" || info.suiteName == info.name) { | |
| return info.suiteName; | |
| } | |
| return `${info.suiteName} - ${info.name}`; | |
| */ | |
| return info.suiteName; | |
| }; | |
| case "www.bilibili.com/h5/mall/digital-card/home": { | |
| const actId = url.searchParams.get("act_id"); | |
| if (!actId) { | |
| con.warn(urlWarnIntro, "parameters incomplete", elem, url.href); | |
| break; | |
| } | |
| let info; | |
| try { | |
| info = await reqGarbDlcActInfo(actId); | |
| } catch (err) { | |
| con.error("reqGarbDlcActInfo failed:", err, actId); | |
| } | |
| if (isFailedInfo(info)) { | |
| break; | |
| } | |
| if (info.medals) { | |
| const imgs = extractDecoCardImgs(elem); | |
| if (imgs.size > 0) { | |
| const foundLvl = findMedalLevelByImgs(info.medals, imgs); | |
| if (foundLvl !== null) return `${info.name} - Lv. ${foundLvl}`; | |
| con.warn("Couldn't deduce collection level from card image(s)", elem, imgs, info.medals); | |
| } | |
| } | |
| return info.name; | |
| }; | |
| case "live.bilibili.com/p/html/live-app-guard-info/index.html": { | |
| const imgs = extractDecoCardImgs(elem); | |
| let foundTier = ""; | |
| if (imgs.size > 0) { | |
| for (const img of imgs) { | |
| foundTier = GUARD_ORNAMENT_IMG_ID_TO_TIER[img]; | |
| if (foundTier) break; | |
| } | |
| if (!foundTier) { | |
| con.warn("Couldn't deduce guard tier from card image(s)", elem, imgs); | |
| } | |
| } | |
| // I don't think this sort of card shows outside of contexts related to the user | |
| // specified in `uid` | |
| /*let userInfo; | |
| const uid = url.searchParams.get("uid"); | |
| if (uid) { | |
| try { | |
| userInfo = await reqUidInfo(uid); | |
| } catch (err) { | |
| con.warn('Requesting info for sailing card "ruid"', uid, "failed:", err); | |
| } | |
| }*/ | |
| return `大航海${foundTier}`; | |
| /* ${!isFailedInfo(userInfo) && !isUserDeleted(userInfo) ? ` - ${userInfo.name} 号` : ""} */ | |
| } | |
| default: | |
| con.warn(urlWarnIntro, "unexpected href", elem, url.href); | |
| break; | |
| } | |
| } | |
| })(); | |
| // TODO: merge https://gist.github.com/Dobby233Liu/cb70b479d0127f000860f416a93053c1 into this? maybe? | |
| // TODO: some way to bail when relevant elements cease to exist | |
| // TODO: user config & alerts where necessary (not very important right now) | |
| (function injectArriveListeners() { | |
| // this is scuffed as of now | |
| /* TODO: alt methodologies such as: | |
| - perform cb on intersection (probably a good idea) | |
| - detect if a relevant elem is being hovered in a global event, and perform cb if so | |
| (not better) | |
| */ | |
| const LAZY_LOAD_TITLE = false; | |
| function shouldAbortElemCb(el) { return !el.isConnected; } | |
| // goofy GUID to prevent collisions | |
| const AE_DELAYCBADDED = "_0DA009960600423DB1886326D8810A86_AE_delayCbAdded"; | |
| function delayCbIfEnabled(cb) { | |
| if (!LAZY_LOAD_TITLE) { | |
| /*return function delayCbIfEnabledClassicWrap(el, ...args) { | |
| // if (shouldAbortElemCb(el)) return; <-- not very helpful | |
| return cb(el, ...args); | |
| }*/ | |
| return cb; | |
| } | |
| return function delayCbIfEnabledLLWrap(el, ...args) { | |
| if (shouldAbortElemCb(el)) return; | |
| if (el[AE_DELAYCBADDED]) { | |
| console.warn("AE_delayCbAdded is already set for", el); | |
| return; | |
| } | |
| function onMouseenter(ev) { | |
| if (ev.target !== el) { | |
| const con = getTaggedConsole("delayCbIfEnabledLLWrap/onMouseenter"); | |
| con.warn("Called for:", el, "but ev.target =", ev.target); | |
| } | |
| el.removeEventListener("mouseenter", onMouseenter); | |
| delete el[AE_DELAYCBADDED]; | |
| return cb(el, ...args); | |
| } | |
| el.addEventListener("mouseenter", onMouseenter); | |
| el[AE_DELAYCBADDED] = true; | |
| } | |
| } | |
| const EAGER = { existing: true }; | |
| const PROCESSING = "(处理中……)"; | |
| function addProcessingLabelToTitle(el, alt=undefined) { | |
| const oldTitle = (el.title.trim?.() != "" && el.title || alt) ?? ""; // this is so goofy | |
| if (LAZY_LOAD_TITLE) return oldTitle; | |
| el.title = (oldTitle ? (oldTitle + "\n") : "") + PROCESSING; | |
| return oldTitle; | |
| } | |
| function setTitleWorkaround(el, title, ariaLabel=null) { | |
| if (LAZY_LOAD_TITLE) { // FIXME | |
| el.removeAttribute("title"); | |
| } | |
| el.title = title; | |
| if ((el.ariaLabel ?? "").trim() == "") { | |
| el.ariaLabel = ariaLabel ?? title; | |
| } | |
| } | |
| const ADD_MOCK_TITLE_DELAY = false; | |
| function debugWait(t) { | |
| if (!ADD_MOCK_TITLE_DELAY) return; | |
| t = t ?? (3000 + Math.floor(Math.random() * 1000)); | |
| return new Promise((resolve, _) => setTimeout(resolve, t)); | |
| } | |
| // TODO: avoid these regexes | |
| const DEBRACKET_REGEX = /\[(.*?)\]/g; | |
| const DEUNDERSCORE_REGEX = /_+/g; | |
| async function addTitleToEmoticon(img, _altFrom=undefined) { | |
| const alt = (_altFrom ?? img).alt; | |
| if (!alt) { | |
| const con = getTaggedConsole("addTitleToEmoticon"); | |
| con.debug(img, "has falsy alt:", alt); | |
| return; | |
| } | |
| const oldTitle = addProcessingLabelToTitle(img, alt); | |
| await debugWait(); | |
| try { | |
| const newTitle = await translateEmoticonName(alt); | |
| const ariaLabel = "表情:" + newTitle.split("\n")[0].replace(DEBRACKET_REGEX, function(_, m) { | |
| return m; | |
| }).replace(DEUNDERSCORE_REGEX, " - "); | |
| setTitleWorkaround(img, newTitle, ariaLabel); | |
| } catch (err) { | |
| img.title = oldTitle; | |
| const con = getTaggedConsole("addTitleToEmoticon"); | |
| con.error("translateEmoticonName failed for", img, err); | |
| } | |
| (_altFrom ?? img).alt = img.title; | |
| if (_altFrom) setTitleWorkaround(_altFrom, img.title); | |
| } | |
| const addTitleToEmoticon_D = delayCbIfEnabled(addTitleToEmoticon); | |
| // .bili-danmaku-x-dm-emoji has no info at all | |
| const emoteSelector = [".bili-rich-text-emoji", ".opus-text-rich-emoji > img"]; | |
| if (location.hostname == "live.bilibili.com") { | |
| emoteSelector.push(".danmaku-item .emoticon img"); | |
| } | |
| document.arrive(emoteSelector.join(","), EAGER, addTitleToEmoticon_D); | |
| arriveInShadowRootOf("bili-rich-text", "#contents img:not(a[data-type] img)", EAGER, addTitleToEmoticon_D); | |
| const HIDE_POPOVER_TITLE_STYLE = ` | |
| /* essentially shows the emote name (again), and they didn't even bother with making it make sense */ | |
| .bili-emoji-popover p:first-of-type { display: none; } | |
| /* compensate for hidden emote name */ | |
| .bili-emoji-popover { | |
| padding-top: 10px; | |
| } | |
| .bili-emoji-popover.placement-top { | |
| margin-top: 18px; | |
| } | |
| `; | |
| GM_addStyle(HIDE_POPOVER_TITLE_STYLE); | |
| const HIDE_SHD_POPOVER_TITLE_STYLE = HIDE_POPOVER_TITLE_STYLE | |
| .replaceAll(".bili-emoji-popover", "#emoji-popover").replaceAll(".placement-", "."); | |
| addStyleInShadowRootOf("bili-emoji-popover", HIDE_SHD_POPOVER_TITLE_STYLE); | |
| // not planning to add copyAltToTitle for the popover atm (see Screenshots\2026-04\msedge_KrOAMe2YrU.png) | |
| // TODO: avoid this regex | |
| const ARIA_FRIENDLY_LEVEL_INDICATOR_REGEX = / - Lv\.([0-9]+)$/; | |
| async function addTitleToDecoCard(link) { | |
| const oldTitle = addProcessingLabelToTitle(link); | |
| await debugWait(); | |
| try { | |
| const newTitle = await makeDecoCardTooltip(link); | |
| if (newTitle) { | |
| const ariaLabel = "装饰卡片:" + newTitle.replace(ARIA_FRIENDLY_LEVEL_INDICATOR_REGEX, function(_, m) { | |
| return `(${m}级)`; | |
| }) | |
| setTitleWorkaround(link, newTitle, ariaLabel); | |
| return; | |
| } | |
| } catch (err) { | |
| const con = getTaggedConsole("addTitleToDecoCard"); | |
| con.error("makeDecoCardTooltip failed for", link, err); | |
| } | |
| // fallback | |
| link.title = oldTitle; | |
| } | |
| const addTitleToDecoCard_D = delayCbIfEnabled(addTitleToDecoCard); | |
| document.arrive(".dyn-decoration-card > a", EAGER, addTitleToDecoCard_D); | |
| // FIXME: for me, the callback may be called twice (first on an instance that gets removed later), because ??? | |
| // maybe the element is getting recreated somehow | |
| arriveInShadowRootOf("bili-comment-user-sailing-card", "#card > a", EAGER, addTitleToDecoCard_D); | |
| function copyImgAltToParentTitle(tab) { | |
| const img = tab.querySelector(":scope > img"); | |
| if (img?.alt) setTitleWorkaround(tab, img.alt); | |
| } | |
| const copyImgAltToParentTitle_D = delayCbIfEnabled(copyImgAltToParentTitle); | |
| function copyEmoteAltToParentTitle(tab) { | |
| const img = tab.querySelector(":scope > img"); | |
| if (img) return addTitleToEmoticon(tab, img); | |
| } | |
| const copyEmoteAltToParentTitle_D = delayCbIfEnabled(copyEmoteAltToParentTitle); | |
| // hopefully .bili-emoji is only for the picker | |
| document.arrive(".bili-emoji .bili-emoji__pkg", copyImgAltToParentTitle_D); | |
| document.arrive(".bili-emoji__list__item", copyEmoteAltToParentTitle_D); | |
| arriveInShadowRootOf("bili-emoji-picker", "#tabs .tab", EAGER, copyImgAltToParentTitle_D); | |
| arriveInShadowRootOf("bili-emoji-picker", "#content .emoji", EAGER, copyEmoteAltToParentTitle_D); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment