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 = `
+
+
+
+
+
配置 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 = `
+
+
+ ${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