// ==UserScript==
// @name 网页文章总结助手
// @namespace http://tampermonkey.net/
// @version 0.2.0
// @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
// @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.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);
}
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);
}
}
// 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;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 999999;
display: flex;
flex-direction: column;
}
#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;
}
#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;
}
.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;
}
`);
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();
}
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));
});
}
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 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;
let textToCopy = outputFormat === 'markdown'
? this.elements.summaryContent.getAttribute('data-markdown') || this.elements.summaryContent.textContent
: this.elements.summaryContent.textContent;
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 class="content-textarea resizable"></textarea>
</div>
</div>
<div id="loadingIndicator">
<div class="spinner"></div>
<p>正在生成总结,请稍候...</p>
</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;
if (outputFormat === 'markdown') {
summaryContent.setAttribute('data-markdown', summary);
summaryContent.innerHTML = this.simpleMarkdownRender(summary);
} else {
summaryContent.innerHTML = this.simpleMarkdownRender(summary);
}
this.uiManager.elements.summaryResult.style.display = 'block';
}
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();
})();