Извлекает главы с fictionzone.net и сохраняет их в FB2 или TXT.
// ==UserScript==
// @name Парсер fictionzone.net — FB2 & TXT
// @version 0.153
// @description Извлекает главы с fictionzone.net и сохраняет их в FB2 или TXT.
// @match https://fictionzone.net/*
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @run-at document-idle
// @namespace https://gf.qytechs.cn/users/789838
// ==/UserScript==
(function() {
'use strict';
// --- КАТЕГОРИЯ: ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ И СОСТОЯНИЯ ---
// Этот раздел содержит все глобальные переменные и константы, управляющие состоянием скрипта.
// - State: Перечисление возможных состояний работы скрипта (IDLE, WORKING, MINING).
// - currentState: Текущее состояние скрипта, определяет, какие действия могут выполняться.
// - isMining: Флаг, указывающий, активен ли в данный момент режим автоматического сбора глав.
// - autoMineLoopTimeout: Идентификатор таймера для цикла автосбора, используется для его остановки.
// - bookCollection: Основной объект, хранящий все данные о книге (метаданные, оглавление, собранные главы).
// - ui: DOM-элемент, содержащий весь интерфейс скрипта.
// - lastCheckedUrl: URL последней проверенной страницы, для предотвращения повторных выполнений при навигации.
// - navigationDebounceTimer: Идентификатор таймера для отложенного вызова `handlePageUpdate` при смене URL.
const State = {
IDLE: 'IDLE', // Ожидание
WORKING: 'WORKING', // Выполнение задачи (добавление главы, генерация файла)
MINING: 'MINING', // Режим автосбора
};
let currentState = State.IDLE;
let isMining = false;
let autoMineLoopTimeout;
let bookCollection = {};
let ui;
let lastCheckedUrl = '';
let navigationDebounceTimer;
// --- КАТЕГОРИЯ: ВСПОМОГАТЕЛЬНЫЕ УТИЛИТЫ ---
// Набор функций для обработки и форматирования данных.
// - escapeXml: Экранирует специальные XML-символы в строке для безопасной вставки в FB2.
// - reformatToParagraphs: Очищает HTML-узел главы от мусора (скрипты, реклама) и форматирует текст в абзацы <p>.
function escapeXml(str) {
if (typeof str !== 'string') return '';
return str.replace(/[<>&'"]/g, c => {
switch (c) {
case '<': return '<';
case '>': return '>';
case '&': return '&';
case '\'': return ''';
case '"': return '"';
default: return c;
}
});
}
function reformatToParagraphs(node) {
if (!node) return '';
const clone = node.cloneNode(true);
clone.querySelectorAll('script, style, .ad-slot, .ad-slot-sticky, a[href*="mailto:"], div[id*="google_ads"], div[style*="min-height:310px"], .chapter-title').forEach(el => el.remove());
let contentHtml = '';
const paragraphs = clone.querySelectorAll('.chapter-content p, .chapter-content div[data-v-27111477] > p');
if (paragraphs.length > 0) {
paragraphs.forEach(p => {
if (p.textContent.trim()) {
contentHtml += `<p>${escapeXml(p.textContent.trim())}</p>`;
}
});
} else {
(clone.innerText || '').split('\n').forEach(line => {
const trimmedLine = line.trim();
if (trimmedLine) {
contentHtml += `<p>${escapeXml(trimmedLine)}</p>`;
}
});
}
return contentHtml;
}
// --- УДАЛЕНО: Функция преобразования слов в числа больше не нужна, так как порядок глав определяется их позицией в списке. ---
// --- КАТЕГОРИЯ: ГЕНЕРАЦИЯ ФАЙЛОВ ---
// Функции, отвечающие за создание и загрузку файлов FB2 и TXT.
// - generateFb2: Создает полный FB2-файл с метаданными и выбранными главами.
// - generateFb2SectionsOnly: Создает фрагмент FB2, содержащий только секции глав (для "упрощенного режима").
// - generateTxt: Создает полный TXT-файл с метаданными и текстом глав.
// - generateTxtSectionsOnly: Создает фрагмент TXT, содержащий только текст глав.
// - triggerDownload: Инициирует загрузку сгенерированного файла в браузере.
function generateFb2(chaptersToProcess) {
const { meta } = bookCollection;
const creationDate = new Date().toISOString().split('T')[0];
const genresXml = (meta.genres || []).map(g => `<genre>${escapeXml(g)}</genre>`).join('\n ');
const keywordsXml = (meta.tags || []).map(t => `<keyword>${escapeXml(t)}</keyword>`).join('\n ');
const annotationXml = (meta.annotation || []).map(p => `<p>${escapeXml(p)}</p>`).join('\n ');
const chaptersXml = chaptersToProcess
.sort((a, b) => a.order - b.order)
.map(ch => `
<section>
<title><p>${escapeXml(ch.title)}</p></title>
${ch.content}
</section>`).join('');
return `<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:l="http://www.w3.org/1999/xlink">
<description>
<title-info>
<book-title>${escapeXml(meta.title || 'Без названия')}</book-title>
<author><first-name>${escapeXml(meta.author || 'Автор не указан')}</first-name></author>
<annotation>${annotationXml}</annotation>
${genresXml}
${keywordsXml}
<date>${creationDate}</date>
<lang>en</lang>
</title-info>
<document-info></document-info>
</description>
<body>${chaptersXml}</body>
</FictionBook>`;
}
function generateFb2SectionsOnly(chaptersToProcess) {
return chaptersToProcess
.sort((a, b) => a.order - b.order)
.map(ch => `
<section>
<title><p>${escapeXml(ch.title)}</p></title>
${ch.content}
</section>`).join('').trim();
}
function generateTxt(chaptersToProcess) {
const { meta } = bookCollection;
let text = `${meta.title || 'Без названия'}\nАвтор: ${meta.author || 'Автор не указан'}\n\n${(meta.annotation || []).join('\n')}\n\n---\n\n`;
text += generateTxtSectionsOnly(chaptersToProcess);
return text;
}
function generateTxtSectionsOnly(chaptersToProcess) {
let text = '';
chaptersToProcess
.sort((a, b) => a.order - b.order)
.forEach(ch => {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = ch.content;
const chapterText = Array.from(tempDiv.querySelectorAll('p')).map(p => p.textContent.trim()).join('\n\n');
text += `Глава: ${ch.title}\n\n${chapterText}\n\n---\n\n`;
});
return text;
}
function triggerDownload(blob, fileName) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// --- КАТЕГОРИЯ: ОСНОВНАЯ ЛОГИКА СБОРА ДАННЫХ ---
// Функции, отвечающие за идентификацию книги, парсинг страниц и управление коллекцией глав.
// - getBookId: Извлекает уникальный идентификатор книги из URL.
// - parseCurrentPage: Анализирует текущую страницу и возвращает ее тип (главная или глава) и данные.
// - addCurrentChapter: Добавляет текущую главу в коллекцию и сохраняет данные.
// - generateFile: Координирует процесс генерации файла на основе выбранного формата и диапазона.
function getBookId() {
const pathParts = window.location.pathname.split('/').filter(p => p);
return (pathParts[0] === 'novel' && pathParts.length >= 2) ? pathParts[1] : 'unknown-book';
}
function parseCurrentPage() {
const isChapter = !!document.querySelector('.chapter-content-container');
if (isChapter) {
const title = document.querySelector('.chapter-title h2')?.textContent.trim() || 'Глава без названия';
let order = -1;
// --- ИЗМЕНЕНИЕ: Порядок главы определяется по ее URL из кэша оглавления, а не из названия ---
if (bookCollection.meta && bookCollection.meta.urlToOrderMap) {
order = bookCollection.meta.urlToOrderMap[window.location.pathname] || -1;
}
const contentNode = document.querySelector('.chapter-content-container');
const content = reformatToParagraphs(contentNode);
return { isChapter: true, data: { title, content, order, url: window.location.pathname } };
} else {
const title = document.querySelector('.novel-title h1')?.textContent.trim() || 'Без названия';
const author = document.querySelector('.novel-author .content')?.textContent.trim() || 'Автор не указан';
const annotationNode = document.querySelector('#synopsis .content');
const annotation = annotationNode ? Array.from(annotationNode.querySelectorAll('p')).map(p => p.textContent.trim()) : [];
const genres = Array.from(document.querySelectorAll('.genre-info .items span')).map(el => el.textContent.trim());
const tags = Array.from(document.querySelectorAll('.tag-info .items span')).map(el => el.textContent.trim());
const tocTab = document.querySelector('#tab-toc');
let totalChapters = 0;
if (tocTab) {
const match = tocTab.textContent.match(/\((\d+)\)/);
if (match) totalChapters = parseInt(match[1], 10);
}
return { isMainPage: true, data: { title, author, annotation, genres, tags, totalChapters } };
}
}
async function addCurrentChapter() {
const pageData = parseCurrentPage();
if (!pageData.isChapter || pageData.data.order === -1) {
showTemporaryStatus('Невозможно добавить: не страница главы.');
console.error('addCurrentChapter called on a non-chapter page or chapter order not found.');
return false;
}
const chapter = pageData.data;
if (bookCollection.chapters.some(ch => ch.order === chapter.order)) {
showTemporaryStatus('Эта глава уже в сборнике.');
return true;
}
setState(State.WORKING, `Добавление главы ${chapter.order}...`);
bookCollection.chapters.push(chapter);
await GM_setValue(getBookId(), JSON.stringify(bookCollection));
// --- ИЗМЕНЕНИЕ: Возвращаем временный статус и обновляем только выпадающие списки ---
showTemporaryStatus(`Глава ${chapter.order} добавлена в сборник.`); // Показывает временный статус
populateChapterDropdowns(); // Обновляет выпадающие списки глав
if (!isMining) {
setState(State.IDLE);
}
return true;
}
async function generateFile(format, startOrder, endOrder) {
if (currentState !== State.IDLE) return;
const simplifiedMode = ui.querySelector('#parser-simplified-mode-toggle').checked;
startOrder = parseInt(startOrder, 10);
endOrder = parseInt(endOrder, 10);
if (isNaN(startOrder) || isNaN(endOrder) || startOrder > endOrder) {
await showInteractiveNotification('Неверный диапазон глав.', [{ text: 'OK', value: true }]);
return;
}
const chaptersToProcess = bookCollection.chapters.filter(ch => ch.order >= startOrder && ch.order <= endOrder);
if (chaptersToProcess.length === 0) {
await showInteractiveNotification('В выбранном диапазоне нет собранных глав.', [{ text: 'OK', value: true }]);
return;
}
setState(State.WORKING, 'Генерация файла...');
let fileContent, fileName, mimeType;
const sanitizedTitle = (bookCollection.meta.title || 'novel').replace(/[^a-z0-9а-яё\s]/gi, '').replace(/\s+/g, '_');
if (format === 'fb2') {
if (simplifiedMode) {
fileContent = generateFb2SectionsOnly(chaptersToProcess);
fileName = `${sanitizedTitle}_главы_${startOrder}-${endOrder}_(секции).fb2`;
} else {
fileContent = generateFb2(chaptersToProcess);
fileName = `${sanitizedTitle}_главы_${startOrder}-${endOrder}.fb2`;
}
mimeType = 'application/xml;charset=utf-8';
} else { // txt
if (simplifiedMode) {
fileContent = generateTxtSectionsOnly(chaptersToProcess);
fileName = `${sanitizedTitle}_главы_${startOrder}-${endOrder}_(упрощенный).txt`;
} else {
fileContent = generateTxt(chaptersToProcess);
fileName = `${sanitizedTitle}_главы_${startOrder}-${endOrder}.txt`;
}
mimeType = 'text/plain;charset=utf-8';
}
const blob = new Blob([fileContent], { type: mimeType });
triggerDownload(blob, fileName);
setTimeout(() => {
setState(State.IDLE);
updateUI(false);
}, 500);
}
// --- КАТЕГОРИЯ: УПРАВЛЕНИЕ ПОЛЬЗОВАТЕЛЬСКИМ ИНТЕРФЕЙСОМ (UI) ---
// Набор функций для создания, обновления и взаимодействия с интерфейсом скрипта.
// - showInteractiveNotification: Показывает модальное окно с сообщением и кнопками.
// - showTemporaryStatus: Отображает временное сообщение в строке статуса.
// - createUI: Создает и добавляет на страницу HTML-структуру интерфейса.
// - attachEventListeners: Назначает обработчики событий для элементов UI.
// - setState: Управляет состоянием интерфейса (блокировка/разблокировка элементов).
// - updateUI: Обновляет информацию в UI в соответствии с текущими данными.
// - createRangeString: Форматирует массив чисел в строку диапазонов (напр., "1-5, 8, 10-12").
// - updateChapterStatusDisplay: Обновляет строку статуса с информацией о собранных главах.
// - populateChapterDropdowns: Заполняет выпадающие списки для выбора диапазона глав.
// - makeDraggable: Делает окно скрипта перетаскиваемым.
// - addStyles: Добавляет CSS-стили для интерфейса.
function showInteractiveNotification(message, buttons) {
return new Promise(resolve => {
const mainView = ui.querySelector('#parser-main-controls');
const notificationView = ui.querySelector('#parser-notification-view');
const notificationText = ui.querySelector('#parser-notification-text');
const notificationActions = ui.querySelector('#parser-notification-actions');
notificationText.innerHTML = message;
notificationActions.innerHTML = '';
buttons.forEach(btnInfo => {
const btn = document.createElement('button');
btn.textContent = btnInfo.text;
btn.onclick = () => {
notificationView.classList.add('hidden');
mainView.classList.remove('hidden');
resolve(btnInfo.value);
};
notificationActions.appendChild(btn);
});
mainView.classList.add('hidden');
notificationView.classList.remove('hidden');
});
}
function showTemporaryStatus(message) {
const statusEl = ui.querySelector('#parser-status');
if (!message) {
updateChapterStatusDisplay();
} else {
statusEl.innerHTML = `<span class="temp-status">${message}</span>`;
setTimeout(() => {
if (statusEl.querySelector('.temp-status')) {
updateChapterStatusDisplay();
}
}, 2000);
}
}
function createUI() {
ui = document.createElement('div');
ui.id = 'nb-parser-ui';
ui.innerHTML = `
<div class="parser-header">
<h2>Сборщик Новеллы</h2>
<div class="header-controls">
<button id="parser-minimize-btn" title="Свернуть">-</button>
<button id="parser-hide-btn" title="Скрыть">_</button>
</div>
</div>
<div class="parser-body">
<div id="parser-waiting-view">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
<p>Ожидание открытия произведения</p>
</div>
<div id="parser-main-controls" class="hidden">
<div id="parser-info"></div>
<div id="parser-status"></div>
<div class="parser-toggle">
<div class="label-and-manual-add">
<button id="parser-add-current-btn" title="Добавить текущую главу">+</button>
<label for="parser-auto-add-toggle">Авто-добавление</label>
</div>
<div class="auto-controls">
<button id="parser-auto-mine-btn" title="Умный автосбор глав (начинает с первой пропущенной)">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v1a2.5 2.5 0 0 1-2.5 2.5h-1A2.5 2.5 0 0 1 6 5.5V5a2.5 2.5 0 0 1 2.5-2.5h1Z"></path>
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v1a2.5 2.5 0 0 0 2.5 2.5h1A2.5 2.5 0 0 0 18 5.5V5a2.5 2.5 0 0 0-2.5-2.5h-1Z"></path>
<path d="M6 10a2.5 2.5 0 0 1 2.5 2.5v1A2.5 2.5 0 0 1 6 16H5a2.5 2.5 0 0 1-2.5-2.5v-1A2.5 2.5 0 0 1 5 10h1Z"></path>
<path d="M18 10a2.5 2.5 0 0 0-2.5 2.5v1a2.5 2.5 0 0 0 2.5 2.5h1a2.5 2.5 0 0 0 2.5-2.5v-1a2.5 2.5 0 0 0-2.5-2.5h-1Z"></path>
<path d="M12 15a2.5 2.5 0 0 1 2.5 2.5v1a2.5 2.5 0 0 1-2.5 2.5h-1a2.5 2.5 0 0 1-2.5-2.5v-1a2.5 2.5 0 0 1 2.5-2.5h1Z"></path>
</svg>
</button>
<label class="switch"><input type="checkbox" id="parser-auto-add-toggle"><span class="slider round"></span></label>
</div>
</div>
<div class="parser-toggle">
<label for="parser-simplified-mode-toggle">Упрощенный режим</label>
<label class="switch"><input type="checkbox" id="parser-simplified-mode-toggle"><span class="slider round"></span></label>
</div>
<div class="parser-actions">
<div class="download-control">
<div class="select-group">
<label>От:</label><select id="from-select"></select>
<label>До:</label><select id="to-select"></select>
</div>
<div class="button-group">
<button id="parser-download-fb2">Скачать FB2</button>
<button id="parser-download-txt">Скачать TXT</button>
</div>
</div>
<button id="parser-clear-collection">Очистить сборник</button>
</div>
<div id="parser-process-status" class="hidden">
<div class="spinner"></div>
<span id="parser-process-text"></span>
</div>
</div>
<div id="parser-notification-view" class="hidden">
<p id="parser-notification-text"></p>
<div id="parser-notification-actions"></div>
</div>
</div>`;
document.body.appendChild(ui);
const showBtn = document.createElement('div');
showBtn.id = 'nb-parser-show-btn';
showBtn.className = 'hidden';
showBtn.title = 'Показать сборщик';
document.body.appendChild(showBtn);
makeDraggable(ui.querySelector('.parser-header'));
attachEventListeners();
}
async function attachEventListeners() {
const showBtn = document.getElementById('nb-parser-show-btn');
ui.querySelector('#parser-minimize-btn').addEventListener('click', () => {
ui.classList.toggle('minimized');
});
ui.querySelector('#parser-hide-btn').addEventListener('click', () => {
ui.classList.add('hidden-completely');
showBtn.classList.remove('hidden');
});
showBtn.addEventListener('click', () => {
ui.classList.remove('hidden-completely');
showBtn.classList.add('hidden');
});
const autoAddToggle = ui.querySelector('#parser-auto-add-toggle');
autoAddToggle.addEventListener('change', async e => {
const isChecked = e.target.checked;
await GM_setValue('autoAddMode', isChecked);
ui.querySelector('#parser-add-current-btn').classList.toggle('hidden', isChecked);
});
ui.querySelector('#parser-add-current-btn').addEventListener('click', addCurrentChapter);
ui.querySelector('#parser-simplified-mode-toggle').addEventListener('change', async e => await GM_setValue('simplifiedMode', e.target.checked));
ui.querySelector('#parser-auto-mine-btn').addEventListener('click', toggleAutoMine);
ui.querySelector('#parser-download-fb2').addEventListener('click', () => {
const start = ui.querySelector('#from-select').value;
const end = ui.querySelector('#to-select').value;
generateFile('fb2', start, end);
});
ui.querySelector('#parser-download-txt').addEventListener('click', () => {
const start = ui.querySelector('#from-select').value;
const end = ui.querySelector('#to-select').value;
generateFile('txt', start, end);
});
ui.querySelector('#parser-clear-collection').addEventListener('click', async () => {
const confirmation = await showInteractiveNotification(
'Вы уверены, что хотите удалить все собранные главы для этой книги?',
[{ text: 'Да', value: true }, { text: 'Отмена', value: false }]
);
if (confirmation) {
setState(State.WORKING, 'Очистка сборника...');
await GM_deleteValue(getBookId());
bookCollection.chapters = [];
if (bookCollection.meta) bookCollection.meta.toc = {};
setTimeout(() => {
setState(State.IDLE);
updateUI(true);
}, 500);
}
});
}
function setState(newState, message = '') {
currentState = newState;
const controlsToDisable = ui.querySelectorAll(
'#parser-auto-add-toggle, #parser-simplified-mode-toggle, #from-select, #to-select, #parser-download-fb2, #parser-download-txt, #parser-clear-collection, #parser-add-current-btn'
);
controlsToDisable.forEach(el => el.disabled = (newState !== State.IDLE));
const processStatus = ui.querySelector('#parser-process-status');
const processText = ui.querySelector('#parser-process-text');
if (newState === State.WORKING || newState === State.MINING) {
processText.textContent = message || 'Обработка...';
processStatus.classList.remove('hidden');
} else {
processStatus.classList.add('hidden');
}
}
function updateUI(resetRange = false) {
if (!ui) return;
ui.querySelector('#parser-info').textContent = bookCollection.meta?.title || 'Информация о книге не найдена';
updateChapterStatusDisplay();
populateChapterDropdowns(resetRange ? parseCurrentPage() : null);
}
function createRangeString(numbersArray) {
if (numbersArray.length === 0) return '';
const sorted = [...new Set(numbersArray)].sort((a, b) => a - b);
const ranges = [];
if (sorted.length === 0) return '';
let startRange = sorted[0];
let endRange = sorted[0];
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] === endRange + 1) {
endRange = sorted[i];
} else {
ranges.push(startRange === endRange ? `${startRange}` : `${startRange}-${endRange}`);
startRange = sorted[i];
endRange = sorted[i];
}
}
ranges.push(startRange === endRange ? `${startRange}` : `${startRange}-${endRange}`);
return ranges.join(', ');
}
function updateChapterStatusDisplay() {
const statusEl = ui.querySelector('#parser-status');
const total = bookCollection.meta.totalChapters || 0;
const collectedOrders = new Set(bookCollection.chapters.map(ch => ch.order));
if (collectedOrders.size === 0) {
statusEl.innerHTML = `Собрано: 0 из ${total}`;
return;
}
const collectedNumbers = Array.from(collectedOrders);
const missingNumbers = [];
if (total > 0 && collectedNumbers.length > 0) {
const maxCollected = Math.max(...collectedNumbers);
for (let i = 1; i <= Math.min(total, maxCollected); i++) {
if (!collectedOrders.has(i)) {
missingNumbers.push(i);
}
}
}
const rangeStr = createRangeString(collectedNumbers);
const missingStr = missingNumbers.length > 0 ? ` (Пропущено: ${createRangeString(missingNumbers)})` : '';
statusEl.innerHTML = `Собрано глав: ${collectedOrders.size} из ${total}. Диапазоны: ${rangeStr} <span class="missing-count">${missingStr}</span>`;
}
function populateChapterDropdowns(pageDataToSet) {
const total = bookCollection.meta.totalChapters || 0;
const fromSelect = ui.querySelector('#from-select');
const toSelect = ui.querySelector('#to-select');
if (total === 0) {
fromSelect.innerHTML = '';
toSelect.innerHTML = '';
return;
}
const collectedChapters = new Map(bookCollection.chapters.map(ch => [ch.order, ch.title]));
const currentFrom = fromSelect.value;
const currentTo = toSelect.value;
fromSelect.innerHTML = '';
toSelect.innerHTML = '';
for (let i = 1; i <= total; i++) {
const option = document.createElement('option');
option.value = i;
const chapterTitle = collectedChapters.get(i);
if (chapterTitle) {
option.textContent = `Гл. ${i}: ${chapterTitle}`;
option.classList.add('collected');
} else {
option.textContent = `Глава ${i} (не собрана)`;
option.classList.add('not-collected');
}
fromSelect.appendChild(option.cloneNode(true));
toSelect.appendChild(option);
}
if (pageDataToSet) {
let defaultFrom = '1';
let defaultTo = String(total);
if (pageDataToSet.isChapter && pageDataToSet.data.order > 0) {
defaultFrom = String(pageDataToSet.data.order);
defaultTo = String(pageDataToSet.data.order);
} else {
const collectedOrders = Array.from(collectedChapters.keys()).sort((a, b) => a - b);
if (collectedOrders.length > 0) {
let longestStart = collectedOrders[0];
let longestLength = 0;
let currentStart = collectedOrders[0];
let currentLength = 1;
for (let i = 1; i < collectedOrders.length; i++) {
if (collectedOrders[i] === collectedOrders[i - 1] + 1) {
currentLength++;
} else {
if (currentLength > longestLength) {
longestLength = currentLength;
longestStart = currentStart;
}
currentStart = collectedOrders[i];
currentLength = 1;
}
}
if (currentLength > longestLength) {
longestLength = currentLength;
longestStart = currentStart;
}
defaultFrom = String(longestStart);
defaultTo = String(longestStart + longestLength - 1);
}
}
fromSelect.value = defaultFrom;
toSelect.value = defaultTo;
} else {
if (currentFrom && fromSelect.querySelector(`option[value="${currentFrom}"]`)) fromSelect.value = currentFrom;
else fromSelect.value = '1';
if (currentTo && toSelect.querySelector(`option[value="${currentTo}"]`)) toSelect.value = currentTo;
else toSelect.value = String(total);
}
}
function makeDraggable(header) {
let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
header.onmousedown = e => {
if (e.target.tagName === 'BUTTON') return;
e.preventDefault(); pos3 = e.clientX; pos4 = e.clientY;
document.onmouseup = () => { document.onmouseup = null; document.onmousemove = null; };
document.onmousemove = ev => {
ev.preventDefault(); pos1 = pos3 - ev.clientX; pos2 = pos4 - ev.clientY;
pos3 = ev.clientX; pos4 = ev.clientY;
ui.style.top = `${ui.offsetTop - pos2}px`; ui.style.left = `${ui.offsetLeft - pos1}px`;
};
};
}
function addStyles() {
GM_addStyle(`
.hidden { display: none !important; }
#nb-parser-ui { position: fixed; top: 40px; right: 20px; width: 340px; z-index: 2147483647; background: #222730; color: #cdd3da; border: 1px solid #444c56; border-radius: 10px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; box-shadow: 0 8px 25px rgba(0,0,0,0.6); user-select: none; }
.parser-header { padding: 10px 15px; background: #2d333b; border-bottom: 1px solid #444c56; border-radius: 10px 10px 0 0; cursor: move; text-align: center; }
.parser-header h2 { margin: 0; font-size: 16px; font-weight: 600; color: #adbac7;}
.header-controls { position: absolute; top: 8px; right: 10px; display: flex; gap: 5px; }
.header-controls button { background: none; border: 1px solid #444c56; border-radius: 5px; color: #768390; font-size: 16px; font-weight: bold; cursor: pointer; line-height: 1; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; transition: all 0.2s; padding-bottom: 3px; }
.header-controls button:hover { color: #fff; background-color: #373e47; }
.parser-body { padding: 15px; }
#nb-parser-ui.minimized .parser-body { display: none; }
#nb-parser-ui.hidden-completely { display: none !important; }
#nb-parser-show-btn { position: fixed; top: 40px; right: 20px; width: 30px; height: 30px; background: #2d333b; border: 1px solid #444c56; border-radius: 5px; z-index: 2147483647; cursor: pointer; transition: background-color 0.2s; }
#nb-parser-show-btn:hover { background-color: #373e47; }
#nb-parser-show-btn::after { content: '📖'; font-size: 16px; display: flex; align-items: center; justify-content: center; height: 100%; color: #cdd3da; }
#parser-info { font-weight: bold; text-align: center; margin-bottom: 5px; color: #fff; }
#parser-status { font-size: 12px; text-align: center; color: #768390; margin-bottom: 15px; min-height: 1.2em; }
#parser-status .missing-count { color: #e37c7c; }
#parser-status .temp-status { color: #76a9fa; font-style: italic; }
#parser-waiting-view { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; text-align: center; color: #768390; }
#parser-waiting-view svg { margin-bottom: 15px; animation: book-pulse 2.5s infinite ease-in-out; }
#parser-waiting-view p { font-size: 14px; font-weight: 500; margin: 0; }
@keyframes book-pulse {
0%, 100% { opacity: 0.6; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
}
#parser-notification-view { text-align: center; }
#parser-notification-text { font-size: 14px; margin-bottom: 20px; }
#parser-notification-actions { display: flex; gap: 15px; justify-content: center; }
#parser-notification-actions button { padding: 8px 16px; border: 1px solid #444c56; border-radius: 5px; background-color: #373e47; color: #cdd3da; cursor: pointer; }
.parser-toggle { display: flex; justify-content: space-between; align-items: center; background-color: #2d333b; padding: 10px; border-radius: 6px; margin-bottom: 10px; border: 1px solid #444c56;}
.parser-toggle label { font-size: 14px; font-weight: 500; }
.label-and-manual-add { display: flex; align-items: center; gap: 10px; }
#parser-add-current-btn { background-color: #373e47; border: 1px solid #444c56; border-radius: 50%; color: #cdd3da; cursor: pointer; font-size: 20px; font-weight: bold; width: 26px; height: 26px; line-height: 24px; padding: 0; text-align: center; transition: background-color 0.2s, color 0.2s; flex-shrink: 0; }
#parser-add-current-btn:hover:not(:disabled) { background-color: #444c56; }
.auto-controls { display: flex; align-items: center; gap: 10px; }
.switch { position: relative; display: inline-block; width: 50px; height: 28px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #444c56; transition: .4s; border-radius: 28px; }
.slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 4px; bottom: 4px; background-color: white; transition: .4s; border-radius: 50%; }
input:checked + .slider { background-color: #2f65c8; }
input:focus + .slider { box-shadow: 0 0 1px #2f65c8; }
input:checked + .slider:before { transform: translateX(22px); }
#parser-auto-mine-btn { background-color: #373e47; border: 1px solid #444c56; border-radius: 6px; cursor: pointer; padding: 4px; line-height: 0; transition: background-color 0.2s, border-color 0.2s; }
#parser-auto-mine-btn svg { color: #cdd3da; transition: transform 0.5s; }
#parser-auto-mine-btn:hover:not(:disabled) { background-color: #444c56; }
#parser-auto-mine-btn.active { background-color: #2f65c8; border-color: #487ee7; }
#parser-auto-mine-btn.active:hover:not(:disabled) { background-color: #3a71d1; }
#parser-auto-mine-btn.active svg { animation: spin 2s linear infinite; }
.parser-actions { display: flex; flex-direction: column; gap: 10px; margin-top: 15px; }
.parser-actions button { padding: 12px; border: 1px solid #444c56; border-radius: 6px; color: #cdd3da; background-color: #373e47; cursor: pointer; font-weight: 500; font-size: 14px; text-align: center; transition: background-color 0.2s, border-color 0.2s; }
.parser-actions button:hover:not(:disabled) { background-color: #444c56; border-color: #555e68; }
#parser-process-status { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background-color: #2d333b; border-radius: 6px; margin-top: 15px; border: 1px solid #444c56; }
.spinner { width: 16px; height: 16px; border: 2px solid #768390; border-top-color: #cdd3da; border-radius: 50%; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
#parser-process-text { font-size: 13px; color: #adbac7; }
.download-control { background: #2d333b; padding: 10px; border-radius: 6px; border: 1px solid #444c56; }
.select-group { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; gap: 5px; }
.select-group label { font-size: 13px; color: #768390; flex-shrink: 0; }
.select-group select { flex-grow: 1; background: #222730; color: #cdd3da; border: 1px solid #444c56; border-radius: 4px; padding: 5px; font-size: 12px; width: 100%; text-overflow: ellipsis; }
.button-group { display: flex; gap: 10px; }
.button-group button { flex: 1; margin-top: 5px; padding: 10px 5px; font-size: 13px; }
.button-group #parser-download-fb2, .button-group #parser-download-txt { background-color: #347d39; border-color: #46954a; color: #fff; }
#parser-clear-collection { background-color: #b73e36; border-color: #cf564e; color: #fff; }
select option.collected { color: #6ee7b7; font-weight: bold; }
select option.not-collected { color: #9ca3af; }
button:disabled, input:disabled, select:disabled { background-color: #2d333b !important; color: #768390 !important; cursor: not-allowed !important; border-color: #444c56 !important; opacity: 0.6; }
input:disabled + .slider { cursor: not-allowed !important; background-color: #2d333b !important; }
`);
}
// --- КАТЕГОРИЯ: ЛОГИКА АВТОСБОРА ("УМНЫЙ" СБОР) ---
// Функции, отвечающие за автоматический сбор пропущенных глав.
// - cacheTableOfContents: Сканирует оглавление на главной странице и сохраняет URL-адреса глав.
// - toggleAutoMine: Запускает или останавливает процесс автосбора, сохраняя его состояние.
// - mineNextChapter: Определяет следующую пропущенную главу и инициирует переход на нее.
async function cacheTableOfContents() {
const chapterLinks = document.querySelectorAll('#pane-toc .list-wrapper .items a.chapter');
if (chapterLinks.length === 0) {
console.log("FictionZone Collector: ToC links not found on this page.");
return false;
}
const toc = {};
const urlToOrderMap = {};
chapterLinks.forEach((link, index) => {
const order = index + 1; // Позиционный порядок, начиная с 1
const url = link.getAttribute('href');
const title = link.querySelector('.chapter-title')?.textContent.trim();
if (url && title) {
toc[order] = { url, title };
urlToOrderMap[url] = order;
}
});
if (Object.keys(toc).length > 0) {
bookCollection.meta.toc = toc;
bookCollection.meta.urlToOrderMap = urlToOrderMap; // Сохраняем карту URL -> Порядок
await GM_setValue(getBookId(), JSON.stringify(bookCollection));
console.log(`FictionZone Collector: Оглавление на ${Object.keys(toc).length} глав кэшировано по их позиции.`);
return true;
}
return false;
}
async function toggleAutoMine() {
const mineBtn = ui.querySelector('#parser-auto-mine-btn');
const bookId = getBookId();
const currentMiningState = await GM_getValue('isMining_' + bookId, false);
if (currentMiningState) { // --- ОСТАНОВКА ---
await GM_setValue('isMining_' + bookId, false);
isMining = false;
clearTimeout(autoMineLoopTimeout);
mineBtn.classList.remove('active');
// Принудительно сбрасываем состояние в IDLE, чтобы разблокировать интерфейс,
// даже если в данный момент идет процесс добавления главы (состояние WORKING).
if (currentState === State.MINING || currentState === State.WORKING) {
setState(State.IDLE);
}
showTemporaryStatus('Автосбор остановлен.');
} else { // --- ЗАПУСК ---
if (currentState === State.WORKING) {
showTemporaryStatus('Подождите завершения текущей операции.');
return;
}
await GM_setValue('isMining_' + bookId, true);
isMining = true;
mineBtn.classList.add('active');
showTemporaryStatus('Автосбор запущен...');
mineNextChapter();
}
}
function mineNextChapter() {
if (!isMining) {
setState(State.IDLE);
return;
}
const mineBtn = ui.querySelector('#parser-auto-mine-btn');
// Общее количество глав берем из кэша оглавления, если он есть - это надежнее
const totalChapters = bookCollection.meta.toc ? Object.keys(bookCollection.meta.toc).length : (bookCollection.meta.totalChapters || 0);
if (totalChapters === 0) {
showTemporaryStatus('Нет данных о главах. Перехожу на главную...');
const bookId = getBookId();
// Переходим на главную, только если мы еще не там
if (!window.location.pathname.startsWith(`/novel/${bookId}`) || window.location.pathname.includes('/chapter-')) {
window.location.href = `/novel/${bookId}`;
}
return;
}
const collectedOrders = new Set(bookCollection.chapters.map(ch => ch.order));
let nextChapterToMine = -1;
for (let i = 1; i <= totalChapters; i++) {
if (!collectedOrders.has(i)) {
nextChapterToMine = i;
break;
}
}
if (nextChapterToMine === -1) {
showTemporaryStatus('Все главы собраны! Автосбор завершен.');
isMining = false;
mineBtn.classList.remove('active');
GM_setValue('isMining_' + getBookId(), false); // Cброс состояния
setState(State.IDLE);
return;
}
setState(State.MINING, `Поиск главы ${nextChapterToMine}...`);
// --- ИЗМЕНЕНИЕ: Навигация теперь всегда основана на кэше оглавления ---
if (bookCollection.meta.toc && bookCollection.meta.toc[nextChapterToMine]) {
const targetUrl = bookCollection.meta.toc[nextChapterToMine].url;
if (window.location.pathname !== targetUrl) {
window.location.href = new URL(targetUrl, window.location.origin).href;
}
return;
} else {
// Если главы нет в кэше, значит он устарел или неполный. Нужно перейти на главную для обновления.
const mainPageUrl = `/novel/${getBookId()}`;
if (window.location.pathname !== mainPageUrl) {
showTemporaryStatus(`Глава ${nextChapterToMine} не в кэше. Обновляю оглавление...`);
window.location.href = mainPageUrl;
} else {
// Если мы уже на главной, но главу найти не можем, значит, проблема (например, сменилась структура сайта).
showTemporaryStatus(`Не удалось найти главу ${nextChapterToMine} после обновления. Автосбор остановлен.`);
isMining = false;
mineBtn.classList.remove('active');
GM_setValue('isMining_' + getBookId(), false);
setState(State.IDLE);
}
}
}
// --- КАТЕГОРИЯ: УПРАВЛЕНИЕ НАВИГАЦИЕЙ И ЖИЗНЕННЫМ ЦИКЛОМ ---
// Основная функция, реагирующая на изменения URL и координирующая действия скрипта.
// - handlePageUpdate: Вызывается при каждой смене страницы. Загружает данные, обновляет UI, запускает автосбор или авто-добавление.
async function handlePageUpdate() {
if (window.location.href === lastCheckedUrl) return;
lastCheckedUrl = window.location.href;
console.log("FictionZone Collector: URL изменился, перепроверка страницы.");
const bookId = getBookId();
const mainControls = ui.querySelector('#parser-main-controls');
const waitingView = ui.querySelector('#parser-waiting-view');
const titleElement = ui.querySelector('.parser-header h2');
if (bookId === 'unknown-book') {
mainControls.classList.add('hidden');
waitingView.classList.remove('hidden');
titleElement.textContent = 'Ожидание открытия произведения';
if (currentState !== State.IDLE) {
setState(State.IDLE);
}
return;
}
mainControls.classList.remove('hidden');
waitingView.classList.add('hidden');
titleElement.textContent = 'Сборщик Новеллы';
isMining = await GM_getValue('isMining_' + bookId, false);
ui.querySelector('#parser-auto-mine-btn').classList.toggle('active', isMining);
const collectionData = await GM_getValue(bookId, null);
bookCollection = collectionData ? JSON.parse(collectionData) : { meta: {}, chapters: [] };
if (!bookCollection.meta) bookCollection.meta = {};
const pageData = parseCurrentPage();
if (pageData.isChapter && !bookCollection.meta.title) {
console.log("FictionZone Collector: Book metadata is missing. Redirecting to main novel page to fetch it.");
showTemporaryStatus('Получение данных о книге...');
window.location.href = `/novel/${bookId}`;
return;
}
if (pageData.isMainPage) {
const newMeta = pageData.data;
let metaChanged = false;
const coreMeta = (({ toc, ...o }) => o)(bookCollection.meta);
if (JSON.stringify(pageData.data) !== JSON.stringify(coreMeta)) {
const existingToc = bookCollection.meta.toc;
bookCollection.meta = newMeta;
if (existingToc) bookCollection.meta.toc = existingToc;
metaChanged = true;
}
const tocUpdated = await cacheTableOfContents();
if (metaChanged && !tocUpdated) {
await GM_setValue(bookId, JSON.stringify(bookCollection));
}
}
updateUI(true);
const autoAddMode = await GM_getValue('autoAddMode', false);
// --- ИЗМЕНЕНИЕ: Логика перехода на следующую главу сделана адаптивной ---
// Теперь, если включен автосбор, переход происходит сразу после добавления главы.
if ((autoAddMode || isMining) && pageData.isChapter) {
const added = await addCurrentChapter();
if (isMining && added) {
// Успешно добавили главу, немедленно переходим к следующей
clearTimeout(autoMineLoopTimeout);
mineNextChapter(); // Вызов без задержки
return; // Прерываем, так как инициирован переход на другую страницу
}
}
if (isMining) {
// Этот блок сработает, только если мы не на странице главы (из-за return выше)
// Оставим здесь небольшую задержку для ориентации пользователя при старте
clearTimeout(autoMineLoopTimeout);
autoMineLoopTimeout = setTimeout(mineNextChapter, 1000);
}
}
// --- КАТЕГОРИЯ: ИНИЦИАЛИЗАЦИЯ СКРИПТА ---
// Функции, отвечающие за первоначальный запуск скрипта на странице.
// - initialize: Главная функция инициализации. Создает UI, стили, загружает настройки и запускает MutationObserver для отслеживания навигации.
async function initialize() {
if (document.getElementById('nb-parser-ui')) return;
createUI();
addStyles();
const autoAddToggle = ui.querySelector('#parser-auto-add-toggle');
const autoAddMode = await GM_getValue('autoAddMode', false);
autoAddToggle.checked = autoAddMode;
ui.querySelector('#parser-add-current-btn').classList.toggle('hidden', autoAddMode);
const simplifiedModeToggle = ui.querySelector('#parser-simplified-mode-toggle');
simplifiedModeToggle.checked = await GM_getValue('simplifiedMode', false);
await handlePageUpdate();
const observer = new MutationObserver(() => {
clearTimeout(navigationDebounceTimer);
// --- ИЗМЕНЕНИЕ: Задержка уменьшена с 500 до 150 мс для более быстрого отклика ---
// если слишком малое значение, ajax обновление страницы произведения с другой страницы. не загружает метаданные произведения
navigationDebounceTimer = setTimeout(handlePageUpdate, 500);
});
const targetNode = document.getElementById('__nuxt');
if (targetNode) {
observer.observe(targetNode, { childList: true, subtree: true });
}
}
// --- ТОЧКА ВХОДА ---
// Запускает инициализацию скрипта после полной загрузки страницы.
if (document.readyState === 'complete') {
initialize();
} else {
window.addEventListener('load', initialize);
}
})();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址