// ==UserScript== // @name 视频截图笔记助手 - 思源笔记集成 // @name:zh-CN 视频截图笔记助手 - 思源笔记集成 // @name:en Video Screenshot Note Helper - SiYuan Note Integration // @namespace https://github.com/taylormao/video-screenshot-siyuan // @version 1.2.1 // @description 在任意视频网站上截取当前画面、记录时间戳,一键发送到思源笔记(SiYuan Note),支持后台异步上传图床及兜底机制。 // @description:zh-CN 在任意视频网站上截取当前画面、记录时间戳,一键发送到思源笔记(SiYuan Note),支持后台异步上传图床及兜底机制。 // @author taylormao // @license MIT // @homepageURL https://github.com/taylormao/video-screenshot-siyuan // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM_addStyle // @connect 127.0.0.1 // @connect 127.0.0.1:6806 // @connect localhost // @connect localhost:6806 // @connect file.915577.xyz // @connect * // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ============================================================ // 第一部分:常量与工具函数 // ============================================================ const SCRIPT_PREFIX = 'vss-'; // Video Screenshot SiYuan const HOTKEY_CAPTURE = 'ctrl+shift+s'; const CONFIG_KEYS = { API_URL: 'siyuan_api_url', API_TOKEN: 'siyuan_api_token', NOTEBOOK_ID: 'siyuan_notebook_id', NOTE_MAP: 'siyuan_note_map', }; /** 思源视频笔记索引数据库的块 ID */ const NOTE_DB_ID = '20260730124418-dolxpfe'; /** 将 DataURL 转换为 Blob */ function dataURLtoBlob(dataURL) { const arr = dataURL.split(','); const mime = arr[0].match(/:(.*?);/)[1]; const bstr = atob(arr[1]); let n = bstr.length; const u8arr = new Uint8Array(n); while (n--) { u8arr[n] = bstr.charCodeAt(n); } return new Blob([u8arr], { type: mime }); } /** 将秒数格式化为 HH:MM:SS 或 MM:SS */ function formatTime(seconds) { if (isNaN(seconds) || seconds == null) return '00:00'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); if (h > 0) { return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } /** 获取纯净 URL 作为数据库唯一主键(去掉 hash,按网站规则清洗 query) */ function getCleanUrl() { const url = new URL(window.location.href); url.hash = ''; if (url.hostname.includes('youtube.com') && url.pathname === '/watch') { const v = url.searchParams.get('v'); url.search = ''; if (v) url.searchParams.set('v', v); return url.href; } if (url.hostname.includes('bilibili.com')) { const p = url.searchParams.get('p'); url.search = ''; if (p && p !== '1') url.searchParams.set('p', p); return url.href; } url.search = ''; return url.href; } /** HTML 转义 */ function escapeHtml(text) { return String(text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function createEl(tag, attrs = {}, children = []) { const el = document.createElement(tag); for (const [key, value] of Object.entries(attrs)) { if (value === undefined || value === null) continue; if (key === 'className') { el.className = value; } else if (key === 'textContent') { el.textContent = value; } else if (key === 'style' && typeof value === 'object') { Object.assign(el.style, value); } else { el.setAttribute(key, value); } } for (const child of children) { if (child === undefined || child === null) continue; if (typeof child === 'string' || typeof child === 'number') { el.appendChild(document.createTextNode(String(child))); } else if (Array.isArray(child)) { child.forEach(c => el.appendChild(c)); } else { el.appendChild(child); } } return el; } // ============================================================ // 第二部分:配置管理 // ============================================================ const Config = { get(key, defaultVal) { return GM_getValue(key, defaultVal); }, set(key, val) { GM_setValue(key, val); }, getApiUrl() { return GM_getValue(CONFIG_KEYS.API_URL, 'http://127.0.0.1:6806'); }, getApiToken() { return GM_getValue(CONFIG_KEYS.API_TOKEN, ''); }, getNotebookId() { return GM_getValue(CONFIG_KEYS.NOTEBOOK_ID, ''); }, getNoteMap() { try { return JSON.parse(GM_getValue(CONFIG_KEYS.NOTE_MAP, '{}')); } catch { return {}; } }, setNoteMap(map) { GM_setValue(CONFIG_KEYS.NOTE_MAP, JSON.stringify(map)); }, getNoteEntry(pageKey) { const map = this.getNoteMap(); return map[pageKey] || null; }, setNoteEntry(pageKey, entry) { const map = this.getNoteMap(); map[pageKey] = entry; this.setNoteMap(map); }, isConfigured() { return !!this.getApiToken() && !!this.getNotebookId(); }, }; // ============================================================ // 第三部分:思源笔记 API 封装 // ============================================================ const SiYuan = { request(endpoint, data = null, method = 'POST') { const REQUEST_TIMEOUT = 10000; return Promise.race([ new Promise((resolve, reject) => { const url = Config.getApiUrl() + endpoint; console.log('[视频截图笔记] 请求:', method, url); const options = { method: method, url: url, headers: { 'Authorization': 'Token ' + Config.getApiToken(), }, timeout: REQUEST_TIMEOUT, onload: function (response) { var text = response.responseText || ''; try { if (!text) { reject(new Error('思源返回了空响应(HTTP ' + response.status + '),请检查 API 地址和 Token 是否正确')); return; } const result = JSON.parse(text); if (result.code === 0) { resolve(result.data); } else { reject(new Error(result.msg || 'API 返回错误 (code=' + result.code + ')')); } } catch (e) { reject(new Error('解析响应失败: ' + e.message + (text ? ' | 原始: ' + text.substring(0, 200) : ' | 响应体为空'))); } }, onerror: function (err) { console.error('[视频截图笔记] 请求错误:', err); reject(new Error('网络错误:无法连接到思源笔记(请确认思源笔记正在运行且 API 地址正确)')); }, ontimeout: function () { reject(new Error('请求超时:思源笔记未在 ' + (REQUEST_TIMEOUT / 1000) + ' 秒内响应')); }, }; if (data) { options.headers['Content-Type'] = 'application/json'; options.data = JSON.stringify(data); } GM_xmlhttpRequest(options); }), new Promise((_, reject) => { setTimeout(function () { reject(new Error('请求无响应:请确认思源笔记正在运行,API 地址为 ' + Config.getApiUrl())); }, REQUEST_TIMEOUT + 2000); }), ]); }, listNotebooks() { return this.request('/api/notebook/lsNotebooks'); }, createDocWithMd(notebook, path, markdown) { return this.request('/api/filetree/createDocWithMd', { notebook: notebook, path: path, markdown: markdown, }); }, appendBlock(parentID, markdown) { return this.request('/api/block/appendBlock', { dataType: 'markdown', data: markdown, parentID: parentID, }); }, async insertBlock(parentID, markdown) { const res = await this.request('/api/block/appendBlock', { dataType: 'markdown', data: markdown, parentID: parentID, }); if (res && res[0] && res[0].doOperations) { const op = res[0].doOperations.find(o => o.action === 'insert'); if (op) return op.id; } throw new Error("未能获取插入块的 ID"); }, updateBlock(id, markdown) { return this.request('/api/block/updateBlock', { id: id, dataType: 'markdown', data: markdown }); }, getDoc(docId) { return this.request('/api/filetree/getDoc', { id: docId, mode: 0, size: 36, }); }, _avKeyIDs: null, async _getAvKeyIDs() { if (this._avKeyIDs) return this._avKeyIDs; const response = await this.request('/api/av/getAttributeView', { id: NOTE_DB_ID }); const av = response.av || response; const keyIDs = {}; const keyValues = av.keyValues || []; for (const kv of keyValues) { const name = (kv.key && kv.key.name) || ''; const id = (kv.key && kv.key.id) || ''; if (name === 'custom-cleanurl') keyIDs.cleanurl = id; else if (name === 'custom-docId') keyIDs.docId = id; else if (name === 'custom-title') keyIDs.title = id; else if (name === 'custom-createdAt') keyIDs.createdAt = id; else if (name === 'custom-videoUrl') keyIDs.videoUrl = id; else if ((kv.key && kv.key.type) === 'block') keyIDs.block = id; } if (!keyIDs.cleanurl || !keyIDs.docId) { throw new Error('数据库中缺少 cleanurl 或 docId 列,请检查数据库设置'); } this._avKeyIDs = keyIDs; return keyIDs; }, async queryNoteByCleanUrl(cleanUrl) { const keyIDs = await this._getAvKeyIDs(); const response = await this.request('/api/av/getAttributeView', { id: NOTE_DB_ID }); const av = response.av || response; const rowMap = {}; const keyValues = av.keyValues || []; for (const kv of keyValues) { const keyID = (kv.key && kv.key.id) || ''; const values = kv.values || []; for (const v of values) { const bid = v.blockID; if (!bid) continue; if (!rowMap[bid]) rowMap[bid] = { id: bid, attrs: {} }; if (v.text && v.text.content !== undefined) { rowMap[bid].attrs[keyID] = v.text.content; } else if (v.block && v.block.content !== undefined) { rowMap[bid].attrs[keyID] = v.block.content; } } } for (const row of Object.values(rowMap)) { const attrs = row.attrs; if (attrs[keyIDs.cleanurl] === cleanUrl) { const docId = attrs[keyIDs.docId]; const title = attrs[keyIDs.title] || ''; if (docId) { return { rowId: row.id, docId: docId, title: title }; } } } return null; }, async insertNoteEntry(cleanUrl, docId, meta) { const keyIDs = await this._getAvKeyIDs(); const cells = []; if (keyIDs.block) cells.push({ keyID: keyIDs.block, block: { content: meta.videoTitle || '' } }); cells.push({ keyID: keyIDs.cleanurl, text: { content: cleanUrl } }); cells.push({ keyID: keyIDs.docId, text: { content: docId } }); if (keyIDs.title) cells.push({ keyID: keyIDs.title, text: { content: meta.videoTitle } }); if (keyIDs.createdAt) cells.push({ keyID: keyIDs.createdAt, text: { content: meta.captureTime } }); if (keyIDs.videoUrl) cells.push({ keyID: keyIDs.videoUrl, text: { content: meta.url } }); await this.request('/api/av/appendAttributeViewDetachedBlocksWithValues', { avID: NOTE_DB_ID, blocksValues: [cells], }); this._avKeyIDs = null; const updatedResponse = await this.request('/api/av/getAttributeView', { id: NOTE_DB_ID }); const updatedAv = updatedResponse.av || updatedResponse; const kv = (updatedAv.keyValues || []).find(function (k) { return k.key && k.key.name === 'custom-docId'; }); const values = kv ? (kv.values || []) : []; for (const v of values) { if (v.text && v.text.content === docId) { return v.blockID; } } return null; } }; // ============================================================ // 第三部分.1:Cloudflare 图床 API 封装 // ============================================================ const ImgBed = { /** * 上传图片到外部图床 * @param {string} dataURL - 截图的 base64 * @returns {Promise} 返回图床的公网图片 URL */ upload(dataURL) { return new Promise((resolve, reject) => { const blob = dataURLtoBlob(dataURL); const formData = new FormData(); formData.append('file', blob, `video-screenshot-${Date.now()}.png`); GM_xmlhttpRequest({ method: 'POST', url: 'https://domain.com/upload?returnFormat=full', headers: { 'Authorization': 'Bearer imgbed_0409b3df7ad38f2a5c901305fcdce3737c74e7fe48c5b721579c001f41d11bd0' }, data: formData, timeout: 30000, onload: function (response) { try { const result = JSON.parse(response.responseText); if (result && result.length > 0) { // 【修改点1】:去掉前面多余的域名拼接 const imgUrl = result[0].publicUrl || result[0].src; resolve(imgUrl); } else { reject(new Error('图床返回的数据格式异常')); } } catch (e) { reject(new Error('解析图床响应失败: ' + response.responseText)); } }, onerror: function (err) { reject(new Error('请求图床网络错误')); }, ontimeout: function () { reject(new Error('图床上传超时')); } }); }); } }; // ============================================================ // 第四部分:视频工具 // ============================================================ const VideoUtils = { getVideo() { if (window.location.hostname.includes('youtube.com')) { const mp = document.querySelector('#movie_player'); if (mp) { const v = mp.querySelector('video.html5-main-video') || mp.querySelector('video'); if (v && (v.videoWidth > 0 || v.readyState >= 2)) return v; } const allVideos = document.querySelectorAll('video'); for (const v of allVideos) { if (v.src && v.src.includes('googlevideo.com')) { if (v.videoWidth > 0 || v.readyState >= 2) return v; } } } const selectors = [ 'video.html5-main-video', 'video.bilibili-player-video', 'video.bpx-player-video', 'video', ]; for (const sel of selectors) { const v = document.querySelector(sel); if (v && v.videoWidth > 0) return v; } return document.querySelector('video'); }, getCurrentTime() { const video = this.getVideo(); if (!video) return null; return video.currentTime; }, getDuration() { const video = this.getVideo(); if (!video) return null; return video.duration; }, hasVideo() { return !!this.getVideo(); }, capture() { const video = this.getVideo(); if (!video) return null; if (!video.videoWidth || !video.videoHeight) return null; const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); try { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const dataUrl = canvas.toDataURL('image/png'); return dataUrl; } catch (e) { console.error('[视频截图笔记] 截图失败(可能是跨域限制):', e); return null; } }, getVideoSrc() { const video = this.getVideo(); if (!video) return ''; return video.src || video.currentSrc || ''; }, }; // ============================================================ // 第五部分:页面元数据提取 // ============================================================ const PageMeta = { getVideoTitle() { const biliTitle = document.querySelector('.video-title .tit, .video-title .tit-text, .video-title .title'); if (biliTitle) return biliTitle.textContent.trim(); const ytSelectors = [ '#title h1 yt-formatted-string', 'h1.ytd-watch-metadata yt-formatted-string', '#above-the-fold #title h1', '#info h1', ]; for (const sel of ytSelectors) { const el = document.querySelector(sel); if (el && el.textContent.trim()) return el.textContent.trim(); } let title = document.title || '未知视频'; if (window.location.hostname.includes('youtube.com')) { title = title.replace(/\s*-\s*YouTube\s*$/i, '').trim(); } return title; }, collect() { const video = VideoUtils.getVideo(); const currentTime = video ? video.currentTime : 0; const duration = video ? video.duration : 0; return { videoTitle: this.getVideoTitle(), pageTitle: document.title || '未知页面', url: window.location.href, cleanUrl: getCleanUrl(), domain: window.location.hostname, videoSrc: VideoUtils.getVideoSrc(), videoDuration: formatTime(duration), videoDurationSeconds: duration, currentTime: formatTime(currentTime), currentTimeSeconds: currentTime, captureTime: new Date().toLocaleString('zh-CN'), }; }, getTimestampedURL(seconds) { const url = new URL(window.location.href); url.hash = 't=' + Math.floor(seconds); return url.toString(); }, }; // ============================================================ // 第六部分:笔记管理器 // ============================================================ const NoteManager = { async getOrCreateNote() { const cleanUrl = getCleanUrl(); const meta = PageMeta.collect(); try { const existing = await SiYuan.queryNoteByCleanUrl(cleanUrl); if (existing && existing.docId) { try { await SiYuan.getDoc(existing.docId); return { docId: existing.docId, isNew: false }; } catch (e) { console.warn('[视频截图笔记] 数据库中的笔记已被删除,将创建新笔记'); } } } catch (e) { console.warn('[视频截图笔记] 数据库查询失败,将创建新笔记:', e.message); } const docId = await this.createNote(meta); return { docId: docId, isNew: true }; }, async createNote(meta) { const notebookId = Config.getNotebookId(); if (!notebookId) { throw new Error('未选择目标笔记本,请先在设置中配置'); } const docTitle = this.sanitizeTitle(meta.videoTitle) + ' - 视频笔记'; const markdown = this.buildNoteMarkdown(meta, docTitle); const path = '/' + docTitle; const docId = await SiYuan.createDocWithMd(notebookId, path, markdown); const cleanUrl = getCleanUrl(); try { await SiYuan.insertNoteEntry(cleanUrl, docId, meta); } catch (e) { console.error('[视频截图笔记] 数据库写入失败:', e); UI.showToast('笔记已创建,但数据库索引写入失败: ' + e.message, 'warning', 6000); } return docId; }, buildNoteMarkdown(meta, docTitle) { let md = ''; md += '# ' + escapeMd(docTitle || (meta.videoTitle + ' - 视频笔记')) + '\n\n'; md += '## 📹 视频信息\n\n'; md += '| 属性 | 值 |\n'; md += '|------|------|\n'; md += '| 视频标题 | ' + escapeMd(meta.videoTitle) + ' |\n'; md += '| 页面标题 | ' + escapeMd(meta.pageTitle) + ' |\n'; md += '| 来源链接 | [' + escapeMd(meta.domain) + '](' + meta.url + ') |\n'; md += '| 视频时长 | ' + meta.videoDuration + ' |\n'; md += '| 笔记创建时间 | ' + meta.captureTime + ' |\n'; md += '\n---\n\n'; md += '## 📸 截图记录\n\n'; md += '> 在视频播放过程中,使用快捷键或浮动按钮可将截图追加到此处。\n\n'; return md; function escapeMd(text) { if (!text) return ''; return text.replace(/\|/g, '\\|').replace(/\n/g, ' '); } }, /** * 追加截图到笔记 (乐观更新:上传到外部图床) */ async appendScreenshot(docId, dataURL, meta) { const jumpUrl = PageMeta.getTimestampedURL(meta.currentTimeSeconds); const titleMd = `### ⏱ ${meta.currentTime}\n\n`; const placeholderMd = `> ⏳ *正在上传截图至云端图床...* \n\n> 📌 [跳转到视频 ${meta.currentTime}](${jumpUrl})\n\n---`; let placeholderBlockId = null; try { await SiYuan.appendBlock(docId, titleMd); placeholderBlockId = await SiYuan.insertBlock(docId, placeholderMd); console.log('[视频截图笔记] 占位符插入成功:', placeholderBlockId); const publicImageUrl = await ImgBed.upload(dataURL); console.log('[视频截图笔记] 图床上传成功:', publicImageUrl); // 【修改点2】:将标准的 markdown 图片语法换成 HTML img 标签,并指定宽度 300px const finalImageMd = `视频截图\n\n> 📌 [跳转到视频 ${meta.currentTime}](${jumpUrl})\n\n---`; await SiYuan.updateBlock(placeholderBlockId, finalImageMd); } catch (error) { console.error('[视频截图笔记] 图床上传或更新失败:', error); if (placeholderBlockId) { // 同理,兜底的 base64 格式也修改为 300px 宽度 const fallbackMd = `${meta.currentTime}\n\n> 📌 [跳转到视频 ${meta.currentTime}](${jumpUrl})\n\n> ⚠️ *图床上传失败,已降级为本地 Base64*\n\n---`; try { await SiYuan.updateBlock(placeholderBlockId, fallbackMd); } catch (e) { console.error('兜底更新失败', e); } } else { throw error; } } }, sanitizeTitle(title) { return (title || '未知视频') .replace(/[\/\\:*?"<>|]/g, '_') .replace(/\s+/g, ' ') .trim() .substring(0, 100); }, }; // ============================================================ // 第七部分:UI 组件 // ============================================================ GM_addStyle(` .${SCRIPT_PREFIX}floating-btn { position: fixed; bottom: 80px; right: 30px; width: 48px; height: 48px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; border: none; cursor: pointer; font-size: 22px; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); z-index: 2147483646; transition: all 0.3s ease; user-select: none; } .${SCRIPT_PREFIX}floating-btn:hover { transform: scale(1.1); box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5); } .${SCRIPT_PREFIX}floating-btn.active { transform: rotate(45deg); } .${SCRIPT_PREFIX}menu { position: fixed; bottom: 140px; right: 30px; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15); padding: 8px; min-width: 200px; z-index: 2147483646; display: none; animation: ${SCRIPT_PREFIX}menu-in 0.2s ease; } .${SCRIPT_PREFIX}menu.show { display: block; } @keyframes ${SCRIPT_PREFIX}menu-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } .${SCRIPT_PREFIX}menu-item { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border: none; background: none; width: 100%; text-align: left; cursor: pointer; border-radius: 8px; font-size: 14px; color: #333; transition: background 0.2s; } .${SCRIPT_PREFIX}menu-item:hover { background: #f0f0f5; } .${SCRIPT_PREFIX}menu-item .icon { font-size: 18px; width: 24px; text-align: center; } .${SCRIPT_PREFIX}menu-item .shortcut { margin-left: auto; font-size: 11px; color: #aaa; } .${SCRIPT_PREFIX}menu-divider { height: 1px; background: #eee; margin: 4px 0; } .${SCRIPT_PREFIX}menu-status { padding: 8px 14px; font-size: 12px; color: #999; } .${SCRIPT_PREFIX}menu-status.ok { color: #4caf50; } .${SCRIPT_PREFIX}menu-status.warn { color: #ff9800; } .${SCRIPT_PREFIX}modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.4); z-index: 2147483647; display: none; align-items: center; justify-content: center; animation: ${SCRIPT_PREFIX}fade-in 0.2s ease; } .${SCRIPT_PREFIX}modal-overlay.show { display: flex; } @keyframes ${SCRIPT_PREFIX}fade-in { from { opacity: 0; } to { opacity: 1; } } .${SCRIPT_PREFIX}modal { background: #fff; border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); width: 480px; max-width: 90vw; max-height: 85vh; overflow-y: auto; animation: ${SCRIPT_PREFIX}modal-in 0.25s ease; } @keyframes ${SCRIPT_PREFIX}modal-in { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } .${SCRIPT_PREFIX}modal-header { padding: 20px 24px; border-bottom: 1px solid #eee; display: flex; align-items: center; justify-content: space-between; } .${SCRIPT_PREFIX}modal-header h2 { margin: 0; font-size: 18px; color: #333; } .${SCRIPT_PREFIX}modal-close { background: none; border: none; font-size: 22px; cursor: pointer; color: #999; line-height: 1; padding: 0; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: background 0.2s; } .${SCRIPT_PREFIX}modal-close:hover { background: #f0f0f0; } .${SCRIPT_PREFIX}modal-body { padding: 24px; } .${SCRIPT_PREFIX}form-group { margin-bottom: 18px; } .${SCRIPT_PREFIX}form-group label { display: block; margin-bottom: 6px; font-size: 14px; font-weight: 500; color: #333; } .${SCRIPT_PREFIX}form-group label .hint { font-size: 12px; color: #999; font-weight: normal; margin-left: 6px; } .${SCRIPT_PREFIX}form-group input, .${SCRIPT_PREFIX}form-group select { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; color: #333; box-sizing: border-box; transition: border-color 0.2s; background: #fff; } .${SCRIPT_PREFIX}form-group input:focus, .${SCRIPT_PREFIX}form-group select:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); } .${SCRIPT_PREFIX}form-group select:disabled { background: #f5f5f5; color: #999; cursor: not-allowed; } .${SCRIPT_PREFIX}btn { padding: 10px 20px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; transition: all 0.2s; font-weight: 500; } .${SCRIPT_PREFIX}btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; } .${SCRIPT_PREFIX}btn-primary:hover { opacity: 0.9; transform: translateY(-1px); } .${SCRIPT_PREFIX}btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .${SCRIPT_PREFIX}btn-secondary { background: #f0f0f5; color: #333; } .${SCRIPT_PREFIX}btn-secondary:hover { background: #e0e0e8; } .${SCRIPT_PREFIX}modal-footer { padding: 16px 24px; border-top: 1px solid #eee; display: flex; gap: 10px; justify-content: flex-end; } .${SCRIPT_PREFIX}connection-status { margin-top: 8px; font-size: 12px; padding: 6px 10px; border-radius: 6px; display: none; } .${SCRIPT_PREFIX}connection-status.show { display: block; } .${SCRIPT_PREFIX}connection-status.success { background: #e8f5e9; color: #2e7d32; } .${SCRIPT_PREFIX}connection-status.error { background: #ffebee; color: #c62828; } .${SCRIPT_PREFIX}help-text { margin-top: 16px; padding: 12px; background: #f8f9fa; border-radius: 8px; font-size: 12px; color: #666; line-height: 1.8; } .${SCRIPT_PREFIX}help-text strong { color: #333; } .${SCRIPT_PREFIX}toast { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); padding: 12px 24px; border-radius: 8px; font-size: 14px; color: #fff; z-index: 2147483647; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); animation: ${SCRIPT_PREFIX}toast-in 0.3s ease; max-width: 80vw; word-break: break-word; } @keyframes ${SCRIPT_PREFIX}toast-in { from { opacity: 0; transform: translateX(-50%) translateY(-20px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } } .${SCRIPT_PREFIX}toast.success { background: #4caf50; } .${SCRIPT_PREFIX}toast.error { background: #f44336; } .${SCRIPT_PREFIX}toast.info { background: #2196f3; } .${SCRIPT_PREFIX}toast.warning { background: #ff9800; } .${SCRIPT_PREFIX}loading-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.3); z-index: 2147483647; display: none; align-items: center; justify-content: center; } .${SCRIPT_PREFIX}loading-overlay.show { display: flex; } .${SCRIPT_PREFIX}spinner { width: 44px; height: 44px; border: 4px solid rgba(255, 255, 255, 0.3); border-top-color: #fff; border-radius: 50%; animation: ${SCRIPT_PREFIX}spin 0.8s linear infinite; } @keyframes ${SCRIPT_PREFIX}spin { to { transform: rotate(360deg); } } `); const UI = { floatingBtn: null, menu: null, modal: null, showToast(message, type = 'info', duration = 3000) { const toast = document.createElement('div'); toast.className = SCRIPT_PREFIX + 'toast ' + type; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; toast.style.transition = 'opacity 0.3s'; setTimeout(() => toast.remove(), 300); }, duration); }, showLoading(text) { let overlay = document.querySelector('.' + SCRIPT_PREFIX + 'loading-overlay'); if (!overlay) { overlay = createEl('div', { className: SCRIPT_PREFIX + 'loading-overlay' }, [ createEl('div', { style: { textAlign: 'center', color: '#fff' } }, [ createEl('div', { className: SCRIPT_PREFIX + 'spinner', style: { margin: '0 auto 12px' } }), createEl('div', { className: SCRIPT_PREFIX + 'loading-text', textContent: text || '处理中...' }), ]), ]); document.body.appendChild(overlay); } else { const textEl = overlay.querySelector('.' + SCRIPT_PREFIX + 'loading-text'); if (textEl) textEl.textContent = text || '处理中...'; } overlay.classList.add('show'); }, hideLoading() { const overlay = document.querySelector('.' + SCRIPT_PREFIX + 'loading-overlay'); if (overlay) overlay.classList.remove('show'); }, createFloatingButton() { if (this.floatingBtn) return; this.floatingBtn = document.createElement('button'); this.floatingBtn.className = SCRIPT_PREFIX + 'floating-btn'; this.floatingBtn.textContent = '📸'; this.floatingBtn.title = '视频截图笔记助手'; this.floatingBtn.addEventListener('click', (e) => { e.stopPropagation(); this.toggleMenu(); }); document.body.appendChild(this.floatingBtn); this.menu = document.createElement('div'); this.menu.className = SCRIPT_PREFIX + 'menu'; this.updateMenu(); document.body.appendChild(this.menu); document.addEventListener('click', (e) => { if (!this.menu.contains(e.target) && !this.floatingBtn.contains(e.target)) { this.hideMenu(); } }); }, updateMenu() { if (!this.menu) return; const configured = Config.isConfigured(); const hasVideo = VideoUtils.hasVideo(); const disabled = !configured || !hasVideo; while (this.menu.firstChild) { this.menu.removeChild(this.menu.firstChild); } const statusText = configured ? '✓ 思源笔记已连接' : '⚠ 请先配置思源笔记'; this.menu.appendChild(createEl('div', { className: SCRIPT_PREFIX + 'menu-status ' + (configured ? 'ok' : 'warn'), textContent: statusText, })); if (!hasVideo) { this.menu.appendChild(createEl('div', { className: SCRIPT_PREFIX + 'menu-status warn', textContent: '⚠ 未检测到视频', })); } this.menu.appendChild(createEl('div', { className: SCRIPT_PREFIX + 'menu-divider' })); const captureBtn = createEl('button', { className: SCRIPT_PREFIX + 'menu-item', 'data-action': 'capture', disabled: disabled ? '' : undefined, style: disabled ? { opacity: '0.5', cursor: 'not-allowed' } : undefined, }, [ createEl('span', { className: 'icon', textContent: '📸' }), createEl('span', { textContent: '截图并发送到思源' }), createEl('span', { className: 'shortcut', textContent: 'Ctrl+Shift+S' }), ]); this.menu.appendChild(captureBtn); const createBtn = createEl('button', { className: SCRIPT_PREFIX + 'menu-item', 'data-action': 'create', disabled: disabled ? '' : undefined, style: disabled ? { opacity: '0.5', cursor: 'not-allowed' } : undefined, }, [ createEl('span', { className: 'icon', textContent: '📝' }), createEl('span', { textContent: '创建视频笔记' }), ]); this.menu.appendChild(createBtn); this.menu.appendChild(createEl('div', { className: SCRIPT_PREFIX + 'menu-divider' })); const settingsBtn = createEl('button', { className: SCRIPT_PREFIX + 'menu-item', 'data-action': 'settings', }, [ createEl('span', { className: 'icon', textContent: '⚙️' }), createEl('span', { textContent: '设置' }), ]); this.menu.appendChild(settingsBtn); this.menu.querySelectorAll('.' + SCRIPT_PREFIX + 'menu-item').forEach(btn => { btn.addEventListener('click', () => { const action = btn.dataset.action; this.hideMenu(); switch (action) { case 'capture': Core.captureAndSend(); break; case 'create': Core.createVideoNote(); break; case 'settings': this.showSettings(); break; } }); }); }, toggleMenu() { if (this.menu.classList.contains('show')) { this.hideMenu(); } else { this.updateMenu(); this.menu.classList.add('show'); this.floatingBtn.classList.add('active'); } }, hideMenu() { if (this.menu) this.menu.classList.remove('show'); if (this.floatingBtn) this.floatingBtn.classList.remove('active'); }, showSettings() { if (this.modal) { this.modal.remove(); } this.modal = createEl('div', { className: SCRIPT_PREFIX + 'modal-overlay' }, [ createEl('div', { className: SCRIPT_PREFIX + 'modal' }, [ createEl('div', { className: SCRIPT_PREFIX + 'modal-header' }, [ createEl('h2', { textContent: '⚙️ 思源笔记设置' }), createEl('button', { className: SCRIPT_PREFIX + 'modal-close', textContent: '×' }), ]), createEl('div', { className: SCRIPT_PREFIX + 'modal-body' }, [ createEl('div', { className: SCRIPT_PREFIX + 'form-group' }, [ createEl('label', {}, [ 'API 地址 ', createEl('span', { className: 'hint', textContent: '(默认 http://127.0.0.1:6806)' }), ]), createEl('input', { type: 'text', id: SCRIPT_PREFIX + 'api-url', value: Config.getApiUrl(), placeholder: 'http://127.0.0.1:6806', }), ]), createEl('div', { className: SCRIPT_PREFIX + 'form-group' }, [ createEl('label', {}, [ 'API Token ', createEl('span', { className: 'hint', textContent: '(在思源笔记 → 设置 → 关于 中查看)' }), ]), createEl('input', { type: 'password', id: SCRIPT_PREFIX + 'api-token', value: Config.getApiToken(), placeholder: '输入 API Token', }), ]), createEl('button', { className: SCRIPT_PREFIX + 'btn ' + SCRIPT_PREFIX + 'btn-secondary', id: SCRIPT_PREFIX + 'test-btn', style: { width: '100%' }, textContent: '🔗 测试连接并加载笔记本', }), createEl('div', { className: SCRIPT_PREFIX + 'connection-status', id: SCRIPT_PREFIX + 'conn-status' }), createEl('div', { className: SCRIPT_PREFIX + 'form-group', style: { marginTop: '18px' } }, [ createEl('label', { textContent: '目标笔记本' }), createEl('select', { id: SCRIPT_PREFIX + 'notebook-select', disabled: '' }, [ createEl('option', { value: '', textContent: '请先测试连接' }), ]), ]), createEl('div', { className: SCRIPT_PREFIX + 'help-text' }, [ createEl('strong', { textContent: '使用说明:' }), createEl('br'), '1. 确保思源笔记正在运行', createEl('br'), '2. 在思源笔记「设置 → 关于」中复制 API Token', createEl('br'), '3. 点击「测试连接」加载笔记本列表', createEl('br'), '4. 选择目标笔记本后保存', createEl('br'), '5. 在视频页面按 ', createEl('strong', { textContent: 'Ctrl+Shift+S' }), ' 快速截图发送', createEl('br'), '6. 同一页面的截图会追加到同一笔记中', ]), ]), createEl('div', { className: SCRIPT_PREFIX + 'modal-footer' }, [ createEl('button', { className: SCRIPT_PREFIX + 'btn ' + SCRIPT_PREFIX + 'btn-secondary', id: SCRIPT_PREFIX + 'cancel-btn', textContent: '取消', }), createEl('button', { className: SCRIPT_PREFIX + 'btn ' + SCRIPT_PREFIX + 'btn-primary', id: SCRIPT_PREFIX + 'save-btn', textContent: '保存', }), ]), ]), ]); document.body.appendChild(this.modal); this.modal.classList.add('show'); if (Config.getApiToken()) { this.testConnection(); } const closeBtn = this.modal.querySelector('.' + SCRIPT_PREFIX + 'modal-close'); const cancelBtn = this.modal.querySelector('#' + SCRIPT_PREFIX + 'cancel-btn'); const saveBtn = this.modal.querySelector('#' + SCRIPT_PREFIX + 'save-btn'); const testBtn = this.modal.querySelector('#' + SCRIPT_PREFIX + 'test-btn'); const closeModal = () => { this.modal.remove(); this.modal = null; }; closeBtn.addEventListener('click', closeModal); cancelBtn.addEventListener('click', closeModal); this.modal.addEventListener('click', (e) => { if (e.target === this.modal) closeModal(); }); testBtn.addEventListener('click', () => this.testConnection()); saveBtn.addEventListener('click', () => { this.saveSettings(); closeModal(); }); }, async testConnection() { const apiUrl = document.querySelector('#' + SCRIPT_PREFIX + 'api-url').value.trim(); const apiToken = document.querySelector('#' + SCRIPT_PREFIX + 'api-token').value.trim(); const statusEl = document.querySelector('#' + SCRIPT_PREFIX + 'conn-status'); const selectEl = document.querySelector('#' + SCRIPT_PREFIX + 'notebook-select'); if (!apiUrl || !apiToken) { statusEl.className = SCRIPT_PREFIX + 'connection-status show error'; statusEl.textContent = '请填写 API 地址和 Token'; return; } Config.set(CONFIG_KEYS.API_URL, apiUrl); Config.set(CONFIG_KEYS.API_TOKEN, apiToken); statusEl.className = SCRIPT_PREFIX + 'connection-status show info'; statusEl.style.background = '#e3f2fd'; statusEl.style.color = '#1565c0'; statusEl.textContent = '正在连接...'; try { const result = await SiYuan.listNotebooks(); const notebooks = result.notebooks.filter(nb => !nb.closed); if (notebooks.length === 0) { statusEl.className = SCRIPT_PREFIX + 'connection-status show error'; statusEl.textContent = '连接成功,但没有打开的笔记本。请在思源笔记中打开一个笔记本。'; selectEl.disabled = true; selectEl.options.length = 0; selectEl.add(new Option('没有可用的笔记本', '')); return; } statusEl.className = SCRIPT_PREFIX + 'connection-status show success'; statusEl.textContent = '✓ 连接成功,共 ' + notebooks.length + ' 个笔记本'; const currentNotebookId = Config.getNotebookId(); selectEl.disabled = false; selectEl.options.length = 0; notebooks.forEach(nb => { const selected = nb.id === currentNotebookId; selectEl.add(new Option(nb.name, nb.id, selected, selected)); }); } catch (e) { statusEl.className = SCRIPT_PREFIX + 'connection-status show error'; statusEl.textContent = '✗ 连接失败: ' + e.message; selectEl.disabled = true; selectEl.options.length = 0; selectEl.add(new Option('连接失败', '')); } }, saveSettings() { const apiUrl = document.querySelector('#' + SCRIPT_PREFIX + 'api-url').value.trim(); const apiToken = document.querySelector('#' + SCRIPT_PREFIX + 'api-token').value.trim(); const notebookId = document.querySelector('#' + SCRIPT_PREFIX + 'notebook-select').value; Config.set(CONFIG_KEYS.API_URL, apiUrl); Config.set(CONFIG_KEYS.API_TOKEN, apiToken); Config.set(CONFIG_KEYS.NOTEBOOK_ID, notebookId); this.showToast('设置已保存', 'success'); this.updateMenu(); }, showFloatingButton() { if (this.floatingBtn) { this.floatingBtn.style.display = 'flex'; } }, hideFloatingButton() { if (this.floatingBtn) { this.floatingBtn.style.display = 'none'; this.hideMenu(); } }, }; // ============================================================ // 第八部分:核心功能 // ============================================================ const Core = { async captureAndSend() { if (!Config.isConfigured()) { UI.showToast('请先配置思源笔记设置', 'warning'); UI.showSettings(); return; } if (!VideoUtils.hasVideo()) { UI.showToast('当前页面未检测到视频', 'warning'); return; } const meta = PageMeta.collect(); if (meta.currentTimeSeconds == null || isNaN(meta.currentTimeSeconds)) { UI.showToast('无法读取视频时间戳', 'error'); return; } const dataURL = VideoUtils.capture(); if (!dataURL) { UI.hideLoading(); UI.showToast('截图失败(可能是跨域限制,请尝试使用浏览器扩展截图)', 'error', 5000); return; } try { const { docId, isNew } = await NoteManager.getOrCreateNote(); // 追加截图(内部实现了乐观更新插入和云端后台上传) await NoteManager.appendScreenshot(docId, dataURL, meta); UI.hideLoading(); const msg = isNew ? '✓ 已创建新笔记并追加截图 (' + meta.currentTime + ')' : '✓ 截图已追加到笔记 (' + meta.currentTime + '),正在后台上传云端图床...'; UI.showToast(msg, 'success'); } catch (e) { UI.hideLoading(); console.error('[视频截图笔记] 发送失败:', e); UI.showToast('发送失败: ' + e.message, 'error', 5000); } }, async createVideoNote() { if (!Config.isConfigured()) { UI.showToast('请先配置思源笔记设置', 'warning'); UI.showSettings(); return; } if (!VideoUtils.hasVideo()) { UI.showToast('当前页面未检测到视频', 'warning'); return; } UI.showLoading('正在创建视频笔记...'); try { const meta = PageMeta.collect(); const docId = await NoteManager.createNote(meta); UI.hideLoading(); const siyuanUrl = 'siyuan://blocks/' + docId; UI.showToast('✓ 视频笔记已创建(' + meta.videoTitle + ')', 'success', 4000); console.log('[视频截图笔记] 笔记已创建:', siyuanUrl); } catch (e) { UI.hideLoading(); console.error('[视频截图笔记] 创建笔记失败:', e); UI.showToast('创建笔记失败: ' + e.message, 'error', 5000); } }, }; // ============================================================ // 第九部分:快捷键监听 // ============================================================ function setupHotkeys() { document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.shiftKey && (e.key === 'S' || e.key === 's')) { e.preventDefault(); e.stopPropagation(); Core.captureAndSend(); } }); } // ============================================================ // 第十部分:视频检测与初始化 // ============================================================ function init() { console.log('[视频截图笔记] 脚本已加载 v1.2.1'); GM_registerMenuCommand('⚙️ 思源笔记设置', () => UI.showSettings()); GM_registerMenuCommand('📸 截图并发送 (Ctrl+Shift+S)', () => Core.captureAndSend()); GM_registerMenuCommand('📝 创建视频笔记', () => Core.createVideoNote()); setupHotkeys(); let checkTimer = null; function checkVideo() { if (checkTimer) clearTimeout(checkTimer); checkTimer = setTimeout(() => { UI.createFloatingButton(); UI.showFloatingButton(); }, 500); } checkVideo(); const observer = new MutationObserver(() => { checkVideo(); }); observer.observe(document.documentElement, { childList: true, subtree: true, }); let lastURL = window.location.href; setInterval(() => { if (window.location.href !== lastURL) { lastURL = window.location.href; checkVideo(); } }, 1000); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();