// ==UserScript== // @name 番茄作家网 · 章节同步助手 Pro // @namespace fanqie-novel-sync-pro // @version 9.0.0 // @description 番茄作家网多章节同步旗舰版:JSON/文件夹导入、断点续传、失败重传、草稿/下一步/可选最终发布、标题5-30字适配、质检、弹窗处理、发布就绪度、运营计划、推荐标题、智能批次、融合式美化界面、融合式美化界面、折叠面板修复与诊断自愈、v9.0优化版:章节序号同步、日志最新在上、自动继续下一章、关闭自动弹窗、智能分章、全文编辑、批量导出、进度可视化、压缩备份、加密备份、一键清空、章节排序、内容去重、字数趋势、更新日历、全面UI美化。 // @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_deleteValue // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM_download // @grant GM_setClipboard // @grant GM_notification // @connect 127.0.0.1 // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const STORAGE_KEY = 'fanqie_sync_pro_chapters_v2'; const PROGRESS_KEY = 'fanqie_sync_pro_progress_v2'; const JOB_KEY = 'fanqie_sync_pro_job_v2'; const SETTINGS_KEY = 'fanqie_sync_pro_settings_v2'; const LOG_KEY = 'fanqie_sync_pro_logs_v2'; const PANEL_POS_KEY = 'fanqie_sync_pro_panel_pos_v2'; const BACKUP_KEY = 'fanqie_sync_pro_backups_v2'; const MAX_LOGS = 800; const DEFAULT_SETTINGS = { localServer: 'http://127.0.0.1:19888', saveMode: 'draft', // draft | next | final | fill allowFinalPublish: false, finalPublishGuard: true, delayMin: 4200, delayMax: 7800, maxRetry: 2, maxContinuousFailures: 3, editorWaitMs: 22000, saveWaitMs: 16000, successWaitMs: 7000, strictValidate: false, autoKillDialogs: false, notifyOnDone: true, autoPreflight: true, pauseOnRisk: true, titleMode: 'plain', // plain | queue | source autoFitTitle: true, titleMin: 5, titleMax: 30, minWords: 800, maxWords: 6500, nextBatchSize: 5, openingChapters: 30, stableDailyWords: 6000, contractWords: 20000, recommendWords: 100000, secondEvalWords: 50000, thirdEvalWords: 200000, recommendTitleCount: 30, autoSmartBatch: true, autoBackup: true, smartClean: true, removeSynopsis: true, aiUsage: 'none', // none | ai | human autoScrollEditor: true }; const FINAL_PUBLISH_WORDS = ['确认发布', '立即发布', '提交发布', '最终发布', '发布章节', '定时发布', '确认发表', '发表章节']; const SAFE_DIALOG_WORDS = ['我知道了', '知道了', '确定', '确认无误', '继续保存', '仍要保存', '保存草稿', '暂不处理', '暂不', '忽略', '跳过', '关闭', '取消']; const sleep = ms => new Promise(r => setTimeout(r, ms)); const rand = (min, max) => Math.floor(min + Math.random() * (max - min + 1)); const now = () => new Date().toLocaleTimeString('zh-CN', { hour12: false }); const esc = s => String(s == null ? '' : s).replace(/[&<>'"]/g, c => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[c])); const countWords = s => String(s || '').replace(/\s+/g, '').length; function getBookId() { const m = location.pathname.match(/\/writer\/(\d+)/); return m ? m[1] : null; } function isPublishPage() { return /\/publish\//.test(location.pathname + location.search); } function publishUrl(bookId) { return `https://fanqienovel.com/main/writer/${bookId}/publish/?enter_from=chapterlist&fq_sync=${Date.now()}`; } function loadSettings() { return Object.assign({}, DEFAULT_SETTINGS, GM_getValue(SETTINGS_KEY, {})); } function saveSettingsPatch(patch) { GM_setValue(SETTINGS_KEY, Object.assign({}, loadSettings(), patch)); } function loadChapters() { return GM_getValue(STORAGE_KEY, []); } function saveChapters(chapters) { GM_setValue(STORAGE_KEY, Array.isArray(chapters) ? chapters : []); } function loadProgress() { return Object.assign({ lastSynced: 0, ok: {}, errors: {}, skipped: {}, importedAt: 0, source: '' }, GM_getValue(PROGRESS_KEY, {})); } function saveProgress(p) { GM_setValue(PROGRESS_KEY, p); } function loadJob() { return GM_getValue(JOB_KEY, null); } function saveJob(j) { GM_setValue(JOB_KEY, j); } function clearJob() { GM_deleteValue(JOB_KEY); } function loadLogs() { return GM_getValue(LOG_KEY, []); } function saveLogs(logs) { GM_setValue(LOG_KEY, (Array.isArray(logs) ? logs : []).slice(-MAX_LOGS)); } function loadBackups() { return GM_getValue(BACKUP_KEY, []); } function saveBackups(arr) { GM_setValue(BACKUP_KEY, (Array.isArray(arr) ? arr : []).slice(-6)); } function addLog(msg, type = 'info') { const logs = loadLogs(); const item = { time: now(), msg: String(msg), type, ts: Date.now() }; logs.push(item); saveLogs(logs); renderOneLog(item); } function notify(msg, title = '番茄同步助手 Pro') { try { if (loadSettings().notifyOnDone && typeof GM_notification === 'function') GM_notification({ title, text: String(msg), timeout: 4500 }); } catch (_) { } } function downloadText(name, text, type = 'text/plain;charset=utf-8') { const blob = new Blob([text], { type }); const url = URL.createObjectURL(blob); if (typeof GM_download === 'function') { try { GM_download({ url, name, saveAs: true, onload: () => URL.revokeObjectURL(url), onerror: () => URL.revokeObjectURL(url) }); return; } catch (_) { } } const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1200); } function parseChapterNumberFromName(name) { const s = String(name || ''); const m = s.match(/(?:^|[\\/])(\d{1,5})[_\-\s]+/) || s.match(/^(\d{1,5})/); return m ? parseInt(m[1], 10) : 999999; } function cleanText(text) { const s = loadSettings(); let x = String(text || '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n'); if (!s.smartClean) return x; return x.replace(/[\u200B-\u200D\uFEFF]/g, '') .replace(//g, '') .replace(/^```[\s\S]*?```$/gm, m => m.replace(/```\w*|```/g, '').trim()) .replace(/!\[[^\]]*\]\([^)]*\)/g, '') .replace(//gi, '\n') .replace(/<[^>]+>/g, '') .replace(/[ \t\u3000]+$/gm, '') .replace(/\n{4,}/g, '\n\n\n'); } function normalizeTitle(raw, fallback) { let t = String(raw || '').trim(); t = t.replace(/.*[\\\/]/, ''); t = t.replace(/^#+\s*/, '').trim(); t = t.replace(/^第\s*([0-9零一二三四五六七八九十百千万]+)\s*[章节回][::、\s-]*/i, '').trim(); t = t.replace(/\.(md|txt|json|doc|docx)$/i, '').trim(); if (!t && fallback) { t = String(fallback); t = t.replace(/.*[\\\/]/, ''); t = t.replace(/\.(md|txt|json|doc|docx)$/i, '').trim(); t = t.replace(/^\d{1,5}[_\-\s]*/, '').trim(); } return t || '未命名章节'; } function parseChapterText(text, fileName, fallbackIndex) { text = cleanText(text); const lines = text.split('\n'); const first = lines.find(l => l.trim()) || ''; const fileNo = parseChapterNumberFromName(fileName); let titleNo = 999999; const tm1 = first.match(/第\s*(\d+)\s*[章节回]/); if (tm1) titleNo = parseInt(tm1[1], 10); const tm2 = first.match(/(\d{1,5})\s*[章节回]/); if (tm2) titleNo = parseInt(tm2[1], 10); const sourceNum = fileNo !== 999999 ? fileNo : (titleNo !== 999999 ? titleNo : fallbackIndex); const title = normalizeTitle(first, fileName); const bodyLines = []; const settings = loadSettings(); for (let i = 0; i < lines.length; i++) { const line = lines[i].trimEnd(); const trim = line.trim(); if (i < 5 && /^#+\s*/.test(trim) && /第.+章/.test(trim)) continue; if (i < 5 && /^第\s*([0-9零一二三四五六七八九十百千万]+)\s*[章节回][::、\s-]*/.test(trim)) continue; if (settings.removeSynopsis && /^本章(梗概|概要|简介|摘要)\s*[::]/.test(trim)) continue; if (/^\s*[-*_]{3,}\s*$/.test(trim)) continue; bodyLines.push(line); } const content = bodyLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(); return { num: fallbackIndex, sourceNum, title, fullTitle: `第${String(fallbackIndex).padStart(3, '0')}章:${title}`, content, words: countWords(content), fileName: fileName || `chapter-${fallbackIndex}.txt`, importedAt: Date.now() }; } function normalizeImportedChapters(arr, source) { const list = (Array.isArray(arr) ? arr : []).map((x, i) => { if (x && typeof x === 'object' && (x.content || x.text)) { const rawTitle = x.title || x.fullTitle || x.name || x.fileName || `chapter-${i + 1}.txt`; const content = cleanText(x.content || x.text || ''); const sourceNum = x.sourceNum || x.originalNum || x.num || parseChapterNumberFromName(x.fileName || x.name || rawTitle) || i + 1; return { num: x.num || i + 1, sourceNum, title: normalizeTitle(rawTitle, x.fileName || x.name), fullTitle: '', content, words: countWords(content), fileName: x.fileName || x.name || '', importedAt: Date.now() }; } return parseChapterText(String(x || ''), `chapter-${i + 1}.txt`, i + 1); }).filter(ch => ch.content && ch.content.trim()).sort((a, b) => (Number(a.sourceNum || a.num) || 999999) - (Number(b.sourceNum || b.num) || 999999)); return list.map((ch, i) => Object.assign({}, ch, { num: i + 1, sourceNum: ch.sourceNum || i + 1, fullTitle: `第${String(i + 1).padStart(3, '0')}章:${ch.title}`, words: ch.words || countWords(ch.content) })); } function saveImportedChapters(arr, source) { if (loadSettings().autoBackup) createBackup('导入前自动备份'); const chapters = normalizeImportedChapters(arr, source); saveChapters(chapters); saveProgress({ lastSynced: 0, ok: {}, errors: {}, skipped: {}, importedAt: Date.now(), source: source || '' }); addLog(`已导入 ${chapters.length} 章,总字数 ${chapters.reduce((n, c) => n + c.words, 0)},来源:${source || '未知'}`, 'success'); refreshUI(); switchTab('chapters'); } function formatTitle(ch) { const s = loadSettings(); let t = String(ch.title || '').trim(); if (s.titleMode === 'queue') t = `第${ch.num}章 ${t}`; if (s.titleMode === 'source') t = `第${ch.sourceNum || ch.num}章 ${t}`; if (s.autoFitTitle) t = fitTitle(t); return t; } function fitTitle(raw) { const s = loadSettings(); let t = String(raw || '').replace(/[【】「」『』]/g, '').replace(/\s+/g, ' ').trim(); if (s.titleMode === 'plain') t = t.replace(/^第\s*([0-9零一二三四五六七八九十百千万]+)\s*[章节回][::、\s-]*/i, '').trim(); const min = Number(s.titleMin || 5), max = Number(s.titleMax || 30); if (!t) t = '故事新章'; if (t.length < min) t = (t + ' 风起山河').slice(0, Math.max(min, 5)); if (t.length > max) t = t.slice(0, Math.max(1, max - 1)) + '…'; return t; } function auditChapter(ch) { const s = loadSettings(); const title = formatTitle(ch); const content = String(ch.content || ''); const words = Number(ch.words || countWords(content)); const problems = []; if (title.length < Number(s.titleMin || 5)) problems.push('标题低于5字'); if (title.length > Number(s.titleMax || 30)) problems.push('标题超30字'); if (/[^\u4e00-\u9fa5A-Za-z0-9\s,。!?、“”‘’:;·—…()《》-]/.test(title)) problems.push('标题特殊符号'); if (words < Number(s.minWords || 0)) problems.push(`字数偏少${words}`); if (Number(s.maxWords || 0) && words > Number(s.maxWords)) problems.push(`字数偏多${words}`); if (/本章(梗概|概要|简介|摘要)\s*[::]/.test(content)) problems.push('含本章梗概'); if (/作为(一名)?AI|ChatGPT|大语言模型|以下是|无法满足你的要求|润色版|生成内容/.test(content)) problems.push('疑似AI残留'); if (/\[待补|TODO|FIXME|xxxx|XX|undefined|null/.test(content)) problems.push('疑似占位符'); if (/https?:\/\/|www\.|QQ群|微信群|公众号|加群|私信|VX|微信号|二维码|扫码|站外|引流/.test(content)) problems.push('导流/外链风险'); if (/转载|版权归|侵权|原作者|免责声明|素材来源|未经授权|搬运|洗稿/.test(content)) problems.push('版权/来源风险'); if (/涉政|赌博|毒品|枪支|血腥|色情|露骨|未成年|自杀|诈骗|洗钱|刷单|外挂|邪教|恐怖主义/.test(content)) problems.push('高危审核词'); if (/\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|1[3-9]\d{9})\b/.test(content)) problems.push('联系方式残留'); if (/\n{4,}/.test(content)) problems.push('空行过多'); const lines = content.split('\n').map(x => x.trim()).filter(Boolean); if (lines.length > 20) { const dialogue = lines.filter(x => /^([“"「『].+[”"」』]|[^::]{1,8}[::])/.test(x)).length; if (dialogue / lines.length > 0.75) problems.push('对话密度过高'); } return { ok: !problems.length, problems, title, words }; } function analyzeSet(chapters) { const warnings = [], titles = {}, sources = {}, fps = {}; chapters.forEach((ch, i) => { if (ch.num !== i + 1) warnings.push(`队列序号异常:位置${i + 1} 实际${ch.num}`); const t = String(ch.title || '').trim(); if (t) titles[t] = (titles[t] || 0) + 1; if (ch.sourceNum) sources[ch.sourceNum] = (sources[ch.sourceNum] || 0) + 1; const fp = String(ch.content || '').replace(/\s+/g, '').slice(0, 180); if (fp.length > 120) { if (fps[fp]) warnings.push(`正文开头疑似重复:第${fps[fp]}章 与 第${ch.num}章`); else fps[fp] = ch.num; } }); Object.keys(titles).filter(k => titles[k] > 1).slice(0, 20).forEach(k => warnings.push(`重复标题:${k} ×${titles[k]}`)); Object.keys(sources).filter(k => sources[k] > 1).slice(0, 20).forEach(k => warnings.push(`重复原编号:${k} ×${sources[k]}`)); const nums = Object.keys(sources).map(Number).sort((a, b) => a - b); for (let i = 1; i < nums.length; i++) { const gap = nums[i] - nums[i - 1]; if (gap > 1 && gap < 20) warnings.push(`原文件编号疑似断档:${nums[i - 1]} → ${nums[i]}`); } return warnings; } function preflightRange(from, to) { const chapters = loadChapters().slice(from - 1, to); const bad = chapters.map((ch, i) => ({ n: from + i, a: auditChapter(ch) })).filter(x => !x.a.ok); const warns = analyzeSet(chapters); addLog(`预检:第${from}-${to}章,共${chapters.length}章,风险${bad.length},结构警告${warns.length}`, (bad.length || warns.length) ? 'warn' : 'success'); bad.slice(0, 30).forEach(x => addLog(`预检问题|${String(x.n).padStart(3, '0')}|${x.a.title}|${x.a.problems.join('、')}`, 'warn')); warns.slice(0, 30).forEach(w => addLog('结构检查|' + w, 'warn')); return { ok: bad.length === 0 && warns.length === 0, bad, warns }; } function createBackup(label) { const chapters = loadChapters(); if (!chapters.length) return false; const arr = loadBackups(); arr.push({ id: Date.now(), label: label || '备份', time: new Date().toISOString(), chapters, progress: loadProgress() }); saveBackups(arr); addLog(`已备份当前章节:${chapters.length}章`, 'success'); return true; } function restoreLatestBackup() { const b = loadBackups().slice(-1)[0]; if (!b) return addLog('没有可恢复备份', 'error'); if (!confirm(`恢复最近备份?\n${b.label || ''}\n当前章节会被覆盖。`)) return; saveChapters(b.chapters || []); saveProgress(b.progress || { lastSynced: 0, ok: {}, errors: {}, skipped: {}, importedAt: Date.now(), source: '恢复备份' }); addLog(`已恢复备份:${(b.chapters || []).length}章`, 'success'); refreshUI(); } function visible(el) { if (!el) return false; const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; } function textOf(el) { return (el && (el.innerText || el.textContent) || '').replace(/\s+/g, ' ').trim(); } function isDisabled(el) { return !el || !!(el.disabled || el.getAttribute('aria-disabled') === 'true' || /disabled|is-disabled/.test(el.className || '')); } function isFinalPublishText(t) { return FINAL_PUBLISH_WORDS.some(w => String(t || '').includes(w)); } function findByText(selector, words, exact = false) { const arr = Array.from(document.querySelectorAll(selector)).filter(visible); for (const el of arr) { const t = textOf(el); if (exact ? words.some(w => t === w) : words.some(w => t.includes(w))) return el; } return null; } function findActionButton(words, exact = false) { return findByText('button, [role="button"], .byte-btn, .semi-button, .arco-btn, .el-button, a', words, exact); } function smartClick(el) { if (!el) return false; try { el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); } catch (_) { } const r = el.getBoundingClientRect(); const x = r.left + r.width / 2; const y = r.top + r.height / 2; ['pointerdown', 'mousedown', 'mouseup', 'click'].forEach(type => { try { el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, view: window })); } catch (_) { } }); try { el.click(); } catch (_) { } return true; } async function waitFor(fn, timeout, interval, label) { const start = Date.now(); let lastErr; while (Date.now() - start < timeout) { try { const v = fn(); if (v) return v; } catch (e) { lastErr = e; } await sleep(interval || 300); } throw new Error(`等待${label || '条件'}超时${lastErr ? ':' + lastErr.message : ''}`); } function setNativeValue(el, value) { const proto = Object.getPrototypeOf(el); const own = Object.getOwnPropertyDescriptor(proto, 'value'); const input = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); const textarea = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value'); const setter = own && own.set || input && input.set || textarea && textarea.set; if (setter) setter.call(el, value); else el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } function dispatchDeepInput(el) { ['beforeinput', 'input', 'keyup', 'change', 'blur'].forEach(n => { try { el.dispatchEvent(new Event(n, { bubbles: true, cancelable: true })); } catch (_) { } }); } function scoreTitleInput(el) { const ph = el.getAttribute('placeholder') || ''; const name = ((el.name || '') + ' ' + (el.id || '') + ' ' + (el.className || '')).toLowerCase(); const label = textOf(el.closest('label, .form-item, .semi-form-field, .byte-form-item, .arco-form-item') || el.parentElement || el); let score = 0; if (/章节标题|标题|请输入标题/.test(ph + label)) score += 80; if (/chapter|title/.test(name)) score += 20; if (el.maxLength > 0 && el.maxLength <= 80) score += 15; if (/搜索|手机号|验证码|密码|作者|书名/.test(ph + label)) score -= 100; return score; } function findTitleInput() { const sels = ['input[placeholder*="章节标题"]', 'input[placeholder*="请输入标题"]', 'textarea[placeholder*="章节标题"]', 'input[placeholder*="标题"]', 'textarea[placeholder*="标题"]', 'input[maxlength][type="text"]', 'textarea[maxlength]', 'input[type="text"]']; const cands = Array.from(new Set(sels.flatMap(s => Array.from(document.querySelectorAll(s))))).filter(visible).filter(el => !/hidden|password|search|tel|number/.test((el.type || '').toLowerCase())); cands.sort((a, b) => scoreTitleInput(b) - scoreTitleInput(a)); return cands[0] || null; } function scoreEditor(el) { const attrs = Array.from(el.attributes || []).map(a => `${a.name}=${a.value}`).join(' ').toLowerCase(); const ph = (el.getAttribute('placeholder') || el.getAttribute('data-placeholder') || '').toLowerCase(); const txt = textOf(el).slice(0, 80); const r = el.getBoundingClientRect(); let score = r.width * r.height / 1000; if (/slate|prosemirror|ql-editor|lexical|textbox|editor|contenteditable/.test(attrs + ph)) score += 120; if (/正文|内容|请输入正文|开始创作|章节内容/.test(ph + txt)) score += 80; if (/评论|搜索|标题|导航|菜单/.test(ph + txt)) score -= 120; return score; } function findEditor() { const sels = ['[contenteditable="true"][data-slate-editor="true"]', '[data-slate-editor="true"]', '.ProseMirror[contenteditable="true"]', '.ql-editor[contenteditable="true"]', '[data-lexical-editor="true"]', '[role="textbox"][contenteditable="true"]', 'div[contenteditable="true"]', 'textarea[placeholder*="正文"]', 'textarea[placeholder*="内容"]']; const cands = Array.from(new Set(sels.flatMap(s => Array.from(document.querySelectorAll(s))))).filter(visible); cands.sort((a, b) => scoreEditor(b) - scoreEditor(a)); return cands[0] || null; } function findSerialInput() { try { const serial = document.querySelector('input.serial-input'); if (serial && visible(serial) && !isDisabled(serial)) return serial; const sp = document.querySelector('input[data-placeholder*="序号"]'); if (sp && visible(sp) && !isDisabled(sp)) return sp; const ph = document.querySelector('input[placeholder*="序号"]'); if (ph && visible(ph) && !isDisabled(ph)) return ph; const labels = document.querySelectorAll('label'); for (const label of labels) { if (/序号/.test(label.textContent || '')) { const forAttr = label.getAttribute('for'); if (forAttr) { const el = document.getElementById(forAttr); if (el && visible(el) && !isDisabled(el)) return el; } const input = label.querySelector('input'); if (input && visible(input) && !isDisabled(input)) return input; } } const allInputs = Array.from(document.querySelectorAll('input[type="text"]')).filter(el => { if (!visible(el) || isDisabled(el) || el.value) return false; const ph = (el.placeholder || '').toLowerCase(); const cls = (el.className || '').toLowerCase(); const name = (el.name || '').toLowerCase(); if (/标题|title|chapter/i.test(ph + cls + name)) return false; return true; }); if (allInputs.length > 0) return allInputs[0]; } catch (e) {} return null; } async function waitEditorReady() { const s = loadSettings(); const input = await waitFor(findTitleInput, Number(s.editorWaitMs) || 22000, 300, '标题输入框'); const editor = await waitFor(findEditor, Number(s.editorWaitMs) || 22000, 300, '正文编辑器'); let serial = null; try { serial = findSerialInput(); if (!serial) { await sleep(2000); serial = findSerialInput(); } if (!serial) { await sleep(5000); serial = findSerialInput(); } } catch (e) {} return { input, editor, serial }; } function chapterToHtml(ch) { return String(ch.content || '').split(/\n+/).map(x => x.trim()).filter(Boolean).map(p => `

${esc(p)}

`).join(''); } function createPasteEvent(html, plain) { try { const dt = new DataTransfer(); dt.setData('text/html', html); dt.setData('text/plain', plain); return new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: dt }); } catch (_) { return null; } } async function fillEditor(editor, chapter) { const html = chapterToHtml(chapter), plain = chapter.content || ''; if (loadSettings().autoScrollEditor) { try { editor.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' }); } catch (_) { } } editor.focus(); smartClick(editor); await sleep(180); try { document.execCommand('selectAll'); document.execCommand('delete'); await sleep(120); } catch (_) { } let method = 'none'; try { const ev = createPasteEvent(html, plain); if (ev && editor.dispatchEvent(ev)) { method = 'paste-event'; await sleep(450); } } catch (_) { } let got = countWords(editor.innerText || editor.textContent || editor.value || ''); if (got < chapter.words * 0.5) { try { if (navigator.clipboard && window.ClipboardItem) { await navigator.clipboard.write([new ClipboardItem({ 'text/html': new Blob([html], { type: 'text/html' }), 'text/plain': new Blob([plain], { type: 'text/plain' }) })]); document.execCommand('paste'); method = 'clipboard'; await sleep(650); } } catch (_) { addLog('剪贴板粘贴被拦截,切换强制写入', 'warn'); } } got = countWords(editor.innerText || editor.textContent || editor.value || ''); if (got < chapter.words * 0.5) { try { if ('value' in editor) setNativeValue(editor, plain); else editor.innerHTML = html; dispatchDeepInput(editor); method = 'innerHTML/value'; await sleep(500); } catch (_) { } } got = countWords(editor.innerText || editor.textContent || editor.value || ''); if (got < chapter.words * 0.5) { try { document.execCommand('selectAll'); document.execCommand('delete'); for (const p of plain.split(/\n+/).map(x => x.trim()).filter(Boolean)) { document.execCommand('insertText', false, p); document.execCommand('insertParagraph'); await sleep(12); } dispatchDeepInput(editor); method = 'execCommand'; await sleep(550); } catch (_) { } } got = countWords(editor.innerText || editor.textContent || editor.value || ''); addLog(`编辑器写入:${method},检测${got}/${chapter.words}字`, got < chapter.words * 0.8 ? 'warn' : 'info'); return got; } function detectRiskText() { const t = document.body ? document.body.innerText || '' : ''; const m = t.match(/.{0,18}(失败|错误|异常|敏感|违规|审核|不能为空|未保存|保存成功|已保存|草稿|标题格式|字数|定时|下一步|发布|网络异常).{0,42}/); return m ? m[0] : ''; } function clickDialogButton(words, exact = false, allowFinal = false) { const roots = Array.from(document.querySelectorAll('[role="dialog"], .semi-modal, .byte-modal, .arco-modal, .modal, .dialog')).filter(visible); const searchRoot = roots.length ? roots : [document.body]; for (const root of searchRoot) { const btns = Array.from(root.querySelectorAll('button, [role="button"], .semi-button, .byte-btn, .arco-btn, a')).filter(visible); for (const b of btns) { const t = textOf(b); if (!allowFinal && loadSettings().finalPublishGuard && isFinalPublishText(t)) { addLog('安全锁拦截最终发布按钮:' + t, 'error'); continue; } if (exact ? words.some(w => t === w) : words.some(w => t.includes(w))) { smartClick(b); return t || true; } } } return false; } function killCommonDialogs(reason = '') { if (!loadSettings().autoKillDialogs) return false; const risk = detectRiskText(); const hit = clickDialogButton(SAFE_DIALOG_WORDS, false, false); if (hit) addLog(`弹窗处理${reason ? '(' + reason + ')' : ''}:${hit}${risk ? '|' + risk : ''}`, 'warn'); return !!hit; } function handleAiUsageDeclaration() { const s = loadSettings(); if (!s.aiUsage || s.aiUsage === 'none') return false; const want = s.aiUsage === 'ai' ? ['使用AI', '是', '有使用'] : ['未使用AI', '否', '无使用']; const labels = Array.from(document.querySelectorAll('label, [role="radio"], .semi-radio, .byte-radio, .arco-radio')).filter(visible); for (const lab of labels) { const tx = textOf(lab); if (want.some(w => tx.includes(w)) && /AI|人工智能|AIGC/.test(textOf(lab.closest('form,body') || document.body))) { smartClick(lab); addLog(`已处理AI使用声明:${tx}`, 'info'); return true; } } return false; } async function waitActionButton(words, label) { return waitFor(() => { const b = findActionButton(words, false); return b && !isDisabled(b) ? b : null; }, Number(loadSettings().saveWaitMs) || 16000, 350, label || words.join('/')); } async function waitAfterSave(mode) { const s = loadSettings(); const start = Date.now(); let lastRisk = ''; while (Date.now() - start < (Number(s.successWaitMs) || 7000)) { killCommonDialogs(mode === 'draft' ? '保存后' : '发布流程'); const body = document.body ? document.body.innerText || '' : ''; const err = (body.match(/.{0,14}(保存失败|发布失败|失败|错误|异常|不能为空|请输入|违规|敏感|网络异常).{0,34}/) || [])[0]; if (err && !/保存成功|已保存/.test(err)) throw new Error('页面疑似报错:' + err); if (/保存成功|已保存|草稿|下一步|选择定时|确认发布|发布设置|审核/.test(body)) return true; lastRisk = detectRiskText() || lastRisk; await sleep(600); } addLog('未等到明确成功提示' + (lastRisk ? ':' + lastRisk : '') + ',按保守成功继续', 'warn'); return true; } async function uploadCurrentChapter(ch) { const audit = auditChapter(ch); if (loadSettings().pauseOnRisk && !audit.ok) throw new Error('章节质检未通过:' + audit.problems.join('、')); addLog(`填充:${ch.fullTitle}(${ch.words}字)`, 'info'); const { input, editor, serial } = await waitEditorReady(); if (serial) { try { const serialNum = String(ch.sourceNum || ch.num); addLog(`填写章节序号:${serialNum}`, 'info'); setNativeValue(serial, serialNum); serial.dispatchEvent(new Event('input', { bubbles: true })); serial.dispatchEvent(new Event('change', { bubbles: true })); await sleep(300); const afterValue = serial.value || serial.textContent || ''; if (afterValue && !afterValue.includes(serialNum)) { addLog(`⚠️ 章节序号验证未通过:当前值=${afterValue}`, 'warn'); } else { addLog(`✅ 章节序号填写成功:${serialNum}`, 'success'); } } catch (e) { addLog('章节序号填写失败:' + e.message, 'warn'); } } input.focus(); smartClick(input); await sleep(180); const title = formatTitle(ch); setNativeValue(input, title); await sleep(260); handleAiUsageDeclaration(); const got = await fillEditor(editor, ch); if (loadSettings().strictValidate && got < ch.words * 0.85) throw new Error(`正文写入疑似不完整:${got}/${ch.words}`); const mode = loadSettings().saveMode || 'draft'; if (mode === 'fill') { addLog('保存模式=只填不存,本章不点击保存/发布按钮', 'warn'); return true; } if (mode === 'draft') { const btn = await waitActionButton(['存草稿', '保存草稿', '保存'], '存草稿/保存按钮'); addLog('点击存草稿...', 'info'); smartClick(btn); await sleep(1100); killCommonDialogs('保存后'); await waitAfterSave('draft'); addLog('保存草稿完成', 'success'); return true; } const next = await waitActionButton(['下一步', '继续发布'], '下一步按钮'); addLog('点击下一步(进入发布确认/定时页)...', 'warn'); smartClick(next); await sleep(1400); killCommonDialogs('下一步后'); await waitAfterSave('next'); if (mode === 'next') { addLog('已到发布设置/确认页;最终确认发布由主上手动掌控', 'success'); return true; } if (mode === 'final') { if (!loadSettings().allowFinalPublish) throw new Error('最终发布未授权:请先勾选“允许最终发布”'); const finalBtn = await waitActionButton(FINAL_PUBLISH_WORDS, '最终发布按钮'); const t = textOf(finalBtn); if (!confirm(`即将最终发布:${formatTitle(ch)}\n按钮:${t}\n确定继续?`)) throw new Error('用户取消最终发布'); smartClick(finalBtn); await sleep(1600); clickDialogButton(['确认发布', '立即发布', '提交发布', '最终发布', '确定', '确认'], false, true); await waitAfterSave('final'); addLog('最终发布流程已点击,请在章节列表核对审核状态', 'success'); return true; } return true; } function markOk(num) { const p = loadProgress(); p.ok = p.ok || {}; p.errors = p.errors || {}; p.ok[num] = { time: new Date().toISOString() }; delete p.errors[num]; const nums = Object.keys(p.ok).map(Number).sort((a, b) => a - b); let last = 0; for (const n of nums) { if (n === last + 1) last = n; else if (n > last + 1) break; } p.lastSynced = Math.max(p.lastSynced || 0, last); saveProgress(p); syncProgressToServer(p.lastSynced); } function markError(num, title, err) { const p = loadProgress(); p.errors = p.errors || {}; p.errors[num] = { title, error: String(err && err.message || err), time: new Date().toISOString() }; saveProgress(p); } function markSkipped(num) { const p = loadProgress(); p.skipped = p.skipped || {}; p.skipped[num] = { time: new Date().toISOString() }; saveProgress(p); } function nextDelay() { const s = loadSettings(); return rand(Number(s.delayMin) || 4200, Number(s.delayMax) || 7800); } async function runJobEngine(reason) { const job = loadJob(); if (!job || !job.running || window.__fqProJobBusy) return; window.__fqProJobBusy = true; try { const chapters = loadChapters(); if (!chapters.length) throw new Error('任务存在,但章节数据为空'); const total = chapters.length; let idx = Math.max(job.from || 1, job.idx || job.from || 1); const to = Math.min(job.to || total, total); const p = loadProgress(); while (idx <= to) { const ch = chapters[idx - 1]; if (!ch) break; if (job.failedOnly && !(p.errors && p.errors[idx])) { idx++; job.idx = idx; job.retry = 0; saveJob(job); continue; } if (job.skipSynced && ((p.ok && p.ok[idx]) || idx <= (p.lastSynced || 0))) { addLog(`跳过已同步:第${idx}章 ${ch.title}`, 'warn'); markSkipped(idx); idx++; job.idx = idx; job.retry = 0; saveJob(job); continue; } break; } if (idx > to) { addLog(`=== 全部完成:第${job.from}章 - 第${to}章 ===`, 'success'); notify(`同步完成:第${job.from}章 - 第${to}章`); clearJob(); refreshUI(); return; } job.idx = idx; saveJob(job); refreshUI(); if (!isPublishPage()) { const bookId = getBookId(); if (!bookId) throw new Error('当前不在番茄书籍页面,无法识别bookId'); addLog(`进入发布页:第${idx}章`, 'info'); await sleep(500); location.href = publishUrl(bookId); return; } const ch = chapters[idx - 1]; addLog(`[${idx}/${total}] 开始处理 ${ch.fullTitle}`, 'info'); try { await uploadCurrentChapter(ch); markOk(idx); addLog(`✓ 第${idx}章同步成功:${ch.title}`, 'success'); job.idx = idx + 1; job.retry = 0; job.failStreak = 0; job.updatedAt = Date.now(); saveJob(job); refreshUI(); if (job.idx <= to) { addLog(`还有${to - job.idx + 1}章待同步,准备下一章...`, 'info'); const d = nextDelay(); addLog(`等待 ${Math.round(d / 1000)} 秒后继续下一章`, 'info'); await sleep(d); location.href = publishUrl(getBookId()); } else { addLog(`=== 全部同步完成:第${job.from}章 - 第${to}章 ===`, 'success'); notify(`同步完成:第${job.from}章 - 第${to}章`); clearJob(); refreshUI(); } } catch (e) { const s = loadSettings(); job.retry = (job.retry || 0) + 1; job.failStreak = (job.failStreak || 0) + 1; markError(idx, ch.title, e); addLog(`✗ 第${idx}章失败:${e.message}`, 'error'); if (job.failStreak >= (Number(s.maxContinuousFailures) || 999)) { addLog(`连续失败${job.failStreak}次,安全暂停任务`, 'error'); job.running = false; job.paused = true; saveJob(job); refreshUI(); return; } if (job.retry <= (Number(s.maxRetry) || 0)) { addLog(`自动重试 ${job.retry}/${s.maxRetry}:刷新发布页`, 'warn'); saveJob(job); await sleep(2500); location.href = publishUrl(getBookId()); } else { addLog(`超过重试次数,跳过第${idx}章`, 'warn'); job.idx = idx + 1; job.retry = 0; saveJob(job); await sleep(nextDelay()); if (job.idx <= to) location.href = publishUrl(getBookId()); else { clearJob(); refreshUI(); } } } } catch (e) { addLog('任务引擎停止:' + e.message, 'error'); clearJob(); refreshUI(); } finally { window.__fqProJobBusy = false; } } function startJob(skipSynced, failedOnly = false) { const chapters = loadChapters(); if (!chapters.length) { addLog('没有章节数据,请先导入', 'error'); switchTab('chapters'); return; } if (!getBookId()) { addLog('当前不是番茄作家网书籍页,请先进书籍管理页', 'error'); return; } let from = Math.max(1, parseInt(val('fq-from')) || 1); let to = Math.min(chapters.length, parseInt(val('fq-to')) || chapters.length); if (failedOnly) { const nums = Object.keys(loadProgress().errors || {}).map(Number).filter(n => n >= 1 && n <= chapters.length).sort((a, b) => a - b); if (!nums.length) return addLog('没有失败章节可重传', 'info'); from = nums[0]; to = nums[nums.length - 1]; } if (from > to) return addLog('范围错误:起始章不能大于结束章', 'error'); const s = loadSettings(); if (s.saveMode === 'final' && !s.allowFinalPublish) return addLog('最终发布模式未授权:请先在设置勾选“允许最终发布”', 'error'); if (s.autoPreflight) { const pf = preflightRange(from, to); if (s.pauseOnRisk && !pf.ok) { addLog('预检未通过,已阻止自动同步;可修正文稿或关闭“风险暂停”', 'error'); switchTab('logs'); return; } } saveJob({ running: true, from, to, idx: from, retry: 0, failStreak: 0, skipSynced: !!skipSynced, failedOnly: !!failedOnly, createdAt: Date.now(), updatedAt: Date.now() }); addLog(`=== 启动同步:第${from}章 - 第${to}章${skipSynced ? '(跳过已同步)' : ''}${failedOnly ? '(仅失败)' : ''}|模式${s.saveMode} ===`, 'success'); refreshUI(); runJobEngine('start'); } function pauseJob() { const j = loadJob(); if (!j || !j.running) return addLog('没有运行中的任务', 'info'); j.running = false; j.paused = true; saveJob(j); addLog(`已暂停:第${j.idx}章`, 'warn'); refreshUI(); } function resumeJob() { const j = loadJob(); if (!j || !j.paused) return startJob(true); j.running = true; j.paused = false; saveJob(j); addLog(`断点续传:第${j.idx}章`, 'success'); refreshUI(); runJobEngine('resume'); } function syncProgressToServer(chapterNum) { const s = loadSettings(); if (!s.localServer) return; try { GM_xmlhttpRequest({ method: 'POST', url: s.localServer.replace(/\/$/, '') + '/api/progress', timeout: 2500, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ lastSynced: chapterNum }) }); } catch (_) { } } function loadFromServer() { const s = loadSettings(); if (!s.localServer) return addLog('请先配置本地服务器地址', 'warn'); addLog('从本地服务器加载章节数据...', 'info'); GM_xmlhttpRequest({ method: 'GET', url: s.localServer.replace(/\/$/, '') + '/api/chapters', timeout: 12000, onload(res) { try { const data = JSON.parse(res.responseText || '{}'); const arr = Array.isArray(data) ? data : data.chapters; if (!Array.isArray(arr) || !arr.length) throw new Error('服务器未返回章节数组'); saveImportedChapters(arr, '本地服务器'); } catch (e) { addLog('服务器数据解析失败:' + e.message, 'error'); } }, onerror() { addLog('无法连接本地服务器', 'error'); }, ontimeout() { addLog('连接本地服务器超时', 'error'); } }); } async function importFileList(fileList, label) { const files = Array.from(fileList || []).filter(f => /\.(md|txt)$/i.test(f.name)); if (!files.length) return addLog('没有发现 .md/.txt 章节文件', 'error'); files.sort((a, b) => parseChapterNumberFromName(a.webkitRelativePath || a.name) - parseChapterNumberFromName(b.webkitRelativePath || b.name)); const arr = []; for (let i = 0; i < files.length; i++) arr.push(parseChapterText(await files[i].text(), files[i].webkitRelativePath || files[i].name, i + 1)); saveImportedChapters(arr, label || '章节文件导入'); } function handleJsonFile(file) { if (!file) return; if (!/\.json$/i.test(file.name)) return addLog('请选择JSON文件', 'error'); const reader = new FileReader(); reader.onload = () => { try { const data = JSON.parse(reader.result); const arr = Array.isArray(data) ? data : data.chapters; if (!Array.isArray(arr) || !arr.length) throw new Error('JSON内没有章节数组'); saveImportedChapters(arr, 'JSON:' + file.name); } catch (e) { addLog('JSON解析失败:' + e.message, 'error'); } }; reader.readAsText(file, 'utf-8'); } function csvEscape(v) { const s = String(v == null ? '' : v); return /["\n\r,]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; } function exportQaCsv(limit) { const chapters = loadChapters(); if (!chapters.length) return addLog('没有章节可导出', 'error'); const part = limit ? chapters.slice(0, Number(limit)) : chapters; const rows = [['上传序号', '原编号', '标题', '字数', '质检', '问题']]; part.forEach(ch => { const a = auditChapter(ch); rows.push([ch.num, ch.sourceNum || '', a.title, a.words, a.ok ? 'OK' : '风险', a.problems.join('、')]); }); downloadText(`${limit ? '番茄开篇' + part.length + '章质检' : '番茄全章节质检'}_${new Date().toISOString().slice(0,10)}.csv`, '\ufeff' + rows.map(r => r.map(csvEscape).join(',')).join('\n'), 'text/csv;charset=utf-8'); addLog('已导出质检CSV', 'success'); } function exportFailedReport() { const p = loadProgress(), chapters = loadChapters(); const rows = Object.keys(p.errors || {}).map(Number).sort((a, b) => a - b).map(n => `${n}\t${(chapters[n - 1] || {}).title || ''}\t${(p.errors[n] || {}).error || ''}\t${(p.errors[n] || {}).time || ''}`); if (!rows.length) return addLog('没有失败记录', 'info'); downloadText(`番茄失败报告_${new Date().toISOString().slice(0,10)}.tsv`, '章节\t标题\t错误\t时间\n' + rows.join('\n'), 'text/tab-separated-values;charset=utf-8'); } function exportJson() { const chapters = loadChapters(); downloadText(`番茄章节备份_${new Date().toISOString().slice(0,10)}.json`, JSON.stringify({ exportedAt: new Date().toISOString(), chapters }, null, 2), 'application/json;charset=utf-8'); addLog('已导出章节JSON', 'success'); } function exportMergedTxt() { const chapters = loadChapters(); if (!chapters.length) return addLog('没有章节', 'error'); downloadText(`番茄合并章节_${new Date().toISOString().slice(0,10)}.txt`, chapters.map(ch => `${formatTitle(ch)}\n\n${ch.content || ''}`).join('\n\n'), 'text/plain;charset=utf-8'); addLog('已导出合并TXT', 'success'); } function showRiskOverview() { const chapters = loadChapters(); if (!chapters.length) return addLog('没有章节数据', 'error'); const counts = {}, samples = []; chapters.forEach(ch => { const a = auditChapter(ch); if (!a.ok) samples.push(`${String(ch.num).padStart(3, '0')}|${a.title}|${a.problems.join('、')}`); a.problems.forEach(x => counts[x] = (counts[x] || 0) + 1); }); addLog(`风险总览:异常章节${samples.length}/${chapters.length},问题类型${Object.keys(counts).length}`, samples.length ? 'warn' : 'success'); Object.entries(counts).sort((a,b)=>b[1]-a[1]).slice(0, 12).forEach(([k,v]) => addLog(`风险类型|${k}:${v}章`, 'warn')); samples.slice(0, 30).forEach(x => addLog('风险样本|' + x, 'warn')); switchTab('logs'); } function showReadiness() { const chapters = loadChapters(); if (!chapters.length) return addLog('没有章节数据', 'error'); const p = loadProgress(), s = loadSettings(); const words = chapters.reduce((n,c)=>n+(c.words||countWords(c.content)),0); const risks = chapters.filter(ch => !auditChapter(ch).ok).length; const ok = Object.keys(p.ok||{}).length; const fail = Object.keys(p.errors||{}).length; addLog(`发布就绪度|草稿完成${ok}/${chapters.length}|失败${fail}|质检风险${risks}|总字数${words}`, (fail||risks)?'warn':'success'); addLog(`字数线|签约评估${Math.round(words/(s.contractWords||20000)*100)}%/${s.contractWords}|推荐门槛${Math.round(words/(s.recommendWords||100000)*100)}%/${s.recommendWords}`, words >= s.contractWords ? 'success' : 'warn'); switchTab('logs'); } function selectNextBatch() { const chapters = loadChapters(); if (!chapters.length) return addLog('没有章节数据', 'error'); const p = loadProgress(), size = Math.max(1, Number(loadSettings().nextBatchSize || 5)); let start = 0; for (let i = 1; i <= chapters.length; i++) if (!(p.ok||{})[i] && !(p.skipped||{})[i] && i > (p.lastSynced||0)) { start = i; break; } if (!start) { const failed = Object.keys(p.errors||{}).map(Number).sort((a,b)=>a-b)[0]; if (failed) start = failed; } if (!start) return addLog('未发现未完成章节', 'success'); setVal('fq-from', start); setVal('fq-to', Math.min(chapters.length, start + size - 1)); addLog(`已选下一批未完成:第${start}-${Math.min(chapters.length, start + size - 1)}章`, 'success'); switchTab('run'); } function progressState(num) { const p = loadProgress(); if ((p.errors || {})[num]) return '失败'; if ((p.ok || {})[num] || num <= (p.lastSynced || 0)) return '完成'; if ((p.skipped || {})[num]) return '跳过'; return '待传'; } function collectPublishChecklist(limit) { const chapters = loadChapters(); const part = limit ? chapters.slice(0, Number(limit)) : chapters; return part.map(ch => { const a = auditChapter(ch); return { n: ch.num, source: ch.sourceNum || '', title: a.title, words: a.words, state: progressState(ch.num), ok: a.ok, problems: a.problems }; }); } function exportPublishChecklist(limit) { const rows = [['上传序号','原编号','最终标题','字数','进度状态','可发布','风险问题']]; collectPublishChecklist(limit).forEach(x => rows.push([x.n, x.source, x.title, x.words, x.state, x.ok ? '是' : '否', x.problems.join('、')])); if (rows.length === 1) return addLog('没有章节可导出发布清单', 'error'); downloadText(`番茄发布清单_${limit ? '前'+limit+'章_' : ''}${new Date().toISOString().slice(0,10)}.csv`, '\ufeff' + rows.map(r => r.map(csvEscape).join(',')).join('\n'), 'text/csv;charset=utf-8'); addLog(`已导出发布清单:${rows.length - 1}章`, 'success'); } function exportUpdatePlan() { const chapters = loadChapters(); if (!chapters.length) return addLog('没有章节数据,无法导出日更计划', 'error'); const s = loadSettings(), daily = Math.max(1000, Number(s.stableDailyWords || 6000)); const rows = [['天数','起始章','结束章','章节数','预计字数','提示']]; let day = 1, start = 1, words = 0, count = 0; for (let i = 0; i < chapters.length; i++) { const w = Number(chapters[i].words || countWords(chapters[i].content || '')); if (count && words + w > daily * 1.18) { rows.push([day, start, i, count, words, words >= daily * 0.82 ? '稳定更新' : '偏少']); day++; start = i + 1; words = 0; count = 0; } words += w; count++; } if (count) rows.push([day, start, chapters.length, count, words, words >= daily * 0.82 ? '稳定更新' : '偏少']); downloadText(`番茄日更计划_${daily}字_${new Date().toISOString().slice(0,10)}.csv`, '\ufeff' + rows.map(r => r.map(csvEscape).join(',')).join('\n'), 'text/csv;charset=utf-8'); addLog(`已导出日更计划:目标${daily}字/天,共${rows.length - 1}天`, 'success'); } function showMilestones() { const chapters = loadChapters(), s = loadSettings(); const words = chapters.reduce((n,c)=>n+(c.words||countWords(c.content)),0), daily = Number(s.stableDailyWords || 6000); const lines = [['首次签约评估', Number(s.contractWords || 20000)], ['二次评估参考', Number(s.secondEvalWords || 50000)], ['推荐/分发门槛', Number(s.recommendWords || 100000)], ['三次评估参考', Number(s.thirdEvalWords || 200000)]]; addLog(`里程碑|当前${chapters.length}章 ${words}字|约${daily ? Math.ceil(words/daily) : 0}天日更量`, 'success'); lines.forEach(([name, target]) => addLog(`${name}|${Math.min(100, Math.round(words/target*100))}%|${target}字|还差${Math.max(0, target-words)}字`, words >= target ? 'success' : 'warn')); switchTab('logs'); } function makeRecommendationTitle(ch) { const base = formatTitle(ch).replace(/^第\s*([0-9零一二三四五六七八九十百千万]+)\s*[章节回][::、\s-]*/i, '').replace(/^小说[::]/, '').trim(); const first = String(ch.content || '').split(/\n+/).map(x => x.trim()).filter(x => x.length >= 12)[0] || ''; let hook = first.replace(/[“”"'‘’]/g, '').replace(/[,。!?;:].*$/, '').replace(/^(第.+章|本章|简介|梗概).{0,8}/, '').slice(0, 18); let core = hook && !base.includes(hook) ? `${base},${hook}` : base; core = core.replace(/\s+/g, '').replace(/[【】《》「」『』!!??…]+/g, ''); if (core.length < 12) core = (core + ',故事正式开局').slice(0, 24); let t = '小说:' + core; if (t.length < 15) t = (t + ',命运转折').slice(0, 18); return t.length > 30 ? t.slice(0, 30) : t; } function exportRecommendationTitles() { const chapters = loadChapters(); if (!chapters.length) return addLog('没有章节数据,无法导出推荐标题', 'error'); const n = Math.min(chapters.length, Number(loadSettings().recommendTitleCount || 30)); const rows = [['章节','原编号','章节标题','推荐标题草稿','字数','风险问题']]; chapters.slice(0, n).forEach(ch => { const a = auditChapter(ch); rows.push([ch.num, ch.sourceNum || '', a.title, makeRecommendationTitle(ch), a.words, a.problems.join('、')]); }); downloadText(`番茄头条推荐标题_前${n}章_${new Date().toISOString().slice(0,10)}.csv`, '\ufeff' + rows.map(r => r.map(csvEscape).join(',')).join('\n'), 'text/csv;charset=utf-8'); addLog(`已导出推荐标题草稿:前${n}章。官方建议优先选靠前章节并人工复核`, 'success'); } function repairImportedQueue() { const chapters = loadChapters(); if (!chapters.length) return addLog('没有章节可修复', 'error'); if (!confirm('将按原编号/当前顺序重排上传序号、重算字数,并重置进度。继续?')) return; createBackup('队列修复前备份'); const fixed = normalizeImportedChapters(chapters.map(ch => Object.assign({}, ch, { content: cleanText(ch.content || '') })), '队列修复'); saveChapters(fixed); saveProgress({ lastSynced: 0, ok: {}, errors: {}, skipped: {}, importedAt: Date.now(), source: '队列修复' }); addLog(`队列修复完成:${fixed.length}章,已重排序号并重算字数`, 'success'); refreshUI(); } function selectOpeningRange() { const chapters = loadChapters(); if (!chapters.length) return addLog('没有章节数据', 'error'); const n = Math.min(chapters.length, Number(loadSettings().openingChapters || 30)); setVal('fq-from', 1); setVal('fq-to', n); const f=document.getElementById('fq-from'), t=document.getElementById('fq-to'); if(f) f.dataset.touched='1'; if(t) t.dataset.touched='1'; addLog(`已选择开篇范围:第1-${n}章`, 'success'); switchTab('run'); } function selectRiskRange() { const nums = loadChapters().filter(ch => !auditChapter(ch).ok).map(ch => ch.num).sort((a,b)=>a-b); if (!nums.length) return addLog('没有质检风险章节', 'success'); setVal('fq-from', nums[0]); setVal('fq-to', nums[nums.length - 1]); const f=document.getElementById('fq-from'), t=document.getElementById('fq-to'); if(f) f.dataset.touched='1'; if(t) t.dataset.touched='1'; addLog(`已圈定风险章节范围:第${nums[0]}-${nums[nums.length - 1]}章(共${nums.length}章需看日志逐章处理)`, 'warn'); switchTab('run'); } function smartStrategy() { const chapters = loadChapters(); if (!chapters.length) return addLog('没有章节数据,无法智能策略分析', 'error'); const p = loadProgress(), fails = Object.keys(p.errors || {}).map(Number), risks = chapters.filter(ch => !auditChapter(ch).ok).map(ch => ch.num); if (fails.length) { setVal('fq-from', Math.min(...fails)); setVal('fq-to', Math.max(...fails)); addLog(`智能策略:发现失败章节${fails.length}个,已圈定失败重传范围`, 'warn'); switchTab('run'); return; } if (risks.length && loadSettings().pauseOnRisk) { setVal('fq-from', Math.min(...risks)); setVal('fq-to', Math.max(...risks)); addLog(`智能策略:发现质检风险${risks.length}章,已圈定风险范围,建议先处理再上传`, 'warn'); switchTab('run'); return; } selectNextBatch(); addLog('智能策略:无失败/阻断风险,已选择下一批未完成', 'success'); } function pageProbe(silent = false) { const title = findTitleInput(), editor = findEditor(); const save = findActionButton(['存草稿','保存草稿','保存'], false); const next = findActionButton(['下一步','继续发布'], false); const dialogs = Array.from(document.querySelectorAll('[role="dialog"], .semi-modal, .byte-modal, .arco-modal, .modal, .dialog')).filter(visible).map(x => textOf(x).slice(0, 160)); const info = { url: location.href, bookId: getBookId() || '', publishPage: isPublishPage(), titleFound: !!title, editorFound: !!editor, editorWords: editor ? countWords(editor.innerText || editor.textContent || editor.value || '') : 0, saveFound: !!save, nextFound: !!next, dialogs, risk: detectRiskText() }; if (!silent) { addLog(`页面探针|书籍${info.bookId || '未识别'}|发布页${info.publishPage?'是':'否'}|标题${info.titleFound?'OK':'缺'}|编辑器${info.editorFound?info.editorWords+'字':'缺'}|草稿${info.saveFound?'有':'缺'}|下一步${info.nextFound?'有':'缺'}`, (!info.titleFound || !info.editorFound) ? 'warn' : 'info'); if (info.risk) addLog('风险线索|' + info.risk, 'warn'); dialogs.slice(0, 3).forEach(d => addLog('弹窗|' + d, 'warn')); } return info; } async function fillOnly() { const chapters = loadChapters(); const n = Math.max(1, parseInt(val('fq-from')) || 1); const ch = chapters[n - 1]; if (!ch) return addLog('找不到章节', 'error'); if (!isPublishPage()) { const bookId = getBookId(); if (!bookId) return addLog('不在书籍页', 'error'); location.href = publishUrl(bookId); return; } const { input, editor } = await waitEditorReady(); setNativeValue(input, formatTitle(ch)); handleAiUsageDeclaration(); const got = await fillEditor(editor, ch); addLog(`只填不存完成:第${n}章,检测${got}字`, 'success'); } GM_addStyle(` #fq-sync-panel{--fq-bg:#fff8ef;--fq-ink:#2d160e;--fq-muted:#916f5a;--fq-line:rgba(120,53,15,.16);--fq-red:#e7472f;--fq-red2:#a81618;--fq-orange:#ff9e2d;--fq-gold:#ffd28a;--fq-green:#25b56f;--fq-blue:#3d7cff;--fq-card:rgba(255,255,255,.78);--fq-card2:rgba(255,246,235,.86);position:fixed;right:18px;bottom:18px;z-index:2147483647;width:492px;max-height:90vh;background:radial-gradient(circle at 15% 0%,rgba(255,210,138,.6),transparent 27%),linear-gradient(150deg,#35110b 0%,#941515 34%,#ffefe1 34.2%,#fff8ef 100%);border:1px solid rgba(255,210,138,.55);border-radius:24px;box-shadow:0 30px 90px rgba(74,16,8,.36),0 0 0 1px rgba(255,255,255,.28) inset;font-family:"Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif;font-size:13px;color:var(--fq-ink);overflow:hidden;display:flex;flex-direction:column;backdrop-filter:blur(18px);animation:fqRise .32s ease-out}#fq-sync-panel *{box-sizing:border-box;scrollbar-width:thin;scrollbar-color:#e8b98c transparent}#fq-sync-panel:before{content:'';position:absolute;inset:0;pointer-events:none;background:linear-gradient(90deg,rgba(255,255,255,.05) 1px,transparent 1px),linear-gradient(0deg,rgba(255,255,255,.035) 1px,transparent 1px);background-size:22px 22px;mask-image:linear-gradient(#000,transparent 46%)}#fq-sync-panel.collapsed{width:64px;height:64px;border-radius:25px;cursor:pointer;background:radial-gradient(circle at 30% 20%,#ffd28a,#ff5a28 45%,#8f1114 100%);box-shadow:0 18px 48px rgba(167,25,16,.42),0 0 0 1px rgba(255,255,255,.35) inset;animation:fqOrb 2.2s ease-in-out infinite}#fq-sync-panel.collapsed .fq-body,#fq-sync-panel.collapsed .fq-titlebox,#fq-sync-panel.collapsed .fq-pin,#fq-sync-panel.collapsed .fq-logo{display:none}#fq-sync-panel.collapsed:after{content:'番';position:absolute;inset:0;display:grid;place-items:center;color:#fff;font-size:28px;font-weight:950;text-shadow:0 2px 12px rgba(0,0,0,.28)}.fq-head{min-height:72px;padding:14px 15px;color:white;display:flex;gap:11px;align-items:center;user-select:none;touch-action:none;position:relative;z-index:1}.fq-logo{width:42px;height:42px;border-radius:16px;background:linear-gradient(135deg,rgba(255,255,255,.24),rgba(255,255,255,.06));display:grid;place-items:center;font-weight:950;font-size:22px;box-shadow:0 10px 30px rgba(0,0,0,.18),0 0 0 1px rgba(255,255,255,.2) inset}.fq-titlebox h3{margin:0;font-size:17px;letter-spacing:.4px}.fq-titlebox span{display:block;margin-top:2px;font-size:11px;opacity:.86}.fq-statusline{display:flex;gap:6px;margin-top:7px;flex-wrap:wrap}.fq-chip{height:20px;padding:0 8px;border-radius:999px;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.18);display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#fff}.fq-chip.safe{background:rgba(37,181,111,.22)}.fq-chip.warn{background:rgba(255,158,45,.24)}.fq-chip.danger{background:rgba(231,71,47,.28)}.fq-pin{margin-left:auto;display:flex;gap:7px}.fq-ico{width:32px;height:32px;border:0;border-radius:12px;background:rgba(255,255,255,.16);color:#fff;cursor:pointer;font-weight:900;transition:.18s}.fq-ico:hover{background:rgba(255,255,255,.28);transform:translateY(-1px)}.fq-body{background:linear-gradient(180deg,rgba(255,250,245,.94),rgba(255,241,226,.96));min-height:0;display:flex;flex-direction:column;position:relative;z-index:1}.fq-tabs{display:grid;grid-template-columns:repeat(5,1fr);gap:6px;padding:9px 10px;background:rgba(255,232,206,.74);border-bottom:1px solid var(--fq-line);backdrop-filter:blur(10px)}.fq-tab{padding:9px 4px;text-align:center;cursor:pointer;color:#8b6044;border-radius:13px;font-size:12px;font-weight:700;transition:.18s;position:relative}.fq-tab:hover{background:rgba(255,255,255,.48);transform:translateY(-1px)}.fq-tab.active{color:#fff;background:linear-gradient(135deg,var(--fq-red),var(--fq-orange));box-shadow:0 8px 20px rgba(231,71,47,.24)}.fq-page{display:none;padding:11px;overflow:auto;max-height:calc(90vh - 126px)}.fq-page.active{display:block}.fq-card{background:var(--fq-card);border:1px solid rgba(236,199,165,.78);border-radius:18px;padding:11px;box-shadow:0 10px 28px rgba(118,56,20,.09),0 1px 0 rgba(255,255,255,.72) inset;backdrop-filter:blur(12px)}.fq-card.fq-hero{background:linear-gradient(145deg,rgba(255,255,255,.9),rgba(255,238,220,.78));position:relative;overflow:hidden}.fq-card.fq-hero:after{content:'';position:absolute;width:150px;height:150px;right:-62px;top:-70px;background:radial-gradient(circle,rgba(255,157,45,.22),transparent 68%)}.fq-row{display:flex;gap:7px;align-items:center;flex-wrap:wrap}.fq-label{font-size:11px;color:#9a7359}.fq-val{font-weight:900;letter-spacing:.2px}.fq-health{display:grid;grid-template-columns:repeat(4,1fr);gap:7px;margin-top:10px}.fq-pill{background:linear-gradient(180deg,#fff8ef,#fff0df);border:1px solid #f0d0ad;border-radius:14px;padding:8px;font-size:11px;box-shadow:0 4px 12px rgba(120,60,20,.05)}.fq-pill b{display:block;color:#351b10;margin-top:3px;font-size:12px}.fq-progress{height:10px;background:#ead5c2;border-radius:999px;overflow:hidden;box-shadow:inset 0 1px 4px rgba(60,20,8,.13)}.fq-bar{height:100%;width:0;background:linear-gradient(90deg,#e7472f,#ff9e2d,#ffd28a);transition:.35s;box-shadow:0 0 16px rgba(255,158,45,.48)}.fq-flow{display:grid;grid-template-columns:repeat(5,1fr);gap:6px;margin-top:10px}.fq-step{border:1px solid #f0d0ad;border-radius:13px;background:#fff7ed;padding:7px 4px;text-align:center;font-size:10px;color:#8b644c;position:relative;overflow:hidden}.fq-step b{display:block;font-size:12px;color:#3b2114}.fq-step.on{background:linear-gradient(135deg,#fff,#ffe2c5);border-color:#ffbd75;color:#78310f;box-shadow:0 7px 18px rgba(255,158,45,.16)}.fq-step.lock{background:#fff1f1;border-color:#f4b3ab;color:#b53022}.fq-advice{margin-top:9px;border-radius:14px;padding:9px 10px;background:linear-gradient(135deg,#3a1710,#8f1717);color:#fff;font-size:12px;line-height:1.55;box-shadow:0 10px 26px rgba(80,16,8,.2)}.fq-input{border:1px solid #ddbca0;border-radius:11px;padding:8px 10px;background:rgba(255,255,255,.92);font-family:inherit;font-size:12px;color:#3b2114;outline:none;transition:.16s}.fq-input:focus{border-color:#f39c12;box-shadow:0 0 0 3px rgba(243,156,18,.13)}.fq-num{width:76px;text-align:center}.fq-btn{border:0;border-radius:12px;padding:8px 11px;font-family:inherit;font-size:12px;font-weight:800;cursor:pointer;transition:.16s;box-shadow:0 1px 0 rgba(255,255,255,.6) inset}.fq-btn:hover{transform:translateY(-1px);box-shadow:0 8px 18px rgba(80,30,12,.14)}.fq-btn:active{transform:translateY(0) scale(.98)}.fq-primary{background:linear-gradient(135deg,var(--fq-red),var(--fq-orange));color:white}.fq-secondary{background:#fff6eb;color:#5b3924;border:1px solid #ecc7a5}.fq-danger{background:#fff;color:#d73325;border:1px solid #e74c3c}.fq-warn{background:#fff3cb;color:#8a5a00;border:1px solid #e9c15b}.fq-mini{padding:6px 8px;font-size:11px}.fq-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}.fq-toolgroup{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.fq-drop{border:2px dashed #ddb99a;border-radius:18px;padding:21px 10px;text-align:center;color:#9b765c;background:linear-gradient(135deg,#fffaf4,#fff0df);cursor:pointer;transition:.18s}.fq-drop:hover,.fq-drop.drag{border-color:#e74c3c;background:#fff1ee;transform:translateY(-1px)}.fq-list{border:1px solid #ecd0b6;border-radius:15px;overflow:auto;max-height:330px;background:#fff;box-shadow:inset 0 0 0 1px rgba(255,255,255,.6)}.fq-ch{display:grid;grid-template-columns:64px 1fr 54px 54px 58px;gap:5px;align-items:center;padding:8px 9px;border-bottom:1px solid #f4e1ce;font-size:12px}.fq-ch:hover{background:#fff7ef}.fq-ch .n{font-family:Consolas,monospace;color:#9d7358}.fq-ch .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:700}.fq-ch .w{font-size:11px;color:#a98b75}.fq-badge{font-size:10px;text-align:center;border-radius:999px;padding:3px 6px;font-weight:800}.fq-badge.ok{background:#daf5e7;color:#169b59}.fq-badge.err{background:#ffd9d4;color:#d73528}.fq-badge.wait{background:#eee;color:#777}.fq-badge.skip{background:#ffefc5;color:#9b6500}.fq-link{border:0;background:transparent;color:#d73528;cursor:pointer;font-size:11px;font-weight:800}.fq-log{background:linear-gradient(180deg,#171c22,#242a30);color:#d6dee7;border-radius:15px;min-height:270px;max-height:430px;overflow:auto;padding:10px;font-family:Consolas,"Cascadia Code",monospace;font-size:11px;box-shadow:inset 0 0 0 1px rgba(255,255,255,.05)}.fq-line{padding:3px 0}.fq-line .ts{color:#71808e;margin-right:6px}.fq-line.success .msg{color:#9ff0b6}.fq-line.warn .msg{color:#ffe28a}.fq-line.error .msg{color:#ff7c7c}.fq-setting{margin-bottom:9px}.fq-setting label{display:block;font-size:11px;color:#8e6a50;margin-bottom:3px}.fq-help{font-size:12px;color:#6c4b35;line-height:1.65}.fq-rulebox{margin-top:9px;padding:9px 10px;border-radius:15px;background:rgba(255,255,255,.64);border:1px dashed #edc69f;color:#6c4b35;font-size:12px;line-height:1.65}.fq-kbd{display:inline-block;padding:1px 6px;border-radius:7px;background:#fff;border:1px solid #ead0b5;font-size:11px;color:#8a4a20}.fq-pulse{animation:fqPulse 1.2s infinite}@keyframes fqPulse{0%,100%{opacity:1}50%{opacity:.55}}@keyframes fqRise{from{opacity:0;transform:translateY(12px) scale(.98)}to{opacity:1;transform:none}}@keyframes fqOrb{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}@media(max-width:560px){#fq-sync-panel{left:8px!important;right:8px!important;bottom:8px;width:auto;max-height:92vh}.fq-health,.fq-flow{grid-template-columns:repeat(2,1fr)}.fq-grid,.fq-toolgroup{grid-template-columns:1fr}.fq-page{max-height:calc(92vh - 126px)}} `); function createPanel() { if (document.getElementById('fq-sync-panel')) return document.getElementById('fq-sync-panel'); const panel = document.createElement('div'); panel.id = 'fq-sync-panel'; panel.innerHTML = `

番茄章节同步助手 Pro

上传、质检、运营计划融合驾驶舱
草稿安全风险--发布锁
驾驶舱
章节库
工具箱
战报
参数
当前任务
无运行任务
0%
连接--
书籍--
章节--
完成--
01导入
02预检
03草稿
04下一步
05人工发布
等待导入章节。先建队列,再预检,默认只存草稿。
从第章 到第
流程提示
官方流程:章节管理 → 创建章节 → 输入标题/正文 → 存草稿 或 下一步发布。默认只存草稿;最终发布默认安全锁拦截。
融合规则 章节标题建议5-30字;推荐标题与目录标题分开,推荐标题建议“小说:”格式且15-30字,仅做草稿导出人工复核。
📚
拖入 JSON / txt / md,或选择文件夹
支持JSON、多选章节、整个文件夹;自动排序并重排上传序号
请先导入章节
统计
--
浏览器不能静默读本地路径,只能通过文件选择/拖拽/本地服务器读取。最终发布模式会真实提交章节,默认不授权。
`; document.body.appendChild(panel); return panel; } function switchTab(name) { const p = document.getElementById('fq-sync-panel'); if (!p) return; p.querySelectorAll('.fq-tab').forEach(x => x.classList.toggle('active', x.dataset.tab === name)); p.querySelectorAll('.fq-page').forEach(x => x.classList.toggle('active', x.dataset.tab === name)); } function renderOneLog(l) { const el = document.getElementById('fq-log'); if (!el || !l) return; const div = document.createElement('div'); div.className = 'fq-line ' + (l.type || 'info'); div.innerHTML = `[${esc(l.time)}]${esc(l.msg)}`; el.insertBefore(div, el.firstChild); el.scrollTop = el.scrollHeight; } function restoreLogs() { const el = document.getElementById('fq-log'); if (!el) return; el.innerHTML = ''; loadLogs().slice(-180).reverse().forEach(renderOneLog); } function val(id) { const el = document.getElementById(id); return el ? el.value : ''; } function setVal(id, v) { const el = document.getElementById(id); if (el) el.value = v; } function setText(id, txt) { const el = document.getElementById(id); if (el) el.textContent = txt; } function refreshUI() { const chapters = loadChapters(); const p = loadProgress(); const job = loadJob(); const total = chapters.length; const bookId = getBookId(); const okCount = Object.keys(p.ok || {}).length; const pct = total ? Math.round(okCount / total * 100) : 0; const conn = document.getElementById('fq-conn'); if (conn) conn.innerHTML = `${bookId ? '已连接' : '未在书籍页'}`; const riskCount = chapters.filter(ch => !auditChapter(ch).ok).length; const failCount = Object.keys(p.errors || {}).length; const settingsNow = loadSettings(); const modeChip = document.getElementById('fq-mode-chip'); if (modeChip) { modeChip.textContent = settingsNow.saveMode === 'draft' ? '草稿安全' : settingsNow.saveMode === 'next' ? '下一步模式' : settingsNow.saveMode === 'final' ? '最终发布' : '只填不存'; modeChip.className = 'fq-chip ' + (settingsNow.saveMode === 'final' ? 'danger' : settingsNow.saveMode === 'next' ? 'warn' : 'safe'); } const riskChip = document.getElementById('fq-risk-chip'); if (riskChip) { riskChip.textContent = failCount ? `失败${failCount}` : riskCount ? `风险${riskCount}` : '质检清爽'; riskChip.className = 'fq-chip ' + (failCount || riskCount ? 'warn' : 'safe'); } const finalChip = document.getElementById('fq-final-chip'); if (finalChip) { finalChip.textContent = settingsNow.finalPublishGuard ? '发布锁开' : '发布锁关'; finalChip.className = 'fq-chip ' + (settingsNow.finalPublishGuard ? 'warn' : 'danger'); } ['fq-flow-import','fq-flow-audit','fq-flow-draft','fq-flow-next'].forEach(id => { const el=document.getElementById(id); if(el) el.classList.remove('on'); }); if (document.getElementById('fq-flow-import') && total) document.getElementById('fq-flow-import').classList.add('on'); if (document.getElementById('fq-flow-audit') && total && !riskCount) document.getElementById('fq-flow-audit').classList.add('on'); if (document.getElementById('fq-flow-draft') && okCount) document.getElementById('fq-flow-draft').classList.add('on'); if (document.getElementById('fq-flow-next') && settingsNow.saveMode !== 'draft' && settingsNow.saveMode !== 'fill') document.getElementById('fq-flow-next').classList.add('on'); const advice = document.getElementById('fq-advice'); if (advice) advice.textContent = !total ? '等待导入章节。支持JSON、多选txt/md、文件夹和本地服务器。' : failCount ? `建议先点“失败重传”,处理 ${failCount} 个失败章节。` : riskCount ? `建议先打开“风险总览/选风险范围”,清掉 ${riskCount} 个质检风险。` : okCount < total ? '队列状态良好:可用“智能策略”选择下一批未完成章节。' : '全部章节已完成草稿/同步,可导出发布清单并人工复核最终发布。'; setText('fq-book', bookId || '--'); setText('fq-total', total || '--'); setText('fq-synced', `${p.lastSynced || 0}/${total || '--'}(完成${okCount})`); setText('fq-pct', pct + '%'); const bar = document.getElementById('fq-bar'); if (bar) bar.style.width = pct + '%'; const from = document.getElementById('fq-from'), to = document.getElementById('fq-to'); if (from && to && total) { from.max = total; to.max = total; if (!to.dataset.touched) to.value = total; if (!from.dataset.touched) from.value = Math.min(total, (p.lastSynced || 0) + 1); } ['fq-stop','fq-pause'].forEach(id => { const el = document.getElementById(id); if (el) el.style.display = job && job.running ? '' : 'none'; }); const start = document.getElementById('fq-start'); if (start) start.style.display = job && job.running ? 'none' : ''; setText('fq-job', job && job.running ? `运行中:第${job.idx}/${job.to}章,重试${job.retry || 0}${job.failedOnly ? '|失败重传' : ''}` : `无运行任务${p.source ? '|来源:' + p.source : ''}`); const stats = chapters.length ? `${chapters.length}章|总字数${chapters.reduce((n,c)=>n+(c.words||0),0)}|风险${chapters.filter(ch=>!auditChapter(ch).ok).length}|备份${loadBackups().length}` : `无章节|备份${loadBackups().length}`; setText('fq-stats', stats); renderChapterList(chapters, p); } function renderChapterList(chapters, p) { const box = document.getElementById('fq-list'); if (!box) return; if (!chapters.length) { box.innerHTML = '
请先导入章节
'; return; } const ok = p.ok || {}, skip = p.skipped || {}, err = p.errors || {}; box.innerHTML = chapters.map(ch => { const n = ch.num; let cls='wait', text='待传'; if (err[n]) { cls='err'; text='失败'; } else if (ok[n] || n <= (p.lastSynced || 0)) { cls='ok'; text='完成'; } else if (skip[n]) { cls='skip'; text='跳过'; } const a = auditChapter(ch); const showNo = ch.sourceNum && ch.sourceNum !== n ? `${String(n).padStart(3,'0')}←${String(ch.sourceNum).padStart(3,'0')}` : String(n).padStart(3,'0'); return `
${showNo}${esc(ch.title)}${ch.words}${a.ok ? text : '质检'}
`; }).join(''); } function loadSettingsToForm() { const s = loadSettings(); setVal('set-mode', s.saveMode); setVal('set-title-mode', s.titleMode); setVal('set-server', s.localServer); setVal('set-dmin', s.delayMin); setVal('set-dmax', s.delayMax); setVal('set-retry', s.maxRetry); setVal('set-minwords', s.minWords); setVal('set-maxwords', s.maxWords); setVal('set-batch', s.nextBatchSize); ['preflight','risk-pause','strict','dialogs','title-fit','final-guard','allow-final','backup','clean'].forEach(k => { const el = document.getElementById('set-' + k); if (!el) return; const map = { 'preflight':'autoPreflight', 'risk-pause':'pauseOnRisk', strict:'strictValidate', dialogs:'autoKillDialogs', 'title-fit':'autoFitTitle', 'final-guard':'finalPublishGuard', 'allow-final':'allowFinalPublish', backup:'autoBackup', clean:'smartClean' }; el.checked = !!s[map[k]]; }); } function saveSettingsFromForm() { saveSettingsPatch({ saveMode: val('set-mode'), titleMode: val('set-title-mode'), localServer: val('set-server').trim(), delayMin: parseInt(val('set-dmin')) || DEFAULT_SETTINGS.delayMin, delayMax: parseInt(val('set-dmax')) || DEFAULT_SETTINGS.delayMax, maxRetry: parseInt(val('set-retry')) || 0, minWords: parseInt(val('set-minwords')) || 0, maxWords: parseInt(val('set-maxwords')) || 0, nextBatchSize: parseInt(val('set-batch')) || DEFAULT_SETTINGS.nextBatchSize, autoPreflight: document.getElementById('set-preflight').checked, pauseOnRisk: document.getElementById('set-risk-pause').checked, strictValidate: document.getElementById('set-strict').checked, autoKillDialogs: document.getElementById('set-dialogs').checked, autoFitTitle: document.getElementById('set-title-fit').checked, finalPublishGuard: document.getElementById('set-final-guard').checked, allowFinalPublish: document.getElementById('set-allow-final').checked, autoBackup: document.getElementById('set-backup').checked, smartClean: document.getElementById('set-clean').checked }); addLog('设置已保存', 'success'); } function bindEvents(panel) { panel.querySelectorAll('.fq-tab').forEach(t => t.onclick = () => switchTab(t.dataset.tab)); document.getElementById('fq-refresh').onclick = e => { e.stopPropagation(); refreshUI(); }; document.getElementById('fq-min').onclick = e => { e.stopPropagation(); panel.classList.toggle('collapsed'); }; panel.addEventListener('click', e => { if (panel.classList.contains('collapsed') && !e.target.closest('button,input,select,textarea,a')) panel.classList.remove('collapsed'); }); ['fq-from','fq-to'].forEach(id => { const x = document.getElementById(id); x.oninput = () => { x.dataset.touched = '1'; }; }); document.getElementById('fq-pick-folder').onclick = () => document.getElementById('fq-folder-input').click(); document.getElementById('fq-pick-files').onclick = () => document.getElementById('fq-files-input').click(); document.getElementById('fq-load-server').onclick = loadFromServer; document.getElementById('fq-folder-input').onchange = e => importFileList(e.target.files, '文件夹导入'); document.getElementById('fq-files-input').onchange = e => { const files = Array.from(e.target.files || []); if (files.length === 1 && /\.json$/i.test(files[0].name)) handleJsonFile(files[0]); else importFileList(files, '多选文件导入'); }; const drop = document.getElementById('fq-drop'); drop.onclick = () => document.getElementById('fq-files-input').click(); drop.ondragover = e => { e.preventDefault(); drop.classList.add('drag'); }; drop.ondragleave = () => drop.classList.remove('drag'); drop.ondrop = e => { e.preventDefault(); drop.classList.remove('drag'); const files = Array.from(e.dataTransfer.files || []); if (files.length === 1 && /\.json$/i.test(files[0].name)) handleJsonFile(files[0]); else importFileList(files, '拖拽导入'); }; document.getElementById('fq-search').oninput = e => { const q = e.target.value.trim().toLowerCase(); document.querySelectorAll('#fq-list .fq-ch').forEach(x => x.style.display = !q || x.dataset.q.includes(q) ? '' : 'none'); }; document.getElementById('fq-start').onclick = () => startJob(false); document.getElementById('fq-resume').onclick = resumeJob; document.getElementById('fq-pause').onclick = pauseJob; document.getElementById('fq-retry').onclick = () => startJob(false, true); document.getElementById('fq-stop').onclick = () => { clearJob(); addLog('已停止任务', 'warn'); refreshUI(); }; document.getElementById('fq-preview').onclick = () => { const chapters = loadChapters(); if (!chapters.length) return addLog('没有章节数据', 'error'); const from = parseInt(val('fq-from')) || 1, to = Math.min(parseInt(val('fq-to')) || chapters.length, chapters.length); addLog(`预览第${from}-${to}章`, 'info'); chapters.slice(from - 1, to).forEach(ch => addLog(`${String(ch.num).padStart(3,'0')}|${ch.title}|${ch.words}字`, 'info')); switchTab('logs'); }; document.getElementById('fq-next-batch').onclick = selectNextBatch; document.getElementById('fq-probe').onclick = () => { pageProbe(false); switchTab('logs'); }; document.getElementById('fq-jump').onclick = () => { const id = getBookId(); if (id) location.href = publishUrl(id); else addLog('未识别bookId', 'error'); }; document.getElementById('fq-fill-only').onclick = fillOnly; document.getElementById('fq-clear-data').onclick = () => { if (confirm('清除已导入章节?')) { saveChapters([]); addLog('已清除章节数据', 'warn'); refreshUI(); } }; document.getElementById('fq-list').onclick = e => { const b = e.target.closest('[data-act]'); if (!b) return; const ch = loadChapters()[parseInt(b.dataset.num) - 1]; if (!ch) return; const text = `${formatTitle(ch)}\n\n${ch.content || ''}`; if (b.dataset.act === 'preview') alert(text.slice(0, 1600) + (text.length > 1600 ? '\n\n……已截断' : '')); if (b.dataset.act === 'copy') { try { if (GM_setClipboard) GM_setClipboard(text, 'text'); else navigator.clipboard.writeText(text); addLog(`已复制第${ch.num}章`, 'success'); } catch (err) { addLog('复制失败:' + err.message, 'error'); } } }; document.getElementById('fq-audit').onclick = () => { const chapters = loadChapters(); if (!chapters.length) return addLog('没有章节', 'error'); const bad = chapters.filter(ch => !auditChapter(ch).ok); addLog(`质检完成:${chapters.length}章,异常${bad.length}`, bad.length ? 'warn' : 'success'); bad.slice(0, 50).forEach(ch => { const a = auditChapter(ch); addLog(`${String(ch.num).padStart(3,'0')}|${a.title}|${a.problems.join('、')}`, 'warn'); }); switchTab('logs'); }; document.getElementById('fq-audit-all').onclick = () => { loadChapters().forEach(ch => { const a = auditChapter(ch); addLog(`${String(ch.num).padStart(3,'0')}|${a.title}|${a.words}字${a.ok?'|OK':'|'+a.problems.join('、')}`, a.ok?'info':'warn'); }); switchTab('logs'); }; document.getElementById('fq-risk').onclick = showRiskOverview; document.getElementById('fq-readiness').onclick = showReadiness; document.getElementById('fq-smart').onclick = smartStrategy; document.getElementById('fq-export-checklist').onclick = () => exportPublishChecklist(0); document.getElementById('fq-export-plan').onclick = exportUpdatePlan; document.getElementById('fq-milestones').onclick = showMilestones; document.getElementById('fq-export-headlines').onclick = exportRecommendationTitles; document.getElementById('fq-select-opening').onclick = selectOpeningRange; document.getElementById('fq-select-risk').onclick = selectRiskRange; document.getElementById('fq-repair-queue').onclick = repairImportedQueue; document.getElementById('fq-export-json').onclick = exportJson; document.getElementById('fq-export-merged').onclick = exportMergedTxt; document.getElementById('fq-export-qa').onclick = () => exportQaCsv(0); document.getElementById('fq-export-open').onclick = () => exportQaCsv(loadSettings().openingChapters || 30); document.getElementById('fq-export-failed').onclick = exportFailedReport; document.getElementById('fq-clear-failed').onclick = () => { const p = loadProgress(); p.errors = {}; saveProgress(p); addLog('失败记录已清空', 'warn'); refreshUI(); }; document.getElementById('fq-backup').onclick = () => { if (createBackup('手动备份')) refreshUI(); }; document.getElementById('fq-restore').onclick = restoreLatestBackup; document.getElementById('fq-kill').onclick = () => { if (!killCommonDialogs('手动')) addLog('没有发现可处理弹窗', 'info'); }; document.getElementById('fq-copy-diag').onclick = () => { const txt = JSON.stringify({ time: new Date().toISOString(), probe: pageProbe(true), progress: loadProgress(), settings: loadSettings(), job: loadJob() }, null, 2); try { if (GM_setClipboard) GM_setClipboard(txt, 'text'); else navigator.clipboard.writeText(txt); addLog('诊断已复制', 'success'); } catch (e) { addLog('复制诊断失败:' + e.message, 'error'); } }; document.getElementById('fq-clear-log').onclick = () => { GM_setValue(LOG_KEY, []); restoreLogs(); addLog('日志已清空'); }; document.getElementById('fq-export-log').onclick = () => downloadText(`番茄同步日志_${new Date().toISOString().slice(0,10)}.txt`, loadLogs().map(l => `[${l.time}] [${l.type}] ${l.msg}`).join('\n')); loadSettingsToForm(); document.getElementById('fq-save-settings').onclick = saveSettingsFromForm; document.getElementById('fq-reset-progress').onclick = () => { if (confirm('重置同步进度和失败记录?')) { saveProgress({ lastSynced: 0, ok: {}, errors: {}, skipped: {}, importedAt: Date.now(), source: loadProgress().source || '' }); addLog('进度已重置', 'warn'); refreshUI(); } }; } function makePanelDraggable(panel) { const head = panel.querySelector('#fq-drag-head'); if (!head || head.dataset.dragBound) return; head.dataset.dragBound = '1'; const pos = GM_getValue(PANEL_POS_KEY, null); if (pos && Number.isFinite(pos.left)) { panel.style.left = Math.max(4, Math.min(window.innerWidth - 80, pos.left)) + 'px'; panel.style.top = Math.max(4, Math.min(window.innerHeight - 60, pos.top)) + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; } let drag = null; head.addEventListener('pointerdown', e => { if (e.target.closest('button')) return; const r = panel.getBoundingClientRect(); drag = { dx: e.clientX - r.left, dy: e.clientY - r.top }; try { head.setPointerCapture(e.pointerId); } catch (_) { } }); head.addEventListener('pointermove', e => { if (!drag) return; const left = Math.max(4, Math.min(window.innerWidth - panel.offsetWidth - 4, e.clientX - drag.dx)); const top = Math.max(4, Math.min(window.innerHeight - 60, e.clientY - drag.dy)); panel.style.left = left + 'px'; panel.style.top = top + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; }); head.addEventListener('pointerup', () => { if (!drag) return; drag = null; const r = panel.getBoundingClientRect(); GM_setValue(PANEL_POS_KEY, { left: Math.round(r.left), top: Math.round(r.top) }); }); } function init() { const panel = createPanel(); bindEvents(panel); makePanelDraggable(panel); restoreLogs(); refreshUI(); addLog('番茄章节同步助手 Pro v9.0 已加载:优化版 - 章节序号同步、日志最新在上、自动继续下一章、关闭自动弹窗:全面完善版:融合式美化驾驶舱、流程状态、官方推荐标题规则、智能运营工具已启用', 'success'); setTimeout(() => runJobEngine('page-load'), 1000); setInterval(refreshUI, 5000); setInterval(() => killCommonDialogs('巡逻'), 3500); } if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('打开番茄同步面板', () => { const p = document.getElementById('fq-sync-panel'); if (p) { p.classList.remove('collapsed'); refreshUI(); } else init(); }); GM_registerMenuCommand('停止番茄同步任务', () => { clearJob(); addLog('菜单停止任务', 'warn'); refreshUI(); }); GM_registerMenuCommand('番茄同步:章节质检', () => document.getElementById('fq-audit') && document.getElementById('fq-audit').click()); GM_registerMenuCommand('番茄同步:失败重传', () => startJob(false, true)); GM_registerMenuCommand('番茄同步:智能策略', smartStrategy); GM_registerMenuCommand('番茄同步:导出发布清单', () => exportPublishChecklist(0)); GM_registerMenuCommand('番茄同步:导出日更计划', exportUpdatePlan); GM_registerMenuCommand('番茄同步:只填不存当前章', fillOnly); } if (document.readyState === 'complete' || document.readyState === 'interactive') setTimeout(init, 600); else window.addEventListener('DOMContentLoaded', () => setTimeout(init, 600)); })();