Last active
March 21, 2025 14:15
-
-
Save Dobby233Liu/832bf82e34ed63f50d42d9ed23638125 to your computer and use it in GitHub Desktop.
Annonate Emoicons: Add emoicon name hoverovers to bilibili rich text
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 Emoicons | |
// @namespace https://dobby233liu.github.io | |
// @version v1.1.3 | |
// @description Add emoicon name hoverovers to bilibili rich text | |
// @author Liu Wenyuan | |
// @match https://*.bilibili.com/* | |
// @icon https://i0.hdslb.com/bfs/garb/126ae16648d5634fe0be1265478fd6722d848841.png | |
// @grant none | |
// @require https://unpkg.com/[email protected]/minified/arrive.min.js | |
// @run-at document-body | |
// @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"; | |
class AEAbortError extends Error { | |
constructor(message) { | |
super(message); | |
this.name = "AEAbortError"; | |
} | |
} | |
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, reject) { | |
setTimeout(resolve, t); | |
}); | |
} | |
// none (*only some) of this logic is mine | |
const maxRequestsAtOnce = 5, maxQueueLength = 20; | |
const requestQueue = []; | |
let activeRequests = 0; | |
let isProcessing = false; | |
const requestTimeout = 5000; | |
function throttledFetch(url) { | |
return new Promise(function(resolve, reject) { | |
async function perform(signal) { | |
activeRequests++; | |
console.log("current activeRequests [pf]:", activeRequests); | |
let res; | |
try { | |
res = await fetch(url, { signal }); | |
resolve(res); | |
} catch (err) { | |
reject(err); | |
} finally { | |
activeRequests--; | |
console.log(res, "processed - current activeRequests [pf]:", activeRequests); | |
scheduleProcessQueue(); | |
} | |
} | |
function scheduleProcessQueue() { | |
if (isProcessing) return; | |
isProcessing = true; | |
requestAnimationFrame(async () => { | |
await processQueue(); | |
isProcessing = false; | |
}); | |
} | |
async function processQueue() { | |
while (activeRequests < maxRequestsAtOnce && requestQueue.length > 0) { | |
const [performNext, _] = requestQueue.shift(); | |
performNext(); | |
await wait(Math.floor(randomRange(50, 200))); // grace period | |
} | |
} | |
const controller = new AbortController(); | |
const abortTimeout = setTimeout(() => controller.abort(), requestTimeout); | |
if (activeRequests < maxRequestsAtOnce) { | |
perform(controller.signal); | |
} else if (requestQueue.length >= maxQueueLength) { | |
reject(new AEAbortError("Queue overflow")); | |
} else { | |
requestQueue.push([ | |
() => perform(controller.signal), | |
function(err) { | |
clearTimeout(abortTimeout); | |
controller.abort(err); | |
} | |
]); | |
} | |
}); | |
} | |
window.addEventListener("beforeunload", () => { | |
for (let [_, forceReject] of requestQueue) { | |
forceReject(new AEAbortError("Exiting current page")); | |
} | |
requestQueue.length = 0; | |
}); | |
const knownUids = {}; | |
const knownUidPromises = {}; | |
const upowerRegex = /(?<=\[)(?:UPOWER_)(\d+)/; // deliberately leaving only the left part of the bracket | |
async function translateName(name) { | |
const match = name.match(upowerRegex); | |
if (!match?.[1]) { | |
return name; | |
} | |
const uid = match[1]; | |
if (!knownUids[uid]) { | |
let promise1; | |
if ((promise1 = knownUidPromises[uid])) { | |
await promise1; | |
} else { | |
const promise2 = knownUidPromises[uid] = (async function() { | |
try { | |
const res = await throttledFetch(`https://api.bilibili.com/x/web-interface/card?mid=${uid}`); | |
knownUids[uid] = { failed: true, timestamp: Date.now() }; | |
if (!res.ok) { | |
throw new Error(`${res.status} (${res.statusText})`); | |
} | |
const data = await res.json(); | |
if (data.code != 0 || !data?.data?.card?.name) { | |
throw data; | |
} | |
knownUids[uid] = { name: data.data.card.name, timestamp: Date.now() }; | |
} catch (err) { | |
knownUids[uid] = { failed: true, timestamp: Date.now() }; | |
/*if (err.name == "AbortError" || err.name == "AEAbortError") { | |
knownUids[uid] = undefined; | |
}*/ | |
console.warn(`While fetching name of ${uid}:`, err); | |
} | |
})(); | |
await promise2; | |
delete knownUidPromises[uid]; | |
} | |
} | |
if (knownUids[uid] && !knownUids[uid].failed) { | |
return name.replace(upowerRegex, `充电专属_${knownUids[uid].name}($1)`); | |
} | |
return name; | |
} | |
// copied too | |
const knownUidTimeout = 15 * 60 * 1000; | |
const knownUidFailedTimeout = 10 * 1000; | |
setInterval(function() { | |
const now = Date.now(); | |
for (const key in knownUids) { | |
if (now - knownUids[key].timestamp > (knownUids[key].failed ? knownUidFailedTimeout : knownUidTimeout)) { | |
delete knownUids[key]; | |
} | |
} | |
}, Math.min(knownUidTimeout, knownUidFailedTimeout)); | |
async function addTitle(img) { | |
img.title = await translateName(img.alt); | |
} | |
// add the injected functions from arrive.js to ShadowRoot | |
// (why did they do that) | |
ShadowRoot.prototype.arrive = HTMLElement.prototype.arrive; | |
ShadowRoot.prototype.leave = HTMLElement.prototype.leave; | |
ShadowRoot.prototype.unbindArrive = HTMLElement.prototype.unbindArrive; | |
ShadowRoot.prototype.unbindLeave = HTMLElement.prototype.unbindLeave; | |
document.body.arrive(".bili-rich-text-emoji", { existing: true }, addTitle); | |
const originalAttachShadow = HTMLElement.prototype.attachShadow; | |
HTMLElement.prototype.attachShadow = function(options) { | |
const isRichText = this.tagName == ("bili-rich-text").toUpperCase(); | |
if (isRichText) { | |
options.mode = "open"; | |
} | |
const shadowRoot = originalAttachShadow.call(this, options); | |
if (!shadowRoot) return; | |
if (isRichText) { | |
shadowRoot.arrive("#contents img", { existing: true }, addTitle); | |
} | |
return shadowRoot; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment