Closed6

CanvasKitで作られたサイトをガチでスクレイピングする

masa5714masa5714

とうとうガチで向き合う必要が出てきた。
...といいつつ、まだ先延ばしにしている。

masa5714masa5714

しかも対象サイトでは gRPC が使われている。
クエリが .proto によってシリアライズされているため解読することが不可能な状態。
よって、node-fetchによるリクエストでの解決は無理そうだ。(Copy as cURL(Bash)からraw値を確認してみたがダメだった。)

masa5714masa5714

Playwrightの .respose() でデータ取得は可能だが、取得するにはイベントを発生させる必要がある。イベント発火ではcanvas要素上をクリックすることになるがクリック座標が必要になる。当然ながらクリック座標は機械的に取得すべきだ。

単純なアイデアとしてタップしたい要素を事前にキャプチャしておいて、それを読み込ませてクリック座標を取得する方法が考えられる。

指定した画像Bを元に、画像Aにマッチする箇所を取得することになる。(上記のようなこと)
これを「テンプレートマッチング」と呼ぶらしい。

テンプレートマッチングをするには OpenCVを使うことになりそう。

masa5714masa5714

Node.jsでOpenCVを動かす準備

npm i opencv-wasm jimp

これが一番カンタンに導入できてTS型が効いてくれる。

  • opencv-wasm: WebAssembly版のOpenCV(もちろんNode.jsで動く)
  • jimp: 画像処理をしてくれるもの(今回は読み込みとbitmap取得のために利用)
masa5714masa5714

テンプレートマッチングをやってみる

▼a.jpg(この画像から)

▼b.png(この箇所の座標を取得する)

index.ts
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
正直何やってるかわからんけど結果さえ取得できればいいので理解を諦めた。
https://github.com/echamudi/opencv-wasm/blob/master/examples/templateMatching.js

関数化した

サイズの大きい方を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座標とバウンディングボックス確認ができるという訳ですね!スクレイピングが捗りそう!

このスクラップは2ヶ月前にクローズされました