🍆

スクレイピング用途でPlaywrightを使うときに知っておきたいこと

2022/12/30に公開

AIを使わない低コストなスクレイピングに重きを置いています。個人開発者にこそ読んで頂きたい記事です!

2024年4月2日: 全文書き直しました!

Playwrightでスクレイピングするために必要な基礎知識や経験から得た知識などをまとめてみました。僕がスクレイピングを初めた頃に知りたかったことも含めています。

ボリュームが多くなってきていて情報が探しにくいと思いますので、Zennの目次機能を使うと便利です。今後もこの記事に情報を追加していきます!

0. プロキシについて

継続的なスクレイピングを行うには欠かせない必須知識です。とても大事な内容のため、一番最初に触れておきます。

0-1. プロキシは必ず刺せ!

スクレイピングの際は必ず有料プロキシを利用しましょう。IPアドレス単位でアクセス制限をかけられることがあります。仮に自宅IPアドレスがブロックされてしまうと、普段の利用ができなくなり不便です。その対策として、ブロックされても問題ない "捨てIPアドレス" として「プロキシ」というものがあります。

プロキシを刺した場合、データの流れは下記の通りになります。

このようにデータ送受信の間にプロキシが入るため、相手サイトには自宅IPアドレスを知られることはありません。相手サイトとしてはプロキシサーバーのIPアドレスしかブロックできない訳ですね。ということで、プロキシはスクレイピングにおいて欠かせないものだと認識しておきましょう。

0-2. プロキシはどうやって手に入れるのか?

「プロキシ 有料」などで検索してみましょう。

たくさんのサービスが出てきますから、お好きなものをお選び頂ければと思います。ただし、日本のサイトのスクレイピングを検討している場合は 日本リージョンのプロキシ があるかを確認しておきましょう。

理由は単純で日本のWebサイトではサイバーセキュリティの観点から海外IPアドレスのアクセスを拒否している場合もあるからです。(海外リージョンのプロキシでもアクセスできることも普通にあります。海外アクセスを拒否しているサイトかどうかをチェックしておきましょう。)

サービス名: WebShare
安くて安定していて速度の劣化がほとんどありません。

金額のイメージをして頂けるように一例をご紹介します。
下記構成は月2.24ドルで利用可能。

  • データセンタープロキシ
  • 共有プロキシ
  • 75個のIPアドレス(全て日本リージョン)
  • 250GBの帯域幅
  • API提供あり

他のサービスを検討して頂くとお分かり頂けますが、この価格はかなり激安です。様々なプロキシを探してきましたが、これほど安く高品質なプロキシは他に見たことありません。一度、 WebShare をお試し頂く価値はあるかと思います。まさに個人開発者に優しいプロキシです。

ダッシュボードでは上記のように「今のペースで使ってると今月は追加課金が必要になるかもしれないから注意してね!」という通知も表示してくれて親切です。優しい!

0-3. WebShareのプロキシリストを使った実装例

Playwrightは実行までに色々と記述をしなければならず気軽にスクレイピングを開始するにはちょっと面倒くさいです。なので僕は実行の際に自動的にプロキシリストからランダムで選択される仕組みを実装して使っています。(下記に畳んであります。)

なお、プロキシはWebShareのIP認証機能でパスしているのでPlaywright上では認証処理を含めていません。

実装例を見る

▼プロキシをランダムで取得する関数

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);
  });
};

▼Playwrightをスクレイピング用に調整した関数

utils/functions/browser.ts
import "dotenv/config";
import { getRandomTheProxy } from "@utils/functions/proxy";
import { chromium, Browser, BrowserContext, Page, Response } from "playwright";

export const chrome = (
  headless = false
): Promise<{
  browser: Browser;
  context: BrowserContext;
  page: Page;
  waitForResponseResource: (resource: string) => Promise<Response>;
}> => {
  return new Promise(async (resolve) => {
    const proxyURL = await getRandomTheProxy();

    // 帯域幅の節約のため、Webフォントと画像を無効化してます
    const browser = await chromium.launch({
      headless: headless,
      args: ["--blink-settings=imagesEnabled=false", "--disable-remote-fonts"],
      proxy: {
        server: `http://${proxyURL}`,
      },
    });
    const context = await browser.newContext();
    const page = await context.newPage();

    // 指定したリソースのレスポンスが返ってくるまで待機する
    // 非同期が含まれるサイトのスクレイピングをする際に超便利!
    function waitForResponseResource(resource: string) {
      return page.waitForResponse(
        (response) => {
          if (response.url() === resource && response.status() === 200) {
            return true;
          }
          return false;
        },
        { timeout: 60 * 1000 }
      );
    }

    resolve({
      browser,
      context,
      page,
      waitForResponseResource,
    });
  });
};

これらの関数を使ってスクレイピングをするには?

index.ts
import { chrome } from "@utils/functions/browser";

(async () => {
  // await chrome(true) とするとヘッドレスで実行できます
  const { page, browser } = await chrome();

  // ブラウザを閉じる
  await browser.close();
})();

pagebrowser も通常のPlaywrightと同じものです。ただ単に使いやすいようにラップしただけです。かなり少ない記述でプロキシを適用したり、画像やWebフォントを無効にした状態でのスクレイピングを開始できるので、この書き方オススメです!

よろしければご活用ください。

0-4. プロキシの帯域幅の節約を意識しよう!

有料プロキシを使う場合、低コストで済ませたいならば、帯域幅を強く意識する必要があります。

帯域幅とはデータ通信量のようなもので送受信できるデータ量には制限が設けられています。不足分は課金すればいいのですが、帯域幅を月250GBから月1,000GBにアップするだけで価格が2倍以上に跳ね上がることもあります。(僕が使ってるプランでは実際にそうでした。)

可能な限り月250GBで済むように帯域幅の節約を意識した方がお財布に優しくなります。対策としてはシンプルなものでして、僕の節約方法としては、

  • Webフォントを無効にする
  • 画像を無効にする
  • CSSを無効にする
  • 不要なJSファイルだけを無効にする

を取り入れています。
たったこれをするだけで余裕で90%ほど削減できるので絶対にやりましょう!(スクレイピングできる量が10倍ぐらいになる)

上記は実際に削減した例です。(上が削減後、下が削減前)
3.6MBから279kBまで大幅に削減できています!もっと下げたいところですが、最近のWebサイトはJSがバンドルされていることが多く限界があります。こればかりは仕方がありません。

画像の非表示とWebフォントの無効化は

const browser = await chromium.launch({
  args: [
    "--blink-settings=imagesEnabled=false",
    "--disable-remote-fonts"
  ],
})

と指定するだけです。他にも様々なオプションがあります。(下記参照)
https://peter.sh/experiments/chromium-command-line-switches/

他にもPlaywrightには「ファイルを指定してリクエストを制御する」ことができます。 "このファイルは必要だけど、これは不要なのでリクエストしない" ができちゃいます。

https://zenn.dev/masa5714/articles/d798c4fa5f8b08

0-5. プロキシの切り替えをする場合はCookieを削除しよう

Playwright単体で使う場合は関係ない話なので無視してください。
Seleniumなどに接続してスクレイピングする場合に気をつけたいのが「CookieやLocalStorage」などの存在です。

プロキシを刺していても "同じ人" と判別できるような痕跡を残さないように注意してください。些細な情報から同一人物と分かってしまうと、全く別のIPアドレスであっても、プロキシリストが一斉にブロックされることがあります。(これは経験によるもので、実際にGAFAMのいずれかのサイトでプロキシリストが一斉にブロックされたことがあります。)

プロキシの無駄死にをさせないためにも注意しておきましょう!

1. Playwrightのスクレイピング処理

スクレイピングでよく使うものをピックアップして掲載しています。

1-0. 処理の基本

import { chromium } from "playwright";

(async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();
})();

このように書くことでスクレイピングを開始できます。
ただ、毎度これらを書くのは面倒なので、「0-3. WebShareのプロキシリストを使った実装例」 で紹介した書き方がオススメです。

1-1. ページ遷移する

await page.goto("https://www.reirieofficial.com/");

指定したURLにアクセスできます。

1-2. 要素の取得

const element = await page.locator("#hoge");

JSの document.querySelectorAll() とほぼ同じだとイメージすると分かりやすいかと思います。対象箇所が複数あるなら全部取得されます。

.locator() には await は不要なはずですが、何故か取得できないことが度々あるので一応つけています。損することはないので付けておいても全然問題ないです。

1-3. 複数要素のループ処理(要素ごとに処理する)

const elements = await page.locator(".article");
const count = await elements.count();
for (let i = 0; i < count; i++) {
  const text = await elements.nth(i).innerText();
  console.log(text);
}

この例では要素ごとにテキストを取得しています。 .nth() で一つずつ対象要素を切り替えているイメージですね。

1-4. テキストを取得する

const element = await page.locator("#hoge");
const text = await element.innerText();

.innerText() でテキストを取得できます。
テキスト取得時には .trim() しておくことをオススメします。

1-5. 要素の有無を判定する

const element = await page.locator("#hoge");

if (await element.count() > 0) {
  console.log("要素がありました!");
} else {
  console.log("要素がありませんでした...。");
}

.count() で要素を数えて 1つ以上 あるかで判定できます。
await 忘れがちなので注意!

1-6. テキストボックスにテキストを入力する

const inputElement = await page.locator("#input-hoge").fill("こんにちは");

.fill() でテキストを入力できます。

ラジオボタンやセレクトボックスについては下記をご覧ください。

https://playwright.dev/docs/input

1-7. マウス操作

カーソルを移動(実際にはカーソルは表示されない)
await page.mouse.move(100, 100);

x方向に100px、y方向に100pxの位置に移動します。

左クリックをする
await page.mouse.click(100, 100)

x方向に100px、y方向に100pxの位置でクリックします

マウス座標を計測する際には下記のブックマークレットが便利でした!

https://gist.github.com/darrenscerri/9019209

※要素をクリックする場合は別の方法が用意されています。基本的には下記を多用していきます。

await page.locator("#button").click();

1-8. ページスクロール

await page.mouse.wheel(0, 200);

マウスホイール操作(スクロールできる)もできます。
x方向に0px、y方向に200px ページを進めます。

なお、マウスホイールと同じ動きをするので、マウスカーソルの位置を移動させる必要がある場合もあるので注意してください。

1-9. キーボード操作

Enterキーを押す
await page.keyboard.press("Enter");

Enterキーを押したときと同じ動きをします。
フォームなどでEnterで送信する場合は事前に await page.locator("#hoge").fill("こんにちは") などでinput要素などを選択状態にしておきましょう。

1-10. クリック先の別ウィンドウをスクレイピングするには?

const [popup] = await Promise.all([
  page.waitForEvent("popup"),
  await page.locator("#button").click(), // ここでポップアップが開く
]);

// 別ウィンドウで開いたページのロード完了を待機
await popup.waitForLoadState();

// 別ウィンドウの中の要素のテキストを取得
await popup.locator("#hoge");

// 別ウィンドウで開いたものだけを閉じる
await popup.close();

page ではなく popup を使ってスクレイピングしていく形になります。

1-11. JSによるページ遷移を待機する

通常、 await page.locator("#link").click() でページ遷移したら、ページ表示されるまで自動的に待機してくれます。しかし、JSによるページ遷移は待ってくれません。(例:location.hrefなどによる遷移)

そんなときは .waitForURL() を使います。(正規表現でも指定可)

https://playwright.dev/docs/api/class-page#page-wait-for-url

もしくは特定のリソースのレスポンスが返ってくるまで待機するのも良いでしょう。

0-3. WebShareのプロキシリストを使った実装例」 で紹介した関数に waitForResponseResource() というものを含めてあります。

await waitForResponseResource("https://example.jp/assets/app.js") と指定すれば、 https://example.jp/assets/app.js のレスポンスが返ってくるまで待機してくれるようになります。DOM上に変化の表れない非同期通信などに活用すると便利だと思います。

1-12. SPAやNext.jsなどの動的なフロントエンドで必要な「要素が出現するまで待機する」に対応するには?

動的に要素が出現する場合、出現を待機しなければなりません。それを解決するには expect() を使います。(『この要素絶対あるから待っとけ!今は無くても待っとけよ。絶対出てくるから。だから監視しとけ。』と伝えるための記述です。)

import { expect } from "@playwright/test";

const inputElement = page.locator("#dynamic-element .input");
await expect(inputElement).toBeVisible({ timeout: 0 });

待機処理は4行目で行われます。( inputElement が画面に表示されるまで待機する )
expect() が絡む to 系の処理はデフォルト 5000ms でタイムアウトとなってしまいます。5秒では足りないケースが大半なので、 timeout: 0 と指定することで、タイムアウトを無効化しています。

実際には 90000ms (1分半)ぐらいにしておき、あり得ないほど待機が発生したらタイムアウトでエラー扱いにした方が便利ではあります。

1-13. スマホのエミュレーションを有効化する

Chromeの開発者ツールにあるスマホデバイスのエミュレーション機能を有効にすることもできます。

import { devices } from "playwright";

await browser.newContext({
  ...devices["iPhone 14 Pro Max"],
});

使用可能なデバイスは こちらのページ をご覧ください。このページは常に更新されているので、お使いのPlaywrightバージョンによっては組み込まれていないこともあります。

また、スマホエミュレーションを有効にしていると、 .click() が使えなくなります。代わりに await page.touchscreen.tap(x, y) を使うことになります。 .locator()await page.locator('.hoge').tap() という形になります。ご注意ください。

1-14. スクリーンショットを撮る

await page.screenshot({
  path: "filename.png"
})

たったこれだけでスクリーンショットを撮影できます。この場合は filename.png というファイル名の画像が出力されます。

スマホのエミュレーションを有効にしている場合は、スマホデバイスのdpi基準となるので注意してください。例えば iPhone 14 Pro Maxは表示上は430pxですが、スクリーンショットになると1200px超のサイズになってしまいます。

解像度を気にしない場合は

await page.screenshot({
  path: "filename.png",
  scale: "css"
})

scalecss とすれば、表示されているそのままのサイズで撮影できます。タップ座標を指定する場合などに引っかかる可能性があるので注意しておきましょう。

また、要素だけに絞ってスクリーンショットを撮影することができます。要素だけを撮影したい場合に活用できます。

const theElement = await page.locator("#hoge");

await theElement.screenshot({
  path: "the-element.png",
  scale: "css"
});

2. スクレイピングで知っておきたいこと

2-1. オリジナルデータは必ず残しながらスクレイピングしよう!(データの加工は後から行おう)

ゴミデータを残したくないという考えで、スクレイピングを実行しながら同時にデータを加工したくなるかもしれません。しかし、これは極力やめましょう。必ずスクレイピングが終わってからデータの加工を行いましょう。

理由はすごく単純で「オリジナルデータが残らないから再度加工ができなくなってしまう」からです。後から別の形式にデータ加工をしたくなってもオリジナルデータが残っていなければ、改めてスクレイピングし直さなければなりません。完全に時間の無駄ですね。

オリジナルデータが残っていれば何度も何度も無限に加工できます。

※ただし .trim() などのオリジナルデータを傷つけない程度であればやっておきましょう。

※オリジナルデータを残しつつ、同時に処理するならば話は別です。この場合はスクレイピング中にデータ処理を行ってもOKです。

2-2. 要素の取得は事前にChromeの開発者ツールのConsoleタブで確認しておこう!

スクレイピングあるあるの一つに「要素の取得ができてなかっただけの単純なミス」があります。初歩的なミスを減らすだけでも大幅に作業効率を改善できます。

セレクタの記述ミスを減らす有効な方法をご紹介します。

document.querySelectorAll(".hoge");

これを事前にGoogle ChromeのConsoleタブで実行し、要素が正しく取得できるかを確認しておきましょう。たったこれだけのチェックだけでかなり改善できます。

page.locator(".hoge") をいきなり書くと想定外に複数要素が取得されていることも珍しくありません。思い込みでコードを書くのは避け、ちょっとした手間で快適なスクレイピングを行いましょう!

2-3. 予想外の動きをしたらひたすら throw new Error() しておこう!

スクレイピングは予期せぬエラーが当たり前に発生します。
一発で完璧なスクレイピングはほぼ不可能です。

想定してない動きや要素数を検出したら全て throw new Error("XXXでエラーが発生") とスローしておきましょう。そのときは処理が止まってしまいますが、コードを修正して既知の問題にしておきましょう。

スクレイピングのコードは "コードを育てる" という感覚を持って書いていきましょう。それが結果的に継続的なスクレイピングが可能な強いコードに育っていきます。

2-4. パフォーマンスを犠牲にしてでも書き込みは都度行うべき!

データ毎にファイルやDBに書き込むのはパフォーマンスの観点から避けたいかもしれません。しかし、スクレイピングにおいては都度書き込むようにしましょう。後でまとめて書き出すために配列で保持しておくなどはやめましょう。

なぜならスクレイピングは当たり前に処理が失敗します。プロキシ、サーバーレスポンス、IPブロック、サイトの更新など要因は様々。処理が止まってしまうと適切な書き方をしていなければ、これまでスクレイピングしたデータを失ってしまいます。

何度もやり直していては永遠に終わらないので、"失敗する前提" で都度書き出しをするようにしましょう。

3. CSVでデータを取り扱う

3-1. CSVとしてデータを書き出す

https://www.npmjs.com/package/csv-writer

csv-writer というパッケージが使いやすくてオススメです。
若干使いにくさも感じていて、そのあたりをカバーする関数を書いておきます。

※下記折り畳んであります。

csv-writerを快適に使うための関数

▼ 新規ファイル作成

_functions.ts
import fs from "fs/promises";

export async function createNewFileCSV(outputFilePath: string, columnNames: string[]) {
  await fs.writeFile(outputFilePath, columnNames.join(",") + "\n", "utf-8");
}

▼ csv-writerのセットアップ

_functions.ts
export function createCsvWriter(outputFilePath: string, columnNames: string[], append = false) {
  const header: {
    id: string;
    title: string;
  }[] = [];

  columnNames.map((theColumnName) => {
    header.push({ id: theColumnName, title: theColumnName });
  });

  return createObjectCsvWriter({
    path: outputFilePath,
    header: header,
    append: append,
  });
}
使い方

CSVファイルを新規作成してデータをCSVで書き出す

import path from "path";
import { createNewFileCSV, createCsvWriter } from "./_functions";

// CSVの項目名を定義する
const csvColumnNames = ["prefecture", "city", "town"];
// 出力先を決定する
const outputFilePath = path.resolve(__dirname, "./sample.csv");

(async () => {
  // CSVファイルを新規作成する
  await createNewFileCSV(outputFilePath, csvColumnNames);

  // 今回書き込むデータ
  const results = [
    {
      prefecture: "愛知県",
      city: "名古屋市中区",
      town: "上前津"
    }
  ]

  // csv-writerを準備する
  const csvWriter = createCsvWriter(outputFilePath, csvColumnNames, true);
  // csv-writerで書き込みを実行する
  await csvWriter.writeRecords(results);
})();

カラムの中にJSON形式でデータを格納したい場合は、 JSON.stringify() で文字列に変換することで実現可能です。

3-2. CSVデータをJSONとして呼び出す

CSVファイルを読み込んでJSONとして使う方法も紹介します。

https://www.npmjs.com/package/csvtojson

csvtojson というパッケージが使いやすいです。

import path from "path";
import csv from "csvtojson";

const inputFilePath = path.resolve(__dirname, "./sample.csv");

(async () => {
  const data: {
    prefecture: string;
    city: string;
    town: string;
  }[] = await csv({
    noheader: false, // カラム名の有無(false -> カラム名あり)
  }).fromFile(inputFilePath);

  // 1行ずつ処理していく
  for (let i = 0; i < data.length; i++) {
    console.log(data[i].town);
  }
})();

JSON.stringify() で格納されたデータは JSON.parse() でJSONに戻すことができます。

ブラウザを表示せずにスクレイピングしたい!

ブラウザを表示せずにバックグラウンドでスクレイピングしたいと考えていませんか?表示せずに使えるブラウザを「ヘッドレスブラウザ」と言います。Google Chromeもヘッドレスモードに対応しています。

※画面に表示されないだけでいつものブラウザと一緒です。JSもCSSも全て処理してくれています。目に見えないだけです。

await chromium.launch({
  headless: true
});

headlessオプションを true にするだけでヘッドレスモードで実行してくれます。

何も表示されずに動くため何をやってくれているか全く分かりません。未完成のスクレイピングコードの状態のときはヘッドレスモードを無効化して表示しながらコードを書いていきましょう!

ほぼ完璧に動くコードが書けてから有効化しましょう!

ヘッドレスモードでスクレイピングすると失敗してしまうことがある(要素の取得ができないなど)

通常ブラウザでは問題なくスクレイピングできていたのに、ヘッドレスモードを有効にした途端、正しくスクレイピングできなくなるというケースも存在します。これはユーザーエージェントに HeadlessChrome などの判定可能な文言が含まれていることが原因になっている可能性が考えられます。

解決策としては

const context = await browser.newContext({
  userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
});

のようにユーザーエージェントで通常ブラウザを装っておくだけです。
ユーザーエージェントは下記ページを参考にすると良いでしょう。(もしくはChromeのNetworkタブから確認する。)

https://techblog.willshouse.com/2012/01/03/most-common-user-agents/

※上記リンクアクセス時にCloudflareのCAPTCHAが表示されることがあります。(危険なサイトっぽい印象を与えがちなCloudflareのCAPTCHAデザインどうにかならんのかなぁ。最近多いよね。)

位置情報やマイクなどの許可を求めるポップアップを出ないようにする

await chromium.launch({
  args: ["--deny-permission-prompts"]
});

argsのオプションとして --deny-permission-prompts を付与してあげるだけで実現できます。逆に許可したい場合は、 .grantPermissions() を使うことでできます。

https://playwright.dev/docs/api/class-browsercontext

HTMLに出力されないようなレスポンスデータをスクレイピングするには?

HTMLには出力されないデータをスクレイピングしたいケースもあることでしょう。
例えば、Spotifyの歌詞機能の行ごとの同期再生時間のデータなど。このようなデータはレスポンスデータに含まれていることが多いです。

Playwrightならレスポンスデータのスクレイピングも可能です。具体的な方法を下記記事にしましたのでご覧ください。

https://zenn.dev/masa5714/articles/f80cc8c291d86c

スクレイピング高速化のアイデア

PlaywrightでのスクレイピングはJSやCSSや画像などのデータが含まれるため、ページ表示に時間がかかってしまいます。これがスクレイピング速度を遅らせる原因となっています。

ページ表示速度を爆速にするための手段として、 .route() という機能があります。"阿部寛のホームページ" 級に軽くできます。

https://zenn.dev/masa5714/articles/d798c4fa5f8b08

https://zenn.dev/masa5714/articles/c8973bd32b5b30

Canvasや複雑なHTMLで作られたサイトのスクレイピング

Flutterで作られたサイトが増えてきています。中には CanvasKit というものを使って出力しているサイトもあります。これらはかなり複雑なHTML構造になっており、HTMLタグからのスクレイピングが実質的に不可能なサイトです。HTMLタグが取得できなければクリックもテキスト取得もできません。

これは困った。

悩んで試行錯誤した結果、スクレイピングする方法を見つけました!取得したいデータが非同期で受け取る前提ではありますが、.route() からフェッチデータを盗み見するという方法です。また、非同期通信を発生させるためにクリックイベント発火が必要になりますが、これも「テンプレートマッチング」という手法を使って解決できました。今のところ、これが最適解だと考えています。(AIを使わない低コストなスクレイピングの場合。)

他に良い方法があればコメント欄で教えてください!

▼ Networkからデータを監視する
https://zenn.dev/masa5714/articles/639b1cfc246abe

▼テンプレートマッチング
https://zenn.dev/masa5714/articles/4aaf433262427e

CAPTCHA対策

CAPTCHAが実装されてるサイトのスクレイピングは不可能です。
というか、スクレイピングやスパム対策として作られたものなので、これを突破しようとするのは不適切です。(スクレイピングが適切かどうかは置いといて...。)

中にはCAPTCHAを突破するためのサービス(2Captcha)が提供されていますが、これを使うのはスクレイパーとして、なんか許せないです。こんなサービスを使うぐらいなら諦めてください。

ただし、人力と機械のハイブリッドなスクレイピングであれば個人的に良しとしています。(都合良すぎ!!)

ということで、CAPTCHAが出現したときに自分がCAPTCHA突破するのを待機する処理を紹介します。

const waitForCAPTCHA = (page) => {
  return new Promise(async (resolve) => {
    const counter = setInterval(async function () {
      const path = await page.url();
      if (path.match(/google\.com\/sorry\/index/)) {
        console.log("停止中");
      } else {
        console.log("続行します");
        clearInterval(counter);
        resolve("");
      }
    }, 500);
  });
};

// 使う
(async () => {
  // ...中略 const page
  await waitForCAPTCHA(page);

  console.log("CAPTCHAを突破したので再開する");
})();

これは google.com/sorry/index が含まれるURLに到達した場合に処理が停止されるコードです。CAPTCHAを突破すると処理が再開されます。

成功するまで規定回数までリトライするには?

スクレイピングは失敗が当たり前です。
データを受け取るまでに時間がかかったり、処理をしてるコンピュータ側の処理が追いつかないなど様々な要因で失敗することが多々あります。そんなとき、再実行のたびにコマンドを打っていては疲れてしまいます。そこで規定回数までリトライする方法をご紹介します。

※結構長くなってしまうので下記に折り畳んであります。

規定回数までリトライをする例
import { chrome } from "@utils/functions/browser";

(async () => {
  let { page, browser, waitForResponseResource } = await chrome(true);
  let retryCount = 0; // リトライが発生でインクリメント
  const configRetryNum = 10; // リトライの規定回数を定義
  let browserRefresh = false; // 再起動後にブラウザ起動の無限ループに入らないように対策

  // スクレイピングするデータを用意する
  const searchKeywords = ["REIRIE", "金子理江", "黒宮れい"];
  let i = 0;

  while (true) {
    try {
      if (retryCount >= configRetryNum) throw new Error(`Error: リトライ上限に達しました。`);

      // プロキシの帯域幅を節約するための記述
      await page.route(/(\.js.*|\.css.*)/, (route) => {
        return route.abort();
      });

      // ※for文のiは一番最後にインクリメントされるよ
      for (; i<searchKeywords.length; i++) {
        if ((i + 1) % 20 === 0 && !browserRefresh) {
          browserRefresh = true;
          throw new Error(`【メモリ解放】route処理が原因で重くなる問題を解決するためPlaywrightを再起動したい`);
        }
        browserRefresh = false;

        // 【ここにスクレイピング処理を書いていく】
        // 例:データを用いてスクレイピングしていく。
        await page.goto(`https://example.com/search/${searchKeywords[i]}`);

        // スクレイピング処理成功したのでリトライ数をリセット
        retryCount = 0;
      }

      // 全てのデータのスクレイピングが完了したらここまで到達する
      await browser.close();
      break; // whileループを抜ける
    } catch (error) {
      await browser.close();

      // 処理中にデータ関連で未知の挙動に遭遇したとき処理を中断する
      // 下記はエラーメッセージに「要調査」の文言が含まれていたら発火する
      if (typeof error === "string" && error.match(/要調査/)) {
        console.log(error);
        break; // whileループを抜けて処理を中断
      }

      // エラーメッセージを出力する
      console.log(error);

      if (retryCount >= configRetryNum) {
        console.log(`リトライが ${configRetryNum} 回連続で発生したため処理を強制終了します。`);
        break; // whileループを抜けて処理を中断
      }

      // ここまで到達できたらただの失敗。
      // リトライに向けて準備を行う
      retryCount = retryCount + 1;
      // 冒頭で呼び出してる関数と同じものを全て上書きする必要がある
      ({ page, browser, waitForResponseResource } = await chrome(true));
    }
  }
})();

スクレイピング予定のデータをメモリに保持しておき、
それをループして失敗したり、予定にない動きをしたらとにかく throw して例外処理へ。例外処理の中でブラウザ再起動の処理を行っています。

エラーメッセージの中に「要調査」とつければ未知の挙動を調査するために停止してくれるようになります。

全ての処理が終わったタイミングや任意のタイミングで break を使って無限ループから抜けて処理を完了するという流れです。仕組み自体はシンプルですがコードがややこしくて辛いですね。

例外エラーはデスクトップ通知が来るようにしておくと便利!(超簡単に導入できます)

VPSを用意するほどでも無い規模(3日以内で終わるようなもの)なら、デスクトップ通知が来るようにしておくとすごく便利です。例えば例外エラーを検出したら通知が来たり、処理が完了したら通知が来るようにするなど...。

node-notifier を使えば数行で導入できてしまいます。

import notifier from "node-notifier";

notifier.notify({
  title: "エラー - Playwright",
  message: "hogeを検出しました。",
  sound: true, // OS標準の通知音を鳴らしてくれる
  wait: false, // 通知の確認を待たずに終了する
});

すごく簡単に導入できるのでぜひ!
これをつけておけば、仮想デスクトップにしておいて裏側で処理させておけば、スクレイピングしてる感覚を捨てて日常生活を送れます!

VPSでスクレイピング処理をするならLinodeやDigitalOceanがオススメ

外部のコンピュータでスクレイピングを実行するとき、FaaSまたはVPSを検討することでしょう。しかし、FaaSは実行時間などの様々な制約があるためスクレイピングには向きません。(スクレイピング用途では実践してないので勝手な思い込みであれば申し訳ないです。)

となると、VPSを使うことになるのですが、海外事業者のサービス(Linoe / DigitalOcean / Vultr)の方がスクレイピングに適しています。起動中の時間だけ課金される仕組みになっており、必要なときだけ立ち上げ、不要になったら破棄するということが可能なため、VPSのコストを大幅に削減できます。Webサーバーとは異なり、スクレイピングはスポット的に動かす用途が主だと思いますので、そういう意味でスクレイピングに適したVPSだと言えます。

先で挙げた中で DigitalOcean には日本リージョンのVPSがありません。一番近くてシンガポールになります。物理的な距離があると通信に遅延が発生するため、極力日本リージョンのあるVPSを選択すべきです。そのため、LinodeやVultrを選んでおく方が安牌でしょう。とはいえ、DigitalOceanはダッシュボードが洗練されているので、使いやすさだけ見ればDigitalOceanも捨てがたいところではあります。

1台のVPSを契約するだけでも複数の処理をさせることができます。Dockerでコンテナを立てて、そのコンテナ内で処理をすればソースコードの改修もほぼ不要で並列処理が実現できます。メモリ1GBであってもnode-fetchによるスクレイピングであれば複数動かせますが、Playwrightでは複数稼働は難しいとお考え頂いた方がよろしいかと思います。

この辺りは色々と試して安定して動くようにバランスを調整してみましょう!

スクレイピング処理はClass化しておいた方が便利!

単なる関数だけで実装しようとするとかなり複雑になりがちです。また、並列実行しながら再起処理を書き加えようとすると難しく感じてしまう場合もあります。そんなときはクラス化しておきましょう!

この処理で使うときのプロキシはコレ、失敗したらプロキシを差し替えるとか、色々なパターンに柔軟に対応することができます。クラスを書き慣れていない方は、TypeScriptの書き方を覚えるとすごく楽になります!(TypeScriptは最初は辛いですが、覚えると脳みそほとんど使わずにコード書けるようになります!!)

▼ Android Chromeでのスクレイピングの例ですが、クラス化すると複雑な処理をこんな感じでコンパクトにできるのでオススメです!また、クラス化すると何故か処理を細かく分けて処理に名前を付けようという気持ちが湧くので後からコードを見たときに何をしているかが分かりやすくなります。

Playwrightだけで解決しようとするな!できれば、node-fetch でスクレイピングできる方法を探した方が時間的・金銭的にもコスト削減できるよ!

スクレイピングする際にPlaywrightだけで解決したくなるかもしれませんが、可能な限り node-fetch でも処理できないか検討しておきましょう。Playwrightは処理が重い上にページ表示まで時間がかかるなど課題があります。

そのような課題を解決するためにも、極力node-fetchを用いたスクレイピングができないかを模索することをオススメします。非力なマシンでも高速に動いてくれます。

ただし、node-fetchでスクレイピングする際に発生する問題点もあります。「リクエストの際にCookieが必要だが、JavaScriptで動的にCookieが生成される」というケースです。この場合、Playwrightでしか解決できないと考えてしまいがちですが、有効的な解決策のアイデアをご紹介します。

とても単純ですが、意外と忘れがちな解決策です。

(await page.context().cookies()).map((theCookie) => `${theCookie.name}=${theCookie.value};`).join(" ")

上記のように書けばPlaywrightのCookieをnode-fetchで使える形に変換できます。

最初の一発目だけPlaywrightに任せて、あとはnode-fetchで高速にデータ収集するという手法です。掛け合わせてスクレイピングできないかを検討するようにしてみると最高なスクレイピング環境が手に入ります!

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

単発のスクレイピングは簡単だが、低コストで継続的なスクレイピングは難しい

スクレイピングは簡単とよく言われていますが、簡単なのは単発のスクレイピングだからです。わずか500ページぐらいのスクレイピングであれば大したことはありません。めちゃくちゃ簡単です。

これが数千ページから1万ページを超えるとブロック対策をする必要が出てきたり、プロキシの帯域幅に気を使う必要が出てくるなど、考慮すべき点が多くなってきます。無限にお金を出せるなら難しくありませんが、個人レベルで低コストかつ大規模に継続的なスクレイピングをしようとすると一気にハードルが上がります。

規模によって難易度が大きく変わることを理解しておきましょう!

Android端末をPlaywrightで操作する

Android端末なら中古で3000円ぐらいでそれなりのスペックのものを購入できます。
これがスクレイピングマシンとして動いてくれたら最高じゃないですか?なんとPlaywrightにはAndroid端末のChromeを操作する機能も備わっています。

ADB経由でPlaywrightのコマンドを実行することができます。
母艦としてPCは必要ですが、重すぎるサイトを1台のパソコンでスクレイピングするのが厳しいとき、ページレンダリング部分をAndroid端末に担わせることができるので、PC側の負荷を下げつつ、大量スクレイピングが可能になります

https://zenn.dev/masa5714/articles/59f1c0e37ccc4c

【Windows向け】Playwrightで起動したChromiumのプロセスを強制終了する

滅多にありませんが、Playwirghtの管理下を離れてChromiumのプロセスが生き残ってしまうことがあります。これをNode.jsからプロセスを強制終了する方法を下記に記載します。なお、これは通常ブラウザのChromeはそのまま生き残らせて、PlaywirghtのChromiumだけを終了させてくれます。

Playwrightで browser.close() ができない状況に陥って機械的にプロセスを終了させたいときにご活用ください。

import { exec } from "child_process";

function killChromium() {
  return new Promise((resolve) => {
    exec(`wmic process where "ExecutablePath LIKE '%chromium%'" get ProcessId`, (error, stdout) => {
      const processes = stdout.match(/\d+/g);
      if (processes) {
        const pids = processes.map(Number);
        for (const pid of pids) {
          exec(`taskkill /F /PID ${pid}`);
        }
      }
      resolve(true);
    });
  });
}

extra. 付録

分類できないおまけ情報を掲載しています。

extra-1. Googleの検索結果のリンクが取得できるCSSセレクタ

Googleの検索結果ではclass名が変わってしまいます。継続的なスクレイピングをするならば、class名に頼らないCSSセレクタが必要です。

document.querySelectorAll('a[href^=http]:not(a[href*=google],a[class]):has(h1,h2,h3,h4,h5,h6):not(:is([jsslot],ul) a[href^=http]:not(a[href*=google],a[class]):has(h1,h2,h3,h4,h5,h6))')

僕が作ったこのセレクタを使ってみてください。

開発者ツールのConsoleで上記を実行してみると、検索結果のリンクだけが取得できるかと思います。(スポンサー、ニュース、マップ、目次リンクや画像リンクなど余計なものが除外されている。下記画像のように 赤枠の箇所を除外 し、あくまでリンクリストだけが対象となる。)

我ながら力作のセレクタです。

extra-2. 正規表現が苦手な人に使って欲しい Regex101

スクレイピングでは正規表現をかなり使います。
でも正規表現難しくて苦手じゃないですか?僕は超苦手です。

そこで、

https://regex101.com/

がオススメです。
ブラウザ上で手軽にテストできるのでちゃんと動く正規表現が作りやすくなります。このツールのおかげで比較的楽に正規表現に向き合えています。

extra-3. 並列処理でスクレイピングしたい

単体のPlaywrightでは並列でスクレイピングは難しいです。
最も簡単な方法は Selenium Grid に接続するという手段です。

興味がある方は下記をご覧ください。

https://zenn.dev/masa5714/articles/01d8f89fff1b68

node-fetchの例ではありますが、並列処理(厳密には並列実行と言うらしい)するコードを下記に記載しています。リンクを辿っていくような処理であっても動的にプロセスを追加して並列処理することが可能なクラスも掲載しています。このクラスでは同時実行の上限数を制御できるようにしています。(例:100件のプロセスが登録されても5件ずつ処理する。)

https://zenn.dev/link/comments/c15edc97eb17c9


extra-4. Supabaseでスクレイピングデータを溜め込んで処理結果を配信する構成を考えてみました。

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

無料 で運用できる方法で、なおかつ負荷分散するアイデアをスクラップにまとめてみました。よろしければご覧ください。

調査中

まだ実践してなくて調査中の内容を掲載しています。
実践後に記事に追加します。

調査中-1. スマホアプリのAPIキーぶっこ抜き

スマホアプリのAPIキーを抜いてリクエストするという荒業を過去にやったことがある。必要になったら手法を確立するために調査したいが、現時点では不要なので調査してない。

ちなみに当時やったのは Charles 経由でリクエストを監視してAPIキーを見つけるというもの。ちゃんとできた記憶がある。

Discussion