AuthorTodayExtractor

The script adds a button to the site for downloading books to an FB2 file

安装此脚本
作者推荐脚本

您可能也喜欢FicbookExtractor

安装此脚本
  1. // ==UserScript==
  2. // @name AuthorTodayExtractor
  3. // @name:ru AuthorTodayExtractor
  4. // @namespace 90h.yy.zz
  5. // @version 1.8.0
  6. // @author Ox90
  7. // @match https://author.today/*
  8. // @description The script adds a button to the site for downloading books to an FB2 file
  9. // @description:ru Скрипт добавляет кнопку для скачивания книги в формате FB2
  10. // @require https://update.greasyfork.org/scripts/468831/1575776/HTML2FB2Lib.js
  11. // @grant GM.xmlHttpRequest
  12. // @grant unsafeWindow
  13. // @connect author.today
  14. // @connect cm.author.today
  15. // @connect *
  16. // @run-at document-start
  17. // @license MIT
  18. // ==/UserScript==
  19.  
  20. /**
  21. * Записи вида `@connect` необходимы пользователям tampermonkey для загрузки обложек и изображений внутри глав.
  22. * Разрешение `@connect cm.author.today` - для загрузки обложек и дополнительных материалов.
  23. * Разрешение `@connect author.today` - для загрузки обложек у старых книг.
  24. * Разрешение `@connect *` необходимо для того, чтобы получить возможность загружать картинки
  25. * внутри глав со сторонних ресурсов, когда авторы ссылаются в своих главах на сторонние хостинги картинок.
  26. * Такое хоть и редко, но встречается. Это разрешение прописано, чтобы пользователю отображалась кнопка
  27. * "Always allow all domains" при подтверждении запроса.
  28. * Детали: https://www.tampermonkey.net/documentation.php#_connect
  29. */
  30.  
  31. (function start() {
  32. "use strict";
  33.  
  34. const PROGRAM_NAME = "ATExtractor";
  35. const PROGRAM_VERSION = GM_info.script.version;
  36.  
  37. let app = null;
  38. let stage = 0;
  39. let mobile = false;
  40. let mainBtn = null;
  41.  
  42. /**
  43. * Начальный запуск скрипта сразу после загрузки страницы сайта
  44. *
  45. * @return void
  46. */
  47. function init() {
  48. addStyles();
  49. pageHandler();
  50. // Следить за ajax контейнером
  51. const ajax_el = document.getElementById("pjax-container");
  52. if (ajax_el) (new MutationObserver(() => pageHandler())).observe(ajax_el, { childList: true });
  53. }
  54.  
  55. /**
  56. * Начальная идентификация страницы и запуск необходимых функций
  57. *
  58. * @return void
  59. */
  60. function pageHandler() {
  61. const path = document.location.pathname;
  62. if (path.startsWith("/account/") || (path.startsWith("/u/") && path.endsWith("/edit")) || path.startsWith("/report/")) {
  63. // Это страница настроек (личный кабинет пользователя)
  64. ensureSettingsMenuItems();
  65. if (path === "/account/settings" && (new URL(document.location)).searchParams.get("script") === "atex") {
  66. // Это страница настроек скрипта
  67. handleSettingsPage();
  68. }
  69. return;
  70. }
  71. if (/work\/\d+$/.test(path)) {
  72. // Страница книги
  73. handleWorkPage();
  74. return;
  75. }
  76. }
  77.  
  78. /**
  79. * Обработчик страницы с книгой. Добавляет кнопку и инициирует необходимые структуры
  80. *
  81. * @return void
  82. */
  83. function handleWorkPage() {
  84. // Найти и сохранить объект App.
  85. // App нужен для получения userId, который используется как часть ключа при расшифровке.
  86. app = window.app || (unsafeWindow && unsafeWindow.app) || {};
  87. // Добавить кнопку на панель
  88. setMainButton();
  89. }
  90.  
  91. /**
  92. * Находит панель и добавляет туда кнопку, если она отсутствует.
  93. * Вызывается не только при инициализации скрипта но и при изменениях ajax контейнере сайта
  94. *
  95. * @return void
  96. */
  97. function setMainButton() {
  98. // Проверить, что это текст, а не, например, аудиокнига, и найти панель для вставки кнопки
  99. let a_panel = null;
  100. if (document.querySelector("div.book-action-panel a[href^='/reader/']")) {
  101. a_panel = document.querySelector("div.book-panel div.book-action-panel");
  102. mobile = false;
  103. } else if (document.querySelector("div.work-details div.row a[href^='/reader/']")) {
  104. a_panel = document.querySelector("div.work-details div.row div.btn-library-work");
  105. a_panel = a_panel && a_panel.parentElement;
  106. mobile = true;
  107. }
  108. if (!a_panel) return;
  109.  
  110. if (!mainBtn) {
  111. // Похоже кнопки нет. Создать кнопку и привязать действие.
  112. mainBtn = createButton(mobile);
  113. const ael = mobile && mainBtn || mainBtn.children[0];
  114. ael.addEventListener("click", event => {
  115. event.preventDefault();
  116. displayDownloadDialog();
  117. });
  118. }
  119.  
  120. if (!a_panel.contains(mainBtn)) {
  121. // Выбрать позицию для кнопки: или после оригинальной, или перед группой кнопок внизу.
  122. // Если не удалось найти нужную позицию, тогда добавить кнопку как последнюю в панели.
  123. let sbl = null;
  124. if (!mobile) {
  125. sbl = a_panel.querySelector("div.mt-lg>a.btn>i.icon-download");
  126. sbl && (sbl = sbl.parentElement.parentElement.nextElementSibling);
  127. } else {
  128. sbl = a_panel.querySelector("#btn-download");
  129. if (sbl) sbl = sbl.nextElementSibling;
  130. }
  131. if (!sbl) {
  132. if (!mobile) {
  133. sbl = document.querySelector("div.mt-lg.text-center");
  134. } else {
  135. sbl = a_panel.querySelector("a.btn-work-more");
  136. }
  137. }
  138. // Добавить кнопку на страницу книги
  139. if (sbl) {
  140. a_panel.insertBefore(mainBtn, sbl);
  141. } else {
  142. a_panel.appendChild(mainBtn);
  143. }
  144. }
  145. }
  146.  
  147. /**
  148. * Создает и возвращает элемент кнопки, которая размещается на странице книги
  149. *
  150. * @return Element HTML-элемент кнопки для добавления на страницу
  151. */
  152. function createButton() {
  153. let btn = null;
  154. if (!mobile) {
  155. btn = document.createElement("div");
  156. btn.classList.add("mt-lg");
  157. btn.innerHTML = "<button style=\"border-color:green;\" class=\"btn btn-default btn-block\"><i class=\"icon-download\"></i> </button>";
  158. } else {
  159. btn = document.createElement("a");
  160. btn.classList.add("btn", "btn-default", "btn-download-work");
  161. btn.innerHTML = "<i class=\"icon-download\"></i> ";
  162. }
  163. btn.setText = function(text) {
  164. const el = this.nodeName === "A" ? this : this.querySelector("button");
  165. el.childNodes[1].textContent = " " + (text || "Скачать FB2");
  166. };
  167. btn.setText();
  168. return btn;
  169. }
  170.  
  171. /**
  172. * Обработчик нажатия кнопки "Скачать FB2" на странице книги
  173. *
  174. * @return void
  175. */
  176. async function displayDownloadDialog() {
  177. if (mainBtn.disabled) return;
  178. try {
  179. mainBtn.disabled = true;
  180. mainBtn.setText("Анализ...");
  181. const params = getBookOverview();
  182. let log = null;
  183. let doc = new FB2DocumentEx();
  184. doc.bookTitle = params.title;
  185. doc.id = params.workId;
  186. doc.idPrefix = "atextr_";
  187. doc.status = params.status;
  188. doc.programName = PROGRAM_NAME + " v" + PROGRAM_VERSION;
  189. const chapters = await getChaptersList(params);
  190. doc.totalChapters = chapters.length;
  191. const dlg = new DownloadDialog({
  192. title: "Формирование файла FB2 (v" + PROGRAM_VERSION + ")",
  193. annotation: !!params.authorNotes,
  194. cover: !!params.cover,
  195. materials: !!params.materials,
  196. settings: {
  197. addnotes: Settings.get("addnotes"),
  198. addcover: Settings.get("addcover"),
  199. addimages: Settings.get("addimages"),
  200. materials: Settings.get("materials")
  201. },
  202. chapters: chapters,
  203. onclose: () => {
  204. Loader.abortAll();
  205. log = null;
  206. doc = null;
  207. if (dlg.link) {
  208. URL.revokeObjectURL(dlg.link.href);
  209. dlg.link = null;
  210. }
  211. },
  212. onsubmit: result => {
  213. result.cover = params.cover;
  214. result.bookPanel = params.bookPanel;
  215. result.annotation = params.annotation;
  216. if (result.authorNotes) result.authorNotes = params.authorNotes;
  217. if (result.materials) result.materials = params.materials;
  218. dlg.result = result;
  219. makeAction(doc, dlg, log);
  220. }
  221. });
  222. dlg.show();
  223. log = new LogElement(dlg.log);
  224. if (chapters.length) {
  225. setStage(0);
  226. } else {
  227. dlg.button.textContent = setStage(3);
  228. dlg.nextPage();
  229. log.warning("Нет доступных глав для выгрузки!");
  230. }
  231. } catch (err) {
  232. console.error(err);
  233. Notification.display(err.message, "error");
  234. } finally {
  235. mainBtn.disabled = false;
  236. mainBtn.setText();
  237. }
  238. }
  239.  
  240. /**
  241. * Фактический обработчик нажатий на кнопку формы выгрузки
  242. *
  243. * @param FB2Document doc Формируемый документ
  244. * @param DownloadDialog dlg Экземпляр формы выгрузки
  245. * @param LogElement log Лог для фиксации прогресса
  246. *
  247. * @return void
  248. */
  249. async function makeAction(doc, dlg, log) {
  250. try {
  251. switch (stage) {
  252. case 0:
  253. dlg.button.textContent = setStage(1);
  254. dlg.nextPage();
  255. await getBookContent(doc, dlg.result, log);
  256. if (stage == 1) dlg.button.textContent = setStage(2);
  257. break;
  258. case 1:
  259. Loader.abortAll();
  260. dlg.button.textContent = setStage(3);
  261. log.warning("Операция прервана");
  262. Notification.display("Операция прервана", "warning");
  263. break;
  264. case 2:
  265. if (!dlg.link) {
  266. dlg.link = document.createElement("a");
  267. dlg.link.setAttribute("download", genBookFileName(doc, { chaptersRange: dlg.result.chaptersRange }));
  268. // Должно быть text/plain, но в этом случае мобильный Firefox к имени файла добавляет .txt
  269. dlg.link.href = URL.createObjectURL(new Blob([ doc ], { type: "application/octet-stream" }));
  270. }
  271. dlg.link.click();
  272. break;
  273. case 3:
  274. dlg.hide();
  275. break;
  276. }
  277. } catch (err) {
  278. if (err.name !== "AbortError") {
  279. console.error(err);
  280. log.message(err.message, "red");
  281. Notification.display(err.message, "error");
  282. }
  283. dlg.button.textContent = setStage(3);
  284. }
  285. }
  286.  
  287. /**
  288. * Выбор стадии работы скрипта
  289. *
  290. * @param int new_stage Числовое значение новой стадии
  291. *
  292. * @return string Текст для кнопки диалога
  293. */
  294. function setStage(new_stage) {
  295. stage = new_stage;
  296. return [ "Продолжить", "Прервать", "Сохранить в файл", "Закрыть" ][new_stage] || "Error";
  297. }
  298.  
  299. /**
  300. * Возвращает объект с предварительными результатами анализа книги
  301. *
  302. * @return Object
  303. */
  304. function getBookOverview() {
  305. const res = {};
  306.  
  307. res.bookPanel = document.querySelector("div.book-panel div.book-meta-panel") ||
  308. document.querySelector("div.work-details div.work-header-content");
  309.  
  310. res.title = res.bookPanel && (res.bookPanel.querySelector(".book-title") || res.bookPanel.querySelector(".card-title"));
  311. res.title = res.title ? res.title.textContent.trim() : null;
  312.  
  313. const wid = /^\/work\/(\d+)$/.exec(document.location.pathname);
  314. res.workId = wid && wid[1] || null;
  315.  
  316. const status_el = res.bookPanel && res.bookPanel.querySelector(".book-status-icon");
  317. if (status_el) {
  318. if (status_el.classList.contains("icon-check")) {
  319. res.status = "finished";
  320. } else if (status_el.classList.contains("icon-pencil")) {
  321. res.status = "in-progress";
  322. }
  323. } else {
  324. res.status = "fragment";
  325. }
  326.  
  327. const empty = el => {
  328. if (!el) return false;
  329. // Считается что аннотация есть только в том случае,
  330. // если имеются непустые текстовые ноды непосредственно в блоке аннотации
  331. return !Array.from(el.childNodes).some(node => {
  332. return node.nodeName === "#text" && node.textContent.trim() !== "";
  333. });
  334. };
  335.  
  336. let annotation = mobile ?
  337. document.querySelector("div.card-content-inner>div.card-description") :
  338. (res.bookPanel && res.bookPanel.querySelector("#tab-annotation>div.annotation"));
  339. if (annotation.children.length > 0) {
  340. const notes = annotation.querySelector(":scope>div.rich-content>p.text-primary.mb0");
  341. if (notes && !empty(notes.parentElement)) res.authorNotes = notes.parentElement;
  342. annotation = annotation.querySelector(":scope>div.rich-content");
  343. if (!empty(annotation) && annotation !== notes) res.annotation = annotation;
  344. }
  345.  
  346. const cover = mobile ?
  347. document.querySelector("div.work-cover>.work-cover-content>img.cover-image") :
  348. document.querySelector("div.book-cover>.book-cover-content>img.cover-image");
  349. if (cover) {
  350. res.cover = cover;
  351. }
  352.  
  353. const materials = mobile ?
  354. document.querySelector("#accordion-item-materials>div.accordion-item-content div.picture") :
  355. res.bookPanel && res.bookPanel.querySelector("div.book-materials div.picture");
  356. if (materials) {
  357. res.materials = materials;
  358. }
  359.  
  360. return res;
  361. }
  362.  
  363. /**
  364. * Возвращает список глав из DOM-дерева сайта в формате
  365. * { title: string, locked: bool, workId: string, chapterId: string }.
  366. *
  367. * @return array Массив объектов с данными о главах
  368. */
  369. async function getChaptersList(params) {
  370. const el_list = document.querySelectorAll(
  371. mobile &&
  372. "div.work-table-of-content>ul.list-unstyled>li" ||
  373. "div.book-tab-content>div#tab-chapters>ul.table-of-content>li"
  374. );
  375.  
  376. if (!el_list.length) {
  377. // Не найдено ни одной главы, возможно это рассказ
  378. // Запрашивает первую главу чтобы получить объект в исходном HTML коде ответа сервера
  379. let chapters = null;
  380. try {
  381. const r = await Loader.addJob(new URL(`/reader/${params.workId}`, document.location), {
  382. method: "GET",
  383. responseType: "text"
  384. });
  385. const meta = /app\.init\("readerIndex",\s*(\{[\s\S]+?\})\s*\)/.exec(r.response); // Ищет строку инициализации с данными главы
  386. if (!meta) throw new Error("Не найдены метаданные книги в ответе сервера");
  387. let w_id = /\bworkId\s*:\s*(\d+)/.exec(r.response);
  388. w_id = w_id && w_id[1] || params.workId;
  389. let c_ls = /\bchapters\s*:\s*(\[.+\])\s*,?[\n\r]+/.exec(r.response);
  390. c_ls = c_ls && c_ls[1] || "[]";
  391. chapters = (JSON.parse(c_ls) || []).map(ch => {
  392. return { title: ch.title, workId: w_id, chapterId: "" + ch.id };
  393. });
  394. const w_fm = /\bworkForm\s*:\s*"(.+)"/.exec(r.response);
  395. if (w_fm && w_fm[1].toLowerCase() === "story" && chapters.length === 1) chapters[0].title = "";
  396. chapters[0].locked = false;
  397. } catch (err) {
  398. console.error(err);
  399. throw new Error("Ошибка загрузки метаданных главы");
  400. }
  401. return chapters;
  402. }
  403. // Анализирует найденные HTML элементы с главами
  404. const res = [];
  405. for (let i = 0; i < el_list.length; ++i) {
  406. const el = el_list[i].children[0];
  407. if (el) {
  408. let ids = null;
  409. const title = el.textContent;
  410. let locked = false;
  411. if (el.tagName === "A" && el.hasAttribute("href")) {
  412. ids = /^\/reader\/(\d+)\/(\d+)$/.exec((new URL(el.href)).pathname);
  413. } else if (el.tagName === "SPAN") {
  414. if (el.parentElement.querySelector("i.icon-lock")) locked = true;
  415. }
  416. if (title && (ids || locked)) {
  417. const ch = { title: title, locked: locked };
  418. if (ids) {
  419. ch.workId = ids[1];
  420. ch.chapterId = ids[2];
  421. }
  422. res.push(ch);
  423. }
  424. }
  425. }
  426. return res;
  427. }
  428.  
  429. /**
  430. * Производит формирование описания книги, загрузку и анализ глав и доп.материалов
  431. *
  432. * @param FB2DocumentEx doc Формируемый документ
  433. * @param Object bdata Объект с предварительными данными
  434. * @param LogElement log Лог для фиксации процесса формирования книги
  435. *
  436. * @return void
  437. */
  438. async function getBookContent(doc, bdata, log) {
  439. await extractDescriptionData(doc, bdata, log);
  440. if (stage !== 1) return;
  441.  
  442. log.message("---");
  443. await extractChapters(doc, bdata.chapters, { noImages: !bdata.addimages }, log);
  444. if (stage !== 1) return;
  445.  
  446. if (bdata.materials) {
  447. log.message("---");
  448. log.message("Дополнительные материалы:");
  449. await extractMaterials(doc, bdata.materials, log);
  450. ++doc.extraChapters;
  451. if (stage !== 1) return;
  452. }
  453. if (doc.wishes.likes || doc.wishes.comments) {
  454. log.message("---");
  455. log.message("Обращение к читателю:");
  456. addWishesChapter(doc, log);
  457. ++doc.extraChapters;
  458. }
  459. if (bdata.addimages) {
  460. const icnt = doc.binaries.reduce((cnt, img) => {
  461. if (!img.value) ++cnt;
  462. return cnt;
  463. }, 0);
  464. if (icnt) {
  465. log.message("---");
  466. log.warning(`Проблемы с загрузкой изображений: ${icnt}`);
  467. await new Promise(resolve => setTimeout(resolve, 100)); // Для обновления лога
  468. if (confirm("Имеются незагруженные изображения. Использовать заглушку?")) {
  469. const li = log.message("Применение заглушки...");
  470. try {
  471. const img = getDummyImage();
  472. replaceBadImages(doc, img);
  473. doc.binaries.push(img);
  474. li.ok();
  475. } catch (err) {
  476. li.fail();
  477. throw err;
  478. }
  479. } else {
  480. log.message("Проблемные изображения заменены на текст");
  481. }
  482. }
  483. }
  484. let webpList = [];
  485. const imgTypes = doc.binaries.reduce((map, bin) => {
  486. if (bin instanceof FB2Image && bin.value) {
  487. const type = bin.type;
  488. map.set(type, (map.get(type) || 0) + 1);
  489. if (type === "image/webp") webpList.push(bin);
  490. }
  491. return map;
  492. }, new Map());
  493. if (imgTypes.size) {
  494. log.message("---");
  495. log.message("Изображения:");
  496. imgTypes.forEach((cnt, type) => log.message(`- ${type}: ${cnt}`));
  497. if (webpList.length) {
  498. log.warning("Найдены изображения формата WebP. Могут быть проблемы с отображением на старых читалках.");
  499. await new Promise(resolve => setTimeout(resolve, 100)); // Для обновления лога
  500. if (confirm("Выполнить конвертацию WebP --> JPEG?")) {
  501. const li = log.message("Конвертация изображений...");
  502. let ecnt = 0;
  503. for (const img of webpList) {
  504. try {
  505. await img.convert("image/jpeg");
  506. } catch(err) {
  507. console.log(`Ошибка конвертации изображения: id=${img.id}; type=${img.type};`);
  508. ++ecnt;
  509. }
  510. }
  511. if (!ecnt) {
  512. li.ok();
  513. } else {
  514. li.fail();
  515. log.warning("Часть изображений не удалось сконвертировать!");
  516. }
  517. }
  518. }
  519. }
  520. if (doc.unknowns) {
  521. log.message("---");
  522. log.warning(`Найдены неизвестные элементы: ${doc.unknowns}`);
  523. log.message("Преобразованы в текст без форматирования");
  524. }
  525. doc.history.push("v1.0 - создание fb2 - (Ox90)");
  526. log.message("---");
  527. log.message("Готово!");
  528. if (Settings.get("sethint", true)) {
  529. log.message("---");
  530. const hint = document.createElement("span");
  531. hint.innerHTML =
  532. "<i>Для формирования имени файла будет использован следующий шаблон: <b>" + Settings.get("filename") +
  533. "</b>. Вы можете настроить скрипт и отключить это сообщение в " +
  534. " <a href=\"/account/settings?script=atex\" target=\"_blank\">в личном кабинете</a>.</i>";
  535. log.message(hint);
  536. }
  537. }
  538.  
  539. /**
  540. * Извлекает доступные данные описания книги из DOM элементов сайта
  541. *
  542. * @param FB2DocumentEx doc Формируемый документ
  543. * @param Object bdata Объект с предварительными данными
  544. * @param LogElement log Лог для фиксации процесса формирования книги
  545. *
  546. * @return void
  547. */
  548. async function extractDescriptionData(doc, bdata, log) {
  549. if (!bdata.bookPanel) throw new Error("Не найдена панель с информацией о книге!");
  550. if (!doc.bookTitle) throw new Error("Не найден заголовок книги");
  551. const book_panel = bdata.bookPanel;
  552.  
  553. // Версия скрипта
  554. log.message(PROGRAM_NAME + ' v' + PROGRAM_VERSION);
  555. // Название книги
  556. log.message("Заголовок:").text(doc.bookTitle);
  557. // Авторы
  558. const authors = mobile ?
  559. book_panel.querySelectorAll("div.card-author>a") :
  560. book_panel.querySelectorAll("div.book-authors>span[itemprop=author]>a");
  561. doc.bookAuthors = Array.from(authors).reduce((list, el) => {
  562. const au = el.textContent.trim();
  563. if (au) {
  564. const a = new FB2Author(au);
  565. const hp = /^\/u\/([^\/]+)\/works(?:\?|$)/.exec((new URL(el.href)).pathname);
  566. if (hp) a.homePage = (new URL(`/u/${hp[1]}`, document.location)).toString();
  567. list.push(a);
  568. }
  569. return list;
  570. }, []);
  571. if (!doc.bookAuthors.length) throw new Error("Не найдена информация об авторах");
  572. log.message("Авторы:").text(doc.bookAuthors.length);
  573. // Жанры
  574. let genres = mobile ?
  575. book_panel.querySelectorAll("div.work-stats a[href^=\"/work/genre/\"]") :
  576. book_panel.querySelectorAll("div.book-genres a[href^=\"/work/genre/\"]");
  577. genres = Array.from(genres).reduce((list, el) => {
  578. const s = el.textContent.trim();
  579. if (s) list.push(s);
  580. return list;
  581. }, []);
  582. doc.genres = new FB2GenreList(genres);
  583. if (doc.genres.length) {
  584. console.info("Жанры: " + doc.genres.map(g => g.value).join(", "));
  585. } else {
  586. console.warn("Не идентифицирован ни один жанр!");
  587. }
  588. log.message("Жанры:").text(doc.genres.length);
  589. // Ключевые слова
  590. const tags = mobile ?
  591. document.querySelectorAll("div.work-details ul.work-tags a[href^=\"/work/tag/\"]") :
  592. book_panel.querySelectorAll("span.tags a[href^=\"/work/tag/\"]");
  593. doc.keywords = Array.from(tags).reduce((list, el) => {
  594. const tag = el.textContent.trim();
  595. if (tag) list.push(tag);
  596. return list;
  597. }, []);
  598. log.message("Ключевые слова:").text(doc.keywords.length || "нет");
  599. // Серия
  600. let seq_el = Array.from(book_panel.querySelectorAll("div>a")).find(el => {
  601. return el.href && /^\/work\/series\/\d+$/.test((new URL(el.href)).pathname);
  602. });
  603. if (seq_el) {
  604. const name = seq_el.textContent.trim();
  605. if (name) {
  606. const seq = { name: name };
  607. seq_el = seq_el.nextElementSibling;
  608. if (seq_el && seq_el.tagName === "SPAN") {
  609. const num = /^#(\d+)$/.exec(seq_el.textContent.trim());
  610. if (num) seq.number = num[1];
  611. }
  612. doc.sequence = seq;
  613. log.message("Серия:").text(name);
  614. if (seq.number) log.message("Номер в серии:").text(seq.number);
  615. }
  616. }
  617. // Дата публикации книги (последнее обновление)
  618. const dt = book_panel.querySelector("span[data-format=calendar-short][data-time]");
  619. if (dt) {
  620. const d = new Date(dt.getAttribute("data-time"));
  621. if (!isNaN(d.valueOf())) doc.bookDate = d;
  622. }
  623. log.message("Последнее обновление:").text(doc.bookDate ? FB2Utils.dateToAtom(doc.bookDate) : "n/a");
  624. // Ссылка на источник
  625. doc.sourceURL = document.location.origin + document.location.pathname;
  626. log.message("Источник:").text(doc.sourceURL);
  627. // Обложка книги
  628. if (bdata.cover) {
  629. const src = bdata.cover.src;
  630. if (src) {
  631. const li = log.message("Загрузка обложки...");
  632. if (!bdata.skipCover) {
  633. const img = new FB2Image(src);
  634. try {
  635. await img.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"));
  636. img.id = "cover" + img.suffix();
  637. doc.coverpage = img;
  638. doc.binaries.push(img);
  639. li.ok();
  640. log.message("Размер обложки:").text(img.size + " байт");
  641. log.message("Тип обложки:").text(img.type);
  642. } catch (err) {
  643. li.fail();
  644. throw err;
  645. }
  646. } else {
  647. li.skipped();
  648. }
  649. }
  650. }
  651. if (!bdata.cover || (!doc.coverpage && !bdata.skipCover)) log.warning("Обложка книги не найдена!");
  652. // Аннотация
  653. if (bdata.annotation || bdata.authorNotes) {
  654. const li = log.message("Анализ аннотации...");
  655. try {
  656. doc.bindParser("a", new AnnotationParser());
  657. if (bdata.annotation) {
  658. await doc.parse("a", log, {}, bdata.annotation);
  659. }
  660. if (bdata.authorNotes) {
  661. if (doc.annotation && doc.annotation.children.length) {
  662. // Пустая строка между аннотацией и примечаниями автора
  663. doc.annotation.children.push(new FB2EmptyLine());
  664. }
  665. await doc.parse("a", log, {}, bdata.authorNotes);
  666. }
  667. li.ok();
  668. } catch (err) {
  669. li.fail();
  670. throw err;
  671. } finally {
  672. doc.bindParser();
  673. }
  674. } else {
  675. log.warning("Нет аннотации!");
  676. }
  677. }
  678.  
  679. /**
  680. * Запрашивает выбранные ранее части книги с сервера по переданному в аргументе списку.
  681. * Главы запрашиваются последовательно, чтобы не удивлять сервер запросами всех глав одновременно.
  682. *
  683. * @param FB2DocumentEx doc Формируемый документ
  684. * @param Array desired Массив с описанием глав для выгрузки (id и название)
  685. * @param object params Параметры формирования глав
  686. * @param LogElement log Лог для фиксации процесса формирования книги
  687. *
  688. * @return void
  689. */
  690. async function extractChapters(doc, desired, params, log) {
  691. let li = null;
  692. try {
  693. const total = desired.length;
  694. let position = 0;
  695. doc.bindParser("c", new ChapterParser());
  696. for (const ch of desired) {
  697. if (stage !== 1) break;
  698. li = log.message(`Получение главы ${++position}/${total}...`);
  699. const html = await getChapterContent(ch.workId, ch.chapterId);
  700. await doc.parse("c", log, params, html.body, ch.title);
  701. const noteCount = makeFootnotes(doc, doc.chapters[doc.chapters.length - 1]);
  702. li.ok();
  703. if (noteCount) log.message(`Найдены сноски: ${noteCount}`);
  704. }
  705. } catch (err) {
  706. if (li) li.fail();
  707. throw err;
  708. } finally {
  709. doc.bindParser();
  710. }
  711. }
  712.  
  713. /**
  714. * Запрашивает содержимое указанной главы с сервера
  715. *
  716. * @param string workId Id книги
  717. * @param string chapterId Id главы
  718. *
  719. * @return HTMLDocument главы книги
  720. */
  721. async function getChapterContent(workId, chapterId) {
  722. // workId числовой, отфильтрован регуляркой, кодировать для запроса не нужно
  723. const url = new URL(`/reader/${workId}/chapter`, document.location);
  724. url.searchParams.set("id", chapterId);
  725. url.searchParams.set("_", Date.now());
  726. const result = await Loader.addJob(url, {
  727. method: "GET",
  728. headers: { "Accept": "application/json, text/javascript, */*; q=0.01" },
  729. responseType: "text"
  730. });
  731. let response = null;
  732. try {
  733. response = JSON.parse(result.response);
  734. } catch (err) {
  735. console.error(err);
  736. throw new Error("Неожиданный ответ сервера");
  737. }
  738. if (!response.isSuccessful) {
  739. if (Array.isArray(response.messages) && response.messages.length) {
  740. if (response.messages[0].toLowerCase() === "unadulted") {
  741. throw new Error("Контент для взрослых. Зайдите в любую главу книги, подтвердите свой возраст и попробуйте снова");
  742. }
  743. }
  744. throw new Error("Сервер ответил: Unsuccessful");
  745. }
  746. const readerSecret = result.headers.get("reader-secret");
  747. if (!readerSecret) throw new Error("Не найден ключ для расшифровки текста");
  748. // Декодировать ответ от сервера
  749. const chapterString = decryptText(response, readerSecret);
  750. // Преобразовать в HTML элемент.
  751. // Присваивание innerHTML не ипользуется по причине его небезопасности.
  752. // Лучше перестраховаться на случай возможного внедрения скриптов в тело книги.
  753. return new DOMParser().parseFromString(chapterString, "text/html");
  754. }
  755.  
  756. /**
  757. * Ищет сноски внутри главы и при возможности формирует их
  758. *
  759. * @param FB2DocumentEx doc Формируемый документ
  760. * @param FB2Chapter chapter Глава для поиска сносок
  761. *
  762. * @return number Количество созданных сносок
  763. */
  764. function makeFootnotes(doc, chapter) {
  765. if (chapter.children.length < 2) return 0;
  766. const fnIds = new Map();
  767. let startId = doc.notes.length + 1;
  768. // Найти сноски с текстом начиная с конца главы
  769. let fnIndex = chapter.children.length - 1;
  770. for ( ; fnIndex > 0; --fnIndex) {
  771. const el = chapter.children[fnIndex];
  772. // Элемент должен быть параграфом
  773. if (!(el instanceof FB2Paragraph)) break;
  774. // Содержимое элемента должно иметь формат: [123] текст
  775. const r = /^\s*\[(\d+)\]\s+(.+)$/.exec(el.textContent());
  776. if (!r) break;
  777. // Идентификаторы сносок должны быть уникальны в пределах одной главы
  778. if (fnIds.has(r[1])) {
  779. console.warn("Дублирование идентификатора сноски!");
  780. return 0;
  781. }
  782. fnIds.set(r[1], new FB2Note(r[2], ""));
  783. }
  784. if (!fnIds.size) return 0;
  785. // Найти в тексте все ссылки на сноски и сохранить их
  786. const fnElements = [];
  787. const fnLinks = new Set();
  788. function findNoteLinks(ch, par) {
  789. if (ch.children.length) {
  790. return ch.children.every(c => findNoteLinks(c, ch));
  791. }
  792. if ((ch instanceof FB2Text || ch instanceof FB2InlineMarkup) && ch.value) {
  793. const m = ch.value.match(/\[\d+\]/g);
  794. if (m) {
  795. for (const s of m) {
  796. const ss = s.slice(1, -1);
  797. if (fnLinks.has(ss)) {
  798. console.warn("Дублирование идентификатора сноски в тексте!");
  799. return false;
  800. }
  801. if (fnIds.has(ss)) {
  802. fnLinks.add(ss);
  803. fnElements.push([ par, ch, ss ]);
  804. }
  805. }
  806. }
  807. }
  808. return true;
  809. }
  810. for (let i = 0; i <= fnIndex; ++i) {
  811. findNoteLinks(chapter.children[i], chapter);
  812. }
  813. // Количество ссылок на сноски должно совпадать с количеством сносок
  814. if (fnElements.length !== fnIds.size) {
  815. console.warn("Количество сносок не сопадает с количеством ссылок на сноски!");
  816. return 0;
  817. }
  818. // Заменить ссылки в тексте и добавить сноски в документ
  819. fnElements.forEach(it => {
  820. const el = it[1];
  821. const fn = fnIds.get(it[2]);
  822. const sep = `[${it[2]}]`;
  823. it[0].children = it[0].children.reduce((res, ch) => {
  824. if (ch === el) {
  825. const ss = ch.value.split(sep);
  826. if (ch instanceof FB2Text) {
  827. res.push(new FB2Text(ss[0]), fn, new FB2Text(ss[1]));
  828. } else {
  829. res.push(new FB2InlineMarkup(ch.name, ss[0]), fn, new FB2InlineMarkup(ch.name, ss[1]));
  830. }
  831. fn.title = "" + startId++;
  832. doc.notes.push(fn);
  833. } else {
  834. res.push(ch);
  835. }
  836. return res;
  837. }, []);
  838. });
  839. // Удалить сноски в конце главы
  840. for (let i = fnIds.size; i; --i) chapter.children.pop();
  841. //
  842. return fnIds.size;
  843. }
  844.  
  845. /**
  846. * Расшифровывает полученную от сервера строку с текстом
  847. *
  848. * @param chapter string Зашифованная глава книги, полученная от сервера
  849. * @param secret string Часть ключа для расшифровки
  850. *
  851. * @return string Расшифрованный текст
  852. */
  853. function decryptText(chapter, secret) {
  854. let ss = secret.split("").reverse().join("") + "@_@" + (app.userId || "");
  855. let slen = ss.length;
  856. let clen = chapter.data.text.length;
  857. let result = [];
  858. for (let pos = 0; pos < clen; ++pos) {
  859. result.push(String.fromCharCode(chapter.data.text.charCodeAt(pos) ^ ss.charCodeAt(Math.floor(pos % slen))));
  860. }
  861. return result.join("");
  862. }
  863.  
  864. /**
  865. * Просматривает элементы с картинками в дополнительных материалах,
  866. * затем загружает их по ссылкам и сохраняет в виде массива с описанием, если оно есть.
  867. *
  868. * @param FB2DocumentEx doc Формируемый документ
  869. * @param Element materials HTML-элемент с дополнительными материалами
  870. * @param LogElement log Лог для фиксации процесса формирования книги
  871. *
  872. * @return void
  873. */
  874. async function extractMaterials(doc, materials, log) {
  875. const list = Array.from(materials.querySelectorAll("figure")).reduce((res, el) => {
  876. const link = el.querySelector("a");
  877. if (link && link.href) {
  878. const ch = new FB2Chapter();
  879. const cp = el.querySelector("figcaption");
  880. const ds = (cp && cp.textContent.trim() !== "") ? cp.textContent.trim() : "Без описания";
  881. const im = new FB2Image(link.href);
  882. ch.children.push(new FB2Paragraph(ds));
  883. ch.children.push(im);
  884. res.push(ch);
  885. doc.binaries.push(im);
  886. }
  887. return res;
  888. }, []);
  889.  
  890. let cnt = list.length;
  891. if (cnt) {
  892. let pos = 0;
  893. while (true) {
  894. const l = [];
  895. // Грузить не более 5 картинок за раз
  896. while (pos < cnt && l.length < 5) {
  897. const li = log.message("Загрузка изображения...");
  898. l.push(list[pos++].children[1].load((loaded, total) => li.text(`${Math.round(loaded / total * 100)}%`))
  899. .then(() => li.ok())
  900. .catch(err => {
  901. li.fail();
  902. if (err.name === "AbortError") throw err;
  903. })
  904. );
  905. }
  906. if (!l.length || stage !== 1) break;
  907. await Promise.all(l);
  908. }
  909. const ch = new FB2Chapter("Дополнительные материалы");
  910. ch.children = list;
  911. doc.chapters.push(ch);
  912. } else {
  913. log.warning("Изображения не найдены");
  914. }
  915. }
  916.  
  917. /**
  918. * Добавляет главу с обращением к читателю по результатам сканирования хештегов
  919. *
  920. * @param FB2DocumentEx doc Формируемый документ
  921. * @param LogElement log Лог для фиксации процесса формирования книги
  922. *
  923. * @return void
  924. */
  925. function addWishesChapter(doc, log) {
  926. const li = log.message("Формирование раздела...");
  927. let wl = [];
  928. try {
  929. if (doc.wishes.likes) wl.push("наградите автора лайком");
  930. if (doc.wishes.comments) wl.push("оставьте отзыв");
  931.  
  932. const ch = new FB2Chapter("Обращение к читателю");
  933. const p = new FB2Paragraph();
  934. p.children.push(new FB2Text("Если вам понравилась книга, " + wl.join(" или ") + ": "));
  935. const l = new FB2Link(doc.sourceURL);
  936. l.value = doc.sourceURL;
  937. p.children.push(l);
  938. ch.children.push(p);
  939. doc.chapters.push(ch);
  940. li.ok();
  941. } catch (err) {
  942. li.fail();
  943. throw err;
  944. }
  945. }
  946.  
  947. /**
  948. * Создает картинку-заглушку в фомате png
  949. *
  950. * @return FB2Image
  951. */
  952. function getDummyImage() {
  953. const WIDTH = 300;
  954. const HEIGHT = 150;
  955. let canvas = document.createElement("canvas");
  956. canvas.setAttribute("width", WIDTH);
  957. canvas.setAttribute("height", HEIGHT);
  958. if (!canvas.getContext) throw new Error("Ошибка работы с элементом canvas");
  959. let ctx = canvas.getContext("2d");
  960. // Фон
  961. ctx.fillStyle = "White";
  962. ctx.fillRect(0, 0, WIDTH, HEIGHT);
  963. // Обводка
  964. ctx.lineWidth = 4;
  965. ctx.strokeStyle = "Gray";
  966. ctx.strokeRect(0, 0, WIDTH, HEIGHT);
  967. // Тень
  968. ctx.shadowOffsetX = 2;
  969. ctx.shadowOffsetY = 2;
  970. ctx.shadowBlur = 2;
  971. ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
  972. // Крест
  973. let margin = 25;
  974. let size = 40;
  975. ctx.lineWidth = 10;
  976. ctx.strokeStyle = "Red";
  977. ctx.moveTo(WIDTH / 2 - size / 2, margin);
  978. ctx.lineTo(WIDTH / 2 + size / 2, margin + size);
  979. ctx.stroke();
  980. ctx.moveTo(WIDTH / 2 + size / 2, margin);
  981. ctx.lineTo(WIDTH / 2 - size / 2, margin + size);
  982. ctx.stroke();
  983. // Текст
  984. ctx.font = "42px Times New Roman";
  985. ctx.fillStyle = "Black";
  986. ctx.textAlign = "center";
  987. ctx.fillText("No image", WIDTH / 2, HEIGHT - 30, WIDTH);
  988. // Формирование итогового FB2 элемента
  989. const img = new FB2Image();
  990. img.id = "dummy.png";
  991. img.type = "image/png";
  992. let data_str = canvas.toDataURL(img.type);
  993. img.value = data_str.substr(data_str.indexOf(",") + 1);
  994. return img;
  995. }
  996.  
  997. /**
  998. * Замена всех незагруженных изображений другим изображением
  999. *
  1000. * @param FB2DocumentEx doc Формируемый документ
  1001. * @param FB2Image img Изображение для замены
  1002. *
  1003. * @return void
  1004. */
  1005. function replaceBadImages(doc, img) {
  1006. const replaceChildren = function(fr, img) {
  1007. for (let i = 0; i < fr.children.length; ++i) {
  1008. const ch = fr.children[i];
  1009. if (ch instanceof FB2Image) {
  1010. if (!ch.value) fr.children[i] = img;
  1011. } else {
  1012. replaceChildren(ch, img);
  1013. }
  1014. }
  1015. };
  1016. if (doc.annotation) replaceChildren(doc.annotation, img);
  1017. doc.chapters.forEach(ch => replaceChildren(ch, img));
  1018. if (doc.materials) replaceChildren(doc.materials, img);
  1019. }
  1020.  
  1021. /**
  1022. * Формирует имя файла для книги
  1023. *
  1024. * @param FB2DocumentEx doc FB2 документ
  1025. * @param Object extra Дополнительные данные
  1026. *
  1027. * @return string Имя файла с расширением
  1028. */
  1029. function genBookFileName(doc, extra) {
  1030. function xtrim(s) {
  1031. const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s);
  1032. return r && r[1] || s;
  1033. }
  1034.  
  1035. const fn_template = Settings.get("filename", true).trim();
  1036. const ndata = new Map();
  1037. // Автор [\a]
  1038. const author = doc.bookAuthors[0];
  1039. if (author) {
  1040. const author_names = [ author.firstName, author.middleName, author.lastName ].reduce(function(res, nm) {
  1041. if (nm) res.push(nm);
  1042. return res;
  1043. }, []);
  1044. if (author_names.length) {
  1045. ndata.set("a", author_names.join(" "));
  1046. } else if (author.nickName) {
  1047. ndata.set("a", author.nickName);
  1048. }
  1049. }
  1050. // Серия [\s, \n, \N]
  1051. const seq_names = [];
  1052. if (doc.sequence && doc.sequence.name) {
  1053. const seq_name = xtrim(doc.sequence.name);
  1054. if (seq_name) {
  1055. const seq_num = doc.sequence.number;
  1056. if (seq_num) {
  1057. ndata.set("n", seq_num);
  1058. ndata.set("N", (seq_num.length < 2 ? "0" : "") + seq_num);
  1059. seq_names.push(seq_name + " " + seq_num);
  1060. }
  1061. ndata.set("s", seq_name);
  1062. seq_names.push(seq_name);
  1063. }
  1064. }
  1065. // Название книги. Делается попытка вырезать название серии из названия книги [\t]
  1066. // Название серии будет удалено из названия книги лишь в том случае, если оно присутвует в шаблоне.
  1067. let book_name = xtrim(doc.bookTitle);
  1068. if (ndata.has("s") && fn_template.includes("\\s")) {
  1069. const book_lname = book_name.toLowerCase();
  1070. const book_len = book_lname.length;
  1071. for (let i = 0; i < seq_names.length; ++i) {
  1072. const seq_lname = seq_names[i].toLowerCase();
  1073. const seq_len = seq_lname.length;
  1074. if (book_len - seq_len >= 5) {
  1075. let str = null;
  1076. if (book_lname.startsWith(seq_lname)) str = xtrim(book_name.substr(seq_len));
  1077. else if (book_lname.endsWith(seq_lname)) str = xtrim(book_name.substr(-seq_len));
  1078. if (str) {
  1079. if (str.length >= 5) book_name = str;
  1080. break;
  1081. }
  1082. }
  1083. }
  1084. }
  1085. ndata.set("t", book_name);
  1086. // Статус скачиваемой книжки [\b]
  1087. let status = "";
  1088. if (doc.totalChapters === doc.chapters.length - doc.extraChapters) {
  1089. switch (doc.status) {
  1090. case "finished":
  1091. status = "F";
  1092. break;
  1093. case "in-progress":
  1094. status = "U";
  1095. break;
  1096. case "fragment":
  1097. status = "P";
  1098. break;
  1099. }
  1100. } else {
  1101. status = "P";
  1102. }
  1103. ndata.set("b", status);
  1104. // Выбранные главы [\c]
  1105. // Если цикл завершен и выбраны все главы (статус "F"), то возвращается пустое значение.
  1106. if (status != "F") {
  1107. const cr = extra.chaptersRange;
  1108. ndata.set("c", cr[0] === cr[1] ? `${cr[0]}` : `${cr[0]}-${cr[1]}`);
  1109. }
  1110. // Id книги [\i]
  1111. ndata.set("i", doc.id);
  1112. // Окончательное формирование имени файла плюс дополнительные чистки и проверки.
  1113. function replacer(str) {
  1114. let cnt = 0;
  1115. const new_str = str.replace(/\\([asnNtbci])/g, (match, ti) => {
  1116. const res = ndata.get(ti);
  1117. if (res === undefined) return "";
  1118. ++cnt;
  1119. return res;
  1120. });
  1121. return { str: new_str, count: cnt };
  1122. }
  1123. function processParts(str, depth) {
  1124. const parts = [];
  1125. const pos = str.indexOf('<');
  1126. if (pos !== 0) {
  1127. parts.push(replacer(pos == -1 ? str : str.slice(0, pos)));
  1128. }
  1129. if (pos != -1) {
  1130. let i = pos + 1;
  1131. let n = 1;
  1132. for ( ; i < str.length; ++i) {
  1133. const c = str[i];
  1134. if (c == '<') {
  1135. ++n;
  1136. } else if (c == '>') {
  1137. --n;
  1138. if (!n) {
  1139. parts.push(processParts(str.slice(pos + 1, i), depth + 1));
  1140. break;
  1141. }
  1142. }
  1143. }
  1144. if (++i < str.length) parts.push(processParts(str.slice(i), depth));
  1145. }
  1146. const sa = [];
  1147. let cnt = 0
  1148. for (const it of parts) {
  1149. sa.push(it.str);
  1150. cnt += it.count;
  1151. }
  1152. return {
  1153. str: (!depth || cnt) ? sa.join("") : "",
  1154. count: cnt
  1155. };
  1156. }
  1157. const fname = processParts(fn_template, 0).str.replace(/[\0\/\\\"\*\?\<\>\|:]+/g, "");
  1158. return `${fname.substr(0, 250)}.fb2`;
  1159. }
  1160.  
  1161. /**
  1162. * Создает пункт меню настроек скрипта если не существует
  1163. *
  1164. * @return void
  1165. */
  1166. function ensureSettingsMenuItems() {
  1167. const menu = document.querySelector("aside nav ul.nav");
  1168. if (!menu || menu.querySelector("li.atex-settings")) return;
  1169. let item = document.createElement("li");
  1170. if (!menu.querySelector("li.Ox90-settings-menu")) {
  1171. item.classList.add("nav-heading", "Ox90-settings-menu");
  1172. menu.appendChild(item);
  1173. item.innerHTML = '<span><i class="icon-cogs icon-fw"></i> Внешние скрипты</span>';
  1174. item = document.createElement("li");
  1175. }
  1176. item.classList.add("atex-settings");
  1177. menu.appendChild(item);
  1178. item.innerHTML = '<a class="nav-link" href="/account/settings?script=atex">AutorTodayExtractor</a>';
  1179. }
  1180.  
  1181. /**
  1182. * Генерирует страницу настроек скрипта
  1183. *
  1184. * @return void
  1185. */
  1186. function handleSettingsPage() {
  1187. // Изменить активный пункт меню
  1188. const menu = document.querySelector("aside nav ul.nav");
  1189. if (menu) {
  1190. const active = menu.querySelector("li.active");
  1191. active && active.classList.remove("active");
  1192. menu.querySelector("li.atex-settings").classList.add("active");
  1193. }
  1194. // Найти секцию с контентом
  1195. const section = document.querySelector("#pjax-container section.content");
  1196. if (!section) return;
  1197. // Очистить секцию
  1198. while (section.firstChild) section.lastChild.remove();
  1199. // Создать свою панель и добавить в секцию
  1200. const panel = document.createElement("div");
  1201. panel.classList.add("panel", "panel-default");
  1202. section.appendChild(panel);
  1203. panel.innerHTML = '<div class="panel-heading">Параметры скрипта AuthorTodayExtractor</div>';
  1204. const body = document.createElement("div");
  1205. body.classList.add("panel-body");
  1206. panel.appendChild(body);
  1207. const form = document.createElement("form");
  1208. form.method = "post";
  1209. form.style.display = "flex";
  1210. form.style.rowGap = "1em";
  1211. form.style.flexDirection = "column";
  1212. body.appendChild(form);
  1213. let fndiv = document.createElement("div");
  1214. fndiv.innerHTML = '<label>Шаблон имени файла (без расширения)</label>';
  1215. form.appendChild(fndiv);
  1216. const filename = document.createElement("input");
  1217. filename.type = "text";
  1218. filename.style.maxWidth = "25em";
  1219. filename.classList.add("form-control");
  1220. filename.value = Settings.get("filename");
  1221. fndiv.appendChild(filename);
  1222. const descr = document.createElement("ul");
  1223. descr.style.color = "gray";
  1224. descr.style.fontSize = "90%";
  1225. descr.style.margin = "0";
  1226. descr.style.paddingLeft = "2em";
  1227. descr.innerHTML =
  1228. "<li>\\a - Автор книги;</li>" +
  1229. "<li>\\s - Серия книги;</li>" +
  1230. "<li>\\n - Порядковый номер в серии;</li>" +
  1231. "<li>\\N - Порядковый номер в серии с ведущим нулем;</li>" +
  1232. "<li>\\t - Название книги;</li>" +
  1233. "<li>\\i - Идентификатор книги (workId на сайте);</li>" +
  1234. "<li>\\b - Статус книги (F - завершена, U - не завершена, P - выгружена частично);</li>" +
  1235. "<li>\\c - Диапазон глав в случае, если книга не завершена или выбраны не все главы;</li>" +
  1236. "<li>&lt;&hellip;&gt; - Если внутри такого блока будут отсутвовать данные для шаблона, то весь блок будет удален;</li>";
  1237. fndiv.appendChild(descr);
  1238. let addnotes = HTML.createCheckbox("Добавить примечания автора в аннотацию", Settings.get("addnotes"));
  1239. let addcover = HTML.createCheckbox("Грузить обложку книги", Settings.get("addcover"));
  1240. let addimages = HTML.createCheckbox("Грузить картинки внутри глав", Settings.get("addimages"));
  1241. let materials = HTML.createCheckbox("Грузить дополнительные материалы", Settings.get("materials"));
  1242. let sethint = HTML.createCheckbox("Отображать подсказку о настройках в логе выгрузки", Settings.get("sethint"));
  1243. form.append(addnotes, addcover, addimages, materials, sethint);
  1244. addnotes = addnotes.querySelector("input");
  1245. addcover = addcover.querySelector("input");
  1246. addimages = addimages.querySelector("input");
  1247. materials = materials.querySelector("input");
  1248. sethint = sethint.querySelector("input");
  1249.  
  1250. const buttons = document.createElement("div");
  1251. buttons.innerHTML = '<button type="submit" class="btn btn-primary">Сохранить</button>';
  1252. form.appendChild(buttons);
  1253.  
  1254. form.addEventListener("submit", event => {
  1255. event.preventDefault();
  1256. try {
  1257. Settings.set("filename", filename.value);
  1258. Settings.set("addnotes", addnotes.checked);
  1259. Settings.set("addcover", addcover.checked);
  1260. Settings.set("addimages", addimages.checked);
  1261. Settings.set("materials", materials.checked);
  1262. Settings.set("sethint", sethint.checked);
  1263. Settings.save();
  1264. Notification.display("Настройки сохранены", "success");
  1265. } catch (err) {
  1266. console.error(err);
  1267. Notification.display("Ошибка сохранения настроек");
  1268. }
  1269. });
  1270. }
  1271.  
  1272. //---------- Классы ----------
  1273.  
  1274. /**
  1275. * Расширение класса библиотеки в целях обеспечения загрузки изображений,
  1276. * информирования о наличии неизвестных HTML элементов и отображения прогресса в логе.
  1277. */
  1278. class FB2DocumentEx extends FB2Document {
  1279. constructor() {
  1280. super();
  1281. this.wishes = {};
  1282. this.unknowns = 0;
  1283. this.extraChapters = 0;
  1284. }
  1285.  
  1286. parse(parser_id, log, params, ...args) {
  1287. const bin_start = this.binaries.length;
  1288. super.parse(parser_id, ...args).forEach(el => {
  1289. log.warning(`Найден неизвестный элемент: ${el.nodeName}`);
  1290. ++this.unknowns;
  1291. });
  1292. const u_bin = this.binaries.slice(bin_start);
  1293. return (async () => {
  1294. const it = u_bin[Symbol.iterator]();
  1295. const get_list = function() {
  1296. const list = [];
  1297. for (let i = 0; i < 5; ++i) {
  1298. const r = it.next();
  1299. if (r.done) break;
  1300. list.push(r.value);
  1301. }
  1302. return list;
  1303. };
  1304. while (true) {
  1305. const list = get_list();
  1306. if (!list.length || stage !== 1) break;
  1307. await Promise.all(list.map(bin => {
  1308. const li = log.message("Загрузка изображения...");
  1309. if (params.noImages) return Promise.resolve().then(() => li.skipped());
  1310. return bin.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"))
  1311. .then(() => li.ok())
  1312. .catch((err) => {
  1313. li.fail();
  1314. if (err.name === "AbortError") throw err;
  1315. });
  1316. }));
  1317. }
  1318. })();
  1319. }
  1320. }
  1321.  
  1322. /**
  1323. * Расширение класса библиотеки в целях передачи элементов с изображениями
  1324. * и неизвестных элементов в документ, для возможности раздельной обработки
  1325. * аннотации и примечаний автора, а также для поиска и удаления хештегов #want*
  1326. * внутри аннотации и примечаний автора.
  1327. */
  1328. class AnnotationParser extends FB2AnnotationParser {
  1329. run(fb2doc, element) {
  1330. this._wishes = new Set();
  1331. this._binaries = [];
  1332. this._unknown_nodes = [];
  1333. this.parse(element);
  1334. this._wishes.forEach(ht => fb2doc.wishes[ht] = true);
  1335. if (this._annotation && this._annotation.children.length) {
  1336. this._annotation.normalize();
  1337. if (!fb2doc.annotation) {
  1338. fb2doc.annotation = this._annotation;
  1339. } else {
  1340. this._annotation.children.forEach(ch => fb2doc.annotation.children.push(ch));
  1341. }
  1342. this._binaries.forEach(bin => fb2doc.binaries.push(bin));
  1343. }
  1344. const un = this._unknown_nodes;
  1345. this._wishes = null;
  1346. this._binaries = null;
  1347. this._annotation = null;
  1348. this._unknown_nodes = null;
  1349. return un;
  1350. }
  1351.  
  1352. processElement(fb2el, depth) {
  1353. if (fb2el instanceof FB2UnknownNode) this._unknown_nodes.push(fb2el.value);
  1354. if (typeof(fb2el.value) === "string" && fb2el.value.length) {
  1355. [ "likes", "comments" ].forEach(ht => {
  1356. fb2el.value = fb2el.value.replace(new RegExp("#want" + ht + "(\\s+|$)", "gi"), () => {
  1357. this._wishes.add(ht);
  1358. return "";
  1359. });
  1360. });
  1361. }
  1362. return super.processElement(fb2el, depth);
  1363. }
  1364. }
  1365.  
  1366. /**
  1367. * Расширение класса библиотеки в целях передачи списка неизвестных элементов в документ
  1368. */
  1369. class ChapterParser extends FB2ChapterParser {
  1370. run(fb2doc, element, title) {
  1371. this._unknown_nodes = [];
  1372. super.run(fb2doc, element, title);
  1373. const un = this._unknown_nodes;
  1374. this._unknown_nodes = null;
  1375. return un;
  1376. }
  1377.  
  1378. startNode(node, depth) {
  1379. if (node.nodeName === "DIV") {
  1380. const nnode = document.createElement("p");
  1381. node.childNodes.forEach(ch => nnode.appendChild(ch.cloneNode(true)));
  1382. node = nnode;
  1383. }
  1384. return super.startNode(node, depth);
  1385. }
  1386.  
  1387. processElement(fb2el, depth) {
  1388. if (fb2el instanceof FB2UnknownNode) this._unknown_nodes.push(fb2el.value);
  1389. return super.processElement(fb2el, depth);
  1390. }
  1391. }
  1392.  
  1393. /**
  1394. * Класс управления модальным диалоговым окном
  1395. */
  1396. class ModalDialog {
  1397. constructor(params) {
  1398. this._modal = null;
  1399. this._overlay = null;
  1400. this._title = params.title || "";
  1401. this._onclose = params.onclose;
  1402. }
  1403.  
  1404. show() {
  1405. this._ensureForm();
  1406. this._ensureContent();
  1407. document.body.appendChild(this._overlay);
  1408. document.body.classList.add("modal-open");
  1409. this._modal.focus();
  1410. }
  1411.  
  1412. hide() {
  1413. this._overlay && this._overlay.remove();
  1414. this._overlay = null;
  1415. this._modal = null;
  1416. document.body.classList.remove("modal-open");
  1417. if (this._onclose) {
  1418. this._onclose();
  1419. this._onclose = null;
  1420. }
  1421. }
  1422.  
  1423. _ensureForm() {
  1424. if (!this._overlay) {
  1425. this._overlay = document.createElement("div");
  1426. this._overlay.classList.add("ate-dlg-overlay");
  1427. this._modal = this._overlay.appendChild(document.createElement("div"));
  1428. this._modal.classList.add("ate-dialog");
  1429. this._modal.tabIndex = -1;
  1430. this._modal.setAttribute("role", "dialog");
  1431. const header = this._modal.appendChild(document.createElement("div"));
  1432. header.classList.add("ate-title");
  1433. header.appendChild(document.createElement("div")).textContent = this._title;
  1434. const cb = header.appendChild(document.createElement("button"));
  1435. cb.type = "button";
  1436. cb.classList.add("ate-close-btn");
  1437. cb.textContent = "×";
  1438. this._modal.appendChild(document.createElement("form"));
  1439.  
  1440. this._overlay.addEventListener("click", event => {
  1441. if (event.target === this._overlay || event.target.closest(".ate-close-btn")) this.hide();
  1442. });
  1443. this._overlay.addEventListener("keydown", event => {
  1444. if (event.code == "Escape" && !event.shiftKey && !event.ctrlKey && !event.altKey) {
  1445. event.preventDefault();
  1446. this.hide();
  1447. }
  1448. });
  1449. }
  1450. }
  1451.  
  1452. _ensureContent() {
  1453. }
  1454. }
  1455.  
  1456. class DownloadDialog extends ModalDialog {
  1457. constructor(params) {
  1458. super(params);
  1459. this.log = null;
  1460. this.button = null;
  1461. this._ann = params.annotation;
  1462. this._cvr = params.cover;
  1463. this._mat = params.materials;
  1464. this._set = params.settings;
  1465. this._chs = params.chapters;
  1466. this._sub = params.onsubmit;
  1467. this._pg1 = null;
  1468. this._pg2 = null;
  1469. }
  1470.  
  1471. hide() {
  1472. super.hide();
  1473. this.log = null;
  1474. this.button = null;
  1475. }
  1476.  
  1477. nextPage() {
  1478. this._pg1.style.display = "none";
  1479. this._pg2.style.display = "";
  1480. }
  1481.  
  1482. _ensureContent() {
  1483. const form = this._modal.querySelector("form");
  1484. form.replaceChildren();
  1485. this._pg1 = form.appendChild(document.createElement("div"));
  1486. this._pg2 = form.appendChild(document.createElement("div"));
  1487. this._pg1.classList.add("ate-page");
  1488. this._pg2.classList.add("ate-page");
  1489. this._pg2.style.display = "none";
  1490.  
  1491. const fst = this._pg1.appendChild(document.createElement("fieldset"));
  1492. const leg = fst.appendChild(document.createElement("legend"));
  1493. leg.textContent = "Главы для выгрузки";
  1494.  
  1495. const chs = fst.appendChild(document.createElement("div"));
  1496. chs.classList.add("ate-chapter-list");
  1497.  
  1498. const ntp = chs.appendChild(document.createElement("div"));
  1499. ntp.classList.add("ate-note");
  1500. ntp.textContent = "Выберите главы для выгрузки. Обратите внимание: выгружены могут быть только доступные вам главы.";
  1501.  
  1502. const tbd = fst.appendChild(document.createElement("div"));
  1503. tbd.classList.add("ate-toolbar");
  1504.  
  1505. const its = tbd.appendChild(document.createElement("span"));
  1506. const selected = document.createElement("strong");
  1507. selected.textContent = 0;
  1508. const total = document.createElement("strong");
  1509. its.append("Выбрано глав: ", selected, " из ", total);
  1510.  
  1511. const tb1 = tbd.appendChild(document.createElement("button"));
  1512. tb1.type = "button";
  1513. tb1.title = "Выделить все/ничего";
  1514. tb1.classList.add("ate-group-select");
  1515. const tb1i = document.createElement("i");
  1516. tb1i.classList.add("icon-check");
  1517. tb1.append(tb1i, " ?");
  1518.  
  1519. const nte = HTML.createCheckbox("Добавить примечания автора в аннотацию", this._ann && this._set.addnotes);
  1520. if (!this._ann) nte.querySelector("input").disabled = true;
  1521. this._pg1.appendChild(nte);
  1522.  
  1523. const cve = HTML.createCheckbox("Грузить обложку книги", this._cvr && this._set.addcover);
  1524. if (!this._cvr) cve.querySelector("input").disabled = true;
  1525. this._pg1.appendChild(cve);
  1526.  
  1527. const img = HTML.createCheckbox("Грузить картинки внутри глав", this._set.addimages);
  1528. this._pg1.appendChild(img);
  1529.  
  1530. const nmt = HTML.createCheckbox("Грузить дополнительные материалы", this._mat && this._set.materials);
  1531. if (!this._mat) nmt.querySelector("input").disabled = true;
  1532. this._pg1.appendChild(nmt);
  1533.  
  1534. const log = this._pg2.appendChild(document.createElement("div"));
  1535.  
  1536. const sbd = form.appendChild(document.createElement("div"));
  1537. sbd.classList.add("ate-buttons");
  1538. const sbt = sbd.appendChild(document.createElement("button"));
  1539. sbt.type = "submit";
  1540. sbt.classList.add("button", "btn", "btn-success");
  1541. sbt.textContent = "Продолжить";
  1542. const cbt = sbd.appendChild(document.createElement("button"));
  1543. cbt.type = "button";
  1544. cbt.classList.add("button", "btn", "btn-default");
  1545. cbt.textContent = "Закрыть";
  1546.  
  1547. let ch_cnt = 0;
  1548. this._chs.forEach(ch => {
  1549. const el = HTML.createChapterCheckbox(ch);
  1550. ch.element = el.querySelector("input");
  1551. chs.append(el);
  1552. ++ch_cnt;
  1553. });
  1554. total.textContent = ch_cnt;
  1555.  
  1556. chs.addEventListener("change", event => {
  1557. const cnt = this._chs.reduce((cnt, ch) => {
  1558. if (!ch.locked && ch.element.checked) ++cnt;
  1559. return cnt;
  1560. }, 0);
  1561. selected.textContent = cnt;
  1562. sbt.disabled = !cnt;
  1563. });
  1564.  
  1565. tb1.addEventListener("click", event => {
  1566. const chf = this._chs.some(ch => !ch.locked && !ch.element.checked);
  1567. this._chs.forEach(ch => {
  1568. ch.element.checked = (chf && !ch.locked);
  1569. });
  1570. chs.dispatchEvent(new Event("change"));
  1571. });
  1572.  
  1573. cbt.addEventListener("click", event => this.hide());
  1574.  
  1575. form.addEventListener("submit", event => {
  1576. event.preventDefault();
  1577. if (this._sub) {
  1578. const res = {};
  1579. res.authorNotes = nte.querySelector("input").checked;
  1580. res.skipCover = !cve.querySelector("input").checked;
  1581. res.addimages = img.querySelector("input").checked;
  1582. res.materials = nmt.querySelector("input").checked;
  1583. let ch_min = 0;
  1584. let ch_max = 0;
  1585. res.chapters = this._chs.reduce((res, ch, idx) => {
  1586. if (!ch.locked && ch.element.checked) {
  1587. res.push({ title: ch.title, workId: ch.workId, chapterId: ch.chapterId });
  1588. ch_max = idx + 1;
  1589. if (!ch_min) ch_min = ch_max;
  1590. }
  1591. return res;
  1592. }, []);
  1593. res.chaptersRange = [ ch_min, ch_max ];
  1594. this._sub(res);
  1595. }
  1596. });
  1597.  
  1598. chs.dispatchEvent(new Event("change"));
  1599. this.log = log;
  1600. this.button = sbt;
  1601. }
  1602. }
  1603.  
  1604. /**
  1605. * Класс общего назначения для создания однотипных HTML элементов
  1606. */
  1607. class HTML {
  1608.  
  1609. /**
  1610. * Создает единичный элемент типа checkbox в стиле сайта
  1611. *
  1612. * @param title string Подпись для checkbox
  1613. * @param checked bool Начальное состояние checkbox
  1614. *
  1615. * @return Element HTML-элемент для последующего добавления на форму
  1616. */
  1617. static createCheckbox(title, checked) {
  1618. const root = document.createElement("div");
  1619. root.classList.add("ate-checkbox");
  1620. const label = root.appendChild(document.createElement("label"));
  1621. const input = document.createElement("input");
  1622. input.type = "checkbox";
  1623. input.checked = checked;
  1624. const span = document.createElement("span");
  1625. span.classList.add("icon-check-bold");
  1626. label.append(input, span, title);
  1627. return root;
  1628. }
  1629.  
  1630. /**
  1631. * Создает checkbox для диалога выбора глав
  1632. *
  1633. * @param chapter object Данные главы
  1634. *
  1635. * @return Element HTML-элемент для последующего добавления на форму
  1636. */
  1637. static createChapterCheckbox(chapter) {
  1638. const root = this.createCheckbox(chapter.title || "Без названия", !chapter.locked);
  1639. if (chapter.locked) {
  1640. root.querySelector("input").disabled = true;
  1641. const lock = document.createElement("i");
  1642. lock.classList.add("icon-lock", "text-muted", "ml-sm");
  1643. root.children[0].appendChild(lock);
  1644. }
  1645. if (!chapter.title) root.style.fontStyle = "italic";
  1646. return root;
  1647. }
  1648. }
  1649.  
  1650. /**
  1651. * Класс для отображения сообщений в виде лога
  1652. */
  1653. class LogElement {
  1654.  
  1655. /**
  1656. * Конструктор
  1657. *
  1658. * @param Element element HTML-элемент, в который будут добавляться записи
  1659. */
  1660. constructor(element) {
  1661. element.classList.add("ate-log");
  1662. this._element = element;
  1663. }
  1664.  
  1665. /**
  1666. * Добавляет сообщение с указанным текстом и цветом
  1667. *
  1668. * @param mixed msg Сообщение для отображения. Может быть HTML-элементом
  1669. * @param string color Цвет в формате CSS (не обязательный параметр)
  1670. *
  1671. * @return LogItemElement Элемент лога, в котором может быть отображен результат или другой текст
  1672. */
  1673. message(msg, color) {
  1674. const item = document.createElement("div");
  1675. if (msg instanceof HTMLElement) {
  1676. item.appendChild(msg);
  1677. } else {
  1678. item.textContent = msg;
  1679. }
  1680. if (color) item.style.color = color;
  1681. this._element.appendChild(item);
  1682. this._element.scrollTop = this._element.scrollHeight;
  1683. return new LogItemElement(item);
  1684. }
  1685.  
  1686. /**
  1687. * Сообщение с темно-красным цветом
  1688. *
  1689. * @param mixed msg См. метод message
  1690. *
  1691. * @return LogItemElement См. метод message
  1692. */
  1693. warning(msg) {
  1694. this.message(msg, "#a00");
  1695. }
  1696. }
  1697.  
  1698. /**
  1699. * Класс реализации элемента записи в логе,
  1700. * используется классом LogElement.
  1701. */
  1702. class LogItemElement {
  1703. constructor(element) {
  1704. this._element = element;
  1705. this._span = null;
  1706. }
  1707.  
  1708. /**
  1709. * Отображает сообщение "ok" в конце записи лога зеленым цветом
  1710. *
  1711. * @return void
  1712. */
  1713. ok() {
  1714. this._setSpan("ok", "green");
  1715. }
  1716.  
  1717. /**
  1718. * Аналогичен методу ok
  1719. */
  1720. fail() {
  1721. this._setSpan("ошибка!", "red");
  1722. }
  1723.  
  1724. /**
  1725. * Аналогичен методу ok
  1726. */
  1727. skipped() {
  1728. this._setSpan("пропущено", "blue");
  1729. }
  1730.  
  1731. /**
  1732. * Отображает указанный текст стандартным цветом сайта
  1733. *
  1734. * @param string s Текст для отображения
  1735. *
  1736. */
  1737. text(s) {
  1738. this._setSpan(s, "");
  1739. }
  1740.  
  1741. _setSpan(text, color) {
  1742. if (!this._span) {
  1743. this._span = document.createElement("span");
  1744. this._element.appendChild(this._span);
  1745. }
  1746. this._span.style.color = color;
  1747. this._span.textContent = " " + text;
  1748. }
  1749. }
  1750.  
  1751.  
  1752. /**
  1753. * Класс реализует доступ к хранилищу с настройками скрипта
  1754. * Здесь используется localStorage
  1755. */
  1756. class Settings {
  1757.  
  1758. /**
  1759. * Возвращает значение опции по ее имени
  1760. *
  1761. * @param name string Имя опции
  1762. * @param reset bool Сбрасывает кэш перед получением опции
  1763. *
  1764. * @return mixed
  1765. */
  1766. static get(name, reset) {
  1767. if (reset) Settings._values = null;
  1768. this._ensureValues();
  1769. let val = Settings._values[name];
  1770. switch (name) {
  1771. case "filename":
  1772. if (typeof(val) !== "string" || val.trim() === "") val = "\\a.< \\s \\N.> \\t [AT-\\i-\\b]";
  1773. break;
  1774. case "sethint":
  1775. case "addcover":
  1776. case "addnotes":
  1777. case "addimages":
  1778. case "materials":
  1779. if (typeof(val) !== "boolean") val = true;
  1780. break;
  1781. }
  1782. return val;
  1783. }
  1784.  
  1785. /**
  1786. * Обновляет значение опции
  1787. *
  1788. * @param name string Имя опции
  1789. * @param value mixed Значение опции
  1790. *
  1791. * @return void
  1792. */
  1793. static set(name, value) {
  1794. this._ensureValues();
  1795. this._values[name] = value;
  1796. }
  1797.  
  1798. /**
  1799. * Сохраняет (перезаписывает) настройки скрипта в хранилище
  1800. *
  1801. * @return void
  1802. */
  1803. static save() {
  1804. localStorage.setItem("atex.settings", JSON.stringify(this._values || {}));
  1805. }
  1806.  
  1807. /**
  1808. * Читает настройки из локального хранилища, если они не были считаны ранее
  1809. */
  1810. static _ensureValues() {
  1811. if (this._values) return;
  1812. try {
  1813. this._values = JSON.parse(localStorage.getItem("atex.settings"));
  1814. } catch (err) {
  1815. this._values = null;
  1816. }
  1817. if (!this._values || typeof(this._values) !== "object") Settings._values = {};
  1818. }
  1819. }
  1820.  
  1821. /**
  1822. * Класс для работы с всплывающими уведомлениями. Для аутентичности используются стили сайта.
  1823. */
  1824. class Notification {
  1825.  
  1826. /**
  1827. * Конструктор. Вызвается из static метода display
  1828. *
  1829. * @param data Object Объект с полями text (string) и type (string)
  1830. *
  1831. * @return void
  1832. */
  1833. constructor(data) {
  1834. this._data = data;
  1835. this._element = null;
  1836. }
  1837.  
  1838. /**
  1839. * Возвращает HTML-элемент блока с текстом уведомления
  1840. *
  1841. * @return Element HTML-элемент для добавление в контейнер уведомлений
  1842. */
  1843. element() {
  1844. if (!this._element) {
  1845. this._element = document.createElement("div");
  1846. this._element.classList.add("toast", "toast-" + (this._data.type || "success"));
  1847. const msg = document.createElement("div");
  1848. msg.classList.add("toast-message");
  1849. msg.textContent = "ATEX: " + this._data.text;
  1850. this._element.appendChild(msg);
  1851. this._element.addEventListener("click", () => this._element.remove());
  1852. setTimeout(() => {
  1853. this._element.style.transition = "opacity 2s ease-in-out";
  1854. this._element.style.opacity = "0";
  1855. setTimeout(() => {
  1856. const ctn = this._element.parentElement;
  1857. this._element.remove();
  1858. if (!ctn.childElementCount) ctn.remove();
  1859. }, 2000); // Продолжительность плавного растворения уведомления - 2 секунды
  1860. }, 10000); // Длительность отображения уведомления - 10 секунд
  1861. }
  1862. return this._element;
  1863. }
  1864.  
  1865. /**
  1866. * Метод для отображения уведомлений на сайте. К тексту сообщения будет автоматически добавлена метка скрипта
  1867. *
  1868. * @param text string Текст уведомления
  1869. * @param type string Тип уведомления. Допустимые типы: `success`, `warning`, `error`
  1870. *
  1871. * @return void
  1872. */
  1873. static display(text, type) {
  1874. let ctn = document.getElementById("toast-container");
  1875. if (!ctn) {
  1876. ctn = document.createElement("div");
  1877. ctn.id = "toast-container";
  1878. ctn.classList.add("toast-top-right");
  1879. ctn.setAttribute("role", "alert");
  1880. ctn.setAttribute("aria-live", "polite");
  1881. document.body.appendChild(ctn);
  1882. }
  1883. ctn.appendChild((new Notification({ text: text, type: type })).element());
  1884. }
  1885. }
  1886.  
  1887. /**
  1888. * Класс загрузчика данных с сайта.
  1889. * Реализован через GM.xmlHttpRequest чтобы обойти ограничения CORS
  1890. * Если протокол, домен и порт совпадают, то используется стандартная загрузка.
  1891. */
  1892. class Loader extends FB2Loader {
  1893.  
  1894. /**
  1895. * Старт загрузки ресурса с указанного URL
  1896. *
  1897. * @param url Object Экземпляр класса URL (обязательный)
  1898. * @param params Object Объект с параметрами запроса (необязательный)
  1899. *
  1900. * @return mixed
  1901. */
  1902. static async addJob(url, params) {
  1903. params ||= {};
  1904. if (url.origin === document.location.origin) {
  1905. params.extended = true;
  1906. return super.addJob(url, params);
  1907. }
  1908.  
  1909. params.url = url;
  1910. params.method ||= "GET";
  1911. params.responseType = params.responseType === "binary" ? "blob" : "text";
  1912. if (!this.ctl_list) this.ctl_list = new Set();
  1913.  
  1914. return new Promise((resolve, reject) => {
  1915. let req = null;
  1916. params.onload = r => {
  1917. if (r.status === 200) {
  1918. const headers = new Headers();
  1919. r.responseHeaders.split("\n").forEach(hs => {
  1920. const h = /^([A-Za-z][A-Za-z0-9-]*):\s*(.+)$/.exec(hs);
  1921. if (h) headers.append(h[1], h[2].trim());
  1922. });
  1923. resolve({ headers: headers, response: r.response });
  1924. } else {
  1925. reject(new Error(`Сервер вернул ошибку (${r.status})`));
  1926. }
  1927. };
  1928. params.onerror = err => reject(err);
  1929. params.ontimeout = err => reject(err);
  1930. params.onloadend = () => {
  1931. if (req) this.ctl_list.delete(req);
  1932. };
  1933. if (params.onprogress) {
  1934. const progress = params.onprogress;
  1935. params.onprogress = pe => {
  1936. if (pe.lengthComputable) {
  1937. progress(pe.loaded, pe.total);
  1938. }
  1939. };
  1940. }
  1941. try {
  1942. req = GM.xmlHttpRequest(params);
  1943. if (req) this.ctl_list.add(req);
  1944. } catch (err) {
  1945. reject(err);
  1946. }
  1947. });
  1948. }
  1949.  
  1950. static abortAll() {
  1951. super.abortAll();
  1952. if (this.ctl_list) {
  1953. this.ctl_list.forEach(ctl => ctl.abort());
  1954. this.ctl_list.clear();
  1955. }
  1956. }
  1957. }
  1958.  
  1959. /**
  1960. * Переопределение загрузчика для возможности использования своего лоадера
  1961. * а также для того, чтобы избегать загрузки картинок в формате webp.
  1962. */
  1963. FB2Image.prototype._load = async function(url, params) {
  1964. // Попытка избавиться от webp через подмену параметров запроса
  1965. const u = new URL(url);
  1966. if (u.pathname.endsWith(".webp")) {
  1967. // Изначально была загружена картинка webp. Попытаться принудить сайт отдать картинку другого формата.
  1968. u.searchParams.set("format", "jpeg");
  1969. } else if (u.searchParams.get("format") === "webp") {
  1970. // Изначально картинка не webp, но параметр присутсвует. Вырезать.
  1971. // Возможно позже придется указывать его явно, когда сайт сделает webp форматом по умолчанию.
  1972. u.searchParams.delete("format");
  1973. }
  1974. // Еще одна попытка избавиться от webp через подмену заголовков
  1975. params ||= {};
  1976. params.headers ||= {};
  1977. if (!params.headers.Accept) params.headers.Accept = "image/jpeg,image/png,*/*;q=0.8";
  1978. // Использовать свой лоадер
  1979. return (await Loader.addJob(u, params)).response;
  1980. };
  1981.  
  1982. //-------------------------
  1983.  
  1984. function addStyle(css) {
  1985. const style = document.getElementById("ate_styles") || (function() {
  1986. const style = document.createElement('style');
  1987. style.type = 'text/css';
  1988. style.id = "ate_styles";
  1989. document.head.appendChild(style);
  1990. return style;
  1991. })();
  1992. const sheet = style.sheet;
  1993. sheet.insertRule(css, (sheet.rules || sheet.cssRules || []).length);
  1994. }
  1995.  
  1996. function addStyles() {
  1997. [
  1998. ".ate-dlg-overlay, .ate-title { display:flex; align-items:center; justify-content:center; }",
  1999. ".ate-dialog, .ate-dialog form, .ate-page, .ate-dialog fieldset, .ate-chapter-list { display:flex; flex-direction:column; }",
  2000. ".ate-page, .ate-dialog form, .ate-dialog fieldset { flex:1; overflow:hidden; }",
  2001. ".ate-dlg-overlay { position:fixed; top:0; left:0; bottom:0; right:0; overflow:auto; background-color:rgba(0,0,0,.3); white-space:nowrap; z-index:10000; }",
  2002. ".ate-dialog { position:fixed; top:0; left:0; bottom:0; right:0; background-color:#fff; overflow-y:auto; }",
  2003. ".ate-title { flex:0 0 auto; padding:10px; color:#66757f; background-color:#edf1f2; border-bottom:1px solid #e5e5e5; }",
  2004. ".ate-title>div:first-child { margin:auto; }",
  2005. ".ate-close-btn { cursor:pointer; border:0; background-color:transparent; font-size:21px; font-weight:bold; line-height:1; text-shadow:0 1px 0 #fff; opacity:.4; }",
  2006. ".ate-close-btn:hover { opacity:.9 }",
  2007. ".ate-dialog form { padding:10px 15px 15px; white-space:normal; gap:10px; min-height:30em; }",
  2008. ".ate-page { gap:10px; }",
  2009. ".ate-dialog fieldset { border:1px solid #bbb; border-radius:6px; padding:10px; margin:0; gap:10px; }",
  2010. ".ate-dialog legend { display:inline; width:unset; font-size:100%; margin:0; padding:0 5px; border:none; }",
  2011. ".ate-chapter-list { flex:1; gap:10px; overflow-y:auto; }",
  2012. ".ate-toolbar { display:flex; align-items:center; padding-top:10px; border-top:1px solid #bbb; }",
  2013. ".ate-group-select { margin-left:auto; }",
  2014. ".ate-log { flex:1; padding:6px; border:1px solid #bbb; border-radius:6px; overflow:auto; }",
  2015. ".ate-buttons { display:flex; flex-direction:column; gap:10px; }",
  2016. ".ate-buttons button { min-width:8em; }",
  2017. ".ate-checkbox label { cursor:pointer; margin:0; }",
  2018. ".ate-checkbox input { position:static; visibility:hidden; width:0; float:right; }", // position:absolute провоцирует прокрутку overlay-я в мобильной версии сайта
  2019. ".ate-checkbox span { position:relative; display:inline-block; width:17px; height:17px; margin-top:2px; margin-right:10px; text-align:center; vertical-align:top; border-radius:2px; border:1px solid #ccc; }",
  2020. ".ate-checkbox span:before { position:absolute; top:0; left:-1px; right:0; bottom:0; margin-left:1px; opacity:0; text-align:center; font-size:10px; line-height:16px; vertical-align:middle; }",
  2021. ".ate-checkbox:hover span { border-color:#5d9ced; }",
  2022. ".ate-checkbox input:checked + span { border-color:#5d9cec; background-color:#5d9ced; }",
  2023. ".ate-checkbox input:disabled + span { border-color:#ddd; background-color:#ddd; }",
  2024. ".ate-checkbox input:checked + span:before { color:#fff; opacity:1; transition:color .3s ease-out; }",
  2025. //".ate-chapter-list .ate-note { margin-bottom: 5px; }",
  2026. //".ate-chapter-list .ate-checkbox label { padding:5px; width:99%; }",
  2027. //".ate-chapter-list .ate-checkbox label:hover { color:#34749e; background-color:#f5f7fa; }",
  2028. "@media (min-width:520px) and (min-height:600px) {" +
  2029. ".ate-dialog { position:static; max-width:35em; min-width:30em; height:80vh; border-radius:6px; border:1px solid rgba(0,0,0,.2); box-shadow:0 3px 9px rgba(0,0,0,.5); }" +
  2030. ".ate-title { border-top-left-radius:6px; border-top-right-radius:6px; }" +
  2031. ".ate-buttons { flex-flow:row wrap; justify-content:center; }" +
  2032. ".ate-buttons .btn-default { display:none; }" +
  2033. "}"
  2034. ].forEach(s => addStyle(s));
  2035. }
  2036.  
  2037. // Запускает скрипт после загрузки страницы сайта
  2038. if (document.readyState === "loading") window.addEventListener("DOMContentLoaded", init);
  2039. else init();
  2040.  
  2041. })();