Awesome Notion PDF Exporter

Export Notion page as a single, styled HTML file.

// ==UserScript==
// @name         Awesome Notion PDF Exporter
// @namespace    http://tampermonkey.net/
// @version      3.0
// @description  Export Notion page as a single, styled HTML file.
// @author       krg
// @match        https://www.notion.so/*
// @grant        GM_addStyle
// @license      MIT
// @icon         data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iYmxhY2siPjxwYXRoIGQ9Ik01IDIwaDE0di0ySDV2MnpNMTIgMkw1LjMzIDloMy41OHY2aDQuMThWOWhzLjU4TDEyIDJ6Ii8+PC9zdmc+
// ==/UserScript==

(function() {
    'use strict';

    GM_addStyle(`
        #notion-exporter-container {
            display: flex;
            align-items: center;
            gap: 4px;
            margin: 0 4px;
        }
        #notion-exporter-button, #notion-exporter-margin-select {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            height: 28px;
            padding: 0 8px;
            border-radius: 6px;
            font-size: 14px;
            font-weight: 500;
            color: var(--c-texPri);
            background-color: transparent;
            border: 1px solid transparent;
            cursor: pointer;
            user-select: none;
            transition: background-color 20ms ease-in;
            white-space: nowrap;
        }
        #notion-exporter-button { gap: 6px; }
        #notion-exporter-button:hover, #notion-exporter-margin-select:hover {
            background-color: var(--c-bacHover);
        }
        #notion-exporter-margin-select {
            padding-right: 2px; /* to align with default notion selects */
        }
        #notion-exporter-button.loading {
            cursor: wait;
            background-color: var(--c-bacActive);
        }
        #notion-exporter-button svg {
             width: 18px;
             height: 18px;
             fill: var(--c-icoPri);
        }
    `);

    // --- メイン処理 ---

    function resourceToDataURI(url) {
        return fetch(url)
            .then(response => response.blob())
            .then(blob => new Promise((resolve, reject) => {
                const reader = new FileReader();
                reader.onloadend = () => resolve(reader.result);
                reader.onerror = reject;
                reader.readAsDataURL(blob);
            }));
    }

    async function exportNotionPage() {
        const button = document.getElementById('notion-exporter-button');
        button.classList.add('loading');
        button.querySelector('span').textContent = '処理中 (CSS)...';

        try {
            // --- 1. スタイルの抽出 ---
            const cssSelector = 'head > link[rel="stylesheet"], head > link[href*="katex"]';
            const cssLinks = Array.from(document.querySelectorAll(cssSelector));

            const cssProcessingPromises = cssLinks.map(async (link) => {
                try {
                    const response = await fetch(link.href);
                    let cssText = await response.text();
                    const urlRegex = /url\((['"]?)(?!data:)(.*?)\1\)/g;
                    for (const match of [...cssText.matchAll(urlRegex)]) {
                        try {
                            const absoluteUrl = new URL(match[2], link.href).href;
                            const dataURI = await resourceToDataURI(absoluteUrl);
                            cssText = cssText.replace(match[0], `url(${dataURI})`);
                        } catch (e) { console.warn(`Data URI変換失敗: ${match[2]}`, e); }
                    }
                    return cssText;
                } catch (e) { console.error('CSS取得失敗:', link.href, e); return ''; }
            });

            const processedCssTexts = await Promise.all(cssProcessingPromises);
            const allLinkedCss = processedCssTexts.join('\n');
            button.querySelector('span').textContent = '処理中 (HTML)...';

            const styleTagsHtml = Array.from(document.querySelectorAll('head > style')).map(style => style.outerHTML).join('\n');
            const htmlClass = document.documentElement.className;
            const htmlStyle = document.documentElement.style.cssText;
            const htmlLang = document.documentElement.lang;
            const bodyClass = document.body.className;

            // --- 2. コンテンツの抽出 ---
            const mainFrame = document.querySelector('main.notion-frame');
            if (!mainFrame) throw new Error('エクスポート対象のコンテンツフレームが見つかりませんでした。');

            const titleElement = mainFrame.querySelector('h1.notranslate')?.closest('.notion-page-block');
            const contentElement = mainFrame.querySelector('.notion-page-content');
            if (!contentElement) throw new Error('エクスポート対象のコンテンツ本体が見つかりませんでした。');

            const titleClone = titleElement ? titleElement.cloneNode(true) : null;
            const contentClone = contentElement.cloneNode(true);

            const blocks = contentClone.querySelectorAll('.notion-selectable');
            for (let i = 0; i < blocks.length - 2; i++) {
                if (blocks[i].classList.contains('notion-divider-block') && blocks[i + 1].classList.contains('notion-divider-block') && blocks[i + 2].classList.contains('notion-divider-block')) {
                    const pageBreakIndicator = document.createElement('div');
                    pageBreakIndicator.className = 'page-break-indicator';
                    blocks[i + 2].parentNode.replaceChild(pageBreakIndicator, blocks[i + 2]);
                    blocks[i].remove();
                    blocks[i + 1].remove();
                    i += 2;
                }
            }

            [titleClone, contentClone].filter(Boolean).forEach(clone => {
                 clone.querySelectorAll('img').forEach(img => {
                    if (img.src.startsWith('/')) img.src = location.origin + img.src;
                });
                clone.querySelectorAll('[contenteditable="true"]').forEach(el => el.removeAttribute('contenteditable'));
            });

            const titleHtml = titleClone ? titleClone.outerHTML : '';
            const contentHtml = contentClone.outerHTML;

            // --- 3. 新しいHTMLの構築 ---
            const marginSetting = document.getElementById('notion-exporter-margin-select').value;
            let mainFrameStyle = `max-width: 900px; padding: 80px 40px 30vh;`; // Notion風
            if (marginSetting === 'report') mainFrameStyle = `max-width: 100%; padding: 50px 80px 30vh;`; // レポート風
            else if (marginSetting === 'full') mainFrameStyle = `max-width: 100%; padding: 50px 30px 30vh;`; // ページ全体

            const pageTitle = document.title;
            const appInnerClass = document.querySelector('.notion-app-inner')?.className || '';

            const finalHtml = `
<!DOCTYPE html>
<html lang="${htmlLang}" class="${htmlClass}" style="${htmlStyle}">
<head>
    <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${pageTitle}</title>
    ${styleTagsHtml}
    <style>
        ${allLinkedCss}

        html, body {
            background-color: var(--c-bacPri); /* 念のため背景色を指定 */
        }

        .print-background {
            display: none; /* 通常表示では隠す */
        }

        @media print {
            /* 印刷時に背景色を強制描画 */
            html, body {
                -webkit-print-color-adjust: exact;
                print-color-adjust: exact;
            }

            /* 印刷時専用の背景レイヤー */
            .print-background {
                display: block;
                position: fixed; /* 各ページに追従させる */
                top: 0;
                left: 0;
                width: 100%;
                height: 100%;
                background-color: var(--c-bacPri); /* Notionの背景色を適用 */
                z-index: -1; /* コンテンツの最背面に配置 */
            }

            main.exported-notion-frame {
                max-width: 100% !important;
                /* Web表示用の巨大な下パディング(30vh)を上書きして、不要な改ページを防ぐ */
                padding: 2cm 2.5cm !important;
            }

            .page-break-indicator {
                break-before: page;
                height: 0;
                border: none;
                margin: 0;
            }
        }

        body { overflow: auto !important; }
        .notion-app-inner { display: flex; justify-content: center; color: var(--c-texPri); fill: currentcolor; line-height: 1.5; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI Variable Display", "Segoe UI", Helvetica, "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Hiragino Sans GB", メイリオ, Meiryo, "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol"; -webkit-font-smoothing: auto; background-color: var(--c-bacPri); }
        main.exported-notion-frame { position: relative; width: 100%; box-sizing: border-box; ${mainFrameStyle} }
        .notion-code-block .notranslate { white-space: pre-wrap !important; word-break: break-all !important; }
        main.exported-notion-frame .notion-page-block h1 { margin-bottom: 24px !important; }
        .page-break-indicator {}
    </style>
</head>
<body class="${bodyClass}">
    <div class="print-background"></div>

    <div id="notion-app"> <div class="${appInnerClass}">
        <main class="exported-notion-frame"> ${titleHtml} ${contentHtml} </main>
    </div> </div>
</body>
</html>`;

            // --- 4. ダウンロード処理 ---
            const blob = new Blob([finalHtml], { type: 'text/html' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url; a.download = `${pageTitle.replace(/[\\/?%*:|"<>]/g, '-')}.html`;
            document.body.appendChild(a); a.click();
            document.body.removeChild(a); URL.revokeObjectURL(url);

        } catch (error) {
            console.error('Notion Exporter Error:', error);
            alert(`エクスポートに失敗しました: ${error.message}`);
        } finally {
            button.classList.remove('loading');
            button.querySelector('span').textContent = 'HTMLエクスポート';
        }
    }

    // --- UI追加処理 ---
    function addExportUI() {
        if (document.getElementById('notion-exporter-container')) return;

        const target = document.querySelector('.notion-topbar-action-buttons');
        if (target) {
            const container = document.createElement('div');
            container.id = 'notion-exporter-container';

            const select = document.createElement('select');
            select.id = 'notion-exporter-margin-select';
            select.innerHTML = `<option value="notion">Notion風</option><option value="report" selected>レポート風</option><option value="full">ページ全体</option>`;
            container.appendChild(select);

            const button = document.createElement('div');
            button.id = 'notion-exporter-button';
            button.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 20h14v-2H5v2zm7-18L5.33 9h3.58v6h4.18V9h3.58L12 2z"></path></svg><span>HTMLエクスポート</span>`;
            button.addEventListener('click', exportNotionPage);
            container.appendChild(button);

            target.prepend(container);
        }
    }

    const observer = new MutationObserver(() => {
        if (document.querySelector('.notion-topbar-action-buttons')) {
             addExportUI();
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });

})();