您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
为 NodeSeek 网站提供优化版嵌套评论(楼中楼)功能,支持可靠的用户名提取、完全异步的跨页引用和提及处理。
// ==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 }); } }); })();