Notionで作成した仕様書をいい感じでPDFに変換する(クラウド版)


はじめに

前回はNotionの文章をPDFに変換する際に必要になるHTMLの編集作業を、ローカルPCに構築したPython環境を利用して自動化しました。

これと同様の処理をクラウドで処理するようにしてみます。
そうすることで、ローカルPCにPython環境がなくても、WEBブラウザを使って編集処理の自動化が可能です。

利用するクラウド PythonAnywhereとは?

PythonAnywhereは、Pythonで作ったWebアプリを無料(または有料)でインターネット上に公開できるサービスです。
サーバーやネットワークの知識がなくても、Webアプリを公開できます。
アカウントを作るだけで、すぐにWebサービスを公開できるのが魅力です。

Flaskとは?

Flask(フラスク)は、PythonでWebアプリを作るための「フレームワーク」と呼ばれるものです。
フレームワークとは、Webアプリを作る際の土台や便利な道具をまとめたもの。
Flaskはシンプルで使いやすく、初心者でもWebアプリを作りやすいのが特徴です。

PythonAnywhereでFlaskアプリを公開する手順

PythonAnywhereでアカウントを作る

PythonAnywhereの公式サイトで無料アカウントを作成します。

Flaskアプリのファイルを準備する

今回のアプリのために次のファイルを準備します。

  • app.py(Webアプリ本体)
  • requirements.txt(必要なパッケージ)
  • templates/index.html(画面のテンプレート)
  • static/custom.css(スタイルシート)

各ファイルの内容は次の通りです。

app.py

import os
from flask import Flask, render_template, request, send_file, url_for
from bs4 import BeautifulSoup
import uuid
import threading

app = Flask(__name__)

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
os.makedirs(UPLOAD_FOLDER, exist_ok=True)

CSS_FILE_PATH = os.path.join(BASE_DIR, 'static', 'custom.css')

def process_html(input_file_path):
    with open(input_file_path, 'r', encoding='utf-8') as f:
        soup = BeautifulSoup(f, 'html.parser')

        # 追加: imgタグがaタグの中にある場合、aタグを削除してimgタグだけ残す
        for img in soup.find_all('img'):
            if img.parent.name == 'a':
                img.parent.unwrap()

        head_tag = soup.find('head')
        if not head_tag:
            head_tag = soup.new_tag('head')
            soup.html.insert(0, head_tag)

        script1 = soup.new_tag('script', src='https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js')
        head_tag.append(script1)

        script2 = soup.new_tag('script')
        script2.string = "mermaid.initialize({startOnLoad:true,theme: 'default'});"
        head_tag.append(script2)

        for code in soup.find_all('code', class_='language-Mermaid'):
            new_div = soup.new_tag('div', **{'class': 'mermaid'})
            for content in code.contents:
                content.extract()
                new_div.append(content)
            code.replace_with(new_div)

        toc_h1 = soup.find('h1', string='目次')
        if toc_h1:
            prev_elements = []
            current = toc_h1.previous_sibling
            while current:
                prev_elements.insert(0, current)
                current = current.previous_sibling
            if prev_elements:
                center_div = soup.new_tag('div', id='top-page', style='text-align:center;margin:20px 0;')
                for elem in prev_elements:
                    elem.extract()
                    center_div.append(elem)
                toc_h1.insert_before(center_div)

        for elem in soup.find_all(True, class_=lambda c: c and 'table_of_contents-item' in c.split() and 'table_of_contents-indent-0' in c.split()):
            if elem.text.strip() == '目次':
                prev = elem.find_previous_sibling(name=True)
                if prev:
                    prev.decompose()
                elem.decompose()
                break

        if os.path.exists(CSS_FILE_PATH):
            with open(CSS_FILE_PATH, 'r', encoding='utf-8') as css_file:
                css_content = css_file.read()
            style_tag = head_tag.find('style')
            if style_tag:
                style_tag.string = (style_tag.string or '') + '\n' + css_content
            else:
                style_tag = soup.new_tag('style')
                style_tag.string = css_content
                head_tag.append(style_tag)
        else:
            pass

        for details in soup.find_all('details'):
            prev_p = details.find_previous_sibling('p')
            if prev_p and (not prev_p.get_text(strip=True)):
                prev_p.decompose()
            next_p = details.find_next_sibling('p')
            if next_p and (not next_p.get_text(strip=True)):
                next_p.decompose()

    return str(soup)

def delete_files_after_delay(input_path, output_path, delay_seconds=60):
    def delete():
        try:
            if os.path.exists(input_path):
                os.remove(input_path)
            if os.path.exists(output_path):
                os.remove(output_path)
        except Exception as e:
            app.logger.error(f'ファイル削除に失敗しました: {e}')
    timer = threading.Timer(delay_seconds, delete)
    timer.start()

@app.route('/', methods=['GET', 'POST'])
def index():
    message = ''
    if request.method == 'POST':
        if 'file' not in request.files:
            message = 'ファイルがありません'
            return render_template('index.html', message=message)
        file = request.files['file']
        if file.filename == '':
            message = 'ファイル名がありません'
            return render_template('index.html', message=message)
        input_filename = str(uuid.uuid4()) + '_' + file.filename
        input_file_path = os.path.join(UPLOAD_FOLDER, input_filename)
        file.save(input_file_path)

        try:
            modified_html = process_html(input_file_path)
        except Exception as e:
            message = f'HTML変換中にエラーが発生しました: {e}'
            return render_template('index.html', message=message)

        output_filename = str(uuid.uuid4()) + '_modified.html'
        output_file_path = os.path.join(UPLOAD_FOLDER, output_filename)
        try:
            with open(output_file_path, 'w', encoding='utf-8') as f:
                f.write(modified_html)
        except Exception as e:
            message = f'ファイル保存中にエラーが発生しました: {e}'
            return render_template('index.html', message=message)

        if not os.path.exists(output_file_path):
            message = '変換後ファイルの保存に失敗しました'
            return render_template('index.html', message=message)

        # 60秒後にファイルを削除
        delete_files_after_delay(input_file_path, output_file_path, delay_seconds=60)
        return send_file(output_file_path, as_attachment=True, download_name='modified.html', mimetype='text/html')

    return render_template('index.html', message=message)

if __name__ == '__main__':
    app.run(debug=True)

requirements.txt

flask
beautifulsoup4

templates/index.html

<!DOCTYPE html>
<html>
<head>
    <title>Notion PDF Converter</title>
</head>
<body>
    <h1>Notion PDF Converter</h1>
    <form method="post" enctype="multipart/form-data">
        <input type="file" name="file">
        <input type="submit" value="ファイル変換">
    </form>
    {% if message %}
    <p>{{ message }}</p>
    {% endif %}
</body>
</html>

static/custom.css

header {
    display: none;
}

#top-page h1 {
    margin-top: 10rem;
    margin-bottom: 10rem;
}

#top-page .column-list {
	display:block;
	width: 100%;
	text-align: center;
}
#top-page .column-list .column {
		width: 100% !important;
		padding: 0;
		text-align: center;
}

#top-page .column-list .column .image {
	margin-top: 5rem;
	margin-bottom: 5rem;
	text-align: center !important;
}

h1 {
    page-break-before: always;
}
/* 先頭の見出し1は改ページ無効 */
/*
h1:first-of-type {
    page-break-before: auto;
}
*/

hr {
    page-break-after: always;
    visibility: hidden !important;
}

a, a.visited {
    color: #000;
}

.table_of_contents-link {
    opacity: 1;
    border-bottom: none;
}

.table_of_contents {
    margin-top:1.5rem;
}

.table_of_contents-item {
    font-size: 1rem;
    line-height: 1.5;
}

table {
    width: 100%;
    table-layout: fixed;
    max-width: 100%;
}

tr, th, td {
    color: #000 !important;
    border-color: #000 !important;
}

th {
    font-weight: bold !important;
    background: rgb(247, 246, 243);
    text-align: center;
}

.code:has(.mermaid) {
    padding: 0 !important;
}

ul li details td {
    text-align: right;
}

.toggle > li > details {
    padding-left: 0;
}

.toggle > li > details > summary {
    margin-left: 0;
}

details > summary {
    list-style: none;
}
details > summary::-webkit-details-marker {
    display: none;
}

@media print {
    @page {
        size: portrait;
    }
    @page rotated {
        size: landscape;
    }

    body {
        margin: 0;
        padding: 0;
    }

    details:not(ul li details)  {
        page: rotated;
    }

    td, th {
        word-wrap: break-word;
        overflow-wrap: break-word;
    }

    .mermaid > svg {
        max-width: 100% !important; /* ページの幅に合わせて最大幅を100%にする */
        max-height: 100vh !important; /* ページの高さに合わせて最大高さを100%にする */
        width: auto !important; /* アスペクト比を保ちつつ幅を自動調整 */
        height: auto !important; /* アスペクト比を保ちつつ高さを自動調整 */
        page-break-inside: avoid !important; /* 図の途中で改ページされるのを防ぐ */
    }

    #top-page .column-list {
        position: absolute;
		bottom: 0;
    }
}

ディレクトリ作成とファイルをアップロードする

次の構造になるようにディレクトリを作成し、先程のファイルをアップロードします。

/home/ユーザー名/
└── notionconv/                # プロジェクトルート
    ├── app.py                 # Flaskアプリ本体(エントリーポイント)
    ├── requirements.txt       # 必要なPythonパッケージリスト
    ├── uploads/               # アップロード・変換ファイル保存用ディレクトリ
    ├── static/                # CSSやJSなどの静的ファイル
    │   └── custom.css         # カスタムCSSファイル
    └── templates/             # HTMLテンプレート
        └── index.html         # メインページ用テンプレート

仮想環境を作ってパッケージをインストールする

「Consoles」タブからBashコンソールを開き、以下のコマンドを入力します。

cd notionconv
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

Webアプリを作成・設定する

  • 「Web」タブで「Add a new web app」をクリック。
  • 「Manual configuration」を選び、Pythonのバージョン(今回は3.13)を選択。
  • 「Source code」に/home/ユーザー名/notionconvを指定。

WSGI設定ファイルを編集する

  • 「Web」タブの「WSGI configuration file」リンクをクリック。
  • 下記のように編集します。
import sys
path = '/home/ユーザー名/notionconv'
if path not in sys.path:
    sys.path.append(path)
from app import app as application

アプリを起動・再読み込みする

  • 「Web」タブの「Reload」ボタンを押してアプリを再起動します。
  • https://ユーザー名.pythonanywhere.com/ にアクセスして、アプリが動いているか確認します。

正常に起動していれば、下図のような画面が表示されます。

「ファイルを選択」ボタンで、NotionからエクスポートしたHTMLを選択したあと、「ファイル変換」ボタンをクリックすると、変換後HTMLのダウンロード画面になります。

運用上の注意点

限定公開

「Web」タブのSecurityブロックに、Password protection の設定があります。
有効にするとベーシック認証が利用でき、アプリ利用時にユーザー名とパスワードを要求されます。

無料利用における3ヶ月ごとの更新

無料で利用を継続するためには、3ヶ月毎に「Web」タブの「Run until 3 months from tody」をクリックする必要があります。

FAQ

これは何をする機能ですか?

Notionで作成したドキュメント(HTML形式)を、クラウド上で自動的に整形してHTMLに変換し、ダウンロードできる機能です。最終的にPDF化したい場合は、ダウンロードしたHTMLをブラウザで印刷してPDFとして保存できます。

利用するのに必要なものは何ですか?

PythonAnywhereの無料アカウントと、NotionからエクスポートしたHTMLファイルが必要です。ローカルPCにはPython環境は不要です。

どうやってアプリを公開するのですか?

PythonAnywhereでアカウントを作成し、Flaskアプリのファイルをアップロードして設定するだけで公開できます。サーバーやネットワークの知識は不要です。

仮想環境の作成やパッケージのインストールは必要ですか?

はい、PythonAnywhereのBashコンソールで仮想環境を作成し、requirements.txtに記載されたパッケージをインストールする必要があります。

アプリのセキュリティはどうなっていますか?

PythonAnywhereの「Web」タブでパスワード保護(ベーシック認証)を設定できます。これにより、アプリ利用時にユーザー名とパスワードを要求できます。

無料プランで利用する場合、注意点はありますか?

無料プランでは、3ヶ月ごとに「Run until 3 months from today」をクリックして継続利用を申請する必要があります。

アップロードしたファイルはどこに保存されますか?

アップロード・変換ファイルは/home/ユーザー名/notionconv/uploads/ディレクトリに保存されます。

変換後のHTMLをPDFにするにはどうすればいいですか?

ダウンロードしたHTMLファイルをブラウザで開き、「印刷」からPDFとして保存してください。