// ==UserScript==
// @name Linux.do 帖子多功能助手
// @namespace https://greasyfork.org/zh-CN/scripts/547708-linux-do-%E5%B8%96%E5%AD%90%E5%A4%9A%E5%8A%9F%E8%83%BD%E5%8A%A9%E6%89%8B
// @version 1.0.3
// @description 1.自动收集帖子内容并使用AI总结。 2.标记已回复。 3.增加发布者标签。 4.始皇曰解密
// @author lishizhen
// @icon https://www.google.com/s2/favicons?sz=64&domain=linux.do
// @match https://linux.do/*
// @match https://idcflare.com/*
// @grant GM.download
// @grant GM.xmlHttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @require https://cdn.jsdelivr.net/npm/marked@16.2.1/lib/marked.umd.min.js
// @connect *
// @license MIT
// @downloadURL https://update.greasyfork.icu/scripts/547708/Linuxdo%20%E5%B8%96%E5%AD%90%E5%A4%9A%E5%8A%9F%E8%83%BD%E5%8A%A9%E6%89%8B.user.js
// @updateURL https://update.greasyfork.icu/scripts/547708/Linuxdo%20%E5%B8%96%E5%AD%90%E5%A4%9A%E5%8A%9F%E8%83%BD%E5%8A%A9%E6%89%8B.meta.js
// ==/UserScript==
(function () {
'use strict';
// =============================================================================
// 配置与常量
// =============================================================================
const CONFIG_KEY = 'LINUXDO_AI_SUMMARIZER_CONFIG_V6';
const CACHE_PREFIX = 'LINUXDO_SUMMARY_CACHE_';
const AI_ICON_SVG = ``;
const POSTED_ICON_SVG = ``
const DEFAULT_CONFIG = {
apiProvider: 'openai',
openai: {
apiKey: '',
baseUrl: 'https://api.openai.com',
model: 'gpt-4o-mini'
},
gemini: {
apiKey: 'AIzaSyCD4E-8rV6IrBCiP8cTqTE1wuYHfmRjCaQ',
baseUrl: 'https://generativelanguage.googleapis.com',
model: 'gemini-2.5-flash-lite-preview-06-17'
},
maxPostsCount: 100,
prompt: `你是一个善于总结论坛帖子的 AI 助手。请根据以下包含了楼主和所有回复的帖子内容,进行全面、客观、精炼的总结。总结应涵盖主要观点、关键信息、不同意见的交锋以及最终的普遍共识或结论。请使用简体中文,并以 Markdown 格式返回,以便于阅读。\n\n帖子内容如下:\n---\n{content}\n---`
};
// =============================================================================
// 全局状态管理
// =============================================================================
class AppState {
constructor() {
this.reset();
}
reset() {
this.status = 'idle'; // idle, collecting, collected, finished
this.posts = [];
this.processedIds = new Set();
this.cachedSummary = null;
this.currentSummaryText = null;
this.topicData = null;
this.isStreaming = false;
this.streamController = null;
}
addPost(post) {
if (!this.processedIds.has(post.id)) {
this.posts.push(post);
this.processedIds.add(post.id);
return true;
}
return false;
}
clearPosts() {
this.posts = [];
this.processedIds.clear();
}
randomPosts() {
// 确保始终包含第一条帖子,然后对剩余帖子进行随机抽样
if (this.posts.length === 0) return [];
// 过滤有效内容的帖子
const validPosts = this.posts.filter(m => m.content.length >= 4);
if (validPosts.length === 0) return [];
// 第一条帖子(通常是楼主帖)
const firstPost = validPosts[0];
const result = [firstPost];
// 获取配置中的最大帖子数量
const config = GM_getValue(CONFIG_KEY, DEFAULT_CONFIG);
const maxCount = Math.min(validPosts.length, config.maxPostsCount || DEFAULT_CONFIG.maxPostsCount);
// 如果只有一条帖子或需要的数量为1,直接返回第一条
if (maxCount <= 1) return result;
// 对剩余帖子进行随机抽样
const remainingPosts = validPosts.slice(1);
const shuffled = [...remainingPosts].sort(() => 0.5 - Math.random());
const sampled = shuffled.slice(0, maxCount - 1);
return result.concat(sampled);
}
getTopicId() {
const match = window.location.pathname.match(/\/t\/[^\/]+\/(\d+)/);
return match ? match[1] : null;
}
getCacheKey() {
const topicId = this.getTopicId();
return topicId ? `${CACHE_PREFIX}${topicId}` : null;
}
isTopicPage() {
return /\/t\/[^\/]+\/\d+/.test(window.location.pathname);
}
loadCache() {
const cacheKey = this.getCacheKey();
if (cacheKey) {
this.cachedSummary = GM_getValue(cacheKey, null);
if (this.cachedSummary) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = this.cachedSummary;
this.currentSummaryText = tempDiv.textContent || tempDiv.innerText || '';
}
}
}
saveCache(summary) {
const cacheKey = this.getCacheKey();
if (cacheKey) {
this.cachedSummary = summary;
GM_setValue(cacheKey, summary);
}
}
stopStreaming() {
this.isStreaming = false;
if (this.streamController) {
this.streamController.abort = true;
}
}
}
const appState = new AppState();
// =============================================================================
// API 调用类
// =============================================================================
class TopicAPI {
constructor() {
this.baseUrl = window.location.origin;
}
async fetchTopicData(topicId, postNumber = 1) {
let url = `${this.baseUrl}/t/${topicId}/${postNumber}.json`;
if (postNumber == 1) {
url = `${this.baseUrl}/t/${topicId}.json`;
}
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: 'GET',
url: url,
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
responseType: 'json',
onload: (response) => {
if (response.status === 200) {
resolve(response.response);
} else {
reject(new Error(`API 请求失败: ${response.status} ${response.statusText}`));
}
},
onerror: () => reject(new Error('网络请求失败'))
});
});
}
async getTopicData(topicId) {
try {
const response = await this.fetchTopicData(topicId);
if (!response || !response.id) {
throw new Error('获取帖子数据失败,可能是帖子不存在或已被删除');
}
return response;
} catch (error) {
console.error('助手脚本:获取帖子数据失败:', error);
throw error;
}
}
async getAllPosts(topicId, callback) {
const posts = [];
const processedIds = new Set();
let totalPosts = 0;
let topicData = null;
try {
// 获取第一页数据来确定总帖子数
const firstResponse = await this.fetchTopicData(topicId, 1);
topicData = {
id: firstResponse.id,
title: firstResponse.title,
fancy_title: firstResponse.fancy_title,
posts_count: firstResponse.posts_count
};
totalPosts = firstResponse.posts_count;
console.log(`助手脚本:开始收集帖子,总计 ${totalPosts} 条`);
let currentPostNumber = 0;
// 处理第一页的帖子 (1-20)
if (firstResponse.post_stream && firstResponse.post_stream.posts) {
firstResponse.post_stream.posts.forEach(post => {
if (!processedIds.has(post.id)) {
const cleanContent = this.cleanPostContent(post.cooked);
if (cleanContent) {
posts.push({
id: post.id,
username: post.username || post.name || '未知用户',
content: cleanContent
});
processedIds.add(post.id);
} else {
console.info(`助手脚本:跳过内容太短的帖子 ${post.post_number} - ${post.id}:${post.cooked}`);
}
}
currentPostNumber = post.post_number;
});
}
callback && callback(posts, topicData);
// 如果总帖子数大于10,需要继续收集
if (totalPosts > 10) {
// 使用较小的区间,每次递增10
while (currentPostNumber < totalPosts) {
try {
const response = await this.fetchTopicData(topicId, currentPostNumber);
if (!response.post_stream || !response.post_stream.posts) {
console.warn(`助手脚本:第 ${currentPost} 条附近没有返回有效数据`);
currentPost += 10;
continue;
}
let newPostsCount = 0;
let lastNumber = 0;
response.post_stream.posts.forEach(post => {
if (!processedIds.has(post.id)) {
const cleanContent = this.cleanPostContent(post.cooked);
if (cleanContent) {
posts.push({
id: post.id,
username: post.username || post.name || '未知用户',
content: cleanContent
});
processedIds.add(post.id);
newPostsCount++;
} else {
console.info(`助手脚本:跳过内容太短的帖子 ${post.post_number} - ${post.id}:${post.cooked}`);
}
}
lastNumber = post.post_number;
});
// 较小的递增步长,减少遗漏
if (lastNumber > 0) {
currentPostNumber = lastNumber + 1; // 直接跳到最后一个帖子后面
} else {
currentPostNumber += (newPostsCount > 0 ? newPostsCount : 10);
}
callback && callback(posts, topicData);
// 添加延时避免请求过快
await new Promise(resolve => setTimeout(resolve, 300));
} catch (error) {
console.warn(`助手脚本:获取第 ${currentPost} 条附近数据失败:`, error);
currentPost += 10; // 继续下一个区间
}
}
}
// 按帖子ID排序确保顺序正确
posts.sort((a, b) => parseInt(a.id) - parseInt(b.id));
console.log(`助手脚本:收集完成,共获得 ${posts.length}/${totalPosts} 条有效帖子`);
return { posts, topicData };
} catch (error) {
console.error('助手脚本:收集帖子失败:', error);
throw error;
}
}
cleanPostContent(htmlContent) {
if (!htmlContent) return '';
// 创建临时div来处理HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
// 移除引用、代码显示按钮等不需要的元素
tempDiv.querySelectorAll('aside.quote, .cooked-selection-barrier, .action-code-show-code-btn, .lightbox-wrapper').forEach(el => el.remove());
// 获取纯文本内容
const content = tempDiv.innerText.trim().replace(/\n{2,}/g, '\n');
// 过滤掉太短的内容
return content;
}
}
const topicAPI = new TopicAPI();
// =============================================================================
// 流式渲染类
// =============================================================================
class StreamRenderer {
constructor(container) {
this.container = container;
this.content = '';
this.lastRenderedLength = 0;
// 配置marked
if (typeof marked !== 'undefined') {
marked.setOptions({
breaks: true,
gfm: true,
sanitize: false
});
}
}
appendContent(chunk) {
this.content += chunk;
this.render();
}
setContent(content) {
this.content = content;
this.lastRenderedLength = 0;
this.render();
}
render() {
if (typeof marked === 'undefined') {
// 降级到简单的文本渲染
this.container.innerHTML = this.content.replace(/\n/g, '
');
return;
}
try {
// 只渲染新增的内容部分,提高性能
const newContent = this.content.slice(this.lastRenderedLength);
if (newContent.trim()) {
const htmlContent = marked.parse(this.content);
this.container.innerHTML = htmlContent;
this.lastRenderedLength = this.content.length;
this.scrollHandle();
}
} catch (error) {
console.error('Markdown渲染失败:', error);
// 降级到纯文本
this.container.innerHTML = this.content.replace(/\n/g, '
');
}
}
scrollHandle() {
// 滚动到底部
// 滚动父级容器到底部
if (this.container.parentElement) {
this.container.parentElement.scrollTop = this.container.parentElement.scrollHeight;
} else {
this.container.scrollTop = this.container.scrollHeight;
}
}
clear() {
this.content = '';
this.lastRenderedLength = 0;
this.container.innerHTML = '';
}
addTypingIndicator() {
// 创建一个包裹容器
const wrapper = document.createElement('div');
wrapper.className = 'typing-indicator-wrapper';
const indicator = document.createElement('div');
indicator.className = 'typing-indicator';
indicator.innerHTML = '●●●';
wrapper.appendChild(indicator);
this.container.appendChild(wrapper);
this.scrollHandle();
}
removeTypingIndicator() {
const indicator = this.container.querySelector('.typing-indicator-wrapper');
if (indicator) {
indicator.remove();
}
}
}
// =============================================================================
// UI 组件管理
// =============================================================================
class UIManager {
constructor() {
this.elements = {};
this.streamRenderer = null;
this.topicData = {}
}
create() {
if (!this.shouldShowUI()) return false;
const targetArea = document.querySelector('div.timeline-controls');
if (!targetArea || document.getElementById('userscript-summary-btn')) return false;
this.addStyles();
this.createButtons(targetArea);
this.createModals();
this.updateStatus();
console.log('助手脚本:UI 已创建');
return true;
}
shouldShowUI() {
return appState.isTopicPage();
}
removeCreatedUserName() {
const discourse_tags = document.querySelector(".discourse-tags");
if (discourse_tags && discourse_tags.querySelector('.username')) {
discourse_tags.removeChild(discourse_tags.querySelector('.username'));
}
}
createCreatedUserName() {
const { summaryBtn } = this.elements;
const topicData = this.topicData;
if (topicData.posted) {
if (summaryBtn && !summaryBtn.querySelector('.posted-icon-span')) {
const postedIcon = this.createElement('span', {
className: 'posted-icon-span',
innerHTML: POSTED_ICON_SVG,
});
summaryBtn.append(postedIcon);
}
}
const created_by = topicData.details?.created_by;
if (created_by) {
const discourse_tags = document.querySelector(".discourse-tags");
if (discourse_tags && !discourse_tags.querySelector('.username')) {
const name = `${created_by.username} · ${created_by.name || created_by.username}`;
const user_a = this.createElement('a', {
className: 'username discourse-tag box',
style: 'background: var(--d-button-primary-bg-color);color: rgb(255, 255, 255);border-radius: 3px;',
href: '/u/' + created_by.username,
innerHTML: '' + name,
// innerHTML: '@' + (created_by.name || created_by.username),
});
discourse_tags.append(user_a);
}
}
}
createButtons(targetArea) {
// AI总结按钮
const summaryIcon = this.createElement('span', {
className: 'icon-span',
innerHTML: AI_ICON_SVG,
});
// AI总结按钮
const summaryBtn = this.createElement('button', {
id: "userscript-summary-btn",
className: 'summary-btn btn no-text btn-icon icon btn-default reader-mode-toggle',
title: 'AI 一键收集并总结',
onclick: () => this.startSummary()
});
// 状态显示
const statusSpan = this.createElement('span', {
className: 'userscript-counter',
title: '已收集的帖子数量',
textContent: '0'
});
try {
setTimeout(() => {
topicAPI.getTopicData(appState.getTopicId()).then(topicData => {
this.topicData = topicData;
this.createCreatedUserName();
});
}, 1000);
} catch (error) {
console.error('助手脚本:获取帖子数据失败:', error);
}
summaryBtn.append(summaryIcon);
summaryBtn.append(statusSpan);
targetArea.prepend(
summaryBtn,
);
this.elements = { summaryBtn, summaryIcon, statusSpan };
}
createElement(tag, props) {
const element = document.createElement(tag);
Object.assign(element, props);
return element;
}
updateStatus() {
const { summaryBtn, summaryIcon, statusSpan } = this.elements;
if (!summaryBtn) return;
const count = appState.posts.length;
const hasCache = appState.cachedSummary;
// 更新计数器
if (statusSpan) {
statusSpan.textContent = count;
statusSpan.classList.toggle('visible', count > 0);
}
// 更新按钮状态
switch (appState.status) {
case 'idle':
summaryBtn.disabled = false;
summaryIcon.innerHTML = AI_ICON_SVG;
summaryBtn.title = hasCache ? 'AI 查看总结 (有缓存)' : 'AI 一键收集并总结';
break;
case 'collecting':
summaryBtn.disabled = true;
summaryIcon.innerHTML = ``;
summaryBtn.title = '正在收集帖子内容...';
break;
case 'collected':
summaryBtn.disabled = true;
summaryIcon.innerHTML = ``;
summaryBtn.title = appState.isStreaming ? '正在生成总结... (点击停止)' : '正在请求 AI 总结...';
if (appState.isStreaming) {
summaryBtn.disabled = false;
summaryBtn.onclick = () => this.stopStreaming();
}
break;
case 'finished':
summaryBtn.disabled = false;
summaryIcon.innerHTML = AI_ICON_SVG;
summaryBtn.title = 'AI 查看总结 / 重新生成';
summaryBtn.onclick = () => this.startSummary();
break;
}
}
stopStreaming() {
appState.stopStreaming();
this.updateStreamingUI(false);
appState.status = 'finished';
this.updateStatus();
// 更新footer显示已停止
const modal = document.getElementById('ai-summary-modal-container');
const footer = modal.querySelector('.ai-summary-modal-footer');
const statusDiv = footer.querySelector('.streaming-status');
if (statusDiv) {
statusDiv.innerHTML = '● 已停止生成';
}
}
hide() {
const elements = ['userscript-summary-btn', 'userscript-download-li', 'ai-summary-modal-container'];
elements.forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
this.topicData = {};
}
show() {
const elements = ['userscript-summary-btn', 'userscript-download-li'];
elements.forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = '';
});
this.createCreatedUserName();
}
async startSummary() {
// 检查缓存
if (appState.cachedSummary && appState.status === 'idle') {
this.showSummaryModal('success', appState.cachedSummary, true);
return;
}
// 开始收集
await this.collectPosts();
}
async collectPosts() {
appState.status = 'collecting';
appState.clearPosts();
appState.saveCache('');
this.updateStatus();
try {
const topicId = appState.getTopicId();
if (!topicId) {
throw new Error('无法获取帖子ID');
}
const { posts, topicData } = await topicAPI.getAllPosts(topicId, (posts, topicData) => {
// 添加所有帖子到状态
posts.forEach(post => {
appState.addPost(post);
});
appState.topicData = topicData;
this.updateStatus();
});
// 添加所有帖子到状态
posts.forEach(post => {
appState.addPost(post);
});
appState.topicData = topicData;
this.updateStatus();
if (appState.posts.length > 0) {
appState.status = 'collected';
this.updateStatus();
setTimeout(() => this.requestAISummary(), 1000);
} else {
throw new Error('未收集到任何有效内容');
}
} catch (error) {
console.error('助手脚本:收集失败:', error);
alert(`收集失败: ${error.message}`);
appState.status = 'idle';
this.updateStatus();
}
}
async requestAISummary(forceRegenerate = false, clearPosts = false) {
if (forceRegenerate) {
appState.saveCache('');
if (appState.posts.length == 0) {
clearPosts = true;
}
if (clearPosts) {
appState.clearPosts();
await this.collectPosts();
return;
}
}
if (!forceRegenerate && appState.cachedSummary) {
this.showSummaryModal('success', appState.cachedSummary, true);
appState.status = 'finished';
this.updateStatus();
return;
}
if (appState.posts.length == 0) {
this.showSummaryModal('error', '没有收集到任何帖子内容,请先收集帖子。');
appState.status = 'idle';
this.updateStatus();
return;
}
this.showSummaryModal('streaming');
try {
const panel = document.getElementById('ai-settings-panel');
const config = GM_getValue(CONFIG_KEY, DEFAULT_CONFIG);
panel.querySelector('#enable-streaming').checked = config.enableStreaming !== false;
const content = this.formatPostsForAI();
const prompt = config.prompt.replace('{content}', content);
let summary;
if (config.apiProvider === 'gemini') {
summary = await this.callGeminiStream(prompt, config);
} else {
summary = await this.callOpenAIStream(prompt, config);
}
if (summary && !appState.streamController?.abort) {
const htmlSummary = this.streamRenderer.container.innerHTML;
appState.currentSummaryText = summary;
appState.saveCache(htmlSummary);
}
this.updateStreamingUI(false);
appState.status = 'finished';
this.updateStatus();
} catch (error) {
if (!appState.streamController?.abort) {
this.showSummaryModal('error', error.message);
}
appState.status = 'finished';
this.updateStatus();
}
}
formatPostsForAI() {
const title = appState.topicData?.fancy_title || appState.topicData?.title || document.querySelector('#topic-title .fancy-title')?.innerText.trim() || '无标题';
const posts = appState.randomPosts().map(p => `${p.username}: ${p.content}`).join('\n\n---\n\n');
return `帖子标题: ${title}\n\n${posts}`;
}
async callOpenAIStream(prompt, config) {
if (!config.openai.apiKey) {
throw new Error('OpenAI API Key 未设置');
}
// 检查配置中是否启用流式输出
const enableStreaming = GM_getValue(CONFIG_KEY, DEFAULT_CONFIG).enableStreaming !== false;
if (!enableStreaming) {
// 使用非流式调用
const result = await this.callOpenAI(prompt, config);
this.streamRenderer.setContent(result);
return result;
}
appState.isStreaming = true;
appState.streamController = { abort: false };
try {
const response = await fetch(`${config.openai.baseUrl.replace(/\/$/, '')}/v1/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.openai.apiKey}`
},
body: JSON.stringify({
model: config.openai.model,
messages: [{ role: 'user', content: prompt }],
stream: true
}),
signal: appState.streamController.signal
});
if (!response.ok) {
let errorMessage = `OpenAI API 请求失败 (${response.status}): ${response.statusText}`;
try {
const errorData = await response.json();
errorMessage = `OpenAI API 请求失败 (${response.status}): ${errorData.error?.message || response.statusText}`;
} catch (e) {
// 使用默认错误消息
}
throw new Error(errorMessage);
}
return await this.processStreamResponse(response);
} catch (error) {
appState.isStreaming = false;
if (error.name === 'AbortError') {
console.log('流式请求被用户取消');
throw new Error('请求已取消');
}
throw error;
}
}
async processStreamResponse(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullContent = '';
let buffer = '';
try {
while (true) {
// 检查是否需要中止
if (appState.streamController?.abort) {
reader.cancel();
break;
}
const { done, value } = await reader.read();
if (done) {
console.log('流式响应完成');
break;
}
// 解码数据块
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
// 处理完整的事件
const events = buffer.split('\n\n');
buffer = events.pop() || ''; // 保留最后一个可能不完整的事件
for (const event of events) {
if (event.trim()) {
const processed = this.processOpenAIStreamEvent(event);
if (processed) {
fullContent += processed;
this.streamRenderer.appendContent(processed);
}
}
}
}
// 处理剩余的缓冲数据
if (buffer.trim()) {
const processed = this.processOpenAIStreamEvent(buffer);
if (processed) {
fullContent += processed;
this.streamRenderer.appendContent(processed);
}
}
return fullContent;
} finally {
appState.isStreaming = false;
reader.releaseLock();
}
}
processOpenAIStreamEvent(event) {
const lines = event.split('\n');
let content = '';
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('data: ')) {
const data = trimmedLine.slice(6).trim();
if (data === '[DONE]') {
console.log('收到流式结束标志');
appState.isStreaming = false;
continue;
}
try {
const parsed = JSON.parse(data);
const deltaContent = parsed.choices?.[0]?.delta?.content;
if (deltaContent) {
content += deltaContent;
}
// 检查是否完成
const finishReason = parsed.choices?.[0]?.finish_reason;
if (finishReason) {
console.log('流式完成,原因:', finishReason);
appState.isStreaming = false;
}
} catch (e) {
console.warn('解析 SSE 数据时出错:', e, '数据:', data);
}
}
}
return content;
}
async callGeminiStream(prompt, config) {
if (!config.gemini.apiKey) {
throw new Error('Gemini API Key 未设置');
}
// 检查配置中是否启用流式输出
const enableStreaming = GM_getValue(CONFIG_KEY, DEFAULT_CONFIG).enableStreaming !== false;
if (!enableStreaming) {
// 使用非流式调用
const result = await this.callGemini(prompt, config);
this.streamRenderer.setContent(result);
return result;
}
appState.isStreaming = true;
appState.streamController = { abort: false };
const url = `${config.gemini.baseUrl.replace(/\/$/, '')}/v1beta/models/${config.gemini.model}:streamGenerateContent?key=${config.gemini.apiKey}`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }]
}),
signal: appState.streamController.signal
});
if (!response.ok) {
let errorMessage = `Gemini API 请求失败 (${response.status}): ${response.statusText}`;
try {
const errorData = await response.json();
errorMessage = `Gemini API 请求失败 (${response.status}): ${errorData.error?.message || response.statusText}`;
} catch (e) {
// 使用默认错误消息
}
throw new Error(errorMessage);
}
return await this.processGeminiStreamResponse(response);
} catch (error) {
appState.isStreaming = false;
if (error.name === 'AbortError') {
console.log('Gemini 流式请求被用户取消');
throw new Error('请求已取消');
}
throw error;
}
}
async processGeminiStreamResponse(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullContent = '';
let buffer = '';
try {
while (true) {
// 检查是否需要中止
if (appState.streamController?.abort) {
reader.cancel();
break;
}
const { done, value } = await reader.read();
if (done) {
console.log('Gemini 流式响应完成');
break;
}
// 解码数据块
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
// 处理缓冲区中的完整 JSON 对象
let processedData;
({ processedData, buffer } = this.extractCompleteJsonObjects(buffer));
if (processedData) {
const processed = this.processGeminiStreamData(processedData);
if (processed) {
fullContent += processed;
this.streamRenderer.appendContent(processed);
}
}
}
// 处理剩余的缓冲数据
if (buffer.trim()) {
const processed = this.processGeminiStreamData(buffer);
if (processed) {
fullContent += processed;
this.streamRenderer.appendContent(processed);
}
}
return fullContent;
} finally {
appState.isStreaming = false;
reader.releaseLock();
}
}
extractCompleteJsonObjects(buffer) {
let processedData = '';
let remainingBuffer = buffer;
// Gemini 流式响应通常是换行分隔的 JSON 对象
const lines = buffer.split('\n');
remainingBuffer = lines.pop() || ''; // 保留最后一行,可能不完整
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine) {
processedData += trimmedLine + '\n';
}
}
return { processedData, buffer: remainingBuffer };
}
processGeminiStreamData(data) {
const lines = data.split('\n');
let content = '';
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine && (trimmedLine.startsWith('{') || trimmedLine.startsWith('['))) {
try {
const parsed = JSON.parse(trimmedLine);
// Gemini 流式响应结构
const candidates = parsed.candidates;
if (candidates && candidates.length > 0) {
const candidate = candidates[0];
const textContent = candidate.content?.parts?.[0]?.text;
if (textContent) {
content += textContent;
}
// 检查完成状态
if (candidate.finishReason) {
console.log('Gemini 流式完成,原因:', candidate.finishReason);
appState.isStreaming = false;
}
}
// 处理错误信息
if (parsed.error) {
console.error('Gemini 流式响应错误:', parsed.error);
throw new Error(`Gemini API 错误: ${parsed.error.message}`);
}
} catch (e) {
console.warn('解析 Gemini 流式数据时出错:', e, '数据:', trimmedLine);
}
}
}
return content;
}
// 降级方案:非流式调用
async callOpenAI(prompt, config) {
if (!config.openai.apiKey) {
throw new Error('OpenAI API Key 未设置');
}
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: 'POST',
url: `${config.openai.baseUrl.replace(/\/$/, '')}/v1/chat/completions`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.openai.apiKey}`
},
data: JSON.stringify({
model: config.openai.model,
messages: [{ role: 'user', content: prompt }]
}),
responseType: 'json',
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
const content = response.response.choices?.[0]?.message?.content;
if (content) {
resolve(content);
} else {
reject(new Error('API 返回内容格式不正确'));
}
} else {
reject(new Error(`API 请求失败 (${response.status}): ${response.response?.error?.message || response.statusText}`));
}
},
onerror: () => reject(new Error('网络请求失败'))
});
});
}
async callGemini(prompt, config) {
if (!config.gemini.apiKey) {
throw new Error('Gemini API Key 未设置');
}
const url = `${config.gemini.baseUrl.replace(/\/$/, '')}/v1beta/models/${config.gemini.model}:generateContent?key=${config.gemini.apiKey}`;
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: 'POST',
url: url,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }]
}),
responseType: 'json',
onload: (response) => {
if (response.status === 200) {
const content = response.response.candidates?.[0]?.content?.parts?.[0]?.text;
if (content) {
resolve(content);
} else {
reject(new Error('Gemini API 返回内容格式不正确'));
}
} else {
reject(new Error(`Gemini API 请求失败 (${response.status}): ${response.response?.error?.message || response.statusText}`));
}
},
onerror: () => reject(new Error('网络请求失败'))
});
});
}
downloadPosts() {
if (appState.posts.length === 0) {
alert('尚未收集任何帖子!');
return;
}
const title = appState.topicData?.fancy_title || appState.topicData?.title || document.querySelector('#topic-title .fancy-title')?.innerText.trim() || document.title.split(' - ')[0];
const filename = `${title.replace(/[\\/:*?"<>|]/g, '_')} (共 ${appState.posts.length} 楼).txt`;
let content = `帖子标题: ${title}\n帖子链接: ${window.location.href}\n收集时间: ${new Date().toLocaleString()}\n总帖子数: ${appState.topicData?.posts_count || appState.posts.length}\n\n`;
if (appState.currentSummaryText) {
content += "================ AI 总结 ================\n";
content += appState.currentSummaryText + "\n\n";
}
content += "============== 帖子原文 ================\n\n";
appState.posts.forEach((post, index) => {
content += `#${index + 1} 楼 - ${post.username}:\n${post.content}\n\n---\n\n`;
});
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
GM.download({ url: URL.createObjectURL(blob), name: filename });
}
// Modal 相关方法
createModals() {
this.createSummaryModal();
this.createSettingsPanel();
}
createSummaryModal() {
if (document.getElementById('ai-summary-modal-container')) return;
const modal = document.createElement('div');
modal.id = 'ai-summary-modal-container';
modal.className = 'ai-summary-modal-container';
modal.innerHTML = `
${content}
AI 总结时最多使用的帖子数量,包含楼主帖。数量越多,内容越全面但成本更高
流式输出可以实时看到AI生成内容的过程,但可能在某些网络环境下不稳定
始皇曰:xxx,那么应该找到code元素,当然不一定是code标签
let codeElement = post.querySelector('*');
const walker = document.createTreeWalker(
codeElement,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
while (node = walker.nextNode()) {
if (node.textContent.includes(content)) {
codeElement = node.parentElement;
break;
}
}
if (codeElement) {
const decrypt_content = this.decrypt_neo(content);
if (decrypt_content) {
// 存储原始内容
if (!codeElement.getAttribute('data-original-html')) {
codeElement.setAttribute('data-original-html', codeElement.innerHTML);
}
// 使用原始内容拼接解密内容
const originalHtml = codeElement.getAttribute('data-original-html');
codeElement.innerHTML = originalHtml + `