// ==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();
}
})();