はてなブログからGitHub Pagesへ移行します

個人ブログとして2017年12月にはてなブログで解説したこのブログについて、このたびGitHub Pagesへの移行を行います。 そう思い立ったのはいくつかの理由があります。

1番大きい理由は、ここ数年はブログを書く先は主にZenn - morihayaとなっていることです。 開設当時は技術ブログの主流がはてなブログにありましたが、Qiitaが開始され、その後Zennが出てきてブログプラットフォームの幅が広がってきました。 私自身もそれぞれを利用してきましたが、GitHub連携の便利さと所属会社の企業ブログがZennにあることからそちらがメインとなっています。

2つ目は生成AIの進化です。今回の移行は移行先の壁打ちはもちろん、実行に至るまですべてClaude CodeのOpus5で行っています。 一人でやるには気が重い作業ですが、生成AIの力を借りれば作業の指示だけでほぼ全てが終わりました。つくづくすごい時代になったものです。

以降は実際に行った工程をOpus5にまとめてもらいました。特に加筆修正せずに記載しておきます。

最初にまとめ

  • はてなブログから GitHub Pages(Hugo)へ移行します
  • カスタムドメイン blog.morihaya.tech と記事URL /entry/YYYY/MM/DD/HHMMSS はそのまま維持しています
    • つまり既存のリンクとはてなブックマーク数はそのまま生きています
  • 解約前に必ずやるべきは、はてなフォトライフの画像をローカルへ吸い出すことです
    • 記事本文の [f:id:...] は画像の実体を持っておらず、参照しているだけだからです
  • コメント・はてなスター、そして /feed /rss のフィードURLは引き継げませんでした

移行前後の構成

移行前移行後
ホスティングはてなブログProGitHub Pages
記事の管理GitHubリポジトリ + blogsync同じリポジトリ
記事の形式はてな記法混じりのMarkdown素のMarkdown
画像はてなフォトライフリポジトリ内 static/images/
サイト生成はてなブログHugo(自作の最小テーマ)
ドメインblog.morihaya.techblog.morihaya.tech(変わらず)

もともとはてなブログのGitHub管理用ボイラープレートを使って記事をリポジトリで管理していたので、記事本文は既に手元に揃っている状態からのスタートでした。

手順1: 記事が手元に揃っていることを確認する

私の場合は blogsync で同期済みだったため、entries/ 配下に全102記事がある状態でした。

まだの方は、はてなブログの管理画面から「記事のバックアップと製本サービス」でエクスポートするか、blogsync で pull しておきます。

手順2: はてなフォトライフの画像を吸い出す(最重要)

ここが移行作業で一番重要です。

記事本文に埋め込まれている画像は、以下のような記法で書かれています。

[f:id:morihaya:20220423001109p:plain]

これは「はてなフォトライフ上の画像を参照する」という指定であって、画像の実体はリポジトリのどこにもありません。この状態ではてなを解約すると、記事から画像が消えます。

記法から実体のURLは以下の規則で組み立てられます。

https://cdn-ak.f.st-hatena.com/images/fotolife/<idの頭文字>/<id>/<日付8桁>/<タイムスタンプ14桁>.<拡張子>

末尾の1文字が画像フォーマットを表しています(p = png、j = jpg、g = gif)。

私の場合は77箇所・ユニーク75件、合計11.8MBでした。これをスクリプトで一括ダウンロードし、static/images/fotolife/ へ格納しました。

手順3: はてな記法をMarkdownに変換する

長年書いていると、思ったより多くのはてな固有記法を使っていました。実際に出てきたのは以下です。

はてな記法件数変換後
[URL:embed:cite]107[タイトル](URL)
[URL:embed#テキスト]66[テキスト](URL)
[f:id:...]77![alt](/images/fotolife/...)
((脚注))148[^1] + 末尾に定義
[:contents]29目次ショートコード
[URL:title]9[タイトル](URL)
[URL:image=alt]4![alt](URL)

変換で気をつけた点をいくつか挙げます。

画像の6割は <figure> に包まれていた

編集画面から画像を貼ると、こういうHTMLで囲まれます。

<figure class="figure-image figure-image-fotolife" title="キャプション">[f:id:morihaya:20220423001109p:plain]<figcaption>キャプション</figcaption></figure>

生HTMLの中に書いたMarkdownはレンダリングされないため、figureごと潰して figcaption を alt に移す必要がありました。

脚注の中に括弧が入れ子になっている

((...)) を素朴な正規表現で拾うと、脚注の中にMarkdownリンクがある場合に途中で切れます。

((詳しくは[RFC 5735](https://www.rfc-editor.org/rfc/rfc5735)を参照))

括弧の深さを数えながら終端を探す処理にしました。

[URL:embed:cite] にはタイトルが入っていない

はてなが表示時にリンク先を取得してタイトルを出しているため、本文にはURLしか残っていません。そのまま変換するとリンクテキストがURLのままになります。リンク先から <title> を取得するスクリプトを別途書いて、119件中92件はタイトル付きにできました。取得できなかった27件(connpass、SlideShare など)はURLのままです。

コードブロックの中は変換してはいけない

当たり前ですが、記事中のコード例に (({{ が含まれることがあります。コードフェンスとインラインコードは変換対象から除外しました。

手順4: Hugoでサイトを組む

静的サイトジェネレータには Hugo を選びました。

テーマは既製のものを使わず、layouts/assets/ に自前で置く最小構成にしています。submodule も Hugo Modules も使わないので、リポジトリ単体でビルドが完結し、表示がおかしいときの原因が必ず自分のコードの中にあるのが気楽です。テンプレートは全部で200行ほどでした。

はてなから移ってきた記事のために必要だった設定は以下です。

[markup]
  [markup.goldmark.renderer]
    # 記事に生の <img> タグが含まれるため必須
    unsafe = true
  [markup.tableOfContents]
    # 本文の大見出しに # を使っている記事があるため 1 から拾う
    startLevel = 1

startLevel は、Hugoの既定値(2)のままだと目次が空になる記事が出て気づきました。はてなブログのMarkdownでは記事タイトルがh1なので # を本文の大見出しに使っていたのですが、Hugoではそのまま h1 になるためです。私の場合、44記事が該当しました。

URL構造を維持する

これは移行前から決めていた要件でした。

content/entry/2025/12/14/232826.md というファイル配置にすると、Hugoは既定の動作でそのまま /entry/2025/12/14/232826/ というURLを生成します。permalinksの設定は不要でした。

全102記事について、はてな時代のURLと生成後のURLが一致することをスクリプトで確認しています。

手順5: GitHub Actionsでデプロイする

main への push でビルドしてデプロイするワークフローを置きました。要点だけ抜き出します。

      - name: Configure Pages
        id: pages
        uses: actions/configure-pages@v5

      - name: Build
        run: hugo --minify --gc --baseURL "${{ steps.pages.outputs.base_url }}/"

baseURLconfigure-pages の出力から取るのがポイントです。カスタムドメインを設定する前は https://<user>.github.io/<repo>/、設定後はカスタムドメインが自動的に入るため、DNS切り替えのタイミングでワークフローを書き換える必要がありません

なお、GitHub Pages を無料で使うにはリポジトリをPublicにする必要があります。私は履歴を含めて機密情報が無いことを確認してから切り替えました。

手順6: DNSを切り替える

カスタムドメインは自分で保有しているドメインなので、はてなは何も権限を持っていません。Route 53 のレコードを向け替えるだけです。

# 移行前
blog.morihaya.tech.  CNAME  hatenablog.com.

# 移行後
blog.morihaya.tech.  CNAME  <username>.github.io.

切り替えの順序は以下にしました。

  1. Route 53 で現行レコードのTTLを300秒に下げ、旧TTLの経過を待つ(切り戻しを速くするため)
  2. GitHub の Settings > Pages でカスタムドメインを設定する(この時点ではDNSチェックが失敗しますが、設定は保存されます)
  3. Route 53 のCNAMEを向け替える
  4. GitHubがLet’s Encryptの証明書を自動発行するのを待つ
  5. Enforce HTTPS を有効にする

2を3より先にやることで、切り替え直後に「GitHub Pages に該当サイトがありません」が表示される時間を避けられます。

DNSを切り替える前に、https://<username>.github.io/<repo>/ で表示確認をしておくことを強くお勧めします。 私はここで後述の不具合を踏みました。

ハマったところ

relURL はサブパスを付けてくれない

github.io のプロジェクトページ(https://<user>.github.io/<repo>/)で確認したところ、画像が全部404になりました。

Hugoの relURL は、先頭が / のパスを「サイトルートからの絶対パス」と解釈するため、baseURLのサブパスを付けません。実際に出力を並べるとこうなります。

入力     = /images/fotolife/20220423001109.png
relURL   = /images/fotolife/20220423001109.png        ← 変わらない
absURL   = https://user.github.io/images/...          ← サブパスが落ちる

先頭の / を落としてから relURL に通すことで解決しました。画像のレンダーフックを1つ置くだけで、記事側は変更不要です。

{{- $src := .Destination -}}
{{- if hasPrefix $src "/" -}}{{- $src = strings.TrimPrefix "/" $src | relURL -}}{{- end -}}
<img src="{{ $src }}" alt="{{ .PlainText }}" loading="lazy">

最終的なカスタムドメイン(ルート配置)ではこの問題は起きません。github.io で先に確認する手順を踏んだからこそ見つかった不具合でした。

python.org 版の Python は CA 証明書を持っていない

画像のダウンロードスクリプトが全件 CERTIFICATE_VERIFY_FAILED で失敗しました。curl では通るのにPythonでは通らない、というやつです。

python.org からインストールしたPythonはCAバンドルを同梱しておらず、Install Certificates.command を実行するまで証明書検証に失敗します。検証を無効化するのは論外なので、certifi とシステムのCAバンドル(macOSなら /etc/ssl/cert.pem)を順に探すようにしました。

維持できたもの・失われるもの

維持できたもの

  • 記事URL(/entry/YYYY/MM/DD/HHMMSS
  • カスタムドメイン
  • はてなブックマーク数(URL単位で紐づくため、URLが同じなら維持されます)
  • 記事本文と画像

失われるもの

  • コメント、はてなスター(エクスポート手段がありません)
  • morihaya.hatenablog.com 宛のリンク(はてなから新サイトへのリダイレクトは張れません)
  • フィードURL(後述)

RSSを購読いただいている方へ

フィードのURLが変わります。

移行前移行後
フィード/feed /rss/index.xml

GitHub Pages にはリダイレクト機能がないため、/feed/rss を再現できませんでした。

これまで購読いただいていた方には申し訳ないのですが、お手数ですが以下へ登録し直していただけると嬉しいです。

https://blog.morihaya.tech/index.xml

リポジトリ

移行に使った変換スクリプトとサイトの構成一式は公開しています。

GitHub - morihaya/Hatena-Blog-Workflows: はてなブログの管理用 · GitHub

scripts/migration/ に3本置いてあります。いずれも標準ライブラリのみで動き、entries/ は変更しません。

スクリプト役割
fetch_fotolife.pyフォトライフの画像をローカルへ取得
fetch_link_titles.py埋め込み記法のリンク先タイトルを収集
convert_entries.pyfront matter と本文記法を変換

同じようにはてなブログからの移行を考えている方の参考になれば幸いです。特にフォトライフの画像だけは、解約前に必ず手元へ落としておいてください