// ==UserScript== // @name 网页剪藏到思源笔记 (标签测试版3) // @namespace http://tampermonkey.net/ // @version 1.1.2-test // @description 通用网页剪藏工具,支持多网站内容剪藏到思源笔记,测试标签功能 // @author You // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_addStyle // @grant GM_registerMenuCommand // @require https://cdn.jsdelivr.net/npm/turndown@7.1.2/dist/turndown.js // @require https://cdn.jsdelivr.net/npm/@mozilla/readability@0.4.4/Readability.js // ==/UserScript== (function() { 'use strict'; // ===== 1. 配置管理模块 ===== const Config = { keys: { apiUrl: 'siyuan_api_url', token: 'siyuan_token', notebookId: 'siyuan_notebook_id', buttonPosition: 'button_position', enableTags: 'enable_tags', customTags: 'custom_tags' }, getAll: function() { return { apiUrl: GM_getValue(this.keys.apiUrl, ''), token: GM_getValue(this.keys.token, ''), notebookId: GM_getValue(this.keys.notebookId, ''), buttonPosition: GM_getValue(this.keys.buttonPosition, 'top-right'), enableTags: GM_getValue(this.keys.enableTags, true), customTags: GM_getValue(this.keys.customTags, '') }; }, setAll: function(newConfig) { GM_setValue(this.keys.apiUrl, newConfig.apiUrl); GM_setValue(this.keys.token, newConfig.token); GM_setValue(this.keys.notebookId, newConfig.notebookId); GM_setValue(this.keys.buttonPosition, newConfig.buttonPosition); GM_setValue(this.keys.enableTags, newConfig.enableTags); GM_setValue(this.keys.customTags, newConfig.customTags); }, isComplete: function() { const config = this.getAll(); return config.apiUrl && config.token && config.notebookId; }, showSettings: function() { const config = this.getAll(); if (document.getElementById('siyuan-settings-modal')) return; GM_addStyle(` #siyuan-settings-modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 100000; display: flex; justify-content: center; align-items: center; font-family: sans-serif; } #siyuan-settings-content { background: #fff; padding: 20px 30px; border-radius: 8px; width: 90%; max-width: 500px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); max-height: 80vh; overflow-y: auto; } #siyuan-settings-content h2 { margin-top: 0; color: #333; } #siyuan-settings-content label { display: block; margin-top: 15px; margin-bottom: 5px; font-weight: bold; color: #555; } #siyuan-settings-content input, #siyuan-settings-content select, #siyuan-settings-content textarea { width: 100%; padding: 8px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px; } #siyuan-settings-content textarea { height: 60px; resize: vertical; } #siyuan-settings-content .checkbox-group { display: flex; align-items: center; margin-top: 15px; } #siyuan-settings-content .checkbox-group input { width: auto; margin-right: 8px; } #siyuan-settings-buttons { margin-top: 20px; text-align: right; } #siyuan-settings-buttons button { padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer; margin-left: 10px; } #siyuan-save-btn { background-color: #007AFF; color: white; } #siyuan-cancel-btn { background-color: #f0f0f0; color: #333; } .tag-help { font-size: 12px; color: #888; margin-top: 5px; } `); const modal = document.createElement('div'); modal.id = 'siyuan-settings-modal'; const content = document.createElement('div'); content.id = 'siyuan-settings-content'; const title = document.createElement('h2'); title.textContent = '思源剪藏配置'; content.appendChild(title); // API 地址输入框 const apiUrlLabel = document.createElement('label'); apiUrlLabel.textContent = '思源 API 地址 (如: https://siyuan.example.com)'; apiUrlLabel.setAttribute('for', 'apiUrl'); content.appendChild(apiUrlLabel); const apiUrlInput = document.createElement('input'); apiUrlInput.type = 'url'; apiUrlInput.id = 'apiUrl'; apiUrlInput.placeholder = '请输入你的思源服务器地址'; apiUrlInput.value = config.apiUrl; content.appendChild(apiUrlInput); // Token 输入框 const tokenLabel = document.createElement('label'); tokenLabel.textContent = 'API Token'; tokenLabel.setAttribute('for', 'token'); content.appendChild(tokenLabel); const tokenInput = document.createElement('input'); tokenInput.type = 'text'; tokenInput.id = 'token'; tokenInput.placeholder = '在思源设置 -> 关于 -> API Token 中生成'; tokenInput.value = config.token; content.appendChild(tokenInput); // 笔记本 ID 输入框 const notebookIdLabel = document.createElement('label'); notebookIdLabel.textContent = '笔记本 ID (如: 20211231123456-abcdefg)'; notebookIdLabel.setAttribute('for', 'notebookId'); content.appendChild(notebookIdLabel); const notebookIdInput = document.createElement('input'); notebookIdInput.type = 'text'; notebookIdInput.id = 'notebookId'; notebookIdInput.placeholder = '在思源笔记设置 -> 关于 -> 笔记本列表中查找'; notebookIdInput.value = config.notebookId; content.appendChild(notebookIdInput); // 按钮位置选择 const buttonPositionLabel = document.createElement('label'); buttonPositionLabel.textContent = '浮动按钮位置'; buttonPositionLabel.setAttribute('for', 'buttonPosition'); content.appendChild(buttonPositionLabel); const buttonPositionSelect = document.createElement('select'); buttonPositionSelect.id = 'buttonPosition'; const positions = [ { value: 'top-right', text: '右上角' }, { value: 'top-left', text: '左上角' }, { value: 'bottom-right', text: '右下角' }, { value: 'bottom-left', text: '左下角' } ]; positions.forEach(pos => { const option = document.createElement('option'); option.value = pos.value; option.textContent = pos.text; if (pos.value === config.buttonPosition) option.selected = true; buttonPositionSelect.appendChild(option); }); content.appendChild(buttonPositionSelect); // 标签功能开关 const checkboxGroup = document.createElement('div'); checkboxGroup.className = 'checkbox-group'; const enableTagsCheckbox = document.createElement('input'); enableTagsCheckbox.type = 'checkbox'; enableTagsCheckbox.id = 'enableTags'; enableTagsCheckbox.checked = config.enableTags; checkboxGroup.appendChild(enableTagsCheckbox); const enableTagsLabel = document.createElement('label'); enableTagsLabel.textContent = '启用自动打标签功能'; enableTagsLabel.setAttribute('for', 'enableTags'); enableTagsLabel.style.fontWeight = 'normal'; checkboxGroup.appendChild(enableTagsLabel); content.appendChild(checkboxGroup); // 自定义标签输入框 const customTagsLabel = document.createElement('label'); customTagsLabel.textContent = '自定义标签 (用逗号分隔)'; customTagsLabel.setAttribute('for', 'customTags'); content.appendChild(customTagsLabel); const customTagsInput = document.createElement('textarea'); customTagsInput.id = 'customTags'; customTagsInput.placeholder = '例如:技术,编程,AI\n也可以使用变量:${hostname}, ${title}'; customTagsInput.value = config.customTags; content.appendChild(customTagsInput); const tagHelp = document.createElement('div'); tagHelp.className = 'tag-help'; tagHelp.textContent = '可用变量: ${hostname}(网站域名), ${title}(文章标题), ${date}(日期)'; content.appendChild(tagHelp); const buttons = document.createElement('div'); buttons.id = 'siyuan-settings-buttons'; const cancelButton = document.createElement('button'); cancelButton.id = 'siyuan-cancel-btn'; cancelButton.textContent = '取消'; cancelButton.addEventListener('click', () => modal.remove()); buttons.appendChild(cancelButton); const saveButton = document.createElement('button'); saveButton.id = 'siyuan-save-btn'; saveButton.textContent = '保存'; saveButton.addEventListener('click', () => { const newConfig = { apiUrl: apiUrlInput.value.trim(), token: tokenInput.value.trim(), notebookId: notebookIdInput.value.trim(), buttonPosition: buttonPositionSelect.value, enableTags: enableTagsCheckbox.checked, customTags: customTagsInput.value.trim() }; this.setAll(newConfig); modal.remove(); alert('配置已保存!'); window.location.reload(); }); buttons.appendChild(saveButton); content.appendChild(buttons); modal.appendChild(content); document.body.appendChild(modal); modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); } }; // ===== 2. 核心剪藏模块 ===== const Clipper = { config: {}, init: function() { this.config = Config.getAll(); // 检查配置是否完整 if (!Config.isComplete()) { console.warn('脚本配置不完整,请在菜单中打开设置进行配置。'); return; } // 创建浮动按钮 this.createClipButton(); }, 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); }); }, generateSafePath: function(title) { const safeTitle = title.toLowerCase() .replace(/[^\w\u4e00-\u9fa5\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .trim(); const timestamp = new Date().toISOString().slice(0, 10); return `/web-clips/${timestamp}/${safeTitle}`; }, generateTags: function(articleData) { if (!this.config.enableTags) return ''; const tags = new Set(); // 1. 添加基于域名的标签 const hostname = window.location.hostname.replace(/^www\./, ''); tags.add(hostname); // 2. 添加自定义标签 if (this.config.customTags) { const customTagsList = this.config.customTags.split(',').map(tag => tag.trim()); const variables = { hostname: hostname, title: articleData.title, date: new Date().toISOString().slice(0, 10) }; customTagsList.forEach(tagTemplate => { // 替换变量 let tag = tagTemplate; Object.keys(variables).forEach(key => { const regex = new RegExp(`\\$\\{${key}\\}`, 'g'); tag = tag.replace(regex, variables[key]); }); if (tag) { tags.add(tag); } }); } // 3. 基于内容的简单关键词提取(可选) const content = articleData.content.toLowerCase(); const keywords = ['javascript', 'python', 'ai', '机器学习', '编程', '技术', '开发', '设计']; keywords.forEach(keyword => { if (content.includes(keyword)) { tags.add(keyword); } }); // 返回逗号分隔的字符串 return Array.from(tags).slice(0, 10).join(','); }, extractArticle: function() { try { // 使用 Readability 提取文章内容 const documentClone = document.cloneNode(true); const reader = new Readability(documentClone); const article = reader.parse(); if (!article) { throw new Error('无法提取文章内容'); } return { title: article.title || document.title || '无标题', content: article.content, url: window.location.href, siteName: document.title.split(' - ').pop() || window.location.hostname }; } catch (error) { console.error('内容提取失败:', error); throw error; } }, convertToMarkdown: function(articleData) { const turndownService = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' }); const markdown = turndownService.turndown(articleData.content); // 按照模板格式组织内容 const template = `# ${articleData.title} **来源**:[${articleData.siteName}](${articleData.url}) **时间**:${new Date().toLocaleString()} --- ${markdown}`; return template; }, createSiYuanDocument: async function(title, markdown, tags = '') { const path = this.generateSafePath(title); // 构建请求数据,使用字符串格式的标签 const docData = { notebook: this.config.notebookId, path: path, markdown: markdown, tags: tags, // 关键:使用字符串而不是数组 clippingHref: window.location.href // 添加来源URL }; console.log('发送到思源的数据:', docData); // 调试信息 const response = await this.fetchWithGM({ method: 'POST', url: `${this.config.apiUrl}/api/filetree/createDocWithMd`, headers: { 'Content-Type': 'application/json', 'Authorization': `Token ${this.config.token}` }, data: JSON.stringify(docData) }); const result = JSON.parse(response.responseText); if (result.code !== 0) { throw new Error(`思源API错误: ${result.msg}`); } return result.data; }, clipToSiyuan: async function() { const button = document.getElementById('siyuan-clip-btn'); if (!button) return; const originalText = button.textContent; button.disabled = true; button.textContent = '剪藏中...'; try { const articleData = this.extractArticle(); const markdown = this.convertToMarkdown(articleData); if (!markdown.trim()) { throw new Error('转换后的内容为空'); } // 生成标签(字符串格式) const tags = this.generateTags(articleData); console.log('生成的标签字符串:', tags); // 调试信息 // 创建文档并添加标签 await this.createSiYuanDocument(articleData.title, markdown, tags); const tagCount = tags ? tags.split(',').length : 0; button.textContent = `✓ 成功 (${tagCount > 0 ? tagCount + '个标签' : '无标签'})`; setTimeout(() => { button.textContent = originalText; button.disabled = false; }, 3000); } catch (error) { console.error('剪藏失败:', error); button.textContent = '✗ 失败'; alert(`剪藏失败: ${error.message}`); setTimeout(() => { button.textContent = originalText; button.disabled = false; }, 3000); } }, createClipButton: function() { if (document.getElementById('siyuan-clip-btn')) return; const config = this.config; const btn = document.createElement('div'); btn.id = 'siyuan-clip-btn'; btn.innerHTML = '📋 剪藏到思源'; // 根据配置设置按钮位置 let positionStyle = ''; switch(config.buttonPosition) { case 'top-left': positionStyle = 'top: 20px; left: 20px;'; break; case 'bottom-right': positionStyle = 'bottom: 20px; right: 20px;'; break; case 'bottom-left': positionStyle = 'bottom: 20px; left: 20px;'; break; default: // top-right positionStyle = 'top: 20px; right: 20px;'; } btn.style.cssText = ` position: fixed; ${positionStyle} background: #4CAF50; color: white; padding: 10px 15px; border-radius: 5px; cursor: pointer; z-index: 9999; font-size: 14px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); transition: all 0.3s ease; `; btn.addEventListener('mouseenter', () => { btn.style.background = '#45a049'; btn.style.transform = 'scale(1.05)'; }); btn.addEventListener('mouseleave', () => { btn.style.background = '#4CAF50'; btn.style.transform = 'scale(1)'; }); btn.addEventListener('click', () => this.clipToSiyuan()); document.body.appendChild(btn); } }; // ===== 3. 初始化和菜单注册 ===== GM_registerMenuCommand('⚙️ 思源剪藏设置', () => { Config.showSettings(); }); GM_registerMenuCommand('📋 立即剪藏', () => { Clipper.clipToSiyuan(); }); console.log('网页剪藏到思源笔记脚本已加载 (标签测试版3)'); // 检查配置,如果配置不全,则弹出设置窗口 if (!Config.isComplete()) { console.warn('检测到配置不完整,已弹出设置窗口。'); Config.showSettings(); } else { // 初始化剪藏模块 Clipper.init(); } })();