ReadliBookExtractor

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

目前为 2023-08-20 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name ReadliBookExtractor
  3. // @namespace 90h.yy.zz
  4. // @version 0.4.2
  5. // @author Ox90
  6. // @match https://readli.net/*
  7. // @description The script adds a button to the site for downloading books to an FB2 file
  8. // @description:ru Скрипт добавляет кнопку для скачивания книги в формате FB2
  9. // @require https://gf.qytechs.cn/scripts/468831-html2fb2lib/code/HTML2FB2Lib.js?version=1237858
  10. // @grant unsafeWindow
  11. // @run-at document-start
  12. // @license MIT
  13. // ==/UserScript==
  14.  
  15. (function start() {
  16.  
  17. const PROGRAM_NAME = "RLBookExtractor";
  18.  
  19. let env = {};
  20. let stage = 0;
  21.  
  22. Date.prototype.toAtomDate = function() {
  23. let m = this.getMonth() + 1;
  24. let d = this.getDate();
  25. return "" + this.getFullYear() + '-' + (m < 10 ? "0" : "") + m + "-" + (d < 10 ? "0" : "") + d;
  26. };
  27.  
  28. function init() {
  29. env.popupShow = window.popupShow || (unsafeWindow && unsafeWindow.popupShow);
  30. pageHandler();
  31. }
  32.  
  33. function pageHandler() {
  34. if (!document.querySelector("a.book-actions__button[href^=\"/chitat-online/\"]")) return;
  35. const book_page = document.querySelector("main.main>section.wrapper.page");
  36. if (book_page) {
  37. const dlg_data = makeDownloadDialog();
  38. insertDownloadButton(book_page, dlg_data);
  39. }
  40. }
  41.  
  42. function insertDownloadButton(book_page, dlg_data) {
  43. const btn_list = book_page.querySelector("section.download>ul.download__list");
  44. if (btn_list) {
  45. // Создать кнопку
  46. const btn = document.createElement("li");
  47. btn.classList.add("download__item");
  48. const link = document.createElement("a");
  49. link.classList.add("download__link");
  50. link.href = "#";
  51. link.textContent = "fb2-ex";
  52. btn.appendChild(link);
  53. // Попытаться вставить новую кнопку сразу после оригинальной fb2
  54. let item = btn_list.firstElementChild;
  55. while (item) {
  56. if (item.textContent === "fb2") break;
  57. item = item.nextElementSibling;
  58. }
  59. if (item) {
  60. item.after(btn);
  61. } else {
  62. btn_list.appendChild(btn);
  63. }
  64. // Ссылка на данные книги
  65. let book_data = null;
  66. // Установить обработчик для новой кнопки
  67. btn.addEventListener("click", event => {
  68. event.preventDefault();
  69. try {
  70. dlg_data.log.clean();
  71. dlg_data.sbm.textContent = setStage(0);
  72. env.popupShow("#rbe-download-dlg");
  73. book_data = getBookInfo(book_page, dlg_data.log);
  74. dlg_data.sbm.disabled = false;
  75. } catch (e) {
  76. dlg_data.log.message(e.message, "red");
  77. }
  78. });
  79. // Установить обработчик для основной кнопки диалога
  80. dlg_data.sbm.addEventListener("click", () => makeAction(book_data, dlg_data));
  81. // Установить обработчик для скрытия диалога
  82. dlg_data.dlg.addEventListener("dlg-hide", () => {
  83. if (dlg_data.link) {
  84. URL.revokeObjectURL(dlg_data.link.href);
  85. dlg_data.link = null;
  86. }
  87. book_data = null;
  88. });
  89. }
  90. }
  91.  
  92. function makeDownloadDialog() {
  93. const popups = document.querySelector("div.popups");
  94. if (!popups) throw new Error("Не найден блок popups");
  95. const dlg_c = document.createElement("div");
  96. dlg_c.id = "rbe-download-dlg";
  97. popups.appendChild(dlg_c);
  98. dlg_c.innerHTML =
  99. '<div class="popup" data-src="#rbe-download-dlg">' +
  100. '<button class="popup__close button-close-2"></button>' +
  101. '<div class="popup-form">' +
  102. '<h2>Скачать книгу</h2>' +
  103. '<div class="rbe-log"></div>' +
  104. '<button class="button rbe-submit" disabled="true">Продолжить</button>' +
  105. '</div>' +
  106. '</div>';
  107. const dlg = dlg_c.querySelector("div.popup-form");
  108. const dlg_data = {
  109. dlg: dlg,
  110. log: new LogElement(dlg.querySelector(".rbe-log")),
  111. sbm: dlg.querySelector("button.rbe-submit")
  112. };
  113. (new MutationObserver(() => {
  114. if (dlg_c.children.length) {
  115. dlg.dispatchEvent(new CustomEvent("dlg-hide"));
  116. }
  117. })).observe(dlg_c, { childList: true });
  118. return dlg_data;
  119. }
  120.  
  121. async function makeAction(book_data, dlg_data) {
  122. try {
  123. switch (stage) {
  124. case 0:
  125. dlg_data.sbm.textContent = setStage(1);
  126. await getBookContent(book_data, dlg_data.log);
  127. dlg_data.sbm.textContent = setStage(2);
  128. break;
  129. case 1:
  130. FB2Loader.abortAll();
  131. dlg_data.sbm.textContent = setStage(3);
  132. break;
  133. case 2:
  134. if (!dlg_data.link) {
  135. dlg_data.link = document.createElement("a");
  136. dlg_data.link.download = genBookFileName(book_data.fb2doc);
  137. dlg_data.link.href = URL.createObjectURL(new Blob([ book_data.fb2doc ], { type: "application/octet-stream" }));
  138. dlg_data.fb2doc = null;
  139. }
  140. dlg_data.link.click();
  141. break;
  142. case 3:
  143. dlg_data.dlg.closest("div.popup[data-src=\"#rbe-download-dlg\"]").querySelector("button.popup__close").click();
  144. break;
  145. }
  146. } catch (err) {
  147. console.error(err);
  148. dlg_data.log.message(err.message, "red");
  149. dlg_data.sbm.textContent = setStage(3);
  150. }
  151. }
  152.  
  153. function setStage(new_stage) {
  154. stage = new_stage;
  155. return [ "Продолжить", "Прервать", "Сохранить в файл", "Закрыть" ][new_stage] || "Error";
  156. }
  157.  
  158. function getBookInfo(book_page, log) {
  159. const data = {};
  160. // Id книги
  161. const id = (() => {
  162. const el = book_page.querySelector("a.book-actions__button[href^=\"/chitat-online/\"]");
  163. if (el) {
  164. const id = (new URL(el)).searchParams.get("b");
  165. if (id) return id;
  166. }
  167. throw new Error("Не найден Id книги!");
  168. })();
  169. data.id = id;
  170. // Название книги
  171. const title = (() => {
  172. const el = book_page.querySelector("div.main-info>h1.main-info__title");
  173. return el && el.textContent.trim() || "";
  174. })();
  175. if (!title) throw new Error("Не найдено название книги");
  176. let li = log.message("Название:").text(title);
  177. data.bookTitle = title;
  178. // Авторы
  179. const authors = Array.from(book_page.querySelectorAll("div.main-info>a[href^=\"/avtor/\"]")).reduce((list, el) => {
  180. const content = el.textContent.trim();
  181. if (content) {
  182. const author = new FB2Author(content);
  183. author.homePage = el.href;
  184. list.push(author);
  185. }
  186. return list;
  187. }, []);
  188. log.message("Авторы:").text(authors.length || "нет");
  189. if (!authors.length) log.warning("Не найдена информация об авторах");
  190. data.authors = authors;
  191. // Жанры
  192. const genres = Array.from(book_page.querySelectorAll("div.book-info a[href^=\"/cat/\"]")).reduce((list, el) => {
  193. const content = el.textContent.trim();
  194. if (content) list.push(content);
  195. return list;
  196. }, []);
  197. data.genres = new FB2GenreList(genres);
  198. log.message("Жанры:").text(data.genres.length || "нет");
  199. // Ключевые слова
  200. const tags = Array.from(book_page.querySelectorAll("div.tags>ul.tags__list>li.tags__item")).reduce((list, el) => {
  201. const content = el.textContent.trim();
  202. const r = /^#(.+)$/.exec(content);
  203. if (r) list.push(r[1]);
  204. return list;
  205. }, []);
  206. log.message("Теги:").text(tags && tags.length || "нет");
  207. data.keywords = tags;
  208. // Серия
  209. const sequence = (() => {
  210. let el = book_page.querySelector("div.book-info a[href^=\"/serie/\"]");
  211. if (el) {
  212. let r = /^(.+?)(?:\s+#(\d+))?$/.exec(el.textContent.trim());
  213. if (r && r[1]) {
  214. const res = { name: r[1]};
  215. log.message("Серия:").text(r[1]);
  216. if (r[2]) {
  217. res.number = r[2];
  218. log.message("Номер в серии:").text(r[2]);
  219. }
  220. return res;
  221. }
  222. }
  223. })();
  224. if (sequence) data.sequence = sequence;
  225. // Дата
  226. const bookDate = (() => {
  227. const el = book_page.querySelector("ul.book-chars>li.book-chars__item");
  228. if (el) {
  229. const r = /^Размещено.+(\d{2})\.(\d{2})\.(\d{4})$/.exec(el.textContent.trim());
  230. if (r) {
  231. log.message("Последнее обновление:").text(`${r[1]}.${r[2]}.${r[3]}`);
  232. return new Date(`${r[3]}-${r[2]}-${r[1]}`);
  233. }
  234. }
  235. })();
  236. if (bookDate) data.bookDate = bookDate;
  237. // Ссылка на источник
  238. data.sourceURL = document.location.origin + document.location.pathname;
  239. // Аннотация
  240. const annotation = (() => {
  241. const el = book_page.querySelector("article.seo__content");
  242. if (el && el.firstElementChild && el.firstElementChild.tagName === "H2" && el.firstElementChild.textContent === "Аннотация") {
  243. const c_el = el.cloneNode(true);
  244. c_el.firstElementChild.remove();
  245. return c_el;
  246. }
  247. })();
  248. if (annotation) {
  249. data.annotation = annotation;
  250. } else {
  251. log.warning("Аннотация не найдена!");
  252. }
  253. // Количество страниц
  254. const pages = (() => {
  255. const li = log.message("Количество страниц:");
  256. const el = book_page.querySelector(".book-about__pages .button-pages__right");
  257. if (el) {
  258. const pages_str = el.textContent;
  259. let r = /^(\d+)/.exec(pages_str);
  260. if (r) {
  261. li.text(r[1]);
  262. return parseInt(r[1]);
  263. }
  264. }
  265. li.fail();
  266. return 0;
  267. })();
  268. if (pages) data.pageCount = pages;
  269. // Обложка книги
  270. const cover_url = (() => {
  271. const el = book_page.querySelector("div.book-image img");
  272. if (el) return el.src;
  273. return null;
  274. })();
  275. if (cover_url) data.coverpageURL = cover_url;
  276. //--
  277. return data;
  278. }
  279.  
  280. async function getBookContent(book_data, log) {
  281. let li = null;
  282. try {
  283. const fb2doc = new FB2Document();
  284. fb2doc.id = book_data.id;
  285. fb2doc.bookTitle = book_data.bookTitle;
  286. fb2doc.bookAuthors = book_data.authors;
  287. fb2doc.genres = book_data.genres;
  288. if (book_data.sequence) fb2doc.sequence = book_data.sequence;
  289. fb2doc.lang = "ru";
  290. // Обложка книги
  291. if (book_data.coverpageURL) {
  292. li = log.message("Загрузка обложки...");
  293. fb2doc.coverpage = new FB2Image(book_data.coverpageURL);
  294. await fb2doc.coverpage.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"));
  295. fb2doc.coverpage.id = "cover" + fb2doc.coverpage.suffix();
  296. fb2doc.binaries.push(fb2doc.coverpage);
  297. li.ok();
  298. log.message("Размер обложки:").text(fb2doc.coverpage.size + " байт");
  299. log.message("Тип файла обложки:").text(fb2doc.coverpage.type);
  300. } else {
  301. log.warning("Обложка книги не найдена!");
  302. }
  303. // Анализ аннотации
  304. if (book_data.annotation) {
  305. const li = log.message("Анализ аннотации...");
  306. try {
  307. await (new ReadliFB2AnnotationParser(fb2doc)).parse(book_data.annotation);
  308. li.ok();
  309. if (!fb2doc.annotation) log.warning("Не найдено содержимое аннотации!");
  310. } catch (err) {
  311. li.fail();
  312. throw err;
  313. }
  314. }
  315. //--
  316. li = null;
  317. if (book_data.keywords.length) fb2doc.keywords = new FB2Element("keywords", book_data.keywords.join(", "));
  318. if (book_data.bookDate) fb2doc.bookDate = book_data.bookDate;
  319. fb2doc.sourceURL = book_data.sourceURL;
  320. //--
  321. log.message("---");
  322. // Страницы
  323. const page_url = new URL("/chitat-online/", document.location);
  324. page_url.searchParams.set("b", book_data.id);
  325. const pparser = new ReadliFB2PageParser(fb2doc, log);
  326. for (let pn = 1; pn <= book_data.pageCount; ++pn) {
  327. li = log.message(`Получение страницы ${pn}/${book_data.pageCount}...`);
  328. page_url.searchParams.set("pg", pn);
  329. const page = getPageElement(await FB2Loader.addJob(page_url));
  330. if (pn !== 1 || ! await getAuthorNotes(fb2doc, page, log)) {
  331. await pparser.parse(page);
  332. }
  333. li.ok();
  334. }
  335. log.message("---");
  336. log.message("Всего глав:").text(fb2doc.chapters.length);
  337. li = log.message("Анализ содержимого глав...");
  338. fb2doc.chapters.forEach(ch => ch.normalize());
  339. li.ok();
  340. log.message("---");
  341. log.message("Готово!");
  342. //--
  343. book_data.fb2doc = fb2doc;
  344. } catch (err) {
  345. li && li.fail();
  346. throw err;
  347. }
  348. }
  349.  
  350. async function getAuthorNotes(fb2doc, page, log) {
  351. const hdr = page.querySelector("section>subtitle");
  352. if (!hdr || hdr.textContent !== "Примечания автора:" || !hdr.nextSibling) return false;
  353. if (await (new ReadliFB2NotesParser(fb2doc)).parse(hdr.parentNode, hdr.nextSibling)) {
  354. log.message("Найдены примечания автора");
  355. return true;
  356. }
  357. return false;
  358. }
  359.  
  360. function getPageElement(html) {
  361. const doc = (new DOMParser()).parseFromString(html, "text/html");
  362. const page_el = doc.querySelector("article.reading__content>div.reading__text");
  363. if (!page_el) throw new Error("Ошибка анализа HTML страницы");
  364. // Предварительная чистка мусорных тегов
  365. const res_el = document.createElement("div");
  366. Array.from(page_el.childNodes).forEach(node => {
  367. if (node.nodeName === "EMPTY-LINE") { // Скорее всего результат кривого импорта из fb2
  368. Array.from(node.childNodes).forEach(node => res_el.appendChild(node));
  369. } else {
  370. res_el.appendChild(node);
  371. }
  372. });
  373. return res_el;
  374. }
  375.  
  376. function genBookFileName(fb2doc) {
  377. function xtrim(s) {
  378. const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s);
  379. return r && r[1] || s;
  380. }
  381.  
  382. const parts = [];
  383. if (fb2doc.bookAuthors.length) parts.push(fb2doc.bookAuthors[0]);
  384. if (fb2doc.sequence) {
  385. let name = xtrim(fb2doc.sequence.name);
  386. if (fb2doc.sequence.number) {
  387. const num = fb2doc.sequence.number;
  388. name += (num.length < 2 ? "0" : "") + num;
  389. }
  390. parts.push(name);
  391. }
  392. parts.push(xtrim(fb2doc.bookTitle));
  393. let fname = (parts.join(". ") + " [RL-" + fb2doc.id + "]").replace(/[\0\/\\\"\*\?\<\>\|:]+/g, "");
  394. if (fname.length > 250) fname = fname.substr(0, 250);
  395. return fname + ".fb2";
  396. }
  397.  
  398. //---------- Классы ----------
  399.  
  400. class ReadliFB2Parser extends FB2Parser {
  401. constructor(fb2doc, log) {
  402. super();
  403. this._fb2doc = fb2doc;
  404. this._log = log;
  405. }
  406. }
  407.  
  408. class ReadliFB2AnnotationParser extends ReadliFB2Parser {
  409. async parse(htmlNode) {
  410. this._annotation = new FB2Annotation();
  411. this._fb2doc.annotation = null;
  412. await super.parse(htmlNode);
  413. if (this._annotation.children.length) {
  414. this._annotation.normalize();
  415. this._fb2doc.annotation = this._annotation;
  416. }
  417. }
  418.  
  419. processElement(fb2el, depth) {
  420. if (depth === 0 && fb2el) {
  421. this._annotation.children.push(fb2el);
  422. }
  423. return fb2el;
  424. }
  425. }
  426.  
  427. class ReadliFB2NotesParser extends ReadliFB2Parser {
  428. async parse(htmlNode, fromNode) {
  429. this._notes = new FB2Annotation();
  430. await super.parse(htmlNode, fromNode);
  431. if (this._notes.children.length) {
  432. if (!this._fb2doc.annotation) {
  433. this._fb2doc.annotation = new FB2Annotation();
  434. } else {
  435. this._fb2doc.annotation.children.push(new FB2EmptyLine());
  436. }
  437. this._fb2doc.annotation.children.push(new FB2Paragraph("Примечания автора:"));
  438. this._notes.normalize();
  439. for (const nt of this._notes.children) {
  440. this._fb2doc.annotation.children.push(nt);
  441. }
  442. return true;
  443. }
  444. return false;
  445. }
  446.  
  447. startNode(node, depth) {
  448. if (depth === 0 && node.nodeName === "SUBTITLE") {
  449. this._stop = true;
  450. return null;
  451. }
  452. return node;
  453. }
  454.  
  455. processElement(fb2el, depth) {
  456. if (depth === 0) this._notes.children.push(fb2el);
  457. return fb2el;
  458. }
  459. }
  460.  
  461. class ReadliFB2PageParser extends ReadliFB2Parser {
  462. async parse(htmlNode) {
  463. // Вырезать ведущие пустые дочерние ноды
  464. while (htmlNode.firstChild && !htmlNode.firstChild.textContent.trim()) {
  465. htmlNode.firstChild.remove();
  466. }
  467. //--
  468. this._binaries = [];
  469. // Анализировать страницу
  470. const res = await super.parse(htmlNode);
  471.  
  472. // Загрузить бинарные данные страницы, не более 5 загрузок одновременно
  473. let it = this._binaries[Symbol.iterator]();
  474. let done = false;
  475. while (!done) {
  476. let p_list = []
  477. while (p_list.length < 5) {
  478. const r = it.next();
  479. done = r.done;
  480. if (done) break;
  481. const bin = r.value;
  482. const li = this._log.message("Загрузка изображения...");
  483. this._fb2doc.binaries.push(bin);
  484. p_list.push(
  485. bin.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"))
  486. .then(() => li.ok())
  487. .catch((err) => {
  488. li.fail();
  489. if (err.name === "AbortError") throw err;
  490. })
  491. );
  492. }
  493. if (!p_list.length) break;
  494. await Promise.all(p_list);
  495. }
  496. //--
  497. return res;
  498. }
  499.  
  500. startNode(node, depth) {
  501. if (depth === 0) {
  502. switch (node.nodeName) {
  503. case "H3":
  504. // Добавить новую главу
  505. this._chapter = new FB2Chapter(node.textContent.trim());
  506. this._fb2doc.chapters.push(this._chapter);
  507. return null;
  508. }
  509. }
  510. switch (node.nodeName) {
  511. case "DIV":
  512. case "INS":
  513. // Пропустить динамически подгружаемые рекламные блоки. Могут быть на 0 и 1 уровне вложения.
  514. // Поскольку изначально они пустые, то другие проверки можно не делать.
  515. if (node.textContent.trim() === "") return null;
  516. break;
  517. }
  518. return node;
  519. }
  520.  
  521. processElement(fb2el, depth) {
  522. if (fb2el instanceof FB2Image) this._binaries.push(fb2el);
  523. if (depth === 0 && fb2el) {
  524. if (!this._chapter) {
  525. this._chapter = new FB2Chapter();
  526. this._fb2doc.chapters.push(this._chapter);
  527. }
  528. this._chapter.children.push(fb2el);
  529. }
  530. return fb2el;
  531. }
  532. }
  533.  
  534. class LogElement {
  535. constructor(element) {
  536. element.style.padding = ".5em";
  537. element.style.fontSize = "90%";
  538. element.style.border = "1px solid lightgray";
  539. element.style.marginBottom = "1em";
  540. element.style.borderRadius = "6px";
  541. element.style.textAlign = "left";
  542. element.style.overflowY = "auto";
  543. element.style.maxHeight = "50vh";
  544. this._element = element;
  545. }
  546.  
  547. clean() {
  548. while (this._element.firstChild) this._element.lastChild.remove();
  549. }
  550.  
  551. message(message, color) {
  552. const item = document.createElement("div");
  553. if (message instanceof HTMLElement) {
  554. item.appendChild(message);
  555. } else {
  556. item.textContent = message;
  557. }
  558. if (color) item.style.color = color;
  559. this._element.appendChild(item);
  560. this._element.scrollTop = this._element.scrollHeight;
  561. return new LogItemElement(item);
  562. }
  563.  
  564. warning(s) {
  565. this.message(s, "#a00");
  566. }
  567. }
  568.  
  569. class LogItemElement {
  570. constructor(element) {
  571. this._element = element;
  572. this._span = null;
  573. }
  574.  
  575. ok() {
  576. this._setSpan("ok", "green");
  577. }
  578.  
  579. fail() {
  580. this._setSpan("ошибка!", "red");
  581. }
  582.  
  583. text(s) {
  584. this._setSpan(s, "");
  585. }
  586.  
  587. _setSpan(text, color) {
  588. if (!this._span) {
  589. this._span = document.createElement("span");
  590. this._element.appendChild(this._span);
  591. }
  592. this._span.style.color = color;
  593. this._span.textContent = " " + text;
  594. }
  595. }
  596.  
  597. //-------------------------
  598.  
  599. // Запускает скрипт после загрузки страницы сайта
  600. if (document.readyState === "loading") window.addEventListener("DOMContentLoaded", init);
  601. else init();
  602.  
  603. })();

QingJ © 2025

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