// ==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/dompurify@3.0.6/dist/purify.min.js
// @require https://cdn.jsdelivr.net/npm/@mozilla/readability@0.5.0/Readability.js
// @require https://cdn.jsdelivr.net/npm/marked@4.3.0/marked.min.js
// @run-at document-idle
// @downloadURL https://update.greasyfork.icu/scripts/573345/%E7%BD%91%E9%A1%B5%E5%86%85%E5%AE%B9%E8%B6%85%E7%BA%A7%E6%8D%95%E6%89%8B.user.js
// @updateURL https://update.greasyfork.icu/scripts/573345/%E7%BD%91%E9%A1%B5%E5%86%85%E5%AE%B9%E8%B6%85%E7%BA%A7%E6%8D%95%E6%89%8B.meta.js
// ==/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 = `
请先在「📄 一键提取」标签下提取网页内容。
切换到本标签即可自动生成总结。
📄 Word 导出格式设置
`;
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 = '🗑️ 网页内容已清空
请点击上方「🔄 重新执行」
';
viewAi.removeAttribute('contenteditable');
viewAi.innerHTML = '请先在「一键提取」标签下提取文章。
切换到本标签即可自动生成总结。
';
showToast('🗑️ 正文内容已清空');
} else if (currentTab === 'ai') {
viewAi.removeAttribute('contenteditable');
viewAi.innerHTML = '🗑️ 总结内容已清空
点击上方「🔄 重新执行」可再次生成
';
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 = '❌ Readability引擎未能加载,请检查网络。
';
return;
}
viewExtract.removeAttribute('contenteditable');
viewExtract.innerHTML = '正在分析网页深层结构...
';
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 = `
${article.title || '无标题文章'}
${article.byline ? `作者/来源: ${article.byline}
` : ''}
${cleanHTML}
`;
extractHasData = true;
aiNeedsUpdate = true;
viewExtract.setAttribute('contenteditable', 'true');
showToast('✅ 提取成功,内容支持直接编辑');
} else throw new Error("未能识别到网页主体。");
} catch (err) {
viewExtract.innerHTML = `❌ 提取失败
${err.message}
`;
}
}, 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 = `
🤖 AI 智能总结
正在连接 AI 服务...
`;
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) + '';
} catch (e) {
let fallbackHTML = text.replace(/\n/g, '
').replace(/\*\*(.*?)\*\*/g, '$1').replace(/\*(.*?)\*/g, '$1');
outputDiv.innerHTML = fallbackHTML + '';
}
} else {
let fallbackHTML = text.replace(/\n/g, '
').replace(/\*\*(.*?)\*\*/g, '$1').replace(/\*(.*?)\*/g, '$1');
outputDiv.innerHTML = fallbackHTML + '';
}
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 = `❌ 解析响应数据失败或模型未响应正确内容`;
}
} 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 = `❌ 生成失败: 流读取异常 (${e.message})`;
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 = `❌ 生成失败: ${errorMsg}`;
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 = `❌ 生成失败: 网络请求异常 (请检查API地址、网络或代理环境)`;
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 = `
${documentTitle}
`;
const htmlFooter = ``;
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);
}
});
})();