AuthorTodayExtractor

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

目前为 2024-06-20 提交的版本。查看 最新版本

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址