diff --git a/posts_ai.js b/posts_ai.js index e69de29..90b4fcb 100644 --- a/posts_ai.js +++ b/posts_ai.js @@ -0,0 +1,1595 @@ +// ==UserScript== +// @name Linux.do实时帖子 & AI 分析侧边栏 +// @namespace http://tampermonkey.net/ +// @version 3.1.0 +// @description 在 linux.do 页面左侧显示实时帖子列表并使用 Gemini API 进行分析总结,优化版 +// @author NullUser +// @match https://linux.do/* +// @grant GM_addStyle +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_notification +// @grant GM_xmlhttpRequest +// @connect generativelanguage.googleapis.com +// @connect g.shatang.me +// @require https://cdn.jsdelivr.net/npm/marked/marked.min.js +// ==/UserScript== + +(function() { + 'use strict'; + + class LinuxDoEnhancer { + constructor() { + // Default Configuration + this.config = { + POSTS_URL: '/posts.json', + DEFAULT_POST_FETCH_INTERVAL: 30 * 1000, // Default: Fetch posts every 30 seconds + DEFAULT_AI_ANALYZE_INTERVAL: 120 * 1000, // Default: Analyze posts every 2 minutes (120 seconds) + SCROLL_INTERVAL: 60, // Scroll every 60ms for smoother effect + SCROLL_AMOUNT: 1, // Scroll 1 pixel down each time + MAX_DISPLAY_POSTS: 100, // Limit the number of posts displayed in the list + AI_ANALYSIS_POST_COUNT: 15, // Number of recent posts to send to AI + SIDEBAR_WIDTH: '320px', // Slightly wider for more info + API_KEY_STORAGE_KEY: 'linuxdo_enhancer_gemini_api_key_v3', + API_URL_STORAGE_KEY: 'linuxdo_enhancer_gemini_api_base_url_v3', + FETCH_INTERVAL_STORAGE_KEY: 'linuxdo_enhancer_fetch_interval_v3', + ANALYZE_INTERVAL_STORAGE_KEY: 'linuxdo_enhancer_analyze_interval_v3', + // --- Configurable API Base URL --- + // Use Google's official API: 'https://generativelanguage.googleapis.com/v1beta/models/' + // Or your chosen proxy: 'https://g.shatang.me/v1beta/models/' + GEMINI_API_BASE_URL: 'https://generativelanguage.googleapis.com/v1beta/models/', // Default to official + GEMINI_MODEL: 'gemini-2.0-flash' // Or 'gemini-pro' or specific version like 'gemini-2.0-flash' + }; + + this.state = { + rawPostData: [], // Store raw fetched posts + // No longer need filteredPostData after removing search + // filteredPostData: [], + scrollIntervalId: null, + isSidebarVisible: true, + isFetchingPosts: false, + isAnalyzing: false, + isScrollingPaused: false, + lastUpdateTime: null + }; + + this.domElements = {}; + this.intervals = {}; // Store fetch and analyze interval IDs + + // Ensure marked is available, fallback to simple passthrough if not loaded + this.marked = typeof marked !== 'undefined' ? marked : { parse: (text) => text }; + + + this.init(); + } + + init() { + this.injectStyles(); + this.waitForBody(() => { + this.injectUI(); + // Wait for the main sidebar element to exist before initializing others + this.waitForElement('#linuxdo-enhancer-sidebar', () => { + this.initElements(); + this.setupEventListeners(); + this.loadConfigAndStart(); // Load saved config and then start + }); + }); + } + + // Helper to wait for a specific element to appear + waitForElement(selector, callback) { + const element = document.querySelector(selector); + if (element) { + callback(); + } else { + const observer = new MutationObserver((mutations, obs) => { + const found = document.querySelector(selector); + if (found) { + callback(); + obs.disconnect(); + } + }); + // Observe the body (or html) for changes + observer.observe(document.body || document.documentElement, { childList: true, subtree: true }); + } + } + + injectStyles() { + // Using GM_addStyle directly with a template literal + GM_addStyle(` + #linuxdo-enhancer-sidebar { + position: fixed; + top: 0; + left: 0; /* Visible by default */ + bottom: 0; + width: ${this.config.SIDEBAR_WIDTH}; + background-color: #1e1e1e; /* Deeper dark background */ + color: #e0e0e0; /* Light text */ + z-index: 10000; /* High z-index to be on top */ + box-shadow: 2px 0 15px rgba(0, 0, 0, 0.6); + display: flex; + flex-direction: column; + transition: left 0.3s ease; /* Smooth toggle animation */ + overflow: hidden; /* Hide sidebar scrollbar */ + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 13px; /* Slightly smaller font */ + } + + #linuxdo-enhancer-sidebar.hidden { + left: -${this.config.SIDEBAR_WIDTH}; /* Hide by moving off screen */ + } + + #linuxdo-enhancer-toggle-button { + position: fixed; + top: 10px; + left: ${this.config.SIDEBAR_WIDTH}; /* Positioned just outside the sidebar */ + z-index: 10001; /* Higher than sidebar */ + background-color: #1e1e1e; + color: #64b5f6; /* Accent color */ + border: 1px solid #444; + border-left: none; + padding: 5px 8px; /* Smaller padding */ + cursor: pointer; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + box-shadow: 2px 0 5px rgba(0, 0, 0, 0.3); + transition: left 0.3s ease, background-color 0.2s ease; /* Match sidebar transition */ + font-size: 14px; /* Slightly larger icon */ + line-height: 1; /* Center text */ + text-align: center; + } + + #linuxdo-enhancer-sidebar.hidden + #linuxdo-enhancer-toggle-button { + left: 0; /* Move button to left edge when sidebar is hidden */ + } + #linuxdo-enhancer-toggle-button:hover { + background-color: #333; + } + + + #linuxdo-enhancer-sidebar h2 { + color: #34c759; /* Accent color */ + margin: 10px 15px 5px; + padding-bottom: 5px; + border-bottom: 1px solid #3a3a3a; + font-size: 1.2em; + flex-shrink: 0; + display: flex; + justify-content: space-between; + align-items: center; + } + + .linuxdo-enhancer-count-badge { + background-color: #444; + color: #fff; + border-radius: 10px; + padding: 2px 8px; + font-size: 0.8em; + } + + /* Search filter removed */ + /* + #linuxdo-enhancer-filter-controls { + padding: 10px 15px; + background: #2a2a2a; + flex-shrink: 0; + } + #linuxdo-enhancer-search { + width: 100%; padding: 8px; border: 1px solid #444; border-radius: 4px; + background-color: #3a3a3a; color: #dcdcdc; box-sizing: border-box; font-size: 1em; + } + #linuxdo-enhancer-search::placeholder { color: #aaa; } + #linuxdo-enhancer-search:focus { outline: none; border-color: #64b5f6; box-shadow: 0 0 5px rgba(100, 181, 246, 0.5); } + */ + + + #linuxdo-enhancer-posts { + flex-grow: 1; + overflow-y: scroll; /* Use native scroll but hide scrollbar */ + -ms-overflow-style: none; + scrollbar-width: none; + padding: 0 15px; + mask-image: linear-gradient(to bottom, transparent 0%, black 5%, black 95%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 5%, black 95%, transparent 100%); + scroll-behavior: smooth; /* Smooth scrolling */ + } + + #linuxdo-enhancer-posts::-webkit-scrollbar { + display: none; + } + + .linuxdo-enhancer-post-item { + background-color: #2a2a2a; /* Card background */ + padding: 12px; + margin-bottom: 10px; + border-radius: 6px; + border-left: 3px solid #64b5f6; /* Accent color */ + opacity: 0.95; + transition: all 0.2s ease; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; /* For meta positioning */ + box-shadow: 0 2px 5px rgba(0,0,0,0.3); + } + + .linuxdo-enhancer-post-item:hover { + opacity: 1; + background-color: #3a3a3a; /* Darker on hover */ + transform: translateY(-2px); /* Lift effect */ + box-shadow: 0 4px 8px rgba(0,0,0,0.4); + } + + .linuxdo-enhancer-post-header { + display: flex; + align-items: center; + margin-bottom: 8px; + } + + .linuxdo-enhancer-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + margin-right: 8px; + flex-shrink: 0; + background-color: #555; /* Placeholder background */ + object-fit: cover; /* Prevent stretching */ + } + + .linuxdo-enhancer-user-info { + flex-grow: 1; + } + + .linuxdo-enhancer-username { + font-weight: bold; + color: #fff; + font-size: 1em; + white-space: nowrap; /* Prevent wrapping */ + overflow: hidden; + text-overflow: ellipsis; + } + .linuxdo-enhancer-user-title { + font-size: 0.7em; + color: #aaa; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + + .linuxdo-enhancer-topic-title a { + color: #e0e0e0; + text-decoration: none; + font-weight: bold; + font-size: 1.1em; + display: block; + margin-bottom: 5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .linuxdo-enhancer-topic-title a:hover { + color: #ffffff; + text-decoration: underline; + } + + .linuxdo-enhancer-excerpt { + font-size: 0.9em; + color: #bbb; + margin-bottom: 8px; + display: -webkit-box; + -webkit-line-clamp: 2; /* Limit excerpt to 2 lines */ + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.4; + } + + + .linuxdo-enhancer-post-meta { + font-size: 0.8em; + color: #aaa; + display: flex; + flex-wrap: wrap; + justify-content: space-between; /* Spread items */ + margin-top: auto; /* Push to bottom */ + padding-top: 5px; + border-top: 1px solid #333; /* Separator line */ + } + + .linuxdo-enhancer-post-meta span { + margin-right: 10px; + display: flex; + align-items: center; + } + .linuxdo-enhancer-post-meta span:last-child { + margin-right: 0; + } + + .linuxdo-enhancer-meta-icon { + margin-right: 3px; + width: 12px; + height: 12px; + fill: currentColor; /* Use parent text color */ + vertical-align: middle; + flex-shrink: 0; /* Prevent icon from shrinking */ + } + /* Adjust specific icons if needed */ + .linuxdo-enhancer-meta-icon.clock { color: #ffeb3b; } /* Yellow for time */ + .linuxdo-enhancer-meta-icon.topic { color: #00b0ff; } /* Blue for topic */ + .linuxdo-enhancer-meta-icon.floor { color: #00e676; } /* Green for floor */ + .linuxdo-enhancer-meta-icon.heart { color: #ff5252; } /* Red for likes */ + .linuxdo-enhancer-meta-icon.eye { color: #9c27b0; } /* Purple for views */ + .linuxdo-enhancer-meta-icon.reply { color: #ff9800; } /* Orange for replies */ + + + #linuxdo-enhancer-ai-analysis { + flex-shrink: 0; + max-height: 45%; /* Slightly more space for AI */ + overflow-y: auto; + padding: 0 15px 15px; + font-size: 0.9em; + line-height: 1.5; + scrollbar-width: thin; + scrollbar-color: #555 #2a2a2a; + } + + #linuxdo-enhancer-ai-analysis::-webkit-scrollbar { + width: 6px; + } + + #linuxdo-enhancer-ai-analysis::-webkit-scrollbar-track { + background: #2a2a2a; + } + + #linuxdo-enhancer-ai-analysis::-webkit-scrollbar-thumb { + background-color: #555; + border-radius: 3px; + } + + .linuxdo-enhancer-analysis-item { + background-color: #2a2a2a; + padding: 12px; + margin-bottom: 12px; + border-radius: 6px; + border-left: 3px solid #34c759; + box-shadow: 0 2px 5px rgba(0,0,0,0.3); + } + .linuxdo-enhancer-analysis-item:last-child { + margin-bottom: 0; + } + + + .linuxdo-enhancer-analysis-item h3 { + color: #58e27c; + margin-top: 0; + margin-bottom: 6px; + font-size: 1.1em; + } + + .linuxdo-enhancer-analysis-item p { + margin: 0; + color: #dcdcdc; + word-break: break-word; /* Prevent long words from overflowing */ + } + + .linuxdo-enhancer-analysis-item a { + color: #64b5f6; + text-decoration: underline; + } + .linuxdo-enhancer-analysis-item .markdown-body { + /* Basic markdown styles within the analysis item */ + word-break: break-word; + } + .linuxdo-enhancer-analysis-item .markdown-body p { margin-bottom: 0.5em; } + .linuxdo-enhancer-analysis-item .markdown-body a { color: #64b5f6; text-decoration: underline; } + /* Markdown list styles */ + .linuxdo-enhancer-analysis-item .markdown-body ul, + .linuxdo-enhancer-analysis-item .markdown-body ol { + margin: 0.5em 0; + padding-left: 1.5em; + color: #ccc; /* List item color */ + } + .linuxdo-enhancer-analysis-item .markdown-body li { + margin-bottom: 0.3em; + } + /* Markdown code styles */ + .linuxdo-enhancer-analysis-item .markdown-body code { + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + background-color: rgba(100, 181, 246, 0.1); /* Light blue background */ + color: #64b5f6; /* Accent color */ + padding: 0.1em 0.3em; + border-radius: 3px; + font-size: 0.9em; + } + + + .linuxdo-enhancer-loading, .linuxdo-enhancer-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + color: #aaa; + text-align: center; + } + + .linuxdo-enhancer-spinner { + border: 3px solid rgba(255,255,255,0.3); + border-radius: 50%; + border-top: 3px solid #64b5f6; + width: 20px; + height: 20px; + animation: linuxdo-enhancer-spin 1s linear infinite; + margin-bottom: 10px; + } + + @keyframes linuxdo-enhancer-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + .linuxdo-enhancer-error { + color: #ff5252; + padding: 10px; + background-color: rgba(255, 82, 82, 0.1); + border-radius: 4px; + margin: 10px 15px; /* Match sidebar padding */ + font-size: 0.9em; + word-break: break-word; + } + + #linuxdo-enhancer-footer { + font-size: 0.8em; + color: #aaa; + padding: 10px 15px; /* Match content padding */ + flex-shrink: 0; + border-top: 1px solid #3a3a3a; + display: flex; + justify-content: space-between; + align-items: center; + } + + #linuxdo-enhancer-footer-buttons { + display: flex; + gap: 8px; /* Increased gap */ + align-items: center; + } + + #linuxdo-enhancer-footer-buttons svg { + width: 14px; + height: 14px; + /* fill: #aaa; <-- Removed, use currentColor */ + vertical-align: middle; + flex-shrink: 0; + } + + + #linuxdo-enhancer-footer button { + background: none; /* No background */ + border: none; /* No border */ + color: #aaa; + padding: 0; /* No padding */ + cursor: pointer; + font-size: 1em; /* Match parent font size */ + transition: color 0.2s ease; + display: flex; /* Align icon and text */ + align-items: center; + gap: 3px; /* Space between icon and text */ + } + + #linuxdo-enhancer-footer button:hover { + color: #dcdcdc; + } + #linuxdo-enhancer-footer button:hover svg { + fill: #dcdcdc; + } + + + #linuxdo-enhancer-footer button.active { + color: #34c759; /* Active color */ + } + #linuxdo-enhancer-footer button.active svg { + fill: #34c759; + } + + + #linuxdo-enhancer-update-time { + font-size: 0.9em; + color: #777; + } + + /* Adjust body padding when sidebar is visible */ + body { + padding-left: ${this.config.SIDEBAR_WIDTH} !important; + transition: padding-left 0.3s ease; + } + + body.linuxdo-enhancer-sidebar-hidden { + padding-left: 0 !important; + } + + /* Ensure main Discourse content adjusts */ + #main-outlet { + width: 100% !important; + box-sizing: border-box !important; + } + /* If header is fixed, adjust its position */ + .d-header-wrap { + left: ${this.config.SIDEBAR_WIDTH} !important; + width: calc(100% - ${this.config.SIDEBAR_WIDTH}) !important; + transition: left 0.3s ease, width 0.3s ease; + } + body.linuxdo-enhancer-sidebar-hidden .d-header-wrap { + left: 0 !important; + width: 100% !important; + } + + + /* Config Modal */ + #linuxdo-enhancer-config-modal { + display: none; + position: fixed; + z-index: 20000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0,0,0,0.8); + justify-content: center; + align-items: center; + } + + #linuxdo-enhancer-config-modal-content { + background-color: #2a2a2a; + padding: 30px; + border: 1px solid #888; + width: 90%; + max-width: 400px; + border-radius: 10px; + text-align: center; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.7); + color: #dcdcdc; + } + + #linuxdo-enhancer-config-modal-content h2 { + color: #64b5f6; + margin: 0 0 20px 0; /* Adjusted margin */ + font-size: 1.5em; + border-bottom: none; /* Remove h2 border */ + } + + #linuxdo-enhancer-config-modal-content p { + margin-bottom: 15px; + color: #bbb; + } + + #linuxdo-enhancer-api-url-select-container { + margin-bottom: 20px; + } + + #linuxdo-enhancer-api-url-select-container label { + color: #ccc; + margin-right: 5px; + } + + #linuxdo-enhancer-api-url-select { + padding: 8px; + border: 1px solid #555; + border-radius: 4px; + background-color: #333; + color: #dcdcdc; + font-size: 1em; + } + + .linuxdo-enhancer-config-section { + margin-bottom: 20px; + padding: 15px; + border: 1px solid #444; + border-radius: 8px; + background-color: #333; + text-align: left; /* Align text left within sections */ + } + .linuxdo-enhancer-config-section h3 { + color: #34c759; + margin-top: 0; + margin-bottom: 10px; + font-size: 1.2em; + border-bottom: 1px solid #444; + padding-bottom: 5px; + } + .linuxdo-enhancer-config-section label { + display: block; /* Label on its own line */ + margin-bottom: 5px; + color: #ccc; + } + .linuxdo-enhancer-config-section input[type="number"] { + width: calc(100% - 22px); + padding: 8px; + border: 1px solid #555; + border-radius: 4px; + background-color: #2a2a2a; + color: #dcdcdc; + box-sizing: border-box; + font-size: 1em; + margin-bottom: 10px; + } + .linuxdo-enhancer-config-section input[type="number"]:focus { + outline: none; + border-color: #64b5f6; + box-shadow: 0 0 5px rgba(100, 181, 246, 0.5); + } + .linuxdo-enhancer-config-section input[type="number"]::-webkit-outer-spin-button, + .linuxdo-enhancer-config-section input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + + #linuxdo-enhancer-config-modal-content input[type="password"], + #linuxdo-enhancer-config-modal-content input[type="text"] { /* Allow text for visibility option */ + width: calc(100% - 22px); + padding: 10px; + margin-bottom: 20px; + border: 1px solid #555; + border-radius: 5px; + background-color: #333; + color: #dcdcdc; + font-size: 1em; + box-sizing: border-box; + } + + #linuxdo-enhancer-config-modal-content button { + background-color: #34c759; + color: white; + padding: 10px 20px; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 1.1em; + transition: background-color 0.3s ease; + /* Override button style from footer */ + display: inline-block; + gap: 0; + } + + #linuxdo-enhancer-config-modal-content button:hover:not(:disabled) { + background-color: #58e27c; + } + + #linuxdo-enhancer-config-modal-content button:disabled { + background-color: #555; + cursor: not-allowed; + } + + #linuxdo-enhancer-config-modal-content .warning { + font-size: 0.9em; + color: #ffeb3b; + margin-top: 20px; + } + + /* Responsive */ + @media (max-width: 768px) { + #linuxdo-enhancer-sidebar { + width: 260px; /* Slightly smaller on mobile */ + } + + body { + padding-left: 260px !important; + } + + #linuxdo-enhancer-toggle-button { + left: 260px; + } + .d-header-wrap { + left: 260px !important; + width: calc(100% - 260px) !important; + } + body.linuxdo-enhancer-sidebar-hidden .d-header-wrap { + left: 0 !important; + width: 100% !important; + } + #linuxdo-enhancer-footer { + flex-direction: column; + gap: 8px; + } + #linuxdo-enhancer-footer-buttons { + justify-content: center; + } + .linuxdo-enhancer-config-section input[type="number"] { + width: calc(100% - 18px); /* Adjust for mobile padding */ + } + + + } + `); + } + + waitForBody(callback) { + if (document.body) { + callback(); + } else { + const observer = new MutationObserver((mutations, obs) => { + if (document.body) { + callback(); + obs.disconnect(); + } + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + } + } + + injectUI() { + const sidebarHTML = ` +
+

最新帖子 0

+
+
+
+ 加载中... +
+
+

AI 智能分析 等待

+
+
+
+ 等待获取最新帖子并进行分析... +
+
+ +
+ +
+
+

配置 Gemini API 及脚本设置

+

输入您的 Google Gemini API 密钥并选择 API 地址:

+ +
+

API 设置

+
+ + +
+ + +

注意:密钥和选择的API地址将保存在您的浏览器本地(油猴存储),请勿分享截图。

+

建议使用专用API密钥并定期轮换。

+
+ +
+

刷新频率 (秒)

+ + + + +
+ + + + +
+
+ `; + + document.body.insertAdjacentHTML('beforeend', sidebarHTML); + } + + initElements() { + this.domElements = { + sidebar: document.getElementById('linuxdo-enhancer-sidebar'), + toggleButton: document.getElementById('linuxdo-enhancer-toggle-button'), + postList: document.getElementById('linuxdo-enhancer-posts'), + aiAnalysisDiv: document.getElementById('linuxdo-enhancer-ai-analysis'), + // Removed search input: searchInput: document.getElementById('linuxdo-enhancer-search'), + postCount: document.getElementById('linuxdo-enhancer-post-count'), + aiStatus: document.getElementById('linuxdo-enhancer-ai-status'), + updateTime: document.getElementById('linuxdo-enhancer-update-time'), + settingsButton: document.getElementById('linuxdo-enhancer-settings-button'), + pauseScrollButton: document.getElementById('linuxdo-enhancer-pause-scroll'), + configModal: document.getElementById('linuxdo-enhancer-config-modal'), + apiKeyInput: document.getElementById('linuxdo-enhancer-gemini-api-key-input'), + saveConfigButton: document.getElementById('linuxdo-enhancer-save-config-button'), + apiUrlSelect: document.getElementById('linuxdo-enhancer-api-url-select'), // Get select element + fetchIntervalInput: document.getElementById('linuxdo-enhancer-fetch-interval-input'), // Get new input + analyzeIntervalInput: document.getElementById('linuxdo-enhancer-analyze-interval-input') // Get new input + }; + // Check if all critical elements were found + const criticalElements = [ + 'sidebar', 'toggleButton', 'postList', 'aiAnalysisDiv', + 'postCount', 'aiStatus', 'updateTime', + 'settingsButton', 'pauseScrollButton', 'configModal', + 'apiKeyInput', 'saveConfigButton', 'apiUrlSelect', + 'fetchIntervalInput', 'analyzeIntervalInput' // Added new elements + ]; + for (const key of criticalElements) { + if (!this.domElements[key]) { + console.error(`LinuxDoEnhancer Error: Critical DOM element "${key}" not found! Script may not function correctly.`); + // Potentially display a persistent error message on the page if sidebar exists + if(this.domElements.sidebar) { + this.domElements.sidebar.innerHTML = `
脚本初始化失败:找不到关键UI元素 (${key})。请尝试刷新页面或检查脚本是否正确安装。
`; + } else { + alert(`LinuxDoEnhancer Error: Critical DOM element "${key}" not found! Script initialization failed.`); + } + // Stop initialization process + throw new Error(`Missing critical DOM element: ${key}`); + } + } + + // Initial state for pause button text/icon + this.domElements.pauseScrollButton.innerHTML = ' 暂停'; // Pause icon + } + + setupEventListeners() { + this.domElements.toggleButton.addEventListener('click', () => this.toggleSidebar()); + this.domElements.pauseScrollButton.addEventListener('click', () => this.toggleScroll()); + this.domElements.settingsButton.addEventListener('click', () => this.showConfigModal()); + + // Enable save button if API key and intervals are valid + const checkSaveButtonState = () => { + const key = this.domElements.apiKeyInput.value.trim(); + const fetchInterval = parseInt(this.domElements.fetchIntervalInput.value, 10); + const analyzeInterval = parseInt(this.domElements.analyzeIntervalInput.value, 10); + this.domElements.saveConfigButton.disabled = !(key && fetchInterval >= 10 && analyzeInterval >= 60); + }; + + this.domElements.apiKeyInput.addEventListener('input', checkSaveButtonState); + this.domElements.fetchIntervalInput.addEventListener('input', checkSaveButtonState); + this.domElements.analyzeIntervalInput.addEventListener('input', checkSaveButtonState); + this.domElements.saveConfigButton.addEventListener('click', () => this.handleSaveConfig()); + + // Pause/resume scrolling on post list hover + this.domElements.postList.addEventListener('mouseenter', () => this.stopScrolling()); + this.domElements.postList.addEventListener('mouseleave', () => { + if (!this.state.isScrollingPaused) { + this.startScrolling(); + } + }); + } + + loadConfigAndStart() { + // Load API Key + const savedApiKey = GM_getValue(this.config.API_KEY_STORAGE_KEY); + + // Load API URL preference + const savedApiUrl = GM_getValue(this.config.API_URL_STORAGE_KEY); + if (savedApiUrl) { + this.config.GEMINI_API_BASE_URL = savedApiUrl; + } // Default is already set in constructor + + // Load intervals preference + const savedFetchInterval = GM_getValue(this.config.FETCH_INTERVAL_STORAGE_KEY); + const savedAnalyzeInterval = GM_getValue(this.config.ANALYZE_INTERVAL_STORAGE_KEY); + + // Use saved value if valid, otherwise use default + this.config.POST_FETCH_INTERVAL = savedFetchInterval && savedFetchInterval >= 10 * 1000 ? savedFetchInterval : this.config.DEFAULT_POST_FETCH_INTERVAL; + this.config.AI_ANALYZE_INTERVAL = savedAnalyzeInterval && savedAnalyzeInterval >= 60 * 1000 ? savedAnalyzeInterval : this.config.DEFAULT_AI_ANALYZE_INTERVAL; + + if (savedApiKey) { + console.log("API key found. Starting fetch and analysis with loaded config."); + this.startFetchingAndAnalyzing(); + } else { + console.log("API key not found. Showing config modal."); + this.showConfigModal(); + } + } + + toggleSidebar() { + this.state.isSidebarVisible = !this.state.isSidebarVisible; + this.domElements.sidebar.classList.toggle('hidden', !this.state.isSidebarVisible); + document.body.classList.toggle('linuxdo-enhancer-sidebar-hidden', !this.state.isSidebarVisible); + // Adjust header position immediately on toggle + const header = document.querySelector('.d-header-wrap'); + if (header) { + if (this.state.isSidebarVisible) { + header.style.left = this.config.SIDEBAR_WIDTH; + header.style.width = `calc(100% - ${this.config.SIDEBAR_WIDTH})`; + // When showing, restart intervals if not paused + if (!this.state.isScrollingPaused) { + this.startFetchingAndAnalyzing(); + } else { + // If paused, only start scrolling + this.startScrolling(); + } + } else { + header.style.left = '0'; + header.style.width = '100%'; + // When hiding, stop all intervals + this.stopFetchingAndAnalyzing(); + } + } + } + + toggleScroll() { + this.state.isScrollingPaused = !this.state.isScrollingPaused; + this.domElements.pauseScrollButton.classList.toggle('active', this.state.isScrollingPaused); + + if (this.state.isScrollingPaused) { + this.domElements.pauseScrollButton.innerHTML = ' 继续'; // Play icon (reused pause icon path) + this.stopScrolling(); + } else { + this.domElements.pauseScrollButton.innerHTML = ' 暂停'; // Pause icon + this.startScrolling(); + } + } + + startScrolling() { + // Only start scrolling if sidebar is visible and scrolling is not paused + if (this.state.scrollIntervalId || this.state.isScrollingPaused || !this.state.isSidebarVisible) return; + + this.state.scrollIntervalId = setInterval(() => { + const list = this.domElements.postList; + if (!list || list.scrollHeight <= list.clientHeight) { // Defensive check & stop if no overflow + this.stopScrolling(); + return; + } + const { scrollTop, scrollHeight, clientHeight } = list; + + // If we are at the bottom or near bottom, jump back to top + if (scrollTop + clientHeight >= scrollHeight - this.config.SCROLL_AMOUNT) { + // Small delay before jumping to top + setTimeout(() => { list.scrollTop = 0; }, 500); + } else { + list.scrollTop += this.config.SCROLL_AMOUNT; + } + }, this.config.SCROLL_INTERVAL); + } + + stopScrolling() { + if (this.state.scrollIntervalId) { + clearInterval(this.state.scrollIntervalId); + this.state.scrollIntervalId = null; + } + } + + + startFetchingAndAnalyzing() { + // Only start if sidebar is visible + if (!this.state.isSidebarVisible) { + console.log("Sidebar is hidden, skipping start of intervals."); + return; + } + + // Clear existing intervals before starting new ones + this.stopFetchingAndAnalyzing(); + + // Initial fetch + this.fetchPosts().then(() => { + // Start initial analysis a bit after fetch completes and posts are rendered + setTimeout(() => { + if (this.state.rawPostData.length > 0) { + this.analyzePostsWithAI(); + } else { + console.log("Initial fetch yielded no posts. Skipping first analysis."); + if(this.domElements.aiAnalysisDiv) { + this.domElements.aiAnalysisDiv.innerHTML = '

未能加载帖子,等待下一次尝试...

'; + } + if(this.domElements.aiStatus) { + this.domElements.aiStatus.textContent = '等待'; + } + } + }, 2000); // Wait 2 seconds after fetch attempts + }); + + // Set up intervals using configured frequencies + this.intervals.fetchInterval = setInterval(() => this.fetchPosts(), this.config.POST_FETCH_INTERVAL); + this.intervals.analyzeInterval = setInterval(() => { + if (this.state.rawPostData.length > 0) { + this.analyzePostsWithAI(); + } else { + console.log("No posts available for analysis this cycle."); + if(this.domElements.aiAnalysisDiv) { + this.domElements.aiAnalysisDiv.innerHTML = '

等待加载最新帖子进行分析...

'; + } + if(this.domElements.aiStatus) { + this.domElements.aiStatus.textContent = '等待'; + } + } + }, this.config.AI_ANALYZE_INTERVAL); + + console.log(`Enhancer started: fetching every ${this.config.POST_FETCH_INTERVAL/1000}s, analyzing every ${this.config.AI_ANALYZE_INTERVAL/1000}s.`); + // Adjust header position on start (redundant if called by toggle, but safe) + const header = document.querySelector('.d-header-wrap'); + if (header) { + header.style.left = this.config.SIDEBAR_WIDTH; + header.style.width = `calc(100% - ${this.config.SIDEBAR_WIDTH})`; + } + // Ensure scrolling starts if not paused + this.startScrolling(); + } + + stopFetchingAndAnalyzing() { + if (this.intervals.fetchInterval) { + clearInterval(this.intervals.fetchInterval); + this.intervals.fetchInterval = null; + } + if (this.intervals.analyzeInterval) { + clearInterval(this.intervals.analyzeInterval); + this.intervals.analyzeInterval = null; + } + // Stop scrolling when intervals are stopped (except if explicitly paused?) + // Decided to let toggleScroll handle scroll pausing state separately + // this.stopScrolling(); // Do not stop scrolling here, let toggle handle it + + console.log("Enhancer intervals cleared."); + // Reset header position (redundant if called by toggle, but safe) + const header = document.querySelector('.d-header-wrap'); + if (header && !this.state.isSidebarVisible) { // Only reset if sidebar is actually hidden + header.style.left = '0'; + header.style.width = '100%'; + } + } + + async fetchPosts() { + if (this.state.isFetchingPosts) { + console.log("Fetch in progress, skipping."); + return; + } + // Only fetch if sidebar is visible + if (!this.state.isSidebarVisible) { + console.log("Sidebar is hidden, skipping fetch."); + return; + } + + this.state.isFetchingPosts = true; + console.log('Fetching posts...'); + + const postListElement = this.domElements.postList; + const updateTimeElement = this.domElements.updateTime; + + + try { + // Only show full loading state if list is currently empty or has error + if (this.state.rawPostData.length === 0 || (postListElement && postListElement.querySelector('.linuxdo-enhancer-error'))) { + this.showLoading(postListElement); + } else { + console.log("Fetching new posts..."); + } + + if(this.domElements.aiStatus) this.domElements.aiStatus.textContent = '更新中'; + + const response = await fetch(this.config.POSTS_URL); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, body: ${errorText.substring(0, 100)}...`); + } + + const data = await response.json(); + + // Check if data format is as expected for latest_posts + if (Array.isArray(data.latest_posts)) { + const newPosts = data.latest_posts.map(post => ({ + id: post.id, // Post ID + topic_id: post.topic_id, + topic_slug: post.topic_slug, + topic_title: post.topic_title, + fancy_title: post.topic_html_title || post.topic_title, // Use HTML title which might have emojis + post_number: post.post_number, // Floor number + created_at: post.created_at, // UTC time + updated_at: post.updated_at, // UTC time + views: post.views, // Views for the TOPIC + posts_count: post.posts_count, // Total posts in the TOPIC + author: post.username || '未知', // Use username directly from JSON + display_author_name: post.display_username, // Store display_username separately + // --- Simplified Avatar Path --- + author_avatar_template: post.avatar_template, // Store the template directly + // --- End Simplified Avatar Path --- + user_title: post.user_title, // User's custom title + link: `https://linux.do/t/${post.topic_slug}/${post.topic_id}${post.post_number ? `/${post.post_number}` : ''}`, // Link directly to the post, handle first post edge case + raw: post.raw, // Raw markdown + cooked: post.cooked, // Cooked HTML (can contain images, links etc.) + excerpt: post.excerpt, // Short text excerpt + post_likes: post.reaction_users_count || 0 // Likes for THIS POST - Use this for per-post likes + })); + + // Simple check if posts have changed significantly (first few IDs) + const currentTopIds = this.state.rawPostData.slice(0, 5).map(p => p.id).join(','); + const newTopIds = newPosts.slice(0, 5).map(p => p.id).join(','); + + if (!this.state.rawPostData.length || newTopIds !== currentTopIds) { + console.log('New posts received. Updating display.'); + this.state.rawPostData = newPosts; + // No filtering needed after removing search + this.state.filteredPostData = [...this.state.rawPostData]; + this.renderPosts(); + } else { + console.log('No significant change in top posts.'); + // Ensure filteredData is still a copy of rawData + this.state.filteredPostData = [...this.state.rawPostData]; + // Re-render is needed even if no new posts arrived if there are existing posts + if(this.state.rawPostData.length > 0) { + this.renderPosts(); // Render existing data + } else { + // If no raw data, ensure empty state is rendered + this.renderPosts(); + } + } + this.updatePostCount(); + this.updateLastUpdateTime(); + + } else { + console.error('Unexpected data format from posts.json:', data); + // Show error only if we couldn't process the data + if(postListElement) this.showError(postListElement, '获取的帖子数据格式异常!'); + } + } catch (error) { + console.error('Error fetching posts:', error); + // Show error only if fetch failed and no previous data exists + if (this.state.rawPostData.length === 0) { + if(postListElement) this.showError(postListElement, `加载帖子失败: ${error.message}`); + } else { + // If we have old data, maybe just log the error subtly and indicate connection issue + console.warn("Failed to fetch new posts, displaying old data.", error); + if(updateTimeElement) { + updateTimeElement.textContent = `最后更新: ${this.state.lastUpdateTime ? this.state.lastUpdateTime.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) : '--'} (连接失败)`; + } + } + + } finally { + this.state.isFetchingPosts = false; + } + } + + renderPosts() { + // We now render directly from rawPostData as there's no filtering + const postsToDisplay = this.state.rawPostData.slice(0, this.config.MAX_DISPLAY_POSTS); + const postListElement = this.domElements.postList; + + if (!postListElement) { // Defensive check + console.error("Attempted to render posts, but postList element is null."); + this.stopScrolling(); // Stop scrolling if element is gone + return; + } + + postListElement.innerHTML = ''; // Clear current list + + if (postsToDisplay.length === 0) { + postListElement.innerHTML = ` +
+ +

暂无帖子。

+
+ `; + return; + } + + postsToDisplay.forEach(post => { + const postElement = document.createElement('div'); + postElement.classList.add('linuxdo-enhancer-post-item'); + + const postDate = new Date(post.created_at); + // Convert UTC to Beijing Time (UTC+8) + const options = { + // year: 'numeric', // Removed year for brevity + month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', // second: '2-digit', // Removed seconds for brevity + timeZone: 'Asia/Shanghai', // Beijing Time + hour12: false // Use 24-hour format + }; + const formattedDate = postDate.toLocaleString('zh-CN', options); + + // Use display_author_name if available, fallback to username + const displayUsername = post.display_author_name && post.display_author_name.trim() !== '' ? post.display_author_name : post.author; + // --- Simplified Avatar Path Construction --- + // Directly use the template if available, replace size + const avatarUrl = post.author_avatar_template ? `https://linux.do${post.author_avatar_template.replace('{size}', '40')}` : null; + // Fallback avatar if template is null or invalid (Discourse letter avatars are predictable) + // Use the post.author for fallback avatar + const defaultAvatarUrl = `https://linux.do/letter_avatar/u/${post.author.substring(0,1).toLowerCase()}/40/1.png`; + + + postElement.innerHTML = ` +
+ ${displayUsername || post.author} +
+
${post.author}
+ ${post.user_title ? `
${post.user_title}
` : ''} +
+
+
+ + ${post.fancy_title || post.topic_title} + +
+ ${post.excerpt ? `
${post.excerpt.trim()}
` : ''} +
+ + + ${formattedDate} + + + + #${post.post_number} + + + + ${post.post_likes} + + ${post.views > 0 ? ` + + + ${post.views} + + ` : ''} + + + ${post.posts_count - 1} + +
+ `; + postListElement.appendChild(postElement); + }); + + // Reset scroll position only if not filtering (search removed) and not paused + // Also, only reset if there are posts to scroll + if (!this.state.isScrollingPaused && postsToDisplay.length > 0) { + postListElement.scrollTop = 0; + } + } + + updatePostCount() { + if (this.domElements.postCount) { + // Count from rawPostData since filtering is removed + this.domElements.postCount.textContent = this.state.rawPostData.length; + } + } + + updateLastUpdateTime() { + const updateTimeElement = this.domElements.updateTime; + if (!updateTimeElement) return; + + const now = new Date(); + // Convert to Beijing Time (UTC+8) for display + const options = { + hour: '2-digit', minute: '2-digit', // Removed seconds for brevity + timeZone: 'Asia/Shanghai', + hour12: false // Use 24-hour format + // day and month will be part of the main date if we add it back + }; + // For "最后更新", maybe just time is sufficient + const timeString = now.toLocaleTimeString('zh-CN', options); + + // For date, keep month-day + const dateOptions = { + month: '2-digit', day: '2-digit', + timeZone: 'Asia/Shanghai', + } + const dateString = now.toLocaleDateString('zh-CN', dateOptions).replace(/\//g, '-'); // Format like MM-DD + + + updateTimeElement.textContent = `最后更新: ${dateString} ${timeString}`; + this.state.lastUpdateTime = now; // Store JS Date object + } + + + async analyzePostsWithAI() { + // Check if AI Analysis section or status badge exist + const aiAnalysisDiv = this.domElements.aiAnalysisDiv; + const aiStatus = this.domElements.aiStatus; + + // Only analyze if sidebar is visible and there's data + if (!this.state.isSidebarVisible || this.state.isAnalyzing || !this.state.rawPostData.length) { + console.log("Sidebar hidden, analysis in progress, or no data, skipping analysis."); + if (aiStatus) aiStatus.textContent = this.state.isAnalyzing ? '分析中' : (this.state.rawPostData.length > 0 ? aiStatus.textContent : '等待'); + return; + } + this.state.isAnalyzing = true; + console.log('Starting AI analysis...'); + + const apiKey = GM_getValue(this.config.API_KEY_STORAGE_KEY); + if (!apiKey) { + if (aiAnalysisDiv) this.showError(aiAnalysisDiv, 'Gemini API 密钥未配置'); + if (aiStatus) aiStatus.textContent = '密钥缺失'; + this.state.isAnalyzing = false; + return; + } + + try { + if (aiAnalysisDiv) { + this.showLoading(aiAnalysisDiv); + } + if (aiStatus) aiStatus.textContent = '分析中'; + + // Use recent posts for analysis, ensure data exists + const postsForPrompt = this.state.rawPostData.slice(0, this.config.AI_ANALYSIS_POST_COUNT); + if (postsForPrompt.length === 0) { + console.log("No recent posts to send to AI."); + if (aiAnalysisDiv) aiAnalysisDiv.innerHTML = '

暂无帖子可供分析。

'; + if (aiStatus) aiStatus.textContent = '无数据'; + this.state.isAnalyzing = false; + return; + } + + // Use the 'author' field which now consistently holds the username from JSON + const postSummaryText = postsForPrompt.map(post => + `标题: ${post.fancy_title || post.topic_title}\n作者: ${post.author}${post.display_author_name && post.display_author_name !== post.author ? ` (${post.display_author_name})` : ''}${post.user_title ? ` [${post.user_title}]` : ''}\n回复数(话题): ${post.posts_count - 1}\n浏览量(话题): ${post.views}\n点赞数(此贴): ${post.post_likes}\n链接: ${post.link}\n` + ).join('\n---\n'); // Join with separator + + // Refined prompt for better structure and parsing + const prompt = `你是一个经验丰富的论坛内容分析助手,请基于以下最新的论坛帖子列表,严格按照指定格式输出分析结果。优先分析帖子标题和元信息,如果excerpt或cooked内容简洁明确也可以参考。 + +帖子列表数据(每项由---分隔): +${postSummaryText} + +请根据以上数据,完成以下任务,并严格按照以下格式输出,每个部分前加上对应的中文标题和冒号,并在每个部分结束后换行,不包含额外的解释或Markdown格式(除了推荐阅读的链接格式 [标题](链接),标题使用帖子原标题): + +总结: 简要总结这些帖子主要讨论了哪些话题,提炼核心内容。 +真假分析: 基于帖子标题和你的知识,分析其中某些话题(如果涉及声明、优惠、传闻等)的可能性或真实性。请谨慎,如果无法确定,请说明这仅仅是基于有限信息进行的推测。 +推荐阅读: 从列表中挑选1-2个你认为最有趣或最有价值的帖子(基于标题、回复、浏览量、点赞等信息)推荐给读者,并说明理由。请务必使用 Markdown 链接格式 [标题](链接),标题使用帖子原标题。 +辣评: 用一句话或简短的一段话,对这些帖子或论坛的整体氛围进行一个辛辣、幽默或直接的点评。 +`; + + const GEMINI_API_URL = `${this.config.GEMINI_API_BASE_URL}${this.config.GEMINI_MODEL}:generateContent?key=${apiKey}`; + + // Use GM_xmlhttpRequest for cross-origin API call + const response = await new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + method: 'POST', + url: GEMINI_API_URL, + headers: { + 'Content-Type': 'application/json' + }, + data: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { + temperature: 0.7, // Adjust creativity + maxOutputTokens: 1000, // Limit response length + } + }), + timeout: 60000, // 60 seconds timeout + onload: function(response) { + if (response.status >= 200 && response.status < 300) { + try { + resolve({ status: response.status, response: JSON.parse(response.responseText) }); + } catch (e) { + reject(new Error(`Failed to parse API response JSON: ${e.message}`)); + } + } else { + // Provide more details in the error message + let errorDetails = `Status: ${response.status}`; + if (response.statusText) errorDetails += ` ${response.statusText}`; + if (response.responseText) { + try { + const errorJson = JSON.parse(response.responseText); + if (errorJson.error && errorJson.error.message) { + errorDetails += ` - ${errorJson.error.message}`; + // Check for specific "suspended" message + if (errorJson.error.message.includes("suspended") || errorJson.error.message.includes("denied")) { + errorDetails += "\n您的API密钥可能无效或已被暂停使用,请检查。"; + } + } else { + errorDetails += `\nBody: ${response.responseText.substring(0, 150)}...`; + } + } catch (_) { + errorDetails += `\nBody: ${response.responseText.substring(0, 150)}...`; + } + } + reject(new Error(`API request failed: ${errorDetails}`)); + } + }, + onerror: function(error) { + // The error object from GM_xmlhttpRequest might have different properties + const errorMsg = error.statusText || error.responseText || error.message || 'Unknown error'; + reject(new Error(`GM_xmlhttpRequest error: ${errorMsg}`)); + }, + ontimeout: function() { + reject(new Error('API request timed out.')); + } + }); + }); + + + const result = response.response; + console.log('Gemini API result:', result); + + if (result.error) { + console.error('Gemini API returned error in body:', result.error); + let displayError = result.error.message || '未知错误'; + if (displayError.includes("suspended") || displayError.includes("denied")) { + displayError += "\n您的API密钥可能无效或已被暂停使用,请检查。"; + } + if (aiAnalysisDiv) this.showError(aiAnalysisDiv, `AI返回错误: ${displayError}`); + if (aiStatus) aiStatus.textContent = 'API错误'; + } else { + const text = result.candidates?.[0]?.content?.parts?.[0]?.text; + if (text) { + console.log('Gemini Raw Output:', text); + this.displayAIAnalysis(text); + if (aiStatus) aiStatus.textContent = '已更新'; + } else { + console.warn('Gemini API returned no text content or unexpected structure.', result); + if (aiAnalysisDiv) this.showError(aiAnalysisDiv, 'AI 生成内容为空或格式异常。'); + if (aiStatus) aiStatus.textContent = '生成失败'; + } + } + + } catch (error) { + console.error('AI analysis error:', error); + const displayError = error.message.length > 200 ? error.message.substring(0, 200) + '...' : error.message; + if (aiAnalysisDiv) this.showError(aiAnalysisDiv, `AI分析失败: ${displayError}`); + if (aiStatus) aiStatus.textContent = '错误'; + } finally { + this.state.isAnalyzing = false; + } + } + + + displayAIAnalysis(rawText) { + const aiAnalysisDiv = this.domElements.aiAnalysisDiv; + if (!aiAnalysisDiv) return; // Defensive check + + // Function to robustly extract content between sections + const getSectionContent = (text, sectionTitle) => { + // Escape regex special characters in the title + const escapedTitle = sectionTitle.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + // Regex: look for the title followed by :, capture content until the next known title or end of string + // Using lookahead for the next section title or end of string ($) + // Adding \s* after the colon to handle optional whitespace + const regex = new RegExp(`${escapedTitle}:\\s*([\\s\\S]*?)(?=\\n(?:总结|真假分析|推荐阅读|辣评):|$)$`, 'm'); // 'm' flag for multiline ^ $ + const match = text.match(regex); + return match?.[1]?.trim() ?? `未能解析${sectionTitle}内容。`; // Provide fallback if not found + }; + + // Extract content for each section + const summary = getSectionContent(rawText, '总结'); + const truthAnalysis = getSectionContent(rawText, '真假分析'); + const recommendations = getSectionContent(rawText, '推荐阅读'); + const spicyComment = getSectionContent(rawText, '辣评'); + + // Clear previous content and add structure back + aiAnalysisDiv.innerHTML = ` +
+

总结

+
${this.marked.parse(summary)}
+
+
+

真假分析

+
${this.marked.parse(truthAnalysis)}
+
+
+

推荐阅读

+
${this.marked.parse(recommendations)}
+
+
+

辣评

+
${this.marked.parse(spicyComment)}
+
+ `; + + // Log if parsing failed for debugging + if (summary.includes('未能解析') || truthAnalysis.includes('未能解析') || recommendations.includes('未能解析') || spicyComment.includes('未能解析')) { + console.warn("Partial or failed parsing of Gemini output. Raw text:", rawText); + } + } + + showLoading(element) { + if (!element) return; // Defensive check + element.innerHTML = ` +
+
+ 加载中... +
+ `; + } + + showError(element, message) { + if (!element) return; // Defensive check + element.innerHTML = ` +
${message}
+ `; + } + + showConfigModal() { + const apiKeyInput = this.domElements.apiKeyInput; + const saveConfigButton = this.domElements.saveConfigButton; + const configModal = this.domElements.configModal; + const apiUrlSelect = this.domElements.apiUrlSelect; + const fetchIntervalInput = this.domElements.fetchIntervalInput; + const analyzeIntervalInput = this.domElements.analyzeIntervalInput; + + + if (!apiKeyInput || !saveConfigButton || !configModal || !apiUrlSelect || !fetchIntervalInput || !analyzeIntervalInput) { + console.error("Config modal elements not found."); + return; + } + + apiKeyInput.value = GM_getValue(this.config.API_KEY_STORAGE_KEY, ''); + + // Load current interval values into inputs, convert ms to seconds + fetchIntervalInput.value = GM_getValue(this.config.FETCH_INTERVAL_STORAGE_KEY, this.config.DEFAULT_POST_FETCH_INTERVAL) / 1000; + analyzeIntervalInput.value = GM_getValue(this.config.ANALYZE_INTERVAL_STORAGE_KEY, this.config.DEFAULT_AI_ANALYZE_INTERVAL) / 1000; + + // Set the current API URL in the select box + apiUrlSelect.value = this.config.GEMINI_API_BASE_URL; + + // Check initial state of save button + const key = apiKeyInput.value.trim(); + const fetchVal = parseInt(fetchIntervalInput.value, 10); + const analyzeVal = parseInt(analyzeIntervalInput.value, 10); + saveConfigButton.disabled = !(key && fetchVal >= 10 && analyzeVal >= 60); + + + configModal.style.display = 'flex'; + } + + hideConfigModal() { + if (this.domElements.configModal) { + this.domElements.configModal.style.display = 'none'; + } + } + + handleSaveConfig() { + const apiKeyInput = this.domElements.apiKeyInput; + const apiUrlSelect = this.domElements.apiUrlSelect; + const fetchIntervalInput = this.domElements.fetchIntervalInput; + const analyzeIntervalInput = this.domElements.analyzeIntervalInput; + + if (!apiKeyInput || !apiUrlSelect || !fetchIntervalInput || !analyzeIntervalInput) return; + + const key = apiKeyInput.value.trim(); + const apiUrl = apiUrlSelect.value; + const fetchIntervalSec = parseInt(fetchIntervalInput.value, 10); + const analyzeIntervalSec = parseInt(analyzeIntervalInput.value, 10); + + if (key && apiUrl && fetchIntervalSec >= 10 && analyzeIntervalSec >= 60) { + // Save config values + GM_setValue(this.config.API_KEY_STORAGE_KEY, key); + GM_setValue(this.config.API_URL_STORAGE_KEY, apiUrl); + GM_setValue(this.config.FETCH_INTERVAL_STORAGE_KEY, fetchIntervalSec * 1000); // Save in ms + GM_setValue(this.config.ANALYZE_INTERVAL_STORAGE_KEY, analyzeIntervalSec * 1000); // Save in ms + + // Update config in memory + this.config.GEMINI_API_BASE_URL = apiUrl; + this.config.POST_FETCH_INTERVAL = fetchIntervalSec * 1000; + this.config.AI_ANALYZE_INTERVAL = analyzeIntervalSec * 1000; + + + this.hideConfigModal(); + this.startFetchingAndAnalyzing(); // Restart everything with the new config + GM_notification({ + title: 'Linux.do 增强脚本', + text: '设置已保存并应用!', + timeout: 3000 + }); + } else { + let errorMessage = '请填写所有必填项并确保频率值有效:\n'; + if (!key) errorMessage += '- 请输入API密钥。\n'; + if (!apiUrl) errorMessage += '- 请选择API地址。\n'; + if (fetchIntervalSec < 10) errorMessage += `- 帖子刷新频率不得小于 10 秒 (当前: ${fetchIntervalSec || 0}秒)。\n`; + if (analyzeIntervalSec < 60) errorMessage += `- AI分析频率不得小于 60 秒 (当前: ${analyzeIntervalSec || 0}秒)。\n`; + + + GM_notification({ + title: 'Linux.do 增强脚本', + text: errorMessage, + timeout: 5000 + }); + } + } + } + + // Initialize the enhancer + // Use a small delay to allow the main page structure to settle slightly before injecting UI + setTimeout(() => { + new LinuxDoEnhancer(); + }, 100); // Small delay + + +})(); \ No newline at end of file