Script lấy sitekey trả về null nhưng widget Turnstile vẫn hiện rõ trên màn hình? Gần như chắc chắn CAPTCHA nằm trong shadow root của một web component, và selector của bạn dừng đúng ở ranh giới đó.
Bài viết dành cho dev đang bảo trì suite Playwright hoặc Puppeteer chạy thật. Ví dụ dùng Cloudflare Turnstile, nhưng cách duyệt cây áp dụng y nguyên cho reCAPTCHA v2 và v3.
Ba bước xử lý
- Xác nhận widget nằm trong shadow root, không phải trong iframe.
- Duyệt đệ quy để lấy
data-sitekey, gửi task tớiin.phpvớimethod=turnstile. - Polling
res.php, chèn token vào inputcf-turnstile-responserồi kích hoạt callback.
Vì sao CAPTCHA rơi vào shadow root
Shadow root xuất hiện vì lý do kiến trúc, không phải để chặn automation.
| Bối cảnh | Nguyên nhân |
|---|---|
| Component đăng nhập nội bộ | Form đóng gói thành web component dùng chung |
| Widget của bên thứ ba | Nhà cung cấp bọc cả form để cô lập CSS |
| Micro-frontend | Mỗi micro-app dùng shadow root riêng |
| Design system | CAPTCHA nằm trong phần tử của thư viện component |
Bước 1: xác nhận widget nằm trong shadow root
Chạy đoạn dưới trong console DevTools: nó liệt kê mọi phần tử có shadowRoot và in sitekey nếu tìm thấy widget.
// 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);
}
}
});
Có tên thẻ host kèm sitekey là đủ để sang bước tự động hóa. Vòng lặp không in gì thì shadow root nhiều khả năng đang đóng.
Bước 2: xuyên shadow DOM bằng Playwright
Locator engine của Playwright tự vượt ranh giới shadow root, nên css=.cf-turnstile là đủ. Đoạn dưới gộp cả trích xuất, giải và chèn token.
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()
extract_from_shadow_domthử selector xuyên tầng trước, thất bại mới rơi về đệ quy trongpage.evaluate.solve_turnstilegửi task tớiin.phprồi pollingres.phpmỗi 5 giây.inject_token_shadow_domtìm inputcf-turnstile-responsevà gọi callback nếu có.
Bước 3: bản Puppeteer cho team Node.js
Puppeteer không có selector xuyên tầng, nên phần duyệt phải viết tay trong page.evaluate.
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();
})();
Thứ tự các bước giống hệt bản Python, nên suite đang chạy Puppeteer không cần đổi sang Playwright.
Độ sâu shadow root
| Tình huống | Ví dụ | Cách duyệt |
|---|---|---|
| Một cấp | <custom-form> #shadow-root > .cf-turnstile |
Truy vấn thẳng shadowRoot của host |
| Hai cấp trở lên | <app-shell> #shadow > <login-form> #shadow > .cf-turnstile |
Đệ quy qua từng shadow root con |
| Shadow root đóng | el.shadowRoot trả về null |
Ghi đè attachShadow({mode:'open'}) trước khi trang render |
Thứ tự chẩn đoán khi không thấy widget
- Quét các shadow root mở trước — trường hợp chiếm đa số.
- Chưa thấy thì kiểm tra ranh giới iframe, rồi mới tới callback ở tầng ứng dụng.
- Ghi log lớp nào đã lộ ra widget và luôn giữ nhánh dự phòng cho shadow root đóng.
Tình huống thực tế tại một team outsourcing
Một team ở TP.HCM bảo trì test hồi quy cho cổng đăng nhập của khách. Sau khi khách nâng cấp design system, form bị bọc thành <auth-form> và mấy chục test Playwright đỏ cùng lúc với đúng một lỗi: sitekey trả về None.
- Gom hàm trích xuất vào một module dùng chung để lần sau chỉ sửa một chỗ.
- Mẫu lỗi này cũng hay gặp khi theo dõi giá công khai trên Shopee, Lazada hay Tiki.
- Ghi log sitekey, pageurl và thời điểm giải — Nghị định 13/2023/NĐ-CP là lý do nên làm sớm.
Bao nhiêu thread là đủ
Shadow DOM không đổi cách tính tiền. CaptchaAI tính theo thread (luồng giải đồng thời), không tính theo lượt giải, và mỗi gói không giới hạn số lần giải trong tháng. Suite hồi quy chạy tuần tự thường chỉ cần BASIC ($15/tháng, 5 thread); chạy song song nhiều worker thì nâng lên STANDARD ($30/tháng, 15 thread) hoặc ADVANCE ($90/tháng, 50 thread). Giá niêm yết bằng USD.
Lỗi thường gặp
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
shadowRoot trả về null |
Shadow root đóng | Ghi đè attachShadow trước khi trang tải, ép mode: 'open' |
| Có sitekey nhưng token bị từ chối | pageurl không khớp | Dùng page.url() lúc trích xuất |
| Chèn token xong form không gửi | Callback chưa chạy | Gọi callback sau khi gán token |
| Đệ quy chạy chậm | Cây DOM quá sâu | Giới hạn độ sâu; nhắm thẳng tên thẻ host |
| Lần tải đầu không thấy CAPTCHA | Widget nạp bất đồng bộ | Chờ page.waitForSelector('custom-form') |
Câu hỏi thường gặp
Widget trong shadow root có làm token kém giá trị hơn không?
Không. CaptchaAI giải dựa trên sitekey và pageurl, không quan tâm widget nằm ở tầng DOM nào. Shadow DOM chỉ ảnh hưởng lúc đọc sitekey và lúc đặt token trở lại.
Nên chọn Playwright hay Puppeteer cho shadow DOM?
Playwright đỡ việc hơn vì locator engine tự vượt ranh giới shadow root. Puppeteer vẫn làm được nhưng phải viết hàm duyệt tay; cả hai gửi cùng một request tới in.php.
Chạy headless có tìm được widget trong shadow root không?
Có. Shadow root do JavaScript của trang dựng nên, headless hay có giao diện đều như nhau. Headless không thấy thường là vấn đề thời điểm: chờ phần tử host rồi hãy duyệt.
Có cách nào bỏ hẳn bước duyệt shadow DOM?
Có, nếu lấy được sitekey từ nguồn khác: mã nguồn trang, response API hoặc biến cấu hình của team bạn. Khi đó chỉ cần gọi API với sitekey và pageurl.
Bài viết liên quan
- So sánh GeeTest và Cloudflare Turnstile
- Khắc phục lỗi 403 sau khi gửi token Turnstile
- Các chế độ hiển thị của widget Cloudflare Turnstile
Bước tiếp theo
Chép hàm trích xuất vào helper của suite, lấy API key CaptchaAI rồi chạy lại các test đang đỏ vì sitekey rỗng.