株式会社メンバーズ メンバーズルーツカンパニーの岡田です。
本記事は、WordPressからmicroCMSにコンテンツを移行する方法のチュートリアルです。
前回は「準備編」として、移行手段の選定や移行の流れなどについて解説をしました。今回は、「実行編」として、移行に用いる具体的なサンプルコードの解説をメインに説明します。
前回のおさらい
まずは、前回の記事でご紹介した移行プロセスの全体像について改めておさらいをします。
移行には、大きく2つのステップがあります。
STEP1はWordPressからデータを取得するプロセスで、STEP2は取得したデータをmicroCMSに登録するプロセスです。
STEP1では、基本的にWordPressの管理画面およびサーバから投稿データとメディアを取得します。
STEP2では、コード(Node.js)を用意し、自動で処理を実行します。
STEP1については「準備編」で解説したとおりなので、ここからは、具体的にSTEP2で用いるサンプルコードを示しながら、どのように移行を実行していくかを説明していきます。
WordPressから取得したデータをmicroCMSに登録するサンプルコード
スクリプトを実行した際のイメージ
具体的なコードの解説に入る前に、まずはイメージを持っていただくために、実行時の動画をお見せします。
動画では、STEP1でWordPressおよびmicroCMSの準備を整えた後に移行スクリプトを実行をしています。完全に自動でmicroCMS側にコンテンツが移行されていることが分かるかと思います。
(スクリプトを実行するシーンは 1:32 〜)
サンプルコードの構成
まずは、今回ご紹介するサンプルコードのリポジトリを貼っておきます。全体像についてはこちらでご確認ください。
https://github.com/MEM02210/wp2microcms
フォルダ構成は、下記のようになります。data
フォルダには、WordPressからエクスポートしたデータを配置します。src
フォルダには、移行処理を記述したコードのファイルを配置します。
/data
│ ├ /media // WordPressからエクスポートしたメディアファイルを格納する
│ │ └ /2024 // /data/media配下のディレクトリを再帰的に走査し、ファイルを取得します
│ │ └ /10
│ │ └ /sample.png // アップロードするメディアファイル例
│ └ /WordPress.YYYY-MM-DD.xml // WordPressからエクスポートした投稿データ
/src
│ ├ /libs // ライブラリ
│ │ └ /...
│ ├ /config.js // 設定ファイル
│ ├ /media.js // メディアファイルのアップロード用スクリプト
│ └ /main.js // 投稿データの登録用スクリプト
.env // 環境変数ファイル
package.json // パッケージファイル
README.md // 説明ファイル
(一部省略)
設定ファイル(config.js)
設定ファイル( /src/config.js
)には、WordPressからエクスポートしたデータのパスや移行対象の投稿タイプ、タクソノミー名(*1)、microCMSのAPIエンドポイント名、APIスキーマを記載します。今回のスクリプトでは、ステータスの設定(*2)やAPIの実行の有無の設定も可能としています。
// /src/config.js
export const config = {
xmlPath: "WordPress.YYYY-MM-DD.xml", // 読み取るXMLファイル名(dataフォルダ直下に配置)
postType: "post", // WordPressの移行対象の投稿タイプ
draft: true, // 下書き投稿を移行するかどうか
apiName: "post", // microCMSのAPIのエンドポイント名
apiSchema: {
title: true, // テキストフィールド:タイトル(必須)
contents: true, // リッチエディタ:本文コンテンツ
author: true, // テキストフィールド:著者
eyecatch: true, // 画像:アイキャッチ画像
categories: true, // 複数コンテンツ参照:カテゴリを移行するかどうか
tags: true // 複数コンテンツ参照:タグを移行するかどうか
}, // microCMSのAPIのスキーマ設定
category: {
execute: true, // カテゴリ移行を実行するかどうか
taxonomy: "category", // WordPressのカテゴリタクソノミー名(/wp-admin/edit-tags.php?taxonomy=XXXX)
apiName: "category", // microCMSのカテゴリ用APIのエンドポイント名
apiSchema: {
name: true, // テキストフィールド:カテゴリー名(必須)
description: false // テキストエリア:カテゴリー説明を移行するかどうか
} // microCMSのカテゴリ用APIのスキーマ設定
},
tag: {
execute: true, // タグ移行を実行するかどうか
taxonomy: "post_tag", // WordPressのタグタクソノミー名(/wp-admin/edit-tags.php?taxonomy=XXXX)
apiName: "tag", // microCMSのタグ用APIのエンドポイント名
apiSchema: {
name: true, // テキストフィールド:タグ名(必須)
description: false // テキストエリア:タグ説明を移行するかどうか
} // microCMSのタグ用APIのスキーマ設定
},
};
APIスキーマには、WordPressの標準的なデータのフィールドをあらかじめ設定していますが、別途フィールドを追加する場合には、スクリプトにも記述を追加する必要があります。
(*1)タクソノミーとは?
タクソノミーとは、物事を分類するための体系やルールを指す用語です。WordPressでは、コンテンツの属性を「カテゴリー」や「タグ」といった分類で管理しています。これらの管理情報を移行することで、microCMSでも同様の管理が可能となります。
(*2)ステータスの設定
コンテンツAPIのPOST/PUTにおいて、status=draft
というパラメータを付与すると下書き状態で登録することができます(Teamプラン以上のみ)。
環境変数ファイル(.env)
環境変数ファイル( .env
)には、microCMSのサービスドメイン名やAPIキー、WordPressのメディアファイルのベースパスを記載します。
MICROCMS_SERVICE_DOMAIN=your-service
MICROCMS_API_KEY=your-api-key
WORDPRESS_MEDIA_BASE_PATH=https://example.com/wp-content/uploads/
サンプルコードの解説
メディアのアップロードとコンテンツのアップロードはそれぞれ別のファイルで実行します。
1. メディアのアップロード
まずはメディアのアップロード( media.js
)について説明します。コードの全体像はこちらです。
// /src/media.js
import fs from "fs";
import FormData from "form-data";
import axios from "axios";
import { glob } from "glob";
import "dotenv/config";
const serviceDomain = process.env.MICROCMS_SERVICE_DOMAIN;
const apiKey = process.env.MICROCMS_API_KEY;
const wordpressMediaBasePath = process.env.WORDPRESS_MEDIA_BASE_PATH;
// microCMSのマネジメントAPIに画像をアップロードする関数
const postMediaData = async (filePath) => {
try {
const form = new FormData();
form.append("file", fs.createReadStream(filePath));
const response = await axios.post(`https://${serviceDomain}.microcms-management.io/api/v1/media`, form, {
headers: {
...form.getHeaders(),
"X-MICROCMS-API-KEY": apiKey
}
});
console.log(`[Success] Uploaded image ${filePath}`);
return response.data;
} catch (error) {
console.error(`[Error] uploading image ${filePath}:`, error.message);
throw error;
}
};
// 画像ファイルごとにAPIリクエストを実行する
const postAllMediaData = async () => {
// /data/mediaフォルダのファイルを再帰的に取得する
const images = glob.sync("./data/media/**/*", {
nodir: true, // ディレクトリを除外するかどうか
});
const uploadResults = [];
for (const image of images) {
try {
const result = await postMediaData(image);
const oldUrl = wordpressMediaBasePath + image.replace(/\\/g, "/").replace(/^data\/media\//, "");
const newUrl = result.url;
// レスポンスのURLを利用して対応表データを作成する
uploadResults.push({ oldUrl, newUrl });
} catch (error) {
console.error(`[Error] Failed to upload ${image}`);
}
// レートリミット(IPアドレス単位):10回 / 10秒
await new Promise((resolve) => setTimeout(resolve, 1000));
}
// 作成した対応表データを/data/フォルダにmigrateMedia.jsonとして保存する
if (uploadResults.length !== 0) {
fs.writeFileSync("./data/migrateMedia.json", JSON.stringify(uploadResults, null, 2));
console.log("Created data/migrateMedia.json");
}
};
postAllMediaData();
/data/media/
フォルダに格納されているファイルを再帰的に取得し、microCMSのマネジメントAPIを利用してアップロードするスクリプトです。
利用しているライブラリ
・glob
:ファイルパターンマッチングライブラリで、指定したパターンに一致するファイルを再帰的に検索します。
・FormData
:フォームデータを構築するためのライブラリで、ファイルのアップロードに使用されます。このスクリプトでは、FormDataを使用してファイルをmicroCMSのAPIに送信します。
・fs
:ファイルシステムモジュールで、ファイルの読み書きを行います。このスクリプトでは、fsを使用してファイルを読み込み、アップロード結果をJSONファイルとして保存します。
・axios
:HTTPクライアントライブラリで、HTTPリクエストを簡単に送信できます。
APIキーの権限設定
こちらのスクリプトを実行する際には、APIキーの権限設定でマネジメントAPIの「メディアのアップロード」を許可することを確認してください。
マネジメントAPIのレートリミットへの対応
マネジメントAPIには、特定の時間内にAPIに対して送信できるリクエストの最大数を制限する仕組み(=レートリミット)が設定されています。
サンプルコードでは、setTimeout関数を利用して各リクエストの間隔を空けることで、このレートリミットに対応しています。
マネジメントAPIの制限事項の詳細は、以下のmicroCMS公式ドキュメントをご覧ください。
マネジメントAPI(ベータ)に関する制限事項
アップロード成功時の対応
ファイルのアップロードに成功した場合には、/data/migrateMedia.json
にアップロードしたファイルのURLとWordPressのメディアファイルのURLの対応表を保存します。
この対応表は、投稿データに含まれるWordPressのメディアファイルの記述を、microCMSにアップロードしたメディアファイルを参照する記述に置き換えるために利用します。migrationMedia.json
のフォーマットは以下のようになります。
[
{
"oldUrl": "https://example.com/wp-content/uploads/YYYY/MM/sample.png",
"newUrl": "https://your-service.microcms.io/media/XXXXXXXXXX"
},
...
]
2. 投稿データのアップロード
続いて、投稿データをアップロードする処理( main.js
)について、部分ごとに分けながら解説していきます。
WordPressの投稿データを読み込む
WordPressからエクスポートしたXMLファイルを読み込み、投稿データを取得します。
import fs from "fs";
import xml2js from "xml2js";
import { config } from "./config.js";
const xmlFile = fs.readFileSync(`./data/${config.xmlPath}`, "utf-8");
const xmlData = await loadXml(xmlFile);
const loadXml = async (data) => {
return new Promise((resolve, reject) =>
xml2js.parseString(data, (err, result) => {
if (err) {
return reject(err);
} else {
return resolve(result);
}
})
);
};
XMLファイルは下記のような構造になっています。xml2js
ライブラリを使用してJavaScriptオブジェクトとしてそれぞれのデータを参照できるように変換します。
// data/WordPress.YYYY-MM-DD.xml
(省略)
<wp:term>
<wp:term_id>9</wp:term_id>
<wp:term_taxonomy><![CDATA[category]]></wp:term_taxonomy>
<wp:term_slug><![CDATA[important-notice]]></wp:term_slug>
<wp:term_parent><![CDATA[]]></wp:term_parent>
<wp:term_name><![CDATA[重要なお知らせ]]></wp:term_name>
</wp:term>
(省略)
<item>
<title><![CDATA[シンプル投稿]]></title>
<link>http://localhost:8080/%e3%83%86%e3%82%b9%e3%83%88%e6%8a%95%e7%a8%bf/</link>
<pubDate>Wed, 07 Aug 2024 07:26:55 +0000</pubDate>
<dc:creator><![CDATA[admin]]></dc:creator>
<content:encoded><![CDATA[]]></content:encoded>
<wp:post_id>6</wp:post_id>
<wp:post_date><![CDATA[2024-08-07 16:26:55]]></wp:post_date>
<wp:post_date_gmt><![CDATA[2024-08-07 07:26:55]]></wp:post_date_gmt>
<wp:status><![CDATA[publish]]></wp:status>
<wp:post_type><![CDATA[post]]></wp:post_type>
<category domain="category" nicename="important-notice"><![CDATA[重要なお知らせ]]></category>
(一部省略)
</item>
(省略)
POSTリクエストを実行する関数を定義する
投稿およびタクソノミー(カテゴリ/タグ)の登録のどちらにも利用するPOSTリクエストを実行する関数をあらかじめ定義しておきます。
import "dotenv/config";
const serviceDomain = process.env.MICROCMS_SERVICE_DOMAIN;
const apiKey = process.env.MICROCMS_API_KEY;
const executePostAPI = async (endpoint, arrayRequestBody, isDraft = false) => {
try {
// isDraftがtrueの場合、status=draftをクエリパラメータとして追加して下書き状態でコンテンツを作成する
const url = isDraft
? `https://${serviceDomain}.microcms.io/api/v1/${endpoint}?status=draft`
: `https://${serviceDomain}.microcms.io/api/v1/${endpoint}`;
const requestParameters = [];
let count = 0;
let listCount = 0;
// リクエストパラメータのまとめ処理
// microCMSのAPI制限があるので、5個ずつにまとめる
arrayRequestBody.forEach((requestBody) => {
if (count <= 0) {
requestParameters.push([]);
}
const parameters = {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-MICROCMS-API-KEY": apiKey
},
body: JSON.stringify(requestBody)
};
requestParameters[listCount].push(parameters);
count++;
if (count >= 5) {
count = 0;
listCount++;
}
});
// 複数のfetchを並行して実行
let log = "";
const allResult = [];
for (let i = 0; i < requestParameters.length; i++) {
console.log("########################################");
console.log(`${i + 1}/${requestParameters.length}`);
const fetchProcesses = [];
requestParameters[i].forEach((parameters) => {
console.log(parameters.body.substr(0, 70));
fetchProcesses.push(fetch(url, parameters));
});
const results = await Promise.all(fetchProcesses);
for (let j = 0; j < results.length; j++) {
const data = JSON.parse(requestParameters[i][j].body);
const message = await results[j].json();
allResult.push({ ...data, ...message });
if (results[j].status !== 201) {
log += "--------------------------------------------\n";
log += message + "\n";
log += requestParameters[i][j].body + "\n";
log += "--------------------------------------------\n\n";
console.log("----ERROR-----------------------------------");
console.log(message);
console.log(requestParameters[i][j].body.substr(0, 70));
console.log("--------------------------------------------");
}
}
// WRITE API(POST / PUT)のレートリミット(サービス単位):5回 / 1秒
await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (log !== "") fs.writeFileSync("./data/log.txt", log);
return allResult;
} catch (error) {
console.error(error);
}
};
この関数は、fetch
関数を使用して複数のリクエストを並行して実行します。リクエストパラメータを5個ずつまとめ、Promise.all
を利用して一度に送信することで、APIのレートリミットに対応しています。
(今回は複数のリクエストを並行して実行していますが、リクエストの負荷によって500番台のエラーが返却される場合は、並列に送信するリクエスト数を減らすか、直列にリクエストをするなどの方法をお試しください)
下書き投稿を登録する場合には、status=draft
をクエリパラメータとして追加してリクエストを実行することで下書き状態でコンテンツの登録を行うことを可能としています。
また、今回のスクリプトでは、コンテンツIDの重複を防ぐ目的とmicroCMSに登録するコンテンツIDを一意なものにして投稿記事とタクソノミーの紐付けを行えるようにするために、下記のようにIDとなるキーワードをハッシュ化する関数を利用します。
import crypto from "crypto";
const hashKeyword = (keyword, length = 10) => {
// SHA-256ハッシュを生成
const hash = crypto.createHash("sha256").update(keyword).digest("hex");
// 指定された桁数に制限
return hash.substring(0, length);
};
※microCMSの仕様として、IDを指定せずにリクエストした場合、API設定で利用が許可された文字列からランダムに生成されるようにはなっています。
「カテゴリ」と「タグ」をアップロードする
設定ファイル(/src/config.js
)に記載されたタクソノミー名に一致するデータをXMLデータオブジェクトから抽出し、タクソノミーの登録を行います。下記のコードは「カテゴリ」のアップロード処理ですが、「タグ」のアップロードを行う場合も同様に実行します。
if (config.category.execute) {
if (config.category.taxonomy) {
const arrayTerm = [];
xmlData.rss.channel[0]["wp:term"].forEach((term) => {
if (term["wp:term_taxonomy"][0] === config.category.taxonomy) {
arrayTerm.push(term);
}
});
if (arrayTerm.length !== 0) {
const categoryList = [];
arrayTerm.forEach((term) => {
const category = {};
category.id = hashKeyword(term["wp:term_slug"][0]);
const apiSchema = config.category.apiSchema;
if (apiSchema.name) category.name = term["wp:term_name"] ? term["wp:term_name"][0] : null;
if (apiSchema.description) category.description = term["wp:term_description"] ? term["wp:term_description"][0] : null;
categoryList.push(category);
});
// categoryList = [{id: "XXXXXXXXXX", name: "hogehoge", description: "fugafuga"}, ...]
// カテゴリ用のコンテンツAPIに送信
const result = await executePostAPI(config.category.apiName, categoryList);
// console.log("result:", result);
console.log("カテゴリの登録処理が完了しました。");
} else {
console.log("[Warning] config.category.taxonomyに設定されたカテゴリが見つかりませんでした。");
}
} else {
console.log("[Error] config.category.taxonomyが設定されていません。");
}
}
投稿データのアップロードを実行する
設定ファイル( /src/config.js
)に記載された投稿タイプに一致するデータをXMLデータオブジェクトから抽出し、投稿データのアップロードを行います。
const contents = [];
const draftContents = [];
const items = xmlData.rss.channel[0].item;
// メディアデータの取得
const attachmentItems = items.filter((item) => item["wp:post_type"][0] === "attachment");
// メディアの移行対応表の取得
let migrateMedia = [];
if (fs.existsSync("./data/migrateMedia.json")) {
migrateMedia = JSON.parse(fs.readFileSync("./data/migrateMedia.json", "utf-8"));
} else {
console.log("[Warning] data/migrateMedia.jsonが見つかりませんでした。\n画像の移行処理を先に行わない場合、画像の移行は行われません。");
}
// 指定した投稿タイプかつ公開済みの記事のみを抽出
let filteredItems = items.filter(
(item) => item["wp:post_type"][0] === config.postType && item["wp:status"][0] === "publish"
);
// config.draftがtrueの場合、未公開記事を抽出して実行する
const draftStatuses = ["draft", "future", "pending", "private", "auto-draft"];
if (config.draft) {
const draftItems = items.filter(
(item) => item["wp:post_type"][0] === config.postType && draftStatuses.includes(item["wp:status"][0])
);
filteredItems.push(...draftItems);
}
if (filteredItems.length !== 0) {
let i = 0;
filteredItems.forEach((item) => {
// ローデータ用のオジェクト定義
const contentsItem = {};
// コンテンツID(組み込みスキーマ)の設定
contentsItem["id"] = hashKeyword(item["wp:post_id"][0]);
// 下書き記事かどうかの判定
const isDraftItem = draftStatuses.includes(item["wp:status"][0]);
// 公開日時(組み込みスキーマ)の設定
if (!isDraftItem) {
contentsItem["publishedAt"] = new Date(item["wp:post_date_gmt"][0]);
}
// タイトルの設定
if (config.apiSchema.title) contentsItem["title"] = item["title"][0];
// 本文コンテンツの設定
if (config.apiSchema.contents) {
const contentsBody = replaceContent(item["content:encoded"][0], migrateMedia);
contentsItem["contents"] = contentsBody;
}
// 著者の設定
if (config.apiSchema.author) contentsItem["author"] = item["dc:creator"][0];
// アイキャッチ画像の設定
if (config.apiSchema.eyecatch && !isDraftItem) {
if (item["wp:postmeta"] && migrateMedia.length !== 0) {
const thumbnailMeta = item["wp:postmeta"].find(
(meta) => meta["wp:meta_key"][0] === "_thumbnail_id"
);
if (thumbnailMeta) {
const thumbnailPostId = thumbnailMeta["wp:meta_value"][0];
// メディアの検索
attachmentItems.some((attachment) => {
if (attachment["wp:post_id"][0] === thumbnailPostId) {
const attachment_url = attachment["wp:attachment_url"][0];
migrateMedia.some((media) => {
if (media["oldUrl"] === attachment_url) {
contentsItem["eyecatch"] = media["newUrl"];
return true;
}
});
return true;
}
});
}
}
}
// カテゴリー/タグの設定
if (item["category"] !== undefined) {
const categories = [];
const tags = [];
item["category"].forEach((taxonomyItem) => {
if (taxonomyItem["$"]["domain"] === config.category.taxonomy) {
categories.push(hashKeyword(taxonomyItem["$"]["nicename"]));
} else if (taxonomyItem["$"]["domain"] === config.tag.taxonomy) {
tags.push(hashKeyword(taxonomyItem["$"]["nicename"]));
}
});
if (config.apiSchema.categories && categories.length !== 0) contentsItem["categories"] = categories;
if (config.apiSchema.tags && tags.length !== 0) contentsItem["tags"] = tags;
}
if (isDraftItem) {
draftContents.push(contentsItem);
} else {
contents.push(contentsItem);
}
i++;
});
const result = await executePostAPI(config.apiName, contents);
if (config.draft) {
const resultDraft = await executePostAPI(config.apiName, draftContents, true);
}
// console.log("result:", result);
console.log("コンテンツの登録処理が完了しました。");
} else {
console.log("[Warning] config.postTypeに設定された投稿タイプが見つかりませんでした。");
}
今回のスクリプトでは、WordPressのメディアファイルのURLを、メディアのアップロード時に取得できるmicroCMSのメディアファイルのURLに差し替えています。
差し替え処理の対象となる要素は、アイキャッチ画像(画像フィールド)と本文(リッチエディタ)で使われている画像です。それぞれの画像のURLに対して、メディアのアップロード時に作成した migrationMedia.json
に一致するデータがある場合にURLの差し替えを行います。
リッチエディタのパース処理
また、本文のコンテンツのみ、WordPressのリッチエディタで作成されるHTML形式のテキストでエクスポートされるため、別途下記の関数を用意してパースする処理を加えています。
HTML形式の文字列データをJavaScriptで扱いやすくするために、cheerio
ライブラリを使用して置換処理を行っています。
その他にも、本文のコンテンツに含まれるHTMLを操作したい場合には、こちらのライブラリを利用した関数内に処理を追加することで、HTMLの操作を行うことが可能です。
import * as cheerio from "cheerio";
import "dotenv/config";
const wordpressMediaBasePath = process.env.WORDPRESS_MEDIA_BASE_PATH;
const replaceContent = (content, migrateMedia) => {
const cheerioDom = cheerio.load(content);
// 画像の置換処理
cheerioDom("img").replaceWith((index, element) => {
const oldSrc = cheerioDom(element).attr("src");
// WordPressのメディアパスに一致するものがあれば、microCMSのメディアパスに置換する
if (oldSrc.includes(wordpressMediaBasePath)) {
if (migrateMedia.length > 0) {
migrateMedia.some((media) => {
if (media["oldUrl"] === oldSrc) {
const newSrc = media["newUrl"];
cheerioDom(element).attr("src", newSrc);
return true;
}
});
} else {
console.log("[Error] replaceContent関数の第二引数のmigrateMediaが不正です。");
}
}
return cheerioDom(element).toString();
});
return cheerioDom("body").html();
};
以上がサンプルコードの紹介となります。
改めて、今回紹介したサンプルコードは、下記のリポジトリで公開しています。
スクリプトの全体像を確認したり、実際の移行に利用する際の参考にしていただければ幸いです。
迅速な返答はお約束できませんが、プルリクエストやイシュー報告も歓迎しておりますので、皆さまの意見やフィードバックをお待ちしています。
https://github.com/MEM02210/wp2microcms
おわりに
2回にわたり、WordPressからmicroCMSへコンテンツを移行する方法を解説しました。
一般的なWordPressのユースケースであれば、本記事でご紹介した方法やCSVインポート機能を利用することで移行が可能となるかと思います。
しかしながら、WordPressはカスタマイズやプラグインを利用することで機能を拡張することが可能であり、また、WordPressのバージョンアップによって機能が追加されることもあるため、移行データの形式が多様であることが予想されます。
そのため、本記事でご紹介した方法にて移行が実施できない場合には、microCMSのサポートにお問い合わせいただくか、microCMSのパートナー企業にご相談いただくことをお勧めいたします。
https://microcms.io/partners-list
WordPressからmicroCMSへの移行を検討されている方は、本記事が参考になれば幸いです。