// ==UserScript== // @name Bilibili 收藏夹失效视频信息还原 // @name:zh-TW Bilibili 收藏夾失效影片資訊還原 // @name:en Bilibili Fav Restore // @namespace https://github.com/k0504/bilibili-fav-restore // @version 1.2.1 // @description 在 bilibili 网页版收藏夹页面,自动还原失效(已删除 / UP 自删)视频的原始封面、标题与 metadata。 // @description:zh-TW 在 bilibili 網頁版收藏夾頁面,自動還原失效(已刪除 / UP 自刪)影片的原始封面、標題與 metadata。 // @description:en Restore original cover/title/metadata of invalid (deleted) videos on bilibili web favorites pages. // @author k0504 // @homepageURL https://github.com/k0504/bilibili-fav-restore // @supportURL https://github.com/k0504/bilibili-fav-restore/issues // @updateURL https://raw.githubusercontent.com/k0504/bilibili-fav-restore/main/dist/bilibili-fav-restore.user.js // @downloadURL https://raw.githubusercontent.com/k0504/bilibili-fav-restore/main/dist/bilibili-fav-restore.user.js // @match https://space.bilibili.com/* // @match https://www.bilibili.com/list/* // @match https://www.bilibili.com/medialist/* // @match https://www.bilibili.com/favlist* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_setClipboard // @grant GM_openInTab // @grant GM_registerMenuCommand // @grant unsafeWindow // @run-at document-start // @connect api.bilibili.com // @connect passport.bilibili.com // @connect hdslb.com // @connect biliplus.com // @connect jijidown.com // @license MIT // ==/UserScript== /* * AUTO-GENERATED — do not edit by hand. * Source: src/*.js assembled by bundle.py (CORE_VERSION = 1.2.1) * @match/@grant/@connect parsed from bilibili-fav-list-fix.user.js. * Regenerate with: python build.py * * For dev workflow (edit core + reload tab without rebuilding) see * README "Development" section — uses bilibili-fav-list-fix.user.js * (bootstrap) + serve.py instead. */ /* bilibili-fav-list-fix-core.js * * Loaded by bilibili-fav-list-fix.user.js bootstrap. Runs in the userscript * ISOLATED world (inherits GM_* APIs from the stub's @grant block). * * What it does: * 1. Detects bilibili web favorites pages (space.bilibili.com/{mid}/favlist * OR www.bilibili.com/list/ml{media_id}). * 2. Walks the rendered list looking for items whose cover is the global * "video deleted" placeholder (filename starts with `be27fd62…`) OR * whose title is exactly "已失效视频". * 3. Calls /x/v3/fav/folder/resources with Android-app signing — that * endpoint returns the ORIGINAL cover/title for invalid items * (verified by capturing the genuine Android app via emulator + mitm). * 4. Patches the DOM: replaces the placeholder cover img src and the * "已失效视频" title text with the real values, and overlays a small * badge so the user knows the item is still invalid (just shown with * its original metadata). * * Auth: TV QR login on first use (one scan via the bilibili mobile app, * access_key cached ~30 days). Manual access_key override available via * the Tampermonkey menu for users who already captured one another way. * * Caveat: the Android-main-app appkey (1d8b6e7d45233436) was the one * verified to return is_invalid + real cover. TV QR login issues an * access_key bound to the TV appkey (4409e2ce8ffd12b8). The same endpoint * with TV signing usually returns the same fields — but if you find it * silently strips invalid items, switch to manual mode and paste an * Android-app access_key (captured from the real app). */ (function () { 'use strict'; // Bump on every meaningful change so `__biliFavFix.VERSION` in DevTools // is a reliable "is this the version I just edited?" check. Same idea // as dl-manager's CORE_VERSION — see userscripts/bilibili/src/main.js. var CORE_VERSION = '1.2.1'; // Pick the page-world window so `__biliFavFix` is reachable from // DevTools F12 console (which evaluates in page world). Without // unsafeWindow, the assignment goes to the userscript ISOLATED-world // window and `window.__biliFavFix` from DevTools returns undefined — // exactly the symptom that confused us for an hour. dl-manager uses // the same `typeof unsafeWindow !== 'undefined' ? unsafeWindow : window` // pattern (see bilibili-core.user.js:3560). var pageWin = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; if (pageWin.__biliFavFixLoaded) { console.warn('[fav-fix] already loaded — skipping (existing version: ' + (pageWin.__biliFavFix && pageWin.__biliFavFix.VERSION) + ')'); return; } pageWin.__biliFavFixLoaded = true; pageWin.__biliFavFixVersion = CORE_VERSION; // ─── Constants ────────────────────────────────────────────────────── var TV_APPKEY = '4409e2ce8ffd12b8'; var TV_APPSEC = '59b43e04ad6965f34319062b478f83dd'; var AND_APPKEY = '1d8b6e7d45233436'; var AND_APPSEC = '560c52ccd288fed045859ed18bffd973'; // The global "video deleted" cover placeholder. All invalid items the // web API returns share this image (filename starts with `be27fd62`). var PLACEHOLDER_COVER_TOKEN = 'be27fd62'; var INVALID_TITLE = '已失效视频'; // ─── Default rationale for the tunables ──────────────────────────── // The values below are USER-SETTABLE; they live in SETTINGS_SCHEMA // (01a-settings.js) and are read through cfg(). What stays here is the // reasoning behind each DEFAULT — too long for a settings-modal caption, // and the thing a future reader needs before changing one. // Max pages walked for ANY full-collection traversal (ps=20 → this×20 // items). Single source of truth for three call sites that previously // used 20/20/30 inconsistently: // - phase-1 resolver + flap retry: the 20-page cap silently degraded // invalid-card recovery past item #400 (av only reachable on a // later page never resolved via paginated sources). // - fetchFullPhase1Avs (missing-item baseline): the fixed 30-page cap // made the >600-item case falsely report its tail as "silently // dropped" because the diff compared the FULL ids list against a // truncated walk. Now the walk also reports whether it reached a // natural has_more=false end (see fetchFullPhase1Avs `complete`), // and the banner only renders when it did. // 50 pages = 1000 items covers the overwhelming majority of real // collections; larger ones skip the (unreliable) missing banner rather // than emit a false positive. // → cfg('maxPageWalk'), default 50. // ─── Android flap recovery (background) ───────────────────────────── // The android fav endpoint is eventually-consistent: ~5% of invalid // items drop in/out across consecutive walks (same access_key, same // mediaId, seconds apart — confirmed by a 3-walk diagnostic returning // 888 / 879 / 887 of a claimed 923). Crucially the drop is NOT uniform: // deactivated-account / short-legacy-aid items are 7.6x over-represented, // so the stubborn subset misses far more than 5% per walk. A single extra // walk only recovers the easy half. The statistically-correct fix is to // UNION several INDEPENDENT walks — each walk is a fresh server sample, so // P(still missing after N) ≈ (per-walk miss)^N. // // ONE background loop (runFlapRecovery in 08-resolver.js) owns the ENTIRE // retry lifecycle for a folder — it is the SOLE retry path. No cache-TTL // timer, no scroll-to-retry: the loop re-walks android on an ADAPTIVE // backoff and live-patches each recovered card until everything recovers // or it gives up. The cadence AND the give-up are driven by one counter: // - recovered ≥1 this walk → dry resets to 0 → sample again fast // - recovered 0 this walk → dry++ → wait longer next walk // So while items keep flapping back it samples quickly; once recoveries // dry up it eases off and finally stops (dry === flapMaxDry). This makes // a still-flapping folder converge fast while a genuinely-deleted set is // abandoned after ~7 cheap samples instead of being hammered. // → cfg('flapBackoffMs'), default [1000,2000,5000,15000,30000,60000,120000]. // Delay BEFORE the next walk, indexed by the current dry count (clamped // to the last entry). Front-loaded burst (1-5s) catches seconds-level // flapping; the tail widens to a gentle 2-min cadence for stubborn items. // → cfg('flapMaxDry'), default 7 — give up after this many consecutive // 0-recovery walks. // → cfg('flapTimeBudgetMin'), default 30 — overall hard ceiling on one // recovery run (active-recovery backstop). // Missing-item banner baseline (fetchFullPhase1Avs in 13-missing.js). The // "静默丢弃 N 项" count = (ids inventory) MINUS (what the paginated source // actually returned). When that source is android, a SINGLE walk drops a // large, VARIABLE fraction to flap (observed 42/89 = 47% on one walk), so a // one-walk baseline falsely flags those as dropped and inflates N. UNION // independent android walks until the union STOPS GROWING — an item only // counts dropped if EVERY walk missed it. A fixed walk count is fragile // (flap rate varies per folder/moment: too few → over-report, too many → // wasted load), so converge like runFlapRecovery instead: keep walking // until missingDryRounds consecutive walks add 0 new avs (union saturated), // capped by missingMaxWalks. public is stable → one walk. // → cfg('missingDryRounds'), default 2 — union saturated after this many // 0-new walks. // → cfg('missingMaxWalks'), default 8 — hard backstop on android union // walks. // One bilibili fav "card" across the modern + legacy layouts. Single // source of truth shared by findInvalidContainers Strategy 2 (scope the // title-text scan to cards instead of the whole document) and stats() // (card count). If bilibili ships a new card class, add it HERE once and // both consumers pick it up. (Strategy 1's placeholder-img scan is // class-independent, so a missing class here at worst loses the SVG- // placeholder fallback for that layout, not the common path.) var CARD_SELECTOR = '.bili-video-card, .fav-video-card, .small-item'; // Hot-path mirror of cfg('debug'), kept as a bare boolean because log() // reads it on every call. Seeded here (01a-settings.js has not run yet at // this point) and thereafter maintained by that setting's `apply` hook. var DEBUG = !!GM_getValue('debug', false); function log() { if (!DEBUG) return; var args = ['[fav-fix]'].concat(Array.prototype.slice.call(arguments)); console.log.apply(console, args); } function warn() { var args = ['[fav-fix]'].concat(Array.prototype.slice.call(arguments)); console.warn.apply(console, args); } // ─── Settings registry ────────────────────────────────────────────── // // Every tunable in this script used to be a `var X = ` sitting // next to the code that read it. That is the right shape for a protocol // constant (a ZIP signature, an appkey, the cache schema version) but the // wrong one for a value the USER has a legitimate reason to disagree with: // how many pages a rescue walk may burn, how long a cached merge stays // trusted, whether an av number may be sent to a third-party archive. // // This module is the single registry of those values. It is DATA, not // code — the same shape as SOURCES, FIELD_PRIORITY and FAB_MENU elsewhere // in this file — so adding a tunable means appending one schema row and // nothing else: persistence, validation, the reset paths and every row of // the settings modal (16b-settings.js) are all derived from the schema. // // ── Contract ── // cfg(key) → the live value. Cheap (memoised), safe to call // on a hot path, never throws, falls back to the // schema default for a missing/corrupt store. // cfgSet(key, raw) → {ok, value} | {ok:false, error} — validates, // persists, updates the memo, runs `apply`. // cfgReset(key) → back to the default, store entry deleted. // // ── Why call-time reads ── // Callers read cfg() where they USE the value, not once at boot, so a // change takes effect without a reload. The two exceptions are deliberate // and documented at their sites: a long-running loop (runFlapRecovery) and // a single archive sweep (SOURCES.biliplus / SOURCES.jijidown) each // snapshot their parameters into locals up front, so a mid-flight edit // lands on the NEXT round instead of changing the rules under a // half-finished one. // // ── Storage ── // GM storage under `cfg:`, one entry per NON-DEFAULT setting; a // setting left at its default stores nothing. GM_addValueChangeListener is // not granted and the bootstrap @version is frozen (see AGENTS.md), so — // exactly like the 停止重试 list in 07a — a change made in tab A reaches // tab B only after a reload. Do not try to work around it here. var SETTINGS_PREFIX = 'cfg:'; var SETTINGS_GROUPS = [ { id: 'scan', label: '扫描与修复' }, { id: 'retry', label: '后台重试' }, { id: 'cache', label: '缓存与标记' }, { id: 'net', label: '网络与数据源' }, { id: 'backup', label: '备份' }, { id: 'ui', label: '界面与调试' } ]; // One row per tunable. Fields: // key/group/label/desc identity, and what the modal renders // type 'int' | 'bool' | 'intlist' // def the value shipped in this version // min/max inclusive bounds (an 'int', or EACH member of an // 'intlist'). Out-of-range input is REFUSED with a // message rather than silently clamped: a clamp // would report success and then do something else. // unit suffix rendered after the field // gmKey storage-key override, for a setting that existed // before this registry did // apply hook run after a successful set, for a value // mirrored elsewhere (see `debug`) var SETTINGS_SCHEMA = [ // ── 扫描与修复 ── { key: 'maxPageWalk', group: 'scan', type: 'int', def: 50, min: 5, max: 500, unit: '页', label: '修复扫描页数上限', desc: '每页 20 条。收藏夹条目超出上限时,靠后的失效条目扫不到;调高会延长一次完整扫描的耗时。' }, { key: 'patchDebounceMs', group: 'scan', type: 'int', def: 400, min: 100, max: 3000, unit: '毫秒', label: '页面变动合并延迟', desc: '页面结构变动后等待多久执行一次修补。调低响应更快,但翻页途中会重复触发。' }, { key: 'spaSwitchDelayMs', group: 'scan', type: 'int', def: 1500, min: 200, max: 10000, unit: '毫秒', label: '切换收藏夹后的检测延迟', desc: '切换收藏夹后等待页面稳定的时间,随后才开始检测静默丢弃的条目。网络较慢时可调高。' }, { key: 'coverLoadTimeoutMs', group: 'scan', type: 'int', def: 4000, min: 1000, max: 20000, unit: '毫秒', label: '封面加载等待上限', desc: '替换封面后等待图片加载结果的上限。超时即撤下加载动画,避免动画长期停留。' }, { key: 'missingDryRounds', group: 'scan', type: 'int', def: 2, min: 1, max: 5, unit: '轮', label: '丢弃检测收敛轮数', desc: '连续这么多轮走访没有发现新条目,即认定并集已饱和。调低会高估「静默丢弃」的数量。' }, { key: 'missingMaxWalks', group: 'scan', type: 'int', def: 8, min: 1, max: 20, unit: '次', label: '丢弃检测走访上限', desc: 'android 数据源逐轮抖动,需多轮取并集才准确。这是硬上限,防止无止境走访。' }, // ── 后台重试 ── { key: 'flapMaxDry', group: 'retry', type: 'int', def: 7, min: 1, max: 30, unit: '轮', label: '放弃前的连续无收获轮数', desc: '后台重试连续这么多轮没有恢复任何条目即停止,并写入自动「停止重试」标记。' }, { key: 'flapTimeBudgetMin', group: 'retry', type: 'int', def: 30, min: 1, max: 240, unit: '分钟', label: '后台重试总时限', desc: '单个收藏夹的后台重试从开始计时的硬上限,无论是否仍有条目待恢复。' }, { key: 'flapBackoffMs', group: 'retry', type: 'intlist', def: [1000, 2000, 5000, 15000, 30000, 60000, 120000], min: 200, max: 900000, minLen: 1, maxLen: 12, unit: '毫秒', label: '重试间隔梯度', desc: '按当前连续无收获轮数取用,超出长度则取最后一项。必须非递减,用逗号分隔。前段密集以捕捉秒级抖动,尾段拉长以降低请求压力。' }, // ── 缓存与标记 ── { key: 'cacheTtlDays', group: 'cache', type: 'int', def: 30, min: 1, max: 365, unit: '天', label: '成功恢复的缓存有效期', desc: '已确定恢复的条目在本地缓存中的存活时间,到期后重新向数据源查询。' }, { key: 'cacheTtlDegenerateMin', group: 'cache', type: 'int', def: 10, min: 1, max: 1440, unit: '分钟', label: '未确定恢复的缓存有效期', desc: '仅拿到占位信息、或仍在重试的条目所用的缓存有效期。刻意远短于上一项,避免一次失败的走访被锁定数十天。' }, { key: 'autoNoRetryTtlDays', group: 'cache', type: 'int', def: 7, min: 1, max: 90, unit: '天', label: '自动「停止重试」标记有效期', desc: '后台重试放弃后写入的标记的存活时间。手动按下的停止标记不受此项影响,永久有效。' }, // ── 网络与数据源 ── { key: 'httpTimeoutMs', group: 'net', type: 'int', def: 15000, min: 2000, max: 60000, unit: '毫秒', label: 'GET 请求超时', desc: '未单独指定超时的 GET 请求所使用的默认值。' }, { key: 'httpPostTimeoutMs', group: 'net', type: 'int', def: 10000, min: 2000, max: 60000, unit: '毫秒', label: 'POST 请求超时', desc: '登录轮询等表单提交所使用的默认超时。' }, { key: 'phase2BudgetMs', group: 'net', type: 'int', def: 10000, min: 1000, max: 60000, unit: '毫秒', label: '第三方数据源总预算', desc: '一次解析中留给全部第三方存档站的合计时间。超时即跳过剩余数据源,先把已拿到的信息贴回页面。' }, { key: 'sourceFailThreshold', group: 'net', type: 'int', def: 3, min: 1, max: 10, unit: '次', label: '数据源熔断阈值', desc: '同一数据源连续失败这么多次后暂时停用。刷新页面会立即重置熔断状态。' }, { key: 'sourceBackoffMin', group: 'net', type: 'int', def: 5, min: 1, max: 60, unit: '分钟', label: '数据源熔断时长', desc: '触发熔断后停用该数据源的时长。' }, { key: 'thirdPartyTimeoutMs', group: 'net', type: 'int', def: 5000, min: 1000, max: 30000, unit: '毫秒', label: '第三方存档站单次超时', desc: 'biliplus 与 jijidown 每次请求的超时。刻意短于通用 GET 超时,避免慢速存档站拖住页面修补。' }, { key: 'biliplusChunk', group: 'net', type: 'int', def: 50, min: 1, max: 200, unit: '条', label: 'biliplus 每批查询条数', desc: '单次请求携带的 av 号数量。调高可减少请求次数,但更易触发对方限流。' }, { key: 'jijidownPollMs', group: 'net', type: 'int', def: 1200, min: 200, max: 10000, unit: '毫秒', label: 'jijidown 重轮询间隔', desc: 'jijidown 首次查询冷门条目会先返回「正在加载」,需隔一段时间再问一次。' }, { key: 'jijidownMaxPolls', group: 'net', type: 'int', def: 2, min: 0, max: 10, unit: '次', label: 'jijidown 重轮询次数', desc: '首次请求之外的追加轮询次数。设为 0 即不再追问,冷门条目基本查不到。' }, { key: 'enableBiliplus', group: 'net', type: 'bool', def: true, label: '启用 biliplus 存档源', desc: '关闭后不再向 biliplus.com 发送任何请求。启用时该站会收到失效视频的 av 号。' }, { key: 'enableJijidown', group: 'net', type: 'bool', def: true, label: '启用 jijidown 存档源', desc: '关闭后不再向 jijidown.com 发送任何请求。启用时该站会收到失效视频的 av 号。' }, // ── 备份 ── { key: 'backupMaxPages', group: 'backup', type: 'int', def: 500, min: 10, max: 2000, unit: '页', label: '备份走访页数上限', desc: '每页 20 条。备份是用户主动发起的一次性操作,上限刻意远高于修复扫描:截断即等于丢失数据。' }, { key: 'backupPageDelayMs', group: 'backup', type: 'int', def: 300, min: 0, max: 5000, unit: '毫秒', label: '备份翻页间隔', desc: '相邻两页之间的等待时间。调低会加快备份,但请求密度更高。' }, { key: 'backupBlobConcurrency', group: 'backup', type: 'int', def: 3, min: 1, max: 16, unit: '并发', label: '封面下载并发数', desc: '同时下载多少张封面。调高更快,但占用更多内存与带宽。' }, { key: 'backupProgressEvery', group: 'backup', type: 'int', def: 3, min: 1, max: 50, unit: '页', label: '备份进度提示间隔', desc: '每走访这么多页弹出一次进度提示。第一页始终提示。' }, { key: 'autoPromoteRestored', group: 'backup', type: 'bool', def: true, label: '自动保存还原结果到备份', desc: '确认还原的信息与封面会自动转存到本地备份,不受缓存有效期影响,并计入备份管理与导出。' }, // ── 界面与调试 ── { key: 'mgrPageSize', group: 'ui', type: 'int', def: 20, min: 5, max: 100, unit: '条', label: '备份管理每页条数', desc: '备份管理面板一页列出多少条记录。调高会同时加载更多封面缩略图。' }, { key: 'mgrSearchDebounceMs', group: 'ui', type: 'int', def: 300, min: 50, max: 2000, unit: '毫秒', label: '备份管理搜索延迟', desc: '停止输入多久之后才执行过滤。' }, { key: 'tooltipRefreshMs', group: 'ui', type: 'int', def: 1000, min: 200, max: 10000, unit: '毫秒', label: '悬浮信息刷新间隔', desc: '鼠标停留在仍在重试的卡片上时,信息浮层的刷新频率。' }, { key: 'debug', group: 'ui', type: 'bool', def: false, gmKey: 'debug', label: '调试日志', desc: '在浏览器控制台输出详细过程日志。与菜单中的「开关调试日志」是同一个开关。', apply: function (v) { DEBUG = v; } } ]; // key → entry. A linear scan per cfg() call would be fine at this size, // but cfg() sits on the DOM-patch path and this index costs nothing. var _cfgIndex = (function () { var m = {}; for (var i = 0; i < SETTINGS_SCHEMA.length; i++) m[SETTINGS_SCHEMA[i].key] = SETTINGS_SCHEMA[i]; return m; })(); // key → resolved value. Populated lazily by cfg(); invalidated only by // cfgSet / cfgReset — nothing else may write it. var _cfgCache = {}; function cfgEntry(key) { return _cfgIndex[key] || null; } function cfgGmKey(e) { return e.gmKey || (SETTINGS_PREFIX + e.key); } // Parse + validate ONE candidate against its schema row. Returns // {ok:true, value} or {ok:false, error}. Feeds both user input (strings // out of the modal) and stored values (already typed), so every branch // has to accept the string form as well. function cfgCoerce(e, raw) { if (e.type === 'bool') { if (typeof raw === 'boolean') return { ok: true, value: raw }; if (raw === 'true' || raw === 1 || raw === '1') return { ok: true, value: true }; if (raw === 'false' || raw === 0 || raw === '0') return { ok: true, value: false }; return { ok: false, error: '必须是开或关' }; } if (e.type === 'int') { var s = String(raw).trim(); if (!s) return { ok: false, error: '不能为空' }; var n = Number(s); if (!isFinite(n)) return { ok: false, error: '必须是数字' }; if (Math.floor(n) !== n) return { ok: false, error: '必须是整数' }; if (n < e.min || n > e.max) return { ok: false, error: '取值范围 ' + e.min + ' – ' + e.max }; return { ok: true, value: n }; } if (e.type === 'intlist') { var parts = Array.isArray(raw) ? raw.slice() : String(raw).split(/[,,\s]+/).filter(function (x) { return x !== ''; }); if (parts.length < e.minLen || parts.length > e.maxLen) { return { ok: false, error: '需要 ' + e.minLen + ' – ' + e.maxLen + ' 个数值' }; } var out = []; for (var i = 0; i < parts.length; i++) { var v = Number(String(parts[i]).trim()); if (!isFinite(v) || Math.floor(v) !== v) { return { ok: false, error: '第 ' + (i + 1) + ' 项不是整数' }; } if (v < e.min || v > e.max) { return { ok: false, error: '第 ' + (i + 1) + ' 项超出范围 ' + e.min + ' – ' + e.max }; } // The ladder is indexed by a counter that only grows, so a dip // would make the script back off LESS the longer a failure // persists — the opposite of what a backoff is for. if (i && v < out[i - 1]) { return { ok: false, error: '第 ' + (i + 1) + ' 项小于前一项,必须非递减' }; } out.push(v); } return { ok: true, value: out }; } return { ok: false, error: '未知的设置类型' }; } // The live value. Never throws: an unreadable store, a value written by an // older version whose bounds have since narrowed, or a key dropped from the // schema all degrade to the default rather than taking a caller down with // them — every call site here is on a path the user is watching. function cfg(key) { if (Object.prototype.hasOwnProperty.call(_cfgCache, key)) return _cfgCache[key]; var e = cfgEntry(key); if (!e) { warn('cfg: unknown setting ' + key); return undefined; } var val = e.def; try { var raw = GM_getValue(cfgGmKey(e), null); if (raw !== null && raw !== undefined) { var r = cfgCoerce(e, raw); if (r.ok) val = r.value; else warn('cfg: stored value for ' + key + ' rejected (' + r.error + ') — using default'); } } catch (err) { warn('cfg: read failed for ' + key, err); } // Frozen because the memo hands the SAME array to every caller: an // accidental push / sort at one call site would otherwise rewrite the // setting for the rest of this page's life. if (Array.isArray(val)) Object.freeze(val); _cfgCache[key] = val; return val; } function cfgIsDefault(key) { var e = cfgEntry(key); if (!e) return true; var v = cfg(key); // Arrays compare by identity, and the stored one is a different object // from e.def even when it holds the same numbers. if (Array.isArray(e.def)) return String(v) === String(e.def); return v === e.def; } // Validate → persist → memoise → apply. A value equal to the default // DELETES its store entry instead of writing it, so "what has this user // actually changed" stays answerable, and a future change to a default // still reaches everyone who never touched that setting. function cfgSet(key, raw) { var e = cfgEntry(key); if (!e) return { ok: false, error: '未知设置项' }; var r = cfgCoerce(e, raw); if (!r.ok) return r; var isDef = Array.isArray(e.def) ? (String(r.value) === String(e.def)) : (r.value === e.def); try { if (isDef) GM_deleteValue(cfgGmKey(e)); else GM_setValue(cfgGmKey(e), r.value); } catch (err) { warn('cfg: write failed for ' + key, err); return { ok: false, error: '无法写入设置存储' }; } if (Array.isArray(r.value)) Object.freeze(r.value); _cfgCache[key] = r.value; // A throwing hook must not make the write look failed: the value IS // stored by this point, and saying otherwise would invite the user to // set it a second time. if (e.apply) { try { e.apply(r.value); } catch (err2) { warn('cfg: apply hook threw for ' + key, err2); } } return { ok: true, value: r.value }; } function cfgReset(key) { var e = cfgEntry(key); if (!e) return { ok: false, error: '未知设置项' }; return cfgSet(key, e.def); } // Resets one group, or everything when groupId is omitted. Returns how many // settings were actually carrying a non-default value, so a caller can tell // "已恢复 6 项" apart from "nothing to reset". function cfgResetAll(groupId) { var n = 0; for (var i = 0; i < SETTINGS_SCHEMA.length; i++) { var e = SETTINGS_SCHEMA[i]; if (groupId && e.group !== groupId) continue; if (cfgIsDefault(e.key)) continue; if (cfgReset(e.key).ok) n++; } return n; } // Every non-default setting — for the debug surface, and for the "what did // you change" question every bug report eventually needs answered. function cfgChanged() { var out = {}; for (var i = 0; i < SETTINGS_SCHEMA.length; i++) { var k = SETTINGS_SCHEMA[i].key; if (!cfgIsDefault(k)) out[k] = cfg(k); } return out; } // Run every `apply` hook once at boot, so a mirrored value (DEBUG) reflects // the store from the first line of the first patch rather than from the // first time someone happens to open the settings modal. function cfgBoot() { for (var i = 0; i < SETTINGS_SCHEMA.length; i++) { var e = SETTINGS_SCHEMA[i]; if (!e.apply) continue; try { e.apply(cfg(e.key)); } catch (err) { warn('cfg: boot apply threw for ' + e.key, err); } } } // ─── MD5 (RFC 1321, compact public-domain implementation) ─────────── // Source: blueimp-md5 (https://github.com/blueimp/JavaScript-MD5), // MIT-licensed. Minified inline so the core stays single-file. var md5 = (function () { function safeAdd(x, y) { var lsw = (x & 0xffff) + (y & 0xffff); var msw = (x >> 16) + (y >> 16) + (lsw >> 16); return (msw << 16) | (lsw & 0xffff); } function bitRol(num, cnt) { return (num << cnt) | (num >>> (32 - cnt)); } function md5cmn(q, a, b, x, s, t) { return safeAdd(bitRol(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b); } function md5ff(a, b, c, d, x, s, t) { return md5cmn((b & c) | (~b & d), a, b, x, s, t); } function md5gg(a, b, c, d, x, s, t) { return md5cmn((b & d) | (c & ~d), a, b, x, s, t); } function md5hh(a, b, c, d, x, s, t) { return md5cmn(b ^ c ^ d, a, b, x, s, t); } function md5ii(a, b, c, d, x, s, t) { return md5cmn(c ^ (b | ~d), a, b, x, s, t); } function binlMD5(x, len) { x[len >> 5] |= 0x80 << (len % 32); x[(((len + 64) >>> 9) << 4) + 14] = len; var a = 1732584193, b = -271733879, c = -1732584194, d = 271733878; for (var i = 0; i < x.length; i += 16) { var oa = a, ob = b, oc = c, od = d; a = md5ff(a, b, c, d, x[i], 7, -680876936); d = md5ff(d, a, b, c, x[i+1], 12, -389564586); c = md5ff(c, d, a, b, x[i+2], 17, 606105819); b = md5ff(b, c, d, a, x[i+3], 22, -1044525330); a = md5ff(a, b, c, d, x[i+4], 7, -176418897); d = md5ff(d, a, b, c, x[i+5], 12, 1200080426); c = md5ff(c, d, a, b, x[i+6], 17, -1473231341); b = md5ff(b, c, d, a, x[i+7], 22, -45705983); a = md5ff(a, b, c, d, x[i+8], 7, 1770035416); d = md5ff(d, a, b, c, x[i+9], 12, -1958414417); c = md5ff(c, d, a, b, x[i+10], 17, -42063); b = md5ff(b, c, d, a, x[i+11], 22, -1990404162); a = md5ff(a, b, c, d, x[i+12], 7, 1804603682); d = md5ff(d, a, b, c, x[i+13], 12, -40341101); c = md5ff(c, d, a, b, x[i+14], 17, -1502002290); b = md5ff(b, c, d, a, x[i+15], 22, 1236535329); a = md5gg(a, b, c, d, x[i+1], 5, -165796510); d = md5gg(d, a, b, c, x[i+6], 9, -1069501632); c = md5gg(c, d, a, b, x[i+11], 14, 643717713); b = md5gg(b, c, d, a, x[i], 20, -373897302); a = md5gg(a, b, c, d, x[i+5], 5, -701558691); d = md5gg(d, a, b, c, x[i+10], 9, 38016083); c = md5gg(c, d, a, b, x[i+15], 14, -660478335); b = md5gg(b, c, d, a, x[i+4], 20, -405537848); a = md5gg(a, b, c, d, x[i+9], 5, 568446438); d = md5gg(d, a, b, c, x[i+14], 9, -1019803690); c = md5gg(c, d, a, b, x[i+3], 14, -187363961); b = md5gg(b, c, d, a, x[i+8], 20, 1163531501); a = md5gg(a, b, c, d, x[i+13], 5, -1444681467); d = md5gg(d, a, b, c, x[i+2], 9, -51403784); c = md5gg(c, d, a, b, x[i+7], 14, 1735328473); b = md5gg(b, c, d, a, x[i+12], 20, -1926607734); a = md5hh(a, b, c, d, x[i+5], 4, -378558); d = md5hh(d, a, b, c, x[i+8], 11, -2022574463); c = md5hh(c, d, a, b, x[i+11], 16, 1839030562); b = md5hh(b, c, d, a, x[i+14], 23, -35309556); a = md5hh(a, b, c, d, x[i+1], 4, -1530992060); d = md5hh(d, a, b, c, x[i+4], 11, 1272893353); c = md5hh(c, d, a, b, x[i+7], 16, -155497632); b = md5hh(b, c, d, a, x[i+10], 23, -1094730640); a = md5hh(a, b, c, d, x[i+13], 4, 681279174); d = md5hh(d, a, b, c, x[i], 11, -358537222); c = md5hh(c, d, a, b, x[i+3], 16, -722521979); b = md5hh(b, c, d, a, x[i+6], 23, 76029189); a = md5hh(a, b, c, d, x[i+9], 4, -640364487); d = md5hh(d, a, b, c, x[i+12], 11, -421815835); c = md5hh(c, d, a, b, x[i+15], 16, 530742520); b = md5hh(b, c, d, a, x[i+2], 23, -995338651); a = md5ii(a, b, c, d, x[i], 6, -198630844); d = md5ii(d, a, b, c, x[i+7], 10, 1126891415); c = md5ii(c, d, a, b, x[i+14], 15, -1416354905); b = md5ii(b, c, d, a, x[i+5], 21, -57434055); a = md5ii(a, b, c, d, x[i+12], 6, 1700485571); d = md5ii(d, a, b, c, x[i+3], 10, -1894986606); c = md5ii(c, d, a, b, x[i+10], 15, -1051523); b = md5ii(b, c, d, a, x[i+1], 21, -2054922799); a = md5ii(a, b, c, d, x[i+8], 6, 1873313359); d = md5ii(d, a, b, c, x[i+15], 10, -30611744); c = md5ii(c, d, a, b, x[i+6], 15, -1560198380); b = md5ii(b, c, d, a, x[i+13], 21, 1309151649); a = md5ii(a, b, c, d, x[i+4], 6, -145523070); d = md5ii(d, a, b, c, x[i+11], 10, -1120210379); c = md5ii(c, d, a, b, x[i+2], 15, 718787259); b = md5ii(b, c, d, a, x[i+9], 21, -343485551); a = safeAdd(a, oa); b = safeAdd(b, ob); c = safeAdd(c, oc); d = safeAdd(d, od); } return [a, b, c, d]; } function binl2rstr(input) { var out = ''; for (var i = 0; i < input.length * 32; i += 8) { out += String.fromCharCode((input[i >> 5] >>> (i % 32)) & 0xff); } return out; } function rstr2binl(input) { var out = []; out[(input.length >> 2) - 1] = undefined; for (var i = 0; i < out.length; i += 1) out[i] = 0; for (var j = 0; j < input.length * 8; j += 8) { out[j >> 5] |= (input.charCodeAt(j / 8) & 0xff) << (j % 32); } return out; } function rstrMD5(s) { return binl2rstr(binlMD5(rstr2binl(s), s.length * 8)); } function rstr2hex(input) { var hexTab = '0123456789abcdef', out = '', x; for (var i = 0; i < input.length; i += 1) { x = input.charCodeAt(i); out += hexTab.charAt((x >>> 4) & 0x0f) + hexTab.charAt(x & 0x0f); } return out; } function str2rstrUTF8(input) { return unescape(encodeURIComponent(input)); } return function (s) { return rstr2hex(rstrMD5(str2rstrUTF8(s))); }; })(); // ─── Signing ──────────────────────────────────────────────────────── function signParams(params, appsec) { var keys = Object.keys(params).sort(); var q = keys.map(function (k) { return k + '=' + params[k]; }).join('&'); var out = {}; for (var i = 0; i < keys.length; i++) out[keys[i]] = params[keys[i]]; out.sign = md5(q + appsec); return out; } function toQuery(p) { return Object.keys(p).map(function (k) { return encodeURIComponent(k) + '=' + encodeURIComponent(p[k]); }).join('&'); } // ─── HTTP wrapper ────────────────────────────────────────────────── function gmGet(url, opts) { opts = opts || {}; var timeoutMs = opts.timeout || cfg('httpTimeoutMs'); // GM_xmlhttpRequest's `timeout` field is unreliable for connections // that stall mid-handshake (no FIN/RST ever sent — e.g. biliplus // when its server is overloaded). The `ontimeout` callback simply // never fires and the request hangs forever. Wrap with a client-side // Promise.race so caller-supplied budgets are HARD. Live request // continues in the background after we reject (no way to cancel // GM_xmlhttpRequest cleanly across all TM versions), but that's // acceptable for best-effort fallback sources. var underlying = new Promise(function (resolve, reject) { GM_xmlhttpRequest({ method: opts.method || 'GET', url: url, data: opts.data, headers: opts.headers || { 'User-Agent': 'Mozilla/5.0 BiliDroid/8.94.0' }, timeout: timeoutMs, onload: function (resp) { var text = resp.responseText; // Some responses arrive with no text body — an empty 204, // an opaque/blocked response, or a GM build that leaves // responseText undefined for certain statuses. Guard // BEFORE JSON.parse AND before building the error string: // the old code did `resp.responseText.slice(0, 200)` inside // the parse-failure catch, which itself threw a TypeError // when responseText was undefined. That throw escaped as an // *uncaught* error in GM's onload (reject was never reached), // so the promise never rejected — it hung until the // client-side guard fired timeoutMs+500 later. Turn it into // a clean rejection the caller's .catch already handles. if (typeof text !== 'string') { reject(new Error('empty/non-text response (status=' + resp.status + '): ' + url)); return; } try { resolve(JSON.parse(text)); } catch (e) { reject(new Error('JSON parse failed: ' + e.message + ' body=' + text.slice(0, 200))); } }, onerror: function () { reject(new Error('network error: ' + url)); }, ontimeout: function () { reject(new Error('timeout: ' + url)); } }); }); // Pad client-side guard by 500ms so the underlying GM timer wins // for legitimate timeouts (cleaner error message), and we only // catch the pathological stall case. Clear the guard once the race // settles so a successful request doesn't leave a live timer pending // for timeoutMs+500 (every call would otherwise leak one). var guardTimer = null; var guard = new Promise(function (_, rej) { guardTimer = setTimeout(function () { rej(new Error('client-side timeout (' + timeoutMs + 'ms+500): ' + url)); }, timeoutMs + 500); }); return Promise.race([underlying, guard]).finally(function () { if (guardTimer) clearTimeout(guardTimer); }); } // Binary sibling of gmGet, for the manual backup (15a-backup.js): pulls a // cover image straight into a Blob so it can be stored in IndexedDB. Kept // as a separate function rather than a flag on gmGet because gmGet's whole // contract is "resolves to parsed JSON" and every caller depends on it. // Same client-side Promise.race guard, same reason (GM's own `timeout` // never fires on a connection that stalls mid-handshake). function gmGetBlob(url, opts) { opts = opts || {}; var timeoutMs = opts.timeout || cfg('httpPostTimeoutMs'); var underlying = new Promise(function (resolve, reject) { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'blob', // hdslb serves covers without a Referer check today, but send // one anyway — a hotlink guard would otherwise turn every // backup into "封面失败 N". headers: opts.headers || { 'Referer': 'https://www.bilibili.com/' }, timeout: timeoutMs, onload: function (resp) { if (resp.status && (resp.status < 200 || resp.status >= 300)) { reject(new Error('HTTP ' + resp.status + ': ' + url)); return; } var b = resp.response; // Duck-typed, NOT `instanceof Blob`: the Blob is minted in // the GM sandbox realm, whose Blob constructor is not // necessarily the one visible here — instanceof can be // false for a perfectly good Blob. if (!b || typeof b.size !== 'number' || !b.size) { reject(new Error('empty/non-blob response (status=' + resp.status + '): ' + url)); return; } resolve(b); }, onerror: function () { reject(new Error('network error: ' + url)); }, ontimeout: function () { reject(new Error('timeout: ' + url)); } }); }); var guardTimer = null; var guard = new Promise(function (_, rej) { guardTimer = setTimeout(function () { rej(new Error('client-side timeout (' + timeoutMs + 'ms+500): ' + url)); }, timeoutMs + 500); }); return Promise.race([underlying, guard]).finally(function () { if (guardTimer) clearTimeout(guardTimer); }); } function gmPostForm(url, body) { return gmGet(url, { method: 'POST', data: body, headers: { 'User-Agent': 'Mozilla/5.0 BiliDroid/8.94.0', 'Content-Type': 'application/x-www-form-urlencoded' } }); } // ─── Auth storage ─────────────────────────────────────────────────── // Supports two modes: // mode='tv' → use access_key issued via TV QR login (TV appkey signing) // mode='android' → use a manually-pasted access_key + Android-main-app signing function getAuth() { return { mode: GM_getValue('auth_mode', 'tv'), access_key: GM_getValue('access_key', ''), ts: GM_getValue('access_key_ts', 0) }; } function setAuth(mode, ak) { GM_setValue('auth_mode', mode); GM_setValue('access_key', ak); GM_setValue('access_key_ts', Date.now()); } function clearAuth() { GM_deleteValue('access_key'); GM_deleteValue('access_key_ts'); // Also drop the mode so getAuth() falls back to its 'tv' default // after logout instead of retaining a stale 'android'/'tv' choice. GM_deleteValue('auth_mode'); } function appkeyFor(mode) { return mode === 'android' ? AND_APPKEY : TV_APPKEY; } function appsecFor(mode) { return mode === 'android' ? AND_APPSEC : TV_APPSEC; } // ─── TV QR login ──────────────────────────────────────────────────── function tvAuthCode() { var p = signParams({ appkey: TV_APPKEY, local_id: '0', ts: String(Math.floor(Date.now() / 1000)) }, TV_APPSEC); return gmPostForm('https://passport.bilibili.com/x/passport-tv-login/qrcode/auth_code', toQuery(p)); } function tvPoll(authCode) { var p = signParams({ appkey: TV_APPKEY, auth_code: authCode, local_id: '0', ts: String(Math.floor(Date.now() / 1000)) }, TV_APPSEC); return gmPostForm('https://passport.bilibili.com/x/passport-tv-login/qrcode/poll', toQuery(p)); } function showQrModal(loginUrl, onClose) { // Tiny modal. Uses api.qrserver.com to render the QR — bilibili // doesn't ship a JS QR encoder and pulling one would double the // file. qrserver.com has been live since ~2014; if it ever dies, // swap in a JS encoder (e.g. davidshimjs/qrcodejs). var qr = 'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=' + encodeURIComponent(loginUrl); var host = document.createElement('div'); host.id = '__fav_fix_qr_host'; host.style.cssText = [ 'position:fixed', 'inset:0', 'z-index:2147483647', 'display:flex', 'align-items:center', 'justify-content:center', 'background:rgba(0,0,0,.45)', 'font:14px/1.4 -apple-system,Segoe UI,sans-serif' ].join(';'); host.innerHTML = '' + '
' + '
扫码登录(TV 端授权)
' + '
请使用 bilibili 手机客户端的扫一扫功能
' + '' + '
等待扫描
' + '' + '
'; document.body.appendChild(host); host.querySelector('#__fav_fix_qr_close').addEventListener('click', function () { onClose('cancel'); }); return host; } function setQrStatus(host, text) { var el = host && host.querySelector('#__fav_fix_qr_status'); if (el) el.textContent = text; } function closeQr(host) { if (host && host.parentNode) host.parentNode.removeChild(host); } async function tvLogin() { toast('正在请求登录授权…'); var ac; try { ac = await tvAuthCode(); } catch (e) { toast('授权请求失败:' + e.message, 'err'); return; } if (ac.code !== 0 || !ac.data || !ac.data.auth_code || !ac.data.url) { toast('授权响应异常:错误码 ' + ac.code + ',' + ac.message, 'err'); return; } var host = null, done = false; var promise = new Promise(function (resolve) { host = showQrModal(ac.data.url, function (reason) { done = true; closeQr(host); resolve({ ok: false, reason: reason }); }); // Poll every 2s for up to 3 minutes. var deadline = Date.now() + 180000; (function tick() { if (done) return; if (Date.now() > deadline) { setQrStatus(host, '登录超时,请重新尝试'); setTimeout(function () { closeQr(host); resolve({ ok: false, reason: 'timeout' }); }, 1500); return; } tvPoll(ac.data.auth_code).then(function (r) { if (done) return; if (r.code === 0 && r.data && r.data.access_token) { done = true; setAuth('tv', r.data.access_token); setQrStatus(host, '登录成功'); setTimeout(function () { closeQr(host); resolve({ ok: true }); }, 800); return; } // 86039 = waiting for scan; 86090 = scanned, waiting for confirm; 86038 = expired if (r.code === 86090) setQrStatus(host, '已扫描,请在手机上确认登录'); else if (r.code === 86038) { setQrStatus(host, '二维码已过期,请重新尝试'); setTimeout(function () { closeQr(host); resolve({ ok: false, reason: 'expired' }); }, 1500); return; } setTimeout(tick, 2000); }).catch(function (e) { warn('poll failed', e); setTimeout(tick, 2000); }); })(); }); var res = await promise; if (res.ok) toast('登录成功,凭据已保存', 'ok'); else toast('登录已取消(' + res.reason + ')', 'warn'); } function manualLogin() { var ak = prompt('请输入 Android 主版 access_key\n(由 appkey ' + AND_APPKEY + ' 签发,对应 mobi_app=android)'); if (!ak || !ak.trim()) return; setAuth('android', ak.trim()); toast('凭据已保存(手动输入 / Android 模式)', 'ok'); } // ─── Source registry + resolver ───────────────────────────────────── // // Each source exposes: // name: short id used in priority maps // enabled: () => bool — whether we can call it right now // fetchPage({mediaId, pn}) → Promise<{list:[normItem], has_more}> // Each list item is normalized to the Android-app shape (oid is av, // upper={mid,name,face}, cnt_info={play,danmaku,thumb_up,coin, // reply,collect}, etc.) so the merge layer is field-name-stable. // // Merge layer: each field has a priority order (which source's value // to prefer) plus a QUALITY predicate (a value that fails the quality // check — e.g. the global placeholder cover URL — is skipped, falling // through to the next source). // // Cache: merged per-avid records are persisted in GM storage so a tab // reload doesn't re-call any API. In-memory per-page Promise cache // dedups concurrent requests within a session. // ─── Source failure gate (backoff) ───────────────────────────────── // Tracks consecutive timeout/network failures per source. After N hits // in a row, the source is considered "down" and `gate.isOpen(name)` // returns false for the cooldown window so callers can short-circuit. // State is intentionally process-memory only: a page reload resets the // gate so the user gets a fresh attempt without waiting out the cooldown. var sourceFailureGate = (function () { // Threshold and cooldown are read at FAILURE time, not captured here: // this IIFE runs once at load, and a value captured now could never // reflect a setting changed later in the same page. var failureCounts = {}; // src → consecutive fail count var openAt = {}; // src → Date.now() when re-enabled return { isOpen: function (src) { var until = openAt[src] || 0; if (until && Date.now() < until) return false; // Window expired — reset and try again. if (until && Date.now() >= until) { failureCounts[src] = 0; openAt[src] = 0; } return true; }, onFail: function (src, reason) { var backoffMs = cfg('sourceBackoffMin') * 60000; failureCounts[src] = (failureCounts[src] || 0) + 1; if (failureCounts[src] >= cfg('sourceFailThreshold') && !openAt[src]) { openAt[src] = Date.now() + backoffMs; console.warn('[fav-fix/' + src + '] gated for ' + (backoffMs / 60000) + ' min after ' + failureCounts[src] + ' consecutive failures (last: ' + reason + '). Reload the page to retry sooner.'); } }, onOk: function (src) { if (failureCounts[src] || openAt[src]) { console.info('[fav-fix/' + src + '] backoff gate reset after successful response'); } failureCounts[src] = 0; openAt[src] = 0; }, // For stats() inspection. snapshot: function () { var out = {}; Object.keys(failureCounts).forEach(function (s) { var until = openAt[s] || 0; out[s] = { consecutiveFailures: failureCounts[s], gatedUntilMs: until, gatedForSec: until ? Math.max(0, Math.round((until - Date.now()) / 1000)) : 0 }; }); return out; } }; })(); var SOURCES = { android: { name: 'android', paginated: true, enabled: function () { return !!getAuth().access_key; }, fetchPage: function (ctx) { var auth = getAuth(); var base = { media_id: String(ctx.mediaId), pn: String(ctx.pn), ps: '20', appkey: appkeyFor(auth.mode), access_key: auth.access_key, ts: String(Math.floor(Date.now() / 1000)), // Required-for-invalid-items hints (verified by bisect // against the captured Android-app traffic — dropping // any of these four causes the server to silently // filter invalid items out of the page response): mobi_app: 'android', platform: 'android', build: '8940300', disable_rcmd: '0', c_locale: 'en', s_locale: 'en', channel: 'bili', statistics: '{"appId":1,"platform":3,"version":"8.94.0","abtest":""}' }; var signed = signParams(base, appsecFor(auth.mode)); var url = 'https://api.bilibili.com/x/v3/fav/folder/resources?' + toQuery(signed); return gmGet(url).then(function (r) { if (r.code !== 0) throw new Error('android API code=' + r.code + ' msg=' + r.message); var d = r.data || {}; // Already in Android-app shape — pass through. return { list: d.list || [], has_more: !!d.has_more, total: d.total }; }); } }, 'public': { name: 'public', paginated: true, enabled: function () { return true; }, fetchPage: function (ctx) { // Try public spaceDetail (no auth, works for own public favs) // first, then fall back to fav/resource/list?platform=web // (uses SESSDATA cookie sent by GM_xmlhttpRequest, works for // own private favs). Both endpoints return placeholders for // invalid items' cover/title, but preserve pubtime / fav_time // / tid / pages / cnt_info — the fields the Android-app // endpoint omits. var qs = 'media_id=' + ctx.mediaId + '&pn=' + ctx.pn + '&ps=20' + '&keyword=&order=mtime&type=0&tid=0'; var publicUrl = 'https://api.bilibili.com/medialist/gateway/base/spaceDetail?' + qs + '&jsonp=jsonp'; var privateUrl = 'https://api.bilibili.com/x/v3/fav/resource/list?' + qs + '&platform=web'; return gmGet(publicUrl).then(function (r1) { if (r1.code === 0 && r1.data) return normalizePublicResp(r1.data); log('public spaceDetail code=', r1.code, '— falling back to private/web'); return gmGet(privateUrl).then(function (r2) { if (r2.code !== 0) throw new Error('public failed (public=' + r1.code + ' private=' + r2.code + ' msg=' + r2.message + ')'); return normalizePublicResp(r2.data); }); }); } }, // ─── Third-party archives ────────────────────────────────────── // Per-AV (not paginated). The resolver calls fetchAvs(avs) ONLY // for avs whose cover or title is still poor quality after the // paginated sources (android + public) finish — gates expensive // off-site calls. biliplus rate-limits with code -503 (retry-once); // jijidown answers a cold aid with a "loading" stub first and only // serves the real record on a follow-up poll (see its fetchAvs). // // (A third archive, xbeibeix, used to sit here. It was first dropped // from SOURCES when the site moved behind Cloudflare's interactive // Turnstile — GM_xmlhttpRequest only ever got the "Just a moment…" // page — and kept for a while as a manual link on the missing-item // banner, which a real browser could still clear. The site has since // stopped being maintained, so that link is gone too: every trace of // xbeibeix is out of this codebase. Do not re-add it.) // // Failure backoff (sourceFailureGate): if a source eats its full // per-request timeout (5s) on N consecutive chunks, we mark it // disabled for the whole cooldown window so we don't waste another 5s // per patch cycle. State is in-memory only — a TM page reload // resets and retries everything (typical "is it back yet" check). biliplus: { name: 'biliplus', paginated: false, enabled: function () { return cfg('enableBiliplus') && sourceFailureGate.isOpen('biliplus'); }, fetchAvs: async function (avs) { var out = new Map(); if (!avs.length) return out; // Always-on (not gated by debug mode): 3rd-party calls are // rare and the user often wants to see whether they fired. console.info('[fav-fix/biliplus] querying', avs.length, 'av(s):', avs.slice(0, 5).join(',') + (avs.length > 5 ? ',…' : '')); // Snapshotted for the whole sweep so the chunk boundaries and // the progress numbers below stay consistent with each other. var CHUNK = cfg('biliplusChunk'); // All 3rd-party archives (biliplus / jijidown) are // best-effort fallbacks; never let a slow archive hold // up patching the DOM. The per-request timeout is deliberately // shorter than gmGet's default, keeping the worst case bounded. var REQ_TIMEOUT = cfg('thirdPartyTimeoutMs'); var sawAnySuccess = false; var sawAnyFailure = false; for (var i = 0; i < avs.length; i += CHUNK) { var chunk = avs.slice(i, i + CHUNK); var url = 'https://www.biliplus.com/api/aidinfo?aid=' + chunk.join(','); var r; try { r = await gmGet(url, { timeout: REQ_TIMEOUT }); } catch (e) { console.warn('[fav-fix/biliplus] network error:', e.message); sawAnyFailure = true; continue; } if (r && r.code === -503) { console.warn('[fav-fix/biliplus] rate-limited (code -503), retrying in 2s'); await new Promise(function (res) { setTimeout(res, 2000); }); try { r = await gmGet(url, { timeout: REQ_TIMEOUT }); } catch (e) { sawAnyFailure = true; continue; } } if (!r || r.code !== 0 || !r.data) { console.warn('[fav-fix/biliplus] chunk failed: code=', r && r.code, 'message=', r && r.message); sawAnyFailure = true; continue; } sawAnySuccess = true; var chunkHits = 0; for (var av in r.data) { var info = r.data[av]; if (!info || !info.title) continue; out.set(String(av), { oid: Number(av), title: info.title, cover: info.pic, upper: info.author ? { name: info.author } : undefined }); chunkHits++; } console.info('[fav-fix/biliplus] chunk', i / CHUNK + 1, 'returned', chunkHits, 'of', chunk.length); } // Feed the backoff gate: any single successful response // resets the counter (server's back). If every chunk failed // (timeouts, -503, or malformed responses), count it as a // single failure event for this fetchAvs call. if (sawAnySuccess) sourceFailureGate.onOk('biliplus'); else if (sawAnyFailure) sourceFailureGate.onFail('biliplus', 'all chunks failed'); console.info('[fav-fix/biliplus] total:', out.size, '/', avs.length); return out; } }, jijidown: { name: 'jijidown', paginated: false, enabled: function () { return cfg('enableJijidown') && sourceFailureGate.isOpen('jijidown'); }, fetchAvs: async function (avs) { if (!avs.length) return new Map(); console.info('[fav-fix/jijidown] querying', avs.length, 'av(s) (sequential):', avs.slice(0, 5).join(',') + (avs.length > 5 ? ',…' : '')); var out = new Map(); // Per-av timeout — see the biliplus comment above. var REQ_TIMEOUT = cfg('thirdPartyTimeoutMs'); // get_info is two-phase: the FIRST hit for an aid jijidown // hasn't warmed returns a loading stub // ({code:0, msg:'loading', title:'正在加载数据...'} with NO // upid); the real record only lands a second or two later. A // single shot therefore drops every cold aid (upid undefined → // "no record"), which is most invalid items — the exact case // this source exists for. Re-poll the stub a few times before // giving up. The phase-2 budget in resolveItems still caps // total wall time, so a folder full of cold aids can't stall // the DOM patch. var LOADING_POLL_MS = cfg('jijidownPollMs'); var LOADING_MAX_POLLS = cfg('jijidownMaxPolls'); // 1 initial request + this many re-polls var sawAnyResponse = false; for (var i = 0; i < avs.length; i++) { var av = avs[i]; var url = 'https://www.jijidown.com/api/v1/video/get_info?id=' + av; var r = null; for (var attempt = 0; attempt <= LOADING_MAX_POLLS; attempt++) { try { r = await gmGet(url, { timeout: REQ_TIMEOUT }); } catch (e) { console.warn('[fav-fix/jijidown] av', av, 'network error:', e.message); r = null; break; } sawAnyResponse = true; if (r && (r.msg === 'loading' || r.title === '正在加载数据...')) { if (attempt < LOADING_MAX_POLLS) { await new Promise(function (res) { setTimeout(res, LOADING_POLL_MS); }); continue; // still warming up — re-poll } console.info('[fav-fix/jijidown] av', av, 'still loading after', LOADING_MAX_POLLS + 1, 'polls, skip'); r = null; } break; } if (!r) continue; if (!r.upid || r.upid <= 0) { console.info('[fav-fix/jijidown] av', av, 'no record (upid=' + r.upid + ')'); continue; } if (r.title === '视频去哪了呢?' || r.title === '该视频或许已经被删除了') { console.info('[fav-fix/jijidown] av', av, 'archive empty:', r.title); continue; } if (r.title === String(av) && !r.img) continue; out.set(String(av), { oid: Number(av), title: r.title, cover: r.img, upper: r.up && r.up.author ? { name: r.up.author, mid: r.upid } : undefined }); } if (sawAnyResponse) sourceFailureGate.onOk('jijidown'); else sourceFailureGate.onFail('jijidown', 'all requests errored'); console.info('[fav-fix/jijidown] total:', out.size, '/', avs.length); return out; } } }; function normalizePublicResp(d) { // Both spaceDetail and fav/resource/list return { medias: [...], info: {...}, has_more }. // medias[].id is the avid (vs Android's oid). Otherwise field // names line up closely; we rename id→oid and pass the rest through. var medias = d.medias || []; var list = medias.map(function (m) { var out = {}; for (var k in m) out[k] = m[k]; out.oid = m.id != null ? m.id : m.oid; return out; }); // folderTitle: the folder's own display name, carried by both public // endpoints in d.info.title. The backup walker persists it into the // meta store so the manager panel can label folders by name instead // of a raw media_id. Resolver callers simply ignore the field. return { list: list, has_more: !!d.has_more, total: d.info && d.info.media_count, folderTitle: (d.info && d.info.title) || null }; } // ─── Quality predicates ──────────────────────────────────────────── // // Returning 0 means "skip this value, try the next source for this field". var COVER_PLACEHOLDER_RE = /be27fd62/i; var QUALITY = { cover: function (url) { if (!url) return 0; if (COVER_PLACEHOLDER_RE.test(url)) return 0; if (/bfs\/archive/i.test(url)) return 10; return 5; }, title: function (t) { if (!t) return 0; t = String(t).trim(); if (t === '已失效视频' || t === '该视频已被删除' || t === '已失效') return 0; return 10; }, upper: function (u) { if (!u || typeof u !== 'object') return 0; if (!u.name || u.name === '账号已注销' || u.name === '账号已注销.') return 0; return 10; }, cnt_info: function (c) { if (!c || typeof c !== 'object') return 0; // Public endpoint sometimes returns all zeros for invalid items; // Android-app endpoint preserves the snapshot. Prefer whichever // has more non-zero fields (handled by priority + this check). var nonzero = 0; for (var k in c) if (c[k] && typeof c[k] === 'number') nonzero++; return nonzero > 0 ? 10 : 1; }, 'default': function (v) { if (v == null) return 0; if (typeof v === 'string' && !v.trim()) return 0; if (Array.isArray(v) && v.length === 0) return 0; return 10; } }; // Priority order: source name LEFT wins if its value passes QUALITY. // `backup` (15a-backup.js) leads every field it supplies: it is the only // source captured while the video was still ALIVE, so its snapshot beats // any post-mortem one by construction. It is absent from attr / link / // playback_desc on purpose — those describe the item's CURRENT state and // must come from a live source. // 3rd-party archives (biliplus / jijidown) carry only title/cover/ // upper.name — they're the last-resort fallback for items even the // Android-app snapshot couldn't save. var FIELD_PRIORITY = { // Android endpoint preserves invalid-item snapshots for these. cover: ['backup', 'android', 'public', 'biliplus', 'jijidown'], title: ['backup', 'android', 'public', 'biliplus', 'jijidown'], upper: ['backup', 'android', 'public', 'biliplus', 'jijidown'], intro: ['backup', 'android', 'public'], duration: ['backup', 'android', 'public'], playback_desc: ['android', 'public'], attr: ['android', 'public'], link: ['android', 'public'], bvid: ['backup', 'public', 'android'], // Public endpoint has these; Android omits them for invalid items: cnt_info: ['backup', 'public', 'android'], pubtime: ['backup', 'public'], ctime: ['backup', 'public', 'android'], fav_time: ['backup', 'public'], tid: ['backup', 'public'], pages: ['backup', 'public'], page: ['backup', 'public', 'android'] }; function mergeBySource(perSource) { var out = {}; var srcs = Object.keys(SOURCES); // Field-priority merge for (var field in FIELD_PRIORITY) { var order = FIELD_PRIORITY[field]; for (var i = 0; i < order.length; i++) { var src = order[i]; var data = perSource[src]; if (!data) continue; var v = data[field]; var q = (QUALITY[field] || QUALITY['default'])(v); if (q > 0) { out[field] = v; out['_src_' + field] = src; break; } } } // Pass-through for any field not covered by FIELD_PRIORITY (e.g. // oid, otype, ugc, card_type, jump_link…). First source that has // the field wins (Android > public by registry order). for (var s = 0; s < srcs.length; s++) { var d = perSource[srcs[s]]; if (!d) continue; for (var k in d) { if (out[k] !== undefined) continue; if (FIELD_PRIORITY[k]) continue; out[k] = d[k]; } } out._sources = Object.keys(perSource); // Degenerate = neither cover nor title passed QUALITY from any source. // Card will render visually unchanged (placeholder cover + "已失效视频" // text) but with the red marker — easy to confuse with a permanent // 404. Flagged so loadCache can use a short TTL: the most common // cause is Android API's walk-to-walk drop (server returns the av // on some walks, not others), and a fresh walk in ~10 min often // recovers it. Without the short TTL, one bad walk locks in 30 days. if (!out._src_cover && !out._src_title) out._degenerate = true; return out; } // Shared promotion predicate: is this merged record a CONFIDENT full // recovery — one whose cover AND title are real, settled data? Used by // the backup walker's merged-fallback gate (15a buildBackupRecord) and by // the recovery-time promotion pipeline (15e-promote.js), so the two can // never drift apart on what "confident" means. The _degenerate / _pending // / _cover_pending exclusions are implied by requiring both _src_* fields // (mergeBySource only sets those when QUALITY passed), and the QUALITY // re-checks are likewise redundant — stated anyway as belt-and-braces, // because a record passing this gate may be written into the backup store // where a bad value would be served back as gospel forever. function promotionGate(m) { return !!m && !m._degenerate && !m._pending && !m._cover_pending && !!m._src_cover && !!m._src_title && QUALITY.cover(m.cover) >= 5 && QUALITY.title(m.title) >= 10; } // ─── Persistent per-avid cache ────────────────────────────────────── // // CACHE_VERSION exists so that adding a new source (or changing merge // semantics) can invalidate every cached entry at once — otherwise // items cached before the new source was added would never get the // chance to be re-fetched from it. // // BUMP CACHE_VERSION when: // - adding a new SOURCES entry // - changing FIELD_PRIORITY / QUALITY in a way that affects merge // - changing the merged-item shape (renaming fields etc.) var CACHE_PREFIX = 'item:av'; var CACHE_VERSION = 7; // bumped: SOURCES.backup now offers a cover only when it holds the bytes (byteless records fall to _cover_pending instead of winning the cover slot with a dead url) // → cfg('cacheTtlDays'), default 30. // Short TTL for NOT-confidently-recovered merges (_degenerate / _pending). // This is a STALENESS guard, NOT a retry timer: live retry is owned wholly // by the background runFlapRecovery loop (08-resolver.js), which re-walks // android on its own backoff and re-patches in place. The short TTL only // governs what a FUTURE fresh resolve (a reload / folder-switch minutes or // hours later) does — after it expires, that fresh resolve re-fetches and // re-kicks the loop instead of reusing a stale "still deleted" snapshot. // A 30-day lock-in would instead turn one bad android walk into a permanent // failure, so these stay short. // → cfg('cacheTtlDegenerateMin'), default 10. function loadCache(av) { var v = GM_getValue(CACHE_PREFIX + av, null); if (!v) return null; if (v._cache_version !== CACHE_VERSION) { log('cache av', av, 'version', v._cache_version, '!=', CACHE_VERSION, '— invalidating'); return null; } // Short TTL for any NOT-confidently-recovered entry: // _degenerate — some source returned only placeholders, or // _pending — android may still flap the real snapshot back in // (see runFlapRecovery in 08-resolver.js), or // _cover_pending — the title was recovered but the cover is still a // placeholder; the card is patched, yet the flap // loop is still chasing the image. Long-TTL locking // here is what a title-only LOCAL BACKUP record // (which never expires) would otherwise impose on // every future resolve of that av. // Locking these for 30 days would turn a transient android walk-to-walk // drop into a permanent "deleted" (observed: a war-footage folder where // android returned 58/89 on one walk and the dropped items all fell to // _no_source). Only a confidently-recovered merge gets the long TTL. var ttl = (v._degenerate || v._pending || v._cover_pending) ? cfg('cacheTtlDegenerateMin') * 60000 : cfg('cacheTtlDays') * 86400000; if (v._cached_at && (Date.now() - v._cached_at > ttl)) return null; return v; } // loadCache without the staleness test. The entry is past its TTL, but it // is still the best thing this script knows about the av — and for an av on // the 停止重试 list that is the whole story, because no network work will be // scheduled to learn anything better. The resolver uses it there instead of // overwriting a title-bearing merge with a bare _pending stub (08-resolver.js // merge block). Deliberately does NOT refresh _cached_at: the entry has to // stay stale so the first resolve after the suppression lapses re-fetches. function loadCacheStale(av) { var v = GM_getValue(CACHE_PREFIX + av, null); if (!v) return null; if (v._cache_version !== CACHE_VERSION) return null; return v; } function saveCache(av, merged) { merged._cache_version = CACHE_VERSION; merged._cached_at = Date.now(); try { GM_setValue(CACHE_PREFIX + av, merged); } catch (e) { warn('saveCache failed for av', av, e); } } // Note for anyone extending the clearing paths below: they clear DERIVED // data (re-fetchable merges) only. The IndexedDB backup store // (15a-backup.js) is user-authored data with no upstream to re-fetch from // and must NEVER be dropped here. function clearItemCache(av) { GM_deleteValue(CACHE_PREFIX + av); } function clearAllItemCache() { if (typeof GM_listValues !== 'function') { warn('GM_listValues not granted — bulk clear unavailable'); return -1; } var n = 0; GM_listValues().forEach(function (k) { if (k.indexOf(CACHE_PREFIX) === 0) { GM_deleteValue(k); n++; } }); return n; } // ─── "Stop retrying" list (user decision, deliberately NOT cache) ──── // // Why this is its own GM prefix instead of a field on the item cache: // the item cache (07-cache.js, `item:av…`) holds DERIVED data — every entry // is re-fetchable, carries a TTL, and the "清除所有缓存" menu command wipes // the lot. A user's decision to stop retrying a video is none of those // things: it has to outlive the cache, survive a cache purge, and take // effect the instant the badge is clicked instead of when the snapshot next // expires. So it lives under its own prefix, is held in memory as a Set/Map // for O(1) render-time lookups, and is read LIVE everywhere — the cached // merge shape and CACHE_VERSION are untouched by this feature. // // Two modes, at most one record per av: // 'user' — the user pressed 停止重试. Never expires. // 'auto' — runFlapRecovery genuinely gave up on this av. Expires after // the auto TTL, so a folder that merely flapped badly for // one afternoon is not written off permanently. // 'user' overwrites 'auto'; 'auto' must NEVER overwrite 'user' (that would // silently downgrade a permanent decision into one expiring in a week). // // Which predicate to use where: // isRetrySuppressed — every AUTOMATIC path (does resolveItems walk pages, // does the background loop arm itself). Honours both // modes. // isNoRetryUser — everything the user explicitly triggered (manual // retry, the loop's in-flight pruning). Honours the // 'user' mode only, because a manual retry means // "ignore what the loop concluded and sample again". // // No new GM_* API is introduced: GM_getValue / GM_setValue / GM_deleteValue // / GM_listValues are all already in the bootstrap @grant block. In // particular GM_addValueChangeListener is NOT granted and the bootstrap // @version is frozen at 1.0.0 (it is a contract — see AGENTS.md), so this // list does NOT sync across tabs: a stop pressed in tab A is visible in // tab B only after a reload. That limit is documented in README 已知限制; // do not try to work around it here. var NORETRY_PREFIX = 'noretry:av'; // → cfg('autoNoRetryTtlDays'), default 7. Read through autoNoRetryTtlMs() // rather than inlined, because three call sites need the same conversion. function autoNoRetryTtlMs() { return cfg('autoNoRetryTtlDays') * 86400000; } var _noRetryUser = new Map(); // av → ms the user pressed stop (permanent) var _noRetryAuto = new Map(); // av → ms the loop gave up (7-day life) var _noRetryLoaded = false; // Build the in-memory index from GM storage once per page load, dropping // auto records that have aged out on the way through so the store cannot // grow without bound. Idempotent via _noRetryLoaded: boot() calls it once, // and every accessor below calls it too, so a future change to boot order // can never leave the list silently empty. function loadNoRetryIndex() { if (_noRetryLoaded) return; _noRetryLoaded = true; if (typeof GM_listValues !== 'function') { // Same degradation as clearAllItemCache: warn and carry on with an // empty list. Losing the feature is acceptable; throwing here would // take boot() — and with it the whole resolve path — down with it. warn('GM_listValues not granted — 停止重试 list unavailable'); return; } var now = Date.now(), expired = 0; GM_listValues().forEach(function (k) { if (k.indexOf(NORETRY_PREFIX) !== 0) return; var av = k.slice(NORETRY_PREFIX.length); var v = GM_getValue(k, null); // Unreadable / shapeless record: drop it rather than guessing a // mode, otherwise a corrupt entry suppresses an av forever with no // way for the UI to show or clear it. if (!v || (v.mode !== 'user' && v.mode !== 'auto')) { GM_deleteValue(k); return; } if (v.mode === 'user') { _noRetryUser.set(av, v.at || 0); return; } if (now - (v.at || 0) > autoNoRetryTtlMs()) { GM_deleteValue(k); expired++; return; } _noRetryAuto.set(av, v.at || 0); }); log('noretry: loaded', _noRetryUser.size, 'user +', _noRetryAuto.size, 'auto record(s)' + (expired ? ' (' + expired + ' expired auto dropped)' : '')); } // Manual, permanent stop. The card menu and the cover badge both ask this. function isNoRetryUser(av) { loadNoRetryIndex(); return _noRetryUser.has(String(av)); } // When the user stopped this av, in ms — the tooltip prints it as // 「已于 YYYY-MM-DD 停止」. null when the av is not on the user list, or // when the record predates the timestamp being written. function noRetryUserAt(av) { loadNoRetryIndex(); return _noRetryUser.get(String(av)) || null; } // The gate for automatic network work: a manual stop, or an auto record the // loop wrote less than autoNoRetryTtlMs() ago. Expired auto records are // cleared on the spot so the storage entry disappears with the suppression. function isRetrySuppressed(av) { loadNoRetryIndex(); av = String(av); if (_noRetryUser.has(av)) return true; var at = _noRetryAuto.get(av); if (at == null) return false; if (Date.now() - at > autoNoRetryTtlMs()) { clearNoRetry(av); return false; } return true; } function setNoRetryUser(av) { loadNoRetryIndex(); av = String(av); var at = Date.now(); _noRetryUser.set(av, at); _noRetryAuto.delete(av); // one record per av: user supersedes auto try { GM_setValue(NORETRY_PREFIX + av, { at: at, mode: 'user' }); } catch (e) { warn('setNoRetryUser failed for av', av, e); } } // Written by runFlapRecovery when it truly gives up (see its finally). A // pre-existing user record outranks it and must survive untouched. function markAutoNoRetry(av) { loadNoRetryIndex(); av = String(av); if (_noRetryUser.has(av)) return; var at = Date.now(); _noRetryAuto.set(av, at); try { GM_setValue(NORETRY_PREFIX + av, { at: at, mode: 'auto' }); } catch (e) { warn('markAutoNoRetry failed for av', av, e); } } function clearNoRetry(av) { loadNoRetryIndex(); av = String(av); _noRetryUser.delete(av); _noRetryAuto.delete(av); GM_deleteValue(NORETRY_PREFIX + av); } // Returns what was cleared, so the menu command can report both modes. function clearAllNoRetry() { loadNoRetryIndex(); var counts = { user: _noRetryUser.size, auto: _noRetryAuto.size }; _noRetryUser.forEach(function (at, av) { GM_deleteValue(NORETRY_PREFIX + av); }); _noRetryAuto.forEach(function (at, av) { GM_deleteValue(NORETRY_PREFIX + av); }); _noRetryUser.clear(); _noRetryAuto.clear(); return counts; } function noRetryCounts() { loadNoRetryIndex(); return { user: _noRetryUser.size, auto: _noRetryAuto.size }; } // Flat dump for the debug surface (__biliFavFix.noRetry.list()). function noRetryList() { loadNoRetryIndex(); var out = []; _noRetryUser.forEach(function (at, av) { out.push({ av: av, mode: 'user', at: at, when: at ? new Date(at).toLocaleString() : null }); }); _noRetryAuto.forEach(function (at, av) { out.push({ av: av, mode: 'auto', at: at, when: at ? new Date(at).toLocaleString() : null, expiresAt: at ? new Date(at + autoNoRetryTtlMs()).toLocaleString() : null }); }); return out; } // ─── Resolver ─────────────────────────────────────────────────────── var pageCache = new Map(); // key=`${src}|${mediaId}|${pn}` → Promise var pageItems = new Map(); // key=`${src}|${oid}` → raw item from that source // Drop EVERY cache layer for a single av so the next patchOnce truly // re-fetches it from the network: // - persistent GM item (clearItemCache) // - in-memory raw rows for ALL sources (pageItems) // - the paginated page-promise cache (pageCache) — otherwise ensurePage // hands back the resolved page whose list ALREADY dropped this av, so // the merge re-runs on stale rows and re-saves identical data. // Shared by the card menu "清缓存并重抓" and the forceRefetch() debug API // so both paths behave identically. function dropItemCaches(av) { clearItemCache(av); Object.keys(SOURCES).forEach(function (src) { pageItems.delete(src + '|' + av); }); pageCache.clear(); } function ensurePage(src, mediaId, pn) { var key = src + '|' + mediaId + '|' + pn; if (pageCache.has(key)) return pageCache.get(key); var p = SOURCES[src].fetchPage({ mediaId: mediaId, pn: pn }).then(function (page) { (page.list || []).forEach(function (it) { if (it && it.oid != null) pageItems.set(src + '|' + it.oid, it); }); log(src, 'page', pn, '→', (page.list || []).length, 'items has_more=', page.has_more); return page; }); pageCache.set(key, p); // On error, evict so the next attempt can retry. p.catch(function () { pageCache.delete(key); }); return p; } // True iff any source already gave us a passable cover AND title for // this av — used to gate expensive 3rd-party calls in phase 2. function hasGoodCoverAndTitle(av) { var srcs = Object.keys(SOURCES); var hasCover = false, hasTitle = false; for (var s = 0; s < srcs.length; s++) { var item = pageItems.get(srcs[s] + '|' + av); if (!item) continue; if (QUALITY.cover(item.cover) >= 5) hasCover = true; if (QUALITY.title(item.title) >= 10) hasTitle = true; } return hasCover && hasTitle; } async function resolveItems(avs, mediaId) { var result = new Map(); // av → merged item var todoAvs = []; for (var i = 0; i < avs.length; i++) { var c = loadCache(avs[i]); if (c) result.set(avs[i], c); else todoAvs.push(avs[i]); } if (todoAvs.length === 0) { log('all', avs.length, 'avs satisfied by cache'); return result; } log('todo', todoAvs.length, 'of', avs.length, '(cache hit', avs.length - todoAvs.length + ')'); // Avs the "停止重试" list (07a-noretry.js) still allows network work for. // Both modes count here because this is the AUTOMATIC path. Everything // below that costs a request is scoped to activeTodo; todoAvs keeps its // full membership for phase 0 and for the merge, because neither of // those touches the network and a suppressed av must still be // classified (and restored from a local backup) exactly as before. var activeTodo = todoAvs.filter(function (av) { return !isRetrySuppressed(av); }); if (activeTodo.length !== todoAvs.length) { log('noretry: ' + (todoAvs.length - activeTodo.length) + ' of ' + todoAvs.length + ' todo av(s) suppressed — no network retry for them this pass'); } var srcOrder = Object.keys(SOURCES); // Track which sources (paginated AND per-av) were attempted per av. // Tooltip uses this for "已查询但无记录" so the user can see "we // asked android + public but they had no record" instead of those // sources silently vanishing from the UI. Defined here (not in // phase 2 like before) because phase 1 also writes to it. var attemptedPerAv = new Map(); // av → Set function markAttempted(av, src) { if (!attemptedPerAv.has(av)) attemptedPerAv.set(av, new Set()); attemptedPerAv.get(av).add(src); } // ─── Phase 0: local backup (IndexedDB) ─────────────────────── // The manual backup (15a-backup.js) is the only source that captured // the item while it was still ALIVE, so it runs before anything on the // network and leads FIELD_PRIORITY. A hit here cascades: the av passes // hasGoodCoverAndTitle, so phase 2 never spends budget on third-party // archives for it, and the merge is neither _degenerate nor _pending, // so the background flap loop never chases it. Every todoAv is marked // attempted — a miss is a real "已查询但无记录" answer, exactly like a // paginated source whose page didn't list the av. A broken or blocked // IDB must never take the resolver down: warn and fall through to the // network sources. if (SOURCES.backup && SOURCES.backup.enabled()) { todoAvs.forEach(function (av) { markAttempted(av, 'backup'); }); try { var backupHits = await SOURCES.backup.fetchAvs(todoAvs); backupHits.forEach(function (item, av) { pageItems.set('backup|' + av, item); }); if (backupHits.size) log('phase 0: backup covered', backupHits.size, 'of', todoAvs.length); } catch (e) { warn('phase 0 backup lookup failed:', e && e.message); } } // ─── Phase 1: paginated sources (android, public) ──────────── // Walk pages of each enabled paginated source until every ACTIVE todoAv // appears or we hit MAX_PN. Page Promises are deduped by ensurePage. // // The whole phase is skipped when every todo av is suppressed — this is // the feature's main throttle: re-entering a folder whose invalid items // were all abandoned must not fire a single request. attemptedPerAv is // deliberately left alone for suppressed avs (nothing was queried, so // claiming "已查询但无记录" would be a lie), and the walk's termination // test must read activeTodo too: with todoAvs it would never see // allFound for a suppressed av and would run the full page-walk cap. if (!activeTodo.length && todoAvs.length) { log('phase 1 skipped — all', todoAvs.length, 'todo av(s) on the 停止重试 list'); } for (var s = 0; activeTodo.length && s < srcOrder.length; s++) { var src = srcOrder[s]; var def = SOURCES[src]; if (!def.enabled()) { log(src, 'disabled, skip'); continue; } if (!def.paginated) continue; // Mark every active todoAv as attempted up-front: paginated sources // fetch the entire page, so each av is implicitly looked up // whether or not the response actually contains it. "Got no // record" = the av wasn't in the response. activeTodo.forEach(function (av) { markAttempted(av, src); }); var pn = 1, MAX_PN = cfg('maxPageWalk'); while (pn <= MAX_PN) { var allFound = activeTodo.every(function (av) { return pageItems.has(src + '|' + av); }); if (allFound) break; var page; try { page = await ensurePage(src, mediaId, pn); } catch (e) { warn(src + ' page ' + pn + ' failed:', e.message); break; } if (!page.has_more) break; pn++; } } // ─── Phase 1.5 (REMOVED — now background) ────────────────────── // The android fav endpoint is eventually-consistent (~5% of invalid // items flap in/out per walk; deactivated-account items 7.6x over- // represented — see the FLAP_* block in 01-constants.js for the data). // This used to do ONE synchronous extra android walk here, which (a) // only recovered the easy half of the flappers and (b) froze every // loading spinner for its ~5-8s. It is now a single self-driving // BACKGROUND loop (runFlapRecovery, kicked off at the end of this // function) that re-walks android on an adaptive backoff and re-patches // recovered cards in place — so first paint is never blocked and the // stubborn subset gets the multiple samples it statistically needs. // Phase 2 (3rd-party) still runs synchronously below so first paint has // a best-available cover. // ─── Phase 2: per-av sources (biliplus, jijidown) ──────────── // Only query 3rd-party archives for avs whose cover OR title is // still bad after phase 1. Skip entirely if everything is satisfied. // attemptedPerAv already tracks phase 1; phase 2 adds 3rd-party // sources to the same map so the tooltip sees the union. // Overall budget for ALL phase-2 sources combined. Per-request // timeouts (5s) bound a single chunk; this bounds the total wall // time so a single dead archive can't postpone the DOM patch. // If we blow the budget, abort the loop and merge with what we have. var PHASE2_BUDGET_MS = cfg('phase2BudgetMs'); var phase2Deadline = Date.now() + PHASE2_BUDGET_MS; for (var s = 0; s < srcOrder.length; s++) { var src = srcOrder[s]; var def = SOURCES[src]; if (def.paginated) continue; if (src === 'backup') continue; // local, already queried in phase 0 if (!def.enabled()) { // Most commonly this means the source is in the backoff // window (sourceFailureGate.isOpen returned false). Loud // log so the user understands why a source was skipped // without diving into the cache state. console.info('[fav-fix] phase 2 skip', src, '(disabled / in backoff window)'); continue; } if (Date.now() > phase2Deadline) { warn('phase 2 budget exhausted, skipping remaining sources (e.g. ' + src + ')'); break; } // activeTodo, not todoAvs: a suppressed av must not cost a // third-party request either. Its merge still lands as _pending — // the classification describes the DATA, not our intent to chase it. var needed = activeTodo.filter(function (av) { return !hasGoodCoverAndTitle(av); }); if (needed.length === 0) { log(src, 'all avs already good, skip'); continue; } needed.forEach(function (av) { markAttempted(av, src); }); // Race fetchAvs against the remaining budget so even an // ill-behaved source (no per-request timeout, infinite redirect, // etc.) cannot push us past the deadline. var remaining = Math.max(1000, phase2Deadline - Date.now()); try { var batch = await Promise.race([ def.fetchAvs(needed), new Promise(function (_, rej) { setTimeout(function () { rej(new Error('budget exceeded (' + remaining + 'ms)')); }, remaining); }) ]); batch.forEach(function (item, av) { pageItems.set(src + '|' + av, item); }); } catch (e) { warn(src + ' fetchAvs failed:', e.message); } } // ─── Merge ───────────────────────────────────────────────────── // Terminal-vs-retriable classification (the crux of android flap // handling): when android is UP, an item that came back with no usable // cover/title THIS pass is NOT terminal. android's fav endpoint is // eventually-consistent and, for some folders, badly so (observed: a // war-footage folder returned 58/89 on one walk; `public` returns ZERO // invalid snapshots for it, so android is the only source). An item // android flapped out this pass therefore lands as either: // - _no_source (no source had it — public doesn't carry invalids here) // - _degenerate (only placeholders) // Either way android may return its real snapshot on a LATER walk, so we // flag it `_pending` (short TTL, card kept in the native "已失效视频" // state = re-detectable, no terminal "(视频已删除)") and let // runFlapRecovery chase it across several union walks. Only when android // is DOWN (no access_key → no retry possible) do we write the terminal // _no_source stub ("(视频已删除)", long TTL). var androidUp = SOURCES.android.enabled(); todoAvs.forEach(function (av) { var perSource = {}; for (var s2 = 0; s2 < srcOrder.length; s2++) { var item = pageItems.get(srcOrder[s2] + '|' + av); if (item) perSource[srcOrder[s2]] = item; } var attempted = attemptedPerAv.get(av); var rec; if (Object.keys(perSource).length === 0) { // No source returned this av this pass. For a SUPPRESSED av // that is not a finding: phase 1 was skipped and phase 2 never // asked for it, so an empty perSource means "we did not look", // not "nobody has it". Writing the bare _pending stub below // would overwrite the previous merge — typically a // _cover_pending record whose title / UP / date are already // patched onto the card — and nothing would ever restore it, // because every automatic path stays switched off until the // record expires (each later visit would rewrite the same // stub). Serve the stored merge past its staleness TTL instead // and leave storage untouched. This is the hazard 11-menu.js // already guards on the manual path by clearing the record // before 清缓存并重抓; the TTL-expiry path needs it too. if (isRetrySuppressed(av)) { var kept = loadCacheStale(av); if (kept) { log('av', av, 'suppressed and not queried this pass — keeping the stored merge' + ' instead of overwriting it with a pending stub'); result.set(av, kept); return; } } rec = { oid: Number(av), // _attempted: union of every source that queried this av // (phase 1 paginated + phase 2 per-av). Tooltip "已查询 // 但无记录" reads this; old entries used _attempted_3rd. _attempted: attempted ? Array.from(attempted) : [], _tried_sources: srcOrder.slice() }; if (androidUp) { rec._pending = true; // retriable via background android re-walk log('av', av, 'not found this pass — pending (android may flap it back)'); } else { rec._no_source = true; // terminal: no android to retry with log('av', av, 'NOT FOUND in any source — caching stub (android down)'); } } else { rec = mergeBySource(perSource); if (attempted) rec._attempted = Array.from(attempted); // Degenerate (placeholders only) but android could still recover // it on a later walk → keep retriable instead of stuck. if (rec._degenerate && androidUp) rec._pending = true; // Title recovered, cover still missing / a placeholder. NOT // _pending — the title is real and belongs on the card right // now — but not settled either: android flaps covers exactly as // it flaps whole rows. Flag it so loadCache keeps the short // staleness TTL and the background loop keeps sampling for the // image (holding out for a COVER, not retiring on the title it // already has). Without this, a title-only merge locks the card // to its placeholder cover for 30 days — and a title-only local // BACKUP record, which never expires, would re-impose that lock // on every later resolve, permanently disabling the only retry // path the system has. else if (androidUp && rec._src_title && !rec._src_cover) rec._cover_pending = true; log('av', av, 'merged from {' + Object.keys(perSource).join(',') + '}', attempted ? '(attempted: ' + Array.from(attempted).join(',') + ')' : '', '→', 'cover=' + (rec._src_cover || '·'), 'title=' + (rec._src_title || '·'), 'upper=' + (rec._src_upper || '·'), 'cnt=' + (rec._src_cnt_info || '·'), 'dates=' + (rec._src_pubtime || rec._src_fav_time || '·'), rec._pending ? '[pending]' : ''); } saveCache(av, rec); // Recovery-time promotion into the backup store (15e-promote.js). // Deliberately condition-free here: the classifier no-ops for // stubs / degenerate merges / backup-sourced merges, and the // in-store value-compare makes re-promotion free. Covers the // recoveries that land confidently on the FIRST resolve (never // _pending, so the flap loop's hook below would miss them) and // stamps title-only merges as 'restored_meta'. maybePromoteRecovered(av, rec, mediaId); result.set(av, rec); }); // ─── Background: recover stubborn android flappers ─────────────── // (replaces the old synchronous phase 1.5; see runFlapRecovery below) // Candidates = every item still `_pending` after phase 1 + phase 2: // android either flapped it out entirely (_no_source-would-have-been) // or only placeholders came back. android's per-walk flapping means // more independent UNION walks materially raise the hit rate. Fire-and- // forget: the caller paints from `result` immediately; recovered cards // are re-patched in place as each walk lands (they stay "已失效视频" so // findInvalidContainers keeps finding them until upgraded). // `_cover_pending` avs join the same loop but with a stricter promotion // test (they already have a title; only a cover retires them). if (androidUp) { var bgCandidates = []; var bgCoverOnly = []; todoAvs.forEach(function (av) { var m = result.get(av); if (!m) return; // Suppressed avs keep their _pending / _cover_pending marker but // must not arm the loop — otherwise "停止重试" would stop the // page walk and still spend four minutes sampling android. if (isRetrySuppressed(av)) return; if (m._pending) bgCandidates.push(av); else if (m._cover_pending) { bgCandidates.push(av); bgCoverOnly.push(av); } }); if (bgCandidates.length) { runFlapRecovery(mediaId, bgCandidates, bgCoverOnly) .catch(function (e) { warn('flap-bg threw:', e && e.message); }); } } return result; } // ─── Background android flap recovery (single self-driving loop) ───── // THE retry mechanism. One loop owns a folder's whole retry lifecycle: // re-walks the android fav endpoint, UNIONing each fresh server sample into // pageItems, until every candidate recovers or it gives up. Each walk is a // new chance to catch an item the previous walk's eventually-consistent // filtering dropped. There is no other retry path — no cache-TTL timer, no // scroll-to-retry; the short _pending TTL in 07-cache.js is now purely a // staleness guard for a future fresh page load, NOT a retry trigger. // // It is no longer started unconditionally: resolveItems drops avs on the // 停止重试 list (07a-noretry.js) from its candidate set, this loop prunes // avs the user stops mid-run at the top of every round, and its finally // records an auto stop for whatever it genuinely gave up on — so the same // hopeless folder is not re-sampled from scratch on every visit. // // Adaptive backoff (cfg('flapBackoffMs') / cfg('flapMaxDry')): the // `dry` counter drives BOTH cadence and termination. A walk that recovers // something resets dry → next walk fires after the short burst gap; a walk // that recovers nothing bumps dry → the gap widens and, at maxDry, // the loop concludes the leftovers are genuinely filtered and stops. So a // still-flapping folder is sampled fast and converges; a truly-deleted set // is abandoned after ~7 cheap samples (~4 min) instead of being hammered. // // Two kinds of candidate share the loop: `_pending` avs (nothing usable yet // — any cover OR title retires them) and `_cover_pending` avs (title already // patched onto the card, only the image missing — nothing but a cover // retires them). The caller passes the latter as `coverNeeded`; without that // distinction the title they already have would retire them on walk 1 and // the cover would never be chased. A `_pending` candidate whose walk lands // a TITLE but no cover migrates into the cover-only set mid-run (stamped // `_cover_pending`, saved, kept pending) — same rule, discovered later. // // Re-patch strategy: when a walk recovers an av we saveCache() the upgraded // merge and call schedule(). The still-pending cards remain detectable by // findInvalidContainers Strategy 2 (their title is still "已失效视频"), and // loadCache now returns the good merge, so patchOnce's fast path swaps // cover+title in place — no stored DOM hits to go stale across bilibili's // virtualized scroll, no spinner re-flash (recovered avs hit the cache fast // path, not the resolver). Cards still pending while the loop is alive show // a "重试中" badge (applyPatch reads _flapBgRunning); they flip to "待重试" // only once the loop terminates (the finally's schedule()), or to // "已停止重试" the moment the user is on the stop list. // // Concurrency / race notes: // - Single loop at a time (_flapBgRunning, true for the loop's WHOLE life // including backoff sleeps). Re-entry is a no-op. Backoff sleeps are // sliced ~1s so a folder switch frees _flapBgRunning within a second — // the next folder's resolve (fired after the SPA settle delay) can then // start its own loop instead of being locked out for minutes. // - Walks call SOURCES.android.fetchPage DIRECTLY (not ensurePage) and // write only pageItems — never pageCache. This deliberately bypasses // the page-promise cache (we WANT a fresh sample each round) AND avoids // racing a concurrent foreground ensurePage walk that shares pageCache. // pageItems writes are same-shaped overwrites, safe under JS's single // thread (no await mid-write). // - Bails the instant the folder changes (detectMediaId() !== mediaId); // dropAllInMemory() on SPA folder-switch clears pageItems out from // under us, which is fine — we re-check before each page, walk, and // backoff slice. var _flapBgRunning = false; // Live progress of the active flap loop, surfaced to the pending card's // hover tooltip (buildTipHtml _pending branch) so the user can SEE what the // background retry is doing — which walk, how many left, walking vs backing- // off, countdown to the next sample — instead of a static "重试中". null // whenever no loop runs (set on entry below, cleared in the finally). var _flapProgress = null; // Avs the loop GAVE UP on (still pending after it stopped), kept so a card's // "立即重试" menu item (kickManualRetry) can re-arm the loop with the WHOLE // leftover set in one walk instead of chasing a single av. Scoped to one // folder via _flapLeftoverMid; cleared on folder switch (dropAllInMemory). var _flapLeftover = new Set(); // Subset of _flapLeftover whose retry is about the COVER only (the title is // already patched onto the card). Kept apart so a manual re-arm restores the // same promotion rule instead of retiring them on the first walk. var _flapLeftoverCover = new Set(); var _flapLeftoverMid = null; async function runFlapRecovery(mediaId, candidates, coverNeeded) { if (_flapBgRunning) return; if (!candidates || !candidates.length) return; if (!SOURCES.android.enabled()) return; _flapBgRunning = true; // Flip any on-screen pending badges to "重试中" right away: the loop may // sleep on its first backoff before any recovery-driven schedule(), and // a MANUAL re-arm has nothing else to repaint the cards. schedule(); var pending = new Set(candidates.map(String)); // Avs enqueued because their COVER is missing while the title is // already good (`_cover_pending`). The default promotion test below // (!_degenerate) is already satisfied by that title, so it would retire // them after one walk without ever obtaining the image — for these the // merge has to actually carry a cover. var needCover = new Set((coverNeeded || []).map(String)); // The loop's own parameters are snapshotted ONCE, here. This run can // last the better part of an hour; re-reading them mid-flight would // change the rules under a half-finished sampling campaign (a lowered // maxDry would end it retroactively, on evidence gathered under the // old one). An edit lands on the next armed loop instead. var maxDry = cfg('flapMaxDry'); var backoff = cfg('flapBackoffMs'); var deadline = Date.now() + cfg('flapTimeBudgetMin') * 60000; var walk = 0, dry = 0; // Consecutive walks whose android requests ALL threw — an expired / // invalidated access_key (code -101), risk control (-352), a few // minutes offline. android has no sourceFailureGate (05-sources.js only // checks that an access_key string exists) and nothing clears the key on // an API error, so such a run continues until this loop stops it. Those // walks sample NOTHING, so they must not feed `dry`: otherwise a // transient outage stamps 7-day auto 停止重试 records on a whole folder // on the strength of zero evidence, and the next visit (phase 1 skipped, // loop never armed) does no network work at all. They still widen the // backoff and still terminate the loop — but without a verdict. var errRun = 0; // Did any walk ever obtain a sample? Guards the budget-exhausted exit // below for the same reason. var everSampled = false; // Did the loop reach a CONCLUSION (dry ran out / the time budget did), // as opposed to being interrupted? Only a conclusion may write the // auto 停止重试 records in the finally. Re-deriving this from `dry` down // there would misfire: a folder switch can abort the loop at a moment // when dry happens to sit at maxDry, and an interrupted loop is // not a verdict on a folder it never finished sampling. var gaveUp = false; _flapProgress = { mediaId: mediaId, startedAt: Date.now(), deadline: deadline, total: pending.size, remaining: pending.size, walk: 0, dry: 0, maxDry: maxDry, phase: 'walking', page: 0, nextWalkAt: 0, lastRecovered: 0 }; try { log('flap-bg: start', pending.size, 'candidate(s):', Array.from(pending).slice(0, 5).join(',') + (pending.size > 5 ? ',…' : '')); while (pending.size && dry < maxDry) { if (detectMediaId() !== mediaId) { log('flap-bg: folder changed, abort'); break; } // Budget exhausted counts as a conclusion only if android // answered at least once: 30 minutes of failed requests is a // statement about the connection, not about the videos. if (Date.now() > deadline) { log('flap-bg: time budget exhausted'); gaveUp = everSampled; break; } // Drop anything the user stopped WHILE the loop was running: // pressing 停止重试 has to take effect on the next round, not // whenever the loop happens to run out of budget. Only the // 'user' mode is honoured — the 'auto' records are written by // THIS loop's own finally, and a manual re-arm exists precisely // to override them. var stopped = 0; Array.from(pending).forEach(function (av) { if (isNoRetryUser(av)) { pending.delete(av); stopped++; } }); if (stopped) log('flap-bg: dropped', stopped, 'candidate(s) the user stopped'); // Pending emptied by those stops is NOT a give-up: there is // nothing left to record, and the loop simply retires. if (!pending.size) { log('flap-bg: no candidates left to chase'); break; } walk++; _flapProgress.walk = walk; _flapProgress.phase = 'walking'; _flapProgress.remaining = pending.size; // One fresh android walk straight into pageItems. // sampledOk — this walk actually holds android's answer about // the candidates: a page came back, or the allFound early // exit fired because every candidate already has a USABLE // answer — for a cover-needed av that means a row whose // cover passes QUALITY, not mere row presence (a stale // coverless row is not an answer to "where is the cover"). // A walk whose every request threw has neither. // interrupted — a folder switch truncated the walk. The // observer's dropAllInMemory() has cleared pageItems out // from under it, so the promotion pass below can recover // nothing BY CONSTRUCTION; that is not a result either. var sampledOk = false, interrupted = false; var pn = 1; while (pn <= cfg('maxPageWalk')) { if (detectMediaId() !== mediaId) { interrupted = true; break; } if (Date.now() > deadline) break; // Quality-aware, not presence-only: a cover-needed av // (the coverNeeded set, incl. mid-run title-only migrants) // always HAS a stale android row — that row is HOW it got // its title. A presence test would break here at pn=1 on // every later walk, so the promotion pass would re-merge // the same stale rows, no fresh page would ever be // fetched, and dry would climb to maxDry over ~7 // no-network walks — the cover chase structurally a no-op. // Counting those avs as satisfied only once their row // carries a usable cover keeps the walk fetching fresh // samples, which is the entire point of this loop. var allFound = true; pending.forEach(function (av) { var row = pageItems.get('android|' + av); if (!row) { allFound = false; return; } if (needCover.has(av) && QUALITY.cover(row.cover) < 5) allFound = false; }); if (allFound) { sampledOk = true; break; } _flapProgress.page = pn; var page; try { page = await SOURCES.android.fetchPage({ mediaId: mediaId, pn: pn }); } catch (e) { warn('flap-bg walk ' + walk + ' pn ' + pn + ' failed:', e.message); break; } sampledOk = true; (page.list || []).forEach(function (it) { if (it && it.oid != null) pageItems.set('android|' + it.oid, it); }); if (!page.has_more) break; pn++; } // Leave before an interrupted walk can be read as a verdict. The // top-of-loop guard would break next round anyway — but only // AFTER this round's dry++ had possibly pushed `dry` to // maxDry and stamped auto records on every remaining av of // the folder the user has just left. if (interrupted) { log('flap-bg: folder changed mid-walk, abort'); break; } if (sampledOk) everSampled = true; // Promote any candidate android now covers with usable data. var recovered = []; var titleOnly = []; // stamped _cover_pending this walk (see below) Array.from(pending).forEach(function (av) { var aItem = pageItems.get('android|' + av); if (!aItem) return; if (QUALITY.cover(aItem.cover) < 5 && QUALITY.title(aItem.title) < 10) return; var perSource = {}; Object.keys(SOURCES).forEach(function (s) { var it = pageItems.get(s + '|' + av); if (it) perSource[s] = it; }); var merged = mergeBySource(perSource); // Keep trying while the walk has not produced what this av // was enqueued for: a cover for the _cover_pending set, any // usable cover-or-title for everyone else. if (needCover.has(av)) { if (!merged._src_cover) return; } else if (merged._degenerate) { return; } else if (merged._src_title && !merged._src_cover) { // Title landed, cover did not. Retiring here would save // a merge WITHOUT the _cover_pending flag — the card // would render as fully recovered and nothing would // ever chase the image again (the resolver's classifier // runs on ITS merge pass, not on ours). Stamp + save so // the title repaints now via the fast path, then move // the av into the cover-only set and KEEP it pending: // from the next walk on, only a real cover retires it. // Counts as progress below (the title is a repaint), // and staying in pending keeps the finally's // _flapLeftover / _flapLeftoverCover bookkeeping right. merged._cover_pending = true; saveCache(av, merged); // Title-only recovery → 'restored_meta' in the backup // store (classifier picks META off the missing // _src_cover); upgraded in place by the hook below // when a later walk lands the cover. maybePromoteRecovered(av, merged, mediaId); needCover.add(av); titleOnly.push(av); return; } saveCache(av, merged); // Flap-recovered merge → promotion hook (the merge block's // hook never saw these avs; they were _pending / // _cover_pending there). FULL or META per the classifier. maybePromoteRecovered(av, merged, mediaId); pending.delete(av); recovered.push(av); }); if (recovered.length || titleOnly.length) { dry = 0; errRun = 0; // progress → reset cadence to the burst gap and keep sampling fast log('flap-bg walk ' + walk + ': recovered', recovered.length, '· title-only', titleOnly.length, '(cover still chased)', '→ re-patch;', pending.size, 'left'); // Upgrade on-screen cards via the normal fast path. schedule(); } else if (!sampledOk) { // android never answered. Back off exactly like a dry walk // does, but do not count it as one — the candidates were not // sampled, so there is nothing to conclude about them. errRun++; log('flap-bg walk ' + walk + ': android unreachable (' + errRun + '/' + maxDry + ' consecutive) — not counted toward dry'); } else { errRun = 0; dry++; // no progress → widen the gap, step toward giving up log('flap-bg walk ' + walk + ': 0 new (dry ' + dry + '/' + maxDry + ')'); } _flapProgress.dry = dry; _flapProgress.lastRecovered = recovered.length; _flapProgress.remaining = pending.size; if (!pending.size || dry >= maxDry || errRun >= maxDry) { if (dry >= maxDry) gaveUp = true; // An error run stops the loop WITHOUT a verdict: the cards // stay 待重试 and a reload — or 立即重试, or the user simply // logging in again — re-arms the loop exactly as before. else if (errRun >= maxDry) log('flap-bg: stopping for now — ' + errRun + ' consecutive walk(s) could not reach android; no auto 停止重试 written'); break; } // Adaptive backoff before the next walk: gap widens with `dry`. // Sleep in ~1s slices so a folder switch / budget expiry breaks // out within a second (frees _flapBgRunning for the next folder). // Widen on whichever counter is running: a failing android must // back off just as a fruitless one does, or an outage would be // hammered at the 1s burst gap. var gap = backoff[Math.min(Math.max(dry, errRun), backoff.length - 1)]; var until = Date.now() + gap; _flapProgress.phase = 'sleeping'; _flapProgress.nextWalkAt = until; while (Date.now() < until) { if (detectMediaId() !== mediaId || Date.now() > deadline) break; await new Promise(function (r) { setTimeout(r, Math.min(1000, until - Date.now())); }); } } log('flap-bg: done after', walk, 'walk(s);', pending.size, 'still unrecovered (stays 待重试 until a fresh reload re-attempts)'); } finally { _flapBgRunning = false; _flapProgress = null; // Auto 停止重试 records — written ONLY when the loop reached a // conclusion (gaveUp). They expire after 7 days, so the next visit // within that window skips the page walk entirely instead of // re-running ~4 minutes of sampling for items that are almost // certainly deleted; after it, the folder gets a fresh chance. if (gaveUp && pending.size) { pending.forEach(function (av) { markAutoNoRetry(av); }); log('flap-bg: recorded auto 停止重试 for', pending.size, 'av(s)'); } // Remember the avs we gave up on so a card's "立即重试" can re-arm // the loop over the WHOLE leftover set (not just the clicked card). // If the loop recovered everything, pending is empty → no leftover → // the retry menu item won't render (cards are no longer _pending). // Avs the user stopped are excluded: the leftover set is the manual // re-arm's payload, and a manual retry must never resurrect a card // whose retries the user switched off. _flapLeftover = new Set(); pending.forEach(function (av) { if (!isNoRetryUser(av)) _flapLeftover.add(av); }); _flapLeftoverCover = new Set(); _flapLeftover.forEach(function (av) { if (needCover.has(av)) _flapLeftoverCover.add(av); }); _flapLeftoverMid = mediaId; // Re-run the patch pass with the loop now inactive so any still- // pending cards flip their badge from "重试中" to "待重试". // Recovered cards already upgraded via the per-walk schedule() calls. schedule(); } } // Manual re-arm of the flap loop from a card's "立即重试" menu item. Re-runs // THE loop (runFlapRecovery) over every av it gave up on in THIS folder, so a // single android walk recovers all still-pending cards, not just the clicked // one. Falls back to [clickedAv] if the leftover set is stale/empty (e.g. a // fresh page load started a new loop). No-op with a toast if a loop is alive // (the card already shows 重试中) or android is unavailable. function kickManualRetry(clickedAv) { var mid = detectMediaId(); if (!mid) { toast('无法识别当前收藏夹', 'warn'); return; } if (!SOURCES.android.enabled()) { toast('android 接口不可用,无法重试', 'warn'); return; } if (_flapBgRunning) { toast('后台正在重试中,请稍候', 'ok'); return; } var sameFolder = (_flapLeftoverMid === mid); var cands = sameFolder ? Array.from(_flapLeftover) : []; var coverOnly = sameFolder ? Array.from(_flapLeftoverCover) : []; if (!cands.length && clickedAv) { cands = [String(clickedAv)]; // Derive the cover-only classification from the live cache entry: // a lone re-armed `_cover_pending` av dropped into the default set // would be retired on walk 1 by the title it already has, without // ever fetching the cover — the button would silently do nothing. var _kc = loadCache(String(clickedAv)); coverOnly = (_kc && _kc._cover_pending) ? [String(clickedAv)] : []; } // Honour the user's manual stops only. An 'auto' record is exactly what // this button is for overriding — the user is telling us to ignore the // loop's give-up and sample once more — but a card the user stopped // by hand must stay stopped even when it rides along in the leftover set. cands = cands.filter(function (av) { return !isNoRetryUser(av); }); coverOnly = coverOnly.filter(function (av) { return !isNoRetryUser(av); }); if (!cands.length) { toast('没有待重试的视频', 'ok'); return; } // The leftover set can also hold cards that ARE patched and only lack a // cover, so the wording is deliberately not "待重试" alone. toast('正在重新抓取 ' + cands.length + ' 项未完全还原的视频', 'ok'); runFlapRecovery(mid, cands, coverOnly).catch(function (e) { warn('manual retry threw:', e); }); } // ─── URL / page detection ─────────────────────────────────────────── function detectMediaId() { // Pattern 1: www.bilibili.com/list/ml{media_id} var m = location.pathname.match(/\/list\/ml(\d+)/); if (m) return m[1]; // Pattern 2: ?fid={media_id} on space.bilibili.com/{mid}/favlist var qs = new URLSearchParams(location.search); var fid = qs.get('fid'); if (fid) return fid; // Pattern 3: ?fav_id={media_id} (some new pages) var favId = qs.get('fav_id'); if (favId) return favId; return null; } function isFavPage() { return /\/favlist/.test(location.pathname) || /\/list\/ml\d+/.test(location.pathname); } // ─── DOM scanning / patching ──────────────────────────────────────── function findInvalidContainers() { // Strategy 1: covers whose URL contains the placeholder hash token. // Filter out imgs we've already processed (recovered → solid red // mark, OR _no_source → "nodata" mark); they still match the // selector because we don't rewrite the placeholder URL on nodata, // but re-processing them is wasted observer work and inflates // `invalidDetectedNow` to the user. var imgs = document.querySelectorAll( 'img[src*="' + PLACEHOLDER_COVER_TOKEN + '"]:not([data-fav-fix-marked])' ); var nodes = Array.from(imgs).map(function (img) { // Resolve the container to the WHOLE fav card, not the first // ancestor that happens to hold a /video/ link. On the modern // layout that ancestor is `div.bili-video-card__cover`, which // contains the cover but NOT the title — the title is a // sibling leaf . A cover-only container means patchTitle can // never reach the title node, so a recovered item shows its real // cover with a stale "(视频已删除)" / "已失效视频" title (verified // on a real card). Scoping to the card keeps cover AND title in // one patchable subtree. var card = img.closest(CARD_SELECTOR); if (card) { return { container: card, img: img, link: card.querySelector('a[href*="/video/"]') }; } // Fallback (img not inside a known card class): old walk-up. var n = img; while (n && n !== document.body) { var a = n.querySelector && n.querySelector('a[href*="/video/"]'); if (a) return { container: n, img: img, link: a }; n = n.parentElement; } return { container: img.parentElement, img: img, link: null }; }); // Strategy 2 (fallback): titles that match "已失效视频" exactly. // Only used to detect items whose cover URL doesn't include the // placeholder token (some pages render an inline SVG instead). // // Scope the scan to fav cards (CARD_SELECTOR) instead of every // p/span/div/a in the document. The old全-document querySelectorAll // returned thousands of nodes (sidebar / nav / recs / footer) and ran // on every debounced observer tick — the heaviest single step in the // patch cycle. Cards are ~20-40 per page, so this is one to two orders // of magnitude fewer nodes. Container resolution (walk up to the // nearest /video/ link ancestor) is kept identical to Strategy 1 so // the two strategies produce the SAME container object for a card and // the dedupe below still collapses them. var titleHits = []; var cards = document.querySelectorAll(CARD_SELECTOR); for (var ci = 0; ci < cards.length; ci++) { var card = cards[ci]; var cand = card.querySelectorAll('p, span, div, a'); var titleEl = null; for (var k = 0; k < cand.length; k++) { if (cand[k].children.length === 0 && cand[k].textContent.trim() === INVALID_TITLE) { titleEl = cand[k]; break; } } if (!titleEl) continue; // Container = the whole card (same as Strategy 1), so the dedupe // below collapses both strategies' hits for one card into one and // patchTitle/patchCover share a single subtree that holds both the // title leaf and the cover . var link2 = card.querySelector('a[href*="/video/"]'); if (link2) titleHits.push({ container: card, img: card.querySelector('img'), link: link2, titleEl: titleEl }); } // Merge by container (dedupe). var seen = new Set(); var out = []; nodes.concat(titleHits).forEach(function (hit) { if (!hit.container || seen.has(hit.container)) return; seen.add(hit.container); out.push(hit); }); return out; } function extractAvFromLink(href) { if (!href) return null; var m = href.match(/\/video\/av(\d+)/i); if (m) return m[1]; // BV → bvid; we can't resolve to av without an extra API call. // The fav API returns oid as av number, so BV-only items would // need a BV→av conversion. For POC, skip BV-only items and log. var bv = href.match(/\/video\/(BV[0-9A-Za-z]+)/); if (bv) { log('skip BV-only item (av not derivable from DOM)', bv[1]); return null; } return null; } function avToBv(aid) { // 2023 new-algorithm AV→BV (mirror of bvToAv below). Verbatim from // bilibili-API-collect docs/misc/bvid_desc.md JS section. var XOR_CODE = 23442827791579n; var MAX_AID = 1n << 51n; var BASE = 58n; var data = 'FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf'; try { var bytes = ['B','V','1','0','0','0','0','0','0','0','0','0']; var bvIdx = bytes.length - 1; var tmp = (MAX_AID | BigInt(aid)) ^ XOR_CODE; while (tmp > 0n) { bytes[bvIdx] = data[Number(tmp % BASE)]; tmp = tmp / BASE; bvIdx -= 1; } var t; t = bytes[3]; bytes[3] = bytes[9]; bytes[9] = t; t = bytes[4]; bytes[4] = bytes[7]; bytes[7] = t; return bytes.join(''); } catch (e) { return null; } } function bvToAv(bvid) { // 2023 new-algorithm BV→AV. Verbatim from bilibili-API-collect // docs/misc/bvid_desc.md JS section. Pure function; works for any // 12-char "BV1XXXXXXXXX" issued after 2020-03-23 (i.e. anything a // modern fav page would link to). Pre-2020 BVs with the old 6-char // layout will decode to garbage — but those are essentially extinct // on modern bilibili. var XOR_CODE = 23442827791579n; var MASK_CODE = 2251799813685247n; var MAX_AID = 1n << 51n; var BASE = 58n; var data = 'FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf'; try { if (typeof bvid !== 'string' || bvid.length !== 12) return null; var arr = bvid.split(''); var t; t = arr[3]; arr[3] = arr[9]; arr[9] = t; t = arr[4]; arr[4] = arr[7]; arr[7] = t; arr.splice(0, 3); var tmp = 0n; for (var i = 0; i < arr.length; i++) { var idx = data.indexOf(arr[i]); if (idx < 0) return null; tmp = tmp * BASE + BigInt(idx); } var avBig = (tmp & MASK_CODE) ^ XOR_CODE; if (avBig <= 0n || avBig >= MAX_AID) return null; return avBig.toString(); } catch (e) { return null; } } function getAvFromHit(hit) { var href = hit.link && hit.link.getAttribute('href'); if (!href) return null; var m = href.match(/\/video\/av(\d+)/i); if (m) return m[1]; var bv = href.match(/\/video\/(BV[0-9A-Za-z]+)/); if (bv) return bvToAv(bv[1]); return null; } function patchCover(img, realCoverUrl, av) { if (!img || !realCoverUrl) return; // bilibili web is https — Android-app responses are sometimes http. var u = realCoverUrl.replace(/^http:\/\//, 'https://'); if (img.getAttribute('data-fav-fix-original')) return; // already patched img.setAttribute('data-fav-fix-original', img.src || ''); // Last-resort cover: bilibili sometimes purges the IMAGE while the // metadata snapshot survives, so the URL we just recovered 404s and the // card ends up with real title + broken cover. If a manual backup // (15a-backup.js) captured the bytes, swap in an objectURL. // data-fav-fix-blob-cover is the idempotence guard for BOTH the // observer (which re-enters patchCover on every tick) and the error // handler itself — without it an undecodable blob would re-fire // `error` on the objectURL and loop forever. if (av && !img.getAttribute('data-fav-fix-blob-cover')) { img.addEventListener('error', function onCoverError() { img.removeEventListener('error', onCoverError); if (img.getAttribute('data-fav-fix-blob-cover')) return; img.setAttribute('data-fav-fix-blob-cover', 'pending'); backupCoverObjectUrl(av).then(function (objUrl) { if (!objUrl) { img.setAttribute('data-fav-fix-blob-cover', 'miss'); return; } img.setAttribute('data-fav-fix-blob-cover', 'hit'); img.src = objUrl; }).catch(function (e) { img.setAttribute('data-fav-fix-blob-cover', 'miss'); warn('backup cover fallback failed for av', av, e && e.message); }); }); } img.src = u; img.style.opacity = '1'; } // Rewrite an element's visible title text in place. The title is a leaf // (text-only) on bilibili's card, so we set its first non-empty text node // and blank any extras — keeping the element itself (and its listeners / // data attrs) rather than reassigning textContent wholesale. function setTitleText(el, text) { var tw = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); var n, set = false; while ((n = tw.nextNode())) { if (!n.nodeValue.trim()) continue; if (!set) { n.nodeValue = text; set = true; } else n.nodeValue = ''; } if (!set) el.textContent = text; } function patchTitle(container, realTitle) { if (!container || !realTitle) return; // Robust path: once a prior patch tagged the title element, update it // directly — independent of what it currently shows ("已失效视频", // "(视频已删除)", or a previous real title). The old text-match-only // approach was one-shot: after the first rewrite the text no longer // equalled INVALID_TITLE, so a later refetch (android flap → recovered) // updated the cover but never the title. Verified on a real card: the // title is a leaf outside the cover container, reachable here only // via the INVALID_TITLE match, which can never fire twice. var tagged = container.querySelectorAll('[data-fav-fix-title]'); if (tagged.length) { tagged.forEach(function (el) { // Leaf element → safe to rewrite text. Non-leaf (tagged only // for its title attribute) → leave children, just fix the attr. if (el.children.length === 0) setTitleText(el, realTitle); if (el.hasAttribute('title')) el.setAttribute('title', realTitle); }); return; } // First touch: rewrite the INVALID_TITLE leaf and tag it so subsequent // patches skip the (fragile) text match. var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null); var node; while ((node = walker.nextNode())) { if (node.nodeValue.trim() === INVALID_TITLE) { node.nodeValue = node.nodeValue.replace(INVALID_TITLE, realTitle); if (node.parentElement) node.parentElement.setAttribute('data-fav-fix-title', '1'); } } // Also patch + tag title attributes (native tooltip). container.querySelectorAll('[title="' + INVALID_TITLE + '"]').forEach(function (el) { el.setAttribute('title', realTitle); el.setAttribute('data-fav-fix-title', '1'); }); } // ─── Hover tooltip (rich) ─────────────────────────────────────────── // Third-party archives (biliplus, jijidown) are an // untrusted boundary: a poisoned/compromised source could return a // cover/avatar URL of `javascript:…` or `data:text/html,…`. Setting such // a value as img.src is inert (browsers never execute it), but handing it // to GM_openInTab would navigate a real tab there. Whitelist absolute // http(s) before any URL crosses into GM_openInTab or an we // build from source data. function isHttpUrl(url) { return typeof url === 'string' && /^https?:\/\//i.test(url); } function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function fmtCount(n) { if (n == null) return ''; n = Number(n); if (!isFinite(n)) return ''; if (n >= 1e8) return (n / 1e8).toFixed(n >= 1e9 ? 0 : 1) + '亿'; if (n >= 1e4) return (n / 1e4).toFixed(n >= 1e6 ? 0 : 1) + '万'; return String(n); } function fmtDuration(sec) { sec = Number(sec) || 0; if (sec <= 0) return ''; var h = Math.floor(sec / 3600); var m = Math.floor((sec % 3600) / 60); var s = sec % 60; var pad = function (x) { return x < 10 ? '0' + x : String(x); }; return h > 0 ? h + ':' + pad(m) + ':' + pad(s) : m + ':' + pad(s); } function fmtTime(ts) { if (!ts) return ''; var d = new Date(Number(ts) * 1000); if (isNaN(d.getTime())) return ''; var pad = function (x) { return x < 10 ? '0' + x : String(x); }; return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); } // Elapsed wall time for the pending-card live status ("已用时 …"). Takes a // millisecond delta (Date.now() - startedAt), NOT a unix ts like fmtTime. function fmtElapsed(ms) { var s = Math.max(0, Math.floor(ms / 1000)); var m = Math.floor(s / 60); s = s % 60; return m > 0 ? (m + ' 分 ' + s + ' 秒') : (s + ' 秒'); } // Single source of truth for "投稿时间" so the hover tooltip and the // "复制完整信息" clipboard text never diverge (AGENTS.md gotcha #83). // Both must read the SAME field-fallback chain and the SAME source tag, // otherwise an item that carries ctime but not pubtime shows a date on // hover but a blank one when copied. function pickPubTs(real) { return real.ctime || real.pubtime || real.pubdate || null; } function pickPubSrc(real) { return real._src_ctime || real._src_pubtime || real._src_pubdate || null; } // Single global tooltip element, reused across all hovers. var _tipEl = null; // While hovering a pending (重试中) card, the tooltip is rebuilt once a // second so its live status (countdown / elapsed / round) actually ticks — // visible proof the loop is not stuck. One timer at a time; cleared on hide // and when the card stops being pending. var _tipRefreshTimer = null; function stopTipRefresh() { if (_tipRefreshTimer) { clearInterval(_tipRefreshTimer); _tipRefreshTimer = null; } } function getTip() { if (_tipEl) return _tipEl; _tipEl = document.createElement('div'); _tipEl.id = '__fav_fix_tip'; _tipEl.style.cssText = [ 'position:fixed', 'z-index:2147483646', 'pointer-events:none', 'max-width:340px', 'padding:10px 12px', 'border-radius:8px', 'background:rgba(28,28,30,.96)', 'color:#fff', 'box-shadow:0 8px 24px rgba(0,0,0,.35)', 'font:12px/1.55 -apple-system,Segoe UI,"PingFang SC","Microsoft YaHei",sans-serif', 'opacity:0', 'transition:opacity .12s', 'display:none' ].join(';'); document.body.appendChild(_tipEl); return _tipEl; } // Two-column "label / value" row helper, used throughout the tooltip. function row(label, valueHtml, srcName) { return '
' + '' + esc(label) + '' + '' + valueHtml + srcTag(srcName) + '' + '
'; } function codeTag(text) { return '' + esc(text) + ''; } // Source attribution chips. Color-coded per source so the user can // see at a glance which API contributed which field of the snapshot. var SOURCE_COLORS = { backup: '#8e44ad', android: '#5b8def', 'public': '#67c23a', biliplus: '#e6a23c', jijidown: '#f56c6c' }; function srcTag(src) { if (!src) return ''; var color = SOURCE_COLORS[src] || '#909399'; return ' ' + esc(src) + ''; } function buildTipHtml(real) { if (!real) return ''; // Unrecoverable stub — no source returned data. Render a slim // tooltip that says exactly that (and lists which sources we tried) // instead of the normal rich layout with empty fields everywhere. if (real._no_source) { var av = real.oid != null ? String(real.oid) : ''; var bv = av ? avToBv(av) : null; // Prefer _attempted (new, accurate: phase 1 + phase 2 unioned). // Fall back to _attempted_3rd (legacy: 3rd-party only) for old // cache entries, then to _tried_sources (oldest: all possible // sources from the registry, not necessarily queried) as a // last-resort display. var attemptedList = real._attempted || real._attempted_3rd || real._tried_sources || []; var attemptedHtml = attemptedList.map(srcTag).join(''); return '
' + '视频已删除 · 无任何数据来源保留快照
' + (av ? '
AV ' + codeTag('av' + av) + '
' : '') + (bv ? '
BV ' + codeTag(bv) + '
' : '') + '
' + '已查询以下数据来源,均无记录:' + '
' + '
' + attemptedHtml + '
' + '
' + 'fav-fix · 视频可能已被永久删除' + '
'; } // Pending — still being chased by the background android flap loop, or // waiting for a future retry after it gave up. There's no good snapshot // yet, so render a state-aware explainer (NOT the normal rich layout // with empty fields). _flapBgRunning and the 停止重试 list are read LIVE // here — showTip rebuilds innerHTML on every hover, and once a second // while hovering — so the text tracks all three badge states: the loop // is alive (重试中), it gave up (待重试), or the user switched this av // off (已停止重试). The stopped copy is static, which makes the // once-a-second rebuild harmless rather than something to special-case. if (real._pending) { var pav = real.oid != null ? String(real.oid) : (real.bvid ? bvToAv(real.bvid) : ''); var pbv = real.bvid || (pav ? avToBv(pav) : null); // Third, highest-priority state: the user pressed 停止重试. Read // live from the list (07a-noretry.js), same as the badge — the flap // loop's liveness is irrelevant once the av is switched off. var pStopped = pav ? isNoRetryUser(pav) : false; // Fourth state, and the one the copy below has to distinguish: the // loop gave up on this av and recorded an 'auto' 停止重试 (7 days). // Nothing is chasing it — resolveItems keeps it out of the walk AND // out of the loop's candidate set — so it must neither borrow a // running loop's 重试中 wording nor be told that reloading retries. var pPaused = !pStopped && pav ? isRetrySuppressed(pav) : false; var pActive = !pStopped && !pPaused && _flapBgRunning; var pHead = pStopped ? '已停止重试' : (pActive ? '正在找回此视频快照…' : (pPaused ? '暂未找回,自动重试已暂停' : '暂未找回,等待重试')); // Live status block: only while the loop is actually running AND its // progress belongs to THIS folder (the loop nulls _flapProgress on // exit / folder switch). Answers "why still 重试中 / what is it doing" // with the real loop state. showTip re-renders this once a second so // the countdown and elapsed time tick visibly (proof it isn't stuck). var liveHtml = ''; var prog = _flapProgress; if (pActive && prog && prog.mediaId === detectMediaId()) { var stateLine; if (prog.phase === 'sleeping') { var secs = Math.max(0, Math.ceil((prog.nextWalkAt - Date.now()) / 1000)); stateLine = '当前:等待下次采样(约 ' + secs + ' 秒后)'; } else { stateLine = '当前:正在重新采样(第 ' + (prog.page || 1) + ' 页)'; } liveHtml = '
' + '已采样 ' + prog.walk + ' 轮 · 整夹还剩 ' + prog.remaining + ' 项待找回
' + esc(stateLine) + '
' + '已用时 ' + fmtElapsed(Date.now() - prog.startedAt) + ' · 连续 ' + prog.dry + '/' + prog.maxDry + ' 轮无新增即停' + '
'; } var pBody = pStopped ? '此视频的自动重试已由你手动停止,脚本不会再为它请求任何接口。点封面上的「恢复重试」按钮,或在本卡片右上「···」菜单选同名项,即可恢复并立即重新抓取一轮。' : (pActive ? 'bilibili 的 android 收藏接口会随机漏掉一部分失效视频,脚本正在后台多次重新采样把它捞回来。找回后本卡片会自动更新封面与标题,无需手动操作。' : (pPaused // The give-up copy below promises that a reload re-tries. With // an auto record in place that is false: the next resolve skips // the page walk for this av entirely. Say what actually happens. ? '后台已多次重新采样仍未取回——可能视频确实已被删除,也可能是 bilibili 接口暂时不返回。为避免每次进入收藏夹都重跑一轮,脚本已暂停对它的自动重试,约一周后自动恢复;期间重新整理本页不会再为它请求接口。如需立刻再试一轮,可在本卡片右上「···」菜单点「立即重试」。' : '后台已多次重新采样仍未取回——可能视频确实已被删除,也可能是 bilibili 接口暂时不返回。重新整理本页会自动再试一轮;也可在本卡片右上「···」菜单点「立即重试」立刻再抓一轮。')); // When the stop was recorded. Only for the manual mode: an auto // record is not the user's decision and has no place in a tooltip // that tells them what they themselves switched off. var pStoppedAt = pStopped ? noRetryUserAt(pav) : null; var pStoppedAtHtml = pStoppedAt ? '
已于 ' + esc(fmtTime(Math.floor(pStoppedAt / 1000))) + ' 停止
' : ''; return '
' + esc(pHead) + '
' + (pav ? '
AV ' + codeTag('av' + pav) + '
' : '') + (pbv ? '
BV ' + codeTag(pbv) + '
' : '') + liveHtml + '
' + esc(pBody) + '
' + pStoppedAtHtml + '
' + 'fav-fix · ' + (pStopped ? '已停止重试' : (pActive ? '重试中(后台自动)' : (pPaused ? '待重试(自动重试已暂停)' : '待重试'))) + '
'; } var parts = []; // Title — full width, bold, with source chip after the title text. parts.push('
' + esc(real.title || '(无标题)') + srcTag(real._src_title) + '
'); // 封面 — dedicated row showing WHICH source supplied the patched // cover. The cover itself isn't shown in tooltip (it's on the // card img), so this row exists purely for source attribution. if (real.cover && real._src_cover) { parts.push(row('封面', '已恢复', real._src_cover)); } else if (real._cover_pending) { // Partial card: the row would otherwise silently vanish and the // rich layout would read as full success. State is read LIVE // (stop list + _flapBgRunning), same pattern as the _pending // branch above, so the copy tracks the loop's life: user-stopped // wins, then auto-suppressed (nothing is chasing it for ~7 days), // then a running loop, then the plain fallback — the loop simply // finished, or there is no loop at all (credential-less local // restore, where the stamp is in-memory only). var cav = real.oid != null ? String(real.oid) : (real.bvid ? bvToAv(real.bvid) : ''); var cStopped = cav ? isNoRetryUser(cav) : false; var cPaused = !cStopped && cav ? isRetrySuppressed(cav) : false; var cTxt = cStopped ? '暂未找回,已停止重试' : (cPaused ? '暂未找回,自动重试已暂停' : (_flapBgRunning ? '仍在后台重试' : '暂未找回')); parts.push(row('封面', '' + esc(cTxt) + '')); } // UP 主 if (real.upper && (real.upper.name || real.upper.face)) { var faceUrl = real.upper.face ? real.upper.face.replace(/^http:\/\//, 'https://') : ''; // Only render the avatar img for absolute http(s) faces (esc still // guards attribute breakout; isHttpUrl rejects javascript:/data:). var avatar = isHttpUrl(faceUrl) ? '' : ''; var uid = real.upper.mid ? ' UID ' + esc(real.upper.mid) + '' : ''; parts.push(row('UP 主', avatar + esc(real.upper.name || '(未知)') + uid, real._src_upper)); } // Stats — no emoji, Chinese labels separated by middle dot. var c = real.cnt_info || {}; var stats = []; if (c.play != null) stats.push('播放 ' + fmtCount(c.play)); if (c.danmaku != null) stats.push('弹幕 ' + fmtCount(c.danmaku)); if (c.thumb_up != null) stats.push('点赞 ' + fmtCount(c.thumb_up)); if (c.reply != null) stats.push('评论 ' + fmtCount(c.reply)); if (c.collect != null) stats.push('收藏 ' + fmtCount(c.collect)); if (stats.length) parts.push(row('数据', stats.join(' · '), real._src_cnt_info)); // Duration var dur = real.playback_desc || fmtDuration(real.duration); if (dur) parts.push(row('时长', esc(dur), real._src_duration || real._src_playback_desc)); // BV + AV — always show both; if response only carries one, derive the other. var av = real.oid != null ? String(real.oid) : (real.bvid ? bvToAv(real.bvid) : null); var bv = real.bvid || (av ? avToBv(av) : null); if (av) parts.push(row('AV', codeTag('av' + av))); if (bv) parts.push(row('BV', codeTag(bv), real._src_bvid)); // Dates — invalid-item snapshots may omit some date fields. Show // the row regardless so user knows what's missing vs unknown. var pubT = fmtTime(pickPubTs(real)); var favT = fmtTime(real.fav_time); var dateBits = []; dateBits.push('投稿 ' + (pubT || '快照未记录')); dateBits.push('收藏 ' + (favT || '快照未记录')); // Use the source that gave us at least one of the two dates // (typically public). var dateSrc = pickPubSrc(real) || real._src_fav_time; parts.push(row('日期', dateBits.join(' · '), dateSrc)); // Intro (truncated to 240). if (real.intro && real.intro.trim()) { var intro = real.intro.length > 240 ? real.intro.slice(0, 240) + '…' : real.intro; parts.push('
' + esc(intro) + srcTag(real._src_intro) + '
'); } // Footer — list of contributing sources + sources that queried but // returned nothing. Two fields drive this: // _sources = sources whose data ended up in some merged field // _attempted = every source that actually queried this av // (phase 1 paginated + phase 2 per-av). Tooltip // shows attempted-minus-contributing as "已查询但 // 无记录" so the user can see "android + public // + biliplus all came back empty, only jijidown // had it" instead of those sources silently // disappearing. // _attempted_3rd = legacy field (3rd-party only). Read as a // fallback so pre-0.7.2 cached entries still // show whatever attempts they have. var srcChips = ''; if (real._sources && real._sources.length) { srcChips = '
数据来源:' + real._sources.map(srcTag).join('') + '
'; } var attempted = real._attempted || real._attempted_3rd || []; var missAttempts = attempted.filter(function (s) { return !real._sources || real._sources.indexOf(s) === -1; }); var missChips = ''; if (missAttempts.length) { missChips = '
已查询但无记录:' + missAttempts.map(srcTag).join('') + '
'; } parts.push('
' + 'fav-fix · 数据来自收藏时的快照' + srcChips + missChips + '
'); return parts.join(''); } function showTip(el, real, evt) { var tip = getTip(); stopTipRefresh(); tip.innerHTML = buildTipHtml(real); tip.style.display = 'block'; // Position: prefer above the element, centered horizontally; // if it would clip off the top, place it below. var r = el.getBoundingClientRect(); var vw = window.innerWidth, vh = window.innerHeight; // Make sure layout is computed before reading offsetWidth. tip.style.left = '0px'; tip.style.top = '0px'; var tw = tip.offsetWidth, th = tip.offsetHeight; var left = Math.max(8, Math.min(vw - tw - 8, r.left + r.width / 2 - tw / 2)); var top = r.top - th - 10; if (top < 8) top = Math.min(vh - th - 8, r.bottom + 10); tip.style.left = left + 'px'; tip.style.top = top + 'px'; // rAF so the opacity transition actually animates from 0 → 1. requestAnimationFrame(function () { tip.style.opacity = '1'; }); // Pending card: keep the live status fresh while hovering. Re-read the // element's CURRENT real each tick (el.__favFixReal) so that if the flap // loop recovers this card mid-hover, the tooltip upgrades to the normal // rich layout and the refresh stops on its own. Position is left as set // (content height barely changes between ticks). if (real && real._pending) { _tipRefreshTimer = setInterval(function () { if (tip.style.display === 'none') { stopTipRefresh(); return; } var liveReal = (el && el.__favFixReal) || real; tip.innerHTML = buildTipHtml(liveReal); if (!liveReal._pending) stopTipRefresh(); }, cfg('tooltipRefreshMs')); } } function hideTip() { var tip = getTip(); stopTipRefresh(); tip.style.opacity = '0'; setTimeout(function () { if (tip.style.opacity === '0') tip.style.display = 'none'; }, 150); } // ─── Per-card menu injection ──────────────────────────────────────── // // bilibili's own three-dot dropdown on each card (the "更多操作" menu) // is the most natural place for per-item actions — copy AV/BV/full // info, open the cover in a new tab, jump to a mirror site, clear // this item's cache. cerenkov's approach (mouseenter on the trigger, // delay 500ms, find the dynamically-rendered popper, append) works // well; we port it to vanilla and add dedup via data-fav-fix-key // so each item is only injected once per session. function buildPlainInfo(real) { var av = real.oid != null ? String(real.oid) : (real.bvid ? bvToAv(real.bvid) : null); var bv = real.bvid || (av ? avToBv(av) : null); // tag(field) → '[src]' or '' — inline source attribution. function tag(field) { var s = real['_src_' + field]; return s ? ' [' + s + ']' : ''; } var lines = []; lines.push('【fav-fix】数据来自收藏时的快照'); lines.push('────────────'); lines.push('标题:' + (real.title || '(无)') + tag('title')); // Mirror of the tooltip's cover row (AGENTS.md invariant: the two must // not diverge). Deliberately static — the copied text is a snapshot, // so the live three-way suppression read would be stale by the time // anyone reads the clipboard. if (real.cover) lines.push('封面:(已恢复)' + tag('cover')); else if (real._cover_pending) lines.push('封面:(后台重试中)'); if (real.upper) lines.push('UP 主:' + (real.upper.name || '(无)') + (real.upper.mid ? ' UID ' + real.upper.mid : '') + tag('upper')); if (av) lines.push('AV:av' + av); if (bv) lines.push('BV:' + bv + tag('bvid')); if (real.duration || real.playback_desc) lines.push('时长:' + (real.playback_desc || fmtDuration(real.duration)) + tag('duration')); if (real.cnt_info) { var c = real.cnt_info, bits = []; if (c.play != null) bits.push('播放 ' + c.play); if (c.danmaku != null) bits.push('弹幕 ' + c.danmaku); if (c.thumb_up != null) bits.push('点赞 ' + c.thumb_up); if (c.coin != null) bits.push('投币 ' + c.coin); if (c.reply != null) bits.push('评论 ' + c.reply); if (c.collect != null) bits.push('收藏 ' + c.collect); if (bits.length) lines.push('数据:' + bits.join(' · ') + tag('cnt_info')); } if (real.tid != null) lines.push('分区 TID:' + real.tid + tag('tid')); // Use the same fallback chain + source as the hover tooltip // (pickPubTs / pickPubSrc) so copied text matches what was shown. var _pubTs = pickPubTs(real); if (_pubTs) { var _pubSrc = pickPubSrc(real); lines.push('投稿:' + new Date(_pubTs * 1000).toLocaleString() + (_pubSrc ? ' [' + _pubSrc + ']' : '')); } if (real.fav_time) lines.push('收藏:' + new Date(real.fav_time * 1000).toLocaleString() + tag('fav_time')); if (real.intro && real.intro.trim()) lines.push('简介:' + real.intro + tag('intro')); if (real.link) lines.push('原 link:' + real.link); lines.push('────────────'); if (real._sources) lines.push('数据来源:' + real._sources.join('、')); // Same split as buildTipHtml's footer: show "queried but empty" // sources so the user copying full info also sees the full chain. var pAttempted = real._attempted || real._attempted_3rd || []; var pMiss = pAttempted.filter(function (s) { return !real._sources || real._sources.indexOf(s) === -1; }); if (pMiss.length) lines.push('已查询但无记录:' + pMiss.join('、')); return lines.join('\n'); } function buildMenuItems(hit, real) { var av = real.oid != null ? String(real.oid) : (real.bvid ? bvToAv(real.bvid) : null); // Re-read the freshest cache entry. The `real` captured when injectCardMenu // bound this card's dropdown can be STALE: a pending card may have since // recovered (or vice-versa), and the new-UI popper handler keeps the // original closure. av/bv identity is stable, but _pending / title / // sources may have changed — build from the live entry so a recovered // card never shows "立即重试" and a pending card never copies an empty // snapshot. (buildMenuItems runs fresh on each menu open, so this stays // current.) if (av) { var _lc = loadCache(av); if (_lc) real = _lc; } var bv = real.bvid || (av ? avToBv(av) : null); // Same live-read reasoning as the loadCache line above, for the same // reason: the user may have stopped or resumed this av since the // dropdown was bound, and the closure would show the stale action. var stopped = av ? isNoRetryUser(av) : false; var items = []; // Retry controls, mutually exclusive by card state (the existing // 立即重试 / 清缓存并重抓 split, now with the stop switch folded in): // stopped → 恢复重试 only. Offering 立即重试 next // to it would be two buttons for one // decision the user already made. // _pending / _cover_pending → 立即重试 (re-arm now) + 停止重试. // Partial cards carry NO badge (the // cover is chased quietly in the // background), so the menu — plus the // amber outline and the tooltip's // 封面 row — is their entry point. // kickManualRetry re-derives the // cover-only rule from loadCache, so // a lone partial av is not retired by // the title it already has. if (av && stopped) items.push({ key: 'retry', label: '恢复重试', onClick: function () { resumeRetryForAv(av); } }); else if (av && (real._pending || real._cover_pending)) { items.push({ key: 'retry', label: '立即重试', onClick: function () { kickManualRetry(av); } }); items.push({ key: 'stop-retry', label: '停止重试', onClick: function () { stopRetryForAv(av); } }); } if (av) items.push({ key: 'cp-av', label: '复制 AV 号', successMsg: '已复制 av' + av + ' 至剪贴板', onClick: function () { GM_setClipboard('av' + av, 'text'); } }); if (bv) items.push({ key: 'cp-bv', label: '复制 BV 号', successMsg: '已复制 ' + bv + ' 至剪贴板', onClick: function () { GM_setClipboard(bv, 'text'); } }); items.push({ key: 'cp-info', label: '复制完整信息', successMsg: '完整信息已复制至剪贴板', onClick: function () { GM_setClipboard(buildPlainInfo(real), 'text'); } }); // Cover URL may come from an untrusted 3rd-party source — only offer // "open cover" for an absolute http(s) URL (isHttpUrl rejects // javascript:/data: that GM_openInTab would otherwise navigate to). var coverUrl = real.cover ? String(real.cover).replace(/^http:\/\//, 'https://') : ''; if (coverUrl && !COVER_PLACEHOLDER_RE.test(coverUrl) && isHttpUrl(coverUrl)) items.push({ key: 'open-cover', label: '查看原始封面', onClick: function () { // Re-check at click time (defensive; coverUrl is captured above). if (!isHttpUrl(coverUrl)) { toast('封面链接异常,已拦截', 'warn'); return; } GM_openInTab(coverUrl, { active: true, insert: true, setParent: true }); } }); if (av) items.push({ key: 'open-bp', label: '在 biliplus 查看', onClick: function () { GM_openInTab('https://www.biliplus.com/video/av' + av + '/', { active: true, insert: true, setParent: true }); } }); // Hidden on _pending cards: they have no real cached snapshot to clear // (just a placeholder stub), so "清缓存并重抓" is a heavier, noisier // duplicate of "立即重试" (full-page foreground re-resolve + spinner // re-flash vs. a quiet android re-walk). Hidden on _cover_pending // (partial) cards for the opposite reason: they DO hold a real // title/UP/date snapshot, and clearing it discards data the card is // already displaying just to re-chase an image 立即重试 re-samples // without touching. The retry-flavored actions are mutually exclusive // by card state: 立即重试 for pending/partial, 清缓存并重抓 for // recovered/terminal cards where nuking a possibly-wrong snapshot // actually means something. (real is the live loadCache re-read above, // so this self-corrects as the card transitions pending↔recovered.) // Also hidden while the av is user-stopped: "恢复重试" already IS a // clear-and-refetch (resumeRetryForAv drops the caches and re-runs // patchOnce), and a second button that clears the cache while every // network path stays suppressed would only downgrade a patched card to // a bare pending stub. if (av && !real._pending && !real._cover_pending && !stopped) items.push({ // Label kept short to avoid wrapping inside bilibili's // fixed-width card-menu popper. "清除本条缓存并重新抓取" (11 // chars) wrapped to two lines and the second line overflowed // the popper bounds — see git log around this label change. key: 'clear-cache', label: '清缓存并重抓', successMsg: '缓存已清除,重新抓取中', onClick: function () { // An 'auto' 停止重试 record would silently gut this action: // the caches would be dropped and then resolveItems would skip // every network source, leaving the card worse off than before // the click. An explicit user action overrides what the loop // concluded, so drop that record too. (A 'user' record cannot be // present — this item is not offered for stopped cards.) clearNoRetry(av); // Cache nuke for this av (GM item + in-memory rows + page // promises). Shared with forceRefetch() via dropItemCaches so // both paths really re-fetch instead of re-merging stale rows. dropItemCaches(av); // The hit captured in this closure is from whenever // injectCardMenu was last called — possibly stale by now if // bilibili's SPA re-rendered the card. Operating on stale // refs paints overlays into detached subtrees that the user // never sees. Re-resolve to the LIVE element by hunting for // the card whose link mentions this av's avid or bvid. var bvid = null; try { bvid = avToBv(av); } catch (e) { /* invalid av */ } var sel = 'a[href*="/video/av' + av + '"]' + (bvid ? ', a[href*="/video/' + bvid + '"]' : ''); var liveContainer = null, liveImg = null; document.querySelectorAll(sel).forEach(function (a) { if (liveContainer) return; // Resolve to the WHOLE card (same scope findInvalidContainers // now uses) so the title reset, mark-clearing, and the // __favFixReal tooltip binding all act on the node markPatched // actually touched — the cover-only sub-div never held the // title leaf, which is why the reset used to miss it. var card = a.closest(CARD_SELECTOR); if (card) { liveContainer = card; liveImg = card.querySelector('img[src*="' + PLACEHOLDER_COVER_TOKEN + '"]') || card.querySelector('img'); return; } var n = a; while (n && n !== document.body) { var img = n.querySelector && n.querySelector('img'); if (img) { liveContainer = n; liveImg = img; return; } n = n.parentElement; } }); // Fallback to closure hit if live lookup misses (defensive // — should not happen for a card the user just clicked). if (!liveContainer) liveContainer = hit && hit.container; if (!liveImg) liveImg = hit && hit.img; // Tear down any prior overlay on the LIVE img (rapid // double-click stacks spinners otherwise). if (liveImg) clearLoading({ img: liveImg, container: liveContainer }); // Reset img: restore the placeholder src if we swapped it, // strip inline styles applied by markPatched / _no_source, // drop marker attrs so findInvalidContainers re-detects. if (liveImg) { var orig = liveImg.getAttribute('data-fav-fix-original'); if (orig) { liveImg.src = orig; liveImg.removeAttribute('data-fav-fix-original'); } liveImg.style.outline = ''; liveImg.style.outlineOffset = ''; liveImg.style.opacity = ''; liveImg.style.filter = ''; liveImg.removeAttribute('data-fav-fix-marked'); liveImg.removeAttribute('data-fav-fix-loading'); // Hygiene: the item is hidden for partial cards, but the // card's state can shift between menu render and click — // a full reset must not leave the amber partial marker. liveImg.removeAttribute('data-fav-fix-partial'); } // Reset container: revert any title text we wrote // (recovered title or "(视频已删除)"), drop marker attrs. // Keep `data-fav-fix-tipbound` + bound listeners — they // read __favFixReal live, so the next markPatched picks up // the new payload automatically. if (liveContainer) { var prevReal = liveContainer.__favFixReal; var patchedTitles = ['(视频已删除)']; if (prevReal && prevReal.title) patchedTitles.push(prevReal.title); var walker = document.createTreeWalker(liveContainer, NodeFilter.SHOW_TEXT, null); var node; while ((node = walker.nextNode())) { var t = node.nodeValue.trim(); if (patchedTitles.indexOf(t) !== -1) { node.nodeValue = node.nodeValue.replace(t, INVALID_TITLE); } } patchedTitles.forEach(function (pt) { liveContainer.querySelectorAll('[title="' + pt + '"]').forEach(function (el) { el.setAttribute('title', INVALID_TITLE); }); }); liveContainer.removeAttribute('data-fav-fix-marked'); liveContainer.removeAttribute('data-fav-fix-loading'); liveContainer.removeAttribute('data-fav-fix-partial'); liveContainer.__favFixReal = null; } // Paint spinner on the LIVE img immediately — user sees the // click took effect before any network round-trip. if (liveImg) markLoading({ img: liveImg, container: liveContainer }); // Fire patchOnce immediately rather than going through // schedule()'s 400ms debounce — the user just told us they // want action now. patchOnce dedups via `data-fav-fix-loading` // so the spinner we just painted won't double-stack. patchOnce().catch(function (e) { warn('clear-cache patchOnce threw:', e); }); } }); return items; } function appendMenuItems(popper, items, opts) { // opts: { itemClass, itemTag } // Clear-then-append, NOT dedup-by-key. bilibili's new-UI dropdown reuses // / pools popper nodes across cards (observed: the same popper, or a // small pool, serves whichever card is hovered). The old dedup-by-key // kept the FIRST card's items when a different card reused the popper — // so a card could show a previous card's stale av bindings, or (once the // item set became state-specific) a recovered card showing 立即重试 / a // pending card showing 清缓存并重抓. Removing our prior items first // guarantees the popper reflects ONLY the current card. The handler // re-runs on every trigger mouseenter, so this stays fresh; it only ever // touches our own [data-fav-fix-key] nodes, never bilibili's. Array.from(popper.querySelectorAll('[data-fav-fix-key]')).forEach(function (el) { el.remove(); }); items.forEach(function (it) { var el = document.createElement(opts.itemTag || 'div'); el.className = opts.itemClass + ' bili-fav-fix-menu-item'; el.setAttribute('data-fav-fix-key', it.key); el.textContent = it.label; el.style.cursor = 'pointer'; // Hard cap on item width — bilibili's popper is fixed-width and // long labels otherwise wrap onto a 2nd line that bleeds past // the popper edge. nowrap + ellipsis truncates cleanly inside // the popper if someone ever adds a label past ~6 Chinese chars. el.style.whiteSpace = 'nowrap'; el.style.overflow = 'hidden'; el.style.textOverflow = 'ellipsis'; el.addEventListener('click', function (e) { e.stopPropagation(); try { it.onClick(); } catch (err) { warn('menu', it.key, 'threw', err); } // Confirmation goes to the toast (bottom-center), NOT back // into the menu cell. Previous in-place text replacement // (label = successMsg for 1.5s) caused the menu to bleed // out of bounds because the popper width is fixed and most // successMsgs were 10+ chars. Toast has its own width logic // (max-width + word-wrap) so it handles long strings. if (it.successMsg) toast(it.successMsg, 'ok'); }); popper.appendChild(el); }); } function injectCardMenu(hit, real) { if (!hit.container) return; // New UI: trigger is .bili-card-dropdown inside the card; the // popper is rendered LATE (appended to body, not the card) and // gets class .visible only while it's open. Wait 500ms after // mouseenter to give the popper time to render before we look. var trigger = hit.container.querySelector('.bili-card-dropdown'); if (trigger && !trigger.__favFixBound) { trigger.__favFixBound = true; trigger.addEventListener('mouseenter', function () { // Card mouseenter already fired our hover tooltip; close it // so it doesn't overlap with the B 站 native dropdown popper. // (mouseleave on the card never fires because the three-dot // is INSIDE the card.) hideTip(); setTimeout(function () { var popper = document.querySelector('.bili-card-dropdown-popper.visible'); if (!popper) { log('new-UI popper not visible at inject time'); return; } hideTip(); // belt-and-suspenders: kill tooltip again when popper appears appendMenuItems(popper, buildMenuItems(hit, real), { itemClass: 'bili-card-dropdown-popper__item', itemTag: 'div' }); }, 500); }); return; } // Old UI: