一个基于 Tampermonkey 的小脚本,可以在 NodeSeek 论坛里批量调用 AI 给帖子做 分类 & 打分。
主要解决的问题:版块里水贴多,想快速挑出有价值的内容。
功能特点
🚥 自动分类
🎨 彩色标签:不同分类自动上色,更直观
⚙️ 可配置项:
- API Key
- 模型名称
- API 请求地址(支持自建代理)
- 最低显示分数
- 最大并发数
- 批处理等待时间
使用方法
- 安装 Tampermonkey
- 新建脚本 → 粘贴本仓库的代码
- 在 NodeSeek 打开任意帖子列表,右下角会出现「AI 设置」按钮
- 配置好 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也是瞎写的,优化一下应该能准确很多):
评论 (0)