Skip to content

Instantly share code, notes, and snippets.

@Dobby233Liu
Last active February 15, 2026 18:17
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.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