自分の会社の営業応募を自動化していて、Contact Form 7 のサイトで延々と
メッセージの送信に失敗しました。後でまたお試しください。
を食らい続けました。最初は「wpcf7 が悪い」と結論づけたのですが、これは誤りでした。
CAPTCHAの有無で振り分け直したら、結果がきれいに割れました。
- CAPTCHAが載っていない Contact Form 7 → 5サイト中5サイト成功
- reCAPTCHA v3 / Turnstile が載っている → 11サイト中11サイト失敗
つまり真因はフォームプラグインではなく CAPTCHA です。そして重要なのは、これは着手前にHTMLを1回GETするだけで判別できるということでした。
この記事は、その判別コードと、45サイトを実測した内訳の話です。
先に立場をはっきりさせておきます
この記事はCAPTCHAの回避方法ではありません。 書いてあるのは逆で、
CAPTCHAが載っているフォームは自動化の対象から外し、最初から手作業のリストに振り分ける
ための判別方法です。相手が明示的に置いたbot対策を迂回する話は一切扱いません(実際、後述するとおり迂回を試みて失敗しています)。
なぜこれが実務で効くかというと、「そのフォームは自動化が成立するか」を、1行もコードを書く前に見積もれるからです。50サイト分のフローを実装してから半分が通らないと分かるのが、いちばん高くつきます。
環境
| 項目 | 値 |
|---|---|
| Node.js | 22.x(fetch / AbortController を標準で使う) |
| 依存パッケージ |
なし(node:fs / node:path のみ) |
| ブラウザ | 使わない(Playwright / Puppeteer とも不要) |
| 対象 | 日本語の企業サイトの問い合わせフォーム 45件 |
| 実測日 | 2026-07-26 |
なぜブラウザを使わないのか
最初はヘッドレスChromiumで全部やっていました。やめた理由は3つです。
- 遅い。 45サイトの偵察だけで Chromium を45回起動することになる。
- リソースを食う。 並列度を上げるとハンドルとGDIオブジェクトが積み上がり、他の作業に影響が出た。
-
そもそも要らない。 知りたいのは「フォームがあるか」「入力欄の
nameは何か」「CAPTCHAは何か」だけで、これはサーバーが返す最初のHTMLに全部書いてある。
JSで描画するフォーム(Next.js / Wix など)だけは取れませんが、それは取れないという結果自体が有益な判定でした(後述、45件中7件がこれ)。
CAPTCHAを判別するコード
これが本体です。40行弱。
/**
* ページに載っているCAPTCHAの種類を返す。
* 戻り値の例: ['reCAPTCHA v3'] / ['Turnstile', 'reCAPTCHA v2'] / []
*/
export function detectCaptcha(html) {
const h = html;
const kinds = [];
// Cloudflare Turnstile
if (/challenges\.cloudflare\.com\/turnstile/i.test(h) ||
/cf-turnstile/i.test(h) ||
/_wpcf7_turnstile/i.test(h)) kinds.push('Turnstile');
// hCaptcha
if (/hcaptcha\.com/i.test(h) || /h-captcha/i.test(h)) kinds.push('hCaptcha');
// reCAPTCHA: v3 と v2 の切り分けが肝
const v3 = /(google\.com|gstatic\.com|recaptcha\.net)\/recaptcha\/api\.js\?[^"'<>]*render=(?!explicit)/i.test(h);
const hasApiJs = /recaptcha\/api\.js/i.test(h);
const hasV2Div = /class=["'][^"']*g-recaptcha/i.test(h) ||
/grecaptcha\.render/i.test(h) ||
/data-sitekey/i.test(h);
if (v3) kinds.push('reCAPTCHA v3');
else if (hasApiJs || hasV2Div) kinds.push('reCAPTCHA v2');
// 画像 / 算術 CAPTCHA(Really Simple CAPTCHA, SiteGuard など)
if (/really[_-]?simple[_-]?captcha|captcha-image|認証文字|画像の文字|計算して|の答えを|siteguard/i.test(h))
kinds.push('画像/算術CAPTCHA(疑)');
return kinds;
}
v3 と v2 をどう切り分けているか
ここが一番間違えやすいところです。両者は同じ api.js を読み込むので、recaptcha の有無だけでは区別できません。
見るのはクエリパラメータです。
<!-- v3: サイトキーが render= に直接入る -->
<script src="https://www.google.com/recaptcha/api.js?render=6Lxxxxxxxxxxxxxxxx"></script>
<!-- v2 (explicit render): 文字列 "explicit" が入る -->
<script src="https://www.google.com/recaptcha/api.js?onload=cb&render=explicit"></script>
<!-- v2 (自動レンダリング): render= が無く、DOM側に g-recaptcha がある -->
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<div class="g-recaptcha" data-sitekey="6Lxxxxxxxxxxxxxxxx"></div>
なので render= があり、かつその値が explicit でなければ v3。正規表現の render=(?!explicit) はこれを表しています。この否定先読みを入れ忘れると、v2のサイトを全部v3と誤判定します(最初やりました)。
判定順にも意味があります。if (v3) ... else if (hasApiJs || hasV2Div) としているのは、v3のページにもウィジェット由来の痕跡が残っていることがあるためで、v3を先に確定させます。
既知の誤検出(正直に書いておきます)
このコードには誤検出が1つあります。Turnstile も data-sitekey 属性を使うので、Turnstileだけのページが ['Turnstile', 'reCAPTCHA v2'] と返ります。
detectCaptcha('<div class="cf-turnstile" data-sitekey="0x4"></div>');
// → [ 'Turnstile', 'reCAPTCHA v2' ] ← v2 は誤検出
私の用途では**「CAPTCHAが何かしら載っている=自動化しない」という結論が変わらない**ので放置しています。種別を厳密に出したいなら、reCAPTCHA判定側で recaptcha の文字列を含むことを条件に足してください。
const hasV2Div = /class=["'][^"']*g-recaptcha/i.test(h) ||
/grecaptcha\.render/i.test(h) ||
(/data-sitekey/i.test(h) && /recaptcha/i.test(h)); // ← Turnstileを弾く
判別結果が実務上どう違うのか
| 種類 | 何が起きるか | どう扱うか |
|---|---|---|
| reCAPTCHA v3 | ウィジェットが無い。スコアで黙って弾かれるので、UI上は「送信に失敗しました」としか出ない | 自動化を諦める。 解くべきウィジェットが存在しない |
| reCAPTCHA v2 | チェックボックスや画像選択。人間の操作が要る | 人がやる |
| Turnstile | 多くは非対話。v3と同じく黙って落ちる | 自動化を諦める |
| hCaptcha | v2と同様 | 人がやる |
| 画像/算術 | 「4 + 10 = ?」等。機械的には解けてしまう | 解かない。 相手が意図的に置いた対策なので手作業に回す |
| なし | 素直にPOSTが通る | 自動化の対象 |
いちばん困るのは v3 と Turnstile です。 「解けない」のではなく「解く対象が画面に無い」ので、リトライしても永久に同じ結果になります。ここを早く見切ることに価値があります。
45サイトの実測内訳
node form-recon.mjs targets.json ./out
を回して得た内訳がこれです。
| 判定 | 件数 |
|---|---|
| CAPTCHAなし | 14 |
| reCAPTCHA v3 | 12 |
| reCAPTCHA v2 | 2 |
| Turnstile | 3(うち2件は v2 併用) |
| 画像 / 算術 CAPTCHA | 1 |
HTMLに <form> が出てこない(JS描画・到達不可) |
7 |
| 対象外(募集終了・条件不適合など) | 6 |
| 合計 | 45 |
そして送信結果。
- CAPTCHAなしの14サイトへHTTPで送信 → 13サイトで完了画面の文言を実測(残り1件は先方のメール受信箱が恒久拒否でバウンス。これは自動化とは無関係な先方都合)
- 別バッチでブラウザ自動化を使い、reCAPTCHA v3 / Turnstile のサイト11件に送信 → 11件すべて失敗
- CAPTCHAなしの Contact Form 7 は 5件すべて成功
wpcf7 のバージョンは 4.7 / 5.0 / 5.4.2 / 5.9.3 / 6.0.4 / 6.1.2 / 6.1.4 / 6.1.5 / 6.1.6 とバラバラでしたが、成否はバージョンではなくCAPTCHAの有無できれいに割れました。
例外が1件ありました。wpcf7 6.1.5 + reCAPTCHA v3 のサイトが1件だけ通っています。v3のスコア閾値はサイト側で設定できるので、緩い設定なら通ることもある、ということです。「v3なら必ず落ちる」ではなく「まず落ちる」が正確です。
「人間らしく動く」対策は効かなかった
v3で落ちるなら人間らしい挙動を足せばいいのでは、と思って実装しました。ブラウザ自動化側に、送信前に
- マウス移動を12回
- ページのスクロール
- Tabキーによるフォーカス移動
- 6秒の滞留
を挟むオプション(HUMAN=1)を足して再試行しました。
結果は変わりませんでした。 失敗のままです。
傍証として、wpcf7 ではない自前フォーム+reCAPTCHA のサイトが1件あり、そこは**「reCAPTCHA 認証に失敗しました。」と明示的に返してきました**。つまり落ちている理由はスコアで確定です。
ここで手を止めたのは判断でもあります。先方が意図して置いた対策を突破しにいくのは、技術的に可能かどうか以前にやるべきでないので、以後この方向は追っていません。
エラー文言が信用できないという副作用
Contact Form 7 の
メッセージの送信に失敗しました。後でまたお試しください。
は、mail_failed(メール送信の失敗)と spam(スパム判定)の両方で出ます。 見た目からは「サーバーのメール設定が壊れている」のか「CAPTCHAに弾かれた」のか区別がつきません。
なので文言でデバッグしようとすると迷子になります。 送信前にHTMLを見てCAPTCHA種別を確定させておけば、この曖昧さを踏まずに済みます。これが「先に判別する」いちばん実務的な理由でした。
なお wpcf7 には REST エンドポイントがあり、
POST /wp-json/contact-form-7/v1/contact-forms/<id>/feedback
を叩くと status が mail_sent / validation_failed / spam で明確に返ります。成否判定に迷うくらいならこちらを使うほうが早いです。
おまけ1: 文字コードで9割が化ける
日本語の企業サイトを相手にするなら、これを避けて通れません。Shift_JIS と EUC-JP がまだ現役です。
await res.text() は UTF-8 決め打ちなので、そのままだと本文もフォームの value も文字化けします。バイト列で受けてから、ページが宣言している charset でデコードし直す必要があります。
const buf = Buffer.from(await r.arrayBuffer());
// 1) Content-Type ヘッダ
let cs = (r.headers.get('content-type') || '').match(/charset=([\w-]+)/i)?.[1];
// 2) 無ければ <meta charset> を先頭4KBから
if (!cs) {
const head = buf.subarray(0, 4000).toString('latin1');
cs = head.match(/charset=["']?([\w-]+)/i)?.[1];
}
cs = (cs || 'utf-8').toLowerCase();
if (/shift[_-]?jis|sjis|windows-31j|ms932|cp932/.test(cs)) cs = 'shift_jis';
else if (/euc-?jp/.test(cs)) cs = 'euc-jp';
else cs = 'utf-8';
let html;
try { html = new TextDecoder(cs).decode(buf); }
catch { html = buf.toString('utf8'); }
<meta charset> を探す側を latin1 でデコードしているのがポイントです。charsetが分からない段階でUTF-8としてデコードすると、そのdecode自体が壊れることがあるので、1バイト=1文字が保証される latin1 で覗きます。
おまけ2: <form> が無いサイトが15%ある
45件中7件(15%)は、HTMLを取っても <form> が出てきませんでした。Next.js や Wix でクライアント描画しているケースです。
これは CAPTCHA とは別の失敗要因なので、判定を「CAPTCHA種別」だけにすると取りこぼします。私は captcha と forms.length を必ず併記するようにしました。
acme01 200 forms=1 captcha=なし
acme02 200 forms=1 captcha=reCAPTCHA v3
acme03 200 forms=0 captcha=なし ← ここが JS描画。CAPTCHAが無くても自動化できない
acme04 200 forms=2 captcha=Turnstile+reCAPTCHA v2
もうひとつ、募集ページとフォームは別URLのことが多いです。募集ページだけ見て forms=0 で切り捨てると大量に取りこぼします(実際、リンクを1段追ってフォームURLを特定する処理を足したら、14件中9件はそれで拾ったものでした)。
まとめ
- Contact Form 7 で送信に失敗する原因は、プラグインではなく reCAPTCHA v3 / Turnstile だった(v3・Turnstile 11/11失敗、CAPTCHAなしのwpcf7 5/5成功)。
- CAPTCHAの種別はHTMLを1回GETするだけで判別できる。v3とv2は
render=(?!explicit)で切り分ける。 - v3とTurnstileは解くべきウィジェットが存在しないので、リトライしても無意味。早く見切って手作業へ回す。
- 人間らしい挙動を足しても v3 のスコアは改善しなかった。
- 日本語サイトを相手にするなら、Shift_JIS / EUC-JP のデコードは必須。
- 判定は「CAPTCHA種別」と「
<form>の有無」を必ず併記する(15%は<form>が無い)。
コード
この記事で使った偵察ツールを MIT ライセンスで公開しています。detectCaptcha / parseForms / 文字コード判定つきの get がそのまま入っています。依存パッケージはゼロです。
node form-recon.mjs targets.json ./out --keywords 採用,パートナー
筆者: タク(AI Jidoka Lab)。業務自動化の設計と実装をしています。 https://setlog-app.github.io/ai-jidoka-lab/