Last active
January 18, 2026 11:24
-
-
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.9e | |
| // @description Adds emoticon and decoration card name hover hints to bilibili | |
| // @author Liu Wenyuan | |
| // @match https://*.bilibili.com/* | |
| // @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-body | |
| // @grant GM_getValue | |
| // @grant GM_setValue | |
| // @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 createTaggedConsole = (function() { | |
| const consoles = {}; | |
| return function createTaggedConsole(tag) { | |
| if (!consoles[tag]) { | |
| const _tag = "[AE/" + tag + "]"; | |
| consoles[tag] = { | |
| _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), | |
| group: console.group.bind(console), | |
| groupEnd: console.groupEnd.bind(console), | |
| }; | |
| } | |
| return consoles[tag]; | |
| } | |
| })(); | |
| const arriveInShadowRootOf = (function() { | |
| const listenerTable = {}; | |
| function _addListenersToShadowRoot(tag, shadowRoot, listeners) { | |
| const myListenerTable = listeners ?? listenerTable[tag.toUpperCase()]; | |
| if (!myListenerTable) return; | |
| const arrive = HTMLElement.prototype.arrive.bind(shadowRoot); | |
| for (const [selector, options, listener] of myListenerTable) { | |
| arrive(selector, options, listener); | |
| } | |
| } | |
| const APPLY_POSTCREATION_ARRIVE_LISTENERS = false; // we don't need this yet | |
| // probably kind of memory expensive but it's the best way I can think of | |
| const shadowRootStore = APPLY_POSTCREATION_ARRIVE_LISTENERS ? new Map() : null; | |
| /* global WeakRef */ | |
| // TODO: check if document fragment is not being referenced somehow | |
| class NotWeakRef { | |
| constructor(target) { | |
| this.target = target; | |
| } | |
| deref() { | |
| return this.target; | |
| } | |
| } | |
| if (!WeakRef) { | |
| createTaggedConsole("init/arriveInShadowRootOf") | |
| .warn("WeakRef not available, downgrading to NotWeakRef!! Doubles the mem leak if you use bili-cleaner"); | |
| } | |
| function hook(obj, funcName, newFunc) { | |
| const origFunc = obj[funcName]; | |
| return (obj[funcName] = function(...args) { | |
| return newFunc.bind(this)(origFunc.bind(this), ...args); | |
| }); | |
| } | |
| hook(HTMLElement.prototype, "attachShadow", function(orig, options, ...etc) { | |
| const ret = orig(options, ...etc); | |
| if (this.shadowRoot) { | |
| const tag = this.tagName.toUpperCase(); | |
| _addListenersToShadowRoot(tag, this.shadowRoot); | |
| if (APPLY_POSTCREATION_ARRIVE_LISTENERS) { | |
| if (!shadowRootStore.has(tag)) { | |
| shadowRootStore.set(tag, new Set()); | |
| } | |
| shadowRootStore.get(tag).add(new (WeakRef || NotWeakRef)(this.shadowRoot)); | |
| } | |
| } | |
| return ret; | |
| }); | |
| function arriveInShadowRootOf(_tag, selector, ...args) { | |
| const tag = _tag.toUpperCase(); | |
| let options, listener; | |
| if (args.length >= 2) { | |
| [options, listener] = args; | |
| } else { | |
| [listener] = args; | |
| } | |
| listenerTable[tag.toUpperCase()] ??= new Set(); | |
| const data = [selector, options ?? {}, listener]; | |
| if (!listenerTable[tag].has(data)) { | |
| listenerTable[tag].add(data); | |
| if (APPLY_POSTCREATION_ARRIVE_LISTENERS && shadowRootStore.has(tag)) { | |
| const roots = shadowRootStore.get(tag); | |
| for (const shadowRootRef of roots) { | |
| const shadowRoot = shadowRootRef.deref(); | |
| if (shadowRoot) { | |
| _addListenersToShadowRoot(tag, shadowRoot); | |
| } else { | |
| roots.delete(shadowRootRef); | |
| } | |
| } | |
| } | |
| } | |
| return listener; | |
| } | |
| return arriveInShadowRootOf; | |
| })(); | |
| 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); | |
| }); | |
| } | |
| const AERequestCancelledError = (function() { | |
| return class AERequestCancelledError extends Error { | |
| constructor(...args) { | |
| super(...args); | |
| this.name = "AERequestCancelledError"; | |
| } | |
| } | |
| })(); | |
| // this was vibe coded but has been heavily rewritten since then | |
| const throttledFetch = (function() { | |
| // TODO: having second thoughts on max queue length | |
| const MAX_CONCURRENT_REQUESTS = 3, MAX_QUEUE_LENGTH = 24; | |
| const GRACE_PERIOD_MIN = 50, GRACE_PERIOD_MAX = 200; | |
| const REQUEST_TIMEOUT = 10000; | |
| const requestQueue = []; | |
| let activeRequests = 0; | |
| async function processQueue() { | |
| const con = createTaggedConsole("throttledFetch/processQueue"); | |
| con.debug("Queue length =", requestQueue.length, "activeRequests =", activeRequests); | |
| while (activeRequests < MAX_CONCURRENT_REQUESTS && requestQueue.length > 0) { | |
| requestQueue.shift().perform(); | |
| await wait(Math.floor(randomRange(GRACE_PERIOD_MIN, GRACE_PERIOD_MAX))); | |
| } | |
| } | |
| let isProcessingQueue = false; | |
| function scheduleProcessQueue() { | |
| if (isProcessingQueue) return; | |
| isProcessingQueue = true; | |
| requestAnimationFrame(async function _runProcessQueue() { | |
| try { | |
| await processQueue(); | |
| } catch (err) { | |
| const con = createTaggedConsole("throttledFetch/scheduleProcessQueue"); | |
| con.error("Failed to process queue:", err); | |
| } finally { | |
| isProcessingQueue = false; | |
| } | |
| }); | |
| } | |
| function throttledFetch(url, options) { | |
| return new Promise(function _throttledFetch(resolve, reject) { | |
| const controller = new AbortController(); | |
| async function perform() { | |
| const con = createTaggedConsole("throttledFetch/perform"); | |
| activeRequests++; | |
| con.debug("[" + activeRequests + "]", "Requesting:", url); | |
| try { | |
| const res = await fetch(url, { | |
| ...options, | |
| signal: AbortSignal.any([controller.signal, AbortSignal.timeout(REQUEST_TIMEOUT)]) | |
| }); | |
| con.debug("[" + activeRequests + "]", "Processed request:", res); | |
| resolve(res); | |
| } catch (err) { | |
| reject(err); | |
| } finally { | |
| activeRequests--; | |
| if (activeRequests < 0) { | |
| con.warn("activeRequests underflow"); | |
| activeRequests = 0; | |
| } | |
| scheduleProcessQueue(); | |
| } | |
| } | |
| if (activeRequests < MAX_CONCURRENT_REQUESTS) { | |
| perform(); | |
| } else if (requestQueue.length >= MAX_QUEUE_LENGTH) { | |
| reject(new AERequestCancelledError("Request queue is full")); | |
| } else { | |
| requestQueue.push({ | |
| perform, | |
| forceReject: (err) => controller.abort(err) | |
| }); | |
| } | |
| }); | |
| } | |
| window.addEventListener("pagehide", (ev) => { | |
| if (ev.persisted) return; // TODO: ? | |
| for (const i of requestQueue) { | |
| i.forceReject(new AERequestCancelledError("Current page is being unloaded")); | |
| } | |
| requestQueue.length = 0; | |
| }); | |
| return throttledFetch; | |
| })(); | |
| function extractBfsImgId(url) { | |
| let result = url.pathname; | |
| const start = "/bfs/"; | |
| if (result.substring(0, start.length) != start) { | |
| return url.origin + url.pathname; // w/e | |
| } | |
| result = result.substring(start.length); | |
| const paramStartIndex = result.lastIndexOf("@"); | |
| if (paramStartIndex >= 0) { | |
| result = result.substring(0, paramStartIndex); | |
| } | |
| const extStartIndex = result.lastIndexOf("."); | |
| if (extStartIndex >= 0) { | |
| result = result.substring(0, extStartIndex); | |
| } | |
| return result; | |
| } | |
| let knownUids = {}; | |
| const { | |
| requestInfoForUid, requestInfoForGarbSuitItem, requestInfoForGarbDlcAct, | |
| infoFailed | |
| } = (function() { | |
| const KNOWN_UIDS_REFRESH_TIMEOUT = 1 * 60 * 60 * 1000; | |
| const FAILED_UIDS_REFRESH_TIMEOUT = 10 * 1000; | |
| const KNOWN_UIDS_MAX_RETENTION_COUNT = 30; | |
| const KNOWN_UIDS_REF_COUNT_LOAD_DECAY_FACTOR = 1; // TODO: temporary because this part doesn't make much sense | |
| 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; | |
| async function _loadKnownUids() { // marked async just so it generates a Promise | |
| 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); // ? | |
| } | |
| const con = createTaggedConsole("_loadKnownUids"); | |
| // TODO: maybe don't clean up in req offline mode | |
| const now = Date.now(); | |
| // TODO: is this useful to have anymore | |
| let outdatedEntries = 0; | |
| for (const [uid, info] of Object.entries(newKnownUids)) { | |
| if ((now - info.timestamp) > (info.failed ? FAILED_UIDS_REFRESH_TIMEOUT : KNOWN_UIDS_REFRESH_TIMEOUT)) { | |
| delete newKnownUids[uid]; | |
| outdatedEntries++; | |
| } | |
| } | |
| if (outdatedEntries > 0) { | |
| con.log("Outdated entries to be deleted:", outdatedEntries); | |
| } | |
| const sortedByRefCount = Object.entries(newKnownUids).sort((a, b) => { | |
| const fallbackLastAccessTs = now - 1000; | |
| const scoreA = (a[1].refCount ?? 1) | |
| + (now - (a[1].lastAccessTimestamp ?? fallbackLastAccessTs)) / KNOWN_UIDS_TS_PENALTY_WEIGHT; | |
| const scoreB = (b[1].refCount ?? 1) | |
| + (now - (b[1].lastAccessTimestamp ?? fallbackLastAccessTs)) / 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 = createTaggedConsole("loadKnownUids"); | |
| con.debug("_loadKnownUids already in process, caller should wait"); | |
| } | |
| return loadKnownUidsPromise; | |
| } | |
| //(async () => saveKnownUids(await loadKnownUids()))(); | |
| let savingKnownUids = 0; | |
| function saveKnownUids(localKuids) { | |
| const con = createTaggedConsole("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_REFRESH_TIMEOUT, FAILED_UIDS_REFRESH_TIMEOUT));*/ | |
| let saveKnownUidsTimeout = null; | |
| function scheduleSaveKnownUids() { | |
| clearTimeout(saveKnownUidsTimeout); | |
| saveKnownUidsTimeout = setTimeout(() => saveKnownUids(knownUids), 300); | |
| return saveKnownUidsTimeout; | |
| } | |
| // TODO: https://www.tampermonkey.net/documentation.php?locale=en#api:GM_addValueChangeListener | |
| const requestInfoPromises = {}; | |
| async function _requestInfo(dataType, func, ver, id, ...args) { | |
| const con = createTaggedConsole("_requestInfo"); | |
| await loadKnownUids(); | |
| const shouldDownload = !knownUids[id] || knownUids[id].version != ver; | |
| if (!REQUEST_INFO_FORCE_OFFLINE && 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 == "AERequestCancelledError") { | |
| result = null; | |
| } | |
| } | |
| if (result) { | |
| result.version = ver; | |
| result.timestamp = Date.now(); | |
| result.refCount = 0; | |
| knownUids[id] = result; | |
| //scheduleSaveKnownUids(); // ? | |
| } | |
| })().finally(function _requestInfoEnd() { | |
| if (!requestInfoPromises[id]) { | |
| createTaggedConsole("_requestInfo/_requestInfoEnd").warn("requestInfoPromise", 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 (REQUEST_INFO_FORCE_OFFLINE && shouldDownload) { | |
| con.warn("No info cached; did not download from server"); | |
| } | |
| return info; | |
| } | |
| let _csrfToken = null; | |
| function getCsrfToken() { | |
| if (_csrfToken === null) { | |
| _csrfToken = Cookies.get("bili_jct"); | |
| if (!_csrfToken) { | |
| createTaggedConsole("_requestInfo/getCsrfToken").debug("bili_jct doesn't exist, not logged in?"); | |
| _csrfToken = ""; | |
| } | |
| } | |
| return _csrfToken ?? ""; | |
| } | |
| function throwIfResponseNotOk(res) { | |
| if (!res.ok) { | |
| throw new Error(`${res.status} (${res.statusText})`); | |
| } | |
| } | |
| function apiComplaint(obj) { | |
| return new Error(JSON.stringify(obj)); | |
| } | |
| const INFO_UID_VERSION = 1; | |
| async function _requestInfoForUidReal(uid) { | |
| const endpointUrl = new URL("https://api.bilibili.com/x/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 apiComplaint(content); | |
| } | |
| return { name: content.data.card.name }; | |
| } | |
| async function requestInfoForUid(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("https://api.bilibili.com/x/garb/v2/user/suit/benefit"); | |
| 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 apiComplaint(content); | |
| } | |
| if (content.data === null) { | |
| // Straight up unavailable (see id 5887) | |
| return { unavailable: true }; | |
| } | |
| if (!content.data?.name) { | |
| throw apiComplaint(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 apiComplaint(content); | |
| } | |
| itemName = item.name; | |
| }*/ | |
| return { suiteName: content.data.name, name: itemName }; | |
| } | |
| async function requestInfoForGarbSuitItem({ item_id, part, is_diy, vmid }) { | |
| 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("https://api.bilibili.com/x/vas/dlc_act/act/basic"); | |
| endpointUrl.searchParams.set("act_id", id); | |
| 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 apiComplaint(content); | |
| } | |
| function _parseMedalInfo(dataJson) { | |
| if (!dataJson) return; | |
| const con = createTaggedConsole("_requestInfoForGarbDlcAct/_parseMedalInfo"); | |
| 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("Level smaller than 1 exists, 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]) { | |
| 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 = createTaggedConsole("_requestInfoForGarbDlcAct"); | |
| con.warn("_parseMedalInfo failed:", err); | |
| } | |
| return { name: content.data.act_title, medals: medals } | |
| } | |
| async function requestInfoForGarbDlcAct(id) { | |
| return await _requestInfo("name of digital collection campaign", _requestInfoForGarbDlcAct, INFO_DLC_ACT_VERSION, | |
| "dlc_act_" + id, // fake id for cache | |
| 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 | |
| // UPOWER example: https://t.bilibili.com/1157733942460153857 | |
| // UP example: https://t.bilibili.com/1158469928105279506?comment_on=1&comment_secondary_id=286679189489#reply286679189489 | |
| const UP_EMOTE_REGEX = /(?<=\[)(?:UPOWER_|UP_)(\d+)/; | |
| // see https://t.bilibili.com/1028818742423846918 ; originally "充电专属_" | |
| const UP_EMOTE_TL_PREFIX = "UP主表情_"; | |
| return async function translateEmoticonName(name) { | |
| const match = name.match(UP_EMOTE_REGEX); | |
| if (!match?.[1]) { | |
| return name; | |
| } | |
| const uid = match[1]; | |
| let userInfo; | |
| try { | |
| userInfo = await requestInfoForUid(uid); | |
| } catch (err) { | |
| const con = createTaggedConsole("translateEmoticonName"); | |
| con.error(`While handling ${name}:`, err); | |
| } | |
| if (!infoFailed(userInfo)) { | |
| return name.replace(UP_EMOTE_REGEX, UP_EMOTE_TL_PREFIX + knownUids[uid].name) + `\n(UID:${uid})`; | |
| } | |
| return name.replace(UP_EMOTE_REGEX, UP_EMOTE_TL_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 | |
| }; | |
| // Might help: https://s1.hdslb.com/bfs/seed/ogv/garb-component/garb-asset-equipment.umd.js | |
| async function getTitleForDecoCard(elem) { | |
| const con = createTaggedConsole("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 | |
| 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) { | |
| // horrifying | |
| 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 = lvlSet.intersection(myImgUrlHashes).size, uni = lvlSet.union(myImgUrlHashes).size; | |
| 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 (if cors lets us): | |
| // - https://api.live.bilibili.com/xlive/app-ucenter/v1/guard/MainGuardCard?ruid=<streamer uid> (12 guards only) | |
| // - https://api.live.bilibili.com/xlive/web-ucenter/user/MedalWall?target_id=<fan 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); | |
| } | |
| } | |
| 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); | |
| } | |
| } | |
| return `大航海${foundTier}${!infoFailed(userInfo) ? ` - ${userInfo.name} 号` : ""}`; | |
| break; | |
| } | |
| default: | |
| con.warn("Unrecognized decoration card URL: unexpected href", elem, url.href); | |
| break; | |
| } | |
| return ""; | |
| } | |
| return getTitleForDecoCard; | |
| })(); | |
| // TODO: maybe add in some sort of lame lazy load mechanism? like, only look up on mouseover | |
| // TODO: merge https://gist.github.com/Dobby233Liu/cb70b479d0127f000860f416a93053c1 into this? maybe? | |
| // TODO: some way to bail when elements cease to exist | |
| (function injectArriveListeners() { | |
| const PROCESSING = "(处理中……)"; | |
| async function addTitleToEmoticon(img) { | |
| const oldTitle = img.title; | |
| img.title = img.title + "\n" + PROCESSING; | |
| let ok = true; | |
| try { | |
| img.title = await translateEmoticonName(img.alt); | |
| } catch (err) { | |
| ok = false; | |
| img.title = oldTitle; | |
| const con = createTaggedConsole("addTitleToEmoticon"); | |
| con.error("translateEmoticonName failed for", img, err); | |
| } | |
| if (ok) { | |
| img.alt = img.title; | |
| } | |
| } | |
| let emoteSelector = [".bili-rich-text-emoji", ".opus-text-rich-emoji > img"]; | |
| if (location.hostname == "live.bilibili.com") { | |
| emoteSelector.push(".danmaku-item .emoticon img"); | |
| } | |
| document.body.arrive(emoteSelector.join(","), { existing: true }, addTitleToEmoticon); | |
| arriveInShadowRootOf("bili-rich-text", "#contents img", { existing: true }, addTitleToEmoticon); | |
| async function addTitleToDecoCard(link) { | |
| const oldTitle = link.title; | |
| link.title = PROCESSING; | |
| try { | |
| link.title = await getTitleForDecoCard(link); | |
| } catch (err) { | |
| link.title = oldTitle; | |
| const con = createTaggedConsole("addTitleToDecoCard"); | |
| con.error("getTitleForDecoCard failed for", link, err); | |
| } | |
| } | |
| document.body.arrive(".dyn-decoration-card > a", { existing: true }, addTitleToDecoCard); | |
| arriveInShadowRootOf("bili-comment-user-sailing-card", "#card > a", { existing: true }, addTitleToDecoCard); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment