WordPressの書き出しファイル(WXR)をEmDashへ取り込む作業は、EmDashの管理画面のウィザードを使わずに、APIを順に呼ぶスクリプトでも実行できます。データベースを作り直しながら取り込みを何度も試す時は、ファイルを選んでボタンを押す操作を毎回繰り返さずに済みます。呼ぶ順番は、ウィザードと同じく analyze → prepare → execute → media → rewrite-urls です。
確かめたのはEmDash 1.3.1です。公式ドキュメントには管理画面での手順が書かれていますが、APIを直接呼ぶ方法は見つからなかったので、管理画面のJavaScriptから呼び出しの順番と送る内容を読み取りました。管理画面のためのAPIなので、バージョンが上がると変わる可能性があります。
5つのAPIの役割
| API | 役割 |
|---|---|
analyze |
WXRを解析して、投稿タイプ・添付ファイル・必要なフィールドを返す |
prepare |
取り込み先のコレクションとフィールドを作る |
execute |
本文を取り込む。taxonomy・content・finalizeの3段階に分かれ、contentはdoneが返るまで繰り返す |
media |
元のサイトから画像をダウンロードして登録する |
rewrite-urls |
本文中の旧画像URLを新しいURLに置き換える |
executeには、毎回WXRファイルそのものを送ります。前の段階で返されたcursorとchunkを次の呼び出しに渡して、続きから処理させる仕組みです。
ログインしたセッションを用意する
APIは、ログインしたセッションで呼びます。開発サーバーなら、/_emdash/api/setup/dev-bypassにPOSTすると開発用の管理者でログインできるので、Cookieをcookie.txtに保存しておきます。?content=0は、テンプレートのサンプル記事を入れない指定です。このエンドポイントは開発サーバーでだけ有効です。
curl -s -c cookie.txt -b cookie.txt -X POST "http://localhost:4321/_emdash/api/setup/dev-bypass?content=0"
ログインが必要なAPIは、CSRF対策としてX-EmDash-Request: 1ヘッダーが付いているかを確かめます。スクリプトでは、このヘッダーを毎回付けています(あわせてOriginも付けています)。
取り込みのスクリプト
実際に取り込みに使ったスクリプトです。スクリプトはscripts/import-wxr.mjsに置きます。Cookieは、プロジェクト直下に保存したcookie.txtから読み込みます。Node.js 24で、node scripts/import-wxr.mjs export.xmlのように実行します。
import fs from "node:fs";
import path from "node:path";
const BASE = "http://localhost:4321/_emdash/api";
const ROOT = path.resolve(import.meta.dirname, "..");
// curl の Netscape 形式 cookie.txt からセッション cookie を組み立てる
function loadCookie() {
const lines = fs.readFileSync(path.join(ROOT, "cookie.txt"), "utf8").split("\n");
return lines
.filter((l) => l && (!l.startsWith("#") || l.startsWith("#HttpOnly_")))
.map((l) => l.split("\t"))
.filter((cols) => cols.length >= 7)
.map((cols) => `${cols[5]}=${cols[6]}`)
.join("; ");
}
const headers = {
Cookie: loadCookie(),
// 管理画面の apiFetch と同じく、CSRF 対策のヘッダーを付ける
"X-EmDash-Request": "1",
Origin: "http://localhost:4321",
};
// API を呼んで { success, data } の data を返す。失敗したら中身を出して止める
async function api(endpoint, init) {
const res = await fetch(`${BASE}${endpoint}`, { ...init, headers: { ...headers, ...init.headers } });
const json = await res.json().catch(() => null);
if (!res.ok || !json?.success) {
console.error(`NG ${endpoint} (${res.status})`, JSON.stringify(json)?.slice(0, 1000));
process.exit(1);
}
return json.data;
}
const wxrPath = process.argv[2];
const wxrBuf = fs.readFileSync(wxrPath);
// FormData には毎回新しい Blob を入れる(同じファイルを何度も送るため)
const wxrFile = () => new File([wxrBuf], path.basename(wxrPath), { type: "application/xml" });
// ---- 1. analyze
const fd1 = new FormData();
fd1.append("file", wxrFile());
const analysis = await api("/import/wordpress/analyze", { method: "POST", body: fd1 });
const postTypes = analysis.postTypes.filter((p) => p.suggestedCollection);
console.log("analyze:", postTypes.map((p) => `${p.name}=${p.count}`).join(", "));
// ---- 2. prepare(posts / pages コレクションに必要なフィールドを揃える)
const prepared = await api("/import/wordpress/prepare", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
postTypes: postTypes.map((p) => ({ name: p.name, collection: p.suggestedCollection, fields: p.requiredFields })),
}),
});
console.log("prepare:", JSON.stringify(prepared));
// ---- 3. execute(taxonomy → content を done まで繰り返す → finalize)
const config = {
postTypeMappings: Object.fromEntries(
postTypes.map((p) => [p.name, { collection: p.suggestedCollection, enabled: true }]),
),
skipExisting: true,
};
async function executeChunk(phase, cursor, chunk) {
const fd = new FormData();
fd.append("file", wxrFile());
fd.append("config", JSON.stringify(config));
fd.append("phase", phase);
if (cursor) fd.append("cursor", JSON.stringify(cursor));
if (chunk) fd.append("chunk", JSON.stringify(chunk));
return api("/import/wordpress/execute", { method: "POST", body: fd });
}
let r = await executeChunk("taxonomy");
let cursor = r.cursor;
let chunk = r.chunk;
let imported = 0;
let skipped = 0;
const errors = [];
for (let done = false; !done; ) {
r = await executeChunk("content", cursor, chunk);
imported += r.result?.imported ?? 0;
skipped += r.result?.skipped ?? 0;
errors.push(...(r.result?.errors ?? []));
cursor = r.cursor;
chunk = r.chunk;
done = r.done;
console.log(`content: imported=${imported} skipped=${skipped}`);
}
await executeChunk("finalize", cursor, chunk);
if (errors.length) console.log("content errors:", JSON.stringify(errors).slice(0, 2000));
// ---- 4. media(wp-cli の export は添付を重複して出すので ID で重複を除く)
const seen = new Set();
const attachments = analysis.attachments.items.filter((a) => !seen.has(a.id) && seen.add(a.id));
const urlMap = {};
const failed = [];
for (let i = 0; i < attachments.length; i += 10) {
const m = await api("/import/wordpress/media", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ attachments: attachments.slice(i, i + 10), stream: false }),
});
Object.assign(urlMap, m.urlMap);
failed.push(...m.failed);
console.log(`media: ${Math.min(i + 10, attachments.length)}/${attachments.length}`);
}
if (failed.length) console.log("media failed:", JSON.stringify(failed).slice(0, 2000));
// ---- 5. rewrite-urls(本文中の https://example.com/wp-content/uploads/... を置き換え)
const rewritten = await api("/import/wordpress/rewrite-urls", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ urlMap, collections: Object.values(config.postTypeMappings).map((m) => m.collection) }),
});
console.log("rewrite-urls:", JSON.stringify(rewritten).slice(0, 500));
wp-cliで書き出したWXRには、同じ添付ファイルが複数回入っていることがあります。試したファイルでは、添付の<item>が117件ありましたが、投稿IDの重複を除くと66件でした。そのため、IDで重複を除いてからmediaに渡しています。mediaには、1回に10件ずつ送りました。
実行結果
記事30本・固定ページ4本・画像66枚のWXRで、エラーなく最後まで終わりました。出力の抜粋です。
analyze: post=30, page=4
content: imported=30 skipped=0
content: imported=34 skipped=0
media: 66/66
rewrite-urls: {"updated":30,"byCollection":{"posts":30},"urlsRewritten":66,"errors":[]}
スクリプトではskipExisting: trueを指定しています。公式ドキュメントによると、この指定で取り込み直すと、コレクション・スラッグ・ロケールが一致する記事はスキップされます。途中で失敗した時は、skipExistingを有効にしたまま取り込み直す手順が、公式ドキュメントでも案内されています。