// ==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 = ``;
});
}
_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();
})();