Skip to content

Instantly share code, notes, and snippets.

@Dobby233Liu
Last active May 9, 2026 17:21
Show Gist options
  • Select an option

  • Save Dobby233Liu/832bf82e34ed63f50d42d9ed23638125 to your computer and use it in GitHub Desktop.

Select an option

Save Dobby233Liu/832bf82e34ed63f50d42d9ed23638125 to your computer and use it in GitHub Desktop.
Annonate Emoticons: Adds emoticon and decoration card name hover hints to bilibili
// ==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