将 PHPWind 论坛回复重构为 Reddit 嵌套风格
// ==UserScript==
// @name PHPWind Reddit Style Refactor
// @namespace http://tampermonkey.net/
// @version 1.4.6
// @description 将 PHPWind 论坛回复重构为 Reddit 嵌套风格
// @match *://*.blue-plus.net/read.php?*
// @match *://*.east-plus.net/read.php?*
// @match *://*.imoutolove.me/read.php?*
// @match *://*.level-plus.net/read.php?*
// @match *://*.north-plus.net/read.php?*
// @match *://*.snow-plus.net/read.php?*
// @match *://*.soul-plus.net/read.php?*
// @match *://*.south-plus.net/read.php?*
// @match *://*.south-plus.org/read.php?*
// @match *://*.spring-plus.net/read.php?*
// @match *://*.summer-plus.net/read.php?*
// @match *://*.white-plus.net/read.php?*
// @run-at document-start
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// 防止脚本在 iframe 中重复运行(如帖子内容嵌入了同域 read.php 的 iframe)
if (window.top !== window.self) return;
// 第一优先级:立即隐藏页面,消除从论坛列表点进帖子时的原始排版闪烁。
// 直接操作 documentElement + 注入内联 style 标签,比 GM_addStyle 更快,
// 因为在 document-start 时机 head 元素可能尚未解析。
const docEl = document.documentElement;
docEl.style.visibility = 'hidden';
const antiFlashStyle = document.createElement('style');
antiFlashStyle.textContent = 'html,body{visibility:hidden!important}';
docEl.appendChild(antiFlashStyle);
function showPage() {
docEl.style.visibility = '';
if (antiFlashStyle.parentNode) antiFlashStyle.remove();
document.body.classList.add('reddit-ready');
}
// --- 配置与常量 ---
const CONFIG = {
DEBUG: true,
NESTING_LIMIT: 10,
FETCH_CONCURRENCY: 4, // 并行抓取并发数
};
const LAYOUT_STORAGE_KEY = 'phpwind-reddit-layout-mode';
// --- CSS 样式 ---
const styles = `
/* 初始完全隐藏 body,消除原样式闪烁,reddit 就绪后再显示 */
body { visibility: hidden !important; }
body.reddit-ready { visibility: visible !important; }
/* 原始排版模式下隐藏原始元素 */
.original-hidden { display: none !important; }
/* Reddit 容器 */
#reddit-container {
max-width: 800px;
margin: 20px auto;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #fff;
padding: 20px;
color: #1c1c1c;
}
.reddit-post-wrapper {
position: relative;
margin-top: 12px;
}
.reddit-comment {
display: flex;
flex-direction: row;
min-height: 40px; /* 确保即使内容很少,也有足够高度容纳头像和基础信息 */
}
.reddit-comment-left {
display: flex;
flex-direction: column;
align-items: center;
width: 32px;
margin-right: 12px;
position: relative;
flex-shrink: 0;
}
.reddit-avatar-wrapper {
position: relative;
width: 32px;
height: 32px;
z-index: 3;
}
.reddit-avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
background-color: #f6f7f8;
border: 2px solid #fff; /* 给头像加个白边,防止被线穿过 */
}
.reddit-threadline {
position: absolute;
top: 32px; /* 从头像底部开始 */
bottom: -12px; /* 延伸到容器底部 */
width: 2px;
background-color: #edeff1;
cursor: pointer;
transition: background-color 0.2s;
z-index: 2;
left: 15px; /* 居中 */
}
.reddit-threadline:hover {
background-color: #0079d3;
}
.reddit-comment-right {
flex-grow: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.reddit-comment-header {
display: flex;
align-items: center;
font-size: 13px;
margin-bottom: 11px;
line-height: 16px;
min-height: 18px;
}
.reddit-author {
font-weight: 600;
color: #1c1c1c;
text-decoration: none;
margin-right: 4px;
}
.reddit-author:hover {
text-decoration: underline;
}
.reddit-time {
color: #787c7e;
}
.reddit-comment-body {
font-size: 14px;
line-height: 1.5;
word-wrap: break-word;
margin-bottom: 4px;
color: #1c1c1c;
}
/* 隐藏某些冗余的原始元素 */
.reddit-comment-body .f12,
.reddit-comment-body h1[id^="subject_"] {
display: none !important;
}
/* 引用框样式重构,不再隐藏,而是改为 Reddit 风格的引用 */
.reddit-comment-body .quote:not(.jumbotron),
.reddit-comment-body .blockquote:not(.jumbotron),
.reddit-comment-body .blockquote2,
.reddit-comment-body .blockquote3 {
display: block !important;
margin: 8px 0 !important;
padding: 2px 0 2px 12px !important;
border-left: 3px solid #edeff1 !important;
color: #576f76 !important;
background: transparent !important;
font-size: 13px !important;
}
/* 隐藏引用中的头部标题(如“引用”、“回复X楼”),因为嵌套结构已经表达了关系 */
.reddit-comment-body .quote h6,
.reddit-comment-body .blockquote h6,
.reddit-comment-body .blockquote2 h6,
.reddit-comment-body .blockquote3 h6 {
display: none !important;
}
/* 冗余引用消除:当引用内容仅仅是重复父楼层内容时隐藏 */
.reddit-comment-body .redundant-quote {
display: none !important;
}
/* 隐藏冗余引用后的换行,防止留白 */
.reddit-comment-body .redundant-quote + br {
display: none !important;
}
/* 购买框样式微调 */
.reddit-comment-body .jumbotron {
display: block !important;
margin: 10px 0;
padding: 12px;
background-color: #fff5f5;
border: 1px solid #feb2b2;
border-radius: 6px;
}
/* 购买框内的所有子元素保持可见,不被上述隐藏规则影响 */
.reddit-comment-body .jumbotron .blockquote,
.reddit-comment-body .jumbotron .blockquote2,
.reddit-comment-body .jumbotron .blockquote3,
.reddit-comment-body .jumbotron .f12,
.reddit-comment-body .jumbotron .quote {
display: revert !important;
}
.reddit-comment-actions {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 8px;
}
.reddit-action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 9999px;
border: none;
background: transparent;
color: #576f76;
font-size: 12px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
line-height: 16px;
}
.reddit-action-btn:hover {
background-color: #eaedef;
}
.reddit-action-icon {
width: 16px;
height: 16px;
fill: currentColor;
}
/* 嵌套样式与曲线设计 */
.reddit-children {
position: relative;
margin-left: 36px; /* 增加缩进,为大弧度留出空间 */
padding-left: 0;
}
/* 曲线连接线:连接父级垂直线到子级头像 */
.reddit-children > .reddit-post-wrapper::before {
content: "";
position: absolute;
left: -21px; /* 36(margin) - 15(parent threadline) = 21 */
top: -12px;
width: 37px; /* 延伸到子头像中心 (36 - 15 + 16 = 37) */
height: 28px; /* 到达头像中心高度 */
border-left: 2px solid #edeff1;
border-bottom: 2px solid #edeff1;
border-bottom-left-radius: 24px; /* 更大、更平缓的弧度 */
pointer-events: none;
z-index: 1;
}
/* 垂直补充线:如果当前评论之后还有兄弟评论,则垂直线继续向下延伸 */
.reddit-children > .reddit-post-wrapper:not(:last-child)::after {
content: "";
position: absolute;
left: -21px;
top: 16px;
bottom: -12px;
border-left: 2px solid #edeff1;
pointer-events: none;
z-index: 1;
}
/* 折叠按钮 */
.reddit-collapse-btn {
position: absolute;
left: 6px; /* 居中对齐到 threadline (15px) */
top: 36px;
width: 20px;
height: 20px;
background: #fff;
border: 1px solid #edeff1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 4;
color: #878a8c;
opacity: 1; /* 常显,更符合直觉 */
transition: all 0.2s;
padding: 0;
}
.reddit-comment:hover .reddit-collapse-btn {
border-color: #d7d9db;
color: #576f76;
}
.reddit-collapse-btn:hover {
background-color: #eaedef;
border-color: #878a8c;
color: #1c1c1c;
}
.reddit-collapse-btn svg {
width: 12px;
height: 12px;
}
/* 针对折叠状态 */
.reddit-post-wrapper.collapsed > .reddit-comment > .reddit-comment-right > .reddit-comment-body,
.reddit-post-wrapper.collapsed > .reddit-comment > .reddit-comment-right > .reddit-comment-actions,
.reddit-post-wrapper.collapsed > .reddit-children,
.reddit-post-wrapper.collapsed > .reddit-comment > .reddit-comment-left > .reddit-threadline,
.reddit-post-wrapper.collapsed > .reddit-comment > .reddit-comment-left > .reddit-collapse-btn {
display: none !important;
}
.reddit-post-wrapper.collapsed > .reddit-comment > .reddit-comment-left > .reddit-avatar-wrapper {
opacity: 0.6;
}
/* 头部折叠/展开按钮 */
.reddit-expand-inline,
.reddit-collapse-inline {
display: none;
margin-left: 8px;
width: 16px;
height: 16px;
cursor: pointer;
vertical-align: middle;
fill: #787c7e;
transition: fill 0.2s;
}
.reddit-expand-inline {
fill: #0079d3;
}
.reddit-comment-header:hover .reddit-collapse-inline {
display: inline-block;
}
.reddit-post-wrapper.collapsed > .reddit-comment > .reddit-comment-right > .reddit-comment-header .reddit-expand-inline {
display: inline-block;
}
.reddit-post-wrapper.collapsed > .reddit-comment > .reddit-comment-right > .reddit-comment-header .reddit-collapse-inline {
display: none !important;
}
/* 切换按钮 */
#layout-toggle-btn {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
padding: 10px 15px;
background: #0079d3;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
font-weight: bold;
}
#layout-toggle-btn:hover { background: #005fa3; }
/* Loading 状态 */
#reddit-loader {
text-align: center;
padding: 50px;
font-size: 18px;
color: #666;
}
/* 回复框样式 */
.reddit-reply-box {
margin-top: 10px;
margin-bottom: 15px;
padding: 12px;
background: #f6f7f8;
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 8px;
}
.reddit-reply-textarea {
width: 100%;
min-height: 100px;
padding: 8px;
border: 1px solid #edeff1;
border-radius: 4px;
font-family: inherit;
resize: vertical;
}
.reddit-reply-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.reddit-reply-footer-actions {
display: flex;
align-items: center;
gap: 8px;
}
.reddit-reply-footer-submit {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.reddit-reply-btn {
padding: 4px 16px;
border-radius: 999px;
font-weight: 700;
cursor: pointer;
border: none;
font-size: 12px;
}
.reddit-reply-submit {
background-color: #0079d3;
color: white;
}
.reddit-reply-submit:hover { background-color: #005fa3; }
.reddit-reply-cancel {
background-color: transparent;
color: #576f76;
}
.reddit-reply-cancel:hover { background-color: #eaedef; }
/* 表情相关 */
.reddit-reply-actions {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.reddit-emoji-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
color: #576f76;
border-radius: 4px;
transition: background-color 0.2s;
}
.reddit-emoji-btn:hover {
background-color: #eaedef;
}
.reddit-emoji-container {
display: none;
margin: 6px 0 8px;
}
.reddit-emoji-container.active {
display: block;
}
#reddit-smilie-storage {
display: none !important;
}
#smiliebox {
display: block !important;
border: 1px solid #edeff1 !important;
padding: 10px !important;
background: #fff !important;
max-width: 100%;
margin: 8px 0 !important;
border-radius: 4px;
}
#menu_face, #menu_generalface {
z-index: 10001 !important;
}
/* 投票容器样式 */
.reddit-vote-container {
margin: 15px 0;
padding: 10px;
border: 1px solid #edeff1;
border-radius: 8px;
background: #f8f9fa;
}
.reddit-vote-container form {
display: block !important;
margin: 0 !important;
}
.reddit-vote-container .tt2 {
display: block !important;
border: 1px solid #edeff1 !important;
border-radius: 6px;
overflow: hidden;
background: #fff;
}
.reddit-vote-container table {
width: 100% !important;
border-collapse: collapse;
background: #fff !important;
}
.reddit-vote-container th,
.reddit-vote-container td {
padding: 8px !important;
border-bottom: 1px solid #edeff1;
font-size: 13px;
text-align: left !important;
font-weight: normal !important;
line-height: 1.6 !important;
}
.reddit-vote-container tr:last-child th,
.reddit-vote-container tr:last-child td {
border-bottom: none !important;
}
.reddit-vote-container .h {
background: #f1f3f5 !important;
color: #1c1c1c !important;
font-weight: 600 !important;
}
.reddit-vote-container input[type="radio"],
.reddit-vote-container input[type="checkbox"] {
appearance: auto !important;
-webkit-appearance: auto !important;
width: 16px !important;
height: 16px !important;
margin: 0 8px 0 0 !important;
cursor: pointer;
vertical-align: middle;
display: inline-block !important;
opacity: 1 !important;
position: static !important;
}
.reddit-vote-container input[type="submit"],
.reddit-vote-container input[type="button"] {
padding: 6px 16px;
background: #0079d3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
margin-top: 10px;
}
.reddit-vote-container input[type="submit"]:hover {
background: #005fa3;
}
.reddit-vote-container img {
vertical-align: middle;
max-width: 100%;
}
.reddit-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.reddit-title-text {
font-size: 24px;
font-weight: bold;
color: #1c1c1c;
min-width: 0;
}
.reddit-topic-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
.reddit-topic-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: #0079d3;
color: #fff !important;
font-size: 13px;
font-weight: 700;
text-decoration: none !important;
white-space: nowrap;
}
.reddit-topic-btn:hover {
background: #005fa3;
}
/* Apple 风格悬浮导航栏 */
#reddit-sidebar {
position: fixed;
left: 24px;
top: 50%;
transform: translateY(-50%);
z-index: 10002;
display: flex;
flex-direction: column;
gap: 16px;
transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
#reddit-sidebar.hidden {
opacity: 0;
pointer-events: none;
transform: translateY(-50%) translateX(-30px);
}
.sidebar-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 64px;
min-height: 64px;
padding: 8px 4px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-radius: 18px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.04);
text-decoration: none;
color: #1d1d1f;
transition: all 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
border: 1px solid rgba(255, 255, 255, 0.4);
overflow: visible;
}
.sidebar-item:hover {
background: rgba(255, 255, 255, 0.92);
transform: scale(1.08) translateX(4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08);
border-color: rgba(255, 255, 255, 0.6);
color: #0071e3; /* Apple Blue */
}
.sidebar-icon {
width: 22px;
height: 22px;
margin-bottom: 4px;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.sidebar-text {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
text-align: center;
word-break: break-all;
line-height: 1.3;
max-width: 100%;
}
/* 发帖按钮下拉菜单 */
.reddit-post-dropdown {
position: relative;
display: inline-block;
}
.reddit-post-menu {
display: none;
position: absolute;
top: 100%;
right: 0;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
min-width: 100px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
border-radius: 12px;
z-index: 10005;
margin-top: 8px;
border: 1px solid rgba(255, 255, 255, 0.4);
padding: 6px;
overflow: visible; /* 改为 visible 以允许伪元素超出 */
animation: redditFadeIn 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.reddit-post-menu::before {
content: '';
position: absolute;
top: -12px;
left: 0;
right: 0;
height: 12px;
z-index: -1;
}
@keyframes redditFadeIn {
from { opacity: 0; transform: translateY(-8px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.reddit-post-dropdown:hover .reddit-post-menu {
display: block;
}
.reddit-post-menu-item {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 12px;
color: #1c1c1c;
text-decoration: none;
font-size: 13px;
font-weight: 600;
border-radius: 8px;
transition: all 0.2s;
white-space: nowrap;
}
.reddit-post-menu-item:hover {
background: rgba(0, 113, 227, 0.1);
color: #0071e3;
}
/* === 响应式布局:窄屏时自动收起侧栏和切换按钮 === */
/* 左侧悬浮触发区域(窄屏时出现) */
#reddit-sidebar-trigger {
display: none;
position: fixed;
left: 0;
top: 0;
width: 28px;
height: 100vh;
z-index: 10001;
background: transparent;
}
/* 窄屏下的侧栏 + 按钮响应式 */
@media (max-width: 1100px) {
/* 触发区域可见 */
#reddit-sidebar-trigger {
display: block;
}
/* 侧栏默认隐藏 */
#reddit-sidebar {
opacity: 0;
pointer-events: none;
transform: translateY(-50%) translateX(-30px);
}
/* 鼠标悬浮触发区域或侧栏自身时,显示侧栏 */
#reddit-sidebar-trigger:hover ~ #reddit-sidebar,
#reddit-sidebar:hover {
opacity: 1 !important;
pointer-events: auto !important;
transform: translateY(-50%) translateX(0) !important;
}
/* 切换按钮移到右下角,避免遮挡标题区的"回帖/发新帖"按钮 */
#layout-toggle-btn {
top: auto;
bottom: 20px;
right: 20px;
}
}
`;
GM_addStyle(styles);
// --- 全局状态 ---
let redditContainer = null;
let isRedditMode = getStoredLayoutMode() !== 'original';
let allPosts = new Map(); // floorNumber -> PostObject
let rootPosts = [];
let originalElements = [];
let activeTextarea = null;
let originalSmilieParent = null;
let originalSmilieNextSibling = null;
function getStoredLayoutMode() {
try {
return localStorage.getItem(LAYOUT_STORAGE_KEY) || 'reddit';
} catch (e) {
return 'reddit';
}
}
function setStoredLayoutMode(mode) {
try {
localStorage.setItem(LAYOUT_STORAGE_KEY, mode);
} catch (e) { }
}
let isUpdating = false;
async function refreshDataNoReload(targetFloor = null) {
if (isUpdating) return;
isUpdating = true;
if (CONFIG.DEBUG) console.log('Refreshing data...', targetFloor);
// 1. 记录状态
const savedScrollY = window.scrollY;
const containerHeight = redditContainer.offsetHeight;
const collapsedFloors = new Set();
document.querySelectorAll('.reddit-post-wrapper.collapsed').forEach(el => {
const floor = el.dataset.floor;
if (floor) collapsedFloors.add(floor);
});
try {
// 2. 抓取最新数据 (带缓存穿透)
const currentPage = getCurrentPageNumber();
const totalPagesBefore = getTotalPages();
const pageUrls = buildPageUrls(totalPagesBefore);
const currentPageEntry = pageUrls.find(p => p.page === currentPage);
if (!currentPageEntry) return;
// 给服务端一点处理时间,防止数据库主从延迟等
await new Promise(r => setTimeout(r, 200));
// 抓取当前页
const cacheBuster = (url) => url + (url.includes('?') ? '&' : '?') + '_t=' + Date.now();
const doc = await fetchPage(cacheBuster(currentPageEntry.url));
const totalPagesAfter = getTotalPages(doc);
parsePosts(doc);
// 抓取新增页
if (totalPagesAfter > totalPagesBefore) {
const newEntries = buildPageUrls(totalPagesAfter).filter(p => p.page > totalPagesBefore);
for (const entry of newEntries) {
const newDoc = await fetchPage(cacheBuster(entry.url));
parsePosts(newDoc);
}
}
// 3. 渲染
buildTree();
redditContainer.style.minHeight = containerHeight + 'px';
renderReddit();
redditContainer.style.minHeight = '';
// 4. 恢复状态
document.querySelectorAll('.reddit-post-wrapper').forEach(el => {
const floor = el.dataset.floor;
if (collapsedFloors.has(floor)) el.classList.add('collapsed');
});
// 5. 定位
if (targetFloor === 'latest') {
const floors = Array.from(allPosts.keys()).sort((a, b) => b - a);
if (floors.length > 0) {
const el = document.querySelector(`.reddit-post-wrapper[data-floor="${floors[0]}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.style.backgroundColor = 'rgba(0, 121, 211, 0.1)';
setTimeout(() => el.style.backgroundColor = '', 2000);
}
}
} else if (targetFloor !== null) {
const el = document.querySelector(`.reddit-post-wrapper[data-floor="${targetFloor}"]`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
window.scrollTo(0, savedScrollY);
}
} catch (e) {
console.error('Refresh failed:', e);
alert('刷新失败: ' + e.message);
} finally {
isUpdating = false;
}
}
// --- 初始化 ---
async function init() {
// 1. 识别并隐藏原始所有主要容器,防止它们在 Reddit 模式下露出来
const selectors = ['#toptool', '#wrapA', '#main', '#footer', '#breadcrumbs', '.bdbA', '.t3', 'audio#music'];
selectors.forEach(s => {
const el = document.querySelector(s);
if (el) {
if (isRedditMode) el.classList.add('original-hidden');
originalElements.push(el);
}
});
if (originalElements.length === 0) {
showPage();
return;
}
const originalSmilieBox = document.getElementById('smiliebox');
if (originalSmilieBox) {
originalSmilieParent = originalSmilieBox.parentNode;
originalSmilieNextSibling = originalSmilieBox.nextSibling;
}
// 覆盖原生的 addsmile 函数,使其支持我们的动态回复框
const originalAddSmile = window.addsmile;
window.addsmile = function (code) {
if (insertSmileCode(code)) {
return;
} else if (originalAddSmile) {
originalAddSmile(code);
}
};
// 2. 创建 Reddit 容器
redditContainer = document.createElement('div');
redditContainer.id = 'reddit-container';
// 3. 监听全局点击事件
document.addEventListener('click', handlePurchaseClick, true);
document.body.appendChild(redditContainer);
// 3. 创建左侧悬浮触发区域(窄屏幕时使用,必须在 toggleBtn 和 sidebar 之前插入以使 CSS ~ 选择器生效)
const sidebarTrigger = document.createElement('div');
sidebarTrigger.id = 'reddit-sidebar-trigger';
if (!isRedditMode) sidebarTrigger.style.display = 'none';
document.body.appendChild(sidebarTrigger);
// 4. 创建切换按钮
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.id = 'layout-toggle-btn';
toggleBtn.innerText = isRedditMode ? '返回原始排版' : '切换到 Reddit 样式';
toggleBtn.onclick = toggleLayout;
document.body.appendChild(toggleBtn);
// 5. 创建悬浮导航栏
createFloatingSidebar();
// 5. 开始抓取数据
try {
parsePosts(document);
buildTree();
renderReddit();
if (!isRedditMode) redditContainer.style.display = 'none';
// 首次渲染完成后显示页面,消除原样式闪烁
showPage();
await startFetching();
if (!isRedditMode) redditContainer.style.display = 'none';
} catch (e) {
console.error('Reddit Refactor Error:', e);
redditContainer.innerHTML = '<div style="text-align:center;padding:20px;">加载失败: ' + e.message + '</div>';
showPage();
}
}
function toggleLayout() {
isRedditMode = !isRedditMode;
const sidebar = document.getElementById('reddit-sidebar');
const sidebarTrigger = document.getElementById('reddit-sidebar-trigger');
if (isRedditMode) {
originalElements.forEach(el => el.classList.add('original-hidden'));
redditContainer.style.display = 'block';
if (sidebar) sidebar.classList.remove('hidden');
if (sidebarTrigger) sidebarTrigger.style.display = '';
document.getElementById('layout-toggle-btn').innerText = '返回原始排版';
setStoredLayoutMode('reddit');
} else {
restoreOriginalSmilieBox();
originalElements.forEach(el => el.classList.remove('original-hidden'));
redditContainer.style.display = 'none';
if (sidebar) sidebar.classList.add('hidden');
if (sidebarTrigger) sidebarTrigger.style.display = 'none';
document.getElementById('layout-toggle-btn').innerText = '切换到 Reddit 样式';
setStoredLayoutMode('original');
}
}
function createFloatingSidebar() {
if (document.getElementById('reddit-sidebar')) return;
const sidebar = document.createElement('div');
sidebar.id = 'reddit-sidebar';
if (!isRedditMode) sidebar.classList.add('hidden');
// 提取当前版块链接
const forumLink = document.querySelector('.bdbA #breadcrumbs a[href*="fid-"]') ||
document.querySelector('#breadcrumbs a[href*="fid-"]') ||
{ href: 'thread.php?fid=9', innerText: '茶馆' };
const forumName = (forumLink.innerText || forumLink.textContent || '茶馆').trim().replace(/<i>.*<\/i>/g, '');
sidebar.innerHTML = `
<a href="index.php" class="sidebar-item" title="首页">
<svg class="sidebar-icon" viewBox="0 0 24 24"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
<span class="sidebar-text">首页</span>
</a>
<a href="${forumLink.href}" class="sidebar-item" title="${forumName}">
<svg class="sidebar-icon" viewBox="0 0 24 24"><path d="M18 8h1a4 4 0 0 1 0 8h-1"></path><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"></path><line x1="6" y1="1" x2="6" y2="4"></line><line x1="10" y1="1" x2="10" y2="4"></line><line x1="14" y1="1" x2="14" y2="4"></line></svg>
<span class="sidebar-text">${forumName}</span>
</a>
<a href="search.php" class="sidebar-item" title="搜索">
<svg class="sidebar-icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<span class="sidebar-text">搜索</span>
</a>
<a href="javascript:void(0)" class="sidebar-item" title="收藏" id="reddit-sidebar-favor">
<svg class="sidebar-icon" viewBox="0 0 24 24"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path></svg>
<span class="sidebar-text">收藏</span>
</a>
`;
document.body.appendChild(sidebar);
// 绑定收藏功能
const favorBtn = sidebar.querySelector('#reddit-sidebar-favor');
if (favorBtn) {
favorBtn.onclick = (e) => {
e.preventDefault();
// 优先尝试点击页面原有的收藏按钮,这能触发论坛自带的弹窗提示
const originalFavorBtn = document.getElementById('favor');
if (originalFavorBtn && (originalFavorBtn.getAttribute('onclick') || '').includes('favor')) {
originalFavorBtn.click();
return;
}
// 降级方案:自己发送 AJAX 请求,防止跳转导致空白页
const tid = getTopicId();
if (!tid) {
alert('无法获取帖子ID,收藏失败。');
return;
}
GM_xmlhttpRequest({
method: 'GET',
url: `pw_ajax.php?action=favor&tid=${tid}`,
onload: function (res) {
const text = res.responseText || '';
if (text.includes('已经收藏')) {
alert('您已经收藏过此帖了!');
} else if (text.includes('成功') || text.includes('success')) {
alert('收藏成功!');
} else if (text.includes('登录')) {
alert('收藏失败:请先登录。');
} else {
// PHPWind 返回的信息通常是用 \t 分隔的
const parts = text.split('\t');
const msg = parts.length > 1 ? parts[1] : text;
alert('收藏提示:\n' + msg.replace(/<[^>]+>/g, '').trim());
}
},
onerror: function () {
alert('网络请求失败,收藏失败。');
}
});
};
}
}
// --- 数据抓取逻辑 ---
function getTotalPages(doc = document) {
let maxPage = 1;
// 方法1:通过分页链接的数字来判断最大页数
const pageElements = doc.querySelectorAll('.pages a, .pages b, .pages li');
pageElements.forEach(el => {
const text = (el.innerText || el.textContent || '').trim();
if (/^\d+$/.test(text)) {
const pageNum = parseInt(text, 10);
if (pageNum > maxPage) maxPage = pageNum;
}
});
// 方法2:通过分页文本提取 "Pages: 1/4" 或 "1 / 4"
const pagesContainers = doc.querySelectorAll('.pages');
pagesContainers.forEach(container => {
const text = container.innerText || '';
const match = text.match(/\/\s*(\d+)/);
if (match) {
const pageNum = parseInt(match[1], 10);
if (pageNum > maxPage) maxPage = pageNum;
}
});
return maxPage;
}
function getCurrentPageNumber() {
const pageMatch = window.location.href.match(/[?&]page=(\d+)|-page-(\d+)/);
if (!pageMatch) return 1;
return parseInt(pageMatch[1] || pageMatch[2], 10) || 1;
}
function buildPageUrls(totalPages) {
let baseUrl = window.location.href;
baseUrl = baseUrl.replace(/[&?]page=\d+/, '').replace(/-page-\d+/, '');
const urls = [];
for (let i = 1; i <= totalPages; i++) {
let url = baseUrl;
if (url.includes('.html')) {
url = url.replace('.html', `-page-${i}.html`);
} else {
url += (url.includes('?') ? '&' : '?') + `page=${i}`;
}
urls.push({ page: i, url });
}
return urls;
}
// --- 并行抓取工具 ---
async function fetchPagesParallel(urls, concurrency = CONFIG.FETCH_CONCURRENCY) {
const results = new Array(urls.length);
let idx = 0;
async function worker() {
while (idx < urls.length) {
const i = idx++;
try {
results[i] = await fetchPage(urls[i].url);
} catch (e) {
if (CONFIG.DEBUG) console.warn('Fetch page failed:', urls[i].page, e.message);
results[i] = null;
}
}
}
const workers = Array.from({ length: Math.min(concurrency, urls.length) }, () => worker());
await Promise.all(workers);
return results;
}
async function startFetching() {
const totalPages = getTotalPages();
if (totalPages <= 1) return;
const currentPage = getCurrentPageNumber();
const pageEntries = buildPageUrls(totalPages);
const pagesToFetch = pageEntries.filter(entry => entry.page !== currentPage);
if (CONFIG.DEBUG) console.time('parallelFetch');
// 并行抓取所有页面,限制并发数
const docs = await fetchPagesParallel(pagesToFetch);
// 一次性解析所有结果
for (const doc of docs) {
if (doc) parsePosts(doc);
}
if (CONFIG.DEBUG) console.timeEnd('parallelFetch');
// 全部完成后一次性渲染
buildTree();
renderReddit();
}
async function fetchPage(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
timeout: 10000,
onload: (res) => {
if (res.status !== 200) {
reject(new Error(`HTTP ${res.status}`));
return;
}
const parser = new DOMParser();
resolve(parser.parseFromString(res.responseText, 'text/html'));
},
onerror: () => reject(new Error('网络请求错误')),
ontimeout: () => reject(new Error('请求超时'))
});
});
}
function getNodeText(node) {
return (node?.innerText || node?.textContent || '').replace(/\u00a0/g, ' ').trim();
}
function decodeCloudflareEmail(encoded) {
if (!encoded || encoded.length < 4) return '';
try {
const key = parseInt(encoded.slice(0, 2), 16);
let email = '';
for (let i = 2; i < encoded.length; i += 2) {
email += String.fromCharCode(parseInt(encoded.slice(i, i + 2), 16) ^ key);
}
return email;
} catch (e) {
return '';
}
}
function getDisplayText(node) {
const protectedEmail = node?.querySelector?.('.__cf_email__[data-cfemail]');
if (protectedEmail) {
const decoded = decodeCloudflareEmail(protectedEmail.getAttribute('data-cfemail'));
if (decoded) return decoded;
}
return getNodeText(node);
}
function insertSmileCode(code, target = activeTextarea) {
target = target || document.querySelector('form[name="FORM"] textarea[name="atc_content"]');
if (!target || code === undefined || code === null || code === '') return false;
const insert = ' [s:' + code + '] ';
const start = typeof target.selectionStart === 'number' ? target.selectionStart : target.value.length;
const end = typeof target.selectionEnd === 'number' ? target.selectionEnd : start;
const val = target.value || '';
target.value = val.substring(0, start) + insert + val.substring(end);
target.focus();
target.selectionStart = target.selectionEnd = start + insert.length;
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
function bindSmilieBoxClicks(smilieBox, textarea) {
if (!smilieBox || smilieBox.dataset.redditSmileBound === '1') return;
smilieBox.dataset.redditSmileBound = '1';
smilieBox.addEventListener('click', event => {
const smile = event.target?.closest?.('img[onclick*="addsmile"]');
if (!smile || !smilieBox.contains(smile)) return;
const onclick = smile.getAttribute('onclick') || '';
const match = onclick.match(/addsmile\(([^)]+)\)/i);
if (!match) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
const code = match[1].replace(/['"\s]/g, '');
insertSmileCode(code, activeTextarea || textarea);
}, true);
}
function getSmilieStorage() {
let storage = document.getElementById('reddit-smilie-storage');
if (!storage) {
storage = document.createElement('div');
storage.id = 'reddit-smilie-storage';
document.body.appendChild(storage);
}
return storage;
}
function hideSmilieBox() {
const smilieBox = document.getElementById('smiliebox');
if (smilieBox) getSmilieStorage().appendChild(smilieBox);
document.querySelectorAll('.reddit-emoji-container.active').forEach(container => {
container.classList.remove('active');
});
}
function restoreOriginalSmilieBox() {
const smilieBox = document.getElementById('smiliebox');
document.querySelectorAll('.reddit-emoji-container.active').forEach(container => {
container.classList.remove('active');
});
if (!smilieBox || !originalSmilieParent) return;
if (smilieBox.parentNode !== originalSmilieParent) {
if (originalSmilieNextSibling && originalSmilieNextSibling.parentNode === originalSmilieParent) {
originalSmilieParent.insertBefore(smilieBox, originalSmilieNextSibling);
} else {
originalSmilieParent.appendChild(smilieBox);
}
}
}
function showSmilieBoxFor(textarea, emojiContainer) {
const smilieBox = document.getElementById('smiliebox');
if (!smilieBox || !emojiContainer) return;
if (emojiContainer.contains(smilieBox)) {
hideSmilieBox();
return;
}
activeTextarea = textarea;
hideSmilieBox();
emojiContainer.classList.add('active');
emojiContainer.appendChild(smilieBox);
bindSmilieBoxClicks(smilieBox, textarea);
if (window.showDefault && !document.getElementById('menu_show')?.innerHTML) {
window.showDefault();
}
textarea?.focus();
}
function bindReplyBox(replyBox, textarea, submitBtn) {
const emojiBtn = replyBox.querySelector('.reddit-emoji-btn');
const emojiContainer = replyBox.querySelector('.reddit-emoji-container');
if (!emojiBtn || !emojiContainer) return;
textarea.onfocus = () => { activeTextarea = textarea; };
textarea.onclick = () => { activeTextarea = textarea; };
emojiBtn.onmousedown = () => { activeTextarea = textarea; };
emojiBtn.onclick = () => showSmilieBoxFor(textarea, emojiContainer);
// 快捷键支持: Ctrl + Enter 提交
textarea.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
submitBtn.click();
}
});
}
function getOriginalTopicActionLinks() {
const actionArea = document.querySelector('.t3');
const scope = actionArea || document;
const newTopicLink = scope.querySelector('a[href^="post.php?fid"]') || document.querySelector('#menu_post a[href^="post.php?fid"]');
const replyLink = scope.querySelector('a[href*="action-reply"][href*="tid"]') || document.querySelector('a[href*="action-reply"][href*="tid"]');
// 查找投票链接
let pollLink = scope.querySelector('a[href*="special=1"], a[href*="special-1"]');
if (!pollLink) {
const menuPost = document.getElementById('menu_post');
if (menuPost) {
pollLink = Array.from(menuPost.querySelectorAll('a')).find(a => /投票/.test(a.innerText) || /special[=-]1/.test(a.href));
}
}
return {
newTopicHref: newTopicLink ? newTopicLink.href : '',
replyHref: replyLink ? replyLink.href : '',
pollHref: pollLink ? pollLink.href : ''
};
}
function extractParentFloorFromText(text) {
const normalized = (text || '').replace(/\u00a0/g, ' ');
const match = normalized.match(/(?:回|回复|引用第?)\s*(楼主|\d+)\s*楼?/);
if (!match) return null;
return match[1] === '楼主' ? 0 : parseInt(match[1], 10);
}
function extractParentFloor(table) {
// 优先从内容中的引用块提取,因为引用块包含最精确的回复对象信息
const contentElement = table.querySelector('.tpc_content');
if (contentElement) {
const quoteElements = contentElement.querySelectorAll('.quote, blockquote, .blockquote2, .blockquote3');
for (const quote of quoteElements) {
const parentFloor = extractParentFloorFromText(getNodeText(quote));
if (parentFloor !== null) return parentFloor;
}
}
// 降级方案:从标题中提取(往往包含“回 楼主”等信息,但不够精确)
const subject = table.querySelector('h1[id^="subject_"]');
if (subject) {
let parentFloor = extractParentFloorFromText(getNodeText(subject));
if (parentFloor !== null) return parentFloor;
}
return null;
}
function findPostActionLink(table, postId, textPattern, hrefPattern) {
const doc = table.ownerDocument;
const areas = [table];
if (postId) {
const menu = doc.getElementById(`menu_read_${postId}`);
if (menu) areas.push(menu);
}
for (const area of areas) {
const links = area.querySelectorAll('a');
for (const link of links) {
const text = getNodeText(link);
const title = link.getAttribute('title') || '';
const href = link.getAttribute('href') || '';
if (textPattern.test(text) || textPattern.test(title) || hrefPattern.test(href)) {
return {
href,
onclick: link.getAttribute('onclick') || ''
};
}
}
}
return null;
}
function getVoteMultiLimit(doc) {
const scripts = Array.from(doc.querySelectorAll('script'));
for (const script of scripts) {
const text = script.textContent || '';
const match = text.match(/var\s+multi\s*=\s*['"]?(\d+)['"]?/);
if (match) return parseInt(match[1], 10) || 0;
}
return 0;
}
function getVoteHtml(doc) {
const voteForm = doc.querySelector('form[name="vote"], form[action*="action=vote"]');
const voteContent = voteForm?.querySelector('.tt2') || Array.from(doc.querySelectorAll('.tt2')).find(el => /投票|票数|人参与|本次投票/.test(getNodeText(el)));
const source = voteForm && voteContent ? voteForm : voteContent;
if (!source) return '';
const clone = source.cloneNode(true);
clone.querySelectorAll('script, style').forEach(el => el.remove());
if (clone.tagName === 'FORM') {
clone.classList.add('reddit-vote-form');
clone.dataset.voteMulti = String(getVoteMultiLimit(doc));
if (!clone.getAttribute('method')) clone.setAttribute('method', 'post');
}
return `<div class="reddit-vote-container">${clone.outerHTML}</div>`;
}
// --- 购买按钮点击处理 ---
function handlePurchaseClick(event) {
// 1. 寻找最接近的可能是按钮的元素
const target = event.target.closest('input, button, a, span, div');
if (!target) return;
// 2. 向上寻找带有购买特征的元素
const purchaseEl = target.closest('[data-reddit-onclick]') ||
target.closest('[onclick*="buytopic"], [onclick*="sellcontent"], [onclick*="sendmsg"], [onclick*="location.href"]') ||
target;
const onclick = purchaseEl.getAttribute('data-reddit-onclick') || purchaseEl.getAttribute('onclick') || '';
const text = (purchaseEl.value || purchaseEl.textContent || '').trim();
// 精确匹配购买意图
const isPurchaseBtn = (/购买|付钱|支付|buy|完成/i.test(text) && (['INPUT', 'BUTTON', 'A', 'SPAN', 'DIV'].includes(purchaseEl.tagName))) ||
/sellcontent|buytopic|action=sell|buy_me/i.test(onclick) ||
purchaseEl.hasAttribute('data-reddit-onclick');
if (!isPurchaseBtn) return;
// 不再强制要求在 .jumbotron 内,只要识别到购买意图即可拦截
let requestUrl = '';
let requestData = '';
// 情况1: location.href 或 location.assign 跳转 (通常是免费购买)
const locationMatch = onclick.match(/(?:window\.)?location(?:\.href)?\s*=\s*['"]([^'"]+)['"]/) ||
onclick.match(/(?:window\.)?location\.assign\(['"]([^'"]+)['"]\)/);
if (locationMatch) {
requestUrl = locationMatch[1] || locationMatch[2];
} else {
const sendmsgMatch = onclick.match(/sendmsg\s*\(\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]*)['"]/);
if (sendmsgMatch) {
requestUrl = sendmsgMatch[1];
requestData = sendmsgMatch[2];
}
}
if (!requestUrl) return;
// 解码 HTML 实体 (例如 & -> &)
requestUrl = requestUrl.replace(/&/g, '&');
const postWrapper = target.closest('.reddit-post-wrapper');
const floorNum = postWrapper ? postWrapper.dataset.floor : null;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (CONFIG.DEBUG) console.log('Purchase intercepted:', requestUrl, 'Floor:', floorNum);
// 按钮状态反馈
if (purchaseEl.tagName === 'INPUT') {
purchaseEl.value = '处理中...';
purchaseEl.disabled = true;
}
executeAsyncPurchase(requestUrl, requestData, floorNum, purchaseEl);
}
function executeAsyncPurchase(requestUrl, requestData, targetFloorNum = null, purchaseBtn = null) {
// 确保URL是绝对路径
if (requestUrl && !requestUrl.startsWith('http')) {
requestUrl = new URL(requestUrl, window.location.href).href;
}
const requestMethod = requestData ? 'POST' : 'GET';
const resetBtn = () => {
if (purchaseBtn && purchaseBtn.tagName === 'INPUT') {
purchaseBtn.value = '愿意购买,我买,我付钱';
purchaseBtn.disabled = false;
}
};
GM_xmlhttpRequest({
method: requestMethod,
url: requestUrl,
data: requestData || undefined,
headers: requestData ? {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Referer': window.location.href
} : {},
timeout: 15000,
onload: async function (res) {
if (res.status < 200 || res.status >= 400) {
resetBtn();
alert('操作失败: ' + res.status);
return;
}
const responseText = res.responseText || '';
if (/success|成功|完成|操作/.test(responseText) || responseText.trim().length === 0 || /ok/i.test(responseText)) {
setTimeout(async () => {
await refreshDataNoReload();
}, 600);
} else if (/不足|不够|余额|SP币/.test(responseText)) {
resetBtn();
alert('购买失败:余额不足');
} else {
setTimeout(() => refreshDataNoReload(), 600);
}
},
onerror: () => { resetBtn(); alert('网络错误'); },
ontimeout: () => { resetBtn(); alert('请求超时'); }
});
}
function initVoteForms(container = redditContainer) {
if (!container) return;
container.querySelectorAll('.reddit-vote-form:not([data-reddit-vote-ready])').forEach(form => {
form.dataset.redditVoteReady = '1';
const limit = parseInt(form.dataset.voteMulti || '0', 10);
if (limit <= 0) return;
form.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(input => {
input.addEventListener('change', () => {
if (input.type === 'radio') return;
const checked = Array.from(form.querySelectorAll('input[type="checkbox"]:checked'));
if (checked.length > limit) {
input.checked = false;
alert(`本投票最多可选 ${limit} 项`);
}
});
});
});
}
// --- 数据解析逻辑 ---
function parsePosts(doc) {
const tables = doc.querySelectorAll('.t5, .read_t');
tables.forEach(table => {
const tiptop = table.querySelector('.tiptop');
if (!tiptop) return;
const floorText = getNodeText(tiptop);
const floorMatch = floorText.match(/(楼主)|(\d+)楼|GF|B(\d+)F/);
if (!floorMatch) return;
let floorNum;
if (floorMatch[1] || floorMatch[0] === 'GF') {
floorNum = 0;
} else if (floorMatch[3]) {
floorNum = parseInt(floorMatch[3], 10);
} else if (floorMatch[2]) {
floorNum = parseInt(floorMatch[2], 10);
} else {
return;
}
const existingPost = allPosts.get(floorNum);
const postIdElement = table.querySelector('th[id^="td_"], td[id^="td_"]');
const postId = postIdElement ? postIdElement.id.replace(/^td_/, '') : '';
const authorTags = table.querySelectorAll('a[href^="u.php?action-show"]');
let author = '匿名';
let authorHref = '#';
let avatarUrl = '';
for (const a of authorTags) {
const img = a.querySelector('img');
if (img && !avatarUrl) {
avatarUrl = img.src;
}
const text = getDisplayText(a);
if (text && text !== '作者资料' && author === '匿名') {
author = text;
authorHref = a.href;
}
}
if (!avatarUrl) avatarUrl = 'https://www.redditstatic.com/avatars/defaults/v2/avatar_default_1.png';
// 提取主内容
const contentElement = table.querySelector('.tpc_content');
let content = contentElement ? contentElement.innerHTML : '';
if (content.includes('buytopic') || content.includes('sellcontent') || content.includes('action=sell')) {
// 安全改写:改名属性,防止原生跳转
content = content.replace(/\bonclick\s*=/gi, 'data-reddit-onclick=');
}
// 提取附件和签名 (位于 tpc_content 之后,tipad 之前)
if (contentElement) {
let next = contentElement.nextElementSibling;
while (next && !next.classList.contains('tipad') && !next.classList.contains('tiptop')) {
// 排除一些已知的干扰元素,但保留购买框 (jumbotron)
if (!next.classList.contains('gray') && (!next.tagName.startsWith('H') || next.classList.contains('jumbotron') || next.innerHTML.includes('buytopic'))) {
let html = next.outerHTML;
if (html.includes('buytopic') || html.includes('sellcontent') || html.includes('action=sell')) {
html = html.replace(/\bonclick\s*=/gi, 'data-reddit-onclick=');
if (!next.classList.contains('jumbotron')) {
html = `<div class="jumbotron">${html}</div>`;
}
}
content += html;
}
next = next.nextElementSibling;
}
}
// 如果是楼主,提取投票表单
if (floorNum === 0) {
content += getVoteHtml(doc);
}
let time = '未知时间';
const timeSpan = tiptop.querySelector('.gray[title]');
if (timeSpan) {
time = getNodeText(timeSpan);
} else {
const timeMatch = floorText.match(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/);
if (timeMatch) time = timeMatch[0];
}
const parentFloor = extractParentFloor(table);
// 识别冗余引用:如果引用块指向的是父楼层,则标记为冗余并隐藏
// 使用临时 DOM 解析,确保能读取到引用块的完整文本内容
if (parentFloor !== null && content) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content;
const quoteEls = tempDiv.querySelectorAll('.quote, .blockquote, .blockquote2, .blockquote3, blockquote');
let modified = false;
quoteEls.forEach(el => {
// 跳过购买框内的引用(jumbotron)
if (el.closest('.jumbotron')) return;
const textContent = (el.textContent || el.innerText || '').replace(/\u00a0/g, ' ').trim();
if (extractParentFloorFromText(textContent) === parentFloor) {
el.classList.add('redundant-quote');
modified = true;
}
});
if (modified) content = tempDiv.innerHTML;
}
const tipad = table.querySelector('.tipad');
const replyBtn = tipad ? tipad.querySelector('.huifu a') : null;
const replyOnclick = replyBtn ? replyBtn.getAttribute('onclick') : '';
const quoteBtn = tipad ? tipad.querySelector('.yinyong a') : null;
const quoteHref = quoteBtn ? quoteBtn.getAttribute('href') : '#';
const quoteOnclick = quoteBtn ? quoteBtn.getAttribute('onclick') : '';
const editLink = findPostActionLink(table, postId, /编辑/, /action[-=](?:modify|edit)/i);
if (existingPost) {
// 更新已有楼层的可变内容(处理购买后内容变化、编辑等)
existingPost.content = content;
existingPost.time = time;
} else {
allPosts.set(floorNum, {
id: floorNum,
postId,
author,
authorHref,
avatarUrl,
content,
time,
replyOnclick,
quoteHref,
quoteOnclick,
editHref: editLink ? editLink.href : '',
editOnclick: editLink ? editLink.onclick : '',
parentFloor,
children: []
});
}
});
}
// --- 树结构构建 ---
function buildTree() {
rootPosts = [];
// 使用 for...of 替代 forEach 减少闭包开销
for (const post of allPosts.values()) {
post.children = [];
}
const sortedFloors = Array.from(allPosts.keys()).sort((a, b) => a - b);
for (let i = 0; i < sortedFloors.length; i++) {
const floorNum = sortedFloors[i];
const post = allPosts.get(floorNum);
if (post.parentFloor !== null && allPosts.has(post.parentFloor) && post.parentFloor < floorNum) {
allPosts.get(post.parentFloor).children.push(post);
} else {
rootPosts.push(post);
}
}
}
// --- 回复提交逻辑 ---
function getTopicId() {
const formTid = document.querySelector('form[name="FORM"] input[name="tid"]')?.value;
if (formTid) return formTid;
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('tid') || window.location.href.match(/tid-(\d+)/)?.[1] || '';
}
function buildReplyFormData(replyText) {
const form = document.querySelector('form[name="FORM"]');
const tid = getTopicId();
if (!tid) throw new Error('无法识别的主题 ID (tid)');
const formData = new URLSearchParams();
if (form) {
Array.from(form.elements).forEach(element => {
if (!element.name || element.disabled) return;
if (['file', 'submit', 'button', 'image', 'reset'].includes(element.type)) return;
if (['checkbox', 'radio'].includes(element.type) && !element.checked) return;
formData.append(element.name, element.value);
});
}
formData.set('step', '2');
formData.set('action', 'reply');
formData.set('tid', tid);
formData.set('atc_content', replyText);
if (!formData.get('atc_title')) {
formData.set('atc_title', document.querySelector('input[name="atc_title"]')?.value || 'Re:');
}
if (!formData.get('atc_autourl')) formData.set('atc_autourl', '1');
formData.set('Submit', ' 提 交 ');
return formData;
}
function getReplySubmitUrl() {
const form = document.querySelector('form[name="FORM"]');
return new URL(form?.getAttribute('action') || 'post.php?', window.location.href).href;
}
function getPostErrorMessage(responseText) {
const text = (responseText || '').replace(/\s+/g, ' ');
const titleError = text.match(/<title>\s*([^<]*(?:错误|失败)[^<]*)<\/title>/i);
if (titleError) return titleError[1].trim();
const alertError = text.match(/alert\(['"]([^'"]*(?:错误|失败|登录|权限|验证码|内容|标题|灌水)[^'"]*)['"]\)/i);
if (alertError) return alertError[1].trim();
const keywordError = text.match(/(请先登录|您还没有登录|权限不足|非法请求|验证码(?:错误|不正确)?|内容少于|标题不能为空|发帖间隔|灌水预防)/);
return keywordError ? keywordError[1] : '';
}
function isReplySuccess(res) {
const responseText = res.responseText || '';
const responseUrl = res.finalUrl || res.responseURL || '';
if (/read\.php.*(?:tid[=-])\d+/i.test(responseUrl)) return true;
return /(?:发表成功|回复成功|发帖成功|操作完成|如果您的浏览器没有自动跳转)/.test(responseText);
}
function getFloorLabel(floorId) {
return floorId === 0 ? '楼主' : `${floorId}楼`;
}
async function submitReply(post, text) {
// 如果有post信息则构造引用,否则直接回复
const replyText = post ? `[quote]回 ${getFloorLabel(post.id)}(${post.author}) 的帖子[/quote]\n${text}` : text;
const formData = buildReplyFormData(replyText);
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: getReplySubmitUrl(),
data: formData.toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Referer': window.location.href
},
onload: (res) => {
if (res.status < 200 || res.status >= 400) {
reject(new Error(`服务器返回 HTTP ${res.status}`));
return;
}
const errorMessage = getPostErrorMessage(res.responseText);
if (errorMessage) {
reject(new Error(errorMessage));
return;
}
if (isReplySuccess(res)) {
resolve();
} else {
reject(new Error('服务器未返回明确的回复成功信息,请切回原始排版确认后再重试'));
}
},
onerror: () => reject(new Error('网络请求失败'))
});
});
}
// --- 渲染逻辑 ---
function renderReddit() {
if (isRedditMode) {
hideSmilieBox();
} else {
restoreOriginalSmilieBox();
}
redditContainer.innerHTML = '';
const fragment = document.createDocumentFragment();
if (rootPosts.length === 0) {
redditContainer.innerHTML = '<div style="text-align:center;padding:20px;">未发现可解析的内容。</div>';
return;
}
// 渲染发帖标题与原始发帖/回帖入口
const titleElement = document.querySelector('#subject_tpc');
const titleText = titleElement ? (titleElement.innerText || titleElement.textContent || '').trim() : '';
const topicLinks = getOriginalTopicActionLinks();
if (titleText || topicLinks.replyHref || topicLinks.newTopicHref) {
const titleRow = document.createElement('div');
titleRow.className = 'reddit-title-row';
const titleContainer = document.createElement('div');
titleContainer.className = 'reddit-title-text';
titleContainer.innerText = titleText;
titleRow.appendChild(titleContainer);
const actions = document.createElement('div');
actions.className = 'reddit-topic-actions';
if (topicLinks.replyHref) {
const replyLink = document.createElement('a');
replyLink.className = 'reddit-topic-btn';
replyLink.href = topicLinks.replyHref;
replyLink.innerText = '回帖';
actions.appendChild(replyLink);
}
if (topicLinks.newTopicHref) {
const dropdown = document.createElement('div');
dropdown.className = 'reddit-post-dropdown';
const mainBtn = document.createElement('a');
mainBtn.className = 'reddit-topic-btn';
mainBtn.href = topicLinks.newTopicHref;
mainBtn.innerText = '发新帖';
dropdown.appendChild(mainBtn);
if (topicLinks.pollHref) {
const menu = document.createElement('div');
menu.className = 'reddit-post-menu';
const itemNew = document.createElement('a');
itemNew.className = 'reddit-post-menu-item';
itemNew.href = topicLinks.newTopicHref;
itemNew.innerText = '新 帖';
menu.appendChild(itemNew);
const itemPoll = document.createElement('a');
itemPoll.className = 'reddit-post-menu-item';
itemPoll.href = topicLinks.pollHref;
itemPoll.innerText = '投 票';
menu.appendChild(itemPoll);
dropdown.appendChild(menu);
}
actions.appendChild(dropdown);
}
titleRow.appendChild(actions);
fragment.appendChild(titleRow);
}
rootPosts.forEach(post => {
fragment.appendChild(createPostElement(post, 0));
});
// 渲染底部回复框
const replyBox = document.createElement('div');
replyBox.className = 'reddit-reply-box';
replyBox.style.marginTop = '40px';
replyBox.innerHTML = `
<textarea class="reddit-reply-textarea" placeholder="写下你的回复..."></textarea>
<div class="reddit-emoji-container"></div>
<div class="reddit-reply-footer">
<div class="reddit-reply-footer-actions">
<button type="button" class="reddit-emoji-btn" title="选择表情">
<svg style="width:20px;height:20px" viewBox="0 0 20 20" fill="currentColor"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zM6.5 9a1.5 1.5 0 110-3 1.5 1.5 0 010 3zm7 0a1.5 1.5 0 110-3 1.5 1.5 0 010 3zm-7 4c1.11 0 2.06.67 2.48 1.61.1.2.33.39.52.39s.42-.19.52-.39A2.75 2.75 0 0113.5 13h-7z"></path></svg>
</button>
</div>
<div class="reddit-reply-footer-submit">
<button type="button" class="reddit-reply-btn reddit-reply-submit">回复</button>
</div>
</div>
`;
const textarea = replyBox.querySelector('textarea');
const submitBtn = replyBox.querySelector('.reddit-reply-submit');
bindReplyBox(replyBox, textarea, submitBtn);
submitBtn.onclick = async () => {
const text = textarea.value.trim();
if (!text) return;
submitBtn.disabled = true;
submitBtn.innerText = '正在提交...';
try {
await submitReply(null, text);
textarea.value = '';
submitBtn.disabled = false;
submitBtn.innerText = '回复';
await refreshDataNoReload(); // 不传 'latest',保持当前滚动位置
} catch (e) {
alert('回复失败: ' + e.message);
submitBtn.disabled = false;
submitBtn.innerText = '回复';
}
};
fragment.appendChild(replyBox);
redditContainer.appendChild(fragment);
initVoteForms();
}
function createPostElement(post, depth) {
const wrapper = document.createElement('div');
wrapper.className = 'reddit-post-wrapper';
wrapper.dataset.floor = String(post.id);
wrapper.innerHTML = `
<div class="reddit-comment">
<div class="reddit-comment-left">
<div class="reddit-avatar-wrapper">
<img src="${post.avatarUrl}" class="reddit-avatar" />
</div>
${post.children.length > 0 ? `
<div class="reddit-threadline" title="折叠线程"></div>
<button type="button" class="reddit-collapse-btn" title="折叠评论">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M15 9H5v2h10V9z"></path></svg>
</button>
` : ''}
</div>
<div class="reddit-comment-right">
<div class="reddit-comment-header">
<a href="${post.authorHref}" class="reddit-author" target="_blank">${post.author}</a>
<span class="reddit-time">• ${post.time}</span>
${post.children.length > 0 ? `
<svg class="reddit-collapse-inline" viewBox="0 0 20 20" fill="currentColor"><path d="M15 9H5v2h10V9z"></path></svg>
<svg class="reddit-expand-inline" viewBox="0 0 20 20" fill="currentColor"><path d="M11 9h4v2h-4v4H9v-4H5V9h4V5h2v4z"></path></svg>
` : ''}
</div>
<div class="reddit-comment-body">${post.content}</div>
<div class="reddit-comment-actions">
<button type="button" class="reddit-action-btn reddit-reply-toggle-btn">
<svg class="reddit-action-icon" viewBox="0 0 20 20"><path d="M15.429 16.353A8.44 8.44 0 0 1 9.07 18.5a8.775 8.775 0 0 1-4.704-1.36 1.488 1.488 0 0 1-.582-1.688l.685-2.228-1.579-1.365A6.99 6.99 0 0 1 1.5 6.5C1.5 2.915 5.314.5 10 .5s8.5 2.415 8.5 6c0 3.824-3.528 7.306-3.071 9.853Zm-1.89-1.257c-.198-1.128.535-4.227.535-4.227 1.636-1.536 3.176-3.14 3.176-4.869 0-2.824-3.142-4.75-7.25-4.75-4.108 0-7.25 1.926-7.25 4.75 0 2.224 1.691 3.99 4.316 4.542l.216.045-.065.212-.862 2.802a7.513 7.513 0 0 0 3.655-.918l.21-.112.213.109a7.195 7.195 0 0 0 5.106 2.416Z"></path></svg>
回复
</button>
${post.editHref ? `
<a class="reddit-action-btn reddit-edit-btn" href="${post.editHref}">
<svg class="reddit-action-icon" viewBox="0 0 20 20"><path d="M15.9 2.7a2.1 2.1 0 0 0-3 0L4 11.6 3 17l5.4-1 8.9-8.9a2.1 2.1 0 0 0 0-3l-1.4-1.4ZM7.7 14.6l-2.8.5.5-2.8 6.6-6.6 2.3 2.3-6.6 6.6Zm7.7-7.7-2.3-2.3 1-1a.6.6 0 0 1 .8 0L16.3 5a.6.6 0 0 1 0 .8l-.9 1.1Z"></path></svg>
编辑
</a>
` : ''}
</div>
<div class="reddit-reply-container"></div>
</div>
</div>
`;
const toggleCollapse = () => {
wrapper.classList.toggle('collapsed');
};
const threadline = wrapper.querySelector('.reddit-threadline');
if (threadline) threadline.onclick = toggleCollapse;
const collapseBtn = wrapper.querySelector('.reddit-collapse-btn');
if (collapseBtn) collapseBtn.onclick = toggleCollapse;
const inlineCollapse = wrapper.querySelector('.reddit-collapse-inline');
if (inlineCollapse) inlineCollapse.onclick = toggleCollapse;
const inlineExpand = wrapper.querySelector('.reddit-expand-inline');
if (inlineExpand) inlineExpand.onclick = toggleCollapse;
// 回复功能逻辑
const replyBtn = wrapper.querySelector('.reddit-reply-toggle-btn');
const replyContainer = wrapper.querySelector('.reddit-reply-container');
replyBtn.onclick = () => {
if (replyContainer.innerHTML === '') {
const replyBox = document.createElement('div');
replyBox.className = 'reddit-reply-box';
replyBox.innerHTML = `
<textarea class="reddit-reply-textarea" placeholder="写下你的回复..."></textarea>
<div class="reddit-emoji-container"></div>
<div class="reddit-reply-footer">
<div class="reddit-reply-footer-actions">
<button type="button" class="reddit-emoji-btn" title="选择表情">
<svg style="width:20px;height:20px" viewBox="0 0 20 20" fill="currentColor"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zM6.5 9a1.5 1.5 0 110-3 1.5 1.5 0 010 3zm7 0a1.5 1.5 0 110-3 1.5 1.5 0 010 3zm-7 4c1.11 0 2.06.67 2.48 1.61.1.2.33.39.52.39s.42-.19.52-.39A2.75 2.75 0 0113.5 13h-7z"></path></svg>
</button>
</div>
<div class="reddit-reply-footer-submit">
<button type="button" class="reddit-reply-btn reddit-reply-cancel">取消</button>
<button type="button" class="reddit-reply-btn reddit-reply-submit">回复</button>
</div>
</div>
`;
const textarea = replyBox.querySelector('textarea');
const submitBtn = replyBox.querySelector('.reddit-reply-submit');
const cancelBtn = replyBox.querySelector('.reddit-reply-cancel');
bindReplyBox(replyBox, textarea, submitBtn);
// 自动聚焦
setTimeout(() => textarea.focus(), 10);
cancelBtn.onclick = () => {
hideSmilieBox();
replyContainer.innerHTML = '';
};
submitBtn.onclick = async () => {
const text = textarea.value.trim();
if (!text) return;
submitBtn.disabled = true;
submitBtn.innerText = '正在提交...';
try {
await submitReply(post, text);
hideSmilieBox();
replyContainer.innerHTML = '';
await refreshDataNoReload(); // 不传 'latest',保持当前滚动位置
} catch (e) {
alert('回复失败: ' + e.message);
submitBtn.disabled = false;
submitBtn.innerText = '回复';
}
};
replyContainer.appendChild(replyBox);
} else {
if (replyContainer.querySelector('#smiliebox')) hideSmilieBox();
replyContainer.innerHTML = '';
}
};
if (post.children.length > 0 && depth < CONFIG.NESTING_LIMIT) {
const childrenDiv = document.createElement('div');
childrenDiv.className = 'reddit-children';
post.children.forEach(child => {
childrenDiv.appendChild(createPostElement(child, depth + 1));
});
wrapper.appendChild(childrenDiv);
}
return wrapper;
}
// 启动
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();