Greasy Fork is available in English.
快速捕获网页正文内容,完整保留原文格式(包括各级标题、加粗斜体、图片表格、超链接等)。支持自由编辑、AI总结,支持一键复制和另存为Word文档两个选项。修复了部分网页图片捕获失败、另存为word图片保存失败的问题,通过多线程并发将图片下载到内存后再另存,另存后自动将图片从内存中清除。
// ==UserScript== // @name 网页内容超级捕手 // @namespace http://tampermonkey.net/ // @version 1.1.1 // @description 快速捕获网页正文内容,完整保留原文格式(包括各级标题、加粗斜体、图片表格、超链接等)。支持自由编辑、AI总结,支持一键复制和另存为Word文档两个选项。修复了部分网页图片捕获失败、另存为word图片保存失败的问题,通过多线程并发将图片下载到内存后再另存,另存后自动将图片从内存中清除。 // @author Mrchen // @match *://*/* // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @connect * // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.min.js // @require https://cdn.jsdelivr.net/npm/@mozilla/[email protected]/Readability.js // @require https://cdn.jsdelivr.net/npm/[email protected]/marked.min.js // @run-at document-idle // ==/UserScript== (function() { 'use strict'; if (window.top !== window.self) return; if (window.__WEB_EXTRACTOR_INJECTED__) return; window.__WEB_EXTRACTOR_INJECTED__ = true; console.log('[Web Content Extractor] 插件已成功注入当前页面'); if (typeof Readability === 'undefined') { console.error('[Web Content Extractor] 致命错误: Readability.js 核心库未加载成功,请检查网络。'); } if (typeof marked === 'undefined') { console.warn('[Web Content Extractor] 警告: marked.js (Markdown解析库) 未加载成功,AI 总结将降级为普通文本显示。'); } // ========================================== // 1. 配置管理 // ========================================== const DEFAULT_CONFIG = { apiUrl: 'https://api.deepseek.com/chat/completions', apiKey: '', apiModel: 'deepseek-chat', apiPrompt: '请帮我精简总结以下网页内容,提取核心观点和关键信息,使用Markdown格式进行排版:\n\n', uiTheme: 'dark', // Word 导出专属设置 wordFontCn: '楷体', wordFontEn: 'Times New Roman', wordSizeBody: '12pt', // 小四 wordSizeH1: '18pt', // 小二 wordSizeH2: '16pt', // 三号 wordSizeH3: '14pt' // 四号 }; function getConfig() { return { apiUrl: GM_getValue('apiUrl', DEFAULT_CONFIG.apiUrl), apiKey: GM_getValue('apiKey', DEFAULT_CONFIG.apiKey), apiModel: GM_getValue('apiModel', DEFAULT_CONFIG.apiModel), apiPrompt: GM_getValue('apiPrompt', DEFAULT_CONFIG.apiPrompt), uiTheme: GM_getValue('uiTheme', DEFAULT_CONFIG.uiTheme), wordFontCn: GM_getValue('wordFontCn', DEFAULT_CONFIG.wordFontCn), wordFontEn: GM_getValue('wordFontEn', DEFAULT_CONFIG.wordFontEn), wordSizeBody: GM_getValue('wordSizeBody', DEFAULT_CONFIG.wordSizeBody), wordSizeH1: GM_getValue('wordSizeH1', DEFAULT_CONFIG.wordSizeH1), wordSizeH2: GM_getValue('wordSizeH2', DEFAULT_CONFIG.wordSizeH2), wordSizeH3: GM_getValue('wordSizeH3', DEFAULT_CONFIG.wordSizeH3), }; } function setConfig(key, value) { GM_setValue(key, value); } if (typeof GM_registerMenuCommand !== 'undefined') { GM_registerMenuCommand('⚙️ 系统设置', openSettingsModal); } // ========================================== // 2. 初始化 Shadow DOM 与核心 UI // ========================================== if (!document.documentElement) return; const host = document.createElement('div'); host.id = 'web-extractor-root'; host.style.cssText = 'position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647; pointer-events: none; overflow: visible; display: block;'; document.documentElement.appendChild(host); const shadow = host.attachShadow({ mode: 'closed' }); const style = document.createElement('style'); style.textContent = ` :host { font-family: "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --transition-speed: 0.3s; } /* ----- 主题变量定义 ----- */ .theme-dark { --bg: rgba(15, 23, 42, 0.85); --bg-solid: #0f172a; --text: #f8fafc; --text-muted: #94a3b8; --border: rgba(51, 65, 85, 0.8); --primary: #00f0ff; --primary-hover: #00c3cc; --primary-bg: rgba(0, 240, 255, 0.1); --shadow: 0 10px 40px rgba(0, 0, 0, 0.5), 0 0 20px rgba(0, 240, 255, 0.15); --toast-bg: rgba(0, 0, 0, 0.9); --modal-bg: rgba(0, 0, 0, 0.6); --glass-blur: blur(12px); --btn-md-bg: rgba(255,255,255,0.1); --btn-md-hover: rgba(255,255,255,0.2); } .theme-light { --bg: rgba(255, 255, 255, 0.9); --bg-solid: #ffffff; --text: #1e293b; --text-muted: #64748b; --border: rgba(226, 232, 240, 0.9); --primary: #3b82f6; --primary-hover: #2563eb; --primary-bg: rgba(59, 130, 246, 0.1); --shadow: 0 10px 30px rgba(0, 0, 0, 0.08), 0 4px 6px rgba(0, 0, 0, 0.04); --toast-bg: rgba(30, 41, 59, 0.9); --modal-bg: rgba(0, 0, 0, 0.3); --glass-blur: blur(16px); --btn-md-bg: rgba(0,0,0,0.05); --btn-md-hover: rgba(0,0,0,0.1); } .theme-yellow { --bg: rgba(250, 246, 233, 0.95); --bg-solid: #faf6e9; --text: #3f3f3b; --text-muted: #78716c; --border: rgba(214, 211, 201, 0.8); --primary: #65a30d; --primary-hover: #4d7c0f; --primary-bg: rgba(101, 163, 13, 0.1); --shadow: 0 10px 30px rgba(0, 0, 0, 0.06); --toast-bg: rgba(63, 63, 59, 0.9); --modal-bg: rgba(0, 0, 0, 0.3); --glass-blur: blur(10px); --btn-md-bg: rgba(0,0,0,0.05); --btn-md-hover: rgba(0,0,0,0.1); } /* 触发条 */ #trigger-bar { position: fixed; right: 0; top: 40vh; width: 6px; height: 60px; background: var(--primary); border-radius: 8px 0 0 8px; cursor: pointer; pointer-events: auto; transition: width var(--transition-speed), box-shadow var(--transition-speed), transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1); box-shadow: 0 0 10px var(--primary-bg); z-index: 2147483647; user-select: none; } #trigger-bar:hover { width: 14px; box-shadow: 0 0 15px var(--primary); } /* 主面板 */ #main-panel { position: fixed; right: 20px; top: 5vh; width: 440px; height: 90vh; min-width: 320px; min-height: 400px; max-width: 95vw; max-height: 95vh; background: var(--bg); backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); border-radius: 16px; box-shadow: var(--shadow); border: 1px solid var(--border); display: flex; flex-direction: column; pointer-events: auto; transform: translateX(120%); transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); overflow: hidden; z-index: 2147483647; resize: both; /* 允许自由拉伸大小 */ } #main-panel.open { transform: translateX(0); } /* 顶部区域 */ #header { padding: 10px 16px; background: rgba(0,0,0,0.04); border-bottom: 1px solid var(--border); display: flex; gap: 10px; cursor: move; align-items: center; user-select: none; } .tabs { display: flex; gap: 4px; flex: 1; } .tab-btn { background: transparent; border: none; color: var(--text-muted); padding: 8px 12px; font-size: 14px; font-weight: 600; border-radius: 6px; cursor: pointer; transition: all 0.2s ease; } .tab-btn:hover { color: var(--text); background: rgba(128,128,128,0.1); } .tab-btn.active { background: var(--primary-bg); color: var(--primary); box-shadow: 0 0 8px var(--primary-bg); } .icon-btn { background: transparent; border: 1px solid transparent; border-radius: 8px; color: var(--text-muted); cursor: pointer; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; font-size: 16px; transition: all 0.2s; } .icon-btn:hover { color: var(--text); background: rgba(128,128,128,0.1); } #btn-clear:hover { color: #ef4444; background: rgba(239, 68, 68, 0.1); } #content-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; position: relative; } /* 独立视图区 */ .view-content { padding: 20px 15px 20px 20px; overflow-y: auto; color: var(--text); font-size: 15px; line-height: 1.7; word-wrap: break-word; display: none; } .view-content.active { display: block; flex: 1; height: 100%; box-sizing: border-box; } .view-content[contenteditable="true"]:focus { outline: none; box-shadow: inset 0 0 0 2px var(--primary-bg); border-radius: 8px; } /* 美化并强化滚动条以便于拖动 */ .view-content::-webkit-scrollbar { width: 8px; } .view-content::-webkit-scrollbar-track { background: rgba(128,128,128,0.1); border-radius: 4px; } .view-content::-webkit-scrollbar-thumb { background: rgba(128,128,128,0.4); border-radius: 4px; } .view-content::-webkit-scrollbar-thumb:hover { background: var(--primary); } /* 排版格式 */ .view-content h1 { font-size: 1.6em; font-weight: 700; margin: 0.5em 0; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; } .view-content h2 { font-size: 1.4em; font-weight: 600; margin: 0.8em 0 0.5em; color: var(--primary); } .view-content h3 { font-size: 1.2em; font-weight: 600; margin: 0.6em 0; } .view-content p { margin-bottom: 1em; } .view-content img { max-width: 100%; height: auto; border-radius: 8px; display: block; margin: 15px 0; box-shadow: 0 4px 12px rgba(0,0,0,0.1); } .view-content a { color: var(--primary); text-decoration: none; border-bottom: 1px dashed var(--primary); padding-bottom: 1px; } .view-content a:hover { text-decoration: solid; } .view-content pre { background: var(--bg-solid); padding: 12px; border-radius: 8px; border: 1px solid var(--border); overflow-x: auto; margin-bottom: 1em; } .view-content code { background: var(--bg-solid); color: #ec4899; padding: 2px 6px; border-radius: 4px; font-family: 'Fira Code', monospace; font-size: 0.9em; border: 1px solid var(--border); } .view-content blockquote { border-left: 4px solid var(--primary); margin: 0 0 1em 0; padding: 8px 12px; background: var(--primary-bg); border-radius: 0 8px 8px 0; color: var(--text-muted); } .view-content ul, .view-content ol { margin-bottom: 1em; padding-left: 20px; } /* 底部 */ #footer { padding: 14px 16px; border-top: 1px solid var(--border); background: rgba(0,0,0,0.02); display:flex; gap: 10px; align-items: stretch;} .copy-action-btn { flex: 1; padding: 10px 4px; color: #fff; border: none; border-radius: 8px; font-size: 13px; font-weight: 600; letter-spacing: 0.5px; cursor: pointer; transition: all 0.2s; display: flex; justify-content: center; align-items: center; gap: 4px; } #copy-btn { background: var(--btn-md-bg); color: var(--text); border: 1px solid var(--border); } #copy-btn:hover { background: var(--btn-md-hover); transform: translateY(-1px); } #save-md-btn { background: var(--btn-md-bg); color: var(--text); border: 1px solid var(--border); } #save-md-btn:hover { background: var(--btn-md-hover); transform: translateY(-1px); } #save-word-btn { background: var(--primary); box-shadow: 0 4px 12px var(--primary-bg); } #save-word-btn:hover { filter: brightness(1.1); transform: translateY(-1px); } #save-word-btn:active { transform: translateY(1px); } .loader { display: inline-block; width: 14px; height: 14px; border: 2px solid transparent; border-radius: 50%; border-top-color: currentColor; border-left-color: currentColor; animation: spin 0.8s linear infinite; vertical-align: middle; margin-right: 6px; } @keyframes spin { to { transform: rotate(360deg); } } .loading-text { text-align: center; color: var(--text-muted); margin-top: 60px; font-size: 14px; letter-spacing: 0.5px;} #toast-container { position: absolute; bottom: 80px; left: 50%; transform: translateX(-50%) translateY(10px); background: var(--toast-bg); color: #fff; padding: 10px 20px; border-radius: 30px; font-size: 13px; opacity: 0; pointer-events: none; transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1); z-index: 10; white-space: nowrap; box-shadow: 0 4px 15px rgba(0,0,0,0.2); font-weight: 500; } #toast-container.show { opacity: 1; transform: translateX(-50%) translateY(0); } /* 设置弹窗 */ #settings-modal { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: var(--modal-bg); display: none; justify-content: center; align-items: center; z-index: 9999; pointer-events: auto; backdrop-filter: blur(4px); } #settings-modal.show { display: flex; } .modal-content { background: var(--bg-solid); padding: 30px; border-radius: 16px; width: 90%; max-width: 480px; max-height: 85vh; overflow-y: auto; box-shadow: 0 20px 50px rgba(0,0,0,0.3); color: var(--text); border: 1px solid var(--border); } .modal-content::-webkit-scrollbar { width: 6px; } .modal-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; } .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; } .modal-header h3 { margin: 0; font-size: 18px; font-weight: 600; color: var(--text); } .form-group { margin-bottom: 18px; } .form-group label { display: block; font-weight: 500; margin-bottom: 6px; font-size: 13px; color: var(--text-muted);} .form-group input, .form-group textarea, .form-group select { width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: var(--text); box-sizing: border-box; font-family: inherit; font-size: 14px; outline: none; } .form-group input:focus, .form-group textarea:focus, .form-group select:focus { border-color: var(--primary); } .form-group select { appearance: none; background-color: var(--bg); cursor: pointer;} .form-group textarea { resize: vertical; min-height: 80px; } .section-title { font-size: 14px; font-weight: 600; color: var(--primary); margin: 25px 0 15px 0; border-bottom: 1px dashed var(--border); padding-bottom: 8px;} .grid-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .save-btn { width: 100%; padding: 12px; background: var(--primary); color: #fff; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; margin-top: 10px; transition: 0.2s; } .save-btn:hover { filter: brightness(1.1); } .ai-cursor { display: inline-block; width: 8px; height: 16px; background-color: var(--primary); animation: blink 1s step-end infinite; vertical-align: middle; margin-left: 4px; border-radius: 2px; } @keyframes blink { 0%, 100% { opacity: 1; box-shadow: 0 0 5px var(--primary); } 50% { opacity: 0; box-shadow: none; } } .ai-block { padding: 16px; background: var(--primary-bg); border-left: 4px solid var(--primary); border-radius: 0 8px 8px 0; margin-top: 15px; line-height: 1.8; } .stop-btn { background: transparent; border: 1px solid #ef4444; color: #ef4444; border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 12px; transition: 0.2s; } .stop-btn:hover { background: rgba(239, 68, 68, 0.1); } #btn-toggle-key { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: transparent; border: none; cursor: pointer; font-size: 16px; padding: 0; opacity: 0.6; transition: 0.2s; color: var(--text); } #btn-toggle-key:hover { opacity: 1; } `; shadow.appendChild(style); const wrapper = document.createElement('div'); wrapper.id = 'app-wrapper'; wrapper.innerHTML = ` <div id="trigger-bar"></div> <div id="main-panel"> <div id="header"> <div class="tabs"> <button class="tab-btn active" id="tab-extract">📄 一键提取</button> <button class="tab-btn" id="tab-ai">✨ AI 总结</button> </div> <div style="display:flex; gap:6px;"> <button class="tab-btn" id="btn-refresh" title="强制重新执行当前标签任务" style="padding: 6px 10px; font-size: 12px;">🔄 重新执行</button> <button class="tab-btn" id="btn-clear" title="清空当前页内容" style="padding: 6px 10px; font-size: 12px;">🗑️ 清空</button> <button class="icon-btn" id="close-btn" title="收起面板">×</button> </div> </div> <div id="content-area"> <div id="view-extract" class="view-content active" contenteditable="false"> <div class="loading-text">点击上方「📄 一键提取」开始提取,<br>提取后的内容可<b>自由编辑,或点击🗑️清空</b>。</div> </div> <div id="view-ai" class="view-content" contenteditable="false"> <div class="loading-text">请先在「📄 一键提取」标签下提取网页内容。<br>切换到本标签即可自动生成总结。</div> </div> </div> <div id="toast-container"></div> <div id="footer"> <button class="icon-btn" id="settings-btn" title="系统设置" style="width:40px; height:40px; border:1px solid var(--border);">⚙️</button> <button id="copy-btn" class="copy-action-btn">📋 复制</button> <button id="save-md-btn" class="copy-action-btn">📝 存为 MD</button> <button id="save-word-btn" class="copy-action-btn">💾 存为 Word</button> </div> </div> <!-- 设置弹窗 --> <div id="settings-modal"> <div class="modal-content"> <div class="modal-header"> <h3>设置面板</h3> <button class="icon-btn close-modal" id="close-modal-btn" style="font-size:24px;">×</button> </div> <div class="form-group"> <label>UI 主题风格</label> <select id="cfg-theme"> <option value="dark">🌌 科技黑 (Dark)</option> <option value="light">☁️ 极简白 (Light) </option> <option value="yellow">📜 护眼黄 (Yellow) </option> </select> </div> <div class="form-group"> <label>AI 请求地址 (URL)</label> <input type="text" id="cfg-url" placeholder="https://api.deepseek.com/chat/completions"> </div> <div class="form-group"> <label>API Key (仅在您的本地保存)</label> <div style="position: relative;"> <input type="password" id="cfg-key" placeholder="sk-..." style="width: 100%; box-sizing: border-box; padding-right: 36px;"> <button type="button" id="btn-toggle-key" title="显示/隐藏">👁️</button> </div> </div> <div class="form-group"> <label>AI 模型名称</label> <input type="text" id="cfg-model" placeholder="deepseek-chat"> </div> <div class="form-group"> <label>系统提示词 (Prompt)</label> <textarea id="cfg-prompt" placeholder="请将以下内容进行精简总结..."></textarea> </div> <div class="section-title">📄 Word 导出格式设置</div> <div class="grid-2col"> <div class="form-group"> <label>中文字体</label> <select id="cfg-word-cn"> <option value="宋体">宋体</option> <option value="黑体">黑体</option> <option value="楷体">楷体</option> <option value="仿宋">仿宋</option> <option value="微软雅黑">微软雅黑</option> </select> </div> <div class="form-group"> <label>英文字体</label> <select id="cfg-word-en"> <option value="Times New Roman">Times New Roman</option> <option value="Arial">Arial</option> <option value="Calibri">Calibri</option> <option value="Helvetica">Helvetica</option> <option value="Tahoma">Tahoma</option> </select> </div> </div> <div class="grid-2col"> <div class="form-group"> <label>正文及三级以下标题字号</label> <select id="cfg-word-sz-body"> <option value="26pt">一号</option> <option value="24pt">小一</option> <option value="22pt">二号</option> <option value="18pt">小二</option> <option value="16pt">三号</option> <option value="15pt">小三</option> <option value="14pt">四号</option> <option value="12pt">小四</option> <option value="10.5pt">五号</option> <option value="9pt">小五</option> </select> </div> <div class="form-group"> <label>一级标题字号</label> <select id="cfg-word-sz-h1"> <option value="26pt">一号</option> <option value="24pt">小一</option> <option value="22pt">二号</option> <option value="18pt">小二</option> <option value="16pt">三号</option> <option value="15pt">小三</option> <option value="14pt">四号</option> <option value="12pt">小四</option> <option value="10.5pt">五号</option> <option value="9pt">小五</option> </select> </div> </div> <div class="grid-2col"> <div class="form-group"> <label>二级标题字号</label> <select id="cfg-word-sz-h2"> <option value="26pt">一号</option> <option value="24pt">小一</option> <option value="22pt">二号</option> <option value="18pt">小二</option> <option value="16pt">三号</option> <option value="15pt">小三</option> <option value="14pt">四号</option> <option value="12pt">小四</option> <option value="10.5pt">五号</option> <option value="9pt">小五</option> </select> </div> <div class="form-group"> <label>三级标题字号</label> <select id="cfg-word-sz-h3"> <option value="26pt">一号</option> <option value="24pt">小一</option> <option value="22pt">二号</option> <option value="18pt">小二</option> <option value="16pt">三号</option> <option value="15pt">小三</option> <option value="14pt">四号</option> <option value="12pt">小四</option> <option value="10.5pt">五号</option> <option value="9pt">小五</option> </select> </div> </div> <button class="save-btn" id="save-cfg-btn">保存并应用设置</button> </div> </div> `; shadow.appendChild(wrapper); // ========================================== // 3. UI 交互逻辑及状态机 // ========================================== const appWrapper = shadow.querySelector('#app-wrapper'); const triggerBar = shadow.querySelector('#trigger-bar'); const mainPanel = shadow.querySelector('#main-panel'); const header = shadow.querySelector('#header'); const tabExtract = shadow.querySelector('#tab-extract'); const tabAi = shadow.querySelector('#tab-ai'); const viewExtract = shadow.querySelector('#view-extract'); const viewAi = shadow.querySelector('#view-ai'); const btnRefresh = shadow.querySelector('#btn-refresh'); const btnClear = shadow.querySelector('#btn-clear'); const btnClose = shadow.querySelector('#close-btn'); const btnCopy = shadow.querySelector('#copy-btn'); const btnSaveMd = shadow.querySelector('#save-md-btn'); const btnSaveWord = shadow.querySelector('#save-word-btn'); const btnSettings = shadow.querySelector('#settings-btn'); const toastContainer = shadow.querySelector('#toast-container'); const modal = shadow.querySelector('#settings-modal'); const btnToggleKey = shadow.querySelector('#btn-toggle-key'); const inputKey = shadow.querySelector('#cfg-key'); let isPanelOpen = false; let currentTab = 'extract'; let extractHasData = false; let aiNeedsUpdate = true; let currentAbortController = null; btnToggleKey.addEventListener('click', () => { if (inputKey.type === 'password') { inputKey.type = 'text'; btnToggleKey.textContent = '🙈'; } else { inputKey.type = 'password'; btnToggleKey.textContent = '👁️'; } }); function applyTheme(themeName) { appWrapper.className = `theme-${themeName}`; } applyTheme(getConfig().uiTheme); const togglePanel = (forceOpen = null) => { isPanelOpen = forceOpen !== null ? forceOpen : !isPanelOpen; if (isPanelOpen) { mainPanel.classList.add('open'); triggerBar.style.transform = 'translateX(100%)'; } else { mainPanel.classList.remove('open'); triggerBar.style.transform = 'translateX(0)'; } }; let isTriggerDragging = false; let triggerDragStartY; let triggerInitialTop; triggerBar.addEventListener('mousedown', (e) => { isTriggerDragging = false; triggerDragStartY = e.clientY; triggerInitialTop = triggerBar.getBoundingClientRect().top; const onMouseMove = (moveEvent) => { const dy = moveEvent.clientY - triggerDragStartY; if (Math.abs(dy) > 3) { isTriggerDragging = true; let newTop = triggerInitialTop + dy; newTop = Math.max(0, Math.min(newTop, window.innerHeight - triggerBar.offsetHeight)); triggerBar.style.top = `${newTop}px`; triggerBar.style.transition = 'none'; } }; const onMouseUp = () => { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); triggerBar.style.transition = 'width var(--transition-speed), box-shadow var(--transition-speed), transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1)'; if (!isTriggerDragging) { togglePanel(true); } }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); e.preventDefault(); }); btnClose.addEventListener('click', () => togglePanel(false)); document.addEventListener('mousedown', (e) => { if (isPanelOpen && !host.contains(e.target) && modal.style.display !== 'flex') togglePanel(false); }); const showToast = (msg, duration = 2500) => { toastContainer.textContent = msg; toastContainer.classList.add('show'); setTimeout(() => toastContainer.classList.remove('show'), duration); }; let isDragging = false, dragStartX, dragStartY, initialRight, initialTop; header.addEventListener('mousedown', (e) => { if (e.target.tagName.toLowerCase() === 'button') return; isDragging = true; dragStartX = e.clientX; dragStartY = e.clientY; const rect = mainPanel.getBoundingClientRect(); initialRight = window.innerWidth - rect.right; initialTop = rect.top; mainPanel.style.transition = 'none'; }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; const panelWidth = mainPanel.offsetWidth; let newRight = Math.max(0, Math.min(initialRight + (dragStartX - e.clientX), window.innerWidth - panelWidth)); let newTop = Math.max(0, Math.min(initialTop + (e.clientY - dragStartY), window.innerHeight - header.offsetHeight)); mainPanel.style.right = `${newRight}px`; mainPanel.style.top = `${newTop}px`; }); document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; mainPanel.style.transition = 'transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1)'; } }); // ========================================== // 4. 多标签页核心路由与逻辑 // ========================================== function switchTab(target) { currentTab = target; if (target === 'extract') { tabExtract.classList.add('active'); tabAi.classList.remove('active'); viewExtract.classList.add('active'); viewAi.classList.remove('active'); if (!extractHasData) runExtract(); } else if (target === 'ai') { tabAi.classList.add('active'); tabExtract.classList.remove('active'); viewAi.classList.add('active'); viewExtract.classList.remove('active'); if (!extractHasData) { showToast('⚠️ 暂无内容,请先提取网页内容'); switchTab('extract'); return; } if (aiNeedsUpdate) { runAI(); } } } tabExtract.addEventListener('click', () => switchTab('extract')); tabAi.addEventListener('click', () => switchTab('ai')); btnRefresh.addEventListener('click', () => { if (currentTab === 'extract') runExtract(); else if (currentTab === 'ai') runAI(true); }); btnClear.addEventListener('click', () => { if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; } if (currentTab === 'extract') { extractHasData = false; aiNeedsUpdate = true; viewExtract.removeAttribute('contenteditable'); viewExtract.innerHTML = '<div class="loading-text" style="margin-top: 100px;">🗑️ 网页内容已清空<br><br>请点击上方「🔄 重新执行」</div>'; viewAi.removeAttribute('contenteditable'); viewAi.innerHTML = '<div class="loading-text">请先在「一键提取」标签下提取文章。<br>切换到本标签即可自动生成总结。</div>'; showToast('🗑️ 正文内容已清空'); } else if (currentTab === 'ai') { viewAi.removeAttribute('contenteditable'); viewAi.innerHTML = '<div class="loading-text" style="margin-top: 100px;">🗑️ 总结内容已清空<br><br>点击上方「🔄 重新执行」可再次生成</div>'; aiNeedsUpdate = false; viewAi.removeAttribute('data-raw-markdown'); // 清空备份的纯文本 markdown showToast('🗑️ 总结内容已清空'); } }); // 监听用户输入事件 viewExtract.addEventListener('input', () => { aiNeedsUpdate = true; }); viewAi.addEventListener('input', () => { viewAi.removeAttribute('data-raw-markdown'); }); // ========================================== // 5. 模块:正文提取 // ========================================== function runExtract() { if (typeof Readability === 'undefined') { viewExtract.innerHTML = '<div class="loading-text" style="color:#ef4444;">❌ Readability引擎未能加载,请检查网络。</div>'; return; } viewExtract.removeAttribute('contenteditable'); viewExtract.innerHTML = '<div class="loading-text"><span class="loader"></span>正在分析网页深层结构...</div>'; setTimeout(() => { try { let article; try { const documentClone = document.cloneNode(true); const reader = new Readability(documentClone, { keepClasses: false, debug: false }); article = reader.parse(); } catch (cloneErr) { console.warn("[Web Content Extractor] 启动 DOM 净化降级方案..."); const cleanDoc = document.implementation.createHTMLDocument(document.title); cleanDoc.body.innerHTML = document.body.innerHTML; const reader2 = new Readability(cleanDoc, { keepClasses: false, debug: false }); article = reader2.parse(); } if (article && article.content) { const cleanHTML = DOMPurify.sanitize(article.content, { ALLOWED_TAGS: ['h1','h2','h3','h4','h5','h6','p','a','ul','ol','li','b','i','strong','em','strike','code','hr','br','div','table','thead','tbody','tr','th','td','pre','blockquote','img'], ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'data-src', 'data-original', 'srcset'] }); viewExtract.innerHTML = ` <h1>${article.title || '无标题文章'}</h1> ${article.byline ? `<p style="color:var(--text-muted); font-size: 0.9em; margin-top:-5px;">作者/来源: ${article.byline}</p>` : ''} <hr style="border:0; border-top: 1px solid var(--border); margin: 15px 0;"> ${cleanHTML} `; extractHasData = true; aiNeedsUpdate = true; viewExtract.setAttribute('contenteditable', 'true'); showToast('✅ 提取成功,内容支持直接编辑'); } else throw new Error("未能识别到网页主体。"); } catch (err) { viewExtract.innerHTML = `<div class="loading-text" style="color:#ef4444;">❌ 提取失败<br><br><small>${err.message}</small></div>`; } }, 300); } // ========================================== // 6. 模块:流式 AI 总结 // ========================================== function runAI(force = false) { if (currentAbortController) { if (force) { currentAbortController.abort(); } else return; } const currentText = viewExtract.innerText.trim(); if (!currentText || !extractHasData) return; const config = getConfig(); if (!config.apiKey) { openSettingsModal(); showToast('⚠️ 请先配置 API Key'); switchTab('extract'); return; } aiNeedsUpdate = false; viewAi.removeAttribute('contenteditable'); viewAi.removeAttribute('data-raw-markdown'); // 清空旧的 Markdown viewAi.innerHTML = ` <h3 style="color: var(--primary); margin-top:0; display:flex; justify-content:space-between; align-items:center;"> <span>🤖 AI 智能总结</span> <button id="btn-stop-ai" class="stop-btn">⏹️ 停止生成</button> </h3> <div id="ai-output" class="ai-block"> <span style="color:var(--text-muted); font-style:italic;" id="ai-status">正在连接 AI 服务...</span><span class="ai-cursor"></span> </div> `; const btnStopAi = shadow.querySelector('#btn-stop-ai'); const statusSpan = shadow.querySelector('#ai-status'); const outputDiv = shadow.querySelector('#ai-output'); const payload = { model: config.apiModel, messages: [ { role: "system", content: "你是一个专业的阅读助手,擅长提取文章核心内容。请直接输出精简总结,使用markdown排版。" }, { role: "user", content: config.apiPrompt + "\n\n" + currentText.substring(0, 12000) } ], temperature: 0.5, stream: true }; const thisAbortController = new AbortController(); currentAbortController = thisAbortController; let fullText = ""; let streamBuffer = ""; let isDone = false; let lastIndex = 0; let isStream = true; let streamHandled = false; let streamReader = null; let hasFinished = false; // 统一提取渲染函数 const renderOutput = (text) => { if (typeof marked !== 'undefined') { try { let parsedHTML = marked.parse(text); outputDiv.innerHTML = DOMPurify.sanitize(parsedHTML) + '<span class="ai-cursor"></span>'; } catch (e) { let fallbackHTML = text.replace(/\n/g, '<br>').replace(/\*\*(.*?)\*\*/g, '<strong style="color:var(--text);">$1</strong>').replace(/\*(.*?)\*/g, '<em>$1</em>'); outputDiv.innerHTML = fallbackHTML + '<span class="ai-cursor"></span>'; } } else { let fallbackHTML = text.replace(/\n/g, '<br>').replace(/\*\*(.*?)\*\*/g, '<strong style="color:var(--text);">$1</strong>').replace(/\*(.*?)\*/g, '<em>$1</em>'); outputDiv.innerHTML = fallbackHTML + '<span class="ai-cursor"></span>'; } if (currentTab === 'ai') viewAi.scrollTop = viewAi.scrollHeight; }; const processChunk = (chunk) => { // 检测是否非流式同步返回(如配置报错或模型不支持流式) if (fullText.length === 0 && streamBuffer.length === 0 && chunk.trim().startsWith('{') && !chunk.includes('data: ')) { isStream = false; } if (!isStream) { streamBuffer += chunk; return; } streamBuffer += chunk; const lines = streamBuffer.split('\n'); streamBuffer = lines.pop() || ""; for (let line of lines) { const trimmedLine = line.trim(); if (!trimmedLine) continue; if (trimmedLine === 'data: [DONE]') { isDone = true; break; } if (trimmedLine.startsWith('data: ')) { const jsonStr = trimmedLine.substring(6).trim(); if (!jsonStr || jsonStr === '[DONE]') continue; try { const data = JSON.parse(jsonStr); const content = data.choices?.[0]?.delta?.content || ""; if (content) { fullText += content; renderOutput(fullText); } } catch (e) { // 忽略单个数据块的 JSON 解析错误,防止断流 } } } }; const cleanUp = () => { if (currentAbortController === thisAbortController) { if (btnStopAi && btnStopAi.parentNode) btnStopAi.remove(); currentAbortController = null; viewAi.setAttribute('contenteditable', 'true'); } }; const finishGeneration = () => { if (hasFinished) return; hasFinished = true; if (!isStream) { try { const data = JSON.parse(streamBuffer); fullText = data.choices?.[0]?.message?.content || ""; renderOutput(fullText); showToast('💡 当前模型不支持流式,已为您同步生成'); } catch (e) { outputDiv.innerHTML = `<span style="color:#ef4444;">❌ 解析响应数据失败或模型未响应正确内容</span>`; } } else if (streamBuffer.trim() && !isDone) { processChunk('\n'); } const cursor = shadow.querySelector('.ai-cursor'); if (cursor) cursor.remove(); viewAi.dataset.rawMarkdown = fullText; cleanUp(); }; // 绑定手动停止事件 btnStopAi.addEventListener('click', () => { if (currentAbortController === thisAbortController) { thisAbortController.abort(); showToast('⏹️ 用户手动终止生成'); } }); const requestObj = GM_xmlhttpRequest({ method: 'POST', url: config.apiUrl, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.apiKey}`, 'Accept': 'application/json, text/event-stream' }, data: JSON.stringify(payload), responseType: 'stream', onreadystatechange: async function(response) { if (thisAbortController.signal.aborted) return; // 不能在 onload 中读取流。必须在 readyState === 2 或 3 时尽早拦截 if (!streamHandled && response.response instanceof ReadableStream) { streamHandled = true; if (statusSpan && statusSpan.parentNode) statusSpan.remove(); try { streamReader = response.response.getReader(); const decoder = new TextDecoder('utf-8'); while (!isDone) { const { value, done } = await streamReader.read(); if (done) { isDone = true; break; } if (thisAbortController.signal.aborted) { streamReader.cancel(); break; } const chunkStr = decoder.decode(value, { stream: true }); processChunk(chunkStr); } finishGeneration(); } catch (e) { outputDiv.innerHTML = `<span style="color:#ef4444;">❌ 生成失败: 流读取异常 (${e.message})</span>`; cleanUp(); } return; } // 兜底方案:老旧环境若不支持 ReadableStream 返回,采用传统的 responseText 切片渐进读取 if (!streamHandled && (response.readyState === 3 || response.readyState === 4)) { if (statusSpan && statusSpan.parentNode) statusSpan.remove(); const currentResponseText = response.responseText || ""; if (currentResponseText.length > lastIndex) { const newText = currentResponseText.substring(lastIndex); lastIndex = currentResponseText.length; processChunk(newText); } if (response.readyState === 4) { finishGeneration(); } } }, onload: function(response) { if (thisAbortController.signal.aborted) return; // HTTP 状态非 200 的报错处理 if (response.status !== 200) { let errorMsg = `HTTP 错误状态: ${response.status}`; try { const errData = JSON.parse(response.responseText || "{}"); errorMsg = errData.error?.message || errorMsg; } catch(e){} outputDiv.innerHTML = `<span style="color:#ef4444;">❌ 生成失败: ${errorMsg}</span>`; cleanUp(); return; } // 仅作为防丢帧措施:如果既不支持 ReadableStream 也没有触发 readyState = 3 if (!streamHandled && lastIndex === 0) { if (statusSpan && statusSpan.parentNode) statusSpan.remove(); const text = response.responseText || ""; if (text) processChunk(text); finishGeneration(); } }, onerror: function(error) { if (thisAbortController.signal.aborted) return; outputDiv.innerHTML = `<span style="color:#ef4444;">❌ 生成失败: 网络请求异常 (请检查API地址、网络或代理环境)</span>`; cleanUp(); } }); // 监听 AbortController 发出的终止信号切断底层网络请求 thisAbortController.signal.addEventListener('abort', () => { requestObj.abort(); if (statusSpan) { statusSpan.textContent = '⏹️ 已手动终止生成'; statusSpan.style.color = '#ef4444'; } const cursor = shadow.querySelector('.ai-cursor'); if (cursor) cursor.remove(); cleanUp(); }); } // ========================================== // 7. 设置面板模块 // ========================================== function openSettingsModal() { const config = getConfig(); shadow.querySelector('#cfg-theme').value = config.uiTheme; shadow.querySelector('#cfg-url').value = config.apiUrl; shadow.querySelector('#cfg-key').value = config.apiKey; inputKey.type = 'password'; btnToggleKey.textContent = '👁️'; shadow.querySelector('#cfg-model').value = config.apiModel; shadow.querySelector('#cfg-prompt').value = config.apiPrompt; // Word 导出设置反填 shadow.querySelector('#cfg-word-cn').value = config.wordFontCn; shadow.querySelector('#cfg-word-en').value = config.wordFontEn; shadow.querySelector('#cfg-word-sz-body').value = config.wordSizeBody; shadow.querySelector('#cfg-word-sz-h1').value = config.wordSizeH1; shadow.querySelector('#cfg-word-sz-h2').value = config.wordSizeH2; shadow.querySelector('#cfg-word-sz-h3').value = config.wordSizeH3; modal.classList.add('show'); togglePanel(true); } btnSettings.addEventListener('click', openSettingsModal); shadow.querySelector('#close-modal-btn').addEventListener('click', () => modal.classList.remove('show')); shadow.querySelector('#save-cfg-btn').addEventListener('click', () => { const selectedTheme = shadow.querySelector('#cfg-theme').value; setConfig('uiTheme', selectedTheme); setConfig('apiUrl', shadow.querySelector('#cfg-url').value.trim()); setConfig('apiKey', inputKey.value.trim()); setConfig('apiModel', shadow.querySelector('#cfg-model').value.trim()); setConfig('apiPrompt', shadow.querySelector('#cfg-prompt').value.trim()); // 保存 Word 导出设置,若用户清空则回落为默认值 setConfig('wordFontCn', shadow.querySelector('#cfg-word-cn').value.trim() || '楷体'); setConfig('wordFontEn', shadow.querySelector('#cfg-word-en').value.trim() || 'Times New Roman'); setConfig('wordSizeBody', shadow.querySelector('#cfg-word-sz-body').value.trim() || '12pt'); setConfig('wordSizeH1', shadow.querySelector('#cfg-word-sz-h1').value.trim() || '18pt'); setConfig('wordSizeH2', shadow.querySelector('#cfg-word-sz-h2').value.trim() || '16pt'); setConfig('wordSizeH3', shadow.querySelector('#cfg-word-sz-h3').value.trim() || '14pt'); applyTheme(selectedTheme); modal.classList.remove('show'); showToast('✅ 设置已应用'); }); // ========================================== // 8. 复制模块 (Markdown 与 Word 智能路由) // ========================================== function getActiveView() { return currentTab === 'extract' ? viewExtract : viewAi; } // --- 提取内容的公共函数 (确保复制和导出MD的格式100%一致) --- function extractTextAndHtml() { const targetView = getActiveView(); let cleanNode = targetView.cloneNode(true); const stopBtn = cleanNode.querySelector('#btn-stop-ai'); if (stopBtn) stopBtn.remove(); // 移除 GitHub 中因为洗掉类名变为纯粹空 a 标签的锚点 cleanNode.querySelectorAll('a').forEach(a => { if (!a.textContent.trim() && !a.querySelector('img')) { a.remove(); } }); // 移除所有的空块级元素,防止富文本粘贴时Markdown解析器产生幽灵空行 cleanNode.querySelectorAll('p, div, h1, h2, h3, h4, h5, h6, li, span').forEach(el => { if (!el.textContent.trim() && !el.querySelector('img') && !el.querySelector('canvas')) { el.remove(); } }); // 在复制前把图片的相对路径修正为绝对路径 const images = cleanNode.querySelectorAll('img'); images.forEach(img => { let src = img.getAttribute('src') || img.getAttribute('data-src') || img.getAttribute('data-original'); if (src && !src.startsWith('data:')) { try { if (src.startsWith('//')) src = window.location.protocol + src; else if (src.startsWith('/')) src = window.location.origin + src; else if (!src.startsWith('http')) src = new URL(src, window.location.href).href; img.src = src; // 写回标准的绝对路径 } catch (e) {} } }); // 提取富文本内容 const cleanHTML = cleanNode.innerHTML; // 提取纯文本内容 let cleanText = ""; if (currentTab === 'ai' && viewAi.dataset.rawMarkdown) { // 如果在 AI 界面并且存在原始 Markdown,则优先采用原始 Markdown,防止 # 丢失 cleanText = viewAi.dataset.rawMarkdown; } else { // 针对 GitHub / README 等纯文本编辑器环境,手动构建简易 Markdown 结构以保留语义 const mdNode = cleanNode.cloneNode(true); // 必须先处理图片,否则后续 a 标签的处理会抹除 img 元素 mdNode.querySelectorAll('img').forEach(img => { const alt = img.getAttribute('alt') || 'image'; let src = img.src; if (src && src.includes('github.com') && src.includes('/blob/')) { src = src.replace(/\/blob\//, '/raw/'); } const span = document.createElement('span'); span.textContent = src ? `` : ''; img.replaceWith(span); }); mdNode.querySelectorAll('h1').forEach(h => h.prepend('# ')); mdNode.querySelectorAll('h2').forEach(h => h.prepend('## ')); mdNode.querySelectorAll('h3').forEach(h => h.prepend('### ')); mdNode.querySelectorAll('h4,h5,h6').forEach(h => h.prepend('#### ')); mdNode.querySelectorAll('strong, b').forEach(s => { s.prepend('**'); s.append('**'); }); mdNode.querySelectorAll('em, i').forEach(e => { e.prepend('*'); e.append('*'); }); mdNode.querySelectorAll('li').forEach(li => li.prepend('* ')); mdNode.querySelectorAll('blockquote').forEach(bq => bq.prepend('> ')); mdNode.querySelectorAll('code').forEach(c => { if(c.parentElement.tagName !== 'PRE') { c.prepend('`'); c.append('`'); } }); mdNode.querySelectorAll('pre').forEach(p => { p.prepend('```\n'); p.append('\n```'); }); // 处理链接:此时 innerText 已包含图片的 Markdown 标记 mdNode.querySelectorAll('a').forEach(a => { let href = a.getAttribute('href'); if(href && !href.startsWith('javascript:')) { // 链接地址也同步修复 GitHub 路径 if (href.includes('github.com') && href.includes('/blob/')) { href = href.replace(/\/blob\//, '/raw/'); } const linkText = a.textContent.trim() || 'link'; a.textContent = `[${linkText}](${href})`; } }); // 挂载并提取 innerText 以获得正确的换行排版 mdNode.style.position = 'absolute'; mdNode.style.left = '-9999px'; mdNode.style.whiteSpace = 'pre-wrap'; document.body.appendChild(mdNode); cleanText = mdNode.innerText; document.body.removeChild(mdNode); // 彻底消除由于 DOM 结构转换产生的多余空白行 cleanText = cleanText.replace(/(\r?\n[\t ]*){3,}/g, '\n\n'); } return { cleanHTML, cleanText }; } // --- 一键复制 (富文本/纯文本双轨复制) --- btnCopy.addEventListener('click', async () => { if (currentTab === 'ai' && currentAbortController) { showToast('⚠️ AI 生成未完成,无法完整复制'); return; } if (!extractHasData) { showToast('⚠️ 暂无内容可复制'); return; } const originalText = btnCopy.textContent; btnCopy.textContent = '🔄 复制中...'; try { const { cleanHTML, cleanText } = extractTextAndHtml(); if (typeof ClipboardItem !== 'undefined') { await navigator.clipboard.write([new ClipboardItem({ 'text/html': new Blob([cleanHTML], { type: 'text/html' }), 'text/plain': new Blob([cleanText], { type: 'text/plain' }) })]); } else { await navigator.clipboard.writeText(cleanText); } showToast('✅ 已复制到剪贴板'); btnCopy.textContent = '✅ 复制成功'; } catch (err) { showToast('❌ 剪贴板受限,请手动框选复制'); } setTimeout(() => { btnCopy.textContent = originalText; }, 2000); }); // --- 另存为 Markdown (下载为 .md) --- btnSaveMd.addEventListener('click', async () => { if (currentTab === 'ai' && currentAbortController) { showToast('⚠️ AI 生成未完成,无法完整处理'); return; } if (!extractHasData) { showToast('⚠️ 暂无内容可保存'); return; } const originalText = btnSaveMd.textContent; btnSaveMd.textContent = '🔄 准备处理...'; try { const { cleanText } = extractTextAndHtml(); const targetView = getActiveView(); const h1Element = targetView.querySelector('h1'); const documentTitle = h1Element ? h1Element.innerText.trim().replace(/[\\/:*?"<>|]/g, '') : (currentTab === 'ai' ? 'AI生成总结' : '网页提取内容'); const filename = documentTitle + '.md'; // 加入 BOM (\ufeff) 有助于兼容部分编辑器对 utf-8 编码的识别 const blob = new Blob(['\ufeff', cleanText], { type: 'text/markdown;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 3000); showToast('✅ 已成功保存为 Markdown 文档'); btnSaveMd.textContent = '✅ 保存成功'; } catch (err) { console.error(err); showToast('❌ 保存失败,请重试'); } finally { setTimeout(() => { btnSaveMd.textContent = originalText; }, 2000); } }); // --- 图片转换为 Base64 助手函数 --- function fetchImageAsBase64(url, maxWidth) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'blob', timeout: 10000, ontimeout: function() { reject(new Error('Image download timeout: ' + url)); }, onload: function(response) { if (response.status !== 200) { reject(new Error('Network response was not ok, status: ' + response.status)); return; } const blob = response.response; const objectUrl = URL.createObjectURL(blob); const img = new Image(); img.onload = function() { let width = img.width; let height = img.height; if (width > maxWidth) { height = Math.round((maxWidth / width) * height); width = maxWidth; } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#FFFFFF'; ctx.fillRect(0, 0, width, height); ctx.drawImage(img, 0, 0, width, height); const dataURL = canvas.toDataURL('image/jpeg', 0.85); URL.revokeObjectURL(objectUrl); resolve(dataURL); }; img.onerror = function(e) { URL.revokeObjectURL(objectUrl); reject(e); }; img.src = objectUrl; }, onerror: function(err) { reject(err); } }); }); } // --- 另存为 Word (下载为 .doc) --- btnSaveWord.addEventListener('click', async () => { if (btnSaveWord.dataset.processing === 'true') { showToast('⏳ 正在处理中,请勿重复点击'); return; } if (currentTab === 'ai' && currentAbortController) { showToast('⚠️ AI 生成未完成,无法完整处理'); return; } if (!extractHasData) { showToast('⚠️ 暂无内容可保存'); return; } btnSaveWord.dataset.processing = 'true'; // 加锁 const targetView = getActiveView(); const originalText = btnSaveWord.textContent; btnSaveWord.textContent = '🔄 准备处理...'; await new Promise(resolve => setTimeout(resolve, 30)); try { const config = getConfig(); let cloneNode = targetView.cloneNode(true); const stopBtn = cloneNode.querySelector('#btn-stop-ai'); if (stopBtn) stopBtn.remove(); const wordCSS = ` body, p, div, span, li, a, h1, h2, h3, h4, h5, h6 { font-family: "${config.wordFontEn}", "${config.wordFontCn}", serif; mso-ascii-font-family: "${config.wordFontEn}"; mso-fareast-font-family: "${config.wordFontCn}"; mso-hansi-font-family: "${config.wordFontEn}"; } code, pre { font-family: 'Courier New', Courier, monospace; mso-ascii-font-family: 'Courier New'; mso-fareast-font-family: "${config.wordFontCn}"; background-color: #f3f4f6; padding: 4pt; font-size: ${config.wordSizeBody}; } img { max-width: 600px; height: auto; } `; const elements = cloneNode.querySelectorAll('*'); elements.forEach(el => { const tag = el.tagName.toLowerCase(); if (['p', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag)) { el.style.marginTop = '6pt'; el.style.marginBottom = '6pt'; el.style.lineHeight = '1.5'; } if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag)) { el.style.fontWeight = 'bold'; el.style.marginTop = '12pt'; } if (tag === 'h1') { el.style.fontSize = config.wordSizeH1; } else if (tag === 'h2') { el.style.fontSize = config.wordSizeH2; } else if (tag === 'h3') { el.style.fontSize = config.wordSizeH3; } else if (['h4', 'h5', 'h6', 'p', 'span', 'div', 'li'].includes(tag)) { el.style.fontSize = config.wordSizeBody; } }); const images = cloneNode.querySelectorAll('img'); const tasks = []; images.forEach(img => { let src = img.getAttribute('src') || img.getAttribute('data-src') || img.getAttribute('data-original'); if (!src || src.startsWith('data:')) return; if (src.startsWith('//')) src = window.location.protocol + src; else if (src.startsWith('/')) src = window.location.origin + src; else if (!src.startsWith('http')) src = new URL(src, window.location.href).href; tasks.push({ img, src }); }); const totalTasks = tasks.length; let completedTasks = 0; if (totalTasks > 0) { btnSaveWord.textContent = `🔄 处理图片 (0/${totalTasks})...`; const MAX_CONCURRENT = 5; let currentIndex = 0; const worker = async () => { while (currentIndex < totalTasks) { const taskIndex = currentIndex++; const { img, src } = tasks[taskIndex]; try { const base64Data = await fetchImageAsBase64(src, 600); img.src = base64Data; img.removeAttribute('srcset'); img.removeAttribute('data-src'); img.removeAttribute('data-original'); img.removeAttribute('loading'); } catch (e) { console.warn('[Web Content Extractor] 图片转Base64失败,保留原图链接:', src, e); } finally { completedTasks++; btnSaveWord.textContent = `🔄 处理图片 (${completedTasks}/${totalTasks})...`; } } }; const workers = Array.from({ length: Math.min(MAX_CONCURRENT, totalTasks) }).map(() => worker()); await Promise.all(workers); } btnSaveWord.textContent = '🔄 正在生成文档...'; const h1Element = cloneNode.querySelector('h1'); const documentTitle = h1Element ? h1Element.innerText.trim().replace(/[\\/:*?"<>|]/g, '') : (currentTab === 'ai' ? 'AI生成总结' : '网页提取内容'); const filename = documentTitle + '.doc'; const htmlHeader = `<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'> <head> <meta charset='utf-8'> <title>${documentTitle}</title> <style>${wordCSS}</style> </head><body>`; const htmlFooter = `</body></html>`; let fullHTML = htmlHeader + cloneNode.innerHTML + htmlFooter; fullHTML = fullHTML.replace(/"/g, "'"); const blob = new Blob(['\ufeff', fullHTML], { type: 'application/msword' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 3000); showToast('✅ 已成功保存为 Word 文档'); btnSaveWord.textContent = '✅ 保存成功'; btnSaveWord.style.background = '#10b981'; } catch (err) { console.error(err); showToast('❌ 保存失败,请重试'); } finally { delete btnSaveWord.dataset.processing; setTimeout(() => { btnSaveWord.textContent = originalText; btnSaveWord.style.background = ''; }, 2000); } }); })();