WordPress(Cocoon)1,358 記事を URL を変えずに Astro + Cloudflare Pages へ移行した手順
2009 年から WordPress(テーマは Cocoon)で運用してきたこのブログを、Astro で生成した静的サイトに移して Cloudflare Pages で配信するようにしました。記事は 1,358 本、URL は 1 本も変えていません。作業は Claude Code に任せ、10 月 12 日の夜に始めて 13 日に本番を切り替えました。
同じことをやる人向けに、構成と手順、つまずいた点を書きます。
移した理由
WordPress をやめたかった理由は 2 つです。
- 脆弱性と維持管理。本体・テーマ・プラグインの更新を追い続ける必要があり、放置すると攻撃対象になる。記事を書かない期間も PHP と MySQL の面倒を見続けることになる
- AI に扱わせにくい。記事が DB の中にあると、「全記事の題名と冒頭を直して」のような作業を AI に頼みにくい。Markdown と HTML で Git にあれば、そのまま渡せる
先行事例として 19 年運用した WordPress 661 記事を Astro + Cloudflare Pages へ移行した を手順書代わりにしました。同じ Cocoon で、Claude Code で 8 割が半自動だったとあります。この記事は、そこに無かった 3 点(URL を変えない、SEO タグをテストで担保する、サーバー解約までの段取り)を中心に書きます。
構成
| 移行前 | 移行後 | |
|---|---|---|
| 配信 | レンタルサーバー(PHP + MySQL) | Cloudflare Pages(静的 HTML + Functions) |
| 生成 | WordPress + Cocoon | Astro 5(content collections) |
| 記事の形式 | DB の HTML | 旧記事は描画済み HTML を JSON で保持、新記事は Markdown |
| 検索 | MySQL の LIKE | Pagefind(ビルド時に索引を生成、ブラウザ内で検索) |
| 旧 URL の転送 | WordPress のリダイレクト | Pages Functions |
| RSS / sitemap | プラグイン | Astro のエンドポイント(/feed.xml、/sitemap.xml) |
| SEO タグ・構造化データ | All in One SEO | レイアウトで生成 + ビルド後の検査スクリプト |
| コメント | WordPress | 過去分を静的に表示 |
| お問い合わせ | Contact Form 7 | メールアドレスの案内 |
| 画像 | Jetpack Photon 経由 | 自サーバー分は同じパスに配置、外部(Flickr)は直接参照 |
ビルドは 2,026 ページで 3〜6 秒、Pages への初回アップロードは 4,953 ファイルで 23 秒でした。
手順 1: WordPress から REST API で取り出す
記事は WXR の書き出しではなく、REST API(/wp-json/wp/v2/)から JSON で取りました。理由は content.rendered が使えるからです。
REST API の content.rendered は WordPress が描画した後の HTML なので、Cocoon のショートコード(ブログカード、カエレバ、吹き出し)は展開済みです。先行事例で一番手間がかかっていたのは「HTML → Markdown 変換でショートコードの変換漏れを 304 記事手で直した」ところでした。描画済み HTML をそのまま使えば、この工程は丸ごと無くなります。古い記事を Markdown にする必要はなく、新しく書く記事だけ Markdown にすれば十分です。
# 100 件ずつ。posts のほか pages / categories / tags / media / comments も同じ形
curl "https://example.com/wp-json/wp/v2/posts?per_page=100&page=1&status=publish" -o posts-1.json
取れたのは記事 1,358、固定ページ 5、カテゴリー 53、タグ 261、コメント 129 です。注意点が 2 つあります。
- REST API で取れるのは公開済みのものだけです。下書き・非公開記事・メディアライブラリの未使用画像は含まれないので、WXR の書き出しと DB ダンプは別に取っておきます
- 100 件ずつの取得で、1 ページだけサーバー側が PHP のメモリ上限(256MB)で落ちました。そのページは
per_page=20&offset=Nに分けて取り直しています
手順 2: 変換スクリプトで JSON と対応表を作る
Python のスクリプト 1 本で、記事ごとの JSON(frontmatter 相当 + 本文 HTML)、固定ページ、カテゴリー・タグの一覧、旧 URL の対応表を作ります。本文に対する書き換えは次の 4 つだけです。
# srcset / sizes は Photon 前提なので落とす
body = re.sub(r'\s(srcset|sizes)="[^"]*"', '', body)
# Photon 経由の自サイト画像 → 同じパスのローカル
body = re.sub(r'https?://i\d\.wp\.com/example\.com/(wp-content/uploads/[^"?\s]+)(\?[^"\s]*)?', r'/\1', body)
# Photon 経由の外部画像(Flickr 等) → 直接
body = re.sub(r'https?://i\d\.wp\.com/([^"?\s/]+/[^"?\s]+)(\?[^"\s]*)?', r'https://\1', body)
# 自サイトの絶対 URL → 相対
body = re.sub(r'https?://(?:www\.)?example\.com/(wp-content/uploads/|archives/)', r'/\1', body)
これに加えて、古い記事 3 本に生の HTML 文書(<html> <title> <h1> を含む)がそのまま貼ってあったので、文書構造のタグは文字として表示し、本文中の h1 は h2 に下げています。WordPress でも崩れて表示されていたもので、後述の検査スクリプトが拾いました。
手順 3: Astro の content collections に JSON で載せる
Astro 5 の content layer は JSON を glob loader で読めます。旧記事はこの形です。
// src/content.config.ts
const posts = defineCollection({
loader: glob({ pattern: '**/*.json', base: './src/content/posts' }),
schema: z.object({
id: z.number(), title: z.string(), date: z.string(), modified: z.string(),
categories: z.array(z.string()), tags: z.array(z.string()),
image: z.string(), description: z.string(), body: z.string(),
comments: z.array(z.object({ id: z.number(), parent: z.number(), author: z.string(), date: z.string(), body: z.string() })),
}),
});
const articles = defineCollection({ // 新しく書く記事は Markdown
loader: glob({ pattern: '**/*.md', base: './src/content/articles' }),
schema: z.object({ title: z.string(), date: z.coerce.date(), description: z.string(), draft: z.boolean().default(false), /* … */ }),
});
記事ページでは、旧記事は <Fragment set:html={body} />、新記事は render() の <Content /> で出し分けます。Markdown の中に HTML を埋め込む方法は取っていません。本文に空行入りの HTML が多く、Markdown パーサーが段落を壊すためです。
手順 4: URL を変えない
記事 URL が /archives/36108 のような番号だけなら、番号をそのままファイル名にすればリダイレクトは 1 本も要りません。Astro の設定は次の 2 行です。
// astro.config.mjs
export default defineConfig({ site: 'https://example.com', trailingSlash: 'never', build: { format: 'file' } });
format: 'file' で /archives/36108 が archives/36108.html として出力され、Cloudflare Pages は拡張子無しの URL でそれを返します。カテゴリー(/archives/category/diving/kyusyu/amami)とタグ(/archives/tag/…)も [...path].astro の getStaticPaths で同じ形を生成します。ページ送りは /page/2、/archives/category/diving/page/2 のように WordPress と同じにしました。
古い本文には、さらに昔のパーマリンク /2011/01/21/記事のスラッグ/ へのリンクが残っていました。WordPress はこれを今も 301 していたので、同じことを Pages Functions でやります。変換時に「日付 + スラッグ → /archives/番号」の対応表(1,358 件)を JSON で作っておき、年月日の形のリクエストだけを引きます。
// functions/[year]/[month]/[day]/[[slug]].js
import map from '../../../../src/data/legacy-redirects.json';
export function onRequest({ params, request, next }) {
if (!/^\d{4}$/.test(params.year) || !/^\d{2}$/.test(params.month) || !/^\d{2}$/.test(params.day)) return next();
const slug = decodeURIComponent((params.slug || []).join('/')).replace(/\/+$/, '');
const to = map[`/${params.year}/${params.month}/${params.day}/${slug}`];
return to ? Response.redirect(new URL(to, request.url).toString(), 301) : next();
}
1 行目の「年月日の形でなければ next()」が無い版を最初に出したところ、/archives/category/… から /wp-content/… まで 4 階層までのパスを全部この関数が横取りして 404 になりました。Pages Functions のファイル名ルーティングは、深さが合えば何でも受けます。
_redirects には固定の転送だけ置きます(2,000 行の上限があるので記事単位の転送には使わない)。
/feed /feed.xml 301
/wp-sitemap.xml /sitemap.xml 301
/sitemap.xml.gz /sitemap.xml 301
www はカスタムドメインに足すとそのまま配信されてしまうので、functions/_middleware.js でホスト名を見て本体へ 301 しています。
手順 5: 画像は数えてから動かす
本文中の画像 URL を数えたら 11,870 件で、約 1 万 1 千件が Flickr、自サーバー(wp-content/uploads)は 474 件でした。アイキャッチを足しても、ダウンロードが必要なのは 1,502 枚、241MB です。
自サーバーの画像は public/wp-content/uploads/ に同じパスで置きます。画像の URL も変わらないので、外部からの直リンクや Google 画像検索の索引もそのまま生きます。Cloudflare Pages の上限は 1 ファイル 25MB、2 万ファイルなので、サムネイルの派生ファイルは持ち込まず、元画像だけにしました。
一覧用のサムネイルは、ビルド前に sharp で幅 320 の JPEG を生成します(1,017 枚、15MB)。元画像は数 MB のスマホ写真が多く、一覧で 20 枚並べると重いためです。
手順 6: All in One SEO の代わりは検査スクリプト
静的サイトにして一番の不安は、プラグインが全ページで揃えていた title・canonical・OG・構造化データを誰が揃え続けるか、でした。AI に書かせれば 1 ページ分は正しく出ます。問題は、記事を足したり直したりして崩れても気づかないことです。
なので、プラグインの代わりはテストにしました。ビルド後の dist/ の HTML 2,026 ページを全部読んで、次を検査するスクリプトを npm run check に置き、デプロイ前に必ず通します。
<title>が 1 つ、index されるページ同士で重複しないmeta descriptionがあるcanonicalが自分自身の URLh1が 1 つog:imageがある- 記事の JSON-LD(BlogPosting)に headline・datePublished・dateModified・author・image・publisher.logo がある
- ページ送りとタグ一覧は noindex
- index されるページが全部 sitemap にあり、各 URL に lastmod がある
- Photon 経由の自サイト画像 URL が残っていない
最初の実行で、前述の「生 HTML が貼られた記事 3 本」と「同じ題名の記事 2 本」を拾いました。崩したら落ちる、が手に入ったので、記事を増やしても怖くありません。
構造化データは Cocoon が出していた Article 相当に加えて、ページ種別ごとに出し分けています。記事に BreadcrumbList、トップに WebSite(SearchAction 付き)と Organization、About に ProfilePage、カテゴリーに CollectionPage です。Google のリッチリザルトテストで「記事」「パンくずリスト」が有効、エラー 0 でした。
sitemap は @astrojs/sitemap を使っていません。lastmod を URL ごとに入れたかったので、src/pages/sitemap.xml.ts で記事は更新日、カテゴリーはその中で一番新しい記事の日付を書いています。
手順 7: description は本文から作り直す
WordPress の自動抜粋は冒頭 55 語で機械的に切るので、1,358 本のうち 1,279 本が文の途中で切れていました。変換時に、見出し・コード・表を飛ばした本文の冒頭を、文の切れ目で 70〜120 字に切り直しています。手書きの抜粋がある記事はそれを優先します。
description は順位には効きません。検索結果の文面に出るだけで、Google が書き換えることも多い。それでも途中で切れた文が 1,279 本並ぶよりはよいと判断しました。
手順 8: プレビューで curl を叩いてから切り替える
本番の DNS を触る前に、nob-log.pages.dev に対して次を curl で確認しました。
- 記事、カテゴリー、日本語スラッグのタグ、ページ送り、固定ページ、404
- 旧 URL
/YYYY/MM/DD/slug/の 301 と転送先 /feedの 301、/sitemap.xml、/robots.txt- 画像とサムネイルの 200
DNS はもともと Cloudflare にあったので、Pages のカスタムドメインに本体と www を追加するだけで切り替わりました。証明書も自動です。切替後に同じ curl を本番ドメインに対して流し、Search Console に /sitemap.xml を送り直しました。URL が変わっていないので、それ以外の作業はありません。
WordPress はレンタルサーバーに 1〜2 週間残します。戻す場合はカスタムドメインを外して A レコードを戻すだけです。
持ち込んだもの、やめたもの
- AdSense(自動広告)と GA4 は、WordPress のページに入っていた ID をそのままレイアウトに入れました。GA4 は Google タグ(GT-)経由ではなく測定 ID(G-)を直接書いています
- お問い合わせフォームはやめて、メールアドレスの案内にしました。必要になれば Pages Functions と Turnstile で作れます
- コメントは過去分を静的に表示し、新規は受けません
残っていること
- 古い記事の HTML を Markdown に変換するのは、必要になった記事からやります
- レンタルサーバーには EC-CUBE のデモ環境も載っているので、その行き先を決めてから解約します
まとめ
WordPress を静的サイトにする手間の大半は、HTML → Markdown の変換と、プラグインがやっていた細かい仕事の引き継ぎです。今回は前者を「描画済み HTML をそのまま使う」ことで飛ばし、後者を「ビルド後の HTML を検査するスクリプト」に置き換えました。URL が番号ベースならリダイレクトも不要です。この 3 点が揃えば、1,000 記事規模でも 1 日で終わります。