Last active
July 4, 2026 14:11
-
-
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.42d | |
| // @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/i18next@26.2.0/dist/umd/i18next.min.js#sha256-bjL1i5htT8PqA6IpfaStsjeM4iValKbh2d0wDx+rwLY= | |
| // @require https://unpkg.com/arrive@2.5.3/minified/arrive.min.js#sha256-+gKSaI/burjOA4W/Mew429ENOpM82cWsHDnkwECrFMs= | |
| // @require https://unpkg.com/js-cookie@3.0.7/dist/js.cookie.min.js#sha256-6QiSkZ4R3739gw1nnO2/l/v0hS8vAeLCsjSCgBUfeCE= | |
| // @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 | |
| // @grant GM_notification | |
| // @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"; | |
| /* globals i18next, Arrive, Cookies, ADLER32 */ | |
| (function initStrings() { | |
| /* i18next is only used to centralize any remotely end-user-facing messages | |
| I believe localization is currently NOT feasible due to how this userscript works */ | |
| const STRINGS = { | |
| // half-user facing stuff begin | |
| "taggedConsole": { | |
| "errorAlertIntro": "====== 于 {{timestamp}} 输出了错误 - 详见控制台 ======", | |
| "errorAlertTruncEllipsis": "...", | |
| }, | |
| "requestInfo": { | |
| "cleanMemo": { | |
| // I don't think the actual messages fit here | |
| "titleWrap": "[AE] {{title}}", | |
| "titleSaved": "已保存 knownIds", | |
| }, | |
| }, | |
| // half-user facing stuff end | |
| "emote": { | |
| "type": { | |
| /* reference: */ | |
| /* | |
| - 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 | |
| * 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"]: "充电表情包", | |
| /* | |
| - https://live.bilibili.com/1710489335 | |
| * 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 | |
| * 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 | |
| }, | |
| "prefix": "$t(emote:type.{{type}})_{{uid}}", | |
| "prefix_userKnown": "$t(emote:type.{{type}})_{{username}}({{uid}})", | |
| "prefix_userUnknown": "$t(emote:type.{{type}})_{{uid}}(未能查询)", | |
| "ariaLabel": "表情:{{name}}", | |
| "ariaLabelReplacementDelimiter": " - " | |
| }, | |
| "decoCard": { | |
| "guardTiers": { | |
| "captain": "舰长", | |
| "commander": "提督", | |
| "general": "总督", | |
| }, | |
| "guardCard": "大航海$t(decoCard:guardTiers.{{context}})", | |
| // "guardCardWithStreamer": "大航海$t(decoCard:guardTiers.{{context}}) - {{username}} 号", | |
| "unavailableSuit": "【已下架装扮】", | |
| // "suitAndItemName": "{{suiteName}} - {{name}}", | |
| "dlcWithLevel": "{{name}} - Lv. {{level}}", | |
| // TODO: avoid this regex | |
| "levelIndicatorRegex": / - Lv\. ([0-9]+)$/, | |
| "ariaLabel": "装饰卡片:{{name}}", | |
| "ariaLabel_hasLevel": "装饰卡片:{{name}}({{level}}级)", | |
| }, | |
| "misc": { | |
| "processing": "(处理中……)" | |
| } | |
| }; | |
| i18next.init({ | |
| ns: ["taggedConsole", "requestInfo", "emote", "decoCard", "misc"], | |
| defaultNS: "misc", | |
| lng: "zh-cmn-Hans", | |
| initAsync: false, // we don't use external resources | |
| resources: { | |
| "zh-cmn-Hans": STRINGS | |
| }, | |
| debug: GM_getValue?.("stringsDebug") ?? false, | |
| }); | |
| })(); | |
| 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); | |
| } | |
| get #tag() { | |
| return this.#_tag; | |
| } | |
| set #tag(tag) { | |
| this.#_tag = tag; | |
| this.log = console.log.bind(console, this.#tag); | |
| this.error = ALERT_ON_ERROR ? this.errorDebug : console.error.bind(console, this.#tag); | |
| this.warn = console.warn.bind(console, this.#tag); | |
| this.debug = console.debug.bind(console, this.#tag); | |
| this.trace = console.trace.bind(console, this.#tag); | |
| } | |
| errorDebug(...args) { | |
| const ret = console.error(this.#tag, ...args); | |
| try { | |
| const msg2 = stringifyConsoleArgs(this.#tag, ...args); | |
| const msg = i18next.t("taggedConsole:errorAlertIntro", { timestamp: Date.now() }) + "\n" | |
| + msg2.substring(0, MSG_TRUNCATE_TO_LENGTH) | |
| + (msg2.length > MSG_TRUNCATE_TO_LENGTH ? i18next.t("taggedConsole:errorAlertTruncEllipsis") : ""); | |
| 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: _orig_arriveInShadowRootOf, addStyleInShadowRootOf | |
| } = (function({ | |
| addRetroactively = false, console = console | |
| } = {}) { | |
| // The core idea is taken from bili-cleaner | |
| // 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 { | |
| #target; | |
| constructor(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 | |
| // perhaps listen to DOMNodeInserted/DOMNodeRemoved/etc. to call it, we're dealing with | |
| // outdated browsers anyways | |
| #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 USE_NOT_WEAK_REF = typeof WeakRef !== "function"; | |
| if (addRetroactively && USE_NOT_WEAK_REF) { | |
| 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.call(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 (!USE_NOT_WEAK_REF ? 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 ArriveDelayModes = Object.freeze({ | |
| // most functional | |
| HandleImmediately: Symbol("HandleImmediately"), | |
| // slightly weird but working | |
| WaitForMouseover: Symbol("WaitForMouseover"), | |
| // more experimental | |
| WaitForIntersection: Symbol("WaitForIntersection") | |
| }); | |
| const ARRIVE_DELAY_MODE = (function pickArriveDelayMode() { | |
| const DEFAULT_MODE = "HandleImmediately"; | |
| const modeStr = GM_getValue?.("arriveDelayMode") ?? DEFAULT_MODE, | |
| mode = ArriveDelayModes[modeStr]; | |
| if (!mode) { | |
| const con = getTaggedConsole("pickArriveDelayMode"); | |
| con.error("Bad arriveDelayMode choice:", modeStr, | |
| "; supported modes:", Object.keys(ArriveDelayModes), | |
| `; picking ${DEFAULT_MODE}`); | |
| return ArriveDelayModes.HandleImmediately; | |
| } | |
| return mode; | |
| })(); | |
| const { | |
| arriveDelayed, arriveInShadowRootOfDelayed: arriveInShadowRootOf | |
| } = (function({ | |
| arriveInShadowRootOf, | |
| arriveDelayMode, | |
| mode2IntersectionObserverOptions = {}, | |
| mode2CallCbWhenIdle, | |
| mode2IdleCallbackTimeout | |
| }) { | |
| // this is a little scuffed as of now | |
| /* TODO: one extra alt strategy (though it's not better): listen for hover events of relevant elems | |
| in a global event */ | |
| if (arriveDelayMode != ArriveDelayModes.HandleImmediately) { | |
| getTaggedConsole("init_delayedArriveUtils") | |
| .log("Using arriveDelayMode (experimental)", arriveDelayMode.description); | |
| } | |
| // goofy GUID to prevent collisions | |
| const AE_UUID = "_0DA009960600423DB1886326D8810A86_AE_", | |
| AE_DELAYEDCBS = AE_UUID + "delayedCbs"; | |
| function _initCbArray(el) { | |
| if (!Array.isArray(el[AE_DELAYEDCBS])) { | |
| el[AE_DELAYEDCBS] = []; | |
| return true; | |
| } | |
| } | |
| function _callCbs(el, knownTarget) { | |
| const cbs = el[AE_DELAYEDCBS]; | |
| if (!cbs) return false; | |
| delete el[AE_DELAYEDCBS]; | |
| function _cbInvocator() { | |
| for (const [cb, ...args] of cbs) { | |
| cb.call(el, el, ...args); | |
| } | |
| } | |
| if (!mode2CallCbWhenIdle) { | |
| _cbInvocator(); | |
| } else if (typeof requestIdleCallback === "function") { | |
| requestIdleCallback(_cbInvocator, { | |
| timeout: mode2IdleCallbackTimeout | |
| }); | |
| } else { | |
| requestAnimationFrame(_cbInvocator); | |
| } | |
| } | |
| function _getDelayedCbMode1(cb) { | |
| return function delayedCbMode1Wrap(el, ...args) { | |
| function onMouseenter(ev) { | |
| if (ev) { | |
| if (ev.target !== el) { | |
| const con = getTaggedConsole("delayedCbMode1Wrap/onMouseenter"); | |
| con.warn("Called for:", el, "but ev.target =", ev.target); | |
| } | |
| } | |
| el.removeEventListener("mouseenter", onMouseenter); | |
| _callCbs(el); | |
| } | |
| const firstInit = _initCbArray(el); | |
| el[AE_DELAYEDCBS].push([cb, ...args]); | |
| if (el.matches(":hover")) { | |
| return onMouseenter(); | |
| } | |
| if (firstInit) el.addEventListener("mouseenter", onMouseenter); | |
| } | |
| } | |
| function _intersectionObserverCbMode2(entries, observer) { | |
| const elems = new Set(); | |
| for (const entry of entries.sort((a, b) => a.time - b.time)) { | |
| if (entry.isIntersecting) { | |
| elems.add(entry.target); | |
| } else { | |
| // always pops up right after the element is subscribed to, interesting design | |
| elems.delete(entry.target); | |
| } | |
| } | |
| for (const elem of elems) { | |
| observer.unobserve(elem); | |
| _callCbs(elem); | |
| } | |
| } | |
| const intersectionObserverMode2 = arriveDelayMode == ArriveDelayModes.WaitForIntersection ? | |
| new IntersectionObserver(_intersectionObserverCbMode2, { ...mode2IntersectionObserverOptions }) : null; | |
| function _getDelayedCbMode2(cb) { | |
| if (!intersectionObserverMode2) { | |
| throw new TypeError("Intersection observer is uninitialized"); | |
| } | |
| return function delayedCbMode2Wrap(el, ...args) { | |
| const firstInit = _initCbArray(el); | |
| el[AE_DELAYEDCBS].push([cb, ...args]); | |
| // "The observer callback will always fire the first render cycle after observe() is called" | |
| // so (hopefully) AE_delayedCbs is not cleared immediately | |
| if (firstInit) intersectionObserverMode2.observe(el); | |
| } | |
| } | |
| const delayedCbWraps = arriveDelayMode == ArriveDelayModes.HandleImmediately ? null : new Map(); | |
| const delayedCbWrapInterfaces = { | |
| [ArriveDelayModes.WaitForMouseover]: _getDelayedCbMode1, | |
| [ArriveDelayModes.WaitForIntersection]: _getDelayedCbMode2 | |
| }; | |
| function _wrapListener(listener) { | |
| if (arriveDelayMode == ArriveDelayModes.HandleImmediately) return listener; | |
| if (!delayedCbWraps) { | |
| throw new TypeError("delayedCbWraps is uninitialized"); | |
| } | |
| if (!delayedCbWraps.has(listener)) { | |
| const wrapInterface = delayedCbWrapInterfaces[arriveDelayMode]; | |
| if (!wrapInterface) { | |
| throw new TypeError("Bad arriveDelayMode value", arriveDelayMode); | |
| } | |
| delayedCbWraps.set(listener, wrapInterface(listener)); | |
| } | |
| return delayedCbWraps.get(listener); | |
| } | |
| function arriveDelayed(elem, selector, ...args) { | |
| let options, listener; | |
| if (args.length >= 2) { | |
| [options, listener] = args; | |
| return elem.arrive(selector, options, _wrapListener(listener)); | |
| } else { | |
| [listener] = args; | |
| return elem.arrive(selector, _wrapListener(listener)); | |
| } | |
| } | |
| function arriveInShadowRootOfDelayed(tag, selector, ...args) { | |
| let options, listener; | |
| if (args.length >= 2) { | |
| [options, listener] = args; | |
| return arriveInShadowRootOf(tag, selector, options, _wrapListener(listener)); | |
| } else { | |
| [listener] = args; | |
| return arriveInShadowRootOf(tag, selector, _wrapListener(listener)); | |
| } | |
| } | |
| return { arriveDelayed, arriveInShadowRootOfDelayed }; | |
| })({ | |
| arriveInShadowRootOf: _orig_arriveInShadowRootOf, | |
| arriveDelayMode: ARRIVE_DELAY_MODE, | |
| // TODO: tuning | |
| mode2IntersectionObserverOptions: { | |
| delay: 50, | |
| threshold: 1/12 | |
| }, | |
| mode2CallCbWhenIdle: false, | |
| mode2IdleCallbackTimeout: 120 | |
| }); | |
| const { throttledFetch, ThrottledRequestCancelledError } = (function({ | |
| getTaggedConsole = (tag) => console, | |
| maxConcurrentRequests, queueSize, | |
| minimumGracePeriod, maximumGracePeriod, | |
| requestTimeout, processQueueIdleCbTimeout, | |
| } = {}) { | |
| // 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)); | |
| } | |
| 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 ProcessQueueCbType = Object.freeze({ | |
| AnimationFrame: Symbol("AnimationFrame"), | |
| IdleCallback: Symbol("IdleCallback"), | |
| }); | |
| 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 = [ | |
| ProcessQueueCbType.IdleCallback, | |
| requestIdleCallback(_runProcessQueue, { timeout: processQueueIdleCbTimeout })]; | |
| } else { | |
| processQueueCbId = [ | |
| ProcessQueueCbType.AnimationFrame, | |
| requestAnimationFrame(_runProcessQueue)]; | |
| } | |
| } | |
| function cancelProcessQueueCb() { | |
| if (!processQueueCbId) return; | |
| if (processQueueCbId[0] == ProcessQueueCbType.AnimationFrame) { | |
| cancelAnimationFrame(processQueueCbId[1]); | |
| } else if (processQueueCbId[0] == ProcessQueueCbType.IdleCallback) { | |
| cancelIdleCallback(processQueueCbId[1]); | |
| } | |
| processQueueCbId = null; | |
| } | |
| window.addEventListener("pagehide", (ev) => { | |
| if (ev.persisted) return; | |
| 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 {} | |
| function throttledFetch(url, options) { | |
| const controller = new AbortController(); | |
| async function perform(resolve, reject) { | |
| controller.signal.throwIfAborted(); | |
| activeRequests++; | |
| try { | |
| const res = await fetch(url, { | |
| ...options, | |
| // TODO: polyfill??? | |
| signal: AbortSignal.any([controller.signal, AbortSignal.timeout(requestTimeout)]) | |
| }); | |
| resolve(res); | |
| } finally { | |
| activeRequests--; | |
| if (activeRequests < 0) { | |
| getTaggedConsole("throttledFetch/perform").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 if a rejection is warranted here | |
| 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? | |
| // at least this default likely isn't too bad even in the worst case, since currently | |
| // each reqInfo func only makes 1 request | |
| 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() { | |
| // TODO: maybe don't keep knownIds here | |
| let knownIds = {}; | |
| let knownIdsCleanMemo = ""; | |
| // LRU-ish cache (might be too much) | |
| // TODO: allow each data type to have their own 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; // seems ok for now | |
| 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 doesn't mean internet is reachable, but currently we don't really care | |
| about that */ | |
| 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(); | |
| 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 ${expiredEntries} expired entries`); | |
| } | |
| if (Object.keys(newKnownIds).length > KNOWN_IDS_MAX_RETENTION_COUNT) { | |
| const sortedByRefCount = Object.entries(newKnownIds) | |
| .map(([id, info]) => { | |
| if (!("lastAccess" in info) && "lastAccessTimestamp" in info) { | |
| info.lastAccess = info.lastAccessTimestamp; | |
| delete info.lastAccessTimestamp; | |
| } | |
| const roughTsDiff = Math.max(0, now - (info.lastAccess ?? info.ts ?? now)); | |
| return [id, (info.refs ?? info.refCount ?? 1) - roughTsDiff / KNOWN_IDS_TS_PENALTY_WEIGHT]; | |
| }).sort(([_, scoreA], [__, scoreB]) => scoreB - scoreA); | |
| 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 ${shearedEntries} less-used entries`); | |
| } | |
| if (Object.keys(newKnownIds).length <= 0) { | |
| logCleanMemo("All entries will be deleted"); | |
| } | |
| } | |
| 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")]; | |
| } | |
| const SHOULD_SHOW_CLEAN_MEMO = GM_getValue?.("showKnownIdsCleanMemo"); | |
| function showKnownIdsCleanMemo(title) { | |
| if (!SHOULD_SHOW_CLEAN_MEMO) return; | |
| if (typeof GM_notification !== "function" || knownIdsCleanMemo == "") { | |
| return; | |
| } | |
| GM_notification({ | |
| title: i18next.t("requestInfo:cleanMemo.titleWrap", { title: 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 defaultValue; | |
| } | |
| 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); | |
| if (storedKnownIds) { | |
| // in case we have more entries locally, though ehh | |
| newKnownIds = Object.assign({}, storedKnownIds, newKnownIds); | |
| } | |
| knownIdsCleanMemo = ""; | |
| [knownIds, knownIdsCleanMemo] = _cleanKnownIds(newKnownIds); | |
| return knownIds; | |
| } | |
| // we have singletons at home | |
| let loadKnownIdsPromise = null; | |
| function loadKnownIds() { // callers should treat this as async | |
| if (!loadKnownIdsPromise) { | |
| function _loadKnownIds_finally() { | |
| if (!loadKnownIdsPromise) { | |
| getTaggedConsole("_loadKnownIds_finally").warn("loadKnownIdsPromise is already null"); | |
| } | |
| loadKnownIdsPromise = null; | |
| } | |
| loadKnownIdsPromise = _loadKnownIds().finally(_loadKnownIds_finally); | |
| } 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); | |
| // con.debug("Saved knownIds"); | |
| try { | |
| showKnownIdsCleanMemo(i18next.t("requestInfo:cleanMemo.titleSaved")); | |
| } catch (err) { | |
| con.warn("While sending showKnownIdsCleanMemo notification:", 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));*/ | |
| const KNOWN_IDS_SAVE_TIMEOUT = 170; | |
| let saveKnownIdsTimeout = null; | |
| function _scheduleSaveKnownIdsDo() { | |
| saveKnownIdsTimeout = null; | |
| saveKnownIds(knownIds); | |
| } | |
| function scheduleSaveKnownIds() { // TODO: can this use an idle callback | |
| clearTimeout(saveKnownIdsTimeout); | |
| saveKnownIdsTimeout = setTimeout(_scheduleSaveKnownIdsDo, KNOWN_IDS_SAVE_TIMEOUT); | |
| return saveKnownIdsTimeout; | |
| } | |
| window.addEventListener("pagehide", function saveKnownIdsOnPagehide(ev) { | |
| if (ev.persisted) return; | |
| if (!saveKnownIdsTimeout) return; | |
| 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); | |
| _scheduleSaveKnownIdsDo(); | |
| }); | |
| 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; | |
| } | |
| function isFailedInfo(info) { | |
| return !!(!info || info.failed); | |
| } | |
| // TODO: decide the most 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; | |
| /* consider bumping data version if new fields have been added, or field meanings have changed | |
| it may not be necessary to bump version just for field removals, depends on the extent | |
| note that even if info is outdated it may not get refreshed, so make sure to not change field | |
| meanings heavily (or prepare to add a bunch of compat code) unless if absolutely necessary */ | |
| // note that reqInfo funcs should process but not render data (that's the callers' responsibility) | |
| const requestInfoPromises = {}; | |
| async function requestInfoHelper(func, ver, typeDisplayName, id, ...args) { | |
| await loadKnownIds(); | |
| const oldInfo = getKnownIdInfo(id, false); | |
| if (oldInfo && !("ver" in oldInfo) && "version" in oldInfo) { | |
| oldInfo.ver = oldInfo.version; | |
| delete oldInfo.version; | |
| } | |
| const con = getTaggedConsole("requestInfoHelper"); | |
| if (ver >= (1 << INFO_VERSION_BSHIFT)) { | |
| con.error("Datatype", typeDisplayName, "ver", ver, ">=", 1 << INFO_VERSION_BSHIFT); | |
| } | |
| const versionToWrite = (INFO_VERSION << INFO_VERSION_BSHIFT) + ver; | |
| const oldVersion = oldInfo?.ver; | |
| const infoFormatOutdated = oldInfo && oldVersion != versionToWrite; | |
| if (infoFormatOutdated) { | |
| con.debug(id, "format is outdated:", oldVersion, "!=", versionToWrite); | |
| } | |
| const requestNeeded = !oldInfo || infoFormatOutdated; | |
| const reqErrorLogPrologue = `While fetching info of ${typeDisplayName} ${id}`; | |
| async function _performRequest() { | |
| let result = { failed: true }, err; | |
| try { | |
| result = await func(...args) || result; | |
| } catch (_err) { | |
| err = _err; | |
| if (err instanceof ThrottledRequestCancelledError) { | |
| // do not write | |
| result = undefined; | |
| } | |
| } | |
| if (result) { | |
| result.ver = versionToWrite; | |
| addKnownIdInfo(id, result); | |
| } | |
| if (err) { | |
| throw err; | |
| } else if (isFailedInfo(result)) { | |
| const con = getTaggedConsole("requestInfoHelper/_performRequest"); | |
| con.warn(`Got a failed result for ${id}, but there is no error (func returned nothing?)`); | |
| return false; | |
| } | |
| return true; | |
| } | |
| // TODO: better retrying logic | |
| async function performRequest(retryingForOffline=false) { | |
| const con = getTaggedConsole("requestInfoHelper/performRequest"); | |
| con.debug("Requesting", id, "retryingForOffline:", retryingForOffline); | |
| let requested = false, err; | |
| const wasOnline = isOnline(); | |
| if (wasOnline) { | |
| if (!requestInfoPromises[id]) { | |
| function _performRequest_finally() { | |
| if (!requestInfoPromises[id]) { | |
| getTaggedConsole("requestInfoHelper/_performRequest_finally") | |
| .warn("requestInfoPromise for", id, "is already null"); | |
| } | |
| delete requestInfoPromises[id]; | |
| } | |
| requestInfoPromises[id] = _performRequest().finally(_performRequest_finally); | |
| } | |
| try { | |
| requested = await requestInfoPromises[id]; | |
| } catch (_err) { | |
| err = _err; | |
| if (!(PASS_THROUGH_REQ_ERRORS && !PASS_THROUGH_REQ_ERRORS_ON_CACHE_MISS)) { | |
| con.error(reqErrorLogPrologue, _err); | |
| } | |
| } | |
| } | |
| if (!requested) { | |
| con.warn("Did not/could not request", id, "when we should have"); | |
| // obvious problem: we're checking for this too soon | |
| // at least the logic is here for now | |
| if (!retryingForOffline && !wasOnline && isOnline()) { | |
| return await performRequest(true); | |
| } | |
| } | |
| if (PASS_THROUGH_REQ_ERRORS && err) throw err; | |
| } | |
| if (requestNeeded) { | |
| try { | |
| await performRequest(); | |
| } catch (err) { | |
| if (PASS_THROUGH_REQ_ERRORS | |
| && !(PASS_THROUGH_REQ_ERRORS_ON_CACHE_MISS && (requestNeeded && isFailedInfo(info)))) { | |
| throw new TypeError(`Failed to fetch info of ${typeDisplayName} ${id}`, { cause: err }); | |
| } else { | |
| con.error(reqErrorLogPrologue, err); | |
| } | |
| } | |
| } | |
| const info = getKnownIdInfo(id); | |
| con.debug(requestNeeded ? "Done requesting" : "Retrieved", id, "isFailedInfo:", isFailedInfo(info)); | |
| return info; | |
| } | |
| 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) | |
| // TODO: maybe support storing the url wholesale ?? sounds wack | |
| 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; | |
| } | |
| } | |
| // TODO: rework everything that works with this | |
| function checkIfNumeric(id, dataType) { | |
| if (isNaN(Number(id))) { | |
| 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), | |
| String(Number(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)); | |
| 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 can just store "" as name in this case | |
| 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 | |
| String(Number(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)); | |
| 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, | |
| String(Number(id))); | |
| } | |
| return { reqUidInfo, isUserDeleted, reqGarbSuitItemInfo, reqGarbDlcActInfo }; | |
| })(); | |
| const translateEmoticonName = (function() { | |
| const UP_EMOTE_REGEX = /(?<=\[)(UPOWER|UP)_(\d+)(?=_.+?\])/; | |
| 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); | |
| } | |
| const tlContext = { type: match[1], context: "userKnown", uid: uid }; | |
| if (!userInfo?.name) { // omitting isFailedInfo check for now | |
| tlContext.context = "userUnknown"; | |
| } else if (!isUserDeleted(userInfo)) { | |
| tlContext.username = userInfo.name; | |
| } else { | |
| tlContext.context = "userDeleted"; | |
| } | |
| return name.replace(UP_EMOTE_REGEX, i18next.t("emote:prefix", tlContext)); | |
| } | |
| })(); | |
| 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; | |
| // yeah I know this part is wonderful | |
| /*let*/ const 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 (medalHashes && imgHashes.some(hash => hash in medalHashes)) return level; | |
| } | |
| } else { | |
| const imgHashes = new Set(Array.from(imgs).map(i => ADLER32.str(i))); | |
| const levelByInvertedJaccardIndex = Array.from(medals.map((hashes, level) => { | |
| let index = 0; | |
| if (hashes) { | |
| const hashesSet = new Set(hashes); | |
| const intersection = calcSetIntersectionCount(hashesSet, imgHashes); | |
| const union = calcSetUnionCount(hashesSet, imgHashes); | |
| index = ((intersection == 0 || union == 0) ? 0 : (intersection/union)); | |
| } | |
| return [1 + level, 1 - index]; | |
| })) | |
| .sort((a, b) => b[1] - a[1]) // then sort it in reverse (this is why index is inverted) | |
| .reverse(); | |
| if (levelByInvertedJaccardIndex.length > 0) { | |
| return levelByInvertedJaccardIndex[0][0]; | |
| } | |
| } | |
| } | |
| } | |
| // very logical (damn md5 hashes) | |
| const GUARD_ORNAMENT_IMG_ID_TO_TIER = { | |
| "garb/item/7605b10f0bae26fdc95e359b7ef11e5359783560": "captain", | |
| "garb/item/22c143523cbd71f5b03de64f8c0a1e429541ebe6": "commander", | |
| "garb/item/85f9dced6dd1525b0f7f2b5a54990fed21ade1e5": "general" | |
| }; | |
| // 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 i18next.t("decoCard:unavailableSuit"); | |
| } | |
| /* | |
| if (typeof info.name === "string" && info.suiteName != info.name) { | |
| return i18next.t("decoCard:suitAndItemName", { suiteName: info.suiteName, name: 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; | |
| } | |
| const lotteryId = url.searchParams.get("lottery_id"); | |
| if (lotteryId && lotteryId != "0") { | |
| con.debug("Found DLC activity URL with lottery_id", lotteryId, "act_id", actId, "(TODO)", 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 i18next.t("decoCard:dlcWithLevel", { name: info.name, level: 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); | |
| } | |
| } | |
| if (!isFailedInfo(userInfo) && !isUserDeleted(userInfo)) { | |
| return i18next.t("decoCard:guardCardWithStreamer", { | |
| context: foundTier, | |
| username: userInfo.name | |
| }); | |
| } | |
| */ | |
| return i18next.t("decoCard:guardCard", { context: foundTier }); | |
| } | |
| default: | |
| con.warn(urlWarnIntro, "unexpected href", elem, url.href); | |
| break; | |
| } | |
| } | |
| })(); | |
| // TODO: merge https://gist.github.com/Dobby233Liu/cb70b479d0127f000860f416a93053c1 into this? maybe? | |
| // TODO: bail when relevant elements cease to exist? (might not matter) | |
| // TODO: user config & alerts where necessary (not very important right now) | |
| (function injectArriveListeners() { | |
| const EAGER = { existing: true }; | |
| function addProcessingLabelToTitle(el, alt=undefined) { | |
| const oldTitle = (el.title.trim?.() != "" && el.title || alt) ?? ""; // this is so goofy | |
| if (ARRIVE_DELAY_MODE == ArriveDelayModes.HandleImmediately) return oldTitle; | |
| el.title = (oldTitle ? (oldTitle + "\n") : "") + i18next.t("processing"); | |
| return oldTitle; | |
| } | |
| function setTitleWorkaround(el, title, ariaLabel=null) { | |
| function _setTitle() { | |
| el.title = title; | |
| if ((el.ariaLabel ?? "").trim() == "") { | |
| el.ariaLabel = ariaLabel ?? title; | |
| } | |
| } | |
| if (ARRIVE_DELAY_MODE != ArriveDelayModes.HandleImmediately) { | |
| el.removeAttribute("title"); | |
| (requestIdleCallback ?? requestAnimationFrame)(_setTitle); | |
| } else { | |
| _setTitle(); | |
| } | |
| } | |
| const ADD_ARTIFICAL_TITLE_DELAY = false; | |
| function debugWait(t) { | |
| if (!ADD_ARTIFICAL_TITLE_DELAY) return; | |
| t = t ?? (3000 + Math.floor(Math.random() * 1000)); | |
| return new Promise((resolve, _) => setTimeout(resolve, t)); | |
| } | |
| // TODO: avoid these regexes (at least they are i18n-independent) | |
| 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.trace(img, "has falsy alt:", alt); | |
| return; | |
| } | |
| const oldTitle = addProcessingLabelToTitle(img, alt); | |
| await debugWait(); | |
| try { | |
| const newTitle = await translateEmoticonName(alt); | |
| const ariaLabelName = newTitle.split("\n")[0] | |
| .replace(DEBRACKET_REGEX, (_, m) => m) | |
| .replace(DEUNDERSCORE_REGEX, i18next.t("emote:ariaLabelReplacementDelimiter")); | |
| setTitleWorkaround(img, newTitle, i18next.t("emote:ariaLabel", { name: ariaLabelName })); | |
| } 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); | |
| } | |
| // .bili-danmaku-x-dm-emoji has no info at all | |
| const emoteSelectors = [".bili-rich-text-emoji", ".opus-text-rich-emoji > img"]; | |
| if (location.hostname == "live.bilibili.com") { | |
| emoteSelectors.push(".danmaku-item .emoticon img"); | |
| } | |
| arriveDelayed(document, emoteSelectors.join(","), EAGER, addTitleToEmoticon); | |
| arriveInShadowRootOf("bili-rich-text", "#contents img:not(a[data-type] > *)", EAGER, addTitleToEmoticon); | |
| 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_CUST_ELEM_POPOVER_TITLE_STYLE = HIDE_POPOVER_TITLE_STYLE | |
| .replaceAll(".bili-emoji-popover", "#emoji-popover").replaceAll(".placement-", "."); | |
| addStyleInShadowRootOf("bili-emoji-popover", HIDE_CUST_ELEM_POPOVER_TITLE_STYLE); | |
| // not planning to add copyAltToTitle for the popover atm (NTS: msedge_KrOAMe2YrU.png) | |
| async function addTitleToDecoCard(link) { | |
| const oldTitle = addProcessingLabelToTitle(link); | |
| await debugWait(); | |
| try { | |
| const newTitle = await makeDecoCardTooltip(link); | |
| if (newTitle) { | |
| const regex = i18next.t("decoCard:levelIndicatorRegex", { returnObjects: true }); | |
| const level = regex ? newTitle.match(regex)?.[1] : null; | |
| const ariaLabelName = regex ? newTitle.replace(regex, "") : newTitle; | |
| setTitleWorkaround(link, newTitle, i18next.t("decoCard:ariaLabel", { | |
| name: ariaLabelName, | |
| context: level ? "hasLevel" : undefined, | |
| level: level | |
| })); | |
| return; | |
| } | |
| } catch (err) { | |
| const con = getTaggedConsole("addTitleToDecoCard"); | |
| con.error("makeDecoCardTooltip failed for", link, err); | |
| } | |
| // fallback | |
| link.title = oldTitle; | |
| } | |
| arriveDelayed(document, ".dyn-decoration-card > a", EAGER, addTitleToDecoCard); | |
| /* 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); | |
| function copyImgAltToParentTitle(tab) { | |
| const img = tab.querySelector(":scope > img"); | |
| if (img?.alt) setTitleWorkaround(tab, img.alt); | |
| } | |
| function copyEmoteAltToParentTitle(tab) { | |
| const img = tab.querySelector(":scope > img"); | |
| if (img) return addTitleToEmoticon(tab, img); | |
| } | |
| // hopefully .bili-emoji is only for the picker | |
| arriveDelayed(document, ".bili-emoji .bili-emoji__pkg", copyImgAltToParentTitle); | |
| arriveDelayed(document, ".bili-emoji__list__item", copyEmoteAltToParentTitle); | |
| arriveInShadowRootOf("bili-emoji-picker", "#tabs .tab", EAGER, copyImgAltToParentTitle); | |
| arriveInShadowRootOf("bili-emoji-picker", "#content .emoji", EAGER, copyEmoteAltToParentTitle); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment