🍆

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

2022/12/30に公開

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

なお、プロキシは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まで大幅に削減できています!

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

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

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

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

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

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

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

2-1. データの加工はスクレイピングが終わってからにしよう!

スクレイピング実行しながら同時にデータ加工したくなるかもしれません。
しかし、これは避けるべきです。

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

オリジナルデータが残っていれば何度も何度も加工をやり直せます・

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

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

await page.locator(".hoge") といきなり書くのはオススメできません。目視では問題なさそうでも意外と「複数要素あるんかい!」となる場面も少なくありません。思い込みでコードを書くのではなく、ChromeのConsoleタブから何個取得されそうかを調査しておきましょう。

document.querySelectorAll(".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 にするだけでヘッドレスモードで実行してくれます。

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

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

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

通常ブラウザでは問題ないはずが、 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/

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

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

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

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

スクレイピングの高速化

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

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

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

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

Canvasで作られたサイトのスクレイピング

最近は Flutter で作られたサイトも増えてきました。
中には CanvasKit を採用しているものもあります。

CanvasはHTML要素と異なりデータアクセスができません。
これは困った。

でも実はCanvasで作られていてもスクレイピングする術が残されています。
.waitForResponse() を用いてNetworkからfetchデータを盗み見してしまうという方法です。僕は今のところ、これが最適解だと思っています。

下記に記事を書きましたのでご覧ください。
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は最初は辛いですが、覚えると脳みそほとんど使わずにコード書けるようになります!!)

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

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. 任意のリクエストを作成して発火する

まだ調査中で試してないので何も分かりませんが、CORSを発生させずに任意のリクエストを作成して実行できる機能もありそうです。(UI上にボタンが無くても任意のURLに form-data を添えてリクエストするようなことができそう?)

https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-fetch

試して動きそうであれば詳しく追記しておきます。

調査中-2. 隣接する要素を取得する

下記のようにそれぞれが囲われておらず、なおかつ隣接する要素が増減するケースに対応する方法を確立させたい。

Aパターン
<div class="list">
  <h1>REIRIE</h1>
  <p class="name">金子理江</p>
  <p class="instagram">https://www.instagram.com/bite_me_3/</p>
  <p class="name">黒宮れい</p>
  <p class="instagram">https://www.instagram.com/suicide_u/</p>
</div>
Bパターン
<div class="list">
  <h1>REIRIE</h1>
  <p class="name">金子理江</p>
  <p class="name">黒宮れい</p>
  <p class="instagram">https://www.instagram.com/suicide_u/</p>
</div>

Aパターンは .name も .instagram も存在してるので、

const nameElements = await page.locator('.name');
const instagramElements = await page.locator('.instagram');

for (let i = 0; i < await nameElements.count(); i++) {
  const nameText = (nameElements.nth(i).innerText()).trim();
  const instagramText = (instagramElements.nth(i).innerText()).trim();
}

とできる。
しかし、Bパターンでは .instagram が一部抜けているため for 文で回してしまうとズレてしまい、不適切なデータ取得となってしまう。

現時点の解決策としては .evaluate() を用いてChrome側でJSを走らせて、要素を囲って使いやすい形に変更するのが良さそうです。jQueryの .wrapAll() 相当の処理を行ってから await page.locator('.hoge') で要素を取得するという流れ。

つまり、

Bパターン
<div class="list">
  <h1>REIRIE</h1>
  <div class="profile">
    <p class="name">金子理江</p>
  </div>
  <div class="profile">
    <p class="name">黒宮れい</p>
    <p class="instagram">https://www.instagram.com/suicide_u/</p>
  </div>
</div>

となるように頑張ってJSで操作するということです。

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

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

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

Discussion