はじめに
GMOコネクトの平島です。
社内で評判だった Web研修をClaude Codeに委譲し1講座15〜30分で完走する運用論(ntaka329さん) を読んで、自分も同じ要領でWeb研修の課題提出を Claude Code + Playwright CLI に委譲してみよう、と意気込みました。Claude Codeにブラウザ操作まで任せられれば、PDFの講義資料を読ませて回答案を作らせ、そのまま提出フォームに入力させる、まで一気通貫で回せるはずでした。
ところが、いざClaude CodeからPlaywright CLIを呼ばせてみると、既ログイン済みのChromeにアタッチする段で3つも続けて踏み抜きました😇
要点だけ書くと、--remote-debugging-port を付けてChromeを起動するだけではCDP(Chrome DevTools Protocol)は開かず、社内のSSLインスペクション環境では playwright install が落ち、検証用スクショで setViewportSize を雑に弄るとClaude Codeのスクリプトが人間の操作中のChromeを巻き込み事故します。
この記事は、Claude CodeにPlaywrightでブラウザを触らせる運用で踏んだ3つのハマりと、その回避策の備忘録です。
先にまとめ
| 症状 | 真因 | 回避策 |
|---|---|---|
--remote-debugging-port=9222 を付けて起動しても /json/version に繋がらない |
既存Chromeプロセスにフラグが引き渡されて新規プロセスが立たない |
--user-data-dir を明示して別プロセスとして起動する |
playwright install 等が SELF_SIGNED_CERT_IN_CHAIN で失敗する |
社内SSLインスペクションで証明書チェーンに社内CAが挟まり、Node.jsのTLS検証で落ちる |
NODE_EXTRA_CA_CERTS に社内CAを通す。あるいはそもそもCDPアタッチで内蔵Chromiumを使わない |
| 自動入力後、ユーザーが「スクロールできず送信ボタンが押せない」状態になる | 縦長スクショ取得のため setViewportSize を巨大化したまま戻していない |
fullPage: true で撮るか、try/finally で必ず元のサイズに戻す |
最終的に落ち着いた起動コマンドはこれです(詳細は記事末尾)。
$env:NODE_EXTRA_CA_CERTS = "C:\path\to\corporate-ca.crt"
Start-Process "C:\Program Files\Google\Chrome\Application\chrome.exe" -ArgumentList @(
"--remote-debugging-port=9222",
"--user-data-dir=$PWD\chrome-profile",
"--no-first-run",
"--no-default-browser-check"
)
const browser = await chromium.connectOverCDP('http://127.0.0.1:9222')
.catch(() => chromium.launch({ channel: 'chrome', headless: false }));
やりたかったこと
Claude CodeにPlaywright CLIを呼ばせて、Web研修の課題提出フォームにテキスト・画像を自動入力させたい、というのが出発点です。条件は以下:
- 既ログイン済みのChromeをそのまま使いたい(社内SSO・MFA通過済みのセッションをスクリプト側で再現するのは面倒すぎる)
- Claude Codeはターミナル越しにPlaywrightを起動するが、画面の最終確認と提出ボタンは人間が自分のChromeで行う
- 入力結果は検証用スクショで残してから人間レビュー
つまり Claude Codeが触るブラウザと人間が触るブラウザを同じChromeに同居させたい、という構図です。「既存のChromeにアタッチすれば全部解決」という素朴な見積もりでスタートしましたが、ここから3連敗します。
ハマり1: --remote-debugging-port を付けてもCDPが開かない
症状
PowerShellでこう起動します。
Start-Process "C:\Program Files\Google\Chrome\Application\chrome.exe" `
-ArgumentList "--remote-debugging-port=9222","--remote-allow-origins=*"
疎通確認しても落ちます。
Invoke-WebRequest "http://127.0.0.1:9222/json/version" -UseBasicParsing -TimeoutSec 3
# → タイムアウト or 404
タチが悪いのが、Chromeのユーザーデータディレクトリには DevToolsActivePort というファイルが作られて中身には 9222 と書いてあるのに、実際のポートは開いていないというケース。一見「起動できているように見える」ので原因切り分けに時間を溶かします。
真因
既存のChromeが起動中の状態で chrome.exe --remote-debugging-port=9222 を叩くと、新フラグは既存プロセスに引き渡されて新規プロセスが立ち上がりません。Chromeは1プロファイル=1プロセスに統合する挙動があるためで、後から付けたCDPフラグはまるごと無視されます。
加えて、Chrome v136以降は --user-data-dir を明示しないと --remote-debugging-port 自体が無視される、という挙動変更が公式アナウンスされています(Chrome公式ブログ: Changes to remote debugging port)。古いChromeで動いていたコードがある日突然動かなくなった場合はバージョン確認も併せて。
既存プロファイルだとセッションリストアやインストール済み拡張機能の都合でCDPサーバが上がらないこともあります。
解決
--user-data-dir を明示して 別プロファイル=別プロセス として起動します。
Start-Process "C:\Program Files\Google\Chrome\Application\chrome.exe" `
-ArgumentList @(
"--remote-debugging-port=9222",
"--user-data-dir=$PWD\chrome-profile",
"--no-first-run",
"--no-default-browser-check"
)
設計判断のポイント:
-
--user-data-dirで別プロファイルを切れば必ず別プロセスとして起動するので、新フラグが確実に効く - プロファイルは作業ディレクトリ配下に置いて使い回す。初回だけ手動ログインすれば、以降はそのまま既ログイン状態
-
--no-first-run/--no-default-browser-checkで初回ダイアログを消し、自動化の最初の数秒を無人にできる
切り分け手順を残しておきます。
-
chrome://versionを開き「コマンドライン」欄に--remote-debugging-port=9222が含まれているか確認(含まれていなければフラグが引き渡されていない) -
Invoke-WebRequest http://127.0.0.1:9222/json/versionで200が返るか - 返らないなら、
Stop-Process -Name chrome -Forceで全Chromeを落としてから--user-data-dir付きで起動し直す
Playwright側のアタッチコード:
const browser = await chromium.connectOverCDP('http://127.0.0.1:9222');
const context = browser.contexts()[0];
const page = context.pages()[0] ?? await context.newPage();
connectOverCDP で取得したcontextは既存タブそのものを掴むので、newContext() を呼ぶと既存のログインCookieを共有しない別ウィンドウになります。既ログイン状態を活かしたい場合は browser.contexts()[0] から既存contextを取り出すのが基本です。
ハマり2: 社内SSLインスペクションで playwright install が落ちる
症状
$ npx playwright install chromium
Error: self signed certificate in certificate chain
playwright install だけでなく、Playwrightから外部にfetchを投げるコードでも SELF_SIGNED_CERT_IN_CHAIN や unable to get local issuer certificate で落ちます。
真因
社内ネットワークがHTTPS通信をSSLインスペクション(中間者でTLSを開いて再暗号化)している場合、サーバ証明書のチェーンに社内独自CAが挟まります。Chrome自体は社内CAを信頼ストアに持っているので普段通り使えますが、Node.js(Playwright)は別途バンドルされたCA束を参照するため、社内CAは未知の発行者扱いになって検証で落ちます。
回避策
選択肢は3つ。安全と楽さのトレードオフで使い分けます。
| 対策 | 安全性 | 手間 | 推奨度 |
|---|---|---|---|
NODE_EXTRA_CA_CERTS に社内CAを通す |
高 | 中(CA入手) | 本筋 |
connectOverCDP で既存Chromeを使い、内蔵Chromiumを使わない |
高 | 低 | 一番おすすめ |
NODE_TLS_REJECT_UNAUTHORIZED=0 で検証ごと無効化 |
低 | 低 | ローカル緊急のみ |
本筋: 社内CAを通す
情シスから社内CAのPEMを入手して、Node.jsに追加CAとして食わせます。
$env:NODE_EXTRA_CA_CERTS = "C:\path\to\corporate-ca.crt"
これでTLS検証は通常通り走りつつ、社内CA経由の証明書も信頼されます。検証を切らずに通せるので、CIに残しても怖くないのがポイント。
一番楽: そもそも内蔵Chromiumを使わない
ハマり1で組んだCDPアタッチを使えば、npx playwright install chromium を一度も叩かなくて済みます。既存のChromeはOSの信頼ストアを使うので社内CAも普通に通り、Playwrightは制御プロトコル(CDP)越しに指示を出すだけ。
channel: 'chrome' でローカルChromeを起動する方法でも、内蔵Chromiumをダウンロードしないので同じく社内SSL問題を回避できます。社内ネット下では筆者はこのパターンを基本にしています。
// 内蔵Chromiumを使わずローカルChromeを使う
const browser = await chromium.launch({ channel: 'chrome', headless: false });
緊急回避: NODE_TLS_REJECT_UNAUTHORIZED=0
$env:NODE_TLS_REJECT_UNAUTHORIZED = "0"
1行でTLS検証ごと無効化します。中間者攻撃の検出も全部切るので、手元の使い捨てスクリプト限定 にしてください。CI、共有スクリプト、長期運用のジョブには残してはいけません。書くなら同じスクリプト内で「これはローカル緊急回避」とコメントを残し、本筋対応への移行をTODOに積むのが安全です。
ハマり3: 検証スクショで setViewportSize を大きくしたまま放置するとユーザーが操作できなくなる
症状
自動入力が無事終わり、ユーザーに「送信ボタンを押してください」と引き渡したところで一言。
フォームスクロールできないので送信ボタンが押せない
検証用に縦長フォーム全体を1枚に収めたスクショを撮っていたのが原因でした。
真因
ありがちなコード:
await page.setViewportSize({ width: 1280, height: 4000 });
await page.screenshot({ path: 'verify.png' });
// ← 元のサイズに戻していない
setViewportSize は実際のブラウザウィンドウのレンダリング解像度を変えます。connectOverCDP でアタッチした既存Chromeに対しても効きます。Viewportを巨大化したまま終了するとウィンドウサイズより内部レイアウトの方が大きい状態で残り、スクロールバーが効かなくなる/送信ボタンが画面外に追いやられる、という挙動になります。
Claude Code経由でPlaywrightを動かす運用で特に厄介なのは、Claude Codeのスクリプトが、人間が操作するChromeを巻き込み事故する こと。スクリプトが落ちようが成功しようがユーザーのウィンドウに影響が残ります。検証スクショ用に一時的に変えただけのつもりが、Claude Codeの仕事が終わった瞬間からユーザーの操作不能の原因になります。
解決
Viewportを変えずに撮るのが第一選択。
await page.screenshot({ path: 'verify.png', fullPage: true });
fullPage: true は内部的にスクロールしながら合成してくれるので、Viewportを弄らずに全体スクショが撮れます。
どうしてもViewportを変える必要があるときは、try/finally で必ず復元します。
const original = page.viewportSize();
try {
await page.setViewportSize({ width: 1280, height: 4000 });
await page.screenshot({ path: 'verify.png' });
} finally {
if (original) await page.setViewportSize(original);
}
finally に置く理由は、撮影中に例外が出ても 必ず元のサイズに戻す ため。途中で page.click が失敗してスクリプトが死ぬと、復元処理がスキップされて事故が再発します。
派生のハマり: 内側スクロールコンテナで仮想化が解除されていない
長尺フォームでは「ページ全体ではなく内側のコンテナがスクロール対象」というUIがあります。MS FormsやReact製の長いフォームでよく見ます。Playwrightで設問数を数えたら Text inputs found: 0 で「いやいや絶対あるだろ」となる原因の8割はこれです。
対処(.your-scroll-container は実際のスクロール対象セレクタに置き換え):
// 内側コンテナを下までスクロールして仮想化を解除
await page.$eval('.your-scroll-container', el => {
el.scrollTop = el.scrollHeight;
});
await page.waitForTimeout(500); // 描画反映待ち
// DOM再取得後、上に戻してユーザーに自然な状態で引き渡す
await page.$eval('.your-scroll-container', el => { el.scrollTop = 0; });
window.scrollBy を使うと外側のbodyだけが動いて中身が動かない、という空振りになります。スクロール対象セレクタは DevTools の Elements パネルで「スクロールバーがついている要素」を辿って特定します。
完成形:既ログインChromeに最小手で噛むテンプレ
3つのハマりを踏まえた起動テンプレです。
PowerShell側(疎通先行 → 失敗時のみ起動)
# 社内SSL対策: 社内CAを通す(本筋)
$env:NODE_EXTRA_CA_CERTS = "C:\path\to\corporate-ca.crt"
# 既にCDPが開いていれば何もしない、開いていなければ起動
try {
Invoke-WebRequest "http://127.0.0.1:9222/json/version" -UseBasicParsing -TimeoutSec 3 | Out-Null
} catch {
Start-Process "C:\Program Files\Google\Chrome\Application\chrome.exe" -ArgumentList @(
"--remote-debugging-port=9222",
"--user-data-dir=$PWD\chrome-profile",
"--no-first-run",
"--no-default-browser-check"
)
# CDPが開くまで最大10秒待つ
$deadline = (Get-Date).AddSeconds(10)
while ((Get-Date) -lt $deadline) {
try {
Invoke-WebRequest "http://127.0.0.1:9222/json/version" -UseBasicParsing -TimeoutSec 2 | Out-Null
break
} catch { Start-Sleep -Milliseconds 500 }
}
}
ポイント:
- CDP疎通を先にチェックして、繋がるなら起動しない(既ログイン状態を温存できる)
- 起動した場合は
/json/versionがポーリングで通るまで待ってから次工程に進む。即connectOverCDPするとレース条件で失敗することがある
Playwright側(CDP優先 + 失敗時フォールバック)
const { chromium } = require('playwright');
async function attachOrLaunch() {
try {
return await chromium.connectOverCDP('http://127.0.0.1:9222');
} catch (e) {
console.warn('CDPアタッチ失敗、新規起動にフォールバック:', e.message);
return await chromium.launch({ channel: 'chrome', headless: false });
}
}
(async () => {
const browser = await attachOrLaunch();
const context = browser.contexts()[0] ?? await browser.newContext();
const page = context.pages()[0] ?? await context.newPage();
const original = page.viewportSize();
try {
await page.goto('https://example.com/form');
// 入力処理...
await page.screenshot({ path: 'verify.png', fullPage: true });
} finally {
if (original) await page.setViewportSize(original);
}
})();
ポイント:
-
connectOverCDPが失敗したら新規Chrome起動にフォールバック(Claude Codeに自律実行させると、アタッチ失敗時にリトライループでハマって時間を溶かすので、新規プロセス起動に切り替える分岐を入れておくと完走率が一気に上がります) -
browser.contexts()[0]で既存タブを掴む。newContext()は既ログイン状態を捨てるのでアタッチでは原則使わない - Viewportを触る処理は
try/finallyで復元。引き渡す相手のChromeを汚さない
まとめ
-
ハマり1:
--remote-debugging-portは既存Chromeに引き渡されてCDPが立たない。--user-data-dirを明示して別プロセス起動するのが必須(Chrome v136以降の仕様変更で顕在化) -
ハマり2: 社内SSLインスペクション下では
NODE_EXTRA_CA_CERTSで社内CAを通すのが本筋。あるいは既存ChromeへのCDPアタッチで内蔵Chromium自体を使わない方針に倒すのが楽 -
ハマり3: 検証スクショで
setViewportSizeを雑に弄ると、Claude Codeが人間のChromeを破壊する。fullPage: trueで撮るか、try/finallyで必ず復元する - 設計指針: Claude CodeにPlaywrightで全自動化させるより、「ユーザーがコピペした方が速い・確実な」箇所は人に残すハイブリッド設計の方が、社内環境では完走率が高い
最後に、GMOコネクトではサービス開発支援や技術支援をはじめ、幅広い支援を行っておりますので、何かありましたらお気軽にお問合せください。