Jupyter notebook をはてなブログのマークダウンに変換する

Insight Edgeのデータサイエンティストのki_ieです。 今日の記事ではJupyter notebookをはてなブログで公開できるマークダウンに変換する方法を紹介します。

はじめに

数式とコード、さらにコードの実行結果を含む技術的な記事を書くには、Jupyter notebookが便利です。 しかしJupyter notebookは、そのままでははてなブログに公開できる形ではありません。 下書きだけJupyter notebookで行って最後は手作業でmarkdownに移行して仕上げる という方法もありますが、ソースが2つできてしまい記事の修正時が大変です。

notebookファイルだけをソースとして、ブログ記事は変換スクリプト一発生成できる方が望ましいですよね! 本記事では、この変換を実現するスクリプトを紹介します。

変換スクリプト

想定環境

実験は以下の環境で行いました。 Pythonのバージョンあまり依存しないと想定しています。 jupyterのバージョンによっては、正しく動作しない可能性があります。

  • Python: 3.11
  • jupyter:
    • nbformat: 5.8.0
    • notebook: 6.5.4

スクリプト

スクリプトは以下の通りです。

# FILENAME: nb_hatena_converter.py
"""
Usage:
python nb_hatena_converter.py YOUR_NOTEBOOK.ipynb OUTPUT.md
"""

import nbformat, nbconvert
import sys
import re

bs = "\\"
nl = "\n"
NO_CONV_TAG = "<!-- NO_NB_HATENA_CONVERSION -->"
special_chars = r"\*_`#+-.!{}[]()^"  # backslash は文字列末尾には置かない ("がエスケープされるため)
math_range_re = re.compile(r"(\${1,2}[^\$]+\${1,2})")
special_chars_re = re.compile(
    "".join(['['] + [bs + ch for ch in special_chars] + [']'])
)
mathjax_html_tag =r'''
<script> MathJax = {tex: {inlineMath: [['$', '$']]}}; </script>
<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"></script>
'''
# 参考URL: https://o-treetree.hatenablog.com/entry/mathjaxexample


def convert_str(s: str, cell, cell_no) -> str:
    # 数式部分またはそれ以外の部分の文字列 s を入力とし、必要な変換を施します。

    full_match = math_range_re.fullmatch(s)
    res = s  # 特に変換がなかった場合、もとの入力を返します。

    # セルが NO_CONV_TAG で始まる場合、そのセルは変換しません。
    if cell.source.find(NO_CONV_TAG) == 0:  
        pass

    # s が数式部分でない場合、変換しません。
    elif not full_match:
        pass

    # s が数式部分の場合、特殊文字をエスケープします。
    elif full_match:
        res = special_chars_re.sub(repl=lambda match_obj: bs + match_obj.group(0), string=s)
        if res != s:
            print(f'{s}  -->  {res}  @  {cell_no=}  beginning with  {cell.source[:15].replace(nl, bs + "n")}')
    else:
        raise NotImplementedError()
    assert isinstance(res, str)
    return res


def main(path_to_input, path_to_output):
    # 1. ノートブックを読み込みます。
    notebook = nbformat.reads(open(path_to_input).read(), as_version=4)
    md_exporter = nbconvert.MarkdownExporter()

    # 2. ノートブックのセルを, convert_str を使って書き換えます。
    # (メモリ上での操作なので、ファイルに変更は加わりません)
    print('Substitutions: ')
    for cell_no, cell in enumerate(notebook.cells):
        # markdown セルのみ変換処理の対象です。
        if cell.cell_type == 'markdown':
            # 2.1. cell の内容を、数式部分とそうでない部分に分割します。
            # 例: source = "abc $d$ ef $$g$$"  
            #     --> 
            #     substrs = ["abc ", "$d$", " ef ", "$$g$$"]
            substrs = math_range_re.split(cell.source)

            # 2.2. 各部分に対して、 convert_str で必要なエスケープ変換を施します。
            new_source = ''.join([convert_str(s, cell, cell_no) for s in substrs])
            cell.source = new_source
        else:
            pass

    # 3. nbconvert.MarkdownExporter を使って、markdown に変換します。
    #    この変換処理は MarkdownExporter のデフォルトの処理を利用します。
    converted_md, md_info = md_exporter.from_notebook_node(notebook)

    # 4. MathJax を利用できるように、ファイルの最初に HTML タグを追加します
    result_md = '\n'.join([mathjax_html_tag, converted_md])

    # 5. 出力します
    with open(path_to_output, 'w') as f:
        f.write(result_md)

if __name__ == '__main__':
    main(sys.argv[1], sys.argv[2])

使い方

1. jupyter notebookの準備

notebookに特別な工夫は不要です。数式を含むnotebookを用意しましょう。数式は、インラインでも、別行立て数式でも大丈夫です。 ここでは、以下の数式がどのように変換されるか確認しましょう

$n = p \cdot q$
$$f(x)=\frac{{\sum_{i \in I} |x_i|}^2}{\sum_{i \in I} x_i^2}$$

2. 変換

以下の方法でプログラムを起動し、変換結果のmarkdownファイルを得ます。

python nb_hatena_converter.py YOUR_NOTEBOOK.ipynb OUTPUT.md

3. 変換結果の確認

先程の数式は以下のように変換されます:

$n = p \cdot q$ $$f(x)=\frac{{\sum_{i \in I} |x_i|}^2}{\sum_{i \in I} x_i^2}$$

(この記事自体も nb_hatena_converter.py で生成されたものです。上記の数式がはてなブログで正しく表示されているなら、それはこのスクリプトが正しく動作した結果です!)

4. 仕様

仕様概要は次の通りです。詳細は、プログラムとコメントを確認してください。

変換対象

markdownセルだけが変換対象です。codeやrawセルは変換されません。 markdownセルで数式部分と判定された部分に対して、変換処理が施されます。

セル単位のスキップ

<!-- NO_NB_HATENA_CONVERSION --> で始まるmarkdownセルは変換を受けません。 このタグが含まれていても、セルの最初でなければ変換の対象となります。

数式部分の判別

$ または $$ で囲まれた部分を数式部分だと認識しています(詳細: math_range_re )。大雑把な判別なので、必要に応じて適宜改造してください。

変換処理の概要

はてな上での表示とjupyter notebook上での表示のずれは特殊記号の処理の優先順位が異なるため発生します。 このスクリプトは数式部分内に存在するmarkdown上の特殊記号をエスケープすることで、特殊記号が数式部分の開始/終了記号( $, $$ )よりも優先されて処理されることを防いでいます。

おわりに

このスクリプトで数式のあるブログ執筆のハードルが少しでも下がったら幸いです👍