🍆

Canvasで描画されたWebサイトをPlaywrightでスクレイピングする方法が確立できた

2024/07/21に公開

Flutterの「CanvasKit」で作られたサイトが増えてきています。これを使って作られたサイトは、特徴的なHTMLタグが無かったり、Canvasで描画されているなど、スクレイピングがかなり困難なサイトの一つです。

スクリーンショットを撮ってAIに投げてしまえば一瞬で解決するかもしれませんが、貧乏スクレイパー精神の僕としてはAIに頼らない低コストなスクレイピングを実現させたいのです。そこで考えた方法が、.waitForResponse() を使ってChromeが解析してくれたレスポンスリソースを盗み見るというものです。(詳しくは下記の記事をご覧ください。)

https://zenn.dev/masa5714/articles/639b1cfc246abe

しかし、この方法には 不完全な部分 がありました。リクエスト/レスポンスを発生させるためにマウスクリック( await page.mouse.click(x, y) )をしなければなりません。その際に クリックするマウス座標をコードに決め打ちしなければならない 状態でした。これでは何らかのコンテンツが追加されてしまうと座標がズレるのでスクレイピング不可になってしまいます。

遂に!この問題を解決できる方法が確立できましたので記事にします。

マウス座標は「テンプレートマッチング」で解決しよう!

テンプレートマッチングとは、画像Aから画像Bにマッチする箇所を認識する手法です。物体認識系の記事で下記のような画像を見たことはないでしょうか?これをスクレイピングに取り入れようというのが今回の記事です。

とはいえ、 AI界隈の人間でもないし、画像処理界隈の人間でもないので分からない という方も多いのではないでしょうか。

結論から言うと OpenCV を使えば簡単にテンプレートマッチングできます。しかも GPU非搭載のパソコンでも0.3秒ぐらいで処理が終わる 爆速っぷりです。 AIを挟まないのでコストも抑えられます!

テンプレートマッチングを実装してみよう!

インストールする必要があるもの
npm install opencv-wasm jimp@0.22.12
  • opencv-wasm:OpenCVをNode.jsで使えるようにしてくれているnpmパッケージです。TypeScript型もそれなりに効くのでこのライブラリを選択しました。
  • jimp:画像周りの処理をしてくれます。最新バージョンでは.read() の型が効かないためv0.22を指定しています。

今回は例として下記の2つの画像を使用します。

この画像から この箇所を見つける
ファイル名: a.jpg

YouTubeライブ配信によって同接13,000超えの大物と判明した七原くん。稼がない配信者としては最大規模。はよ配信しろや。
ファイル名: 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);
})();

OpenCVでは標準でテンプレートマッチング機能を提供してくれています。
それほど難しい記述も無しで利用できます。

上記のコードを実行すると、下記の結果になります。
正直minが何を表すのか知りませんが、スクレイピング用途ではmax値だけ見ればOKです。

出力結果
{
  minVal: -0.4658215045928955, // 何の値かは知らん
  maxVal: 0.9975075721740723, // マッチ具合
  minLoc: { x: 435, y: 72 }, // 何の値かは知らん
  maxLoc: { x: 392, y: 28 } // これがマッチした座標(始まり部分)
}

maxVal はどれだけマッチしているかを数値化してくれています。1に近づくほどマッチしています。画像を切り取ったものはおおよそ 0.99ぐらいになる印象です。

maxLoc はマッチした座標です。画像の左上部分の座標になっています。(なのでクリック座標として使うには調整が必要です。後述します。)

Figmaで確認してみた

マッチした箇所を示す四角形(バウンディングボックスといいます)の表示は後述するとして、取り急ぎFigmaで確認してみましょう。X座標(392)とY座標(28)を指定してみると、上記画像のようにピッタリと収まっていることがお分かり頂けるかと思います。

これで問題なくテンプレートマッチングが動くことが確認できましたね!

スクレイピング用に関数化しておこう!(バウンディングボックス表示にも対応しよう!)

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();
  }
}

使用例

使ってみる
(async () => {
  const imageSourcePath = path.resolve(__dirname, "./src/images/a.jpg");
  const imageTemplatePath = path.resolve(__dirname, "./src/images/b.png");

  const { isSuccess, position } = await getImageMatchPosition({
    imageSourcePath: imageSourcePath,
    imageTemplatePath: imageTemplatePath,
    outputBoundingBoxPath: path.resolve(__dirname, "./src/images/c.jpg"),
  });

  console.log(isSuccess);
  console.log(position);
})();

マッチする箇所があれば isSuccessにはtrue、positionにはクリック座標(b.pngの中央部分)が取得できます。

マッチする箇所が無ければ isSuccessにはfalse、positionにはそれぞれ0が入ります。

outputBoundingBoxPath オプションには画像出力先をファイル名込みで指定できます。指定例 path.resolve(__dirname, "./src/images/c.jpg") このオプションが空の場合はバウンディングボックスを描画した画像の出力はされません。

▼バウンディングボックスの出力例

スクレイピングではどうやって使うのか?

Playwright には await page.screenshot({ path: path.resolve(__dirname, "./src/images/screenshot.png") }) というスクリーンショットを撮影する機能があります。

これで撮影したキャプチャ画像を imageSourcePath に指定します。
事前に用意した要素画像を imageTemplatePath に指定します。

取得できた position から await page.mouse.click(position.x, position.y); とすれば要素位置が変わっても問題なくクリックできるようになります。

マッチしなければスクロールして再試行...を繰り返していくだけでOKです。

要素画像を作るときの注意点

スクリーンショットと同じ倍率で画像を作るようにしましょう。
拡大率が100%であれば、要素の画像も拡大率100%にする必要があります。拡大率がずれていると違う箇所として認識されてしまいます。

おしまい

OpenCVでテンプレートマッチングをして、その座標をクリック座標として活用する例をご紹介しました。これでCanvasで作られたサイトでも機械的にクリック座標を取得してスクレイピングできるようになりましたね!

やったぜ!!

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

https://zenn.dev/link/comments/fe3a9e214b1614

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

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

Discussion