自动化脚本在 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,把上面的动态检测代码接入你的自动化脚本。
相关指南: