CanvasKit + gRPCで作られたWebサイトをスクレイピングするならPlaywrightの.waitForResponseを使おう
最近ではFlutterで実装されたWebサイトも珍しくありません。
その中にはCanvasKitというものを用いてCanvas上でレンダリングしていることがあります。これに関してはまだまだ数は多くありませんが、クロスプラットフォームに対応しやすいとあって今後も数が増えていくことでしょう。
CanvasKitはモダンな開発環境なので、フェッチデータもgRPCというモダンな技術を用いていることもあります。gRPCはデータをシリアライズしているため、いわば暗号化されたような状態でデータが流れています。それをブラウザ側が解析して使える状態にしているようです。(※実装をしたことないのでいい加減な解釈をしています。)
0-1. CanvasはHTML要素とは違ってデータアクセスが困難。
Canvasはご存知の通り、HTML要素としてアクセスできないのでテキストや画像の抽出が難しいです。クリックイベントもマウス座標指定で実行するしかありませんし、一般的なHTMLサイトのスクレイピングの知識は全く役に立ちません。
仮に動的にマウス座標を取得しようとするならば、事前に対象要素を画像化しておき、スクレイピング時にページキャプチャと比較して、画像解析によってその対象要素の座標を取得するという手段があることでしょう。とはいえ、動的じゃなくてもハードコーディングで決め打ちでクリック座標を指定しても十分だと思います。
0-2. gRPCは辛い
※サイト特定されにくくするため一部隠しています。
上記はgRPCのデータをChromeのNetworkタブで見たときのプレビューです。
このように「制御文字」と呼ばれる謎の文字が含まれていたり、文字列に特徴がそれほどない状態でデータ加工をしていく必要があります。
正規表現でどうにか欲しい文字列を抽出できるようにしなければならないのが辛いポイントです。
1. どうやってスクレイピングしていくのか?
手法としてはとてもシンプルで、 Playwright を使ってスクレイピングを実現します。
Playwrightには .waitForResponse()
というメソッドが用意されています。これはブラウザが受け取ったNetworkのレスポンスの中身を監視できる機能です。つまり、Canvasでイベントを発火さえできてしまえば、そこで発生した通信データを取得できてしまうのです。
※本記事では下記記事を踏襲した書き方をしています。中身はただのPlaywrightですので、適宜読み替えてください。
ちなみに、 .waitForResponse()
のタイムアウトはデフォルトで30秒です。ちょっと短いので60秒ぐらいにしておくと良いかと思います。
▼ 特定のレスポンスを受け取るまで処理を待機する関数
function waitForResponseResource(page: Page, resourceURL: string) {
return page.waitForResponse((response) => {
if (response.url() === resourceURL && response.status() === 200) {
return true;
}
return false;
});
}
await waitForResponseResource(page, "https://example.com/hoge/fuga")
とすると、https://example.com/hoge/fuga
がNetworkタブでレスポンスが返ってくるまで待機してくれます。レスポンスが返ってくると正常とみなし処理が再開されます。
▼ 特定のレスポンスを受け取って、中身のデータをテキストとして返す関数
function getTextFromResponseResource(page: Page, resourceURL: string): Promise<string> {
let responseText = "";
return new Promise(async (resolve) => {
const response = page.waitForResponse(async (response) => {
if (response.url() === resourceURL && response.status() === 200) {
responseText = await response.text();
return true;
}
return false;
});
await response;
resolve(responseText);
});
}
const text = await getTextFromResponseResource(page, "https://example.com/hoge/fuga")
とすると、 https://example.com/hoge/fuga
のデータをそのままテキストとして返してくれます。
これらの関数を用いてスクレイピングしていきます。
2. 実際にスクレイピングしてみる
上記で作った2つの関数を活用してスクレイピングしてみます。
import { chrome } from "@utils/functions/browser";
import { Page } from "playwright";
(async () => {
const { browser, context, page } = await chrome();
// CanvasKitが動く状態になるまで待機する
// リソースのレスポンス状態で判定を行っている
await waitForResponseResource(page, "https://example.com/hoge/fuga");
await page.waitForTimeout(400); // 問題なく動くはずだが、念の為余裕を持って400msほど待機
// クリックして通信を発生させる
await page.mouse.click(100, 100);
const text = await getTextFromResponseResource(page, "https://example.com/api/grpc");
console.log(text);
})();
URLは適当ですが、おおよそこういう流れでスクレイピングが実現できます。 text
の中にレスポンスデータが格納されます。このようにクリック座標が分かってしまえば、レスポンスからデータを抽出してスクレイピングが可能になりました。
gRPCのデータの中から制御文字を削除したい
これは僕もよく分からないのですが、Claude 3さんに聞いたところ、
text.replace(/[\x00-\x1F\x7F]/g, "");
とすればいいとのことでした。
僕の環境では現時点で問題なく使えています。
※制御文字とは、「gRPCは辛い」見出しの画像に含まれる赤くて小さい文字の部分です。SUBとかETXとかNULとかがあります。
制御文字のクレンジングをする
制御文字のクレンジングは結構たいへんです。
この辺りはClaudeさんに投げて正規表現を作ってもらいましょう。
function convertControlCharsToHex(input: string) {
return input.replace(/[\x00-\x1F\x7F]/g, (char) => {
// 改行文字 (\n) は特別に処理
if (char === "\n") {
return "\\n";
}
// その他の制御文字を16進数に変換
return `<${char.charCodeAt(0).toString(16).padStart(2, "0")}>`;
});
}
この関数を使って返ってきた値を await fs.writeFile()
でデータを格納し、そのデータをClaudeさんに投げればそれなりに整った正規表現を作ってくれます。(AIはデータクレンジング結構得意な印象。)
僕が試したところ、 /<1a>(.+?)".*?I([^@]+)@.*?�<03>(?:<03>|<02>)(\d+)�<04>(?:<03>|<02>)(\d+)/g
という自分では絶対に導き出せない複雑な正規表現を作ってくれました。ほぼ完璧でした!
マウス座標を知りたい
上記のブックマークレットがすごくいい感じでした。
拡張機能と違ってバックドア的な心配も無く安心して使えます。(画面右下あたりに結果が表示されます。)
さいごに
CanvasKit + gRPCで作られていたとしても一応はスクレイピングできることがお分かりいただけたかと思います。もっと良い方法だったり、動的にマウス座標を取得する方法があれば教えて欲しいです!
また、CanvasKitをJSから解析しちゃう強強スクレイパーの方がいらっしゃればぜひコメントにてご教示頂けると嬉しいです!
Discussion