CanvasKitで作られたサイトをガチでスクレイピングする
とうとうガチで向き合う必要が出てきた。
...といいつつ、まだ先延ばしにしている。
しかも対象サイトでは gRPC が使われている。
クエリが .proto によってシリアライズされているため解読することが不可能な状態。
よって、node-fetchによるリクエストでの解決は無理そうだ。(Copy as cURL(Bash)からraw値を確認してみたがダメだった。)
Playwrightの .respose()
でデータ取得は可能だが、取得するにはイベントを発生させる必要がある。イベント発火ではcanvas要素上をクリックすることになるがクリック座標が必要になる。当然ながらクリック座標は機械的に取得すべきだ。
単純なアイデアとしてタップしたい要素を事前にキャプチャしておいて、それを読み込ませてクリック座標を取得する方法が考えられる。
指定した画像Bを元に、画像Aにマッチする箇所を取得することになる。(上記のようなこと)
これを「テンプレートマッチング」と呼ぶらしい。
テンプレートマッチングをするには OpenCVを使うことになりそう。
Node.jsでOpenCVを動かす準備
npm i opencv-wasm jimp
これが一番カンタンに導入できてTS型が効いてくれる。
- opencv-wasm: WebAssembly版のOpenCV(もちろんNode.jsで動く)
- jimp: 画像処理をしてくれるもの(今回は読み込みとbitmap取得のために利用)
テンプレートマッチングをやってみる
▼a.jpg(この画像から)
▼b.png(この箇所の座標を取得する)
import Jimp from "jimp";
import { cv } from "opencv-wasm";
(async () => {
// "元の画像" と "マッチ箇所を調べたい画像" を読み込む
const imageSource = await Jimp.read(__dirname + "/src/assets/images/a.jpg");
const imageTemplate = await Jimp.read(__dirname + "/src/assets/images/b.png");
// OpenCVで処理できる形式に変換する(Mat形式)
let src = cv.matFromImageData(imageSource.bitmap);
let templ = cv.matFromImageData(imageTemplate.bitmap);
// 結果格納用に空のMatを準備する
let processedImage = new cv.Mat();
// テンプレートマッチングを実行し、processedImageに結果を格納する
cv.matchTemplate(src, templ, processedImage, cv.TM_CCOEFF_NORMED);
// 結果の中からminMaxLocを取得する
const minMax = cv.minMaxLoc(processedImage);
console.log(minMax);
console.log(imageTemplate.getWidth(), imageTemplate.getHeight());
})();
出力結果は下記の通りになった。
{
minVal: -0.4658215045928955, // 知らん
maxVal: 0.9975075721740723, // マッチ具合
minLoc: { x: 435, y: 72 }, // 知らん
maxLoc: { x: 392, y: 28 } // これがマッチした座標(始まり部分)
}
(なにがminなのかmaxなのかは知らん。座標取れればええんや。)
取り急ぎ確認してみる
取り急ぎチェックするため、Figma でXY座標を指定してピッタリはまっているか確認した。
結果はこの通りにピッタリだった。
正常にテンプレートマッチングできていることが確認できた。
処理スピードを知る
なお処理時間は0.3秒前後だった。たまに上振れして0.6秒とかかかるときもわずかながらあった。
10回実行した結果
テンプレートマッチングをやるために参考にしたコードは下記Github
正直何やってるかわからんけど結果さえ取得できればいいので理解を諦めた。
関数化した
サイズの大きい方をimageTemplateにしてしまうと値が不適切なので注意したい。
エラーや全然違うのにマッチしてしまう場合への対応として下記の関数を作った。
export async function getImageMatchPosition({ imageSourcePath = "", imageTemplatePath = "", outputBoundingBoxPath = "" }) {
let src, templ, processedImage;
try {
const imageSource = await Jimp.read(imageSourcePath);
const imageTemplate = await Jimp.read(imageTemplatePath);
src = cv.matFromImageData(imageSource.bitmap);
templ = cv.matFromImageData(imageTemplate.bitmap);
processedImage = new cv.Mat();
cv.matchTemplate(src, templ, processedImage, cv.TM_CCOEFF_NORMED);
const threshold = 0.95; // マッチ具合のしきい値(1に近いほど完璧なマッチ)
const minMax = cv.minMaxLoc(processedImage);
if (minMax.maxVal <= threshold) throw new Error("マッチしませんでした。");
if (outputBoundingBoxPath !== "") {
let srcClone = src.clone();
const point1 = new cv.Point(minMax.maxLoc.x, minMax.maxLoc.y);
const point2 = new cv.Point(minMax.maxLoc.x + imageTemplate.getWidth(), minMax.maxLoc.y + imageTemplate.getHeight());
cv.rectangle(srcClone, point1, point2, [0, 0, 255, 255], 2);
new Jimp({
width: srcClone.cols,
height: srcClone.rows,
data: Buffer.from(srcClone.data),
}).write(outputBoundingBoxPath);
srcClone.delete();
}
return {
isSuccess: true,
position: {
x: minMax.maxLoc.x + Math.floor(imageTemplate.getWidth() / 2),
y: minMax.maxLoc.y + Math.floor(imageTemplate.getHeight() / 2),
},
};
} catch (e) {
return {
isSuccess: false,
position: {
x: 0,
y: 0,
},
};
} finally {
// メモリ解放のための記述
if (src) src.delete();
if (templ) templ.delete();
if (processedImage) processedImage.delete();
}
}
マッチした場合
isSuccess: true;
position: 座標が入る
マッチしなかった場合
isSuccess: false;
position: XY座標ともに0が入る
オプション
outputBoundingBoxPath に出力先を指定すると、バウンディングボックスを青色で描画したものを出力するようにしました。(指定例: __dirname + "/src/images/c.jpg"
)
▼バウンディングボックスの出力例
(async () => {
const imageSourcePath = __dirname + "/src/images/a.jpg";
const imageTemplatePath = __dirname + "/src/images/b.png";
const { position } = await getImageMatchPosition({
imageSourcePath: imageSourcePath,
imageTemplatePath: imageTemplatePath,
outputBoundingBoxPath: __dirname + "/src/images/c.jpg",
});
console.log(position);
})();
つまりこうやって書くだけでクリックすべきXY座標とバウンディングボックス確認ができるという訳ですね!スクレイピングが捗りそう!
清書