Last active
February 15, 2026 18:17
-
-
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.19c | |
| // @description Adds emoticon and decoration card name hover hints to bilibili | |
| // @author Liu Wenyuan | |
| // @match https://*.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"; | |
| /* global Arrive */ | |
| /* global Cookies */ | |
| /* global ADLER32 */ | |
| const getTaggedConsole = (function() { | |
| const consoles = new Map(); | |
| return function getTaggedConsole(tag) { | |
| if (!consoles.has(tag)) { | |
| const _tag = "[AE]<" + tag + ">"; | |
| consoles.set(tag, Object.freeze({ | |
| _tag: _tag, | |
| log: console.log.bind(console, _tag), | |
| warn: console.warn.bind(console, _tag), | |
| error: console.error.bind(console, _tag), | |
| trace: console.trace.bind(console, _tag), | |
| debug: console.debug.bind(console, _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(); | |
| 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) { | |
| 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. event for calling _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]; | |
| return (obj[funcName] = function(...args) { | |
| return newFunc.bind(this)(origFunc.bind(this), ...args); | |
| }); | |
| } | |
| hookIn(HTMLElement.prototype, "attachShadow", function(orig, options, ...etc) { | |
| const ret = orig(options, ...etc); | |
| if (this.shadowRoot) { // not going to access ret here | |
| 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 }; | |
| })({ | |
| addRetroactively: false, // we don't need this yet | |
| console: getTaggedConsole("init_arriveInShadowRootOf") | |
| }); | |
| // this was vibe coded but has been heavily rewritten since then | |
| const { throttledFetch, ThrottledRequestCancelledError } = (function({ | |
| getTaggedConsole = function(tag) { return console; }, | |
| maxConcurrentRequests, queueSize, | |
| minimumGracePeriod, maximumGracePeriod, | |
| requestTimeout | |
| } = {}) { | |
| const requestQueue = []; | |
| let activeRequests = 0; | |
| 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(function(resolve, _) { | |
| setTimeout(resolve, t); | |
| }); | |
| } | |
| 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))); | |
| } | |
| } | |
| let isProcessingQueue = false; | |
| function scheduleProcessQueue() { | |
| if (isProcessingQueue) return; | |
| isProcessingQueue = true; | |
| requestAnimationFrame(async function _runProcessQueue() { | |
| try { | |
| await processQueue(); | |
| } catch (err) { | |
| const con = getTaggedConsole("throttledFetch/scheduleProcessQueue"); | |
| con.error("Failed to process queue:", err); | |
| } finally { | |
| isProcessingQueue = false; | |
| } | |
| }); | |
| } | |
| class ThrottledRequestCancelledError extends Error { | |
| constructor(...args) { | |
| super(...args); | |
| this.name = "ThrottledRequestCancelledError"; | |
| } | |
| } | |
| // TODO: dedupe by request contents (using a stupid Map, obj as key and promise as value)? maybe? | |
| function throttledFetch(url, options) { | |
| return new Promise(function _throttledFetch(resolve, reject) { | |
| const controller = new AbortController(); | |
| async function perform() { | |
| const con = getTaggedConsole("throttledFetch/perform"); | |
| activeRequests++; | |
| const thisReqIdentifier = "[" + activeRequests + "-" + Date.now() + "]"; | |
| con.debug(thisReqIdentifier, "Requesting:", url); | |
| try { | |
| const res = await fetch(url, { | |
| ...options, | |
| signal: AbortSignal.any([controller.signal, AbortSignal.timeout(requestTimeout)]) | |
| }); | |
| con.debug(thisReqIdentifier, "Processed request:", res.status, res.url); | |
| resolve(res); | |
| } catch (err) { | |
| reject(err); | |
| } finally { | |
| activeRequests--; | |
| if (activeRequests < 0) { | |
| con.warn("activeRequests underflow"); | |
| activeRequests = 0; | |
| } | |
| scheduleProcessQueue(); | |
| } | |
| } | |
| if (activeRequests < maxConcurrentRequests) { | |
| perform(); | |
| } else if (requestQueue.length >= queueSize) { | |
| reject(new ThrottledRequestCancelledError("Request queue is full")); | |
| } else { | |
| requestQueue.push({ | |
| perform, | |
| abort: (err) => controller.abort(err) | |
| }); | |
| } | |
| }); | |
| } | |
| window.addEventListener("pagehide", (ev) => { | |
| if (ev.persisted) return; // TODO: ? | |
| for (const i of requestQueue) { | |
| i.abort(new ThrottledRequestCancelledError("Current page is being unloaded")); | |
| } | |
| requestQueue.length = 0; | |
| }); | |
| return { throttledFetch, ThrottledRequestCancelledError }; | |
| })({ | |
| getTaggedConsole, | |
| maxConcurrentRequests: 3, | |
| // TODO: having second thoughts on queue size | |
| queueSize: 24, | |
| minimumGracePeriod: 100, | |
| maximumGracePeriod: 250, | |
| requestTimeout: 10000 | |
| }); | |
| const { extractBfsImgInfo, extractBfsImgId, makeBfsImgUrlByInfo, parseBfsImgParams, packBfsImgParams } = (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 | |
| } | |
| function makeBfsImgUrlByInfo(info) { | |
| return new URL( | |
| info.server | |
| + info.id + info.origFormat | |
| + ((info.params && info.params != "") ? "@" + info.params : "")); | |
| } | |
| const LOWERALPHA_REGEX = /^[a-z]+$/; | |
| function parseBfsImgParams(params) { | |
| const con = getTaggedConsole("parseBfsImgParams"); | |
| const kv = {}; | |
| const extStartIndex = params.indexOf("."); | |
| if (extStartIndex >= 0) { | |
| kv.format = params.substring(extStartIndex); | |
| params = params.substring(0, extStartIndex); | |
| } | |
| for (const entry of params.split("_")) { | |
| if (entry.length == 0) continue; | |
| if (entry.substring(0, 1) == "!") { | |
| if (kv.from) con.warn('"from" key already added, old', kv.from, "new", entry); | |
| kv.from = entry.substring(1); | |
| continue; | |
| } | |
| const k = entry.substring(entry.length - 1); | |
| if (!k.match(LOWERALPHA_REGEX)) { | |
| con.warn("Key", k, "doesn't match LOWERALPHA_REGEX, in params", params); | |
| continue; | |
| } | |
| if (k.length != 1) { | |
| con.warn("Key", k, "is not a single character"); | |
| } | |
| const v = entry.substring(0, entry.length - 1); | |
| if (v.length == 0) { | |
| con.warn("Value of key", k, "is empty"); | |
| continue; | |
| } | |
| kv[k] = v; | |
| } | |
| return Object.keys(kv).length == 0 ? null : kv; | |
| } | |
| function packBfsImgParams(kv) { | |
| const con = getTaggedConsole("packBfsImgParams"); | |
| const specialKeys = ["format", "from"]; | |
| const params = []; | |
| for (const [k, _v] of Object.entries(kv)) { | |
| if (specialKeys.includes(k)) continue; | |
| if (!k.match(LOWERALPHA_REGEX)) { | |
| con.warn("Key", k, "doesn't match LOWERALPHA_REGEX, value:", _v); | |
| continue; | |
| } | |
| if (k.length != 1) { | |
| con.warn("Key", k, "is not a single character"); | |
| } | |
| // help | |
| function isUndefinedOrNull(i) { return typeof i === "undefined" || i === null; } | |
| const v = isUndefinedOrNull(_v) ? "" : (typeof _v === "boolean" ? (!_v ? "0" : "1") : _v.toString()); | |
| if (v.length == 0) { | |
| con.warn("Value of key", k, "is empty"); | |
| continue; | |
| } | |
| params.push(v + k); | |
| } | |
| if (kv.from) { | |
| params.push("!" + kv.from); | |
| } | |
| return params.join("_") + (kv.format ?? ""); | |
| } | |
| return { extractBfsImgInfo, extractBfsImgId, makeBfsImgUrlByInfo, parseBfsImgParams, packBfsImgParams }; | |
| })(); | |
| // has to be here because we actively clobber this | |
| let knownUids = {}; | |
| const { | |
| requestInfoForUid, requestInfoForGarbSuitItem, requestInfoForGarbDlcAct, | |
| infoFailed | |
| } = (function() { | |
| // TODO: allow other data types to have different expire times? | |
| const KNOWN_UIDS_EXPIRE_TIME = 1 * 60 * 60 * 1000; | |
| const FAILED_UIDS_EXPIRE_TIME = 10 * 1000; | |
| const KNOWN_UIDS_MAX_RETENTION_COUNT = 30; | |
| const KNOWN_UIDS_REF_COUNT_LOAD_DECAY_FACTOR = 0.99; // TODO: temporary | |
| const KNOWN_UIDS_REF_COUNT_REF_DECAY_FACTOR = 0.95; | |
| const KNOWN_UIDS_TS_PENALTY_WEIGHT = 5 * 60 * 1000; | |
| const KNOWN_UIDS_STORAGE_KEY = "knownUids"; | |
| const KNOWN_UIDS_FORCE_NO_STORAGE = false; | |
| const REQUEST_INFO_FORCE_OFFLINE = false; | |
| // This does not mean internet is reachable, but we don't really care about that for now | |
| function isOnline() { | |
| return navigator.onLine && !REQUEST_INFO_FORCE_OFFLINE; | |
| } | |
| async function _loadKnownUids() { // marked async just so it generates a Promise | |
| const con = getTaggedConsole("_loadKnownUids"); | |
| let newKnownUids = knownUids; | |
| const storedKnownUids = | |
| (!KNOWN_UIDS_FORCE_NO_STORAGE && typeof GM_getValue === "function") | |
| ? GM_getValue(KNOWN_UIDS_STORAGE_KEY, null) | |
| : null; | |
| if (storedKnownUids !== null) { | |
| newKnownUids = Object.assign({}, storedKnownUids, newKnownUids); // ? | |
| } | |
| // 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 [uid, info] of Object.entries(newKnownUids)) { | |
| if ((now - info.timestamp) > (info.failed ? FAILED_UIDS_EXPIRE_TIME : KNOWN_UIDS_EXPIRE_TIME)) { | |
| delete newKnownUids[uid]; | |
| expiredEntries++; | |
| } | |
| } | |
| if (expiredEntries > 0) { | |
| con.log("Expired entries to be deleted:", expiredEntries); | |
| } | |
| const sortedByRefCount = Object.entries(newKnownUids).sort((a, b) => { | |
| const fallbackLats = now - 1000; | |
| const scoreA = (a[1].refCount ?? 1) + (now - (a[1].lastAccessTimestamp ?? fallbackLats)) / KNOWN_UIDS_TS_PENALTY_WEIGHT; | |
| const scoreB = (b[1].refCount ?? 1) + (now - (b[1].lastAccessTimestamp ?? fallbackLats)) / KNOWN_UIDS_TS_PENALTY_WEIGHT; | |
| return scoreB - scoreA; | |
| }); | |
| if (sortedByRefCount.length > KNOWN_UIDS_MAX_RETENTION_COUNT) { | |
| const shearedEntries = sortedByRefCount.length - KNOWN_UIDS_MAX_RETENTION_COUNT; | |
| for (const [uid, _] of sortedByRefCount.slice(KNOWN_UIDS_MAX_RETENTION_COUNT)) { | |
| delete newKnownUids[uid]; | |
| } | |
| con.log("Entries to be sheared:", shearedEntries); | |
| } | |
| } | |
| if (KNOWN_UIDS_REF_COUNT_LOAD_DECAY_FACTOR != 1) { // TEMP | |
| for (const info of Object.values(newKnownUids)) { | |
| info.refCount = Math.ceil((info.refCount ?? 1) * KNOWN_UIDS_REF_COUNT_LOAD_DECAY_FACTOR); | |
| } | |
| } | |
| knownUids = newKnownUids; | |
| return knownUids; | |
| } | |
| // we have singletons at home | |
| let loadKnownUidsPromise = null; | |
| function loadKnownUids() { // pretend this is async | |
| if (!loadKnownUidsPromise) { | |
| loadKnownUidsPromise = _loadKnownUids().finally(() => { | |
| loadKnownUidsPromise = null; | |
| }); | |
| } else { | |
| const con = getTaggedConsole("loadKnownUids"); | |
| con.debug("Caller will wait for completion of the previous load attempt"); | |
| } | |
| return loadKnownUidsPromise; | |
| } | |
| //(async () => saveKnownUids(await loadKnownUids()))(); | |
| let savingKnownUids = 0; | |
| function saveKnownUids(localKuids) { | |
| const con = getTaggedConsole("saveKnownUids"); | |
| if (KNOWN_UIDS_FORCE_NO_STORAGE || typeof GM_setValue !== "function") { | |
| con.warn("Storage disabled. Not saving"); | |
| return false; | |
| } | |
| if (localKuids !== knownUids) { | |
| con.warn("localKuids !== knownUids"); | |
| } | |
| if (savingKnownUids > 0) { | |
| con.debug("Already working on it (what to do? idk)"); | |
| //return false; | |
| } | |
| savingKnownUids++; | |
| GM_setValue(KNOWN_UIDS_STORAGE_KEY, knownUids); | |
| savingKnownUids--; | |
| if (savingKnownUids < 0) { | |
| con.warn("savingKnownUids underflow"); | |
| savingKnownUids = 0; | |
| } | |
| return true; | |
| } | |
| // might cause some extra race conditions | |
| /*setInterval(async function() { | |
| saveKnownUids(await loadKnownUids()); | |
| }, Math.min(KNOWN_UIDS_EXPIRE_TIME, FAILED_UIDS_EXPIRE_TIME));*/ | |
| let saveKnownUidsTimeout = null; | |
| function scheduleSaveKnownUids() { | |
| clearTimeout(saveKnownUidsTimeout); | |
| saveKnownUidsTimeout = setTimeout(() => { | |
| saveKnownUidsTimeout = null; | |
| saveKnownUids(knownUids); | |
| }, 200); | |
| return saveKnownUidsTimeout; | |
| } | |
| window.addEventListener("pagehide", function saveKnownUidsOnPagehide(ev) { | |
| if (ev.persisted) return; // TODO: ? | |
| if (saveKnownUidsTimeout) { | |
| const con = getTaggedConsole("saveKnownUidsOnPagehide"); | |
| con.warn("saveKnownUidsTimeout did not trigger in time!! Extreme corner case, attempting to save right now"); | |
| if (savingKnownUids > 0) { | |
| con.warn("savingKnownUids > 0. Should exit here maybe?"); | |
| } | |
| clearTimeout(saveKnownUidsTimeout); | |
| saveKnownUidsTimeout = null; | |
| saveKnownUids(knownUids); | |
| } | |
| }); | |
| // TODO: https://www.tampermonkey.net/documentation.php?locale=en#api:GM_addValueChangeListener | |
| // TODO: retrying | |
| const requestInfoPromises = {}; | |
| async function _requestInfo(dataType, func, ver, id, ...args) { | |
| id = id.toString(); | |
| const con = getTaggedConsole("_requestInfo"); | |
| await loadKnownUids(); | |
| const isOnlineCurrently = isOnline(); | |
| const shouldDownload = !knownUids[id] || knownUids[id].version != ver; | |
| if (isOnlineCurrently && shouldDownload) { | |
| if (!requestInfoPromises[id]) { | |
| requestInfoPromises[id] = (async function _doRequestInfo() { | |
| let result = { failed: true }; | |
| try { | |
| result = await func(id, ...args); | |
| } catch (err) { | |
| con.error(`While fetching ${dataType} ${id}:`, err); | |
| if (err.name == "ThrottledRequestCancelledError") { | |
| result = null; | |
| } | |
| } | |
| if (result) { | |
| result.version = ver; | |
| result.timestamp = Date.now(); | |
| result.refCount = 0; | |
| knownUids[id] = result; | |
| //scheduleSaveKnownUids(); // ? | |
| } | |
| })().finally(function _requestInfoEnd() { | |
| if (!requestInfoPromises[id]) { | |
| getTaggedConsole("_requestInfo/_requestInfoEnd").warn("requestInfoPromise for", id, "is already null"); | |
| } | |
| delete requestInfoPromises[id]; | |
| }); | |
| } | |
| await requestInfoPromises[id]; | |
| } | |
| const info = knownUids[id] ?? null; // ? | |
| if (info) { | |
| info.refCount = Math.ceil((info.refCount ?? 1) * KNOWN_UIDS_REF_COUNT_REF_DECAY_FACTOR); | |
| info.refCount++; | |
| info.lastAccessTimestamp = Date.now(); | |
| scheduleSaveKnownUids(); | |
| } else if (!isOnlineCurrently && shouldDownload) { | |
| con.warn("No info cached for", id, "Did not download from server"); | |
| if (isOnline()) { | |
| con.warn("Online now but we don't have the means to retry"); | |
| } | |
| } | |
| return info; | |
| } | |
| const B_API = "https://api.bilibili.com/x"; | |
| let csrfTokenExistenceWarning = true; | |
| function getCsrfToken() { | |
| const csrfToken = Cookies.get("bili_jct"); | |
| if (!csrfToken) { | |
| if (csrfTokenExistenceWarning) { | |
| getTaggedConsole("_requestInfo/getCsrfToken").debug("bili_jct doesn't exist, not logged in?"); | |
| csrfTokenExistenceWarning = false; | |
| } | |
| } else { | |
| csrfTokenExistenceWarning = true; | |
| } | |
| return csrfToken ?? ""; | |
| } | |
| function throwIfResponseNotOk(res) { | |
| if (!res.ok) { | |
| throw new Error(`${res.status} (${res.statusText})`, { cause: res }); | |
| } | |
| } | |
| class BilibiliApiError extends Error { | |
| constructor(response) { | |
| super(undefined, { cause: response }); | |
| } | |
| get message() { | |
| return `${this.cause.message} (${this.cause.code})\n` + JSON.stringify(this.cause); | |
| } | |
| } | |
| const NUMERIC_REGEX = /^[0-9]+$/; | |
| function doNumericCheck(id, ctx) { | |
| if (isNaN(id) && !(typeof id === "string" && id.match(NUMERIC_REGEX))) { | |
| getTaggedConsole("_requestInfo/doNumericCheck").warn("ctx", ctx, "id", id, "is not numeric"); | |
| return false; | |
| } | |
| return true; | |
| } | |
| const INFO_UID_VERSION = 1; | |
| async function _requestInfoForUidReal(uid) { | |
| const endpointUrl = new URL(B_API + "/web-interface/card"); | |
| endpointUrl.searchParams.set("mid", uid); | |
| const res = await throttledFetch(endpointUrl.href); | |
| throwIfResponseNotOk(res); | |
| const content = await res.json(); | |
| if (content.code != 0 || !content.data?.card?.name) { | |
| throw new BilibiliApiError(content); | |
| } | |
| return { name: content.data.card.name?.trim() }; | |
| } | |
| async function requestInfoForUid(uid) { | |
| doNumericCheck(uid, "uid"); | |
| return await _requestInfo("name of user", _requestInfoForUidReal, INFO_UID_VERSION, uid); | |
| } | |
| const INFO_GARB_SUIT_ITEM_VERSION = 2; | |
| async function _requestInfoForGarbSuitItemReal(_, itemId, partType, isDiy, vmid) { | |
| const endpointUrl = new URL(B_API + "/garb/v2/user/suit/benefit"); | |
| // this API will work without a csrf token | |
| endpointUrl.searchParams.set("csrf", getCsrfToken()); | |
| endpointUrl.searchParams.set("is_diy", isDiy); | |
| endpointUrl.searchParams.set("item_id", itemId); | |
| endpointUrl.searchParams.set("part", partType); | |
| // idk if this is necessary when is_diy is false | |
| // the only difference it seems to make in that case is changing how data is sorted | |
| if (isDiy != "0") { | |
| endpointUrl.searchParams.set("vmid", vmid); | |
| } | |
| const res = await throttledFetch(endpointUrl.href); | |
| throwIfResponseNotOk(res); | |
| const content = await res.json(); | |
| if (content.code != 0) { | |
| throw new BilibiliApiError(content); | |
| } | |
| if (content.data === null) { | |
| // "很遗憾,当前装扮暂时无法查看,去看看其他装扮吧~" or "empty-rights", see id 5887 | |
| return { unavailable: true }; | |
| } | |
| if (!content.data?.name) { | |
| throw new BilibiliApiError(content); | |
| } | |
| 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); | |
| } | |
| itemName = item.name; | |
| }*/ | |
| return { suiteName: content.data.name?.trim(), name: itemName?.trim() }; | |
| } | |
| async function requestInfoForGarbSuitItem({ item_id, part, is_diy, vmid }) { | |
| doNumericCheck(item_id, "garb_suit"); | |
| return await _requestInfo("name of personalized suit item", _requestInfoForGarbSuitItemReal, INFO_GARB_SUIT_ITEM_VERSION, | |
| "garb_suit_" + item_id, // fake id for cache | |
| item_id, part, is_diy, vmid); | |
| } | |
| const INFO_DLC_ACT_VERSION = 2; | |
| async function _requestInfoForGarbDlcAct(_, id) { | |
| const endpointUrl = new URL(B_API + "/vas/dlc_act/act/basic"); | |
| endpointUrl.searchParams.set("act_id", id); | |
| // this API will work without a csrf token iirc ? | |
| endpointUrl.searchParams.set("csrf", getCsrfToken()); | |
| const res = await throttledFetch(endpointUrl.href); | |
| throwIfResponseNotOk(res); | |
| const content = await res.json(); | |
| if (content.code != 0 || !content.data?.act_title) { | |
| throw new BilibiliApiError(content); | |
| } | |
| function _parseMedalInfo(dataJson) { | |
| if (!dataJson) return; | |
| const data = JSON.parse(dataJson); | |
| const levels = data.map(i => i.level).sort((a, b) => a - b); | |
| if (levels.length <= 0) return; | |
| if (levels[0] < 1) { | |
| throw new Error("1st level is smaller than 1, but we assume Lv1 is the first!"); | |
| } | |
| 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("_requestInfoForGarbDlcAct/_parseMedalInfo"); | |
| con.warn("Already have hashes for level", medal.level); | |
| } | |
| const imgs = Array.from(new Set(Object.values(medal.scene_image))); // nuts | |
| imgUrlHashesByLvl[destIndex] = imgs.map(i => ADLER32.str(extractBfsImgId(new URL(i)))); | |
| } | |
| return imgUrlHashesByLvl; | |
| } | |
| let medals; | |
| try { | |
| medals = _parseMedalInfo(content.data.collector_medal_info); | |
| } catch (err) { | |
| const con = getTaggedConsole("_requestInfoForGarbDlcAct"); | |
| con.warn("_parseMedalInfo failed:", err); | |
| } | |
| return { name: content.data.act_title?.trim(), medals: medals } | |
| } | |
| async function requestInfoForGarbDlcAct(id) { | |
| doNumericCheck(id, "dlc_act"); | |
| return await _requestInfo("name of digital collection campaign", _requestInfoForGarbDlcAct, INFO_DLC_ACT_VERSION, | |
| "dlc_act_" + id, | |
| id); | |
| } | |
| function infoFailed(info) { | |
| return !info || info.failed; | |
| } | |
| return { | |
| requestInfoForUid, requestInfoForGarbSuitItem, requestInfoForGarbDlcAct, | |
| infoFailed | |
| }; | |
| })(); | |
| const translateEmoticonName = (function() { | |
| // deliberately checking only the left part of the bracket | |
| const UP_EMOTE_REGEX = /(?<=\[)(UPOWER|UP)_(\d+)/; | |
| // UPOWER 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 "充电表情包" | |
| // -> 充电表情包_ / 充电专属_ / 充电表情_ | |
| const UP_EMOTE_UPOWER_TL_PREFIX = "充电表情包_"; | |
| // UP 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 ("T1", loosely) emotes are unlocked only once for common users | |
| // -> 直播表情包_ / 直播间表情_ / 粉丝团专属_ / 大航海专属_ | |
| const UP_EMOTE_GLOBAL_TL_PREFIX = "直播间表情包_"; | |
| // please ask me for these screenshots if you need them | |
| return async function translateEmoticonName(name) { | |
| name = name?.trim(); | |
| if (!name.startsWith("[") || !name.endsWith("]")) { | |
| // Live-specific emotes | |
| return name; | |
| } | |
| const match = name.match(UP_EMOTE_REGEX); | |
| const uid = match?.[2]; | |
| if (!uid) { | |
| return name; | |
| } | |
| let userInfo; | |
| try { | |
| userInfo = await requestInfoForUid(uid); | |
| } catch (err) { | |
| const con = getTaggedConsole("translateEmoticonName"); | |
| con.error(`While handling ${name}:`, err); | |
| } | |
| const prefix = match[1] == "UP" ? UP_EMOTE_GLOBAL_TL_PREFIX : UP_EMOTE_UPOWER_TL_PREFIX; | |
| if (!infoFailed(userInfo)) { | |
| return name.replace(UP_EMOTE_REGEX, prefix + knownUids[uid].name) + `\n(UID:${uid})`; | |
| } | |
| return name.replace(UP_EMOTE_REGEX, prefix + `(${uid})`) + "\n(查询UP主失败)"; | |
| } | |
| })(); | |
| const getTitleForDecoCard = (function() { | |
| // This totally makes sense. (Damn md5 hashes) | |
| const GUARD_T3 = "舰长"; | |
| const GUARD_T2 = "提督"; | |
| const GUARD_T1 = "总督"; | |
| const GUARD_ORNAMENT_IMG_ID_TO_TIER = { | |
| "garb/item/7605b10f0bae26fdc95e359b7ef11e5359783560": GUARD_T3, | |
| "garb/item/22c143523cbd71f5b03de64f8c0a1e429541ebe6": GUARD_T2, | |
| "garb/item/85f9dced6dd1525b0f7f2b5a54990fed21ade1e5": GUARD_T1 | |
| }; | |
| 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; | |
| } | |
| // Might help: https://s1.hdslb.com/bfs/seed/ogv/garb-component/garb-asset-equipment.umd.js | |
| async function getTitleForDecoCard(elem) { | |
| const con = getTaggedConsole("getTitleForDecoCard"); | |
| 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 && elem.parentElement.classList.contains("dyn-decoration-card")) { | |
| for (const child of elem.children) { // one level | |
| // dawg I'm not going to Array.from the classList | |
| let isBackgroundImage = false; | |
| for (const cls of child.classList) { | |
| if (cls.startsWith("_backgroundImg_")) { | |
| isBackgroundImage = true; | |
| break; | |
| } | |
| } | |
| if (!isBackgroundImage) continue; | |
| // 50% from https://stackoverflow.com/a/14013171 | |
| const style = child.currentStyle || window.getComputedStyle(child, false); | |
| if (style.backgroundImage == "") continue; | |
| if (!(style.backgroundImage.startsWith("url(") && style.backgroundImage.endsWith(")"))) { | |
| con.warn("background-image is not url", style.backgroundImage, child); | |
| continue; | |
| } | |
| imgs.add(extractBfsImgId(new URL(style.backgroundImage.slice(4, -1).replaceAll('"', "")))); | |
| } | |
| } | |
| const url = new URL(elem.href); | |
| switch (url.hostname + url.pathname) { | |
| case "www.bilibili.com/h5/mall/equity-link/collect-home": { | |
| const reqData = { | |
| item_id: url.searchParams.get("item_id"), | |
| is_diy: url.searchParams.get("isdiy") ?? "0", | |
| part: url.searchParams.get("part"), | |
| vmid: url.searchParams.get("vmid") ?? "2", | |
| }; | |
| if (!reqData.item_id || !reqData.part) { | |
| con.warn("Unrecognized decoration card URL: parameters incomplete", elem, url.href); | |
| break; | |
| } | |
| let info; | |
| try { | |
| info = await requestInfoForGarbSuitItem(reqData); | |
| } catch (err) { | |
| con.error("requestInfoForGarbSuitItem failed:", err, reqData); | |
| } | |
| if (infoFailed(info)) { | |
| break; | |
| } | |
| if (info.unavailable) { | |
| return "【已下架装扮】"; | |
| } | |
| /* | |
| if (typeof info.name !== "string" || info.suiteName == info.name) { | |
| return info.suiteName ?? ""; | |
| } | |
| return `${info.suiteName} - ${info.name}`; | |
| */ | |
| if (!info.suiteName) { | |
| con.warn("suiteName is undefined for item:", reqData.item_id); | |
| break; | |
| } | |
| return info.suiteName; | |
| break; | |
| }; | |
| case "www.bilibili.com/h5/mall/digital-card/home": { | |
| const actId = url.searchParams.get("act_id"); | |
| if (!actId) { | |
| con.warn("Unrecognized decoration card URL: parameters incomplete", elem, url.href); | |
| break; | |
| } | |
| let info; | |
| try { | |
| info = await requestInfoForGarbDlcAct(actId); | |
| } catch (err) { | |
| con.error("requestInfoForGarbDlcAct failed:", err, actId); | |
| } | |
| if (infoFailed(info)) { | |
| break; | |
| } | |
| let foundLvl = null; | |
| if (info.medals && imgs) { | |
| if (imgs.size == 1) { | |
| const myImgUrlHash = ADLER32.str(imgs.values().next().value); | |
| for (let i = info.medals.length - 1; i >= 0; i--) { | |
| const medal = info.medals[i]; | |
| if (medal.includes(myImgUrlHash)) { | |
| foundLvl = 1 + i; | |
| break; | |
| } | |
| } | |
| } else if (imgs.size > 0) { | |
| // TODO: horrifying, this could be saner | |
| // also what if imgs has a image not in info.medals | |
| const myImgUrlHashes = new Set(Array.from(imgs).map(i => ADLER32.str(i))); | |
| const reverseJaccardIndexByLevel = Array.from( | |
| info.medals.entries() | |
| .map(([n, j]) => { | |
| const lvlSet = new Set(j ?? []); | |
| const inst = calcSetIntersectionCount(lvlSet, myImgUrlHashes); | |
| const uni = calcSetUnionCount(lvlSet, myImgUrlHashes); | |
| return [1 + n, 1 - ((inst == 0 || uni == 0) ? 0 : (inst/uni))]; | |
| }) | |
| ).sort((a, b) => b[1] - a[1]); | |
| if (reverseJaccardIndexByLevel.length > 0) { | |
| foundLvl = reverseJaccardIndexByLevel[reverseJaccardIndexByLevel.length - 1][0]; | |
| } | |
| } | |
| if (!foundLvl) { | |
| con.warn("Couldn't deduce collection level from card image(s)", elem); | |
| } | |
| } | |
| return info.name + (foundLvl ? (" - Lv." + foundLvl) : ""); | |
| break; | |
| }; | |
| case "live.bilibili.com/p/html/live-app-guard-info/index.html": { | |
| // MAYBE also check https://api.live.bilibili.com/xlive/web-ucenter/user/MedalWall?target_id=<fan uid> | |
| let foundTier = ""; | |
| if (imgs && imgs.size > 0) { | |
| for (const i of imgs) { | |
| foundTier = GUARD_ORNAMENT_IMG_ID_TO_TIER[i]; | |
| if (foundTier) break; | |
| } | |
| if (!foundTier) { | |
| con.warn("Couldn't deduce guard tier from card image(s)", elem); | |
| } | |
| } | |
| // 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 requestInfoForUid(uid); | |
| } catch (err) { | |
| con.error("Sailing card \"ruid\" info request failed:", err, uid); | |
| } | |
| }*/ | |
| return `大航海${foundTier}` /* + !infoFailed(userInfo) ? ` - ${userInfo.name} 号` : ""*/; | |
| break; | |
| } | |
| default: | |
| con.warn("Unrecognized decoration card URL: unexpected href", elem, url.href); | |
| break; | |
| } | |
| return null; | |
| } | |
| return getTitleForDecoCard; | |
| })(); | |
| const increaseImageServedSize = (function() { | |
| const IMAGE_RESIZE_FACTOR = 2; | |
| function _increaseImageServedSizeByUrl(href, resizeBy=undefined) { | |
| const con = getTaggedConsole("increaseImageServedSize/_increaseImageServedSizeByUrl"); | |
| const origUrl = new URL(href); | |
| const info = extractBfsImgInfo(origUrl); | |
| if (!info || !info.params) { | |
| con.debug(origUrl, "is not a bfs img or has no params, skipping"); | |
| return; | |
| } | |
| const oldParams = info.params; | |
| const paramsKv = parseBfsImgParams(info.params); | |
| if (!paramsKv.w && !paramsKv.h) { | |
| con.debug(origUrl, "params have no keys of interest, skipping"); | |
| return; | |
| } | |
| if (paramsKv.w) { | |
| const w = parseInt(paramsKv.w); | |
| if (isNaN(w)) { | |
| con.warn(paramsKv.w, "not an integer"); | |
| } else { | |
| paramsKv.w = Math.floor(w * (resizeBy ?? IMAGE_RESIZE_FACTOR)); | |
| } | |
| } | |
| if (paramsKv.h) { | |
| const h = parseInt(paramsKv.h); | |
| if (isNaN(h)) { | |
| con.warn(paramsKv.h, "not an integer"); | |
| } else { | |
| paramsKv.h = Math.floor(h * (resizeBy ?? IMAGE_RESIZE_FACTOR)); | |
| } | |
| } | |
| info.params = packBfsImgParams(paramsKv); | |
| if (info.params == oldParams) { | |
| con.debug(origUrl, "params did not change"); | |
| return; | |
| } | |
| return makeBfsImgUrlByInfo(info); | |
| } | |
| return async function increaseImageServedSize(img) { | |
| if (img.tagName.toUpperCase() == "IMG" && img.src) { | |
| const newSrc = _increaseImageServedSizeByUrl(new URL(img.src)); | |
| if (newSrc) img.src = newSrc.href; | |
| } | |
| // TODO: process background-image | |
| } | |
| })(); | |
| // TODO: merge https://gist.github.com/Dobby233Liu/cb70b479d0127f000860f416a93053c1 into this? maybe? | |
| // TODO: some way to bail when elements cease to exist | |
| (function injectArriveListeners() { | |
| // this is scuffed | |
| const ENABLE_TITLE_LAZY_LOADING = false; | |
| function shouldAbortElemCb(el) { return !el.isConnected; } | |
| function delayCbIfEnabled(cb) { | |
| if (!ENABLE_TITLE_LAZY_LOADING) { | |
| return function delayCbIfEnabledClassicWrap(el, ...args) { | |
| if (shouldAbortElemCb(el)) return; | |
| return cb(el, ...args); | |
| } | |
| } | |
| return function delayCbIfEnabledLLWrap(el, ...args) { | |
| if (shouldAbortElemCb(el)) return; | |
| function onMouseenter(ev) { | |
| if (ev.target !== el) { | |
| const con = getTaggedConsole("delayCbIfEnabledLLWrap/onMouseenter"); | |
| con.warn("Called for:", el, "ev.target =", ev.target); | |
| } | |
| el.removeEventListener("mouseenter", onMouseenter); | |
| return cb(el, ...args); | |
| } | |
| el.addEventListener("mouseenter", onMouseenter); | |
| } | |
| } | |
| const PROCESSING = "(处理中……)"; | |
| function addProcessingLabelToTitle(el, alt=undefined) { | |
| const oldTitle = el.title ?? alt ?? ""; | |
| if (ENABLE_TITLE_LAZY_LOADING) return oldTitle; | |
| el.title = (oldTitle ? oldTitle + "\n" : "") + PROCESSING; | |
| return oldTitle; | |
| } | |
| // this regex is unreadable | |
| const DEBRACKET_REGEX = /\[([^\]]*)\]/g; | |
| const DEUNDERSCORE_REGEX = /_+/g; | |
| function setTitleWorkaround(el, title) { | |
| if (ENABLE_TITLE_LAZY_LOADING) el.removeAttribute("title"); | |
| el.title = title; | |
| if (!el.ariaLabel || el.ariaLabel?.trim() == "") { | |
| el.ariaLabel = title.split("\n")[0].replace(DEBRACKET_REGEX, function(_, m) { | |
| return m; | |
| }).replace(DEUNDERSCORE_REGEX, " - "); | |
| } | |
| } | |
| async function addTitleToEmoticon(img, _altFrom=undefined) { | |
| const alt = (_altFrom ?? img).alt; | |
| const oldTitle = addProcessingLabelToTitle(img, alt); | |
| try { | |
| setTitleWorkaround(img, await translateEmoticonName(alt)); | |
| } 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); | |
| /*// might move this to another script | |
| if (location.hostname == "live.bilibili.com") { | |
| increaseImageServedSize(img); | |
| }*/ | |
| } | |
| 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(","), { existing: true }, addTitleToEmoticon_D); | |
| arriveInShadowRootOf("bili-rich-text", "#contents img", { existing: true }, 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); | |
| async function addTitleToDecoCard(link) { | |
| const oldTitle = addProcessingLabelToTitle(link); | |
| try { | |
| const newTitle = await getTitleForDecoCard(link); | |
| if (newTitle) setTitleWorkaround(link, newTitle); | |
| } catch (err) { | |
| link.title = oldTitle; | |
| const con = getTaggedConsole("addTitleToDecoCard"); | |
| con.error("getTitleForDecoCard failed for", link, err); | |
| } | |
| } | |
| const addTitleToDecoCard_D = delayCbIfEnabled(addTitleToDecoCard); | |
| document.arrive(".dyn-decoration-card > a", { existing: true }, addTitleToDecoCard_D); | |
| // FIXME: in this case the callback may be called twice (once on an instance that is removed later) because ??? | |
| // I think the entire bili-comment-renderer might actually be recreated in such a situation but idk | |
| arriveInShadowRootOf("bili-comment-user-sailing-card", "#card > a", { existing: true }, 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); | |
| return new Promise(function(r, _) { r(); }); | |
| } | |
| const copyEmoteAltToParentTitle_D = delayCbIfEnabled(copyEmoteAltToParentTitle); | |
| document.arrive(".bili-emoji .bili-emoji__pkg", { existing: true }, copyImgAltToParentTitle_D); | |
| document.arrive(".bili-emoji__list__item", { existing: true }, copyEmoteAltToParentTitle_D); | |
| arriveInShadowRootOf("bili-emoji-picker", "#tabs .tab", { existing: true }, copyImgAltToParentTitle_D); | |
| arriveInShadowRootOf("bili-emoji-picker", "#content .emoji", { existing: true }, copyEmoteAltToParentTitle_D); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment