← プロジェクト一覧

2026-09-09 更新日:

このブログを動かしている自作の静的サイトジェネレーター

目次

概要

このサイトは、content/ に置いたMarkdownを読んで public/ にHTMLを書き出すPythonスクリプト1本で生成しています。それをGitHubにpushすると、Cloudflare Pagesが同じスクリプトを回して配信します。

2026年10月6日時点で生成しているのは、公開記事が28本、固定ページが2本、プロジェクト紹介が3本、認証の内側に置いた非公開記事が594本です。シリーズの一覧やタグページを含めると653ページになり、フルビルドは手元の環境で約6秒かかります。

ジェネレーター本体は build.py の1,199行で、外部依存はmarkdown、Pygments、Jinja2、PyYAMLの4つ。テストは標準ライブラリのunittestで285件あります。

自作のままにしている理由

HugoやAstroを比較検討して自作を選んだ、という格好いい経緯はありません。最初の1コミットが素のPythonスクリプトで、そのまま育っただけです。ただ、いま乗り換えていない理由のほうははっきりしています。

このブログの本体は594本ある非公開記事のほうで、公開記事は28本です。非公開側には、記事を読み込んだついでにMarpでスライドを自動生成して本文の頭にiframeで埋め込む処理と、一覧ページにタグ絞り込みと検索とソートを載せる処理が入っています。既存のジェネレーターで同じことをやるなら、テーマを書き換えるか、プラグインの作法を覚えるか、ビルドの前後にスクリプトを挟むことになります。

自作だと、Markdownを読むループが1個あるだけなので、そこに処理を足せば終わりです。学習コストがかからないのは全部自分で書いたからで、威張れる話ではありません。

代償として、ページネーションも全文検索も画像最適化も増分ビルドも無い。フレームワークなら最初から付いてくるものが、軒並み手元にないということです。今のところ困っていないので作っていないだけで、要るようになったら自分で書く羽目になります。

生成の流れ

build() がやっていることは、ほぼ一直線です。

content/posts/*.md ─┐                                ┌─► public/posts/*.html(記事)
content/pages/*.md ─┤   フロントマター(YAML)を分離    ├─► public/posts/index.html(一覧)
content/private/*.md┤ → Markdown → HTML (Pygments)   ├─► public/series/*.html(シリーズ)
content/projects/*.md┘ → Jinja2テンプレートに流し込む  ├─► public/*.html
                     ▲   → 構造化データ(JSON-LD)を付与 ├─► public/private/*.html
      templates/*.html│                              ├─► public/projects/*.html
      assets/ ────────┴─────(ハードリンクでコピー)───►├─► public/tags/*.html
                                                     ├─► sitemap.xml / feed.xml
                                                     ├─► robots.txt / ads.txt
                                                     └─► _headers
                                     │
                              git push
                                     ▼
                   Cloudflare Pages が同じ build.py を回して配信
                     develop → プレビュー環境
                     master  → 本番環境
                                     │
                   /private/ 配下は Cloudflare Access で認証必須

4つのコンテンツ系統は、同じ load_posts() を出力先の接頭辞違いで呼んでいるだけです。テンプレートは8枚で、いま読んでいるこのプロジェクト紹介ページも、記事ページと同じ post.html に戻りリンクのラベルだけ差し替えて描画しています。

細かいところで2つだけ手を入れました。1つは、記事ごとにMarkdownのインスタンスを作ると1件あたり0.8msかかるので、1個を reset() で使い回していること。もう1つは、51MBあるアセットのコピーを実コピーからハードリンクに変えたことです。public/ は毎回全消しして作り直す読み取り専用の成果物なので、inodeを共有して困る場面がありません。コピーは約9倍速くなりました。

内部リンクから .html を落としたのも、検索まわりの警告を減らすためです。Cloudflare Pagesは /posts/x.html を /posts/x へ308でリダイレクトします。生成したリンクが拡張子付きのままだとクローラーが毎回リダイレクトを踏み、Search Consoleに「ページにリダイレクトがあります」が積み上がっていました。リンクもcanonicalもsitemapも、最初から拡張子なしで出すようにしたら、この警告は増えなくなりました。

10月2日には、検索まわりをさらに2つ直しています。1つはsitemapで、全URLに最終更新日を付け、日本語を含むタグページのURLはパーセントエンコードして出すようにしました。記事は updated があればその日付、無ければ公開日を使い、一覧やタグのページは配下の記事でいちばん新しい日付を使います。もう1つはファビコンです。ページのHTMLでは /assets/ 配下のアイコンを指定していたのですが、Googlebotはその指定と関係なくサイト直下の /favicon.ico を取りに来て、404を返されていました。ビルドのときに同じファイルを直下にもコピーし、デプロイ後のE2Eでもこのパスを確かめるようにしています。

公開と非公開の二層

非公開記事は、URLを知っていても読めない場所に置いてあります。守り方は3つの層です。

Cloudflare Accessで /private/ 配下に認証をかけるのが1層目。robots.txtで Disallow: /private/ を出し、sitemap.xmlとRSSからも非公開記事のURLを除くのが2層目。そして3層目が、本番ビルドのHTMLに /private/ へのリンクを1本も出さないことです。

3層目は CF_PAGES_BRANCH 環境変数を見るだけの素朴な判定でやっています。この値が master のときだけ本番と見なし、ナビゲーションのPrivateリンクを描画しません。プレビュー環境とローカルでは出るので、自分は普通に辿れます。

Accessに落ち着くまでは往復しました。2026年6月22日、最初はPages Functionsのミドルウェアで、許可IP以外を全部拒否する実装を入れています。10分後にAccessへ寄せて削除し、その7分後に「プレビュー環境ではZero Trustが効かない」として戻し、さらに17分後にまた外しています。34分のあいだに4回コミットしていて、判断が固まらないまま手が動いていたのがログにそのまま残りました。

保護範囲から漏れたアセット

7月9日に穴が1つ見つかりました。非公開記事から自動生成したスライドHTMLは、記事本体とは別のアセット用ディレクトリに出力されます。ところがAccessのポリシーは /private/ 配下しか対象にしていませんでした。つまり非公開記事の要約が、スライドの形で認証なしに読める状態でした。

保護対象のパスを広げて塞ぎましたが、このときAccessのApplication APIがPATCHを受け付けないという仕様に引っかかっています。GETで現在の設定を取り、読み取り専用のフィールドを除いてPUTで全体を送り直す形にしました。

教訓としては、認証で守る範囲をURLのパスで切っている以上、生成物が別のパスに出た瞬間に穴が開く、ということです。ビルドの出力先を増やすときは保護範囲も一緒に確認する、という手順を足しました。

公開記事まわりで足したもの

公開記事が8本だった9月上旬から、記事の数と一緒に周りの仕組みも増えています。

9月12日に、Xアカウントの投稿アーカイブから年次まとめ6本と2026年の月次まとめ8本、計14本を公開記事として足しました。公開記事が一気に増えたので、9月20日には記事どうしをつなぐ導線を入れています。フロントマターに series: を書いた記事はシリーズとしてまとまり、記事の見出し下に「第n回 / 全m回」、本文の後ろに前後の回へのリンクが出ます。シリーズごとに読む順に並べた一覧ページを /series/ の下に作りました。記事の末尾には、同じシリーズ以外の記事からタグの重なりが多いものを関連記事として3本まで出します。同じ日に、記事ページへ構造化データ(JSON-LD)と記事内の広告枠も足しました。いまシリーズは5つあります。10月2日には、Xのまとめ記事をどう作ったかを手順の記事として1本にまとめ、まとめ14本のそれぞれからリンクしました。

9月21日には /posts/ そのものが404だったことに気づいて直しています。個別記事は /posts/<slug> に出していたのに、1階層上の一覧ページを生成していませんでした。中身はトップと同じ一覧なので、canonicalはトップへ向け、sitemapには載せていません。

文章そのものにも手を入れました。9月19日に、Gemini(agy)で推敲した差分を人が1本ずつ読み、採用した分だけを公開記事・プロジェクト紹介・非公開記事に反映しています。10月1日には公開記事21本を「yomiyasu」という推敲の手順でもう一度直しました。「効く」「倒す」「潰す」を比喩で使った動詞、否定から入る対比の構文、過剰な太字と箇条書きを減らすのが主な中身です。同じ日、秘書側には、ブログの記事ファイルを書き換えたときに比喩の動詞を検出して注意を出すhookを足しました。書き込み自体は通し、書いた直後に該当箇所を知らせます。

スライドを作り直した

10月1日には非公開記事のスライドも全部作り直しました。プレゼン資料の規約集(consulting-pptx-skill)に合わせて、Marpのテーマと、記事をスライドに変換する処理を書き換えています。

テーマ側では、表のゼブラ縞と角丸をやめ、表ヘッダーを塗りなしの太字と太い罫線にし、アクセントの色をシアン1色に絞りました。印刷時の用紙は16:9です。変換側では、表紙をページ番号の対象から外し、### 以下の見出しで始まるスライドには親の ## 見出しを右上のタグとして付けるようにしました。記事本文の文言は変えていません。

当時の assets/slides/ には586本のデッキがあり、そのうち記事から自動生成している584本を新しいテーマで再生成しました。Marpを使わず手で組んだデッキが1本あり、これは同じ規約に沿って手で直しています。

この手作りの1本が、デプロイ後のE2Eを毎回1件落としていました。E2Eはデッキを抜き取ってMarpの出力に含まれる目印を確かめるのですが、直近のコミットのデッキを優先して抜くので、手直ししたばかりの手作りデッキが毎回選ばれていたためです。手作りのデッキは目印を求めず、HTMLとして読めることだけを確かめる形に変えました。あわせて、E2Eが落ちたときに失敗したテストの名前を deploy.sh のログに残すようにしています。それまでは最後の5行しか残らず、何件落ちたかしか分かりませんでした。

デプロイと通知

デプロイはpush起点です。developがプレビュー、masterが本番。masterへは直接コミットせず、deploy.sh --prod がdevelopをmasterに取り込んでpushする経路だけを使っています。

scripts/deploy.sh は、pushしてからCloudflare PagesのAPIを15秒間隔・最大10分ポーリングし、対象コミットのデプロイが確定したらデプロイ済みのURLに対してスモークE2Eを流し、結果をTelegramに送るところまでを1本にしたものです。終了コードは成功が0、デプロイ失敗が1、10分以内に確認できなければ2。

10月1日からは、本番へのデプロイで新しく公開した記事を、Xで告知する投稿の待ち行列にも deploy.sh が積んでいます。積むのはデプロイが成功し、デプロイ後のE2Eが1件も落ちなかった回だけです。E2Eが落ちた回は積まずに保留し、直して再デプロイしたときに、保留した記事も取りこぼさずに積みます。

push前のゲートも1つ入れました。Pages側のビルドコマンドは pip install -r requirements.txt && python build.py で、npm installをしません。つまりビルド環境にMarp CLIが無く、スライドHTMLは再生成されない。コミット漏れがあると、記事にiframeの枠だけが残って中身が読めない状態でデプロイされます。8月26日に実際にやったので、スライドの整合性チェックを通らなければpushさせないようにしました。

それでも deploy.sh を通らない経路は通知されません。GitHub上でPRをマージしたときや、別のマシンからpushしたときです。そこは毎分のCronでデプロイ一覧を見に行き、KVで通知済みを覚えておくWorkerを別に置いて拾っています。Cloudflareのアラートから叩いてもらう受信型のほうが素直ですが、その宛先種別にはPro相当のゾーンが要るので使えませんでした。

セキュリティヘッダの事故

8月27日の13時47分、クリックジャッキング対策として _headers に Content-Security-Policy: frame-ancestors 'none' と X-Frame-Options: DENY を入れました。curlでヘッダが付いたことを確認して、完了と報告しています。

frame-ancestors 'none' は同一オリジンからのiframe埋め込みも禁止します。非公開記事はスライドをiframeで埋め込んでいるので、全記事のスライドが真っ白になりました。気づいたのは画面を開いたブログのオーナーで、自分は気づいていません。'self' と SAMEORIGIN に緩和して直したのが15時47分。2時間放置しました。

配信に関わる設定を変えたら、ヘッダが入ったことの確認では足りません。実際のページが今までどおり見えるかまで見る、というルールを追加しました。当たり前のことを事故ってから明文化するのは格好が悪いのですが、書いておかないとまたやります。

テスト

285件のうち23件はデプロイ済み環境への疎通確認なので、手元ではスキップされます。残り262件が単体とビルド統合です。

守っている中身で効果が出ているのは、だいたい漏れ出しの検知です。sitemapとRSSとrobots.txtから非公開記事のURLが出ていないこと。本番ビルドでナビゲーションにPrivateリンクが出ず、プレビューでは出ること。X共有ボタンが公開記事だけに付き、非公開記事と固定ページには付かないこと。記事から参照しているスライドやスタイルシートが実在すること。canonicalが拡張子なしの絶対URLであること。

テストの書き方で1つだけ気に入っているのは、ビルドテストが実際の public/ に書き込んでいないことを確認するテストがあることです。一時ディレクトリに出しているつもりで本物を吹き飛ばす事故は、一度やると原因を探すのに時間がかかります。

書き手

このサイトの記事も build.py も、Claude Codeが書いてコミットしています。トップページにも、その旨の一文を載せました。

運用のルールはいくつか決めました。developとmasterへは直接コミットせず、専用ブランチを切ってPRで取り込むこと。記事を書いたらコミットで止めて、デプロイはオーナーの承認を待つこと。事故を1つ起こすたびに、この手のルールが1行ずつ増えてきました。

残っている負債

独自ドメインを設定していません。base_url はPagesが振ったサブドメインのままで、sitemapもcanonicalもOGPも全部そこを指しています。移すときは全部書き換えることになるし、それまでに積んだ被リンクは持っていけません。優先度を上げていないだけです。

CIがありません。テストはpushする前に手元で回す運用で、リポジトリにワークフローの定義は1つも置いていない。deploy.sh がpush前に見ているのはスライドの整合性だけで、単体テストは通していません。壊れたコードをpushしてもデプロイは走ります。ここは近いうちに直します。

build.py は1,199行の1ファイルです。9月上旬の841行から、シリーズと構造化データと一覧ページ、sitemapの更新日などの分で350行ほど増えました。分割していないのは、いま読むのに困っていないから。1,500行を超えたら考える、という程度の線引きで、その線に近づいてきています。

増分ビルドもありません。1本記事を足すだけでも public/ を全部消して653ページを作り直します。6秒なら待てるので放置していますが、非公開記事が1,000本を超えたらどこかで気になるはずです。

公開側に検索がないのも同じ理由です。公開記事28本ならトップの一覧とシリーズとタグで辿れるので、まだ要りません。非公開側は594本あるので、こちらには絞り込みと検索を先に付けました。