// ==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 = `

⚙️ 剪藏工具配置

📔 思源笔记配置
🤖 AI 服务配置 (用于生成标签)
🌐 其他配置
`; 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(); })();