您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
自动总结网页文章内容,支持多种格式输出,适用于各类文章网站
当前为
// ==UserScript== // @name 网页文章总结助手 // @namespace http://tampermonkey.net/ // @version 0.2.1 // @description 自动总结网页文章内容,支持多种格式输出,适用于各类文章网站 // @author h7ml <[email protected]> // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_getResourceText // @connect api.gptgod.online // @connect api.deepseek.com // @connect localhost // @connect * // @resource marked https://cdn.bootcdn.net/ajax/libs/marked/4.3.0/marked.min.js // @resource highlight https://cdn.bootcdn.net/ajax/libs/highlight.js/11.7.0/highlight.min.js // @resource highlightStyle https://cdn.bootcdn.net/ajax/libs/highlight.js/11.7.0/styles/github.min.css // ==/UserScript== (function () { 'use strict'; // 配置管理类 class ConfigManager { constructor() { this.DEFAULT_API_SERVICE = 'ollama'; this.DEFAULT_CONFIGS = { ollama: { url: 'http://localhost:11434/api/chat', model: 'llama2', key: '' // Ollama 不需要 API key }, gptgod: { url: 'https://api.gptgod.online/v1/chat/completions', model: 'gpt-4o-all', key: 'sk-L1rbJXBp3aDrZLgyrUq8FugKU54FxElTbzt7RfnBaWgHOtFj' }, deepseek: { url: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-chat', key: '' }, custom: { url: '', model: '', key: '' } }; this.DEFAULT_FORMAT = 'markdown'; this.DEFAULT_APP_SIZE = { width: 400, height: 500 }; this.OLLAMA_MODELS = [ 'llama2', 'llama2:13b', 'llama2:70b', 'mistral', 'mixtral', 'gemma:2b', 'gemma:7b', 'qwen:14b', 'qwen:72b', 'phi3:mini', 'phi3:small', 'phi3:medium', 'yi:34b', 'vicuna:13b', 'vicuna:33b', 'codellama', 'wizardcoder', 'nous-hermes2', 'neural-chat', 'openchat', 'dolphin-mixtral', 'starling-lm' ]; } getConfigs() { return GM_getValue('apiConfigs', this.DEFAULT_CONFIGS); } getApiService() { return GM_getValue('apiService', this.DEFAULT_API_SERVICE); } getOutputFormat() { return GM_getValue('outputFormat', this.DEFAULT_FORMAT); } getConfigCollapsed() { return GM_getValue('configCollapsed', false); } getAppMinimized() { return GM_getValue('appMinimized', false); } getAppPosition() { return GM_getValue('appPosition', null); } getIconPosition() { return GM_getValue('iconPosition', null); } getAppSize() { return GM_getValue('appSize', this.DEFAULT_APP_SIZE); } setConfigs(configs) { GM_setValue('apiConfigs', configs); } setApiService(service) { GM_setValue('apiService', service); } setOutputFormat(format) { GM_setValue('outputFormat', format); } setConfigCollapsed(collapsed) { GM_setValue('configCollapsed', collapsed); } setAppMinimized(minimized) { GM_setValue('appMinimized', minimized); } setAppPosition(position) { GM_setValue('appPosition', position); } setIconPosition(position) { GM_setValue('iconPosition', position); } setAppSize(size) { GM_setValue('appSize', size); } } // UI管理类 class UIManager { constructor(configManager) { this.configManager = configManager; this.app = null; this.iconElement = null; this.elements = {}; this.isDragging = false; this.isIconDragging = false; this.isMaximized = false; this.previousSize = {}; this.apiService = null; // 将在 init 中初始化 } async init() { this.apiService = new APIService(this.configManager); await this.loadLibraries(); this.createApp(); this.createIcon(); this.bindEvents(); this.restoreState(); // 如果当前服务是 Ollama,尝试获取模型列表 if (this.configManager.getApiService() === 'ollama') { this.fetchOllamaModels(); } } async loadLibraries() { // 添加基础样式 GM_addStyle(` #article-summary-app { position: fixed; top: 20px; right: 20px; width: 400px; max-height: 80vh; min-width: 320px; min-height: 300px; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); z-index: 999999; display: flex; flex-direction: column; resize: both; overflow: hidden; } #article-summary-icon { position: fixed; bottom: 20px; right: 20px; width: 40px; height: 40px; background: #4CAF50; border-radius: 50%; display: none; /* 默认隐藏图标 */ align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 2px 5px rgba(0,0,0,0.2); z-index: 999999; color: white; } #summary-header { padding: 12px 16px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; cursor: move; } #summary-header h3 { margin: 0; font-size: 16px; color: #333; } #summary-header-actions { display: flex; gap: 8px; } .header-btn { background: none; border: none; padding: 4px; cursor: pointer; color: #666; border-radius: 4px; } .header-btn:hover { background: #f5f5f5; } #summary-body { padding: 16px; overflow-y: auto; flex: 1; } .form-group { margin-bottom: 16px; } .form-label { display: block; margin-bottom: 4px; color: #666; } .form-input, .form-select { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; } #configPanel { margin-top: 8px; padding: 12px; background: #f9f9f9; border-radius: 4px; } #configPanel.collapsed { display: none; } #formatOptions { display: flex; gap: 8px; } .format-btn { padding: 4px 8px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; font-size: 14px; } .format-btn.active { background: #4CAF50; color: white; border-color: #4CAF50; } #generateBtn { width: 100%; padding: 12px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; gap: 8px; } #generateBtn:disabled { background: #ccc; cursor: not-allowed; } #summaryResult { margin-top: 16px; display: none; flex-direction: column; height: 100%; } #summaryHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } #summaryHeader h4 { margin: 0; color: #333; } .action-btn { background: none; border: none; padding: 4px 8px; cursor: pointer; color: #666; display: flex; align-items: center; gap: 4px; } .action-btn:hover { color: #4CAF50; } #loadingIndicator { display: none; text-align: center; padding: 20px; } .spinner { width: 40px; height: 40px; margin: 0 auto 16px; border: 3px solid #f3f3f3; border-top: 3px solid #4CAF50; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .app-minimized { display: none; } .icon { width: 20px; height: 20px; } .toggle-icon { transition: transform 0.3s; } .markdown-body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #333; flex: 1; display: flex; flex-direction: column; } .markdown-body h1 { font-size: 1.5rem; margin: 1rem 0; } .markdown-body h2 { font-size: 1.25rem; margin: 1rem 0; } .markdown-body h3 { font-size: 1.1rem; margin: 1rem 0; } .markdown-body p { margin: 0.5rem 0; } .markdown-body code { background: #f6f8fa; padding: 0.2em 0.4em; border-radius: 3px; font-family: monospace; } .markdown-body pre { background: #f6f8fa; padding: 1rem; border-radius: 3px; overflow-x: auto; } #modelSelect { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; } #modelName { display: none; } .ollama-service #modelSelect { display: block; } .ollama-service #modelName { display: none; } .non-ollama-service #modelSelect { display: none; } .non-ollama-service #modelName { display: block; } .content-textarea { width: 100%; height: 100%; min-height: 200px; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-family: system-ui, -apple-system, sans-serif; font-size: 14px; line-height: 1.6; resize: vertical; flex: 1; box-sizing: border-box; background-color: #fff; color: #333; } .content-textarea:focus { outline: none; border-color: #4CAF50; box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2); } .resize-handle { position: absolute; bottom: 0; right: 0; width: 15px; height: 15px; cursor: nwse-resize; background: linear-gradient(135deg, transparent 50%, #ccc 50%, #ccc 100%); border-radius: 0 0 4px 0; } `); console.log('Markdown 渲染库加载完成'); } createApp() { this.app = document.createElement('div'); this.app.id = 'article-summary-app'; this.app.innerHTML = this.getAppHTML(); document.body.appendChild(this.app); this.initializeElements(); } createIcon() { this.iconElement = document.createElement('div'); this.iconElement.id = 'article-summary-icon'; this.iconElement.innerHTML = this.getIconHTML(); document.body.appendChild(this.iconElement); // 不需要在这里设置display,因为CSS已经默认设置为none } initializeElements() { this.elements = { apiService: document.getElementById('apiService'), apiUrl: document.getElementById('apiUrl'), apiUrlContainer: document.getElementById('apiUrlContainer'), apiKey: document.getElementById('apiKey'), apiKeyContainer: document.getElementById('apiKeyContainer'), modelName: document.getElementById('modelName'), modelSelect: document.getElementById('modelSelect'), generateBtn: document.getElementById('generateBtn'), summaryResult: document.getElementById('summaryResult'), summaryContent: document.getElementById('summaryContent'), loadingIndicator: document.getElementById('loadingIndicator'), configToggle: document.getElementById('configToggle'), configPanel: document.getElementById('configPanel'), toggleMaxBtn: document.getElementById('toggleMaxBtn'), toggleMinBtn: document.getElementById('toggleMinBtn'), formatBtns: document.querySelectorAll('.format-btn'), copyBtn: document.getElementById('copyBtn') }; } bindEvents() { this.bindAppEvents(); this.bindIconEvents(); this.bindConfigEvents(); this.bindResizeEvents(); } bindAppEvents() { const header = document.getElementById('summary-header'); header.addEventListener('mousedown', this.dragStart.bind(this)); document.addEventListener('mousemove', this.drag.bind(this)); document.addEventListener('mouseup', this.dragEnd.bind(this)); this.elements.toggleMaxBtn.addEventListener('click', this.toggleMaximize.bind(this)); this.elements.toggleMinBtn.addEventListener('click', this.toggleMinimize.bind(this)); this.elements.copyBtn.addEventListener('click', this.copyContent.bind(this)); } bindIconEvents() { this.iconElement.addEventListener('mousedown', this.iconDragStart.bind(this)); document.addEventListener('mousemove', this.iconDrag.bind(this)); document.addEventListener('mouseup', this.iconDragEnd.bind(this)); this.iconElement.addEventListener('click', this.toggleApp.bind(this)); } bindConfigEvents() { this.elements.apiService.addEventListener('change', this.handleApiServiceChange.bind(this)); this.elements.apiUrl.addEventListener('change', this.handleConfigChange.bind(this)); this.elements.apiKey.addEventListener('change', this.handleConfigChange.bind(this)); this.elements.modelName.addEventListener('change', this.handleConfigChange.bind(this)); this.elements.modelSelect.addEventListener('change', this.handleModelSelectChange.bind(this)); this.elements.configToggle.addEventListener('click', this.toggleConfig.bind(this)); this.elements.formatBtns.forEach(btn => { btn.addEventListener('click', this.handleFormatChange.bind(this)); }); } bindResizeEvents() { // 添加窗口大小调整事件 const resizeHandle = document.querySelector('.resize-handle'); if (resizeHandle) { resizeHandle.addEventListener('mousedown', this.startResize.bind(this)); } } startResize(e) { e.preventDefault(); e.stopPropagation(); // 初始位置 this.isResizing = true; this.initialWidth = this.app.offsetWidth; this.initialHeight = this.app.offsetHeight; this.initialX = e.clientX; this.initialY = e.clientY; // 创建绑定的处理函数 this.resizeHandler = this.resize.bind(this); this.stopResizeHandler = this.stopResize.bind(this); // 添加临时事件监听器 document.addEventListener('mousemove', this.resizeHandler); document.addEventListener('mouseup', this.stopResizeHandler); } resize(e) { if (!this.isResizing) return; // 计算新尺寸,设置最小值限制 const minWidth = 320; const minHeight = 300; const newWidth = Math.max(minWidth, this.initialWidth + (e.clientX - this.initialX)); const newHeight = Math.max(minHeight, this.initialHeight + (e.clientY - this.initialY)); // 应用新尺寸 this.app.style.width = newWidth + 'px'; this.app.style.height = newHeight + 'px'; // 保存尺寸到配置 this.saveAppSize(newWidth, newHeight); } saveAppSize(width, height) { // 保存应用尺寸到配置 const appSize = { width, height }; this.configManager.setAppSize(appSize); } stopResize() { this.isResizing = false; // 移除临时事件监听器 document.removeEventListener('mousemove', this.resizeHandler); document.removeEventListener('mouseup', this.stopResizeHandler); } restoreState() { try { const configs = this.configManager.getConfigs(); const apiService = this.configManager.getApiService(); // 确保服务配置存在 if (!configs[apiService]) { configs[apiService] = { url: apiService === 'ollama' ? 'http://localhost:11434/api/chat' : '', model: apiService === 'ollama' ? 'llama2' : '', key: '' }; // 保存新创建的配置 this.configManager.setConfigs(configs); } const currentConfig = configs[apiService]; // 设置表单值 this.elements.apiKey.value = currentConfig.key || ''; this.elements.modelName.value = currentConfig.model || ''; this.elements.apiUrl.value = currentConfig.url || ''; // 显示/隐藏 API Key 输入框 this.elements.apiKeyContainer.style.display = apiService === 'ollama' ? 'none' : 'block'; // 根据服务类型添加类名 if (apiService === 'ollama') { this.app.classList.add('ollama-service'); this.app.classList.remove('non-ollama-service'); // 设置选中的模型 const modelValue = currentConfig.model || 'llama2'; const option = Array.from(this.elements.modelSelect.options).find(opt => opt.value === modelValue); if (option) { this.elements.modelSelect.value = modelValue; } else { this.elements.modelSelect.value = 'llama2'; } // 尝试获取 Ollama 模型列表 this.fetchOllamaModels(); } else { this.app.classList.remove('ollama-service'); this.app.classList.add('non-ollama-service'); } this.elements.apiService.value = apiService; const format = this.configManager.getOutputFormat(); this.elements.formatBtns.forEach(btn => { if (btn.dataset.format === format) { btn.classList.add('active'); } else { btn.classList.remove('active'); } }); const configCollapsed = this.configManager.getConfigCollapsed(); if (configCollapsed) { this.elements.configPanel.classList.add('collapsed'); this.elements.configToggle.querySelector('.toggle-icon').style.transform = 'rotate(-90deg)'; } // 恢复最小化状态 - 使用直接的DOM操作 const appMinimized = this.configManager.getAppMinimized(); console.log('恢复状态: 最小化状态 =', appMinimized); if (appMinimized) { // 直接设置显示状态 document.getElementById('article-summary-app').style.display = 'none'; document.getElementById('article-summary-icon').style.display = 'flex'; console.log('已恢复最小化状态,图标显示状态:', document.getElementById('article-summary-icon').style.display); } else { // 直接设置显示状态 document.getElementById('article-summary-app').style.display = 'flex'; document.getElementById('article-summary-icon').style.display = 'none'; console.log('已恢复正常状态,图标显示状态:', document.getElementById('article-summary-icon').style.display); } // 恢复位置 const appPosition = this.configManager.getAppPosition(); if (appPosition) { this.app.style.left = appPosition.x + 'px'; this.app.style.top = appPosition.y + 'px'; // 确保right和bottom属性被移除,避免位置冲突 this.app.style.right = 'auto'; this.app.style.bottom = 'auto'; } // 恢复尺寸 const appSize = this.configManager.getAppSize(); if (appSize) { this.app.style.width = appSize.width + 'px'; this.app.style.height = appSize.height + 'px'; } const iconPosition = this.configManager.getIconPosition(); if (iconPosition) { this.iconElement.style.left = iconPosition.x + 'px'; this.iconElement.style.top = iconPosition.y + 'px'; // 确保right和bottom属性被移除,避免位置冲突 this.iconElement.style.right = 'auto'; this.iconElement.style.bottom = 'auto'; } } catch (error) { console.error('恢复状态过程中出错:', error); } } // 拖拽相关方法 dragStart(e) { this.isDragging = true; this.initialX = e.clientX - this.app.offsetLeft; this.initialY = e.clientY - this.app.offsetTop; } drag(e) { if (this.isDragging) { e.preventDefault(); const currentX = Math.max(0, Math.min( e.clientX - this.initialX, window.innerWidth - this.app.offsetWidth )); const currentY = Math.max(0, Math.min( e.clientY - this.initialY, window.innerHeight - this.app.offsetHeight )); this.app.style.left = currentX + 'px'; this.app.style.top = currentY + 'px'; } } dragEnd() { if (this.isDragging) { this.isDragging = false; const position = { x: parseInt(this.app.style.left), y: parseInt(this.app.style.top) }; this.configManager.setAppPosition(position); } } // 图标拖拽相关方法 iconDragStart(e) { this.isIconDragging = true; this.iconInitialX = e.clientX - this.iconElement.offsetLeft; this.iconInitialY = e.clientY - this.iconElement.offsetTop; this.iconElement.style.cursor = 'grabbing'; } iconDrag(e) { if (this.isIconDragging) { e.preventDefault(); const currentX = Math.max(0, Math.min( e.clientX - this.iconInitialX, window.innerWidth - this.iconElement.offsetWidth )); const currentY = Math.max(0, Math.min( e.clientY - this.iconInitialY, window.innerHeight - this.iconElement.offsetHeight )); this.iconElement.style.left = currentX + 'px'; this.iconElement.style.top = currentY + 'px'; this.iconElement.style.right = 'auto'; } } iconDragEnd() { if (this.isIconDragging) { this.isIconDragging = false; this.iconElement.style.cursor = 'pointer'; const position = { x: parseInt(this.iconElement.style.left), y: parseInt(this.iconElement.style.top) }; this.configManager.setIconPosition(position); } } // 配置相关方法 handleApiServiceChange() { const service = this.elements.apiService.value; const configs = this.configManager.getConfigs(); // 确保服务配置存在,如果不存在则创建默认配置 if (!configs[service]) { configs[service] = { url: service === 'ollama' ? 'http://localhost:11434/api/chat' : '', model: service === 'ollama' ? 'llama2' : '', key: '' }; // 保存新创建的配置 this.configManager.setConfigs(configs); } const currentConfig = configs[service]; // 设置表单值 this.elements.apiKey.value = currentConfig.key || ''; this.elements.modelName.value = currentConfig.model || ''; this.elements.apiUrl.value = currentConfig.url || ''; // 显示/隐藏 API Key 输入框 this.elements.apiKeyContainer.style.display = service === 'ollama' ? 'none' : 'block'; // 根据服务类型添加类名 if (service === 'ollama') { this.app.classList.add('ollama-service'); this.app.classList.remove('non-ollama-service'); // 设置选中的模型 const modelValue = currentConfig.model || 'llama2'; const option = Array.from(this.elements.modelSelect.options).find(opt => opt.value === modelValue); if (option) { this.elements.modelSelect.value = modelValue; } else { this.elements.modelSelect.value = 'llama2'; } // 尝试获取 Ollama 模型列表 this.fetchOllamaModels(); } else { this.app.classList.remove('ollama-service'); this.app.classList.add('non-ollama-service'); } this.configManager.setApiService(service); } handleConfigChange() { const service = this.elements.apiService.value; const configs = this.configManager.getConfigs(); // 确保服务配置存在 if (!configs[service]) { configs[service] = { url: service === 'ollama' ? 'http://localhost:11434/api/chat' : '', model: service === 'ollama' ? 'llama2' : '', key: '' }; } // 获取当前表单值 const apiKey = this.elements.apiKey.value || ''; const modelName = service === 'ollama' ? (this.elements.modelSelect.value || 'llama2') : (this.elements.modelName.value || ''); const apiUrl = this.elements.apiUrl.value || (service === 'ollama' ? 'http://localhost:11434/api/chat' : ''); // 更新配置 configs[service] = { ...configs[service], key: apiKey, model: modelName, url: apiUrl }; // 保存配置 this.configManager.setConfigs(configs); } toggleConfig() { this.elements.configPanel.classList.toggle('collapsed'); const isCollapsed = this.elements.configPanel.classList.contains('collapsed'); const toggleIcon = this.elements.configToggle.querySelector('.toggle-icon'); toggleIcon.style.transform = isCollapsed ? 'rotate(-90deg)' : ''; this.configManager.setConfigCollapsed(isCollapsed); } handleFormatChange(e) { this.elements.formatBtns.forEach(btn => btn.classList.remove('active')); e.target.classList.add('active'); this.configManager.setOutputFormat(e.target.dataset.format); } handleModelSelectChange() { // 确保选择的值有效 const selectedModel = this.elements.modelSelect.value || 'llama2'; this.elements.modelName.value = selectedModel; // 触发配置更新 this.handleConfigChange(); } // UI状态相关方法 toggleMaximize() { if (!this.isMaximized) { this.previousSize = { width: this.app.style.width, height: this.app.style.height, left: this.app.style.left, top: this.app.style.top }; this.app.style.width = '100%'; this.app.style.height = '100vh'; this.app.style.left = '0'; this.app.style.top = '0'; this.elements.toggleMaxBtn.innerHTML = this.getMaximizeIcon(); } else { Object.assign(this.app.style, this.previousSize); this.elements.toggleMaxBtn.innerHTML = this.getRestoreIcon(); } this.isMaximized = !this.isMaximized; } toggleMinimize() { try { // 直接操作DOM元素 document.getElementById('article-summary-app').style.display = 'none'; document.getElementById('article-summary-icon').style.display = 'flex'; // 保存状态 this.configManager.setAppMinimized(true); console.log('应用已最小化,图标显示状态:', document.getElementById('article-summary-icon').style.display); } catch (error) { console.error('最小化过程中出错:', error); } } toggleApp() { try { // 直接操作DOM元素 document.getElementById('article-summary-app').style.display = 'flex'; document.getElementById('article-summary-icon').style.display = 'none'; // 保存状态 this.configManager.setAppMinimized(false); console.log('应用已恢复,图标显示状态:', document.getElementById('article-summary-icon').style.display); } catch (error) { console.error('恢复应用过程中出错:', error); } } // 工具方法 copyContent() { const outputFormat = document.querySelector('.format-btn.active').dataset.format; const summaryTextarea = document.getElementById('summaryTextarea'); let textToCopy = summaryTextarea.value; navigator.clipboard.writeText(textToCopy).then(() => { const originalHTML = this.elements.copyBtn.innerHTML; this.elements.copyBtn.innerHTML = this.getCopiedIcon(); setTimeout(() => { this.elements.copyBtn.innerHTML = originalHTML; }, 2000); }).catch(err => { console.error('复制失败:', err); alert('复制失败,请手动选择文本复制'); }); } // HTML模板方法 getAppHTML() { return ` <div id="summary-header"> <h3>文章总结助手</h3> <div id="summary-header-actions"> <button id="toggleMaxBtn" class="header-btn" data-tooltip="最大化"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"></path> </svg> </button> <button id="toggleMinBtn" class="header-btn" data-tooltip="最小化"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M20 12H4"></path> </svg> </button> </div> </div> <div id="summary-body"> <div id="config-section"> <div id="configToggle"> <span>配置选项</span> <svg class="icon toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M19 9l-7 7-7-7"></path> </svg> </div> <div id="configPanel"> <div class="form-group"> <label class="form-label" for="apiService">API服务</label> <select id="apiService" class="form-select"> <option value="ollama">Ollama (本地)</option> <option value="gptgod">GPT God</option> <option value="deepseek">DeepSeek</option> <option value="custom">自定义</option> </select> </div> <div id="apiUrlContainer" class="form-group"> <label class="form-label" for="apiUrl">API地址</label> <input type="text" id="apiUrl" class="form-input" placeholder="http://localhost:11434/api/chat"> </div> <div class="form-group" id="apiKeyContainer"> <label class="form-label" for="apiKey">API Key</label> <input type="password" id="apiKey" class="form-input" placeholder="sk-..."> </div> <div class="form-group"> <label class="form-label" for="modelName">模型</label> <select id="modelSelect" class="form-select"> <option value="llama2">llama2</option> <option value="llama2:13b">llama2:13b</option> <option value="llama2:70b">llama2:70b</option> <option value="mistral">mistral</option> <option value="mixtral">mixtral</option> <option value="gemma:2b">gemma:2b</option> <option value="gemma:7b">gemma:7b</option> <option value="qwen:14b">qwen:14b</option> <option value="qwen:72b">qwen:72b</option> <option value="phi3:mini">phi3:mini</option> <option value="phi3:small">phi3:small</option> <option value="phi3:medium">phi3:medium</option> <option value="yi:34b">yi:34b</option> <option value="vicuna:13b">vicuna:13b</option> <option value="vicuna:33b">vicuna:33b</option> <option value="codellama">codellama</option> <option value="wizardcoder">wizardcoder</option> <option value="nous-hermes2">nous-hermes2</option> <option value="neural-chat">neural-chat</option> <option value="openchat">openchat</option> <option value="dolphin-mixtral">dolphin-mixtral</option> <option value="starling-lm">starling-lm</option> </select> <input type="text" id="modelName" class="form-input" placeholder="模型名称"> </div> <div class="form-group"> <label class="form-label">输出格式</label> <div id="formatOptions"> <span class="format-btn active" data-format="markdown">Markdown</span> <span class="format-btn" data-format="bullet">要点列表</span> <span class="format-btn" data-format="paragraph">段落</span> </div> </div> </div> </div> <button type="button" id="generateBtn"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path> </svg> 生成总结 </button> <div id="summaryResult"> <div id="summaryHeader"> <h4>文章总结</h4> <div id="summaryActions"> <button id="copyBtn" class="action-btn" data-tooltip="复制到剪贴板"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"></path> </svg> 复制 </button> </div> </div> <div id="summaryContent" class="markdown-body"> <textarea id="summaryTextarea" class="content-textarea" placeholder="生成的总结将显示在这里..."></textarea> </div> </div> <div id="loadingIndicator"> <div class="spinner"></div> <p>正在生成总结,请稍候...</p> </div> <div class="resize-handle"></div> </div> `; } getIconHTML() { return ` <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M9 12h6m-6 4h6m2-10H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V8a2 2 0 00-2-2z"></path> </svg> `; } getMaximizeIcon() { return ` <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"></path> </svg> `; } getRestoreIcon() { return ` <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"></path> </svg> `; } getCopiedIcon() { return ` <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M5 13l4 4L19 7"></path> </svg> 已复制 `; } async fetchOllamaModels() { try { const models = await this.apiService.fetchOllamaModels(); if (models && models.length > 0) { // 清空现有选项 this.elements.modelSelect.innerHTML = ''; // 添加获取到的模型选项 models.forEach(model => { const option = document.createElement('option'); option.value = model; option.textContent = model; this.elements.modelSelect.appendChild(option); }); // 设置当前选中的模型 const configs = this.configManager.getConfigs(); const currentModel = configs.ollama.model; if (currentModel && models.includes(currentModel)) { this.elements.modelSelect.value = currentModel; } else if (models.includes('llama2')) { this.elements.modelSelect.value = 'llama2'; } else if (models.length > 0) { this.elements.modelSelect.value = models[0]; } console.log('成功获取 Ollama 模型列表:', models); } } catch (error) { console.error('获取 Ollama 模型列表失败:', error); } } } // 文章提取类 class ArticleExtractor { constructor() { this.selectors = [ '#js_content', '.RichText', '.article-content', '#article_content', '#cnblogs_post_body', 'article', '.article', '.post-content', '.content', '.entry-content', '.article-content', 'main', '#main', '.main' ]; this.removeSelectors = [ 'script', 'style', 'iframe', 'nav', 'header', 'footer', '.advertisement', '.ad', '.ads', '.social-share', '.related-posts', '.comments', '.comment', '.author-info', '.article-meta', '.article-info', '.article-header', '.article-footer', '#article-summary-app' ]; } async extract() { // 尝试使用不同的选择器获取内容 for (const selector of this.selectors) { const element = document.querySelector(selector); if (element) { const content = this.processElement(element); if (content.length > 100) { return content; } } } // 如果上述方法都失败,尝试获取整个页面的主要内容 const content = this.processElement(document.body); if (content.length < 100) { throw new Error('无法获取足够的文章内容'); } return content; } processElement(element) { const clone = element.cloneNode(true); this.removeUnwantedElements(clone); return this.cleanText(clone.innerText); } removeUnwantedElements(element) { this.removeSelectors.forEach(selector => { const elements = element.querySelectorAll(selector); elements.forEach(el => el.remove()); }); } cleanText(text) { return text .replace(/\s+/g, ' ') .replace(/\n\s*\n/g, '\n') .trim(); } } // API服务类 class APIService { constructor(configManager) { this.configManager = configManager; } async generateSummary(content) { const configs = this.configManager.getConfigs(); const apiService = this.configManager.getApiService(); const currentConfig = configs[apiService]; const outputFormat = this.configManager.getOutputFormat(); const apiEndpoint = this.getApiEndpoint(apiService, currentConfig); const systemPrompt = this.getSystemPrompt(outputFormat); const messages = this.createMessages(systemPrompt, content); return this.makeRequest(apiEndpoint, currentConfig, messages); } async fetchOllamaModels() { return new Promise((resolve, reject) => { const ollamaConfig = this.configManager.getConfigs().ollama; // 从 API URL 中提取基础 URL const baseUrl = ollamaConfig.url.split('/api/')[0] || 'http://localhost:11434'; const modelsEndpoint = `${baseUrl}/api/tags`; GM_xmlhttpRequest({ method: 'GET', url: modelsEndpoint, headers: { 'Content-Type': 'application/json' }, onload: (response) => { try { if (response.status >= 400) { console.warn('获取 Ollama 模型列表失败:', response.statusText); resolve([]); // 失败时返回空数组,使用默认模型列表 return; } const data = JSON.parse(response.responseText); if (data.models && Array.isArray(data.models)) { // 提取模型名称 const models = data.models.map(model => model.name); resolve(models); } else { console.warn('Ollama API 返回的模型列表格式异常:', data); resolve([]); } } catch (error) { console.error('解析 Ollama 模型列表失败:', error); resolve([]); } }, onerror: (error) => { console.error('获取 Ollama 模型列表请求失败:', error); resolve([]); // 失败时返回空数组,使用默认模型列表 } }); }); } getApiEndpoint(apiService, config) { return config.url; } getSystemPrompt(format) { const prompts = { markdown: "请用中文总结以下文章的主要内容,以标准Markdown格式输出,包括标题、小标题和要点。确保格式规范,便于阅读。", bullet: "请用中文总结以下文章的主要内容,以简洁的要点列表形式输出,每个要点前使用'- '标记。", paragraph: "请用中文总结以下文章的主要内容,以连贯的段落形式输出,突出文章的核心观点和结论。" }; return prompts[format] || "请用中文总结以下文章的主要内容,以简洁的方式列出重点。"; } createMessages(systemPrompt, content) { const apiService = this.configManager.getApiService(); if (apiService === 'ollama') { return [ { role: "system", content: systemPrompt }, { role: "user", content: content } ]; } else { return [ { role: "system", content: systemPrompt }, { role: "user", content: content } ]; } } makeRequest(endpoint, config, messages) { return new Promise((resolve, reject) => { const apiService = this.configManager.getApiService(); // 确保配置有效 if (!endpoint) { reject(new Error('API 地址无效')); return; } if (!config.model) { reject(new Error('模型名称无效')); return; } // 构建请求数据 const requestData = { model: config.model, messages: messages, stream: false }; // 构建请求头 const headers = { 'Content-Type': 'application/json' }; // 非 Ollama 服务需要 API Key if (apiService !== 'ollama' && config.key) { headers['Authorization'] = `Bearer ${config.key}`; } // 发送请求 GM_xmlhttpRequest({ method: 'POST', url: endpoint, headers: headers, data: JSON.stringify(requestData), onload: this.handleResponse.bind(this, resolve, reject, apiService), onerror: (error) => reject(new Error('网络请求失败: ' + (error.message || '未知错误'))) }); }); } handleResponse(resolve, reject, apiService, response) { try { // 检查响应是否为 HTML if (response.responseText.trim().startsWith('<')) { reject(new Error(`API返回了HTML而不是JSON (状态码: ${response.status})`)); return; } // 检查状态码 if (response.status >= 400) { try { const data = JSON.parse(response.responseText); reject(new Error(data.error?.message || `请求失败 (${response.status})`)); } catch (e) { reject(new Error(`请求失败 (${response.status}): ${response.responseText.substring(0, 100)}`)); } return; } // 解析响应数据 const data = JSON.parse(response.responseText); // 检查错误 if (data.error) { reject(new Error(data.error.message || '未知错误')); return; } // 根据不同的 API 服务提取内容 if (apiService === 'ollama' && data.message) { // Ollama API 响应格式 resolve(data.message.content); } else if (data.choices && data.choices.length > 0 && data.choices[0].message) { // OpenAI 兼容的 API 响应格式 resolve(data.choices[0].message.content); } else { // 未知的响应格式 console.warn('未知的 API 响应格式:', data); // 尝试从响应中提取可能的内容 if (data.content) { resolve(data.content); } else if (data.text) { resolve(data.text); } else if (data.result) { resolve(data.result); } else if (data.response) { resolve(data.response); } else if (data.output) { resolve(data.output); } else if (data.generated_text) { resolve(data.generated_text); } else { reject(new Error('API 返回格式异常,无法提取内容')); } } } catch (error) { reject(new Error(`解析API响应失败: ${error.message || '未知错误'}`)); } } } // 主应用类 class ArticleSummaryApp { constructor() { this.configManager = new ConfigManager(); this.uiManager = new UIManager(this.configManager); this.articleExtractor = new ArticleExtractor(); this.apiService = new APIService(this.configManager); this.version = '0.2.1'; // 更新版本号 } async init() { this.logScriptInfo(); await this.uiManager.init(); this.bindGenerateButton(); } logScriptInfo() { const styles = { title: 'font-size: 16px; font-weight: bold; color: #4CAF50;', subtitle: 'font-size: 14px; font-weight: bold; color: #2196F3;', normal: 'font-size: 12px; color: #333;', key: 'font-size: 12px; color: #E91E63;', value: 'font-size: 12px; color: #3F51B5;' }; console.log('%c网页文章总结助手', styles.title); console.log('%c基本信息', styles.subtitle); console.log(`%c版本:%c ${this.version}`, styles.key, styles.value); console.log(`%c作者:%c h7ml <[email protected]>`, styles.key, styles.value); console.log(`%c描述:%c 自动总结网页文章内容,支持多种格式输出,适用于各类文章网站`, styles.key, styles.value); console.log('%c支持的API服务', styles.subtitle); console.log(`%c- Ollama:%c 本地大语言模型服务,无需API Key`, styles.key, styles.normal); console.log(`%c- GPT God:%c 支持多种OpenAI模型`, styles.key, styles.normal); console.log(`%c- DeepSeek:%c 支持DeepSeek系列模型`, styles.key, styles.normal); console.log(`%c- 自定义:%c 支持任何兼容OpenAI API格式的服务`, styles.key, styles.normal); console.log('%c支持的功能', styles.subtitle); console.log(`%c- 自动提取:%c 智能提取网页文章内容`, styles.key, styles.normal); console.log(`%c- 多种格式:%c 支持Markdown、要点列表、段落等输出格式`, styles.key, styles.normal); console.log(`%c- 动态获取:%c 自动获取Ollama本地已安装模型列表`, styles.key, styles.normal); console.log(`%c- 界面定制:%c 支持拖拽、最小化、最大化等操作`, styles.key, styles.normal); console.log('%c当前配置', styles.subtitle); const configs = this.configManager.getConfigs(); const apiService = this.configManager.getApiService(); const currentConfig = configs[apiService] || {}; console.log(`%c当前API服务:%c ${apiService}`, styles.key, styles.value); console.log(`%c当前模型:%c ${currentConfig.model || '未设置'}`, styles.key, styles.value); console.log(`%c当前API地址:%c ${currentConfig.url || '未设置'}`, styles.key, styles.value); console.log(`%c输出格式:%c ${this.configManager.getOutputFormat()}`, styles.key, styles.value); console.log('%c使用提示', styles.subtitle); console.log(`%c- 点击右上角按钮可最小化或最大化界面`, styles.normal); console.log(`%c- 最小化后可通过右下角图标恢复界面`, styles.normal); console.log(`%c- 可拖动顶部标题栏移动位置`, styles.normal); console.log(`%c- 使用Ollama服务时会自动获取本地已安装模型`, styles.normal); } bindGenerateButton() { this.uiManager.elements.generateBtn.addEventListener('click', this.handleGenerate.bind(this)); } async handleGenerate() { const apiService = this.uiManager.elements.apiService.value; const apiKey = this.uiManager.elements.apiKey.value.trim(); const apiUrl = this.uiManager.elements.apiUrl.value.trim(); // 获取当前配置 const configs = this.configManager.getConfigs(); const currentConfig = configs[apiService] || { url: apiService === 'ollama' ? 'http://localhost:11434/api/chat' : '', model: apiService === 'ollama' ? 'llama2' : '', key: '' }; // 检查 API URL 是否有效 if (!apiUrl) { alert('请输入有效的 API 地址'); return; } // 检查 API Key(Ollama 不需要) if (apiService !== 'ollama' && !apiKey) { alert('请输入有效的 API Key'); return; } // 检查模型是否有效 const modelName = apiService === 'ollama' ? (this.uiManager.elements.modelSelect.value || 'llama2') : (this.uiManager.elements.modelName.value || ''); if (!modelName) { alert('请选择或输入有效的模型名称'); return; } this.showLoading(); try { const content = await this.articleExtractor.extract(); const summary = await this.apiService.generateSummary(content); this.displaySummary(summary); } catch (error) { this.handleError(error); } finally { this.hideLoading(); } } showLoading() { this.uiManager.elements.loadingIndicator.style.display = 'block'; this.uiManager.elements.generateBtn.disabled = true; this.uiManager.elements.generateBtn.innerHTML = ` <svg class="icon spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="12" cy="12" r="10" stroke-opacity="0.25" stroke-dasharray="30" stroke-dashoffset="0"></circle> <circle cx="12" cy="12" r="10" stroke-dasharray="30" stroke-dashoffset="15"></circle> </svg> 生成中... `; } hideLoading() { this.uiManager.elements.loadingIndicator.style.display = 'none'; this.uiManager.elements.generateBtn.disabled = false; this.uiManager.elements.generateBtn.innerHTML = ` <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path> </svg> 生成总结 `; } displaySummary(summary) { const outputFormat = this.configManager.getOutputFormat(); const summaryContent = this.uiManager.elements.summaryContent; const summaryTextarea = document.getElementById('summaryTextarea'); if (outputFormat === 'markdown') { summaryTextarea.value = summary; summaryContent.setAttribute('data-markdown', summary); } else { summaryTextarea.value = summary; } this.uiManager.elements.summaryResult.style.display = 'flex'; } handleError(error) { let errorMsg = error.message; if (errorMsg.includes('Authentication Fails') || errorMsg.includes('no such user')) { errorMsg = 'API Key 无效或已过期,请更新您的 API Key'; } else if (errorMsg.includes('rate limit')) { errorMsg = 'API 调用次数已达上限,请稍后再试'; } alert('生成总结失败:' + errorMsg); console.error('API 错误详情:', error); } simpleMarkdownRender(text) { let html = '<div class="summary-container">'; const content = text .replace(/^# (.*$)/gm, '<h1>$1</h1>') .replace(/^## (.*$)/gm, '<h2>$1</h2>') .replace(/^### (.*$)/gm, '<h3>$1</h3>') .replace(/^\d+\.\s+\*\*(.*?)\*\*:([\s\S]*?)(?=(?:\d+\.|$))/gm, (match, title, items) => { const listItems = items .split(/\n\s*-\s+/) .filter(item => item.trim()) .map(item => `<li>${item.trim()}</li>`) .join(''); return `<h2>${title}</h2><ul>${listItems}</ul>`; }) .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') .replace(/`([^`]+)`/g, '<code>$1</code>') .replace(/([^\n]+)(?:\n|$)/g, (match, p1) => { if (!p1.startsWith('<') && p1.trim()) { return `<p>${p1}</p>`; } return p1; }); html += content + '</div>'; return html; } } // 初始化应用 const app = new ArticleSummaryApp(); app.init(); })();