// ==UserScript== // @name V2EX Tweaks // @namespace https://tampermonkey.net/ // @version 2.1.2 // @description V2EX 日常增强:回复按引用关系重组为嵌套树并合并所有分页;自动标记未读新回复,j/k 键快速跳转;高赞回复一键全屏浏览;Base64 自动解码内联展示;每日签到静默后台完成;Imgur 图片自动走代理加载。 // @author you // @match https://v2ex.com/* // @match https://www.v2ex.com/* // @grant GM_addStyle // @grant GM_setClipboard // @grant GM_getValue // @grant GM_setValue // @grant GM_notification // @run-at document-end // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/572364/V2EX%20Tweaks.user.js // @updateURL https://update.greasyfork.icu/scripts/572364/V2EX%20Tweaks.meta.js // ==/UserScript== (() => { 'use strict'; // ========================= // 0) 通用小工具 // ========================= const log = (...args) => console.log('[V2EX-Enhance]', ...args); const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; function notify(title, text, timeout = 3500) { try { GM_notification({ title, text, timeout }); } catch (_) {} } function ymdLocal() { const d = new Date(); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } function isTopicPage() { return /^\/t\/\d+/.test(location.pathname); } // ========================= // 1) 样式(合并注入) // ========================= GM_addStyle(` /* ===== 楼层树(Hacker News Style)===== */ :root { --indent-width: 16px; --line-color: #ebebeb; --line-hover: #a8beff; --bg-hover: #fafbff; --new-accent: #4a7af0; --new-accent-soft: rgba(74, 122, 240, 0.08); --bg-new: #edf2ff; } .box { padding-bottom: 0 !important; } /* ── 树形缩进容器 ── */ .reply-children { margin-left: var(--indent-width); border-left: 2px solid var(--line-color); transition: border-color 0.2s, opacity 0.2s; position: relative; } /* 折叠状态 */ .reply-children.is-collapsed { display: none; } /* 可折叠的缩进线:hover 时变蓝,提示可点击 */ /* cursor: pointer 只作用于左侧 20px 伪元素,与 JS 的判断区域对齐 */ .reply-children.collapsible { cursor: auto; } .reply-children.collapsible::before { content: ''; position: absolute; left: -2px; /* 盖住 2px 的 border 本身 */ top: 0; bottom: 0; width: 20px; cursor: pointer; } .reply-children.collapsible:hover { border-left-color: var(--line-hover); } /* 禁止子节点的 click 冒泡到父缩进线 */ .reply-children .reply-children { pointer-events: auto; } /* ── 折叠指示器 badge ── */ .reply-collapsed-hint { display: none; font-size: 11px; color: #999; padding: 3px 8px 3px calc(var(--indent-width) + 4px); cursor: pointer; user-select: none; transition: color 0.15s; } .reply-collapsed-hint:hover { color: var(--new-accent); } .reply-children.is-collapsed + .reply-collapsed-hint { display: block; } /* ── 单条回复 ── */ .reply-wrapper .cell { padding: 6px 8px !important; border-bottom: 1px solid #f5f5f5 !important; background: transparent; transition: background 0.12s; } .reply-wrapper > .cell:hover { background-color: var(--bg-hover); } .reply-wrapper .avatar { display: block; width: 100% !important; min-width: 0 !important; max-width: 100% !important; height: auto !important; min-height: 0 !important; max-height: none !important; aspect-ratio: 1 / 1; object-fit: cover; flex: none; max-inline-size: 100% !important; border-radius: 4px; margin: 0 auto; } .reply_content { font-size: 14px; line-height: 1.5; margin-top: 2px; } .ago, .no, .fade { font-size: 11px !important; } /* ── 未读新回复高亮 ── */ .reply-new > .cell { background: linear-gradient( 90deg, rgba(74, 122, 240, 0.10) 0%, rgba(74, 122, 240, 0.04) 50%, transparent 100% ) !important; border-left: 3px solid #4a7af0 !important; padding-left: 5px !important; animation: new-reply-flash 0.6s ease-out; } @keyframes new-reply-flash { 0% { background-color: rgba(74, 122, 240, 0.18); } 100% { background-color: transparent; } } /* ── NEW 角标 ── 改为放在楼层号之后(strong 的右侧),脱离加粗 strong 上下文 使用 outline 风格,不抢眼但清晰可辨 */ .new-badge { display: inline-block; font-size: 9px; font-weight: 700; color: var(--new-accent); background: transparent; border: 1px solid rgba(74, 122, 240, 0.45); border-radius: 3px; padding: 0 3px; line-height: 14px; height: 14px; /* 放在 strong 右侧,与 .ago 同排 */ margin-left: 5px; margin-right: 2px; vertical-align: middle; letter-spacing: 0.5px; position: relative; top: -1px; } /* ── 新回复数量提示条 ── */ #v2ex-new-count-bar { padding: 6px 12px; background: linear-gradient(90deg, #eef2ff 0%, #f8f9ff 100%); border-bottom: 1px solid #dde5ff; border-radius: 4px 4px 0 0; font-size: 12px; color: #6680cc; display: flex; align-items: center; gap: 6px; user-select: none; } #v2ex-new-count-bar .ncb-dot { width: 6px; height: 6px; background: var(--new-accent); border-radius: 50%; flex-shrink: 0; } #v2ex-new-count-bar strong { color: var(--new-accent); font-weight: 700; } #v2ex-new-count-bar .ncb-hint { margin-left: auto; opacity: 0.5; font-size: 11px; } #v2ex-loading-bar { padding: 8px; background: #fff; text-align: center; border-bottom: 1px solid #eee; font-size: 12px; color: #999; } .cell[style*="text-align: center"], #bottom-pagination, a[name="last_page"] { display: none; } /* ===== j/k 导航 HUD ===== */ #v2ex-nav-hud { position: fixed; bottom: 28px; right: 28px; z-index: 99998; display: flex; align-items: center; gap: 6px; padding: 6px 14px 6px 10px; background: rgba(22, 27, 46, 0.90); color: #dde4ff; border-radius: 20px; font-size: 12px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0.3px; backdrop-filter: blur(8px); box-shadow: 0 4px 20px rgba(0,0,0,0.25), 0 0 0 1px rgba(255,255,255,0.06); pointer-events: none; opacity: 0; transform: translateY(8px) scale(0.97); transition: opacity 0.18s ease, transform 0.18s ease; } #v2ex-nav-hud.visible { opacity: 1; transform: translateY(0) scale(1); } #v2ex-nav-hud .hud-arrow { font-size: 13px; opacity: 0.7; } #v2ex-nav-hud .hud-label { font-size: 10px; font-weight: 700; letter-spacing: 1px; color: #7fa8ff; opacity: 0.8; } #v2ex-nav-hud .hud-count { font-weight: 600; color: #c5d5ff; font-variant-numeric: tabular-nums; } #v2ex-nav-hud .hud-sep { opacity: 0.2; } #v2ex-nav-hud .hud-hint { opacity: 0.35; font-size: 11px; font-family: monospace; } /* 键盘导航当前高亮 */ .reply-nav-active > .cell { outline: 2px solid rgba(74, 122, 240, 0.50) !important; outline-offset: -2px; transition: outline 0.15s ease; } /* ===== Base64 原地解码 ===== */ /* 包裹容器:允许长 URL 换行,避免溢出 */ .v2-b64-wrap { word-break: break-all; } /* URL → 可点击链接,颜色与脚本 --new-accent 蓝色系一致 */ .v2-b64-link { color: #4a7af0 !important; text-decoration: none; } .v2-b64-link:hover { color: #3060d8 !important; text-decoration: underline; } /* 纯文本 / JSON → 带点状下划线,hover 提示原文 */ .v2-b64-plain { text-decoration: underline; text-decoration-style: dotted; text-decoration-color: #8aa8f8; text-underline-offset: 2px; cursor: help; } /* b64 来源角标:放在内容左侧,与脚本蓝色体系协调 */ .v2-b64-mark { display: inline-block; font-size: 9px; font-weight: 700; font-style: normal; color: #8aa8f8; border: 1px solid #d0defe; border-radius: 2px; padding: 0 3px; line-height: 13px; vertical-align: middle; position: relative; top: -1px; margin-right: 4px; cursor: default; user-select: none; text-decoration: none !important; letter-spacing: 0.2px; } /* ===== 高赞阅览室 ===== */ #v2ex-hot-btn { display: inline-block; margin-left: 10px; padding: 2px 10px; background-color: #f0f2f5; color: #ccc; border-radius: 12px; font-size: 12px; cursor: pointer; transition: all 0.2s ease; line-height: 1.5; border: 1px solid transparent; } #v2ex-hot-btn:hover { background-color: #e3e8f0; color: #555; border-color: #ccc; } #hot-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(240, 242, 245, 0.95); z-index: 99999; display: flex; justify-content: center; align-items: flex-start; overflow-y: scroll; opacity: 0; visibility: hidden; transition: opacity 0.15s ease; } #hot-overlay.active { opacity: 1; visibility: visible; } .hot-container { width: 92%; max-width: 1000px; margin: 30px auto 80px auto; background: #fff; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,0.08); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; overflow: hidden; padding: 0; } .hot-card { background: #fff; padding: 14px 24px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: column; transition: background 0.1s; } .hot-card:last-child { border-bottom: none; } .hot-card:hover { background: #fafafa; } .rank-1 { border-left: 3px solid #faad14; background: linear-gradient(90deg, #fffdf5 0%, #fff 100%); } .rank-2 { border-left: 3px solid #ccc; } .rank-3 { border-left: 3px solid #d48806; } .card-header-row { display: flex; align-items: center; margin-bottom: 6px; font-size: 12px; } .user-avatar { display: block; width: 18px; min-width: 18px; max-width: 18px; height: 18px; min-height: 18px; max-height: 18px; aspect-ratio: 1 / 1; object-fit: cover; flex: none; max-inline-size: none; border-radius: 3px; margin-right: 8px; } .user-name { font-weight: 600; color: #444; text-decoration: none; margin-right: 8px; } .floor-tag { background: #f5f5f5; color: #aaa; padding: 0 5px; border-radius: 3px; margin-right: 10px; cursor: pointer; font-size: 11px; height: 18px; line-height: 18px; } .floor-tag:hover { background: #e6f7ff; color: #1890ff; } .time-tag { color: #ddd; margin-right: auto; transform: scale(0.9); transform-origin: left; } .likes-pill { font-size: 12px; font-weight: 600; padding: 0 6px; } .rank-1 .likes-pill { color: #faad14; } .rank-normal .likes-pill { color: #ff6b6b; opacity: 0.8; } .card-content { font-size: 14px; line-height: 1.6; color: #222; word-wrap: break-word; padding-left: 26px; } .card-content p { margin: 0 0 5px 0; } .card-content img { max-width: 100%; max-height: 350px; border-radius: 4px; margin: 5px 0; display: block; cursor: zoom-in; } .card-content pre { padding: 10px; background: #f8f8f8; border: 1px solid #eee; border-radius: 3px; font-size: 12px; margin: 8px 0; } #hot-overlay::-webkit-scrollbar { width: 4px; } #hot-overlay::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; } `); // ========================= // 2) 功能A:每日自动签到 // ========================= const Daily = (() => { const CFG = { dailyPage: '/mission/daily', delayMinMs: 1500, delayMaxMs: 3800, storeKey: 'v2ex_daily_check_ymd_v2', notify: true, }; function isLoggedIn() { const hasSignOut = !!document.querySelector('a[href="/signout"]'); const hasSignIn = !!document.querySelector('a[href="/signin"]'); return hasSignOut || !hasSignIn; } async function fetchText(url) { const res = await fetch(url, { method: 'GET', credentials: 'include', cache: 'no-store', }); if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); return await res.text(); } function parseHtml(html) { return new DOMParser().parseFromString(html, 'text/html'); } function alreadyRedeemed(doc) { const text = doc.body?.innerText || ''; return /已领取|已经领取|每日登录奖励已领取|redeemed|already redeemed|已完成/.test(text); } function findRedeemUrl(doc) { const a = doc.querySelector('a[href^="/mission/daily/redeem"]'); if (a?.getAttribute('href')) return a.getAttribute('href'); const btn = doc.querySelector('input[type="button"][onclick*="redeem"], input[value^="领取"][onclick]'); if (btn) { const onclick = btn.getAttribute('onclick') || ''; const m = onclick.match(/'([^']+)'/); if (m?.[1]) return m[1]; } const any = [...doc.querySelectorAll('[onclick]')].find(el => (el.getAttribute('onclick') || '').includes('/mission/daily/redeem')); if (any) { const s = any.getAttribute('onclick') || ''; const m = s.match(/'([^']+)'/); if (m?.[1]) return m[1]; } return null; } async function run() { if (!CFG.notify) return; if (!isLoggedIn()) return; const today = ymdLocal(); const last = GM_getValue(CFG.storeKey, ''); if (last === today) return; GM_setValue(CFG.storeKey, today); await sleep(randInt(CFG.delayMinMs, CFG.delayMaxMs)); const html1 = await fetchText(CFG.dailyPage); const doc1 = parseHtml(html1); if (alreadyRedeemed(doc1)) { if (CFG.notify) notify('V2EX 签到', '今日奖励已领取(或已完成)'); return; } const redeemUrl = findRedeemUrl(doc1); if (!redeemUrl) { if (CFG.notify) notify('V2EX 签到', '未找到领取按钮/链接(可能结构变更)'); return; } const html2 = await fetchText(redeemUrl); const doc2 = parseHtml(html2); if (alreadyRedeemed(doc2) || /奖励/.test(doc2.body?.innerText || '')) { if (CFG.notify) notify('V2EX 签到', '领取成功 ✅'); } else { if (CFG.notify) notify('V2EX 签到', '已发起领取,请打开 /mission/daily 确认'); } } function boot() { window.addEventListener('load', () => { setTimeout(() => { run().catch(err => { GM_setValue(CFG.storeKey, ''); if (CFG.notify) notify('V2EX 签到', `失败:${err?.message || err}`); }); }, 800); }); } return { boot }; })(); // ========================= // 3) 功能B:Base64 自动解码 // ========================= const B64 = (() => { const CFG = { MIN_LEN: 8, TARGET_SELECTORS: ['.topic_content', '.reply_content'], EXCLUDE_LIST: [ 'boss', 'bilibili', 'Bilibili', 'Encrypto', 'encrypto', 'Window10', 'airpords', 'Windows7', ], }; const BASE64_RE = /[A-Za-z0-9+/=]+/g; // ── 内容类型判断 ────────────────────────────────────────── function detectType(s) { if (/^https?:\/\//i.test(s)) return 'url'; try { JSON.parse(s); return 'json'; } catch (_) {} return 'text'; } // ── 解码(与原版逻辑一致)──────────────────────────────── function customEscape(str) { return str.replace( /[^a-zA-Z0-9_.!~*'()-]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}` ); } function tryDecode(text) { if (text.length % 4 !== 0) return null; if (text.length <= CFG.MIN_LEN) return null; if (CFG.EXCLUDE_LIST.includes(text)) return null; if (text.includes('=')) { const pi = text.indexOf('='); if (pi !== text.length - 1 && pi !== text.length - 2) return null; } try { const d = decodeURIComponent(customEscape(window.atob(text))); if (!/[A-Za-z0-9一-鿿]/.test(d)) return null; return d; } catch (_) { return null; } } // ── 构建替换节点 ────────────────────────────────────────── // // URL → b64decoded // text → b64decoded // JSON → 同 text,title 附加格式化 JSON // // 返回 null 表示不应替换(URL 格式验证失败等) // function makeReplacement(raw, decoded) { const type = detectType(decoded); const wrap = document.createElement('span'); wrap.className = 'v2-b64-wrap'; if (type === 'url') { // 用 URL 构造函数做严格校验 let href; try { const u = new URL(decoded); if (u.protocol !== 'https:' && u.protocol !== 'http:') return null; href = u.href; } catch (_) { return null; } const a = document.createElement('a'); a.className = 'v2-b64-link'; a.href = href; a.target = '_blank'; a.rel = 'noreferrer noopener'; a.title = href; // 鼠标悬停显示完整 URL(浏览器状态栏也会显示) a.textContent = decoded; wrap.appendChild(a); } else { // text / json:显示解码内容,title 提示原始 base64 let titleStr = `base64 解码\n原文:${raw}`; if (type === 'json') { try { titleStr += `\n\n${JSON.stringify(JSON.parse(decoded), null, 2)}`; } catch (_) {} } const span = document.createElement('span'); span.className = 'v2-b64-plain'; span.textContent = decoded; span.title = titleStr; wrap.appendChild(span); } // 来源角标:插到最前面(左侧) const mark = document.createElement('span'); mark.className = 'v2-b64-mark'; mark.textContent = 'b64'; mark.title = `由 base64 解码\n原文:${raw}`; wrap.prepend(mark); return wrap; } // ── 扫描单个内容块 ──────────────────────────────────────── function processContent(contentEl) { if (!contentEl || contentEl.dataset.v2b64scanned === '1') return; const excludeTextList = [ ...contentEl.getElementsByTagName('a'), ...contentEl.getElementsByTagName('img'), ].map((el) => el.outerHTML); const walker = document.createTreeWalker( contentEl, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node.nodeValue || node.nodeValue.length <= CFG.MIN_LEN) return NodeFilter.FILTER_REJECT; const p = node.parentElement; if (p.closest('.v2-b64-wrap')) return NodeFilter.FILTER_REJECT; if (p.closest('a, img')) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; }, } ); const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); nodes.forEach((node) => { const text = node.nodeValue; let last = 0; const frag = document.createDocumentFragment(); let changed = false; BASE64_RE.lastIndex = 0; let m; while ((m = BASE64_RE.exec(text)) !== null) { const candidate = m[0]; if (excludeTextList.some((ex) => ex.includes(candidate))) continue; const decoded = tryDecode(candidate); if (!decoded) continue; const replacement = makeReplacement(candidate, decoded); if (!replacement) continue; // URL 验证失败,跳过 changed = true; frag.appendChild(document.createTextNode(text.slice(last, m.index))); frag.appendChild(replacement); last = m.index + candidate.length; } if (changed) { frag.appendChild(document.createTextNode(text.slice(last))); node.parentNode.replaceChild(frag, node); } }); contentEl.dataset.v2b64scanned = '1'; } // ── 批量扫描 + MutationObserver 监听动态内容 ───────────── function scanAll() { for (const sel of CFG.TARGET_SELECTORS) { document.querySelectorAll(sel).forEach(processContent); } } let scheduled = false; const scheduleScan = () => { if (scheduled) return; scheduled = true; setTimeout(() => { scheduled = false; scanAll(); }, 60); }; function boot() { if (!isTopicPage()) return; scanAll(); const root = document.querySelector('#Main') || document.body; new MutationObserver((mutations) => { for (const mut of mutations) { if (mut.type === 'childList' && (mut.addedNodes?.length || mut.removedNodes?.length)) { scheduleScan(); break; } } }).observe(root, { childList: true, subtree: true }); } return { boot }; })(); // ========================= // 4) 功能C:楼层树 + 多页加载 // ========================= const ThreadTree = (() => { function parseReplyCell(cell, idx) { if (!cell || !cell.id || !cell.id.startsWith('r_')) return null; const replyId = cell.id.replace('r_', ''); const contentEl = cell.querySelector('.reply_content'); const authorEl = cell.querySelector('strong a'); const floorEl = cell.querySelector('.no'); const avatarEl = cell.querySelector('img.avatar'); if (!contentEl || !authorEl || !floorEl) return null; const memberName = authorEl.innerText; const memberLink = authorEl.href; const memberAvatar = avatarEl ? avatarEl.src : ''; const content = contentEl.innerText; const floor = floorEl.innerText; const floorNum = parseInt(floor, 10); const likes = parseInt(cell.querySelector('span.small')?.innerText || '0', 10); const memberNameMatches = Array.from(content.matchAll(/@([a-zA-Z0-9]+)/g)); const refMemberNames = memberNameMatches.length > 0 ? memberNameMatches.map(([, name]) => name) : undefined; const floorMatches = Array.from(content.matchAll(/#(\d+)/g)); const refFloors = floorMatches.length > 0 ? floorMatches.map(([, f]) => f) : undefined; return { element: cell, id: replyId, index: idx, memberName, memberLink, memberAvatar, content, floor, floorNum, likes, refMemberNames, refFloors, children: [], }; } function extractRepliesFromDoc(doc) { const cells = Array.from(doc.querySelectorAll('div.cell[id^="r_"]')); return cells.map((cell, idx) => parseReplyCell(cell, idx)).filter(Boolean); } function inferParent(reply, allReplies) { const { refMemberNames, refFloors, index, floorNum } = reply; if (!refMemberNames || refMemberNames.length === 0) return null; for (let j = index - 1; j >= 0; j--) { const r = allReplies[j]; if (r.memberName.toLowerCase() === refMemberNames[0].toLowerCase()) { let parentIdx = j; const firstRefFloor = refFloors?.[0]; if (firstRefFloor && parseInt(firstRefFloor, 10) !== r.floorNum) { const targetIdx = allReplies.slice(0, j).findIndex( (data) => data.floorNum === parseInt(firstRefFloor, 10) && data.memberName.toLowerCase() === refMemberNames[0].toLowerCase() ); if (targetIdx >= 0) { parentIdx = targetIdx; } } if (allReplies[parentIdx].floorNum < floorNum) { return allReplies[parentIdx]; } return null; } } if (refFloors && refFloors.length > 0) { const targetFloor = parseInt(refFloors[0], 10); if (targetFloor < floorNum) { return allReplies.find(r => r.floorNum === targetFloor); } } return null; } function renderTree(flatReplies, container) { const roots = []; flatReplies.forEach(r => { r.children = []; }); flatReplies.forEach(r => { const parent = inferParent(r, flatReplies); if (parent) parent.children.push(r); else roots.push(r); }); const fragment = document.createDocumentFragment(); function appendNode(reply, parentContainer) { const wrapper = document.createElement('div'); wrapper.className = 'reply-wrapper'; reply.element.classList.remove('inner'); wrapper.appendChild(reply.element); if (reply.children.length > 0) { const childCount = reply.children.length; const childrenContainer = document.createElement('div'); childrenContainer.className = 'reply-children collapsible'; reply.children.forEach(child => appendNode(child, childrenContainer)); // 折叠指示器(collapsed 时显示在 children 之后) const collapsedHint = document.createElement('div'); collapsedHint.className = 'reply-collapsed-hint'; collapsedHint.textContent = `▶ 展开 ${childCount} 条回复`; // 点击缩进线 → 折叠 childrenContainer.addEventListener('click', (e) => { // 仅响应直接点击缩进线区域(左侧 16px 内),不影响子回复交互 const rect = childrenContainer.getBoundingClientRect(); if (e.clientX - rect.left > 20) return; e.stopPropagation(); toggleCollapse(childrenContainer, collapsedHint, childCount); }); // 点击折叠提示 → 展开 collapsedHint.addEventListener('click', () => { toggleCollapse(childrenContainer, collapsedHint, childCount); }); wrapper.appendChild(childrenContainer); wrapper.appendChild(collapsedHint); } parentContainer.appendChild(wrapper); } function toggleCollapse(childrenContainer, hint, count) { const isNowCollapsed = childrenContainer.classList.toggle('is-collapsed'); hint.textContent = isNowCollapsed ? `▶ 展开 ${count} 条回复` : `▼ 折叠 ${count} 条回复`; // 展开后重置 hint 文字(短暂延迟后恢复默认,不常驻占位) if (!isNowCollapsed) { setTimeout(() => { hint.textContent = `▶ 展开 ${count} 条回复`; }, 1800); } } roots.forEach(r => appendNode(r, fragment)); container.innerHTML = ''; container.appendChild(fragment); } // 返回新回复数量,供 init 显示计数条 function handleReadStatus(topicId, replies) { const STORAGE_KEY = `v2_last_read_${topicId}`; const storedValue = localStorage.getItem(STORAGE_KEY); let maxFloor = 0; for (const r of replies) if (r.floorNum > maxFloor) maxFloor = r.floorNum; if (storedValue === null) { localStorage.setItem(STORAGE_KEY, String(maxFloor)); return 0; } const lastReadFloor = parseInt(storedValue, 10) || 0; let newCount = 0; for (const r of replies) { if (r.floorNum > lastReadFloor) { newCount++; r.element.classList.add('reply-new'); // ── 改动:NEW badge 插到 之后(strong 的下一个兄弟位置) // 而非 prepend 到 strong 内部,避免在加粗文字中显示奇怪 const strongEl = r.element.querySelector('strong'); if (strongEl && !r.element.querySelector('.new-badge')) { const badge = document.createElement('span'); badge.className = 'new-badge'; badge.textContent = 'NEW'; badge.title = '未读新回复'; // insertAdjacentElement 'afterend' = strong 元素之后、作为兄弟节点 strongEl.insertAdjacentElement('afterend', badge); } } } localStorage.setItem(STORAGE_KEY, String(maxFloor)); return newCount; } async function init() { if (!isTopicPage()) return; const topicId = location.pathname.match(/\/t\/(\d+)/)?.[1]; if (!topicId) return; const replyBox = Array.from(document.querySelectorAll('.box')).find(b => b.querySelector('div[id^="r_"]')); if (!replyBox) return; const loadingBar = document.createElement('div'); loadingBar.id = 'v2ex-loading-bar'; loadingBar.innerText = '加载中...'; replyBox.parentNode.insertBefore(loadingBar, replyBox); let totalPages = 1; const pageInput = document.querySelector('.page_input'); if (pageInput) { totalPages = parseInt(pageInput.max, 10) || 1; } else { const pageLinks = document.querySelectorAll('a.page_normal'); if (pageLinks.length > 0) { totalPages = parseInt(pageLinks[pageLinks.length - 1].innerText, 10) || 1; } } let allReplies = []; allReplies = allReplies.concat(extractRepliesFromDoc(document)); if (totalPages > 1) { const currentP = parseInt(new URLSearchParams(location.search).get('p') || '1', 10); const fetchPromises = []; for (let p = 1; p <= totalPages; p++) { if (p === currentP) continue; fetchPromises.push( fetch(`${location.pathname}?p=${p}`) .then(res => res.text()) .then(html => { const doc = new DOMParser().parseFromString(html, 'text/html'); return extractRepliesFromDoc(doc); }) .catch(() => []) ); } const otherPagesReplies = await Promise.all(fetchPromises); otherPagesReplies.forEach(list => { allReplies = allReplies.concat(list); }); } allReplies.sort((a, b) => a.floorNum - b.floorNum); allReplies.forEach((reply, i) => { reply.index = i; }); document.querySelectorAll('.page_input, .page_current, .page_normal') .forEach(el => el.closest('div')?.remove()); renderTree(allReplies, replyBox); const newCount = handleReadStatus(topicId, allReplies); loadingBar.remove(); document.querySelectorAll('a[name="last_page"]').forEach(e => e.remove()); // ── 新回复计数提示条(有新回复时才显示) if (newCount > 0) { const bar = document.createElement('div'); bar.id = 'v2ex-new-count-bar'; bar.innerHTML = ` ${newCount} 条新回复 j / k 键跳转 `; replyBox.parentNode.insertBefore(bar, replyBox); } } function boot() { init().catch(err => log('ThreadTree error:', err)); } return { boot }; })(); // ========================= // 5) 功能D:高赞回复阅览室 // ========================= const HotRoom = (() => { function extractComments() { const comments = []; const cells = document.querySelectorAll('.cell[id^="r_"]'); cells.forEach((cell) => { try { const smallFades = cell.querySelectorAll('.small.fade'); let likes = 0; for (const span of smallFades) { const text = span.innerText || ''; const m1 = text.match(/(?:♥|❤️)\s*(\d+)/); if (m1) { likes = parseInt(m1[1], 10); break; } const heartImg = span.querySelector('img[alt="❤️"]'); if (heartImg && text.trim().length > 0) { likes = parseInt(text.trim(), 10); break; } } if (likes > 0) { comments.push({ id: cell.id, likes, avatar: cell.querySelector('img.avatar')?.src || '', username: cell.querySelector('strong > a')?.innerText || 'Unknown', userUrl: cell.querySelector('strong > a')?.href || '#', time: cell.querySelector('.ago')?.innerText || '', contentHtml: cell.querySelector('.reply_content')?.innerHTML || '', floor: cell.querySelector('.no')?.innerText || '#', }); } } catch (_) {} }); return comments.sort((a, b) => b.likes - a.likes); } function buildUI(comments) { const old = document.getElementById('hot-overlay'); if (old) old.remove(); const overlay = document.createElement('div'); overlay.id = 'hot-overlay'; const container = document.createElement('div'); container.className = 'hot-container'; if (!comments.length) { const empty = document.createElement('div'); empty.style.cssText = 'text-align:center;padding:40px;color:#ccc;font-size:13px;'; empty.textContent = '暂无高赞回复'; container.appendChild(empty); } else { comments.forEach((c, index) => { let rankClass = 'rank-normal'; if (index === 0) rankClass = 'rank-1'; else if (index === 1) rankClass = 'rank-2'; else if (index === 2) rankClass = 'rank-3'; const card = document.createElement('div'); card.className = `hot-card ${rankClass}`; const header = document.createElement('div'); header.className = 'card-header-row'; const avatar = document.createElement('img'); avatar.className = 'user-avatar'; avatar.src = c.avatar; header.appendChild(avatar); const user = document.createElement('a'); user.className = 'user-name'; user.href = c.userUrl; user.target = '_blank'; user.rel = 'noreferrer noopener'; user.textContent = c.username; header.appendChild(user); const floor = document.createElement('div'); floor.className = 'floor-tag'; floor.title = '跳转'; floor.textContent = c.floor; floor.addEventListener('click', () => { closeOverlay(overlay); setTimeout(() => { const el = document.getElementById(c.id); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 250); }); header.appendChild(floor); const time = document.createElement('span'); time.className = 'time-tag'; time.textContent = c.time; header.appendChild(time); const likes = document.createElement('div'); likes.className = 'likes-pill'; likes.textContent = `♥ ${c.likes}`; header.appendChild(likes); const content = document.createElement('div'); content.className = 'card-content'; content.innerHTML = c.contentHtml; card.appendChild(header); card.appendChild(content); container.appendChild(card); }); } overlay.appendChild(container); document.body.appendChild(overlay); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeOverlay(overlay); }); const onKey = (e) => { if (e.key === 'Escape') closeOverlay(overlay); }; document.addEventListener('keydown', onKey); overlay._cleanup = () => document.removeEventListener('keydown', onKey); return overlay; } function closeOverlay(overlay) { if (!overlay) return; overlay.classList.remove('active'); setTimeout(() => { if (!overlay.classList.contains('active')) { overlay._cleanup?.(); overlay.remove(); } }, 200); } function initButton() { if (!isTopicPage()) return; const topicHeader = document.querySelector('#Main .header h1'); const boxHeader = document.querySelector('#Main .box .header'); const target = topicHeader || boxHeader; if (target && !document.getElementById('v2ex-hot-btn')) { const btn = document.createElement('span'); btn.id = 'v2ex-hot-btn'; btn.innerText = '高赞'; btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const overlay = buildUI(extractComments()); requestAnimationFrame(() => overlay.classList.add('active')); }); target.appendChild(btn); } } function boot() { if (!isTopicPage()) return; setTimeout(initButton, 500); } return { boot }; })(); // ========================= // 6) 功能E:j/k 键盘导航新回复 // ========================= const NavKeys = (() => { const POLL_INTERVAL = 200; const POLL_TIMEOUT = 8000; const SCROLL_OFFSET_RATIO = 0.22; let newReplies = []; let curIndex = -1; let hudTimer = null; function getHud() { let hud = document.getElementById('v2ex-nav-hud'); if (!hud) { hud = document.createElement('div'); hud.id = 'v2ex-nav-hud'; document.body.appendChild(hud); } return hud; } function showHud(index, total, direction) { const hud = getHud(); const arrow = direction === 'next' ? '↓' : '↑'; hud.innerHTML = ` ${arrow} NEW ${index + 1} / ${total} j↓ k↑ `; hud.classList.add('visible'); clearTimeout(hudTimer); hudTimer = setTimeout(() => hud.classList.remove('visible'), 2200); } function scrollToReply(el) { const targetTop = el.getBoundingClientRect().top + window.scrollY - window.innerHeight * SCROLL_OFFSET_RATIO; window.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' }); } function setActive(el) { document.querySelectorAll('.reply-nav-active').forEach(e => { e.classList.remove('reply-nav-active'); }); if (el) { const wrapper = el.closest('.reply-wrapper') || el; wrapper.classList.add('reply-nav-active'); } } function refreshList() { newReplies = Array.from(document.querySelectorAll('.reply-new')); } function navigate(direction) { refreshList(); if (!newReplies.length) return; if (direction === 'next') { curIndex = Math.min(curIndex + 1, newReplies.length - 1); } else { curIndex = Math.max(curIndex - 1, 0); } const target = newReplies[curIndex]; setActive(target); scrollToReply(target); showHud(curIndex, newReplies.length, direction); } function onKeyDown(e) { const tag = document.activeElement?.tagName?.toLowerCase(); if (tag === 'input' || tag === 'textarea' || document.activeElement?.isContentEditable) return; if (e.metaKey || e.ctrlKey || e.altKey) return; if (document.getElementById('hot-overlay')?.classList.contains('active')) return; if (e.key === 'j') { e.preventDefault(); navigate('next'); } else if (e.key === 'k') { e.preventDefault(); navigate('prev'); } } function waitAndBoot() { const start = Date.now(); const timer = setInterval(() => { const found = document.querySelectorAll('.reply-new').length > 0; const timedOut = Date.now() - start > POLL_TIMEOUT; if (found || timedOut) { clearInterval(timer); if (found) { refreshList(); document.addEventListener('keydown', onKeyDown); log(`NavKeys ready: ${newReplies.length} new replies`); } } }, POLL_INTERVAL); } function boot() { if (!isTopicPage()) return; waitAndBoot(); } return { boot }; })(); // ========================= // 7) 功能F:Imgur 图片代理 // ========================= const ImgurProxy = (() => { function processImage(img) { const src = img.getAttribute('src'); if (!src) return; if (src.includes('imgur.com') && !src.includes('external-content.duckduckgo.com')) { let fullUrl = src; if (src.startsWith('//')) { fullUrl = 'https:' + src; } else if (!src.startsWith('http')) { fullUrl = 'https://' + src; } const proxyUrl = `https://external-content.duckduckgo.com/iu/?u=${encodeURIComponent(fullUrl)}&f=1&nofb=1`; img.setAttribute('src', proxyUrl); img.dataset.proxied = '1'; const parent = img.parentElement; if (parent && parent.tagName.toLowerCase() === 'a') { const href = parent.getAttribute('href'); if (href && href.includes('imgur.com') && !href.includes('external-content.duckduckgo.com')) { let fullHref = href; if (href.startsWith('//')) fullHref = 'https:' + href; const proxyHref = `https://external-content.duckduckgo.com/iu/?u=${encodeURIComponent(fullHref)}&f=1&nofb=1`; parent.setAttribute('href', proxyHref); } } } } function scanAll() { document.querySelectorAll('img[src*="imgur.com"]').forEach(processImage); } function boot() { scanAll(); const observer = new MutationObserver((mutations) => { let shouldScan = false; for (const m of mutations) { if (m.addedNodes && m.addedNodes.length > 0) { shouldScan = true; break; } } if (shouldScan) scanAll(); }); observer.observe(document.body, { childList: true, subtree: true }); } return { boot }; })(); // ========================= // 8) 启动 // ========================= Daily.boot(); if (isTopicPage()) { ThreadTree.boot(); B64.boot(); HotRoom.boot(); NavKeys.boot(); ImgurProxy.boot(); } })();