FicbookExtractor

The script allows you to download books to an FB2 file without any limits

  1. // ==UserScript==
  2. // @name FicbookExtractor
  3. // @namespace 90h.yy.zz
  4. // @version 0.6.3
  5. // @author Ox90
  6. // @match https://ficbook.net/readfic/*/download
  7. // @description The script allows you to download books to an FB2 file without any limits
  8. // @description:ru Скрипт позволяет скачивать книги в FB2 файл без ограничений
  9. // @require https://update.greasyfork.org/scripts/468831/1478439/HTML2FB2Lib.js
  10. // @grant GM.xmlHttpRequest
  11. // @license MIT
  12. // ==/UserScript==
  13.  
  14. (function start() {
  15.  
  16. const PROGRAM_NAME = GM_info.script.name;
  17.  
  18. let stage = 0;
  19.  
  20. function init() {
  21. try {
  22. updatePage();
  23. } catch (err) {
  24. console.error(err);
  25. }
  26. }
  27.  
  28. function updatePage() {
  29. const cs = document.querySelector("section.content-section>div.clearfix");
  30. if (!cs) throw new Error("Ошибка идентификации блока download");
  31. if (cs.querySelector(".fbe-download-section")) return; // Для отработки кнопки "Назад" в браузере.
  32. let ds = Array.from(cs.querySelectorAll("section.fanfic-download-option")).find(el => {
  33. const hdr = el.firstElementChild;
  34. return hdr.tagName === "H5" && hdr.textContent.endsWith(" fb2");
  35. });
  36. if (!ds) {
  37. ds = makeDownloadSection();
  38. cs.append(ds);
  39. }
  40. ds.appendChild(makeDownloadButton()).querySelector("button.btn-primary").addEventListener("click", event => {
  41. event.preventDefault();
  42. let log = null;
  43. let doc = new DocumentEx();
  44. doc.idPrefix = "fbe_";
  45. doc.programName = PROGRAM_NAME + " v" + GM_info.script.version;
  46. const dlg = new Dialog({
  47. onsubmit: () => {
  48. makeAction(doc, dlg, log);
  49. },
  50. onhide: () => {
  51. Loader.abortAll();
  52. doc = null;
  53. if (dlg.link) {
  54. URL.revokeObjectURL(dlg.link.href);
  55. dlg.link = null;
  56. }
  57. }
  58. });
  59. dlg.show();
  60. log = new LogElement(dlg.log);
  61. dlg.button.textContent = setStage(0);
  62. makeAction(doc, dlg, log);
  63. });
  64. }
  65.  
  66. function makeDownloadSection() {
  67. const sec = document.createElement("section");
  68. sec.classList.add("fanfic-download-option");
  69. sec.innerHTML = "<h5 class=\"font-bold\">Скачать в fb2</h5>";
  70. return sec;
  71. }
  72.  
  73. function makeDownloadButton() {
  74. const ctn = document.createElement("div");
  75. ctn.classList.add("fanfic-download-container", "fbe-download-section");
  76. ctn.innerHTML =
  77. "<svg class=\"ic_document-file-fb2 svg-icon hidden-xs\" viewBox=\"0 0 45.1 45.1\">" +
  78. "<path d=\"M33.4,0H5.2v45.1h34.7V6.3L33.4,0z M36.9,42.1H8.2V3h23.7v4.8h5L36.9,42.1L36.9,42.1z\"></path>" +
  79. "<g><path d=\"M10.7,19h6.6v1.8h-3.9v1.5h3.3v1.7h-3.3v3.5h-2.7V19z\"></path>" +
  80. "<path d=\"M18.7,19h5c0.8,0,1.5,0.2,1.9,0.6s0.7,0.9,0.7,1.5c0,0.5-0.2,0.9-0.5,1.3c-0.2,0.2-0.5,0.4-0.9," +
  81. "0.6 c0.6,0.1,1.1,0.4,1.4,0.8s0.4,0.8,0.4,1.4c0,0.4-0.1,0.8-0.3,1.2s-0.5,0.6-0.8,0.8c-0.2,0.1-0.6," +
  82. "0.2-1,0.3c-0.6,0.1-1,0.1-1.2,0.1 h-4.6V19z M21.4,22.4h1.2c0.4,0,0.7-0.1,0.9-0.2s0.2-0.3," +
  83. "0.2-0.6c0-0.2-0.1-0.4-0.2-0.6s-0.4-0.2-0.8-0.2h-1.2V22.4z M21.4,25.8 h1.4c0.5,0,0.8-0.1,1-0.2s0.3-0.4," +
  84. "0.3-0.7c0-0.3-0.1-0.5-0.3-0.6s-0.5-0.2-1-0.2h-1.3V25.8z\"></path>" +
  85. "<path d=\"M34.7,27.6h-7.2c0.1-0.7,0.3-1.4,0.7-2s1.2-1.4,2.3-2.2c0.7-0.5,1.1-0.9,1.3-1.2s0.3-0.5," +
  86. "0.3-0.8c0-0.3-0.1-0.5-0.3-0.7 s-0.4-0.3-0.7-0.3c-0.3,0-0.6,0.1-0.7,0.3s-0.3,0.5-0.4,1l-2.4-0.2c0.1-0.7," +
  87. "0.3-1.2,0.5-1.6s0.6-0.7,1.1-0.9s1.1-0.3,1.9-0.3 c0.8,0,1.5,0.1,2,0.3s0.8,0.5,1.1,0.9s0.4,0.8,0.4,1.3c0," +
  88. "0.5-0.2,1-0.5,1.5s-0.9,1-1.7,1.6c-0.5,0.3-0.8,0.6-1,0.7 s-0.4,0.3-0.6,0.5h3.7V27.6z\"></path></g></svg>" +
  89. "<div class=\"fanfic-download-description\">FB2 - формат электронных книг. Лимиты не действуют. " +
  90. "Скачивайте и наслаждайтесь! <em style=\"color:#c69e6b; margin-left:.75em; white-space:nowrap;\">" +
  91. "[ from FicbookExtractor with love ]</em></div>" +
  92. "<button class=\"btn btn-primary btn-responsive\">" +
  93. "<svg class=\"ic_download svg-icon\" viewBox=\"0 0 32 32\">" +
  94. "<path d=\"M6 32h20a6 6 0 0 0 6-6H0a6 6 0 0 0 6 6zm20-4h2v2h-2v-2zM25 8l-9 9-9-9h7V0h4v8zm7 15c.1.6-.2 1-.8" +
  95. " 1H.8c-.6 0-1-.4-.8-1l3.5-10c.2-.6.8-1 1.3-1H7l8 8h2l8-8h2.2c.5 0 1.1.4 1.3 1L32 23z\"></path>" +
  96. "</svg> Скачать</button>";
  97. return ctn;
  98. }
  99.  
  100. async function makeAction(doc, dlg, log) {
  101. try {
  102. switch (stage) {
  103. case 0:
  104. await getBookInfo(doc, log);
  105. dlg.button.textContent = setStage(1);
  106. dlg.button.disabled = false;
  107. break;
  108. case 1:
  109. dlg.button.textContent = setStage(2);
  110. await getBookContent(doc, log);
  111. dlg.button.textContent = setStage(3);
  112. break;
  113. case 2:
  114. Loader.abortAll();
  115. dlg.button.textContent = setStage(4);
  116. break;
  117. case 3:
  118. if (!dlg.link) {
  119. dlg.link = document.createElement("a");
  120. dlg.link.download = genBookFileName(doc);
  121. dlg.link.href = URL.createObjectURL(new Blob([ doc ], { type: "application/octet-stream" }));
  122. }
  123. dlg.link.click();
  124. break;
  125. case 4:
  126. dlg.hide();
  127. break;
  128. }
  129. } catch (err) {
  130. console.error(err);
  131. log.message(err.message, "red");
  132. dlg.button.textContent = setStage(4);
  133. dlg.button.disabled = false;
  134. }
  135. }
  136.  
  137. function setStage(newStage) {
  138. stage = newStage;
  139. return [ "Анализ...", "Продолжить", "Прервать", "Сохранить в файл", "Закрыть" ][newStage] || "Error";
  140. }
  141.  
  142. function getBookInfoElement(htmlString) {
  143. const doc = (new DOMParser()).parseFromString(htmlString, "text/html");
  144. return doc.querySelector("section.chapter-info");
  145. }
  146.  
  147. async function getBookInfo(doc, log) {
  148. const logTitle = log.message("Название:");
  149. const logAuthors = log.message("Авторы:");
  150. const logTags = log.message("Теги:");
  151. const logUpdate = log.message("Последнее обновление:");
  152. const logChapters = log.message("Всего глав:");
  153. //--
  154. const idR = /^\/readfic\/([^\/]+)/.exec(document.location.pathname);
  155. if (!idR) throw new Error("Не найден id произведения");
  156. const url = new URL(`/readfic/${encodeURIComponent(idR[1])}`, document.location);
  157. const bookEl = getBookInfoElement(await Loader.addJob(url));
  158. if (!bookEl) throw new Error("Не найдено описание произведения");
  159. // ID произведения
  160. doc.id = idR[1];
  161. // Название произведения
  162. doc.bookTitle = (() => {
  163. const el = bookEl.querySelector("h1[itemprop=name]") || bookEl.querySelector("h1[itemprop=headline]");
  164. const str = el && el.textContent.trim() || null;
  165. if (!str) throw new Error("Не найдено название произведения");
  166. return str;
  167. })();
  168. logTitle.text(doc.bookTitle);
  169. // Авторы
  170. doc.bookAuthors = (() => {
  171. return Array.from(
  172. bookEl.querySelectorAll(".hat-creator-container .creator-info a.creator-username + i")
  173. ).reduce((list, el) => {
  174. if ([ "автор", "соавтор", "переводчик", "сопереводчик" ].includes(el.textContent.trim().toLowerCase())) {
  175. const name = el.previousElementSibling.textContent.trim();
  176. if (name) {
  177. const au = new FB2Author(name);
  178. au.homePage = el.href;
  179. list.push(au);
  180. }
  181. }
  182. return list;
  183. }, []);
  184. })();
  185. logAuthors.text(doc.bookAuthors.length || "нет");
  186. if (!doc.bookAuthors.length) log.warning("Не найдена информация об авторах");
  187. // Жанры
  188. doc.genres = new FB2GenreList([ "фанфик" ]);
  189. // Ключевые слова
  190. doc.keywords = (() => {
  191. // Селектор :not(.hidden) исключает спойлерные теги
  192. return Array.from(bookEl.querySelectorAll(".tags a.tag[href^=\"/tags/\"]:not(.hidden)")).reduce((list, el) => {
  193. const tag = el.textContent.trim();
  194. if (tag) list.push(tag);
  195. return list;
  196. }, []);
  197. })();
  198. logTags.text(doc.keywords.length || "нет");
  199. // Список глав
  200. const chapters = getChaptersList(bookEl);
  201. if (!chapters.length) {
  202. // Возможно это короткий рассказ, так что есть шанс, что единственная глава находится тут же.
  203. const chData = getChapterData(bookEl);
  204. if (chData) {
  205. const titleEl = bookEl.querySelector("article .title-area h2");
  206. const title = titleEl && titleEl.textContent.trim();
  207. const pubEl = bookEl.querySelector("article div[itemprop=datePublished] span");
  208. const published = pubEl && pubEl.title || "";
  209. chapters.push({
  210. id: null,
  211. title: title !== doc.bookTitle ? title : null,
  212. updated: published,
  213. data: chData
  214. });
  215. }
  216. }
  217. // Дата произведения (последнее обновление)
  218. const months = new Map([
  219. [ "января", "01" ], [ "февраля", "02" ], [ "марта", "03" ], [ "апреля", "04" ], [ "мая", "05" ], [ "июня", "06" ],
  220. [ "июля", "07" ], [ "августа", "08" ], [ "сентября", "09" ], [ "октября", "10" ], [ "ноября", "11" ], [ "декабря", "12" ]
  221. ]);
  222. doc.bookDate = (() => {
  223. return chapters.reduce((result, chapter) => {
  224. const rr = /^(\d+)\s+([^ ]+)\s+(\d+)\s+г\.\s+в\s+(\d+:\d+)$/.exec(chapter.updated);
  225. if (rr) {
  226. const m = months.get(rr[2]);
  227. const d = (rr[1].length === 1 ? "0" : "") + rr[1];
  228. const ts = new Date(`${rr[3]}-${m}-${d}T${rr[4]}`);
  229. if (ts instanceof Date && !isNaN(ts.valueOf())) {
  230. if (!result || result < ts) result = ts;
  231. }
  232. }
  233. return result;
  234. }, null);
  235. })();
  236. logUpdate.text(doc.bookDate && doc.bookDate.toLocaleString() || "n/a");
  237. // Ссылка на источник
  238. doc.sourceURL = url.toString();
  239. //--
  240. logChapters.text(chapters.length);
  241. if (!chapters.length) throw new Error("Нет глав для выгрузки!");
  242. doc.element = bookEl;
  243. doc.chapters = chapters;
  244. }
  245.  
  246. function getChaptersList(bookEl) {
  247. return Array.from(bookEl.querySelectorAll("ul.list-of-fanfic-parts>li.part")).reduce((list, el) => {
  248. const aEl = el.querySelector("a.part-link");
  249. const rr = /^\/readfic\/[^\/]+\/(\d+)/.exec(aEl.getAttribute("href"));
  250. if (rr) {
  251. const tEl = el.querySelector(".part-title");
  252. const dEl = el.querySelector(".part-info>span[title]");
  253. const chapter = {
  254. id: rr[1],
  255. title: tEl && tEl.textContent.trim() || "Без названия",
  256. updated: dEl && dEl.title.trim() || null
  257. };
  258. list.push(chapter);
  259. }
  260. return list;
  261. }, []);
  262. }
  263.  
  264. async function getBookContent(doc, log) {
  265. const bookEl = doc.element;
  266. delete doc.element;
  267. let li = null;
  268. try {
  269. // Загрузка обложки
  270. doc.coverpage = await ( async () => {
  271. const el = bookEl.querySelector(".fanfic-hat-body fanfic-cover");
  272. if (el) {
  273. const url = el.getAttribute("src-desktop") || el.getAttribute("src-original") || el.getAttribute("src-mobile");
  274. if (url) {
  275. const img = new FB2Image(url);
  276. let li = log.message("Загрузка обложки...");
  277. try {
  278. await img.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"));
  279. img.id = "cover" + img.suffix();
  280. doc.binaries.push(img);
  281. log.message("Размер обложки:").text(img.size + " байт");
  282. log.message("Тип обложки:").text(img.type);
  283. li.ok();
  284. return img;
  285. } catch (err) {
  286. li.fail();
  287. return false;
  288. }
  289. }
  290. }
  291. })();
  292. if (!doc.coverpage) log.warning(doc.coverpage === undefined ? "Обложка не найдена" : "Не удалось загрузить обложку");
  293. // Аннотация
  294. const annData = (() => {
  295. const result = [];
  296. // Фендом
  297. const fdEl = bookEl.querySelector(".fanfic-main-info svg.ic_book + a");
  298. if (fdEl) {
  299. const text = Array.from(fdEl.parentElement.querySelectorAll("a")).map(el => el.textContent.trim()).join(", ");
  300. result.push({ index: 1, title: "Фэндом:", element: text, inline: true });
  301. }
  302. // Бейджики
  303. Array.from(bookEl.querySelectorAll("section div .badge-text")).forEach(te => {
  304. const parent = te.parentElement;
  305. if (parent.classList.contains("direction")) {
  306. result.push({ index: 2, title: "Направленность:", element: te.textContent.trim(), inline: true });
  307. } else if (Array.from(parent.classList).some(c => c.startsWith("badge-rating"))) {
  308. result.push({ index: 3, title: "Рейтинг:", element: te.textContent.trim(), inline: true });
  309. } else if (Array.from(parent.classList).some(c => c.startsWith("badge-status"))) {
  310. result.push({ index: 4, title: "Статус:", element: te.textContent.trim(), inline: true });
  311. }
  312. });
  313. // Рейтинг
  314. // Статус
  315. const descrMap = new Map([
  316. [ "автор оригинала:", { index: 5, selector: "a", inline: true } ],
  317. [ "оригинал:", { index: 6, inline: true } ],
  318. [ "пэйринг и персонажи:", { index: 7, selector: "a", inline: true } ],
  319. [ "размер:", { index: 8, inline: true } ],
  320. [ "метки:", { index: 9, selector: "a:not(.hidden)", inline: true } ],
  321. [ "описание:", { index: 10, inline: false } ],
  322. [ "примечания:", { index: 11, inline: false } ]
  323. ]);
  324. return Array.from(bookEl.querySelectorAll(".description strong")).reduce((list, strongEl) => {
  325. const title = strongEl.textContent.trim();
  326. const md = descrMap.get(title.toLowerCase());
  327. if (md && strongEl.nextElementSibling) {
  328. let element = null;
  329. if (md.selector) {
  330. element = strongEl.ownerDocument.createElement("span");
  331. element.textContent = Array.from(
  332. strongEl.nextElementSibling.querySelectorAll(md.selector)
  333. ).map(el => el.textContent).join(", ");
  334. } else {
  335. element = strongEl.nextElementSibling;
  336. }
  337. list.push({ index: md.index, title: title, element: element, inline: md.inline });
  338. }
  339. return list;
  340. }, result);
  341. })();
  342. if (annData.length) {
  343. li = log.message("Формирование аннотации...");
  344. doc.bindParser("ann", new AnnotationParser());
  345. annData.sort((a, b) => (a.index - b.index));
  346. annData.forEach(it => {
  347. if (doc.annotation) {
  348. if (!it.inline) doc.annotation.children.push(new FB2EmptyLine());
  349. } else {
  350. doc.annotation = new FB2Annotation();
  351. }
  352. let par = new FB2Paragraph();
  353. par.children.push(new FB2Element("strong", it.title));
  354. doc.annotation.children.push(par);
  355. if (it.inline) {
  356. par.children.push(new FB2Text(" " +(typeof(it.element) === "string" ? it.element : it.element.textContent).trim()));
  357. } else {
  358. doc.parse("ann", log, it.element);
  359. }
  360. });
  361. doc.bindParser("ann", null);
  362. li.ok();
  363. } else {
  364. log.warning("Аннотация не найдена");
  365. }
  366. log.message("---");
  367. // Получение и формирование глав
  368. doc.bindParser("chp", new ChapterParser());
  369. const chapters = doc.chapters;
  370. doc.chapters = [];
  371. let chIdx = 0;
  372. let chCnt = chapters.length;
  373. while (chIdx < chCnt) {
  374. const chItem = chapters[chIdx];
  375. li = log.message(`Получение главы ${chIdx + 1}/${chCnt}...`);
  376. try {
  377. let chData = chItem.data;
  378. if (!chData) {
  379. const url = new URL(`/readfic/${encodeURIComponent(doc.id)}/${encodeURIComponent(chItem.id)}`, document.location);
  380. await sleep(100);
  381. chData = getChapterData(await Loader.addJob(url));
  382. }
  383. // Преобразование в FB2
  384. doc.parse("chp", log, genChapterElement(chData), chItem.title, chData.notes);
  385. li.ok();
  386. li = null;
  387. ++chIdx;
  388. } catch (err) {
  389. if (err instanceof HttpError && err.code === 429) {
  390. li.fail();
  391. log.warning("Ответ сервера: слишком много запросов");
  392. log.message("Ждем 30 секунд");
  393. await sleep(30000);
  394. } else {
  395. throw err;
  396. }
  397. }
  398. }
  399. doc.bindParser("chp", null);
  400. //--
  401. doc.history.push("v1.0 - создание fb2 - (Ox90)");
  402. if (doc.unknowns) {
  403. log.message("---");
  404. log.warning(`Найдены неизвестные элементы: ${doc.unknowns}`);
  405. log.message("Преобразованы в текст без форматирования");
  406. }
  407. log.message("---");
  408. log.message("Готово!");
  409. } catch (err) {
  410. li && li.fail();
  411. doc.bindParser();
  412. throw err;
  413. }
  414. }
  415.  
  416. function genChapterElement(chData) {
  417. const chapterEl = document.createElement("div");
  418. const parts = [];
  419. [ "topComment", "content", "bottomComment" ].reduce((list, it) => {
  420. if (chData[it]) list.push(chData[it]);
  421. return list;
  422. }, []).forEach((partEl, idx) => {
  423. if (idx) chapterEl.append("\n\n----------\n\n");
  424. if (partEl.id !== "content") {
  425. const titleEl = document.createElement("strong");
  426. titleEl.textContent = "Примечания:";
  427. chapterEl.append(titleEl, "\n\n");
  428. }
  429. while (partEl.firstChild) chapterEl.append(partEl.firstChild);
  430. });
  431. return chapterEl;
  432. }
  433.  
  434. function getChapterData(html) {
  435. const result = {};
  436. const doc = typeof(html) === "string" ? (new DOMParser()).parseFromString(html, "text/html") : html;
  437. // Извлечение элемента с содержанием
  438. const chapter = doc.querySelector("article #content[itemprop=articleBody]");
  439. if (!chapter) throw new Error("Ошибка анализа HTML данных главы");
  440. result.content = chapter;
  441. // Поиск данных сносок
  442. const rr = /\s+textFootnotes\s+=\s+({.*\})/.exec(html);
  443. if (rr) {
  444. try {
  445. result.notes = JSON.parse(rr[1]);
  446. } catch (err) {
  447. throw new Error("Ошибка анализа данных заметок");
  448. }
  449. }
  450. // Примечания автора к главе
  451. [ [ "topComment", ".part-comment-top>strong + div" ], [ "bottomComment", ".part-comment-bottom>strong + div" ] ].forEach(it => {
  452. const commentEl = chapter.parentElement.querySelector(it[1]);
  453. if (commentEl) result[it[0]] = commentEl;
  454. });
  455. //--
  456. return result;
  457. }
  458.  
  459. function genBookFileName(doc) {
  460. function xtrim(s) {
  461. const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s);
  462. return r && r[1] || s;
  463. }
  464.  
  465. const fn_template = Settings.get("filename", true).trim();
  466. const ndata = new Map();
  467. // Автор [\a]
  468. const author = doc.bookAuthors[0];
  469. if (author) {
  470. const author_names = [ author.firstName, author.middleName, author.lastName ].reduce((res, nm) => {
  471. if (nm) res.push(nm);
  472. return res;
  473. }, []);
  474. if (author_names.length) {
  475. ndata.set("a", author_names.join(" "));
  476. } else if (author.nickName) {
  477. ndata.set("a", author.nickName);
  478. }
  479. }
  480. // Название книги [\t]
  481. ndata.set("t", xtrim(doc.bookTitle));
  482. // Количество глав [\c]
  483. ndata.set("c", `${doc.chapters.length}`);
  484. // Id книги [\i]
  485. ndata.set("i", doc.id);
  486. // Окончательное формирование имени файла плюс дополнительные чистки и проверки.
  487. function replacer(str) {
  488. let cnt = 0;
  489. const new_str = str.replace(/\\([atci])/g, (match, ti) => {
  490. const res = ndata.get(ti);
  491. if (res === undefined) return "";
  492. ++cnt;
  493. return res;
  494. });
  495. return { str: new_str, count: cnt };
  496. }
  497. function processParts(str, depth) {
  498. const parts = [];
  499. const pos = str.indexOf('<');
  500. if (pos !== 0) {
  501. parts.push(replacer(pos == -1 ? str : str.slice(0, pos)));
  502. }
  503. if (pos !== -1) {
  504. let i = pos + 1;
  505. let n = 1;
  506. for ( ; i < str.length; ++i) {
  507. const c = str[i];
  508. if (c == '<') {
  509. ++n;
  510. } else if (c == '>') {
  511. --n;
  512. if (!n) {
  513. parts.push(processParts(str.slice(pos + 1, i), depth + 1));
  514. break;
  515. }
  516. }
  517. }
  518. if (++i < str.length) parts.push(processParts(str.slice(i), depth));
  519. }
  520. const sa = [];
  521. let cnt = 0
  522. for (const it of parts) {
  523. sa.push(it.str);
  524. cnt += it.count;
  525. }
  526. return {
  527. str: (!depth || cnt) ? sa.join("") : "",
  528. count: cnt
  529. };
  530. }
  531. const fname = processParts(fn_template, 0).str.replace(/[\0\/\\\"\*\?\<\>\|:]+/g, "");
  532. return `${fname.substr(0, 250)}.fb2`;
  533. }
  534.  
  535. async function sleep(msecs) {
  536. return new Promise(resolve => setTimeout(resolve, msecs));
  537. }
  538.  
  539. function decodeHTMLChars(s) {
  540. const e = document.createElement("div");
  541. e.innerHTML = s;
  542. return e.textContent;
  543. }
  544.  
  545. //---------- Классы ----------
  546.  
  547. class DocumentEx extends FB2Document {
  548. constructor() {
  549. super();
  550. this.unknowns = 0;
  551. }
  552.  
  553. parse(parserId, log, ...args) {
  554. const pdata = super.parse(parserId, ...args);
  555. pdata.unknownNodes.forEach(el => {
  556. log.warning(`Найден неизвестный элемент: ${el.nodeName}`);
  557. ++this.unknowns;
  558. });
  559. return pdata.result;
  560. }
  561. }
  562.  
  563. class TextParser extends FB2Parser {
  564. run(doc, htmlNode) {
  565. this._unknownNodes = [];
  566. const res = super.run(doc, htmlNode);
  567. const pdata = { result: res, unknownNodes: this._unknownNodes };
  568. delete this._unknowNodes;
  569. return pdata;
  570. }
  571.  
  572. /**
  573. * Текст глав на сайте оформляется довольно странно. Фактически это plain text
  574. * с нерегулярными вкраплениями разметки. Тег <p> используется, но в основном как
  575. * контейнер для выравнивания строк текста и подзаголовков.
  576. * ---
  577. * Перед парсингом блоки текста упаковываются в параграфы, разделитель - символ новой строки
  578. * Все пустые строки заменяются на empyty-line. Также учитывается вложенность других элементов.
  579. */
  580. parse(htmlNode) {
  581. const doc = htmlNode.ownerDocument;
  582. const newNode = htmlNode.cloneNode(false);
  583. let nodeChain = [ doc.createElement("p") ];
  584. newNode.append(nodeChain[0]);
  585.  
  586. function insertText(text, newBlock) {
  587. if (newBlock) {
  588. if (nodeChain[0].textContent.trim() === "") {
  589. newNode.lastChild.remove();
  590. newNode.append(doc.createElement("br"));
  591. }
  592. let parent = newNode;
  593. nodeChain = nodeChain.map(n => {
  594. const nn = n.cloneNode(false);
  595. parent = parent.appendChild(nn);
  596. return nn;
  597. });
  598. parent.append(text);
  599. } else {
  600. nodeChain[nodeChain.length - 1].append(text);
  601. }
  602. }
  603.  
  604. function rewriteChildNodes(node) {
  605. let cn = node.firstChild;
  606. while (cn) {
  607. if (cn.nodeName === "#text") {
  608. const lines = cn.textContent.split("\n");
  609. for (let i = 0; i < lines.length; ++i) insertText(lines[i], i > 0);
  610. } else {
  611. const nn = cn.cloneNode(false);
  612. nodeChain[nodeChain.length - 1].append(nn);
  613. nodeChain.push(nn);
  614. rewriteChildNodes(cn);
  615. nodeChain.pop();
  616. }
  617. cn = cn.nextSibling;
  618. }
  619. }
  620.  
  621. rewriteChildNodes(htmlNode);
  622. return super.parse(newNode);
  623. }
  624.  
  625. processElement(fb2el, depth) {
  626. if (fb2el instanceof FB2UnknownNode) this._unknownNodes.push(fb2el.value);
  627. return super.processElement(fb2el, depth);
  628. }
  629. }
  630.  
  631. class AnnotationParser extends TextParser {
  632. run(doc, htmlNode) {
  633. this._annotation = new FB2Annotation();
  634. const res = super.run(doc, htmlNode);
  635. this._annotation.normalize();
  636. if (doc.annotation) {
  637. this._annotation.children.forEach(el => doc.annotation.children.push(el));
  638. } else {
  639. doc.annotation = this._annotation;
  640. }
  641. delete this._annotation;
  642. return res;
  643. }
  644.  
  645. processElement(fb2el, depth) {
  646. if (fb2el && !depth) this._annotation.children.push(fb2el);
  647. return super.processElement(fb2el, depth);
  648. }
  649. }
  650.  
  651. class ChapterParser extends TextParser {
  652. run(doc, htmlNode, title, notes) {
  653. this._chapter = new FB2Chapter(title);
  654. this._noteValues = notes;
  655. const res = super.run(doc, htmlNode);
  656. this._chapter.normalize();
  657. doc.chapters.push(this._chapter);
  658. delete this._chapter;
  659. return res;
  660. }
  661.  
  662. startNode(node, depth, fb2to) {
  663. if (node.nodeName === "SPAN") {
  664. if (node.classList.contains("footnote") && node.textContent === "") {
  665. // Это заметка
  666. if (this._noteValues) {
  667. const value = this._noteValues[node.id];
  668. if (value) {
  669. const nt = new FB2Note(decodeHTMLChars(value), "");
  670. this.processElement(nt, depth);
  671. fb2to && fb2to.children.push(nt);
  672. }
  673. }
  674. return null;
  675. }
  676. } else if (node.nodeName === "P") {
  677. if (node.style.textAlign === "center" && [ "•••", "* * *", "***" ].includes(node.textContent.trim())) {
  678. // Это подзаголовок
  679. const sub = new FB2Subtitle("* * *")
  680. this.processElement(sub, depth);
  681. fb2to && fb2to.children.push(sub);
  682. return null;
  683. }
  684. }
  685. return super.startNode(node, depth, fb2to);
  686. }
  687.  
  688. processElement(fb2el, depth) {
  689. if (fb2el && !depth) this._chapter.children.push(fb2el);
  690. return super.processElement(fb2el, depth);
  691. }
  692. }
  693.  
  694. class Dialog {
  695. constructor(params) {
  696. this._onsubmit = params.onsubmit;
  697. this._onhide = params.onhide;
  698. this._dlgEl = null;
  699. this.log = null;
  700. this.button = null;
  701. }
  702.  
  703. show() {
  704. this._mainEl = document.createElement("div");
  705. this._mainEl.tabIndex = -1;
  706. this._mainEl.classList.add("modal");
  707. this._mainEl.setAttribute("role", "dialog");
  708. const backEl = document.createElement("div");
  709. backEl.classList.add("modal-backdrop", "in");
  710. backEl.style.zIndex = 0;
  711. backEl.addEventListener("click", () => this.hide());
  712. const dlgEl = document.createElement("div");
  713. dlgEl.classList.add("modal-dialog");
  714. dlgEl.setAttribute("role", "document");
  715. const ctnEl = document.createElement("div");
  716. ctnEl.classList.add("modal-content");
  717. dlgEl.append(ctnEl);
  718. const bdyEl = document.createElement("div");
  719. bdyEl.classList.add("modal-body");
  720. ctnEl.append(bdyEl);
  721. const tlEl = document.createElement("div");
  722. const clBtn = document.createElement("button");
  723. clBtn.classList.add("close");
  724. clBtn.innerHTML = "<span aria-hidden=\"true\">×</span>";
  725. clBtn.addEventListener("click", () => this.hide());
  726. const hdrEl = document.createElement("h3");
  727. hdrEl.textContent = "Формирование файла FB2";
  728. tlEl.append(clBtn, hdrEl);
  729. const container = document.createElement("form");
  730. container.classList.add("modal-container");
  731. bdyEl.append(tlEl, container);
  732. this.log = document.createElement("div");
  733. const stBtn = document.createElement("p");
  734. stBtn.style.cursor = "pointer";
  735. stBtn.style.textDecoration = "underline";
  736. stBtn.style.margin = "-.5em 0 0";
  737. stBtn.style.fontSize = "85%";
  738. stBtn.style.opacity = ".7";
  739. stBtn.textContent = "Настройки";
  740. const stForm = document.createElement("div");
  741. stForm.style.display = "none";
  742. stForm.style.padding = ".5em";
  743. stForm.style.margin = ".75em 0";
  744. stForm.style.border = "1px solid lightgray";
  745. stForm.style.borderRadius = "5px";
  746. stForm.innerHTML = '<div><label>Шаблон имени файла (без расширения)</label>' +
  747. '<input type="text" style="width:100%; background-color:transparent; border:1px solid gray; border-radius:3px; font-size:90%">' +
  748. '<ul style="color:gray; font-size:85%; margin:0; padding-left:1em;">' +
  749. '<li>\\a - Автор книги;</li><li>\\t - Название книги;</li><li>\\i - Идентификатор книги;</li><li>\\c - Количество глав;</li>' +
  750. '<li>&lt;…&gt; - Если внутри такого блока будут отсутвовать данные для шаблона, то весь блок будет удален;</li>' +
  751. '</ul><div style="color:gray; font-size:85%;">' +
  752. '<span style="color:red; font-weight:bold;">!</span> Оставьте это поле пустым, если хотите вернуть шаблон по умолчанию.</div>';
  753. stBtn.addEventListener("click", event => {
  754. if (stForm.style.display) {
  755. stForm.querySelector("input").value = Settings.get("filename");
  756. stForm.style.removeProperty("display");
  757. } else {
  758. stForm.style.display = "none";
  759. Settings.set("filename", stForm.querySelector("input").value);
  760. Settings.save();
  761. }
  762. });
  763. const buttons = document.createElement("div");
  764. buttons.style.display = "flex";
  765. buttons.style.justifyContent = "center";
  766. this.button = document.createElement("button");
  767. this.button.type = "submit";
  768. this.button.disabled = true;
  769. this.button.classList.add("btn", "btn-primary");
  770. this.button.textContent = "Продолжить";
  771. buttons.append(this.button);
  772. container.append(this.log, stBtn, stForm, buttons);
  773. this._mainEl.append(backEl, dlgEl);
  774. container.addEventListener("submit", event => {
  775. event.preventDefault();
  776. if (!stForm.style.display) stBtn.dispatchEvent(new Event("click"));
  777. stBtn.remove();
  778. this._onsubmit && this._onsubmit();
  779. });
  780.  
  781. this._mainEl.addEventListener('keydown', event => {
  782. if (event.code == 'Escape' && !event.shiftKey && !event.ctrlKey && !event.altKey) {
  783. event.preventDefault();
  784. this.hide();
  785. }
  786. });
  787.  
  788. const dlgList = document.querySelector("div.js-modal-destination");
  789. if (!dlgList) throw new Error("Не найден контейнер для модальных окон");
  790. dlgList.append(this._mainEl);
  791. document.body.classList.add("modal-open");
  792. this._mainEl.style.display = "block";
  793. this._mainEl.focus();
  794. }
  795.  
  796. hide() {
  797. this.log = null;
  798. this.button = null;
  799. this._mainEl && this._mainEl.remove();
  800. document.body.classList.remove("modal-open");
  801. this._onhide && this._onhide();
  802. }
  803. }
  804.  
  805. class LogElement {
  806. constructor(element) {
  807. element.style.padding = ".5em";
  808. element.style.fontSize = "90%";
  809. element.style.border = "1px solid lightgray";
  810. element.style.marginBottom = "1em";
  811. element.style.borderRadius = "5px";
  812. element.style.textAlign = "left";
  813. element.style.overflowY = "auto";
  814. element.style.maxHeight = "50vh";
  815. this._element = element;
  816. }
  817.  
  818. message(message, color) {
  819. const item = document.createElement("div");
  820. if (message instanceof HTMLElement) {
  821. item.appendChild(message);
  822. } else {
  823. item.textContent = message;
  824. }
  825. if (color) item.style.color = color;
  826. this._element.appendChild(item);
  827. this._element.scrollTop = this._element.scrollHeight;
  828. return new LogItemElement(item);
  829. }
  830.  
  831. warning(s) {
  832. this.message(s, "#a00");
  833. }
  834. }
  835.  
  836. class LogItemElement {
  837. constructor(element) {
  838. this._element = element;
  839. this._span = null;
  840. }
  841.  
  842. ok() {
  843. this._setSpan("ok", "green");
  844. }
  845.  
  846. fail() {
  847. this._setSpan("ошибка!", "red");
  848. }
  849.  
  850. skipped() {
  851. this._setSpan("пропущено", "blue");
  852. }
  853.  
  854. text(s) {
  855. this._setSpan(s, "");
  856. }
  857.  
  858. _setSpan(text, color) {
  859. if (!this._span) {
  860. this._span = document.createElement("span");
  861. this._element.appendChild(this._span);
  862. }
  863. this._span.style.color = color;
  864. this._span.textContent = " " + text;
  865. }
  866. }
  867.  
  868. class Settings {
  869. static get(name, reset) {
  870. if (reset) Settings._values = null;
  871. this._ensureValues();
  872. let val = Settings._values[name];
  873. switch (name) {
  874. case "filename":
  875. if (typeof(val) !== "string" || val.trim() === "") val = "<\\a. >\\t [FBN-\\i]";
  876. break;
  877. }
  878. return val;
  879. }
  880.  
  881. static set(name, value) {
  882. this._ensureValues();
  883. this._values[name] = value;
  884. }
  885.  
  886. static save() {
  887. localStorage.setItem("fbe.settings", JSON.stringify(this._values || {}));
  888. }
  889.  
  890. static _ensureValues() {
  891. if (this._values) return;
  892. try {
  893. this._values = JSON.parse(localStorage.getItem("fbe.settings"));
  894. } catch (err) {
  895. this._values = null;
  896. }
  897. if (!this._values || typeof(this._values) !== "object") Settings._values = {};
  898. }
  899. }
  900.  
  901. class HttpError extends Error {
  902. constructor(message, code) {
  903. super(message);
  904. this.name = "HttpError";
  905. this.code = code;
  906. }
  907. }
  908.  
  909. class Loader extends FB2Loader {
  910. static async addJob(url, params) {
  911. if (url.origin === document.location.origin) {
  912. return super.addJob(url, params).catch(err => {
  913. if (err.message.endsWith("(429)")) err = new HttpError(err.message, 429);
  914. throw err;
  915. });
  916. }
  917.  
  918. params ||= {};
  919. params.url = url;
  920. params.method ||= "GET";
  921. params.responseType = params.responseType === "binary" ? "blob" : "text";
  922. if (!this.ctl_list) this.ctl_list = new Set();
  923.  
  924. return new Promise((resolve, reject) => {
  925. let req = null;
  926. params.onload = r => {
  927. if (r.status === 200) {
  928. resolve(r.response);
  929. } else {
  930. reject(new HttpError("Сервер вернул ошибку (" + r.status + ")", r.status));
  931. }
  932. };
  933. params.onerror = err => reject(err);
  934. params.ontimeout = err => reject(err);
  935. params.onloadend = () => {
  936. if (req) this.ctl_list.delete(req);
  937. };
  938. if (params.onprogress) {
  939. const progress = params.onprogress;
  940. params.onprogress = pe => {
  941. if (pe.lengthComputable) {
  942. progress(pe.loaded, pe.total);
  943. }
  944. };
  945. }
  946. try {
  947. req = GM.xmlHttpRequest(params);
  948. if (req) this.ctl_list.add(req);
  949. } catch (err) {
  950. reject(err);
  951. }
  952. });
  953. }
  954.  
  955. static abortAll() {
  956. super.abortAll();
  957. if (this.ctl_list) {
  958. this.ctl_list.forEach(ctl => ctl.abort());
  959. this.ctl_list.clear();
  960. }
  961. }
  962. }
  963.  
  964. FB2Image.prototype._load = function(...args) {
  965. if (!(this.url instanceof URL)) this.url = new URL(this.url);
  966. return Loader.addJob(...args);
  967. };
  968.  
  969. //-------------------------
  970.  
  971. // Запускает скрипт после загрузки страницы сайта
  972. if (document.readyState === "loading") window.addEventListener("DOMContentLoaded", init);
  973. else init();
  974.  
  975. })();