見出し画像

記事をスクレイピングしてmicroCMSに移行してみた

この記事は「株式会社メンバーズ Jamstack研究会主催 Advent Calendar 2023」の6日目の記事です。


目的

cheerioを使用したスクレイピングで、microCMSに記事を移行します。

技術スタック

  • microCMS

  • Node.js v18.18.1

  • cheerio

microCMSとは

日本製のヘッドレスCMSです。管理画面は誰でも簡単に扱える、分かりやすい直感的なUIを持っていて、データ取得に使うAPIは開発者向けに最適化されています。

cheerioとは

HTMLとXMLを解析し、得たデータを操作できるJavaScriptライブラリです。jQueryのような記述ができ、ほぼ全てのHTML、XMLを解析できるのが特徴です。

こちらのライブラリを使用して、ウェブ上の記事ページをスクレイピングし、HTMLを解析して記事データを取得します。

移行対象

株式会社メンバーズのHPにある実績紹介ページの4つの記事を例として移行しようと思います。

今回は社内ページを利用しますが、スクレイピングによる連続アクセスは攻撃とみなされる場合もあるため、対象のサイトに負荷をかけないようアクセス間隔を空けるなど注意が必要です。

また、利用規約で禁止されている場合もあるため、事前に確認しサイト運営者に迷惑をかけないようにしましょう。

実績紹介ページ

移行対象の項目は以下の通りです。

  • 記事タイトル

  • 記事本文(記事内の画像含む)

  • カテゴリ

サムネイル画像については、記事執筆時点(2023年11月21日)でmicroCMSのAPIが画像スキーマの送信に対応していないため、プログラムによる移行はできませんでした。

それでは、まずmicroCMS側の準備から進めていきます。

microCMS側の準備

microCMSのアカウント、サービス登録は完了済みの想定です。

APIキーのPOST権限を許可する

APIのPOST権限がデフォルトでは許可されていないため、許可します。

POST権限を許可

POST APIについては詳しくはこちらをご覧ください。

POST権限を許可することで、プログラム上からPOSTで記事データを送ることができるようになりました。

マネジメントAPIの権限を許可

マネジメントAPIの「メディアの取得」権限を許可します。

マネジメントAPIのメディア取得を許可

これでmicroCMS上の画像などをAPIで取得できるようになりました。

マネジメントAPIについては詳しくはこちらをご覧ください。

このAPIはmicroCMS上にアップロードしたメディアのファイル名を取得するために必要になります。

コンテンツ(API)の作成

今回は記事とカテゴリのデータのみあれば良いので、コンテンツは2つになります。

どちらもリスト形式で作成しました。

エンドポイントは実績紹介をresults、カテゴリをcategoriesにしました。

APIスキーマはこのようなシンプルな形です。

実績紹介APIのスキーマ
カテゴリのAPIスキーマ

移行手順

記事の移行は以下の順番で行います。

  1. 記事内で使用している画像のダウンロード(手元にある場合は不要)

  2. 記事内で使用している画像をmicroCMS上にアップロード(手動)

  3. スクレイピングでカテゴリを取得し、microCMS上にデータ挿入

  4. スクレイピングで記事データを取得し、画像パスを書き換え、microCMS上にデータ挿入

記事内で使用している画像ごと移行する場合、事前にmicroCMS上に画像をアップロードする必要があります。

また、カテゴリは複数コンテンツ参照を利用しているため、記事データよりも先にカテゴリを移行する必要があります。

記事移行の準備

環境構築

cheerioをインストールします。

npm install cheerio

.envファイルから環境変数を読み込むためにdotenvもインストールします。

npm install dotenv

スクレイピング

記事一覧を取得するため、ブラウザの検証ツールで記事のセレクタをコピーします。

記事一覧のセレクタをコピー

fetchで記事一覧ページのHTMLを取得して、cheerioで解析を行います。

const response = await fetch("https://www.members.co.jp/results/");
const html = await response.text();
const $ = cheerio.load(html);

記事本文やカテゴリは記事一覧ページから取得できないので、記事一覧のリンクを元に遷移先の記事詳細ページをスクレイピングします。

// 一覧ページの記事を取得
// コピーしたセレクタで取得
const items = $("#container > article > div > div:nth-child(3) > div > div");

// 記事分ループ
for (let i = 0; i < items.length; i++) {
	// 記事一覧ページのaタグのhrefを取得
	const url = items.eq(i).find("a.normal").attr("href");

	// 記事一覧のリンクを元に記事詳細ページをスクレイピングする
	const response = await fetch("https://www.members.co.jp" + url);
	const html = await response.text();
	const $ = cheerio.load(html);

	// 記事ごとの処理を書く
}

画像

画像をダウンロード

次に、記事本文で使用している画像をダウンロードするためのプログラムを作成します。

こちらが記事詳細ページの本文内のimgをダウンロードする処理です。

// download-images.js

import * as cheerio from "cheerio";
import fs from "fs";

const response = await fetch("https://www.members.co.jp/results/");
const html = await response.text();
const $ = cheerio.load(html);

// 一覧ページの記事を取得
const items = $("#container > article > div > div:nth-child(3) > div > div");

// 記事分ループ
for (let i = 0; i < items.length; i++) {
	// 記事一覧ページのaタグのhrefを取得
	const url = items.eq(i).find("a.normal").attr("href");

	// 記事一覧のリンクを元に記事詳細ページをスクレイピングする
	const response = await fetch("https://www.members.co.jp" + url);
	const html = await response.text();
	const $ = cheerio.load(html);


	// imgタグ取得
	const images = $("#container > article > div.main-block img");

	// 本文内の画像分処理
	for (let j = 0; j < images.length; j++) {
		// imgのsrcを取得
		let imageUrl = images.eq(j).attr("src");

		// 絶対パスと相対パスをURLに変換
		if (/^\//.test(imageUrl)) {
			imageUrl = "https://www.members.co.jp" + imageUrl;
		} else {
			imageUrl = "https://www.members.co.jp/results/success/" + imageUrl;
		}

		// 画像を取得
		const response = await fetch(imageUrl);

		// バイナリデータを取り出す
		const arrayBuffer = await response.arrayBuffer();

		// バッファ型に変換
		const buffer = Buffer.from(arrayBuffer);

		// 画像のファイル名をパスから取り出す
		const imageName = imageUrl.match(/([^\/]+)$/)[0];

		// 画像をダウンロード
		fs.writeFileSync(`./data/images/${imageName}`, buffer);
	}
}
node ./download-images.js

実行すると以下のようにダウンロードできました。

ダウンロードできた画像リスト

画像のアップロード

ダウンロードした画像をmicroCMSのメディアページにドラッグ&ドロップで一括アップロードを行います。

画像をmicroCMSのメディア欄にD&D

一度に大量の画像をアップロードしようとした場合、エラーになったことがあるため何回かに分けてアップロードするとスムーズです。

カテゴリの移行

次にカテゴリの移行を行います。

先ほどのスクレイピングプログラムを流用し、カテゴリのみ抽出した後、fetchでmicroCMSのPOST APIを利用します。

カテゴリのセレクタをコピー
// categories.js

import "dotenv/config";
import * as cheerio from "cheerio";

const response = await fetch("https://www.members.co.jp/results/");
const html = await response.text();
const $ = cheerio.load(html);

// 一覧ページの記事を取得
const items = $("#container > article > div > div:nth-child(3) > div > div");

// カテゴリ格納用変数
let categories = [];

for (let i = 0; i < items.length; i++) {
	// 記事一覧ページのaタグのhrefを取得
	const url = items.eq(i).find("a.normal").attr("href");

	// 記事一覧のリンクを元に記事詳細ページをスクレイピングする
	const response = await fetch("https://www.members.co.jp" + url);
	const html = await response.text();
	const $ = cheerio.load(html);


	// カテゴリ取得
	const categoriesElement = $("#container > article > ul > li");
	for (let j = 0; j < categoriesElement.length; j++) {
		const className = categoriesElement.eq(j).attr("class");

		categories.push({
			id: className, // class名をIDにする
			name: categoriesElement.eq(j).text()
		});
	}
}

// 重複削除
categories = Array.from(new Map(categories.map((category) => [category.id, category])).values());

// microCMSに送信
const serviceDomain = process.env.MICROCMS_SERVICE_DOMAIN; // サービスドメイン
const apiKey = process.env.MICROCMS_API_KEY; // APIキー
const endpoint = "categories" // 送信先のエンドポイント
const url = `https://${serviceDomain}.microcms.io/api/v1/${endpoint}`;

for (let i = 0; i < categories.length; i++) {
	const parameters = {
		method: "POST",
		headers: {
			"Content-Type": "application/json",
			"X-MICROCMS-API-KEY": apiKey
		},
		body: JSON.stringify(categories[i])
	};

	try {
		await fetch(url, parameters);
	} catch (e) {
		console.log(e);
	}
}

カテゴリのIDは現行のclass名を流用することにしました。

node ./categories.js

実行すると、無事移行できていました。

カテゴリ移行完了

コンテンツIDにもclass名がしっかり適用されています。

記事の移行

記事移行の準備ができたので、最後に記事の移行を行います。

記事本文で使用されているimgのsrcをmicroCMS上のものと置き換える処理を作成します。

まずは、microCMSのマネジメントAPIを使用して、microCMS上の画像を取得する処理を作成します。

const serviceDomain = process.env.MICROCMS_SERVICE_DOMAIN; // サービスドメイン
const apiKey = process.env.MICROCMS_API_KEY; // APIキー

// microCMSの画像格納用配列
const microCMSImages = [];

// microCMSの画像取得
const getMicroCMSImages = async (offset = 0) => {
	const limit = 100;

	const url = `https://${serviceDomain}.microcms-management.io/api/v1/media?limit=${limit}&offset=${offset}`;

	const requestParameters = {
		method: "GET",
		headers: {
			"Content-Type": "application/json",
			"X-MICROCMS-API-KEY": apiKey
		}
	};

	console.log(url);

	let data;
	try {
		const response = await fetch(url, requestParameters);
		data = await response.json();
		data.media.forEach((media) => {
			microCMSImages.push({
				url: media.url, // URLを取得
				name: media.url.match(/([^\/]+)$/)[0] // ファイル名を正規表現で取得
			});
		});
	} catch (e) {
		console.log(e);
	}

	if (data.totalCount <= offset + limit || data === undefined) {
		return;
	}

	offset += limit;

	await getMicroCMSImages(offset);
};

await getMicroCMSImages(0);

microCMS上の画像を取得できたので、cheerioのreplaceWithを使用してimgタグのsrcを置き換える処理を作成します。

// コンテンツ取得
const contents = $("#container > article > div.main-block");

// imgのsrc置換
contents.find("img").replaceWith(function () {
	const imgTag = $("<img>");

	// 元の属性を引き継ぐ
	imgTag.attr($(this).attr());

	// ファイル名を取得
	const oldSrc = $(this).attr("src");
	const imageName = oldSrc.match(/([^\/]+)$/)[0];

	// microCMS上にある画像のファイル名と一致している場合、URLを置換
	microCMSImages.forEach((microCMSImage) => {
		if (imageName === microCMSImage.name) {
			imgTag.attr("src", microCMSImage.url);
		}
	});

	return imgTag;
});

これら2つの処理とmicroCMSへの移行処理を合わせたものがこちらになります。

// contents.js

import "dotenv/config";
import * as cheerio from "cheerio";

const serviceDomain = process.env.MICROCMS_SERVICE_DOMAIN;
const apiKey = process.env.MICROCMS_API_KEY;

// microCMSの画像格納用
const microCMSImages = [];

// microCMSの画像取得
const getMicroCMSImages = async (offset = 0) => {
	const limit = 100;

	const url = `https://${serviceDomain}.microcms-management.io/api/v1/media?limit=${limit}&offset=${offset}`;

	const requestParameters = {
		method: "GET",
		headers: {
			"Content-Type": "application/json",
			"X-MICROCMS-API-KEY": apiKey
		}
	};

	console.log(url);

	let data = "";
	try {
		const response = await fetch(url, requestParameters);
		data = await response.json();
		data.media.forEach((media) => {
			microCMSImages.push({
				url: media.url,
				name: media.url.match(/([^\/]+)$/)[0]
			});
		});
	} catch (e) {
		console.log(e);
	}

	if (data.totalCount <= offset + limit || data === "") {
		return;
	}

	offset += limit;

	await getMicroCMSImages(offset);
};

// microCMS上の画像取得処理実行
await getMicroCMSImages(0);

// microCMSに送信するデータ格納用
const data = [];

const response = await fetch("https://www.members.co.jp/results/");
const html = await response.text();
const $ = cheerio.load(html);

// 一覧ページの記事を取得
const items = $("#container > article > div > div:nth-child(3) > div > div");

for (let i = 0; i < items.length; i++) {
	const url = items.eq(i).find("a.normal").attr("href");
	const response = await fetch("https://www.members.co.jp" + url);
	const html = await response.text();
	const $ = cheerio.load(html);

	// タイトル取得
	const title = $("#container > article > h1").text();

	// カテゴリ取得
	const categoriesElement = $("#container > article > ul > li");
	const categories = [];
	for (let j = 0; j < categoriesElement.length; j++) {
		categories.push(categoriesElement.eq(j).attr("class"));
	}

	// コンテンツ取得
	const contents = $("#container > article > div.main-block");

	// imgのsrc置換
	contents.find("img").replaceWith(function () {
		const imgTag = $("<img>");

		// 元の属性を引き継ぐ
		imgTag.attr($(this).attr());

		// ファイル名を取得
		const oldSrc = $(this).attr("src");
		const imageName = oldSrc.match(/([^\/]+)$/)[0];

		// microCMS上にある画像のファイル名と一致している場合、URLを置換
		microCMSImages.forEach((microCMSImage) => {
			if (imageName === microCMSImage.name) {
				imgTag.attr("src", microCMSImage.url);
			}
		});

		return imgTag;
	});

	data.push({
		title,
		categories,
		contents: contents.html()
	});
}

// microCMSに送信
const endpoint = "results"; // エンドポイント
const url = `https://${serviceDomain}.microcms.io/api/v1/${endpoint}`;

for (let i = 0; i < data.length; i++) {
	console.log(data[i].title);

	const parameters = {
		method: "POST",
		headers: {
			"Content-Type": "application/json",
			"X-MICROCMS-API-KEY": apiKey
		},
		body: JSON.stringify(data[i])
	};

	try {
		await fetch(url, parameters);
	} catch (e) {
		console.log(e);
	}
}
node ./contents.js

実行すると無事移行できていました。

記事移行完了

コンテンツ参照のカテゴリも問題なくセットされています。

見出しやテーブル、画像も問題なく移行されています。

まとめ

今回は、株式会社メンバーズのHPにある実績紹介ページを例に、スクレイピングでmicroCMSに移行するプログラムを作ってみました。

リッチエディタがmicroCMSのWRITE APIに対応したことにより、今まではテキストエリアに移行していたものが、リッチエディタに直接移行できるようになって良かったと思います。

ただリッチエディタは万能ではないため、ボタン要素や横並びの画像など、丸ごと移行するだけではきれいに移行されないこともあると思います。

これらはカスタムクラスを使ったり別のスキーマと併用しつつ、サイトの構造に合わせて移行処理を作る必要があると思います。

こちらの記事がCMS移行する際の参考になれば嬉しいです。

#Jamstack #メンバーズ #microCMS #Node #cheerio

この記事が気に入ったらサポートをしてみませんか?