// ==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 })();