2026年10月1日

2026年10月1日

Strapi・ContentfulからWordPressへコンテンツを移行するインポート手順

Strapi や Contentful のようなヘッドレス CMS から WordPress へ記事を移すときは、「CMS から JSON で書き出す」「JSON を WordPress の投稿の形に変換する」「WP-CLI でまとめて取り込み、画像とリダイレクトを整える」の3段階に分けると、失敗しても何度でもやり直せる移行になります。この記事では、両 CMS の書き出し方法、リッチテキストを HTML に変換するインポートスクリプト、移行後の確認とリダイレクトまでを手順に沿って説明します。

Notion などから継続的に同期する仕組みとは違い、ここで扱うのは「旧 CMS をやめて WordPress に引っ越す一度きりの移行」です。そのため、スクリプトは試し実行(ドライラン)と再実行ができる作りにしています。

移行の前に決めておくこと

最初に、旧 CMS のコンテンツモデルと WordPress の構造の対応表を作ります。ヘッドレス CMS は自由にフィールドを定義できるぶん、WordPress の「タイトル・本文・抜粋・アイキャッチ・カテゴリー・タグ」にそのまま当てはまらない項目が出てきます。

  • 記事に当たるコンテンツタイプ → 投稿(または独自の投稿タイプ)
  • カテゴリーやタグに当たる参照フィールド → タクソノミー
  • それ以外の項目(著者の肩書き、関連リンクなど) → カスタムフィールド
  • 本文のリッチテキスト → HTML(変換が必要)
  • メディア(画像) → メディアライブラリ

もう一つ大事なのが URL です。旧サイトのフロントエンド(Next.js など)で使っていた URL 構造を一覧にし、WordPress のパーマリンク設定でどこまで同じ形にできるかを決めます。同じにできない URL は、後でリダイレクトを設定します。

作業はステージング環境で行い、取り込み前に wp db export で WordPress のデータベースを書き出しておきます。

ステップ1:Contentful から書き出す

Contentful には公式の CLI(contentful-cli)があり、スペースの中身を JSON に書き出せます。Node.js 環境でインストールし、管理用(Content Management API)のトークンを指定して実行します。

npm install -g contentful-cli
contentful space export \
  --space-id your-space-id \
  --environment-id master \
  --management-token your-cma-token \
  --export-dir ./export

書き出した JSON には entries(エントリー)と assets(画像などのファイル)が入っています。注意したいのは、管理 API 形式の書き出しでは各フィールドがロケールごとに分かれていることです。たとえばタイトルは fields.title["ja-JP"] のように入っているので、スペースで使っているロケールコードを先に確認しておきます。下書き状態のエントリーも移したい場合は、CLI のオプションで下書きを含めるよう指定します(オプション名は CLI のヘルプで確認してください)。

本文の Rich Text フィールドは HTML ではなく、nodeType を持つ JSON の木構造です。ステップ3でこれを HTML に変換します。

ステップ2:Strapi から書き出す

Strapi は REST API から JSON を取得するのが扱いやすい方法です。Strapi の管理画面で読み取り専用の API トークンを発行し、記事のコレクションをページ単位で取得します。

mkdir -p export
curl -g -sS \
  -H "Authorization: Bearer your-api-token" \
  "https://cms.example.com/api/articles?pagination[page]=1&pagination[pageSize]=100&populate=*" \
  -o export/strapi-articles-1.json

-g は、URL に含まれる角かっこを curl が特殊文字として扱わないようにするためのオプションです。レスポンスの meta.pagination.pageCount を見て、ページ番号を変えながら全ページ分を取得します。1回に取得できる件数の上限はサーバー設定で変わるため、件数が足りないときは上限設定を確認します。

Strapi はメジャーバージョンでレスポンスの形が変わっています。v4 では各項目が data[].attributes の中にあり、v5 では attributes がなくなってフィールドが直接並び、documentId が加わりました(2026年10月現在の公式ドキュメントに基づく説明です)。populate の挙動もバージョンで差があるため、画像や関連が入っているかを実際の JSON で確かめてください。本文が「Rich text(Blocks)」型なら type と children を持つ JSON、旧来の Markdown 型ならMarkdown文字列で返ります。

ステップ3:インポートスクリプトを用意する

WordPress 側は WP-CLI の wp eval-file で PHP スクリプトを実行します。WordPress が読み込まれた状態で動くため、wp_insert_post() などのコア関数をそのまま使えます。スクリプトは公開ディレクトリの外(たとえばホームディレクトリ)に置き、作業後は削除します。

まず、両 CMS のデータを共通の形(source_id・タイトル・スラッグ・日付・HTML・画像)に揃える変換部分です。

<?php
// import-headless.php
if ( ! defined( 'WP_CLI' ) ) {
	exit;
}

// Contentful の Rich Text(nodeType の木構造)を HTML にする。
function wpdr_cf_node( $n, $assets, &$images ) {
	$type = $n['nodeType'] ?? '';
	if ( 'text' === $type ) {
		$s    = esc_html( $n['value'] ?? '' );
		$tags = array( 'bold' => 'strong', 'italic' => 'em', 'code' => 'code', 'underline' => 'u' );
		foreach ( $n['marks'] ?? array() as $m ) {
			if ( isset( $tags[ $m['type'] ] ) ) {
				$s = '<' . $tags[ $m['type'] ] . '>' . $s . '</' . $tags[ $m['type'] ] . '>';
			}
		}
		return $s;
	}
	$inner = '';
	foreach ( $n['content'] ?? array() as $c ) {
		$inner .= wpdr_cf_node( $c, $assets, $images );
	}
	if ( preg_match( '/^heading-([1-6])$/', $type, $m ) ) {
		$l = max( 2, (int) $m[1] );
		return "<h{$l}>{$inner}</h{$l}>\n";
	}
	switch ( $type ) {
		case 'document':       return $inner;
		case 'paragraph':      return "<p>{$inner}</p>\n";
		case 'unordered-list': return "<ul>{$inner}</ul>\n";
		case 'ordered-list':   return "<ol>{$inner}</ol>\n";
		case 'list-item':      return "<li>{$inner}</li>";
		case 'blockquote':     return "<blockquote>{$inner}</blockquote>\n";
		case 'hr':             return "<hr>\n";
		case 'hyperlink':
			return '<a href="' . esc_url( $n['data']['uri'] ?? '' ) . '">' . $inner . '</a>';
		case 'embedded-asset-block':
			$id = $n['data']['target']['sys']['id'] ?? '';
			if ( isset( $assets[ $id ] ) ) {
				$images[] = $assets[ $id ];
				return '{{img:' . ( count( $images ) - 1 ) . "}}\n";
			}
			return '';
	}
	WP_CLI::warning( 'Contentful: 未対応のノード ' . $type );
	return $inner;
}

// Strapi の Rich text(Blocks)を HTML にする。
function wpdr_st_node( $n, &$images, $base ) {
	if ( 'text' === ( $n['type'] ?? '' ) ) {
		$s = esc_html( $n['text'] ?? '' );
		foreach ( array( 'code' => 'code', 'bold' => 'strong', 'italic' => 'em', 'underline' => 'u', 'strikethrough' => 's' ) as $k => $tag ) {
			if ( ! empty( $n[ $k ] ) ) {
				$s = "<{$tag}>{$s}</{$tag}>";
			}
		}
		return $s;
	}
	$inner = '';
	foreach ( $n['children'] ?? array() as $c ) {
		$inner .= wpdr_st_node( $c, $images, $base );
	}
	switch ( $n['type'] ?? '' ) {
		case 'paragraph': return "<p>{$inner}</p>\n";
		case 'heading':
			$l = max( 2, min( 6, (int) ( $n['level'] ?? 2 ) ) );
			return "<h{$l}>{$inner}</h{$l}>\n";
		case 'list':
			$t = 'ordered' === ( $n['format'] ?? '' ) ? 'ol' : 'ul';
			return "<{$t}>{$inner}</{$t}>\n";
		case 'list-item': return "<li>{$inner}</li>";
		case 'quote':     return "<blockquote><p>{$inner}</p></blockquote>\n";
		case 'code':      return "<pre><code>{$inner}</code></pre>\n";
		case 'link':
			return '<a href="' . esc_url( $n['url'] ?? '' ) . '">' . $inner . '</a>';
		case 'image':
			$images[] = array(
				'url' => wpdr_abs( $n['image']['url'] ?? '', $base ),
				'alt' => $n['image']['alternativeText'] ?? '',
			);
			return '{{img:' . ( count( $images ) - 1 ) . "}}\n";
	}
	WP_CLI::warning( 'Strapi: 未対応のブロック ' . ( $n['type'] ?? '?' ) );
	return $inner;
}

function wpdr_abs( $url, $base ) {
	return ( 0 === strpos( $url, '/' ) && 0 !== strpos( $url, '//' ) ) ? rtrim( $base, '/' ) . $url : $url;
}

見出しレベルの最小を2にしているのは、WordPress では記事タイトルが h1 になるためです。文字列はすべて esc_html() を通してからタグで囲み、リンク先は esc_url() で検査しています。本文中の画像は、この時点ではまだ WordPress に存在しないので {{img:番号}} という仮の目印にしておき、投稿を作った後で差し替えます。

次に、書き出した JSON から共通形式のレコードを作る部分です。フィールド名(title・slug・body・heroImage・content・cover)は例なので、自分のコンテンツモデルに合わせて書き換えます。

function wpdr_from_contentful( $json, $locale, $content_type ) {
	$assets = array();
	foreach ( $json['assets'] ?? array() as $a ) {
		$file = $a['fields']['file'][ $locale ] ?? null;
		if ( $file ) {
			$assets[ $a['sys']['id'] ] = array(
				'url' => 'https:' . $file['url'],
				'alt' => $a['fields']['description'][ $locale ] ?? ( $a['fields']['title'][ $locale ] ?? '' ),
			);
		}
	}
	$out = array();
	foreach ( $json['entries'] ?? array() as $e ) {
		if ( ( $e['sys']['contentType']['sys']['id'] ?? '' ) !== $content_type ) {
			continue;
		}
		$f      = $e['fields'];
		$images = array();
		$hero   = $f['heroImage'][ $locale ]['sys']['id'] ?? '';
		$out[]  = array(
			'source_id' => 'contentful:' . $e['sys']['id'],
			'title'     => $f['title'][ $locale ] ?? '',
			'slug'      => $f['slug'][ $locale ] ?? '',
			'date_gmt'  => $e['sys']['firstPublishedAt'] ?? $e['sys']['createdAt'],
			'html'      => isset( $f['body'][ $locale ] ) ? wpdr_cf_node( $f['body'][ $locale ], $assets, $images ) : '',
			'images'    => $images,
			'cover'     => $assets[ $hero ] ?? null,
		);
	}
	return $out;
}

function wpdr_from_strapi( $json, $base ) {
	$out = array();
	foreach ( $json['data'] ?? array() as $item ) {
		if ( isset( $item['attributes'] ) ) { // v4 形式を v5 形式に揃える。
			$item = array_merge( array( 'id' => $item['id'] ), $item['attributes'] );
		}
		$images = array();
		$html   = '';
		foreach ( (array) ( $item['content'] ?? array() ) as $block ) {
			$html .= wpdr_st_node( $block, $images, $base );
		}
		$cover = $item['cover']['data']['attributes'] ?? ( $item['cover'] ?? null );
		$out[] = array(
			'source_id' => 'strapi:' . ( $item['documentId'] ?? $item['id'] ),
			'title'     => $item['title'] ?? '',
			'slug'      => $item['slug'] ?? '',
			'date_gmt'  => $item['publishedAt'] ?? ( $item['createdAt'] ?? '' ),
			'html'      => $html,
			'images'    => $images,
			'cover'     => empty( $cover['url'] ) ? null : array(
				'url' => wpdr_abs( $cover['url'], $base ),
				'alt' => $cover['alternativeText'] ?? '',
			),
		);
	}
	return $out;
}

Contentful の画像 URL は //images.ctfassets.net/... のようにプロトコルなしで入っているため https: を付けています。Strapi のメディアは、ローカル保存の場合 /uploads/... のような相対パスで返るので、Strapi のベース URL を前に付けています。

ステップ4:投稿とメディアを登録する

最後に、レコードを投稿として登録する部分と、実行の入口です。_source_id メタで旧 CMS の ID を記録し、同じスクリプトを何度実行しても投稿が重複しないようにします。

require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';

function wpdr_media( $url, $post_id, $alt ) {
	$found = get_posts( array(
		'post_type' => 'attachment', 'post_status' => 'inherit', 'fields' => 'ids',
		'meta_key'  => '_source_url', 'meta_value' => $url, 'posts_per_page' => 1,
	) );
	if ( $found ) {
		return (int) $found[0];
	}
	$id = media_sideload_image( $url, $post_id, null, 'id' );
	if ( is_wp_error( $id ) ) {
		WP_CLI::warning( $url . ': ' . $id->get_error_message() );
		return 0;
	}
	update_post_meta( $id, '_source_url', $url );
	if ( $alt ) {
		update_post_meta( $id, '_wp_attachment_image_alt', sanitize_text_field( $alt ) );
	}
	return $id;
}

function wpdr_upsert( $r, $dry ) {
	$found = get_posts( array(
		'post_type' => 'post', 'post_status' => 'any', 'fields' => 'ids', 'no_found_rows' => true,
		'meta_key'  => '_source_id', 'meta_value' => $r['source_id'], 'posts_per_page' => 1,
	) );
	$post_id = $found ? (int) $found[0] : 0;

	if ( $post_id && 'publish' === get_post_status( $post_id ) ) {
		WP_CLI::log( 'skip(公開済み): ' . $r['source_id'] );
		return;
	}
	if ( $dry ) {
		WP_CLI::log( sprintf( '[dry-run] %s %s %s', $post_id ? 'update' : 'create', $r['source_id'], $r['title'] ) );
		return;
	}

	$gmt     = $r['date_gmt'] ? gmdate( 'Y-m-d H:i:s', strtotime( $r['date_gmt'] ) ) : current_time( 'mysql', true );
	$post_id = wp_insert_post( array(
		'ID'            => $post_id,
		'post_type'     => 'post',
		'post_status'   => 'draft',
		'post_title'    => sanitize_text_field( $r['title'] ),
		'post_name'     => sanitize_title( $r['slug'] ),
		'post_date_gmt' => $gmt,
		'post_date'     => get_date_from_gmt( $gmt ),
	), true );
	if ( is_wp_error( $post_id ) ) {
		WP_CLI::warning( $r['source_id'] . ': ' . $post_id->get_error_message() );
		return;
	}
	update_post_meta( $post_id, '_source_id', $r['source_id'] );

	$html = preg_replace_callback( '/\{\{img:(\d+)\}\}/', function ( $m ) use ( $r, $post_id ) {
		$img = $r['images'][ (int) $m[1] ] ?? null;
		$att = $img ? wpdr_media( $img['url'], $post_id, $img['alt'] ) : 0;
		return $att ? sprintf(
			'<figure><img src="%s" alt="%s" class="wp-image-%d"></figure>',
			esc_url( wp_get_attachment_image_url( $att, 'large' ) ), esc_attr( $img['alt'] ), $att
		) : '';
	}, $r['html'] );

	wp_update_post( array( 'ID' => $post_id, 'post_content' => wp_kses_post( $html ) ) );

	if ( $r['cover'] ) {
		$att = wpdr_media( $r['cover']['url'], $post_id, $r['cover']['alt'] );
		if ( $att ) {
			set_post_thumbnail( $post_id, $att );
		}
	}
	WP_CLI::log( 'ok: ' . $r['source_id'] . ' → ' . $post_id );
}

// wp eval-file import-headless.php <JSON> <contentful|strapi> [run]
$file   = $args[0] ?? '';
$source = $args[1] ?? '';
$dry    = 'run' !== ( $args[2] ?? '' );
$json   = json_decode( (string) file_get_contents( $file ), true );
if ( ! is_array( $json ) ) {
	WP_CLI::error( 'JSON を読み込めません: ' . $file );
}
$records = 'contentful' === $source
	? wpdr_from_contentful( $json, 'ja-JP', 'blogPost' )
	: wpdr_from_strapi( $json, 'https://cms.example.com' );

foreach ( $records as $r ) {
	wpdr_upsert( $r, $dry );
}
WP_CLI::success( count( $records ) . ' 件を処理しました' . ( $dry ? '(ドライラン)' : '' ) );

取り込んだ投稿はすべて下書きにしています。公開済みの投稿は再実行時に上書きしないので、確認を終えて公開した記事が、スクリプトの修正後の再実行で戻ってしまうことはありません。

ステップ5:試し実行から本番の取り込みまで

  1. wp eval-file import-headless.php export/contentful-export.json contentful のように、第3引数なしで実行します。これはドライランで、作成・更新の予定だけが表示されます。件数と未対応ノードの警告を確認します。
  2. 問題がなければ数件だけのテスト用 JSON で run を付けて実行し、管理画面で本文・画像・アイキャッチ・日付を目で確認します。
  3. 全件を run で取り込みます。件数が多い場合は JSON を分割して複数回に分けると、途中で止まったときの影響を小さくできます。
  4. 本文中に旧サイトの URL が残っている場合は、wp search-replace を --dry-run 付きで実行して影響範囲を確かめてから置換します。
  5. Markdown 型の本文は、そのままでは見出しや強調が反映されません。Markdown を HTML に変換するライブラリを組み込むか、該当記事を手作業で整えます。

ステップ6:URL の対応とリダイレクト

旧サイトと URL が変わる記事には 301 リダイレクトを設定します。wp post list --post_type=post --post_status=any --meta_key=_source_id --fields=ID,post_name --format=csv で取り込んだ投稿の一覧を CSV で書き出し、旧 URL と突き合わせて対応表を作ります。設定は、管理画面で扱いたいなら Redirection プラグインの CSV インポート、件数が多いなら Web サーバーの設定で行うのが一般的です。

DNS を切り替えた後は、主要な旧 URL に実際にアクセスして新しい記事へ転送されるかを確認し、Google Search Console でサイトマップを再送信してクロールエラーの推移を見ます。

まとめ

  • 移行は「書き出し」「変換」「取り込み」に分け、旧 CMS の ID を _source_id に保存して再実行できるようにします。
  • Contentful は CLI で JSON に書き出し、ロケール別のフィールドと Rich Text の木構造に注意します。
  • Strapi は REST API で取得し、v4 と v5 でレスポンスの形が違う点を吸収します。
  • 本文は文字列をエスケープしてから HTML に組み立て、画像はメディアライブラリへ取り込んでから差し替えます。
  • ドライラン、少数件のテスト、全件の順に進め、最後に URL の対応表から 301 リダイレクトを設定します。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲