3言語ブログを411行のPython静的サイトジェネレーターで作る
Hugoではなく自作した理由と、hreflang、noindexの旧記事、広告枠、JSON-LD、安全な差分FTPデプロイをどう扱っているか。
このブログは自作の静的サイトジェネレーターで動いています。411行のPythonファイル1つと、Jinja2テンプレートとMarkdownファイルです。3言語167ページを約0.8秒でビルドします。この記事では、既存のジェネレーターを使わなかった理由、いちばん考えた部分(hreflang、旧記事、広告)、そして何かがサーバーに届く前に走るチェックについて書きます。
Hugoを使わなかった理由
MarkdownをHTMLに変換するのは簡単な部分で、どのジェネレーターでも上手にできます。私たちの難しい要件は、すべてこのサイト特有のものでした。
- アプリストアのURL。 24件のストア掲載が、古いプライバシーURLといくつかのサポートURLを指しています。これらのページはアプリのデータから生成し、古いアドレスのまま配信します。
- データから作るプライバシーページ。 各アプリはデータ項目のリスト(
ads、push、cameraなど)を持ち、そこからサポートページ、プライバシーページ、全アプリの一覧表を3言語で生成します。 - 旧記事。 2021年の講座記事67本は公開したまま、改訂が終わるまで
noindexにしています。サイトマップとhreflangからは外し、読めるようにはしておく必要があります。 - 共用のサーバー。 同じドキュメントルートで、アプリのAPIや広告設定のエンドポイントも動いています。デプロイはそれらに絶対に触れてはいけません。
どれもHugoで不可能ではありません。ただ、テーマの上書きやプラグイン探しの代わりに、それぞれ数行のPythonで済み、ジェネレーター全体を一度に読み通せます。
コンテンツの形
content/en/*.md content/ja/*.md content/ko/*.md content/ko/_legacy/*.md
---
title: "Dartのhttpクライアントは POST の301を追わない:実測と対処"
description: "…"
slug: dart-http-post-redirect-not-followed # 全言語で同じスラッグ
date: 2026-11-09
category: flutter # 表示名ではなくキー
translation_key: dart-http-post-redirect # 3言語版をつなぐ
faq: [{q: "…", a: "…"}]
---
あとで効いてくる選択が2つあります。カテゴリーはキー(flutter)で書き、言語ごとの表示名は site.yaml から取ります。そのため「同じカテゴリーか」を機械で確かめられます。そして言語版どうしをつなぐのは translation_key だけです。ファイル名やタイトルは自由に違って構いません。
パーサーは失敗をはっきり知らせます。必須項目の欠落、未知のカテゴリー、ASCII以外のスラッグ、スラッグの重複があれば、ファイル名を示してビルドを止めます。
hreflangは「インデックス可能な兄弟」どうしだけ
hreflangは検索エンジンに「このページには他言語版がある」と伝えます。noindex のページを指したり、翻訳がないページに付けたりすると、矛盾した信号になります。ジェネレーターのルールは次のとおりです。
siblings = by_key.get(p.get("translation_key"), {})
hreflangs = []
if not p["noindex"] and len(siblings) > 1:
idx = {l: s for l, s in siblings.items() if not s["noindex"]}
if len(idx) > 1:
hreflangs = [{"hreflang": l, "url": s["url"]} for l, s in idx.items()]
xd = idx.get(site["default_lang"]) or next(iter(idx.values()))
hreflangs.append({"hreflang": "x-default", "url": xd["url"]})
韓国語の旧記事には、日本語版と英語版を伴う改訂が出るまでhreflangが付きません。改訂版が出れば、3つが自動で互いを指します。hreflangは人には見えないので、同じデータから読者向けの「他の言語で読む」リンクも出しています。
広告枠のためのセクション分割
記事内のAdSenseの定番の位置は「本文の上」「目次の下」「見出しの前」「記事の下」です。ジェネレーターは描画したHTMLを <h2> ごとに分けます。
def split_sections(body):
return [p for p in re.split(r"(?=<h2[ >])", body) if p.strip()]
def ad_positions(n):
return {i for i in (2, 5) if i < n} # 本文中の枠は最大2つ
テンプレートはセクションと広告枠を交互に並べます。広告枠が出力されるのは、枠のIDが設定されていて、かつページがインデックス可能なときだけです。旧記事やプレビューのページには広告コードが一切入りません。サイトの承認前は枠のIDがすべて空なので、本文中の広告マークアップは出力されません。
同じフロントマターから構造化データ
各記事には、TechArticle、BreadcrumbList、そしてフロントマターに faq があれば FAQPage を含む1つのJSON-LDの @graph を付けます。FAQPage の回答は、ページに表示している文と同じです。JSONは </script> に対してエスケープし、チェッカーがすべてのブロックを json.loads で読み戻すので、引用符1つで構造化データが黙って壊れることはありません。
本当の製品はチェッカー
check_content.py(186行)はビルドの出力に対して走り、何か1つでも報告があればデプロイスクリプトは動きません。
| チェック | 実際に捕まえたもの |
|---|---|
保護パス:出力ファイルがサーバー側の所有パス(tlabo-ad/、api/、app/*/index.php、ads.txt など)と重ならない |
わざと3つ置いて確認:エラー3件 |
| ストアに登録されたURLの行き先ページがすべて存在する | 24件の掲載を守る |
内部リンクと #アンカー が解決できる |
まだなし。ビルドのたびに実行 |
サイトマップのURLがすべて存在し、noindex でない |
noindexの38ページをサイトマップに載せたプレビュービルド |
<title> の長さ、description、canonical、lang |
70文字を超える英語タイトル(何度か) |
プレビュービルド(--drafts)は全ページを noindex にし、サイトマップを書きません。誤ってプレビューをアップロードしても、インデックスされることはありません。
共有サーバーへの安全なデプロイ
サイトは235行のスクリプトでFTPS経由でアップロードします。
- 差分だけ。 アップロードした全ファイルのMD5をマニフェストに記録します。直近の運営者情報ページの修正では294ファイル中11個、記事の追加ではふつう30〜40個を送ります。
- 許可リスト。 アップロードできるのは、ジェネレーターが所有するパス(
en/、ja/、ko/、static/、固定ページ、app/<id>/support|privacy/)だけです。それ以外が1つでもあれば、デプロイ全体を中止します。 - 他人のファイル。 サーバーにあるのにマニフェストにないファイルがあれば止まります。明示的なフラグがあるときだけ、元のファイルを先にダウンロードしてから続けます。これで、検索エンジンのサイト確認用の行を含む
robots.txtを守れました。 - 既定では削除しない。 削除するのは、以前に自分がアップロードし、今のビルドでは生成されなくなったファイルだけです。
数字
| 項目 | 値 |
|---|---|
| ジェネレーター | 411行(build.py)+186行(check_content.py)+235行(deploy.py) |
| テンプレート・CSS・JS | 約900行 |
| ビルド | 0.8秒(HTML 167ページ、3言語、サイトマップ4、フィード3) |
| 出力 | 4.8MB。うち2.2MBは静的ファイル(大半は旧記事の画像) |
| 1ページのCSS/JS | 13.4KB/3.6KB。72KBのシンタックスハイライターはコードのあるページだけで読み込む |
| 最初の1バイトまで(本番) | 約0.13〜0.14秒 |
自作が向いているとき
| 状況 | 選択 |
|---|---|
| 一般的なブログやドキュメントサイト | Hugo、Eleventy、Astro。テーマとプラグインの価値が、細かな制御より大きい |
| 同じサーバーで他のシステムと共存する必要がある | 小さな自作ジェネレーター+厳格なデプロイ許可リスト |
| 業務データ(アプリ、ポリシー、リダイレクト)からページを作る | 自作。データモデルこそが要点 |
| コードを保守するつもりがない | 既存のジェネレーター。必ず |
ジェネレーター自体は面白い部分ではありません。やることが少ないから短いのです。サイトを安全に運用できるようにしたのは、デプロイの手前にあるチェッカーと、「デプロイはジェネレーターが所有するものにしか触れない」というルールです。
よくある質問
なぜHugoなど既存のジェネレーターを使わなかった?
本文の変換は簡単な部分でした。難しい要件はこのサイト固有のものでした。アプリストアに登録されたURLで配信すること、アプリのプライバシーページをデータから生成すること、67本の旧記事を改訂までnoindexに保つこと、他のシステムが持つファイルの上にはデプロイしないこと。小さなジェネレーターなら、どれも数行で済みました。
一部だけ翻訳された記事のhreflangを正しく出すには?
フロントマターのtranslation_keyで記事をまとめ、すべてがインデックス可能な兄弟記事のあいだだけでhreflangを出します。noindexのページや翻訳のないページにはhreflangを付けません。英語版があればx-defaultはそれを指します。
速度は?
ノートPCで、3言語167ページのHTMLにサイトマップとフィードを加えて約0.8秒です。デプロイはMD5が変わったファイルだけを送り、記事の追加なら294ファイル中10〜40程度です。