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