Tutorials

渐进式 Web 应用程序 (PWA) 中的验证码处理

自动化脚本在 PWA 页面死等 .g-recaptcha.cf-turnstile,却总也等不到。

问题通常出在渲染方式:验证码要等路由跳转、组件挂载后才插入 DOM,不像传统页面直接写在初始 HTML 里。

CaptchaAI 负责识别 Turnstile 和 reCAPTCHA v2,检测时机得自己处理。

用对 MutationObserver 或框架信号,才能在验证码出现时抓到 sitekey——本文用 Playwright(JavaScript)和 Selenium(Python)示例讲清完整流程。

PWA 验证码检测为什么更难

传统网站的验证码写在首次返回的 HTML 里,抓取时机很好判断。PWA 不是这样,差异集中在这几点:

  • 验证码加载时机——传统网站随 HTML 一起返回;PWA 要等页面加载后由 JavaScript 动态渲染
  • 页面导航方式——传统网站整页刷新;PWA 走客户端路由,不刷新页面
  • Service Worker——传统网站不存在;PWA 会缓存资源,可能拦截请求
  • DOM 可用时机——传统网站立即可用;PWA 要等框架渲染完成
  • 网络请求路径——传统网站直接请求;PWA 可能被 Service Worker 拦截

举例来说,测试国内电商类 PWA 的结账页面时,检测逻辑写死在 page.goto() 后立即执行几乎必然漏检——验证码要等表单挂载后才注入。

另外 reCAPTCHA v2 依赖 Google 托管脚本,国内网络下偶尔不稳;Turnstile 通常更稳,不少国内站点因此优先接入 Turnstile。

步骤 1:等验证码渲染完成再提取 sitekey

最常见的错误,是在 PWA 框架还没渲染出验证码组件时就去抓 sitekey。用 MutationObserver 监听 DOM 变化,或者监听框架自己暴露的信号,等验证码真正出现再动手:

// pwa_captcha_detector.js — Playwright script
const { chromium } = require('playwright');
const axios = require('axios');

const API_KEY = 'YOUR_API_KEY';

async function detectCaptchaInPWA(page) {
  // Wait for the PWA app shell to render
  await page.waitForLoadState('networkidle');

  // Use MutationObserver to detect dynamically loaded CAPTCHAs
  const captchaInfo = await page.evaluate(() => {
    return new Promise((resolve) => {
      // Check if CAPTCHA is already present
      const existing = document.querySelector('.g-recaptcha, .cf-turnstile');
      if (existing) {
        resolve({
          type: existing.classList.contains('g-recaptcha')
            ? 'recaptcha_v2' : 'turnstile',
          sitekey: existing.getAttribute('data-sitekey'),
          pageurl: window.location.href,
        });
        return;
      }

      // Watch for CAPTCHA elements added dynamically
      const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
          for (const node of mutation.addedNodes) {
            if (node.nodeType !== 1) continue;
            const captcha = node.matches?.('.g-recaptcha, .cf-turnstile')
              ? node
              : node.querySelector?.('.g-recaptcha, .cf-turnstile');
            if (captcha) {
              observer.disconnect();
              resolve({
                type: captcha.classList.contains('g-recaptcha')
                  ? 'recaptcha_v2' : 'turnstile',
                sitekey: captcha.getAttribute('data-sitekey'),
                pageurl: window.location.href,
              });
              return;
            }
          }
        }
      });

      observer.observe(document.body, {
        childList: true,
        subtree: true,
      });

      // Timeout after 15 seconds
      setTimeout(() => {
        observer.disconnect();
        resolve(null);
      }, 15000);
    });
  });

  return captchaInfo;
}

async function main() {
  const browser = await chromium.launch({ headless: false });
  const context = await browser.newContext();
  const page = await context.newPage();

  await page.goto('https://example-pwa.com/login');

  const captcha = await detectCaptchaInPWA(page);

  if (!captcha) {
    console.log('No CAPTCHA detected');
    await browser.close();
    return;
  }

  console.log(`Detected ${captcha.type}: ${captcha.sitekey}`);

  // Solve with CaptchaAI
  const token = await solveCaptcha(captcha);
  console.log(`Token: ${token.substring(0, 50)}...`);

  // Inject token
  await injectToken(page, captcha.type, token);

  // Submit form
  await page.click('button[type="submit"]');
  await page.waitForNavigation({ waitUntil: 'networkidle' });

  console.log('Form submitted');
  await browser.close();
}

async function solveCaptcha(captcha) {
  const params = {
    key: API_KEY,
    pageurl: captcha.pageurl,
    json: '1',
  };

  if (captcha.type === 'recaptcha_v2') {
    params.method = 'userrecaptcha';
    params.googlekey = captcha.sitekey;
  } else {
    params.method = 'turnstile';
    params.sitekey = captcha.sitekey;
  }

  const submit = await axios.get(
    'https://ocr.captchaai.com/in.php', { params }
  );
  if (submit.data.status !== 1) throw new Error(submit.data.request);

  const taskId = submit.data.request;

  for (let i = 0; i < 30; i++) {
    await new Promise((r) => setTimeout(r, 5000));
    const poll = await axios.get('https://ocr.captchaai.com/res.php', {
      params: { key: API_KEY, action: 'get', id: taskId, json: '1' },
    });
    if (poll.data.status === 1) return poll.data.request;
    if (poll.data.request !== 'CAPCHA_NOT_READY') {
      throw new Error(poll.data.request);
    }
  }
  throw new Error('Timeout');
}

async function injectToken(page, type, token) {
  if (type === 'recaptcha_v2') {
    await page.evaluate((t) => {
      document.getElementById('g-recaptcha-response').value = t;
      try {
        const clients = ___grecaptcha_cfg.clients;
        Object.keys(clients).forEach((k) => {
          Object.keys(clients[k]).forEach((j) => {
            if (clients[k][j]?.callback) clients[k][j].callback(t);
          });
        });
      } catch (e) {}
    }, token);
  } else {
    await page.evaluate((t) => {
      const input = document.querySelector('[name="cf-turnstile-response"]');
      if (input) input.value = t;
      const cb = document.querySelector('.cf-turnstile')
        ?.getAttribute('data-callback');
      if (cb && typeof window[cb] === 'function') window[cb](t);
    }, token);
  }
}

main().catch(console.error);

步骤 2:处理被缓存的验证码脚本

Service Worker 可能缓存验证码脚本,导致拿到的 sitekey 是旧的。

给相关请求加不缓存的响应头:

// Intercept and QA tanılama Service Worker cache for CAPTCHA scripts
await page.route('**/recaptcha/**', (route) => {
  route.continue({ headers: { ...route.request().headers(), 'Cache-Control': 'no-cache' } });
});

await page.route('**/turnstile/**', (route) => {
  route.continue({ headers: { ...route.request().headers(), 'Cache-Control': 'no-cache' } });
});

步骤 3:跟踪客户端路由,捕获新页面验证码

PWA 是客户端路由,跳转到受保护页面不会触发整页刷新,也就没有传统 load 事件。

监听路由变化,等目标路由渲染完再检测:

// Monitor PWA route changes for new CAPTCHAs
await page.evaluate(() => {
  const originalPushState = history.pushState;
  history.pushState = function() {
    originalPushState.apply(this, arguments);
    window.dispatchEvent(new Event('pwa-route-change'));
  };
});

page.on('console', async (msg) => {
  // React to route changes if needed
});

// Or wait for specific route
await page.waitForURL('**/checkout', { waitUntil: 'networkidle' });
// Then detect CAPTCHA on the new route
const captcha = await detectCaptchaInPWA(page);

步骤 4:Python + Selenium 版本

思路一致:等元素出现,提取 sitekey,提交给 CaptchaAI,把 token 写回页面:

# pwa_captcha_selenium.py
import time
import requests
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

API_KEY = "YOUR_API_KEY"

driver = webdriver.Chrome()
driver.get("https://example-pwa.com/login")

# Wait for PWA to render CAPTCHA
wait = WebDriverWait(driver, 20)
captcha_el = wait.until(
    EC.presence_of_element_located((By.CSS_SELECTOR, ".g-recaptcha, .cf-turnstile"))
)

sitekey = captcha_el.get_attribute("data-sitekey")
pageurl = driver.current_url
is_turnstile = "cf-turnstile" in captcha_el.get_attribute("class")

# Submit to CaptchaAI
params = {"key": API_KEY, "pageurl": pageurl, "json": "1"}
if is_turnstile:
    params["method"] = "turnstile"
    params["sitekey"] = sitekey
else:
    params["method"] = "userrecaptcha"
    params["googlekey"] = sitekey

resp = requests.get("https://ocr.captchaai.com/in.php", params=params)
task_id = resp.json()["request"]

# Poll
for _ in range(30):
    time.sleep(5)
    poll = requests.get("https://ocr.captchaai.com/res.php", params={
        "key": API_KEY, "action": "get", "id": task_id, "json": "1",
    })
    if poll.json().get("status") == 1:
        token = poll.json()["request"]
        break
else:
    raise TimeoutError("CAPTCHA not solved")

# Inject token
driver.execute_script(f"""
    document.getElementById('g-recaptcha-response').value = '{token}';
""")

driver.find_element(By.CSS_SELECTOR, 'button[type="submit"]').click()
print("Form submitted")
driver.quit()

故障排除

  • 验证码元素一直不出现——PWA 还没渲染到对应路由,用 waitForSelector 放宽超时时间,确认路由已完成
  • 导航后 sitekey 是旧的——Service Worker 返回了缓存 HTML,加不缓存的响应头
  • 找不到 token 提交回调——框架管理状态方式不一样,检查 React/Vue/Angular 状态管理,手动触发表单更新
  • 表单提交没带上 token——表单读的是组件状态,不是 DOM,需同步更新框架状态(如 React ref)

Token 什么时候需要重新获取

  • 路由跳转、登录状态变化或 Service Worker 刷新页面上下文时,缓存的 token 就该作废。
  • 只保留把 token 接回当前视图所需的最少状态。
  • 提交前确认 token 是否新鲜,别假设后台早解决好的 token 还能用。

常见问题

PWA 里的验证码和普通网站是同一套吗?

是同一套 reCAPTCHA、Turnstile 组件,没有为 PWA 单独设计新类型。

区别只在时机:PWA 要等渲染完才插入 DOM。

验证码要等多久才会渲染出来?

没有固定答案,看框架挂载速度。

用 MutationObserver 配合 10–15 秒超时,比猜一个固定时间稳妥。

Service Worker 一直返回旧的 sitekey,是缓存问题吗?

大概率是。

给相关请求加不缓存的响应头,或自动化时临时禁用 Service Worker,就能拿到最新的 sitekey。

token 提交了,页面还是提示要验证码,怎么排查?

先确认 token 写进了正确的 DOM 节点(如 #g-recaptcha-response)。

再检查 SPA 是否从组件状态而非 DOM 读值——很多时候要同步更新框架状态,表单才会认。

相关文章

下一步

验证码检测逻辑理清楚之后,剩下的识别工作交给 CaptchaAI 就行——获取 CaptchaAI API Key,把上面的动态检测代码接入你的自动化脚本。

相关指南:

该文章已禁用评论。