// ==UserScript==
// @name NodeSeek Threads v1.6
// @name:zh-CN NodeSeek 楼中楼 v1.6
// @namespace http://tampermonkey.net/
// @version 1.6
// @description Optimized nested comments with reliable username extraction, fully asynchronous cross-page quoting and mention processing.
// @description:zh-CN 为 NodeSeek 网站提供优化版嵌套评论(楼中楼)功能,支持可靠的用户名提取、完全异步的跨页引用和提及处理。
// @author Dean & Gemini
// @match https://www.nodeseek.com/post-*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @icon https://www.google.com/s2/favicons?sz=64&domain=nodeseek.com
// @license MIT
// @homepageURL https://github.com/deanhzed/NodeSeekThreads
// ==/UserScript==
(function() {
'use strict';
// --- 1. Configuration & Settings / 配置与设置 ---
const settings = {
// Whether to show user signatures / 是否显示用户签名
showSignatures: GM_getValue('showSignatures', true),
// Mention nesting feature is always enabled (asynchronous processing) / 提及嵌套功能始终开启 (异步处理)
enableMentions: true,
};
/**
* Registers Tampermonkey menu commands.
* 注册 Tampermonkey 菜单命令。
*/
function registerMenuCommands() {
GM_registerMenuCommand(`${settings.showSignatures ? '隐藏' : '显示'} 签名栏 / ${settings.showSignatures ? 'Hide' : 'Show'} Signatures`, () => {
GM_setValue('showSignatures', !settings.showSignatures);
window.location.reload(); // Reload page to apply settings / 重新加载页面以应用设置
});
}
// --- 2. Page Cache (for cross-page quoting) / 页面缓存 (用于跨页引用) ---
const pageCache = new Map();
// --- 3. CSS Styles / CSS 样式 ---
const nestedAvatarSize = 36; // Nested comment avatar size / 嵌套评论头像大小
const avatarMargin = 8; // Avatar margin / 头像外边距
const nestedIndent = nestedAvatarSize + avatarMargin; // Nested comment indentation / 嵌套评论缩进量
/**
* Applies custom CSS styles to the page.
* 应用自定义 CSS 样式到页面。
*/
function applyStyles() {
let styles = `
/* --- Base Styles for Top-level Comments / 顶级评论基础样式 --- */
.comments > .content-item {
background: rgba(0,0,0,.05) !important;
border: 1px solid rgba(0,0,0,.05) !important;
padding: 10px 10px 10px 16px !important;
margin-bottom: 10px !important;
border-radius: 1px !important;
transition: background .3s, border .3s;
position: relative;
}
.comments > .content-item::before, .comments > .content-item::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 3px;
}
.comments > .content-item::before { left: 0; background-color: rgba(0,0,0,.1); }
.comments > .content-item::after { left: 3px; background-color: rgba(0,0,0,.2); }
/* --- Base Styles for Nested Replies Container / 嵌套回复容器基础样式 --- */
.nested-replies-container {
border: 1px solid rgba(0,0,0,.1);
border-left: none;
margin-top: 10px;
padding: 0 8px 5px 12px;
border-radius: 1px;
transition: border .3s;
background: hsla(0,0%,100%,.01) !important;
position: relative;
}
.nested-replies-container::before, .nested-replies-container::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 2px;
}
.nested-replies-container::before { left: 0; background-color: rgba(0,0,0,.05); }
.nested-replies-container::after { left: 2px; background-color: rgba(0,0,0,.1); }
/* --- Base Styles for Nested Comment Items / 嵌套评论项基础样式 --- */
.nested-replies-container > .content-item.is-nested {
border: none !important;
padding: 8px 0 0 0 !important;
margin-bottom: 0 !important;
border-bottom: 1px dashed rgba(0,0,0,.08) !important;
background: transparent !important;
transition: border .3s;
}
.nested-replies-container > .content-item.is-nested:last-child { border-bottom: none !important; }
.nested-replies-container > .content-item.is-nested:first-child { padding-top: 5px !important; }
/* --- Nested Elements Layout / 嵌套元素布局 --- */
.is-nested .avatar-normal { width: ${nestedAvatarSize}px !important; height: ${nestedAvatarSize}px !important; }
.is-nested > .post-content, .is-nested > .signature { margin-left: ${nestedIndent}px; }
/* --- Cross-Page Quote Block Styles / 跨页引用块样式 --- */
.cross-page-quote {
border-left: 3px solid rgba(0,0,0,.15);
padding: 12px;
margin: 10px 0;
font-size: 0.95em;
color: #555;
background: rgba(0,0,0,.03);
border-radius: 1px 1px 1px 1px;
}
.cross-page-quote .quote-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 5px;
}
.cross-page-quote .quote-header .avatar-normal {
width: 24px;
height: 24px;
border-radius: 15%;
}
.cross-page-quote .quote-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
flex-grow: 1;
}
.cross-page-quote .quote-author {
font-weight: bold;
}
.cross-page-quote .quote-time {
font-size: 0.8em;
color: #888;
}
.cross-page-quote .quote-floor-id {
background: rgba(0,0,0,.1);
padding: 2px 6px;
border-radius: 5px;
font-size: 0.85em;
}
.cross-page-quote .quote-content {
color: #666;
padding-left: 10px;
border-left: 1px dotted rgba(0,0,0,.1);
}
/* --- Dark Mode Overrides / 暗色模式覆盖样式 --- */
body.dark-layout .comments > .content-item {
background: hsla(0,0%,100%,.01) !important;
border: 1px solid hsla(0,0%,100%,.05) !important;
}
body.dark-layout .comments > .content-item::before { background-color: hsla(0,0%,100%,.05); }
body.dark-layout .comments > .content-item::after { background-color: hsla(0,0%,100%,.1); }
body.dark-layout .nested-replies-container { border-color: hsla(0,0%,100%,.1) !important; }
body.dark-layout .nested-replies-container::before { background-color: hsla(0,0%,100%,.05); }
body.dark-layout .nested-replies-container::after { background-color: hsla(0,0%,100%,.1); }
body.dark-layout .nested-replies-container > .content-item.is-nested { border-bottom-color: hsla(0,0%,100%,.08) !important; }
body.dark-layout .cross-page-quote {
border-left-color: hsla(0,0%,100%,.2);
color: #bbb;
background: hsla(0,0%,100%,.03);
}
body.dark-layout .cross-page-quote .quote-content {
color: #aaa;
border-left-color: hsla(0,0%,100%,.1);
}
body.dark-layout .quote-floor-id { background: hsla(0,0%,100%,.1); }
body.dark-layout .cross-page-quote .quote-time {
color: #999;
}
/* --- Floor Link Styles / 楼层链接样式 --- */
.content-item:not(.is-nested) .floor-link {
font-weight: bold;
background: rgba(0,0,0,.1);
padding: 2px 6px;
border-radius: 5px;
font-size: 0.85em;
margin-right: 8px;
}
/* --- Nested Floor Link Styles / 嵌套楼层链接样式 --- */
.is-nested .floor-link {
color: #888;
font-weight: normal;
font-size: 0.9em;
}
`;
if (!settings.showSignatures) {
styles += `.signature { display: none !important; }`;
}
GM_addStyle(styles);
}
// --- 4. Core Logic Functions / 核心逻辑函数 ---
/**
* Reliably extracts the author name from a comment element.
* This function is designed to get the author of the comment itself, not who it mentions.
* It prioritizes the .author-name element.
* 从评论元素中可靠地提取作者名。
* 此函数旨在获取评论本身的作者,而不是其提及的对象。
* 优先从 .author-name 元素中获取。
* @param {HTMLElement} element The comment element. / 评论元素。
* @returns {string} The extracted author name or 'unknown'. / 提取到的作者名或 'unknown'。
*/
function extractAuthorName(element) {
const authorNameElement = element.querySelector('.author-name');
if (authorNameElement) {
const name = authorNameElement.textContent.trim();
return name;
}
return 'unknown';
}
/**
* Creates a cross-page quote block HTML element.
* 创建一个跨页引用块的 HTML 元素。
* @param {string} authorName The author's name. / 作者名。
* @param {string} floorId The floor ID. / 楼层ID。
* @param {string|null} content The quoted content HTML, or null if not available. / 引用内容 HTML,如果不可用则为 null。
* @param {string} originalPostUrl The URL of the original post containing the quoted comment. / 包含被引用评论的原始帖子URL。
* @param {string|null} avatarUrl The URL of the author's avatar. / 作者头像URL。
* @param {string|null} postTime The formatted post time. / 格式化的发布时间。
* @returns {HTMLElement} The created blockquote element. / 创建的 blockquote 元素。
*/
function createCrossPageQuote(authorName, floorId, content = null, originalPostUrl = '', avatarUrl = null, postTime = null) {
const blockquote = document.createElement('blockquote');
blockquote.className = 'cross-page-quote';
let contentHtml = content ? `<div class="quote-content">${content}</div>` : '<div>无法获取引用内容</div>';
// Create clickable links for author and floor ID / 创建作者和楼层ID的可点击链接
const authorLink = `<a href="/member?t=${encodeURIComponent(authorName)}" data-eusoft-scrollable-element="1">${authorName}</a>`;
const floorLink = originalPostUrl ? `<a href="${originalPostUrl.split('#')[0]}#${floorId}" data-eusoft-scrollable-element="1">#${floorId}</a>` : `#${floorId}`;
const avatarHtml = avatarUrl ? `
<div class="avatar-wrapper">
<a href="/member?t=${encodeURIComponent(authorName)}" data-eusoft-scrollable-element="1">
<img class="avatar-normal" src="${avatarUrl}" alt="${authorName}">
</a>
</div>` : '';
const timeHtml = postTime ? `
<span class="date-created">
<time>${postTime}</time>
</span>` : '';
blockquote.innerHTML = `
<div class="quote-header">
${avatarHtml}
<div class="quote-meta">
<span class="quote-author">${authorLink}</span>
${timeHtml}
</div>
<span class="quote-floor-id">${floorLink}</span>
</div>
${contentHtml}
`;
return blockquote;
}
/**
* Parses a comment's author, content, avatar, and post time from fetched HTML.
* 从获取到的 HTML 中解析评论的作者、内容、头像和发布时间。
* @param {string} html The HTML content of the page. / 页面的 HTML 内容。
* @param {string} floorId The ID of the comment to parse. / 要解析的评论ID。
* @returns {{author: string, content: string|null, avatarUrl: string|null, postTime: string|null}} The parsed data. / 解析后的数据。
*/
function parseCommentFromHtml(html, floorId) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const commentElement = doc.getElementById(floorId);
if (!commentElement) {
return { author: 'unknown', content: null, avatarUrl: null, postTime: null };
}
const author = extractAuthorName(commentElement);
const contentElement = commentElement.querySelector('.post-content');
const content = contentElement ? contentElement.innerHTML : null;
const avatarImg = commentElement.querySelector('.avatar-normal');
const avatarUrl = avatarImg ? avatarImg.src : null;
const timeElement = commentElement.querySelector('.date-created time');
const postTime = timeElement ? timeElement.textContent.trim() : null;
return { author, content, avatarUrl, postTime };
}
/**
* Fetches a page and parses quote data from it.
* 获取页面并从中解析引用数据。
* @param {string} parentUrl The URL of the parent comment's page. / 父评论页面的URL。
* @param {string} parentFloorId The floor ID of the parent comment. / 父评论的楼层ID。
* @param {string|null} initialAuthorName An initial author name if available from the current comment. / 初始作者名(如果可用)。
* @returns {Promise<Object>} A Promise that resolves with the quote data. / 包含引用数据的 Promise。
*/
function fetchAndParseQuoteData(parentUrl, parentFloorId, initialAuthorName = null) {
const pageUrl = parentUrl.split('#')[0];
let finalAuthor = initialAuthorName || 'unknown';
// Check cache first / 检查缓存
if (pageCache.has(pageUrl)) {
const cachedData = parseCommentFromHtml(pageCache.get(pageUrl), parentFloorId);
if (cachedData.author !== 'unknown') {
finalAuthor = cachedData.author;
}
return Promise.resolve({ ...cachedData, author: finalAuthor, originalPostUrl: pageUrl });
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: pageUrl,
onload: function(response) {
if (response.status >= 200 && response.status < 400) {
const htmlContent = response.responseText;
pageCache.set(pageUrl, htmlContent);
const parsedData = parseCommentFromHtml(htmlContent, parentFloorId);
if (parsedData.author !== 'unknown') {
finalAuthor = parsedData.author;
}
resolve({ ...parsedData, author: finalAuthor, originalPostUrl: pageUrl });
} else {
reject(new Error(`Failed to fetch page ${pageUrl}: Status ${response.status}`));
}
},
onerror: function(error) {
reject(new Error(`Network error fetching page: ${pageUrl} - ${error}`));
}
});
});
}
/**
* Normalizes a username for consistent matching.
* 规范化用户名以便一致匹配。
* @param {string} name The username to normalize. / 要规范化的用户名。
* @returns {string} The normalized username. / 规范化后的用户名。
*/
function normalizeUsername(name) {
return name.replace(/^@/, '')
.replace(/\s+/g, '')
.replace(/[^\w\u4e00-\u9fa5]/g, '')
.toLowerCase();
}
// --- 5. Main Comment Processing Logic / 主要评论处理逻辑 ---
let commentsToProcessForMentionsGlobal = []; // Global variable to store comments for mention nesting processing / 全局变量,存储需要进行提及嵌套处理的评论
let userLastCommentMap = new Map(); // Global variable to map normalized usernames to their last comment element / 全局变量,存储规范化用户名到其最新评论元素的映射
let commentsToProcessForCrossQuotesGlobal = []; // Global variable to store comments for cross-page quoting processing / 全局变量,存储需要进行跨页引用处理的评论数据
/**
* Processes comments for same-page nesting and collects data for asynchronous processing.
* 处理评论以进行同页嵌套,并收集数据以进行异步处理。
*/
function processComments() {
const allCommentsInDOM = Array.from(document.querySelectorAll('.content-item')); // Get all comment elements in the DOM / 获取所有评论元素
const commentMap = new Map(); // Maps floor ID to comment element / 存储楼层ID到评论元素的映射
commentsToProcessForMentionsGlobal = []; // Reset global list / 重置全局列表
userLastCommentMap = new Map(); // Reset global map / 重置全局映射
commentsToProcessForCrossQuotesGlobal = []; // Reset global list / 重置全局列表
// Iterate through all comments, populate maps, and attempt same-page floor nesting / 遍历所有评论,填充映射并尝试进行同页楼层嵌套处理
allCommentsInDOM.forEach(comment => {
comment.classList.remove('nested-processed', 'mention-processed'); // Reset processing flags / 重置处理标记
const floorId = comment.getAttribute('id');
if (floorId) {
commentMap.set(floorId, comment);
}
const authorName = extractAuthorName(comment);
if (authorName !== 'unknown') {
userLastCommentMap.set(normalizeUsername(authorName), comment);
}
// Skip already nested comments, they should not be processed as top-level comments again / 跳过已嵌套的评论,它们不应再作为顶级评论处理
if (comment.parentElement?.classList.contains('nested-replies-container')) {
return;
}
let hasAnyFloorReference = false; // Flag indicating if the comment contains any floor reference / 标记评论是否包含任何楼层引用
// let handledByFloorNesting = false; // Flag indicating if the comment has been successfully nested by floor / 标记评论是否已被楼层嵌套成功处理 (不再直接使用,因为跨页引用也异步化了)
const replyLinks = comment.querySelectorAll('.post-content a[href*="/post-"]');
for (const replyLink of replyLinks) {
if (replyLink.closest('blockquote')) { // Skip links inside blockquotes / 跳过引用块内的链接
continue;
}
hasAnyFloorReference = true; // Mark that a floor reference exists / 标记存在楼层引用
if (!replyLink.hash || replyLink.hash === '#0') { // If it's #0 or no hash, skip nesting processing / 如果是 #0 或没有哈希,跳过嵌套处理
continue;
}
const parentFloorId = replyLink.hash.substring(1);
const parentComment = commentMap.get(parentFloorId);
if (parentComment) { // Parent comment found on the current page (potential same-page nesting) / 在当前页面找到父评论 (同页嵌套)
if (parentComment !== comment && parseInt(parentFloorId) < parseInt(comment.id)) {
// Valid same-page nesting / 有效的同页嵌套
let container = parentComment.querySelector('.nested-replies-container');
if (!container) {
container = document.createElement('div');
container.className = 'nested-replies-container';
parentComment.appendChild(container);
}
container.appendChild(comment);
comment.classList.add('is-nested');
// handledByFloorNesting = true; // No longer needed as cross-page is async / 不再需要,因为跨页引用也异步化了
break; // Break after successful nesting / 成功嵌套后跳出循环
}
} else { // Parent comment NOT found on the current page (cross-page reference) / 当前页面未找到父评论 (跨页引用)
if (!comment.classList.contains('cross-quote-processed')) {
comment.classList.add('cross-quote-processed'); // Add flag immediately to prevent duplicate processing / 立即添加标记,防止重复处理
// Add comment to cross-page quoting processing list, instead of calling fetchAndQuote immediately
// 将评论添加到跨页引用处理列表,而不是立即调用 fetchAndQuote
commentsToProcessForCrossQuotesGlobal.push({
replyLink: replyLink,
parentFloorId: parentFloorId,
currentComment: comment,
initialAuthorName: replyLink.textContent.match(/@([^#\s]+)/) ? replyLink.textContent.match(/@([^#\s]+)/)[1] : null
});
// handledByFloorNesting = true; // No longer needed as cross-page is async / 不再需要,因为跨页引用也异步化了
break; // Break after initiating cross-page quote / 启动跨页引用后跳出循环
}
}
}
// If no floor reference was found, add to the list for mention processing / 如果没有楼层引用,则添加到待处理提及列表
if (!hasAnyFloorReference) {
commentsToProcessForMentionsGlobal.push(comment);
}
comment.classList.add('nested-processed'); // Mark as processed / 标记为已处理
});
}
/**
* Asynchronously processes mention nesting logic.
* 异步处理提及嵌套逻辑。
*/
function processMentionsSeparately() {
if (!settings.enableMentions) { // Check if mention nesting is enabled / 检查是否启用提及嵌套
return;
}
commentsToProcessForMentionsGlobal.forEach(comment => {
// If the comment is already nested, skip mention processing / 如果评论已被嵌套,则跳过提及处理
if (comment.classList.contains('is-nested')) {
return;
}
const mentionLinks = comment.querySelectorAll('.post-content a[href^="/member?"]');
for (const mentionLink of mentionLinks) {
if (mentionLink.closest('blockquote') || !mentionLink.textContent.startsWith('@')) {
continue;
}
const username = mentionLink.textContent.substring(1).trim();
const normalizedUsername = normalizeUsername(username);
if (normalizedUsername.length < 2) {
continue;
}
const parentComment = userLastCommentMap.get(normalizedUsername);
if (parentComment && parentComment !== comment && !comment.contains(parentComment) && parseInt(parentComment.id) < parseInt(comment.id)) {
let container = parentComment.querySelector('.nested-replies-container');
if (!container) {
container = document.createElement('div');
container.className = 'nested-replies-container';
parentComment.appendChild(container);
}
container.appendChild(comment);
comment.classList.add('is-nested');
break;
}
}
});
}
/**
* Asynchronously processes cross-page quoting logic.
* 异步处理跨页引用逻辑。
*/
async function processCrossPageQuotesSeparately() {
for (const quoteData of commentsToProcessForCrossQuotesGlobal) {
try {
const parsedQuoteData = await fetchAndParseQuoteData(
quoteData.replyLink.href,
quoteData.parentFloorId,
quoteData.initialAuthorName
);
const blockquote = createCrossPageQuote(
parsedQuoteData.author,
parsedQuoteData.floorId || quoteData.parentFloorId, // Use parsed floorId if available, else original / 如果可用,使用解析的楼层ID,否则使用原始楼层ID
parsedQuoteData.content,
parsedQuoteData.originalPostUrl,
parsedQuoteData.avatarUrl,
parsedQuoteData.postTime
);
quoteData.currentComment.querySelector('.post-content')?.prepend(blockquote);
} catch (error) {
// If fetching fails, still add a "failed to load" quote block / 如果获取失败,仍然添加一个“加载失败”的引用框
const blockquote = createCrossPageQuote(
quoteData.initialAuthorName || 'unknown',
quoteData.parentFloorId,
'加载失败',
quoteData.replyLink.href,
null,
null
);
quoteData.currentComment.querySelector('.post-content')?.prepend(blockquote);
}
}
}
// --- 6. Initialization / 初始化 ---
window.addEventListener('load', () => {
registerMenuCommands(); // Register menu commands / 注册菜单命令
applyStyles(); // Apply styles / 应用样式
// Initial processing of comments (including same-page floor nesting) / 初始处理评论 (包括同页楼层嵌套)
processComments();
// Asynchronously process mention nesting / 异步处理提及嵌套
// Use setTimeout to ensure execution when the main thread is idle, not blocking page load
// 使用 setTimeout 确保在主线程空闲时执行,不阻塞页面加载
setTimeout(processMentionsSeparately, 100);
// Asynchronously process cross-page quoting / 异步处理跨页引用
// Give mention nesting some time, then process cross-page quoting
// 给予提及嵌套一些时间,再处理跨页引用
setTimeout(processCrossPageQuotesSeparately, 200);
// Set up MutationObserver to listen for DOM changes, to re-process comments / 设置 MutationObserver 监听 DOM 变化,以便重新处理评论
const commentsContainer = document.querySelector('.comments');
if (commentsContainer) {
const observer = new MutationObserver(() => {
// Debounce processing to avoid excessive runs on rapid DOM changes / 防抖处理,避免在快速DOM变化时过度运行
clearTimeout(window._nsThreadsProcessTimeout);
window._nsThreadsProcessTimeout = setTimeout(() => {
processComments();
setTimeout(processMentionsSeparately, 100); // Re-process mention nesting / 重新处理提及嵌套
setTimeout(processCrossPageQuotesSeparately, 200); // Re-process cross-page quoting / 重新处理跨页引用
}, 300);
});
observer.observe(commentsContainer, { childList: true, subtree: true });
}
});
})();