この記事は「簡易的なSite Checkerを作ろう!」シリーズの第1回です
- Playwright vs Cheerio、チューニング・工夫 (本記事)
- SEO スコアリング編 (仮)(近日公開)
- フロントエンド編 ─ Canvas でサイト構造を可視化 (仮)(近日公開)
はじめに
こんにちは、マーズフラッグの小池です。
普段はフロントエンドエンジニアとして働いていますが、最近はバックエンドにも興味が出てきて、個人開発で挑戦しています。
最近社内で「site checker」というサービスの開発案が出ています。
ですが本格的に進みそうなのはまだまだ先の話らしいので、「待ってるだけじゃつまらないな、簡易的なものを先に作ってみよう」と思い立ち、個人で開発してみました。
この記事では、自分なりに考えて作った点や苦労した点を書いていきます。
同じようなツールを作りたい方の参考になれば嬉しいです。
私の考える簡易的site checker とは?
site checker とは、特定のサイトをクロールし、その結果をもとにサイトの品質を独自スコアリングするサービスです。
具体的なチェック項目の例:
- title タグが設定されているか
- meta description が設定されているか
- meta keywords が設定されているか
- canonical タグが設定されているか
- OGP が設定されているか
- リンク切れがないか(HTTP 404 などが返ってこないか)
すごく簡易的ですが、まずはこれらの項目を取得してスコアリングする仕組みを作っていきます。
まずは第一回ということで、このサービスの根幹となる、サイトの情報を取得する「クロール」の部分にフォーカスしていきたいと思います。
技術選定
では実際サイトのクロールってどうやって行っていけばいいのかなと思い、色々記事を読んだりしました。
そこで目に止まったのが「Crawlee」というライブラリでした。
Crawlee とは?
Crawlee は、 Web スクレイピング・クローリングライブラリです。
Apify 社が開発しており、以下のような特徴があります。
- TypeScript 対応 - 型安全に開発できる
- 複数のクローラータイプ - Cheerio、Playwright、Puppeteer から選択可能
- 自動スケーリング - 負荷に応じて並行処理数を自動調整
- リトライ機能 - 失敗したリクエストを自動で再試行
- セッション管理 - Cookie やセッションを自動管理
公式サイト: https://crawlee.dev/
Playwright vs Cheerio
Crawlee では、クローラーのエンジンとして主に3つの選択肢があります。
| 観点 | Playwright | Puppeteer | Cheerio |
|---|---|---|---|
| 動作方式 | ヘッドレスブラウザ | ヘッドレスブラウザ | HTTP リクエストのみ |
| 対応ブラウザ | Chromium, Firefox, WebKit | Chrome | - |
| JS 実行 | ○ | ○ | × |
| 速度 | 遅い | 遅い | 速い |
| メモリ使用量 | 多い | 多い | 少ない |
| 取得できる HTML | レンダリング後の DOM | レンダリング後の DOM | raw HTML |
| 開発元 | Microsoft | - |
最初は Playwright を選定
正直、最初は深く考えずに Playwright を選びました。
「ブラウザを動かせた方が高機能でなんでもできるしいいかな!」くらいの気持ちです。
実際に動かしてみると、ちゃんとクロールできるので「やるな〜、すごいな〜」と喜んでいました。
しかし、いくつか問題が出てきました。
- メモリ消費が激しい - ブラウザを複数立ち上げるため
- 速度が遅い - ページごとにレンダリングを待つ
ローカルの環境でやっていたので、上記項目が結構気になってしまいました。
再度要件定義確認を実施
ここで改めて要件を整理しました。
欲しいもの:
- title、description、OGP などのメタ情報
- canonical、リンク切れのチェック
- サイトの階層構造
不要なもの:
- JavaScript 実行後の DOM
- スクリーンショット
- SPA のレンダリング結果
今回は簡易版で、取得する内容がブラウザ不要のものだけだったので、Playwright でなくてもいいという結果になりました。
また公式ドキュメントにも記載があり、JavaScript の実行が不要なサイトであれば CheerioCrawler が推奨されています。Cheerio は Playwright/Puppeteer と比べて約10倍高速とのことです。ドキュメントの確認不足は反省点です。
Cheerio に変更
メタ情報やリンクの取得であれば、サーバーから返ってくる raw HTML で十分です。
JavaScript の実行は不要ということがわかりました。
ということで、Cheerio に変更しました。
結果:
- 速度が大幅に向上 - ブラウザ起動のオーバーヘッドがない
- メモリ消費が激減 - HTTP リクエストのみなので軽量
- 環境構築がシンプルに - ブラウザのインストール不要
「高機能のものを選んどけば間違いない!」という安易な考えでいつもオーバースペック気味のものを選んでしまいますが、要件にあったものを選定することの大切さを学びました。
Crawlee の設定
ここからは、実際の Crawlee の設定について解説します。
基本的なセットアップ
import { CheerioCrawler } from "crawlee";
const crawler = new CheerioCrawler({
async requestHandler({ request, response, body, contentType, $ }) {
const data = [];
// Do some data extraction from the page with Cheerio.
$('.some-collection').each((index, el) => {
data.push({ title: $(el).find('.some-title').text() });
});
// Save the data to dataset.
await Dataset.pushData({
url: request.url,
html: body,
data,
})
},
});
await crawler.run([
'http://www.example.com',
]);
シンプルでかつわかりやすい + 記法が js なのでフロントをやっている私に優しいです。
設定地獄の始まり
開発を進めていよいよクロールしよう!とサーバーにリクエストを送りました。
しかし、なんだか遅いし、エラーも出ちゃうし、何やらおかしいことがたくさんあることに気が付き、クローラの設定を見直すことにしました。
ですが、ドキュメントを読んでいけば行くほど色々な制御があり、直面したエラーの回避に当たる設定を見つけ出していくのが地獄でした...
下記が実際に私が行っている設定です!
const crawler = new CheerioCrawler({
requestHandler: router,
maxRequestsPerCrawl: 100,
maxRequestRetries: 2,
maxConcurrency: 50,
useSessionPool: true,
requestHandlerTimeoutSecs: 60,
minConcurrency: 1,
sessionPoolOptions: {
maxPoolSize: 50,
sessionOptions: {
maxUsageCount: 100,
maxErrorScore: 5,
},
},
autoscaledPoolOptions: {
minConcurrency: 1,
maxConcurrency: 50,
systemStatusOptions: {
maxEventLoopOverloadedRatio: 0.4,
maxCpuOverloadedRatio: 0.4,
maxMemoryOverloadedRatio: 0.8,
},
},
});
「わーお、何これ...」という気持ちでした。笑
主要な設定項目の解説
英語のドキュメントを読むのが大変だったので、1つ1つ設定を日本語で書いていきたいと思います。
※ 以下の説明は私が公式ドキュメントを読んで理解した内容です。誤りがありましたらコメントでご指摘いただけると嬉しいです。
並行処理系
| 設定 | 説明 | 設定値 |
|---|---|---|
maxConcurrency |
最大同時実行数 | 50 |
minConcurrency |
最小同時実行数 | 1 |
maxRequestsPerCrawl |
クロールする最大ページ数 | 100 |
Cheerio は軽量なので、Playwright より同時実行数を増やせます。
Playwright だと 5〜10 が限界でしたが、Cheerio なら 50 でも安定しました。
リトライ・タイムアウト系
| 設定 | 説明 | 設定値 |
|---|---|---|
maxRequestRetries |
リトライ回数 | 2 |
requestHandlerTimeoutSecs |
タイムアウト秒数 | 60 |
サーバーの応答が遅いページもあるので、タイムアウトは長めに設定しました。
セッションプール
sessionPoolOptions: {
maxPoolSize: 50,
sessionOptions: {
maxUsageCount: 100, // セッションが破棄されるまでの最大使用回数
maxErrorScore: 5, // この値に達するとセッションがブロック扱いで破棄される
},
},
セッションプールを使うことで、同一サイトへのリクエストで Cookie などを維持できます。
maxErrorScore に達したセッションは「ブロックされた」と判断され、自動的に新しいセッションに切り替わります。
オートスケーリング
autoscaledPoolOptions: {
systemStatusOptions: {
maxEventLoopOverloadedRatio: 0.4,
maxCpuOverloadedRatio: 0.4,
maxMemoryOverloadedRatio: 0.8,
},
},
システムの負荷状況に応じて、自動で並行処理数を調整してくれます。
この値は、CPU やメモリが逼迫したときに処理を抑制する閾値です。
※ 公式ドキュメントでは「十分なテストなしにデフォルト値を変更しないこと」が推奨されています。私の環境では上記の値で安定しましたが、環境によって最適値は異なる可能性があります。
工夫したポイント
また上記設定以外にもクロールを安定的にできるよう、工夫をいくつかしてみました。
1. リクエスト間の遅延(レートリミット対策)
クローラーを作る上で大事なのは「礼儀正しくリクエストを送る」ことだと認識してます。(なんのリクエストでもそうだと思いますが。)
サーバーに過度な負荷をかけると、ブロックされるリスクがあります。
そこで、リクエスト間に1秒の遅延を入れるようにしました。
router.addDefaultHandler(async ({ request, $, log }) => {
// リクエスト間に1秒の遅延
await new Promise((resolve) => setTimeout(resolve, 1000));
// クロール処理...
});
これにより、サーバーへの負荷を軽減し、ブロックされるリスクを減らすことができます。
実際に多くのページを高速でかつ安定的に処理することができるようになりました。
2. ストレージの選択的クリア
Crawleeは内部でストレージを使用しており、前回のクロール結果が残っていると問題になることがあります。
しかし、すべてをクリアしてしまうと、セッションプールの状態まで消えてしまいます。
そこで、セッション状態を保持しつつ、前回のクロール結果だけをクリアする関数を作りました。
async function clearStorage() {
const kvPath = "./storage/key_value_stores/default";
// 保護するファイル(セッション状態など)
const protectedFiles = ["SDK_SESSION_POOL_STATE.json", "INPUT.json"];
const files = await fs.readdir(kvPath);
for (const file of files) {
if (!protectedFiles.includes(file)) {
await fs.unlink(path.join(kvPath, file));
}
}
}
セッションプールの状態を維持することで、毎回ゼロから始めずに効率的にクロールできます。
ハマったポイント
上記設定や工夫の他にも色々ややこしい点やハマってしまった部分もあります。
1. 別ドメインが混入する問題
https://www.example.com/ をクロールしていたら、なぜか https://admin.example.com/ もクロールされていました。
原因: enqueueLinks の strategy を same-domain にしていたため、サブドメインも対象になっていた
解決策: strategy: "same-hostname" に変更する
await enqueueLinks({
strategy: "same-hostname", // 同じホスト名のみ(サブドメインは含まない)
exclude: [/\.(pdf|jpg|png|gif)$/i],
userData: { userId, projectId, crawlResultsId },
});
2. 認証ページでクラッシュ
認証が必要なページ(401、403)にアクセスすると、エラーになることがありました。
解決策: ステータスコードをチェックしてスキップ
requestHandler: async ({ request, response }) => {
if (response?.statusCode === 401 || response?.statusCode === 403) {
log.info(`Skipping protected page: ${request.url}`);
return;
}
// 通常の処理
}
3. ハッシュ URL の対応
https://example.com/page#section のような URL が別ページとして認識されてしまう問題がありました。
解決策: ハッシュを除去して処理
const urlWithoutHash = request.url.split('#')[0];
まとめ
今回は Crawlee でサイトクローラーを作る際の技術選定と設定について書きました。
学んだこと:
- 要件に合った技術を選ぶことが大事(Playwright vs Cheerio)
- 設定項目は多いが、一つずつ理解すれば怖くない
- 礼儀正しいクローラーを心がける(レートリミット対策)
- ハマりポイントは事前に知っておくと楽
次回は、クロールしたデータを使った SEO スコアリング (仮) について書いていきます。
最後まで読んでいただきありがとうございました!
