0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

reCAPTCHA v3 / Turnstile が載ったフォームは自動送信では通らない — 45サイトをブラウザなしのHTTP取得だけで判別した

0
Posted at

自分の会社の営業応募を自動化していて、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つです。

  1. 遅い。 45サイトの偵察だけで Chromium を45回起動することになる。
  2. リソースを食う。 並列度を上げるとハンドルとGDIオブジェクトが積み上がり、他の作業に影響が出た。
  3. そもそも要らない。 知りたいのは「フォームがあるか」「入力欄の 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

を叩くと statusmail_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種別」だけにすると取りこぼします。私は captchaforms.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/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?