Greasy Fork 支持简体中文。

DDB Book Downloader

Save your DBB books to PDF!

/* 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)
    }
})();