プログラミング

大量のMarkdownを1ファイルにまとめるWindows用GUIツールを作りました(Markdown Combiner)

複数のMarkdownファイルをAIに読ませやすい1ファイルに整形しながら結合する、Windows向けの自作GUIツール Markdown Combiner の紹介です。自然順ソート・YAMLフロントマター除去・見出しレベル変換・ファイル名のH1挿入という4つの処理を、実装コードの抜粋を交えて内部の動きまで説明します。ロジック約100行・tkinter画面約130行・ユニットテスト62件の構成です。

目次

はじめに

みなさんこんにちは。
今回は、自分用に作ったWindows向けの小さなGUIツール Markdown Combiner を紹介します。
複数のMarkdownファイルを、ClaudeやChatGPT、NotebookLMといったAIに「一気に読ませやすい1ファイル」へ整形しながら結合するツールです。

作ったきっかけは単純です。
Obsidianに溜めたノート50個をまとめてClaudeに読ませて、横断的な要約を作ろうとしたら、アップロードのファイル数上限・順番の崩れ・メタデータの混入に阻まれました。
1個ずつ整形してからコピペすればいいのは分かっているのですが、毎回それをやるのは面倒くさいですよね。
だったら「選んで・押すだけ」にしてしまおう、というのがこのツールです。

この記事は、よくある「こんなツールを作りました、機能はこれです」で終わらせずに、実装のコードを抜粋しながら、内部でどう動いているかまで書いてみます。
といっても、中身はPython+標準ライブラリだけのロジック約100行の小さなプロジェクトです。
コードの部分は雰囲気だけ眺めて読み飛ばしてもらっても話が繋がるように書きますので、「使えればいい」という方も安心して読み進めてください。
逆に技術に興味がある方は、読み終わる頃には自分で同じものを組める程度の解像度になるはずです。

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

まず完成形から

細かい説明より先に、入力と出力を見てもらうのが一番早いかと思います。

手元に章ごとのノートが3つあるとします。
chapter10.md には、Obsidianが付けたYAMLフロントマター(ノートの先頭に入る title: などのメタデータ)が残っています。

(chapter1.md)          (chapter2.md)          (chapter10.md)
# はじめに               # 基礎                   ---
本文…                    ## 用語                  title: 結論
                         本文…                    tags: [draft]
                                                  ---
                                                  # 結論
                                                  ## まとめ
                                                  本文…

これをMarkdown Combinerに通すと(ファイル名挿入ON・見出しを1段下げる設定)、出力はこうなります。

# chapter1

## はじめに
本文…

# chapter2

## 基礎
### 用語
本文…

# chapter10

## 結論
### まとめ
本文…

起きたことは4つです。

  • chapter10 が chapter2 の後ろに来ている(辞書順ではなく数字の値で並ぶ)
  • YAMLフロントマターが消えている
  • 各ファイルの先頭に # ファイル名 が入り、AIに「ここから別のファイル」と伝わる
  • 元の見出しが1段降格され、文書全体の階層が「ファイル名>章>節」で破綻していない

ひとことで言えば、「正しく並べて・ノイズを掃除して・名札を付けて・階層を揃える」の4点セットです。
この1ファイルをチャット画面にドロップすれば、「全部読んで、章をまたいだ要約を作って」が一発で通ります。
アップロード上限にも引っかかりませんし、「この情報はどのファイル由来?」という質問にも答えてもらえるようになります。

Markdown Combiner のメイン画面

メイン画面。入力ファイルの一覧、出力先、オプション2種、実行ボタンだけの構成です。

なぜ既存の方法ではダメだったのか

「Markdownの結合なんて cat *.md > all.md で終わりでは?」と思った方、半分正解です。
私も最初はそうしていました。
ただ、AIに読ませる前提だと、単純結合には4つの穴があります。

単純結合の4つの穴

1つ目の「辞書順」は、ファイル名を文字として頭から比べる並べ方のことです。
chapter10 と chapter2 を比べると、4文字目までは同じで、5文字目の 1 と 2 で勝負がつくので、10 の方が先に来てしまいます。
エクスプローラーの並びに慣れていると意外な挙動ですが、プログラムの世界の素の並び替えはこうなっています。

フロントマターも地味に効きます。
私のノートは1ファイルあたり平均4〜6行のメタデータが付いているので、50ファイルなら200〜300行のノイズです。
AIは本文と区別せずこれも読んでしまうので、コンテキストの圧迫になりますし、API課金で使っている人なら丸ごと無駄なトークンですよね。

この4つの穴を、毎回手作業で塞ぐのは現実的ではない。
それが自作の動機でした。

設計方針:先に「やらないこと」を決めた

実装の話に入る前に、最初に決めた割り切りを書いておきます。
小さなツールを作るときは、何を作るかより何を作らないかを決める方が大事だと毎回思います。
中身のコードが約100行で済んでいる理由は、ほぼこれです。

  • 外部ライブラリを使わない。 GUIは tkinter、パス操作は pathlib、文字列処理は re。すべてPythonに最初から入っている標準ライブラリです。配布した相手に pip install させなくて済むことを最優先しました(後述しますが、これが「ダブルクリックで動く」に直結します)
  • 完璧なMarkdownパーサーを書かない。 文書の構造を丸ごと解析するのではなく、1行ずつ見て判定する「行ベース」の変換に徹します。精度の上限と引き換えに、実装が誰でも追える分量になります
  • ロジックとGUIを完全に分ける。 結合処理は core.py に「同じ入力を渡せば必ず同じ出力が返る、画面とは無関係な関数」(いわゆる純粋関数)として置き、gui.py はそれを呼ぶだけの画面にします。テストと改造のしやすさのためです
  • 割り切った仕様は、テストで「仕様である」と固定する。 後述しますが、既知の制限すらテストケースにしてあります

ここから、core.py の中身を上から順に読んでいきます。
コードはすべて実装からの抜粋で、コピペして動かす用ではなく「こう動いている」を見てもらうためのものです。
各コードの直後に日本語で種明かしを書くので、コードを飛ばして読んでも大丈夫です。

実装を読む①:並び順は「数字のリスト」で決まる

最初の部品は、chapter2 を chapter10 より先に並べるための「自然順ソート」です。
自然順ソートというと比較ロジックをゴリゴリ書くイメージがあるかもしれませんが、実装はこれだけです。

_NUMBER_PATTERN = re.compile(r"\d+")

def natural_sort_key(file_path_str: str) -> List[int]:
    """ファイル名に含まれる数字を抽出し、自然順ソート用のキーを返す。"""
    stem = pathlib.Path(file_path_str).stem
    return [int(n) for n in _NUMBER_PATTERN.findall(stem)]

やっていることは、ファイル名(拡張子抜き)から数字の塊を全部拾って、整数のリストにするだけです。
chapter10.md なら [10]part1_chapter3.md なら [1, 3] になります。

並べ替えそのものは、このリストを sorted(files, key=natural_sort_key) に渡すだけで終わりです。
Pythonはリスト同士を渡されると、先頭の要素から順に比べて、勝負がついたところで打ち切ります。
国語辞典の引き方と同じ要領ですね。
つまり、比較のロジックは1行も書かず、Pythonのリスト比較に仕事を丸投げしています。
正直、この関数がこのツールで一番気に入っている部分です。

この方式の帰結を整理しておきます。

  • chapter2.md[2]chapter10.md[10]。リスト比較は数値の比較なので 2 が先になり、章の順で並ぶ
  • part1_chapter3.md[1, 3] のように、複数の数字も要素ごとに順に比較される
  • 001.md1.md はどちらも [1] になり、ゼロ埋めの有無の差は消える
  • 数字を含まないファイル(intro.md など)は空リストになり、数字付きのファイルより前に来る

実装を読む②:フロントマターは「境界」しか見ない

2つ目の部品は、YAMLフロントマターの除去です。
YAML全体を構文解析するのではなく、囲みの --- という境界だけを見る割り切りにしました。
おかげで、実装はこの短さに収まっています。

def _strip_frontmatter(lines: Sequence[str]) -> List[str]:
    """YAML フロントマターがあれば取り除いて返す。"""
    if not lines or lines[0].strip() != _FRONTMATTER_DELIMITER:
        return list(lines)

    for i in range(1, len(lines)):
        if lines[i].strip() == _FRONTMATTER_DELIMITER:
            return list(lines[i + 1 :])
    return []

_FRONTMATTER_DELIMITER"---" という定数です)

日本語にすると「1行目が --- だったら、次の --- を探して、そこまでを捨てる。それだけ」です。
読みどころは3つあります。

  • 発動条件は「1行目が ---」のみ。 本文の途中にある ---(水平線)は1行目ではないので、絶対に誤爆しません。条件を厳しく絞ったおかげで、安心して全ファイルに適用できます
  • 区切り行は strip()(前後の空白を落とす処理)をかけてから比較。 ---(末尾スペース)や ---(字下げ)のような微妙に汚れた区切りも受け付けます。エクスポートされたmdファイルは、この辺りが意外と汚れているものなので
  • 閉じる --- が見つからないまま最後まで行ったら、return [] で全行を捨てる。 一見乱暴ですが、「どこまでがメタデータか確定できないファイルを中途半端に残す」より「丸ごと空にして異変に気づいてもらう」方が安全、という判断です

もう1つ、関連して踏んだ落とし穴がBOMです。
BOMというのは、ファイルの先頭にこっそり付いていることがある目印用の数バイトのデータで、Windows界隈のUTF-8ファイルにはよく付いています。
これが残っていると、1行目が「目に見えないBOM+---」になって、見た目は同じなのに判定をすり抜けるんですね。
ファイルを読む側で encoding="utf-8-sig"(BOMがあれば自動で剥がしてくれる指定)にして吸収しました。
実際に一度すり抜けられてから直した箇所です。

効果のほうは冒頭に書いた通りで、ノート50個なら200〜300行分のノイズがここで消えます。
地味ですが、たぶん一番働いている部品です。

実装を読む③:見出しの判定は正規表現1本

3つ目の部品は、見出しレベルの一括変換(各ファイルの ### に降格する、など)です。
ここの本質は「どの行を見出しとみなすか」で、判定はこの正規表現1本に集約しています。

_HEADING_PATTERN = re.compile(r"^(#+)\s")

def _convert_heading(line: str, heading_map: HeadingMap) -> str:
    """1 行の見出しを heading_map に従って変換する。"""
    match = _HEADING_PATTERN.match(line)
    if not match:
        return line
    original = match.group(1)
    new = heading_map.get(original)
    if new is None or new == original:
        return line
    return line.replace(original, new, 1)

^(#+)\s を日本語にすると「行の先頭に # が1個以上並んでいて、そのすぐ後ろに空白(スペースかタブ)がある」です。
条件はたったこれだけですが、Markdownの標準仕様(CommonMark)の見出し定義に合わせてあって、#hashtag のような空白なしの行は見出し扱いされません。
Obsidianユーザーにはお馴染みのタグ行を巻き込まないための、実用上も大事な線引きです。

変換ルールの heading_map{"#": "##", "##": "###"} のような「変換前→変換後」の対応表(辞書)です。
ここで地味に大事なのが、変換は1行につき対応表を1回引くだけという点です。
もし「### に置換した結果に、続けて ##### のルールも適用」という作りにしてしまうと、降格が玉突きで連鎖して、H1が一番深いレベルまで滑り落ちます。
対応表を1回引いて終わり、という構造にしたことで、この事故はそもそも起こりようがありません。

対応表はGUIのプルダウン(元のH1〜H4それぞれの変換先を選ぶ)から組み立てますが、組み立て時に範囲チェックが入っていて、1〜6以外を指定すると ValueError(「見出しレベルは 1〜6 の範囲で指定してください」)で弾かれます。
Markdownの見出しはH6までなので。
なおプルダウンの初期値は全部「変換しない」にしてあります。
何も触らなければ見出しはそのまま素通りするので、まずは初期設定で試してもらって大丈夫です。

実装を読む④:組み立ては一本道のパイプライン

部品が3つ揃ったので、最後に全部を繋ぐ結合本体です。
中心のループはこれだけです。

sorted_files = sorted((str(p) for p in input_files), key=natural_sort_key)

parts: List[str] = []
for file_path_str in sorted_files:
    file_path = pathlib.Path(file_path_str)
    if include_title:
        parts.append(f"# {file_path.stem}\n\n")
    parts.append(process_file(file_path, heading_map))
    parts.append("\n\n")

流れは「自然順ソート → ファイル名をH1として挿入(オプション、既定ON)→ 各ファイルをフロントマター除去+見出し変換に通す → 空行を挟んで連結」の一本道です。
途中に出てくる process_file() は、②と③をその順に適用するだけの関数です。
分岐らしい分岐がないので、何かおかしな出力が出たときも、どの段で起きたかをすぐ切り分けられます。

出力まわりの仕様も書いておきます。

  • 全ファイルを文字列としてメモリに集め、最後に1回だけ書き出す。副産物として、出力先に入力ファイルの1つを指定しても壊れません(全部読み終わってから上書きされるため)
  • 出力先の親フォルダが無ければ自動で作る(mkdir(parents=True)
  • 出力はUTF-8・改行LFに統一。入力がCRLFでも揃います

GUIは130行の「薄い皮」

ロジックの話が終わったので、画面の話を少しだけ。
gui.py は約130行で、設計方針3(ロジックとGUIの分離)をそのまま形にしたものです。
画面まわりは凝りだすといくらでも時間が溶けるので、意識して「薄く」しました。
やっていることを全部書くと、

  • ファイル選択ダイアログ(.md フィルタ・複数選択可)で選んだパスをリストボックスに並べる
  • 出力先を「名前を付けて保存」ダイアログで決めてもらう(手入力のタイプミスを避けるため、欄は読み取り専用にしてダイアログ経由のみ)
  • チェックボックス(ファイル名H1挿入)とプルダウン4つ(H1〜H4の変換先)から heading_map を組み立てる
  • 「結合実行」で core.pycombine_files() を呼ぶ
  • 成功したら出力先パスを完了ダイアログで表示、例外が出たらエラーダイアログでそのまま見せる

これで全部です。
結合の知識はGUI側に1行もありません。
おかげでテストはGUIを起動せずに済みますし、将来CLI版が欲しくなったら combine_files() を呼ぶスクリプトを書くだけで済みます。

テストを「仕様書」にする

このツールにはユニットテストが62件あり(これも標準ライブラリの unittest です)、手元のWindows環境では0.2秒ほどで完走します。
数を自慢したいわけではなくて、お伝えしたいのは使い方の方です。
ここまで書いてきた「割り切り」を、テストで仕様として固定してあります。
たとえば、

  • BOM付きUTF-8でもフロントマター除去が効くこと
  • 未閉鎖フロントマターでファイル全体が空になること(事故ではなく仕様)
  • 数字なしファイル同士が選択順を保つこと(安定ソートの性質に依存しているので、崩れたら気づきたい)
  • #hashtag(空白なし)が見出し扱いされないこと
  • そして、コードブロック内の # コメント は見出しと誤認されること

最後のものは、要するに「既知のバグ的挙動」です。
それをあえてテストに書いて「現状こう動く。これは認識済みの割り切りである」と明文化してあります。
半年後の自分が「これバグでは?」と首をかしげたときの申し送りになりますし、将来コードフェンス対応を入れるなら、このテストの期待値を書き換えるところから始まる、という目印にもなります。
小さな自作ツールでも、この「仕様をテストに書いておく」やり方は本当におすすめです。

導入:「インストール不要」の正体

必要なものは Windows 10/11 と公式サイトのPython 3.9以上(tkinter 同梱のもの)だけです。
導入手順は「フォルダを展開して start.bat をダブルクリック」で終わりなのですが、なぜ pip install が要らないのか、せっかくなので種明かしをしておきます。

start.bat は起動する前に、同梱の src フォルダを PYTHONPATH という環境変数に追加しています。
PYTHONPATH は「Pythonが部品(モジュール)を探しに行く場所のリスト」のようなもので、ここに載せておけば、パッケージとしてインストールしていなくても python -m markdown_combiner で起動できるわけです。
さらに、コンソールの黒い窓を出さない pythonw を探して起動し、無ければ通常の python にフォールバックする、という小細工も入れています。

それでも動かないときのために、環境診断用の setup.bat を同梱しています。
実行すると「①Pythonが見つかるか → ②tkinter が使えるか → ③ツール本体が読み込めるか」を順にチェックして、[OK]/[NG] でどこで詰まったかを表示します。
経験上、つまずきの大半は①(PythonがPATHに無い)なので、Pythonを入れるときは「Add Python to PATH」のチェックを忘れないでください。
ここさえ乗り越えれば後は楽です。

macOS / Linux は、tkinter なので理屈の上では動くはずですが、未検証です。

使い方の例

私が実際に回しているパターンを3つ挙げます。
操作はどれも「選ぶ→出力先を決める→実行」だけです。

例1:Obsidianのノート50個をClaudeに読ませる。
vaultから対象のmdをまとめて選択し、ファイル名H1挿入をON・見出しを1段下げる設定で結合します。
出来た for_claude.md をチャットにドロップして、「横断的に要約して」「ノートAとBで矛盾している箇所は?」のような聞き方をします。
出典がH1で入っているので、「それはどのノートの記述?」まで追えるのが気に入っています。

例2:リポジトリの docs/ をコードベース説明書にする。
ドキュメント類を全部選んで1ファイルにし、「このプロジェクトの全体像を説明して」と渡します。
新しいプロジェクトのキャッチアップがかなり速くなります。

例3:章ノートから確認問題を作る。
chapter1.mdchapter12.md を選べば自然順ソートで章順が保たれるので、結合ファイルを渡して「各章の要点3つと確認問題を3問ずつ」と頼みます。
教科書1冊分の確認テストが一度に出来上がります。
資格勉強のお供にどうぞ。

ファイル選択ダイアログ

ファイル選択は複数選択可のダイアログです。フォルダごとドラッグではなくダイアログ経由なのは、tkinter 標準の部品だけで済ませたためです。

既知の制限とQ&A

行ベース変換の割り切りに由来する制限を含め、ハマりそうな点をまとめます。

Q. コードブロック内の # コメント が見出しに変換された

A. 既知の制限です。
コードフェンスの内外を判定する状態を持っていないため、フェンス内の行も同じルールで変換されます。
テストで「現状はこの挙動」と固定済みの割り切りなので、気になる場合は結合後に手で直してください。

Q. ファイルが丸ごと空になった

A. --- で始まるのに閉じる --- が無いファイルは、安全側に倒して全体を破棄します(実装を読む②参照)。
元ファイルのフロントマターを閉じてから再実行してください。

Q. フロントマターが除去されない

A. 判定条件は「1行目が ---」です。
先頭に空行などがあると発動しません。
なお本文途中の ---(水平線)は1行目ではないので、巻き込まれる心配はありません。

Q. 並び順が想定と違う

A. 並び順は「ファイル名から抜いた数字のリスト」の比較で決まります(実装を読む①参照)。
数字なしのファイルは数字付きより前に来ます。
確実に制御したければ連番リネームが手っ取り早いです。

Q. ダブルクリックで何も起きない

A. まず setup.bat で診断してください。
大半はPythonがPATHに無いケースで、公式インストーラーの再実行(「Add Python to PATH」にチェック)で直ります。
Microsoft Store版Pythonは tkinter が入っていないことがあるので、公式インストーラー版が確実です。

Q. Setext形式(=== 下線)の見出しは?

A. 対象外です。
見出しはATX形式(# 見出し)のみ、H5以降も変換ルールを指定するUIが無いため素通しです。

まとめ

いかがでしたか。
今回は自作ツールの紹介として、機能だけでなく実装の中身まで開けて見せる書き方をしてみました。
振り返ると、Markdown Combinerの正体は、

  • 数字を整数リスト化してPythonのリスト比較に乗せる自然順ソート
  • 囲いの --- だけを見て、未閉鎖なら安全側に捨てるフロントマター除去
  • ^(#+)\s で判定し、対応表を1行1回だけ引く見出しシフト
  • 以上を一本道で繋いで、ファイル名H1を差し込みながら書き出す結合処理

をロジック約100行に収め、約130行の tkinter 画面とテスト62件で包んだものです。
大げさな技術は何も使っていませんが、「AIに読ませる前処理」は毎日のように発生するので、作った中では費用対効果のかなり高いツールになりました。
この記事の抜粋と構成をChatGPTやClaudeに渡して、自分の用途に合わせた改造版を作ってもらうのも面白いと思います。

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

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