DDB Book Downloader

Save your DBB books to PDF!

当前为 2024-09-23 提交的版本,查看 最新版本

  1. /* globals waitForKeyElements */
  2. // ==UserScript==
  3. // @name DDB Book Downloader
  4. // @namespace http://tampermonkey.net/
  5. // @version 0.1.9
  6. // @description Save your DBB books to PDF!
  7. // @author rsminsmith (Adapted from C T Zaran, print styling from /u/Ninjaen37)
  8. // @match https://www.dndbeyond.com/sources/*
  9. // @icon https://www.google.com/s2/favicons?sz=64&domain=dndbeyond.com
  10. // @grant none
  11. // ==/UserScript==
  12. (function() {
  13. 'use strict';
  14.  
  15. const minPageDelay = 0; // Time to wait between each page. Increase if you're getting bot detected
  16. const maxPageDelay = 0;
  17. let bookHTML = '';
  18.  
  19. // Get necessary URL components
  20. const slug = document.location.pathname;
  21. const urlParts = slug.split('/');
  22. if (urlParts[1] != 'sources') {
  23. console.info('DNDBeyondPdf: Not a source page');
  24. return;
  25. }
  26.  
  27. // If this is a subpage, only worry about indexing it
  28. const tocHeaderEl = document.querySelector('.compendium-toc-full-header');
  29. if (!tocHeaderEl) {
  30. console.info('DNDBeyondPdf: Not a ToC page, ignoring');
  31. return;
  32. }
  33.  
  34. // Build helper container and button elements
  35. const buttonContainer = document.createElement('div');
  36. buttonContainer.style.display = 'inline-block';
  37. buttonContainer.style.float = 'right';
  38.  
  39. // Build buttons
  40. const resetButton = document.createElement('button');
  41. resetButton.classList.add('reset-pdf');
  42. resetButton.textContent = 'Reset Cache';
  43. resetButton.type = 'button';
  44.  
  45. const pdfButton = document.createElement('button');
  46. pdfButton.classList.add('generate-pdf');
  47. pdfButton.textContent = 'Generate PDF';
  48. pdfButton.type = 'button';
  49.  
  50. // Spelljammer has 3 books on one page; need to export them individually
  51. if (slug === '/sources/sais') {
  52. buttonContainer.style.backgroundColor = 'yellow';
  53. buttonContainer.style.color = 'black';
  54. buttonContainer.style.padding = '0.5em';
  55. buttonContainer.textContent = 'Navigate to each book within Spelljammer to generate PDF';
  56. } else {
  57.  
  58. // Otherwise append generate and reset buttons
  59. buttonContainer.append(pdfButton);
  60. buttonContainer.append(resetButton);
  61. }
  62.  
  63. // Attach buttons to header
  64. tocHeaderEl.prepend(buttonContainer);
  65.  
  66. // Handle PDF generation
  67. pdfButton.onclick = async function() {
  68.  
  69. // Update the button
  70. pdfButton.textContent = 'Generating...';
  71. pdfButton.disabled = true;
  72.  
  73. // Only fetch data if needed
  74. if (!bookHTML) {
  75.  
  76. // Get book info
  77. const bookTitle = document.title;
  78.  
  79. // Pull all links in ToC and filter out the ones that aren't needed
  80. let tocLinkEls = document.querySelectorAll('.compendium-toc-full-text a');
  81. let validPages = [];
  82. let pageNames = {};
  83. for (const a of tocLinkEls) {
  84. let href = a.href.replace(/#.*$/, ''); // remove the fragment from the url if any.
  85. if (validPages.includes(href)) continue; // skip duplicates
  86. validPages.push(href);
  87. pageNames[href] = a.textContent;
  88. }
  89.  
  90. // Create initial page HTML
  91. bookHTML = '<!DOCTYPE html>' +
  92. '<html lang="en-us" class="no-js">'+
  93. '<head>' +
  94. '<meta charset="UTF-8">' +
  95. '<title>' + bookTitle + '</title>' +
  96. '<link rel="stylesheet" href="https://www.dndbeyond.com/content/1-0-2352-0/skins/blocks/css/compiled.css"/>' +
  97. '<link rel="stylesheet" href="https://www.dndbeyond.com/content/1-0-2352-0/skins/waterdeep/css/compiled.css"/>' +
  98. '<link rel="stylesheet" type="text/css" href="https://www.dndbeyond.com/api/custom-css" />' +
  99. '<style>body {width: 850px; margin-left:30px} table{text-align: left;} figcaption{text-align: center;} img {max-width: 100%;} ' +
  100. '@media print {h1, h2.compendium-hr {break-before: always; page-break-before: always;} ' +
  101. '.print-section {break-after: always; page-break-after: always; } ' +
  102. 'h2.heading-anchor, caption {break-before: avoid; page-break-before: avoid} h1, h2, h3 {break-after: avoid; page-break-after: avoid;} ' +
  103. 'aside, blockquote, table, ul, ol, figure, img {break-inside: avoid; page-break-inside: avoid;} ' +
  104. '.compendium-image-left {float: left; display: block;} .compendium-image-right {float: right; display: block;} ' +
  105. '.monster-image-left {float: left; display: block;} .monster-image-right {float: right; display: block;} ' +
  106. 'img.compendium-center-banner-img {width: 100%;}}</style>' +
  107. '</head>' +
  108. '<body>' +
  109. '<div class="print-section"> ';
  110.  
  111. // Replace ToC links with internal links
  112. const baseUrl = 'https://www.dndbeyond.com/sources/';
  113. let tocEl = document.querySelector('.compendium-toc-full-text').cloneNode(true);
  114. tocEl.querySelectorAll('a[href]').forEach((tocAEl) => {
  115.  
  116. // Get the link, remove the irrelevant pieces and any anchor tags
  117. let sectionName = tocAEl.href.replace(baseUrl, '');
  118. const anchorPos = sectionName.indexOf('#');
  119. if (anchorPos >= 0) {
  120. sectionName = sectionName.substring(0, anchorPos);
  121. }
  122. const slugPieces = sectionName.split('/');
  123. const pageName = slugPieces[slugPieces.length - 1];
  124. const isImage = sectionName.indexOf('.jpg') >= 0 || sectionName.indexOf('.png') >= 0;
  125.  
  126. // Update the link to point to the generated internal section
  127. if (isImage) {
  128. const imageTitle = pageNames[sectionName];
  129. const imageName = imageTitle.toLowerCase().replace(' ', '_');
  130. tocAEl.href = '#section-' + imageName;
  131. } else {
  132. tocAEl.href = '#section-' + pageName;
  133. }
  134. });
  135.  
  136. // Finish ToC
  137. bookHTML += tocEl.outerHTML + '</div>';
  138.  
  139. // Loop through each page with a delay and store the contents by slug
  140. const numPages = validPages.length;
  141. let currentPage = 0;
  142. for (const page of validPages) {
  143.  
  144. // Update button
  145. currentPage += 1;
  146. pdfButton.textContent = `Generating (${currentPage}/${numPages})...`;
  147.  
  148. // Get page info
  149. const slugPieces = page.split('/');
  150. const pageName = slugPieces[slugPieces.length - 1];
  151. const isImage = pageName.indexOf('.jpg') >= 0 || pageName.indexOf('.png') >= 0;
  152.  
  153. // Handle image links
  154. if (isImage) {
  155. console.info('DNDBeyondPdf: Including image ', page);
  156. const imageTitle = pageNames[page];
  157. const sectionName = imageTitle.toLowerCase().replace(' ', '_');
  158. bookHTML += '<div id="section-' + sectionName +'" class="print-section">' +
  159. '<h1>' + imageTitle + '</h1>' +
  160. '<img src="' + page + '" alt="' + imageTitle + '" class="ddb-lightbox-inner" />' +
  161. '</div>';
  162. } else {
  163.  
  164. // Handle actual content pages
  165. console.info('DNDBeyondPdf: Fetching page ', slugPieces[slugPieces.length - 1]);
  166. const pageRequest = await fetch(page);
  167. const pageContent = await pageRequest.text();
  168. let tempEl = document.createElement('div');
  169. tempEl.innerHTML = pageContent;
  170. bookHTML += '<div id="section-' + pageName +'" class="print-section"> ' + tempEl.querySelector('.p-article-content').innerHTML + '</div>';;
  171. }
  172.  
  173. // Wait before requesting next section if required
  174. const delay = minPageDelay + (Math.random() * (maxPageDelay - minPageDelay));
  175. await new Promise(r => setTimeout(r, delay));
  176. }
  177.  
  178. // Finalize page
  179. bookHTML += '</body></html>';
  180. }
  181.  
  182. // Open book HTML in a new tab
  183. const openBook = window.open();
  184. openBook.document.write(bookHTML);
  185.  
  186. // Re-enable the button
  187. pdfButton.textContent = 'Generate PDF';
  188. pdfButton.disabled = false;
  189.  
  190. // Log completion
  191. console.info('DNDBeyondPdf: Done!');
  192. }
  193.  
  194. // Handle cache reset
  195. resetButton.onclick = function() {
  196. bookHTML = '';
  197.  
  198. // Update the button
  199. resetButton.textContent = 'Done!';
  200. resetButton.disabled = true;
  201.  
  202. // Re-enable the button
  203. setTimeout(function() {
  204. resetButton.textContent = 'Reset Cache';
  205. resetButton.disabled = false;
  206. }, 2500)
  207. }
  208. })();