// ==UserScript== // @name V2EX Tweaks // @namespace https://tampermonkey.net/ // @version 2.1.0 // @description 多页加载并以 Hacker News 风格重排楼层;Base64 自动解码;每日自动签到;高赞回复阅览室;自动将 Imgur 图片替换为 DuckDuckGo 代理加载;j/k 键在新回复间快速导航。 // @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 none // ==/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: #f0f0f0; --line-hover: #c0c0c0; --bg-hover: #fafafa; --new-accent: #4a7af0; --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; } .reply-children:hover { border-left-color: var(--line-hover); } .reply-wrapper .cell { padding: 6px 8px !important; border-bottom: 1px solid #fafafa !important; background: transparent; } .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.13) 0%, rgba(74, 122, 240, 0.05) 40%, transparent 100% ) !important; border-left: 4px solid #4a7af0 !important; padding-left: 4px !important; } .new-badge { display: inline-block; font-size: 10px; font-weight: 600; color: var(--new-accent); background: rgba(91, 138, 245, 0.10); border: 1px solid rgba(91, 138, 245, 0.22); border-radius: 3px; padding: 0 4px; line-height: 15px; height: 15px; margin-right: 6px; vertical-align: middle; letter-spacing: 0.4px; } /* 键盘导航当前高亮(在 reply-new 基础上叠加环形描边)*/ .reply-nav-active > .cell { outline: 2px solid rgba(74, 122, 240, 0.55) !important; outline-offset: -2px; transition: outline 0.15s ease; } #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: 8px; padding: 7px 14px 7px 10px; background: rgba(30, 34, 45, 0.88); color: #e8eaf0; border-radius: 20px; font-size: 12px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0.3px; backdrop-filter: blur(6px); box-shadow: 0 4px 16px rgba(0,0,0,0.22); pointer-events: none; opacity: 0; transform: translateY(6px); transition: opacity 0.18s ease, transform 0.18s ease; } #v2ex-nav-hud.visible { opacity: 1; transform: translateY(0); } #v2ex-nav-hud .hud-icon { font-size: 11px; opacity: 0.6; } #v2ex-nav-hud .hud-count { font-weight: 600; color: #7fa8ff; } #v2ex-nav-hud .hud-hint { opacity: 0.45; font-size: 11px; margin-left: 2px; } /* ===== Base64 Badge(极简)===== */ .v2-b64-badge{ display:inline-flex; gap:6px; align-items:center; margin-left:6px; padding:2px 6px; border-radius:6px; font-size:12px; line-height:1.6; background:rgba(0,0,0,.06); border:1px solid rgba(0,0,0,.08); vertical-align:middle; user-select:text; max-width:520px; } .v2-b64-text{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .v2-b64-btn{ cursor:pointer; font-size:12px; padding:1px 6px; border:1px solid rgba(0,0,0,.12); border-radius:4px; background:transparent; } .v2-b64-link{ font-size:12px; text-decoration:none; padding:1px 6px; border:1px solid rgba(0,0,0,.12); border-radius:4px; color:inherit; } /* ===== 高赞阅览室(宽屏沉浸版)===== */ #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; /** * 对字符串进行自定义转义处理,使非 ASCII 字符安全用于 URL 解码。 */ function customEscape(str) { return str.replace( /[^a-zA-Z0-9_.!~*'()-]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}` ); } /** * 检查字符串是否可能是 base64 编码并尝试解码。 * 返回解码后的字符串,或 null(如果无法解码)。 */ function tryDecode(text) { // 检查长度是否为 4 的倍数 if (text.length % 4 !== 0) return null; // 字符长度太小排除掉 if (text.length <= CFG.MIN_LEN) return null; // 排除已知高频非 base64 字符串 if (CFG.EXCLUDE_LIST.includes(text)) return null; // 检查填充字符 "=" 的位置是否正确(只能在末尾 1 或 2 位) if (text.includes('=')) { const paddingIndex = text.indexOf('='); if (paddingIndex !== text.length - 1 && paddingIndex !== text.length - 2) { return null; } } try { const decodedStr = decodeURIComponent(customEscape(window.atob(text))); // 解码后必须包含有意义的内容(至少有字母、数字或中文) if (!/[A-Za-z0-9一-鿿]/.test(decodedStr)) return null; return decodedStr; } catch (_) { return null; } } function makeBadge(raw, decoded) { const wrap = document.createElement('span'); wrap.className = 'v2-b64-badge'; wrap.title = `base64: ${raw}`; const label = document.createElement('span'); label.className = 'v2-b64-text'; label.textContent = decoded; wrap.appendChild(label); const btnCopy = document.createElement('button'); btnCopy.className = 'v2-b64-btn'; btnCopy.textContent = '复制'; btnCopy.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); GM_setClipboard(decoded); btnCopy.textContent = '已复制'; setTimeout(() => (btnCopy.textContent = '复制'), 900); }); wrap.appendChild(btnCopy); // 如果解码结果是 URL,添加打开链接按钮 if (/^https?:\/\//i.test(decoded)) { const a = document.createElement('a'); a.className = 'v2-b64-link'; a.textContent = '打开'; a.href = decoded; a.target = '_blank'; a.rel = 'noreferrer noopener'; wrap.appendChild(a); } return wrap; } function processContent(contentEl) { if (!contentEl || contentEl.dataset.v2b64scanned === '1') return; // 获取需要排除的内容(a 和 img 标签) const excludeTextList = [ ...contentEl.getElementsByTagName('a'), ...contentEl.getElementsByTagName('img'), ].map((ele) => ele.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-badge')) 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((excludeText) => excludeText.includes(candidate))) { continue; } const decoded = tryDecode(candidate); if (!decoded) continue; changed = true; frag.appendChild(document.createTextNode(text.slice(last, m.index))); frag.appendChild(makeBadge(candidate, decoded)); last = m.index + candidate.length; } if (changed) { frag.appendChild(document.createTextNode(text.slice(last))); node.parentNode.replaceChild(frag, node); } }); contentEl.dataset.v2b64scanned = '1'; } 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; const observer = new MutationObserver((mutations) => { for (const m of mutations) { if (m.type === 'childList' && (m.addedNodes?.length || m.removedNodes?.length)) { scheduleScan(); break; } } }); observer.observe(root, { childList: true, subtree: true }); } return { boot }; })(); // ========================= // 4) 功能C:楼层树 + 多页加载 // ========================= const ThreadTree = (() => { // 采用 V2EX_Polish 的楼层识别逻辑 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); // 提取引用的用户名(@username) const memberNameMatches = Array.from(content.matchAll(/@([a-zA-Z0-9]+)/g)); const refMemberNames = memberNameMatches.length > 0 ? memberNameMatches.map(([, name]) => name) : undefined; // 提取引用的楼层号(#123) 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); } // 采用 V2EX_Polish 的嵌套评论查找逻辑 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 childrenContainer = document.createElement('div'); childrenContainer.className = 'reply-children'; reply.children.forEach(child => appendNode(child, childrenContainer)); wrapper.appendChild(childrenContainer); } parentContainer.appendChild(wrapper); } roots.forEach(r => appendNode(r, fragment)); container.innerHTML = ''; container.appendChild(fragment); } 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; } const lastReadFloor = parseInt(storedValue, 10) || 0; for (const r of replies) { if (r.floorNum > lastReadFloor) { r.element.classList.add('reply-new'); const authorContainer = r.element.querySelector('strong'); if (authorContainer && !authorContainer.querySelector('.new-badge')) { const badge = document.createElement('span'); badge.className = 'new-badge'; badge.textContent = 'NEW'; badge.title = '未读新回复'; authorContainer.prepend(badge); } } } localStorage.setItem(STORAGE_KEY, String(maxFloor)); } 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); handleReadStatus(topicId, allReplies); loadingBar.remove(); document.querySelectorAll('a[name="last_page"]').forEach(e => e.remove()); } 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 = (() => { // ThreadTree 完成渲染后 .reply-new 元素才存在, // 用轻量轮询等待(最多 8 秒),检测到后立即激活。 const POLL_INTERVAL = 200; const POLL_TIMEOUT = 8000; // 目标回复距视口顶部的偏移比例(0.22 = 约 22% 处,视觉舒适) const SCROLL_OFFSET_RATIO = 0.22; let newReplies = []; // 按 DOM 顺序排列的 .reply-new 元素 let curIndex = -1; // 当前聚焦的索引,-1 表示尚未导航 let hudTimer = null; // ── HUD 浮层 ────────────────────────────────────────── 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); } // ── 滚动到目标,令其显示在视口约 22% 处 ───────────── 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) { // .reply-new 在 cell 上,其父级是 .reply-wrapper const wrapper = el.closest('.reply-wrapper') || el; wrapper.classList.add('reply-nav-active'); } } // ── 刷新新回复列表(DOM 顺序)──────────────────────── function refreshList() { // querySelectorAll 按 DOM 顺序返回,符合楼层顺序 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; // 有 modifier 键时不拦截 if (e.metaKey || e.ctrlKey || e.altKey) return; // overlay 打开时不拦截(高赞阅览室) 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'); } } // ── 等待 reply-new 出现后激活 ──────────────────────── 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 图片代理 (DuckDuckGo Proxy) // ========================= const ImgurProxy = (() => { function processImage(img) { const src = img.getAttribute('src'); if (!src) return; // 检查是否包含 imgur.com 且还没被代理过 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; } // 替换 src const proxyUrl = `https://external-content.duckduckgo.com/iu/?u=${encodeURIComponent(fullUrl)}&f=1&nofb=1`; img.setAttribute('src', proxyUrl); img.dataset.proxied = '1'; // 顺带替换包裹在外层的 标签的 href (V2EX 经常会将大图用 a 标签包裹) 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(); // 监听 DOM 变化 (兼容 ThreadTree 多页拉取及 HotRoom 高赞动态生成) 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(); } })();