Читалка для Litnet в стиле Author.Today с обходом защиты от копирования и встроенным экспортом в FB2.
// ==UserScript==
// @name Litnet Reader & FB2 Exporter
// @description Читалка для Litnet в стиле Author.Today с обходом защиты от копирования и встроенным экспортом в FB2.
// @match https://litnet.com/*/reader/*
// @noframes
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_listValues
// @connect *
// @run-at document-idle
// @version 0.15
// @namespace https://greasyfork.org/users/789838
// ==/UserScript==
(function() {
'use strict';
if (window.top !== window.self) return;
// ============================================================
// Хранилище Violentmonkey (GM_*): настройки, кэш глав, закладки, метаданные книги.
// Данные живут в storage расширения, а не в localStorage сайта.
// ============================================================
function storageGet(key, defaultValue) {
if (defaultValue === undefined) defaultValue = null;
try {
const raw = GM_getValue(key, null);
if (raw === null || raw === undefined || raw === '') return defaultValue;
if (typeof raw === 'string') {
try {
return JSON.parse(raw);
} catch (e) {
return defaultValue;
}
}
return raw;
} catch (e) {
return defaultValue;
}
}
function storageSet(key, value) {
try {
GM_setValue(key, JSON.stringify(value));
return true;
} catch (e) {
return false;
}
}
function storageRemove(key) {
try {
GM_deleteValue(key);
} catch (e) {}
}
function storageKeys() {
try {
return GM_listValues() || [];
} catch (e) {
return [];
}
}
// ============================================================
// Инициализация: настройки пользователя, метаданные книги, глобальные переменные
// ============================================================
const AT_ICONS = {
star: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>',
note: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>',
page: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>',
calendar: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zM9 14H7v-2h2v2zm4 0h-2v-2h2v2zm4 0h-2v-2h2v2zm-8 4H7v-2h2v2zm4 0h-2v-2h2v2zm4 0h-2v-2h2v2z"/></svg>',
eye: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>',
library: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"/></svg>',
heart: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>',
comment: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>',
lock: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>',
check: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>',
checkCircle: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>',
bookmark: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M19 18l2 1V3c0-1.1-.9-2-2-2H8.99C7.89 1 7 1.9 7 3h10c1.1 0 2 .9 2 2v13zM15 5H5c-1.1 0-2 .9-2 2v16l7-3 7 3V7c0-1.1-.9-2-2-2z"/></svg>'
};
function atIcon(name, className) {
const svg = AT_ICONS[name] || '';
if (!svg) return '';
if (className) return svg.replace('<svg ', '<svg class="' + className + '" ');
return svg;
}
const defaultSettings = {
themeStep: 1,
fontSize: 22,
lineHeight: 1.6,
textWidth: 900,
fontFamily: 'Roboto',
textColor: '#333333',
bgColor: '#ffffff',
hyphens: false
};
let userSettings = storageGet('at-reader-settings', null) || defaultSettings;
if (userSettings.themeStep === undefined) userSettings.themeStep = 1;
if (userSettings.hyphens === undefined) userSettings.hyphens = false;
const urlPathParts = window.location.pathname.split('?')[0].split('/');
const bookSlug = urlPathParts[urlPathParts.length - 1];
const fallbackIdMatch = bookSlug.match(/-b(\d+)$/);
let bookMeta = {
bookSlug: bookSlug,
idBook: fallbackIdMatch ? fallbackIdMatch[1] : null,
inLibrary: false,
isLiked: false,
likeCount: 0,
tags: [],
genres: [],
rawStats: [],
title: document.title,
headerTitle: document.title,
author: 'Автор неизвестен',
authorLink: '#',
annotation: '',
cover: '',
cycleName: '',
cycleNumber: null,
cycleBooksCount: null,
url: window.location.origin + window.location.pathname.replace(/\/reader\//, '/book/').split('?')[0]
};
let tocHtml = '';
let currentChapterTitle = '';
let nextChapterUrl = '';
let prevChapterUrl = '';
let nextChapterTitle = '';
let prevChapterTitle = '';
let availableChapters = [];
// ============================================================
// Модуль генерации FB2-файлов из HTML: парсинг, маппинг жанров, сборка XML
// ============================================================
/**
* Базовый парсер для конвертации HTML-узлов в FB2-элементы.
* Рекурсивно обходит DOM-дерево, создаёт FB2-структуру через processElement и endNode.
*/
class FB2Parser {
run(fb2doc, htmlNode, fromNode) {
this._stop = null;
const res = this.parse(htmlNode, fromNode);
return res;
}
parse(htmlNode, fromNode) {
const that = this;
function _parse(node, from, fb2el, depth) {
let nextSibling = from || node.firstChild;
while (nextSibling) {
const startNodeResult = that.startNode(nextSibling, depth, fb2el);
if (startNodeResult) {
const processedElement = that.processElement(FB2Element.fromHTML(startNodeResult, false), depth);
if (processedElement) {
if (fb2el) fb2el.children.push(processedElement);
_parse(startNodeResult, null, processedElement, depth + 1);
}
that.endNode(startNodeResult, depth);
}
if (that._stop) break;
nextSibling = nextSibling.nextSibling;
}
}
_parse(htmlNode, fromNode, null, 0);
return this._stop;
}
startNode(node, depth, fb2to) {
return node;
}
processElement(fb2el, depth) {
return fb2el;
}
endNode(node, depth) {
}
}
/**
* Парсер аннотации книги: извлекает HTML-контент боковой панели и преобразует его в FB2 annotation.
* Собирает изображения в binaries для последующей вшивки в FB2-файл.
*/
class FB2AnnotationParser extends FB2Parser {
run(fb2doc, htmlNode, fromNode) {
this._binaries = [];
const res = super.run(fb2doc, htmlNode, fromNode);
fb2doc.annotation = this._annotation;
if (fb2doc.annotation) {
fb2doc.annotation.normalize();
this._binaries.forEach(bin => fb2doc.binaries.push(bin));
this._binaries = null;
}
return res;
}
parse(htmlNode, fromNode) {
this._annotation = new FB2Annotation();
const res = super.parse(htmlNode, fromNode);
if (!this._annotation.children.length) this._annotation = null;
return res;
}
processElement(fb2el, depth) {
if (fb2el) {
if (depth === 0) this._annotation.children.push(fb2el);
if (fb2el instanceof FB2Image) this._binaries.push(fb2el);
}
return super.processElement(fb2el, depth);
}
}
/**
* Парсер содержимого главы: извлекает HTML-текст главы и преобразует в FB2 section.
* Собирает изображения в binaries для последующей загрузки и вшивки в файл.
*/
class FB2ChapterParser extends FB2Parser {
run(fb2doc, htmlNode, title, fromNode) {
this._binaries = [];
const res = this.parse(title, htmlNode, fromNode);
this._chapter.normalize();
fb2doc.chapters.push(this._chapter);
this._binaries.forEach(bin => fb2doc.binaries.push(bin));
this._binaries = null;
return res;
}
parse(title, htmlNode, fromNode) {
this._chapter = new FB2Chapter(title);
return super.parse(htmlNode, fromNode);
}
processElement(fb2el, depth) {
if (fb2el) {
if (depth === 0) this._chapter.children.push(fb2el);
if (fb2el instanceof FB2Image) this._binaries.push(fb2el);
}
return super.processElement(fb2el, depth);
}
}
/**
* Структура FB2-документа: метаданные книги, жанры, авторы, аннотация, главы, заметки.
* Генерирует полный XML-файл FictionBook 2.0 через toString().
*/
class FB2Document {
constructor() {
this.notes = [];
this.binaries = [];
this.bookAuthors = [];
this.annotation = null;
this.genres = [];
this.keywords = [];
this.chapters = [];
this.history = [];
this.xmldoc = null;
this._parsers = new Map();
}
toString() {
this._ensureXMLDocument();
const root = this.xmldoc.documentElement;
this._markNotes();
this._markBinaries();
root.appendChild(this._makeDescriptionElement());
root.appendChild(this._makeBodyElement());
if (this.notes.length) root.appendChild(this._makeNotesElement());
this._makeBinaryElements().forEach(el => root.appendChild(el));
let res = (new XMLSerializer()).serializeToString(this.xmldoc);
this.xmldoc = null;
res = FB2Utils.prettyPrintXml(res);
return res;
}
createElement(name) {
this._ensureXMLDocument();
return this.xmldoc.createElementNS(this.xmldoc.documentElement.namespaceURI, name);
}
createTextNode(value) {
this._ensureXMLDocument();
return this.xmldoc.createTextNode(value);
}
createDocumentFragment() {
this._ensureXMLDocument();
return this.xmldoc.createDocumentFragment();
}
bindParser(parserId, parser) {
if (!parser && !parserId) {
this._parsers.clear();
return;
}
this._parsers.set(parserId, parser);
}
parse(parserId, ...args) {
const parser = this._parsers.get(parserId);
if (!parser) throw new Error(`Unknown parser id: ${parserId}`);
return parser.run(this, ...args);
}
_ensureXMLDocument() {
if (!this.xmldoc) {
this.xmldoc = new DOMParser().parseFromString(
'<?xml version="1.0" encoding="UTF-8"?><FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0"/>',
"application/xml"
);
this.xmldoc.documentElement.setAttribute("xmlns:l", "http://www.w3.org/1999/xlink");
}
}
_makeDescriptionElement() {
const desc = this.createElement("description");
const t_info = this.createElement("title-info");
desc.appendChild(t_info);
const ch_num = t_info.children.length;
this.genres.forEach(genreItem => {
if (genreItem instanceof FB2Genre) {
t_info.appendChild(genreItem.xml(this));
} else if (typeof(genreItem) === "string") {
(new FB2GenreList(genreItem)).forEach(genre => t_info.appendChild(genre.xml(this)));
}
});
if (t_info.children.length === ch_num) t_info.appendChild((new FB2Genre("network_literature")).xml(this));
(this.bookAuthors.length ? this.bookAuthors : [ new FB2Author("Неизвестный автор") ]).forEach(authorItem => {
t_info.appendChild(authorItem.xml(this));
});
t_info.appendChild((new FB2Element("book-title", this.bookTitle)).xml(this));
if (this.annotation) t_info.appendChild(this.annotation.xml(this));
let keywords = null;
if (Array.isArray(this.keywords) && this.keywords.length) {
keywords = this.keywords.join(", ");
} else if (typeof(this.keywords) === "string" && this.keywords.trim()) {
keywords = this.keywords.trim();
}
if (keywords) t_info.appendChild((new FB2Element("keywords", keywords)).xml(this));
if (this.bookDate) {
const dateElement = this.createElement("date");
dateElement.setAttribute("value", FB2Utils.dateToAtom(this.bookDate));
dateElement.textContent = this.bookDate.toISOString().slice(0, 10);
t_info.appendChild(dateElement);
}
if (this.coverpage) {
const coverpageElement = this.createElement("coverpage");
(Array.isArray(this.coverpage) ? this.coverpage : [ this.coverpage ]).forEach(coverImage => {
coverpageElement.appendChild(coverImage.xml(this));
});
t_info.appendChild(coverpageElement);
}
const langElement = this.createElement("lang");
langElement.textContent = "ru";
t_info.appendChild(langElement);
if (this.sequence) {
const sequenceElement = this.createElement("sequence");
sequenceElement.setAttribute("name", this.sequence.name);
if (this.sequence.number) sequenceElement.setAttribute("number", this.sequence.number);
t_info.appendChild(sequenceElement);
}
const d_info = this.createElement("document-info");
desc.appendChild(d_info);
if (this.programName) d_info.appendChild((new FB2Element("program-used", this.programName)).xml(this));
if (this.bookDate instanceof Date && !isNaN(this.bookDate.getTime())) {
const dateElement = this.createElement("date");
dateElement.setAttribute("value", FB2Utils.dateToAtom(this.bookDate));
dateElement.textContent = this.bookDate.toISOString().slice(0, 10);
d_info.appendChild(dateElement);
}
if (this.sourceURL) {
d_info.appendChild((new FB2Element("src-url", this.sourceURL)).xml(this));
}
if (this.bookId) {
d_info.appendChild((new FB2Element("id", String(this.bookId))).xml(this));
}
if (this.history.length) {
const hs = this.createElement("history");
d_info.appendChild(hs);
this.history.forEach(historyItem => hs.appendChild((new FB2Paragraph(historyItem)).xml(this)));
}
return desc;
}
_makeBodyElement() {
const body = this.createElement("body");
if (this.bookTitle || this.bookAuthors.length) {
const title = this.createElement("title");
body.appendChild(title);
if (this.bookAuthors.length) title.appendChild((new FB2Paragraph(this.bookAuthors.join(", "))).xml(this));
if (this.bookTitle) title.appendChild((new FB2Paragraph(this.bookTitle)).xml(this));
}
this.chapters.forEach(chapter => body.appendChild(chapter.xml(this)));
return body;
}
_markNotes() {
let noteIndex = 0;
this.notes.forEach(note => {
if (!note.id) note.id = "note" + (++noteIndex);
if (!note.title) note.title = noteIndex.toString();
});
}
_makeNotesElement() {
const body = this.createElement("body");
body.setAttribute("name", "notes");
const title = this.createElement("title");
title.appendChild(this.createElement("p")).textContent = "Примечания";
body.append(title);
this.notes.forEach(note => body.append(note.xmlSection(this)));
return body;
}
_markBinaries() {
let binaryIndex = 0;
this.binaries.forEach(binaryItem => {
if (!binaryItem.id) binaryItem.id = "image" + (++binaryIndex) + binaryItem.suffix();
});
}
_makeBinaryElements() {
return this.binaries.reduce((list, img) => {
if (img.value) list.push(img.xmlBinary(this));
return list;
}, []);
}
}
/**
* Базовый класс всех FB2-элементов. Представляет собой узел дерева FB2 с именем тега, значением и дочерними элементами.
* Содержит методы для конвертации HTML в FB2 (fromHTML), нормализации структуры и генерации XML.
*/
class FB2Element {
constructor(name, value) {
this.name = name;
this.value = value !== undefined ? value : null;
this.children = [];
}
static fromHTML(node, recursive) {
let fb2el = null;
const names = new Map([
[ "U", "emphasis" ], [ "EM", "emphasis" ], [ "EMPHASIS", "emphasis" ], [ "I", "emphasis" ],
[ "S", "strikethrough" ], [ "DEL", "strikethrough" ], [ "STRIKE", "strikethrough" ],
[ "STRONG", "strong" ], [ "B", "strong" ], [ "SUB", "sub" ], [ "SUP", "sup" ],
[ "SCRIPT", null ], [ "#comment", null ]
]);
const inline = new Set([ "emphasis", "strikethrough", "strong", "sub", "sup" ]);
const node_name = node.nodeName;
if (names.has(node_name)) {
const name = names.get(node_name);
if (!name) return null;
fb2el = inline.has(name) ? new FB2InlineMarkup(name) : new FB2Element(name);
} else {
switch (node_name) {
case "#text":
return new FB2Text(node.textContent);
case "SPAN":
fb2el = new FB2Text();
break;
case "P":
case "LI":
fb2el = new FB2Paragraph();
break;
case "SUBTITLE":
fb2el = new FB2Subtitle();
break;
case "BLOCKQUOTE":
fb2el = new FB2Cite();
break;
case "A":
fb2el = new FB2Link(node.href || node.getAttribute("l:href"));
break;
case "OL":
fb2el = new FB2OrderedList();
break;
case "UL":
fb2el = new FB2UnorderedList();
break;
case "BR":
return new FB2EmptyLine();
case "HR":
return new FB2Paragraph("---");
case "IMG":
return new FB2Image(node.src);
default:
return new FB2UnknownNode(node);
}
}
if (recursive) fb2el.appendContentFromHTML(node);
return fb2el;
}
hasValue() {
if (this.children && this.children.length) return true;
if (this.value === undefined || this.value === null) return false;
if (typeof this.value === 'string') {
const visible = this.value.replace(/[\s\u200B-\u200F\u2028-\u202F\u2060-\u206F\uFEFF\u00AD]/g, '');
return visible.length > 0;
}
return true;
}
appendContentFromHTML(data, fb2doc, log) {
for (const node of data.childNodes) {
let fe = FB2Element.fromHTML(node, true);
if (fe) this.children.push(fe);
}
}
normalize() {
const _normalize = function(list) {
let done = true;
let res_list = list.reduce((accumulated, currentElement) => {
accumulated.push(currentElement);
const tmpChildren = currentElement.children;
currentElement.children = [];
tmpChildren.forEach(childElement => {
if (
((childElement instanceof FB2Paragraph || childElement instanceof FB2EmptyLine) &&
(!(currentElement instanceof FB2Chapter || currentElement instanceof FB2Annotation || currentElement instanceof FB2Cite || currentElement.name === "title")))
|| ((childElement instanceof FB2Cite) && (!(currentElement instanceof FB2Chapter || currentElement instanceof FB2Annotation)))
|| ((childElement instanceof FB2Subtitle) && (!(currentElement instanceof FB2Chapter || currentElement instanceof FB2Cite)))
) {
const elementName = currentElement.name;
if (currentElement instanceof FB2InlineMarkup) {
const inlineElement = new currentElement.constructor();
if (!inlineElement.name) inlineElement.name = elementName;
inlineElement.children = childElement.children;
childElement.children = [ inlineElement ];
}
accumulated.push(childElement);
currentElement = new currentElement.constructor();
if (!currentElement.name) currentElement.name = elementName;
accumulated.push(currentElement);
done = false;
} else {
childElement.normalize().forEach(normalizedChild => {
if (!normalizedChild.value && normalizedChild.children.length === 1 && normalizedChild.name === normalizedChild.children[0].name) {
normalizedChild = normalizedChild.children[0];
}
if (normalizedChild !== childElement) done = false;
if (normalizedChild.hasValue()) currentElement.children.push(normalizedChild);
});
}
});
return accumulated;
}, []);
return { list: res_list, done: done };
}
let result = _normalize([ this ]);
while (!result.done) {
result = _normalize(result.list);
}
return result.list;
}
textContent() {
let res = (!(this instanceof FB2BlockElement)) && this.value || '';
return this.children.reduce((accumulated, childEl) => {
accumulated += childEl.textContent();
return accumulated;
}, res);
}
xml(doc) {
const el = doc.createElement(this.name);
if (this.value !== null) el.textContent = this.value;
this.children.forEach(childEl => el.appendChild(childEl.xml(doc)));
return el;
}
}
/**
* Базовый класс для блочных FB2-элементов: параграфы, цитаты, списки.
* Реализует нормализацию — обрезку пробелов по краям и удаление пустых узлов.
*/
class FB2BlockElement extends FB2Element {
normalize() {
this.children = this.children.reduce((list, childEl) => {
childEl.normalize().forEach(normalizedChild => list.push(normalizedChild));
return list;
}, []);
while (this.children.length) {
const lastChild = this.children[this.children.length - 1];
if (lastChild instanceof FB2Text) lastChild.trimRight();
if (!lastChild.hasValue()) {
this.children.pop();
continue;
}
break;
}
while (this.children.length) {
const firstChild = this.children[0];
if (firstChild instanceof FB2Text) firstChild.trimLeft();
if (!firstChild.hasValue()) {
this.children.shift();
continue;
}
break;
}
if (!this.children.length && typeof(this.value) === "string") {
this.value = this.value.trim();
}
return super.normalize();
}
}
/**
* Базовый класс для элементов inline-разметки: emphasis, strong, strikethrough, sub, sup.
* Не содержит собственной логики — наследуется от FB2Element.
*/
class FB2InlineMarkup extends FB2Element {
}
/**
* FB2-элемент верхнего уровня: секция главы. Содержит заголовок и дочерние элементы контента.
* При генерации XML создаёт обёртку title с названием главы.
*/
class FB2Chapter extends FB2Element {
constructor(title) {
super("section");
this.title = title;
}
normalize() {
this.children = this.children.reduce((list, childEl) => {
if (![ "p", "subtitle", "image", "empty-line", "cite", "list" ].includes(childEl.name)) {
const paragraphElement = new FB2Paragraph();
paragraphElement.children.push(childEl);
childEl = paragraphElement;
}
childEl.normalize().forEach(normalizedChild => {
if (normalizedChild.hasValue()) list.push(normalizedChild);
});
return list;
}, []);
return [ this ];
}
xml(doc) {
const chapterElement = super.xml(doc);
if (this.title) {
const titleElement = doc.createElement("title");
const paragraphElement = doc.createElement("p");
paragraphElement.textContent = this.title;
titleElement.appendChild(paragraphElement);
chapterElement.prepend(titleElement);
}
return chapterElement;
}
}
/**
* FB2-элемент верхнего уровня: аннотация книги. Содержит текст описания с параграфами и цитатами.
* Нормализует содержимое — группирует текст в параграфы, обрабатывает пустые строки.
*/
class FB2Annotation extends FB2Element {
constructor() {
super("annotation");
}
normalize() {
let lp = null;
const newParagraph = list => {
lp = new FB2Paragraph();
list.push(lp);
};
this.children = this.children.reduce((list, childEl) => {
if ([ "p", "subtitle", "cite" ].includes(childEl.name)) {
list.push(childEl);
lp = null;
} else if (childEl.name === "empty-line") {
if (!lp) {
if (list.length) list.push(new FB2EmptyLine);
} else if (!lp.children.length) {
list.pop();
list.push(new FB2EmptyLine());
list.push(lp);
} else {
newParagraph(list);
}
} else {
if (!lp) newParagraph(list);
lp.children.push(childEl);
}
return list;
}, []);
this.children = this.children.reduce((list, childEl) => {
childEl.normalize().forEach(normalizedChild => {
if (normalizedChild.hasValue()) list.push(normalizedChild);
});
return list;
}, []);
for (let len = this.children.length; len; ) {
if (this.children[len - 1].name !== "empty-line") break;
this.children.pop();
--len;
}
return [ this ];
}
}
/**
* FB2-элемент: подзаголовок внутри текста главы. Наследуется от FB2BlockElement.
*/
class FB2Subtitle extends FB2BlockElement {
constructor(value) {
super("subtitle", value);
}
}
/**
* FB2-элемент: параграф текста. Основной блок содержимого главы. Наследуется от FB2BlockElement.
*/
class FB2Paragraph extends FB2BlockElement {
constructor(value) {
super("p", value);
}
}
/**
* FB2-элемент: цитата или блокquote. Содержит параграфы, подзаголовки, пустые строки и таблицы.
* Нормализует содержимое — оборачивает запрещённые элементы в параграфы.
*/
class FB2Cite extends FB2BlockElement {
constructor() {
super("cite");
}
normalize() {
this.children = this.children.reduce((list, childEl) => {
if (![ "p", "subtitle", "empty-line", "table", "text-author" ].includes(childEl.name)) {
const pe = new FB2Paragraph();
pe.children.push(childEl);
childEl = pe;
}
childEl.normalize().forEach(normalizedChild => {
if (normalizedChild.hasValue()) list.push(normalizedChild);
});
return list;
}, []);
return [ this ];
}
}
/**
* FB2-элемент: пустая строка (empty-line). Всегда считается имеющим значение для структуры документа.
*/
class FB2EmptyLine extends FB2Element {
constructor() {
super("empty-line");
}
hasValue() {
return true;
}
}
/**
* FB2-элемент: текстовый узел. Представляет собой текст с возможной inline-разметкой внутри.
* Поддерживает обрезку пробелов по краям (trimLeft/trimRight) и возврат document fragment при наличии дочерних элементов.
*/
class FB2Text extends FB2Element {
constructor(value) {
super("text", value);
}
trimLeft() {
if (typeof(this.value) === "string") this.value = this.value.trimLeft() || null;
if (!this.value) {
while (this.children.length) {
const first_child = this.children[0];
if (first_child instanceof FB2Text) first_child.trimLeft();
if (first_child.hasValue()) break;
this.children.shift();
}
}
}
trimRight() {
while (this.children.length) {
const last_child = this.children[this.children.length - 1];
if (last_child instanceof FB2Text) last_child.trimRight();
if (last_child.hasValue()) break;
this.children.pop();
}
if (!this.children.length && typeof(this.value) === "string") {
this.value = this.value.trimRight() || null;
}
}
xml(doc) {
if (!this.value && this.children.length) {
let fr = doc.createDocumentFragment();
for (const childEl of this.children) {
fr.appendChild(childEl.xml(doc));
}
return fr;
}
return doc.createTextNode(this.value);
}
}
/**
* FB2-элемент: гиперссылка. Сохраняет href и генерирует XML с атрибутом l:href для namespace XLink.
*/
class FB2Link extends FB2Element {
constructor(href) {
super("a");
this.href = href;
}
xml(doc) {
const anchorElement = super.xml(doc);
anchorElement.setAttribute("l:href", this.href);
return anchorElement;
}
}
/**
* Базовый класс для FB2-элементов списка. Генерирует document fragment с элементами списка,
* оборачивая inline-содержимое в параграфы и фильтруя пустые элементы.
*/
class FB2List extends FB2Element {
constructor() {
super("list");
}
xml(doc) {
const fr = doc.createDocumentFragment();
for (const childEl of this.children) {
if (childEl.hasValue()) {
let ch_el = null;
if (childEl instanceof FB2BlockElement) {
ch_el = childEl.xml(doc);
} else {
const par = new FB2Paragraph();
par.children.push(childEl);
ch_el = par.xml(doc);
}
if (ch_el.textContent.trim() !== "") fr.appendChild(ch_el);
}
}
return fr;
}
}
/**
* FB2-элемент: нумерованный список. Добавляет номера перед каждым элементом при генерации XML.
*/
class FB2OrderedList extends FB2List {
xml(doc) {
let listItemIndex = 0;
const fr = super.xml(doc);
for (const listItem of fr.children) {
++listItemIndex;
listItem.prepend(`${listItemIndex}. `);
}
return fr;
}
}
/**
* FB2-элемент: маркированный список. Добавляет дефис перед каждым элементом при генерации XML.
*/
class FB2UnorderedList extends FB2List {
xml(doc) {
const fr = super.xml(doc);
for (const listItem of fr.children) {
listItem.prepend("- ");
}
return fr;
}
}
/**
* FB2-элемент: автор книги. Парсит имя на firstName, middleName, lastName, nickName.
* Генерирует XML с полями author namespace FictionBook 2.0.
*/
class FB2Author extends FB2Element {
constructor(authorName) {
super("author");
const nameParts = authorName.split(" ");
switch (nameParts.length) {
case 1:
this.nickName = authorName;
break;
case 2:
this.firstName = nameParts[0];
this.lastName = nameParts[1];
break;
default:
this.firstName = nameParts[0];
this.middleName = nameParts.slice(1, -1).join(" ");
this.lastName = nameParts[nameParts.length - 1];
break;
}
this.homePage = null;
}
hasValue() {
return (!!this.firstName || !!this.lastName || !!this.middleName);
}
toString() {
if (!this.firstName) return this.nickName;
return [ this.firstName, this.middleName, this.lastName ].reduce((list, name) => {
if (name) list.push(name);
return list;
}, []).join(" ");
}
xml(doc) {
const authorElement = super.xml(doc);
[
[ "first-name", this.firstName ], [ "middle-name", this.middleName ],
[ "last-name", this.lastName ], [ "nickname", this.nickName ],
[ "home-page", this.homePage ]
].forEach(authorField => {
if (authorField[1]) {
const element = doc.createElement(authorField[0]);
element.textContent = authorField[1];
authorElement.appendChild(element);
}
});
return authorElement;
}
}
/**
* FB2-элемент: изображение. Загружает картинку по URL через fetch или GM_xmlhttpRequest,
* конвертирует в base64 и вшивает как binary в FB2-файл. Поддерживает PNG, JPEG, GIF, WebP.
*/
class FB2Image extends FB2Element {
constructor(value) {
super("image");
if (typeof(value) === "string") {
this.url = value;
} else {
this.value = value;
}
}
async load(onprogress) {
if (this.url) {
const bin = await this._load(this.url, { responseType: "binary", onprogress: onprogress });
this.type = bin.type;
this.size = bin.size;
if (!this.suffix()) throw new Error("Неизвестный формат изображения");
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("loadend", (event) => resolve(event.target.result));
reader.readAsDataURL(bin);
}).then(base64str => {
this.value = this._getBase64String(base64str);
}).catch(error => {
throw new Error("Ошибка загрузки изображения");
});
}
}
hasValue() {
return true;
}
xml(doc) {
if (this.value) {
const imageElement = doc.createElement(this.name);
imageElement.setAttribute("l:href", "#" + this.id);
return imageElement;
}
const id = this.id || "изображение";
return doc.createTextNode(`[ ${id} ]`);
}
xmlBinary(doc) {
const binaryElement = doc.createElement("binary");
binaryElement.setAttribute("id", this.id);
binaryElement.setAttribute("content-type", this.type);
binaryElement.textContent = this.value;
return binaryElement;
}
suffix() {
switch (this.type) {
case "image/png":
return ".png";
case "image/jpeg":
return ".jpg";
case "image/gif":
return ".gif";
case "image/webp":
return ".webp";
}
return "";
}
async _load(...args) {
return FB2Loader.addJob(...args);
}
_getBase64String(data) {
return data.substr(data.indexOf(",") + 1);
}
}
/**
* FB2-элемент: жанр книги. Простой обёрточный элемент с тегом "genre" и текстовым значением.
*/
class FB2Genre extends FB2Element {
constructor(value) {
super("genre", value);
}
}
/**
* FB2-элемент: неизвестный узел. Обёртка для HTML-тегов, которые не распознаны парсером.
*/
class FB2UnknownNode extends FB2Element {
constructor(value) {
super("unknown", value);
}
xml(doc) {
return doc.createTextNode(this.value && this.value.textContent || "");
}
}
/**
* Маппинг жанров Litnet в FB2-жанры. Расширяемый список правил сопоставления:
* ключ — FB2-жанр, значения — точное название на русском и ключевые слова для частичного совпадения.
* При конвертации анализирует фразы жанров из книги и выбирает наиболее подходящий FB2-жанр по весу.
*/
class FB2GenreList extends Array {
constructor(...args) {
if (args.length === 1 && typeof(args[0]) === "number") {
super(args[0]);
return;
}
const list = (args.length === 1) ? (Array.isArray(args[0]) ? args[0] : [ args[0] ]) : args;
super();
if (!list.length) return;
const keys = FB2GenreList._keys;
const gmap = new Map();
const addWeight = (name, weight) => gmap.set(name, (gmap.get(name) || 0) + weight);
list.forEach(genrePhrase => {
genrePhrase = genrePhrase.toLowerCase();
let words = genrePhrase.split(/[\s,.;]+/);
if (words.length === 1) words = [];
for (const genreKey of keys) {
const exactNames = Array.isArray(genreKey[1]) ? genreKey[1] : [ genreKey[1] ];
if (genreKey[0] === genrePhrase || exactNames.includes(genrePhrase)) {
addWeight(genreKey[0], 3);
break;
}
let weight = words.some(word => exactNames.includes(word)) ? 2 : 0;
genreKey[2] && genreKey[2].forEach(keyword => {
if (words.includes(keyword)) ++weight;
});
if (weight >= 2) addWeight(genreKey[0], weight);
}
});
const res = [];
gmap.forEach((weight, name) => res.push([ name, weight]));
if (!res.length) return;
res.sort((genreA, genreB) => genreB[1] > genreA[1]);
let currentWeight = 0;
for (const resultItem of res) {
if (resultItem[1] !== currentWeight && this.length >= 10) break;
currentWeight = resultItem[1];
this.push(new FB2Genre(resultItem[0]));
}
}
}
FB2GenreList._keys = [
[ "adv_animal", "природа и животные", [ "приключения", "животные", "природа" ] ],
[ "adventure", "приключения" ],
[ "adv_geo", "путешествия и география", [ "приключения", "география", "путешествие" ] ],
[ "adv_history", "исторические приключения", [ "история", "приключения" ] ],
[ "adv_indian", "вестерн, про индейцев", [ "индейцы", "вестерн" ] ],
[ "adv_maritime", "морские приключения", [ "приключения", "море" ] ],
[ "adv_modern", "приключения в современном мире", [ "современный", "мир" ] ],
[ "adv_story", "авантюрный роман" ],
[ "antique", "старинное" ],
[ "antique_ant", "античная литература", [ "старинное", "античность" ] ],
[ "antique_east", "древневосточная литература", [ "старинное", "восток" ] ],
[ "antique_european", "европейская старинная литература", [ "старинное", "европа" ] ],
[ "antique_myths", "мифы. легенды. эпос", [ "мифы", "легенды", "эпос", "фольклор" ] ],
[ "antique_russian", "древнерусская литература", [ "древнерусское", "старинное" ] ],
[ "aphorism_quote", "афоризмы, цитаты", [ "афоризмы", "цитаты", "проза" ] ],
[ "architecture_book", "скульптура и архитектура", [ "дизайн" ] ],
[ "art_criticism", "искусствоведение" ],
[ "art_world_culture", "мировая художественная культура", [ "искусство", "искусствоведение" ] ],
[ "astrology", "астрология и хиромантия", [ "астрология", "хиромантия" ] ],
[ "auto_business", "автодело" ],
[ "auto_regulations", "автомобили и ПДД", [ "дорожного", "движения", "дорожное", "движение" ] ],
[ "banking", "финансы", [ "банки", "деньги" ] ],
[ "child_adv", "приключения для детей и подростков" ],
[ "child_classical", "классическая детская литература" ],
[ "child_det", "детская остросюжетная литература" ],
[ "child_education", "детская образовательная литература" ],
[ "child_folklore", "детский фольклор" ],
[ "child_prose", "проза для детей" ],
[ "children", "детская литература", [ "детское" ] ],
[ "child_sf", "фантастика для детей" ],
[ "child_tale", "сказки народов мира" ],
[ "child_tale_rus", "русские сказки" ],
[ "child_verse", "стихи для детей" ],
[ "cine", "кино" ],
[ "comedy", "комедия" ],
[ "comics", "комиксы" ],
[ "comp_db", "программирование, программы, базы данных", [ "программирование", "базы", "программы" ] ],
[ "comp_hard", "компьютерное железо", [ "аппаратное" ] ],
[ "comp_soft", "программное обеспечение" ],
[ "computers", "компьютеры" ],
[ "comp_www", "ос и сети, интернет", [ "ос", "сети", "интернет" ] ],
[ "design", "дизайн" ],
[ "det_action", [ "боевики", "боевик" ], [ "триллер" ] ],
[ "det_classic", "классический детектив" ],
[ "det_crime", "криминальный детектив", [ "криминал" ] ],
[ "det_espionage", "шпионский детектив", [ "шпион", "шпионы", "детектив" ] ],
[ "det_hard", "крутой детектив" ],
[ "det_history", "исторический детектив", [ "история" ] ],
[ "det_irony", "иронический детектив" ],
[ "det_maniac", "про маньяков", [ "маньяки", "детектив" ] ],
[ "det_police", "полицейский детектив", [ "полиция", "детектив" ] ],
[ "det_political", "политический детектив", [ "политика", "детектив" ] ],
[ "det_su", "советский детектив", [ "ссср", "детектив" ] ],
[ "detective", "детектив", [ "детективы" ] ],
[ "drama", "драма" ],
[ "drama_antique", "античная драма" ],
[ "dramaturgy", "драматургия" ],
[ "economics", "экономика" ],
[ "economics_ref", "деловая литература" ],
[ "epic", "былины, эпопея", [ "былины", "эпопея" ] ],
[ "epistolary_fiction", "эпистолярная проза" ],
[ "equ_history", "история техники" ],
[ "fairy_fantasy", "мифологическое фэнтези", [ "мифология", "фантастика" ] ],
[ "family", "семейные отношения", [ "дом", "семья" ] ],
[ "fanfiction", "фанфик" ],
[ "folklore", "фольклор, загадки" ],
[ "folk_songs", "народные песни" ],
[ "folk_tale", "народные сказки" ],
[ "foreign_antique", "средневековая классическая проза" ],
[ "foreign_children", "зарубежная литература для детей" ],
[ "foreign_prose", "зарубежная классическая проза" ],
[ "geo_guides", "путеводители, карты, атласы", [ "география", "атласы", "карты", "путеводители" ] ],
[ "gothic_novel", "готический роман" ],
[ "great_story", "роман", [ "повесть" ] ],
[ "home", "домоводство", [ "дом", "семья" ] ],
[ "home_collecting", "коллекционирование" ],
[ "home_cooking", "кулинария", [ "домашняя", "еда" ] ],
[ "home_crafts", "хобби и ремесла" ],
[ "home_diy", "сделай сам" ],
[ "home_entertain", "развлечения" ],
[ "home_garden", "сад и огород" ],
[ "home_health", "здоровье" ],
[ "home_pets", "домашние животные" ],
[ "home_sex", "семейные отношения, секс" ],
[ "home_sport", "боевые исскусства, спорт" ],
[ "hronoopera", "хроноопера" ],
[ "humor", "юмор" ],
[ "humor_anecdote", "анекдоты" ],
[ "humor_prose", "юмористическая проза" ],
[ "humor_satire", "сатира" ],
[ "humor_verse", "юмористические стихи, басни", [ "юмор", "стихи", "басни" ] ],
[ "limerick", [ "частушки", "прибаутки", "потешки" ] ],
[ "literature_18", "классическая проза XVII-XVIII веков" ],
[ "literature_19", "классическая проза ХIX века" ],
[ "literature_20", "классическая проза ХX века" ],
[ "love", "любовные романы" ],
[ "love_contemporary", "современные любовные романы" ],
[ "love_detective", "остросюжетные любовные романы", [ "детектив", "любовь" ] ],
[ "love_erotica", "эротика", [ "эротическая", "литература" ] ],
[ "love_hard", "порно" ],
[ "love_history", "исторические любовные романы", [ "история", "любовь" ] ],
[ "love_sf", "любовное фэнтези" ],
[ "love_short", "короткие любовные романы" ],
[ "lyrics", "лирика" ],
[ "military_history", "военная история", [ "война", "история" ] ],
[ "military_special", "военное дело" ],
[ "military_weapon", "военная техника и вооружение", [ "военная", "вооружение", "техника" ] ],
[ "modern_tale", "современная сказка" ],
[ "music", "музыка" ],
[ "network_literature", "сетевая литература" ],
[ "nonf_biography", "биографии и мемуары", [ "биография", "биографии", "мемуары" ] ],
[ "nonf_criticism", "критика" ],
[ "nonfiction", "документальная литература" ],
[ "nonf_military", "военная документалистика и аналитика" ],
[ "nonf_publicism", "публицистика" ],
[ "notes:", "партитуры" ],
[ "org_behavior", "маркентиг, pr", [ "организации" ] ],
[ "painting", "живопись", [ "альбомы", "иллюстрированные", "каталоги" ] ],
[ "palindromes", "визуальная и экспериментальная поэзия", [ "верлибры", "палиндромы", "поэзия" ] ],
[ "periodic", "журналы, газеты", [ "журналы", "газеты" ]],
[ "poem", "поэма", [ "эпическая", "поэзия" ] ],
[ "poetry", "поэзия" ],
[ "poetry_classical", "классическая поэзия" ],
[ "poetry_east", "поэзия востока" ],
[ "poetry_for_classical", "классическая зарубежная поэзия" ],
[ "poetry_for_modern", "современная зарубежная поэзия" ],
[ "poetry_modern", "современная поэзия" ],
[ "poetry_rus_classical", "классическая русская поэзия" ],
[ "poetry_rus_modern", "современная русская поэзия", [ "русская", "поэзия" ] ],
[ "popadanec", "попаданцы", [ "попаданец" ] ],
[ "popular_business", "карьера, кадры", [ "карьера", "дело", "бизнес" ] ],
[ "prose", "проза" ],
[ "prose_abs", "фантасмагория, абсурдистская проза" ],
[ "prose_classic", "классическая проза" ],
[ "prose_contemporary", "современная русская и зарубежная проза", [ "современная", "проза" ] ],
[ "prose_counter", "контркультура" ],
[ "prose_game", "игры, упражнения для детей", [ "игры", "упражнения" ] ],
[ "prose_history", "историческая проза", [ "история", "проза" ] ],
[ "prose_magic", "магический реализм", [ "магия", "проза" ] ],
[ "prose_military", "проза о войне" ],
[ "prose_neformatny", "неформатная проза", [ "экспериментальная", "проза" ] ],
[ "prose_rus_classic", "русская классическая проза" ],
[ "prose_su_classics", "советская классическая проза" ],
[ "proverbs", "пословицы", [ "поговорки" ] ],
[ "ref_dict", "словари", [ "справочник" ] ],
[ "ref_encyc", "энциклопедии", [ "энциклопедия" ] ],
[ "ref_guide", "руководства", [ "руководство", "справочник" ] ],
[ "ref_ref", "справочники", [ "справочник" ] ],
[ "reference", "справочная литература" ],
[ "religion", "религия", [ "духовность", "эзотерика" ] ],
[ "religion_budda", "буддизм" ],
[ "religion_catholicism", "католицизм" ],
[ "religion_christianity", "христианство" ],
[ "religion_esoterics", "эзотерическая литература", [ "эзотерика" ] ],
[ "religion_hinduism", "индуизм" ],
[ "religion_islam", "ислам" ],
[ "religion_judaism", "иудаизм" ],
[ "religion_orthdoxy", "православие" ],
[ "religion_paganism", "язычество" ],
[ "religion_protestantism", "протестантизм" ],
[ "religion_self", "самосовершенствование" ],
[ "russian_fantasy", "славянское фэнтези", [ "русское", "фэнтези" ] ],
[ "sci_biology", "биология", [ "биофизика", "биохимия" ] ],
[ "sci_botany", "ботаника" ],
[ "sci_build", "строительство и сопромат", [ "строительтво", "сопромат" ] ],
[ "sci_chem", "химия" ],
[ "sci_cosmos", "астрономия и космос", [ "астрономия", "космос" ] ],
[ "sci_culture", "культурология" ],
[ "sci_ecology", "экология" ],
[ "sci_economy", "экономика" ],
[ "science", "научная литература" ],
[ "sci_geo", "геология и география" ],
[ "sci_history", "история" ],
[ "sci_juris", "юриспруденция" ],
[ "sci_linguistic", "языкознание", [ "иностранный", "язык" ] ],
[ "sci_math", "математика" ],
[ "sci_medicine_alternative", "альтернативная медицина" ],
[ "sci_medicine", "медицина" ],
[ "sci_metal", "металлургия" ],
[ "sci_oriental", "востоковедение" ],
[ "sci_pedagogy", "педагогика, воспитание детей, литература для родителей", [ "воспитание", "детей" ] ],
[ "sci_philology", "литературоведение" ],
[ "sci_philosophy", "философия" ],
[ "sci_phys", "физика" ],
[ "sci_politics", "политика" ],
[ "sci_popular", "зарубежная образовательная литература", [ "зарубежная", "научно-популярная" ] ],
[ "sci_psychology", "психология и психотерапия" ],
[ "sci_radio", "радиоэлектроника" ],
[ "sci_religion", "религиоведение", [ "религия", "духовность" ] ],
[ "sci_social_studies", "обществознание", [ "социология" ] ],
[ "sci_state", "государство и право" ],
[ "sci_tech", "технические науки", [ "техника", "наука" ] ],
[ "sci_textbook", "учебники и пособия" ],
[ "sci_theories", "альтернативные науки и научные теории" ],
[ "sci_transport", "транспорт и авиация" ],
[ "sci_veterinary", "ветеринария" ],
[ "sci_zoo", "зоология" ],
[ "science", "научная литература", [ "образование" ] ],
[ "screenplays", "сценарии", [ "сценарий" ] ],
[ "sf", "научная фантастика", [ "наука", "фантастика" ] ],
[ "sf_action", "боевая фантастика" ],
[ "sf_cyberpunk", "киберпанк" ],
[ "sf_detective", "детективная фантастика", [ "детектив", "фантастика" ] ],
[ "sf_epic", "эпическая фантастика", [ "эпическое", "фэнтези" ] ],
[ "sf_etc", "фантастика" ],
[ "sf_fantasy", "фэнтези" ],
[ "sf_fantasy_city", "городское фэнтези" ],
[ "sf_heroic", "героическая фантастика", [ "героическое", "герой", "фэнтези" ] ],
[ "sf_history", "альтернативная история", [ "историческое", "фэнтези" ] ],
[ "sf_horror", "ужасы", [ "фантастика" ] ],
[ "sf_humor", "юмористическая фантастика", [ "юмор", "фантастика" ] ],
[ "sf_litrpg", "литрпг", [ "litrpg", "рпг" ] ],
[ "sf_mystic", "мистика", [ "мистическая", "фантастика" ] ],
[ "sf_postapocalyptic", "постапокалипсис" ],
[ "sf_realrpg", "реалрпг", [ "realrpg" ] ],
[ "sf_social", "Социально-психологическая фантастика", [ "социум", "психология", "фантастика" ] ],
[ "sf_space", "космическая фантастика", [ "космос", "фантастика" ] ],
[ "sf_stimpank", "стимпанк" ],
[ "sf_technofantasy", "технофэнтези" ],
[ "song_poetry", "песенная поэзия" ],
[ "story", "рассказ", [ "рассказы", "эссе", "новеллы", "новелла", "феерия", "сборник", "рассказов" ] ],
[ "tale_chivalry", "рыцарский роман", [ "рыцари", "приключения" ] ],
[ "tbg_computers", "учебные пособия, самоучители", [ "пособия", "самоучители" ] ],
[ "tbg_higher", "учебники и пособия ВУЗов", [ "учебники", "пособия" ] ],
[ "tbg_school", "школьные учебники и пособия, рефераты, шпаргалки", [ "школьные", "учебники", "шпаргалки", "рефераты" ] ],
[ "tbg_secondary", "учебники и пособия для среднего и специального образования", [ "учебники", "пособия", "образование" ] ],
[ "theatre", "театр" ],
[ "thriller", "триллер", [ "триллеры", "детектив", "детективы" ] ],
[ "tragedy", "трагедия", [ "драматургия" ] ],
[ "travel_notes", " география, путевые заметки", [ "география", "заметки" ] ],
[ "vaudeville", "мистерия", [ "буффонада", "водевиль" ] ],
];
/**
* Загрузчик HTTP-запросов для FB2: обёртка над fetch с поддержкой прогресса,
* бинарных ответов и отмены через AbortController.
*/
class FB2Loader {
static async addJob(url, params) {
params ||= {};
const fetchParams = {};
fetchParams.method = params.method || "GET";
fetchParams.credentials = "same-origin";
fetchParams.signal = this._getSignal();
if (params.headers) fetchParams.headers = params.headers;
const resp = await fetch(url, fetchParams);
if (!resp.ok) throw new Error(`Сервер вернул ошибку (${resp.status})`);
const reader = resp.body.getReader();
const type = resp.headers.get("Content-Type");
const total = +resp.headers.get("Content-Length");
let loaded = 0;
const chunks = [];
const onprogress = (total && typeof(params.onprogress) === "function") ? params.onprogress : null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.length;
if (onprogress) onprogress(loaded, total);
}
let result = null;
switch (params.responseType) {
case "binary":
result = new Blob(chunks, { type: type });
break;
default:
{
let pos = 0;
const data = new Uint8Array(loaded);
for (let chunk of chunks) {
data.set(chunk, pos);
pos += chunk.length;
}
result = (new TextDecoder("utf-8")).decode(data);
}
break;
}
return params.extended ? { headers: resp.headers, response: result } : result;
}
static _getSignal() {
let controller = this._controller;
if (!controller) this._controller = controller = new AbortController();
return controller.signal;
}
}
/**
* Утилиты для FB2: конвертация даты в формат Atom (ISO 8601) для тега date FictionBook.
*/
class FB2Utils {
static dateToAtom(date) {
const month = date.getMonth() + 1;
const day = date.getDate();
return "" + date.getFullYear() + '-' + (month < 10 ? "0" : "") + month + "-" + (day < 10 ? "0" : "") + day;
}
/**
* Красивое форматирование FB2/XML с отступами для читаемости в блокноте.
* Сохраняет декларацию XML и не ломает текст внутри тегов.
*/
static prettyPrintXml(xml) {
if (!xml || typeof xml !== 'string') return xml;
try {
let s = String(xml)
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/>\s*</g, '>\n<');
s = s.replace(/^(<\?xml[^?]*\?>)\s*/i, '$1\n');
const lines = s.split('\n');
const padUnit = ' ';
let indent = 0;
const out = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
if (!line) continue;
const isClosing = /^<\//.test(line);
const isDecl = /^<\?/.test(line);
const isComment = /^<!--/.test(line);
const isSelfClosing = /\/>$/.test(line) || /^<!/.test(line);
const isOpenOnly = !isClosing && !isDecl && !isComment && !isSelfClosing
&& /^<[A-Za-z_][^>]*>$/.test(line)
&& !/^<[^>]+>.*<\//.test(line);
if (isClosing) {
indent = Math.max(0, indent - 1);
}
out.push(padUnit.repeat(indent) + line);
if (isOpenOnly) {
indent++;
}
}
return out.join('\n');
} catch (e) {
console.warn('FB2 prettyPrintXml failed', e);
return String(xml).replace(/>\s*</g, '>\n<');
}
}
}
// ============================================================
// Переопределение загрузчика изображений FB2: использует GM_xmlhttpRequest
// вместо fetch/XHR для обхода ограничений CORS в UserScript-среде
// ============================================================
FB2Image.prototype._load = async function(url, params) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url.toString(),
responseType: "blob",
onload: (response) => {
if (response.status >= 200 && response.status < 300) resolve(response.response);
else reject(new Error("HTTP " + response.status));
},
onerror: reject
});
});
};
// ============================================================
// Управление кэшем глав в GM storage: очистка старых записей,
// чтение и запись кэшированных страниц с проверкой даты и последнего редактирования
// ============================================================
function getTodayString() {
return new Date().toLocaleDateString('ru-RU');
}
function cleanOldCaches() {
const today = getTodayString();
const keysToRemove = [];
storageKeys().forEach(key => {
if (key && key.startsWith('at-chap-')) {
try {
const data = storageGet(key, null);
if (!data || data.date !== today) {
keysToRemove.push(key);
}
} catch (error) {
keysToRemove.push(key);
}
}
});
keysToRemove.forEach(k => storageRemove(k));
}
function loadChapterCache(chapterId, lastEdit) {
cleanOldCaches();
try {
const data = storageGet(`at-chap-${chapterId}`, null);
if (data && data.date === getTodayString()) {
if (lastEdit && data.lastEdit && lastEdit !== data.lastEdit) {
storageRemove(`at-chap-${chapterId}`);
return null;
}
return { pages: data.pages, totalPages: data.totalPages || 1 };
}
} catch (error) {}
return null;
}
function saveChapterCache(chapterId, pagesObj, lastEdit, totalPages) {
const ok = storageSet(`at-chap-${chapterId}`, {
date: getTodayString(),
lastEdit: lastEdit || '',
totalPages: totalPages || 1,
pages: pagesObj
});
if (!ok) {
cleanOldCaches();
storageSet(`at-chap-${chapterId}`, {
date: getTodayString(),
lastEdit: lastEdit || '',
totalPages: totalPages || 1,
pages: pagesObj
});
}
}
// ============================================================
// Исправление нативных модальных окон Litnet: перемещение в body и установка z-index,
// чтобы они отображались поверх кастомного интерфейса читалки
// ============================================================
function fixNativeModals() {
const modals = ['complaint-modal', 'reward-author-modal-dialog', 'modal_user_no_book', 'modal_user_no_lib', 'modal_no_user', 'form-complaint-modal', 'button-reward-modal-dialog-view'];
modals.forEach(id => {
const el = document.getElementById(id);
if (el && el.parentElement !== document.body) {
document.body.appendChild(el);
}
});
GM_addStyle(`
.modal-backdrop { z-index: 2147483640 !important; visibility: visible !important; pointer-events: auto !important; display: block !important; background: rgba(15, 23, 42, 0.45) !important; }
.modal-backdrop-adult { z-index: 2147483650 !important; pointer-events: auto !important; }
.modal { z-index: 2147483645 !important; visibility: visible !important; pointer-events: auto !important; }
.bootbox { z-index: 2147483646 !important; visibility: visible !important; pointer-events: auto !important; }
.modal {
display: none !important;
align-items: center !important;
justify-content: center !important;
padding: 24px 16px !important;
}
.modal.in,
.modal.show {
display: flex !important;
}
.modal .modal-dialog {
margin: 0 auto !important;
transform: none !important;
top: auto !important;
max-height: calc(100vh - 48px) !important;
}
#complaint-modal .modal-dialog,
#form-complaint-modal .modal-dialog {
width: 560px !important;
max-width: calc(100vw - 32px) !important;
}
#button-reward-modal-dialog-view .modal-dialog,
.reward-modal-dialog {
width: 789px !important;
max-width: calc(100vw - 32px) !important;
min-width: 0 !important;
}
#complaint-modal .modal-content,
#form-complaint-modal .modal-content,
#button-reward-modal-dialog-view .modal-content,
.reward-modal-dialog .modal-content {
border-radius: 12px !important;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.2) !important;
border: none !important;
max-height: calc(100vh - 48px) !important;
display: flex !important;
flex-direction: column !important;
}
#complaint-modal .modal-header,
#form-complaint-modal .modal-header,
#button-reward-modal-dialog-view .modal-header {
border-bottom: 1px solid #eef2f7 !important;
flex: 0 0 auto !important;
}
#complaint-modal .close,
#form-complaint-modal .close,
#button-reward-modal-dialog-view .close {
opacity: 0.7 !important;
}
#complaint-modal .close:hover,
#form-complaint-modal .close:hover,
#button-reward-modal-dialog-view .close:hover {
opacity: 1 !important;
}
.reward-modal-wrap,
.reward-modal-form {
max-height: none !important;
overflow: visible !important;
flex: 1 1 auto !important;
}
.reward-modal-form-list {
max-height: min(52vh, 360px) !important;
overflow-y: auto !important;
overflow-x: hidden !important;
}
#complaint-modal .simple-content-form,
#form-complaint-modal .simple-content-form {
padding: 16px 28px 24px !important;
}
`);
const hoistModals = () => {
['complaint-modal', 'form-complaint-modal', 'button-reward-modal-dialog-view', 'reward-author-modal-dialog'].forEach(id => {
const el = document.getElementById(id);
if (el && el.parentElement !== document.body) {
document.body.appendChild(el);
}
});
};
try {
const obs = new MutationObserver(() => hoistModals());
obs.observe(document.documentElement, { childList: true, subtree: true });
} catch (e) {}
}
// ============================================================
// Отключение защиты от копирования: блокировка selectstart, contextmenu, dragstart,
// а также включение пользовательского выделения текста через CSS
// ============================================================
function killAntiCopy() {
const stopProp = (event) => {
if (event.target && event.target.closest && (event.target.closest('.modal') || event.target.closest('.modal-backdrop') || event.target.closest('.bootbox') || event.target.closest('#reward-author-modal-dialog') || event.target.closest('#complaint-modal'))) {
return;
}
event.stopPropagation();
};
['selectstart', 'mousedown', 'mouseup', 'copy', 'contextmenu', 'dragstart'].forEach(evt => {
document.addEventListener(evt, stopProp, true);
});
GM_addStyle(`
* {
-webkit-user-select: text !important;
-moz-user-select: text !important;
-ms-user-select: text !important;
user-select: text !important;
}
`);
}
// ============================================================
// Очистка HTML главы: удаление пагинации, кнопок, скриптов, стилей;
// извлечение только текстового контента и фильтрация заголовков глав
// ============================================================
function cleanChapterHtml(rawHtml, expectedTitle) {
if (!rawHtml) return '';
const tempDiv = document.createElement('div');
tempDiv.innerHTML = rawHtml;
tempDiv.querySelectorAll('.reader-pagination, .pagination, .js-pagination, .clearfix, script, style, .reader-btn-w, .audio_btn_prompt').forEach(el => el.remove());
let contentEl = tempDiv.querySelector('.jsReaderText, [data-test-id="reader-text"]');
let contentHtml = contentEl ? contentEl.innerHTML : tempDiv.innerHTML;
const cleanDiv = document.createElement('div');
cleanDiv.innerHTML = contentHtml;
let headings = cleanDiv.querySelectorAll('h1, h2, h3');
headings.forEach(heading => {
if (expectedTitle && heading.textContent.trim().toLowerCase() === expectedTitle.toLowerCase()) {
heading.remove();
}
});
let html = cleanDiv.innerHTML.replace(/onmousedown="[^"]*"/gi, '');
html = html.replace(/[\u200B-\u200F\u2028-\u202F\u2060-\u206F\uFEFF\u00AD]/g, '');
html = html.replace(/(?:​|​|‌|‍)+/gi, '');
const stripDiv = document.createElement('div');
stripDiv.innerHTML = html;
stripDiv.querySelectorAll('p, div, span').forEach(el => {
const visible = (el.textContent || '').replace(/\s+/g, '').trim();
if (!visible && !el.querySelector('img, image, svg, table, br')) {
el.remove();
}
});
stripDiv.querySelectorAll('p').forEach(el => {
if (!(el.textContent || '').replace(/\s+/g, '').trim() && !el.querySelector('img')) {
el.remove();
}
});
return stripDiv.innerHTML.trim();
}
// ============================================================
// Извлечение данных со страницы книги: метаданные, жанры, теги, статистика,
// список глав, текущая глава и номер страницы. Парсинг из ng-state JSON и DOM
// ============================================================
function extractData() {
let foundTotalPages = false;
window.atTotalChapterPages = 1;
let serverPage = 1;
const idBookEl = document.querySelector('.book-id');
if (idBookEl) {
bookMeta.idBook = idBookEl.textContent.trim();
}
const rightBlockGenres = document.querySelectorAll('.jsAddTargetBlank a[href*="/top/"]');
if (rightBlockGenres.length > 0) {
bookMeta.genres = Array.from(rightBlockGenres).map(genreLink => genreLink.textContent.trim());
}
const likeBtn = document.querySelector('.likes-btn, .not-likes-btn');
if (likeBtn) {
bookMeta.isLiked = likeBtn.classList.contains('likes-btn');
const countEl = likeBtn.querySelector('.count');
if (countEl) bookMeta.likeCount = parseInt(countEl.textContent.trim(), 10) || 0;
}
const libIn = document.querySelector('.to_lib.in-lib');
bookMeta.inLibrary = !!libIn;
window.atCurrentLastEdit = '';
const editEl = document.querySelector('.last-edit');
if (editEl) {
window.atCurrentLastEdit = editEl.textContent.trim();
} else {
const htmlMatch = document.body.innerHTML.match(/Отредактировано:\s*[\d\.]+/i);
if (htmlMatch) window.atCurrentLastEdit = htmlMatch[0];
}
const urlParams = new URLSearchParams(window.location.search);
let urlPageParam = parseInt(urlParams.get('p'), 10);
if (isNaN(urlPageParam)) urlPageParam = null;
window.atCurrentPage = urlPageParam || 1;
window.atCurrentChapterId = urlParams.get('c');
window.atBaseUrl = window.location.pathname.split('?')[0];
let chapterHtml = '';
try {
const ngStateScript = document.getElementById('ng-state');
if (ngStateScript) {
const stateStr = ngStateScript.textContent;
const tpMatch = stateStr.match(/"totalPages"\s*:\s*(\d+)/);
if (tpMatch) {
window.atTotalChapterPages = parseInt(tpMatch[1], 10);
foundTotalPages = true;
}
const stateData = JSON.parse(stateStr);
for (const key in stateData) {
const obj = stateData[key]?.body || stateData[key]?.data || stateData[key];
if (obj && obj.idBook) bookMeta.idBook = obj.idBook;
if (obj && obj.bookId) bookMeta.idBook = obj.bookId;
if (obj && obj.bookInLib !== undefined) bookMeta.inLibrary = obj.bookInLib;
if (obj && obj.totalPages !== undefined) {
window.atTotalChapterPages = parseInt(obj.totalPages, 10);
foundTotalPages = true;
if (obj.data && typeof obj.data === 'string') {
chapterHtml = cleanChapterHtml(obj.data, obj.chapterTitle);
}
if (obj.page !== undefined) serverPage = parseInt(obj.page, 10);
if (obj.chapterTitle) currentChapterTitle = obj.chapterTitle;
}
if (obj && obj.author) {
if (obj.author.name) bookMeta.author = obj.author.name;
}
if (obj && obj.title) bookMeta.title = obj.title;
if (obj && obj.chapters && Array.isArray(obj.chapters) && availableChapters.length === 0) {
obj.chapters.forEach((chapter, idx) => {
availableChapters.push({
idx: idx + 1,
id: chapter.id.toString(),
title: chapter.title,
url: window.atBaseUrl + '?c=' + chapter.id,
locked: !!(chapter.locked || chapter.isLocked || chapter.paid === false || chapter.is_paid === false)
});
});
}
}
}
} catch (error) {}
if (!chapterHtml) {
const textContainer = document.querySelector('.jsReaderText, [data-test-id="reader-text"]');
if (textContainer) {
chapterHtml = cleanChapterHtml(textContainer.parentElement.innerHTML, currentChapterTitle);
}
}
if (!foundTotalPages) {
let maxPage = window.atTotalChapterPages || 1;
document.querySelectorAll('.pagination a, .js-pagination a, a[href*="&p="], a[href*="?p="]').forEach(paginationLink => {
const match = paginationLink.href.match(/[?&]p=(\d+)/);
if (match) maxPage = Math.max(maxPage, parseInt(match[1], 10));
});
document.querySelectorAll('[onclick*="Reader.goTo"]').forEach(goButton => {
const match = goButton.getAttribute('onclick').match(/Reader\.goTo\((\d+)\)/);
if (match) maxPage = Math.max(maxPage, parseInt(match[1], 10));
});
window.atTotalChapterPages = maxPage;
}
if (!chapterHtml || chapterHtml.length < 50) return false;
const authorDomLinks = document.querySelectorAll('.book-page-author__about, .sa-name, a[href*="-u"]');
for (const authorLink of authorDomLinks) {
if (authorLink.href && !authorLink.href.includes('search')) {
bookMeta.authorLink = authorLink.href;
bookMeta.author = authorLink.textContent.replace(/Автор\s*•.*$/i, '').replace(/Автор\s*книг.*$/i, '').trim();
break;
}
}
if (!bookMeta.authorLink || bookMeta.authorLink === '#') {
const langPrefix = window.location.pathname.split('/')[1] || 'ru';
bookMeta.authorLink = `/${langPrefix}/search?q=${encodeURIComponent(bookMeta.author)}`;
}
let rawTitle = bookMeta.title || document.title;
rawTitle = rawTitle.replace(/^Книга\s+/i, '').split(/,(?:\s*глава)?/i)[0].split(/\s*—/)[0].split(/\|/)[0].trim();
const h1BookTitle = document.querySelector('h1.book-heading a, h1.book-heading');
if (h1BookTitle) {
rawTitle = h1BookTitle.textContent.trim();
}
bookMeta.title = rawTitle;
bookMeta.headerTitle = rawTitle;
window.atRequestedPage = urlPageParam || serverPage || 1;
window.atCurrentPage = window.atRequestedPage;
const cached = loadChapterCache(window.atCurrentChapterId, window.atCurrentLastEdit);
window.atChapterPagesCache = cached ? cached.pages : {};
if (cached && cached.totalPages > window.atTotalChapterPages) {
window.atTotalChapterPages = cached.totalPages;
}
if (serverPage === window.atRequestedPage && !window.atChapterPagesCache[serverPage]) {
window.atChapterPagesCache[serverPage] = chapterHtml;
}
if (availableChapters.length === 0) {
const chapterSelect = document.querySelector('select.js-chapter-change');
if (chapterSelect) {
if (!window.atCurrentChapterId) window.atCurrentChapterId = chapterSelect.value;
const options = Array.from(chapterSelect.options);
options.forEach((opt, index) => {
availableChapters.push({
idx: index + 1,
id: opt.value,
title: opt.text,
url: window.atBaseUrl + '?c=' + opt.value,
locked: false
});
});
}
}
availableChapters.forEach((chapter, index) => {
const isActive = chapter.id === window.atCurrentChapterId;
const lockClass = chapter.locked ? ' locked' : '';
const lockMark = chapter.locked ? `<span class="at-toc-lock" title="Глава недоступна без покупки">${atIcon('lock')}</span>` : '';
tocHtml += `<a href="${chapter.url}" class="at-toc-item${lockClass}${isActive ? ' active' : ''}" data-chapter-id="${chapter.id}" data-locked="${chapter.locked ? '1' : '0'}"><span class="at-toc-title-text">${chapter.title}</span>${lockMark}</a>`;
if (isActive) {
currentChapterTitle = currentChapterTitle || chapter.title;
if (index > 0) {
prevChapterUrl = availableChapters[index - 1].url;
prevChapterTitle = availableChapters[index - 1].title;
}
if (index < availableChapters.length - 1) {
nextChapterUrl = availableChapters[index + 1].url;
nextChapterTitle = availableChapters[index + 1].title;
}
}
});
return true;
}
// ============================================================
// Загрузка дополнительных метаданных книги: теги, жанры, аннотация, статистика,
// обложка. Использует кэш GM storage с проверкой валидности (7 дней, непустая аннотация)
// ============================================================
function rebuildTocList() {
const tocList = document.querySelector('.at-toc-list');
if (!tocList) return;
tocList.innerHTML = availableChapters.map(chapter => {
const isActive = String(chapter.id) === String(window.atCurrentChapterId);
const lockClass = chapter.locked ? ' locked' : '';
const lockMark = chapter.locked ? `<span class="at-toc-lock" title="Глава недоступна без покупки">${atIcon('lock')}</span>` : '';
return `<a href="${chapter.url}" class="at-toc-item${lockClass}${isActive ? ' active' : ''}" data-chapter-id="${chapter.id}" data-locked="${chapter.locked ? '1' : '0'}"><span class="at-toc-title-text">${chapter.title}</span>${lockMark}</a>`;
}).join('');
bindTocLockClicks(tocList);
updatePaginationLockUi();
}
function bindTocLockClicks(root) {
const scope = root || document;
scope.querySelectorAll('.at-toc-item').forEach(link => {
link.addEventListener('click', (event) => {
const locked = link.dataset.locked === '1' || link.classList.contains('locked');
if (!locked) return;
event.preventDefault();
event.stopPropagation();
const id = link.dataset.chapterId;
const ch = availableChapters.find(c => String(c.id) === String(id));
showPaidChapterModal(ch || { title: link.textContent.trim(), id: id, url: link.href });
});
});
}
function formatStatDate(text) {
const months = {
'янв': '01', 'фев': '02', 'мар': '03', 'апр': '04', 'мая': '05', 'май': '05',
'июн': '06', 'июл': '07', 'авг': '08', 'сен': '09', 'окт': '10', 'ноя': '11', 'дек': '12'
};
const m = String(text).match(/(\d{1,2})\s*([а-яё]+)\s*(\d{4})/i);
if (m) {
const monKey = m[2].toLowerCase().slice(0, 3);
const mon = months[monKey] || '01';
const day = m[1].padStart(2, '0');
return `${m[3]}.${mon}.${day}`;
}
const d = String(text).match(/(\d{1,2})[./](\d{1,2})[./](\d{2,4})/);
if (d) {
const y = d[3].length === 2 ? ('20' + d[3]) : d[3];
return `${y}.${d[2].padStart(2, '0')}.${d[1].padStart(2, '0')}`;
}
return text;
}
async function showPaidChapterModal(chapter) {
let overlay = document.getElementById('at-paywall-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'at-paywall-overlay';
overlay.className = 'at-paywall-overlay';
document.body.appendChild(overlay);
}
const title = (chapter && chapter.title) ? chapter.title : 'Глава';
const bookUrl = bookMeta.url || '#';
const chapterUrl = (chapter && chapter.url) ? chapter.url : (window.atBaseUrl + '?c=' + (chapter && chapter.id ? chapter.id : ''));
overlay.innerHTML = `
<div class="at-paywall-dialog" role="dialog" aria-modal="true">
<button type="button" class="at-paywall-close" title="Закрыть" id="at-paywall-close">×</button>
<p class="at-paywall-text">Загрузка…</p>
</div>
`;
overlay.classList.add('open');
const close = () => overlay.classList.remove('open');
overlay.querySelector('#at-paywall-close').onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
let payHtml = '';
let parsed = {
text: '',
cover: bookMeta.cover || '',
buyLabel: bookMeta.buyPrice ? ('Купить / ' + bookMeta.buyPrice) : 'Купить доступ к книге',
bookLink: bookUrl,
bookLinkLabel: 'Открыть информацию о книге'
};
try {
const res = await fetch(chapterUrl, { credentials: 'include' });
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const paid = doc.querySelector('.content.chapter_paid, .chapter_paid');
if (paid) {
payHtml = paid.innerHTML;
const p = paid.querySelector('p');
if (p && p.textContent.trim()) parsed.text = p.textContent.trim();
const img = paid.querySelector('img');
if (img && (img.getAttribute('src') || img.src)) {
parsed.cover = img.getAttribute('src') || img.src;
}
const buyBtn = paid.querySelector('#js-buyModal, a.buy-btn, .btn-success');
if (buyBtn) {
const buyText = buyBtn.textContent.replace(/\s+/g, ' ').trim();
if (buyText) parsed.buyLabel = buyText;
}
const infoLink = paid.querySelector('a.btn-default[href*="/book/"], a[href*="/book/"]');
if (infoLink) {
if (infoLink.href) parsed.bookLink = infoLink.href;
const lab = infoLink.textContent.replace(/\s+/g, ' ').trim();
if (lab) parsed.bookLinkLabel = lab;
}
}
} catch (e) {}
if (!parsed.text) {
parsed.text = 'Чтобы продолжить чтение, приобретите доступ к тексту книги.';
}
const safeTitle = title.replace(/</g, '<').replace(/"/g, '"');
const safeText = parsed.text.replace(/</g, '<');
const safeCover = (parsed.cover || '').replace(/"/g, '"');
const safeBuy = parsed.buyLabel.replace(/</g, '<');
const safeBookLab = parsed.bookLinkLabel.replace(/</g, '<');
overlay.innerHTML = `
<div class="at-paywall-dialog" role="dialog" aria-modal="true">
<button type="button" class="at-paywall-close" title="Закрыть" id="at-paywall-close">×</button>
<p class="at-paywall-text">${safeText}</p>
${safeCover ? `<img class="at-paywall-cover" src="${safeCover}" alt="${safeTitle}">` : ''}
<p class="at-paywall-chapter">${safeTitle}</p>
<button type="button" class="at-paywall-buy" id="at-paywall-buy">${safeBuy}</button>
<a class="at-paywall-book" href="${parsed.bookLink}">${safeBookLab}</a>
</div>
`;
overlay.querySelector('#at-paywall-close').onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
overlay.querySelector('#at-paywall-buy').onclick = async () => {
const bookId = bookMeta.idBook;
if (bookId) {
try {
const res = await fetch('/book/popup-buy?id=' + encodeURIComponent(bookId), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.text();
if (data && data.includes('buyModal')) {
const wrap = document.createElement('div');
wrap.innerHTML = data;
document.body.appendChild(wrap);
if (typeof window.jQuery !== 'undefined' && window.jQuery('#buyModal').length) {
window.jQuery('#buyModal').modal();
close();
return;
}
}
} catch (e) {}
}
const nativeBtn = document.querySelector('#js-buyModal');
if (nativeBtn) {
nativeBtn.click();
close();
return;
}
window.location.href = parsed.bookLink || bookUrl;
};
}
async function syncChapterLocks() {
if (!availableChapters.length && !bookMeta.idBook) return;
const bookId = bookMeta.idBook;
if (!bookId) return;
try {
let isPurchased = false;
try {
const bookRes = await fetch('https://superapi.litnet.com/v2/book/' + bookId, {
credentials: 'include',
mode: 'cors',
headers: { 'Accept': 'application/json' }
});
if (bookRes.ok) {
const bookJson = await bookRes.json();
isPurchased = !!(bookJson.access_right && (
bookJson.access_right.is_purchased ||
bookJson.access_right.is_rented ||
bookJson.access_right.is_purchased_audio
));
if (bookJson.prices) {
const p = bookJson.prices.price_with_discount || bookJson.prices.price;
if (p) bookMeta.buyPrice = p + ' RUB';
}
if (bookJson.stats && (!bookMeta.rawStats || !bookMeta.rawStats.length)) {
const s = bookJson.stats;
const stats = [];
if (s.rating != null) stats.push({ icon: 'star', label: 'Рейтинг', value: formatCompactNum(s.rating), title: 'Рейтинг: ' + s.rating });
if (bookJson.flags) {
if (bookJson.flags.is_finished) {
stats.push({ icon: 'checkCircle', label: 'Статус', value: 'Закончена', title: 'Статус: Закончена' });
} else {
stats.push({ icon: 'note', label: 'Статус', value: 'В процессе', title: 'Статус: В процессе' });
}
}
if (bookJson.pages) stats.push({ icon: 'page', label: 'Объём', value: bookJson.pages + ' стр', title: 'Объём: ' + bookJson.pages + ' стр' });
if (bookJson.updated_at) stats.push({ icon: 'calendar', label: 'Обновлено', value: bookJson.updated_at, title: 'Дата обновления: ' + bookJson.updated_at });
if (s.count_views != null) stats.push({ icon: 'eye', label: 'Просмотры', value: formatCompactNum(s.count_views), title: 'Просмотры: ' + s.count_views });
if (s.count_in_libraries != null) stats.push({ icon: 'library', label: 'В библиотеках', value: formatCompactNum(s.count_in_libraries), title: 'В библиотеках: ' + s.count_in_libraries });
if (s.count_likes != null) stats.push({ icon: 'heart', label: 'Лайки', value: formatCompactNum(s.count_likes), title: 'Лайки: ' + s.count_likes });
if (stats.length) bookMeta.rawStats = stats;
}
if (bookJson.image_original || bookJson.image) {
bookMeta.cover = bookJson.image_original || bookJson.image;
}
if (bookJson.finished_at) bookMeta.finishedAt = bookJson.finished_at;
if (bookJson.updated_at) bookMeta.updatedAt = bookJson.updated_at;
if (bookJson.published_at) bookMeta.publishedAt = bookJson.published_at;
if (bookJson.cycle && bookJson.cycle.title) {
bookMeta.cycleName = String(bookJson.cycle.title).trim();
if (bookJson.cycle.books_count != null) {
bookMeta.cycleBooksCount = Number(bookJson.cycle.books_count) || null;
}
}
if (bookJson.priority_cycle != null && bookJson.priority_cycle !== '') {
const n = parseInt(bookJson.priority_cycle, 10);
if (!isNaN(n) && n > 0) bookMeta.cycleNumber = n;
}
}
} catch (e) {}
const chapRes = await fetch('https://superapi.litnet.com/v2/chapters/book/' + bookId, {
credentials: 'include',
headers: { 'Accept': 'application/json' }
});
if (!chapRes.ok) return;
const list = await chapRes.json();
if (!Array.isArray(list)) return;
const byId = new Map(list.map(ch => [String(ch.id), ch]));
if (!isPurchased) {
const probe = list.find(ch => ch && ch.is_free === false) || list[list.length - 1];
if (probe && probe.id) {
try {
const probeUrl = (window.atBaseUrl || (location.origin + location.pathname)) + '?c=' + probe.id;
const probeRes = await fetch(probeUrl, { credentials: 'include' });
const probeHtml = await probeRes.text();
const hasPaywall = probeHtml.includes('chapter_paid') || probeHtml.includes('ознакомительный фрагмент');
const hasText = probeHtml.includes('jsReaderText') ||
probeHtml.includes('reader-text') ||
probeHtml.includes('id="ng-state"') ||
probeHtml.includes("id='ng-state'");
if (!hasPaywall && hasText) {
isPurchased = true;
}
} catch (e) {}
}
}
if (!availableChapters.length) {
list.forEach((ch, idx) => {
availableChapters.push({
idx: ch.priority || (idx + 1),
id: String(ch.id),
title: ch.title || ('Глава ' + (idx + 1)),
url: window.atBaseUrl + '?c=' + ch.id,
locked: isPurchased ? false : !ch.is_free
});
});
} else {
availableChapters.forEach(ch => {
const apiCh = byId.get(String(ch.id));
if (apiCh) {
ch.locked = isPurchased ? false : !apiCh.is_free;
if (apiCh.title) ch.title = apiCh.title;
} else if (isPurchased) {
ch.locked = false;
}
});
}
rebuildTocList();
updateSidebarUI();
try {
const cacheKey = `at-book-meta-${bookMeta.bookSlug}`;
const cached = storageGet(cacheKey, null) || {};
cached.chapterLocks = availableChapters.map(ch => ({ id: String(ch.id), locked: !!ch.locked }));
cached.rawStats = bookMeta.rawStats;
cached.cover = bookMeta.cover;
cached.buyPrice = bookMeta.buyPrice;
cached.cycleName = bookMeta.cycleName;
cached.cycleNumber = bookMeta.cycleNumber;
cached.cycleBooksCount = bookMeta.cycleBooksCount;
cached.cachedAt = Date.now();
storageSet(cacheKey, cached);
} catch (e) {}
} catch (e) {}
}
function formatCompactNum(n) {
n = Number(n) || 0;
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'К';
return String(n);
}
function mergeChapterLocksFromDoc(doc) {
if (!doc || !availableChapters.length) return;
const items = doc.querySelectorAll('.view-contents__item, [class*="view-contents__item"]');
if (!items.length) return;
const lockByTitle = new Map();
const lockByIdx = new Map();
items.forEach((item, index) => {
const hasLock = !!(
item.querySelector('img[alt="lock"], img[src*="lock.svg"], [name="lock"], .lib-icon[name="lock"]')
|| (item.innerHTML && item.innerHTML.includes('lock.svg'))
);
const titleEl = item.querySelector('.view-contents__item-title, p.view-contents__item-title');
const title = titleEl ? titleEl.textContent.replace(/\s+/g, ' ').trim() : '';
if (title) {
lockByTitle.set(title.toLowerCase(), hasLock);
const num = title.match(/^(\d+)\s*[.:)/]/);
if (num) lockByIdx.set(parseInt(num[1], 10), hasLock);
}
lockByIdx.set(index + 1, hasLock);
});
availableChapters.forEach(ch => {
const t = (ch.title || '').toLowerCase().trim();
if (lockByTitle.has(t)) {
ch.locked = lockByTitle.get(t);
return;
}
for (const [title, locked] of lockByTitle.entries()) {
if (t && (title.includes(t) || t.includes(title))) {
ch.locked = locked;
return;
}
}
if (lockByIdx.has(ch.idx)) {
ch.locked = lockByIdx.get(ch.idx);
}
});
}
function updatePaginationLockUi() {
const pag = document.querySelector('.at-pagination');
if (!pag || !availableChapters.length) return;
const curIdx = availableChapters.findIndex(c => String(c.id) === String(window.atCurrentChapterId));
const prev = curIdx > 0 ? availableChapters[curIdx - 1] : null;
const next = curIdx >= 0 && curIdx < availableChapters.length - 1 ? availableChapters[curIdx + 1] : null;
const links = pag.querySelectorAll('a');
let prevHtml = '<div></div>';
let nextHtml = '<div></div>';
if (prev) {
if (prev.locked) {
prevHtml = `<a href="${prev.url}" class="at-pag-locked" data-chapter-id="${prev.id}" data-locked="1" title="Глава недоступна без покупки">← ${atIcon('lock', 'at-inline-icon')} <span>${prev.title}</span></a>`;
} else {
prevHtml = `<a href="${prev.url}">← <span>${prev.title}</span></a>`;
}
}
if (next) {
if (next.locked) {
nextHtml = `<a href="${next.url}" class="at-pag-locked" data-chapter-id="${next.id}" data-locked="1" title="Глава недоступна без покупки"><span>${next.title}</span> ${atIcon('lock', 'at-inline-icon')} →</a>`;
} else {
nextHtml = `<a href="${next.url}"><span>${next.title}</span> →</a>`;
}
}
pag.innerHTML = prevHtml + nextHtml;
pag.querySelectorAll('a.at-pag-locked').forEach(link => {
link.addEventListener('click', (event) => {
event.preventDefault();
const id = link.dataset.chapterId;
const ch = availableChapters.find(c => String(c.id) === String(id));
showPaidChapterModal(ch || { title: link.textContent.trim(), id, url: link.href });
});
});
}
async function fetchBookExtraInfo() {
const cacheKey = `at-book-meta-${bookMeta.bookSlug}`;
const cachedData = storageGet(cacheKey, null);
if (cachedData && typeof cachedData === 'object') {
try {
let cacheValid = true;
if (!cachedData.cover && !cachedData.annotation) cacheValid = false;
const cacheAgeMs = Date.now() - (cachedData.cachedAt || 0);
if (cacheAgeMs > 7 * 24 * 60 * 60 * 1000) cacheValid = false;
if (cacheValid) {
if (cachedData.tags) bookMeta.tags = cachedData.tags;
if (cachedData.genres && cachedData.genres.length) bookMeta.genres = cachedData.genres;
if (cachedData.annotation) bookMeta.annotation = cachedData.annotation;
if (cachedData.rawStats) bookMeta.rawStats = cachedData.rawStats;
if (cachedData.author) bookMeta.author = cachedData.author;
if (cachedData.authorLink) bookMeta.authorLink = cachedData.authorLink;
if (cachedData.cover) bookMeta.cover = cachedData.cover;
if (cachedData.cycleName) bookMeta.cycleName = cachedData.cycleName;
if (cachedData.cycleNumber != null) bookMeta.cycleNumber = cachedData.cycleNumber;
if (cachedData.cycleBooksCount != null) bookMeta.cycleBooksCount = cachedData.cycleBooksCount;
if (Array.isArray(cachedData.chapterLocks)) {
const map = new Map(cachedData.chapterLocks.map(x => [String(x.id), !!x.locked]));
availableChapters.forEach(ch => {
if (map.has(String(ch.id))) ch.locked = map.get(String(ch.id));
});
rebuildTocList();
}
updateSidebarUI();
}
} catch (error) {}
}
try {
let html = '';
try {
const response = await fetch(bookMeta.url, { credentials: 'include' });
if (response.ok) html = await response.text();
} catch (fetchErr) {
html = '';
}
if (!html || html.length < 500) {
html = await new Promise((resolve) => {
try {
GM_xmlhttpRequest({
method: 'GET',
url: bookMeta.url,
onload: (r) => resolve(r.responseText || ''),
onerror: () => resolve(''),
ontimeout: () => resolve(''),
timeout: 15000
});
} catch (e) {
resolve('');
}
});
}
if (!html) {
updateSidebarUI();
return;
}
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
let cover = '';
const ogImage = doc.querySelector('meta[property="og:image"], meta[name="og:image"]');
if (ogImage && ogImage.getAttribute('content')) {
cover = ogImage.getAttribute('content').trim();
}
if (!cover) {
const coverImg = doc.querySelector('img[alt*="Книга"], img[alt*="Обложка"], img.img_soc, img[src*="/books/covers/"]');
if (coverImg && coverImg.src) cover = coverImg.src;
}
if (cover) {
cover = cover.replace(/\/books\/covers\/\d+\//, '/books/covers/0/');
bookMeta.cover = cover;
}
let annotation = '';
const annoSelectors = [
'.lib-description__description',
'[data-test-id*="lib-tab-list-description"]',
'[data-test-id*="description"]',
'.book-annotation',
'.annotation',
'.view-book-page-tabs__content'
];
for (const sel of annoSelectors) {
const el = doc.querySelector(sel);
if (el && el.textContent && el.textContent.trim().length > 40) {
annotation = el.innerHTML.trim();
break;
}
}
if (!annotation) {
const metaDesc = doc.querySelector('meta[name="description"]');
if (metaDesc && metaDesc.getAttribute('content')) {
annotation = '<p>' + metaDesc.getAttribute('content').trim() + '</p>';
}
}
if (annotation) bookMeta.annotation = annotation;
const tagEls = doc.querySelectorAll(
'.view-book-page-header-desktop__tags-tag span, .lib-tag span, a[href*="/tag/"] span, a[href*="/tag/"]'
);
if (tagEls.length > 0) {
const tags = Array.from(tagEls)
.map(el => el.textContent.replace(/^#/, '').trim())
.filter(t => t && t.length > 1 && t.length < 80);
if (tags.length) {
bookMeta.tags = tags.filter((value, index, array) => array.indexOf(value) === index);
}
}
if (!bookMeta.genres || bookMeta.genres.length === 0) {
let genreEls = doc.querySelectorAll(
'a[href*="/top/"]:not(header a):not(nav a):not(footer a)'
);
let parsedGenres = [];
Array.from(genreEls).forEach(genreElement => {
if (genreElement.closest('.dropdown, .menu, nav, header, footer, .lib-dropdown__content, .ln_topbar, .ln_topbar_genres')) return;
let text = genreElement.textContent
.replace(/^\s*#?\s*\d+\s*(?:[-–—]\s*)?/, '')
.replace(/\s*\(из.*?\)\s*/i, '')
.replace(/\s+/g, ' ')
.trim();
if (!text || text.length < 3 || text.length > 60) return;
if (/^(все жанры|по популярности|по обновлениям|новинки|бестселлеры|книги с аудио)$/i.test(text)) return;
parsedGenres.push(text);
});
if (parsedGenres.length > 0) {
bookMeta.genres = Array.from(new Set(parsedGenres)).slice(0, 12);
}
}
const authorEl = doc.querySelector(
'a[href*="-u"][href*="/ru/"], a.view-book-page-header-desktop__author, a.lib-author__name, .sa-name'
);
if (authorEl) {
if (authorEl.href && !authorEl.href.includes('search')) {
bookMeta.authorLink = authorEl.href;
}
const nameEl = authorEl.querySelector('.book-page-author__about-info-name, .lib-author__name');
const nameText = (nameEl ? nameEl.textContent : authorEl.textContent)
.replace(/Автор\s*•.*$/i, '')
.replace(/Автор\s*книг.*$/i, '')
.replace(/\s+/g, ' ')
.trim();
if (nameText && nameText.length > 1) bookMeta.author = nameText;
}
const classifyStat = (raw, iconHint) => {
const text = String(raw || '').replace(/\s+/g, ' ').trim();
if (!text || text.length > 48) return null;
const lower = text.toLowerCase();
if (/стр/i.test(text)) return { icon: 'page', label: 'Объём', value: text, title: 'Объём книги: ' + text };
if (/закончен|полностью/i.test(lower)) return { icon: 'checkCircle', label: 'Статус', value: text, title: 'Статус: ' + text };
if (/процесс|в работе/i.test(lower)) return { icon: 'note', label: 'Статус', value: text, title: 'Статус: ' + text };
if (/\d{1,2}\s*(янв|фев|мар|апр|май|июн|июл|авг|сен|окт|ноя|дек)/i.test(lower) || /\d{1,2}[./]\d{1,2}[./]\d{2,4}/.test(text)) {
return { icon: 'calendar', label: 'Обновлено', value: text, title: 'Дата обновления: ' + text };
}
if (iconHint === 'star' || /^\d+[.,]?\d*\s*[kкmм]?$/i.test(text) && iconHint === 'rating') {
return { icon: 'star', label: 'Рейтинг', value: text, title: 'Рейтинг: ' + text };
}
if (iconHint === 'eye' || iconHint === 'view') return { icon: 'eye', label: 'Просмотры', value: text, title: 'Просмотры: ' + text };
if (iconHint === 'like' || iconHint === 'heart') return { icon: 'heart', label: 'Лайки', value: text, title: 'Лайки: ' + text };
if (iconHint === 'library' || iconHint === 'bookmark') return { icon: 'library', label: 'В библиотеках', value: text, title: 'Добавили в библиотеку: ' + text };
if (iconHint === 'comment') return { icon: 'comment', label: 'Комментарии', value: text, title: 'Комментарии: ' + text };
if (iconHint === 'star') return { icon: 'star', label: 'Рейтинг', value: text, title: 'Рейтинг: ' + text };
return { icon: '', label: '', value: text, title: text };
};
const pushStat = (list, item) => {
if (!item || !item.value) return;
if (list.some(s => s.value === item.value && s.label === item.label)) return;
list.push(item);
};
let parsedStats = [];
const statEls = doc.querySelectorAll('.view-book-page-header-desktop__labels .lib-label, .book-header .lib-label, [class*="book-page-header"] .lib-label');
statEls.forEach(statElement => {
if (statElement.closest('nav, header, footer, .ln_topbar')) return;
const iconEl = statElement.querySelector('img.lib-icon__img, img, svg use, svg');
let hint = '';
const src = (iconEl && (iconEl.src || iconEl.getAttribute('href') || iconEl.getAttribute('xlink:href') || '')) || '';
const srcL = src.toLowerCase();
if (srcL.includes('star')) hint = 'star';
else if (srcL.includes('eye') || srcL.includes('view')) hint = 'eye';
else if (srcL.includes('like') || srcL.includes('heart')) hint = 'like';
else if (srcL.includes('comment') || srcL.includes('chat')) hint = 'comment';
else if (srcL.includes('library') || srcL.includes('bookmark') || srcL.includes('book')) hint = 'library';
const text = (statElement.querySelector('.lib-label__text') || statElement).textContent.replace(/\s+/g, ' ').trim();
pushStat(parsedStats, classifyStat(text, hint));
});
if (parsedStats.length === 0) {
const headerBlock = doc.querySelector('[class*="view-book-page-header"], [class*="book-page-header"], main') || doc.body;
const blob = headerBlock ? headerBlock.textContent : '';
const pageMatch = blob.match(/(\d+[\s\u00a0]*стр)/i);
if (pageMatch) pushStat(parsedStats, classifyStat(pageMatch[1].replace(/\s+/g, ' '), ''));
const statusMatch = blob.match(/(В процессе|Закончена|Полностью)/i);
if (statusMatch) pushStat(parsedStats, classifyStat(statusMatch[1], ''));
const dateMatch = blob.match(/(\d{1,2}\s*(?:янв|фев|мар|апр|мая|май|июн|июл|авг|сен|окт|ноя|дек)[а-я]*\s*\d{4})/i);
if (dateMatch) pushStat(parsedStats, classifyStat(dateMatch[1], ''));
}
if (parsedStats.length) bookMeta.rawStats = parsedStats.slice(0, 10);
if (!bookMeta.title || bookMeta.title === document.title) {
const ogTitle = doc.querySelector('meta[property="og:title"]');
if (ogTitle && ogTitle.getAttribute('content')) {
bookMeta.title = ogTitle.getAttribute('content').trim();
bookMeta.headerTitle = bookMeta.title;
}
}
if (!bookMeta.cycleName) {
const cycleLink = doc.querySelector('a[href*="/cycle/"], a[href*="/bundle/"], [class*="cycle"] a, a[href*="cycle"]');
const cycleTextMatch = (doc.body && doc.body.textContent || '').match(/В\s+цикле\s*:\s*([^\n\r#]+)/i);
if (cycleLink && cycleLink.textContent.trim()) {
bookMeta.cycleName = cycleLink.textContent.replace(/^В\s+цикле\s*:\s*/i, '').trim();
} else if (cycleTextMatch) {
bookMeta.cycleName = cycleTextMatch[1].trim();
}
}
if (bookMeta.cycleNumber == null) {
const numBadge = doc.querySelector('[class*="priority"], [class*="cycle-number"], .lib-label');
const hashNum = (doc.body && doc.body.textContent || '').match(/#\s*(\d+)\s*(?:эксклюзив|в\s+цикле)?/i);
if (hashNum) {
const n = parseInt(hashNum[1], 10);
if (!isNaN(n) && n > 0 && n < 500) bookMeta.cycleNumber = n;
}
}
const isBlocked = doc.title && doc.title.includes('Один момент') || doc.querySelector('#challenge-error-text') || doc.querySelector('.cf-browser-verification');
if (!isBlocked && bookMeta.annotation) {
storageSet(cacheKey, {
tags: bookMeta.tags,
genres: bookMeta.genres,
annotation: bookMeta.annotation,
rawStats: bookMeta.rawStats,
author: bookMeta.author,
authorLink: bookMeta.authorLink,
cover: bookMeta.cover,
cycleName: bookMeta.cycleName,
cycleNumber: bookMeta.cycleNumber,
cycleBooksCount: bookMeta.cycleBooksCount,
cachedAt: Date.now()
});
}
updateSidebarUI();
await syncChapterLocks();
} catch (error) {
updateSidebarUI();
try { await syncChapterLocks(); } catch (e) {}
}
}
async function fetchChapterPage(chapterId, pageNum, expectedTitle) {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const bodyParams = new URLSearchParams();
bodyParams.append('chapterId', chapterId);
bodyParams.append('page', pageNum);
if (csrfToken) bodyParams.append('_csrf', csrfToken);
const apiRes = await fetch('/reader/get-page', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: bodyParams.toString()
});
if (apiRes.ok) {
const json = await apiRes.json();
if (json && json.status === 1 && json.data) {
if (json.idBook) bookMeta.idBook = json.idBook;
if (json.bookInLib !== undefined) bookMeta.inLibrary = json.bookInLib;
let html = cleanChapterHtml(json.data, expectedTitle);
const tp = parseInt(json.totalPages, 10) || parseInt(json.pagesCount, 10) || parseInt(json.pageCount, 10) || 1;
if (html && html.trim()) {
return { html: html, totalPages: tp };
}
return { html: html || '', totalPages: tp };
}
}
} catch (error) {
console.warn("API fetch error, using fallback", error);
}
const url = window.atBaseUrl + (window.atBaseUrl.includes('?') ? '&' : '?') + 'c=' + chapterId + '&p=' + pageNum;
function isChallengePage(doc) {
return doc.title && doc.title.includes('Один момент')
|| doc.querySelector('#challenge-error-text')
|| doc.querySelector('.cf-browser-verification');
}
return new Promise((resolve, reject) => {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.sandbox = 'allow-same-origin allow-scripts';
document.body.appendChild(iframe);
let checkInterval;
let timeout = setTimeout(() => {
clearInterval(checkInterval);
iframe.remove();
reject(new Error("Таймаут загрузки страницы"));
}, 10000);
iframe.src = url;
checkInterval = setInterval(() => {
try {
const doc = iframe.contentDocument || iframe.contentWindow.document;
if (doc) {
if (isChallengePage(doc)) {
clearInterval(checkInterval);
clearTimeout(timeout);
iframe.remove();
const err = new Error('Cloudflare challenge');
err.isChallenge = true;
reject(err);
return;
}
const textContainer = doc.querySelector('.jsReaderText, [data-test-id="reader-text"]');
if (textContainer && textContainer.querySelectorAll('p').length > 0) {
clearInterval(checkInterval);
clearTimeout(timeout);
let maxPage = 1;
doc.querySelectorAll('.pagination a, .js-pagination a, a[href*="&p="], a[href*="?p="]').forEach(paginationLink => {
const matchResult = paginationLink.href.match(/[?&]p=(\d+)/);
if (matchResult) maxPage = Math.max(maxPage, parseInt(matchResult[1], 10));
});
doc.querySelectorAll('[onclick*="Reader.goTo"]').forEach(goButton => {
const matchResult2 = goButton.getAttribute('onclick').match(/Reader\.goTo\((\d+)\)/);
if (matchResult2) maxPage = Math.max(maxPage, parseInt(matchResult2[1], 10));
});
let html = cleanChapterHtml(textContainer.parentElement.innerHTML, expectedTitle);
iframe.remove();
resolve({ html: html, totalPages: maxPage });
}
}
} catch (error) {}
}, 100);
});
}
// ============================================================
// Инъекция CSS: стили читалки, скрытие нативных элементов Litnet,
// оформление боковой панели, настроек, пагинации и диалога экспорта FB2
// ============================================================
function injectStyles() {
GM_addStyle(`
@import url('https://fonts.googleapis.com/css2?family=Alegreya:wght@400;500;700&family=Roboto:wght@400;500;700&family=Comfortaa:wght@400;700&display=swap');
:root {
--at-bg-color: ${userSettings.bgColor};
--at-text-color: ${userSettings.textColor};
--at-font-size: ${userSettings.fontSize}px;
--at-line-height: ${userSettings.lineHeight};
--at-text-width: ${userSettings.textWidth === '100%' ? '100%' : userSettings.textWidth + 'px'};
--at-font-family: '${userSettings.fontFamily}', sans-serif;
--at-hyphens: ${userSettings.hyphens ? 'auto' : 'none'};
}
.ln_topbar, .wrap, footer, .main_footer, .cookies-w, .pwa-banner, .pwa-banner-wrapper {
position: fixed !important;
top: -9999px !important;
left: -9999px !important;
visibility: hidden !important;
pointer-events: none !important;
z-index: -1 !important;
}
html, body { background-color: var(--at-bg-color) !important; margin: 0 !important; padding: 0 !important; width: 100% !important; min-height: 100vh !important; }
#at-app { display: flex; flex-direction: column; min-height: 100vh; font-family: 'Roboto', sans-serif; background-color: var(--at-bg-color); transition: background-color 0.2s ease; }
#at-header { position: fixed; top: 0; left: 0; right: 0; min-height: 50px; background: #ffffff; border-bottom: 1px solid #e9ecef; display: flex; align-items: center; justify-content: space-between; padding: 5px 20px; z-index: 1030; box-shadow: 0 1px 3px rgba(0,0,0,0.05); transition: transform 0.3s ease !important; }
#at-header.hidden { transform: translateY(-100%) !important; }
.at-header-group { display: flex; gap: 10px; align-items: center; }
.at-btn { background: #f8f9fa; border: 1px solid #ced4da; border-radius: 4px; padding: 6px 12px; cursor: pointer; color: #495057; font-size: 14px; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 6px; transition: all 0.2s; font-weight: 500; outline: none; white-space: nowrap; }
.at-btn:hover { background: #e2e6ea; color: #212529; text-decoration: none; }
.at-btn.btn-action { background: #e0f2fe; color: #0284c7; border-color: #bae6fd; }
.at-btn.btn-action:hover { background: #bae6fd; }
.at-btn-icon { padding: 6px 8px; font-size: 0; line-height: 0; }
.at-btn-icon svg { width: 18px; height: 18px; }
.at-btn-icon.active { color: #e11d48; border-color: #f43f5e; background: #ffe4e6; }
.at-btn-icon.lib-active { color: #0284c7; border-color: #bae6fd; background: #e0f2fe; }
.at-divider { width: 1px; height: 24px; background: #ced4da; margin: 0 5px; }
.at-header-title { font-weight: 700; color: #333; font-size: 15px; text-align: center; max-width: 100%; line-height:1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}
#at-sidebar-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.5); z-index: 1034; opacity: 0; pointer-events: none; transition: opacity 0.3s ease; }
#at-sidebar-overlay.visible { opacity: 1; pointer-events: auto; }
#at-sidebar { position: fixed; top: 0; left: -340px; bottom: 0; width: 340px; background: #fff; box-shadow: 2px 0 12px rgba(0,0,0,0.15); transition: left 0.3s ease; z-index: 1035; overflow-y: auto; }
#at-sidebar.open { left: 0; }
.at-sidebar-close { position: absolute; top: 15px; right: 15px; background: none; border: none; font-size: 28px; color: #999; cursor: pointer; line-height: 1; padding: 0; outline: none; transition: color 0.2s; }
.at-sidebar-close:hover { color: #333; }
.at-sidebar-inner { padding: 40px 18px 24px 18px; display: flex; flex-direction: column; gap: 14px; }
.at-meta-cover { width: 100%; max-width: 200px; margin: 0 auto; border-radius: 6px; display: block; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
.at-meta-title { font-size: 18px; font-weight: 700; color: #333; text-align: center; margin: 0; line-height: 1.3;}
.at-meta-author { font-size: 14px; font-weight: bold; color: #4582af; text-align: center; display: block; text-decoration: none; transition: color 0.2s; }
.at-meta-author:hover { color: #0284c7; text-decoration: underline; }
.at-meta-cycle { font-size: 13px; color: #6b7280; text-align: center; line-height: 1.35; }
.at-meta-stats { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; font-size: 12px; color: #555; background: #f8f9fa; padding: 10px; border-radius: 6px; }
.at-meta-stats span { background: #e9ecef; padding: 4px 10px; border-radius: 12px; font-weight: 500; cursor: help; }
.at-meta-stat-label { font-style: normal; color: #888; font-weight: 600; margin-right: 2px; }
.at-meta-tags { display: flex; flex-wrap: wrap; gap: 5px; justify-content: center; }
.at-meta-tag { background: #e0f2fe; color: #0284c7; padding: 4px 10px; border-radius: 12px; font-size: 11px; font-weight: bold;}
.at-meta-genres { display: flex; flex-wrap: wrap; gap: 5px; justify-content: center; color: #d9534f; font-size: 12px; font-weight: bold; }
.at-meta-anno { font-size: 13px; color: #555; line-height: 1.5; background: #fdfdfd; padding: 10px; border: 1px dashed #eee; border-radius: 4px; max-height: 250px; overflow-y: auto; text-align: justify; }
.at-toc-title { font-size: 13px; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid #eef1f4; padding-bottom: 8px; margin-top: 8px; }
.at-toc-list { display: flex; flex-direction: column; gap: 3px; }
.at-toc-item {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 9px 12px; color: #333; text-decoration: none; border-radius: 8px;
font-size: 14px; line-height: 1.35; transition: background 0.15s, color 0.15s, box-shadow 0.15s;
border: 1px solid transparent;
}
.at-toc-item:hover { background: #f3f6f9; }
.at-toc-item.active {
background: #eef5fb; color: #1e4e79; font-weight: 600;
border-color: #d4e6f5; box-shadow: inset 3px 0 0 #4582af;
}
.at-toc-item.locked {
color: #9aa3ad; background: #f7f8fa; border-color: #eef0f3;
cursor: pointer;
}
.at-toc-item.locked:hover { background: #f0f2f5; color: #7b8692; }
.at-toc-item.locked.active {
background: #f3f5f7; color: #6b7280; box-shadow: inset 3px 0 0 #c5ccd4;
}
.at-toc-title-text { flex: 1; min-width: 0; }
.at-toc-lock {
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: 50%;
background: #e8eaed; color: #8b949e;
}
.at-toc-lock svg { width: 14px; height: 14px; display: block; }
.at-toc-item.locked .at-toc-lock { background: #e5e7eb; color: #9ca3af; }
.at-stat-icon, .at-inline-icon { width: 14px; height: 14px; vertical-align: -2px; display: inline-block; }
.at-meta-stats span { display: inline-flex; align-items: center; gap: 4px; }
.at-meta-stats .at-stat-icon { color: #6b7280; }
#at-settings-panel { position: fixed; top: 50px; right: 20px; width: 320px; background: #fff; border: 1px solid #ced4da; border-top: none; border-radius: 0 0 6px 6px; box-shadow: 0 8px 16px rgba(0,0,0,0.1); padding: 20px; display: none; flex-direction: column; gap: 15px; z-index: 1035; }
#at-settings-panel.open { display: flex; }
.at-setting-row { display: flex; flex-direction: column; gap: 5px; }
.at-setting-row label { font-size: 13px; font-weight: 600; color: #555; display: flex; justify-content: space-between; align-items: center;}
.at-slider { width: 100%; cursor: pointer; accent-color: #4582af; }
.at-select, .at-color { width: 100%; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; outline: none; }
.at-checkbox { width: 18px; height: 18px; cursor: pointer; accent-color: #4582af; }
#at-main { flex: 1; margin-top: 50px; padding: 60px 20px 80px 20px; display: flex; flex-direction: column; align-items: center; }
#at-text-content { width: 100%; max-width: var(--at-text-width); box-sizing: border-box; font-family: var(--at-font-family); font-size: var(--at-font-size); line-height: var(--at-line-height); color: var(--at-text-color); text-align: justify; -webkit-hyphens: var(--at-hyphens); hyphens: var(--at-hyphens); transition: color 0.2s ease; }
.at-chapter-heading { text-align: center; font-weight: 400; font-size: 1.6em; margin-top: 0; margin-bottom: 1.5em; color: var(--at-text-color); }
#at-text-content p { text-indent: 1.5em; margin-top: 0; margin-bottom: 0.5em; }
.at-page-block { position: relative; display: block; }
@keyframes at-spin { 100% { transform: rotate(360deg); } }
.at-spinner { width: 40px; height: 40px; margin: 0 auto 15px auto; border: 4px solid #f3f3f3; border-top: 4px solid #4582af; border-radius: 50%; animation: at-spin 1s linear infinite; }
.at-loader-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 100px 20px; color: #777; font-size: 16px; font-weight: 500; font-family: 'Roboto', sans-serif;}
.at-pagination { width: 100%; max-width: var(--at-text-width); display: flex; justify-content: space-between; margin-top: 60px; padding-top: 20px; border-top: 1px solid rgba(128,128,128,0.3); }
.at-pagination a { color: #4582af; padding: 8px 16px; border: 1px solid #4582af; border-radius: 4px; text-decoration: none; font-family: 'Roboto', sans-serif;}
.at-pagination a.at-pag-locked { color: #999; border-color: #ccc; cursor: pointer; }
.at-paywall-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 2147483000; display: none; align-items: center; justify-content: center; padding: 20px; }
.at-paywall-overlay.open { display: flex; }
.at-paywall-dialog { position: relative; background: #f1f1f1; max-width: 420px; width: 100%; border-radius: 10px; padding: 28px 22px 22px; text-align: center; box-shadow: 0 12px 40px rgba(0,0,0,0.25); }
.at-paywall-close { position: absolute; top: 8px; right: 12px; border: none; background: transparent; font-size: 28px; line-height: 1; color: #888; cursor: pointer; }
.at-paywall-text { font-size: 15px; color: #333; line-height: 1.45; margin: 0 0 16px; }
.at-paywall-cover { width: 140px; max-width: 40%; border-radius: 6px; margin: 0 auto 12px; display: block; box-shadow: 0 4px 14px rgba(0,0,0,0.15); }
.at-paywall-chapter { font-size: 13px; color: #666; margin: 0 0 14px; }
.at-paywall-buy { display: block; width: 100%; background: #28a745; color: #fff; border: none; border-radius: 6px; padding: 12px 16px; font-size: 16px; font-weight: 600; cursor: pointer; margin-bottom: 10px; }
.at-paywall-buy:hover { background: #218838; }
.at-paywall-book { display: block; width: 100%; box-sizing: border-box; background: #fff; color: #333; border: 1px solid #ccc; border-radius: 6px; padding: 12px 16px; font-size: 15px; text-decoration: none; }
.ate-dlg-overlay { position:fixed; top:0; left:0; bottom:0; right:0; background-color:rgba(0,0,0,0.5); z-index:1055; display:flex; align-items:center; justify-content:center; opacity:0; pointer-events:none; transition: opacity 0.2s;}
.ate-dlg-overlay.open { opacity:1; pointer-events:auto; }
.ate-dialog { background:#fff; width: 90%; max-width: 550px; height: 80vh; max-height: 600px; border-radius: 6px; display:flex; flex-direction:column; box-shadow:0 5px 15px rgba(0,0,0,0.5); overflow:hidden; font-family: 'Roboto', sans-serif; }
.ate-title { flex: 0 0 auto; display:flex; align-items:center; justify-content:space-between; padding: 12px 15px; background: #edf1f2; border-bottom: 1px solid #e5e5e5; color: #66757f; font-weight:bold; font-size: 15px;}
.ate-close-btn { cursor:pointer; background:none; border:none; font-size:24px; line-height:1; color:#000; opacity:0.4; padding:0; outline:none; transition: opacity 0.2s; }
.ate-close-btn:hover { opacity:0.9; }
.ate-form-body { display:flex; flex-direction:column; flex:1; overflow:hidden; padding: 15px; }
.ate-page { display:flex; flex-direction:column; flex:1; overflow:hidden; gap: 15px; }
.ate-page.hidden { display: none !important; }
.ate-fieldset { border: 1px solid #bbb; border-radius: 6px; padding: 10px; margin: 0; display:flex; flex-direction:column; flex:1; overflow:hidden;}
.ate-legend { font-size: 13px; font-weight:bold; color: #333; margin: 0; padding: 0 5px; width:auto; border:none; line-height:1;}
.ate-note { font-size: 12px; color: #66757f; margin-bottom: 10px; line-height:1.4; }
.ate-chapter-list { flex:1; overflow-y:auto; border: 1px solid #eee; padding: 5px; border-radius:4px; display:flex; flex-direction:column; gap:4px; }
.ate-toolbar { display:flex; align-items:center; justify-content:space-between; padding-top:10px; border-top:1px solid #bbb; margin-top:10px; font-size: 13px; color:#333; }
.ate-group-select { background:none; border:1px solid #ccc; border-radius:4px; padding:4px 8px; cursor:pointer; font-size:12px; color:#555; }
.ate-settings-list { display:flex; flex-direction:column; gap: 8px; margin-top: 10px; }
.ate-checkbox-wrap { display:flex; align-items:center; font-size: 13px; color: #333; cursor:pointer; gap:8px;}
.ate-checkbox-wrap input { width: 16px; height: 16px; cursor:pointer; accent-color: #4582af; }
.ate-log { flex:1; overflow-y:auto; background:#1e1e1e; color:#ccc; font-family:monospace; font-size:13px; padding:10px; border-radius:4px; line-height:1.5; white-space:pre-wrap; border: 1px solid #333; }
.ate-buttons { display:flex; gap: 10px; justify-content:flex-end; border-top: 1px solid #eee; padding-top: 15px; margin-top: auto; }
.ate-btn-main { background: #5cb85c; color: #fff; border: 1px solid #4cae4c; border-radius:4px; padding: 8px 16px; cursor:pointer; font-weight:500; font-size:14px; outline:none; transition:0.2s;}
.ate-btn-main:hover { background: #449d44; }
.ate-btn-main:disabled { background: #a5d8a5; cursor:not-allowed; }
.ate-btn-alt { background: #fff; color: #333; border: 1px solid #ccc; border-radius:4px; padding: 8px 16px; cursor:pointer; font-size:14px; outline:none; transition:0.2s;}
`);
}
// ============================================================
// Обновление боковой панели: рендер обложки, названия книги, автора, жанров,
// тегов, статистики и аннотации из bookMeta
// ============================================================
function updateSidebarUI() {
const metaContainer = document.getElementById('at-meta-container');
if (!metaContainer) return;
let statsHtml = '';
if (bookMeta.rawStats && bookMeta.rawStats.length > 0) {
statsHtml = `<div class="at-meta-stats">` + bookMeta.rawStats.map(stat => {
if (stat && typeof stat === 'object') {
const title = (stat.title || stat.label || stat.value || '').replace(/"/g, '"');
const icon = stat.icon || '';
let shown = '';
if (stat.label === 'Статус') {
shown = atIcon(icon || 'note', 'at-stat-icon');
} else if (stat.label === 'Обновлено') {
const d = formatStatDate(stat.value || '');
shown = atIcon(icon || 'calendar', 'at-stat-icon') + ' ' + d;
} else {
shown = (icon ? atIcon(icon, 'at-stat-icon') + ' ' : '') + (stat.value || '');
}
return `<span title="${title}">${shown}</span>`;
}
return `<span title="${String(stat).replace(/"/g, '"')}">${stat}</span>`;
}).join('') + `</div>`;
}
let genresHtml = '';
if (bookMeta.genres && bookMeta.genres.length > 0) {
genresHtml = `<div class="at-meta-genres">` + bookMeta.genres.join(', ') + `</div>`;
}
let tagsHtml = '';
if (bookMeta.tags && bookMeta.tags.length > 0) {
tagsHtml = `<div class="at-meta-tags">` + bookMeta.tags.map(tag => `<span class="at-meta-tag">${tag}</span>`).join('') + `</div>`;
}
let cycleHtml = '';
if (bookMeta.cycleName) {
const numPart = bookMeta.cycleNumber
? ` · ${bookMeta.cycleNumber}` + (bookMeta.cycleBooksCount ? `/${bookMeta.cycleBooksCount}` : '')
: (bookMeta.cycleBooksCount ? ` · ${bookMeta.cycleBooksCount} кн.` : '');
cycleHtml = `<div class="at-meta-cycle" title="Цикл / серия">${atIcon('bookmark')} ${bookMeta.cycleName.replace(/</g, '<')}${numPart}</div>`;
}
metaContainer.innerHTML = `
${bookMeta.cover ? `<img src="${bookMeta.cover}" class="at-meta-cover">` : ''}
<h2 class="at-meta-title">${bookMeta.title}</h2>
<a href="${bookMeta.authorLink}" target="_self" class="at-meta-author" id="at-author-link">${bookMeta.author}</a>
${cycleHtml}
${genresHtml}
${statsHtml}
${tagsHtml}
${bookMeta.annotation ? `<div class="at-meta-anno">${bookMeta.annotation}</div>` : ''}
`;
const authorLinkEl = document.getElementById('at-author-link');
if (authorLinkEl) {
authorLinkEl.onclick = (event) => {
event.stopPropagation();
window.location.href = bookMeta.authorLink;
};
}
}
// ============================================================
// Создание DOM-структуры интерфейса читалки: шапка с кнопками, боковая панель
// с оглавлением и метаданными, панель настроек, область контента, диалог FB2-экспорта
// ============================================================
function renderInterface() {
document.body.style.background = 'none';
let themeName = 'Белая';
switch(userSettings.themeStep) {
case 1: themeName = 'Белая'; break;
case 2: themeName = 'Сепия'; break;
case 3: themeName = 'Серая'; break;
case 4: themeName = 'Темная'; break;
case 5: themeName = 'Черная'; break;
}
const app = document.createElement('div');
app.id = 'at-app';
app.innerHTML = `
<header id="at-header">
<div class="at-header-group">
<button type="button" id="at-btn-toc" class="at-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line></svg>
Оглавление
</button>
<a href="${bookMeta.url}" class="at-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
К книге
</a>
<button type="button" id="at-btn-download" class="at-btn btn-action">Скачать FB2</button>
</div>
<div class="at-header-group" style="flex: 1; justify-content: center; overflow: hidden; padding: 0 15px;">
<span class="at-header-title" title="${bookMeta.headerTitle}">${bookMeta.headerTitle}</span>
</div>
<div class="at-header-group">
<button type="button" id="at-btn-like" class="at-btn at-btn-icon ${bookMeta.isLiked ? 'active' : ''}" title="Мне нравится">
<svg width="18" height="18" viewBox="0 0 24 24" fill="${bookMeta.isLiked ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>
<span id="at-like-count" style="margin-left:5px; font-size:13px; font-weight:bold;">${bookMeta.likeCount > 0 ? bookMeta.likeCount : ''}</span>
</button>
<button type="button" id="at-btn-library" class="at-btn at-btn-icon" title="Добавить в библиотеку"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path></svg></button>
<button type="button" id="at-btn-reward" class="at-btn at-btn-icon" title="Наградить автора"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 12 20 22 4 22 4 12"></polyline><rect x="2" y="7" width="20" height="5"></rect><line x1="12" y1="22" x2="12" y2="7"></line><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path></svg></button>
<button type="button" id="at-btn-complain" class="at-btn at-btn-icon" title="Пожаловаться"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path><line x1="4" y1="22" x2="4" y2="15"></line></svg></button>
<div class="at-divider"></div>
<button type="button" id="at-btn-refresh" class="at-btn" title="Очистить кэш и обновить главу">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.59-9.21L21.5 8"></path></svg>
</button>
<button type="button" id="at-btn-settings" class="at-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
</button>
</div>
</header>
<div id="at-sidebar-overlay"></div>
<aside id="at-sidebar">
<button type="button" id="at-btn-close-sidebar" class="at-sidebar-close" title="Закрыть">×</button>
<div class="at-sidebar-inner">
<div id="at-meta-container">
<h2 class="at-meta-title">${bookMeta.title}</h2>
<span style="color:#777; font-size:12px; text-align:center; display:block;">Загрузка информации...</span>
</div>
<div class="at-toc-title">Оглавление</div>
<div class="at-toc-list">${tocHtml}</div>
</div>
</aside>
<div id="at-settings-panel">
<div class="at-setting-row">
<label>Тема (Фон) <span id="at-val-theme">${themeName}</span></label>
<input type="range" class="at-slider" id="at-inp-theme" min="1" max="5" step="1" value="${userSettings.themeStep}">
</div>
<div class="at-setting-row">
<label>Размер шрифта <span id="at-val-fs">${userSettings.fontSize}px</span></label>
<input type="range" class="at-slider" id="at-inp-fs" min="14" max="36" value="${userSettings.fontSize}">
</div>
<div class="at-setting-row">
<label>Ширина текста <span id="at-val-tw">${userSettings.textWidth === '100%' ? 'Макс' : userSettings.textWidth + 'px'}</span></label>
<input type="range" class="at-slider" id="at-inp-tw" min="500" max="1400" step="50" value="${userSettings.textWidth === '100%' ? 1400 : userSettings.textWidth}">
</div>
<div class="at-setting-row">
<label>Высота строк <span id="at-val-lh">${userSettings.lineHeight}</span></label>
<input type="range" class="at-slider" id="at-inp-lh" min="1.0" max="2.5" step="0.1" value="${userSettings.lineHeight}">
</div>
<div class="at-setting-row">
<label>Перенос слов
<input type="checkbox" class="at-checkbox" id="at-inp-hy" ${userSettings.hyphens ? 'checked' : ''}>
</label>
</div>
<div class="at-setting-row">
<label>Шрифт</label>
<select class="at-select" id="at-inp-ff">
<option value="Roboto" ${userSettings.fontFamily === 'Roboto' ? 'selected' : ''}>Roboto</option>
<option value="Alegreya" ${userSettings.fontFamily === 'Alegreya' ? 'selected' : ''}>Alegreya</option>
<option value="Comfortaa" ${userSettings.fontFamily === 'Comfortaa' ? 'selected' : ''}>Comfortaa</option>
<option value="Arial" ${userSettings.fontFamily === 'Arial' ? 'selected' : ''}>Arial</option>
<option value="Georgia" ${userSettings.fontFamily === 'Georgia' ? 'selected' : ''}>Georgia</option>
</select>
</div>
<div class="at-flex-row" style="display:flex; gap:10px;">
<div class="at-setting-row" style="flex:1;">
<label>Текст</label>
<input type="color" class="at-color" id="at-inp-tc" value="${userSettings.textColor}">
</div>
<div class="at-setting-row" style="flex:1;">
<label>Фон</label>
<input type="color" class="at-color" id="at-inp-bc" value="${userSettings.bgColor}">
</div>
</div>
</div>
<main id="at-main">
<div id="at-text-content" lang="ru"></div>
<div class="at-pagination">
${prevChapterUrl ? `<a href="${prevChapterUrl}">← <span>${prevChapterTitle}</span></a>` : '<div></div>'}
${nextChapterUrl ? `<a href="${nextChapterUrl}"><span>${nextChapterTitle}</span> →</a>` : '<div></div>'}
</div>
</main>
<div id="ate-overlay" class="ate-dlg-overlay">
<div class="ate-dialog">
<div class="ate-title"><span>Формирование файла FB2</span><button id="ate-close" class="ate-close-btn">×</button></div>
<div class="ate-form-body">
<div id="ate-page-1" class="ate-page">
<fieldset class="ate-fieldset">
<legend class="ate-legend">Главы для выгрузки</legend>
<div class="ate-note">Выберите главы для скачивания. Все внутренние страницы глав будут загружены автоматически.</div>
<div class="ate-chapter-list" id="ate-chapter-container"></div>
<div class="ate-toolbar">
<span>Выбрано глав: <strong id="ate-selected-count">0</strong> из <strong id="ate-total-count">0</strong></span>
<button id="ate-select-all" class="ate-group-select">✔ Выделить всё/ничего</button>
</div>
</fieldset>
<div class="ate-settings-list">
<label class="ate-checkbox-wrap"><input type="checkbox" id="ate-chk-notes" checked> Добавить аннотацию книги</label>
<label class="ate-checkbox-wrap"><input type="checkbox" id="ate-chk-cover" checked> Вшить обложку книги</label>
<label class="ate-checkbox-wrap"><input type="checkbox" id="ate-chk-img" checked> Загружать иллюстрации в главах</label>
<label class="ate-checkbox-wrap"><input type="checkbox" id="ate-chk-cache" checked> Использовать кеш глав</label>
</div>
</div>
<div id="ate-page-2" class="ate-page hidden"><div id="ate-log-container" class="ate-log"></div></div>
<div class="ate-buttons">
<button id="ate-btn-cancel" class="ate-btn-alt">Закрыть</button>
<button id="ate-btn-action" class="ate-btn-main">Продолжить</button>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(app);
bindEvents();
}
// ============================================================
// Обновление иконок кнопок: состояние «в библиотеке» и «нравится/не нравится»
// ============================================================
function updateLibraryIcon() {
const btn = document.getElementById('at-btn-library');
if (!btn) return;
if (bookMeta.inLibrary) {
btn.classList.add('lib-active');
btn.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path></svg>`;
btn.title = 'В библиотеке (Нажмите, чтобы удалить)';
} else {
btn.classList.remove('lib-active');
btn.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path></svg>`;
btn.title = 'Добавить в библиотеку';
}
}
// ============================================================
// Обновление иконки кнопки «Нравится» и счётчика лайков
// ============================================================
function updateLikeIcon() {
const btnLike = document.getElementById('at-btn-like');
const countSpan = document.getElementById('at-like-count');
if (!btnLike) return;
if (bookMeta.isLiked) {
btnLike.classList.add('active');
btnLike.querySelector('svg').setAttribute('fill', 'currentColor');
} else {
btnLike.classList.remove('active');
btnLike.querySelector('svg').setAttribute('fill', 'none');
}
if (countSpan) countSpan.textContent = bookMeta.likeCount > 0 ? bookMeta.likeCount : '';
}
// ============================================================
// Настройка диалога экспорта в FB2: выбор глав, настройки (аннотация, обложка, иллюстрации),
// пошаговый процесс загрузки контента, парсинга и генерации FB2-файла с логами
// ============================================================
function bindDownloadModalEvents() {
const overlay = document.getElementById('ate-overlay');
const btnOpen = document.getElementById('at-btn-download');
const btnCloseX = document.getElementById('ate-close');
const btnCancel = document.getElementById('ate-btn-cancel');
const btnAction = document.getElementById('ate-btn-action');
const page1 = document.getElementById('ate-page-1');
const page2 = document.getElementById('ate-page-2');
const chapterContainer = document.getElementById('ate-chapter-container');
const selectedCount = document.getElementById('ate-selected-count');
const totalCount = document.getElementById('ate-total-count');
const btnSelectAll = document.getElementById('ate-select-all');
const logContainer = document.getElementById('ate-log-container');
let isDownloading = false;
let isPaused = false;
let resumeResolver = null;
let generatedBlobUrl = null;
let generatedFilename = '';
function waitForResume() {
isPaused = true;
return new Promise(resolve => { resumeResolver = resolve; });
}
function resumeDownload() {
if (!isPaused) return;
isPaused = false;
if (resumeResolver) {
const r = resumeResolver;
resumeResolver = null;
r();
}
}
async function fetchPageWithChallengeGuard(chapterId, pageNum, expectedTitle, logLine) {
while (true) {
try {
return await fetchChapterPage(chapterId, pageNum, expectedTitle);
} catch (err) {
if (err && err.isChallenge) {
const chapterUrl = window.atBaseUrl + '?c=' + chapterId + '&p=' + pageNum;
writeLog('Обнаружена проверка Cloudflare. Открываю вкладку — пройдите проверку и нажмите «Продолжить».', '#f0ad4e');
window.open(chapterUrl, '_blank', 'noopener');
btnAction.textContent = 'Продолжить после проверки';
btnAction.style.background = '#f0ad4e';
btnAction.disabled = false;
await waitForResume();
writeLog('Возобновляю загрузку...', '#5bc0de');
btnAction.textContent = 'Прервать';
btnAction.style.background = '#d9534f';
continue;
}
throw err;
}
}
}
const renderChapterCheckboxList = () => {
chapterContainer.innerHTML = '';
totalCount.textContent = availableChapters.length;
availableChapters.forEach((chapter) => {
const row = document.createElement('label');
row.className = 'ate-checkbox-wrap';
row.style.padding = '4px';
row.style.borderBottom = '1px solid #f5f5f5';
if (chapter.locked) {
row.style.opacity = '0.55';
row.title = 'Глава недоступна без покупки — не включается в выгрузку';
}
const chk = document.createElement('input');
chk.type = 'checkbox';
chk.className = 'ate-chapter-chk';
chk.checked = !chapter.locked;
chk.disabled = !!chapter.locked;
chk.dataset.id = chapter.id;
chk.dataset.idx = chapter.idx;
if (chapter.locked) chk.dataset.locked = '1';
row.appendChild(chk);
const labelSpan = document.createElement('span');
labelSpan.className = 'ate-chapter-label';
labelSpan.innerHTML = (chapter.locked ? atIcon('lock', 'at-inline-icon') + ' ' : '') + `${chapter.idx}. ${chapter.title}`;
row.appendChild(labelSpan);
chapterContainer.appendChild(row);
});
updateCount();
};
const updateCount = () => {
const checked = document.querySelectorAll('.ate-chapter-chk:checked').length;
selectedCount.textContent = checked;
btnAction.disabled = checked === 0;
};
chapterContainer.addEventListener('change', updateCount);
btnSelectAll.onclick = () => {
const checkboxes = Array.from(document.querySelectorAll('.ate-chapter-chk')).filter(c => !c.disabled);
const allChecked = checkboxes.length > 0 && checkboxes.every(checkboxItem => checkboxItem.checked);
checkboxes.forEach(checkboxItem => checkboxItem.checked = !allChecked);
updateCount();
};
const closeModal = () => {
if (isDownloading) return;
isPaused = false;
resumeResolver = null;
overlay.classList.remove('open');
setTimeout(() => {
page1.classList.remove('hidden');
page2.classList.add('hidden');
logContainer.innerHTML = '';
btnAction.textContent = 'Продолжить';
btnAction.style.background = '';
btnCancel.style.display = '';
}, 300);
};
btnOpen.onclick = () => {
renderChapterCheckboxList();
overlay.classList.add('open');
};
btnCloseX.onclick = closeModal;
btnCancel.onclick = closeModal;
overlay.onclick = (event) => { if (event.target === overlay) closeModal(); };
function writeLog(text, color = '#ccc') {
const line = document.createElement('div');
line.style.color = color;
line.textContent = text;
logContainer.appendChild(line);
logContainer.scrollTop = logContainer.scrollHeight;
return line;
}
btnAction.onclick = async () => {
if (isPaused) {
resumeDownload();
return;
}
if (btnAction.textContent === 'Продолжить') {
page1.classList.add('hidden');
page2.classList.remove('hidden');
btnCancel.style.display = 'none';
btnAction.textContent = 'Прервать';
btnAction.style.background = '#d9534f';
isDownloading = true;
writeLog('Подготовка структуры FB2...', '#5bc0de');
let safeAuthor = 'Автор';
try {
const doc = new FB2Document();
doc.bookTitle = bookMeta.title || 'Неизвестная книга';
if (typeof FB2GenreList !== 'undefined' && Array.isArray(FB2GenreList._keys)) {
const litnetKeys = [
[ "sf_fantasy", "фэнтези", ["фэнтези"] ],
[ "sf_heroic", "боевое фэнтези", ["боевое"] ],
[ "sf_history", "историческое фэнтези", ["историческое"] ],
[ "sf_fantasy_city", "городское фэнтези", ["городское"] ],
[ "sf_action", "приключенческое фэнтези", ["приключенческое"] ],
[ "sf_humor", "юмористическое фэнтези", ["юмористическое"] ],
[ "sf_fantasy", "бытовое фэнтези", ["бытовое"] ],
[ "sf_epic", "эпическое фэнтези", ["эпическое"] ],
[ "sf_fantasy", "магическая академия", ["академия"] ],
[ "fairy_fantasy", "азиатское фэнтези", ["азиатское", "уся", "wuxia"] ],
[ "russian_fantasy", "славянское фэнтези", ["славянское"] ],
[ "sf_fantasy", "тёмное фэнтези", ["темное", "тёмное"] ],
[ "fairy_fantasy", "уся (wuxia)", ["уся", "wuxia"] ],
[ "sf_fantasy", "бояръ-аниме", ["бояръ"] ],
[ "love", "любовные романы", ["любовные"] ],
[ "love_sf", "любовное фэнтези", ["любовное"] ],
[ "love_sf", "любовная фантастика", ["любовная"] ],
[ "love_short", "короткий любовный роман", ["короткий"] ],
[ "love_history", "исторический любовный роман", ["исторический"] ],
[ "love_contemporary", "современный любовный роман", ["современный"] ],
[ "love_detective", "мистический любовный роман", ["мистический"] ],
[ "love_detective", "криминальный любовный роман", ["криминальный"] ],
[ "love_contemporary", "романы о неверности", ["неверности"] ],
[ "love_contemporary", "романтическая комедия", ["комедия"] ],
[ "love_contemporary", "служебный роман", ["служебный"] ],
[ "love_detective", "остросюжетный любовный роман", ["остросюжетный"] ],
[ "love_contemporary", "студенческий роман", ["студенческий"] ],
[ "sf", "фантастика", ["фантастика"] ],
[ "sf_action", "боевая фантастика", ["боевая"] ],
[ "sf", "научная фантастика", ["научная"] ],
[ "sf_space", "космическая фантастика", ["космическая"] ],
[ "sf_history", "альтернативная история", ["альтернативная"] ],
[ "sf_postapocalyptic", "постапокалипсис", ["постапокалипсис"] ],
[ "sf_social", "антиутопия", ["антиутопия"] ],
[ "sf_cyberpunk", "киберпанк", ["киберпанк"] ],
[ "sf_humor", "юмористическая фантастика", ["юмористическая"] ],
[ "sf_realrpg", "реалрпг", ["реалрпг", "realrpg"] ],
[ "sf_litrpg", "литрпг", ["литрпг", "litrpg"] ],
[ "child_prose", "молодежная проза", ["молодежная", "молодёжная"] ],
[ "sf_mystic", "молодежная мистика", ["мистика"] ],
[ "child_prose", "подростковая проза", ["подростковая"] ],
[ "popadanec", "попаданцы", ["попаданец"] ],
[ "popadanec", "попаданцы во времени", ["времени"] ],
[ "popadanec", "попаданцы в другие миры", ["миры"] ],
[ "love_erotica", "эротика", ["эротика"] ],
[ "love_erotica", "романтическая эротика", ["романтическая"] ],
[ "love_erotica", "эротическое фэнтези", ["эротическое"] ],
[ "love_erotica", "эротическая фантастика", ["эротическая"] ],
[ "love_erotica", "эротический фанфик", ["эротический"] ],
[ "love_hard", "жесткая эротика", ["жесткая", "жёсткая"] ],
[ "fanfiction", "фанфик", ["фанфик", "фанфики"] ],
[ "fanfiction", "фанфики по фильмам", ["фильмам"] ],
[ "fanfiction", "фанфики по книгам", ["книгам"] ],
[ "fanfiction", "манга фанфики", ["манга"] ],
[ "fanfiction", "аниме фанфики", ["аниме"] ],
[ "detective", "детективы", ["детектив", "детективы"] ],
[ "det_history", "исторический детектив", ["исторический"] ],
[ "det_irony", "женский детектив", ["женский"] ],
[ "det_crime", "криминальный детектив", ["криминальный"] ],
[ "det_classic", "классический детектив", ["классический"] ],
[ "det_police", "полицейский детектив", ["полицейский"] ],
[ "sf_detective", "фантастический детектив", ["фантастический"] ],
[ "sf_detective", "магический детектив", ["магический"] ],
[ "prose", "проза", ["проза"] ],
[ "prose_contemporary", "современная проза", ["современная"] ],
[ "prose_history", "исторический роман", ["исторический"] ],
[ "prose", "мужской роман", ["мужской"] ],
[ "prose", "женский роман", ["женский"] ],
[ "drama", "драма", ["драма"] ],
[ "adventure", "приключенческий роман", ["приключенческий"] ],
[ "det_action", "боевик", ["боевик"] ],
[ "humor", "юмор", ["юмор"] ],
[ "nonfiction", "нон-фикшн", ["нон-фикшн", "нонфикшн"] ],
[ "prose_neformatny", "неформат", ["неформат"] ],
[ "children", "детская литература", ["детская"] ],
[ "religion_esoterics", "эзотерика", ["эзотерика"] ],
[ "popular_business", "бизнес-литература", ["бизнес"] ],
[ "religion_self", "развитие личности", ["личности"] ],
[ "network_literature", "разное", ["разное"] ],
[ "thriller", "триллеры", ["триллер", "триллеры"] ],
[ "thriller", "криминальный триллер", ["криминальный"] ],
[ "thriller", "политический триллер", ["политический"] ],
[ "thriller", "мистический триллер", ["мистический"] ],
[ "thriller", "психологический триллер", ["психологический"] ],
[ "sf_mystic", "мистика/ужасы", ["мистика", "ужасы"] ],
[ "sf_mystic", "паранормальное", ["паранормальное"] ],
[ "gothic_novel", "готика", ["готика"] ],
[ "sf_horror", "хоррор", ["хоррор"] ],
[ "sf_horror", "фолк-хоррор", ["фолк"] ],
[ "sf_horror", "научно-фантастический хоррор", ["научно-фантастический"] ],
[ "story", "мини", ["мини"] ],
[ "sf_fantasy", "мини: фэнтези", ["мини"] ],
[ "sf", "мини: фантастика", ["мини"] ],
[ "love_short", "мини: любовный роман", ["мини"] ],
[ "love_erotica", "мини: эротика", ["мини"] ],
[ "detective", "мини: детектив", ["мини"] ],
[ "child_prose", "мини: молодежная проза", ["мини"] ],
[ "love_sf", "мини: любовное фэнтези", ["мини"] ],
[ "love_sf", "мини: любовная фантастика", ["мини"] ],
[ "love_contemporary", "мини: современный любовный роман", ["мини"] ],
[ "popadanec", "мини: попаданцы", ["мини"] ]
];
litnetKeys.forEach(lk => {
if (!FB2GenreList._keys.some(k => k[1] === lk[1])) {
FB2GenreList._keys.push(lk);
}
});
}
if (bookMeta.genres && bookMeta.genres.length > 0) {
doc.genres = [ bookMeta.genres.join(", ") ];
} else {
doc.genres = ["network_literature"];
}
const keywordParts = [];
if (bookMeta.genres && bookMeta.genres.length) {
keywordParts.push(...bookMeta.genres);
}
if (bookMeta.tags && bookMeta.tags.length) {
keywordParts.push(...bookMeta.tags);
}
doc.keywords = keywordParts.filter((value, index, array) => array.indexOf(value) === index);
safeAuthor = (bookMeta.author || 'Автор').replace(/[\/\\?%*:|"<>]/g, '').trim();
doc.bookAuthors.push(new FB2Author(safeAuthor));
doc.sourceURL = bookMeta.url;
if (bookMeta.idBook) doc.bookId = String(bookMeta.idBook);
const dateStr = bookMeta.finishedAt || bookMeta.updatedAt || bookMeta.publishedAt || '';
if (dateStr) {
const parsed = new Date(dateStr);
if (!isNaN(parsed.getTime())) doc.bookDate = parsed;
}
if (bookMeta.cycleName) {
doc.sequence = {
name: bookMeta.cycleName,
number: bookMeta.cycleNumber || undefined
};
}
if (bookMeta.annotation && document.getElementById('ate-chk-notes').checked) {
const li = writeLog('Парсинг аннотации... ');
try {
doc.bindParser("a", new FB2AnnotationParser());
const dom = new DOMParser().parseFromString(bookMeta.annotation, "text/html");
doc.parse("a", dom.body);
li.innerHTML += '<span style="color:#5cb85c">ok</span>';
} catch(error) {
li.innerHTML += `<span style="color:#d9534f">ошибка (${error.message})</span>`;
} finally {
doc.bindParser();
}
}
if (bookMeta.cover && document.getElementById('ate-chk-cover').checked) {
const li = writeLog('Скачивание обложки... ');
try {
const img = new FB2Image(bookMeta.cover);
await img.load();
img.id = "cover" + img.suffix();
doc.coverpage = img;
doc.binaries.push(img);
li.innerHTML += '<span style="color:#5cb85c">ok</span>';
} catch(error) {
li.innerHTML += '<span style="color:#d9534f">ошибка</span>';
}
}
writeLog('---');
doc.bindParser("c", new FB2ChapterParser());
const selectedChapters = document.querySelectorAll('.ate-chapter-chk:checked');
const useCache = document.getElementById('ate-chk-cache').checked;
for (let chapterIndex = 0; chapterIndex < selectedChapters.length; chapterIndex++) {
if (!isDownloading) break;
const chapterCheckbox = selectedChapters[chapterIndex];
const availableChapter = availableChapters.find(availableItem => availableItem.id === chapterCheckbox.dataset.id);
if (!availableChapter || availableChapter.locked || chapterCheckbox.dataset.locked === '1') {
writeLog(`Пропуск (недоступна): ${availableChapter ? availableChapter.title : chapterCheckbox.dataset.id}`, '#f0ad4e');
continue;
}
const logLine = writeLog(`Загрузка ${chapterIndex+1}/${selectedChapters.length}: ${availableChapter.title}... `);
try {
let chapterFullHtml = '';
let chapterTotalPages = 1;
let currentChapterCache = {};
let cachedTotalPages = 1;
if (useCache) {
const cached = loadChapterCache(availableChapter.id, window.atCurrentLastEdit);
currentChapterCache = cached ? cached.pages : {};
cachedTotalPages = cached ? cached.totalPages : 1;
}
let maxPageFromPagination = 1;
document.querySelectorAll('.reader-pagination a, .reader-pagination [onclick*="Reader.goTo"]').forEach(el => {
const onclickMatch = el.getAttribute('onclick')?.match(/Reader\.goTo\((\d+)\)/);
if (onclickMatch) maxPageFromPagination = Math.max(maxPageFromPagination, parseInt(onclickMatch[1], 10));
else {
const hrefMatch = el.href?.match(/[?&]p=(\d+)/);
if (hrefMatch) maxPageFromPagination = Math.max(maxPageFromPagination, parseInt(hrefMatch[1], 10));
}
});
{
const pageKeys = Object.keys(currentChapterCache)
.map(k => parseInt(k, 10))
.filter(n => !isNaN(n) && n > 0);
const maxCachedPage = pageKeys.length ? Math.max(...pageKeys) : 0;
chapterTotalPages = Math.max(
chapterTotalPages,
cachedTotalPages || 1,
maxCachedPage
);
if (String(availableChapter.id) === String(window.atCurrentChapterId)) {
chapterTotalPages = Math.max(chapterTotalPages, maxPageFromPagination || 1);
}
}
const cachedPageCount = Object.keys(currentChapterCache)
.filter(k => currentChapterCache[k] && currentChapterCache[k] !== 'loading').length;
if (useCache && currentChapterCache[1] && currentChapterCache[1] !== 'loading') {
chapterFullHtml += currentChapterCache[1];
try {
const probe = await fetchPageWithChallengeGuard(availableChapter.id, 1, availableChapter.title, logLine);
chapterTotalPages = Math.max(chapterTotalPages, probe.totalPages || 1);
} catch (probeErr) {
console.warn('Litnet Reader: probe totalPages failed', probeErr);
}
} else {
const data1 = await fetchPageWithChallengeGuard(availableChapter.id, 1, availableChapter.title, logLine);
chapterFullHtml += data1.html || '';
chapterTotalPages = Math.max(chapterTotalPages, data1.totalPages || 1);
if (useCache && data1.html) {
currentChapterCache[1] = data1.html;
}
}
for (let pageNumber = 2; pageNumber <= chapterTotalPages; pageNumber++) {
if (!isDownloading) break;
logLine.innerHTML = `Загрузка ${chapterIndex+1}/${selectedChapters.length}: ${availableChapter.title} (стр ${pageNumber}/${chapterTotalPages})... `;
let pageHtml = '';
if (useCache && currentChapterCache[pageNumber] && currentChapterCache[pageNumber] !== 'loading') {
pageHtml = currentChapterCache[pageNumber];
chapterFullHtml += pageHtml;
} else {
try {
const pageData = await fetchPageWithChallengeGuard(availableChapter.id, pageNumber, null, logLine);
pageHtml = pageData.html || '';
if (pageData.totalPages && pageData.totalPages > chapterTotalPages) {
chapterTotalPages = pageData.totalPages;
}
if (useCache && pageHtml) {
currentChapterCache[pageNumber] = pageHtml;
}
chapterFullHtml += pageHtml;
} catch (pageErr) {
logLine.innerHTML = `Загрузка ${chapterIndex+1}/${selectedChapters.length}: ${availableChapter.title} (стр ${pageNumber})... <span style="color:#d9534f">ошибка</span>`;
}
await new Promise(resolveTimeout => setTimeout(resolveTimeout, 50));
}
}
if (chapterFullHtml && chapterFullHtml.trim().length > 0) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = chapterFullHtml;
doc.parse("c", tempDiv, availableChapter.title);
logLine.innerHTML = `Загрузка ${chapterIndex+1}/${selectedChapters.length}: ${availableChapter.title}... <span style="color:#5cb85c">ok</span>`;
} else {
logLine.innerHTML = `Загрузка ${chapterIndex+1}/${selectedChapters.length}: ${availableChapter.title}... <span style="color:#d9534f">ошибка (пусто)</span>`;
}
if (useCache && Object.keys(currentChapterCache).length > 0) {
saveChapterCache(availableChapter.id, currentChapterCache, window.atCurrentLastEdit, chapterTotalPages);
}
} catch (error) {
logLine.innerHTML = `Загрузка ${chapterIndex+1}/${selectedChapters.length}: ${availableChapter.title}... <span style="color:#d9534f">ошибка (${error.message})</span>`;
}
}
doc.bindParser();
if (isDownloading && document.getElementById('ate-chk-img').checked) {
const chapterImages = doc.binaries.filter(bin => bin instanceof FB2Image && bin !== doc.coverpage && !bin.value);
if (chapterImages.length > 0) {
const logLine = writeLog(`Скачивание иллюстраций (${chapterImages.length} шт)... `);
let loadedCount = 0;
for (const chapterImage of chapterImages) {
if (!isDownloading) break;
try {
await chapterImage.load();
loadedCount++;
} catch(error) {}
}
logLine.innerHTML += `<span style="color:#5cb85c">ok (${loadedCount}/${chapterImages.length})</span>`;
}
}
if (isDownloading) {
writeLog('Сборка FB2 файла...', '#f0ad4e');
let xmlString = doc.toString();
xmlString = xmlString.replace(/<program-used>.*?<\/program-used>/gi, '');
xmlString = FB2Utils.prettyPrintXml(xmlString);
const blob = new Blob([xmlString], { type: 'application/x-fictionbook+xml;charset=utf-8' });
generatedBlobUrl = URL.createObjectURL(blob);
let safeTitle = (bookMeta.title || 'Книга').replace(/[\/\\?%*:|"<>]/g, '').trim();
generatedFilename = `${safeAuthor}. ${safeTitle}`;
if (selectedChapters.length === 1) {
generatedFilename += `. Глава ${selectedChapters[0].dataset.idx}`;
} else if (selectedChapters.length > 1 && selectedChapters.length < availableChapters.length) {
generatedFilename += `. Главы ${selectedChapters[0].dataset.idx}-${selectedChapters[selectedChapters.length - 1].dataset.idx}`;
}
writeLog('Готово! Файл можно скачивать.', '#5cb85c');
btnAction.textContent = 'Сохранить в файл';
btnAction.style.background = '#5cb85c';
isDownloading = false;
}
} catch (fatalErr) {
writeLog(`Критическая ошибка: ${fatalErr.message}`, '#d9534f');
console.error(fatalErr);
isDownloading = false;
btnAction.textContent = 'Закрыть';
btnAction.style.background = '';
}
}
else if (btnAction.textContent === 'Прервать') {
isDownloading = false;
writeLog('Операция прервана!', '#d9534f');
btnAction.textContent = 'Закрыть';
btnAction.style.background = '';
}
else if (btnAction.textContent === 'Сохранить в файл') {
if (generatedBlobUrl) {
const downloadLink = document.createElement('a');
downloadLink.href = generatedBlobUrl;
downloadLink.download = `${generatedFilename}.fb2`;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
}
closeModal();
}
else if (btnAction.textContent === 'Закрыть') {
closeModal();
}
};
}
// ============================================================
// Сборка полной главы: загрузка всех страниц главы с кэшированием,
// рендеринг в DOM, восстановление позиции скролла (bookmark), наблюдение за видимыми страницами
// ============================================================
async function buildFullChapter(forceRefresh = false) {
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
const contentContainer = document.getElementById('at-text-content');
contentContainer.innerHTML = `
<div class="at-loader-container">
<div class="at-spinner"></div>
<div id="at-loader-text">Подготовка главы (всего страниц: ${window.atTotalChapterPages})...</div>
</div>
`;
if (forceRefresh) {
storageRemove(`at-chap-${window.atCurrentChapterId}`);
window.atChapterPagesCache = {};
}
let results = [];
for (let pageNumber = 1; pageNumber <= window.atTotalChapterPages; pageNumber++) {
if (window.atChapterPagesCache[pageNumber] && window.atChapterPagesCache[pageNumber] !== 'loading') {
results.push({ p: pageNumber, html: window.atChapterPagesCache[pageNumber] });
} else {
const loaderText = document.getElementById('at-loader-text');
if (loaderText) loaderText.textContent = `Загрузка страницы ${pageNumber} из ${window.atTotalChapterPages}...`;
try {
const data = await fetchChapterPage(window.atCurrentChapterId, pageNumber, currentChapterTitle);
if (data.totalPages && data.totalPages > window.atTotalChapterPages) {
window.atTotalChapterPages = data.totalPages;
}
results.push({ p: pageNumber, html: data.html });
window.atChapterPagesCache[pageNumber] = data.html;
if (pageNumber < window.atTotalChapterPages) {
await new Promise(r => setTimeout(r, 50));
}
} catch (error) {
results.push({ p: pageNumber, html: `<div style="color:red; text-align:center;">Ошибка загрузки страницы ${pageNumber}</div>` });
}
}
}
saveChapterCache(window.atCurrentChapterId, window.atChapterPagesCache, window.atCurrentLastEdit, window.atTotalChapterPages);
let pagesHtml = '';
results.forEach(res => {
let resHtml = res.html;
pagesHtml += `<div class="at-page-block" data-page="${res.p}" id="at-page-${res.p}">${resHtml}</div>`;
});
contentContainer.innerHTML = (currentChapterTitle ? `<h1 class="at-chapter-heading">${currentChapterTitle}</h1>` : '') + `<div id="at-pages-container">${pagesHtml}</div>`;
pingServerBookmark(window.atCurrentChapterId, window.atRequestedPage);
let isUserScrolling = false;
const stopScrollFix = () => { isUserScrolling = true; };
window.addEventListener('wheel', stopScrollFix, { once: true });
window.addEventListener('touchstart', stopScrollFix, { once: true });
window.addEventListener('mousedown', stopScrollFix, { once: true });
const scrollToTarget = () => {
if (isUserScrolling) return;
let restored = false;
try {
const bm = storageGet(`at-bookmark-${bookMeta.bookSlug}`, null);
if (bm) {
if (bm.chapterId === window.atCurrentChapterId && bm.percent !== undefined) {
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
if (scrollHeight > 0) {
window.scrollTo(0, scrollHeight * bm.percent);
restored = true;
}
}
}
} catch(error) {}
if (!restored) {
const targetPageEl = document.getElementById(`at-page-${window.atRequestedPage}`);
if (targetPageEl && window.atRequestedPage > 1) {
const scrollYPosition = targetPageEl.getBoundingClientRect().top + window.scrollY - 60;
window.scrollTo(0, scrollYPosition);
} else if (window.atRequestedPage === 1) {
window.scrollTo(0, 0);
}
}
};
[50, 150, 300, 600, 1000].forEach(delay => setTimeout(scrollToTarget, delay));
setTimeout(initPageObserver, 1200);
}
// ============================================================
// Отметка прочитанной страницы на сервере Litnet (beacon)
// ============================================================
function pingServerBookmark(chapterId, pageNum) {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const bodyParams = new URLSearchParams();
bodyParams.append('chapterId', chapterId);
bodyParams.append('page', pageNum);
if (csrfToken) bodyParams.append('_csrf', csrfToken);
fetch('/reader/get-page', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: bodyParams.toString()
}).catch(() => {});
} catch (error) {}
}
// ============================================================
// Наблюдение за прокруткой: сохранение позиции чтения в GM storage,
// отслеживание видимой страницы и обновление URL без перезагрузки
// ============================================================
function initPageObserver() {
let scrollTimeout;
window.addEventListener('scroll', () => {
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const scrollY = window.scrollY || document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
if (scrollHeight > 0) {
const percent = Math.max(0, Math.min(1, scrollY / scrollHeight));
try {
storageSet(`at-bookmark-${bookMeta.bookSlug}`, {
chapterId: window.atCurrentChapterId,
percent: percent,
time: Date.now()
});
} catch(error) {}
}
const blocks = document.querySelectorAll('.at-page-block');
let activePage = window.atCurrentPage;
const triggerLine = window.innerHeight * 0.4;
for (let block of blocks) {
const rect = block.getBoundingClientRect();
if (rect.top <= triggerLine && rect.bottom >= triggerLine) {
activePage = parseInt(block.dataset.page);
break;
}
}
if (activePage && activePage !== window.atCurrentPage) {
window.atCurrentPage = activePage;
const url = new URL(window.location);
url.searchParams.set('p', activePage);
window.history.replaceState(null, '', url.toString());
pingServerBookmark(window.atCurrentChapterId, activePage);
}
}, 100);
}, { passive: true });
}
// ============================================================
// Привязка всех обработчиков событий UI: кнопки навигации, библиотека/лайк,
// оглавление, настройки темы/шрифта, скрытие шапки при скролле
// ============================================================
function bindEvents() {
bindDownloadModalEvents();
const btnRefresh = document.getElementById('at-btn-refresh');
const btnToc = document.getElementById('at-btn-toc');
const sidebar = document.getElementById('at-sidebar');
const sidebarOverlay = document.getElementById('at-sidebar-overlay');
const settingsPanel = document.getElementById('at-settings-panel');
const btnSettings = document.getElementById('at-btn-settings');
const headerEl = document.getElementById('at-header');
const btnLike = document.getElementById('at-btn-like');
const btnLibrary = document.getElementById('at-btn-library');
const btnReward = document.getElementById('at-btn-reward');
const btnComplain = document.getElementById('at-btn-complain');
btnLibrary.onclick = async (event) => {
event.preventDefault();
event.stopPropagation();
if (!bookMeta.idBook) {
alert('ID книги не найден. Попробуйте обновить страницу.');
return;
}
bookMeta.inLibrary = !bookMeta.inLibrary;
updateLibraryIcon();
const langPrefix = window.location.pathname.split('/')[1] || 'ru';
const url = bookMeta.inLibrary ? `/${langPrefix}/book/to-library` : `/${langPrefix}/book/from-library`;
const bodyParams = new URLSearchParams();
bodyParams.append('bookId', bookMeta.idBook);
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
if (csrfToken) bodyParams.append('_csrf', csrfToken);
try {
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: bodyParams.toString()
});
} catch (error) {
console.error(error);
bookMeta.inLibrary = !bookMeta.inLibrary;
updateLibraryIcon();
}
};
btnLike.onclick = async (event) => {
event.preventDefault();
event.stopPropagation();
if (!bookMeta.idBook) return;
bookMeta.isLiked = !bookMeta.isLiked;
bookMeta.likeCount += bookMeta.isLiked ? 1 : -1;
updateLikeIcon();
try {
const langPrefix = window.location.pathname.split('/')[1] || 'ru';
const action = bookMeta.isLiked ? 'like' : 'dislike';
await fetch(`/${langPrefix}/book/${action}?id=${bookMeta.idBook}`, {
method: 'GET',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
} catch(error) {
bookMeta.isLiked = !bookMeta.isLiked;
bookMeta.likeCount += bookMeta.isLiked ? 1 : -1;
updateLikeIcon();
}
};
const hoistLitnetModal = () => {
['complaint-modal', 'form-complaint-modal', 'button-reward-modal-dialog-view', 'reward-author-modal-dialog'].forEach(id => {
const el = document.getElementById(id);
if (el && el.parentElement !== document.body) document.body.appendChild(el);
});
};
btnReward.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
const nativeBtn = document.querySelector('[onclick*="rewardAuthor"], div.to-library-btn[onclick*="rewardAuthor"]');
if (nativeBtn) {
nativeBtn.click();
setTimeout(hoistLitnetModal, 200);
setTimeout(hoistLitnetModal, 800);
} else {
alert('Функция награды недоступна для данной книги.');
}
};
btnComplain.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
const nativeBtn = document.querySelector('#common-complaint, .complain-btn');
if (nativeBtn) {
nativeBtn.click();
setTimeout(hoistLitnetModal, 200);
setTimeout(hoistLitnetModal, 800);
} else {
alert('Функция жалобы недоступна.');
}
};
btnRefresh.onclick = () => {
buildFullChapter(true);
};
function closeSidebar() { sidebar.classList.remove('open'); sidebarOverlay.classList.remove('visible'); }
document.getElementById('at-btn-close-sidebar').onclick = closeSidebar;
btnToc.onclick = () => { sidebar.classList.toggle('open'); sidebarOverlay.classList.toggle('visible'); settingsPanel.classList.remove('open'); };
sidebarOverlay.onclick = closeSidebar;
document.addEventListener('click', (event) => {
if (!settingsPanel.contains(event.target) && event.target !== btnSettings) settingsPanel.classList.remove('open');
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { closeSidebar(); settingsPanel.classList.remove('open'); }
});
btnSettings.onclick = (event) => { event.stopPropagation(); settingsPanel.classList.toggle('open'); closeSidebar(); };
function applySettings() {
const themeStep = parseInt(document.getElementById('at-inp-theme').value, 10);
userSettings.themeStep = themeStep;
let themeName = 'Белая';
switch(themeStep) {
case 1: userSettings.bgColor = '#ffffff'; userSettings.textColor = '#333333'; themeName = 'Белая'; break;
case 2: userSettings.bgColor = '#f4ecd8'; userSettings.textColor = '#333333'; themeName = 'Сепия'; break;
case 3: userSettings.bgColor = '#e0e0e0'; userSettings.textColor = '#333333'; themeName = 'Серая'; break;
case 4: userSettings.bgColor = '#222222'; userSettings.textColor = '#eeeeee'; themeName = 'Темная'; break;
case 5: userSettings.bgColor = '#000000'; userSettings.textColor = '#808080'; themeName = 'Черная'; break;
}
userSettings.fontSize = parseInt(document.getElementById('at-inp-fs').value, 10);
userSettings.lineHeight = parseFloat(document.getElementById('at-inp-lh').value);
let twVal = parseInt(document.getElementById('at-inp-tw').value, 10);
userSettings.textWidth = twVal >= 1400 ? '100%' : twVal;
userSettings.fontFamily = document.getElementById('at-inp-ff').value;
userSettings.textColor = document.getElementById('at-inp-tc').value;
userSettings.bgColor = document.getElementById('at-inp-bc').value;
userSettings.hyphens = document.getElementById('at-inp-hy').checked;
document.getElementById('at-val-theme').textContent = themeName;
document.getElementById('at-inp-tc').value = userSettings.textColor;
document.getElementById('at-inp-bc').value = userSettings.bgColor;
document.getElementById('at-val-fs').textContent = userSettings.fontSize + 'px';
document.getElementById('at-val-lh').textContent = userSettings.lineHeight;
document.getElementById('at-val-tw').textContent = userSettings.textWidth === '100%' ? 'Макс' : userSettings.textWidth + 'px';
const root = document.documentElement;
root.style.setProperty('--at-bg-color', userSettings.bgColor);
root.style.setProperty('--at-text-color', userSettings.textColor);
root.style.setProperty('--at-font-size', userSettings.fontSize + 'px');
root.style.setProperty('--at-line-height', userSettings.lineHeight);
root.style.setProperty('--at-text-width', userSettings.textWidth === '100%' ? '100%' : userSettings.textWidth + 'px');
root.style.setProperty('--at-font-family', `'${userSettings.fontFamily}', sans-serif`);
root.style.setProperty('--at-hyphens', userSettings.hyphens ? 'auto' : 'none');
storageSet('at-reader-settings', userSettings);
}
['theme', 'fs', 'lh', 'tw', 'ff', 'tc', 'bc', 'hy'].forEach(settingId => {
const inputElement = document.getElementById(`at-inp-${settingId}`);
if (inputElement) {
inputElement.addEventListener('input', applySettings);
inputElement.addEventListener('change', applySettings);
}
});
document.getElementById('at-inp-theme').addEventListener('input', (event) => {
let textColor = '#333', bgColor = '#fff';
switch(parseInt(event.target.value, 10)) {
case 1: bgColor = '#ffffff'; textColor = '#333333'; break;
case 2: bgColor = '#f4ecd8'; textColor = '#333333'; break;
case 3: bgColor = '#e0e0e0'; textColor = '#333333'; break;
case 4: bgColor = '#222222'; textColor = '#eeeeee'; break;
case 5: bgColor = '#000000'; textColor = '#808080'; break;
}
document.getElementById('at-inp-tc').value = textColor;
document.getElementById('at-inp-bc').value = bgColor;
applySettings();
});
let lastScroll = 0, isHeaderHidden = false;
window.addEventListener('scroll', () => {
const currentScroll = window.scrollY || document.documentElement.scrollTop;
if (currentScroll > lastScroll + 10 && currentScroll > 60) {
if (!isHeaderHidden) { headerEl.classList.add('hidden'); isHeaderHidden = true; }
} else if (currentScroll < lastScroll - 10 || currentScroll <= 60) {
if (isHeaderHidden) { headerEl.classList.remove('hidden'); isHeaderHidden = false; }
}
lastScroll = currentScroll <= 0 ? 0 : currentScroll;
}, { passive: true });
document.addEventListener('mousemove', (event) => {
if (event.clientY <= 60 && isHeaderHidden) { headerEl.classList.remove('hidden'); isHeaderHidden = false; }
});
}
// ============================================================
// Точка входа: последовательная инициализация — извлечение данных, исправление модалок,
// отключение копирования, инъекция стилей, рендеринг интерфейса, загрузка метаданных
// ============================================================
function init() {
if (extractData()) {
fixNativeModals();
killAntiCopy();
injectStyles();
renderInterface();
updateLibraryIcon();
updateLikeIcon();
fetchBookExtraInfo();
buildFullChapter();
return true;
}
return false;
}
// ============================================================
// Таймер инициализации: ожидание загрузки DOM Litnet (ng-state или reader-text),
// обход проверки Cloudflare, лимит попыток для предотвращения бесконечного ожидания
// ============================================================
let initAttempts = 0;
const maxAttempts = 150;
const initTimer = setInterval(() => {
initAttempts++;
if (document.title.includes('Один момент') || document.querySelector('#challenge-error-text') || document.querySelector('.cf-browser-verification')) {
console.log("Litnet Reader: Обнаружена проверка Cloudflare. Ожидание...");
return;
}
const hasState = document.getElementById('ng-state');
const hasText = document.querySelector('.jsReaderText, [data-test-id="reader-text"]');
if (hasState || hasText) {
clearInterval(initTimer);
if (!window.litnetReaderInitialized) {
window.litnetReaderInitialized = true;
setTimeout(init, 50);
}
} else if (initAttempts >= maxAttempts) {
clearInterval(initTimer);
console.warn("Litnet Reader: Превышено время ожидания загрузки страницы.");
}
}, 100);
})();