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: