DDB Book Downloader

Save your DBB books to PDF!

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

/* globals waitForKeyElements */
// ==UserScript==
// @name         DDB Book Downloader
// @namespace    http://tampermonkey.net/
// @version      0.1.9
// @description  Save your DBB books to PDF!
// @author       rsminsmith (Adapted from C T Zaran, print styling from /u/Ninjaen37)
// @match        https://www.dndbeyond.com/sources/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=dndbeyond.com
// @grant        none
// ==/UserScript==
(function() {
    'use strict';

    const minPageDelay = 0; // Time to wait between each page. Increase if you're getting bot detected
    const maxPageDelay = 0;
    let bookHTML = '';

    // Get necessary URL components
    const slug = document.location.pathname;
    const urlParts = slug.split('/');
    if (urlParts[1] != 'sources') {
        console.info('DNDBeyondPdf: Not a source page');
        return;
    }

    // If this is a subpage, only worry about indexing it
    const tocHeaderEl = document.querySelector('.compendium-toc-full-header');
    if (!tocHeaderEl) {
        console.info('DNDBeyondPdf: Not a ToC page, ignoring');
        return;
    }

    // Build helper container and button elements
    const buttonContainer = document.createElement('div');
    buttonContainer.style.display = 'inline-block';
    buttonContainer.style.float = 'right';

    // Build buttons
    const resetButton = document.createElement('button');
    resetButton.classList.add('reset-pdf');
    resetButton.textContent = 'Reset Cache';
    resetButton.type = 'button';

    const pdfButton = document.createElement('button');
    pdfButton.classList.add('generate-pdf');
    pdfButton.textContent = 'Generate PDF';
    pdfButton.type = 'button';

    // Spelljammer has 3 books on one page; need to export them individually
    if (slug === '/sources/sais') {
        buttonContainer.style.backgroundColor = 'yellow';
        buttonContainer.style.color = 'black';
        buttonContainer.style.padding = '0.5em';
        buttonContainer.textContent = 'Navigate to each book within Spelljammer to generate PDF';
    } else {

        // Otherwise append generate and reset buttons
        buttonContainer.append(pdfButton);
        buttonContainer.append(resetButton);
    }

    // Attach buttons to header
    tocHeaderEl.prepend(buttonContainer);

    // Handle PDF generation
    pdfButton.onclick = async function() {

        // Update the button
        pdfButton.textContent = 'Generating...';
        pdfButton.disabled = true;

        // Only fetch data if needed
        if (!bookHTML) {

            // Get book info
            const bookTitle = document.title;

            // Pull all links in ToC and filter out the ones that aren't needed
            let tocLinkEls = document.querySelectorAll('.compendium-toc-full-text a');
            let validPages = [];
            let pageNames = {};
            for (const a of tocLinkEls) {
                let href = a.href.replace(/#.*$/, ''); // remove the fragment from the url if any.
                if (validPages.includes(href)) continue; // skip duplicates
                validPages.push(href);
                pageNames[href] = a.textContent;
            }

            // Create initial page HTML
            bookHTML = '<!DOCTYPE html>' +
                '<html lang="en-us" class="no-js">'+
                '<head>' +
                '<meta charset="UTF-8">' +
                '<title>' + bookTitle + '</title>' +
                '<link rel="stylesheet" href="https://www.dndbeyond.com/content/1-0-2352-0/skins/blocks/css/compiled.css"/>' +
                '<link rel="stylesheet" href="https://www.dndbeyond.com/content/1-0-2352-0/skins/waterdeep/css/compiled.css"/>' +
                '<link rel="stylesheet" type="text/css" href="https://www.dndbeyond.com/api/custom-css" />' +
                '<style>body {width: 850px; margin-left:30px} table{text-align: left;} figcaption{text-align: center;} img {max-width: 100%;} ' +
                '@media print {h1, h2.compendium-hr {break-before: always; page-break-before: always;} ' +
                '.print-section {break-after: always; page-break-after: always; } ' +
                'h2.heading-anchor, caption {break-before: avoid; page-break-before: avoid} h1, h2, h3 {break-after: avoid; page-break-after: avoid;} ' +
                'aside, blockquote, table, ul, ol, figure, img {break-inside: avoid; page-break-inside: avoid;} ' +
                '.compendium-image-left {float: left; display: block;} .compendium-image-right {float: right; display: block;} ' +
                '.monster-image-left {float: left; display: block;} .monster-image-right {float: right; display: block;} ' +
                'img.compendium-center-banner-img {width: 100%;}}</style>' +
                '</head>' +
                '<body>' +
                '<div class="print-section"> ';

            // Replace ToC links with internal links
            const baseUrl = 'https://www.dndbeyond.com/sources/';
            let tocEl = document.querySelector('.compendium-toc-full-text').cloneNode(true);
            tocEl.querySelectorAll('a[href]').forEach((tocAEl) => {

                // Get the link, remove the irrelevant pieces and any anchor tags
                let sectionName = tocAEl.href.replace(baseUrl, '');
                const anchorPos = sectionName.indexOf('#');
                if (anchorPos >= 0) {
                    sectionName = sectionName.substring(0, anchorPos);
                }
                const slugPieces = sectionName.split('/');
                const pageName = slugPieces[slugPieces.length - 1];
                const isImage = sectionName.indexOf('.jpg') >= 0 || sectionName.indexOf('.png') >= 0;

                // Update the link to point to the generated internal section
                if (isImage) {
                    const imageTitle = pageNames[sectionName];
                    const imageName = imageTitle.toLowerCase().replace(' ', '_');
                    tocAEl.href = '#section-' + imageName;
                } else {
                    tocAEl.href = '#section-' + pageName;
                }
            });

            // Finish ToC
            bookHTML += tocEl.outerHTML + '</div>';

            // Loop through each page with a delay and store the contents by slug
            const numPages = validPages.length;
            let currentPage = 0;
            for (const page of validPages) {

                // Update button
                currentPage += 1;
                pdfButton.textContent = `Generating (${currentPage}/${numPages})...`;

                // Get page info
                const slugPieces = page.split('/');
                const pageName = slugPieces[slugPieces.length - 1];
                const isImage = pageName.indexOf('.jpg') >= 0 || pageName.indexOf('.png') >= 0;

                // Handle image links
                if (isImage) {
                    console.info('DNDBeyondPdf: Including image ', page);
                    const imageTitle = pageNames[page];
                    const sectionName = imageTitle.toLowerCase().replace(' ', '_');
                    bookHTML += '<div id="section-' + sectionName +'" class="print-section">' +
                        '<h1>' + imageTitle + '</h1>' +
                        '<img src="' + page + '" alt="' + imageTitle + '" class="ddb-lightbox-inner" />' +
                        '</div>';
                } else {

                    // Handle actual content pages
                    console.info('DNDBeyondPdf: Fetching page ', slugPieces[slugPieces.length - 1]);
                    const pageRequest = await fetch(page);
                    const pageContent = await pageRequest.text();
                    let tempEl = document.createElement('div');
                    tempEl.innerHTML = pageContent;
                    bookHTML += '<div id="section-' + pageName +'" class="print-section"> ' + tempEl.querySelector('.p-article-content').innerHTML + '</div>';;
                }

                // Wait before requesting next section if required
                const delay = minPageDelay + (Math.random() * (maxPageDelay - minPageDelay));
                await new Promise(r => setTimeout(r, delay));
            }

            // Finalize page
            bookHTML += '</body></html>';
        }

        // Open book HTML in a new tab
        const openBook = window.open();
        openBook.document.write(bookHTML);

        // Re-enable the button
        pdfButton.textContent = 'Generate PDF';
        pdfButton.disabled = false;

        // Log completion
        console.info('DNDBeyondPdf: Done!');
    }

    // Handle cache reset
    resetButton.onclick = function() {
        bookHTML = '';

        // Update the button
        resetButton.textContent = 'Done!';
        resetButton.disabled = true;

        // Re-enable the button
        setTimeout(function() {
            resetButton.textContent = 'Reset Cache';
            resetButton.disabled = false;
        }, 2500)
    }
})();