node-fetch + Cheerioでプロキシを刺しながらスクレイピングする【Node.js】
なぜnode-fetchとCheerioでスクレイピングするのか?
この理由は単純ですごく軽量で高速なスクレイピングが可能だからです。
ブラウザでの処理を挟まないため、画像読み込みやJS処理などが発生せず、1ページ分のHTMLの取得にかかる時間はかなり短くて済みます。低スペックなVPSなどでも高速に動かせるのが最も良いポイントでしょう。ただし、SPA等のJSを処理するようなページではPlaywrightを用いたスクレイピングが必要となります。「できるサイト、できないサイトがある」ことを意識せずにスクレイピングしたいなら、Playwrightを採用してしまうことをオススメします。
▼Playwrightを用いたスクレイピングの手法も記事にしています!
この記事に関する注意点
ts-nodeで実行する際に問題が起きたので、
node-fetchはversion 2を指定してインストールしています。
▼ 参考:
必要なものをインストールしよう
- node-fetch
- @types/node-fetch(node-fetchの型データ)
- https-proxy-agent
- Cheerio
npm install node-fetch@2 @types/node-fetch https-proxy-agent cheerio
それぞれの役割を知る
◆node-fetch
リクエストを投げてレスポンスを受け取るためのものです。
プロキシを簡単に適用できて軽量で動くので重宝しています。
node-fetchにはHTMLを解析する機能は付属しておらず、あくまでデータの送受信だけをするものです。
◆Cheerio
HTMLの解析ができるものです。
CSSセレクタ形式で要素へのアクセスが簡単にできて便利です。
スクレイピング処理としては十分な機能が備わっています。
再利用可能なフェッチ関数を用意する
毎度いろいろと記述するのが面倒くさいので、自作でクライアントを作成してフェッチしやすい状態にしておきたいと思います。
- proxy.ts
- fetch.ts
をつくりたいと思います。
リストからプロキシをランダムで1つ取得する関数
import fs from "fs/promises";
import path from "path";
export const getRandomTheProxy = async () => {
const proxy = await proxyList();
const proxyArray = proxy.trim().split(/\n/);
return proxyArray[Math.floor(Math.random() * proxyArray.length)];
};
const proxyList = (): Promise<string> => {
const fileProxyListPath = path.resolve(__dirname, "./../proxy/proxy-list.txt");
return new Promise(async (resolve) => {
const result = await fs.readFile(fileProxyListPath, "utf-8");
resolve(result);
});
};
node-fetchのクライアント
import fetch, { Response } from "node-fetch";
import { HttpsProxyAgent } from "https-proxy-agent";
import { getRandomTheProxy } from "@utils/functions/proxy";
import * as cheerio from "cheerio";
export const fetchClient = async () => {
const proxyHost = await getRandomTheProxy();
const proxyURL = `http://${proxyHost}`;
const client = new HttpsProxyAgent(proxyURL);
async function getRequest(url: string): Promise<{ response: Response; $: cheerio.CheerioAPI } | undefined> {
try {
const response = await fetch(url, { agent: client });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const body = await response.text();
const $ = cheerio.load(body);
return { response, $ };
} catch (error) {
console.error(error);
return undefined;
}
}
async function postRequest(
url: string,
{
headers = {},
parameters = {},
}: {
headers?: {};
parameters?: {};
}
): Promise<{ response: Response; $: cheerio.CheerioAPI } | undefined> {
try {
const response = await fetch(url, {
method: "POST",
agent: client,
headers: headers,
body: new URLSearchParams(parameters),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const body = await response.text();
const $ = cheerio.load(body);
return { response, $ };
} catch (error) {
console.error(error);
return undefined;
}
}
return {
client,
getRequest,
postRequest
};
};
const response = await fetch(url, { agent: client });
という記述がありますが、この { agent: client }
部分によってプロキシが有効化されます。
スクレイピングしてみよう
これで準備は整ったので実際にスクレイピングしてみます。
import { fetchClient } from "@utils/functions/fetch";
const targetURL = "https://www.reirieofficial.com/";
(async () => {
try {
const { getRequest } = await fetchClient();
const clientResponse = await getRequest(targetURL);
if (!clientResponse) throw new Error("GETリクエストで問題が発生しました。");
const { response, $ } = clientResponse;
const newsTitleElement = $("div[id^=comp-] div[data-mesh-id^=comp] h2");
const newsTitleText = newsTitleElement.text();
console.log(newsTitleText);
} catch (error) {
console.log(error);
}
})();
たったこれだけでプロキシ刺しながらスクレイピングが開始できるようになりました!
ポイントは
const { getRequest } = await fetchClient();
でフェッチのクライアントを作成し、
const clientResponse = await getRequest(targetURL);
でGETリクエストを行い、
const { response, $ } = clientResponse;
でCheerioが解析してくれたHTMLを $ として受け取る
の3点だけです。
$
は Cheerioの機能がそのまま使えます。
上記の例ではNEWSという文言を取得しています。
※ ちなみに要素取得をする際は const newsTitleElement = $("div[id^=comp-] div[data-mesh-id^=comp] h2");
のように元サイトがビルドされる度に変わりそうな箇所を予測して、スクレイピングに影響しないような指定を見つけるように意識するようにしましょう。
リストなどの繰り返し要素の値を取得したい
スクレイピングするなら必須ですね。
import { fetchClient } from "@utils/functions/fetch";
const targetURL = "https://www.reirieofficial.com/";
(async () => {
const results: {
title: string;
url: string;
}[] = [];
try {
const { getRequest } = await fetchClient();
const clientResponse = await getRequest(targetURL);
if (!clientResponse) throw new Error("GETリクエストで問題が発生しました。");
const { response, $ } = clientResponse;
const newsArticleElements = $("div[id^=pro-gallery-container-comp-] article");
newsArticleElements.each((index, newsArticle) => {
const newsArticleElement = $(newsArticle);
const newsArticleTitle = newsArticleElement.find("a p").text();
const newsArticleURL = newsArticleElement.find("a").attr("href");
results.push({
title: newsArticleTitle ? newsArticleTitle : "",
url: newsArticleURL ? newsArticleURL : "",
});
});
} catch (error) {
console.log(error);
}
console.log(results);
})();
.each
でループ処理をぶん回せます。
▼実行結果
Postmanでリクエストが通るパターンを事前に見つけよう!
いきなり node-fetch でリクエストしてしまってもいいのですが、
場合によってはheaderやCookie情報の有無で正常なリクエストを判別してるケースがあります。(CloudFrontを利用のサイトでこの動きを確認した。)
この場合、node-fetchで何も考えずにリクエストしてしまうと、そのIPアドレスを無駄死にさせてしまいます。これは勿体ないです。
事前に Postman を使って欠けてもいいデータを選別し確実にリクエストが通るように検証しましょう。(一度リクエストが通ったIPなら数十回は適当なリクエストしてもブロックされることは滅多にありません。仮にブロックされたとしたら相当スクレイピング対策に力を注いでるサイトだと思います。)
また、余裕があれば事前処理として、相手サイトのホストに向けてプロキシの有効性をチェックするような関数を書いておくと良いでしょう。(プロキシリストを一つずつリクエストしてブロックされてないかをチェックするような処理。)
POSTメソッドで何をリクエストで投げればいいか分からない方へ
スクレイピングは必ずしもGETリクエストで行うものではありません。
場合によってはPOSTメソッドで受け取ったデータに応じてビューが切り替わることがあります。
しかし、どのデータをPOSTすればデータが切り替わるか分からないでしょう。
そんなときも Postman を利用しましょう。
まずは Chrome開発者ツールの Network から対象のデータリソースを選択し、Payloadタブの From data欄から何が送信されているかを確認します。その中から処理に関係していそうなものに目星をつけましょう。(こればかりは地味に探すしかありません。)
それっぽいものを見つけたらPostmanで下記のようにやってみましょう。
POSTメソッドにし、Bodyタブでform-dataでキーと値を入れて、Sendをクリックします。
下部に結果が表示されますから、それが望んだ結果になっていれば成功です!
違う場合は他のform-dataを探してみるか、CookieやSessionが影響していないかを検証しましょう。(この作業が一番 "やってる感" あって楽しいです。)
リクエストの際にブラウザJSで動的に生成されるCookie情報が必要な場合
この場合はブラウザの介入が必要です。
PlaywrightでCookie生成までやってもらい、Cookie情報だけを受け取って node-fetch で実行するとコスト的を圧迫せずにスクレイピングが可能になります。
無理してnode-fetchだけで解決しようとせず、Playwright + node-fetch のハイブリッドなスクレイピングを検討してみましょう!(多くのサイトが厳格なものではなく楽観的な作りになっているのでこれで大体解決できます!)
動的に並列に処理したいプロセスを追加する
並列処理する際は Promise.all()
を使います。ただし、スクレイピング処理では失敗するケースがあるので成功・失敗に関わらず全ての処理を遂行してくれる Promise.allSettled()
の方が適していることでしょう。厳密化したい場合には Promise.all()
を使いましょう。
動的に処理を追加することも可能なのですが、普通のプログラミングではあまり使う機会が無いのでその記述例をメモしておきます。
※厳密には並列処理ではなく、並列実行と呼ぶらしいですが、便宜上 並列処理と呼ばせていただきます。
(async () => {
const promises: Promise<any>[] = [];
const keywords = ["七原くん", "ユキちゃん", "横山緑"];
for (let key in keywords) {
const promise = async () => {
// ここにPromiseな処理を書いていく
await nicoHaishinshaSearch(keywords[key]);
}
// 並列に処理したい関数をpromises配列に追加する
promises.push(promise());
}
// 並列処理を実行する
await Promise.allSettled(promises);
// Promise.allの処理がすべて完了したら下記が実行される
console.log("完了しました!");
})();
しかし、これだけでは同時実行数を制御できていないですし、記述も多くて面倒くさいのでclass化しておくことにします。
この処理をclass化する
export class AsyncPool {
allPromises: (() => Promise<any>)[] = [];
strict = false;
limit = 10;
count = {
success: 0,
error: 0,
};
constructor({ limit = 10, strict = false } = {}) {
this.limit = limit;
this.strict = strict;
}
addPromise(promise: () => Promise<any>) {
this.allPromises.push(promise);
}
clearPromises() {
this.allPromises = [];
this.count = {
success: 0,
error: 0,
};
}
async run() {
while (this.allPromises.length > 0) {
const chunkPromises = this.allPromises.slice(0, this.limit).map((thePromise) => thePromise());
this.allPromises.splice(0, this.limit);
if (this.strict) {
await Promise.all(chunkPromises);
} else {
const results = await Promise.allSettled(chunkPromises);
results.map((theResult) => {
switch (theResult.status) {
case "fulfilled":
this.count.success++;
break;
case "rejected":
this.count.error++;
break;
}
});
}
}
console.log(`【AsyncPool】 OK: ${this.count.success} / NG: ${this.count.error}`);
this.clearPromises();
}
getResultCount() {
if (!this.strict) return this.count;
console.warn("【注意】getResultCount() は stric: false でのみ利用できます。");
}
}
this.allPromises
配列でプロセスを管理しています。
終わったプロセスは this.allPromises.splice(0, this.limit);
によって排除していき、この中身が0個になるまでwhileループします。
このclassを使ってみる
function randomSleep() {
const sleepTime = Math.floor(Math.random() * (8000 - 3000 + 1)) + 3000;
console.log(sleepTime / 1000 + "秒待機を実行しています");
return new Promise((resolve, reject) => {
setTimeout(() => {
Math.random() < 0.35 ? resolve("") : reject();
}, sleepTime);
});
}
上記の関数を並列処理する場合、下記のように書けばOKです。
import { AsyncPool } from "@utils/functions/asyncPool";
(async () => {
const asyncPool = new AsyncPool();
for (let i = 0; i < 31; i++) {
asyncPool.addPromise(randomSleep);
}
// 処理実行中でも後からプロセスを追加できる
setTimeout(() => {
for (let i = 0; i < 31; i++) {
asyncPool.addPromise(randomSleep);
}
}, 1000 * 12);
await asyncPool.run();
// asyncPool.getResultCount();
})();
AsyncPool
に2つのオプションを渡せます。
- strict: trueにするとPromise.all() での実行をします。falseにすると Promise.allSettled()で実行します。
- limit: 同時実行の上限数を指定します。デフォルトでは10件同時に処理します。