AuthorTodayExtractor

The script adds a button to the site to download books in FB2 format

目前为 2023-04-03 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name AuthorTodayExtractor
  3. // @name:ru AuthorTodayExtractor
  4. // @namespace 90h.yy.zz
  5. // @version 0.12.8
  6. // @author Ox90
  7. // @include https://author.today/*
  8. // @description The script adds a button to the site to download books in FB2 format
  9. // @description:ru Скрипт добавляет кнопку для выгрузки книги в формате FB2
  10. // @grant GM.xmlHttpRequest
  11. // @grant unsafeWindow
  12. // @connect *
  13. // @run-at document-start
  14. // @license MIT
  15. // ==/UserScript==
  16.  
  17. /**
  18. * Разрешение `@connect *` Необходимо для пользователей tampermonkey, чтобы получить возможность загружать картинки
  19. * внутри глав со сторонних ресурсов, когда авторы ссылаются в своих главах на сторонние хостинги картинок.
  20. * Такое хоть и редко, но встречается. Это разрешение прописано, чтобы пользователю отображалась кнопка
  21. * "Always allow all domains" при подтверждении запроса. Детали:
  22. * https://www.tampermonkey.net/documentation.php#_connect
  23. */
  24.  
  25. (function start() {
  26. "use strict";
  27.  
  28. let PROGRAM_NAME = "ATExtractor";
  29. let PROGRAM_ID = "atextr";
  30.  
  31. let app = null;
  32. let button = null;
  33. let mobile = false;
  34. let observer = null;
  35. let modalDialog = null;
  36.  
  37. Date.prototype.toAtomDate = function() {
  38. let m = this.getMonth() + 1;
  39. let d = this.getDate();
  40. return "" + this.getFullYear() + '-' + (m < 10 ? "0" : "") + m + "-" + (d < 10 ? "0" : "") + d;
  41. };
  42.  
  43. /**
  44. * Начальный запуск скрипта сразу после загрузки страницы сайта
  45. *
  46. * @return void
  47. */
  48. function init() {
  49. // Найти и сохранить объект App.
  50. // Он нужен для получения userId, который используется как часть ключа при расшифровке.
  51. app = window.app || (unsafeWindow && unsafeWindow.app) || {};
  52. // Инициировать структуру прерываемых запросов
  53. afetch.init();
  54. // Добавить кнопку на панель
  55. setMainButton();
  56. // Следить за логотипом сайта для проверки наличия кнопки
  57. observer.start(function() {
  58. observer.stop();
  59. setMainButton();
  60. observer.start();
  61. });
  62. }
  63.  
  64. /**
  65. * Находит панель и добавляет туда кнопку, если она отсутствует.
  66. * Вызывается не только при инициализации скрипта но и при изменениях в DOM-дереве
  67. *
  68. * @return void
  69. */
  70. function setMainButton() {
  71. // Проверить, что это текст, а не, например, аудиокнига, и найти панель для вставки кнопки
  72. let a_panel = null;
  73. if (document.querySelector("div.book-action-panel a[href^='/reader/']")) {
  74. a_panel = document.querySelector("div.book-panel div.book-action-panel");
  75. mobile = false;
  76. } else if (document.querySelector("div.work-details div.row a[href^='/reader/']")) {
  77. a_panel = document.querySelector("div.work-details div.row div.btn-library-work");
  78. a_panel = a_panel && a_panel.parentElement;
  79. mobile = true;
  80. } else return;
  81.  
  82. if (!a_panel) return;
  83.  
  84. if (!button) {
  85. // Похоже кнопки нет. Создать кнопку и привязать действие.
  86. button = createButton(mobile);
  87. let ael = mobile && button || button.children[0];
  88. ael.addEventListener("click", showChaptersDialog);
  89. }
  90.  
  91. if (!a_panel.contains(button)) {
  92. // Выбрать позицию для кнопки: или после оригинальной, или перед группой кнопок внизу.
  93. // Если не найти нужную позицию, тогда добавить кнопку последней в панели.
  94. let sbl = null;
  95. if (!mobile) {
  96. sbl = a_panel.querySelector("div.mt-lg>a.btn>i.icon-download");
  97. sbl && (sbl = sbl.parentElement.parentElement.nextElementSibling);
  98. } else {
  99. sbl = a_panel.querySelector("#btn-download");
  100. sbl && (sbl = sbl.nextElementSibling);
  101. }
  102. if (!sbl) {
  103. if (!mobile) {
  104. sbl = document.querySelector("div.mt-lg.text-center");
  105. } else {
  106. sbl = a_panel.querySelector("a.btn-work-more");
  107. }
  108. }
  109. // Добавить кнопку в документ
  110. if (sbl) {
  111. a_panel.insertBefore(button, sbl);
  112. } else {
  113. a_panel.appendChild(button);
  114. }
  115. }
  116. }
  117.  
  118. /**
  119. * Возвращает список глав из DOM-дерева сайта в формате
  120. * { title: string, locked: bool, workId: string, chapterId: string }.
  121. *
  122. * @return Promise Возвращается промис, который вернет массив объектов с данными о главах
  123. */
  124. function getChaptersList(params) {
  125. let res = [];
  126. let el_list = document.querySelectorAll(
  127. mobile &&
  128. "div.work-table-of-content>ul.list-unstyled>li" ||
  129. "div.book-tab-content>div#tab-chapters>ul.table-of-content>li"
  130. );
  131.  
  132. if (!el_list.length) {
  133. // Не найдено ни одной главы, возможно это рассказ
  134. // Запрашивает первую главу чтобы получить объект в исходном коде ответа сервера
  135. return afetch("/reader/" + params.workId, {
  136. method: "GET",
  137. responseType: "text",
  138. }).catch(function(err) {
  139. console.error(err);
  140. throw new Error("Ошибка загрузки метаданных главы");
  141. }).then(function(r) {
  142. let meta = /app\.init\("readerIndex",\s*(\{[\s\S]+?\})\s*\)/.exec(r.response); // Ищет строку инициализации с данными главы
  143. if (!meta) throw new Error("Не найдены метаданные книги в ответе сервера");
  144. let w_id = /\bworkId\s*:\s*(\d+)/.exec(r.response);
  145. w_id = w_id && w_id[1] || params.workId;
  146. let c_ls = /\bchapters\s*:\s*(\[.+\])\s*,?[\n\r]+/.exec(r.response);
  147. c_ls = c_ls && c_ls[1] || "[]";
  148. let chapters = (JSON.parse(c_ls) || []).map(function(ch) {
  149. return { title: ch.title, workId: w_id, chapterId: "" + ch.id };
  150. });
  151. let w_fm = /\bworkForm\s*:\s*"(.+)"/.exec(r.response);
  152. if (w_fm && w_fm[1].toLowerCase() === "story" && chapters.length === 1) {
  153. chapters[0].title = "";
  154. }
  155. chapters[0].locked = false;
  156. return chapters;
  157. });
  158. }
  159.  
  160. // Анализирует найденные HTML элементы с главами
  161. for (let i = 0; i < el_list.length; ++i) {
  162. let el = el_list[i].children[0];
  163. if (el) {
  164. let ids = null;
  165. let title = el.textContent;
  166. let locked = false;
  167. if (el.tagName === "A" && el.hasAttribute("href")) {
  168. ids = /^\/reader\/(\d+)\/(\d+)$/.exec(el.getAttribute("href"));
  169. } else if (el.tagName === "SPAN") {
  170. if (el.parentElement.querySelector("i.icon-lock")) {
  171. locked = true;
  172. }
  173. }
  174. if (title && (ids || locked)) {
  175. let ch = { title: title, locked: locked };
  176. if (ids) {
  177. ch.workId = ids[1];
  178. ch.chapterId = ids[2];
  179. }
  180. res.push(ch);
  181. }
  182. }
  183. }
  184. return Promise.resolve(res);
  185. }
  186.  
  187. /**
  188. * Запрашивает содержимое главы с сервера
  189. *
  190. * @param workId string Id книги
  191. * @param chapterId string Id главы
  192. *
  193. * @return Promise Возвращается промис, который вернет расшифрованную HTML-строку.
  194. */
  195. function getChapterContent(workId, chapterId) {
  196. // Id-ы числовые, отфильтрованы регуляркой, кодировать для запроса не нужно
  197. return afetch(document.location.origin + "/reader/" + workId + "/chapter?id=" + chapterId, {
  198. method: "GET",
  199. headers: { "Content-Type": "application/json; charset=utf-8" },
  200. responseType: "json",
  201. }).then(function(result) {
  202. let readerSecret = result.headers["reader-secret"];
  203. if (!readerSecret)
  204. throw new Error("Не найден ключ для расшифровки текста");
  205. if (!result.response.isSuccessful)
  206. throw new Error("Сервер ответил: Unsuccessful");
  207. return decryptText(result.response, readerSecret);
  208. }).catch(function(err) {
  209. console.error(err.message);
  210. throw err;
  211. });
  212. }
  213.  
  214. /**
  215. * Извлекает доступные данные описания книги из DOM сайта
  216. *
  217. * @param params object params Элементы описания книги
  218. * @param log Element HTML элемент для отображения процесса выгрузки
  219. *
  220. * @return Promise Возвращает промис который вернет описание книги в виде объекта
  221. */
  222. function extractDescriptionData(params, log) {
  223. let descr = {};
  224. let book_panel = params.bookPanel;
  225. return new Promise(function(resolve, reject) {
  226. if (!book_panel) throw new Error("Не найдена панель с информацией о книге!");
  227.  
  228. // Заголовок книги
  229. if (!params.title) throw new Error("Не найден заголовок книги");
  230. descr.bookTitle = params.title;
  231. logMessage(log, "Заголовок: " + params.title);
  232. // Авторы
  233. let authors = mobile ?
  234. book_panel.querySelectorAll("div.card-author>a") :
  235. book_panel.querySelectorAll("div.book-authors>span[itemprop=author]>a");
  236. authors = Array.prototype.reduce.call(authors, function(list, el) {
  237. let au = el.textContent.trim();
  238. if (au) {
  239. let ao = {};
  240. au = au.split(" ");
  241. switch (au.length) {
  242. case 1:
  243. ao = { nickname: au[0] };
  244. break;
  245. case 2:
  246. ao = { firstName: au[0], lastName: au[1] };
  247. break;
  248. default:
  249. ao = { firstName: au[0], middleName: au.slice(1, -1).join(" "), lastName: au[au.length - 1] };
  250. break;
  251. }
  252. let hp = /^\/u\/([^\/]+)\/works$/.exec(el.getAttribute("href"));
  253. if (hp) ao.homePage = document.location.origin + "/u/" + hp[1];
  254. list.push(ao);
  255. }
  256. return list;
  257. }, []);
  258. if (!authors.length) throw new Error("Не найдена информация об авторах");
  259. descr.authors = authors;
  260. logMessage(log, "Авторы: " + authors.length);
  261. // Вытягивает данные о жанрах, если это возможно
  262. let genres = mobile ?
  263. book_panel.querySelectorAll("div.work-stats a[href^=\"/work/genre/\"]") :
  264. book_panel.querySelectorAll("div.book-genres>a[href^=\"/work/genre/\"]");
  265. genres = Array.prototype.reduce.call(genres, function(list, el) {
  266. let gen = el.textContent.trim();
  267. if (gen) list.push(gen);
  268. return list;
  269. }, []);
  270. genres = identifyGenre(genres);
  271. if (genres.length) {
  272. descr.genres = genres;
  273. console.info("Жанры: " + genres.join(", "));
  274. } else {
  275. console.warn("Не идентифицирован ни один жанр!");
  276. }
  277. logMessage(log, "Жанры: " + genres.length);
  278. // Ключевые слова
  279. let tags = mobile ?
  280. document.querySelectorAll("div.work-details ul.work-tags a[href^=\"/work/tag/\"]") :
  281. book_panel.querySelectorAll("span.tags a[href^=\"/work/tag/\"]");
  282. tags = Array.prototype.reduce.call(tags, function(list, el) {
  283. let tag = el.textContent.trim();
  284. if (tag) list.push(tag);
  285. return list;
  286. }, []);
  287. if (tags.length) descr.keywords = tags;
  288. logMessage(log, "Ключевые слова: " + (tags && tags.length || "нет"));
  289. // Серия
  290. let seq_el = Array.prototype.find.call(book_panel.querySelectorAll("div>a"), function(el) {
  291. return /^\/work\/series\/\d+$/.test(el.getAttribute("href"));
  292. });
  293. if (seq_el) {
  294. let name = seq_el.textContent.trim();
  295. if (name) {
  296. let seq = { name: name };
  297. seq_el = seq_el.nextElementSibling;
  298. if (seq_el && seq_el.tagName === "SPAN") {
  299. let num = /^#(\d+)$/.exec(seq_el.textContent.trim());
  300. if (num) seq.number = num[1];
  301. }
  302. descr.sequence = seq;
  303. logMessage(log, "Серия: " + seq.name);
  304. if (seq.number !== undefined) logMessage(log, "Номер в серии: " + seq.number);
  305. }
  306. }
  307. // Дата книги (Последнее обновление)
  308. let dt = book_panel.querySelector("span[data-format=calendar-short][data-time]");
  309. if (dt) {
  310. dt = new Date(dt.getAttribute("data-time"));
  311. if (!isNaN(dt.valueOf())) descr.bookDate = dt;
  312. }
  313. logMessage(log, "Дата книги: " + (descr.bookDate ? descr.bookDate.toAtomDate() : "n/a"));
  314. // Ссылка на источник
  315. descr.srcUrl = document.location.origin + document.location.pathname;
  316. logMessage(log, "Источник: " + descr.srcUrl);
  317. // Обложка книги
  318. let cp_el = mobile ?
  319. document.querySelector("div.work-cover>a.work-cover-content>img.cover-image") :
  320. document.querySelector("div.book-cover>a.book-cover-content>img.cover-image");
  321. if (cp_el) {
  322. let li = logMessage(log, "Загрузка обложки...");
  323. loadImage(cp_el.getAttribute("src"), li).then(function(img_data) {
  324. descr.coverpage = img_data;
  325. logMessage(log, "Размер обложки: " + img_data.size + " байт");
  326. logMessage(log, "Тип файла обложки: " + img_data.contentType);
  327. li.ok();
  328. resolve(descr);
  329. }).catch(function(err) {
  330. li.fail();
  331. reject(err);
  332. });
  333. } else {
  334. logWarning(log, "Обложка книги не найдена!");
  335. resolve(descr);
  336. }
  337. }).then(function() {
  338. // Аннотация
  339. let li = logMessage(log, "Анализ аннотации...");
  340. let ann_a = [];
  341. if (params.annotation) ann_a.push(params.annotation);
  342. if (params.authorNotes) ann_a.push(params.authorNotes);
  343. if (ann_a.length) {
  344. let par_el = null;
  345. let newParagraph = function() {
  346. if (!par_el || par_el.childNodes.length) {
  347. par_el && (par_el.textContent = par_el.textContent.trim());
  348. par_el = document.createElement("p");
  349. } else // Если идут два переноса подряд, то вместо параграфа добавляется empty-line.
  350. ann_el.insertBefore(document.createElement("br"), par_el);
  351. ann_el.appendChild(par_el);
  352. };
  353. let ann_el = document.createElement("annotation");
  354. ann_a.forEach(function(el, idx) {
  355. if (idx) newParagraph(); // Пустая строка между аннотацией и примечаниями автора
  356. newParagraph();
  357. el.childNodes.forEach(function(node) {
  358. switch (node.nodeName) {
  359. case "BR":
  360. newParagraph();
  361. break;
  362. case "P":
  363. if (par_el.children.length) newParagraph();
  364. par_el.appendChild(document.createTextNode(node.textContent.trim()));
  365. newParagraph();
  366. break;
  367. case "#text":
  368. {
  369. let text = node.textContent;
  370. if (text.trim().length)
  371. par_el.appendChild(document.createTextNode(text));
  372. }
  373. break;
  374. default:
  375. par_el.appendChild(node.cloneNode(true));
  376. break;
  377. }
  378. });
  379. });
  380. par_el && (par_el.textContent = par_el.textContent.trim());
  381. li.ok();
  382. return elementToFragment(ann_el, log);
  383. }
  384. logWarning(log, "Нет аннотации!");
  385. }).then(function(a_fr) {
  386. if (a_fr) {
  387. descr.annotation = a_fr;
  388. }
  389. return descr;
  390. });
  391. }
  392.  
  393. /**
  394. * Возвращает объект с предварительными результатами анализа книги
  395. *
  396. * @return Object
  397. */
  398. function getBookParams() {
  399. let res = {};
  400.  
  401. res.bookPanel = document.querySelector("div.book-panel div.book-meta-panel") ||
  402. document.querySelector("div.work-details div.work-header-content");
  403.  
  404. res.title = res.bookPanel && (res.bookPanel.querySelector(".book-title") || res.bookPanel.querySelector(".card-title"));
  405. res.title = res.title ? res.title.textContent.trim() : null;
  406.  
  407. let wid = /^\/work\/(\d+)$/.exec(document.location.pathname);
  408. res.workId = wid && wid[1] || null;
  409.  
  410. let empty = function(el) {
  411. if (!el) return false;
  412. // Считается что аннотация есть только в том случае,
  413. // если имеются непустые текстовые ноды непосредственно в блоке аннотации
  414. return !Array.prototype.some.call(el.childNodes, function(node) {
  415. return node.nodeName === "#text" && node.textContent.trim() !== "";
  416. });
  417. };
  418.  
  419. let annotation = mobile ?
  420. document.querySelector("div.card-content-inner>div.card-description") :
  421. (res.bookPanel && res.bookPanel.querySelector("#tab-annotation>div.annotation"));
  422. if (annotation.children.length > 0) {
  423. let notes = annotation.querySelector(":scope>div.rich-content>p.text-primary.mb0");
  424. if (notes && !empty(notes.parentElement)) res.authorNotes = notes.parentElement;
  425. annotation = annotation.querySelector(":scope>div.rich-content");
  426. if (!empty(annotation) && annotation !== notes) res.annotation = annotation;
  427. }
  428.  
  429. let materials = mobile ?
  430. document.querySelector("#accordion-item-materials>div.accordion-item-content div.picture") :
  431. res.bookPanel && res.bookPanel.querySelector("div.book-materials div.picture");
  432. if (materials) {
  433. res.materials = materials;
  434. }
  435.  
  436. return res;
  437. }
  438.  
  439. /**
  440. * Запрашивает выбранные ранее части книги с сервера по переданному в аргументе списку.
  441. * Главы запрашиваются последовательно, чтобы не удивлять сервер запросами всех глав одновременно.
  442. * TODO: Может следует добавить случайную задержку в несколько секунд между запросами?
  443. *
  444. * @param chapterList Array Массив с описанием глав (id и название)
  445. * @param log Element HTML-элемент лога.
  446. * @param params object Параметры формирования глав
  447. *
  448. * @return Promise
  449. */
  450. function extractChapters(chaptersList, log, params) {
  451. let chapters = [];
  452. let _resolve = null;
  453. let _reject = null;
  454. let requestsRunner = function(position) {
  455. let ch_data = chaptersList[position++];
  456. let li = logMessage(log, "Получение главы " + position + "/" + chaptersList.length + "...");
  457. getChapterContent(ch_data.workId, ch_data.chapterId).then(function(ch_str) {
  458. li.ok();
  459. li = null;
  460. return parseChapterContent(ch_str, ch_data.title, log, params);
  461. }).then(function(chapter) {
  462. normalizeChapterFragment(chapter);
  463. chapters.push(chapter);
  464. if (position < chaptersList.length) {
  465. requestsRunner(position);
  466. } else {
  467. _resolve(chapters);
  468. }
  469. }).catch(function(err) {
  470. li && li.fail();
  471. _reject(err);
  472. });
  473. };
  474.  
  475. return new Promise(function(resolve, reject) {
  476. _resolve = resolve;
  477. _reject = reject;
  478. requestsRunner(0);
  479. });
  480. }
  481.  
  482. /**
  483. * Просматривает элементы с картинками в дополнительных материалах,
  484. * затем загружает их по ссылкам и сохраняет в виде массива с описанием, если оно есть.
  485. *
  486. * @param materials Element HTML-элемент с дополнительными материалами
  487. * @param log Element HTML-элемент лога.
  488. *
  489. * @return Promise
  490. */
  491. function extractMaterials(materials, log) {
  492. let getMaterial = function(fragment) {
  493. let li = logMessage(log, "Загрузка изображения...");
  494.  
  495. return loadImage(fragment.url, li).then(function(img) {
  496. li.ok();
  497. fragment.children[1].value = img;
  498. }).catch(function(err) {
  499. li.fail();
  500. }).finally(function() {
  501. delete fragment.url;
  502. });
  503. };
  504.  
  505. let list = Array.prototype.reduce.call(materials.querySelectorAll("figure"), function(res, el) {
  506. let link = el.querySelector("a");
  507. if (link && link.hasAttribute("href")) {
  508. let fragment = {
  509. url: link.getAttribute("href"),
  510. type: "chapter",
  511. children: []
  512. };
  513.  
  514. let description = null;
  515. let caption = el.querySelector("figcaption");
  516. if (caption && caption.textContent !== "") {
  517. description = caption.textContent;
  518. } else {
  519. description = "Без описания";
  520. }
  521. fragment.children.push({
  522. type: "paragraph",
  523. children: [ { type: "text", value: description } ]
  524. });
  525.  
  526. fragment.children.push({
  527. type: "image",
  528. value: null
  529. });
  530.  
  531. res.push(fragment);
  532. }
  533. return res;
  534. }, []);
  535.  
  536. return new Promise(function(resolve, reject) {
  537. if (!list.length) resolve(null);
  538. Promise.all(list.map(function(it) {
  539. return getMaterial(it);
  540. })).then(function() {
  541. resolve(list);
  542. }).catch(function(err) {
  543. reject(err);
  544. });
  545. });
  546. }
  547.  
  548. /**
  549. * Конвертирует HTML-строку в HTMLDocument, запускает анализ и преобразование страницы
  550. * во внутреннее представление.
  551. *
  552. * @param chapter_str string HTML-строка, полученная от сервера
  553. * @param title string Заголовок главы
  554. * @param log Element HTML-элемент лога.
  555. * @param params object Параметры формирования глав
  556. *
  557. * @return Promise Да, опять промис
  558. *
  559. */
  560. function parseChapterContent(chapter_str, title, log, params) {
  561. // Присваивание innerHTML не ипользуется по причине его небезопасности.
  562. // Вряд ли сервер будет гадить своим пользователям, но лучше перестраховаться.
  563. let chapter_doc = new DOMParser().parseFromString(chapter_str, "text/html");
  564. let fragment = {};
  565. if (title) fragment.children = [ { type: "title", value: title } ];
  566. return elementToFragment(chapter_doc.body, log, params, fragment);
  567. }
  568.  
  569. /**
  570. * Рекурсивно и асинхронно сканирует переданный элемент со всеми его потомками,
  571. * возвращая специальную структуру, очищенную от HTML-разметки. Загружает внешние ресурсы,
  572. * такие как картинки. Возвращаемая структура может использоваться для формирования FB2 документа.
  573. * Используется для анализа аннотации к книге и для анализа полученных от сервера глав.
  574. *
  575. * @param element Element HTML-элемент с текстом, картинками и разметкой
  576. * @param log Element HTML-элемент лога. Необязательный параметр.
  577. * @param params object Необязательный параметр. Параметры формирования глав.
  578. * @param fragment object Необязательный параметр. В него будут записаны результирующие данные
  579. * Он же будет возвращен в результате промиса. Удобно для предварительного
  580. * размещения результата во внешнем списке. Если не указан, то будет инициирован
  581. * пустым объектом.
  582. * @param depth number Необязательный параметр. Глубина рекурсии. Используется в рекурсивном вызове.
  583. *
  584. * @return Promise Функция асинхронная, так что возрващает промис, который вернет заполненный данными объект,
  585. * который передан в параметре fragment или вновь созданный.
  586. */
  587. function elementToFragment(element, log, params, fragment, depth) {
  588. let markUnknown = function() {
  589. fragment.type = "unknown";
  590. fragment.value = element.nodeName + " [" + depth + "] | " + element.textContent.slice(0, 35);
  591. };
  592. return new Promise(function(resolve, reject) {
  593. depth ||= 0;
  594. fragment ||= {};
  595. fragment.children ||= [];
  596. switch (element.nodeName) {
  597. case "IMG":
  598. {
  599. let li = null;
  600. if (log) li = logMessage(log, "Загрузка изображения...");
  601. if (params.withoutImages) {
  602. fragment.type = "emphasis";
  603. li && li.skipped();
  604. fragment.value = null;
  605. fragment.children = [ { type: "text", value: "[* Здесь было изображение *]" } ];
  606. resolve(fragment);
  607. return;
  608. }
  609. fragment.type = "image";
  610. loadImage(element.getAttribute("src"), li).then(function(img) {
  611. li && li.ok();
  612. fragment.value = img;
  613. resolve(fragment);
  614. }).catch(function(err) {
  615. li && li.fail();
  616. fragment.value = null;
  617. resolve(fragment);
  618. });
  619. }
  620. return;
  621. case "A":
  622. fragment.type = "text";
  623. fragment.value = element.textContent;
  624. resolve(fragment);
  625. return;
  626. case "BR":
  627. fragment.type = "empty";
  628. resolve(fragment);
  629. return;
  630. case "P":
  631. fragment.type = "paragraph";
  632. break;
  633. case "DIV":
  634. fragment.type = "block";
  635. break;
  636. case "BODY":
  637. fragment.type = "chapter";
  638. break;
  639. case "ANNOTATION":
  640. fragment.type = "annotation";
  641. break;
  642. case "STRONG":
  643. fragment.type = "strong";
  644. break;
  645. case "U":
  646. case "EM":
  647. fragment.type = "emphasis";
  648. break;
  649. case "SPAN":
  650. fragment.type = "span";
  651. break;
  652. case "DEL":
  653. case "S":
  654. case "STRIKE":
  655. fragment.type = "strike";
  656. break;
  657. case "BLOCKQUOTE":
  658. fragment.type = "cite";
  659. break;
  660. default:
  661. logWarning(log, "Найден неизвестный тег: " + element.nodeName);
  662. markUnknown();
  663. break;
  664. }
  665. // Сканировать вложенные ноды
  666. let queue = [];
  667. let nodes = element.childNodes;
  668. for (let i = 0; i < nodes.length; ++i) {
  669. let node = nodes[i];
  670. let child = {};
  671. switch (node.nodeName) {
  672. case "#text":
  673. child.type = "text";
  674. child.value = node.textContent;
  675. break;
  676. case "#comment":
  677. break;
  678. default:
  679. queue.push([ node, child ]);
  680. break;
  681. }
  682. fragment.children.push(child);
  683. }
  684. // Запустить асинхронную обработку очереди для вложенных нод
  685. if (queue.length) {
  686. Promise.all(queue.map(function(it) {
  687. return elementToFragment(it[0], log, params, it[1], depth + 1);
  688. })).then(function() {
  689. resolve(fragment);
  690. }).catch(function(err) {
  691. reject(err);
  692. });
  693. } else {
  694. resolve(fragment);
  695. }
  696. });
  697. }
  698.  
  699. /**
  700. * Нормализация уже сгерерированного документа. Например картинки и пустые строки
  701. * будут вынесены из параграфов на первый уровень, непосредственно в <section>.
  702. * Также тут будут удалены пустые стилистические блоки, если они есть.
  703. * Если всплывающий элемент находятся внутри фрагмента с другими данными,
  704. * такой фрагмент будет разбит на два фрагмента, а всплывающий элемент будет
  705. * размещен между ними.
  706. *
  707. * @param fragment Документ для анализа и исправления
  708. *
  709. * @return void
  710. */
  711. function normalizeChapterFragment(fragment) {
  712. let title = null;
  713. let cloneFragment = function(fr) {
  714. let new_fr = { type: fr.type };
  715. fr.children && (new_fr.children = fr.children);
  716. fr.value && (new_fr.value = fr.value);
  717. return new_fr;
  718. };
  719. let normalizeFragment = function(fr, depth) {
  720. if (depth === 1 && fr.type === "title") title = fr.value;
  721. if (fr.children) {
  722. // Обработать детей текущего фрагмента с заменой новыми
  723. fr.children = fr.children.reduce(function(new_list, ch) {
  724. normalizeFragment(ch, depth + 1).forEach(function(fr) {
  725. new_list.push(fr);
  726. });
  727. return new_list;
  728. }, []);
  729. // Проверить обновленный список детей фрагмента на необходимость чистки и корректировки
  730. let l_chtype = 0;
  731. let l_chlist = null;
  732. let new_children = fr.children.reduce(function(new_list, ch) {
  733. let chtype = 1;
  734. let remove = false;
  735. let squeeze = false;
  736. switch (ch.type) {
  737. case "empty":
  738. squeeze = true;
  739. // no break
  740. case "image":
  741. if (depth > 0) chtype = 2;
  742. break;
  743. case "block":
  744. if (depth > 0 && fr.type === "block") chtype = 2;
  745. // no break
  746. case "text":
  747. case "cite":
  748. case "paragraph":
  749. case "strong":
  750. case "emphasis":
  751. case "strike":
  752. case "span":
  753. if (!ch.value && (!ch.children || !ch.children.length)) {
  754. // Удалить пустые элементы разметки
  755. remove = true;
  756. console.info(title + " | Удален пустой элемент " + ch.type);
  757. }
  758. break;
  759. default:
  760. break;
  761. }
  762.  
  763. if (ch.type === "paragraph") {
  764. if ([ "strong", "emphasis", "strike", "span" ].includes(fr.type)) {
  765. // Параграф внутри inline блока
  766. chtype = 3;
  767. }
  768. } else if (depth === 0) {
  769. if ([ "strong", "emphasis", "strike", "span", "text" ].includes(ch.type)) {
  770. // Inline элемент на уровне секции
  771. chtype = 4;
  772. }
  773. }
  774.  
  775. if (!remove) {
  776. if (!squeeze || l_chtype !== chtype || l_chlist[l_chlist.length - 1].type !== ch.type) {
  777. if (l_chtype !== chtype) {
  778. l_chlist = [];
  779. new_list.push([ chtype, l_chlist ]);
  780. }
  781. l_chlist.push(ch);
  782. l_chtype = chtype;
  783. } else {
  784. console.info(title + " | Удален дублирующийся элемент " + ch.type);
  785. }
  786. }
  787. return new_list;
  788. }, []);
  789.  
  790. if (new_children.length === 0) {
  791. // Детей не осталось, возратить изначальный элемент без детей
  792. fr.children = [];
  793. return [ fr ];
  794. }
  795.  
  796. // Оборачивание inline элементов в параграф с заменой типа
  797. let i_cnt = 0;
  798. new_children.forEach(function(it) {
  799. if (it[0] === 4) {
  800. it[0] = 1; // Обычный блок
  801. it[1] = [ { type: "paragraph", children: it[1] } ]; // Единственный элемент - параграф с inline элементами внутри
  802. console.info(title + " | Создан параграф для inline элемент" + (it[1].length === 1 && "а" || "ов"));
  803. ++i_cnt;
  804. }
  805. });
  806. if (i_cnt) {
  807. let accum = null;
  808. new_children = new_children.reduce(function(new_list, it) {
  809. if (it[0] === 1) {
  810. if (!accum)
  811. new_list.push([ 1, accum = [] ]);
  812. it[1].forEach(function(ch) {
  813. accum.push(ch);
  814. });
  815. } else {
  816. accum = null;
  817. new_list.push(it);
  818. }
  819. return new_list;
  820. }, []);
  821. }
  822.  
  823. let popups = {};
  824. let pcount = 0;
  825. let new_fragments = new_children.reduce(function(accum, it) {
  826. switch (it[0]) {
  827. case 2:
  828. // Всплывающие элементы самодостаточны, возвратить как есть
  829. it[1].forEach(function(it) {
  830. accum.push(it);
  831. popups[it.type] = (popups[it.type] || 0) + 1;
  832. ++pcount;
  833. });
  834. break;
  835. case 3:
  836. // Параграф вложен в inline элемент. Да, да, такое тоже встречается на AT.
  837. // Переписывает как параграфы с вложенными inline элементами и с детьми параграфа
  838. it[1].forEach(function(it) {
  839. let new_inline = cloneFragment(fr);
  840. new_inline.children = it.children;
  841. let new_paragraph = cloneFragment(it);
  842. new_paragraph.children = [ new_inline ];
  843. accum.push(new_paragraph);
  844. });
  845. console.info(title + " | Рокировка " + fr.type + " <-> paragraph (" + it[1].length + ")");
  846. break;
  847. default:
  848. // Обычный вложенный фрагмент. Пересоздает родителя и помещает в результат
  849. {
  850. let f = cloneFragment(fr);
  851. f.children = it[1];
  852. accum.push(f);
  853. }
  854. break;
  855. }
  856. return accum;
  857. }, []);
  858. if (pcount) {
  859. // Отобразить информацию о всплытиях в консоли
  860. let pl = Object.keys(popups).reduce(function(list, key) {
  861. list.push(key + " (" + popups[key] + ")");
  862. return list;
  863. }, []);
  864. console.info(title + " | Всплытие для " + pl.join(", "));
  865. }
  866. return new_fragments;
  867. }
  868. return [ fr ];
  869. };
  870. let fragments = normalizeFragment(fragment, 0);
  871. if (fragments.length === 1) fragment.children = fragments[0].children;
  872. }
  873.  
  874. /**
  875. * Асинхронно загружает изображение с переданного в первом аргументе адреса
  876. * и сохраняет в возвращаемой структуре в base64 с content-type.
  877. * Используется для загрузки обложки, изображений внутри глав и доп.материалов.
  878. *
  879. * @param url string Адрес картинки, которую требуется загрузить
  880. * @param li object Запись лога, для отображения прогресса. Необязательный параметр.
  881. *
  882. * @return Promise Промис, который вернет структуру с данными изображения.
  883. */
  884. function loadImage(url, li) {
  885. let origin = document.location.origin;
  886. if (url.startsWith("/")) url = origin + url;
  887. let result = null;
  888. return new Promise(function(resolve, reject) {
  889. let oUrl = new URL(url);
  890. oUrl.searchParams.delete("format"); // Избавляет от format=webp - могут быть проблемы со старыми читалками
  891. afetch(oUrl, {
  892. method: "GET",
  893. responseType: "blob",
  894. }, li).then(function(r) {
  895. let blob = r.response;
  896. result = { size: blob.size, contentType: blob.type };
  897. return new Promise(function(resolve, reject) {
  898. let reader = new FileReader();
  899. reader.onloadend = function() { resolve(reader.result); };
  900. reader.readAsDataURL(blob);
  901. });
  902. }).then(function(base64str) {
  903. result.data = base64str.substr(base64str.indexOf(",") + 1);
  904. resolve(result);
  905. }).catch(function(err) {
  906. console.error(err);
  907. reject(new Error("Ошибка загрузки изображения " + url));
  908. });
  909. });
  910. }
  911.  
  912. /**
  913. * Проверяет картинки внутри глав и предлагает замену, если есть сбойные.
  914. * Выбрасывает исключение в случае неустранимых проблем.
  915. *
  916. * @param book_data object Данные сформированного документа
  917. *
  918. * @return void
  919. */
  920. function checkBinary(book_data) {
  921. let confirm_stub = function() {
  922. if (confirm("Имеются незагруженные изображения. Использовать заглушку?")) return;
  923. throw new Error("Есть нерешенные проблемы с загрузкой изображений");
  924. };
  925.  
  926. for (let i = 0; i < book_data.chapters.length; ++i) {
  927. let ch = book_data.chapters[i];
  928. for (let k = 0; k < ch.children.length; ++k) {
  929. let fr = ch.children[k];
  930. if (fr.type === "image" && !fr.value) {
  931. confirm_stub();
  932. return;
  933. }
  934. }
  935. }
  936. if (book_data.materials) {
  937. for (let i = 0; i < book_data.materials.length; ++i) {
  938. if (!book_data.materials[i].children[1].value) {
  939. confirm_stub();
  940. return;
  941. }
  942. }
  943. }
  944. }
  945.  
  946. /**
  947. * Просматривает все картинки в сформированном документе и назначает каждой уникальный id.
  948. *
  949. * @param book_data object Данные сформированного документа
  950. *
  951. * @return void
  952. */
  953. function makeBinaryIds(book_data) {
  954. let ids_map = {};
  955. let seq_num = 0;
  956.  
  957. let setImageId = function(img, def) {
  958. if (!img.id || ids_map[img.id.toLowerCase()]) {
  959. let id = def || ("image" + (++seq_num));
  960. switch (img.contentType) {
  961. case "image/png":
  962. id += ".png"
  963. break;
  964. case "image/jpeg":
  965. id += ".jpg"
  966. break;
  967. }
  968. img.id = id;
  969. }
  970. ids_map[img.id.toLowerCase()] = true;
  971. };
  972.  
  973. if (book_data.descr.coverpage) setImageId(book_data.descr.coverpage, "cover");
  974.  
  975. book_data.chapters.forEach(function(ch) {
  976. if (ch.children) {
  977. ch.children.forEach(function(frl1) {
  978. if (frl1.type === "image") {
  979. if (frl1.value)
  980. setImageId(frl1.value);
  981. else
  982. frl1.value = { id: "dummy.png" };
  983. }
  984. })
  985. }
  986. });
  987.  
  988. if (book_data.materials) {
  989. book_data.materials.forEach(function(mt) {
  990. let fr_im = mt.children[1];
  991. if (fr_im.value)
  992. setImageId(fr_im.value);
  993. else
  994. fr_im.value = { id: "dummy.png" };
  995. });
  996. }
  997. }
  998.  
  999. /**
  1000. * Формирует описательную часть книги в виде XML-элемента description
  1001. * и добавляет ее в переданный root элемент fb2 документа
  1002. *
  1003. * @param doc XMLDocument Основной XML-документ
  1004. * @param root Element Основной элемент fb2 документа, в который будет добавлено описание
  1005. * @param descr object Объект данных с описанием книги
  1006. *
  1007. * @return void
  1008. **/
  1009. function documentAddDescription(doc, root, descr) {
  1010. let descr_el = documentElement(doc, "description");
  1011. root.appendChild(descr_el);
  1012.  
  1013. let title_info = documentElement(doc, "title-info");
  1014. descr_el.appendChild(title_info);
  1015. // Жанры
  1016. documentElement(doc, title_info, (descr.genres || [ "network_literature" ]).map(function(g) {
  1017. return documentElement(doc, "genre", g);
  1018. }));
  1019. // Авторы
  1020. documentElement(doc, title_info, (descr.authors || []).map(function(a) {
  1021. let items = [];
  1022. if (a.firstName || !a.nickname) {
  1023. items.push(documentElement(doc, "first-name", a.firstName || "Unknown"));
  1024. }
  1025. if (a.middleName) {
  1026. items.push(documentElement(doc, "middle-name", a.middleName));
  1027. }
  1028. if (a.lastName || !a.nickname) {
  1029. items.push(documentElement(doc, "last-name", a.lastName || ""));
  1030. }
  1031. if (a.nickname) {
  1032. items.push(documentElement(doc, "nickname", a.nickname));
  1033. }
  1034. if (a.homePage) {
  1035. items.push(documentElement(doc, "home-page", a.homePage));
  1036. }
  1037. return documentElement(doc, "author", items);
  1038. }));
  1039. // Название книги
  1040. documentElement(doc, title_info, documentElement(doc, "book-title", descr.bookTitle || "???"));
  1041. // Аннотация
  1042. if (descr.annotation) {
  1043. documentAddContentFragment(doc, descr.annotation, title_info);
  1044. }
  1045. // Ключевые слова
  1046. if (descr.keywords) {
  1047. documentElement(doc, title_info, documentElement(doc, "keywords", descr.keywords.join(", ")));
  1048. }
  1049. // Дата книги
  1050. if (descr.bookDate) {
  1051. let d_el = documentElement(doc, "date", descr.bookDate.getFullYear());
  1052. d_el.setAttribute("value", descr.bookDate.toAtomDate());
  1053. title_info.appendChild(d_el);
  1054. }
  1055. // Обложка
  1056. if (descr.coverpage) {
  1057. let img_el = documentElement(doc, "image");
  1058. img_el.setAttribute("l:href", "#" + descr.coverpage.id);
  1059. documentElement(doc, title_info, documentElement(doc, "coverpage", img_el));
  1060. }
  1061. // Язык книги
  1062. documentElement(doc, title_info, documentElement(doc, "lang", "ru"));
  1063. // Серия, в которую входит книга
  1064. if (descr.sequence) {
  1065. let seq = documentElement(doc, "sequence");
  1066. seq.setAttribute("name", descr.sequence.name);
  1067. if (descr.sequence.number) {
  1068. seq.setAttribute("number", descr.sequence.number);
  1069. }
  1070. title_info.appendChild(seq);
  1071. }
  1072.  
  1073. let doc_info = documentElement(doc, "document-info");
  1074. descr_el.appendChild(doc_info);
  1075. // Автор файла-контейнера
  1076. documentElement(doc, doc_info, documentElement(doc, "author", documentElement(doc, "nickname", "Ox90")));
  1077. // Программа, с помощью которой был сгенерен файл
  1078. documentElement(doc, doc_info, documentElement(doc, "program-used", PROGRAM_NAME + " v" + GM_info.script.version));
  1079. // Дата генерации файла
  1080. let file_time = descr.fileTime || new Date();
  1081. let time_el = documentElement(doc, "date", file_time.toUTCString());
  1082. time_el.setAttribute("value", file_time.toAtomDate());
  1083. doc_info.appendChild(time_el);
  1084. // Ссылка на источник
  1085. let src_url = descr.srcUrl || (document.location.origin + document.location.pathname);
  1086. documentElement(doc, doc_info, documentElement(doc, "src-url", src_url));
  1087. // ID документа. Формирует на основе scrUrl.
  1088. documentElement(doc, doc_info, documentElement(doc, "id", PROGRAM_ID + "_" + stringHash(src_url)));
  1089. // Версия документа
  1090. documentElement(doc, doc_info, documentElement(doc, "version", "1.0"));
  1091. }
  1092.  
  1093. /**
  1094. * Формирует дерево XML-элементов по переданному в параметре фрагменту с контентом
  1095. * Обычно фрагметом является аннотация или содержимое главы.
  1096. *
  1097. * @param doc XMLDocument Корневой XML-документ
  1098. * @param fragment object Внутреннее представление данных в будущем fb2 документе
  1099. * @param element Element Родительский элемент, к которому будет добавлено дерево с контентом
  1100. *
  1101. * @return void
  1102. */
  1103. function documentAddContentFragment(doc, fragment, element, depth) {
  1104. let title = null;
  1105. let addContentFragment = function(doc, fragment, element, depth, ptype) {
  1106. let cur_el = element;
  1107. let depthFail = function() {
  1108. throw new Error(
  1109. (title ? "\"" + title + "\"" : "Аннотация") +
  1110. ": \nНеверный уровень вложенности [" + depth + "] для " + fragment.type
  1111. );
  1112. };
  1113. let appendChild = function(name) {
  1114. cur_el = documentElement(doc, name);
  1115. element.appendChild(cur_el);
  1116. };
  1117. switch (fragment.type) {
  1118. case "chapter":
  1119. if (depth) depthFail();
  1120. appendChild("section");
  1121. break;
  1122. case "annotation":
  1123. if (depth) depthFail();
  1124. appendChild("annotation");
  1125. break;
  1126. case "title":
  1127. if (depth !== 1) depthFail();
  1128. title = fragment.value;
  1129. cur_el.appendChild(documentElement(doc, "title", documentElement(doc, "p", fragment.value)));
  1130. break;
  1131. case "paragraph":
  1132. case "block":
  1133. if (depth !== 1 && ptype !== "cite") depthFail();
  1134. appendChild("p");
  1135. break;
  1136. case "strong":
  1137. if (depth <= 1) depthFail();
  1138. appendChild("strong");
  1139. break;
  1140. case "emphasis":
  1141. if (depth <= 1) depthFail();
  1142. appendChild("emphasis");
  1143. break;
  1144. case "strike":
  1145. if (depth <= 1) depthFail();
  1146. appendChild("strikethrough");
  1147. break;
  1148. case "text":
  1149. if (depth <= 1) depthFail();
  1150. cur_el.appendChild(doc.createTextNode(fragment.value));
  1151. break;
  1152. case "span":
  1153. // Как text но с потомками
  1154. if (depth <= 1) depthFail();
  1155. break;
  1156. case "cite":
  1157. if (depth !== 1) depthFail();
  1158. appendChild("cite");
  1159. break;
  1160. case "empty":
  1161. if (depth !== 1) depthFail();
  1162. cur_el.appendChild(documentElement(doc, "empty-line", fragment.value));
  1163. break;
  1164. case "image":
  1165. if (depth !== 1) depthFail();
  1166. {
  1167. let img = documentElement(doc, "image");
  1168. img.setAttribute("l:href", "#" + fragment.value.id);
  1169. cur_el.appendChild(img);
  1170. }
  1171. break;
  1172. case "unknown":
  1173. default:
  1174. throw new Error("Неизвестный тип фрагмента: " + fragment.type + " | " + fragment.value);
  1175. }
  1176. fragment.children && fragment.children.forEach(function(ch_fr) {
  1177. addContentFragment(doc, ch_fr, cur_el, depth + 1, fragment.type);
  1178. });
  1179. };
  1180.  
  1181. addContentFragment(doc, fragment, element, 0);
  1182. }
  1183.  
  1184. /**
  1185. * Формирует дерево XML-документа по переданному списку глав, элемент body
  1186. *
  1187. * @param doc XMLDocument Корневой XML-документ
  1188. * @param body Element Элемент body fb2 документа
  1189. * @param chapters Array Массив с внутренним представлением глав в виде фрагметов
  1190. *
  1191. * @return void
  1192. */
  1193. function documentAddChapters(doc, body, chapters) {
  1194. chapters.forEach(function(ch) {
  1195. documentAddContentFragment(doc, ch, body);
  1196. });
  1197. }
  1198.  
  1199. /**
  1200. * Формирует дерево дополнительных материалов по переданному списку
  1201. *
  1202. * @param doc XMLDocument Корневой XML-документ
  1203. * @param body Element Элемент body fb2 документа
  1204. * @param materials Array Массив с внутренним представлением материалов в виде фрагментов
  1205. *
  1206. * @return void
  1207. */
  1208. function documentAddMaterials(doc, body, materials) {
  1209. let section = documentElement(doc, "section",
  1210. documentElement(doc, "title",
  1211. documentElement(doc, "p", "Дополнительные материалы")
  1212. )
  1213. );
  1214. body.appendChild(section);
  1215. materials.forEach(function(mt) {
  1216. documentAddContentFragment(doc, mt, section);
  1217. });
  1218. }
  1219.  
  1220. /**
  1221. * Сканирует элементы книги, ищет картинки, добавляет их как элементы binary,
  1222. * содержащие картинки, в корневой элемент fb2 документа
  1223. *
  1224. * @param doc XMLDocument Корневой XML-документ
  1225. * @param root Element Корневой элемент fb2 документа
  1226. * @param book_data object Данные книги, по которым формируются элементы binary
  1227. *
  1228. * @return void
  1229. */
  1230. function documentAddBinary(doc, root, book_data) {
  1231. let dummy = false;
  1232.  
  1233. let makeBinary = function(img) {
  1234. if (dummy && !img.data) return;
  1235.  
  1236. let bin_el = documentElement(doc, "binary");
  1237. root.appendChild(bin_el);
  1238. if (img.data) {
  1239. bin_el.setAttribute("id", img.id);
  1240. bin_el.setAttribute("content-type", img.contentType);
  1241. bin_el.textContent = img.data;
  1242. } else if (!dummy) {
  1243. dummy = true;
  1244. bin_el.setAttribute("id", "dummy.png");
  1245. bin_el.setAttribute("content-type", "image/png");
  1246. bin_el.textContent = getDummyImage();
  1247. }
  1248. };
  1249.  
  1250. if (book_data.descr.coverpage) makeBinary(book_data.descr.coverpage);
  1251.  
  1252. book_data.chapters.forEach(function(ch) {
  1253. if (ch.children) {
  1254. ch.children.forEach(function(frl1) {
  1255. if (frl1.type === "image") makeBinary(frl1.value);
  1256. })
  1257. }
  1258. });
  1259.  
  1260. if (book_data.materials) {
  1261. book_data.materials.forEach(function(mt) {
  1262. makeBinary(mt.children[1].value);
  1263. });
  1264. }
  1265. }
  1266.  
  1267. /**
  1268. * Создает или модифицирует элемент документа. При создании используется NS XML-документа
  1269. *
  1270. * @param doc XMLDocument XML документ
  1271. * @param element string|Element Основной элемент. Если передана строка, то это будет tagName для создания элемента
  1272. * @param value Element|array|other Дочерний элемент или массив дочерних элементов, иначе - дочерний TextNode
  1273. *
  1274. * @return Element Основной элемент, переданный в параметре element, или вновь созданный, если была передана строка
  1275. */
  1276. function documentElement(doc, element, value) {
  1277. let el = typeof(element) === "object" ? element : doc.createElementNS(doc.documentElement.namespaceURI, element);
  1278. if (value !== undefined && value !== null) {
  1279. switch (typeof(value)) {
  1280. case "object":
  1281. (Array.isArray(value) ? value : [ value ]).forEach(function(it) {
  1282. el.appendChild(it);
  1283. });
  1284. break;
  1285. default:
  1286. el.appendChild(doc.createTextNode(value));
  1287. break;
  1288. }
  1289. }
  1290. return el;
  1291. }
  1292.  
  1293. /**
  1294. * Старт формирования XML-документа по накопленным данным книги
  1295. *
  1296. * @param book_data object Данные книги, по которым формируется итоговый XML-документ
  1297. * @param log Element Html-элемент в который будут писаться сообщения о прогрессе
  1298. *
  1299. * @return string Содержимое XML-документа, в виде строки
  1300. */
  1301. function documentStart(book_data, log) {
  1302. let doc = new DOMParser().parseFromString(
  1303. '<?xml version="1.0" encoding="UTF-8"?><FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0"/>',
  1304. "application/xml"
  1305. );
  1306. let root = doc.documentElement;
  1307. root.setAttribute("xmlns:l", "http://www.w3.org/1999/xlink");
  1308.  
  1309. logMessage(log, "---");
  1310.  
  1311. let li = null;
  1312. try {
  1313. li = logMessage(log, "Анализ бинарных данных...");
  1314. checkBinary(book_data);
  1315. makeBinaryIds(book_data);
  1316. li.ok();
  1317.  
  1318. li = logMessage(log, "Формирование описания...");
  1319. documentAddDescription(doc, root, book_data.descr);
  1320. let body = documentElement(doc, "body");
  1321. let authors = (book_data.descr.authors || []).map(function(author) {
  1322. let aa = [];
  1323. if (author.firstName) aa.push(author.firstName);
  1324. if (author.middleName) aa.push(author.middleName);
  1325. if (author.lastName) aa.push(author.lastName);
  1326. if (author.nickname) aa.push(author.nickname);
  1327. return aa.join(" ");
  1328. });
  1329. let btitle = documentElement(doc, "title");
  1330. if (authors.length) btitle.appendChild(documentElement(doc, "p", authors.join(", ")));
  1331. btitle.appendChild(documentElement(doc, "p", book_data.descr.bookTitle));
  1332. body.appendChild(btitle);
  1333. root.appendChild(body);
  1334. li.ok();
  1335.  
  1336. li = logMessage(log, "Формирование глав...");
  1337. documentAddChapters(doc, body, book_data.chapters);
  1338. li.ok();
  1339.  
  1340. if (book_data.materials) {
  1341. li = logMessage(log, "Формирование доп.материалов...");
  1342. documentAddMaterials(doc, body, book_data.materials);
  1343. li.ok();
  1344. }
  1345.  
  1346. li = logMessage(log, "Формирование бинарных данных...");
  1347. documentAddBinary(doc, root, book_data);
  1348. li.ok();
  1349. } catch (err) {
  1350. li && li.fail();
  1351. throw err;
  1352. }
  1353.  
  1354. logMessage(log, "---");
  1355. let data = xmldocToString(doc);
  1356. logMessage(log, "Готово!");
  1357. return data;
  1358. }
  1359.  
  1360. /**
  1361. * Создает картинку-заглушку в фомате png и возвращает ее данные в виде строки
  1362. *
  1363. * @return string Base64 строка с данными
  1364. */
  1365. function getDummyImage() {
  1366. let canvas = document.createElement("canvas");
  1367. canvas.setAttribute("width", "300");
  1368. canvas.setAttribute("height", "150");
  1369. if (!canvas.getContext) throw new Error("Ошибка работы с элементом canvas");
  1370. let ctx = canvas.getContext("2d");
  1371. // Фон
  1372. ctx.fillStyle = "White";
  1373. ctx.fillRect(0, 0, 300, 150);
  1374. // Обводка
  1375. ctx.lineWidth = 4;
  1376. ctx.strokeStyle = "Gray";
  1377. ctx.strokeRect(0, 0, 300, 150);
  1378. // Тень
  1379. ctx.shadowOffsetX = 2;
  1380. ctx.shadowOffsetY = 2;
  1381. ctx.shadowBlur = 2;
  1382. ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
  1383. // Крест
  1384. let margin = 25;
  1385. let size = 40;
  1386. ctx.lineWidth = 10;
  1387. ctx.strokeStyle = "Red";
  1388. ctx.moveTo(300 / 2 - size / 2, margin);
  1389. ctx.lineTo(300 / 2 + size / 2, margin + size);
  1390. ctx.stroke();
  1391. ctx.moveTo(300 / 2 + size / 2, margin);
  1392. ctx.lineTo(300 / 2 - size / 2, margin + size);
  1393. ctx.stroke();
  1394. // Текст
  1395. ctx.font = "42px Times New Roman";
  1396. ctx.fillStyle = "Black";
  1397. ctx.textAlign = "center";
  1398. ctx.fillText("No image", 150, 120, 300);
  1399. // Получить данные
  1400. let data_str = canvas.toDataURL("image/png");
  1401. return data_str.substr(data_str.indexOf(",") + 1);
  1402. }
  1403.  
  1404. /**
  1405. * Пишет переданную строку в HTML-элемент лога как текст без дополнительных стилей
  1406. *
  1407. * @param log Element HTML-элемент лога
  1408. * @param message string Строка с сообщением
  1409. *
  1410. * @return object Объект для дальнейших манипуляций с записью
  1411. */
  1412. function logMessage(log, message) {
  1413. let block = document.createElement("div");
  1414. block.textContent = message;
  1415. log.appendChild(block);
  1416. log.scrollTop = log.scrollHeight;
  1417. function setSpan(text, color) {
  1418. if (!block.children.length)
  1419. block.appendChild(document.createElement("span"));
  1420. let sp = block.children[0];
  1421. sp.style.color = color;
  1422. sp.textContent = " " + text;
  1423. };
  1424. return {
  1425. ok: function() { setSpan("ok", "green"); },
  1426. fail: function() { setSpan("ошибка!", "red"); },
  1427. skipped: function() { setSpan("пропущено", "blue"); },
  1428. text: function(s) { setSpan(s, ""); },
  1429. element: function() { return block; },
  1430. };
  1431. }
  1432.  
  1433. /**
  1434. * Пишет переданную строку в HTML-элемент лога как текст предупреждения с цветным выделением
  1435. *
  1436. * @param log Element HTML-элемент лога
  1437. * @param message string Строка с сообщением
  1438. *
  1439. * @return Element Элемент с последним сообщением
  1440. */
  1441. function logWarning(log, message) {
  1442. let lo = logMessage(log, message);
  1443. lo.element().setAttribute("style", "color:#a00;");
  1444. return lo;
  1445. }
  1446.  
  1447. /**
  1448. * Создает и возвращает элемент кнопки, для начала отображения диалога формирования fb2 документа
  1449. *
  1450. * @return Element HTML-элемент кнопки для добавления на страницу
  1451. */
  1452. function createButton() {
  1453. let ae = document.createElement("a");
  1454. ae.setAttribute("class", "btn btn-default " + (mobile && "btn-download-work" || "btn-block"));
  1455. ae.setAttribute("style", "border-color:green;");
  1456. let ie = document.createElement("i");
  1457. ie.setAttribute("class", "icon-download");
  1458. ae.appendChild(ie);
  1459. ae.appendChild(document.createTextNode(""));
  1460. let btn = ae;
  1461. if (!mobile) {
  1462. btn = document.createElement("div");
  1463. btn.setAttribute("class", "mt-lg");
  1464. btn.appendChild(ae);
  1465. }
  1466. btn.setText = function(text) {
  1467. let el = this.nodeName === "A" ? this : this.querySelector("a");
  1468. el.childNodes[1].textContent = " " + (text || "Скачать FB2");
  1469. };
  1470. btn.setText();
  1471. return btn;
  1472. }
  1473.  
  1474. /**
  1475. * Создает и наполняет окно диалога для выбора глав и добавляет обработчики к элементам
  1476. *
  1477. * @return void
  1478. */
  1479. function showChaptersDialog() {
  1480. if (button.disabled) return;
  1481. button.disabled = true;
  1482. button.setText("Анализ...");
  1483.  
  1484. let params = getBookParams();
  1485.  
  1486. // Создает интерактивные элементы, которые будут отображены в форме диалога
  1487. let form = document.createElement("form");
  1488.  
  1489. let fst = document.createElement("fieldset");
  1490. fst.setAttribute("style", "border:1px solid #bbb; border-radius:6px; padding:5px 12px 0 12px;");
  1491. form.appendChild(fst);
  1492. let leg = document.createElement("legend");
  1493. leg.setAttribute("style", "display:inline; width:unset; font-size:100%; margin:0; padding:0 5px; border:none;");
  1494. fst.appendChild(leg);
  1495. leg.appendChild(document.createTextNode("Главы для выгрузки"));
  1496.  
  1497. let chs = document.createElement("div");
  1498. chs.setAttribute("style", "overflow:auto; max-height:50vh;");
  1499. fst.appendChild(chs);
  1500.  
  1501. let ntp = document.createElement("p");
  1502. ntp.setAttribute("class", "mb");
  1503. chs.appendChild(ntp);
  1504. ntp.appendChild(
  1505. document.createTextNode("Выберите главы для выгрузки. Обратите внимание: выгружены могут быть только доступные вам главы.")
  1506. );
  1507.  
  1508. let tbd = document.createElement("div");
  1509. tbd.setAttribute("class", "mt mb");
  1510. tbd.setAttribute("style", "display:flex; padding-top:10px; border-top:1px solid #bbb;");
  1511. fst.appendChild(tbd);
  1512.  
  1513. let its = document.createElement("span");
  1514. its.setAttribute("style", "margin:auto 5px auto 0");
  1515. tbd.appendChild(its);
  1516. its.appendChild(document.createTextNode("Выбрано глав: "));
  1517. let selected = document.createElement("strong");
  1518. selected.appendChild(document.createTextNode("0"));
  1519. its.appendChild(selected);
  1520. its.appendChild(document.createTextNode(" из "));
  1521. let total = document.createElement("strong");
  1522. its.appendChild(total);
  1523.  
  1524. let tb1 = document.createElement("button");
  1525. tb1.setAttribute("type", "button");
  1526. tb1.setAttribute("title", "Выделить все/ничего");
  1527. tb1.setAttribute("style", "margin-left:auto;");
  1528. tbd.appendChild(tb1);
  1529. let tb1i = document.createElement("i");
  1530. tb1i.setAttribute("class", "icon-check");
  1531. tb1.appendChild(tb1i);
  1532. tb1.appendChild(document.createTextNode(" ?"));
  1533.  
  1534. let log = document.createElement("div");
  1535. log.setAttribute("class", "mb");
  1536. log.setAttribute(
  1537. "style",
  1538. "display:none; overflow:auto; height:50vh; min-width:30vw; border:1px solid #bbb; border-radius:6px; padding: 6px;"
  1539. );
  1540. form.appendChild(log);
  1541.  
  1542. let nte = createCheckbox("Добавить примечания автора в аннотацию", !!params.authorNotes);
  1543. if (!params.authorNotes) nte.querySelector("input").disabled = true;
  1544. nte.setAttribute("style", "margin-top:" + (mobile && "10px" || "-10px"));
  1545. form.appendChild(nte);
  1546.  
  1547. let nie = createCheckbox("Не грузить картинки внутри глав", false);
  1548. nie.setAttribute("style", "margin-top:" + (mobile && "10px" || "-10px"));
  1549. form.appendChild(nie);
  1550.  
  1551. let nmt = createCheckbox("Не грузить дополнительные материалы", false);
  1552. if (!params.materials) nmt.querySelector("input").disabled = true;
  1553. nmt.setAttribute("style", "margin-top:" + (mobile && "10px" || "-10px"));
  1554. form.appendChild(nmt);
  1555.  
  1556. let sbd = document.createElement("div");
  1557. sbd.setAttribute("class", "mt text-center");
  1558. form.appendChild(sbd);
  1559. let sbt = document.createElement("button");
  1560. sbt.setAttribute("class", "button btn btn-success");
  1561. sbt.setAttribute("type", "submit");
  1562. sbt.appendChild(document.createTextNode("Продолжить"));
  1563. sbd.appendChild(sbt);
  1564.  
  1565. let chapters_list = [];
  1566.  
  1567. chs.addEventListener("change", function(event) {
  1568. let cnt = chapters_list.reduce(function(cnt, ch) {
  1569. if (!ch.locked && ch.element.children[0].children[0].checked) ++cnt;
  1570. return cnt;
  1571. }, 0);
  1572. selected.textContent = cnt;
  1573. sbt.disabled = !cnt;
  1574. });
  1575.  
  1576. tb1.addEventListener("click", function(event) {
  1577. let chf = chapters_list.some(function(ch) { return !ch.locked && !ch.element.children[0].children[0].checked; });
  1578. chapters_list.forEach(function(ch) { ch.element.children[0].children[0].checked = (chf && !ch.locked); });
  1579. chs.dispatchEvent(new Event("change"));
  1580. });
  1581.  
  1582. let mode = 0;
  1583. let fb2 = null;
  1584. let link = null;
  1585. form.addEventListener("submit", function(event) {
  1586. event.preventDefault();
  1587.  
  1588. if (mode === 1) {
  1589. afetch.abortAll();
  1590. return;
  1591. }
  1592.  
  1593. if (mode === 2) {
  1594. if (!link) {
  1595. link = document.createElement("a");
  1596. link.download = "book_" + chapters_list[0].workId + ".fb2";
  1597. link.href = URL.createObjectURL(new Blob([ fb2 ], { type: 'text/plain' }));
  1598. }
  1599. link.click();
  1600. return;
  1601. }
  1602.  
  1603. if (mode === -1) {
  1604. modalDialog.hide();
  1605. return;
  1606. }
  1607.  
  1608. if (!chapters_list.length) {
  1609. alert("Нет глав для выгрузки!");
  1610. return;
  1611. }
  1612.  
  1613. mode = 1;
  1614. fst.style.display = "none";
  1615. nte.style.display = "none";
  1616. nie.style.display = "none";
  1617. nmt.style.display = "none";
  1618. log.style.display = "block";
  1619. sbt.textContent = "Прервать";
  1620.  
  1621. let book_data = {};
  1622. if (!nte.querySelector("input").checked) params.authorNotes = null;
  1623. let without_img = nie.querySelector("input").checked;
  1624. if (nmt.querySelector("input").checked) params.materials = null;
  1625. extractDescriptionData(params, log).then(function(descr) {
  1626. book_data.descr = descr;
  1627. logMessage(log, "---");
  1628. return extractChapters(chapters_list.filter(function(ch) {
  1629. return !ch.locked && ch.element.children[0].children[0].checked;
  1630. }).map(function(ch) {
  1631. return { title: ch.title, workId: ch.workId, chapterId: ch.chapterId };
  1632. }), log, { withoutImages: without_img });
  1633. }).then(function(chapters) {
  1634. book_data.chapters = chapters;
  1635. if (params.materials) {
  1636. logMessage(log, "---");
  1637. logMessage(log, "Дополнительные материалы:");
  1638. return extractMaterials(params.materials, log);
  1639. }
  1640. }).then(function(materials) {
  1641. book_data.materials = materials;
  1642. fb2 = documentStart(book_data, log);
  1643. sbt.textContent = "Сохранить в файл";
  1644. mode = 2;
  1645. }).catch(function(err) {
  1646. mode = -1;
  1647. sbt.textContent = "Закрыть";
  1648. console.error(err);
  1649. if (err.name === "AbortError")
  1650. alert("Операция прервана")
  1651. else
  1652. alert(err);
  1653. });
  1654. });
  1655.  
  1656. // Получает список глав
  1657. let ch_cnt = 0;
  1658. getChaptersList(params).then(function(list) {
  1659. list.forEach(function(ch) {
  1660. ch.element = createChapterCheckbox(ch);
  1661. chs.appendChild(ch.element);
  1662. ++ch_cnt;
  1663. });
  1664. chapters_list = list;
  1665. chs.dispatchEvent(new Event("change"));
  1666. total.appendChild(document.createTextNode(ch_cnt));
  1667.  
  1668. // Отображает модальное диалоговое окно
  1669. modalDialog.show({
  1670. mobile: mobile,
  1671. title: "Выгрузка книги в FB2",
  1672. body: form,
  1673. onclose: function() {
  1674. fb2 = null;
  1675. if (link) {
  1676. URL.revokeObjectURL(link.href);
  1677. link = null;
  1678. }
  1679. if (mode === 1) afetch.abortAll();
  1680. },
  1681. });
  1682. }).catch(function(err) {
  1683. console.error(err);
  1684. alert(err);
  1685. }).finally(function() {
  1686. button.disabled = false;
  1687. button.setText();
  1688. });
  1689. }
  1690.  
  1691. /**
  1692. * Создает единичный элемент типа checkbox в стиле сайта
  1693. *
  1694. * @param title string Подпись для checkbox
  1695. * @param checked bool Начальное состояние checkbox
  1696. *
  1697. * @return Element HTML-элемент для последующего добавления на форму
  1698. */
  1699. function createCheckbox(title, checked) {
  1700. let root = document.createElement("div");
  1701. root.setAttribute("class", "checkbox c-checkbox no-fastclick mb");
  1702. let label = document.createElement("label");
  1703. root.appendChild(label);
  1704. let input = document.createElement("input");
  1705. input.setAttribute("type", "checkbox");
  1706. label.appendChild(input);
  1707. let span = document.createElement("span");
  1708. span.setAttribute("class", "icon-check-bold");
  1709. label.appendChild(span);
  1710. label.appendChild(document.createTextNode(title));
  1711. if (checked) {
  1712. input.setAttribute("checked", "checked");
  1713. }
  1714. return root;
  1715. }
  1716.  
  1717. /**
  1718. * Создает checkbox для диалога выбора главы
  1719. *
  1720. * @param chapter object Данные главы
  1721. *
  1722. * @return Element HTML-элемент для последующего добавления на форму
  1723. */
  1724. function createChapterCheckbox(chapter) {
  1725. let root = createCheckbox(chapter.title || "Без названия", !chapter.locked);
  1726. if (chapter.locked) {
  1727. root.querySelector("input").disabled = true;
  1728. let lock = document.createElement("i");
  1729. lock.setAttribute("class", "icon-lock text-muted ml-sm");
  1730. root.children[0].appendChild(lock);
  1731. }
  1732. if (!chapter.title) root.style.fontStyle = "italic";
  1733. return root;
  1734. }
  1735.  
  1736. /**
  1737. * Создает диалоговое окно и управляет им.
  1738. * При каждом вызове метода show окно создается заново.
  1739. * Singleton.
  1740. */
  1741. modalDialog = {
  1742. element: null,
  1743. onclose: null,
  1744. mobile: false,
  1745.  
  1746. show: function(params) {
  1747. if (params.mobile) {
  1748. this.mobile = true;
  1749. this._show_m(params);
  1750. return;
  1751. }
  1752.  
  1753. this.element = document.createElement("div");
  1754. this.element.setAttribute("class", "modal fade in");
  1755. this.element.setAttribute("tabindex", "-1");
  1756. this.element.setAttribute("role", "dialog");
  1757. this.element.setAttribute("style", "display:block; padding-right:12px;");
  1758. let dlg = document.createElement("div");
  1759. dlg.setAttribute("class", "modal-dialog");
  1760. dlg.setAttribute("role", "document");
  1761. this.element.appendChild(dlg);
  1762. let ctn = document.createElement("div");
  1763. ctn.setAttribute("class", "modal-content");
  1764. dlg.appendChild(ctn);
  1765. let hdr = document.createElement("div");
  1766. hdr.setAttribute("class", "modal-header");
  1767. ctn.appendChild(hdr);
  1768. let hbt = document.createElement("button");
  1769. hbt.setAttribute("class", "close");
  1770. hbt.setAttribute("type", "button");
  1771. hdr.appendChild(hbt);
  1772. let sbt = document.createElement("span");
  1773. hbt.appendChild(sbt);
  1774. sbt.appendChild(document.createTextNode("×"));
  1775. let htl = document.createElement("h4");
  1776. htl.setAttribute("class", "modal-title");
  1777. hdr.appendChild(htl);
  1778. htl.appendChild(document.createTextNode(params.title));
  1779.  
  1780. let bdy = document.createElement("div");
  1781. bdy.setAttribute("class", "modal-body");
  1782. bdy.setAttribute("style", "color:#656565; min-width:250px; max-width:max(500px,35vw);");
  1783. ctn.appendChild(bdy);
  1784. bdy.appendChild(params.body);
  1785.  
  1786. document.body.appendChild(this.element);
  1787.  
  1788. this.backdrop = document.createElement("div");
  1789. this.backdrop.setAttribute("class", "modal-backdrop fade in");
  1790. document.body.appendChild(this.backdrop);
  1791.  
  1792. document.body.classList.add("modal-open");
  1793.  
  1794. this.onclose = params.onclose || null;
  1795.  
  1796. this.element.addEventListener("click", function(event) {
  1797. if (event.target === this.element || event.target.closest("button.close")) {
  1798. this.hide();
  1799. }
  1800. }.bind(this));
  1801. this.element.addEventListener("keydown", function(event) {
  1802. if (event.code == "Escape" && !event.shiftKey && !event.ctrlKey && !event.altKey) {
  1803. this.hide();
  1804. event.preventDefault();
  1805. }
  1806. }.bind(this));
  1807.  
  1808. this.element.focus();
  1809. },
  1810.  
  1811. hide: function() {
  1812. if (this.mobile) {
  1813. this._hide_m();
  1814. return;
  1815. }
  1816.  
  1817. if (this.element && this.backdrop) {
  1818. this.backdrop.remove();
  1819. this.backdrop = null;
  1820. this.element.remove();
  1821. this.element = null;
  1822. document.body.classList.remove("modal-open");
  1823. if (this.onclose) this.onclose();
  1824. this.onclose = null;
  1825. }
  1826. },
  1827.  
  1828. _show_m: function(params) {
  1829. this.element = document.createElement("div");
  1830. this.element.setAttribute("class", "popup popup-screen-content");
  1831. this.element.setAttribute("style", "overflow:hidden;");
  1832. let ctn = document.createElement("div");
  1833. ctn.setAttribute("class", "content-block");
  1834. this.element.appendChild(ctn);
  1835. let htl = document.createElement("h2");
  1836. htl.setAttribute("class", "text-center");
  1837. htl.appendChild(document.createTextNode(params.title));
  1838. ctn.appendChild(htl);
  1839. let bdy = document.createElement("div");
  1840. bdy.setAttribute("class", "modal-body");
  1841. bdy.setAttribute("style", "color:#656565;");
  1842. ctn.appendChild(bdy);
  1843. bdy.appendChild(params.body);
  1844. let cbt = document.createElement("button");
  1845. cbt.setAttribute("class", "mt button btn btn-default");
  1846. cbt.appendChild(document.createTextNode("Закрыть"));
  1847. ctn.appendChild(cbt);
  1848.  
  1849. cbt.addEventListener("click", function(event) {
  1850. this.hide();
  1851. }.bind(this));
  1852.  
  1853. document.body.appendChild(this.element);
  1854. this.element.style.display = "block";
  1855.  
  1856. this.element.classList.add("modal-in");
  1857. this._turnOverlay_m(true);
  1858.  
  1859. this.element.focus();
  1860. },
  1861.  
  1862. _hide_m: function() {
  1863. if (this.element) {
  1864. this.element.remove();
  1865. this.element = null;
  1866. if (this.onclose) {
  1867. this.onclose();
  1868. this.onclose = null;
  1869. }
  1870. this._turnOverlay_m(false);
  1871. }
  1872. },
  1873.  
  1874. _turnOverlay_m(on) {
  1875. let overlay = document.querySelector("div.popup-overlay");
  1876. if (!overlay && on) {
  1877. overlay = document.createElement("div");
  1878. overlay.setAttribute("class", "popup-overlay");
  1879. document.body.appendChild(overlay);
  1880. }
  1881. if (on) {
  1882. overlay.classList.add("modal-overlay-visible");
  1883. } else if (overlay) {
  1884. overlay.classList.remove("modal-overlay-visible");
  1885. }
  1886. }
  1887. };
  1888.  
  1889. /**
  1890. * Обертка для ассинхронных запросов с возможностью отмены всех запросов разом
  1891. *
  1892. * @param url string Адрес запрашиваемого ресурса
  1893. * @param params object Параметры асинхронного запроса
  1894. * @param li object Запись лога, для отображения прогресса. Необязательный параметр.
  1895. *
  1896. * @return Promise Промис, который вернет запрашиваемые данные
  1897. */
  1898. function afetch(url, params, li) {
  1899. params ||= {};
  1900. params.url = url;
  1901. params.method ||= "GET";
  1902. return new Promise(function(resolve, reject) {
  1903. let req = null;
  1904. params.onload = function(r) {
  1905. if (r.status === 200) {
  1906. let headers = {};
  1907. r.responseHeaders.split("\n").forEach(function(hs) {
  1908. let h = hs.split(":");
  1909. if (h[1]) headers[h[0].trim().toLowerCase()] = h[1].trim();
  1910. });
  1911. resolve({ headers: headers, response: r.response });
  1912. } else {
  1913. reject(new Error("Сервер вернул ошибку (" + r.status + ")"));
  1914. }
  1915. };
  1916. params.onerror = function(e) {
  1917. reject(e);
  1918. };
  1919. params.ontimeout = function(e) {
  1920. reject(e);
  1921. };
  1922. params.onloadend = function() {
  1923. req && afetch.ctl_list.delete(req);
  1924. };
  1925. if (li) {
  1926. params.onprogress = function(pe) {
  1927. if (pe.lengthComputable)
  1928. li.text("" + Math.round(pe.loaded / pe.total * 100) + "%");
  1929. };
  1930. }
  1931. try {
  1932. req = GM.xmlHttpRequest(params);
  1933. req && afetch.ctl_list.add(req);
  1934. } catch (e) {
  1935. reject(e);
  1936. }
  1937. });
  1938. }
  1939.  
  1940. /**
  1941. * Инициирует структуру обертки
  1942. */
  1943. afetch.init = function() {
  1944. afetch.ctl_list = new Set();
  1945. };
  1946.  
  1947. /**
  1948. * Прерывает все выполняющиеся ассинхронные запросы и очищает хранилище контроллеров
  1949. */
  1950. afetch.abortAll = function() {
  1951. afetch.ctl_list.forEach(function(ctl) {
  1952. ctl.abort();
  1953. });
  1954. afetch.ctl_list.clear();
  1955. };
  1956.  
  1957. /**
  1958. * Расшифровывает полученную от сервера строку с текстом
  1959. *
  1960. * @param chapter string Зашифованная глава книги, полученная от сервера
  1961. * @param secret string Часть ключа для расшифровки
  1962. *
  1963. * @return string Расшифрованный текст
  1964. */
  1965. function decryptText(chapter, secret) {
  1966. let ss = secret.split("").reverse().join("") + "@_@" + (app.userId || "");
  1967. let slen = ss.length;
  1968. let clen = chapter.data.text.length;
  1969. let result = [];
  1970. for (let pos = 0; pos < clen; ++pos) {
  1971. result.push(String.fromCharCode(chapter.data.text.charCodeAt(pos) ^ ss.charCodeAt(Math.floor(pos % slen))));
  1972. }
  1973. return result.join("");
  1974. }
  1975.  
  1976. /**
  1977. * Возвращает текстовое представление XML-дерева элементов
  1978. *
  1979. * @param doc XMLDocument XML-документ
  1980. *
  1981. * @return string XML-документ в виде строки
  1982. */
  1983. function xmldocToString(doc) {
  1984. // TODO! Сделать переносы строк и отступы в итоговом XML-файле.
  1985. return (new XMLSerializer()).serializeToString(doc);
  1986. }
  1987.  
  1988. /**
  1989. * Возвращает хэш переданной строки. Используется как часть уникального идентификатора книги
  1990. *
  1991. * @param str string Строка для получения хэша
  1992. *
  1993. * @return string Строковое представление хэша переданной строки
  1994. */
  1995. function stringHash(str) {
  1996. let hash = 0;
  1997. let slen = str.length;
  1998. for (let i = 0; i < slen; ++i) {
  1999. let ch = str.charCodeAt(i);
  2000. hash = ((hash << 5) - hash) + ch;
  2001. hash = hash & hash; // Convert to 32bit integer
  2002. }
  2003. return Math.abs(hash).toString() + (hash > 0 ? "1" : "");
  2004. }
  2005.  
  2006. /**
  2007. * Класс для управления наблюдением за логотипом сайта, чтобы отлавливать изменения в странице
  2008. * производимые сайтом через свои скрипты. Там вместо лого отображается картинка часиков.
  2009. */
  2010. observer = {
  2011. _observer: null,
  2012.  
  2013. start: function(handler) {
  2014. let logo = document.querySelector("div.brand-logo");
  2015. if (logo) {
  2016. if (!this._observer)
  2017. this._observer = new MutationObserver(function() {
  2018. if (!logo.querySelector("div#nprogress"))
  2019. handler();
  2020. });
  2021. this._observer.observe(logo, { childList: true, subtree: true });
  2022. }
  2023. },
  2024.  
  2025. stop: function() {
  2026. if (this._observer)
  2027. this._observer.disconnect();
  2028. }
  2029. };
  2030.  
  2031. /**
  2032. * Список фиксированных жанров для FB2.
  2033. * Первый элемент - Точное название жанра
  2034. * Последующие элементы - ключевые слова в нижнем регистре для дополнительной идентификации жанра
  2035. * Список взят отсюда: https://github.com/gribuser/fb2/blob/master/FictionBookGenres.xsd
  2036. */
  2037. let GENRE_MAP = {
  2038. adv_animal: [ "Природа и животные", "приключения", "животные", "природа" ],
  2039. adv_geo: [ "Путешествия и география", "приключения", "география", "путешествие" ],
  2040. adv_history: [ "Исторические приключения", "история", "приключения" ],
  2041. adv_maritime: [ "Морские приключения", "приключения", "море" ],
  2042. //adv_western: [ ], //??
  2043. adventure: [ "Приключения" ],
  2044. antique: [ "Старинное" ],
  2045. antique_ant: [ "Античная литература", "старинное", "античность" ],
  2046. antique_east: [ "Древневосточная литература", "старинное", "восток" ],
  2047. antique_european: [ "Европейская старинная литература", "старинное", "европа" ],
  2048. antique_myths: [ "Мифы. Легенды. Эпос", "мифы", "легенды", "эпос" ],
  2049. antique_russian: [ "Древнерусская литература", "древнерусское" ],
  2050. aphorism_quote: [ "Афоризмы, цитаты" ],
  2051. architecture_book: [ "Скульптура и архитектура", "дизайн" ],
  2052. auto_regulations: [ "Автомобили и ПДД", "дорожного", "движения", "дорожное", "движение" ],
  2053. banking: [ "Финансы", "банки", "деньги" ],
  2054. beginning_authors: [ "Начинающие авторы" ],
  2055. child_adv: [ "Приключения для детей и подростков" ],
  2056. child_det: [ "Детская остросюжетная литература" ],
  2057. child_education: [ "Детская образовательная литература" ],
  2058. child_prose: [ "Проза для детей" ],
  2059. child_sf: [ "Фантастика для детей" ],
  2060. child_tale: [ "Сказки для детей" ],
  2061. child_verse: [ "Стихи для детей" ],
  2062. children: [ "Детское" ],
  2063. cinema_theatre: [ "Кино и театр" ],
  2064. city_fantasy: [ "Городское фэнтези" ],
  2065. comp_db: [ "Компьютерные базы данных" ],
  2066. comp_hard: [ "Компьютерное железо", "аппаратное" ],
  2067. comp_osnet: [ "ОС и копьютерные сети" ],
  2068. comp_programming: [ "Программирование" ],
  2069. comp_soft: [ "Программное обеспечение" ],
  2070. comp_www: [ "Интернет" ],
  2071. computers: [ "Компьютеры" ],
  2072. design: [ "Дизайн" ],
  2073. det_action: [ "Боевики", "боевик" ],
  2074. det_classic: [ "Классический детектив" ],
  2075. det_crime: [ "Криминальный детектив", "криминал" ],
  2076. det_espionage: [ "Шнионский детектив", "шпион", "шпионы" ],
  2077. det_hard: [ "Крутой детектив" ],
  2078. det_history: [ "Исторический детектив", "история" ],
  2079. det_irony: [ "Иронический детектив" ],
  2080. det_police: [ "Полицейский детектив", "полиция" ],
  2081. det_political: [ "Политический детектив", "политика" ],
  2082. detective: [ "Детективы", "детектив" ],
  2083. dragon_fantasy: [ "Фэнтези с драконами", "драконы", "дракон" ],
  2084. dramaturgy: [ "Драматургия" ],
  2085. economics: [ "Экономика" ],
  2086. essays: [ "Эссэ" ],
  2087. fantasy_fight: [ "Боевое фэнези" ],
  2088. foreign_action: [ "Зарубежные боевики", "иностранные" ],
  2089. foreign_adventure: [ "Зарубежная приключенческая литература", "иностранная", "приключения" ],
  2090. foreign_antique: [ "Средневековая классическая проза" ],
  2091. foreign_business: [ "Зарубежная карьера и бизнес", "иностранная" ],
  2092. foreign_children: [ "Зарубежная литература для детей" ],
  2093. foreign_comp: [ "Зарубежная компьютерная литература" ],
  2094. foreign_contemporary: [ "Зарубежная современная литература" ],
  2095. //foreign_contemporary_lit: [ ], //??
  2096. //foreign_desc: [ ], //??
  2097. foreign_detective: [ "Зарубежные детективы", "иностранные", "зарубежный", "детектив" ],
  2098. foreign_dramaturgy: [ "Зарубежная драматургия" ],
  2099. foreign_edu: [ "Зарубежная образовательная литература", "иностранная" ],
  2100. foreign_fantasy: [ "Зарубежное фэнтези", "иностранное", "иностранная", "зарубежная", "фантастика" ],
  2101. foreign_home: [ "Зарубежное домоводство", "иностранное" ],
  2102. foreign_humor: [ "Зарубежная юмористическая литература", "иностранная" ],
  2103. foreign_language: [ "Иностранные языки" ],
  2104. foreign_love: [ "Зарубежная любовная литература", "иностранная" ],
  2105. foreign_novel: [ "Зарубежные романы", "иностранные" ],
  2106. foreign_other: [ "Другая зарубежная литература", "иностранная" ],
  2107. foreign_poetry: [ "Зарубежная поэзия", "иностранная", "зарубежные", "стихи" ],
  2108. foreign_prose: [ "Зарубежная классическая проза", "иностранная", "проза" ],
  2109. foreign_psychology: [ "Зарубежная литература о прихологии", "иностранная" ],
  2110. foreign_publicism: [ "Зарубежная публицистика", "иностранная", "документальная" ],
  2111. foreign_religion: [ "Зарубежная религия", "иностранная" ],
  2112. foreign_sf: [ "Зарубежная научная фантастика", "иностранная" ],
  2113. geo_guides: [ "Путеводители, карты, атласы", "география" ],
  2114. geography_book: [ "Путешествия и география" ],
  2115. global_economy: [ "Глобальная экономика" ],
  2116. historical_fantasy: [ "Историческое фэнтези" ],
  2117. home: [ "Домоводство", "дом", "семья" ],
  2118. home_cooking: [ "Кулинария" ],
  2119. home_crafts: [ "Хобби и ремесла" ],
  2120. home_diy: [ "Сделай сам" ],
  2121. home_entertain: [ "Развлечения" ],
  2122. home_garden: [ "Сад и огород" ],
  2123. home_health: [ "Здоровье" ],
  2124. home_pets: [ "Домашние животные" ],
  2125. home_sex: [ "Семейные отношения, секс" ],
  2126. home_sport: [ "Боевые исскусства, спорт" ],
  2127. humor: [ "Юмор" ],
  2128. humor_anecdote: [ "Анекдоты" ],
  2129. humor_fantasy: [ "Юмористическое фэтези","юмористическая", "фантастика" ],
  2130. humor_prose: [ "Юмористическая проза" ],
  2131. humor_verse: [ "Юмористические стихи, басни" ],
  2132. industries: [ "Отрасли", "индустрия" ],
  2133. job_hunting: [ "Поиск работы", "работа" ],
  2134. literature_18: [ "Классическая проза XVII-XVIII веков" ],
  2135. literature_19: [ "Классическая проза ХIX века" ],
  2136. literature_20: [ "Классическая проза ХX века" ],
  2137. love_contemporary: [ "Современные любовные романы" ],
  2138. love_detective: [ "Остросюжетные любовные романы", "детектив", "любовь" ],
  2139. love_erotica: [ "Эротическая литература", "эротика" ],
  2140. love_fantasy: [ "Любовное фэнтези" ],
  2141. love_history: [ "Исторические любовные романы", "история", "любовь" ],
  2142. love_sf: [ "Любовно-фантастические романы" ],
  2143. love_short: [ "Короткие любовные романы" ],
  2144. magician_book: [ "Магия, фокусы" ],
  2145. management: [ "Менеджмент", "управление" ],
  2146. marketing: [ "Маркетинг", "продажи" ],
  2147. military_special: [ "Специальная военная литература" ],
  2148. music_dancing: [ "Музыка и танцы" ],
  2149. narrative: [ "Повествование" ],
  2150. newspapers: [ "Газеты" ],
  2151. nonf_biography: [ "Биографии и Мемуары" ],
  2152. nonf_criticism: [ "Критика" ],
  2153. nonf_publicism: [ "Публицистика" ],
  2154. nonfiction: [ "Документальная литература" ],
  2155. org_behavior: [ "Маркентиг, PR", "организации" ],
  2156. paper_work: [ "Канцелярская работа" ],
  2157. pedagogy_book: [ "Педагогическая литература" ],
  2158. periodic: [ "Журналы, газеты" ],
  2159. personal_finance: [ "Личные финансы" ],
  2160. poetry: [ "Поэзия" ],
  2161. popadanec: [ "Попаданцы", "попаданец" ],
  2162. popular_business: [ "Карьера, кадры", "карьера", "дело", "бизнес" ],
  2163. prose_classic: [ "Классическая проза" ],
  2164. prose_counter: [ "Контркультура" ],
  2165. prose_history: [ "Историческая проза", "история", "проза" ],
  2166. prose_military: [ "Проза о войне" ],
  2167. prose_rus_classic: [ "Русская классическая проза" ],
  2168. prose_su_classics: [ "Советская классическая проза" ],
  2169. psy_classic: [ "Классическая психология" ],
  2170. psy_childs: [ "Детская психология" ],
  2171. psy_generic: [ "Общая психология" ],
  2172. psy_personal: [ "Психология личности" ],
  2173. psy_sex_and_family: [ "Семейная психология", "семья", "секс" ],
  2174. psy_social: [ "Социальная психология" ],
  2175. psy_theraphy: [ "Психотерапия", "психология", "терапия" ],
  2176. //real_estate: [ ], // ??
  2177. ref_dict: [ "Словари", "справочник" ],
  2178. ref_encyc: [ "Энциклопедии", "энциклопедия" ],
  2179. ref_guide: [ "Руководства", "руководство", "справочник" ],
  2180. ref_ref: [ "Справочники", "справочник" ],
  2181. reference: [ "Справочная литература" ],
  2182. religion: [ "Религия" ],
  2183. religion_esoterics: [ "Эзотерическая литература", "эзотерика" ],
  2184. //religion_rel: [ ], // ??
  2185. religion_self: [ "Самосовершенствование" ],
  2186. russian_contemporary: [ "Русская современная литература" ],
  2187. russian_fantasy: [ "Славянское фэнтези" ],
  2188. sci_biology: [ "Биология" ],
  2189. sci_chem: [ "Химия" ],
  2190. sci_culture: [ "Культурология" ],
  2191. sci_history: [ "История" ],
  2192. sci_juris: [ "Юриспруденция" ],
  2193. sci_linguistic: [ "Языкознание", "иностранный", "язык" ],
  2194. sci_math: [ "Математика" ],
  2195. sci_medicine: [ "Медицина" ],
  2196. sci_philosophy: [ "Философия" ],
  2197. sci_phys: [ "Физика" ],
  2198. sci_politics: [ "Политика" ],
  2199. sci_religion: [ "Религиоведение", "религия", "духовность" ],
  2200. sci_tech: [ "Технические науки", "техника" ],
  2201. science: [ "Научная литература", "образование" ],
  2202. sf: [ "Научная фантастика", "наука", "фантастика" ],
  2203. sf_action: [ "Боевая фантастика" ],
  2204. sf_cyberpunk: [ "Киберпанк" ],
  2205. sf_detective: [ "Детективная фантастика", "детектив", "фантастика" ],
  2206. sf_fantasy: [ "Фэнтези" ],
  2207. sf_heroic: [ "Героическая фантастика", "герой" ],
  2208. sf_history: [ "Альтернативная история", "история", "фантастика" ],
  2209. sf_horror: [ "Ужасы" ],
  2210. sf_humor: [ "Юмористическая фантастика", "юмор", "фантастика" ],
  2211. sf_social: [ "Социально-психологическая фантастика", "социум", "психология", "фантастика" ],
  2212. sf_space: [ "Космическая фантастика", "космос", "фантастика" ],
  2213. short_story: [ "Рассказы", "рассказ" ],
  2214. sketch: [ "Отрывок", "зарисовка", "набросок", "очерк" ],
  2215. small_business: [ "Малый бизнес", "бизнес", "карьера" ],
  2216. sociology_book: [ "Обществознание", "социология" ],
  2217. stock: [ "Ценные бумаги" ],
  2218. thriller: [ "Триллер", "триллеры" ],
  2219. upbringing_book: [ "Воспитание" ],
  2220. vampire_book: [ "Вампиры", "вампир" ],
  2221. visual_arts: [ "Изобразительное искусство" ],
  2222. };
  2223.  
  2224. /**
  2225. * Преобразование жанров сайта в идентификаторы жанров FB2
  2226. *
  2227. * @param keys Array Массив жанров с сайта
  2228. *
  2229. * @return Array Массив жанров формата FB2
  2230. */
  2231. function identifyGenre(keys) {
  2232. let gmap = {};
  2233. let addWeight = function(name, weight) {
  2234. gmap[name] = (gmap[name] || 0) + weight;
  2235. };
  2236. for (let i = 0; i < keys.length; ++i) {
  2237. let site_key = keys[i].toLowerCase();
  2238. let site_wkeys = site_key.split(/[\s,.;]+/);
  2239. if (site_wkeys.length === 1) site_wkeys = [];
  2240. for (let g_name in GENRE_MAP) {
  2241. let g_values = GENRE_MAP[g_name];
  2242. let g_title = g_values[0].toLowerCase();
  2243. if (site_key === g_title) {
  2244. addWeight(g_name, 3); // Точное совпадение!
  2245. break;
  2246. }
  2247. // Искать каждое слово жанра с сайта отдельно
  2248. let weight = 0;
  2249. if (site_wkeys.indexOf(g_title) !== -1) weight += 2;
  2250. if (site_wkeys.length) {
  2251. for (let k = 1; k < g_values.length; ++k) {
  2252. if (site_wkeys.indexOf(g_values[k]) !== -1) ++weight;
  2253. }
  2254. }
  2255. if (weight >= 2) addWeight(g_name, weight);
  2256. }
  2257. }
  2258.  
  2259. let res = Object.keys(gmap).map(function(genre) {
  2260. return [ genre, gmap[genre] ];
  2261. });
  2262. if (!res.length) return [];
  2263. res.sort(function(a, b) { return b[1] < a[1]; });
  2264.  
  2265. let cur_w = 0;
  2266. let res_genres = [];
  2267. for (let i = 0; i < res.length; ++i) {
  2268. if (res[i][1] !== cur_w && res_genres.length >= 3) break;
  2269. cur_w = res[i][1];
  2270. res_genres.push(res[i][0]);
  2271. }
  2272. return res_genres;
  2273. }
  2274.  
  2275. // Запускает скрипт после загрузки страницы сайта
  2276. if (document.readyState === "loading")
  2277. window.addEventListener("DOMContentLoaded", init);
  2278. else
  2279. init();
  2280. }());
  2281.  

QingJ © 2025

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