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程度です。

  • #Python
  • #静的サイト
  • #Jinja2
  • #hreflang
  • #SEO
  • #多言語

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑