// ==UserScript==
// @name 网页内容摘要器
// @namespace http://tampermonkey.net/
// @version 0.1
// @description 使用OpenAI或Google Gemini API快速总结网页内容
// @author Your Name
// @match *://*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @run-at document-end
// @license MIT
// @require https://cdn.jsdelivr.net/npm/[email protected]/marked.min.js
// ==/UserScript==
(function() {
'use strict';
// 常量定义
const DEFAULT_OPENAI_URL = "https://api.openai.com/v1/chat/completions";
const DEFAULT_GEMINI_URL = "https://generativelanguage.googleapis.com";
const DEFAULT_HOTKEY = "s";
const HOTKEY_MODIFIER = "alt";
const DEFAULT_WIDTH = 500; // 默认宽度
const DEFAULT_HEIGHT = 600; // 默认高度
const MIN_WIDTH = 300; // 最小宽度
const MIN_HEIGHT = 200; // 最小高度
// 默认配置
const DEFAULT_CONFIGS = {
apis: [
{
id: "openai-default",
name: "OpenAI默认",
type: "openai",
baseUrl: DEFAULT_OPENAI_URL,
apiKey: "",
models: ["gpt-3.5-turbo", "gpt-4", "gpt-4-turbo"]
},
{
id: "gemini-default",
name: "Gemini默认",
type: "gemini",
baseUrl: DEFAULT_GEMINI_URL,
apiKey: "",
models: ["gemini-pro", "gemini-1.5-pro", "gemini-1.5-flash"]
}
],
prompts: [
{
id: "summary-default",
name: "一般摘要",
content: "请用中文总结以下网页内容的要点,用简洁的语言描述主要信息。请使用Markdown格式输出,以提高可读性。",
apiType: "openai",
apiId: "openai-default",
model: "gpt-3.5-turbo"
},
{
id: "detailed-summary",
name: "详细摘要",
content: "请详细分析以下网页内容,提供全面的中文摘要,包括主要观点、关键数据和结论。使用Markdown格式输出,合理使用标题、列表、引用等元素增强可读性。",
apiType: "openai",
apiId: "openai-default",
model: "gpt-3.5-turbo"
},
{
id: "structured-summary",
name: "结构化摘要",
content: "请以结构化的方式分析并总结以下网页内容。使用Markdown语法,创建包含以下部分的摘要:\n\n1. **主要内容**: 用1-2段话概述主要内容\n2. **关键点**: 使用项目符号列出3-5个最重要的观点\n3. **细节与数据**: 提取文章中的重要数据和具体细节\n4. **结论**: 总结文章的结论或观点\n\n确保使用合适的Markdown标题、列表、强调和引用格式。",
apiType: "openai",
apiId: "openai-default",
model: "gpt-3.5-turbo"
},
{
id: "gemini-summary",
name: "Gemini摘要",
content: "请用中文总结以下网页内容的要点,用简洁的语言描述主要信息。请使用Markdown格式输出,以提高可读性。",
apiType: "gemini",
apiId: "gemini-default",
model: "gemini-pro"
}
],
settings: {
hotkey: DEFAULT_HOTKEY,
autoExpand: false,
lastUsedPromptId: "summary-default" // 记录上次使用的提示词ID
},
position: null,
size: {
width: DEFAULT_WIDTH
}
};
// 状态变量
let configs = GM_getValue("summarizer_configs", DEFAULT_CONFIGS);
let isDragging = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
let summarizer = null;
let isProcessing = false;
let currentRequest = null; // 保存当前请求的引用
// 添加CSS样式
function addStyles() {
GM_addStyle(`
#web-summarizer {
position: fixed;
top: 0;
right: 0;
width: ${DEFAULT_WIDTH}px;
height: 100vh;
background-color: #fff;
border-left: 1px solid #ccc;
font-family: Arial, sans-serif;
z-index: 10000;
display: none;
overflow: hidden;
transition: none;
display: flex;
flex-direction: column;
min-width: ${MIN_WIDTH}px;
resize: none;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1);
}
#summarizer-header {
padding: 8px 10px;
background-color: #f5f5f5;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
flex-shrink: 0;
}
#summarizer-resize-handle {
position: absolute;
top: 0;
left: 0;
width: 5px;
height: 100%;
cursor: ew-resize;
background-color: transparent;
z-index: 10002;
}
#summarizer-resize-handle:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.resizing #summarizer-resize-handle {
background-color: rgba(66, 133, 244, 0.2);
}
#summarizer-title {
font-weight: bold;
font-size: 14px;
margin: 0;
}
#summarizer-close {
background: none;
border: none;
font-size: 16px;
cursor: pointer;
padding: 0 5px;
}
#summarizer-body {
padding: 15px;
overflow-y: auto;
flex-grow: 1;
}
#summarizer-controls {
margin-bottom: 15px;
display: flex;
flex-direction: column;
}
#prompt-select {
width: 100%;
padding: 8px;
margin-bottom: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
.summarizer-btn {
padding: 8px 12px;
background-color: #4285f4;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin-right: 8px;
font-size: 13px;
}
.button-group {
display: flex;
gap: 8px;
}
.summarizer-btn:hover {
background-color: #3367d6;
}
.summarizer-btn:disabled {
background-color: #b3cefb;
cursor: not-allowed;
}
#summarizer-result {
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
background-color: #f9f9f9;
font-size: 14px;
line-height: 1.6;
max-height: none;
height: auto;
flex-grow: 1;
overflow-y: auto;
word-wrap: break-word;
display: none;
margin-top: 15px;
}
/* Markdown 样式 */
#summarizer-result h1,
#summarizer-result h2,
#summarizer-result h3,
#summarizer-result h4,
#summarizer-result h5,
#summarizer-result h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
line-height: 1.2;
font-weight: 600;
}
#summarizer-result h1 {
font-size: 1.8em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
}
#summarizer-result h2 {
font-size: 1.5em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
}
#summarizer-result h3 {
font-size: 1.3em;
}
#summarizer-result h4 {
font-size: 1.1em;
}
#summarizer-result p {
margin-top: 0.5em;
margin-bottom: 1em;
}
#summarizer-result ul,
#summarizer-result ol {
margin-top: 0.5em;
margin-bottom: 1em;
padding-left: 2em;
}
#summarizer-result li {
margin: 0.3em 0;
}
#summarizer-result code {
background-color: rgba(27, 31, 35, 0.05);
border-radius: 3px;
font-family: monospace;
padding: 0.2em 0.4em;
font-size: 0.9em;
}
#summarizer-result pre {
background-color: #f6f8fa;
border-radius: 3px;
padding: 1em;
overflow: auto;
margin: 1em 0;
}
#summarizer-result pre code {
background-color: transparent;
padding: 0;
white-space: pre;
}
#summarizer-result blockquote {
margin: 1em 0;
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
}
#summarizer-result table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
#summarizer-result table th,
#summarizer-result table td {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
#summarizer-result table tr {
background-color: #fff;
border-top: 1px solid #c6cbd1;
}
#summarizer-result table tr:nth-child(2n) {
background-color: #f6f8fa;
}
#summarizer-result img {
max-width: 100%;
}
#summarizer-result a {
color: #0366d6;
text-decoration: none;
}
#summarizer-result a:hover {
text-decoration: underline;
}
#summarizer-result hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: #e1e4e8;
border: 0;
}
/* 模态设置窗口样式 */
#settings-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 10001;
display: none;
justify-content: center;
align-items: center;
}
#settings-container {
background-color: #fff;
border-radius: 5px;
box-shadow: 0 2px 15px rgba(0, 0, 0, 0.3);
width: 500px;
max-width: 90%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
#settings-header {
padding: 10px 15px;
background-color: #f5f5f5;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
}
#settings-title {
font-weight: bold;
font-size: 16px;
margin: 0;
}
#settings-close {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
padding: 0 5px;
}
#settings-body {
padding: 15px;
overflow-y: auto;
max-height: 70vh;
flex-grow: 1;
}
#settings-footer {
padding: 10px 15px;
background-color: #f5f5f5;
border-top: 1px solid #ddd;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.settings-tabs {
display: flex;
border-bottom: 1px solid #ddd;
margin-bottom: 15px;
}
.settings-tab {
padding: 8px 12px;
cursor: pointer;
background-color: #f5f5f5;
border: 1px solid #ddd;
border-bottom: none;
margin-right: 5px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.settings-tab.active {
background-color: #fff;
border-bottom: 1px solid #fff;
margin-bottom: -1px;
}
.settings-panel {
display: none;
max-height: 350px;
overflow-y: auto;
}
.settings-panel.active {
display: block;
}
.config-item {
margin-bottom: 15px;
border: 1px solid #ddd;
border-radius: 4px;
}
.config-header {
background-color: #f5f5f5;
padding: 8px 10px;
cursor: pointer;
border-bottom: 1px solid #ddd;
}
.config-body {
padding: 10px;
display: none;
}
.config-body.expanded {
display: block;
}
.dragging {
opacity: 0.9;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
.form-group {
margin-bottom: 10px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input, .form-group textarea, .form-group select {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
.form-group textarea {
min-height: 80px;
resize: vertical;
}
#add-api-btn, #add-prompt-btn {
margin-bottom: 15px;
}
.model-list {
border: 1px solid #ddd;
border-radius: 4px;
padding: 5px;
max-height: 100px;
overflow-y: auto;
margin-top: 5px;
}
.model-item {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.delete-btn {
background-color: #f44336;
color: white;
}
.delete-btn:hover {
background-color: #d32f2f;
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top-color: #4285f4;
animation: spin 1s ease infinite;
margin-right: 10px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
#stop-btn {
background-color: #f44336;
}
#stop-btn:hover {
background-color: #d32f2f;
}
`);
}
// 创建UI界面
function createUI() {
try {
// 创建主容器
summarizer = document.createElement('div');
summarizer.id = 'web-summarizer';
// 设置初始状态为隐藏
summarizer.style.display = 'none';
// 如果有保存的大小,应用它
if (configs.size && configs.size.width) {
summarizer.style.width = configs.size.width + 'px';
}
// 创建调整大小的手柄
const resizeHandle = document.createElement('div');
resizeHandle.id = 'summarizer-resize-handle';
// 创建头部
const header = document.createElement('div');
header.id = 'summarizer-header';
header.innerHTML = `
<div id="summarizer-title">网页内容摘要器</div>
<button id="summarizer-close">×</button>
`;
// 创建主体内容
const body = document.createElement('div');
body.id = 'summarizer-body';
// 创建控制区域
const controls = document.createElement('div');
controls.id = 'summarizer-controls';
// 创建提示词选择下拉框
const promptSelect = document.createElement('select');
promptSelect.id = 'prompt-select';
configs.prompts.forEach(prompt => {
const option = document.createElement('option');
option.value = prompt.id;
option.textContent = prompt.name;
// 如果是上次使用的提示词,则默认选中
if (prompt.id === configs.settings.lastUsedPromptId) {
option.selected = true;
}
promptSelect.appendChild(option);
});
// 创建按钮
const buttonGroup = document.createElement('div');
buttonGroup.className = 'button-group';
buttonGroup.innerHTML = `
<button id="summarize-btn" class="summarizer-btn">摘要</button>
<button id="settings-btn" class="summarizer-btn">设置</button>
`;
controls.appendChild(promptSelect);
controls.appendChild(buttonGroup);
// 创建结果区域
const result = document.createElement('div');
result.id = 'summarizer-result';
// 组装主界面
body.appendChild(controls);
body.appendChild(result);
summarizer.appendChild(resizeHandle);
summarizer.appendChild(header);
summarizer.appendChild(body);
// 创建设置模态窗口
const settingsModal = document.createElement('div');
settingsModal.id = 'settings-modal';
const settingsContainer = document.createElement('div');
settingsContainer.id = 'settings-container';
const settingsHeader = document.createElement('div');
settingsHeader.id = 'settings-header';
settingsHeader.innerHTML = `
<div id="settings-title">设置</div>
<button id="settings-close">×</button>
`;
const settingsBody = document.createElement('div');
settingsBody.id = 'settings-body';
settingsBody.innerHTML = `
<div class="settings-tabs">
<div class="settings-tab active" data-tab="api-settings">API配置</div>
<div class="settings-tab" data-tab="prompt-settings">提示词配置</div>
<div class="settings-tab" data-tab="general-settings">常规设置</div>
</div>
<div id="api-settings" class="settings-panel active">
<div id="api-list"></div>
<button id="add-api-btn" class="summarizer-btn">添加新API配置</button>
</div>
<div id="prompt-settings" class="settings-panel">
<div id="prompt-list"></div>
<button id="add-prompt-btn" class="summarizer-btn">添加新提示词</button>
</div>
<div id="general-settings" class="settings-panel">
<div class="form-group">
<label for="hotkey-input">快捷键:</label>
<input type="text" id="hotkey-input" maxlength="1" value="${configs.settings.hotkey}">
<small>单个字母或数字,与Alt键组合使用</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="auto-expand" ${configs.settings.autoExpand ? 'checked' : ''}>
自动展开设置项
</label>
</div>
</div>
`;
const settingsFooter = document.createElement('div');
settingsFooter.id = 'settings-footer';
settingsFooter.innerHTML = `
<button id="save-settings-btn" class="summarizer-btn">保存设置</button>
<button id="cancel-settings-btn" class="summarizer-btn">取消</button>
`;
// 组装设置模态窗口
settingsContainer.appendChild(settingsHeader);
settingsContainer.appendChild(settingsBody);
settingsContainer.appendChild(settingsFooter);
settingsModal.appendChild(settingsContainer);
// 添加到页面
document.body.appendChild(summarizer);
document.body.appendChild(settingsModal);
// 初始化API和提示词列表
updateApiList();
updatePromptList();
// 绑定事件
bindAllEvents();
console.log("[网页内容摘要器] UI创建完成");
} catch (error) {
console.error("[网页内容摘要器] 创建UI失败:", error);
}
}
// 更新API配置列表
function updateApiList() {
const apiList = document.getElementById('api-list');
if (!apiList) return;
apiList.innerHTML = '';
configs.apis.forEach(api => {
const apiItem = document.createElement('div');
apiItem.className = 'config-item';
apiItem.innerHTML = `
<div class="config-header" data-id="${api.id}">
${api.name} (${api.type === 'openai' ? 'OpenAI兼容' : 'Gemini'})
</div>
<div class="config-body" id="api-${api.id}">
<div class="form-group">
<label>API名称:</label>
<input type="text" class="api-name" value="${api.name}">
</div>
<div class="form-group">
<label>API类型:</label>
<select class="api-type">
<option value="openai" ${api.type === 'openai' ? 'selected' : ''}>OpenAI兼容</option>
<option value="gemini" ${api.type === 'gemini' ? 'selected' : ''}>Gemini</option>
</select>
</div>
<div class="form-group base-url-group" ${api.type === 'gemini' ? 'style="display: none;"' : ''}>
<label>基础URL:</label>
<input type="text" class="api-base-url" value="${api.baseUrl}" ${api.type === 'gemini' ? 'readonly' : ''}>
<small>例如: https://api.openai.com/v1/chat/completions</small>
</div>
<div class="form-group">
<label>API密钥:</label>
<input type="password" class="api-key" value="${api.apiKey}">
</div>
<div class="form-group">
<label>可用模型:</label>
<small>每行输入一个模型名称</small>
<textarea class="api-models">${api.models.join('\n')}</textarea>
</div>
<div class="button-group">
<button class="summarizer-btn test-api-btn">测试API连通性</button>
<button class="summarizer-btn delete-btn delete-api-btn">删除</button>
</div>
</div>
`;
apiList.appendChild(apiItem);
});
// 重新绑定配置项展开/折叠事件
bindConfigHeaderEvents();
}
// 更新提示词列表
function updatePromptList() {
const promptList = document.getElementById('prompt-list');
if (!promptList) return;
promptList.innerHTML = '';
configs.prompts.forEach(prompt => {
const promptItem = document.createElement('div');
promptItem.className = 'config-item';
// 获取关联的API
const api = configs.apis.find(a => a.id === prompt.apiId);
promptItem.innerHTML = `
<div class="config-header" data-id="${prompt.id}">
${prompt.name} (${prompt.apiType === 'openai' ? 'OpenAI' : 'Gemini'})
</div>
<div class="config-body" id="prompt-${prompt.id}">
<div class="form-group">
<label>提示词名称:</label>
<input type="text" class="prompt-name" value="${prompt.name}">
</div>
<div class="form-group">
<label>提示词内容:</label>
<textarea class="prompt-content">${prompt.content}</textarea>
</div>
<div class="form-group">
<label>API类型:</label>
<select class="prompt-api-type">
<option value="openai" ${prompt.apiType === 'openai' ? 'selected' : ''}>OpenAI兼容</option>
<option value="gemini" ${prompt.apiType === 'gemini' ? 'selected' : ''}>Gemini</option>
</select>
</div>
<div class="form-group prompt-api-select-group">
<label>选择API配置:</label>
<select class="prompt-api-id">
${getApiOptionsHtml(prompt.apiType, prompt.apiId)}
</select>
</div>
<div class="form-group prompt-model-select-group">
<label>选择模型:</label>
<select class="prompt-model">
${getModelOptionsHtml(prompt.apiId, prompt.model)}
</select>
</div>
<div class="button-group">
<button class="summarizer-btn delete-btn delete-prompt-btn">删除</button>
</div>
</div>
`;
promptList.appendChild(promptItem);
});
// 重新绑定配置项展开/折叠事件
bindConfigHeaderEvents();
}
// 生成API选项HTML
function getApiOptionsHtml(apiType, selectedApiId) {
return configs.apis
.filter(api => api.type === apiType)
.map(api => `<option value="${api.id}" ${api.id === selectedApiId ? 'selected' : ''}>${api.name}</option>`)
.join('');
}
// 生成模型选项HTML
function getModelOptionsHtml(apiId, selectedModel) {
const api = configs.apis.find(a => a.id === apiId);
if (!api) return '';
return api.models
.map(model => `<option value="${model}" ${model === selectedModel ? 'selected' : ''}>${model}</option>`)
.join('');
}
// 绑定事件
function bindAllEvents() {
try {
// 移除可能的旧事件监听器
const elementsToRebind = [
'summarizer-close', 'summarizer-header', 'summarize-btn',
'settings-btn', 'add-api-btn', 'add-prompt-btn',
'save-settings-btn', 'cancel-settings-btn'
];
elementsToRebind.forEach(id => {
const el = document.getElementById(id);
if (el) {
const newEl = el.cloneNode(true);
el.parentNode.replaceChild(newEl, el);
}
});
// 解绑旧的全局事件
document.removeEventListener('change', handleDynamicChangeEvents);
document.removeEventListener('click', handleDynamicClickEvents);
document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', stopDrag);
// 重新绑定所有事件
bindEvents();
} catch (error) {
console.error("[网页内容摘要器] 重新绑定事件失败:", error);
}
}
// 绑定事件
function bindEvents() {
try {
// 关闭按钮
const closeBtn = document.getElementById('summarizer-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
summarizer.style.display = 'none';
});
}
// 拖拽功能
const header = document.getElementById('summarizer-header');
if (header) {
header.addEventListener('mousedown', startDrag);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', stopDrag);
}
// 调整大小功能
const resizeHandle = document.getElementById('summarizer-resize-handle');
if (resizeHandle) {
resizeHandle.addEventListener('mousedown', startResize);
// 监听器在startResize中动态添加
}
// 摘要按钮
const summarizeBtn = document.getElementById('summarize-btn');
if (summarizeBtn) {
summarizeBtn.addEventListener('click', generateSummary);
}
// 设置按钮
const settingsBtn = document.getElementById('settings-btn');
if (settingsBtn) {
settingsBtn.addEventListener('click', () => {
const settingsModal = document.getElementById('settings-modal');
if (settingsModal) {
settingsModal.style.display = 'flex';
}
});
}
// 设置模态窗口关闭按钮
const settingsCloseBtn = document.getElementById('settings-close');
if (settingsCloseBtn) {
settingsCloseBtn.addEventListener('click', () => {
const settingsModal = document.getElementById('settings-modal');
if (settingsModal) {
settingsModal.style.display = 'none';
}
});
}
// 点击模态窗口背景关闭
const settingsModal = document.getElementById('settings-modal');
if (settingsModal) {
settingsModal.addEventListener('click', (e) => {
if (e.target === settingsModal) {
settingsModal.style.display = 'none';
}
});
}
// 设置页签切换
document.querySelectorAll('.settings-tab').forEach(tab => {
tab.addEventListener('click', function() {
// 激活选中的标签
document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
this.classList.add('active');
// 显示对应的面板
const tabId = this.getAttribute('data-tab');
const targetPanel = document.getElementById(tabId);
if (targetPanel) {
document.querySelectorAll('.settings-panel').forEach(panel => {
panel.classList.remove('active');
});
targetPanel.classList.add('active');
}
});
});
// 绑定配置项展开/折叠事件
bindConfigHeaderEvents();
// 添加新API按钮
const addApiBtn = document.getElementById('add-api-btn');
if (addApiBtn) {
addApiBtn.addEventListener('click', addNewApiConfig);
}
// 添加新提示词按钮
const addPromptBtn = document.getElementById('add-prompt-btn');
if (addPromptBtn) {
addPromptBtn.addEventListener('click', addNewPrompt);
}
// 保存设置按钮
const saveSettingsBtn = document.getElementById('save-settings-btn');
if (saveSettingsBtn) {
saveSettingsBtn.addEventListener('click', saveSettings);
}
// 取消按钮
const cancelSettingsBtn = document.getElementById('cancel-settings-btn');
if (cancelSettingsBtn) {
cancelSettingsBtn.addEventListener('click', () => {
const settingsModal = document.getElementById('settings-modal');
if (settingsModal) {
settingsModal.style.display = 'none';
updateApiList();
updatePromptList();
}
});
}
// 使用事件委托处理动态元素的事件
document.addEventListener('change', handleDynamicChangeEvents);
document.addEventListener('click', handleDynamicClickEvents);
console.log("[网页内容摘要器] 事件绑定完成");
} catch (error) {
console.error("[网页内容摘要器] 绑定事件失败:", error);
}
}
// 处理动态元素的change事件
function handleDynamicChangeEvents(e) {
try {
if (e.target && e.target.classList.contains('api-type')) {
const type = e.target.value;
const container = e.target.closest('.config-body');
const baseUrlGroup = container.querySelector('.base-url-group');
const baseUrlInput = container.querySelector('.api-base-url');
if (type === 'gemini') {
baseUrlGroup.style.display = 'none';
baseUrlInput.value = DEFAULT_GEMINI_URL;
baseUrlInput.setAttribute('readonly', 'readonly');
} else {
baseUrlGroup.style.display = 'block';
baseUrlInput.removeAttribute('readonly');
}
}
if (e.target && e.target.classList.contains('prompt-api-type')) {
const type = e.target.value;
const container = e.target.closest('.config-body');
const apiSelect = container.querySelector('.prompt-api-id');
apiSelect.innerHTML = getApiOptionsHtml(type, '');
updateModelOptions(apiSelect);
}
if (e.target && e.target.classList.contains('prompt-api-id')) {
updateModelOptions(e.target);
}
} catch (error) {
console.error("[网页内容摘要器] 处理动态change事件失败:", error);
}
}
// 处理动态元素的click事件
function handleDynamicClickEvents(e) {
try {
if (e.target && e.target.classList.contains('delete-api-btn')) {
const container = e.target.closest('.config-item');
const id = container.querySelector('.config-header').getAttribute('data-id');
if (confirm('确定要删除这个API配置吗?这可能会影响依赖它的提示词。')) {
deleteApiConfig(id);
}
}
if (e.target && e.target.classList.contains('delete-prompt-btn')) {
const container = e.target.closest('.config-item');
const id = container.querySelector('.config-header').getAttribute('data-id');
if (confirm('确定要删除这个提示词吗?')) {
deletePrompt(id);
}
}
if (e.target && e.target.classList.contains('test-api-btn')) {
const container = e.target.closest('.config-body');
const apiType = container.querySelector('.api-type').value;
const baseUrl = container.querySelector('.api-base-url').value;
const apiKey = container.querySelector('.api-key').value;
const models = container.querySelector('.api-models').value.split('\n').filter(m => m.trim());
testApiConnection(apiType, baseUrl, apiKey, models[0] || (apiType === 'openai' ? 'gpt-3.5-turbo' : 'gemini-pro'));
}
} catch (error) {
console.error("[网页内容摘要器] 处理动态click事件失败:", error);
}
}
// 开始拖拽
function startDrag(e) {
if (e.target.id === 'summarizer-header' || e.target.id === 'summarizer-title') {
isDragging = true;
dragOffsetX = e.clientX - summarizer.getBoundingClientRect().left;
dragOffsetY = e.clientY - summarizer.getBoundingClientRect().top;
e.preventDefault();
// 添加拖拽中的样式
summarizer.classList.add('dragging');
document.body.style.userSelect = 'none';
}
}
// 拖拽中
function drag(e) {
if (isDragging) {
const newLeft = e.clientX - dragOffsetX;
const newTop = e.clientY - dragOffsetY;
// 限制在视窗范围内
const maxLeft = window.innerWidth - summarizer.offsetWidth;
const maxTop = window.innerHeight - summarizer.offsetHeight;
const limitedLeft = Math.max(0, Math.min(newLeft, maxLeft));
const limitedTop = Math.max(0, Math.min(newTop, maxTop));
summarizer.style.left = limitedLeft + 'px';
summarizer.style.top = limitedTop + 'px';
summarizer.style.right = 'auto';
e.preventDefault();
}
}
// 停止拖拽
function stopDrag() {
if (isDragging) {
isDragging = false;
// 移除拖拽中的样式
summarizer.classList.remove('dragging');
document.body.style.userSelect = '';
// 保存位置到配置中
configs.position = {
left: summarizer.style.left,
top: summarizer.style.top
};
GM_setValue("summarizer_configs", configs);
}
}
// 更新模型选项
function updateModelOptions(apiSelect) {
const apiId = apiSelect.value;
const container = apiSelect.closest('.config-body');
const modelSelect = container.querySelector('.prompt-model');
modelSelect.innerHTML = getModelOptionsHtml(apiId, '');
}
// 添加新API配置
function addNewApiConfig() {
const newId = 'api-' + Date.now();
const newApi = {
id: newId,
name: "新API配置",
type: "openai",
baseUrl: DEFAULT_OPENAI_URL,
apiKey: "",
models: ["gpt-3.5-turbo"]
};
configs.apis.push(newApi);
updateApiList();
// 重新绑定所有事件
bindAllEvents();
// 展开新创建的配置项
setTimeout(() => {
const newHeader = document.querySelector(`.config-header[data-id="${newId}"]`);
if (newHeader) {
newHeader.click();
}
}, 100);
}
// 添加新提示词
function addNewPrompt() {
const newId = 'prompt-' + Date.now();
const defaultApi = configs.apis.find(a => a.type === 'openai') || configs.apis[0];
const newPrompt = {
id: newId,
name: "新提示词",
content: "请总结以下网页内容:",
apiType: defaultApi ? defaultApi.type : "openai",
apiId: defaultApi ? defaultApi.id : "",
model: defaultApi && defaultApi.models.length > 0 ? defaultApi.models[0] : ""
};
configs.prompts.push(newPrompt);
updatePromptList();
updatePromptSelect();
// 重新绑定所有事件
bindAllEvents();
// 展开新创建的配置项
setTimeout(() => {
const newHeader = document.querySelector(`.config-header[data-id="${newId}"]`);
if (newHeader) {
newHeader.click();
}
}, 100);
}
// 删除API配置
function deleteApiConfig(id) {
configs.apis = configs.apis.filter(api => api.id !== id);
// 更新依赖此API的提示词
configs.prompts.forEach(prompt => {
if (prompt.apiId === id) {
const newApi = configs.apis.find(a => a.type === prompt.apiType);
if (newApi) {
prompt.apiId = newApi.id;
prompt.model = newApi.models.length > 0 ? newApi.models[0] : "";
}
}
});
updateApiList();
updatePromptList();
updatePromptSelect();
// 重新绑定所有事件
bindAllEvents();
}
// 删除提示词
function deletePrompt(id) {
configs.prompts = configs.prompts.filter(prompt => prompt.id !== id);
updatePromptList();
updatePromptSelect();
// 重新绑定所有事件
bindAllEvents();
}
// 保存设置
function saveSettings() {
try {
// 保存API配置
const newApis = [];
document.querySelectorAll('#api-list .config-item').forEach(item => {
const header = item.querySelector('.config-header');
const body = item.querySelector('.config-body');
const id = header.getAttribute('data-id');
newApis.push({
id: id,
name: body.querySelector('.api-name').value,
type: body.querySelector('.api-type').value,
baseUrl: body.querySelector('.api-base-url').value,
apiKey: body.querySelector('.api-key').value,
models: body.querySelector('.api-models').value.split('\n').filter(m => m.trim())
});
});
// 保存提示词配置
const newPrompts = [];
document.querySelectorAll('#prompt-list .config-item').forEach(item => {
const header = item.querySelector('.config-header');
const body = item.querySelector('.config-body');
const id = header.getAttribute('data-id');
newPrompts.push({
id: id,
name: body.querySelector('.prompt-name').value,
content: body.querySelector('.prompt-content').value,
apiType: body.querySelector('.prompt-api-type').value,
apiId: body.querySelector('.prompt-api-id').value,
model: body.querySelector('.prompt-model').value
});
});
// 保存常规设置
const hotkey = document.getElementById('hotkey-input').value;
const autoExpand = document.getElementById('auto-expand').checked;
// 保存位置信息
const position = configs.position;
// 更新配置
configs.apis = newApis;
configs.prompts = newPrompts;
configs.settings.hotkey = hotkey || DEFAULT_HOTKEY;
configs.settings.autoExpand = autoExpand;
configs.settings.lastUsedPromptId = document.getElementById('prompt-select').value || configs.settings.lastUsedPromptId;
configs.position = position;
// 保存到GM存储
GM_setValue("summarizer_configs", configs);
// 更新UI
const settingsModal = document.getElementById('settings-modal');
if (settingsModal) {
settingsModal.style.display = 'none';
}
updateApiList();
updatePromptList();
// 更新选择框
updatePromptSelect();
// 更新热键监听
document.removeEventListener('keydown', hotkeyHandler);
addHotkeyListener();
// 重新绑定所有事件
bindAllEvents();
alert('设置已保存');
} catch (error) {
console.error("[网页内容摘要器] 保存设置失败:", error);
alert('保存设置失败: ' + error.message);
}
}
// 更新提示词选择下拉框
function updatePromptSelect() {
const promptSelect = document.getElementById('prompt-select');
if (!promptSelect) return;
// 保存当前选中的提示词ID
const selectedPromptId = promptSelect.value || configs.settings.lastUsedPromptId;
promptSelect.innerHTML = '';
configs.prompts.forEach(prompt => {
const option = document.createElement('option');
option.value = prompt.id;
option.textContent = prompt.name;
// 恢复选中状态
if (prompt.id === selectedPromptId) {
option.selected = true;
}
promptSelect.appendChild(option);
});
}
// 测试API连接
function testApiConnection(apiType, baseUrl, apiKey, model) {
if (!apiKey) {
alert('请先输入API密钥');
return;
}
if (apiType === 'openai') {
// 测试OpenAI兼容API
GM_xmlhttpRequest({
method: 'POST',
url: baseUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
data: JSON.stringify({
model: model,
messages: [
{
role: 'user',
content: 'Hello, this is a test message. Please respond with "API connection successful".'
}
],
max_tokens: 50
}),
onload: function(response) {
try {
const data = JSON.parse(response.responseText);
if (data.choices && data.choices.length > 0) {
alert('API连接成功!');
} else {
alert('API响应格式不正确: ' + response.responseText);
}
} catch (e) {
alert('API响应解析失败: ' + e.message);
}
},
onerror: function(response) {
alert('API连接失败: ' + response.statusText);
}
});
} else if (apiType === 'gemini') {
// 测试Gemini API
const url = `${baseUrl}/v1beta/models/${model}:generateContent?key=${apiKey}`;
GM_xmlhttpRequest({
method: 'POST',
url: url,
headers: {
'Content-Type': 'application/json'
},
data: JSON.stringify({
contents: [
{
parts: [
{
text: 'Hello, this is a test message. Please respond with "API connection successful".'
}
]
}
],
generationConfig: {
maxOutputTokens: 50
}
}),
onload: function(response) {
try {
const data = JSON.parse(response.responseText);
if (data.candidates && data.candidates.length > 0) {
alert('API连接成功!');
} else {
alert('API响应格式不正确: ' + response.responseText);
}
} catch (e) {
alert('API响应解析失败: ' + e.message);
}
},
onerror: function(response) {
alert('API连接失败: ' + response.statusText);
}
});
}
}
// 生成摘要
function generateSummary() {
// 如果当前正在处理,则表示这是停止操作
if (isProcessing) {
stopSummary();
return;
}
// 获取选中的提示词
const promptId = document.getElementById('prompt-select').value;
const prompt = configs.prompts.find(p => p.id === promptId);
if (!prompt) {
alert('请先选择或创建一个提示词');
return;
}
// 保存当前使用的提示词ID
configs.settings.lastUsedPromptId = promptId;
GM_setValue("summarizer_configs", configs);
// 获取关联的API配置
const api = configs.apis.find(a => a.id === prompt.apiId);
if (!api) {
alert('提示词关联的API配置不存在,请检查设置');
return;
}
if (!api.apiKey) {
alert('请先设置API密钥');
return;
}
// 获取网页内容
const pageContent = getPageContent();
if (!pageContent) {
alert('无法提取页面内容');
return;
}
// 显示处理中状态
const summarizeBtn = document.getElementById('summarize-btn');
const resultDiv = document.getElementById('summarizer-result');
// 将摘要按钮改为停止按钮
summarizeBtn.disabled = false;
summarizeBtn.id = 'stop-btn';
summarizeBtn.innerHTML = '<span class="spinner"></span>停止摘要';
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<div style="text-align: center; padding: 20px;"><span class="spinner"></span> 正在生成摘要,请稍候...</div>';
isProcessing = true;
// A根据API类型发送请求
if (prompt.apiType === 'openai') {
// 调用OpenAI兼容API
currentRequest = GM_xmlhttpRequest({
method: 'POST',
url: api.baseUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${api.apiKey}`
},
data: JSON.stringify({
model: prompt.model,
messages: [
{
role: 'user',
content: `${prompt.content}\n\n${pageContent}`
}
],
max_tokens: 2000 // 增加token数量,确保返回完整内容
}),
onload: function(response) {
try {
// 检查请求是否已经被中止
if (this.readyState === 4 && this.status === 0) {
resultDiv.innerHTML = '<div style="padding: 15px;">摘要已取消</div>';
resetSummaryButton();
return;
}
const data = JSON.parse(response.responseText);
if (data.choices && data.choices.length > 0) {
const summaryText = data.choices[0].message.content;
// 渲染Markdown
resultDiv.innerHTML = renderMarkdown(summaryText);
// 确保显示完整内容
setTimeout(() => {
resultDiv.scrollTop = 0;
}, 100);
} else {
resultDiv.innerHTML = '<div style="color: red; padding: 15px;">生成摘要失败: API响应格式不正确</div>';
console.error('API响应:', data);
}
} catch (e) {
resultDiv.innerHTML = `<div style="color: red; padding: 15px;">生成摘要失败: ${e.message}</div>`;
console.error('API响应解析失败:', e, response.responseText);
}
resetSummaryButton();
},
onerror: function(response) {
resultDiv.innerHTML = `<div style="color: red; padding: 15px;">生成摘要失败: ${response.statusText || '请求错误'}</div>`;
resetSummaryButton();
}
});
} else if (prompt.apiType === 'gemini') {
// 调用Gemini API
const url = `${api.baseUrl}/v1beta/models/${prompt.model}:generateContent?key=${api.apiKey}`;
currentRequest = GM_xmlhttpRequest({
method: 'POST',
url: url,
headers: {
'Content-Type': 'application/json'
},
data: JSON.stringify({
contents: [
{
parts: [
{
text: `${prompt.content}\n\n${pageContent}`
}
]
}
],
generationConfig: {
maxOutputTokens: 2000 // 增加token数量,确保返回完整内容
}
}),
onload: function(response) {
try {
// 检查请求是否已经被中止
if (this.readyState === 4 && this.status === 0) {
resultDiv.innerHTML = '<div style="padding: 15px;">摘要已取消</div>';
resetSummaryButton();
return;
}
const data = JSON.parse(response.responseText);
if (data.candidates && data.candidates.length > 0) {
const summaryText = data.candidates[0].content.parts[0].text;
// 渲染Markdown
resultDiv.innerHTML = renderMarkdown(summaryText);
// 确保显示完整内容
setTimeout(() => {
resultDiv.scrollTop = 0;
}, 100);
} else {
resultDiv.innerHTML = '<div style="color: red; padding: 15px;">生成摘要失败: API响应格式不正确</div>';
console.error('API响应:', data);
}
} catch (e) {
resultDiv.innerHTML = `<div style="color: red; padding: 15px;">生成摘要失败: ${e.message}</div>`;
console.error('API响应解析失败:', e, response.responseText);
}
resetSummaryButton();
},
onerror: function(response) {
resultDiv.innerHTML = `<div style="color: red; padding: 15px;">生成摘要失败: ${response.statusText || '请求错误'}</div>`;
resetSummaryButton();
}
});
}
}
// 停止摘要
function stopSummary() {
if (currentRequest && typeof currentRequest.abort === 'function') {
currentRequest.abort();
}
// 重置按钮和状态
resetSummaryButton();
console.log("[网页内容摘要器] 摘要已停止");
}
// 重置摘要按钮
function resetSummaryButton() {
const stopBtn = document.getElementById('stop-btn');
if (stopBtn) {
stopBtn.id = 'summarize-btn';
stopBtn.textContent = '摘要';
stopBtn.disabled = false;
}
isProcessing = false;
currentRequest = null;
}
// 获取页面内容
function getPageContent() {
try {
// 获取主要内容
let content = '';
// 尝试获取文章内容区
const articleElements = document.querySelectorAll('article, .article, .post, .content, main');
if (articleElements.length > 0) {
// 使用最大的内容区
let maxTextElement = null;
let maxTextLength = 0;
articleElements.forEach(el => {
const textLength = el.textContent.trim().length;
if (textLength > maxTextLength) {
maxTextLength = textLength;
maxTextElement = el;
}
});
if (maxTextElement) {
content = maxTextElement.textContent;
}
}
// 如果没有找到明确的内容区,尝试从段落获取
if (!content || content.length < 100) {
const paragraphs = document.querySelectorAll('p');
const paragraphTexts = [];
paragraphs.forEach(p => {
// 过滤掉短段落和导航/页脚/侧边栏等区域的段落
const text = p.textContent.trim();
if (text.length > 20 && !isElementInNavOrFooter(p)) {
paragraphTexts.push(text);
}
});
content = paragraphTexts.join('\n\n');
}
// 确保内容不为空
if (!content || content.length < 50) {
// 退化方案:获取所有可见文本
content = document.body.innerText;
}
// 添加页面标题
const pageTitle = document.title;
content = `标题: ${pageTitle}\n\n${content}`;
// 过滤内容
content = content
.replace(/\s+/g, ' ') // 替换多个空白字符为单个空格
.replace(/\n{3,}/g, '\n\n') // 替换多个换行为两个换行
.trim();
// 限制内容长度
const maxLength = 6000; // 设置合理的长度限制
if (content.length > maxLength) {
content = content.substring(0, maxLength) + "\n\n... (内容已截断)";
}
return content;
} catch (e) {
console.error('获取页面内容失败:', e);
return document.title + '\n\n' + document.body.innerText.substring(0, 6000);
}
}
// 判断元素是否在导航或页脚中
function isElementInNavOrFooter(element) {
let current = element;
while (current && current !== document.body) {
const tagName = current.tagName.toLowerCase();
const className = (current.className || '').toLowerCase();
const id = (current.id || '').toLowerCase();
if (
tagName === 'nav' ||
tagName === 'footer' ||
tagName === 'header' ||
className.includes('nav') ||
className.includes('menu') ||
className.includes('footer') ||
className.includes('sidebar') ||
id.includes('nav') ||
id.includes('menu') ||
id.includes('footer') ||
id.includes('sidebar')
) {
return true;
}
current = current.parentElement;
}
return false;
}
// 渲染Markdown内容
function renderMarkdown(text) {
try {
// 确保marked库已加载
if (typeof marked === 'undefined') {
console.error('[网页内容摘要器] Marked库未加载');
return text;
}
// 配置marked选项
marked.setOptions({
breaks: true,
gfm: true,
headerIds: false,
mangle: false,
sanitize: false,
smartLists: true
});
// 渲染Markdown
return marked.parse(text);
} catch (error) {
console.error('[网页内容摘要器] Markdown渲染失败:', error);
return text;
}
}
// 初始化
function initialize() {
try {
// 添加CSS样式
addStyles();
// 创建UI界面
createUI();
// 注册油猴菜单命令
GM_registerMenuCommand("打开/关闭摘要器", toggleSummarizer);
// 添加热键监听
addHotkeyListener();
console.log("[网页内容摘要器] 初始化完成,按Alt+" + configs.settings.hotkey.toUpperCase() + "打开摘要器");
} catch (error) {
console.error("[网页内容摘要器] 初始化失败:", error);
// 尝试重新初始化
setTimeout(() => {
try {
console.log("[网页内容摘要器] 尝试重新初始化...");
// 清理之前的UI(如果有)
const oldSummarizer = document.getElementById('web-summarizer');
if (oldSummarizer) {
oldSummarizer.remove();
}
// 重新创建UI
createUI();
console.log("[网页内容摘要器] 重新初始化完成");
} catch (e) {
console.error("[网页内容摘要器] 重新初始化失败:", e);
}
}, 2000);
}
}
// 切换摘要器显示状态
function toggleSummarizer() {
try {
if (!summarizer) {
console.log("[网页内容摘要器] 摘要器未初始化,尝试重新初始化");
initialize();
return;
}
if (summarizer.style.display === 'none' || summarizer.style.display === '') {
summarizer.style.display = 'flex';
} else {
summarizer.style.display = 'none';
}
} catch (error) {
console.error("[网页内容摘要器] 切换显示状态失败:", error);
}
}
// 添加热键监听
function addHotkeyListener() {
try {
// 先移除旧的事件监听(如果有)以避免重复
document.removeEventListener('keydown', hotkeyHandler);
// 添加新的事件监听
document.addEventListener('keydown', hotkeyHandler);
} catch (error) {
console.error("[网页内容摘要器] 添加热键监听失败:", error);
}
}
// 热键处理函数
function hotkeyHandler(e) {
if (e.altKey && e.key.toLowerCase() === configs.settings.hotkey.toLowerCase()) {
e.preventDefault();
toggleSummarizer();
}
}
// 绑定配置项展开/折叠事件
function bindConfigHeaderEvents() {
document.querySelectorAll('.config-header').forEach(header => {
// 移除旧事件以避免重复绑定
const newHeader = header.cloneNode(true);
header.parentNode.replaceChild(newHeader, header);
newHeader.addEventListener('click', function() {
const id = this.getAttribute('data-id');
const body = this.nextElementSibling;
if (body.classList.contains('expanded')) {
body.classList.remove('expanded');
} else {
if (!configs.settings.autoExpand) {
document.querySelectorAll('.config-body').forEach(b => b.classList.remove('expanded'));
}
body.classList.add('expanded');
}
});
});
}
// 开始调整大小
let isResizing = false;
let startX = 0;
let startWidth = 0;
function startResize(e) {
// 防止事件传播
e.stopPropagation();
e.preventDefault();
isResizing = true;
startX = e.clientX;
startWidth = summarizer.offsetWidth;
// 添加调整大小时的事件监听
document.addEventListener('mousemove', resize);
document.addEventListener('mouseup', stopResize);
// 添加调整大小时的样式
document.body.style.userSelect = 'none';
summarizer.classList.add('resizing');
}
// 调整大小中
function resize(e) {
if (!isResizing) return;
// 计算新宽度 - 这里与之前不同,向左拖动会增加宽度
const changeX = startX - e.clientX;
const newWidth = Math.max(MIN_WIDTH, startWidth + changeX);
// 限制最大宽度
const maxWidth = window.innerWidth * 0.8;
summarizer.style.width = Math.min(newWidth, maxWidth) + 'px';
}
// 停止调整大小
function stopResize() {
if (!isResizing) return;
isResizing = false;
// 移除事件监听
document.removeEventListener('mousemove', resize);
document.removeEventListener('mouseup', stopResize);
// 移除样式
document.body.style.userSelect = '';
summarizer.classList.remove('resizing');
// 保存大小到配置
configs.size = {
width: summarizer.offsetWidth
};
GM_setValue("summarizer_configs", configs);
}
// 主函数
// 等待DOM完全加载后再初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
})();