Closed9

node-fetch + Cheerioでプロキシを刺しながらスクレイピングする【Node.js】

ピン留めされたアイテム
masa5714masa5714

【はじめに】安価で高品質な有料のプロキシリストをお探しの方へ

WebShare を強くおすすめします。
僕もスクレイピングの際に絶対に利用してるプロキシリストです。

IP認証機能も備わっていたり、APIが提供されていたり大変便利です。

その上かなり安価です。
様々なプロキシを検討してきましたが、WebShare を超える低価格は現時点で見つかっていません。間違いなく最強です。


masa5714masa5714

なぜnode-fetchとCheerioでスクレイピングするのか?

この理由は単純ですごく軽量で高速なスクレイピングが可能だからです。
ブラウザでの処理を挟まないため、画像読み込みやJS処理などが発生せず、1ページ分のHTMLの取得にかかる時間はかなり短くて済みます。低スペックなVPSなどでも高速に動かせるのが最も良いポイントでしょう。ただし、SPA等のJSを処理するようなページではPlaywrightを用いたスクレイピングが必要となります。「できるサイト、できないサイトがある」ことを意識せずにスクレイピングしたいなら、Playwrightを採用してしまうことをオススメします。

▼Playwrightを用いたスクレイピングの手法も記事にしています!

https://zenn.dev/masa5714/scraps/cac4c2cd6a0ad0

この記事に関する注意点

ts-nodeで実行する際に問題が起きたので、
node-fetchはversion 2を指定してインストールしています。

▼ 参考:
https://zenn.dev/tatsuyasusukida/articles/poor-compatibility-between-ts-node-and-node-fetch

masa5714masa5714

必要なものをインストールしよう

  • 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セレクタ形式で要素へのアクセスが簡単にできて便利です。
スクレイピング処理としては十分な機能が備わっています。

masa5714masa5714

再利用可能なフェッチ関数を用意する

毎度いろいろと記述するのが面倒くさいので、自作でクライアントを作成してフェッチしやすい状態にしておきたいと思います。

  • proxy.ts
  • fetch.ts

をつくりたいと思います。

リストからプロキシをランダムで1つ取得する関数

utils/functions/proxy.ts
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のクライアント

utils/functions/fetch.ts
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 } 部分によってプロキシが有効化されます。

masa5714masa5714

スクレイピングしてみよう

これで準備は整ったので実際にスクレイピングしてみます。

index.ts
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"); のように元サイトがビルドされる度に変わりそうな箇所を予測して、スクレイピングに影響しないような指定を見つけるように意識するようにしましょう。

masa5714masa5714

リストなどの繰り返し要素の値を取得したい

スクレイピングするなら必須ですね。

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 でループ処理をぶん回せます。

▼実行結果

masa5714masa5714

Postmanでリクエストが通るパターンを事前に見つけよう!

いきなり node-fetch でリクエストしてしまってもいいのですが、
場合によってはheaderやCookie情報の有無で正常なリクエストを判別してるケースがあります。(CloudFrontを利用のサイトでこの動きを確認した。)

この場合、node-fetchで何も考えずにリクエストしてしまうと、そのIPアドレスを無駄死にさせてしまいます。これは勿体ないです。

事前に Postman を使って欠けてもいいデータを選別し確実にリクエストが通るように検証しましょう。(一度リクエストが通ったIPなら数十回は適当なリクエストしてもブロックされることは滅多にありません。仮にブロックされたとしたら相当スクレイピング対策に力を注いでるサイトだと思います。)

また、余裕があれば事前処理として、相手サイトのホストに向けてプロキシの有効性をチェックするような関数を書いておくと良いでしょう。(プロキシリストを一つずつリクエストしてブロックされてないかをチェックするような処理。)

masa5714masa5714

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 のハイブリッドなスクレイピングを検討してみましょう!(多くのサイトが厳格なものではなく楽観的な作りになっているのでこれで大体解決できます!)

masa5714masa5714

動的に並列に処理したいプロセスを追加する

並列処理する際は 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化する

asyncPool.ts
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です。

index
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件同時に処理します。
このスクラップは6ヶ月前にクローズされました