Last active
May 24, 2026 11:05
-
-
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.41b | |
| // @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); | |
| } | |
| log = (...args) => console.log(this.#tag, ...args); | |
| warn = (...args) => console.warn(this.#tag, ...args); | |
| debug = (...args) => console.debug(this.#tag, ...args); | |
| trace = (...args) => console.trace(this.#tag, ...args); | |
| error(...args) { | |
| const ret = console.error(this.#tag, ...args); | |
| try { | |
| if (!ALERT_ON_ERROR) return ret; | |
| const msg2 = stringifyConsoleArgs(this.#tag, ...args); | |
| const msg = 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; | |
| // TODO: listen to DOMNodeInserted/DOMNodeRemoved/etc. to call _invalidateIfNecessary; | |
| // we're dealing with outdated browsers anyways | |
| this.#invalidateIfNecessary(); | |
| } | |
| // TODO: (low priority) this is not regularly checked | |
| #invalidateIfNecessary() { | |
| if (this.#target instanceof Node) { | |
| if (!this.#target.isConnected) { | |
| this.#target = undefined; | |
| } | |
| } | |
| if (this.#target instanceof ShadowRoot) { | |
| if (!this.#target.host || !this.#target.host.isConnected) { | |
| this.#target = undefined; | |
| } | |
| } | |
| } | |
| deref() { | |
| this.#invalidateIfNecessary(); | |
| return this.#target; | |
| } | |
| } | |
| const useNotWeakRef = typeof WeakRef !== "function"; | |
| if (addRetroactively && useNotWeakRef) { | |
| console.warn("WeakRef not available, downgrading to NotExactlyWeakRef!! Expect bad memory usage"); | |
| } | |
| function hookIn(obj, funcName, newFunc) { | |
| const origFunc = obj[funcName]; | |
| const hookInvocationFunc = function(...args) { | |
| return newFunc.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 (!useNotWeakRef ? WeakRef : NotExactlyWeakRef)(this.shadowRoot)); | |
| } | |
| } | |
| return ret; | |
| }); | |
| function _addRetroactivelyHelper(tag, func, data) { | |
| if (!addRetroactively || !shadowRootStore.has(tag)) return; | |
| const roots = shadowRootStore.get(tag); | |
| for (const shadowRootRef of roots) { | |
| const shadowRoot = shadowRootRef.deref(); | |
| if (shadowRoot) { | |
| func(tag, shadowRoot, [data]); | |
| } else { | |
| roots.delete(shadowRootRef); | |
| } | |
| } | |
| } | |
| function arriveInShadowRootOf(_tag, selector, ...args) { | |
| const tag = _tag.toUpperCase(); | |
| let options, listener; | |
| if (args.length >= 2) { | |
| [options, listener] = args; | |
| } else { | |
| [listener] = args; | |
| } | |
| if (!listenersByElem.has(tag)) { | |
| listenersByElem.set(tag, new Set()); | |
| } | |
| const listeners = listenersByElem.get(tag); | |
| const data = [selector, options, listener]; | |
| if (!listeners.has(data)) { | |
| listeners.add(data); | |
| _addRetroactivelyHelper(tag, _addListenersToShadowRoot, data); | |
| } | |
| return listener; | |
| } | |
| function addStyleInShadowRootOf(_tag, css) { | |
| const tag = _tag.toUpperCase(); | |
| if (!stylesByElem.has(tag)) { | |
| stylesByElem.set(tag, new Set()); | |
| } | |
| const styles = stylesByElem.get(tag); | |
| if (!styles.has(css)) { | |
| styles.add(css); | |
| _addRetroactivelyHelper(tag, _addStylesToShadowRoot, css); | |
| } | |
| } | |
| return { arriveInShadowRootOf, addStyleInShadowRootOf }; | |
| })({ | |
| console: getTaggedConsole("init_arriveInShadowRootOf"), | |
| addRetroactively: false // we don't need this yet | |
| }); | |
| const 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; | |
| const 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 methodology (though it's not better): | |
| detect if a relevant elem is being hovered in a global event, and perform cb if so */ | |
| if (arriveDelayMode != ArriveDelayModes.HandleImmediately) { | |
| getTaggedConsole("init_delayedArriveUtils") | |
| .log("Using arriveDelayMode (experimental)", arriveDelayMode.description); | |
| } | |
| // goofy GUID to prevent collisions | |
| const AE_UUID = "_0DA009960600423DB1886326D8810A86_AE_"; | |
| const 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]; | |
| let ret = true; | |
| for (const [cb, ...args] of cbs) { | |
| if (!mode2CallCbWhenIdle) { | |
| ret &&= cb.call(el, el, ...args); | |
| } else { | |
| const _cbInvocator = () => cb.call(el, el, ...args); | |
| if (typeof requestIdleCallback === "function") { | |
| requestIdleCallback(_cbInvocator, { | |
| timeout: mode2IdleCallbackTimeout | |
| }); | |
| } else { | |
| requestAnimationFrame(_cbInvocator); | |
| } | |
| } | |
| } | |
| return ret; | |
| } | |
| 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); | |
| return _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: tune | |
| 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))); | |
| } | |
| } | |
| // TODO: make this an "enum" I guess | |
| const PROCESS_QUEUE_CB_TYPE_ANIMFRAME = 0; | |
| const PROCESS_QUEUE_CB_TYPE_IDLECB = 1; | |
| let processQueueCbId = null; | |
| async function _runProcessQueue() { | |
| try { | |
| await processQueue(); | |
| } catch (err) { | |
| const con = getTaggedConsole("throttledFetch/scheduleProcessQueue"); | |
| con.error("Failed to process queue:", err); | |
| } finally { | |
| processQueueCbId = null; | |
| } | |
| } | |
| function scheduleProcessQueue() { | |
| if (processQueueCbId) return; | |
| if (typeof requestIdleCallback === "function") { | |
| processQueueCbId = [ | |
| PROCESS_QUEUE_CB_TYPE_IDLECB, | |
| requestIdleCallback(_runProcessQueue, { timeout: processQueueIdleCbTimeout })]; | |
| } else { | |
| processQueueCbId = [ | |
| PROCESS_QUEUE_CB_TYPE_ANIMFRAME, | |
| requestAnimationFrame(_runProcessQueue)]; | |
| } | |
| } | |
| function cancelProcessQueueCb() { | |
| if (!processQueueCbId) return; | |
| if (processQueueCbId[0] == PROCESS_QUEUE_CB_TYPE_ANIMFRAME) { | |
| cancelAnimationFrame(processQueueCbId[1]); | |
| } else if (processQueueCbId[0] == PROCESS_QUEUE_CB_TYPE_IDLECB) { | |
| cancelIdleCallback(processQueueCbId[1]); | |
| } | |
| processQueueCbId = null; | |
| } | |
| window.addEventListener("pagehide", (ev) => { | |
| if (ev.persisted) return; // TODO: ? | |
| cancelProcessQueueCb(); | |
| const requestQueueCopy = requestQueue.slice(); | |
| requestQueue.length = 0; | |
| for (const req of requestQueueCopy) { | |
| req.abort(new ThrottledRequestCancelledError("Current page is unloading")); | |
| } | |
| }); | |
| class ThrottledRequestCancelledError extends Error {} | |
| // TODO: dedupe by request contents (using a stupid Map, obj as key and Promise as value)? maybe? | |
| function throttledFetch(url, options) { | |
| const controller = new AbortController(); | |
| async function perform(resolve, reject) { | |
| controller.signal.throwIfAborted(); | |
| activeRequests++; | |
| 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 | |
| reject(new ThrottledRequestCancelledError("Request queue is full")); | |
| } else { | |
| requestQueue.push({ | |
| perform: _perform, | |
| abort: controller.abort.bind(controller) | |
| }); | |
| } | |
| }); | |
| } | |
| return { throttledFetch, ThrottledRequestCancelledError }; | |
| })({ | |
| getTaggedConsole, | |
| maxConcurrentRequests: 3, | |
| // TODO: having second thoughts on queue size, maybe add multiple queues? | |
| queueSize: 30, | |
| minimumGracePeriod: 100, | |
| maximumGracePeriod: 250, | |
| requestTimeout: 10000, | |
| processQueueIdleCbTimeout: 100 | |
| }); | |
| const { extractBfsImgInfo, extractBfsImgId } = (function() { | |
| function extractBfsImgInfo(url) { | |
| const START = "/bfs/"; | |
| if (url.pathname.substring(0, START.length) != START) { | |
| return; | |
| } | |
| const info = { | |
| server: url.origin + START, | |
| id: url.pathname.substring(START.length), | |
| origFormat: null, | |
| params: null | |
| }; | |
| const paramStartIndex = info.id.lastIndexOf("@"); | |
| if (paramStartIndex >= 0) { | |
| info.params = info.id.substring(paramStartIndex + 1); | |
| info.id = info.id.substring(0, paramStartIndex); | |
| } | |
| const extStartIndex = info.id.lastIndexOf("."); | |
| if (extStartIndex >= 0) { | |
| info.origFormat = info.id.substring(extStartIndex); | |
| if (info.id) { | |
| info.id = info.id.substring(0, extStartIndex); | |
| } | |
| } | |
| return info; | |
| } | |
| function extractBfsImgId(url) { | |
| return extractBfsImgInfo(url)?.id ?? (url.origin + url.pathname); // w/e | |
| } | |
| return { extractBfsImgInfo, extractBfsImgId }; | |
| })(); | |
| const { requestInfoHelper, isFailedInfo } = (function() { | |
| // TODO: maybe don't keep knownIds as a variable | |
| let knownIds = {}; | |
| let knownIdsCleanMemo = ""; | |
| // LRU-ish cache (might be too much) | |
| // TODO: allow each data type to have different expire times? | |
| const KNOWN_IDS_EXPIRE_TIME = 1 * 60 * 60 * 1000; | |
| const FAILED_IDS_EXPIRE_TIME = 5 * 1000; | |
| const KNOWN_IDS_MAX_RETENTION_COUNT = 30; | |
| const KNOWN_IDS_REF_COUNT_LOAD_DECAY_FACTOR = 0.99; // TODO: temporary | |
| const KNOWN_IDS_REF_COUNT_REF_DECAY_FACTOR = 0.95; | |
| const KNOWN_IDS_TS_PENALTY_WEIGHT = 5 * 60 * 1000; | |
| const KNOWN_IDS_STORAGE_KEY = "knownUids"; // legacy | |
| const KNOWN_IDS_FORCE_NO_STORAGE = false; | |
| const REQUEST_INFO_FORCE_OFFLINE = false; | |
| // This returning true does not mean internet is reachable, but we don't really care | |
| // about the specifics for now | |
| function isOnline() { | |
| return navigator.onLine && !REQUEST_INFO_FORCE_OFFLINE; | |
| } | |
| function _cleanKnownIds(newKnownIds) { | |
| const con = getTaggedConsole("_cleanKnownIds"); | |
| const cleanMemo = []; | |
| function logCleanMemo(...msg) { | |
| con.log(...msg); | |
| cleanMemo.push(stringifyConsoleArgs(...msg)); | |
| } | |
| // if we know we're offline, we want to keep as much data in storage as possible | |
| if (isOnline()) { | |
| const now = Date.now(); | |
| // TODO: is this useful to have anymore | |
| let expiredEntries = 0; | |
| for (const [id, info] of Object.entries(newKnownIds)) { | |
| if (!("ts" in info)) { | |
| info.ts = info.timestamp | |
| ?? (info.lastAccess ?? info.lastAccessTimestamp) // fallback | |
| ?? (now - KNOWN_IDS_EXPIRE_TIME/2); | |
| delete info.timestamp; | |
| } | |
| if ((now - info.ts) > (info.failed ? FAILED_IDS_EXPIRE_TIME : KNOWN_IDS_EXPIRE_TIME)) { | |
| delete newKnownIds[id]; | |
| expiredEntries++; | |
| } | |
| } | |
| if (expiredEntries > 0) { | |
| logCleanMemo("Deleting expired entries:", expiredEntries); | |
| } | |
| function _getLats(info) { | |
| if (!("lastAccess" in info) && "lastAccessTimestamp" in info) { | |
| info.lastAccess = info.lastAccessTimestamp; | |
| delete info.lastAccessTimestamp; | |
| } | |
| return info.lastAccess ?? info.ts ?? now; | |
| } | |
| const sortedByRefCount = Object.entries(newKnownIds).sort(([_, a], [__, b]) => { | |
| const scoreA = (a.refs ?? a.refCount ?? 1) - ((now - _getLats(a)) / KNOWN_IDS_TS_PENALTY_WEIGHT); | |
| const scoreB = (b.refs ?? b.refCount ?? 1) - ((now - _getLats(b)) / KNOWN_IDS_TS_PENALTY_WEIGHT); | |
| return scoreB - scoreA; | |
| }); | |
| if (sortedByRefCount.length > KNOWN_IDS_MAX_RETENTION_COUNT) { | |
| const shearedEntries = sortedByRefCount.length - KNOWN_IDS_MAX_RETENTION_COUNT; | |
| for (const [id, _] of sortedByRefCount.slice(KNOWN_IDS_MAX_RETENTION_COUNT)) { | |
| delete newKnownIds[id]; | |
| } | |
| logCleanMemo("Shearing less used entries:", shearedEntries); | |
| } | |
| } | |
| for (const info of Object.values(newKnownIds)) { | |
| info.refs = Math.ceil((info.refs ?? info.refCount ?? 1) * KNOWN_IDS_REF_COUNT_LOAD_DECAY_FACTOR); | |
| delete info.refCount; | |
| } | |
| return [newKnownIds, cleanMemo.join("\n")]; | |
| } | |
| 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 undefined; // TODO: ? | |
| } | |
| async function _loadKnownIds() { // marked async so it generates a Promise | |
| let newKnownIds = knownIds; | |
| const storedKnownIds = !KNOWN_IDS_FORCE_NO_STORAGE && await GM_getValue_async(KNOWN_IDS_STORAGE_KEY); | |
| if (storedKnownIds) { | |
| // in case we have more entries locally, though ehhhh | |
| 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 scheduleSaveKnownIds() { // TODO: can this be an idle callback | |
| clearTimeout(saveKnownIdsTimeout); | |
| saveKnownIdsTimeout = setTimeout(() => { | |
| saveKnownIdsTimeout = null; | |
| saveKnownIds(knownIds); | |
| }, KNOWN_IDS_SAVE_TIMEOUT); | |
| return saveKnownIdsTimeout; | |
| } | |
| window.addEventListener("pagehide", function saveKnownIdsOnPagehide(ev) { | |
| if (ev.persisted) return; // TODO: ? | |
| 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); | |
| saveKnownIdsTimeout = null; | |
| saveKnownIds(knownIds); | |
| }); | |
| function addKnownIdInfo(id, obj) { | |
| if (!("ts" in obj)) obj.ts = Date.now(); | |
| if (!("refs" in obj)) obj.refs = 0; | |
| return (knownIds[id] = obj); | |
| } | |
| function getKnownIdInfo(id, updateRefCount=true, save=true) { | |
| const info = knownIds[id]; | |
| if (!info) return info; | |
| if (updateRefCount) { | |
| info.refs = Math.ceil((info.refs ?? info.refCount ?? 1) * KNOWN_IDS_REF_COUNT_REF_DECAY_FACTOR) + 1; | |
| delete info.refCount; | |
| info.lastAccess = Date.now(); delete info.lastAccessTimestamp; | |
| if (save) scheduleSaveKnownIds(); | |
| } | |
| return info; | |
| } | |
| function isFailedInfo(info) { | |
| return !!(!info || info.failed); | |
| } | |
| // TODO: decide if passing through errors is favorable behavior | |
| const PASS_THROUGH_REQ_ERRORS = false; | |
| const PASS_THROUGH_REQ_ERRORS_ON_CACHE_MISS = false; | |
| // reset data versions to 0 after bumping this | |
| const INFO_VERSION = 0; | |
| const INFO_VERSION_BSHIFT = 8; | |
| /* 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; | |
| 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 new TypeError(`Failed to fetch info of ${typeDisplayName} ${id}`, { cause: 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 support | |
| 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(_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 && info)) { | |
| throw err; | |
| } else { | |
| con.error(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 attaching 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; | |
| } | |
| } | |
| const NUMERIC_REGEX = /^[0-9]+$/; // TODO: avoid? | |
| function checkIfNumeric(id, dataType) { | |
| if ((typeof id !== "number" || isNaN(id)) && !(typeof id === "string" && id.match(NUMERIC_REGEX))) { | |
| getTaggedConsole("requestInfoHelper/checkIfNumeric").warn("id", id, "of dataType", dataType, "is not numeric"); | |
| // TODO: throw error? | |
| return false; | |
| } | |
| return true; | |
| } | |
| const INFO_UID_VERSION = 1; | |
| const DELETED_ACCOUNT_NAME = "账号已注销"; | |
| async function _reqUidInfo(uid) { | |
| const endpointUrl = makeEndpointUrl("/web-interface/card"); | |
| endpointUrl.searchParams.set("mid", uid); | |
| const res = throwIfResponseNotOk(await throttledFetch(endpointUrl.href, DEFAULT_FETCH_OPTIONS)); | |
| const content = BilibiliApiError.throwIfNotOk(await res.json()); | |
| const name = content.data?.card?.name; | |
| if (!name) throw new BilibiliApiError(content, "Missing name"); | |
| return { name: name == DELETED_ACCOUNT_NAME ? "" : name.trim() }; | |
| } | |
| function reqUidInfo(uid) { | |
| checkIfNumeric(uid, "uid"); | |
| return requestInfoHelper( | |
| _reqUidInfo, INFO_UID_VERSION, | |
| "user", String(uid), | |
| uid); | |
| } | |
| function isUserDeleted(info) { | |
| return info.name == DELETED_ACCOUNT_NAME || info.name == ""; | |
| } | |
| const INFO_GARB_SUIT_ITEM_VERSION = 2; | |
| async function _reqGarbSuitItemInfo(itemId, partType, isDiy, vmid) { | |
| const endpointUrl = makeEndpointUrl("/garb/v2/user/suit/benefit"); | |
| // this API will work without a csrf token if not logged on | |
| const csrfToken = getCsrfToken(); | |
| if (csrfToken) endpointUrl.searchParams.set("csrf", csrfToken); | |
| endpointUrl.searchParams.set("is_diy", isDiy); | |
| endpointUrl.searchParams.set("item_id", itemId); | |
| endpointUrl.searchParams.set("part", partType); | |
| // idk if vmid is necessary when is_diy is false | |
| // the only thing is seems to do in that case is changing how data is sorted | |
| if (isDiy != "0") { | |
| endpointUrl.searchParams.set("vmid", vmid); | |
| } | |
| const res = throwIfResponseNotOk(await throttledFetch(endpointUrl.href, DEFAULT_FETCH_OPTIONS)); | |
| const content = BilibiliApiError.throwIfNotOk(await res.json()); | |
| if (content.data === null) { | |
| // "很遗憾,当前装扮暂时无法查看,去看看其他装扮吧~" or "empty-rights", see id 5887 | |
| // appears for nonexistent items too | |
| // TODO: maybe I also can just store "" as name here | |
| return { unavailable: true }; | |
| } | |
| if (!content.data?.name) throw new BilibiliApiError(content, "Missing name"); | |
| let itemName; | |
| // suit item names seem internal, the mall page doesn't show them at least, so don't present them for now | |
| // (I don't need to bump data version for this I think) | |
| /*const items = content.data.suit_items?.[partType]; | |
| if (items) { // see item id 29 for a case where suit items don't exist | |
| const itemsById = Object.fromEntries(items.map(x => [x.item_id, x])); | |
| const item = itemsById[itemId]; | |
| if (!item) { | |
| throw new BilibiliApiError(content, `No item with id ${itemId}`); | |
| } | |
| itemName = item.name; | |
| }*/ | |
| return { suiteName: content.data.name.trim() /*, name: itemName?.trim()*/ }; | |
| } | |
| function reqGarbSuitItemInfo(itemId, part, isDiy, vmid) { | |
| const dataType = "garb_suit"; | |
| checkIfNumeric(itemId, dataType); | |
| return requestInfoHelper( | |
| _reqGarbSuitItemInfo, INFO_GARB_SUIT_ITEM_VERSION, | |
| "personalized suit item", dataType + "_" + itemId, // fake id for cache | |
| itemId, part, isDiy, vmid); | |
| } | |
| const INFO_DLC_ACT_VERSION = 2; | |
| function _parseMedalInfo(dataJson) { | |
| if (!dataJson) return; | |
| const data = JSON.parse(dataJson); | |
| const levels = data.map(i => i.level).sort(); | |
| if (levels.length <= 0) return; | |
| if (levels[0] < 1) { | |
| throw new RangeError("1st level is smaller than 1, but we assume Lv1 is the first!", { cause: data }); | |
| } | |
| const imgUrlHashesByLvl = new Array(levels[levels.length - 1]); | |
| for (const medal of data) { | |
| if (!medal.scene_image) continue; | |
| const destIndex = medal.level - 1; | |
| if (imgUrlHashesByLvl[destIndex]) { | |
| const con = getTaggedConsole("_reqGarbDlcActInfo/_parseMedalInfo"); | |
| con.warn("Already have hashes for level", medal.level); | |
| } | |
| const hashes = imgUrlHashesByLvl[destIndex] = []; | |
| for (const img of new Set(Object.values(medal.scene_image))) { | |
| hashes.push(ADLER32.str(extractBfsImgId(new URL(img)))); | |
| } | |
| } | |
| return imgUrlHashesByLvl; | |
| } | |
| async function _reqGarbDlcActInfo(id) { | |
| const endpointUrl = makeEndpointUrl("/vas/dlc_act/act/basic"); | |
| endpointUrl.searchParams.set("act_id", id); | |
| // this API will work without a csrf token if not logged on (iirc) | |
| const csrfToken = getCsrfToken(); | |
| if (csrfToken) endpointUrl.searchParams.set("csrf", csrfToken); | |
| const res = throwIfResponseNotOk(await throttledFetch(endpointUrl.href, DEFAULT_FETCH_OPTIONS)); | |
| const content = BilibiliApiError.throwIfNotOk(await res.json()); | |
| if (!content.data?.act_title) throw new BilibiliApiError(content, "Missing act_title"); | |
| let medals; | |
| try { | |
| medals = _parseMedalInfo(content.data.collector_medal_info); | |
| } catch (err) { | |
| const con = getTaggedConsole("_reqGarbDlcActInfo"); | |
| con.warn("Failed to parse medal info:", err); | |
| } | |
| return { name: content.data.act_title.trim(), medals } | |
| } | |
| function reqGarbDlcActInfo(id) { | |
| const dataType = "dlc_act"; | |
| checkIfNumeric(id, dataType); | |
| return requestInfoHelper( | |
| _reqGarbDlcActInfo, INFO_DLC_ACT_VERSION, | |
| "digital collection campaign", dataType + "_" + id, | |
| id); | |
| } | |
| return { reqUidInfo, isUserDeleted, reqGarbSuitItemInfo, reqGarbDlcActInfo }; | |
| })(); | |
| const translateEmoticonName = (function() { | |
| const UP_EMOTE_REGEX = /(?<=\[)(UPOWER|UP)_(\d+)(?=_.+?\])/; | |
| 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; | |
| // TODO: ? | |
| let bimg = child.style.getPropertyValue?.("background-image") ?? child.style.backgroundImage; | |
| /*if (bimg == "") { | |
| con.warn("Calling getComputedStyle on", child); | |
| bimg = window.getComputedStyle(child).backgroundImage; | |
| }*/ | |
| if (bimg == "") { | |
| con.warn("No background-image found on", child); | |
| continue; | |
| } | |
| const bimgMatches = bimg.match(CSS_URL_EXTRACT_REGEX); | |
| if (!bimgMatches) { | |
| con.warn("background-image contains no urls", child, bimg); | |
| continue; | |
| } | |
| imgs.add(extractBfsImgId(new URL(bimgMatches[2]))); | |
| } | |
| } | |
| return imgs; | |
| } | |
| function calcSetIntersectionCount(a, b) { | |
| if (typeof a.intersection !== "undefined") { // es2026 or something | |
| return a.intersection(b).size; | |
| } | |
| let count = 0; | |
| const [small, large] = a.size < b.size ? [a, b] : [b, a]; | |
| for (const item of small) { | |
| if (large.has(item)) { | |
| count++; | |
| } | |
| } | |
| return count; | |
| } | |
| function calcSetUnionCount(a, b) { | |
| if (typeof a.union !== "undefined") { // es2026 or something | |
| return a.union(b).size; | |
| } | |
| return new Set([...a, ...b]).size; | |
| } | |
| function findMedalLevelByImgs(medals, imgs) { | |
| if (imgs.size == 1) { | |
| // awesome "fast path" | |
| const imgHash = ADLER32.str(imgs.values().next().value); | |
| for (let level = medals.length; level >= 1; level--) { // prefer highest matching level | |
| const medalHashes = medals[level - 1]; | |
| if (medalHashes.includes(imgHash)) return level; | |
| } | |
| } else if (imgs.size > 0) { | |
| if (false) { // TODO: verify if this is better | |
| const imgHashes = Array.from(imgs).map(i => ADLER32.str(i)); | |
| for (let level = medals.length; level >= 1; level--) { | |
| const medalHashes = medals[level - 1]; | |
| if (imgHashes.some(hash => hash in medalHashes)) return level; | |
| } | |
| } else { | |
| const imgHashes = new Set(Array.from(imgs).map(i => ADLER32.str(i))); | |
| const reverseJaccardIndex = Array.from( | |
| medals.map((hashes, level) => { | |
| const hashesSet = new Set(hashes ?? []); | |
| const intersection = calcSetIntersectionCount(hashesSet, imgHashes); | |
| const union = calcSetUnionCount(hashesSet, imgHashes); | |
| // level, invertedIndex | |
| return [1 + level, 1 - ((intersection == 0 || union == 0) ? 0 : (intersection/union))]; | |
| }) | |
| ).sort((a, b) => b[1] - a[1]); // then sort it reversed (this is why index is inverted) | |
| if (reverseJaccardIndex.length > 0) { | |
| return reverseJaccardIndex[reverseJaccardIndex.length - 1][0]; | |
| } | |
| } | |
| } | |
| } | |
| // 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; | |
| } | |
| 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: some way to bail when relevant elements cease to exist | |
| // 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_MOCK_TITLE_DELAY = false; | |
| function debugWait(t) { | |
| if (!ADD_MOCK_TITLE_DELAY) return; | |
| t = t ?? (3000 + Math.floor(Math.random() * 1000)); | |
| return new Promise((resolve, _) => setTimeout(resolve, t)); | |
| } | |
| // TODO: avoid these regexes (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_SHD_POPOVER_TITLE_STYLE = HIDE_POPOVER_TITLE_STYLE | |
| .replaceAll(".bili-emoji-popover", "#emoji-popover").replaceAll(".placement-", "."); | |
| addStyleInShadowRootOf("bili-emoji-popover", HIDE_SHD_POPOVER_TITLE_STYLE); | |
| // not planning to add copyAltToTitle for the popover atm (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