adbまわり
adbコマンドを覚えるのはきついのでいつでも気軽に参照できるようにしておきたい。
なお、主にスクレイピング用途で使いそうなものだけを掲載している。
※ちなみにadbコマンドはClaudeさんめちゃくちゃ精度高く教えてくれます!(Claude無課金勢です)
Windows勢向けの準備
Windowsを使っている人は grep コマンドを使えるようにしておこう。
Playwrightスクレイピングでは await device.shell("") のようにコードの中で grep を書かなければならない。
通常Windowsでは findstr を使うことになるが、Ubuntu環境などでは動かないはず。
他マシンでコード共有したときに動かないのは好ましくないので、Ubuntu側に寄せておいてあげよう!
ということで Windowsでもgrepコマンドを使えるようにしておこう!
現在アクティブのアプリを調べる
画面上に表示されているアクティブ状態のアプリを調べる。
※タスクの中に格納されているときには検出しない。
adb shell dumpsys activity activities | grep mResumedActivity
Andorid Chromeが画面上に表示されているか。
adb shell dumpsys activity activities | grep mResumedActivity | grep com.android.chrome
使いどころ
複数端末で並列実行中に暇してる端末を調べるときに使えると思う。
画面の状態や利用可能ステータスを取得する
こんな感じの関数を作った。
export const AndroidDeviceScreen = (device: AndroidDevice) => {
  // 画面の点灯状態を判定する
  // true -> 点灯している/false -> スリープ状態
  async function isScreenAwake() {
    const power = await device.shell('dumpsys power | grep "Display Power"');
    const match = power.toString().match(/state=(ON|OFF)/);
    if (!match) throw new Error("電源状態の確認ができませんでした。");
    return match[1] === "ON";
  }
  // ロック画面が解除された状態かを判定する
  // true -> ロック画面が解除されている/false -> ロックされた状態
  async function isScreenLock() {
    const lock = await device.shell('dumpsys window | grep "mDreamingLockscreen"');
    const match = lock.toString().match(/mDreamingLockscreen=(true|false)/);
    if (!match) throw new Error("画面ロック状態の確認ができませんでした。");
    return match[1] === "true";
  }
  // Android Chromeを起動しているかを判定する
  // true -> Android Chromeを起動中/false -> Android Chromeを起動していない
  async function isLauchChrome() {
    const launchChrome = await device.shell("dumpsys activity activities | grep mResumedActivity | grep com.android.chrome");
    const result = launchChrome.toString().trim();
    return result !== "";
  }
  async function getScreenStatus() {
    const results = {
      status: "",
      avaliable: false,
    };
    if (!(await isScreenAwake()))
      return {
        ...results,
        status: "sleep",
        avaliable: true,
      }; // スリープ状態になっている
    if (await isScreenLock())
      return {
        ...results,
        status: "lock",
        avaliable: true,
      }; // ロック画面を開いている
    if (await isLauchChrome()) {
      return {
        ...results,
        status: "chrome",
        avaliable: false,
      };
    } else {
      return {
        ...results,
        status: "home",
        avaliable: true,
      };
    }
  }
  return { getScreenStatus };
};
getScreenStatus() で返ってきた .avaliable が true であればスクレイピング処理中ではないことを示す。つまり何らかの処理を割り振れる状態にあると言える。statusとしてhome / lock / sleep / chrome の4種類のステータスがある。homeはホーム画面(ロック解除済み)、lockはロック画面(ロック解除してない)、sleepはスリープ状態(画面点いてないしロック解除してない)。
それぞれの状態に合わせて操作をする必要がある。
Android端末に処理を割り振れるようにする
例えばタスクは100件あるが、Android端末は8件しかないものとした場合、最大で8件同時に処理できる。処理が終わったら暇している端末に処理を割り振りたいだろう。そういうときのためのクラスを作った。
import { queue, QueueObject } from "async";
import { _android as android, AndroidDevice } from "playwright";
import { AndroidDeviceScreen } from "./browser";
export class AndroidNodeParallels {
  devices: AndroidDevice[] = [];
  asyncQueue!: QueueObject<() => void>;
  busyNodes: string[] = [];
  constructor() {}
  async initialize() {
    // 接続中のAndroid端末を取得する
    await this.updateConnectingDevices();
    // 並列実行のオブジェクトを作成する
    this.asyncQueue = queue(async function (task: () => Promise<void>) {
      await task();
    }, this.devices.length);
  }
  async updateConnectingDevices() {
    this.devices = await android.devices();
  }
  // タスクを追加すうr
  addNewTask(customFunc: ((serial: string) => void) | ((serial: string) => Promise<void>)) {
    this.asyncQueue.push(async () => {
      let workerSerial = ""; // このタスクがどの端末で実行しているかを覚えておく
      for (let key in this.devices) {
        const { getScreenStatus } = AndroidDeviceScreen(this.devices[key]);
        if ((await getScreenStatus()).avaliable && this.busyNodes.includes(this.devices[key].serial()) === false) {
          workerSerial = this.devices[key].serial();
          this.busyNodes.push(workerSerial); // 処理が同時に割り振られてしまわないようにロックする
          break;
        }
      }
      const result = customFunc(workerSerial);
      if (result instanceof Promise) {
        await result;
      }
      // busyNodes配列から対象端末を利用可能な状態へと解放する
      this.busyNodes = this.busyNodes.filter((theNode) => {
        return theNode !== workerSerial;
      });
      console.log(`${workerSerial}のタスクが終了しました。`);
      console.log(`残タスク: ${this.asyncQueue.length()}件`);
    });
  }
  done(func?: (() => void) | (() => Promise<void>)) {
    return new Promise((resolve) => {
      this.asyncQueue.drain(async () => {
        for (let key in this.devices) {
          await this.devices[key].close();
        }
        if (func) {
          const result = func();
          if (result instanceof Promise) {
            await result;
          }
        }
        resolve(true);
      });
    });
  }
}
実行してみる
import { AndroidNodeParallels } from "@utils/functions/androidNodes";
function getRandomInt(min: number, max: number): number {
  min = Math.ceil(min);
  max = Math.floor(max);
  return Math.floor(Math.random() * (max - min + 1)) + min;
}
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
(async () => {
  const androidNodeParallels = new AndroidNodeParallels();
  await androidNodeParallels.initialize();
  for (let i = 0; i < 10; i++) {
    androidNodeParallels.addNewTask(async (serial: string) => {
      // serialには割り振られた端末のシリアル番号が渡ってくる。
      console.log(`【開始: ${serial}】${i}番目のタスクを実行開始`);
      await sleep(getRandomInt(1, 6) * 1000);
    });
  }
  await androidNodeParallels.done();
  console.log("終了しました!");
})();
適当に100件ぐらいタスクを割り振ってみる。
実際の端末への振り分けは各タスクの中で行う。タスクが実行した際に暇している端末が探される。終了したら端末を解放し、次の処理を受け付けられる状態になっている。
全ての処理が終わったら .close() を行うために await androidNodeParallels.done(); を実行する。この記述によって本来は drain によるものなのでawaitされないが、awaitで待機できるようにしている。
また、drainをinitializeに含めていないのは、何かの拍子にタスクが空になっちゃった場合に処理が勝手に終わってしまうんじゃないかと不安で。そんなことありえないんだけども。
※ .done() に関数を渡せるようにしているが意味ないね...w