TL;DR
Playwright の click() は親切設計で、要素が「クリック可能」になるまで自動で待つ。しかしその待機判定が SPA の「透明な div が上に被さっている」ケースに弱い。エラーログには毎回:
TimeoutError: ElementHandle.click: Timeout 30000ms exceeded.
... <div class="...">...</div> subtree intercepts pointer events
... retrying click action, attempt #1
これが 30 秒続く。本番で 1 タスクごとに 30 秒待たされたら話にならない。
3 段フォールバック
通常 click → force=True → JavaScript の el.click() で諦めずに殴る:
def _safe_click(element) -> bool:
"""Playwright の click が「subtree intercepts」で詰まる時の 3 段フォールバック。"""
# 1) 通常 click(短いタイムアウトで早めに諦める)
try:
element.click(timeout=5000)
return True
except Exception:
pass
# 2) force=True で intercept 判定を無視
try:
element.click(timeout=3000, force=True)
return True
except Exception:
pass
# 3) JS で element.click() を直接叩く(DOM レベルの click イベントを発火)
try:
element.evaluate("el => el.click()")
return True
except Exception:
return False
3 段目の evaluate("el => el.click()") が最強で、ほぼ何でも通る。ただし「人間がクリックしたフリ」ではなく「DOM イベント発火」なので、X や reCAPTCHA みたいな反 bot 検知がある所では検出されうる。普通の SaaS の UI なら問題ない。
なぜ素の click() で詰まるのか
X の場合、ツイートカードの上にホバーで現れるツールチップ用の透明オーバーレイが乗っている。Playwright の visibility/stability チェックはそれを「上に何か乗ってる」と認識して、retry を続ける。retry の合計が 30 秒。
force=True はその判定をスキップしてクリック座標に強制クリックを送る。それでも反応しない場合は、要素にクリックイベントを直接送るしか手段がない。
教訓
「ブラウザ自動化は半分は UI 戦争」。SPA の DOM 構造は来週変わる。今日動くセレクタが明日動く保証はない。_safe_click のような諦めない実装を最初から書いておくと、UI 変更でも壊れにくい。
「素の click() で完璧に動かないコードは綺麗じゃない」と思ってリトライしないと、本番で 30 秒タイムアウトを 3 回踏んで Fargate が空回りして終わる。