// ==UserScript== // @name 网页剪藏工具 Pro (单文件版) // @namespace http://tampermonkey.net/ // @version 5.0.2 // @description 增强版网页剪藏工具,完全在浏览器内运行,支持自定义标签、选择笔记本和现代化UI // @author You // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_addStyle // @grant GM_setClipboard // @connect * // ==/UserScript== (function() { 'use strict'; // ===== 1. 配置管理模块 ===== const Config = { keys: { siyuanApiUrl: 'web_clipper_siyuan_api_url', siyuanApiToken: 'web_clipper_siyuan_api_token', aiApiUrl: 'web_clipper_ai_api_url', aiApiKey: 'web_clipper_ai_api_key', aiModel: 'web_clipper_ai_model', defaultNotebookId: 'web_clipper_default_notebook_id', activeSites: 'web_clipper_active_sites', buttonPosition: 'web_clipper_button_position', lastSelectedNotebookId: 'web_clipper_last_notebook_id' }, getAll: function() { return { siyuanApiUrl: GM_getValue(this.keys.siyuanApiUrl, ''), siyuanApiToken: GM_getValue(this.keys.siyuanApiToken, ''), aiApiUrl: GM_getValue(this.keys.aiApiUrl, ''), aiApiKey: GM_getValue(this.keys.aiApiKey, ''), aiModel: GM_getValue(this.keys.aiModel, 'glm-4'), defaultNotebookId: GM_getValue(this.keys.defaultNotebookId, ''), activeSites: GM_getValue(this.keys.activeSites, []), buttonPosition: GM_getValue(this.keys.buttonPosition, { top: '100px', right: '20px' }), lastSelectedNotebookId: GM_getValue(this.keys.lastSelectedNotebookId, '') }; }, setAll: function(newConfig) { 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); GM_setValue(this.keys.defaultNotebookId, newConfig.defaultNotebookId); 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); } }, isComplete: function() { const config = this.getAll(); return config.siyuanApiUrl && config.siyuanApiToken && config.aiApiUrl && config.aiApiKey && config.aiModel; }, showSettings: function() { const config = this.getAll(); // 移除已存在的模态框 const existingModal = document.getElementById('web-clipper-settings-modal'); if (existingModal) { existingModal.remove(); } // 添加样式 GM_addStyle(` #web-clipper-settings-modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.3); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); z-index: 2147483647; display: flex; justify-content: center; align-items: center; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } #web-clipper-settings-content { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); padding: 32px; border-radius: 20px; width: 90%; max-width: 600px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); position: relative; } #web-clipper-settings-content h2 { margin-top: 0; color: #1a1a1a; font-size: 20px; display: flex; align-items: center; gap: 8px; } #web-clipper-settings-content label { display: block; margin-top: 16px; margin-bottom: 6px; font-weight: 500; color: #4a4a4a; font-size: 14px; } #web-clipper-settings-content input, #web-clipper-settings-content textarea { width: 100%; padding: 12px; box-sizing: border-box; border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 12px; font-size: 14px; transition: all 0.3s; background: rgba(255, 255, 255, 0.8); } #web-clipper-settings-content input:focus, #web-clipper-settings-content textarea:focus { outline: none; border-color: rgba(76, 175, 80, 0.5); box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); background: rgba(255, 255, 255, 0.95); } #web-clipper-settings-tabs { display: flex; margin-top: 16px; margin-bottom: 16px; } .tab-btn { padding: 8px 16px; border: none; background: rgba(0,0,0,0.05); cursor: pointer; border-radius: 8px 8px 0 0; margin-right: 4px; } .tab-btn.active { background: rgba(76, 175, 80, 0.1); font-weight: bold; } .tab-content { display: none; } .tab-content.active { display: block; } #web-clipper-settings-buttons { margin-top: 24px; text-align: right; display: flex; gap: 12px; justify-content: flex-end; } #web-clipper-settings-buttons button { padding: 12px 24px; border: none; border-radius: 12px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.3s; min-width: 80px; } #web-clipper-save-btn { background: rgba(76, 175, 80, 0.9); color: white; } #web-clipper-save-btn:hover { background: rgba(76, 175, 80, 1); transform: translateY(-1px); } #web-clipper-cancel-btn { background: rgba(0, 0, 0, 0.1); color: #4a4a4a; } #web-clipper-cancel-btn:hover { background: rgba(0, 0, 0, 0.15); } .required { color: #f44336; } `); // 创建模态框 const modal = document.createElement('div'); modal.id = 'web-clipper-settings-modal'; const content = document.createElement('div'); content.id = 'web-clipper-settings-content'; // 创建标题 const title = document.createElement('h2'); title.textContent = '⚙️ 剪藏工具配置'; content.appendChild(title); // 创建标签页 const tabs = document.createElement('div'); tabs.id = 'web-clipper-settings-tabs'; const basicTabBtn = document.createElement('button'); basicTabBtn.className = 'tab-btn active'; basicTabBtn.textContent = '基础配置'; const siyuanTabBtn = document.createElement('button'); siyuanTabBtn.className = 'tab-btn'; siyuanTabBtn.textContent = '思源笔记'; const aiTabBtn = document.createElement('button'); aiTabBtn.className = 'tab-btn'; aiTabBtn.textContent = 'AI服务'; tabs.appendChild(basicTabBtn); tabs.appendChild(siyuanTabBtn); tabs.appendChild(aiTabBtn); content.appendChild(tabs); // 基础配置内容 const basicContent = document.createElement('div'); basicContent.className = 'tab-content active'; // 创建激活网站列表 const activeSitesLabel = document.createElement('label'); activeSitesLabel.textContent = '激活网站列表 (每行一个,留空表示全部)'; activeSitesLabel.setAttribute('for', 'activeSites'); basicContent.appendChild(activeSitesLabel); const activeSitesTextarea = document.createElement('textarea'); activeSitesTextarea.id = 'activeSites'; activeSitesTextarea.rows = '5'; activeSitesTextarea.value = config.activeSites.join('\n'); basicContent.appendChild(activeSitesTextarea); content.appendChild(basicContent); // 思源笔记配置内容 const siyuanContent = document.createElement('div'); siyuanContent.className = 'tab-content'; // 思源API URL const siyuanApiUrlLabel = document.createElement('label'); siyuanApiUrlLabel.innerHTML = '思源API地址 *'; siyuanApiUrlLabel.setAttribute('for', 'siyuanApiUrl'); siyuanContent.appendChild(siyuanApiUrlLabel); const siyuanApiUrlInput = document.createElement('input'); siyuanApiUrlInput.type = 'url'; siyuanApiUrlInput.id = 'siyuanApiUrl'; siyuanApiUrlInput.placeholder = 'http://localhost:6806'; siyuanApiUrlInput.value = config.siyuanApiUrl; siyuanContent.appendChild(siyuanApiUrlInput); // 思源API Token const siyuanApiTokenLabel = document.createElement('label'); siyuanApiTokenLabel.innerHTML = '思源API Token *'; siyuanApiTokenLabel.setAttribute('for', 'siyuanApiToken'); siyuanContent.appendChild(siyuanApiTokenLabel); const siyuanApiTokenInput = document.createElement('input'); siyuanApiTokenInput.type = 'password'; siyuanApiTokenInput.id = 'siyuanApiToken'; siyuanApiTokenInput.placeholder = '思源笔记API Token'; siyuanApiTokenInput.value = config.siyuanApiToken; siyuanContent.appendChild(siyuanApiTokenInput); // 默认笔记本ID const defaultNotebookIdLabel = document.createElement('label'); defaultNotebookIdLabel.textContent = '默认笔记本ID (可选)'; defaultNotebookIdLabel.setAttribute('for', 'defaultNotebookId'); siyuanContent.appendChild(defaultNotebookIdLabel); const defaultNotebookIdInput = document.createElement('input'); defaultNotebookIdInput.type = 'text'; defaultNotebookIdInput.id = 'defaultNotebookId'; defaultNotebookIdInput.placeholder = '可留空,剪藏时再选择'; defaultNotebookIdInput.value = config.defaultNotebookId; siyuanContent.appendChild(defaultNotebookIdInput); content.appendChild(siyuanContent); // AI服务配置内容 const aiContent = document.createElement('div'); aiContent.className = 'tab-content'; // AI API URL const aiApiUrlLabel = document.createElement('label'); aiApiUrlLabel.innerHTML = 'AI API地址 *'; aiApiUrlLabel.setAttribute('for', 'aiApiUrl'); aiContent.appendChild(aiApiUrlLabel); const aiApiUrlInput = document.createElement('input'); aiApiUrlInput.type = 'url'; aiApiUrlInput.id = 'aiApiUrl'; aiApiUrlInput.placeholder = 'https://api.zhipuai.cn/v1/chat/completions'; aiApiUrlInput.value = config.aiApiUrl; aiContent.appendChild(aiApiUrlInput); // AI API Key const aiApiKeyLabel = document.createElement('label'); aiApiKeyLabel.innerHTML = 'AI API Key *'; aiApiKeyLabel.setAttribute('for', 'aiApiKey'); aiContent.appendChild(aiApiKeyLabel); const aiApiKeyInput = document.createElement('input'); aiApiKeyInput.type = 'password'; aiApiKeyInput.id = 'aiApiKey'; aiApiKeyInput.placeholder = 'AI服务API Key'; aiApiKeyInput.value = config.aiApiKey; aiContent.appendChild(aiApiKeyInput); // AI Model const aiModelLabel = document.createElement('label'); aiModelLabel.textContent = 'AI 模型'; aiModelLabel.setAttribute('for', 'aiModel'); aiContent.appendChild(aiModelLabel); const aiModelInput = document.createElement('input'); aiModelInput.type = 'text'; aiModelInput.id = 'aiModel'; aiModelInput.placeholder = 'glm-4'; aiModelInput.value = config.aiModel; aiContent.appendChild(aiModelInput); content.appendChild(aiContent); // 添加标签切换事件 basicTabBtn.addEventListener('click', () => { basicTabBtn.className = 'tab-btn active'; siyuanTabBtn.className = 'tab-btn'; aiTabBtn.className = 'tab-btn'; basicContent.className = 'tab-content active'; siyuanContent.className = 'tab-content'; aiContent.className = 'tab-content'; }); siyuanTabBtn.addEventListener('click', () => { basicTabBtn.className = 'tab-btn'; siyuanTabBtn.className = 'tab-btn active'; aiTabBtn.className = 'tab-btn'; basicContent.className = 'tab-content'; siyuanContent.className = 'tab-content active'; aiContent.className = 'tab-content'; }); aiTabBtn.addEventListener('click', () => { basicTabBtn.className = 'tab-btn'; siyuanTabBtn.className = 'tab-btn'; aiTabBtn.className = 'tab-btn active'; basicContent.className = 'tab-content'; siyuanContent.className = 'tab-content'; aiContent.className = 'tab-content active'; }); // 创建按钮 const buttons = document.createElement('div'); buttons.id = 'web-clipper-settings-buttons'; const cancelButton = document.createElement('button'); cancelButton.id = 'web-clipper-cancel-btn'; cancelButton.textContent = '取消'; cancelButton.addEventListener('click', () => { modal.remove(); }); buttons.appendChild(cancelButton); const saveButton = document.createElement('button'); saveButton.id = 'web-clipper-save-btn'; saveButton.textContent = '保存'; saveButton.addEventListener('click', () => { const newConfig = { siyuanApiUrl: siyuanApiUrlInput.value.trim(), siyuanApiToken: siyuanApiTokenInput.value.trim(), aiApiUrl: aiApiUrlInput.value.trim(), aiApiKey: aiApiKeyInput.value.trim(), aiModel: aiModelInput.value.trim() || 'glm-4', defaultNotebookId: defaultNotebookIdInput.value.trim(), activeSites: activeSitesTextarea.value.trim() ? activeSitesTextarea.value.trim().split('\n') : [], buttonPosition: config.buttonPosition, lastSelectedNotebookId: config.lastSelectedNotebookId }; // 验证必填项 if (!newConfig.siyuanApiUrl) { Toast.show('思源API地址不能为空', 'error'); siyuanApiUrlInput.focus(); return; } if (!newConfig.siyuanApiToken) { Toast.show('思源API Token不能为空', 'error'); siyuanApiTokenInput.focus(); return; } if (!newConfig.aiApiUrl) { Toast.show('AI API地址不能为空', 'error'); aiApiUrlInput.focus(); return; } if (!newConfig.aiApiKey) { Toast.show('AI API Key不能为空', 'error'); aiApiKeyInput.focus(); return; } this.setAll(newConfig); modal.remove(); Toast.show('配置已保存!', 'success'); setTimeout(() => window.location.reload(), 1000); }); buttons.appendChild(saveButton); content.appendChild(buttons); modal.appendChild(content); // 添加到body document.body.appendChild(modal); // 绑定背景点击事件 modal.addEventListener('click', (e) => { if (e.target === modal) { modal.remove(); } }); } }; // ===== 2. Toast 通知系统 ===== const Toast = { show: function(message, type = 'info', duration = 3000) { // 添加样式 if (!document.querySelector('#toast-styles')) { GM_addStyle(` .toast { position: fixed; top: 20px; right: 20px; z-index: 2147483647; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border-radius: 12px; padding: 16px 20px; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.2); display: flex; align-items: center; gap: 12px; min-width: 250px; animation: slideInRight 0.3s ease; max-width: 90vw; } .toast-success { border-left: 4px solid rgba(76, 175, 80, 0.8); } .toast-error { border-left: 4px solid rgba(244, 67, 54, 0.8); } .toast-info { border-left: 4px solid rgba(33, 150, 243, 0.8); } .toast-icon { font-size: 20px; } .toast-message { flex: 1; font-size: 14px; color: #1a1a1a; } @keyframes slideInRight { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } `); } // 创建toast元素 const toast = document.createElement('div'); toast.className = `toast toast-${type}`; // 创建图标 const icon = document.createElement('div'); icon.className = 'toast-icon'; icon.textContent = this.getIcon(type); // 创建消息 const messageEl = document.createElement('div'); messageEl.className = 'toast-message'; messageEl.textContent = message; // 组装 toast.appendChild(icon); toast.appendChild(messageEl); // 添加到body document.body.appendChild(toast); // 自动移除 setTimeout(() => { toast.style.animation = 'slideInRight 0.3s ease reverse'; setTimeout(() => toast.remove(), 300); }, duration); }, getIcon: function(type) { const icons = { success: '✅', error: '❌', info: 'ℹ️' }; return icons[type] || icons.info; } }; // ===== 3. 剪藏面板模块 ===== 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 { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.3); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); z-index: 2147483647; display: flex; justify-content: center; align-items: center; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } .clipper-content { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border-radius: 20px; width: 90%; max-width: 500px; max-height: 80vh; overflow-y: auto; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); position: relative; } .clipper-header { padding: 24px; border-bottom: 1px solid rgba(0, 0, 0, 0.1); display: flex; justify-content: space-between; align-items: center; } .clipper-title { font-size: 18px; font-weight: 600; color: #1a1a1a; } .clipper-close { width: 32px; height: 32px; border-radius: 50%; border: none; background: rgba(0, 0, 0, 0.1); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.3s; font-size: 18px; color: #4a4a4a; } .clipper-close:hover { background: rgba(0, 0, 0, 0.15); transform: rotate(90deg); } .clipper-body { padding: 24px; } .info-item { margin-bottom: 20px; } .info-label { font-size: 12px; color: #6a6a6a; margin-bottom: 8px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; } .info-input, .info-select { width: 100%; padding: 12px; border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 12px; font-size: 14px; transition: all 0.3s; background: rgba(255, 255, 255, 0.8); box-sizing: border-box; } /* 为select添加样式 */ .info-select { -webkit-appearance: none; -moz-appearance: none; appearance: none; background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='rgba(0,0,0,0.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6,9 12,15 18,9'%3e%3c/polyline%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right 12px center; background-size: 1em; padding-right: 36px; } .info-input:focus, .info-select:focus { outline: none; border-color: rgba(76, 175, 80, 0.5); box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); background: rgba(255, 255, 255, 0.95); } .info-input:hover, .info-select:hover { border-color: rgba(0, 0, 0, 0.2); } .tags-hint { font-size: 12px; color: #6a6a6a; margin-top: 8px; display: flex; align-items: center; gap: 4px; } .clipper-footer { padding: 24px; border-top: 1px solid rgba(0, 0, 0, 0.1); display: flex; gap: 12px; justify-content: flex-end; } .clipper-btn { padding: 12px 24px; border: none; border-radius: 12px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.3s; min-width: 100px; } .btn-clip { background: rgba(76, 175, 80, 0.9); color: white; } .btn-clip:hover:not(:disabled) { background: rgba(76, 175, 80, 1); transform: translateY(-1px); } .btn-clip:disabled { background: rgba(0, 0, 0, 0.2); cursor: not-allowed; } .btn-cancel { background: rgba(0, 0, 0, 0.1); color: #4a4a4a; } .btn-cancel:hover { background: rgba(0, 0, 0, 0.15); } .loading-spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid #ffffff; border-radius: 50%; border-top-color: transparent; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } `); } // 创建面板 const panel = document.createElement('div'); panel.id = 'clipper-panel'; const content = document.createElement('div'); content.className = 'clipper-content'; // 创建头部 const header = document.createElement('div'); header.className = 'clipper-header'; const title = document.createElement('div'); title.className = 'clipper-title'; title.textContent = '⚡️ 网页剪藏'; const closeButton = document.createElement('button'); closeButton.className = 'clipper-close'; closeButton.textContent = '×'; closeButton.addEventListener('click', () => { this.hide(); }); header.appendChild(title); header.appendChild(closeButton); // 创建主体 const body = document.createElement('div'); body.className = 'clipper-body'; // 笔记本选择下拉框 const notebookContainer = document.createElement('div'); notebookContainer.className = 'info-item'; const notebookLabel = document.createElement('div'); notebookLabel.className = 'info-label'; notebookLabel.textContent = '笔记本'; const notebookSelect = document.createElement('select'); notebookSelect.className = 'info-select'; notebookSelect.id = 'notebook-select'; notebookSelect.innerHTML = ''; notebookSelect.disabled = true; notebookContainer.appendChild(notebookLabel); notebookContainer.appendChild(notebookSelect); // 标题输入 const titleContainer = document.createElement('div'); titleContainer.className = 'info-item'; const titleLabel = document.createElement('div'); titleLabel.className = 'info-label'; titleLabel.textContent = '标题'; const titleInput = document.createElement('input'); titleInput.type = 'text'; titleInput.className = 'info-input'; titleInput.id = 'clip-title-input'; titleInput.value = document.title; titleInput.placeholder = '输入文章标题'; titleContainer.appendChild(titleLabel); titleContainer.appendChild(titleInput); // URL输入 const urlContainer = document.createElement('div'); urlContainer.className = 'info-item'; const urlLabel = document.createElement('div'); urlLabel.className = 'info-label'; urlLabel.textContent = '链接'; const urlInput = document.createElement('input'); urlInput.type = 'url'; urlInput.className = 'info-input'; urlInput.id = 'clip-url-input'; urlInput.value = window.location.href; urlInput.placeholder = '输入文章链接'; urlContainer.appendChild(urlLabel); urlContainer.appendChild(urlInput); // 标签输入 const tagsContainer = document.createElement('div'); tagsContainer.className = 'info-item'; const tagsLabel = document.createElement('div'); tagsLabel.className = 'info-label'; tagsLabel.textContent = '自定义标签(可选)'; const tagsInput = document.createElement('input'); tagsInput.type = 'text'; tagsInput.className = 'info-input'; tagsInput.id = 'custom-tags-input'; tagsInput.placeholder = '输入标签,用逗号或空格分隔'; const tagsHint = document.createElement('div'); tagsHint.className = 'tags-hint'; tagsHint.textContent = '💡 AI将根据您的标签生成补充标签,避免重复'; tagsContainer.appendChild(tagsLabel); tagsContainer.appendChild(tagsInput); tagsContainer.appendChild(tagsHint); // 调整UI顺序 body.appendChild(notebookContainer); body.appendChild(titleContainer); body.appendChild(urlContainer); body.appendChild(tagsContainer); // 创建底部 const footer = document.createElement('div'); footer.className = 'clipper-footer'; const cancelButton = document.createElement('button'); cancelButton.className = 'clipper-btn btn-cancel'; cancelButton.textContent = '取消'; cancelButton.addEventListener('click', () => { this.hide(); }); const clipButton = document.createElement('button'); clipButton.className = 'clipper-btn btn-clip'; const buttonText = document.createElement('span'); buttonText.id = 'clip-button-text'; buttonText.textContent = '开始剪藏'; clipButton.appendChild(buttonText); clipButton.addEventListener('click', () => { WebClipper.doClip(); }); footer.appendChild(cancelButton); footer.appendChild(clipButton); // 组装面板 content.appendChild(header); content.appendChild(body); content.appendChild(footer); panel.appendChild(content); // 添加到body document.body.appendChild(panel); this.panel = panel; // 绑定背景点击事件 panel.addEventListener('click', (e) => { if (e.target === panel) { this.hide(); } }); // 自动聚焦标题输入框 setTimeout(() => { titleInput.focus(); titleInput.select(); }, 300); // 面板显示后,加载笔记本列表 WebClipper.fetchNotebooks(); }, hide: function() { if (this.panel) { this.panel.remove(); this.panel = null; } this.isVisible = false; }, setLoading: function(loading) { const button = document.querySelector('.btn-clip'); const buttonText = document.getElementById('clip-button-text'); if (button && buttonText) { if (loading) { button.disabled = true; buttonText.textContent = ''; const spinner = document.createElement('span'); spinner.className = 'loading-spinner'; buttonText.parentNode.insertBefore(spinner, buttonText); buttonText.textContent = ' 剪藏中...'; } else { button.disabled = false; const spinner = button.querySelector('.loading-spinner'); if (spinner) spinner.remove(); buttonText.textContent = '开始剪藏'; } } } }; // ===== 4. 核心工具库 (Readability & Turndown) ===== // Readability 代码 - 简化版 class Readability { constructor(doc, options = {}) { this._doc = doc; this._options = options; } parse() { // 简单提取主要内容 const article = { title: this._getArticleTitle(), content: this._getArticleContent(), textContent: this._getArticleTextContent() }; return article; } _getArticleTitle() { // 尝试获取最合适的标题 const metaTitle = this._doc.querySelector('meta[property="og:title"]') || this._doc.querySelector('meta[name="twitter:title"]'); if (metaTitle && metaTitle.getAttribute('content')) { return metaTitle.getAttribute('content'); } const h1 = this._doc.querySelector('h1'); if (h1 && h1.textContent.length < 100) { return h1.textContent.trim(); } return this._doc.title; } _getArticleContent() { // 创建一个临时div来存放内容 const articleContent = this._doc.createElement('div'); // 尝试找到主要内容区域 let contentElement = this._doc.querySelector('article') || this._doc.querySelector('.article-content') || this._doc.querySelector('.post-content') || this._doc.querySelector('.content') || this._doc.querySelector('main') || this._doc.body; // 如果找到的内容太短,尝试扩大范围 if (contentElement && contentElement.textContent.length < 500) { contentElement = this._doc.body; } // 克隆内容元素 const contentClone = contentElement.cloneNode(true); // 移除不必要的元素 this._removeUnlikelyCandidates(contentClone); this._cleanStyles(contentClone); // 添加到文章内容 articleContent.appendChild(contentClone); return articleContent.innerHTML; } _getArticleTextContent() { const content = this._getArticleContent(); const tempDiv = this._doc.createElement('div'); tempDiv.innerHTML = content; return tempDiv.textContent; } _removeUnlikelyCandidates(node) { // 移除脚本、样式、广告等不相关内容 const unlikelySelectors = [ 'script', 'style', 'iframe', 'object', 'embed', '.ad', '.ads', '.advertisement', '.banner', '.sidebar', '.footer', '.header', '.comment', '.comments', '.disqus', '.nav', '.menu', '.navigation' ]; unlikelySelectors.forEach(selector => { const elements = node.querySelectorAll(selector); elements.forEach(el => el.remove()); }); } _cleanStyles(node) { // 清理内联样式 node.removeAttribute('style'); const elements = node.querySelectorAll('*'); elements.forEach(el => { el.removeAttribute('style'); el.removeAttribute('class'); }); } } // TurndownService 简化版 class TurndownService { constructor(options = {}) { this.options = { headingStyle: options.headingStyle || 'atx', bulletListMarker: options.bulletListMarker || '-', codeBlockStyle: options.codeBlockStyle || 'fenced' }; } turndown(html) { if (!html) return ''; // 创建一个临时div来解析HTML const tempDiv = document.createElement('div'); tempDiv.innerHTML = html; // 转换特定元素 this._convertHeadings(tempDiv); this._convertLists(tempDiv); this._convertLinks(tempDiv); this._convertImages(tempDiv); this._convertCodeBlocks(tempDiv); this._convertBlockquotes(tempDiv); this._convertTables(tempDiv); // 获取纯文本内容并清理 let markdown = tempDiv.textContent; // 清理多余空行 markdown = markdown.replace(/\n{3,}/g, '\n\n'); // 将多个空格替换为单个 markdown = markdown.replace(/[ \t]{2,}/g, ' '); return markdown.trim(); } _convertHeadings(element) { ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].forEach(tag => { const headings = element.getElementsByTagName(tag); Array.from(headings).forEach(heading => { const level = parseInt(tag.charAt(1)); const prefix = this.options.headingStyle === 'atx' ? '#'.repeat(level) + ' ' : `${'='.repeat(level)}\n`; heading.innerHTML = prefix + heading.textContent; }); }); } _convertLists(element) { // 有序列表 const ols = element.getElementsByTagName('ol'); Array.from(ols).forEach(ol => { let counter = 1; const items = ol.getElementsByTagName('li'); Array.from(items).forEach(li => { li.innerHTML = `${counter}. ${li.innerHTML}`; counter++; }); }); // 无序列表 const uls = element.getElementsByTagName('ul'); Array.from(uls).forEach(ul => { const items = ul.getElementsByTagName('li'); Array.from(items).forEach(li => { li.innerHTML = `${this.options.bulletListMarker} ${li.innerHTML}`; }); }); } _convertLinks(element) { const links = element.getElementsByTagName('a'); Array.from(links).forEach(link => { const href = link.getAttribute('href') || ''; if (href && !href.startsWith('javascript:')) { link.innerHTML = `[${link.textContent}](${href})`; } }); } _convertImages(element) { const images = element.getElementsByTagName('img'); Array.from(images).forEach(img => { const src = img.getAttribute('src') || ''; const alt = img.getAttribute('alt') || ''; img.outerHTML = `![${alt}](${src})`; }); } _convertCodeBlocks(element) { const codeBlocks = element.querySelectorAll('pre code'); Array.from(codeBlocks).forEach(block => { const code = block.innerHTML.replace(//g, '>'); const language = block.className.replace('language-', '') || ''; if (this.options.codeBlockStyle === 'fenced') { block.parentNode.outerHTML = `\`\`\`${language}\n${code}\n\`\`\``; } else { // 缩进式代码块 const indentedCode = code.split('\n').map(line => ` ${line}`).join('\n'); block.parentNode.outerHTML = `\n${indentedCode}\n`; } }); } _convertBlockquotes(element) { const blockquotes = element.getElementsByTagName('blockquote'); Array.from(blockquotes).forEach(blockquote => { const lines = blockquote.innerHTML.split('\n'); const quotedLines = lines.map(line => `> ${line.trim()}`); blockquote.innerHTML = quotedLines.join('\n'); }); } _convertTables(element) { // 简单处理表格 const tables = element.getElementsByTagName('table'); Array.from(tables).forEach(table => { table.innerHTML = '[表格内容]'; }); } } // ===== 5. 核心剪藏模块 ===== const WebClipper = { config: {}, button: null, dragThrottle: null, notebooks: [], // 用于缓存笔记本列表 fetchQueue: {}, // 用于管理GM_xmlhttpRequest请求 init: function() { this.config = Config.getAll(); if (!Config.isComplete()) { console.warn('脚本配置不完整,请在菜单中打开设置进行配置。'); return; } if (this.config.activeSites.length > 0) { const currentHostname = window.location.hostname; const isAllowed = this.config.activeSites.some(site => { const regex = new RegExp('^' + site.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$'); return regex.test(currentHostname); }); if (!isAllowed) { return; } } console.log(`Web Clipper: 启动`); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => this.createFloatingButton()); } else { setTimeout(() => this.createFloatingButton(), 500); } }, // 封装GM_xmlhttpRequest fetchWithGM: function(details) { return new Promise((resolve, reject) => { // 为每个请求生成唯一ID const requestId = Date.now() + Math.random().toString(36).substr(2, 5); // 保存请求到队列 this.fetchQueue[requestId] = { resolve: resolve, reject: reject, details: details }; // 添加onload和onerror处理 details.onload = (res) => { delete this.fetchQueue[requestId]; try { if (res.status >= 200 && res.status < 300) { resolve(res); } else { reject(new Error(`请求失败: ${res.status} ${res.statusText}`)); } } catch (e) { reject(e); } }; details.onerror = (err) => { delete this.fetchQueue[requestId]; reject(err); }; // 设置超时 if (!details.timeout) { details.timeout = 30000; // 30秒超时 } // 发送请求 GM_xmlhttpRequest(details); }); }, // 获取笔记本列表 fetchNotebooks: async function() { const selectElement = document.getElementById('notebook-select'); if (!selectElement) return; 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) { 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 if (this.config.defaultNotebookId) { selectElement.value = this.config.defaultNotebookId; } } 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用户已提供的标签(请避免生成相似或重复的标签):${customTags.join('、')}` : ''; const prompt = `请根据以下文章标题和内容,生成3到5个最能概括文章核心主题的标签。${customTagsText} 要求: 1. 标签需要简洁、精准,使用中文,并且是两字名词 2. 如果用户已提供标签,请生成补充性的标签,避免重复 3. 请直接返回标签,用逗号分隔,不要有任何其他解释或格式 标题: ${title} 内容: ${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, stream: false, thinking: { type: "disabled" } }) }); const result = JSON.parse(response.responseText); const tags = result.choices[0].message.content.trim(); console.log(`AI生成的标签: ${tags}`); return tags; } catch (error) { console.error('调用智谱AI API失败:', error); return ''; } }, // 合并并去重标签 mergeTags: function(customTags, aiTagsString) { const aiTags = aiTagsString ? aiTagsString.split(/[,,]/).map(tag => tag.trim()).filter(tag => tag) : []; const allTags = [...new Set([...customTags, ...aiTags])]; return allTags.join(','); }, // 保存到思源笔记 saveToSiyuan: async function(title, markdown, tags, url, notebookId) { // 按月创建文件夹 const currentMonth = new Date().toISOString().slice(0, 7); // "YYYY-MM" const sanitizedTitle = title.replace(/[\/\\:*?"<>|]/g, '_'); const docPath = `/web-clips/${currentMonth}/${sanitizedTitle}`; const payload = { notebook: notebookId, path: docPath, markdown: markdown, tags: tags }; try { 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); console.log('成功保存到思源笔记:', result); return result; } catch (error) { console.error('保存到思源笔记失败:', error); throw error; } }, // 执行剪藏 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) { Toast.show('请输入标题', 'error'); return; } if (!url) { Toast.show('请输入链接', 'error'); return; } if (!notebookId) { Toast.show('请选择一个笔记本', 'error'); return; } ClipperPanel.setLoading(true); try { // 使用Readability提取主要内容 const reader = new Readability(document); const article = reader.parse(); if (!article || !article.content) { console.error('Readability无法提取文章内容'); throw new Error('无法提取文章内容'); } // 使用Turndown将HTML转换为Markdown const turndownService = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-', codeBlockStyle: 'fenced' }); const markdown = turndownService.turndown(article.content); const markdownWithHeader = `# ${article.title}\n> [原文链接](${url})\n---\n${markdown}`; // 调用AI生成标签 const aiTags = await this.generateTagsWithLLM(article.title, article.textContent, customTags); // 合并并去重标签 const allTags = this.mergeTags(customTags, aiTags); // 保存到思源笔记 await this.saveToSiyuan(article.title, markdownWithHeader, allTags, url, notebookId); // 保存用户选择的笔记本ID 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) { let isDragging = false; let startX, startY, initialTop, initialRight; function handleStart(e) { isDragging = true; startX = e.type.includes('mouse') ? e.clientX : e.touches[0].clientX; startY = e.type.includes('mouse') ? e.clientY : e.touches[0].clientY; const rect = element.getBoundingClientRect(); initialTop = rect.top; initialRight = window.innerWidth - rect.right; element.style.cursor = 'grabbing'; element.style.transition = 'none'; } function handleMove(e) { if (!isDragging) return; // 使用节流优化性能 if (WebClipper.dragThrottle) return; WebClipper.dragThrottle = requestAnimationFrame(() => { WebClipper.dragThrottle = null; e.preventDefault(); const currentX = e.type.includes('mouse') ? e.clientX : e.touches[0].clientX; const currentY = e.type.includes('mouse') ? e.clientY : e.touches[0].clientY; const deltaY = currentY - startY; const deltaX = currentX - startX; const newTop = initialTop + deltaY; const newRight = initialRight - deltaX; element.style.top = `${newTop}px`; element.style.right = `${newRight}px`; element.style.left = 'auto'; element.style.bottom = 'auto'; }); } function handleEnd() { if (!isDragging) return; isDragging = false; element.style.cursor = 'move'; element.style.transition = ''; if (WebClipper.dragThrottle) { cancelAnimationFrame(WebClipper.dragThrottle); WebClipper.dragThrottle = null; } const style = window.getComputedStyle(element); const newPosition = { top: style.top, right: style.right }; WebClipper.config.buttonPosition = newPosition; Config.setAll(WebClipper.config); } element.addEventListener('mousedown', handleStart); document.addEventListener('mousemove', handleMove); document.addEventListener('mouseup', handleEnd); element.addEventListener('touchstart', handleStart, { passive: false }); document.addEventListener('touchmove', handleMove, { passive: false }); element.addEventListener('touchend', handleEnd); }, createFloatingButton: function() { if (document.getElementById('web-clipper-btn')) return; // 添加样式 GM_addStyle(` #web-clipper-btn { position: fixed; z-index: 2147483646; cursor: move; user-select: none; font-size: 24px; top: ${this.config.buttonPosition.top}; right: ${this.config.buttonPosition.right}; width: 48px; height: 48px; border-radius: 50%; background: linear-gradient(135deg, rgba(76, 175, 80, 0.9), rgba(69, 160, 73, 0.9)); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); color: white; display: flex; align-items: center; justify-content: center; box-shadow: 0 8px 32px -8px rgba(76, 175, 80, 0.4); transition: all 0.2s ease; border: 1px solid rgba(255, 255, 255, 0.2); } #web-clipper-btn:hover { transform: scale(1.05); box-shadow: 0 12px 40px -8px rgba(76, 175, 80, 0.5); background: linear-gradient(135deg, rgba(76, 175, 80, 1), rgba(69, 160, 73, 1)); } #web-clipper-btn:active { transform: scale(0.95); } @media (max-width: 768px) { #web-clipper-btn { width: 44px; height: 44px; font-size: 20px; } } `); this.button = document.createElement('div'); this.button.id = 'web-clipper-btn'; this.button.title = '点击剪藏,双击打开设置'; this.button.textContent = '⚡️'; this.button.style.cssText = ` position: fixed; z-index: 2147483646; cursor: move; user-select: none; font-size: 24px; top: ${this.config.buttonPosition.top}; right: ${this.config.buttonPosition.right}; `; document.body.appendChild(this.button); this.makeDraggable(this.button); this.button.addEventListener('click', (e) => { if (e.detail === 1) { setTimeout(() => ClipperPanel.show(), 200); } }); this.button.addEventListener('dblclick', (e) => { e.stopPropagation(); Config.showSettings(); }); console.log('剪藏按钮已创建'); } }; // ===== 6. 初始化和菜单注册 ===== GM_registerMenuCommand('⚙️ 剪藏工具设置', () => { Config.showSettings(); }); console.log('Web Clipper Pro (单文件版) 脚本已加载'); if (!Config.isComplete()) { console.warn('检测到配置不完整,已弹出设置窗口。'); setTimeout(() => Config.showSettings(), 1000); } WebClipper.init(); })();