一个基于 Tampermonkey 的小脚本,可以在 NodeSeek 论坛里批量调用 AI 给帖子做 分类 & 打分
主要解决的问题:版块里水贴多,想快速挑出有价值的内容。

功能特点

  • 🚥 自动分类

  • 🎨 彩色标签:不同分类自动上色,更直观

  • ⚙️ 可配置项

    • API Key
    • 模型名称
    • API 请求地址(支持自建代理)
    • 最低显示分数
    • 最大并发数
    • 批处理等待时间

使用方法

  1. 安装 Tampermonkey
  2. 新建脚本 → 粘贴本仓库的代码
  3. 在 NodeSeek 打开任意帖子列表,右下角会出现「AI 设置」按钮
  4. 配置好 API Key / 模型,就能自动跑起来

效果预览

  • 帖子标题旁边会显示分类 + 分数
  • 分类颜色一目了然
  • 低分内容会自动折叠/隐藏
// ==UserScript==// @name         NodeSeek AI 帖子辅助工具// @namespace    http://tampermonkey.net/// @version      1.2.2// @description  集成OpenAI兼容的AI服务,对NodeSeek论坛帖子进行智能评分、分类和筛选,提升浏览体验。// @author       Gemini// @match        https://*.nodeseek.com/*// @icon         https://www.google.com/s2/favicons?sz=64&domain=nodeseek.com// @grant        GM_addStyle// @grant        GM_xmlhttpRequest// @grant        GM_setValue// @grant        GM_getValue// @grant        GM_registerMenuCommand// @connect      api.openai.com// @connect      *// ==/UserScript==(function() {    'use strict';    // --- [!] 新增: 最大重试次数 ---    const MAX_RETRIES = 2; // 初始尝试失败后,最多再重试2次    // --- 默认配置 ---    const DEFAULTS = {        api_key: '',        api_url: 'https://api.openai.com/v1/chat/completions',        model: 'gpt-3.5-turbo',        score_threshold: 0,        hidden_categories: [],        batch_size: 5,        request_delay: 2000,        max_content_length: 2000,        enabled: true,    };    const CATEGORIES = ['抽奖', '纠纷', '灌水', '交易', '求助', '评测', '拼车', '广告', '技术', '其他'];    // [!] --- 此处前面大部分代码与上一版相同,为保持完整性而保留 ---    const Utils = { log: (message, ...args) => console.log('[NS AI Helper]', message, ...args), getPostIdFromUrl: (url) => { const match = url.match(/post-(\d+)/); return match ? match[1] : null; }, getCategoryColor: (category) => { const index = CATEGORIES.indexOf(category); const hue = (index * (360 / CATEGORIES.length)) % 360; return `hsl(${hue}, 70%, 45%)`; }, getScoreColor: (score) => { if (score >= 81) return '#e67e22'; if (score >= 61) return '#2ecc71'; if (score >= 41) return '#3498db'; if (score >= 21) return '#95a5a6'; return '#e74c3c'; } };    const Config = { _settings: {}, load() { this._settings = { ...DEFAULTS, ...JSON.parse(GM_getValue('NS_AI_SETTINGS', "{}")) }; }, save() { GM_setValue('NS_AI_SETTINGS', JSON.stringify(this._settings)); }, get(key) { return this._settings[key]; }, set(key, value) { this._settings[key] = value; } };    const Cache = { get: (postId) => { const data = GM_getValue(`NS_AI_CACHE_${postId}`); return data ? JSON.parse(data) : null; }, set: (postId, data) => { GM_setValue(`NS_AI_CACHE_${postId}`, JSON.stringify(data)); } };    const UIManager = { processDebounce: null, init() { this.addStyles(); this.injectSettingsButton(); if (window.location.pathname === '/' || window.location.pathname.startsWith('/board/')) { this.injectGlobalToggle(); } }, injectGlobalToggle() { const container = document.querySelector('.site-header .menu'); if (!container || document.querySelector('#ns-ai-toggle')) return; const toggleContainer = document.createElement('div'); toggleContainer.id = 'ns-ai-toggle'; toggleContainer.className = 'menu-item-container'; toggleContainer.innerHTML = `<label for="ns-ai-master-switch" class="ns-ai-toggle-label">AI辅助:</label><input type="checkbox" id="ns-ai-master-switch" ${Config.get('enabled') ? 'checked' : ''}>`; container.prepend(toggleContainer); document.getElementById('ns-ai-master-switch').addEventListener('change', (e) => { Config.set('enabled', e.target.checked); Config.save(); window.location.reload(); }); }, addStyles() { GM_addStyle(` .ns-ai-tag { display: inline-block; margin-left: 8px; padding: 2px 6px; font-size: 12px; font-weight: bold; border-radius: 4px; color: white; vertical-align: middle; } .ns-ai-score { background-color: var(--score-color); } .ns-ai-category { background-color: var(--category-color); } .post-list-item.ns-ai-hidden { display: none !important; } .ns-ai-settings-btn { cursor: pointer; display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; } .ns-ai-settings-btn:hover { background-color: rgba(0,0,0,0.05); border-radius: 5px; } .ns-ai-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.6); z-index: 9998; } .ns-ai-modal-content { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #fff; border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 9999; width: 90%; max-width: 500px; } .dark .ns-ai-modal-content { background: #1a1a1b; color: #d7dadc; } .ns-ai-modal-header { padding: 16px; border-bottom: 1px solid #eee; } .dark .ns-ai-modal-header { border-bottom-color: #343536; } .ns-ai-modal-header h2 { margin: 0; font-size: 18px; } .ns-ai-modal-body { padding: 16px; max-height: 60vh; overflow-y: auto; } .ns-ai-form-group { margin-bottom: 15px; } .ns-ai-form-group label { display: block; margin-bottom: 5px; font-weight: bold; } .ns-ai-form-group input, .ns-ai-form-group select { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; background-color: #fff; color: #333; } .dark .ns-ai-form-group input, .dark .ns-ai-form-group select { background-color: #272729; border-color: #343536; color: #d7dadc; } .ns-ai-category-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 10px; } .ns-ai-category-grid label { display: flex; align-items: center; gap: 5px; font-weight: normal; } .ns-ai-modal-footer { padding: 16px; text-align: right; border-top: 1px solid #eee; } .dark .ns-ai-modal-footer { border-top-color: #343536; } .ns-ai-btn { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; } .ns-ai-btn-primary { background-color: #007bff; color: white; } .ns-ai-btn-secondary { background-color: #6c757d; color: white; margin-left:10px; } body.ns-ai-modal-open { overflow: hidden; } #ns-ai-toggle { padding: 0 10px; } .ns-ai-toggle-label { font-size: 14px; margin-right: 5px; vertical-align: middle; } #ns-ai-master-switch { vertical-align: middle; }`); }, injectSettingsButton() { const menuContainer = document.querySelector('.user-card .menu > div'); if (menuContainer && !document.querySelector('.ns-ai-settings-btn')) { const btn = document.createElement('div'); btn.className = 'ns-ai-settings-btn'; btn.title = 'AI 辅助设置'; btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>`; btn.addEventListener('click', () => this.createSettingsModal()); menuContainer.prepend(btn); GM_registerMenuCommand("AI 辅助设置", () => this.createSettingsModal()); } }, createSettingsModal() { /* ... 此处代码与上一版完全相同 ... */  if (document.getElementById('ns-ai-modal-overlay')) return; const modalOverlay = document.createElement('div'); modalOverlay.id = 'ns-ai-modal-overlay'; modalOverlay.className = 'ns-ai-modal-overlay'; const modalContent = document.createElement('div'); modalContent.id = 'ns-ai-modal-content'; modalContent.className = 'ns-ai-modal-content'; modalContent.innerHTML = ` <div class="ns-ai-modal-header"> <h2>AI 帖子辅助工具设置</h2> </div> <div class="ns-ai-modal-body"> <div class="ns-ai-form-group"> <label for="ns-ai-api-url">API 地址 (兼容 OpenAI)</label> <input type="text" id="ns-ai-api-url" value="${Config.get('api_url')}"> </div> <div class="ns-ai-form-group"> <label for="ns-ai-api-key">API 密钥</label> <input type="password" id="ns-ai-api-key" value="${Config.get('api_key')}"> </div> <div class="ns-ai-form-group"> <label for="ns-ai-model">模型</label> <input type="text" id="ns-ai-model" value="${Config.get('model')}"> </div> <hr> <div class="ns-ai-form-group"> <label for="ns-ai-score-threshold">按评分隐藏 (0 为不隐藏)</label> <input type="number" id="ns-ai-score-threshold" min="0" max="100" value="${Config.get('score_threshold')}"> </div> <div class="ns-ai-form-group"> <label>按分类隐藏</label> <div class="ns-ai-category-grid"> ${CATEGORIES.map(cat => ` <label> <input type="checkbox" class="ns-ai-hidden-cat" value="${cat}" ${Config.get('hidden_categories').includes(cat) ? 'checked' : ''}> ${cat} </label> `).join('')} </div> </div> <hr> <div class="ns-ai-form-group"> <label for="ns-ai-batch-size">批量分析数</label> <input type="number" id="ns-ai-batch-size" min="1" max="10" value="${Config.get('batch_size')}"> </div> <div class="ns-ai-form-group"> <label for="ns-ai-request-delay">请求间隔 (毫秒)</label> <input type="number" id="ns-ai-request-delay" min="500" value="${Config.get('request_delay')}"> </div> </div> <div class="ns-ai-modal-footer"> <button id="ns-ai-save-btn" class="ns-ai-btn ns-ai-btn-primary">保存并刷新</button> <button id="ns-ai-close-btn" class="ns-ai-btn ns-ai-btn-secondary">关闭</button> </div> `; modalOverlay.addEventListener('click', () => this.removeModal()); modalContent.addEventListener('click', (e) => e.stopPropagation()); document.body.appendChild(modalOverlay); document.body.appendChild(modalContent); document.body.classList.add('ns-ai-modal-open'); document.getElementById('ns-ai-save-btn').addEventListener('click', () => { Config.set('api_url', document.getElementById('ns-ai-api-url').value.trim()); Config.set('api_key', document.getElementById('ns-ai-api-key').value.trim()); Config.set('model', document.getElementById('ns-ai-model').value.trim()); Config.set('score_threshold', parseInt(document.getElementById('ns-ai-score-threshold').value, 10)); Config.set('batch_size', parseInt(document.getElementById('ns-ai-batch-size').value, 10)); Config.set('request_delay', parseInt(document.getElementById('ns-ai-request-delay').value, 10)); const hiddenCats = []; document.querySelectorAll('.ns-ai-hidden-cat:checked').forEach(el => hiddenCats.push(el.value)); Config.set('hidden_categories', hiddenCats); Config.save(); this.removeModal(); window.location.reload(); }); document.getElementById('ns-ai-close-btn').addEventListener('click', () => this.removeModal()); }, removeModal() { const overlay = document.getElementById('ns-ai-modal-overlay'); const content = document.getElementById('ns-ai-modal-content'); if (overlay) document.body.removeChild(overlay); if (content) document.body.removeChild(content); document.body.classList.remove('ns-ai-modal-open'); }, updatePostUI(postElement, data) { const titleLink = postElement.matches('.post-title') ? postElement : postElement.querySelector('.post-title a'); if (!titleLink) return; if(titleLink.parentElement.querySelector('.ns-ai-tag')) { const oldTags = titleLink.parentElement.querySelectorAll('.ns-ai-tag'); oldTags.forEach(t => t.remove()); } if(data.error) { const tag = document.createElement('span'); tag.className = 'ns-ai-tag'; tag.textContent = `[分析失败]`; tag.title = `已重试 ${data.retryCount || 0} 次`; tag.style.backgroundColor = '#f39c12'; titleLink.insertAdjacentElement('afterend', tag); return; } const { score, category } = data; const categoryTag = document.createElement('span'); categoryTag.className = 'ns-ai-tag ns-ai-category'; categoryTag.textContent = `[${category}]`; categoryTag.style.setProperty('--category-color', Utils.getCategoryColor(category)); const scoreTag = document.createElement('span'); scoreTag.className = 'ns-ai-tag ns-ai-score'; scoreTag.textContent = `[${score}分]`; scoreTag.style.setProperty('--score-color', Utils.getScoreColor(score)); titleLink.insertAdjacentElement('afterend', scoreTag); scoreTag.insertAdjacentElement('afterend', categoryTag); }, applyFilters(postElement, data) { if (data.error || !postElement.classList.contains('post-list-item')) return; const { score, category } = data; const scoreThreshold = Config.get('score_threshold'); const hiddenCategories = Config.get('hidden_categories'); if ((scoreThreshold > 0 && score < scoreThreshold) || hiddenCategories.includes(category)) { postElement.classList.add('ns-ai-hidden'); } else { postElement.classList.remove('ns-ai-hidden'); } } };    const AIProcessor = {        queue: [], processing: false,        addToQueue(item) { if (!this.queue.some(qItem => qItem.id === item.id)) { this.queue.push(item); } this.processQueue(); },        async processQueue() {            if (this.processing || this.queue.length === 0) return;            this.processing = true; Utils.log(`开始处理新批次,队列剩余: ${this.queue.length}`);            const batch = this.queue.splice(0, Config.get('batch_size'));            try {                const postsContent = await this.fetchPostContents(batch);                const results = await this.callAI(postsContent); this.handleAIResults(results);            }            catch (error) {                Utils.log('AI API 调用失败:', error);                // [!] 核心改动: 记录失败并增加重试次数                batch.forEach(item => {                    const currentCache = Cache.get(item.id) || {};                    const newRetryCount = (currentCache.retryCount || 0) + 1;                    const failureData = { error: true, retryCount: newRetryCount, reason: 'api_failed' };                    Cache.set(item.id, failureData);                    this.updateUIForPost(item.id, failureData);                });            }            setTimeout(() => { this.processing = false; this.processQueue(); }, Config.get('request_delay'));        },        async fetchPostContents(batch) { const fetchPromises = batch.map(item => fetch(item.url).then(res => res.text()).then(html => { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const contentElement = doc.querySelector('article.post-content'); if (contentElement) { const signature = contentElement.querySelector('.signature'); if (signature) signature.remove(); let text = contentElement.innerText.trim(); if (text.length > Config.get('max_content_length')) { text = text.substring(0, Config.get('max_content_length')) + '...'; } return { id: item.id, title: item.title, content: text }; } return { id: item.id, title: item.title, content: item.title }; }).catch(err => ({ id: item.id, title: item.title, content: item.title })) ); return await Promise.all(fetchPromises); },        callAI(posts) {            return new Promise((resolve, reject) => {                const apiKey = Config.get('api_key'); if (!apiKey) return reject('API 密钥未设置');                const prompt = this.buildBatchPrompt(posts);                GM_xmlhttpRequest({                    method: 'POST', url: Config.get('api_url'), headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },                    data: JSON.stringify({ model: Config.get('model'), messages: [{ role: 'user', content: prompt }], response_format: { type: "json_object" } }),                    onload: (response) => {                         try {                            const rawResponse = JSON.parse(response.responseText);                            if (!rawResponse.choices || !rawResponse.choices.length) throw new Error('AI响应中缺少 "choices" 字段');                            const content = rawResponse.choices[0].message.content;                            const jsonMatch = content.match(/{[\s\S]*}/);                            if (!jsonMatch || !jsonMatch[0]) throw new Error(`AI响应中未找到有效的JSON对象: ${content}`);                            const parsedContent = JSON.parse(jsonMatch[0]);                            if(parsedContent.results) { resolve(parsedContent.results); }                            else { throw new Error('AI返回的JSON中缺少 "results" 字段'); }                         }                         catch (e) { reject(`${e.message}, 原始数据: ${response.responseText}`); }                    },                    onerror: (err) => reject(`请求错误: ${JSON.stringify(err)}`),                    ontimeout: () => reject('请求超时')                });            });        },        buildBatchPrompt(posts) {            const postStrings = posts.map(p => `<post><id>${p.id}</id><title><![CDATA[${p.title}]]></title><content><![CDATA[${p.content}]]></content></post>`).join('\n');            return `你是一个论坛内容分析助手。请根据以下 XML 格式的帖子列表,对每个帖子进行质量评分和分类。\n评分标准:\n- 1-20: 垃圾内容,纯灌水。\n- 21-40: 低质量内容,如简单求助、交易、抽奖。\n- 41-60: 普通内容。\n- 61-80: 优质内容,如详细的评测、技术探讨。\n- 81-100: 精华内容,信息详实,逻辑清晰。\n分类选项:\n${CATEGORIES.join(', ')}\n帖子列表:\n${postStrings}\n请严格按照以下 JSON 格式返回,确保 "results" 是一个包含所有帖子分析结果的数组,且不要包含任何Markdown代码块或其他额外描述。\n{"results": [{"id": "<帖子ID>", "score": <分数>, "category": "<分类>"}, ...]}`;        },        handleAIResults(results) { results.forEach(result => { const data = { score: result.score || 0, category: CATEGORIES.includes(result.category) ? result.category : '其他', timestamp: Date.now() }; Cache.set(result.id, data); this.updateUIForPost(result.id, data); }); },        updateUIForPost(postId, data) { document.querySelectorAll(`[data-post-id="${postId}"]`).forEach(item => { UIManager.updatePostUI(item, data); UIManager.applyFilters(item, data); }); if (Utils.getPostIdFromUrl(window.location.href) === postId) { const titleContainer = document.querySelector('.post-title'); if (titleContainer) UIManager.updatePostUI(titleContainer, data); } }    };    let processDebounceTimer;    function processAllVisiblePosts() {        Utils.log('开始扫描页面帖子...');        document.querySelectorAll('.post-list-item:not([data-ai-processed])').forEach(processPostElement);        const detailPageTitle = document.querySelector('.post-title:not([data-ai-processed])');        if(detailPageTitle && window.location.pathname.startsWith('/post-')) {             processDetailPage(detailPageTitle);        }    }    // [!] 核心改动: 加入重试决策逻辑    function processPostElement(postElement) {        postElement.dataset.aiProcessed = 'true';        const titleLink = postElement.querySelector('.post-title a'); if (!titleLink) return;        const postUrl = titleLink.href; const postId = Utils.getPostIdFromUrl(postUrl); const postTitle = titleLink.innerText.trim();        if (!postId) return;        postElement.dataset.postId = postId;        const cachedData = Cache.get(postId);        if (cachedData) {            UIManager.updatePostUI(postElement, cachedData);            UIManager.applyFilters(postElement, cachedData);            // 检查是否需要重试            if (cachedData.error) {                const retryCount = cachedData.retryCount || 0;                if (retryCount < MAX_RETRIES) {                    Utils.log(`帖子 #${postId} 分析失败,将进行第 ${retryCount + 1} 次重试...`);                    AIProcessor.addToQueue({ id: postId, url: postUrl, title: postTitle });                } else {                    Utils.log(`帖子 #${postId} 已达最大重试次数,不再重试。`);                }            }            return;        }        const hiddenCategories = Config.get('hidden_categories');        const matchedHiddenCategory = hiddenCategories.find(cat => postTitle.toLowerCase().includes(cat.toLowerCase()));        if (matchedHiddenCategory) {            postElement.classList.add('ns-ai-hidden');            Utils.log(`帖子 #${postId} 标题命中隐藏分类 [${matchedHiddenCategory}],跳过AI分析。`);            return;        }        AIProcessor.addToQueue({ id: postId, url: postUrl, title: postTitle });    }    // [!] 核心改动: 加入重试决策逻辑    function processDetailPage(titleContainer) {        titleContainer.dataset.aiProcessed = 'true';        const postId = Utils.getPostIdFromUrl(window.location.href); if(!postId) return;        titleContainer.dataset.postId = postId;        const cachedData = Cache.get(postId);        if (cachedData) {            UIManager.updatePostUI(titleContainer, cachedData);            if (cachedData.error) {                const retryCount = cachedData.retryCount || 0;                if (retryCount < MAX_RETRIES) {                    Utils.log(`详情页帖子 #${postId} 分析失败,将进行第 ${retryCount + 1} 次重试...`);                    const postTitle = titleContainer.innerText.trim();                    AIProcessor.addToQueue({ id: postId, url: window.location.href, title: postTitle });                }            }            return;        }        const postTitle = titleContainer.innerText.trim();        AIProcessor.addToQueue({ id: postId, url: window.location.href, title: postTitle });    }    function observePostList() { const targetNode = document.getElementById('nsk-body-left'); if (!targetNode) return; const observer = new MutationObserver(() => { clearTimeout(processDebounceTimer); processDebounceTimer = setTimeout(processAllVisiblePosts, 300); }); observer.observe(targetNode, { childList: true, subtree: true }); }    function main() { Config.load(); UIManager.init(); if (!Config.get('enabled')) { return; } if (!Config.get('api_key') || !Config.get('api_url')) { UIManager.createSettingsModal(); Utils.log('API配置不完整,请在弹出的设置中配置。'); return; } processAllVisiblePosts(); observePostList(); window.addEventListener('turbo:load', () => { setTimeout(processAllVisiblePosts, 100); }); }    main();})();

鉴于以上内容没有一个字符是我写的,所以也就不在GitHub发了,各位想怎么改怎么改吧,自己用着开心就行
注意一下高并发不知道会不会对论坛有什么影响,应该不会吧
后续建议优化一下并发和prompt,prompt优化一下应该能发挥更多作用
放两张测试图(拿杂牌模型跑的图一乐,prompt也是瞎写的,优化一下应该能准确很多):