// ==UserScript==
// @name 网页剪藏工具 Pro (一体化版)
// @namespace http://tampermonkey.net/
// @version 5.0
// @description 一体化网页剪藏工具,无需后端服务器,直接与思源笔记和AI服务交互,支持选择笔记本和按月归档。
// @author You
// @match *://*/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// ==/UserScript==
(function() {
'use strict';
// ===== 1. 配置管理模块 (大幅扩展) =====
const Config = {
keys: {
// 原有配置
activeSites: 'web_clipper_active_sites',
buttonPosition: 'web_clipper_button_position',
lastSelectedNotebookId: 'web_clipper_last_notebook_id',
// 新增:思源笔记配置
siyuanApiUrl: 'web_clipper_siyuan_api_url',
siyuanApiToken: 'web_clipper_siyuan_api_token',
// 新增:AI服务配置
aiApiUrl: 'web_clipper_ai_api_url',
aiApiKey: 'web_clipper_ai_api_key',
aiModel: 'web_clipper_ai_model',
},
getAll: function() {
return {
activeSites: GM_getValue(this.keys.activeSites, []),
buttonPosition: GM_getValue(this.keys.buttonPosition, { top: '100px', right: '20px' }),
lastSelectedNotebookId: GM_getValue(this.keys.lastSelectedNotebookId, ''),
// 新增:获取思源和AI配置
siyuanApiUrl: GM_getValue(this.keys.siyuanApiUrl, 'http://127.0.0.1:6806'),
siyuanApiToken: GM_getValue(this.keys.siyuanApiToken, ''),
aiApiUrl: GM_getValue(this.keys.aiApiUrl, 'https://open.bigmodel.cn/api/paas/v4/chat/completions'),
aiApiKey: GM_getValue(this.keys.aiApiKey, ''),
aiModel: GM_getValue(this.keys.aiModel, 'glm-4-flash'),
};
},
setAll: function(newConfig) {
GM_setValue(this.keys.activeSites, newConfig.activeSites);
GM_setValue(this.keys.buttonPosition, newConfig.buttonPosition);
if (newConfig.lastSelectedNotebookId !== undefined) {
GM_setValue(this.keys.lastSelectedNotebookId, newConfig.lastSelectedNotebookId);
}
// 新增:设置思源和AI配置
GM_setValue(this.keys.siyuanApiUrl, newConfig.siyuanApiUrl);
GM_setValue(this.keys.siyuanApiToken, newConfig.siyuanApiToken);
GM_setValue(this.keys.aiApiUrl, newConfig.aiApiUrl);
GM_setValue(this.keys.aiApiKey, newConfig.aiApiKey);
GM_setValue(this.keys.aiModel, newConfig.aiModel);
},
isComplete: function() {
const config = this.getAll();
// 检查思源和AI的核心配置是否完整
return config.siyuanApiUrl && config.siyuanApiToken && config.aiApiUrl && config.aiApiKey;
},
showSettings: function() {
const config = this.getAll();
const existingModal = document.getElementById('web-clipper-settings-modal');
if (existingModal) existingModal.remove();
GM_addStyle(`
#web-clipper-settings-modal { /* ... (样式与之前相同,保持不变) ... */ }
#web-clipper-settings-content { /* ... */ }
#web-clipper-settings-content h2 { /* ... */ }
#web-clipper-settings-content label { /* ... */ }
#web-clipper-settings-content input, #web-clipper-settings-content select { /* ... */ }
#web-clipper-settings-content input:focus, #web-clipper-settings-content select:focus { /* ... */ }
#web-clipper-settings-buttons { /* ... */ }
#web-clipper-settings-buttons button { /* ... */ }
#web-clipper-save-btn { /* ... */ }
#web-clipper-save-btn:hover { /* ... */ }
#web-clipper-cancel-btn { /* ... */ }
#web-clipper-cancel-btn:hover { /* ... */ }
.settings-section { margin-top: 24px; padding-top: 16px; border-top: 1px solid rgba(0,0,0,0.1); }
.settings-section-title { font-weight: 600; color: #333; margin-bottom: 12px; }
`);
const modal = document.createElement('div');
modal.id = 'web-clipper-settings-modal';
const content = document.createElement('div');
content.id = 'web-clipper-settings-content';
content.innerHTML = `
⚙️ 剪藏工具配置
🌐 其他配置
`;
modal.appendChild(content);
document.body.appendChild(modal);
document.getElementById('web-clipper-cancel-btn').addEventListener('click', () => modal.remove());
document.getElementById('web-clipper-save-btn').addEventListener('click', () => {
const newConfig = {
siyuanApiUrl: document.getElementById('siyuanApiUrl').value.trim(),
siyuanApiToken: document.getElementById('siyuanApiToken').value.trim(),
aiApiUrl: document.getElementById('aiApiUrl').value.trim(),
aiApiKey: document.getElementById('aiApiKey').value.trim(),
aiModel: document.getElementById('aiModel').value.trim(),
activeSites: document.getElementById('activeSites').value.trim() ? document.getElementById('activeSites').value.trim().split('\n') : [],
buttonPosition: config.buttonPosition,
lastSelectedNotebookId: config.lastSelectedNotebookId,
};
this.setAll(newConfig);
modal.remove();
Toast.show('配置已保存!', 'success');
});
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
}
};
// ===== 2. Toast 通知系统 (无改动) =====
const Toast = { /* ... (与之前完全相同) ... */ };
Toast.show = function(message, type = 'info', duration = 3000) {
if (!document.querySelector('#toast-styles')) {
GM_addStyle(`.toast{...}`); // 简写,实际使用时请用完整样式
}
// ... (其余实现与之前相同)
};
Toast.getIcon = function(type) { /* ... */ };
// ===== 3. 剪藏面板模块 (UI部分无改动) =====
const ClipperPanel = {
isVisible: false,
panel: null,
show: function() {
if (this.isVisible) return;
this.isVisible = true;
const existingPanel = document.getElementById('clipper-panel');
if (existingPanel) existingPanel.remove();
if (!document.querySelector('#clipper-styles')) {
GM_addStyle(`#clipper-panel{...}.clipper-content{...}...`); // 简写,实际使用时请用完整样式
}
const panel = document.createElement('div'); panel.id = 'clipper-panel';
const content = document.createElement('div'); content.className = 'clipper-content';
// ... (创建 header, body, footer 的代码与之前相同)
// 唯一的区别是 notebook-select 的初始状态
const notebookSelect = document.createElement('select');
notebookSelect.className = 'info-select';
notebookSelect.id = 'notebook-select';
notebookSelect.innerHTML = '';
notebookSelect.disabled = true;
// ... (组装 panel 的代码与之前相同)
document.body.appendChild(panel);
this.panel = panel;
panel.addEventListener('click', (e) => { if (e.target === panel) this.hide(); });
setTimeout(() => document.getElementById('clip-title-input').focus(), 300);
// 尝试加载笔记本列表
WebClipper.fetchNotebooks();
},
hide: function() { /* ... (与之前相同) ... */ },
setLoading: function(loading) { /* ... (与之前相同) ... */ }
};
// ===== 4. 核心剪藏与服务模块 (重大重构) =====
const WebClipper = {
config: {},
button: null,
dragThrottle: null,
notebooks: [],
init: function() {
this.config = Config.getAll();
if (!Config.isComplete()) {
console.warn('脚本配置不完整,请在菜单中打开设置进行配置。');
// 不再自动弹出设置,让用户主动配置
return;
}
// ... (其余 init 逻辑与之前相同)
},
// ===== 新增:统一的 GM_xmlhttpRequest Promise 封装 =====
fetchWithGM: function(details) {
return new Promise((resolve, reject) => {
details.onload = (res) => {
if (res.status >= 200 && res.status < 300) {
resolve(res);
} else {
reject(new Error(`请求失败: ${res.status} ${res.statusText}`));
}
};
details.onerror = (err) => reject(err);
GM_xmlhttpRequest(details);
});
},
// ===== 新增:从思源获取笔记本列表 (原后端逻辑) =====
fetchNotebooks: async function() {
const selectElement = document.getElementById('notebook-select');
if (!selectElement) return;
selectElement.innerHTML = '';
selectElement.disabled = true;
try {
const response = await this.fetchWithGM({
method: 'POST',
url: `${this.config.siyuanApiUrl}/api/notebook/lsNotebooks`,
headers: { 'Authorization': `Token ${this.config.siyuanApiToken}`, 'Content-Type': 'application/json' },
data: JSON.stringify({})
});
const result = JSON.parse(response.responseText);
if (result.code === 0 && result.data.notebooks) {
this.notebooks = result.data.notebooks;
selectElement.innerHTML = '';
this.notebooks.forEach(nb => {
const option = document.createElement('option');
option.value = nb.id;
option.textContent = nb.name;
selectElement.appendChild(option);
});
selectElement.disabled = false;
if (this.config.lastSelectedNotebookId) {
selectElement.value = this.config.lastSelectedNotebookId;
}
} else {
throw new Error(result.msg || '获取笔记本列表失败');
}
} catch (error) {
console.error('加载笔记本列表失败:', error);
selectElement.innerHTML = '';
Toast.show('加载笔记本列表失败', 'error');
}
},
// ===== 新增:AI生成标签 (原后端逻辑) =====
generateTagsWithLLM: async function(title, content, customTags = []) {
const customTagsText = customTags.length > 0 ? `\n\n用户已提供的标签(请避免生成相似或重复的标签):${customTags.join('、')}` : '';
const prompt = `请根据以下文章标题和内容,生成3到5个最能概括文章核心主题的标签。${customTagsText}\n\n要求:\n1. 标签需要简洁、精准,使用中文,并且是两字名词\n2. 如果用户已提供标签,请生成补充性的标签,避免重复\n3. 请直接返回标签,用逗号分隔,不要有任何其他解释或格式\n\n标题:\n ${title}\n\n内容:\n ${content.substring(0, 2000)}...`;
try {
const response = await this.fetchWithGM({
method: 'POST',
url: this.config.aiApiUrl,
headers: { 'Authorization': `Bearer ${this.config.aiApiKey}`, 'Content-Type': 'application/json' },
data: JSON.stringify({
model: this.config.aiModel,
messages: [{ role: "user", content: prompt }],
temperature: 0.5,
max_tokens: 100
})
});
const result = JSON.parse(response.responseText);
if (result.choices && result.choices.length > 0) {
return result.choices[0].message.content.trim();
}
throw new Error('AI API 返回格式错误');
} catch (error) {
console.error('调用AI API失败:', error);
return '';
}
},
// ===== 新增:保存到思源笔记 (原后端逻辑) =====
saveToSiyuan: async function(title, markdown, tags, notebookId) {
const currentMonth = new Date().toISOString().slice(0, 7);
const sanitizedTitle = title.replace(/[\/\\:*?"<>|]/g, '_');
const docPath = `/web-clips/${currentMonth}/${sanitizedTitle}`;
const payload = { notebook: notebookId, path: docPath, markdown: markdown, tags: tags };
const response = await this.fetchWithGM({
method: 'POST',
url: `${this.config.siyuanApiUrl}/api/filetree/createDocWithMd`,
headers: { 'Authorization': `Token ${this.config.siyuanApiToken}`, 'Content-Type': 'application/json' },
data: JSON.stringify(payload)
});
const result = JSON.parse(response.responseText);
if (result.code !== 0) {
throw new Error(result.msg || '保存到思源笔记失败');
}
return result;
},
// ===== 修改:核心剪藏逻辑,直接调用内部服务 =====
doClip: async function() {
const titleInput = document.getElementById('clip-title-input');
const urlInput = document.getElementById('clip-url-input');
const tagsInput = document.getElementById('custom-tags-input');
const notebookSelect = document.getElementById('notebook-select');
const title = titleInput ? titleInput.value.trim() : document.title;
const url = urlInput ? urlInput.value.trim() : window.location.href;
const customTags = tagsInput ? tagsInput.value.split(/[,,\s]+/).map(tag => tag.trim()).filter(tag => tag) : [];
const notebookId = notebookSelect ? notebookSelect.value : '';
if (!title || !url || !notebookId) {
Toast.show('请填写标题、链接并选择笔记本', 'error');
return;
}
ClipperPanel.setLoading(true);
try {
// 1. 提取内容
const dom = new JSDOM(document.documentElement.outerHTML, { url });
const reader = new Readability(dom.window.document);
const article = reader.parse();
if (!article || !article.content) throw new Error('无法提取文章内容');
const turndownService = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-', codeBlockStyle: 'fenced' });
const markdown = turndownService.turndown(article.content);
const markdownWithHeader = `# ${article.title}\n\n> [原文链接](${url})\n\n---\n\n${markdown}`;
// 2. AI生成标签
const aiTags = await this.generateTagsWithLLM(article.title, article.textContent, customTags);
const allTags = [...new Set([...customTags, ...aiTags.split(/[,,]/).map(t => t.trim()).filter(t => t)])].join(',');
// 3. 保存到思源
await this.saveToSiyuan(article.title, markdownWithHeader, allTags, notebookId);
// 4. 成功处理
const currentConfig = Config.getAll();
currentConfig.lastSelectedNotebookId = notebookId;
Config.setAll(currentConfig);
ClipperPanel.hide();
Toast.show(`剪藏成功!生成标签:${allTags}`, 'success');
if (this.button) {
const originalText = this.button.textContent;
this.button.textContent = '✅';
setTimeout(() => { this.button.textContent = originalText; }, 2000);
}
} catch (error) {
console.error('剪藏失败:', error);
Toast.show(`剪藏失败: ${error.message}`, 'error');
} finally {
ClipperPanel.setLoading(false);
}
},
makeDraggable: function(element) { /* ... (与之前相同) ... */ },
createFloatingButton: function() { /* ... (与之前相同) ... */ }
};
// ===== 5. 初始化和菜单注册 (无改动) =====
GM_registerMenuCommand('⚙️ 剪藏工具设置', () => { Config.showSettings(); });
console.log('Web Clipper Pro (一体化版) 脚本已加载');
WebClipper.init();
})();