Claude Media
WordPressからmicroCMSへ記事を移行する — Claude CodeでWXRを変換して登録

WordPressからmicroCMSへ記事を移行する — Claude CodeでWXRを変換して登録

WordPressのWXRを書き出し、画像をmicroCMSへ先に上げ、本文HTMLをリッチエディタ向けに直してPUTで登録する手順です。各段で件数を照合する帳簿の作り方まで扱います。

WordPressの記事をmicroCMSへ移すときの山場は、記事データの変換ではなく画像とリッチエディタ形式です。WXR(WordPress eXtended RSS、WordPressの書き出し形式)には記事・カテゴリ・添付ファイルの情報が入りますが、添付ファイルの実体は入りません。microCMSのリッチエディタは外部URLの画像を受け付けないので、画像を先に上げてURLを差し替える順序が決まっています。

ここでは、次の4段で進めます。各段の終わりに件数を数え、移行元と移行先の差が出た段で止められるようにします。

手順

移行の4段

  1. 1

    書き出して棚卸しする

    WXRを書き出し、種別と公開状態ごとの件数を数えます。

  2. 2

    画像を先に上げる

    添付画像をmicroCMSのメディアへ上げ、旧URLと新URLの対応表を作ります。

  3. 3

    本文HTMLを直す

    リッチエディタが受けるHTMLの範囲に収め、収まらない記事を一覧にします。

  4. 4

    下書きで登録して照合する

    コンテンツIDを指定したPUTで下書き登録し、4つの件数を突き合わせます。

Claude Codeに任せるのは、変換スクリプトの作成・実行と、変換しきれなかった記事の個別修正です。APIキーを渡す範囲と、移行を再実行しても重複しない作りにする点は、途中で触れます。

WXRには何が入っていて、何が入っていないか

WP-CLIのwp exportは、投稿者・タームとコメント・投稿・添付ファイルを含むWXRを出力します。サイト設定(オプション)と、添付ファイルの実体そのものは含まれません。つまり、WXRだけでは画像が手に入りません。画像のURLは添付ファイルの項目に載っているので、そこから取りに行く形になります。

1ファイルの大きさは--max_file_sizeで決まり、既定は15MBです。記事数が多いサイトでは、{site}.wordpress.{date}.{n}.xmlのように連番付きで複数ファイルに分かれます。件数を数えるときは、全ファイルを通して数えます。

wp export --dir=./export --max_file_size=15
ls -l ./export

このコマンドは種別を絞らずに書き出します。まず全体を書き出して棚卸しで種別を見てから、移す対象を決めるほうが取りこぼしを避けられます。絞るときは、--post_typeにカンマ区切りでpost,page,attachmentのように指定します。attachmentを外すと添付ファイルの項目がWXRに入らず、次の画像の手順で旧URLを取り出せなくなります。

棚卸しスクリプトで種別と状態を数える

書き出したファイルの先頭を数行見て、要素名と名前空間を確かめてから、種別と公開状態の組み合わせごとに数えます。次のスクリプトは、WXRで一般的なwp:post_typeとwp:statusを使う想定です。手元のファイルで要素名が違えば、そこだけ直します。

# inventory.py
import collections, sys
import xml.etree.ElementTree as ET
 
NS = {"wp": "http://wordpress.org/export/1.2/"}
count = collections.Counter()
for path in sys.argv[1:]:
    root = ET.parse(path).getroot()
    for it in root.iter("item"):
        kind = it.findtext("wp:post_type", namespaces=NS)
        state = it.findtext("wp:status", namespaces=NS)
        count[(kind, state)] += 1
for key, n in sorted(count.items()):
    print(key, n)

出力の例として、('post', 'publish') 412のような行が並びます。このpost / publishの件数が、移行元の正解の数字です。下書き(draft)やゴミ箱(trash)を移すかどうかは、ここで決めて記録します。以降の照合は、この数字を基準にします。

microCMS側の受け口と制約を先に表にする

登録に使うAPIと、移行の設計を左右する制約を先に表で見ます。ここを知らずにスクリプトを書くと、件数が合わない原因を後から探すことになります。

項目内容
使えるAPI形式内容リスト形式のAPIのみ。オブジェクト形式では使えない
登録の方法内容POSTは自動採番、PUTはコンテンツIDを指定して作成
状態の指定内容status=draftで下書き、status=closedで公開終了。指定なしは公開中
書き込みの上限内容サービス単位で毎秒5回(POST / PUT / PATCH / DELETE)
本文の大きさ内容POST / PUTのリクエストボディは約300KB、1コンテンツは約200KB
画像アップロード内容マネジメントAPI。1ファイル5MBまで、1リクエスト1ファイル
マネジメントAPIの上限内容IPアドレス単位で10秒に10回

リッチエディタに入れる本文は、HTML文字列で渡します。登録したい項目が空のときは、値を空にするのではなく、その項目のキーをリクエストボディに含めません。また、APIスキーマに初期値を決めていても、リクエストに値がなければ初期値は入りません。必須項目は、移行スクリプト側で必ず値を入れる必要があります。

落とし穴

APIスキーマにない項目や、自動付与の項目は送れません

createdAtやupdatedAtのような自動付与の値を送ると、予期しないキーとしてエラーになります。投稿日を引き継ぎたいときは、公開中のコンテンツに限ってpublishedAtをISO 8601の文字列で指定できます。下書きで登録するときにpublishedAtを付けるとエラーになるので、下書き登録では外します。

投稿日の引き継ぎと下書き登録は同時にできない、という点が設計に効きます。最初に下書きで登録して全件を確認し、公開に切り替えるときにpublishedAtを付けてPATCHで更新する、という二度手間の形が基本になります。公開日を重視しない移行なら、下書き登録だけで足ります。

画像を先に上げて、URLの対応表を作る

リッチエディタのimgタグは、srcにmicroCMSの画像ドメインしか使えません。外部URLの画像は非対応です。しかもURLが同じサービスにアップロードされたものでないとパースされません。WordPress側の画像URLを残したままでは、本文を登録しても画像が欠けます。

画像の移し方は、4つの動作の繰り返しです。WXRの添付項目から旧URLを取り出し、ダウンロードし、POST /api/v1/mediaで上げ、返ってきた新URLを対応表に記録します。

curl -X POST -H "X-MICROCMS-API-KEY: $MEDIA_KEY" \
  -F "file=@sample.jpg" \
  "https://$SERVICE_ID.microcms-management.io/api/v1/media"

成功すると201が返り、本文には{"url":"https://images.microcms-assets.io/assets/..."}の形で新URLが入ります。APIキーは、マネジメントAPIの権限で「メディアのアップロード」を有効にしたものを使います。コンテンツAPI用のキーとは別物として用意します。

# upload_media.py
import json, os, pathlib, subprocess, time
 
SERVICE = os.environ["SERVICE_ID"]
KEY = os.environ["MEDIA_KEY"]
URL = f"https://{SERVICE}.microcms-management.io/api/v1/media"
LIMIT = 5 * 1024 * 1024
 
def upload(path):
    cmd = ["curl", "-sS", "-X", "POST", "-F", f"file=@{path}",
           "-H", f"X-MICROCMS-API-KEY: {KEY}", URL]
    out = subprocess.run(cmd, capture_output=True, text=True)
    return json.loads(out.stdout)["url"]
 
table, oversized = {}, []
for p in sorted(pathlib.Path("images").iterdir()):
    if p.stat().st_size > LIMIT:
        oversized.append(p.name)
        continue
    table[p.name] = upload(p)
    time.sleep(1.1)  # 10秒に10回の上限を下回る間隔
json.dump(table, open("media_map.json", "w"), indent=2)
print(len(table), "uploaded;", len(oversized), "oversized")

5MBを超える画像は、APIでは上げられません。管理画面からのアップロードになるので、oversizedとして名前を残し、手作業の対象に回します。この「手作業の件数」を最初から数字で持っておくと、完了の定義が明確になります。1ファイルずつ1.1秒空ける作りは、マネジメントAPIの上限(IPアドレス単位で10秒に10回)を下回る間隔です。

画像の旧URLと新URLの対応は、ファイル名だけでなく旧URLそのものをキーにした表にするほうが、後の置換で迷いません。WordPressは1枚の画像から縮小版のURLも生成するため、本文には元の画像のURLとは別のURLが入っていることがあります。元の画像だけを対応表に入れていると、縮小版を参照するimgが置換から漏れます。漏れは次の節の検査で拾います。

本文HTMLをリッチエディタが受ける範囲に収める

リッチエディタのWRITE APIは、対応しているHTMLの範囲だけを入稿できます。全てのHTMLの記法に対応しているわけではありません。対応が明記されているのは、見出し(h1〜h5)、段落、改行、太字、斜体、下線、打ち消し線、コード、区切り線、引用、コードブロック、テーブル、リスト、リンク、画像、ファイルなどです。文字サイズと埋め込みは未対応です。

収まらないHTMLの扱いも決まっています。解析に失敗した箇所は、基本的に段落のテキストとして入ります。リクエストの内容によっては500番台のステータスで失敗し、登録されないこともあります。つまり、変換漏れは「静かに平文化する」か「登録に失敗する」かのどちらかで現れます。

WordPress側の本文に出やすいもの移行時の扱い
h6見出し移行時の扱い対応はh1〜h5なので、h5などに寄せる
ブロックエディタのコメント(<!-- wp:... -->)移行時の扱い取り除く
外部URLのimg移行時の扱い画像の対応表で新URLに置換。残ったものは一覧へ
iframeなどの埋め込み移行時の扱い未対応。リンクに置き換えるか、手作業へ回す
縮小版画像のURL移行時の扱い対応表に無いので、元の画像に戻して置換

ここで、検査の関数を先に作ります。変換後のHTMLを通したとき、microcms-assets.io以外を指すimgや、未対応のタグが残っていないかを記事ごとに数えます。

# convert.py(抜粋)
import re
 
BAD_TAGS = re.compile(r"<(iframe|embed|script|style)\b", re.I)
IMG_SRC = re.compile(r"<img\b[^>]*\bsrc=['\"]([^'\"]+)", re.I)
 
def convert(html, media_map):
    html = re.sub(r"<!--.*?-->", "", html, flags=re.S)
    html = re.sub(r"<(/?)h6\b", r"<\1h5", html, flags=re.I)
    for old, new in media_map.items():
        html = html.replace(old, new)
    return html.replace("\r\n", "\n")
 
def problems(html):
    found = []
    for src in IMG_SRC.findall(html):
        if "microcms-assets.io" not in src:
            found.append("external img: " + src)
    if BAD_TAGS.search(html):
        found.append("unsupported tag")
    if len(html.encode("utf-8")) > 250_000:
        found.append("body near size limit")
    return found

problems()が空でない記事は、登録の対象から外し、needs_review.jsonlに理由付きで書き出します。本文が約300KBというリクエストの上限に近い記事は、画像をdata:で埋め込んでいるなどの特殊なケースです。そうしたものを先に拾っておくと、登録の途中で失敗して探す手間が減ります。

変換しきれない記事はClaude Codeで個別に直す

needs_review.jsonlの記事は、ルールだけでは直せません。たとえば独自のショートコードが展開されたdivの入れ子や、プラグイン由来のHTMLです。こうした記事は、Claude Codeを非対話モードで呼び、1記事ずつ修正案を作らせると、手で直すより速く進みます。

非対話モードはclaude -pで動き、標準入力を読めます。入力に使えるのは10MBまでで、超えるとエラーで終了するため、1記事ずつ渡す形が合っています。--output-format jsonと--json-schemaを併用すると、structured_output欄に決まった形の結果が入ります。出力にstructured_outputが無い場合があるので、スクリプト側で存在を確かめてから使います。

cat post-1234.html | claude -p \
  "リッチエディタ対応のHTMLに直して。h1-h5, p, strong, em, ul, ol, \
  table, a, img, blockquote, pre>code のみ使い、意味を変えない。" \
  --output-format json \
  --json-schema '{"type":"object","properties":{"html":{"type":"string"}},"required":["html"]}' \
  | jq -r '.structured_output.html' > post-1234.fixed.html

直した結果は、そのまま登録せず、同じproblems()をもう一度通します。修正案の検査を機械側に残しておくと、Claude Codeの出力を信じる範囲が「検査を通ったもの」に限られます。

実行時の権限は、読むファイルと書くファイルを絞ります。この用途でClaude Codeに必要なのは、export/とimages/の読み取り、変換スクリプトの実行、out/への書き込みです。microCMSのAPIキーは環境変数で渡し、.envをClaude Codeが読まない設定にしておく考え方は、Claude CodeとmicroCMSの連携で扱っています。キーを会話に貼らず、スクリプトだけが読む形に保ちます。

コンテンツIDを指定して下書きで登録する

登録にはPOSTではなくPUTを選びます。PUTはコンテンツIDを指定して作成するため、WordPressの投稿ID、またはスラッグをそのまま使えます。コンテンツIDには、APIごとに書式が決まっています。使える文字は英小文字・英大文字・数字と-・_から選び、文字数は3〜50文字の範囲で固定できます。この設定は、PUTのときに書式を確認する対象です。日本語のスラッグや50文字を超えるスラッグは、そのままでは使えません。wp-1234のような形に変換するのが安全です。

PUTを選ぶ利点は、再実行しても重複しないことです。すでに存在するコンテンツIDに対してPUTすると、Content is already exists. If you want update, please use PATCH request.というエラーで拒否されます。同じ記事が二重に作られず、失敗した分だけを再実行できます。

# register.py(抜粋)
import json, os, time, urllib.error, urllib.request
 
SERVICE, KEY = os.environ["SERVICE_ID"], os.environ["API_KEY"]
BASE = f"https://{SERVICE}.microcms.io/api/v1/blogs"
 
def put(cid, body):
    req = urllib.request.Request(
        f"{BASE}/{cid}?status=draft",
        data=json.dumps(body).encode("utf-8"), method="PUT",
        headers={"X-MICROCMS-API-KEY": KEY,
                 "Content-Type": "application/json"})
    for wait in (1, 2, 4, 8):
        try:
            with urllib.request.urlopen(req) as r:
                return r.status, json.load(r)
        except urllib.error.HTTPError as e:
            if e.code != 429:
                return e.code, e.read().decode("utf-8")
            time.sleep(wait)
    return 429, "retry exhausted"
 
def run(posts):
    with open("ledger.jsonl", "a") as led:
        for cid, body in posts:
            status, res = put(cid, body)
            led.write(json.dumps(
                {"id": cid, "status": status, "res": res}) + "\n")
            time.sleep(0.3)  # 毎秒5回の上限を下回る間隔

エンドポイント名のblogsは、移行先のAPIの名前に合わせて置き換えます。WRITE APIは、サービス単位で毎秒5回です。0.3秒空けると毎秒約3回になり、上限に余裕が残ります。それでも、全体のリクエスト状況によっては、上限内でも429や500番台が返ることがあります。リトライを入れて発生確率を下げる、という前提で、上のスクリプトは429のときに間隔を倍にして再試行します。上のコード例にある4回の再試行は、一般的な作りの一例です。

登録した結果は、成功も失敗もledger.jsonlに1行ずつ書きます。成功は201とコンテンツIDが返ります。400番台は、メッセージがエラーの種類を表しています。'{fieldId}' field required error.は必須項目の入れ忘れ、'{fieldId}' has unexpected data type.はスキーマと値の型の不一致です。台帳に本文ごと残しておけば、あとでClaude Codeにエラーの種類ごとの集計と原因の切り分けを任せられます。

4つの件数を突き合わせる

最後に、移行元から移行先までの件数を一列に並べます。どこで数が減ったかが見えるように、段ごとに別の数字を持ちます。

数字

照合する4つの件数

  • A 書き出し

    棚卸しの数

    post / publish などの組み合わせ

  • B 変換後

    problems空の数

    needs_reviewを除いた数

  • C 台帳

    201の行数

    ledger.jsonlのstatus

  • D 移行先

    totalCount

    GETで取得

どれか1つでも食い違えば、その段に戻ります

差は、次のように説明できます。AとBの差はneeds_review.jsonlの行数と一致し、BとCの差は台帳で201以外の行数と一致します。CとDは同じはずで、食い違うなら、台帳に記録されなかった登録があるか、取得側の権限の問題です。

Dの取得には注意が要ります。GETの一覧は、レスポンスのtotalCountにAPI内のコンテンツ総数を返します。ただし、下書き中のコンテンツを取得するには、APIキーに「下書きコンテンツの全取得」の権限が必要です。下書きで登録したのにtotalCountが0のままなら、権限が足りないだけという可能性があります。

curl -s "https://$SERVICE_ID.microcms.io/api/v1/blogs?limit=1" \
  -H "X-MICROCMS-API-KEY: $API_KEY" | jq '.totalCount'
wc -l < ledger.jsonl
grep -c '"status": 201' ledger.jsonl

4つの数字が揃ったら、管理画面で数件を開き、見出し・表・画像・リンクの崩れを目で確かめます。件数が合っていても、リッチエディタの変換で装飾が平文になっている記事は検出できません。サンプルは、needs_reviewに入れて直した記事を必ず含めて選びます。

公開に切り替えるときは、確認済みの記事をPATCHで公開に変えます。publishedAtを引き継ぐのも、この段です。公開の操作を一括で行う前に、ここまでの照合結果を記録に残しておきます。

CSVインポートとAPI登録のどちらを選ぶか

microCMSには、CSVインポートもあります。リッチエディタの項目にはHTML文字列を入れられ、対応するHTMLの範囲はWRITE APIと同じです。WXRからCSVを作る手もあるため、選択は件数と失敗の扱いで決まります。

比較点CSVインポートPUTによるAPI登録
エラー時CSVインポート1件でもエラーがあると処理が中断し、1件も追加されないPUTによるAPI登録記事ごとに成否が分かれ、失敗分だけ再実行できる
件数の上限CSVインポート一度に入れられる件数がプランごとに異なるPUTによるAPI登録書き込みは毎秒5回まで
文字コードCSVインポートUTF-8(BOMなし)PUTによるAPI登録JSONのUTF-8
画像CSVインポート先にmicroCMSへ上げたURLが必要PUTによるAPI登録同左

なお、CSVインポートでは記事の追加がWebhookで通知されません。登録をきっかけに別の処理を動かしている場合は、この点に注意が要ります。画像の先上げは、どちらの方法でも必要です。CSVは一括で入る反面、1件のエラーで全体が止まるため、移行の最初の1回には向きません。記事ごとに結果が分かれるPUTのほうが、問題の記事を切り離して進められます。数十件の小さなサイトなら、CSVで十分な場合もあります。

移行後の確認で見るもの

登録が終わったあとの確認は、件数のほかに3つあります。

  1. 画像のURLが全てmicrocms-assets.io(またはカスタムドメイン)になっているか。本文のimgを全記事から拾って調べます
  2. 内部リンクが旧URLのままになっていないか。記事間のリンクは、画像と違って自動では書き換わらない
  3. 投稿の分類(カテゴリ)が、移行先のコンテンツ参照かセレクトフィールドかに対応しているか

3つ目は、データの項目対応の話です。型や区分値を変えながら件数を照合する考え方は、データ移行の項目マッピング表を作る手順で別の角度から整理しています。レート制限を守りながら移行スクリプトを回す設計は、Notion APIの移行スクリプトと同じ型です。

移行元のWordPressは、全件の照合が終わるまで消さずに残します。ログと台帳がそろっていれば、問題の記事だけを再登録できます。旧サイトを止めるのは、公開の切り替えと内部リンクの確認が済んでからです。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. document.microcms.io/content-api/post-content
  2. document.microcms.io/manual/rich-editor-write-api
  3. document.microcms.io/manual/limitations
  4. document.microcms.io/management-api/post-media
  5. developer.wordpress.org/cli/commands/export
  6. code.claude.com/docs/en/headless
この記事を共有:XはてブLinkedIn