LibreGRAB

Download all the booty!

  1. // ==UserScript==
  2. // @name LibreGRAB
  3. // @namespace http://tampermonkey.net/
  4. // @version 2025-03-27
  5. // @description Download all the booty!
  6. // @author PsychedelicPalimpsest
  7. // @license MIT
  8. // @supportURL https://github.com/PsychedelicPalimpsest/LibbyRip/issues
  9. // @match *://*.listen.libbyapp.com/*
  10. // @match *://*.listen.overdrive.com/*
  11. // @match *://*.read.libbyapp.com/?*
  12. // @match *://*.read.overdrive.com/?*
  13. // @run-at document-start
  14. // @icon https://www.google.com/s2/favicons?sz=64&domain=libbyapp.com
  15. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
  16. // @grant none
  17. // ==/UserScript==
  18.  
  19. (()=>{
  20.  
  21. // Since the ffmpeg.js file is 50mb, it slows the page down too much
  22. // to be in a "require" attribute, so we load it in async
  23. function addFFmpegJs(){
  24. let scriptTag = document.createElement("script");
  25. scriptTag.setAttribute("type", "text/javascript");
  26. scriptTag.setAttribute("src", "https://github.com/PsychedelicPalimpsest/FFmpeg-js/releases/download/14/0.12.5.bundle.js");
  27. document.body.appendChild(scriptTag);
  28.  
  29. return new Promise(accept =>{
  30. let i = setInterval(()=>{
  31. if (window.createFFmpeg){
  32. clearInterval(i);
  33. accept(window.createFFmpeg);
  34. }
  35. }, 50)
  36. });
  37. }
  38.  
  39. let downloadElem;
  40. const CSS = `
  41. .pNav{
  42. background-color: red;
  43. width: 100%;
  44. display: flex;
  45. justify-content: space-between;
  46. }
  47. .pLink{
  48. color: blue;
  49. text-decoration-line: underline;
  50. padding: .25em;
  51. font-size: 1em;
  52. }
  53. .foldMenu{
  54. position: absolute;
  55. width: 100%;
  56. height: 0%;
  57. z-index: 1000;
  58.  
  59. background-color: grey;
  60. color: white;
  61.  
  62. overflow-x: hidden;
  63. overflow-y: scroll;
  64.  
  65. transition: height 0.3s
  66. }
  67. .active{
  68. height: 40%;
  69. border: double;
  70. }
  71. .pChapLabel{
  72. font-size: 2em;
  73. }`;
  74. /* =========================================
  75. BEGIN AUDIOBOOK SECTION!
  76. =========================================
  77. */
  78.  
  79.  
  80. // Libby, somewhere, gets the crypto stuff we need for mp3 urls, then removes it before adding it to the BIF.
  81. // here, we simply hook json parse to get it for us!
  82.  
  83. const old_parse = JSON.parse;
  84. let odreadCmptParams = null;
  85. JSON.parse = function(...args){
  86. let ret = old_parse(...args);
  87. if (typeof(ret) == "object" && ret["b"] != undefined && ret["b"]["-odread-cmpt-params"] != undefined){
  88. odreadCmptParams = Array.from(ret["b"]["-odread-cmpt-params"]);
  89. }
  90.  
  91. return ret;
  92. }
  93.  
  94.  
  95.  
  96. const audioBookNav = `
  97. <a class="pLink" id="chap"> <h1> View chapters </h1> </a>
  98. <a class="pLink" id="down"> <h1> Export as MP3 </h1> </a>
  99. <a class="pLink" id="exp"> <h1> Export audiobook </h1> </a>
  100. `;
  101. const chaptersMenu = `
  102. <h2>This book contains {CHAPTERS} chapters.</h2>
  103. <button class="shibui-button" style="background-color: white" id="dumpAll"> Download all </button><br>
  104. `;
  105. let chapterMenuElem;
  106.  
  107. function buildPirateUi(){
  108. // Create the nav
  109. let nav = document.createElement("div");
  110. nav.innerHTML = audioBookNav;
  111. nav.querySelector("#chap").onclick = viewChapters;
  112. nav.querySelector("#down").onclick = exportMP3;
  113. nav.querySelector("#exp").onclick = exportChapters;
  114. nav.classList.add("pNav");
  115. let pbar = document.querySelector(".nav-progress-bar");
  116. pbar.insertBefore(nav, pbar.children[1]);
  117.  
  118. // Create the chapters menu
  119. chapterMenuElem = document.createElement("div");
  120. chapterMenuElem.classList.add("foldMenu");
  121. chapterMenuElem.setAttribute("tabindex", "-1"); // Don't mess with tab key
  122. const urls = getUrls();
  123.  
  124. chapterMenuElem.innerHTML = chaptersMenu.replace("{CHAPTERS}", urls.length);
  125. document.body.appendChild(chapterMenuElem);
  126.  
  127. downloadElem = document.createElement("div");
  128. downloadElem.classList.add("foldMenu");
  129. downloadElem.setAttribute("tabindex", "-1"); // Don't mess with tab key
  130. document.body.appendChild(downloadElem);
  131.  
  132.  
  133. }
  134. function getUrls(){
  135. let ret = [];
  136. for (let spine of BIF.objects.spool.components){
  137. let data = {
  138.  
  139. url: location.origin + "/" + spine.meta.path + "?" + odreadCmptParams[spine.spinePosition],
  140. index : spine.meta["-odread-spine-position"],
  141. duration: spine.meta["audio-duration"],
  142. size: spine.meta["-odread-file-bytes"],
  143. type: spine.meta["media-type"]
  144. };
  145. ret.push(data);
  146. }
  147. return ret;
  148. }
  149. function paddy(num, padlen, padchar) {
  150. var pad_char = typeof padchar !== 'undefined' ? padchar : '0';
  151. var pad = new Array(1 + padlen).join(pad_char);
  152. return (pad + num).slice(-pad.length);
  153. }
  154. let firstChapClick = true;
  155. function viewChapters(){
  156. // Populate chapters ONLY after first viewing
  157. if (firstChapClick){
  158. firstChapClick = false;
  159. for (let url of getUrls()){
  160. let span = document.createElement("span");
  161. span.classList.add("pChapLabel")
  162. span.textContent = "#" + (1 + url.index);
  163.  
  164. let audio = document.createElement("audio");
  165. audio.setAttribute("controls", "");
  166. let source = document.createElement("source");
  167. source.setAttribute("src", url.url);
  168. source.setAttribute("type", url.type);
  169. audio.appendChild(source);
  170.  
  171. chapterMenuElem.appendChild(span);
  172. chapterMenuElem.appendChild(document.createElement("br"));
  173. chapterMenuElem.appendChild(audio);
  174. chapterMenuElem.appendChild(document.createElement("br"));
  175. }
  176. }
  177. if (chapterMenuElem.classList.contains("active"))
  178. chapterMenuElem.classList.remove("active");
  179. else
  180. chapterMenuElem.classList.add("active");
  181. chapterMenuElem.querySelector("#dumpAll").onclick = async function(){
  182.  
  183. chapterMenuElem.querySelector("#dumpAll").style.display = "none";
  184.  
  185. await Promise.all(getUrls().map(async function(url){
  186. const res = await fetch(url.url);
  187. const blob = await res.blob();
  188.  
  189. const link = document.createElement('a');
  190. link.href = URL.createObjectURL(blob);
  191. link.download = `${getAuthorString()} - ${BIF.map.title.main}.${url.index}.mp3`;
  192. link.click();
  193.  
  194. URL.revokeObjectURL(link.href);
  195. }));
  196.  
  197. chapterMenuElem.querySelector("#dumpAll").style.display = "";
  198. };
  199. }
  200. function getAuthorString(){
  201. return BIF.map.creator.filter(creator => creator.role === 'author').map(creator => creator.name).join(", ");
  202. }
  203.  
  204. function getMetadata(){
  205. let spineToIndex = BIF.map.spine.map((x)=>x["-odread-original-path"]);
  206. let metadata = {
  207. title: BIF.map.title.main,
  208. description: BIF.map.description,
  209. coverUrl: BIF.root.querySelector("image").getAttribute("href"),
  210. creator: BIF.map.creator,
  211. spine: BIF.map.spine.map((x)=>{return {
  212. duration: x["audio-duration"],
  213. type: x["media-type"],
  214. bitrate: x["audio-bitrate"],
  215. }})
  216. };
  217. if (BIF.map.nav.toc != undefined){
  218. metadata.chapters = BIF.map.nav.toc.map((rChap)=>{
  219. return {
  220. title: rChap.title,
  221. spine: spineToIndex.indexOf(rChap.path.split("#")[0]),
  222. offset: 1*(rChap.path.split("#")[1] | 0)
  223. };
  224. });
  225. }
  226. return metadata;
  227.  
  228. }
  229.  
  230. async function createMetadata(zip){
  231. let folder = zip.folder("metadata");
  232. let metadata = getMetadata();
  233. const response = await fetch(metadata.coverUrl);
  234. const blob = await response.blob();
  235. const csplit = metadata.coverUrl.split(".");
  236. folder.file("cover." + csplit[csplit.length-1], blob, { compression: "STORE" });
  237. folder.file("metadata.json", JSON.stringify(metadata, null, 2));
  238. }
  239. function generateTOCFFmpeg(metadata){
  240. if (!metadata.chapters) return null;
  241. let lastTitle = null;
  242.  
  243. const duration = Math.round(BIF.map.spine.map((x)=>x["audio-duration"]).reduce((acc, val) => acc + val)) * 1000000000;
  244.  
  245. let toc = ";FFMETADATA1\n\n";
  246.  
  247. // Get the offset for each spine element
  248. let temp = 0;
  249. const spineSpecificOffset = BIF.map.spine.map((x)=>{
  250. let old = temp;
  251. temp += x["audio-duration"]*1;
  252. return old;
  253. });
  254.  
  255. // Libby chapter split over many mp3s have duplicate chapters, so we must filter them
  256. // then convert them to be in [title, start_in_nanosecs]
  257. let chapters = metadata.chapters.filter((x)=>{
  258. let ret = x.title !== lastTitle;
  259. lastTitle = x.title;
  260. return ret;
  261. }).map((x)=>[
  262. // Escape the title
  263. x.title.replaceAll("\\", "\\\\").replaceAll("#", "\\#").replaceAll(";", "\\;").replaceAll("=", "\\=").replaceAll("\n", ""),
  264. // Calculate absolute offset in nanoseconds
  265. Math.round(spineSpecificOffset[x.spine] + x.offset) * 1000000000
  266. ]);
  267.  
  268. // Transform chapter to be [title, start_in_nanosecs, end_in_nanosecounds]
  269. let last = duration;
  270. for (let i = chapters.length - 1; -1 != i; i--){
  271. chapters[i].push(last);
  272. last = chapters[i][1];
  273. }
  274.  
  275. chapters.forEach((x)=>{
  276. toc += "[CHAPTER]\n";
  277. toc += `START=${x[1]}\n`;
  278. toc += `END=${x[2]}\n`;
  279. toc += `title=${x[0]}\n`;
  280. });
  281.  
  282. return toc;
  283. }
  284.  
  285. let downloadState = -1;
  286. let ffmpeg = null;
  287. async function createAndDownloadMp3(urls){
  288. if (!window.createFFmpeg){
  289. downloadElem.innerHTML += "Downloading FFmpeg.wasm (~50mb) <br>";
  290. await addFFmpegJs();
  291. downloadElem.innerHTML += "Completed FFmpeg.wasm download <br>";
  292. }
  293. if (!ffmpeg){
  294. downloadElem.innerHTML += "Initializing FFmpeg.wasm <br>";
  295. ffmpeg = await window.createFFmpeg();
  296. downloadElem.innerHTML += "FFmpeg.wasm initalized <br>";
  297. }
  298. let metadata = getMetadata();
  299. downloadElem.innerHTML += "Downloading mp3 files <br>";
  300. await ffmpeg.writeFile("chapters.txt", generateTOCFFmpeg(metadata));
  301.  
  302.  
  303. let fetchPromises = urls.map(async (url) => {
  304. // Download the mp3
  305. const response = await fetch(url.url);
  306. const blob = await response.blob();
  307.  
  308. // Dump it into ffmpeg (We do the request here as not to bog down the worker thread)
  309. const blob_url = URL.createObjectURL(blob);
  310. await ffmpeg.writeFileFromUrl((url.index + 1) + ".mp3", blob_url);
  311. URL.revokeObjectURL(blob_url);
  312.  
  313.  
  314. downloadElem.innerHTML += `Download of disk ${url.index + 1} complete! <br>`
  315. downloadElem.scrollTo(0, downloadElem.scrollHeight);
  316. });
  317.  
  318. let coverName = null;
  319.  
  320. if (metadata.coverUrl){
  321. console.log(metadata.coverUrl);
  322. const csplit = metadata.coverUrl.split(".");
  323. const response = await fetch(metadata.coverUrl);
  324. const blob = await response.blob();
  325.  
  326. coverName = "cover." + csplit[csplit.length-1];
  327.  
  328. const blob_url = URL.createObjectURL(blob);
  329. await ffmpeg.writeFileFromUrl(coverName, blob_url);
  330. URL.revokeObjectURL(blob_url);
  331. }
  332.  
  333.  
  334. await Promise.all(fetchPromises);
  335.  
  336. downloadElem.innerHTML += `<br><b>Downloads complete!</b> Now combining them together! (This might take a <b><i>minute</i></b>) <br> Transcode progress: <span id="mp3Progress">0</span> hours in to audiobook<br>`
  337. downloadElem.scrollTo(0, downloadElem.scrollHeight);
  338.  
  339. let files = "";
  340.  
  341. for (let i = 0; i < urls.length; i++){
  342. files += `file '${i+1}.mp3'\n`
  343. }
  344. await ffmpeg.writeFile("files.txt", files);
  345.  
  346. ffmpeg.setProgress((progress)=>{
  347. // The progress.time feature seems to be in micro secounds
  348. downloadElem.querySelector("#mp3Progress").textContent = (progress.time / 1000000 / 3600).toFixed(2);
  349. });
  350. ffmpeg.setLogger(console.log);
  351.  
  352. await ffmpeg.exec([
  353. "-y", "-f", "concat",
  354. "-i", "files.txt",
  355. "-i", "chapters.txt"]
  356. .concat(coverName ? ["-i", coverName] : [])
  357. .concat([
  358. "-map_metadata", "1",
  359. "-codec", "copy",
  360. "-map", "0:a",
  361. "-metadata", `title=${metadata.title}`,
  362. "-metadata", `album=${metadata.title}`,
  363. "-metadata", `artist=${getAuthorString()}`,
  364. "-metadata", `encoded_by=LibbyRip/LibreGRAB`,
  365. "-c:a", "copy"])
  366. .concat(coverName ? [
  367. "-map", "2:v",
  368. "-metadata:s:v", "title=Album cover",
  369. "-metadata:s:v", "comment=Cover (front)"]
  370. : [])
  371. .concat(["out.mp3"]));
  372.  
  373.  
  374.  
  375. let blob_url = await ffmpeg.readFileToUrl("out.mp3");
  376.  
  377. const link = document.createElement('a');
  378. link.href = blob_url;
  379.  
  380. link.download = getAuthorString() + ' - ' + BIF.map.title.main + '.mp3';
  381. document.body.appendChild(link);
  382. link.click();
  383. link.remove();
  384.  
  385. downloadState = -1;
  386. downloadElem.innerHTML = ""
  387. downloadElem.classList.remove("active");
  388.  
  389. // Clean up the object URL
  390. setTimeout(() => URL.revokeObjectURL(blob_url), 100);
  391.  
  392. }
  393. function exportMP3(){
  394. if (downloadState != -1)
  395. return;
  396.  
  397. downloadState = 0;
  398. downloadElem.classList.add("active");
  399. downloadElem.innerHTML = "<b>Starting MP3</b><br>";
  400. createAndDownloadMp3(getUrls()).then((p)=>{});
  401. }
  402.  
  403.  
  404.  
  405. async function createAndDownloadZip(urls, addMeta) {
  406. const zip = new JSZip();
  407.  
  408. // Fetch all files and add them to the zip
  409. const fetchPromises = urls.map(async (url) => {
  410. const response = await fetch(url.url);
  411. const blob = await response.blob();
  412. const filename = "Part " + paddy(url.index + 1, 3) + ".mp3";
  413.  
  414. let partElem = document.createElement("div");
  415. partElem.textContent = "Download of "+ filename + " complete";
  416. downloadElem.appendChild(partElem);
  417. downloadElem.scrollTo(0, downloadElem.scrollHeight);
  418.  
  419. downloadState += 1;
  420.  
  421. zip.file(filename, blob, { compression: "STORE" });
  422. });
  423. if (addMeta)
  424. fetchPromises.push(createMetadata(zip));
  425.  
  426. // Wait for all files to be fetched and added to the zip
  427. await Promise.all(fetchPromises);
  428.  
  429.  
  430. downloadElem.innerHTML += "<br><b>Downloads complete!</b> Now waiting for them to be assembled! (This might take a <b><i>minute</i></b>) <br>";
  431. downloadElem.innerHTML += "Zip progress: <b id='zipProg'>0</b>%";
  432.  
  433. downloadElem.scrollTo(0, downloadElem.scrollHeight);
  434.  
  435. // Generate the zip file
  436. const zipBlob = await zip.generateAsync({
  437. type: 'blob',
  438. compression: "STORE",
  439. streamFiles: true,
  440. }, (meta)=>{
  441. if (meta.percent)
  442. downloadElem.querySelector("#zipProg").textContent = meta.percent.toFixed(2);
  443.  
  444. });
  445.  
  446. downloadElem.innerHTML += "Generated zip file! <br>"
  447. downloadElem.scrollTo(0, downloadElem.scrollHeight);
  448.  
  449. // Create a download link for the zip file
  450. const downloadUrl = URL.createObjectURL(zipBlob);
  451.  
  452. downloadElem.innerHTML += "Generated zip file link! <br>"
  453. downloadElem.scrollTo(0, downloadElem.scrollHeight);
  454.  
  455. const link = document.createElement('a');
  456. link.href = downloadUrl;
  457.  
  458. link.download = getAuthorString() + ' - ' + BIF.map.title.main + '.zip';
  459. document.body.appendChild(link);
  460. link.click();
  461. link.remove();
  462.  
  463. downloadState = -1;
  464. downloadElem.innerHTML = ""
  465. downloadElem.classList.remove("active");
  466.  
  467. // Clean up the object URL
  468. setTimeout(() => URL.revokeObjectURL(downloadUrl), 100);
  469. }
  470.  
  471. function exportChapters(){
  472. if (downloadState != -1)
  473. return;
  474.  
  475. downloadState = 0;
  476. downloadElem.classList.add("active");
  477. downloadElem.innerHTML = "<b>Starting export</b><br>";
  478. createAndDownloadZip(getUrls(), true).then((p)=>{});
  479. }
  480.  
  481. // Main entry point for audiobooks
  482. function bifFoundAudiobook(){
  483. // New global style info
  484. let s = document.createElement("style");
  485. s.innerHTML = CSS;
  486. document.head.appendChild(s)
  487. if (odreadCmptParams == null){
  488. alert("odreadCmptParams not set, so cannot resolve book urls! Please try refreshing.")
  489. return;
  490. }
  491.  
  492. buildPirateUi();
  493. }
  494.  
  495.  
  496.  
  497. /* =========================================
  498. END AUDIOBOOK SECTION!
  499. =========================================
  500. */
  501.  
  502. /* =========================================
  503. BEGIN BOOK SECTION!
  504. =========================================
  505. */
  506. const bookNav = `
  507. <div style="text-align: center; width: 100%;">
  508. <a class="pLink" id="download"> <h1> Download EPUB </h1> </a>
  509. </div>
  510. `;
  511. window.pages = {};
  512.  
  513. // Libby used the bind method as a way to "safely" expose
  514. // the decryption module. THIS IS THEIR DOWNFALL.
  515. // As we can hook bind, allowing us to obtain the
  516. // decryption function
  517. const originalBind = Function.prototype.bind;
  518. Function.prototype.bind = function(...args) {
  519. const boundFn = originalBind.apply(this, args);
  520. boundFn.__boundArgs = args.slice(1); // Store bound arguments (excluding `this`)
  521. return boundFn;
  522. };
  523.  
  524.  
  525. async function waitForChapters(callback){
  526. let components = getBookComponents();
  527. // Force all the chapters to load in.
  528. components.forEach(page =>{
  529. if (undefined != window.pages[page.id]) return;
  530. page._loadContent({callback: ()=>{}})
  531. });
  532. // But its not instant, so we need to wait until they are all set (see: bifFound())
  533. while (components.filter((page)=>undefined==window.pages[page.id]).length){
  534. await new Promise(r => setTimeout(r, 100));
  535. callback();
  536. console.log(components.filter((page)=>undefined==window.pages[page.id]).length);
  537. }
  538. }
  539. function getBookComponents(){
  540. return BIF.objects.reader._.context.spine._.components.filter(p => "hidden" != (p.block || {}).behavior)
  541. }
  542. function truncate(path){
  543. return path.substring(path.lastIndexOf('/') + 1);
  544. }
  545. function goOneLevelUp(url) {
  546. let u = new URL(url);
  547. if (u.pathname === "/") return url; // Already at root
  548.  
  549. u.pathname = u.pathname.replace(/\/[^/]*\/?$/, "/");
  550. return u.toString();
  551. }
  552. function getFilenameFromURL(url) {
  553. const parsedUrl = new URL(url);
  554. const pathname = parsedUrl.pathname;
  555. return pathname.substring(pathname.lastIndexOf('/') + 1);
  556. }
  557. async function createContent(oebps, imgAssests){
  558.  
  559. let cssRegistry = {};
  560.  
  561. let components = getBookComponents();
  562. let totComp = components.length;
  563. downloadElem.innerHTML += `Gathering chapters <span id="chapAcc"> 0/${totComp} </span><br>`
  564. downloadElem.scrollTo(0, downloadElem.scrollHeight);
  565.  
  566. let gc = 0;
  567. await waitForChapters(()=>{
  568. gc+=1;
  569. downloadElem.querySelector("span#chapAcc").innerHTML = ` ${components.filter((page)=>undefined!=window.pages[page.id]).length}/${totComp}`;
  570. });
  571.  
  572. downloadElem.innerHTML += `Chapter gathering complete<br>`
  573. downloadElem.scrollTo(0, downloadElem.scrollHeight);
  574.  
  575. let idToIfram = {};
  576. let idToMetaId = {};
  577. components.forEach(c=>{
  578. // Nothing that can be done here...
  579. if (c.sheetBox.querySelector("iframe") == null){
  580. console.warn("!!!" + window.pages[c.id]);
  581. return;
  582. }
  583. c.meta.id = c.meta.id || crypto.randomUUID()
  584. idToMetaId[c.id] = c.meta.id;
  585. idToIfram[c.id] = c.sheetBox.querySelector("iframe");
  586.  
  587. c.sheetBox.querySelector("iframe").contentWindow.document.querySelectorAll("link").forEach(link=>{
  588. cssRegistry[c.id] = cssRegistry[c.id] || [];
  589. cssRegistry[c.id].push(link.href);
  590.  
  591. if (imgAssests.includes(link.href)) return;
  592. imgAssests.push(link.href);
  593.  
  594.  
  595. });
  596. });
  597. let url = location.origin;
  598. for (let i of Object.keys(window.pages)){
  599. if (idToIfram[i])
  600. url = idToIfram[i].src;
  601. oebps.file(truncate(i), fixXhtml(idToMetaId[i], url, window.pages[i], imgAssests, cssRegistry[i] || []));
  602. }
  603.  
  604. downloadElem.innerHTML += `Downloading assets <span id="assetGath"> 0/${imgAssests.length} </span><br>`
  605. downloadElem.scrollTo(0, downloadElem.scrollHeight);
  606.  
  607.  
  608. gc = 0;
  609. await Promise.all(imgAssests.map(name=>(async function(){
  610. const response = await fetch(name.startsWith("http") ? name : location.origin + "/" + name);
  611. if (response.status != 200) {
  612. downloadElem.innerHTML += `<b>WARNING:</b> Could not fetch ${name}<br>`
  613. downloadElem.scrollTo(0, downloadElem.scrollHeight);
  614. return;
  615. }
  616. const blob = await response.blob();
  617.  
  618. oebps.file(name.startsWith("http") ? getFilenameFromURL(name) : name, blob, { compression: "STORE" });
  619.  
  620. gc+=1;
  621. downloadElem.querySelector("span#assetGath").innerHTML = ` ${gc}/${imgAssests.length} `;
  622. })()));
  623. }
  624. function enforceEpubXHTML(metaId, url, htmlString, assetRegistry, links) {
  625. const parser = new DOMParser();
  626. const doc = parser.parseFromString(htmlString, 'text/html');
  627. const bod = doc.querySelector("body");
  628. if (bod){
  629. bod.setAttribute("id", metaId);
  630. }
  631.  
  632. // Convert all elements to lowercase tag names
  633. const elements = doc.getElementsByTagName('*');
  634. for (let el of elements) {
  635. const newElement = doc.createElement(el.tagName.toLowerCase());
  636.  
  637. // Copy attributes to the new element
  638. for (let attr of el.attributes) {
  639. newElement.setAttribute(attr.name, attr.value);
  640. }
  641.  
  642. // Move child nodes to the new element
  643. while (el.firstChild) {
  644. newElement.appendChild(el.firstChild);
  645. }
  646.  
  647. // Replace old element with the new one
  648. el.parentNode.replaceChild(newElement, el);
  649. }
  650.  
  651. for (let el of elements) {
  652. if (el.tagName.toLowerCase() == "img" || el.tagName.toLowerCase() == "image"){
  653. let src = el.getAttribute("src") || el.getAttribute("xlink:href");
  654. if (!src) continue;
  655.  
  656. if (!(src.startsWith("http://") || src.startsWith("https://"))){
  657. src = (new URL(src, new URL(url))).toString();
  658. }
  659. if (!assetRegistry.includes(src))
  660. assetRegistry.push(src);
  661.  
  662. if (el.getAttribute("src"))
  663. el.setAttribute("src", truncate(src));
  664. if (el.getAttribute("xlink:href"))
  665. el.setAttribute("xlink:href", truncate(src));
  666. }
  667. }
  668.  
  669.  
  670. // Ensure the <head> element exists with a <title>
  671. let head = doc.querySelector('head');
  672. if (!head) {
  673. head = doc.createElement('head');
  674. doc.documentElement.insertBefore(head, doc.documentElement.firstChild);
  675. }
  676.  
  677. let title = head.querySelector('title');
  678. if (!title) {
  679. title = doc.createElement('title');
  680. title.textContent = BIF.map.title.main; // Default title
  681. head.appendChild(title);
  682. }
  683.  
  684. for (let link of links){
  685. let src = link;
  686. if (!(src.startsWith("http://") || src.startsWith("https://"))) {
  687. src = (new URL(src, new URL(url))).toString();
  688. }
  689. let linkElement = doc.createElement('link');
  690. linkElement.setAttribute("href", truncate(src));
  691. linkElement.setAttribute("rel", "stylesheet");
  692. linkElement.setAttribute("type", "text/css");
  693. head.appendChild(linkElement);
  694. }
  695.  
  696. // Get the serialized XHTML string
  697. const serializer = new XMLSerializer();
  698. let xhtmlString = serializer.serializeToString(doc);
  699.  
  700. // Ensure proper namespaces (if not already present)
  701. if (!xhtmlString.includes('xmlns="http://www.w3.org/1999/xhtml"')) {
  702. xhtmlString = xhtmlString.replace('<html>', '<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xmlns:m="http://www.w3.org/1998/Math/MathML" xmlns:pls="http://www.w3.org/2005/01/pronunciation-lexicon" xmlns:ssml="http://www.w3.org/2001/10/synthesis" xmlns:svg="http://www.w3.org/2000/svg">');
  703. }
  704.  
  705. return xhtmlString;
  706. }
  707. function fixXhtml(metaId, url, html, assetRegistry, links){
  708. html = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
  709. ` + enforceEpubXHTML(metaId, url, `<!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xmlns:m="http://www.w3.org/1998/Math/MathML" xmlns:pls="http://www.w3.org/2005/01/pronunciation-lexicon" xmlns:ssml="http://www.w3.org/2001/10/synthesis" xmlns:svg="http://www.w3.org/2000/svg">`
  710. + html + `</html>`, assetRegistry, links);
  711.  
  712.  
  713.  
  714. return html;
  715. }
  716. function getMimeTypeFromFileName(fileName) {
  717. const mimeTypes = {
  718. jpg: 'image/jpeg',
  719. jpeg: 'image/jpeg',
  720. png: 'image/png',
  721. gif: 'image/gif',
  722. bmp: 'image/bmp',
  723. webp: 'image/webp',
  724. mp4: 'video/mp4',
  725. mp3: 'audio/mp3',
  726. pdf: 'application/pdf',
  727. txt: 'text/plain',
  728. html: 'text/html',
  729. css: 'text/css',
  730. json: 'application/json',
  731. // Add more extensions as needed
  732. };
  733.  
  734. const ext = fileName.split('.').pop().toLowerCase();
  735. return mimeTypes[ext] || 'application/octet-stream';
  736. }
  737. function makePackage(oebps, assetRegistry){
  738. const idStore = [];
  739. const doc = document.implementation.createDocument(
  740. 'http://www.idpf.org/2007/opf', // default namespace
  741. 'package', // root element name
  742. null // do not specify a doctype
  743. );
  744.  
  745. // Step 2: Set attributes for the root element
  746. const packageElement = doc.documentElement;
  747. packageElement.setAttribute('version', '2.0');
  748. packageElement.setAttribute('xml:lang', 'en');
  749. packageElement.setAttribute('unique-identifier', 'pub-identifier');
  750. packageElement.setAttribute('xmlns', 'http://www.idpf.org/2007/opf');
  751. packageElement.setAttribute('xmlns:dc', 'http://purl.org/dc/elements/1.1/');
  752. packageElement.setAttribute('xmlns:dcterms', 'http://purl.org/dc/terms/');
  753.  
  754. // Step 3: Create and append child elements to the root
  755. const metadata = doc.createElementNS('http://www.idpf.org/2007/opf', 'metadata');
  756. packageElement.appendChild(metadata);
  757.  
  758. // Create child elements for metadata
  759. const dcIdentifier = doc.createElementNS('http://purl.org/dc/elements/1.1/', 'dc:identifier');
  760. dcIdentifier.setAttribute('id', 'pub-identifier');
  761. dcIdentifier.textContent = "" + BIF.map["-odread-buid"];
  762. metadata.appendChild(dcIdentifier);
  763.  
  764. // Language
  765. if (BIF.map.language.length){
  766. const dcLanguage = doc.createElementNS('http://purl.org/dc/elements/1.1/', 'dc:language');
  767. dcLanguage.setAttribute('xsi:type', 'dcterms:RFC4646');
  768. dcLanguage.textContent = BIF.map.language[0];
  769. packageElement.setAttribute('xml:lang', BIF.map.language[0]);
  770. metadata.appendChild(dcLanguage);
  771. }
  772.  
  773. // Identifier
  774. const metaIdentifier = doc.createElementNS('http://www.idpf.org/2007/opf', 'meta');
  775. metaIdentifier.setAttribute('id', 'meta-identifier');
  776. metaIdentifier.setAttribute('property', 'dcterms:identifier');
  777. metaIdentifier.textContent = "" + BIF.map["-odread-buid"];
  778. metadata.appendChild(metaIdentifier);
  779.  
  780. // Title
  781. const dcTitle = doc.createElementNS('http://purl.org/dc/elements/1.1/', 'dc:title');
  782. dcTitle.setAttribute('id', 'pub-title');
  783. dcTitle.textContent = BIF.map.title.main;
  784. metadata.appendChild(dcTitle);
  785.  
  786.  
  787. // Creator (Author)
  788. if(BIF.map.creator.length){
  789. const dcCreator = doc.createElementNS('http://purl.org/dc/elements/1.1/', 'dc:creator');
  790. dcCreator.textContent = BIF.map.creator[0].name;
  791. metadata.appendChild(dcCreator);
  792. }
  793.  
  794. // Description
  795. if(BIF.map.description){
  796. // Remove HTML tags
  797. let p = document.createElement("p");
  798. p.innerHTML = BIF.map.description.full;
  799.  
  800.  
  801. const dcDescription = doc.createElementNS('http://purl.org/dc/elements/1.1/', 'dc:description');
  802. dcDescription.textContent = p.textContent;
  803. metadata.appendChild(dcDescription);
  804. }
  805.  
  806. // Step 4: Create the manifest, spine, guide, and other sections...
  807. const manifest = doc.createElementNS('http://www.idpf.org/2007/opf', 'manifest');
  808. packageElement.appendChild(manifest);
  809.  
  810. const spine = doc.createElementNS('http://www.idpf.org/2007/opf', 'spine');
  811. spine.setAttribute("toc", "ncx");
  812. packageElement.appendChild(spine);
  813.  
  814.  
  815. const item = doc.createElementNS('http://www.idpf.org/2007/opf', 'item');
  816. item.setAttribute('id', 'ncx');
  817. item.setAttribute('href', 'toc.ncx');
  818. item.setAttribute('media-type', 'application/x-dtbncx+xml');
  819. manifest.appendChild(item);
  820.  
  821.  
  822. // Generate out the manifest
  823. let components = getBookComponents();
  824. components.forEach(chapter =>{
  825. const item = doc.createElementNS('http://www.idpf.org/2007/opf', 'item');
  826. let id = chapter.meta.id;
  827. if (idStore.includes(id)) {
  828. id = id + "-" + crypto.randomUUID();
  829. }
  830. item.setAttribute('id', id);
  831. idStore.push(id);
  832. item.setAttribute('href', truncate(chapter.meta.path));
  833. item.setAttribute('media-type', 'application/xhtml+xml');
  834. manifest.appendChild(item);
  835.  
  836.  
  837. const itemref = doc.createElementNS('http://www.idpf.org/2007/opf', 'itemref');
  838. itemref.setAttribute('idref', chapter.meta.id);
  839. itemref.setAttribute('linear', "yes");
  840. spine.appendChild(itemref);
  841. });
  842.  
  843. assetRegistry.forEach(asset => {
  844. const item = doc.createElementNS('http://www.idpf.org/2007/opf', 'item');
  845. let aname = asset.startsWith("http") ? getFilenameFromURL(asset) : asset;
  846. let id = aname.split(".")[0];
  847. if (idStore.includes(id)) {
  848. id = id + "-" + crypto.randomUUID();
  849. }
  850. item.setAttribute('id', id);
  851. idStore.push(id);
  852. item.setAttribute('href', aname);
  853. item.setAttribute('media-type', getMimeTypeFromFileName(aname));
  854. manifest.appendChild(item);
  855. });
  856.  
  857. // Step 5: Serialize the document to a string
  858. const serializer = new XMLSerializer();
  859. const xmlString = serializer.serializeToString(doc);
  860.  
  861. oebps.file("content.opf", `<?xml version="1.0" encoding="utf-8" standalone="no"?>\n` + xmlString);
  862. }
  863. function makeToc(oebps){
  864. // Step 1: Create the document with a default namespace
  865. const doc = document.implementation.createDocument(
  866. 'http://www.daisy.org/z3986/2005/ncx/', // default namespace
  867. 'ncx', // root element name
  868. null // do not specify a doctype
  869. );
  870.  
  871. // Step 2: Set attributes for the root element
  872. const ncxElement = doc.documentElement;
  873. ncxElement.setAttribute('version', '2005-1');
  874.  
  875. // Step 3: Create and append child elements to the root
  876. const head = doc.createElementNS('http://www.daisy.org/z3986/2005/ncx/', 'head');
  877. ncxElement.appendChild(head);
  878.  
  879. const uidMeta = doc.createElementNS('http://www.daisy.org/z3986/2005/ncx/', 'meta');
  880. uidMeta.setAttribute('name', 'dtb:uid');
  881. uidMeta.setAttribute('content', "" + BIF.map["-odread-buid"]);
  882. head.appendChild(uidMeta);
  883.  
  884. // Step 4: Create docTitle and add text
  885. const docTitle = doc.createElementNS('http://www.daisy.org/z3986/2005/ncx/', 'docTitle');
  886. ncxElement.appendChild(docTitle);
  887.  
  888. const textElement = doc.createElementNS('http://www.daisy.org/z3986/2005/ncx/', 'text');
  889. textElement.textContent = BIF.map.title.main;
  890. docTitle.appendChild(textElement);
  891.  
  892. // Step 5: Create navMap and append navPoint elements
  893. const navMap = doc.createElementNS('http://www.daisy.org/z3986/2005/ncx/', 'navMap');
  894. ncxElement.appendChild(navMap);
  895.  
  896.  
  897. let components = getBookComponents();
  898.  
  899. components.forEach(chapter =>{
  900. // First navPoint
  901. const navPoint1 = doc.createElementNS('http://www.daisy.org/z3986/2005/ncx/', 'navPoint');
  902. navPoint1.setAttribute('id', chapter.meta.id);
  903. navPoint1.setAttribute('playOrder', '' + (1+chapter.index));
  904. navMap.appendChild(navPoint1);
  905.  
  906. const navLabel1 = doc.createElementNS('http://www.daisy.org/z3986/2005/ncx/', 'navLabel');
  907. navPoint1.appendChild(navLabel1);
  908.  
  909. const text1 = doc.createElementNS('http://www.daisy.org/z3986/2005/ncx/', 'text');
  910. text1.textContent = BIF.map.title.main;
  911. navLabel1.appendChild(text1);
  912.  
  913. const content1 = doc.createElementNS('http://www.daisy.org/z3986/2005/ncx/', 'content');
  914. content1.setAttribute('src', truncate(chapter.meta.path));
  915. navPoint1.appendChild(content1);
  916. });
  917.  
  918.  
  919. // Step 6: Serialize the document to a string
  920. const serializer = new XMLSerializer();
  921. const xmlString = serializer.serializeToString(doc);
  922.  
  923. oebps.file("toc.ncx", `<?xml version="1.0" encoding="utf-8" standalone="no"?>\n` + xmlString);
  924. }
  925. async function downloadEPUB(){
  926. let imageAssets = new Array();
  927.  
  928.  
  929. const zip = new JSZip();
  930. zip.file("mimetype", "application/epub+zip", {compression: "STORE"});
  931. zip.folder("META-INF").file("container.xml", `<?xml version="1.0" encoding="UTF-8"?>
  932. <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
  933. <rootfiles>
  934. <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
  935. </rootfiles>
  936. </container>
  937. `);
  938.  
  939. let oebps = zip.folder("OEBPS");
  940. await createContent(oebps, imageAssets);
  941.  
  942. makePackage(oebps, imageAssets);
  943. makeToc(oebps);
  944.  
  945.  
  946. downloadElem.innerHTML += "<br><b>Downloads complete!</b> Now waiting for them to be assembled! (This might take a <b><i>minute</i></b>) <br>";
  947. downloadElem.innerHTML += "Zip progress: <b id='zipProg'>0</b>%<br>";
  948.  
  949.  
  950. // Generate the zip file
  951. const zipBlob = await zip.generateAsync({
  952. type: 'blob',
  953. compression: "DEFLATE",
  954. streamFiles: true,
  955. }, (meta)=>{
  956. if (meta.percent)
  957. downloadElem.querySelector("#zipProg").textContent = meta.percent.toFixed(2);
  958.  
  959. });
  960.  
  961.  
  962. downloadElem.innerHTML += `EPUB generation complete! Starting download<br>`
  963. downloadElem.scrollTo(0, downloadElem.scrollHeight);
  964.  
  965. const downloadUrl = URL.createObjectURL(zipBlob);
  966. const link = document.createElement('a');
  967. link.href = downloadUrl;
  968. link.download = BIF.map.title.main + '.epub';
  969. link.click();
  970.  
  971.  
  972.  
  973. // Clean up the object URL
  974. setTimeout(() => URL.revokeObjectURL(downloadUrl), 100);
  975.  
  976. downloadState = -1;
  977. }
  978.  
  979. // Main entry point for audiobooks
  980. function bifFoundBook(){
  981. // New global style info
  982. let s = document.createElement("style");
  983. s.innerHTML = CSS;
  984. document.head.appendChild(s)
  985.  
  986. if (!window.__bif_cfc1){
  987. alert("Injection failed! __bif_cfc1 not found");
  988. return;
  989. }
  990. const old_crf1 = window.__bif_cfc1;
  991. window.__bif_cfc1 = (win, edata)=>{
  992. // If the bind hook succeeds, then the first element of bound args
  993. // will be the decryption function. So we just passivly build up an
  994. // index of the pages!
  995. pages[win.name] = old_crf1.__boundArgs[0](edata);
  996. return old_crf1(win, edata);
  997. };
  998.  
  999. buildBookPirateUi();
  1000. }
  1001.  
  1002. function downloadEPUBBBtn(){
  1003. if (downloadState != -1)
  1004. return;
  1005.  
  1006. downloadState = 0;
  1007. downloadElem.classList.add("active");
  1008. downloadElem.innerHTML = "<b>Starting download</b><br>";
  1009.  
  1010. downloadEPUB().then(()=>{});
  1011. }
  1012. function buildBookPirateUi(){
  1013. // Create the nav
  1014. let nav = document.createElement("div");
  1015. nav.innerHTML = bookNav;
  1016. nav.querySelector("#download").onclick = downloadEPUBBBtn;
  1017. nav.classList.add("pNav");
  1018. let pbar = document.querySelector(".nav-progress-bar");
  1019. pbar.insertBefore(nav, pbar.children[1]);
  1020.  
  1021.  
  1022.  
  1023. downloadElem = document.createElement("div");
  1024. downloadElem.classList.add("foldMenu");
  1025. downloadElem.setAttribute("tabindex", "-1"); // Don't mess with tab key
  1026. document.body.appendChild(downloadElem);
  1027. }
  1028.  
  1029. /* =========================================
  1030. END BOOK SECTION!
  1031. =========================================
  1032. */
  1033.  
  1034. /* =========================================
  1035. BEGIN INITIALIZER SECTION!
  1036. =========================================
  1037. */
  1038.  
  1039.  
  1040. // The "BIF" contains all the info we need to download
  1041. // stuff, so we wait until the page is loaded, and the
  1042. // BIF is present, to inject the pirate menu.
  1043. let intr = setInterval(()=>{
  1044. if (window.BIF != undefined && document.querySelector(".nav-progress-bar") != undefined){
  1045. clearInterval(intr);
  1046. let mode = location.hostname.split(".")[1];
  1047. if (mode == "listen"){
  1048. bifFoundAudiobook();
  1049. }else if (mode == "read"){
  1050. bifFoundBook();
  1051. }
  1052. }
  1053. }, 25);
  1054. })();

QingJ © 2025

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