ブラウザ自動操作で実アプリを触ると詰まる3つの壁

ブラウザ自動操作で実アプリを触ると詰まる3つの壁

『AIに画像を一枚ずつ見せて、透かしの入ったものだけ却下させる』。

言葉にすると、ものの数分で片づきそうな軽いタスクに見えます。

ところが実際にブラウザ自動操作で触り始めた途端、判定ロジックの手前で何度も足を止められました。

足を引っ張ったのは、私が書いたコードでもAIの判定精度でもありません。

犯人はいつも、ブラウザやアプリが敷いた実行環境そのものの制約だったのです。

同じ感覚で「選別くらい自動で回せる」と踏んで詰まる人のために、ぶつかった3つの壁と回避策を作業ログのまま書き残します。

前提: ブラウザ自動操作で「透かし入りだけ却下」を目指す

舞台は、ローカルで立ち上げた画像選別用のWebUIです。

画面には生成された画像が次々と並び、キーボードやクリックで一枚ずつ承認と却下に振り分けていく作りになっています。

厄介だったのは、AI生成のクリーンな画像に、配布元の透かし(dreamstime など)が入った画像が混じっている点でした。

透かしの有無だけを見分けて、後者だけを消したい。

操作自体は、ブラウザ拡張ベースの自動操作で問題なく届きました。

ただ、その入り口で地味な落とし穴を踏みます。

矢印キーの割り当てを最初に確かめる

このUIでは、← が承認、→ が却下という割り当てでした。

ところが私は → を「次へ送るキー」と思い込み、透かしのない画像を一枚、誤って却下してしまいました。

気づいたきっかけは、対象件数が50から49に減ったという数字のズレです。

画面の変化が目に入りにくい自動操作では、キー割り当ての確認を最初の一手にする大切さを、いきなり思い知らされます。

壁1: 同一オリジンに見えて、実体はクロスオリジン

透かしを機械的に判定できれば、選別はほぼ自動化できるはずでした。

表示中の画像を canvas に写し取り、画素を読んで透かしの模様を拾う、という素朴な作戦を立てました。

しかし getImageData を呼んだ瞬間、SecurityError で処理が止まります

犯人は、画像の配信元でした。

ページ自体は localhost なのに、画像の実体は S3 の署名付き URLという別オリジンから届いていたのです。

img タグは普通に表示できるので、つい同一オリジンだと錯覚します。

けれど別オリジンの画素を canvas に書き込んだ時点で、canvas は「汚染(taint)」状態へと切り替わる。

汚染された canvas から画素を読み出そうとすると、ブラウザはセキュリティ例外を投げてブロックします。

// 表示中の画像を canvas に写して画素を読もうとする(※これが弾かれる)
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);          // 別オリジンの画素を書き込む
try {
  ctx.getImageData(0, 0, 8, 8);    // ここで SecurityError
} catch (e) {
  console.log('tainted:', e.name);  // → "SecurityError"
}
// 省略: CORS ヘッダの無い配信元では crossOrigin="anonymous" でも通らない

配信元が CORS ヘッダを返していれば crossorigin 属性で回避できますが、今回の S3 はそれを返しませんでした。

この汚染の仕組みと回避条件は、MDN の解説にまとまっています。

ピクセル解析はきっぱり諦め、透かしの判定は目視に切り替えると腹をくくりました。

壁2: 薄い透かしを、どう確実に読ませるか

目視に頼る方針へ切り替えても、次の壁がすぐに立ちはだかります。

問題の透かしは、明るく均一な背景の中央に、ごく薄く重なるタイプです。

一覧に並ぶ 94px ほどのサムネイルや、通常の縮小スクショでは、まず判読できません。

そこで、表示の仕方そのものを変えます。

対象の画像に CSS を差し込み、画面いっぱいに引き伸ばしてから撮影する方法に行き着きました。

position:fixed と 100vw/100vh、object-fit:contain を当てれば、一枚を全画面ビューアーのように広げられます。

// 表示中の大きめのプレビュー img を画面いっぱいに広げてから撮影する
const big = [...document.querySelectorAll('img')]
  .find(i => i.clientWidth > 300);
if (big) {
  big.style.cssText =
    'position:fixed;top:0;left:0;width:100vw;height:100vh;' +
    'object-fit:contain;z-index:99999;background:#888';
}
// この後にスクリーンショットを撮ると、薄い透かしも読み取りやすい

こうすると、サムネでは読めなかった透かしが、はっきりと浮かび上がってくるのです。

最初はズーム系のスクショ機能に頼ろうとしました。

けれどその機能は、ビューポートを 523×115 と誤認してタイムアウトを連発し、まるで当てになりませんでした。

格好は悪くても、全画面化して撮るほうが、結果としてはるかに安定していました。

壁3: ランダムな50件と非同期stateで「全件」が終わらない

三つ目の壁は、そもそも「全部を見終わる」という前提が崩れている点でした。

レビュー枠に出てくるのは、プールからランダムに抽出された50件です。

リロードすると50件に補充され、さらに新しい生成バッチが次々に現れてきます。

実際、タイムスタンプが 452444 から 500040 まで、9 バッチ増え続けたのを確認しました。

固定された母集団が無いので、「全件チェック」はいつまでたっても終わりません。

走査を最後までやり切ることより、どこで区切るかを決める設計のほうが本質だと気づかされます。

送りの操作にも、別の落とし穴がありました。

座標を決め打ちしてサムネをクリックする方式は、ページ高さが 744px と 778px の間で揺れるたびに Y 座標がずれ、空振りします。

そこで座標に頼らず、DOM から「選択中のサムネ」を見つけて、その次をクリックする方式へ切り替えました。

// clientWidth が最大のサムネ = 選択中、とみなして「次」を click する
const thumbs = [...document.querySelectorAll('img')]
  .filter(i => i.clientWidth > 0 && i.clientWidth < 160);
let sel = 0, mx = 0;
thumbs.forEach((im, i) => { if (im.clientWidth > mx) { mx = im.clientWidth; sel = i; } });
const next = thumbs[sel + 1];
if (next) (next.closest('button,[role=button],a') || next).click();
// 注意: 同期ループ内で複数回呼んでも、選択 state が非同期更新のため進まない

複数送りが効かない理由(React の非同期 state)

ここで、もう一つ根深い引っかかりに出会います。

JS の同期ループの中で「次へ」を何度呼んでも、同じ要素を連打するだけで前に進みません。

React の選択 state が非同期に更新されるため、ループ内では常に古い選択位置を見てしまうからです。

この「状態はスナップショットのように固定される」挙動は、React の公式ドキュメントで丁寧に説明されています。

Queueing a Series of State Updates – React

結局、1 回送るごとに待って、状態を撮り直すという 1 往復ずつの地道な進め方に落ち着きました。

却下はおおむね残りましたが、一枚だけ、消したはずの画像が後から復活していたのです。

永続化にも揺らぎがある可能性は、頭の隅に置いておくべきでしょう。

最終的に、透かし入りを計25枚、取り違えて落とした透かし無しを1枚という結果で、一区切りとしました。

最後に

三つの壁に共通していたのは、原因が私のロジックの外側にあった点です。

クロスオリジンの制約、レイアウトの揺れ、ランダムな母集団と非同期な状態。

どれも画面の見た目ではなく、その裏側の実行環境こそがタスクを難しくしていたのだと感じます。

だから自動操作が理由もなく止まったら、自分のコードを疑う前に、オリジン・状態管理・データの供給元という環境の層をまず眺めてみるのが近道です。

一見単純なUI操作ほど、この裏側の設計に足をすくわれやすいと痛感しました。

同じ落とし穴にはまる方の遠回りを、少しでも減らせたらと願っています。

以上です。