プログラミング

ObsidianやAIのMarkdownをそのままNotionに流し込む自作ツールを作りました(Markdown to Notion)

ローカルの .md ファイルを構造を保ったままNotionのネイティブブロックとして一括取り込みするPython製ツールの紹介です。Notionに貼ると崩れる理由、行ベースのパーサ・装飾の重なり解決・2000字と100件の上限処理という実装の中身を抜粋コードで説明し、モジュール構成、テスト162件の内訳、Notion側の設定手順、使い方の3シナリオ、よくあるトラブルまでまとめました。

目次

はじめに

ChatGPTやClaudeに書かせたきれいなMarkdownを、Notionにそのまま貼り付けたら、見出しのレベルがズレたりコードブロックが普通の段落になったりして崩れた、という経験はありませんか。
私はこれが地味にストレスでした。

Notionの貼り付けは、短いメモなら問題なく整形してくれます。
ところが、生成AIが出すようなコードブロック・表・数式・ネストしたリストが混ざった長文になると、とたんに崩れます。
フェンス(コードを囲う ```)の言語指定が消えたり、表がただのテキストになったり。
1ページずつ手で貼り直していると、途中で心が折れます。

そこで作ったのが Markdown to Notion という Python 製のツールです。
ローカルの .md ファイルを、構造を保ったまま Notion の「ネイティブブロック」(Notionが内部で持っている見出し・コード・表といった部品)として一括取り込みします。
Obsidian の vault 移行用に作り始めたのですが、結果的に生成AIの出力をNotionにストックする道具として一番よく使うようになりました。

この記事は「こういうツールを作りました、機能はこれです」で終わらせず、なぜ普通に貼ると崩れるのかと、それをコードでどう解決したのかを、実装から抜粋しながら書きます。
コードの部分は雰囲気だけ眺めて読み飛ばしても話が繋がるように、抜粋の直後には必ず日本語で種明かしを置きます。
「使えればいい」という方は、コードブロックを飛ばして地の文だけ追ってもらって大丈夫です。
逆に技術に興味のある方は、読み終わる頃には同じものを自分で組める程度の解像度になるかと思います。

コード一式(ZIP・MITライセンス)を使ってみたい・見てみたいという方は、コメントでお知らせください。

なぜ「普通に貼ると崩れる」のか

最初に、この記事の軸になる前提を1つだけ説明させてください。
NotionはMarkdownのテキストを内部に持っていません。
Notionのページは「ブロック」という部品の集まりで、1つ1つが「これは見出し1」「これはPython言語のコードブロック」といった型を持つ構造化データです。

つまりMarkdownを貼り付けるとき、Notionは裏で「この行は見出しっぽい」「ここはコードっぽい」と推測しながらブロックに変換しています。
短い文章なら推測が当たりますが、長文でコード・表・数式が入り混じると推測を外し、崩れた状態で確定してしまう。
これが崩れの正体です。

だったら、推測に任せずこちらが明示的に「これは言語Pythonのコードブロック」とNotionに伝えればいい。
Notionには、ページにブロックを直接追加するための API(外部のプログラムから操作するための窓口)があります。
Markdownを自分で解析して、Notionが定義しているブロックの形(JSON)に組み立て、APIで送り込む。
このツールがやっているのは、ひとことで言えばそれだけです。
「推測させず、こちらから正解を渡す」——これが崩れない理由の全部です。

このツールで何ができるか

要点を先にまとめると、こんな感じです。

Markdown to Notion でできることの一覧表

特に効いているのは次の3点です。

  • 生成AIの「クセのあるMarkdown」をそのまま受け入れる。 フェンス言語が c++c# だったり、UTF-8 BOM(後述)が付いていたりという揺れを内部で吸収します。
  • コード・表・数式・ネストしたリストも崩さない。 Notionの貼り付けが一番苦手にする要素ほど、このツールの効果が出ます。
  • ドライランで事故らない。 本番実行の前に「何ページ・何ブロックが作られるか」をAPIを呼ばずに確認できます。Notionは作りすぎたページの一括削除が地味に面倒なので、初回は必ずドライランを通す運用にしています。

対応している記法の一覧

「どこまで崩さず変換できるのか」が一番気になるところかと思います。
実装で対応しているブロックは次のとおりです(左がMarkdown、右がNotion側のブロック種別の名前)。

対応しているMarkdown記法とNotionブロック種別の対応表

文字単位の装飾(リッチテキスト)は、太字・斜体・取消線・インラインコード・ハイライト(Obsidianの ==強調==)・リンクに対応します。
Obsidian独自の記法も拾っていて、タスクリストやコールアウト > [!info]%%コメント%%(Obsidian上で非表示になるコメント。出力からは除去します)まで面倒を見ます。

見出しが「h3まで」なのはNotion側の制約です。
Notionのネイティブ見出しは heading_3 までしか無いので、#### 以降はすべて heading_3 に丸めています。

中身を読む:Markdown → ブロック → API の3段

ここから実装に入ります。
データの流れは3段で、それぞれ別のファイルが担当しています。

  • markdown_parser.pyMarkdownParser.parse() … Markdownの文字列を、Block という中間表現(このツール独自の、型と中身を持ったデータ)のリストに変換する
  • notion_converter.pyNotionConverter.convert_blocks()Block を Notion API が要求するJSONの形(dict)に変換する
  • notion_client.pyNotionClient.append_blocks() … 出来上がったブロックを Notion API に送ってページに追加する

migrator.py がこの3段を束ね、ファイル走査とページ作成を回します。
Markdownの解釈・Notion形式への変換・通信を完全に分けてあるので、どこで何が起きたかを切り分けやすいのが気に入っている点です。
以下、肝になる部品を順に開けていきます。

段①:行ベースのパーサで「型」を確定する

パーサは、文書全体を一度に解析する本格的な構文解析(AST=抽象構文木を作るような方式)ではなく、1行ずつ上から見て型を判定する「行ベース」で書いています。
精度の上限と引き換えに、実装が誰でも追える分量に収まります。

判定の中で一番効いているのが、コードブロックのフェンスを拾う正規表現です(正規表現=文字列のパターンを記述するための小さな言語)。

    # Allow language identifiers like `c++`, `c#`, `objective-c`, not just \w.
    CODE_BLOCK_START = re.compile(r'^```([^\s`]*)$')

日本語にすると「行の先頭がバッククォート3つで始まり、そのあとに空白とバッククォート以外の文字が0個以上続いて、行末で終わる」です。
地味ですが、ここを \w(英数字とアンダースコアだけ)にしてしまうと、生成AIが出しがちな c++ c# objective-c のような言語名でフェンス開始だと認識できず、コードブロックが丸ごと崩れます。
[^\s`]*(空白とバッククォート以外なら何でも)にしてあるのは、その取りこぼしを防ぐためです。
AI出力を相手にすると、こういう小さな許容範囲の差がそのまま崩れにくさに直結します。

フェンス開始を見つけたあとは、終了フェンスまでの行をそのまま中身として溜め、言語名と一緒に1つの Block にします。

code_match = self.CODE_BLOCK_START.match(line)
if code_match:
    language = code_match.group(1)
    code_lines = []
    i += 1
    while i < len(lines) and not self.CODE_BLOCK_END.match(lines[i]):
        code_lines.append(lines[i])
        i += 1
    blocks.append(Block(
        block_type=BlockType.CODE,
        content='\n'.join(code_lines),
        language=language or "plain text"
    ))
    i += 1  # Skip closing ```
    continue

フェンスの中に入ったら、終了フェンスが来るまで見出しやリストの判定を一切しません。
だからフェンス内の # で始まるコメントが見出しに誤認されず、- で始まる行もリスト扱いされません。
この「フェンス内かどうか」の状態管理が、崩れない実装の一番の基盤になっています。

段②:太字・コードの「重なり」を位置で解決する

段落や見出しの中の文字装飾(太字・インラインコード・数式・リンク)は _parse_inline() が処理します。
厄介なのは、複数の装飾パターンが同じ箇所でマッチすることです。
たとえば `**not bold**` はインラインコードなのに、内側の ** が太字としても検出されると、二重変換で崩れてしまいます。

# Sort by start position
patterns.sort(key=lambda x: x[0])

# Remove overlapping patterns (keep first one)
filtered_patterns = []
last_end = 0
for start, end, ptype, content in patterns:
    if start >= last_end:
        filtered_patterns.append((start, end, ptype, content))
        last_end = end

やっていることは、見つかった全装飾を開始位置の早い順に並べ、直前に採用した装飾の終了位置(last_end)より後ろから始まるものだけを残す、です。
重なっているものは、先に始まった方が勝って、後発は捨てられます。
検出の段階でインライン数式を最優先に拾い、次にインラインコード、太字…という順で集めてあるので、`**not bold**` ならインラインコードが先に確定し、内側の太字は重なりとして捨てられる、という具合です。
比較ロジックを書かずに「位置の早い者勝ち」というルール1つで重なりを解いているのが、自分では気に入っているところです。

段③:Notionの形に変換する(種類ごとに専用関数)

Block が揃ったら、NotionConverter がNotionのJSONに翻訳します。
convert_block()match 文で型を見て、_heading() _code() _table() _equation() のような専用関数に振り分けるだけの素直な作りです。
Notionが要求する形はブロックごとに微妙に違うので、ここは「型ごとに正しい形を1つずつ知っている」関数の集合にしてあります。

たとえばコードブロックは、こう組み立てます。

def _code(self, block: Block) -> dict[str, Any]:
    language = block.language.lower()
    notion_lang = lang_map.get(language, "plain text")

    # Split content into 2000-char chunks
    chunks = self._split_text(block.content, 2000)
    rich_text = []
    for chunk in chunks:
        rich_text.append({"type": "text", "text": {"content": chunk}})

    return {
        "object": "block",
        "type": "code",
        "code": {"rich_text": rich_text, "language": notion_lang},
    }

ここに、Notion APIの現実的な制約への対処が2つ詰まっています。

1つ目は言語名の正規化です。
Notionのコードブロックは「Notionが知っている言語名」しか受け付けません。
そこで lang_map という対応表で pypythonjsjavascriptc++c++csharpc# のように寄せ、知らない言語は安全に plain text(プレーンテキスト)へ倒します。
未知の言語でエラーにせず素通しさせる、という割り切りです。

2つ目が2000文字での分割です。
Notion APIは、1つのテキスト要素(リッチテキスト)に入れられる文字数が2000字までという上限を持っています。
生成AIが吐く長いコードや段落はこれを平気で超えるので、_split_text() で2000字ごとに切り、複数の要素に分けて1つのブロックに入れています。
分割の本体はこの1行です。

def _split_text(self, text: str, length: int) -> list[str]:
    """Split text into chunks of specified length."""
    return [text[i:i + length] for i in range(0, len(text), length)]

この分割は、コードブロックだけでなく、太字などの装飾が付いた長いテキストにも効きます。
装飾付きテキストを分割するときは、分割した各かけらに同じ装飾(太字・コード等)を付け直すようにしてあるので、2000字をまたいでも太字が途中で切れません。

数式ブロックも同じく、Notionの上限(数式の式は2000字まで)に合わせて超過分を切り詰めています。
実際、ユニットテストでは「2400字ほどの長い数式を渡すと2000字以下に丸められること」を1ケースとして固定してあります。

段④:100件ずつに分けてAPIへ送る

最後の送信は NotionClient.append_blocks() です。
ここにもう1つ、Notion APIの制約への対処が入っています。

def append_blocks(self, page_id, blocks):
    results = []
    batch_size = 100
    for i in range(0, len(blocks), batch_size):
        batch = blocks[i:i + batch_size]
        response = self.client.blocks.children.append(
            block_id=page_id, children=batch,
        )
        results.extend(response.get("results", []))
    return results

Notion APIは、1回のリクエストで追加できるブロックが100件までです。
長いノートはあっという間に100ブロックを超えるので、100件ずつのバッチに刻んで、複数回に分けて送っています。
これをやらないと、ちょっと長いノートで body failed validation というエラーに当たります。
地味ですが、長文を相手にする以上は必須の処理です。

ここまでをひとことでまとめると、このツールの肝は「行ベースで型を確定 → 重なりを位置で解決 → Notionの形に翻訳しながら2000字・100件の上限をさばく」の4段セットです。

画像とローカルパスの扱い

画像は割り切った設計にしています。
Notion APIの画像ブロックは外部URL(https://...)しか受け付けません。
ローカルの相対パス(![](images/foo.png) のような参照)はAPIからアップロードできないので、無言で消すのではなく、「ローカル画像が含まれていた」と分かるコールアウトに置き換えます。

def _image(self, block: Block) -> dict[str, Any] | None:
    if block.is_local_image:
        return {
            "object": "block",
            "type": "callout",
            "callout": {
                "rich_text": [{
                    "type": "text",
                    "text": {"content": (
                        f"[Local image: {block.image_url}] "
                        "Replace with an external URL, or upload manually in Notion."
                    )},
                }],
                "icon": {"emoji": "🖼️"},
            },
        }
    # 外部URLはそのまま image ブロックへ
    return {
        "object": "block", "type": "image",
        "image": {"type": "external", "external": {"url": block.image_url}},
    }

「どの画像が抜けたか」が元のパス付きでページに残るので、あとから手で直せます。
黙って欠落させるより、異変に気づける形で残す方を選んだ、という判断です。
同じ思想で、リンクも http(s) で始まるものだけをNotionのリンクとして付け、相対パスのリンク(note.md への [[リンク]] など)は表示テキストだけ残してリンクは落とします。
Notion API がローカル参照を受け付けない以上、無効なリンクを作っても仕方がないためです。

どう作ったか(技術構成)

中身を自分で組んでみたい方向けに、実際のモジュール構成を載せておきます。

markdown-to-notion/
├── main.py                     # エントリポイント(python main.py ...)
└── obsidian_to_notion/         # メインパッケージ
    ├── cli.py                  #   引数処理(--source / --files / --dry-run など)
    ├── gui.py                  #   tkinter ベースの簡易 GUI(約300行)
    ├── config.py               #   .env / 環境変数の読込
    ├── markdown_parser.py      #   ① Markdown → 中間表現(Block) にパース
    ├── notion_converter.py     #   ② 中間表現 → Notion API のブロック(JSON)に変換
    ├── notion_client.py        #   ③ Notion API ラッパー(100件ずつ送信)
    └── migrator.py             #   ①②③を束ね、フォルダ走査 + ページ作成

設計の柱は3つです。

  • 言語はPython(3.10以上)、依存はほぼ2つだけ。 使っているのはNotion公式のPythonクライアントと、設定読み込み用の小さなライブラリ(python-dotenv)のみ。GUIはPython標準の tkinter なので追加インストールは要りません。配布相手に余計な pip install をさせない狙いです。
  • 解析・変換・通信を3ファイルに割る。 前章で見たとおり、Markdownの解釈(markdown_parser)、Notion形式への変換(notion_converter)、APIとの通信(notion_client)を分けてあります。CLIとGUIはどちらも Migrator を呼ぶだけで、変換ロジックを二重に持ちません。
  • フォルダ階層はサブページとして再現する。 Migrator.migrate_directory() がフォルダを再帰的に走査し、サブフォルダごとに「 フォルダ名」というページを作って、その配下に各ノートのページをぶら下げます。Obsidianの階層がそのままNotionのサブページ構造になります。

GUIは、処理を別スレッドで動かして標準出力をキューでログ欄に流し込む作りにしてあるので、変換中もウインドウが固まらず進捗を眺められます。

Markdown to Notion のGUIメイン画面

GUIメイン画面。移行モード・ソース・親ページ ID・APIキー・ドライランをまとめて指定できます。

動作の確認(テストで仕様を固定している)

「本当に動くのか」を担保するために、ユニットテストと統合テストを合計162件用意してあります(pytest で実行)。
内訳は、パーサ系が59件(基本14+エッジケース45)、コンバータ系が30件、Obsidian独自記法が25件、フォルダ走査・複数ファイル移行が16件、CLIのサブプロセス越しE2Eが18件、といった配分です。

ここで大事にしているのは件数より観点です。
これまで説明してきた「割り切り」を、テストで仕様として固定してあります。
たとえば、

  • c++ c# のフェンス言語名が正しく言語ブロックになること
  • 5500字のコードが2000字以下のかけらに分割され、合計が元の長さと一致すること
  • 2400字ほどの数式が2000字以下に丸められること
  • ローカル画像が、元のパスを含むコールアウトに置き換わること
  • 相対パスのリンクは落ち、http(s) のリンクだけが残ること

といった、本文で「こう割り切った」と書いた挙動が、そのままテストケースになっています。
半年後の自分が「これはバグでは?」と迷ったときの申し送りにもなります。

導入と設定(Notion側の準備)

ツール自体はPythonが入っていれば動かせる状態にできます。
ここでは、使う前に必要なNotion側の準備を説明します(所要5分ほど)。
設定値は .env という設定ファイルに2つ書くだけです。

  • インテグレーションを作ってAPIキーを取得。 Notionの「インテグレーション」管理画面で新しいインテグレーションを作ると、書き込み用のシークレット(ntn_ で始まる文字列)が発行されます。これを .envNOTION_API_KEY に入れます。
  • 取り込み先ページを作って接続。 Notionで取り込み先のページを1つ作り、そのページのメニューから作ったインテグレーションを「接続」します。これでそのページと子ページに読み書きできます。
  • ページIDを控える。 取り込み先ページのURL末尾の32文字の英数字(ハイフン無し)がページIDです。これを .envNOTION_PARENT_PAGE_ID に入れます。ハイフンを含めると「ページが見つからない」と弾かれます。ここは私もよくやらかすところです。

.env がまだプレースホルダー(雛形のままの値)だと、ツールは「編集してください」と止めてくれます。
config.py 側で、雛形の値はわざと「未設定」と同じ扱いにしているためです。
設定し忘れたまま本番実行して空振りする事故を防ぐためのちょっとした配慮です。

APIキーはワークスペースへの書き込み権限を持つので、第三者と共有しないよう気をつけてください。
万一漏れても、管理画面からシークレットを再生成すれば古いものは無効になります。

使い方チュートリアル

私が実際に回しているシナリオを3つ。
GUIならどれも「モードを選ぶ → ソースを指定する → 実行する」だけです。

シナリオA:AIに書かせた長文レポートを1本取り込む

  • ChatGPT/Claudeに「このトピックでレポートをMarkdownで書いて」と頼む
  • 出力を .md として保存する(例:report_2026.md
  • GUIで「単一ファイル」モードを選び、そのファイルを指定する
  • ドライランを入れたまま実行し、「report_2026 というページが、何ブロックで作られるか」をログで確認する
  • 問題なければドライランを外して本番実行する

単一ファイルモードのドライランは、ページ名・生成ブロック数・見つかったローカル画像の数まで教えてくれます。
画像が想定より多ければ、外部URLに直すか手動アップロードを覚悟する、と先に判断できます。

シナリオB:Obsidian の vault をフォルダごと取り込む

  • Notion側に取り込み先の親ページを用意し、インテグレーションを接続する
  • GUIで「フォルダ全体(階層維持)」モードを選び、vaultのフォルダを指定する
  • ドライランで「何ページ作られるか」を確認する
  • 件数が想定どおりなら、ドライランを外して本番実行する

vaultが大きいときは、サブフォルダ単位で小分けに流すのがおすすめです。
Notion APIにはレート制限があるので、一度に何百ファイルも流すより安全です。
実行後は、Obsidianと同じ階層でサブページが並びます。
なお、.obsidian/ などの設定フォルダ配下に .md を置いている場合は、フォルダ走査が再帰的に全 .md を拾うので、取り込みたくないフォルダはvaultの外に避けてから流すのが確実です。

シナリオC:対話ログ・複数メモをフラットに取り込む

  • 取り込みたい .md を集めておく
  • GUIで「複数ファイル(フラット)」モードを選び、複数選択する
  • ドライランで件数を確認 → 本番実行

このモードは階層を作らず、選んだファイルを親ページ直下にフラットに並べます。
「あとでNotion側で並べ直したい」ケースに向いています。

よくあるトラブルとQ&A

実際の挙動をもとに、ハマりそうな点をまとめます。

Q. 「object_not_found」「ページが見つからない」エラーが出る
A. 取り込み先ページにインテグレーションが接続されていないか、ページIDにハイフンが含まれています。
ページの「···」→「接続」でインテグレーションを追加し、IDは32文字の英数字のみで指定してください。

Q. 「Unauthorized / 401」が出る
A. APIキーが間違っているか、コピー時に空白が入っています。
管理画面から再度コピーし直してください。

Q. 「body failed validation」が出る
A. Markdownのどこかが Notion API の制限に引っかかっています。
1ブロック2000字・1リクエスト100ブロックの上限は内部で処理していますが、表の列数が多すぎる(Notionの表は最大100列)といった別の制限に当たることがあります。
問題のファイルを単一ファイルモードで1つずつ取り込み直すと、原因のファイルを特定しやすいです。

Q. 大きなフォルダの途中で止まる
A. Notion APIのレート制限です。
Rate limited が出たら少し時間をおいて再実行するか、フォルダを分割してください。
1ファイルが失敗しても、Migrator は残りのファイルを止めずに処理を続け、最後に失敗ファイルの一覧を出します。

Q. 見出し4以下が見出し3になる/ローカル画像が取り込まれない
A. どちらも仕様です。
Notionの見出しはh3まで、画像は外部URLのみ受け付けます。
階層を厳密に保ちたいときはMarkdown側をh3までに、画像はあとからNotion側で手動アップロードするか外部ホスティングして取り込んでください。

Q. 同じファイルを再取り込みしたらどうなる
A. 同名の別ページが作られます(更新ではありません)。
重複が増えるので、再取り込み時は元のページを先に消すのが安全です。

Q. Macで動く?
A. 動きます。
WindowsとmacOS / Linuxの両方を想定していて、gui.bat(Windows)と gui.sh(macOS / Linux)の起動スクリプトを同梱しています。

まとめ

いかがでしたか。
今回は、ローカルの Markdown を構造を保ったまま Notion にネイティブブロックとして一括取り込みできる自作ツール Markdown to Notion を、内部の作り込みまで開けて紹介しました。
要点を振り返ると、

  • 崩れの正体は「Notionの推測変換」。だからこちらが「これは言語Pythonのコードブロック」と明示してAPIで送り、推測させない
  • 肝は4段——行ベースで型を確定(フェンス内は判定を止める)→ 装飾の重なりを位置で解決 → Notionの形に翻訳しながら2000字・100件の上限をさばく → 100件ずつ送信
  • 割り切りはテスト162件で仕様として固定——言語名の正規化、長文の分割、ローカル画像のコールアウト化など、本文で説明した挙動をそのままテストにしてある
  • 解析・変換・通信を3ファイルに分離したので、CLIもGUIも同じロジックを呼び、どこで何が起きたか切り分けやすい

このツールを作ってから、生成AIに書かせた長文をNotionにストックするハードルがだいぶ下がりました。
「Markdownで書いて、ボタン1つでNotionに流す」が成立すると、考えてから貯まるまでの摩擦がほとんど無くなります。
この記事の抜粋と構成をChatGPTやClaudeに渡して、自分の用途に合わせた版を作ってもらうのも面白いかと思います。

似た悩み(生成AIの長文をNotionに貼ると崩れる、Obsidianの vault を持っていきたい)をお持ちで、コード一式(ZIP・MITライセンス)を使ってみたい・見てみたいという方は、コメントでお知らせください。
無料でお渡しします。

この記事が誰かの役に立てばうれしいです。