放置していた WordPress ブログを Astro の静的サイトにして Cloudflare Pages へ移した

閉鎖したままサーバーに置いてあった WordPress ブログを、ようやく静的サイトにしました。

MySQL から記事を抜き出して Astro でビルドし、Cloudflare Pages へ直接デプロイする構成です。

はじめに

更新をやめた WordPress ブログを、VPS の Docker で動かし続けていました。

記事は 2,000 件以上あり、古い記事にも検索から少しずつアクセスがあるので、消すに消せない状態です。

ただ、WordPress 本体やプラグインの脆弱性情報は相変わらず頻繁に流れてきますし、WordPress と MySQL のコンテナが VPS のメモリをそれなりに占有していました。

更新しないブログのために PHP と DB を動かし続けるのは割に合わない、というのが今回の動機です。

以前 📄Arrow icon of a page linkHugoブログからAstro-notion-blogへ引っ越し で Hugo から Astro へ移ったこともあり、行き先は迷わず Astro にしました。

この記事では、WordPress からの抽出、本文の掃除、画像の軽量化、Cloudflare Pages へのデプロイまでを順に書きます。

移行前の状態と課題

  • WordPress は VPS 上の Docker コンテナ(WordPress + MySQL)で稼働
  • 公開記事 2,316 件、固定ページ 51 件
  • wp-content/uploads の画像はサムネイル込みで約 5 万ファイル
  • 本文には独自ショートコード、はてなブログカードの iframe、Flickr 画像、YouTube の平文 URL などが混在
  • 20 年近く前の記事もあり、リンク切れが大量にある
  • Google Tag Manager、Search Console、AdSense は WordPress テーマ側で出力

やることは「記事を静的 HTML にする」だけのはずでしたが、古いブログほど本文が汚れているので、そこの掃除が作業の大半になりました。

移行の全体像

flowchart LR
  A["WordPress<br/>MySQL コンテナ"] -->|mysql --xml| B["記事 JSON<br/>src/content"]
  B --> C["本文の掃除<br/>画像・リンク・埋め込み"]
  C --> D["Astro build<br/>静的 HTML"]
  E["uploads 画像<br/>AVIF 化"] --> D
  D -->|wrangler pages deploy| F["Cloudflare Pages"]

リポジトリで管理するのは、記事の JSON とサイトの骨格(Astro のページ・コンポーネント・スクリプト)だけです。

DB ダンプ、画像の原本、ビルド成果物は .gitignore で Git から外しています。

移行手順

1. WordPress から記事を JSON に抜き出す

最初のコミットは、抽出スクリプトと Astro の骨格をまとめて入れた初期状態です。

WordPress の REST API やエクスポート機能は使わず、稼働中の MySQL コンテナへ直接 docker exec で問い合わせました。

// scripts/exportWordpress.mjs(抜粋)
const args = [
  'exec', 'blog-db', 'sh', '-c',
  'mysql --xml --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" wp_blog',
];

--xml で結果を受け取り、1 記事 1 JSON の形で src/content/posts/ と src/content/pages/ に書き出します。

抽出時にやったことは次のとおりです。

  • 公開済みの post と page だけを対象にする(下書き・非公開・コメントは出さない)
  • キャプション、ギャラリー、Amazon、YouTube、地図、段組み、TablePress の表など、よく使っていたショートコードを HTML に置き換える
  • 広告ショートコードは削除する
  • 内部リンクの https://blog.example.com/... をルート相対パスにする
  • 日時は +09:00 付きの ISO 文字列にする
  • 置き換えられなかったショートコードは件数付きでレポートに残す

URL は WordPress のパーマリンクをそのまま維持しています。

astro.config.mjs で trailingSlash: 'always' と build.format: 'directory' を指定し、/記事スラッグ/ の形で出力されるようにしました。

カテゴリ、タグ、著者のアーカイブと /feed/ も WordPress と同じパスで用意しています。

2. 実体のない画像と重複アイキャッチを外す

抽出した本文には、uploads に実体がない画像がたくさん残っていました。

過去にサーバーを何度か引っ越したときに、取りこぼしたものだと思います。

cleanupUploadImages.mjs で本文の img を全件調べ、次の整理をしました。

  • uploads に実体がないローカル画像は本文から外す
  • テンプレートが出すアイキャッチと同じ写真が本文冒頭にある場合は外す
  • ローカル画像が 1 枚もなく、外部画像だけの記事は記事ごと除く
  • どの本文からも参照されない uploads のファイルは削除する

アイキャッチの重複は、ファイル名が一致すればコードで判定します。

ファイル名が違う場合だけ、ファイル名と周辺の文章を AI 判定 API(TypeSafe の Jev)に渡して「同じ写真か」を確率で返してもらい、0.8 以上のときだけ外しました。

画像そのものは見ていないので、完璧ではないと思っています。

この時点で公開記事は 2,316 件から 2,279 件になりました。

3. 切れたリンクと Not Found の埋め込みを外す

次はリンク切れです。

removeBrokenLinks.mjs で本文中の https のリンク・平文 URL・iframe を集め、ユニークな URL にして約 3,000 件を実際に取得しました。

判定は 2 段階にしています。

  • HTTP 404 / 410、タイトルが Not Found のページはコードの判定だけで外す
  • それ以外の失敗やソフト 404 らしきものは、ステータス・タイトル・本文断片を Jev に渡し、0.7 以上でリンク切れと判断されたものだけ外す
  • タイムアウト、認証、ボット拒否はリンク切れとみなさない

外し方も場所によって変えました。

段落がリンクだけならその段落ごと、文中のリンクは文字を残してタグだけ、平文の URL は文字列だけを消します。

pre や code の中はコマンド例なので対象外です。

続くコミットで、Not Found になっていたはてなブログカードの iframe と、404 になった Flickr 画像も同じ仕組みで外しました。

取得結果と判定結果は backup/ にキャッシュしているので、再実行しても同じ URL を聞き直しません。

正直、15 年以上前の記事のリンク先はほぼ全滅に近い状態でした…。

4. 平文の URL を埋め込みとブログカードにする

WordPress では、本文に URL を 1 行だけ置けば oEmbed で埋め込みになっていました。

静的 HTML にするとただの文字列になってしまうので、ビルド時に変換する embedPlainUrls.mjs を作りました。

  • YouTube、Instagram、X の URL は iframe の埋め込みにする
  • 段落の中身が URL だけの一般ページはブログカードにする
  • 文中の URL と画像 URL は普通のリンクにする
  • a、pre、code などの中は触らない

ブログカードのタイトルや画像は Open Graph から取得し、backup/embed-card-cache.json にキャッシュします。

タイムアウトは 8 秒、HTML は 1MB で打ち切り、通信に失敗したものはキャッシュせず次のビルドで取り直します。

5. 残った独自ショートコードを片付ける

抽出時のレポートに残っていたショートコードのうち、[bash] や [php] のような色分け用のものは、中身をコードブロックにしました。

それ以外の独自ショートコードは、タグだけ外して本文を残しています。

対象は 9 記事でした。

6. 画像を AVIF にする

整理後に残った画像を AVIF に変換し、本文の .jpg や .png へのリンクをまとめて .avif に書き換えました。

変更した JSON は 1,834 ファイルです。

- /wp-content/uploads/2018/04/sample_photo.jpg
+ /wp-content/uploads/2018/04/sample_photo.avif

WordPress が自動生成していたサムネイルも不要になったので、uploads は約 5 万ファイルから 2,993 ファイル(134MB)まで減りました。

Cloudflare Pages の無料プランは 1 デプロイあたりのファイル数に上限(20,000 ファイル)があるので、ここを減らせたのは大きかったです。

7. Google Tag Manager・Search Console・AdSense を戻す

WordPress のテーマが出力していたタグを、BaseLayout.astro から全ページ共通で出すようにしました。

ID は googleServices.mjs に定数としてまとめ、形式のチェックとテストも付けています。

// src/lib/googleServices.mjs(ID はダミー)
export const googleTagManagerId = 'GTM-XXXXXXX';
export const googleSiteVerification = 'xxxxxxxxxxxxxxxxxxxx';
export const adsenseClientId = 'ca-pub-0000000000000000';

public/ads.txt のパブリッシャー ID は、AdSense のクライアント ID から ca- を除いた値と一致させておく必要があります。

テーマにあった広告枠(data-ad-slot)は、新しいレイアウトに対応する位置がないので今回は出していません。

8. Cloudflare Pages へ直接デプロイする

画像は Git に入れていないので、GitHub 連携の自動ビルドは使えません。

そこで、画像があるマシンでビルドし、wrangler pages deploy で dist を直接アップロードする方式にしました。

// package.json(抜粋)
"scripts": {
  "build": "node scripts/buildStatic.mjs",
  "deploy": "wrangler pages deploy dist --project-name my-blog --branch main"
}

最初の段階では、ビルド中の 5 万ファイルのコピーを避けるために、uploads を WordPress 側の原本へのシンボリックリンクにしていました。

しかし dist にシンボリックリンクがあっても、Cloudflare 上では解決されません。

buildStatic.mjs を書き直し、uploads が実ディレクトリであることを確認してから Astro にコピーさせる形に変えました。

// scripts/buildStatic.mjs(抜粋)
async function buildStatic() {
  assertUploadsDirectory(uploadsDirectoryPath); // シンボリックリンクなら止める
  await runAstroBuild();
  assertDistUploadsCopied(distUploadsPath);
  placeFeedAsDirectory(distFeedFilePath, distFeedIndexPath);
}

もう 1 つハマったのが /feed/ です。

Astro は拡張子のない dist/feed というファイルを出力しますが、Cloudflare Pages では /feed/ をディレクトリの index.html として配信します。

ビルド後に dist/feed/index.html へ移し、_headers で Content-Type を RSS に指定しました。

# public/_headers
/feed/
  Content-Type: application/rss+xml; charset=utf-8

ついでに、ブログカード用 URL の解析で https://<ip-address>:80/... のようなプレースホルダ入りの URL が例外を投げてビルドが止まっていたので、解析できない URL はリンクのまま残すように直しています。

9. pages.dev を noindex にし、サイトマップを整理する

Cloudflare Pages には *.pages.dev の URL が付くので、放っておくと独自ドメインと同じ内容が二重にインデックスされる可能性があります。

_headers は絶対 URL とプレースホルダでホストを指定できるので、pages.dev 側にだけ X-Robots-Tag: noindex を付けました。

# public/_headers
https://:project.pages.dev/*
  X-Robots-Tag: noindex

https://:version.:project.pages.dev/*
  X-Robots-Tag: noindex

独自ドメインにはこのヘッダーは付きません。

また、robots.txt で案内するサイトマップを sitemap-index.xml から sitemap-0.xml に変え、Search Console にも直接 sitemap-0.xml を登録する形にしました。

サイトマップ周りは 📄Arrow icon of a page linkCloudflareでSearch Consoleのsitemap.xmlが403になる原因と対処法【ads.txt/AI Bot】 で一度ハマっているので、今回は慎重めです。

移行後の効果

項目 移行前(WordPress) 移行後(Astro + Cloudflare Pages)
配信 VPS 上の PHP + MySQL 静的ファイルのみ
公開記事 2,316 件 2,279 件
uploads 約 5 万ファイル 2,993 ファイル(134MB)
画像形式 JPEG / PNG AVIF 中心
デプロイ物 — 6,764 ファイル(185MB)
VPS のコンテナ WordPress + MySQL が常駐 不要

一番うれしいのは、WordPress とプラグインの更新や脆弱性情報を追いかけなくてよくなったことです。

VPS のメモリがどれだけ空いたかは、WordPress コンテナを止めたあとに改めて確認するつもりです。

注意点

画像は Git に入っていない

画像の原本は Git の外にあるので、デプロイできるのは画像を持っているマシンだけです。

別のマシンから作業するなら、R2 などに画像を置いてビルド時に取得する仕組みが要ると思います。

Astro のバージョン

このリポジトリは Astro 7 系で作っています。

📄Arrow icon of a page linkCloudflare PagesでAstro 7へ気軽に上げない方がよい理由 で書いたように、Cloudflare Pages 上でビルドさせる場合は Node のバージョンに注意が必要です。

今回はローカルでビルドして dist だけを上げているので、この問題は回避できています。

AI 判定は完璧ではない

重複アイキャッチとリンク切れの判定の一部を AI に任せています。

しきい値を高めにして「迷ったら残す」側に倒していますが、消すべきでないものを消している可能性はゼロではありません。

削除した記事への内部リンク

外部画像しかなかった記事を除いたので、ほかの記事からその記事へのリンクは 404 になります。

ここはまだ手を付けていません。

まとめ

  • MySQL から直接記事を抜き出し、1 記事 1 JSON にして Astro でビルドしました
  • 本文の掃除(画像・リンク切れ・埋め込み・ショートコード)が作業の大半でした
  • 画像は AVIF にしてファイル数を大きく減らし、Cloudflare Pages へ直接デプロイしています
  • pages.dev は noindex にして、独自ドメインだけをインデックスさせる形にしました

何年も「そのうちやる」と言っていた非 WordPress 化が、やっと片付きました。

これで、ようやくWordPressのセキュリティ対策やVPS 上の Docker コンテナ(WordPress + Kusanagi + MySQL)から解放されました。

同じように、更新していない WordPress ブログをどうするか悩んでいる方の参考になれば幸いです。