Files
ZYHN/posts_ai.js
2025-04-22 21:00:28 +08:00

1595 lines
79 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==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 = `
<div id="linuxdo-enhancer-sidebar">
<h2>最新帖子 <span id="linuxdo-enhancer-post-count" class="linuxdo-enhancer-count-badge">0</span></h2>
<div id="linuxdo-enhancer-posts">
<div class="linuxdo-enhancer-loading">
<div class="linuxdo-enhancer-spinner"></div>
<span>加载中...</span>
</div>
</div>
<h2>AI 智能分析 <span id="linuxdo-enhancer-ai-status" class="linuxdo-enhancer-count-badge">等待</span></h2>
<div id="linuxdo-enhancer-ai-analysis">
<div class="linuxdo-enhancer-loading">
<div class="linuxdo-enhancer-spinner"></div>
<span>等待获取最新帖子并进行分析...</span>
</div>
</div>
<div id="linuxdo-enhancer-footer">
<div id="linuxdo-enhancer-update-time">最后更新: --</div>
<div id="linuxdo-enhancer-footer-buttons">
<button id="linuxdo-enhancer-settings-button" title="设置 Gemini API 密钥和地址">
<svg class="linuxdo-enhancer-meta-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.0007 2C16.4198 2 20.0007 5.58079 20.0007 10C20.0007 14.4192 16.4198 18 12.0007 18C7.58155 18 4.00073 14.4192 4.00073 10C4.00073 5.58079 7.58155 2 12.0007 2ZM12.0007 20C17.5235 20 22.0007 15.5228 22.0007 10C22.0007 4.47715 17.5235 0 12.0007 0C6.47788 0 2.00073 4.47715 2.00073 10C2.00073 15.5228 6.47788 20 12.0007 20ZM12.0007 13C10.3439 13 9.00073 11.6569 9.00073 10C9.00073 8.34315 10.3439 7 12.0007 7C13.6575 7 15.0007 8.34315 15.0007 10C15.0007 11.6569 13.6575 13 12.0007 13ZM12.0007 15C14.7615 15 17.0007 12.7614 17.0007 10C17.0007 7.23858 14.7615 5 12.0007 5C9.23906 5 7.00073 7.23858 7.00073 10C7.00073 12.7614 9.23906 15 12.0007 15ZM12.0007 21C9.49511 21 7.20048 20.1717 5.51847 18.8092L6.93268 17.395C8.16129 18.3754 10.0007 19 12.0007 19C14.0007 19 15.8393 18.3754 17.0679 17.395L18.4821 18.8092C16.7994 20.1717 14.5048 21 12.0007 21ZM2.41452 18.8092C0.732504 17.1717 -0.000734264 14.5048 -0.000734264 12C-0.000734264 9.49544 0.827626 7.20081 2.19014 5.5188L3.60435 6.93301C2.62391 8.16162 2.00073 10 2.00073 12C2.00073 14 2.62391 15.8386 3.60435 17.0672L2.19014 18.4814C2.26299 18.5782 2.33813 18.673 2.41452 18.8092Z"/></svg>
设置
</button>
<button id="linuxdo-enhancer-pause-scroll" title="暂停或继续帖子列表滚动">
<svg class="linuxdo-enhancer-meta-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14 19V5H18V19H14ZM6 19V5H10V19H6Z"></path></svg>
暂停
</button>
</div>
</div>
</div>
<button id="linuxdo-enhancer-toggle-button" title="隐藏/显示侧边栏">≡</button>
<div id="linuxdo-enhancer-config-modal">
<div id="linuxdo-enhancer-config-modal-content">
<h2>配置 Gemini API 及脚本设置</h2>
<p>输入您的 Google Gemini API 密钥并选择 API 地址:</p>
<div class="linuxdo-enhancer-config-section">
<h3>API 设置</h3>
<div id="linuxdo-enhancer-api-url-select-container">
<label for="linuxdo-enhancer-api-url-select">API 地址:</label>
<select id="linuxdo-enhancer-api-url-select">
<option value="https://generativelanguage.googleapis.com/v1beta/models/">Google 官方 API</option>
<option value="https://g.shatang.me/v1beta/models/">g.shatang.me (社区提供)</option>
<!-- Add other options here if needed -->
</select>
</div>
<label for="linuxdo-enhancer-gemini-api-key-input">API 密钥:</label>
<input type="password" id="linuxdo-enhancer-gemini-api-key-input" placeholder="粘贴您的 Gemini API 密钥">
<p class="warning">注意密钥和选择的API地址将保存在您的浏览器本地油猴存储请勿分享截图。</p>
<p class="warning">建议使用专用API密钥并定期轮换。</p>
</div>
<div class="linuxdo-enhancer-config-section">
<h3>刷新频率 (秒)</h3>
<label for="linuxdo-enhancer-fetch-interval-input">帖子列表刷新:</label>
<input type="number" id="linuxdo-enhancer-fetch-interval-input" min="10" value="30">
<label for="linuxdo-enhancer-analyze-interval-input">AI 分析刷新:</label>
<input type="number" id="linuxdo-enhancer-analyze-interval-input" min="60" value="120">
</div>
<button id="linuxdo-enhancer-save-config-button" disabled>保存并应用</button>
</div>
</div>
`;
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 = `<div class="linuxdo-enhancer-error">脚本初始化失败找不到关键UI元素 (${key})。请尝试刷新页面或检查脚本是否正确安装。</div>`;
} 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 = '<svg class="linuxdo-enhancer-meta-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14 19V5H18V19H14ZM6 19V5H10V19H6Z"></path></svg> 暂停'; // 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 = '<svg class="linuxdo-enhancer-meta-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M6 4V20H10V4H6ZM14 4V20H18V4H14Z"/></svg> 继续'; // Play icon (reused pause icon path)
this.stopScrolling();
} else {
this.domElements.pauseScrollButton.innerHTML = '<svg class="linuxdo-enhancer-meta-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14 19V5H18V19H14ZM6 19V5H10V19H6Z"></path></svg> 暂停'; // 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 = '<div class="linuxdo-enhancer-analysis-item"><p>未能加载帖子,等待下一次尝试...</p></div>';
}
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 = '<div class="linuxdo-enhancer-analysis-item"><p>等待加载最新帖子进行分析...</p></div>';
}
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 = `
<div class="linuxdo-enhancer-empty-state">
<svg class="linuxdo-enhancer-meta-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="40" height="40" style="color: #aaa;"><path d="M12.893 3.22194L21.586 12L12.893 20.7781L12 19.8851L20.114 11.7711L12 3.65714L12.893 3.22194ZM3.22194 12.893L12 21.586L20.7781 12.893L19.8851 12L11.7711 20.114L3.65714 12L3.22194 12.893Z"/></svg>
<p>暂无帖子。</p>
</div>
`;
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 = `
<div class="linuxdo-enhancer-post-header">
<img src="${avatarUrl || defaultAvatarUrl}" class="linuxdo-enhancer-avatar" alt="${displayUsername || post.author}">
<div class="linuxdo-enhancer-user-info">
<div class="linuxdo-enhancer-username" title="${displayUsername || post.author}">${post.author}</div> <!-- Always display short username with display name as title -->
${post.user_title ? `<div class="linuxdo-enhancer-user-title" title="${post.user_title}">${post.user_title}</div>` : ''}
</div>
</div>
<div class="linuxdo-enhancer-topic-title">
<a href="${post.link}" target="_blank" title="${post.fancy_title || post.topic_title}">
${post.fancy_title || post.topic_title}
</a>
</div>
${post.excerpt ? `<div class="linuxdo-enhancer-excerpt">${post.excerpt.trim()}</div>` : ''}
<div class="linuxdo-enhancer-post-meta">
<span>
<svg class="linuxdo-enhancer-meta-icon clock" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2C17.52 2 22 6.48 22 12C22 17.52 17.52 22 12 22C6.48 22 2 17.52 2 12C2 6.48 6.48 2 12 2ZM12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4ZM11 12V16H13V12H11ZM11 8H13V10H11V8Z"/></svg>
${formattedDate}
</span>
<span>
<svg class="linuxdo-enhancer-meta-icon floor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 12V16H13V12H11ZM11 8H13V10H11V8Z"/></svg>
#${post.post_number}
</span>
<span>
<svg class="linuxdo-enhancer-meta-icon heart" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.001 4.52853C14.345 2.42678 17.9376 2.45879 20.2428 4.58075C22.5491 6.70376 22.6557 10.2053 20.4876 12.4154L11.9999 21.0995L3.51245 12.4154C1.34438 10.2053 1.45096 6.70376 3.75722 4.58075C6.06236 2.45879 9.65502 2.42678 12.001 4.52853Z"></path></svg>
${post.post_likes}
</span>
${post.views > 0 ? ` <!-- Only show views if > 0 -->
<span>
<svg class="linuxdo-enhancer-meta-icon eye" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 4.5C7 4.5 2.73 7.61 1 12C2.73 16.39 7 19.5 12 19.5C17 19.5 21.27 16.39 23 12C21.27 7.61 17 4.5 12 4.5ZM12 17C9.24 17 7 14.76 7 12C7 9.24 9.24 7 12 7C14.76 7 17 9.24 17 12C17 14.76 14.76 17 12 17ZM12 9C10.34 9 9 10.34 9 12C9 13.66 10.34 15 12 15C13.66 15 15 13.66 15 12C15 10.34 13.66 9 12 9Z"/></svg>
${post.views}
</span>
` : ''}
<span>
<svg class="linuxdo-enhancer-meta-icon reply" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8 11H10V13H8V11ZM14 11H16V13H14V11ZM12 2C17.52 2 22 6.48 22 12C22 14.11 21.34 16.01 20.23 17.55L20.91 20.22L18.22 20.91C16.01 21.66 13.89 22 12 22C6.48 22 2 17.52 2 12C2 6.48 6.48 2 12 2ZM12 20C13.59 20 15.15 19.65 16.53 19.03L18.89 19.63L18.22 16.91C19.32 15.41 20 13.75 20 12C20 7.58 16.42 4 12 4C7.58 4 4 7.58 4 12C4 16.42 7.58 20 12 20ZM7 11H11V15H7V11ZM13 11H17V15H13V11Z"/></svg>
${post.posts_count - 1}
</span>
</div>
`;
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 = '<div class="linuxdo-enhancer-analysis-item"><p>暂无帖子可供分析。</p></div>';
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 = `
<div class="linuxdo-enhancer-analysis-item">
<h3>总结</h3>
<div class="markdown-body">${this.marked.parse(summary)}</div> <!-- Use marked for all content -->
</div>
<div class="linuxdo-enhancer-analysis-item">
<h3>真假分析</h3>
<div class="markdown-body">${this.marked.parse(truthAnalysis)}</div> <!-- Use marked for all content -->
</div>
<div class="linuxdo-enhancer-analysis-item">
<h3>推荐阅读</h3>
<div class="markdown-body">${this.marked.parse(recommendations)}</div> <!-- Use marked for links and other markdown -->
</div>
<div class="linuxdo-enhancer-analysis-item">
<h3>辣评</h3>
<div class="markdown-body">${this.marked.parse(spicyComment)}</div> <!-- Use marked for all content -->
</div>
`;
// 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 = `
<div class="linuxdo-enhancer-loading">
<div class="linuxdo-enhancer-spinner"></div>
<span>加载中...</span>
</div>
`;
}
showError(element, message) {
if (!element) return; // Defensive check
element.innerHTML = `
<div class="linuxdo-enhancer-error">${message}</div>
`;
}
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
})();