验证码明明画在页面上,querySelector 却只返回 null,超时调到 30 秒也一样——选择器根本跨不过 shadow 边界。确认只要一步:DevTools 里选中验证码,看它上方有没有 #shadow-root。有就照本文走:递归找到宿主元素取出 sitekey,交给 CaptchaAI 识别 Turnstile,再把 token 写回组件内部的隐藏字段。
第一步:确认验证码是不是在 shadow root 里
在浏览器控制台扫一遍带 shadowRoot 的宿主元素,看验证码挂在哪个自定义标签下:
// In browser DevTools console
// Regular query returns null even though CAPTCHA is visible
document.querySelector('.cf-turnstile'); // null
// Check for shadow hosts
document.querySelectorAll('*').forEach(el => {
if (el.shadowRoot) {
const captcha = el.shadowRoot.querySelector('.cf-turnstile, .g-recaptcha');
if (captcha) {
console.log('Found CAPTCHA in shadow root of:', el.tagName, el.id || el.className);
console.log('Sitekey:', captcha.dataset.sitekey);
}
}
});
能打出宿主标签名和 sitekey 就确认是 shadow DOM;什么都没有则更可能是 iframe 边界或异步挂载。
shadow root 的层级与 open / closed 模式
穿透写法取决于嵌套多深、宿主用哪种模式:
| 层级 | 结构示例 | 处理方式 |
|---|---|---|
| 单层 | <custom-form> #shadow-root > .cf-turnstile |
直接查这一个 shadow root |
| 两层以上 | <app-shell> #shadow > <login-form> #shadow > .cf-turnstile |
递归遍历 |
| open 模式 | el.shadowRoot 可读 |
标准做法,递归即可 |
| closed 模式 | el.shadowRoot 返回 null |
读不到;加载前覆写 attachShadow({mode:'open'}) |
整体流程:定位、识别、回填
- 定位——递归遍历 shadow root,取出
data-sitekey。 - 识别——sitekey 加页面地址交给 CaptchaAI,轮询
res.php拿 token。 - 回填——token 写回隐藏字段,触发回调后提交表单。
第二步:用 Playwright 取 sitekey 并回填 token
>> 穿透选择器原生跨 shadow 边界,先用它,命不中再退回 page.evaluate 里的递归查找:
import requests
import time
from playwright.sync_api import sync_playwright
API_KEY = "YOUR_API_KEY"
SUBMIT_URL = "https://ocr.captchaai.com/in.php"
RESULT_URL = "https://ocr.captchaai.com/res.php"
def solve_turnstile(sitekey, pageurl):
"""Submit and poll a Turnstile CAPTCHA."""
resp = requests.post(SUBMIT_URL, data={
"key": API_KEY,
"method": "turnstile",
"sitekey": sitekey,
"pageurl": pageurl,
"json": 1,
}, timeout=30).json()
if resp.get("status") != 1:
raise RuntimeError(f"Submit failed: {resp.get('request')}")
task_id = resp["request"]
for _ in range(60):
time.sleep(5)
poll = requests.get(RESULT_URL, params={
"key": API_KEY, "action": "get",
"id": task_id, "json": 1,
}, timeout=15).json()
if poll.get("request") == "CAPCHA_NOT_READY":
continue
if poll.get("status") == 1:
return poll["request"]
raise RuntimeError(f"Solve failed: {poll.get('request')}")
raise RuntimeError("Timeout")
def extract_from_shadow_dom(page):
"""Extract CAPTCHA sitekey from shadow DOM elements."""
# Method 1: Playwright's piercing selector (>>)
# This automatically crosses shadow boundaries
turnstile = page.locator("css=.cf-turnstile >> visible=true").first
if turnstile.count() > 0:
sitekey = turnstile.get_attribute("data-sitekey")
if sitekey:
return sitekey
# Method 2: JavaScript evaluation to pierce all shadow roots
sitekey = page.evaluate("""
() => {
function findInShadowRoots(root) {
// Check direct children
const turnstile = root.querySelector('.cf-turnstile');
if (turnstile && turnstile.dataset.sitekey) {
return turnstile.dataset.sitekey;
}
const recaptcha = root.querySelector('.g-recaptcha');
if (recaptcha && recaptcha.dataset.sitekey) {
return recaptcha.dataset.sitekey;
}
// Recurse into nested shadow roots
for (const el of root.querySelectorAll('*')) {
if (el.shadowRoot) {
const found = findInShadowRoots(el.shadowRoot);
if (found) return found;
}
}
return null;
}
return findInShadowRoots(document);
}
""")
return sitekey
def inject_token_shadow_dom(page, token, captcha_type="turnstile"):
"""Inject solved token into shadow DOM CAPTCHA element."""
if captcha_type == "turnstile":
page.evaluate(f"""
(token) => {{
function findAndInject(root) {{
// Find the response input inside Turnstile
const input = root.querySelector('[name="cf-turnstile-response"]');
if (input) {{
input.value = token;
return true;
}}
// Recurse into shadow roots
for (const el of root.querySelectorAll('*')) {{
if (el.shadowRoot && findAndInject(el.shadowRoot)) {{
return true;
}}
}}
return false;
}}
findAndInject(document);
// Also try callback if defined
if (typeof window.turnstileCallback === 'function') {{
window.turnstileCallback(token);
}}
}}
""", token)
elif captcha_type == "recaptcha":
page.evaluate(f"""
(token) => {{
function findAndInject(root) {{
const textarea = root.querySelector('#g-recaptcha-response');
if (textarea) {{
textarea.value = token;
textarea.style.display = 'block';
return true;
}}
for (const el of root.querySelectorAll('*')) {{
if (el.shadowRoot && findAndInject(el.shadowRoot)) {{
return true;
}}
}}
return false;
}}
findAndInject(document);
if (typeof ___grecaptcha_cfg !== 'undefined') {{
Object.entries(___grecaptcha_cfg.clients).forEach(([_, client]) => {{
Object.entries(client).forEach(([_, val]) => {{
if (val && typeof val === 'object') {{
Object.entries(val).forEach(([_, v]) => {{
if (v && v.callback) v.callback(token);
}});
}}
}});
}});
}}
}}
""", token)
def main():
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto("https://staging.example.com/qa-login")
page.wait_for_load_state("networkidle")
# Extract sitekey from shadow DOM
sitekey = extract_from_shadow_dom(page)
if not sitekey:
print("No CAPTCHA found in shadow DOM or regular DOM")
browser.close()
return
print(f"Found sitekey: {sitekey}")
# Solve via CaptchaAI
token = solve_turnstile(sitekey, page.url)
print(f"Solved: {token[:40]}...")
# Inject token back into shadow DOM
inject_token_shadow_dom(page, token, "turnstile")
print("Token injected into shadow DOM")
# Submit the form
page.click("button[type='submit']")
page.wait_for_load_state("networkidle")
browser.close()
main()
两个坑:pageurl 用 page.url() 实时取,写死会让 token 被判无效;值写进 cf-turnstile-response 后还要触发回调,组件往往在回调里才标记为已验证。
第三步(Node.js 栈):Puppeteer 手写递归
Puppeteer 没有穿透选择器,递归得自己写,逻辑与 Python 版一致:
const puppeteer = require("puppeteer");
const API_KEY = "YOUR_API_KEY";
const SUBMIT_URL = "https://ocr.captchaai.com/in.php";
const RESULT_URL = "https://ocr.captchaai.com/res.php";
async function solveTurnstile(sitekey, pageurl) {
const params = new URLSearchParams({
key: API_KEY, method: "turnstile", sitekey, pageurl, json: "1",
});
const resp = await (await fetch(SUBMIT_URL, { method: "POST", body: params })).json();
if (resp.status !== 1) throw new Error(`Submit: ${resp.request}`);
const taskId = resp.request;
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 5000));
const url = `${RESULT_URL}?key=${API_KEY}&action=get&id=${taskId}&json=1`;
const poll = await (await fetch(url)).json();
if (poll.request === "CAPCHA_NOT_READY") continue;
if (poll.status === 1) return poll.request;
throw new Error(`Solve: ${poll.request}`);
}
throw new Error("Timeout");
}
async function extractSitekeyFromShadowDOM(page) {
return page.evaluate(() => {
function searchShadowRoots(root) {
const selectors = [".cf-turnstile", ".g-recaptcha", ".h-captcha"];
for (const sel of selectors) {
const el = root.querySelector(sel);
if (el && el.dataset.sitekey) return el.dataset.sitekey;
}
for (const el of root.querySelectorAll("*")) {
if (el.shadowRoot) {
const found = searchShadowRoots(el.shadowRoot);
if (found) return found;
}
}
return null;
}
return searchShadowRoots(document);
});
}
async function injectTokenShadowDOM(page, token) {
await page.evaluate((t) => {
function inject(root) {
const input = root.querySelector('[name="cf-turnstile-response"]');
if (input) { input.value = t; return true; }
const textarea = root.querySelector("#g-recaptcha-response");
if (textarea) { textarea.value = t; return true; }
for (const el of root.querySelectorAll("*")) {
if (el.shadowRoot && inject(el.shadowRoot)) return true;
}
return false;
}
inject(document);
}, token);
}
(async () => {
const browser = await puppeteer.launch({ headless: false });
const page = await browser.newPage();
await page.goto("https://staging.example.com/qa-login", { waitUntil: "networkidle2" });
const sitekey = await extractSitekeyFromShadowDOM(page);
if (!sitekey) {
console.log("No CAPTCHA found in shadow DOM");
await browser.close();
return;
}
console.log(`Sitekey: ${sitekey}`);
const token = await solveTurnstile(sitekey, page.url());
console.log(`Solved: ${token.substring(0, 40)}...`);
await injectTokenShadowDOM(page, token);
await page.click('button[type="submit"]');
await page.waitForNavigation();
await browser.close();
})();
什么样的页面会把验证码封进 Web 组件
| 场景 | 为什么用 shadow DOM |
|---|---|
| 自研登录组件 | 封装成可复用组件,样式隔离 |
| 第三方表单挂件 | 供应商把整个表单包进 shadow root |
| 微前端架构 | 子应用各自挂在独立 shadow root 内 |
| 设计系统组件 | 验证码写在组件库元素内部 |
微前端与国内前端栈的实际情况
qiankun、micro-app、wujie 都拿 shadow root 做样式隔离,登录表单落进子应用后主文档就查不到验证码——国内项目里最常见的来源。国内站点多为 GeeTest(极验)滑块,CaptchaAI 支持 v3,v4 只是即将支持。
排查顺序:先 open root,再 iframe,最后回调
- 先扫 open 状态的 shadow root,再看 iframe 边界,最后才怀疑回调没触发。
- 把验证码出现在哪一层记进日志,改版后照着对一遍。
常见报错与处理
| 现象 | 原因 | 处理方式 |
|---|---|---|
shadowRoot 返回 null |
closed 模式 | 加载前覆写 attachShadow 强制 mode: 'open' |
| sitekey 正常但 token 被拒 | pageurl 不对 | 用提取那一刻的 page.url() |
| 首屏找不到验证码 | widget 异步挂载 | 先等宿主:page.waitForSelector('custom-form') |
常见问题
Playwright 和 Puppeteer 哪个更适合处理 shadow DOM?
Playwright。>> 穿透选择器默认跨 shadow 边界,open 模式下一行命中;Puppeteer 得自己写递归。对 closed 模式两者限制相同。
全量递归整棵 DOM 太慢,能只查固定组件吗?
可以。宿主标签名通常稳定,先按标签名拿到宿主再往它的 shadowRoot 里找,全量递归留作兜底。
代码里的 .h-captcha 选择器,是不是说明支持 hCaptcha?
不是,它只用来判断页面挂的是哪种 widget。CaptchaAI 覆盖 reCAPTCHA v2/v3、Cloudflare Turnstile 与 Challenge、GeeTest v3、图片/OCR、九宫格和 BLS,另有 CaptchaFox(测试版)、Friendly Captcha(测试版)、Lemin(测试版);hCaptcha 与 FunCaptcha 不支持。
反复重跑脚本调试,会不会很费钱?
不会。CaptchaAI 按并发线程计价,不按识别次数:BASIC 套餐 $15/月、5 线程,识别次数不限。
相关文章
下一步
只差一个 API Key——注册 CaptchaAI,把示例里的地址换成你的 staging 页面。
相关指南: