🍆

Playwrightで実機のAndroid端末を操作してWebサイトをスクレイピングする

2024/07/26に公開

Playwrightがスクレイピングの定番ツールになりつつありますね。Playwrightはパソコン以外にもAndroid端末でのスクレイピングも簡単にできます。

この記事ではインストールからスクレイピングの実行方法まで紹介します。

この記事の内容をクラス化したものを下記に折りたたんで掲載しておきます。

クラス化したコード
export class AndroidChrome {
  device: AndroidDevice;
  proxy: string | null = null;
  context!: BrowserContext;
  temporaryPath!: string;
  screenshotPath!: string;
  page!: Page;
  screenSize = {
    width: 0,
    height: 0,
  };
  viewport = {
    width: 0,
    height: 0,
  };

  constructor({ device, proxy, path }: { device: AndroidDevice; proxy?: string; path: string }) {
    this.device = device;
    if (proxy) this.proxy = proxy;
    this.temporaryPath = path;
    this.screenshotPath = path + `/temporary/screenshot-${this.device.serial()}.png`;
  }

  async start() {
    await this.initialize();
    await this.launchBrowser();
  }

  async restart() {
    await this.closeBrowser();
    this.start();
  }

  async initialize() {
    await this.deviceScreenToggle("on"); // スクリーンをONにする
    await this.getViewportSize();
    await this.clearChromeCaches(); // Android Chromeのキャッシュを削除する
    await this.skipChromeWelcomeScreen(); // Android Chromeの初期設定をパスする
    await this.applyProxy(); // プロキシを適用する
  }

  async launchBrowser() {
    this.context = await this.device.launchBrowser({
      args: ["--blink-settings=imagesEnabled=false", "--disable-remote-fonts"],
    });
    this.page = this.context.pages()[0];
  }

  private async deviceScreenToggle(forceManual: null | "on" | "off" = null) {
    const screenState = await this.getScreenState();

    if (!forceManual) {
      
      if (screenState === "OFF") {
        // スクリーンが切れている状態だったらスワイプしてロック画面を解除する(右から左にスワイプ)
        await this.device.shell("input keyevent KEYCODE_WAKEUP");
        await this.device.shell("input swipe 1000 500 300 500");
      } else {
        await this.device.shell("input keyevent KEYCODE_SLEEP");
      }
      return;
    }

    switch (forceManual) {
      case "on":
        if (screenState === "OFF") {
          await this.device.shell("input keyevent KEYCODE_WAKEUP");
          await this.device.shell("input swipe 300 1000 300 500");
        }
        break;
      case "off":
        if (screenState === "ON") {
          await this.device.shell("input keyevent KEYCODE_SLEEP");
        }
        break;
    }
  }

  private async getViewportSize() {
    const standardDPI = 160;
    const screenSize = (await this.device.shell('wm size | grep "Physical size"')).toString().replace(/.*\: /g, "").trim().split("x");
    let dpi = Number((await this.device.shell("wm density")).toString().replace(/.*\: /g, "").trim());

    this.screenSize.width = Number(screenSize[0]);
    this.screenSize.height = Number(screenSize[1]);

    this.viewport.width = this.screenSize.width / (dpi / standardDPI);
    this.viewport.height = this.screenSize.height / (dpi / standardDPI);
  }

  convertScreenToViewport(x: number, y: number) {
    const ratioWidth = this.viewport.width / this.screenSize.width;
    const newX = Math.round(x * ratioWidth);
    const newY = Math.round(y * ratioWidth);
    return { newX, newY };
  }

  convertViewportToScreen(x: number, y: number) {
    const ratioWidth = this.screenSize.width / this.viewport.width;
    const newX = Math.round(x * ratioWidth);
    const newY = Math.round(y * ratioWidth);
    return { newX, newY };
  }

  async tap(x: number, y: number) {
    const { newX, newY } = this.convertScreenToViewport(x, y);
    await this.page.mouse.click(newX, newY);
  }

  async mouseMove(x: number, y: number) {
    const { newX, newY } = this.convertScreenToViewport(x, y);
    await this.page.mouse.move(newX, newY);
  }

  async mouseWheel(x: number, y: number) {
    const { newX, newY } = this.convertScreenToViewport(x, y);
    await this.page.mouse.wheel(newX, newY);
  }

  async swipe({ from = [0, 0], to = [0, 0] }: { from: [number, number]; to: [number, number] }) {
    const { newX: fromX, newY: fromY } = this.convertViewportToScreen(from[0], from[1]);
    const { newX: toX, newY: toY } = this.convertViewportToScreen(to[0], to[1]);
    await this.device.shell(`input swipe ${fromX} ${fromY} ${toX} ${toY}`);
  }

  private async getScreenState() {
    const power = await this.device.shell('dumpsys power | grep "Display Power"');
    const match = power.toString().match(/state=(ON|OFF)/);
    if (!match) throw new Error("電源状態の確認ができませんでした。");

    return match[1];
  }

  async clearChromeCaches() {
    await this.device.shell("pm clear com.android.chrome");
  }

  async skipChromeWelcomeScreen() {
    await this.device.shell("am set-debug-app --persistent com.android.chrome");
    await this.device.shell('echo "chrome --disable-fre --no-default-browser-check --no-first-run" > /data/local/tmp/chrome-command-line');
    await this.device.shell("am start -n com.android.chrome/com.google.android.apps.chrome.Main");
  }

  async applyProxy() {
    if (this.proxy) {
      await this.device.shell(`settings put global http_proxy ${this.proxy}`);
    }
  }

  private async removeProxy() {
    await this.device.shell("settings put global http_proxy :0");
  }

  async closeBrowser() {
    if (this.proxy) this.removeProxy();
    await this.device.shell("am force-stop com.android.chrome");
  }

  waitForResponseResource(resource: string) {
    const matchStr = resource.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

    return this.page.waitForResponse(
      (response) => {
        if (response.url().match(new RegExp(matchStr)) && response.status() === 200) {
          return true;
        }
        return false;
      },
      { timeout: 60 * 1000 }
    );
  }

  async tapByTemplateImage(templateImagePath: string, outputBoundingBoxFileName = "") {
    await this.page.screenshot({ path: this.screenshotPath, scale: "css" });

    const { isSuccess, position } = await getImageMatchPosition({
      imageSourcePath: this.screenshotPath,
      imageTemplatePath: templateImagePath,
      outputBoundingBoxPath: outputBoundingBoxFileName !== "" ? this.temporaryPath + `/temporary/actions/${outputBoundingBoxFileName}` : "",
    });

    if (isSuccess) {
      await this.tap(position.x, position.y);
    }
    return isSuccess;
  }

  async quit() {
    await this.closeBrowser();
    await this.deviceScreenToggle("off");
    await this.device.close();
  }
}

このコードを使用した例は下記の通りです。

import path from "path";

(async () => {
  const devices = await android.devices();

  for (let key in devices) {
    const androidChrome = new AndroidChrome({
      device: devices[key],
      proxy: "【ここにプロキシ】",
      path: path.resolve(__dirname), // スクリーンショットの保存場所のルート
    });
    // ブラウザを立ち上げる
    await androidChrome.start();
    await androidChrome.page.goto("https://yahoo.co.jp/");

    // 処理を終了する
    await androidChrome.quit();
  }
})();

これだけの記述でスクリーンをオンにして、ロック画面を解除し、キャッシュクリア、Chrome初期設定をパスして、画像とWebフォントを無効にした状態のブラウザを立ち上げてくれます。ページ移動した後にブラウザを終了して画面をOFFにしてくれます。

実行環境を整える

パソコンからAndroid端末を操作するには、 ADB(Android Debug Bridge) というものが必要になります。これはAndroidのデバッグを可能にするツールで、Playwrightはこれを経由してスクレイピングを可能にしています。

それでは、実行環境を整えるために

  • adbコマンドを叩けるようにし、
  • Android端末側の準備

を行っていきましょう。

ADBコマンドを叩けるようにしよう!

https://developer.android.com/tools/releases/platform-tools?hl=ja#downloads

上記リンクから「SDK Platform-Tools」をダウンロードします。「SDK Platform-Tools for Windows をダウンロード」というリンクからダウンロードできます。ダウンロードしたzipファイルを展開すると、 platform-tools というフォルダが格納されています。このフォルダをどこでもいいので置いてください。

今回は C:\adb というフォルダを作って格納しました。
パスで表現すると C:\adb\platform-tools という状態です。

次に、コマンドを叩けるようにするため、パスを通す必要があります。
環境変数の編集画面から「システム環境変数」の Path に先ほどの platform-tools のパスを追加してください。今回の例では C:\adb\platform-tools を追加する形になります。

最後にコマンドプロンプトから adb と入力して Enterキー を押してコマンドリストが表示されれば準備が整いました!

Android端末とパソコンを接続してみよう!

adbにAndroid端末を認識させましょう。
Android端末側の「開発者オプション」から「USBデバッグ」を有効にします。「USBデバッグを許可しますか?」と出てくるので「このパソコンからUSBデバッグを常に許可する」にチェックを入れて完了します。

パソコン側で

adb devices

を実行して端末が認識されるか確認しましょう。

上記は11個の端末が認識された状態。

PlaywrightのコードがAndroid端末で動くようにする。

現状では adb コマンドが実行できますが、Android Chromeブラウザでの操作ができない状態です。PlaywrightでAndroid Chromeを操作できるように設定を変更します。

Android Chromeのアドレスバーに chrome://flags/ と入力してアクセスしてください。Enable command line on non-rooted devices という項目を探し、 Enabled にして Android Chromeを再起動してください。

これだけで Playwright から Android Chrome が操作できるようになりました!

パソコンからPlaywrightのコードを実行してAndroid Chromeが操作できるか確認してみよう。

index.ts
import { _android as android } from "@playwright/test";

(async () => {
  const [device] = await android.devices();

  await device.shell("am force-stop com.android.chrome");
  const context = await device.launchBrowser();

  // 公式ドキュメントには const page = await context.newPage(); とあるが、
  // about:blankという余分な空コンテキストが作られてしまうので下記のようにしている。
  const page = context.pages()[0];
  await page.goto("https://yahoo.co.jp");
  await device.close();
})();

このコードを実行してみましょう。
Android端末のChromeが立ち上がり、 Yahoo! Japanのページが開きます。

これでPlaywrightからAndroid Chormeを操作できるようになり、スクレイピング環境が整いましたね!

コードを見ていただければお分かりの通り、 page 変数までたどり着ければあとは普通のパソコン版のスクレイピングと全く同じです!

スクレイピングに特化した独特なコード

スクレイピング用途で利用するには プロキシを刺す 必要があります。プロキシを刺すのは簡単なのですが、同一人物によるスクレイピングである痕跡を減らすため、CookieやLocalStorageを削除しなければなりません。その際に発生する問題がいくつかあるので、その解決策をご紹介します。

プロキシを刺したり解除したりする方法

プロキシを刺すには下記を実行するだけでOKです。

await device.shell("settings put global http_proxy 【IPアドレス】:【ポート番号】");

スクレイピング処理の最初の段階でこれを実行するだけでプロキシが適用されます。実行中はバックグラウンドで動いているアプリもプロキシ経由になると思われますのでご注意ください。

スクレイピングが終わったらプロキシを解除したいことでしょう。

await device.shell("settings put global http_proxy :0");

これを実行するだけで解除できます。
0 ではなく :0 です。お間違いなく。

CookieやLocalStorageを削除する方法

await device.shell("pm clear com.android.chrome");

これだけでCookieもLocalStorageも削除できます。
しかし、これは強力な削除で、アプリが保存してくれている内容を全て削除するものです。つまり、最初に設定した chrome://flags/ の設定内容やChrome初期設定も全て削除されてしまいます。Android ChromeをPlaywrightで操作することもできないですし、初期設定画面をパスしなければスクレイピングを開始できません。

▼Chrome初期設定画面とはこのようなもの

これらをスキップする完璧なコードが落ちていましたので紹介します。

https://stackoverflow.com/questions/60444428/android-skip-chrome-welcome-screen-using-adb

await device.shell("am set-debug-app --persistent com.android.chrome");
await device.shell('echo "chrome --disable-fre --no-default-browser-check --no-first-run" > /data/local/tmp/chrome-command-line');
await device.shell("am start -n com.android.chrome/com.google.android.apps.chrome.Main");

1行目で chrome://flags/ での設定を有効化している。
2行目で 初期設定画面をパスするような指定をしている。
3行目で これらの設定を適用している。

結論としては、プロキシを刺す場合は

await device.shell("pm clear com.android.chrome");
await device.shell("am set-debug-app --persistent com.android.chrome");
await device.shell('echo "chrome --disable-fre --no-default-browser-check --no-first-run" > /data/local/tmp/chrome-command-line');
await device.shell("am start -n com.android.chrome/com.google.android.apps.chrome.Main");

await device.shell("settings put global http_proxy 【IPアドレス】:【ポート番号】");

を処理前に実行してあげることで、CookieやLocalStorageを削除し、プロキシを適用できるということになります。

電源ボタンを押してスリープを解除したい。ロック画面も解除したい

電気代を節約するため通常はスリープ状態にしておきたいですよね。
これもPlaywrightで実現できます。(厳密にはadb shellを投げるのですが。)

▼画面を点灯させる(電源ボタンを押したときのような動き)

await device.shell("input keyevent KEYCODE_WAKEUP");

物理的な電源ボタンを押したときと同じ動きをします。(厳密には異なりますが説明の便宜上こう表現しています。)スリープを解除したいときに使います。

▼画面を消灯させる

反対にスリープ状態にするには、

await device.shell("input keyevent KEYCODE_SLEEP")

とするだけでOKです。

▼ロック画面を解除する

await device.shell("input swipe 300 1000 300 500");

上方向にスワイプしています。
ロック解除は パスコードが設定されていない前提 となります。

複数端末で同じ処理を実行したい

並列実行する仕組みは別途自分で用意しなければなりません。
最大実行数を制御でき、処理中に他の処理を割り当てできて便利な Async.js を使うと良いでしょう。

npm install async @types/async
import { queue } from "async";
import { _android as android, Route } from "@playwright/test";

(async () => {
  const devices = await android.devices();

  const asyncQueue = queue(async ({ device, proxy }, callback) => {
    // スクレイピング処理をClass化したものを実行する例です。
    // この辺りはご自身の処理を入れてください。
    const website = new WebsiteScraping({
      device: device,
      proxy: proxy,
      path: path.resolve(__dirname)
    });

    await website.startScraping(); // スクレイピング処理を実行するときの関数
  }, devices.length);

  // 例として処理開始して15秒後に動的に処理追加されるかを確認することにします。
  let count = 0;
  for (let key in devices) {
    setTimeout(() => {
      console.log(devices[key].serial() + "の端末で処理実行します。");
      asyncQueue.push({
        device: devices[key],
        proxy: "123.456.789.123:1234"
      });
    }, 1000 * 15 * count);
    count++;
  }
})();

これを実行すると、1台目はすぐに実行され、15秒後に次の端末で処理が開始されます。動的に処理を実行できる上に、並列で同時に実行できることが確認できることでしょう。

端末毎に別々の処理を行いたいなら device.serial() で振り分けると良いでしょう。
また、スリープ状態の端末を探すような関数を作り、暇な状態の端末にタスクを割り当てるという使い方をするのが良いのではないでしょうか。

動画でよく見る中国のリセマラ工場みたいなアングラ感漂うやべーことが簡単に実現できちゃいます!

▼こういうアングラっぽいやつ

▼我が家ではこうやって構築中

マガジンスタンドを代用しています。
裏面が網になっているので放熱性にも優れているかなと思いまして。(まだ端末集めてる途中です!)

座標を指定してタップしたい

await page.touchscreen.tap() を使うと思いきや await page.mouse.click() を使用してタップする必要があります。また、このXY座標もかなりの罠です。

スクショをしたときのサイズ(例えば1080x1536。)はスクリーンサイズとなりますが、タップ座標はviewport(360x512)なのです。 shell wm sizeshell wm density で取得したサイズを元に計算する必要があります。

かなり手間だったのでスクリーンショットサイズをベースにXY座標を指定できるような関数を作りました。 await androidChrome.tap()

本記事上部に掲載したクラスの中に含めてあります。

おしまい

Android端末をスクレイピングマシンにする方法をご紹介しました。
VPSでのスクレイピング以外にも安価なAndroid端末を買い集めてスクレイピングしてしまうのも面白いと思います!

▼本記事作成にあたってのメモ書き

https://zenn.dev/masa5714/scraps/1be6f4e1a6e367

▼ 25,000文字超えのスクレイピング記事書きました!

https://zenn.dev/masa5714/articles/9328c553bac649

ちなみに、本記事は下記の処理を並列実行するために調べた内容です。
パソコン一台だけだと処理が重すぎるサイトでして(Chromeメモリ使用量が600MBほどある)...。安価で実現する方法を模索したところ、Android端末が良さそうだなと思いまして。

https://zenn.dev/masa5714/articles/4aaf433262427e

Discussion