🍆

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

に公開

AIを使えば簡単と言われ始めているスクレイピング。コストを考えると、特に個人開発者が本格的に使うにはまだまだ厳しいことでしょう。本記事では AI を使わない低コストなスクレイピングだけを取り扱っています。コスト重視でスクレイピングをされる方に読んで頂きたいです。

僕がスクレイピングを初めた頃に知りたかった基礎的な内容が主です。かなりボリュームが増えてきており、情報が探しにくいため、Zennの目次機能を使うと便利です。今後も本記事に情報を追加していきます!

更新情報
スクレイピングに関する注意
ログインが必要なページのスクレイピングについて

やめとけ

0. 【超重要】プロキシの知識

何度実行しても問題なく動くようスクレイピングのコードを育てていく中で、どうしても欠かせないのが「IPアドレス」の問題です。IPアドレス単位でブロックされると処理ができなくなります。継続的なスクレイピングを実施するためには必須知識です。

とても重要な内容ですので、一番最初に触れておきます。プロキシの活用はスクレイピングにおいての基礎知識です。

0-1. プロキシは絶対に刺しておけ!

先述した通り、Webサイトでは IPアドレス単位でアクセス制限がかけられることがあります。これは主に不適切なアクセスをするIPアドレスに対して行われます。 ご存知の通り スクレイピングも不適切なアクセスの一つ です。

仮に自宅のIPアドレスのままスクレイピングを実施すると、これがブロック対象となり、普段の利用ができなくなり不便になってしまいます。その対策として ブロックされても問題ない "捨てIPアドレス" として「プロキシ」を活用 するのです。

プロキシの役割をイメージして頂くため、ざっくりと下記に示してみます。プロキシを刺した場合、データの流れは下記の通りになります。

このように、データ送受信の間にプロキシが入るため、相手サイトにはIPアドレスを知られることはありません。つまり 相手サイト目線ではプロキシのIPアドレスしかブロックしようがない という訳ですね。

スクレイピングにおけるプロキシの重要性がお分かり頂けたことと思います。

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

次に、そのプロキシの入手方法をご紹介します。

「プロキシ 有料」などで検索するだけで様々なサービスが出てきます。お好きなものをお選び頂ければ問題ありません。ただし、日本のサイトをスクレイピングする予定であれば、 日本リージョン(所在)のプロキシ があるか確認しておきましょう。

理由は単純で、Webサイトの中にはサイバーセキュリティの観点から海外IPアドレスからのアクセスを拒否している場合もあるからです。海外からのアクセスを受け付けているサイトであれば、海外リージョンのプロキシでも問題ありません。また、IPアドレスから地域を判定して表示を切り替えているケースも十分に考えられるので事前の確認は重要です。(確認方法: 少量のプロキシを用意して、それらを経由してアクセスできるか確認しましょう。)

0-3. 実際に僕が使っているプロキシサービスは1つだけ!

で、結局オススメ教えてくれればいいよ。という方向けにお答えするならば、「WebShare」をオススメします!

僕もこのプロキシサービスを現役で使っています。他に利用しているプロキシサービスはありません。「WebShare」だけしか使っていません。

強みはなにより 価格と安定性 です。安くてかなり安定していて使い勝手がとても気に入っています。金額のイメージをして頂けるように一例をご紹介します。

なんと1000個のIPアドレスを用意してもわずか 27ドル弱 で済んでしまいます。初めてプロキシに触れる方にとっては高く感じるかもしれません。しかし、この価格はあり得ないほど安いです。ぜひ他のプロキシも検討してみてください。その安さを実感して頂けることでしょう。

様々なプロキシを探してきましたが、これほど安くて高品質なプロキシを他に見たことがありません。初めての方にこそ、まずは「WebShare」をお試し頂く価値はあるかと思います。速度の劣化もほとんど無く、かなり安定していてこの価格。まさに個人開発者に優しいプロキシだと思います。

※プロキシサービスでは、プロキシが大量に自動で作られて管理されているため、動かないゴミみたいなプロキシが混じっていることがあります。使えないプロキシが無いか、node-fetchなどの軽量な手法で事前にプロキシを通してリクエストを投げて選別しておくと更に快適に利用できます。全てが完璧に動くプロキシではありません。 プロキシリストとはそういうものである と理解しておく必要があります。

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等を削除しよう

Seleniumなどに接続してスクレイピングする場合に気をつけたいのが Cookie や LocalStorage などのブラウザ保持データの存在です。

プロキシを使えば IPアドレスによる "同じ人判定" を回避することができます。これだけでは完全ではなく、 ブラウザが保持するデータでも "同じ人判定" ができてしまう点にも留意しておきましょう。プロキシを刺していても判別できてしまいます。

これは僕の経験によるもので、実際にGAFAMのいずれかのサイトで、ブラウザをそのままにプロキシだけを切り替えてスクレイピングしていたところ、プロキシリストの数千個のIPアドレスが一斉にブロックされたことがあります。その後、CookieやLocalStorageを削除する処理を追加したことで大幅に改善しました。

このことから Cookie や LocalStorage の情報を元に "芋づる式に" 同一人物と疑われるIPアドレスを特定するような対策方法が存在するのだろうと推測しました。

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

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

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

※初期設定的な部分は下記のスクラップに書いてたので本記事では省略します。

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

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-0. await page.waitForTimeout() は極力使わないようにしよう!

スクレイピング対象サイト上で、要素が出現したり、処理を待機する際に await page.waitForTimeout() を使いたくなるかもしれません。しかし、これは極力使わないように努力しましょう。

というのも、必ずしも指定した時間内に処理が終わるとは限らないため、正常に処理できない原因になります。 Playwrightには waitFor 系のメソッドが大量に用意されています。これを使うようにしましょう。それでも解決できない場合には、

  • 要素をループで監視することで解決できないか?
  • レスポンスリソースを監視することで解決できないか?
  • 必ずその処理が終わってから出てくる何か特徴的な要素が無いか?

など、様々な視点で調査をしてみましょう。必ず良い選択肢が見えてくるはずです。

それでもどうしても解決できない場合はやむを得ず使う程度にしておきましょう。

※もちろん、一時的なスクレイピング処理の確認としての await page.waitForTimeout() は使うことはあります。継続的なスクレイピングを可能にするコードをしっかり作るときは waitForTimeout() 以外の実装に置き換えましょう!

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でデータを取り扱う

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に戻すことができます。

4. 知っておくと便利な知識

必須ではないが知っておくと便利な知識をまとめていきます。

4-1. Playwrightでもブラウザを表示せずにスクレイピングできる

Playwrightを使ったスクレイピングでもブラウザを表示せずにスクレイピングできます。表示せずに使えるブラウザを「ヘッドレスブラウザ」といいます。Google Chromeもヘッドレスモードに対応しています。ただ単に画面に表示されないだけであり、いつものブラウザとほとんど一緒です。JSもCSSも全て処理してくれます。本当にただ目に見えないだけです。

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

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

ただし、スクレイピングのコードを書いていく開発段階では、ヘッドレスモードを無効(すなわちブラウザを表示させる状態)の方を使っていきましょう。

ほぼ完璧に動くコード(リトライ処理も用意できた状態)が書けてから有効化しましょう!

4-2. ヘッドレスモードでは要素の取得ができないなどの特有の問題が発生することがある

通常ブラウザでは問題なくスクレイピングできていたのに、ヘッドレスモードを有効にした途端に正しくスクレイピングができなくなるというケースも存在します。このケースは ユーザーエージェントの偽装 によって解決できる可能性が高いです。(憶測ではありますが、スクレイピング対策としてユーザーエージェントに含まれる 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",
});

このように書くだけで反映されます。
メジャーなユーザーエージェントは下記ページを参考にすると良いでしょう。ランキング形式になっており、今多くの環境で使われてるものをチェックできます。(余談: アクセスするとCloudflareのCAPTCHAが走るようです。CloudflareのCAPTCHAのデザインって危険な雰囲気で一般ユーザーから悪い印象持たれそうで使いたくないですね。)

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

もしくはご自身のChromeのNetworkタブで確認しても良いでしょう。

4-3. Playwrightでのスクレイピング高速化のアイデア

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

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

これで余計なリクエストを減らすことができるのでスクレイピング先の負担軽減となり得ます。スクレイピングする側のマナーとも言える技術かもしれません。

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

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

4-4. デスクトップ通知が来るようにしておくと何かと便利!

VPSを用意するほどでも無い規模(3日以内で終わるようなもの)なら、デスクトップ通知が来るようにしておくとすごく便利です。スクレイピング失敗しているのに止まっていることに気づかなかったというロスを減らすことができます。

例えば例外エラーを検出したら通知が来たり、処理が完了したら通知が来るようにするなど...。

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

import notifier from "node-notifier";

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

すごく簡単に導入できるのでぜひ!
僕がよく使うスタイルとして、仮想デスクトップで裏側でスクレイピングを走らせておいて、普通にパソコンを使っています。スクレイピングで何か問題が起きれば通知で教えてくれるので、スクレイピングしている感覚を忘れて日常生活を送れます!

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

主にJSを書く人はクラス化するシーン少ないのではないでしょうか。
スクレイピングの処理を書くときはクラス化すると簡潔になったり、コードを自然と整理するようになったり、細かな処理にも名前を付けることになるのでオススメです!

単なる関数だけで実装しようとするとかなり複雑になってしまいます。様々なパターンに柔軟に対応するためにもクラス化が想像以上に便利です。クラスを書き慣れていない方は、TypeScriptを使うとすごく楽になります。(最初はTypeScriptを覚えるまでが辛いですが、使えるようになると無駄なことに脳みそを使わずにコードを書けて快適になります!これはマジです。)

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

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

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

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

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

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

4-7. Playwrightだけで解決しなくてもOK!可能ならば node-fetch でスクレイピングできる方法を探した方が軽い、早い、安い!

スクレイピングは何もPlaywrightだけで解決する必要はありません。どちらかと言うと、可能であれば node-fetch + Cheerio を用いたスクレイピングができないかを検討することをオススメします。Playwrightは処理が重い上にページ表示まで時間がかかりすぎてしまいます。

その問題を解決するのが node-fetch + Cheerio を用いたスクレイピングです。安価で非力なマシンでも高速に動いてくれます。ただし、node-fetch + Cheerio でのスクレイピングの際に発生する特有の問題もあります。

それが「リクエストの際にCookieが必要だが、そのCookie情報はJavaScriptで動的に生成される」というケースです。この場合は Playwright でしか解決できないと思い込みがちですが、実は node-fetch + Cheerio でスクレイピングできるアイデアが存在します。

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

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

上記のように書けばPlaywrightのCookieをnode-fetchで使える形に変換できます。掛け合わせてスクレイピングできないかを検討するようにしてみると最高なスクレイピング環境が手に入ります!

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

4-8. サイトマップを探すことでスクレイピングを効率化できる

スクレイピングしたいページを収集するために、記事一覧から情報を取得することになるとお考えの方も多いことでしょう。もちろんその方法でも全く問題ないのですが、他にも手段が残されているのでご紹介します。その手段が「サイトマップから攻める」です。

サイトマップはGoogleなどのクローラーのクローリング効率を高めるために提供される情報です。SEO対策をしっかりしているサイトでは、ほぼ確実に用意してあります。サイトマップを見つけることで、サイト全体のリンクリストを取得することができます。

しかし、サイトマップの設置場所は表に出てこないので、探す必要があります。探す際に役に立つのが「robots.txt」です。robots.txtはおおよそ /robots.txt に設置されています。例えば、 https://example.com/ というURLがトップページの場合には https://example.com/robots.txt に設置されているというわけですね。

robots.txt にはサイトマップのURLが書かれていることがあります。(書かれていないこともある。)こうしてサイトマップの場所を探し当てることで、スクレイピングの効率化ができます。

4-9. 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 page.goto(`【CAPTCHAが発生しうるページへのアクセス】`);
  await waitForCAPTCHA(page);

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

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

4-10. スクレイピング対象の中身が更新されたかどうかを判定したいならハッシュ化しておくと便利!

過去にスクレイピングした状態から今回のスクレイピングでページ内容が更新されているか判定したいシーンがあるかと思います。その際は、記事の「ハッシュ化」を検討してみてください。

npm install crypto-js @types/crypto-js
import crypto from "crypto";

// (...省略)スクレイピングしてテキストを取得するまでして、
// const content にページの内容が入っていると仮定。

const hash = crypto.createHash("sha256").update(content).digest("hex");

このようにするとページの内容がハッシュ値に変換できます。ハッシュと聞くとデータサイズが増幅するようなイメージですが、仮に2000文字とか膨大な文字数でもハッシュ値になるので64文字ほどになります。もしも記事に1文字でも変化があればハッシュ値が変わるため、この値を比較すれば更新されているかどうかが判別できる、という仕組みです。

ストレージを圧迫せずに手軽に更新を検出できて便利です!

5. マニアックなスクレイピング知識

より踏み込んだスクレイピングを行いたい方に向けた知識です。色々考えて独自に出したスクレイピングのアイデアで、試行錯誤の末に辿り着いたものを放出しています。たぶん海外の情報サイトにも掲載されていない部分もあると思います。

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

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

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

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

5-2. Canvasや複雑なHTMLで作られたサイトのスクレイピング方法

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

これは困った。

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

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

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

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

https://github.com/microsoft/playwright-mcp

5-3. Android端末をPlaywrightで操作する

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

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

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

5-4. 非同期処理のPOSTデータを改ざんする

「このURLにPOSTすると簡単にデータ取得できそうだし、POSTデータを改ざんすれば一度に大量データを取得できそう!でもnode-fetchなどでリクエストすると、CORS回避できなくてリクエストが弾かれちゃう...。」

上記のようなシーンも出てくるかと思います。
PlaywrightならWebサイトを踏み台にすることでCORS回避を実現し、POSTデータを改ざんしてリクエストすることができます。

例えば、Chromeのネットワークタブで解析したPOSTデータが以下のようになっていた場合、

{
  limit: 10
}

limit の値を 10000000 とかに改ざんできれば一度に大量データを取得できそうな気がします。これを変更できたらすごく楽ですよね。やってみましょう。

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

ほとんど上記の記事の内容ではありますが、

await page.route(/searchWords/, async (route) => {
  const response = await route.fetch({
    postData: {
      limit: 100000
    },
  });

  // ここに結果が入っている
  const text = await response.text();
  console.log(text);

  // 対象リソースのリクエストを実行
  route.continue();
});

上記のようにすれば、 searchWords という文字列が入ったURLを検出したときに、任意の処理を加えることができます。この例では、postDataを上書きする形で、本来の値を破棄して、 limit: 100000 という条件でPOSTするように改ざんされます。

この手法を使うと、相手サーバーが予期せぬ "攻撃" になってしまう可能性もありますので、十分に考慮した上で取り組むようにしましょう。極力、控えるべきではあります。

node-fetchなどの手軽なスクレイピングでは突破できない際に、サクッと突破できる手段として持っておくとデータ収集の幅が広がると思います。

6. ポエム

スクレイピングに関するポエムです。読んでも何も得られません。

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

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

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

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

7. データの管理と加工

スクレイピングをすると、データ管理に悩まされることになります。可能であればスクレイピングとデータ加工の工程は切り離せるように設計すべきでしょう。

何かの助けになるようなアイデアを共有していきます。

7-1. スクレイピング直後にCloud Storageへアップロードしてデータをすぐ手放すという手法(データ加工自動化)

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

Cloud Storageにはトリガーという機能があります。アップロードイベントをトリガーにして、データ処理を実行するようなパイプラインを組んでおくと便利です。例えば、上記記事のように Cloud Storage にアップロードすると、 Cloud Run Functions にリクエストが飛び、Gemini API を叩いてデータ処理を完了させるなど。

この方法ならば、しこたまスクレイピングしてアップロードするだけで勝手にデータ加工まで済んでしまう夢のようなことが実現できます。

週次集計、月次集計などが必要であれば、Cloud Storageに入れたJSONファイルを BigQuery に取り込んでクエリをスケジューリングしておき、Cloud Storageに自動エクスポートさせて、格納をトリガーとして Cloud Run Functions を走らせて集計処理を完了するということもできます。

「トリガー」を活用したイベントドリブンなパイプラインの設計を考えてみると幸せになれますよ!

※これだけ豪華な仕様でもほぼ0円で済ませられます!やばすぎ!

もちろん、スクレイピングと同時にアップロードすることで時間が喰いすぎてしまうならば、スクレイピングしてローカルにファイルを出力するだけにしておく。ローカルのファイルをアップロードするだけのプログラムを動かしておく。という構成にしておくとアップロード時間によってスクレイピング効率が下がる問題は解決できるでしょう。

7-2. 大量データを処理するならば、連想配列で高速化を心がけよう!

スクレイピングした後はデータを加工することになるかと思いますが、闇雲に for文 だけで処理すると膨大な時間がかかってしまうことがあります。例えば、重複を排除するときなど。

重複を排除する処理では1つのデータ毎にfor文でデータを検索しがちです。そうではなく、連想配列を使うことでインデックスが効くので、連想配列の形式で実装できないかを考えてみてください。実装が少し手間にはなりますが、処理完了までの速度が段違いですので、ぜひともJavaScriptにおいても「インデックス」を意識してみてください!

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))')

2025年3月12日現在: HTML構造が変わってしまったようで今は使えません。気が向いたタイミングで更新します。

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

開発者ツールの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

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

extra-5. 【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);
    });
  });
}

調査中

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

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

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

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

Discussion