NewtからCraft Cross CMSへ200超の記事を移行する - データ移行スクリプトの設計と実装

はじめに

プレイドでは、2025年9月に Craft Cross CMS をリリースしました。

もともとプレイドのエンジニアブログでは、NewtのヘッドレスCMSを利用していましたが、今回のタイミングでCraft Cross CMSへと移行したので、どのように移行プロジェクトを進めたか、まとめたいと思います。

Craft Cross CMSのAIネイティブなヘッドレスCMS管理画面

移行プロジェクトの概要

今回の移行プロジェクトでは、以下をスコープとしました。

  • エンジニアブログの201記事をすべて移行する
  • 利用している画像ファイルは1238ファイル
  • サイト側のデザイン変更や機能追加は行わず、取得元を移行するのみに留める
  • 移行時に必要になった機能はCraft Cross CMSに適宜追加する

作業量としては、Craft Cross CMSへの機能追加も含めて25人日程度、移行作業のみで15人日程度でした。移行作業の内訳としてはNewtからCraft Cross CMSへのデータ移行が10人日、サイトのソースコード修正が3人日、管理画面の設定やその他の作業が2人日程度でした。

以下の順番で作業を進めていきました。

  1. 要件定義
  2. Craft Cross CMSへのデータ移行
  3. サイトのソースコード修正
  4. 管理画面の設定(webhook・プレビュー)

ただし、実際にはステップ2 → ステップ3を直線的に進めたわけではありません。まず50記事程度を移行した段階でサイト側の修正・表示確認を行い、問題がないことを確認してから残りの全記事を移行しました。リッチテキストのHTML形式の違いなど、データ移行だけでは気づけない問題があるためです。

ステップ1: 要件定義

今回の移行では「現状のエンジニアブログの機能・デザインを変えないまま、取得元のCMSを変更すること」を目的としました。

この目的に沿って、できるだけ円滑に移行が完了するよう、以下のような要件定義を行い、移行を進めていきました。

移行対象のコンテンツ・アセット

今回は「公開済みの全記事(201記事)を移行対象」とすることにしました。

また、アセットについては「利用されているもののみ(1238ファイル)を移行対象」としました。アップロードされているものの、どこからも参照されていないアセットについては邪魔なデータとなってしまうので、移行対象から外すこととしました。

サイト側の修正

もともと、エンジニアブログで使っている技術スタックは以下の通りでした。

  • フレームワーク: Next.js
  • ホスティング: Netlify
  • CMS: Newt

すでにヘッドレスCMSで作成されており、また今回サイト側のデザインや機能追加を目的とした移行ではなかったため、サイト側の修正は「デザイン変更や機能追加は行わず、取得元を移行するのみ」としました。

※ 以下、サイトのソースコード修正を行う箇所では、Next.jsの記法で記載しています。

モデル(スキーマ)の修正

移行に伴い、モデル(スキーマ)については「投稿」「著者」「タグ」の3モデルを移行対象としました。

モデルのフィールドについては見直しを行い、利用していないフィールドを削除しつつ、カスタムフィールド(複数のフィールドを組み合わせられるオブジェクト型のフィールド)を活用して、一部のフィールドはまとめることにしました。

また、よく移行で問題になる「公開日時」のデータですが、システム側が自動で定義する「sys.createdAt」等に登録するのではなく、ユーザー定義の「公開日」フィールドを1つ作成し、そのデータをブログ上で表示することにしました。画面に表示する用途であればユーザー定義のフィールドを作成することがおすすめです。

定義した投稿モデル

移行期間中の入稿停止

移行期間中も、「特に入稿停止の期間は設けない」ことにしました。

もともと1ヶ月に数記事程度の公開ペースで間隔に余裕があったのと、更新停止の期間を設けると、エンジニア全体への周知のコストが大きくなるためです。

ステップ2: Craft Cross CMSへのデータ移行

続いて、移行プロジェクトのメインとなる、NewtからCraft Cross CMSへのデータ移行です。

Craft Cross CMSでは、コンテンツ・アセット・モデルといった様々なリソースを管理するManagement API が用意されており、以下のような操作をAPI経由で実行できます。

  • コンテンツの作成・取得・更新・削除・公開・非公開
  • アセットの作成・取得・更新・削除・公開・非公開
  • モデルの作成・取得・更新・削除

今回の移行ではこのManagement APIを活用し、Node.js(TypeScript)でスクリプトを書いて、自動でデータ移行を行います。ただし、どうしても自動化するのが難しい場合、一部は手動での作業も行いました。

※ 以下、データ移行に関するコード説明では、Node.js(TypeScript)の記法で記載しています。

また、「移行プロジェクトの概要」で触れた通り、実際にはステップ2と3を行き来しながら進めています。詳しい理由は後述の「問題1: リッチテキスト(HTML)の比較が難しい」で説明します。

移行の進め方

移行の進め方は以下のように行いました。

  1. アセットのアップロード・更新
  2. コンテンツの作成(タグ > 著者 > 投稿の順番)

上記のような順番としたのは、参照元のコンテンツを作成する時に、参照先のアセットやコンテンツのidを知っておく必要があるためです。

詳しくは後述しますが、アセット・コンテンツの作成時に新旧のidのマッピングを保持しておき、適切に置き換えることで、正しく参照を設定できるようにします。

大きく差分比較・差分同期・データ削除の3種類のスクリプトを用意しました。順番に実行できるよう、アセット用のスクリプトと、投稿・著者・タグ用のスクリプトをそれぞれ定義しています。

  • 差分比較(diff)
  • Craft Cross CMSへの差分同期(sync)
  • データ削除(delete)
// package.jsonのscriptsでの定義
"scripts": {
  "diff:assets": "tsx scripts/assets/diff.ts",
  "sync:assets": "tsx scripts/assets/sync.ts",
  "delete:assets": "tsx scripts/assets/delete.ts",
  "diff:contents:post": "tsx scripts/contents/post/diff.ts",
  "sync:contents:post": "tsx scripts/contents/post/sync.ts",
  "delete:contents:post": "tsx scripts/contents/post/delete.ts",
  ...
 },

処理のイメージは以下の通りです。コンテンツが全件同期されるまで、差分比較と差分同期を繰り返しながら、進めていきます。最初は10件同期し、問題がなければ次は20件、その次は40件…というように、徐々に件数を増やしながら進めていきました。序盤はHTMLの正規化漏れなど、スクリプト側の問題が見つかりやすいため少量で回し、安定してきたら一気に件数を増やすという考え方です。問題が発生した場合は、スクリプトを修正するなど問題に対応し、データを一度削除してから、再度同期を行いました。

データ移行の処理の流れ。差分比較と差分同期を繰り返しながら進めていく

はじめに「クライアントの作成」「リッチテキストの入稿」について説明したあと、それぞれのメソッドについて解説します。

クライアントの作成

Craft Cross CMSではまだSDKの提供がされていないので、オリジナルのクライアントを作成しました。以下のように作成し、コンテンツの作成・公開等の処理を実行できるようにします。

ポイントは以下です。

  • レートリミットに引っかかった場合に備えて、最大3回のリトライを行う
  • TResponse によって、レスポンスの型を呼び出し側から指定できるようにする
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export const craftPost = async <TResponse = unknown>(
  path: string,
  body: unknown,
): Promise<TResponse> => {
  const url = `${process.env.CRAFT_MANAGEMENT_API_ORIGIN}${path}`;
  const jsonBody = JSON.stringify(body);
  const maxRetries = 3;
  const retryDelay = 60000; // 60秒

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.CRAFT_MANAGEMENT_API_ACCESS_TOKEN}`,
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
      body: jsonBody,
    });

    if (response.ok) {
      const json = (await response.json()) as TResponse;
      return json;
    }

    // 429エラーの場合はリトライ
    if (response.status === 429 && attempt < maxRetries) {
      console.warn(
        `[CraftClient] 429 Too Many Requests (attempt ${attempt}/${maxRetries}). Retrying in ${retryDelay / 1000}s...`,
      );
      await sleep(retryDelay);
      continue;
    }

    // その他のエラーまたは最終リトライ失敗
    const errorText = await response.text();
    throw new Error(`Craft API error: ${response.status} ${response.statusText} - ${errorText}`);
  }

  throw new Error('Unexpected error in craftPost');
};

このクライアントを利用して、 pathbody を指定する形で、以下のようにメソッドを実行できます。

body で送る情報はエンドポイントごとに異なるので、リファレンス を参考に設定してください。

// タグの作成
export const createTag = async (data: CraftTagInput): Promise<CraftTag> => {
  const body = {
    modelId: process.env.CRAFT_TAG_MODEL_ID,
    data,
  };
  return await craftPost<CraftTag>('/v2beta/cms/content/create', body);
};

// タグの公開
export const publishTag = async (tagId: string): Promise<CraftTag> => {
  const body = {
    modelId: process.env.CRAFT_TAG_MODEL_ID,
    contentId: tagId,
  };
  return await craftPost<CraftTag>('/v2beta/cms/content/publish', body);
};

また、アセットの作成・更新時には、JSON形式ではなく、FormData形式でデータを送る必要があります。上記のメソッドとは別に定義します。

Content-Type は自動で設定されるので、設定しないよう注意しましょう。

export const craftPostFormData = async <TResponse = unknown>(
  path: string,
  formData: FormData,
): Promise<TResponse> => {
  // 省略

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.CRAFT_MANAGEMENT_API_ACCESS_TOKEN}`,
        Accept: 'application/json',
      },
      body: formData,
    });

    // 省略
};

このクライアントを利用して、以下のようにアセットをアップロードできます。

// アセットのアップロード
export const uploadAsset = async (formData: FormData): Promise<CraftAsset> => {
  const craftAsset = await craftPostFormData<CraftAsset>('/v2beta/cms/asset/upload', formData);
  return craftAsset;
};

このクライアントを活用して、差分比較・差分同期・データ削除のメソッドを作成します。

リッチテキストの入稿

次にリッチテキストの入稿についてです。Craft Cross CMSのリッチテキストフィールドでは、入稿時にJSON形式でデータを入力することが求められます。HTMLからJSONへの変換用に @craft-cross-cms/rich-text-core というライブラリが提供されているので、そちらを活用します。

まずはライブラリをインストールします。

npm install @craft-cross-cms/rich-text-core

実装のポイントは以下です。

  • Node.js環境で利用するので、@craft-cross-cms/rich-text-core/server からインポートする
  • generateJSON でhtmlをJSONに変換する
import {
  buildTiptapExtensions,
  generateJSON,
  type JSONContent,
} from '@craft-cross-cms/rich-text-core/server';

export const htmlToProseMirror = (
  html: string,
): {
  json: JSONContent;
} => {
  const json = generateJSON(html, buildTiptapExtensions({}));
  return {
    json,
  };
};

差分比較

今回の移行では、移行期間中の入稿停止を行わないので、最新時点での差分比較をチェックする必要があります。Newt・Craft Cross CMSそれぞれからAPIで情報を取得し、差分を比較できるようにしました。

また、この差分比較で、Newtのみにあるアセット・コンテンツを特定し、次のCraft Cross CMSへの同期を実行する時に、移行対象が明確になるようにします。

差分比較の実行時に、以下のようなJSONを保存しました。

例えば、タグの差分比較を行うと、 data/diff/tags.json に以下のようなデータを保存するようにしました。ポイントとなるのは以下の項目です。

項目説明
diff.onlyInNewtNewtにしかないコンテンツの配列(移行対象)
diff.perfectMatch完全一致したコンテンツのペアの配列
diff.needsUpdate内容に不一致のあるコンテンツのペアの配列
{
  "summary": {
    "newtTotal": 157,
    "craftTotal": 7,
    "onlyInNewtCount": 150,
    "onlyInCraftCount": 0,
    "perfectMatchCount": 5,
    "needsUpdateCount": 2,
  },
  "diff": {
    "onlyInNewt": [
      {
        "_id": "xxxxxx",
        "_sys": {...},
        "name": "Datadog",
        "slug": "datadog",
      },
      ...
    ],
    "onlyInCraft": [],
    "perfectMatch": [
      {
        "newt": {
          "_id": "xxxxxx",
          "_sys": {...},
          "name": "Python",
          "slug": "python",
        },
        "craft": {
          "id": "xxxxxx",
          "sys": {...},
          "name": "Python",
          "slug": "python",
        },
        "hasFieldDiff": false,
        "fieldDiff": {
          "publishedAt": false,
          "name": false,
          "slug": false,
          "order": false
        }
      },
      ...
    ],
    "needsUpdate": [
      {
        "newt": {
          "_id": "xxxxxx",
          "_sys": {...},
          "name": "SRE",
          "slug": "sre",
        },
        "craft": {
          "id": "xxxxxx",
          "sys": {...},
          "name": "sre",
          "slug": "sre",
        },
        "hasFieldDiff": true,
        "fieldDiff": {
          "publishedAt": false,
          "name": true,
          "slug": false,
          "order": false
        }
      },
      ...
    ],
  },
  "timestamp": "2025-01-13T03:39:09.294Z"
}

また、 perfectMatchneedsUpdate では、内容に不一致があるかどうかを表す hasFieldDiff というプロパティを持ちます。不一致の詳細がわかるよう、 fieldDiff というプロパティでどこに不一致があるか確認できるようにしました。上記の例では、 name に不一致があるため、fieldDiff.name がtrueになっています。

diff 作成メソッドは以下のように定義しました。ポイントは以下です。

  • slugをキーにして、NewtとCraft Cross CMSのデータのマッチングを行う
  • 不一致がある場合は needsUpdate にpushする。ない場合は perfectMatch にpushする
  • publishedAt の比較では、日時ではなく、公開状態の違いをチェックする
  • 順番も比較し、order の項目で表す
/**
 * Newt と Craft の Tag コンテンツの差分を計算
 * slug をキーとしてマッチングを行う
 */
export const calculateDiff = (newtTags: NewtTag[], craftTags: CraftTag[]): TagDiff => {
  // slug をキーとしてマッチングを行う(インデックス情報も保持)
  const craftBySlug = new Map(craftTags.map((tag, index) => [tag.slug, { tag, index }]));
  const newtBySlug = new Map(newtTags.map((tag) => [tag.slug, tag]));

  const onlyInNewt: NewtTag[] = [];
  const perfectMatch: MatchedTag[] = [];
  const needsUpdate: MatchedTag[] = [];

  newtTags.forEach((newt, newtIndex) => {
    const craftData = craftBySlug.get(newt.slug);
    if (!craftData) {
      onlyInNewt.push(newt);
      return;
    }

    const fieldDiff = compareFields(newt, craftData.tag, newtIndex, craftData.index);
    const hasFieldDiff = hasAnyFieldDiff(fieldDiff);

    const matchedTag: MatchedTag = {
      newt,
      craft: craftData.tag,
      hasFieldDiff,
      fieldDiff,
    };

    (hasFieldDiff ? needsUpdate : perfectMatch).push(matchedTag);
  });

  const onlyInCraft = craftTags.filter((craft) => !newtBySlug.has(craft.slug));

  return {
    onlyInNewt,
    onlyInCraft,
    perfectMatch,
    needsUpdate,
  };
};

/**
 * Tag のフィールドを比較
 */
export const compareFields = (
  newt: NewtTag,
  craft: CraftTag,
  newtIndex: number,
  craftIndex: number,
): FieldDiff => {
  // publishedAt の比較: 公開状態(公開/未公開)の一致を確認
  // null = 未公開, string = 公開済み(公開日時の値は比較しない)
  const publishedAtDiff =
    (newt._sys.raw.publishedAt === null) !== (craft.sys.raw.publishedAt === null);

  return {
    publishedAt: publishedAtDiff,
    name: newt.name !== craft.name,
    slug: newt.slug !== craft.slug,
    order: newtIndex !== craftIndex,
  };
};

/**
 * フィールド差分があるかチェック
 */
export const hasAnyFieldDiff = (fieldDiff: FieldDiff): boolean => {
  return Object.values(fieldDiff).some((hasDiff) => hasDiff);
};

差分同期

差分同期では、差分比較時に特定したNewtのみにあるコンテンツをCraft Cross CMSに同期します。

はじめにNewtのコンテンツを、Craft Cross CMS用に加工し、そのデータを同期します。作成したあと、公開処理も行います。

// タグの同期の場合
for (const tag of tagsToCreate) {
  const tagData = transformTag(tag) // データの加工
  const craftTag = await createTag(tagData); // データの作成
  await publishTag(craftTag.id); // データの公開
}

// データの加工(フィールドのマッピング、必要であれば未定義の場合の対応を行う)
const transformTag = (newtTag: NewtTag): CraftTagInput => {
  return {
    name: newtTag.name,
    slug: newtTag.slug || newtTag.name.toLowerCase().replace(/\s+/g, '-'),
  };
};

データ削除

データ削除用のスクリプトは必須ではありませんが、何か問題に気づいた時など、データを一度すべて削除して、再作成したい場合には用意しておくと便利です。

コンテンツを削除する前に、一度コンテンツを非公開にする必要があるので、注意してください。

// タグの削除の場合
for (const tag of craftTags) {
  try {
    await unpublishTag(tag.id) // データの非公開
    await deleteTag(tag.id); // データの削除
  } catch (err) {
    // 省略
  }
}

難しいポイント

ここまで、差分比較・差分同期・データ削除のメソッドについて紹介しましたが、データ移行を行う場合、難しいポイントが何点かあります。

  • リッチテキスト(HTML)の比較が難しい
    • CMSによって多少形式が異なるので、完全一致で比較するのが難しい
  • 参照フィールドを使う場合、入稿時にidを知っている必要がある
    • 参照先を入稿した後で、参照元を入稿しなければならない
    • 旧CMS(Newt)のidではなく、新CMS(Craft Cross CMS)のidを知る必要がある

問題1: リッチテキスト(HTML)の比較が難しい

リッチテキストで入稿した場合、HTMLやJSON等でデータが返却される場合がほとんどかと思いますが、CMSによって、同じ書式を選択していても、返却されるHTMLが異なる場合があります。

例えば、Newtでは「リスト」の書式の場合、 <li> タグの内側に <p> タグは含まれませんが、Craft Cross CMSでは <li> タグの内側に <p> タグが含まれます。

そのため、完全一致で比較することが難しくなります。

対応1: (できる限り)完全一致で比較できるよう、比較時にHTMLを整形して比較する

完全一致での比較が難しいと書きましたが、そうは言っても全記事を人力でチェックするには労力がかかります。HTML形式の差分を可能な限り吸収して、できるだけスクリプトでチェックできるようにしました。

ここでは <p> タグの削除、 <br> タグの正規化の例を記載しましたが、他にも計20個程度のタグの正規化や削除を行って比較しました。

差分の吸収は、移行前後のCMSの形式によって大変さが変わります。

もともと利用していたNewtのマークダウンフィールドが、自由にHTMLを書くことができ、様々な形式のHTMLが入稿されていたため、今回は難易度が高くなってしまいました。もし、移行前のCMSでHTMLの自由度が高くない場合、これほど大変にはならないと思います。

const removeParagraphTags = (html: string): string => {
  // すべての<p>と</p>を削除(属性付きも含む)
  return html.replace(/<p\b[^>]*>|<\/p>/gi, '');
};

const normalizeBrTags = (html: string): string => {
  // <br /> や <br/> を <br> に統一
  return html.replace(/<br\s*\/?>/gi, '<br>');
};

export const normalizeHtmlFully = (html: string): string => {
  // 1. pタグを完全に削除
  let normalized = removeParagraphTags(html);
  
  // 2. brタグの統一
  normalized = normalizeBrTags(normalized);
  
  ...
  
  return normalized;
};

対応2: 差分の吸収が難しい場合は、諦めて人力でチェックする

中には差分の吸収が難しい場合もあります。

例えば、Craft Cross CMSのリッチテキストフィールドでは、「数式」の形式に対応しておらず、Newtで扱っていた数式の情報を移行することができません。そのため数式を含む記事では、数式を画像に置き換えて対応を行いました。

このような場合、データの手動修正が必要になり、またHTMLでの比較もできなくなるため、目検でのデータチェックが必要になります。

今回の場合、他にも「埋め込み」を利用している場合に、手動修正が必要となりました。

全部で201記事ありましたが、スクリプトのみで修正対応できたのは139記事、手動での修正対応が必要だったのは62記事でした。またスクリプトのみでHTMLをチェックできたのは173記事、目検での対応が必要だったのは28記事でした。

すべてスクリプトで対応することが理想ですが、技術的に難しい場合や、コストがかかりすぎる場合もあります。スケジュールやリソースとの兼ね合いで、どの程度人力での対応を許容するか、バランスを探ると良いと思います。

問題2: 参照フィールドを使う場合、入稿時にidを知っている必要がある

続いて、参照フィールドを使う場合、コンテンツの作成時にidを指定する必要があります。参照元のコンテンツから先に作成することはもちろんですが、作成時にどのidを指定すべきか知っていなければなりません。

やり方は様々あるかと思いますが、今回はマッピングデータを保持して、idを変換することにしました。処理の大きな流れは以下のようになります。

マッピングデータの処理の流れ。同期時にマッピングを作成し、比較時に存在しないidのマッピングを削除する

対応1: 差分同期時に新旧CMSのidのマッピングを保持する

アセット・コンテンツの同期(作成)時に、旧CMS(Newt)のidと、新CMS(Craft Cross CMS)のidとのマッピングを保持することで対応しました。

具体的には、アセット・コンテンツの作成時に、以下のようなJSONを保存しました。

例えば、タグの差分比較を行うと、 data/mapping/tags.json に以下のようなデータを保存するようにしました。

{
  "mappings": [
    {
      "newtId": "5b33658febef3d00b545d48c",
      "craftId": "6944f1ab44a9d033a7a7f1eb",
      "slug": "python"
    },
    ...
  ],
  "totalMapped": 157,
  "lastUpdated": "..."
}

差分同期を行う時に、マッピングも作成します。ポイントは以下です。

  • 同期を1件するごとに、マッピングデータも1件ずつ作成する
  • mergedMappings では、重複除外のフィルタリングを行う
export const syncOnlyInNewtTags = async (limit?: number) => {
  // 省略

  const mappingItems: TagMappingItem[] = [];
  for (const [index, tag] of tagsToCreate.entries()) {
    try {
      const craftTag = await createTag(transformTag(tag));
      await publishTag(craftTag.id);
      // データ作成時にマッピングも作成
      mappingItems.push(createMappingItem(tag._id, craftTag.id, tag.slug));
    } catch (err) {
      // 省略
    }
  }

  // マッピング情報を保存(既存のマッピングに追加)
  if (mappingItems.length > 0) {
    const existingMappingData = await readJson('data/mapping', 'tags.json');
    const existingMappings = (existingMappingData as TagMapping | null)?.mappings || [];

    // 重複しないようフィルタリングを行う
    const mergedMappings = [
      ...existingMappings.filter((m) => !mappingItems.some((item) => item.newtId === m.newtId)),
      ...mappingItems,
    ];

    const mapping: TagMapping = {
      mappings: mergedMappings,
      totalMapped: mergedMappings.length,
      lastUpdated: new Date().toISOString(),
    };

    await saveJson(mapping, 'data/mapping', 'tags.json');
  }
  return mappingItems;
};

const createMappingItem = (
  newtId: string,
  craftId: string,
  slug: string,
): TagMappingItem => {
  return {
    newtId,
    craftId,
    slug,
  };
};

対応2: 差分比較時に存在しないマッピングを削除する

また、作成したマッピングですが、データを削除した場合にはマッピングも削除する必要があります。 差分比較を行う時に、マッピングも更新します。ポイントは以下です。

  • idでフィルタリングし、存在しないデータのマッピングを除外する
export const diffTags = async (): Promise<void> => {
  const newtTags = await getNewtTags();
  const craftTags = await getCraftTags();

  // マッピングのクリーンアップ
  const craftTagIds = new Set(craftTags.map((tag) => tag.id));
  await cleanupMapping<TagMapping['mappings'][0]>(
    'data/mapping',
    'tags.json',
    craftTagIds,
    LOG_PREFIX,
  );

  // 省略
};

const cleanupMapping = async <T extends BaseMappingItem>(
  mappingDir: string,
  mappingFile: string,
  existingCraftIds: Set<string>,
  logPrefix: string,
): Promise<BaseMapping<T> | null> => {
  // マッピングファイルの読み込み
  const mapping = await readJson(mappingDir, mappingFile) as BaseMapping<T> | null;
  if (!mapping) return null;

  // idでフィルタリングし、存在しないデータのマッピングを除外する
  const cleanedMappings = mapping.mappings.filter((item) => existingCraftIds.has(item.craftId));
  const removedCount = mapping.mappings.length - cleanedMappings.length;

  if (removedCount === 0) return mapping;

  const cleanedMapping: BaseMapping<T> = {
    mappings: cleanedMappings,
    totalMapped: cleanedMappings.length,
    lastUpdated: new Date().toISOString(),
  };

  // マッピングファイルの更新
  await saveJson(cleanedMapping, mappingDir, mappingFile);
  return cleanedMapping;
};

対応3: 参照元のコンテンツ作成時に、マッピングを活用してidを設定する

上記で保存した新旧CMSのidのマッピングをもとに、NewtのidをCraft Cross CMSのidに変換できるようにします。以下のようにMap形式のデータを用意しておくと便利です。

/**
 * タグマッピングを効率的な検索用のMapに変換する。
 * @param tagMapping - タグマッピングデータ
 * @returns Newt Tag ID から Craft Tag ID へのMap
 */
const createTagIdMap = (tagMapping: TagMapping | null): Map<string, string> => {
  const map = new Map<string, string>();
  if (tagMapping?.mappings) {
    for (const item of tagMapping.mappings) {
      map.set(item.newtId, item.craftId);
    }
  }
  return map;
};

ここで作成したMapを用いて、データの加工時にCraft Cross CMSのidに変換します。

let tagIds: string[] | undefined;
if (newtPost.tags && newtPost.tags.length > 0) {
  tagIds = newtPost.tags
    .map((tag) => tagIdMap.get(tag._id))
    .filter((id): id is string => !!id);
}

このようにマッピングデータを活用し、idの変換を行いました。

以上がデータ移行の作業となります。

ステップ3: サイトのソースコード修正

続いて、サイト側のソースコードを修正します。

今回、サイト側の修正は「デザイン変更や機能追加は行わず、取得元を移行するのみ」としたので、主にAPIでのデータ取得の部分を変更します。

クライアントの作成

サイト側でもクライアントを作成する必要があります。データ移行のスクリプトでは、Management APIを利用するためのクライアントが必要でしたが、サイト側ではCDN APIとPreview APIを利用するためのクライアントが必要になります。

ポイントは以下です。

  • CDN API用のクライアントと、Preview API用のクライアントをそれぞれ作成する
  • Preview APIでは非公開のコンテンツも取得できるため、Preview用のトークンはフロントエンドに露出しないよう、環境変数の NEXT_PUBLIC_ のプレフィックスをつけない
  • CDN APIとPreview APIで、リクエスト先のドメインは共通だが、パスが異なるので注意する
  • クエリは qs.stringify で設定する(使わなくても問題ないが、設定が簡単になる)

また、Management APIのクライアントと以下の点が異なります

  • レートリミット時のリトライを設定しない(設定しても問題ありませんが、レートリミットが問題になるほどリクエストを行わないので、ここでは設定しません)
const createClient = (options: { token: string; isPreview?: boolean }) => {
  return async <TResponse = unknown>(
    path: "/list" | "/get",
    query?: Record<string, unknown>
  ): Promise<TResponse> => {
    const pathPrefix = options.isPreview ? "/preview" : "";
    let url = `${process.env.NEXT_PUBLIC_CRAFT_CDN_API_ORIGIN}/beta/cms/content${pathPrefix}${path}`;

    if (query) {
      const queryString = qs.stringify(query);
      url += `?${queryString}`;
    }

    const response = await fetch(url, {
      method: "GET",
      headers: {
        Authorization: `Bearer ${options.token}`,
        Accept: "application/json",
        "Content-Type": "application/json",
      },
    });

    if (response.ok) {
      const json = (await response.json()) as TResponse;
      return json;
    }

    // 省略
  };
};

export const cdnClient = createClient({
  token: process.env.NEXT_PUBLIC_CRAFT_CDN_TOKEN!,
});

export const previewClient = createClient({
  token: process.env.CRAFT_PREVIEW_TOKEN!,
  isPreview: true,
});

クライアントを利用したメソッドの呼び方は以下のようになります。

interface ContentListResponse<T> {
  skip: number;
  limit: number;
  total: number;
  items: T[];
}

// 全モデル共通で利用するコンテンツ一覧取得のメソッド
export async function getContents<T>(
  modelId: string,
  query?: Record<string, unknown>,
  options?: { preview?: boolean }
): Promise<ContentListResponse<T>> {
  const client = options?.preview ? previewClient : cdnClient;

  return client<ContentListResponse<T>>("/list", {
    modelId,
    ...query,
  });
}

// CDN APIでタグの全コンテンツを取得するメソッド
export const getAllTags = async () => {
  const { items: tags } = await getContents<Tag>(
    process.env.NEXT_PUBLIC_CRAFT_TAG_MODEL_ID!,
    {
      limit: 1000,
    }
  );
  return tags;
};

参照データの展開(populate)処理の作成

また、Craft Cross CMSでは参照フィールドを利用した場合、idのみが返却されます。NewtではAPI側でデータが展開(populate)された状態で返却されていたため、フロントエンド側で同様の処理を実装する必要があります。

具体的には、IDをもとに実データを取得して結合する、いわゆる「populate処理」を行うMapを作成しました。例えば、「著者(author)」「タグ(tag)」を参照フィールドとして使っている、「投稿」モデルのpopulate処理は以下のようにしました。

ポイントは以下です。

  • 著者・タグの全件取得を行った上で、Mapを作成しておく
  • 参照フィールドの id の値( post.authorpost.tags に入っているidの値)をもとに、Mapからデータを取得する
const populatePosts = async (rawPosts: RawPost[]): Promise<Post[]> => {
  const [authors, tags] = await Promise.all([getAllAuthors(), getAllTags()]);

  const authorMap = new Map(authors.map((author) => [author.id, author]));
  const tagMap = new Map(tags.map((tag) => [tag.id, tag]));

  return rawPosts.map((post) => {
    // 参照フィールド(単数値)のpopulate
    const populatedAuthor = post.author
      ? authorMap.get(post.author) ?? null
      : null;

    // 参照フィールド(複数値)のpopulate
    const populatedTags = post.tags
      .map((tagId) => tagMap.get(tagId))
      .filter((tag): tag is Tag => tag !== undefined);

    return {
      ...post,
      author: populatedAuthor,
      tags: populatedTags,
    };
  });
};

取得順序の設定

また、今回は「公開日時」のデータとして、システム側が自動で定義する「sys.createdAt」ではなく、ユーザー定義の「公開日(publishedAt)」フィールドを1つ作成しました。

投稿一覧を取得する際には、その順番で取得します。

export const getAllPosts = async () => {
  const { total, items } = await getContents<RawPost>(
    process.env.NEXT_PUBLIC_CRAFT_POST_MODEL_ID!,
    {
      limit: 1000,
      order: ["-publishedAt"], // 公開日の降順
    }
  );
  const posts = await populatePosts(items);
  return { total, posts };
};

形式が変更したHTMLへの対応

エンジニアブログでは、 dangerouslySetInnerHTML を利用して、CMSから返却されたHTMLをそのまま設定していました。ステップ2の「問題1」で、「CMSによって返却されるHTMLが異なる」と記載しましたが、NewtとCraft Cross CMSで返却されるHTMLが異なるので、そのままだとスタイルが崩れてしまいます。

サイト側でも以下のどちらかの対応をしないといけません。

  • (スタイルは修正せず)Craft Cross CMSのHTMLを、Newtと同様の形式に修正する
  • (HTMLは修正せず)Craft Cross CMSのHTMLにあわせてスタイルを修正する

今回は「Craft Cross CMSのHTMLを、Newtと同様の形式に修正する」方法を選びました。ステップ2で確認した差分を、反映します。

cheerio を活用して、以下のように修正します。ここでは <table> タグ配下に設定されている <p> タグと、 <li> タグ直下に設定されている <p> タグを削除しています。

const $ = cheerio.load(post.body.html, {}, false);
$("table p").each((_, elm) => {
  const pEl = $(elm);
  pEl.replaceWith(pEl.html());
});
$("li > p").each((_, elm) => {
  const pEl = $(elm);
  pEl.replaceWith(pEl.html());
});

// 省略

post.body.html = $.html();

画像の最適化

最後に画像の最適化です。Craft Cross CMSでは Fastly Image Optimizer を利用して、画像の変換を行えます。

以下のようなメソッドを用意しました。ポイントは以下です。

  • width パラメータで、幅の変更
  • fit=bounds でアスペクト比を保持
  • format=auto で画像フォーマットの自動変更
export const resizeImage = (url: string, width: number) => {
  const urlObj = new URL(url); // 相対パスの場合、new URL() でエラーになるため注意
  urlObj.searchParams.set("width", width.toString());
  urlObj.searchParams.set("fit", "bounds");
  urlObj.searchParams.set("format", "auto");
  return urlObj.toString();
};

他にも様々なクエリが利用できるので、Fastlyのリファレンス をご確認ください。

以上が、サイト側のソースコード修正となります。

ステップ4: 管理画面の設定(webhook・プレビュー)

最後に管理画面から、webhook・プレビューの設定を行います。

webhookの設定

Craft Cross CMSでは、「コンテンツの公開時」「コンテンツの非公開時」「コンテンツの更新時」といったイベントをトリガーにCraft Functions(任意のバックエンドプログラム)を実行できます。

以下のようなCraft Functionsを作成します。

ここではNetlifyでホスティングしているため、変数に NETLIFY_DEPLOY_HOOK を定義し、webhookを送るURLを定義します。

const LOG_LEVEL = '<% LOG_LEVEL %>';
const NETLIFY_DEPLOY_HOOK = '<% NETLIFY_DEPLOY_HOOK %>';

export default async function (data, { MODULES }) {
  const { initLogger } = MODULES;
  const logger = initLogger({ logLevel: LOG_LEVEL });

  try {
    const response = await fetch(NETLIFY_DEPLOY_HOOK, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({}),
    });

    if (!response.ok) {
      const text = await response.text();
      const errorMessage = `Request failed: ${response.status} ${text}`;
      logger.error(errorMessage);
      throw new Error(errorMessage);
    }

    // 省略
    
  } catch (err) {
    // 省略
  }
}

そして「API v2 設定」よりアプリを作成し、「Hook設定」よりトリガーとして以下を定義します。

※管理画面上では「KARTE CMS」という名称で表示されます。

  • KARTE CMS: コンテンツの公開時
  • KARTE CMS: コンテンツの非公開時

これで、コンテンツが公開・非公開されたタイミングで(更新して公開した場合も含みます)、Netlifyで自動でデプロイが実行されます。

アプリのHook設定画面。トリガーにコンテンツの公開と非公開が設定されている

また、Craft Functionsでは、任意のバックエンドプログラムを実行できるため、webhook以外にも様々なユースケースに対応できます。

  • コンテンツ公開時のSlack通知
  • Craft RAGへの自動連携
  • 検索インデックスの自動更新(Algoliaなどの検索サービスと連携している場合)
  • 要約・翻訳の自動生成

JavaScriptで記述でき、条件分岐や複数APIの組み合わせも可能なので、組織固有の業務フローに合わせた自動化を実装できます。

プレビューの設定

「コンテンツ設定」で該当モデルを選択し、「プレビュー設定」から設定できます。

「サイト上でプレビュー」を選択し、「プレビューURL」を登録しましょう。

プレビュー設定画面。ここからプレビューURLを登録する

これですべての設定が完了しました。

移行時・移行後に感じたCraft Cross CMSの使用感

以上でデータ移行の作業は完了したのですが、最後に移行時・移行後に感じたCraft Cross CMSの使用感を書いておきます。

移行時に必要になるAPIは一通り揃っている

今回はアセット・コンテンツの操作をAPI経由で行いましたが、どちらもCRUD処理を行えるAPI(Management API)が揃っていて、効率的に移行作業を実行できました。特に、記事内では詳細を記述していませんが、画像のメタデータの設定は、画像の更新処理を行うAPI を利用したおかげで、かなり効率的に作業できました。

またモデルやカスタムフィールドタイプのCRUD処理を行うAPIも揃っているので、モデルやカスタムフィールドタイプをたくさん利用している場合は、これらもAPI経由で作成できます。

コンテンツ数・アセット数・モデル数が増えれば増えるほど、移行作業を自動化することで工数を削減できるので、大量のデータを移行したい場合は便利かと思います。

リッチテキストの移行は大変

ただし、リッチテキストを移行する場合は、自動での対応のみでは難しい場合がありました。

HTMLの形式の違いがあったり、対応している書式に違いがあると、何かしらの対応が必要になってしまいます。今回の場合も、HTMLを加工したり、手動での対応にかなり時間を使いました。

事前に利用しているCMSにもよりますが、自由度の高いものであればあるほど、難易度は高くなってしまうかと思います。

画像最適化が便利

Craft Cross CMSでは Fastly Image Optimizer を利用していて、様々なクエリをサポートしています。代表的なものには以下のものがあります。

  • format=auto クエリによる画像フォーマットの最適化(webpavif への明示的な変換も可能です)
  • quality クエリによる品質の変更
  • widthheight クエリによる画像サイズの変更
  • fit クエリによるリサイズ時の制御

これらのクエリを活用することで、デバイスや通信環境に応じた最適な画像配信が可能になります。エンジニアブログにおいても、Newtの時に利用していた画像最適化を簡単に実装できました。

AIを活用したコンテンツ作成が便利

Craft Cross CMSにはAIを活用したコンテンツ作成支援機能があり、移行後の運用で活用しています。キーワードや概要からの下書き生成、文章の改善提案やトーン&マナーのチェックなど、執筆・レビューの両面で業務効率が向上しました。

AI Copilotによるコンテンツ作成支援

おわりに

今回は、プレイドのエンジニアブログをNewtからCraft Cross CMSへ移行したプロジェクトについて紹介しました。201記事と1238ファイルの画像を移行するという規模でしたが、Craft Cross CMSのManagement APIを活用することで、効率的にデータ移行を進めることができました。

ヘッドレスCMSの移行は、データの互換性やAPIの違いなど、様々な課題に直面します。しかし、適切な計画と段階的なアプローチ、そして自動化と手動対応のバランスを取ることで、大規模な移行も現実的な工数で実現可能です。

この記事が、CMSの移行を検討されている方々の参考になれば幸いです。移行に関するご質問やCraft Cross CMSについてのお問い合わせがございましたら、お気軽にご連絡ください。