// ==UserScript== // @name 番茄作家网 · 章节同步助手 // @namespace fanqie-novel-sync // @version 1.0.0 // @description 从本地JSON批量上传章节到番茄作家网,支持断点续传、进度保存、可视化操作 // @author Pluto // @match https://fanqienovel.com/main/writer/* // @icon https://lf-lv-buz.qingting.fm/bucket/fanqienovel-common-icon/favicon.ico // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @connect 127.0.0.1 // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ============================================================ // 常量 // ============================================================ const STORAGE_KEY = 'fanqie_sync_chapters'; const PROGRESS_KEY = 'fanqie_sync_progress'; const SETTINGS_KEY = 'fanqie_sync_settings'; const LOG_KEY = 'fanqie_sync_logs'; const DELAY = 3500; const MAX_LOGS = 500; // ============================================================ // 工具函数 // ============================================================ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } function getBookId() { const m = location.pathname.match(/\/writer\/(\d+)/); return m ? m[1] : null; } function extractTitle(fullTitle) { const m = fullTitle.match(/^第[零一二三四五六七八九十百千\d]+章\s*(.+)/); return m ? m[1] : fullTitle; } function extractBody(content) { const lines = content.split('\n').filter(l => l.trim().length > 0); if (lines[0] && lines[0].includes('第') && lines[0].includes('章')) { return lines.slice(1).join('\n'); } return lines.join('\n'); } function contentToHtml(content) { return content.split('\n') .filter(l => l.trim().length > 0) .map(l => `

${l.trim().replace(//g, '>')}

`) .join(''); } function now() { return new Date().toLocaleTimeString('zh-CN', { hour12: false }); } // React 受控输入兼容写法 function setNativeValue(el, value) { const proto = Object.getPrototypeOf(el); const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set || Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; if (setter) setter.call(el, value); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } // ============================================================ // 数据管理 // ============================================================ function loadChapters() { return GM_getValue(STORAGE_KEY, null); } function saveChapters(chapters) { GM_setValue(STORAGE_KEY, chapters); } function loadProgress() { return GM_getValue(PROGRESS_KEY, { lastSynced: 0, errors: [] }); } function saveProgress(p) { GM_setValue(PROGRESS_KEY, p); } function loadSettings() { return GM_getValue(SETTINGS_KEY, { localServer: 'http://127.0.0.1:19888', autoSaveLog: true }); } function saveSettings(s) { GM_setValue(SETTINGS_KEY, s); } // 日志持久化 function loadLogs() { return GM_getValue(LOG_KEY, []); } function saveLogs(logs) { if (logs.length > MAX_LOGS) logs = logs.slice(-MAX_LOGS); GM_setValue(LOG_KEY, logs); } function appendLog(msg, type = 'info') { const logs = loadLogs(); logs.push({ time: now(), msg, type, ts: Date.now() }); saveLogs(logs); } // ============================================================ // 样式 // ============================================================ GM_addStyle(` /* ===== 番茄同步助手 面板 ===== */ #fq-sync-panel { position: fixed; right: 16px; bottom: 16px; z-index: 99999; width: 420px; max-height: 85vh; background: #fff; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.1); font-family: "Microsoft YaHei","PingFang SC","Noto Sans CJK SC",sans-serif; font-size: 13px; color: #333; line-height: 1.5; display: flex; flex-direction: column; overflow: hidden; transition: all 0.3s ease; } #fq-sync-panel.collapsed { width: 56px; height: 56px; border-radius: 50%; cursor: pointer; } #fq-sync-panel.collapsed .fq-body, #fq-sync-panel.collapsed .fq-header-text { display: none; } #fq-sync-panel.collapsed .fq-toggle-btn { right: 14px; } .fq-header { background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); color: #fff; padding: 12px 16px; display: flex; align-items: center; flex-shrink: 0; position: relative; min-height: 44px; } .fq-header-text h3 { font-size: 15px; font-weight: 600; letter-spacing: 0.5px; } .fq-header-text span { font-size: 11px; opacity: 0.8; } .fq-toggle-btn { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: rgba(255,255,255,0.2); border: none; color: #fff; width: 28px; height: 28px; border-radius: 50%; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; transition: background 0.2s; } .fq-toggle-btn:hover { background: rgba(255,255,255,0.35); } .fq-body { flex: 1; overflow-y: auto; display: flex; flex-direction: column; max-height: calc(85vh - 44px); } .fq-body::-webkit-scrollbar { width: 4px; } .fq-body::-webkit-scrollbar-thumb { background: #ccc; border-radius: 2px; } /* Tabs */ .fq-tabs { display: flex; border-bottom: 1px solid #eee; flex-shrink: 0; } .fq-tab { flex: 1; padding: 8px 4px; text-align: center; cursor: pointer; font-size: 12px; color: #999; border-bottom: 2px solid transparent; transition: all 0.2s; user-select: none; } .fq-tab:hover { color: #666; } .fq-tab.active { color: #e74c3c; border-bottom-color: #e74c3c; font-weight: 600; } .fq-tab-content { display: none; padding: 12px; flex: 1; overflow-y: auto; } .fq-tab-content.active { display: flex; flex-direction: column; } /* Status bar */ .fq-status { background: #f8f9fa; border-radius: 8px; padding: 10px 12px; margin-bottom: 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 6px; font-size: 12px; flex-shrink: 0; } .fq-status-item { display: flex; flex-direction: column; } .fq-status-item .fq-label { color: #999; font-size: 11px; } .fq-status-item .fq-value { font-weight: 600; } .fq-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; } .fq-dot.green { background: #27ae60; box-shadow: 0 0 4px #27ae60; } .fq-dot.red { background: #e74c3c; box-shadow: 0 0 4px #e74c3c; } /* Progress */ .fq-progress-bg { height: 6px; background: #eee; border-radius: 3px; overflow: hidden; margin: 4px 0; } .fq-progress-fill { height: 100%; background: linear-gradient(90deg, #e74c3c, #f39c12); border-radius: 3px; transition: width 0.5s ease; } /* Buttons */ .fq-btn { padding: 7px 14px; border: none; border-radius: 6px; font-size: 12px; cursor: pointer; transition: all 0.2s; font-family: inherit; display: inline-flex; align-items: center; gap: 4px; } .fq-btn:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0,0,0,0.12); } .fq-btn:active { transform: translateY(0); } .fq-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .fq-btn-primary { background: #e74c3c; color: #fff; } .fq-btn-primary:hover { background: #c0392b; } .fq-btn-secondary { background: #f1f3f5; color: #495057; } .fq-btn-secondary:hover { background: #e9ecef; } .fq-btn-danger { background: #fff; color: #e74c3c; border: 1px solid #e74c3c; } .fq-btn-danger:hover { background: #fadbd8; } .fq-btn-sm { padding: 4px 10px; font-size: 11px; } .fq-btn-block { width: 100%; justify-content: center; } .fq-btn-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } /* Inputs */ .fq-input { padding: 6px 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 12px; font-family: inherit; outline: none; transition: border-color 0.2s; } .fq-input:focus { border-color: #e74c3c; } .fq-input-sm { width: 60px; text-align: center; } /* Range row */ .fq-range { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; flex-shrink: 0; } .fq-range label { font-size: 12px; color: #666; white-space: nowrap; } /* Chapter list */ .fq-ch-list { flex: 1; overflow-y: auto; border: 1px solid #eee; border-radius: 6px; min-height: 100px; max-height: 250px; } .fq-ch-item { display: flex; align-items: center; padding: 5px 8px; border-bottom: 1px solid #f5f5f5; font-size: 12px; gap: 6px; transition: background 0.15s; cursor: default; } .fq-ch-item:hover { background: #f8f9fa; } .fq-ch-num { width: 28px; text-align: center; color: #aaa; font-size: 11px; flex-shrink: 0; } .fq-ch-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .fq-ch-words { font-size: 10px; color: #aaa; flex-shrink: 0; } .fq-ch-badge { font-size: 10px; padding: 1px 6px; border-radius: 8px; flex-shrink: 0; } .fq-ch-badge.ok { background: #d5f5e3; color: #27ae60; } .fq-ch-badge.err { background: #fadbd8; color: #e74c3c; } .fq-ch-badge.wait { background: #f1f3f5; color: #adb5bd; } .fq-ch-badge.uploading { background: #d6eaf8; color: #2980b9; } .fq-ch-badge.skip { background: #fdebd0; color: #f39c12; } /* Filter */ .fq-filter { display: flex; gap: 6px; margin-bottom: 8px; flex-shrink: 0; } .fq-filter input { flex: 1; } /* Drop zone */ .fq-dropzone { border: 2px dashed #ddd; border-radius: 8px; padding: 24px 12px; text-align: center; color: #999; font-size: 12px; transition: all 0.2s; cursor: pointer; flex-shrink: 0; } .fq-dropzone:hover, .fq-dropzone.drag-over { border-color: #e74c3c; color: #e74c3c; background: #fef5f5; } .fq-dropzone .fq-dz-icon { font-size: 28px; margin-bottom: 6px; } /* Log */ .fq-log { flex: 1; overflow-y: auto; font-family: "Cascadia Code","Consolas","Courier New",monospace; font-size: 11px; line-height: 1.5; background: #212529; color: #ccc; border-radius: 6px; padding: 6px 8px; min-height: 120px; max-height: 300px; } .fq-log::-webkit-scrollbar { width: 4px; } .fq-log::-webkit-scrollbar-thumb { background: #555; border-radius: 2px; } .fq-log-line { padding: 1px 0; } .fq-log-line .fq-log-ts { color: #666; margin-right: 6px; } .fq-log-line.info .fq-log-msg { color: #d6eaf8; } .fq-log-line.success .fq-log-msg { color: #d5f5e3; } .fq-log-line.error .fq-log-msg { color: #ff6b6b; } .fq-log-line.warn .fq-log-msg { color: #fdebd0; } /* Instructions */ .fq-instructions { font-size: 12px; color: #666; line-height: 1.7; } .fq-instructions ol { padding-left: 18px; } .fq-instructions li { margin-bottom: 3px; } .fq-instructions code { background: #f1f3f5; padding: 1px 5px; border-radius: 3px; font-size: 11px; color: #e74c3c; } /* Settings */ .fq-settings-row { margin-bottom: 10px; } .fq-settings-row label { display: block; font-size: 12px; color: #666; margin-bottom: 3px; } .fq-settings-row .fq-input { width: 100%; } /* Animations */ @keyframes fqPulse { 0%,100%{opacity:1} 50%{opacity:0.5} } .fq-pulse { animation: fqPulse 1.5s infinite; } @keyframes fqFadeIn { from{opacity:0;transform:translateY(4px)} to{opacity:1;transform:translateY(0)} } .fq-fade-in { animation: fqFadeIn 0.25s ease; } /* Toggle button when collapsed */ #fq-sync-panel.collapsed::after { content: '番'; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); font-size: 20px; font-weight: bold; color: #fff; pointer-events: none; } #fq-sync-panel.collapsed .fq-header { border-radius: 50%; width: 56px; height: 56px; justify-content: center; padding: 0; } `); // ============================================================ // UI 创建 // ============================================================ function createPanel() { const panel = document.createElement('div'); panel.id = 'fq-sync-panel'; panel.innerHTML = `

同步助手

橙瓜码字 → 番茄作家网
上传
章节
日志
说明
连接 未检测
书籍 --
章节 --
已同步 --
进度 0%
📂
拖拽 chapters-export.json 到此处
或点击选择文件
请先导入章节数据

使用步骤

  1. 切换到「章节」标签,导入章节 JSON 文件
  2. 确认当前页面在番茄作家网的书籍管理页面
  3. 切换到「上传」标签,设置同步范围
  4. 点击「预览」确认章节列表
  5. 点击「开始同步」,脚本会自动逐章上传

获取章节数据

在项目目录运行:

node export-chapters.js

生成 chapters-export.json,然后拖入「章节」页面。

本地服务器(可选)

运行本地服务器可实现:

  • 直接从服务器加载章节数据
  • 同步进度自动保存到本地文件
node fanqie-gui/server.js

注意事项

  • 同步过程中请不要切换页面或操作浏览器
  • 每章上传间隔约 3-4 秒,避免触发限流
  • 进度自动保存,关闭面板后重新打开可续传
  • 如遇失败,可在日志中查看原因后重试
`; document.body.appendChild(panel); return panel; } // ============================================================ // 日志系统 // ============================================================ let logEl = null; function log(msg, type = 'info') { if (!logEl) logEl = document.getElementById('fq-log'); if (!logEl) return; const line = document.createElement('div'); line.className = 'fq-log-line ' + type + ' fq-fade-in'; line.innerHTML = `[${now()}]${escHtml(msg)}`; logEl.appendChild(line); logEl.scrollTop = logEl.scrollHeight; appendLog(msg, type); } function restoreLogs() { if (!logEl) logEl = document.getElementById('fq-log'); const logs = loadLogs(); // Only restore last 100 const recent = logs.slice(-100); recent.forEach(l => { const line = document.createElement('div'); line.className = 'fq-log-line ' + l.type; line.innerHTML = `[${l.time}]${escHtml(l.msg)}`; logEl.appendChild(line); }); logEl.scrollTop = logEl.scrollHeight; } function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } // ============================================================ // 状态更新 // ============================================================ function refreshUI() { const chapters = loadChapters(); const progress = loadProgress(); const bookId = getBookId(); // Connection const connEl = document.getElementById('fq-conn'); if (bookId) { connEl.innerHTML = '已连接'; } else { connEl.innerHTML = '未在作家页'; } // Book ID document.getElementById('fq-book').textContent = bookId || '--'; // Chapters const total = chapters ? chapters.length : 0; document.getElementById('fq-total').textContent = total || '--'; document.getElementById('fq-synced').textContent = (progress.lastSynced || 0) + '/' + (total || '--'); // Progress bar const pct = total ? Math.round((progress.lastSynced / total) * 100) : 0; document.getElementById('fq-bar').style.width = pct + '%'; document.getElementById('fq-pct').textContent = pct + '%'; // Range inputs const fromEl = document.getElementById('fq-from'); const toEl = document.getElementById('fq-to'); if (total) { fromEl.max = total; toEl.max = total; toEl.value = total; if (progress.lastSynced > 0 && progress.lastSynced < total) { fromEl.value = progress.lastSynced + 1; } } // Chapter list renderChapterList(chapters, progress); } function renderChapterList(chapters, progress) { const container = document.getElementById('fq-ch-list'); if (!chapters || chapters.length === 0) { container.innerHTML = '
请先导入章节数据
'; return; } const lastSynced = progress.lastSynced || 0; const errors = progress.errors || []; let html = ''; for (let i = 0; i < chapters.length; i++) { const num = i + 1; const ch = chapters[i]; const body = extractBody(ch.content); const isSynced = num <= lastSynced; const isFailed = errors.some(e => e.chapter === num); let badgeClass = 'wait', badgeText = '待上传'; if (isFailed) { badgeClass = 'err'; badgeText = '失败'; } else if (isSynced) { badgeClass = 'ok'; badgeText = '已同步'; } html += `
${num} ${escHtml(ch.title)} ${body.length}字 ${badgeText}
`; } container.innerHTML = html; } // ============================================================ // 上传自动化 // ============================================================ let running = false; let stopFlag = false; async function waitForEditor(maxWait = 10000) { const start = Date.now(); while (Date.now() - start < maxWait) { const input = document.querySelector('input[placeholder="请输入标题"]'); const editor = document.querySelector('div[contenteditable="true"]'); if (input && editor) return { input, editor }; await sleep(500); } throw new Error('等待编辑器超时'); } async function uploadChapter(chapter) { const bookId = getBookId(); if (!bookId) throw new Error('未在书籍页面'); const title = extractTitle(chapter.title); const body = extractBody(chapter.content); // 导航到发布页 log('正在打开编辑页面...'); location.href = `https://fanqienovel.com/main/writer/${bookId}/publish/?enter_from=chapterlist`; await sleep(4000); // 等待编辑器加载 const { input, editor } = await waitForEditor(); // 填写标题 input.focus(); input.click(); await sleep(200); setNativeValue(input, title); await sleep(300); // 填写正文 — 使用剪贴板粘贴HTML editor.focus(); editor.click(); await sleep(300); // 清空编辑器 document.execCommand('selectAll'); document.execCommand('delete'); await sleep(200); // 通过剪贴板粘贴 HTML 内容 const htmlContent = contentToHtml(body); const blob = new Blob([htmlContent], { type: 'text/html' }); const textBlob = new Blob([body], { type: 'text/plain' }); try { const clipboardItem = new ClipboardItem({ 'text/html': blob, 'text/plain': textBlob, }); await navigator.clipboard.write([clipboardItem]); document.execCommand('paste'); await sleep(500); } catch (e) { // 降级:逐段 insertText log('剪贴板API不可用,使用逐段插入(较慢)', 'warn'); const paragraphs = body.split('\n').filter(l => l.trim().length > 0); for (const para of paragraphs) { document.execCommand('insertText', false, para); await sleep(100); document.execCommand('insertParagraph'); await sleep(50); } await sleep(300); } // 验证内容 const editorText = editor.innerText || ''; if (editorText.length < body.length * 0.5) { log(`警告:编辑器内容(${editorText.length}字)远少于预期(${body.length}字)`, 'warn'); } // 点击"存草稿" log('正在保存...'); const allBtns = document.querySelectorAll('button'); let saveBtn = null; for (const b of allBtns) { if (b.textContent.trim() === '存草稿') { saveBtn = b; break; } } if (!saveBtn) throw new Error('找不到"存草稿"按钮'); saveBtn.scrollIntoView({ block: 'center' }); await sleep(300); saveBtn.click(); await sleep(3000); // 验证保存结果 const pageText = document.body.innerText || ''; if (pageText.includes('已保存')) { log('✓ 已保存到云端', 'success'); return true; } else if (pageText.includes('错误') || pageText.includes('失败')) { throw new Error('保存失败:页面显示错误'); } else { log('保存状态未知,请检查页面', 'warn'); return true; // 假设成功 } } async function runUpload(from, to, skipSynced = false) { const chapters = loadChapters(); if (!chapters) { log('没有章节数据,请先导入', 'error'); return; } const progress = loadProgress(); const startIdx = from - 1; const endIdx = Math.min(to, chapters.length); let success = 0, fail = 0, skipped = 0; running = true; stopFlag = false; setRunningUI(true); log(`=== 开始同步: 第${from}章 - 第${endIdx}章 ===`); for (let i = startIdx; i < endIdx; i++) { if (stopFlag) { log('用户停止同步', 'warn'); break; } const num = i + 1; const ch = chapters[i]; // 跳过已同步 if (skipSynced && num <= progress.lastSynced) { skipped++; updateChapterBadge(num, 'skip', '跳过'); continue; } // 更新当前章节状态 updateChapterBadge(num, 'uploading', '上传中'); updateProgress(num, chapters.length); log(`[${num}/${chapters.length}] ${ch.title}`); try { await uploadChapter(ch); success++; progress.lastSynced = Math.max(progress.lastSynced, num); saveProgress(progress); updateChapterBadge(num, 'ok', '已同步'); // 同步到本地服务器(可选) syncProgressToServer(num); await sleep(DELAY); } catch (e) { fail++; const err = { chapter: num, title: ch.title, error: e.message, time: new Date().toISOString() }; progress.errors.push(err); saveProgress(progress); updateChapterBadge(num, 'err', '失败'); log(`✗ ${ch.title}: ${e.message}`, 'error'); await sleep(2000); } } running = false; setRunningUI(false); log(`=== 同步完成: 成功${success} 失败${fail} 跳过${skipped} ===`, fail > 0 ? 'warn' : 'success'); refreshUI(); } function updateChapterBadge(num, cls, text) { const item = document.querySelector(`.fq-ch-item[data-idx="${num}"]`); if (item) { const badge = item.querySelector('.fq-ch-badge'); if (badge) { badge.className = 'fq-ch-badge ' + cls; badge.textContent = text; } } } function updateProgress(current, total) { const pct = Math.round((current / total) * 100); document.getElementById('fq-bar').style.width = pct + '%'; document.getElementById('fq-pct').textContent = `${current}/${total} (${pct}%)`; document.getElementById('fq-synced').textContent = current + '/' + total; } function setRunningUI(isRunning) { document.getElementById('fq-start').style.display = isRunning ? 'none' : ''; document.getElementById('fq-stop').style.display = isRunning ? '' : 'none'; document.getElementById('fq-resume').style.display = isRunning ? 'none' : ''; document.getElementById('fq-preview').disabled = isRunning; if (isRunning) { document.getElementById('fq-start').classList.add('fq-pulse'); } else { document.getElementById('fq-start').classList.remove('fq-pulse'); } } // ============================================================ // 本地服务器通信 // ============================================================ function syncProgressToServer(chapterNum) { const settings = loadSettings(); if (!settings.localServer) return; try { GM_xmlhttpRequest({ method: 'POST', url: settings.localServer + '/api/progress', data: JSON.stringify({ lastSynced: chapterNum }), headers: { 'Content-Type': 'application/json' }, timeout: 3000, }); } catch (e) { /* 静默失败 */ } } function loadFromServer() { const settings = loadSettings(); if (!settings.localServer) { log('请先在设置中配置本地服务器地址', 'warn'); return; } log('正在从本地服务器加载章节数据...'); GM_xmlhttpRequest({ method: 'GET', url: settings.localServer + '/api/chapters', timeout: 10000, onload: function (res) { try { const data = JSON.parse(res.responseText); if (data.chapters && data.chapters.length > 0) { saveChapters(data.chapters); log(`已加载 ${data.chapters.length} 章`, 'success'); refreshUI(); } else { log('服务器返回空数据', 'warn'); } } catch (e) { log('解析服务器数据失败: ' + e.message, 'error'); } }, onerror: function () { log('无法连接本地服务器。请确认 server.js 已启动。', 'error'); }, ontimeout: function () { log('连接本地服务器超时', 'error'); }, }); } // ============================================================ // 文件导入 // ============================================================ function handleFile(file) { if (!file || !file.name.endsWith('.json')) { log('请选择 JSON 文件', 'error'); return; } const reader = new FileReader(); reader.onload = function (e) { try { const data = JSON.parse(e.target.result); if (!Array.isArray(data) || data.length === 0) { log('文件格式错误:应为章节数组', 'error'); return; } saveChapters(data); log(`已导入 ${data.length} 章: ${file.name}`, 'success'); refreshUI(); } catch (err) { log('JSON 解析失败: ' + err.message, 'error'); } }; reader.readAsText(file); } // ============================================================ // 事件绑定 // ============================================================ function bindEvents(panel) { // Tab 切换 panel.querySelectorAll('.fq-tab').forEach(tab => { tab.addEventListener('click', () => { panel.querySelectorAll('.fq-tab').forEach(t => t.classList.remove('active')); panel.querySelectorAll('.fq-tab-content').forEach(c => c.classList.remove('active')); tab.classList.add('active'); panel.querySelector(`.fq-tab-content[data-tab="${tab.dataset.tab}"]`).classList.add('active'); }); }); // 最小化/展开 document.getElementById('fq-toggle').addEventListener('click', () => { panel.classList.toggle('collapsed'); const btn = document.getElementById('fq-toggle'); btn.textContent = panel.classList.contains('collapsed') ? '+' : '−'; }); // 开始同步 document.getElementById('fq-start').addEventListener('click', () => { const from = parseInt(document.getElementById('fq-from').value) || 1; const to = parseInt(document.getElementById('fq-to').value) || 200; runUpload(from, to); }); // 续传 document.getElementById('fq-resume').addEventListener('click', () => { const progress = loadProgress(); const chapters = loadChapters(); const from = (progress.lastSynced || 0) + 1; const to = chapters ? chapters.length : 200; document.getElementById('fq-from').value = from; document.getElementById('fq-to').value = to; log(`续传:从第${from}章开始`); runUpload(from, to, true); }); // 停止 document.getElementById('fq-stop').addEventListener('click', () => { stopFlag = true; log('正在停止...'); }); // 预览 document.getElementById('fq-preview').addEventListener('click', () => { const chapters = loadChapters(); if (!chapters) { log('没有章节数据', 'error'); return; } const from = parseInt(document.getElementById('fq-from').value) || 1; const to = parseInt(document.getElementById('fq-to').value) || chapters.length; log(`预览: 第${from}章 - 第${Math.min(to, chapters.length)}章`); for (let i = from - 1; i < Math.min(to, chapters.length); i++) { const body = extractBody(chapters[i].content); log(` [${i + 1}] ${chapters[i].title} (${body.length}字)`, 'info'); } // 切换到日志标签 panel.querySelector('.fq-tab[data-tab="logs"]').click(); }); // 文件拖拽 const dropzone = document.getElementById('fq-dropzone'); const fileInput = document.getElementById('fq-file'); dropzone.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', (e) => { if (e.target.files[0]) handleFile(e.target.files[0]); }); dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('drag-over'); }); dropzone.addEventListener('dragleave', () => { dropzone.classList.remove('drag-over'); }); dropzone.addEventListener('drop', (e) => { e.preventDefault(); dropzone.classList.remove('drag-over'); if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]); }); // 从服务器加载 document.getElementById('fq-load-local').addEventListener('click', loadFromServer); // 清除数据 document.getElementById('fq-clear-data').addEventListener('click', () => { if (confirm('确定清除所有已导入的章节数据?(不会影响同步进度)')) { GM_setValue(STORAGE_KEY, null); log('已清除章节数据', 'warn'); refreshUI(); } }); // 搜索 document.getElementById('fq-ch-search').addEventListener('input', (e) => { const q = e.target.value.trim().toLowerCase(); document.querySelectorAll('.fq-ch-item').forEach(item => { const title = item.querySelector('.fq-ch-title').textContent.toLowerCase(); const num = item.querySelector('.fq-ch-num').textContent; item.style.display = (!q || title.includes(q) || num.includes(q)) ? '' : 'none'; }); }); // 清空日志 document.getElementById('fq-clear-log').addEventListener('click', () => { document.getElementById('fq-log').innerHTML = ''; GM_setValue(LOG_KEY, []); log('日志已清空'); }); // 导出日志 document.getElementById('fq-export-log').addEventListener('click', () => { const logs = loadLogs(); const text = logs.map(l => `[${l.time}] [${l.type}] ${l.msg}`).join('\n'); const blob = new Blob([text], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `同步日志_${new Date().toISOString().slice(0, 10)}.txt`; a.click(); URL.revokeObjectURL(url); log('日志已导出', 'success'); }); } // ============================================================ // 初始化 // ============================================================ function init() { const panel = createPanel(); bindEvents(panel); logEl = document.getElementById('fq-log'); restoreLogs(); refreshUI(); log('同步助手已加载'); // 定时刷新状态(不在同步中时) setInterval(() => { if (!running) refreshUI(); }, 10000); } // 注册菜单命令(脚本猫右键菜单) if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('打开同步面板', () => { const panel = document.getElementById('fq-sync-panel'); if (panel) panel.classList.remove('collapsed'); }); } // 页面加载完成后初始化 if (document.readyState === 'complete') { init(); } else { window.addEventListener('load', init); } })();