// ==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 = `