Giải Cloudflare Turnstile trong Node.js gọn lại thành bốn thao tác: đọc HTML trang để lấy sitekey (chuỗi bắt đầu bằng 0x), gửi task method=turnstile tới in.php, polling res.php cho tới khi có kết quả, rồi gắn token vào field cf-turnstile-response khi submit form. Không cần trình duyệt, không cần Puppeteer — fetch có sẵn trong Node.js 18 là đủ.
Bài viết dành cho dev có script scraping hoặc bộ test tự động vừa bị Turnstile chặn giữa chừng. Toàn bộ ví dụ dùng fetch gốc, không thư viện ngoài, và kết thúc bằng một class solver copy thẳng vào project được.
Cần chuẩn bị những gì
- Node.js 18 trở lên — từ bản này
fetchlà API gốc, không cầnnode-fetch. - API key CaptchaAI và số dư còn hiệu lực.
- Một trang test bạn có quyền chạy tự động, tức môi trường staging của chính bạn.
CaptchaAI tính tiền theo thread (số CAPTCHA giải song song), không theo từng lần giải, và mỗi thread giải không giới hạn trong tháng. Gói nhỏ nhất là BASIC ($15/tháng, 5 thread). Giá niêm yết bằng USD.
Bước 1: trích xuất sitekey Turnstile từ trang
Sitekey Turnstile luôn bắt đầu bằng 0x. Nếu chuỗi bạn tìm được bắt đầu bằng 6Le, đó là reCAPTCHA và bạn đang đi nhầm hướng dẫn. Hàm dưới thử bốn cách tìm theo thứ tự ưu tiên, vì mỗi site nhúng widget một kiểu:
async function extractTurnstileSitekey(url) {
const resp = await fetch(url, {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
},
});
const html = await resp.text();
// Method 1: data-sitekey attribute on Turnstile div
const divMatch = html.match(
/class=["'][^"]*cf-turnstile[^"]*["'][^>]*data-sitekey=["']([0-9x][A-Za-z0-9_-]+)["']/
);
if (divMatch) return divMatch[1];
// Method 2: data-sitekey on any element (Turnstile keys start with 0x)
const attrMatch = html.match(
/data-sitekey=["'](0x[A-Za-z0-9_-]+)["']/
);
if (attrMatch) return attrMatch[1];
// Method 3: In JavaScript turnstile.render call
const jsMatch = html.match(
/turnstile\.render\s*\([^,]+,\s*\{[^}]*sitekey\s*:\s*["']([0-9x][A-Za-z0-9_-]+)["']/
);
if (jsMatch) return jsMatch[1];
// Method 4: Generic sitekey in inline script
const inlineMatch = html.match(
/sitekey\s*:\s*["'](0x[A-Za-z0-9_-]+)["']/
);
if (inlineMatch) return inlineMatch[1];
return null;
}
Nếu cả bốn cách đều trả về null, widget đang được render bằng JavaScript: HTML tĩnh không chứa sitekey, phải mở trang bằng Playwright hoặc Puppeteer để đọc DOM.
Bước 2: gửi task và polling kết quả từ CaptchaAI
Hàm dưới làm hai việc trong một lượt: POST task lên in.php với method=turnstile, rồi hỏi res.php mỗi 5 giây cho tới khi có token. Turnstile thường trả kết quả trong dưới 10 giây, nên vòng lặp 30 lần là biên an toàn rộng rãi:
const API_KEY = "YOUR_API_KEY";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function solveTurnstile(sitekey, pageurl, action = null) {
// Submit task
const submitData = {
key: API_KEY,
method: "turnstile",
sitekey: sitekey,
pageurl: pageurl,
json: "1",
};
if (action) {
submitData.action = action;
}
const submitResp = await fetch("https://ocr.captchaai.com/in.php", {
method: "POST",
body: new URLSearchParams(submitData),
});
const submitResult = await submitResp.json();
if (submitResult.status !== 1) {
throw new Error(`Submit error: ${submitResult.request}`);
}
const taskId = submitResult.request;
console.log(`Task ID: ${taskId}`);
// Poll for result
for (let i = 0; i < 30; i++) {
await sleep(5000);
const pollResp = await fetch(
`https://ocr.captchaai.com/res.php?${new URLSearchParams({
key: API_KEY,
action: "get",
id: taskId,
json: "1",
})}`
);
const pollResult = await pollResp.json();
if (pollResult.status === 1) {
return pollResult.request;
}
if (pollResult.request === "ERROR_CAPTCHA_UNSOLVABLE") {
throw new Error("Turnstile unsolvable");
}
}
throw new Error("Solve timed out");
}
ERROR_CAPTCHA_UNSOLVABLE nghĩa là task đã bị bỏ, phải gửi lại từ đầu; còn timeout sau 30 vòng thường là dấu hiệu sitekey hoặc pageurl sai.
Bước 3: gắn token vào form và submit
async function submitTurnstileForm(url, formData, token) {
const body = new URLSearchParams({
...formData,
"cf-turnstile-response": token,
});
const resp = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
},
body,
});
return {
status: resp.status,
body: await resp.text(),
};
}
Tên field bắt buộc là cf-turnstile-response — đúng tên này, không phải tên field của loại CAPTCHA khác. Sai một ký tự là form bị từ chối mà không có thông báo rõ ràng.
Ghép ba bước thành một luồng đăng nhập
async function loginWithTurnstile(loginUrl, credentials) {
// Step 1: Extract sitekey
const sitekey = await extractTurnstileSitekey(loginUrl);
if (!sitekey) {
throw new Error("Turnstile sitekey not found");
}
console.log(`Sitekey: ${sitekey}`);
// Step 2: Solve Turnstile
const token = await solveTurnstile(sitekey, loginUrl);
console.log(`Token: ${token.substring(0, 50)}...`);
// Step 3: Submit form
const result = await submitTurnstileForm(loginUrl, credentials, token);
console.log(`Result: ${result.status}`);
return result;
}
// Usage
const result = await loginWithTurnstile("https://staging.example.com/qa-login", {
email: "[email protected]",
password: "pass123",
});
Token Turnstile có thời hạn sống ngắn. Giải xong thì submit ngay trong cùng một lượt chạy — đừng lưu token lại để dùng cho request sau, nó sẽ hết hạn trước khi bạn kịp dùng.
Class solver dùng được cho môi trường production
Khi đưa vào job chạy định kỳ, bạn sẽ muốn một class đóng gói sẵn API key, phần detect và phần polling:
class TurnstileSolver {
#apiKey;
constructor(apiKey) {
this.#apiKey = apiKey;
}
async solve(sitekey, pageurl, options = {}) {
const taskId = await this.#submit(sitekey, pageurl, options);
return await this.#poll(taskId);
}
async detectAndSolve(url) {
const sitekey = await this.#detect(url);
if (!sitekey) throw new Error("No Turnstile found");
return await this.solve(sitekey, url);
}
async #detect(url) {
const resp = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0" },
});
const html = await resp.text();
const match = html.match(/data-sitekey=["'](0x[A-Za-z0-9_-]+)["']/);
return match ? match[1] : null;
}
async #submit(sitekey, pageurl, options) {
const body = new URLSearchParams({
key: this.#apiKey,
method: "turnstile",
sitekey,
pageurl,
json: "1",
...(options.action && { action: options.action }),
...(options.cdata && { data: options.cdata }),
});
const resp = await fetch("https://ocr.captchaai.com/in.php", {
method: "POST",
body,
});
const data = await resp.json();
if (data.status !== 1) throw new Error(`Submit: ${data.request}`);
return data.request;
}
async #poll(taskId) {
const params = new URLSearchParams({
key: this.#apiKey,
action: "get",
id: taskId,
json: "1",
});
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 5000));
const resp = await fetch(`https://ocr.captchaai.com/res.php?${params}`);
const data = await resp.json();
if (data.status === 1) return data.request;
if (data.request === "ERROR_CAPTCHA_UNSOLVABLE") {
throw new Error("Unsolvable");
}
}
throw new Error("Timed out");
}
}
// Usage
const solver = new TurnstileSolver("YOUR_API_KEY");
const token = await solver.detectAndSolve("https://staging.example.com/qa-login");
Khi widget có tham số action và cData
Một số site cấu hình Turnstile kèm action và cData. Nếu trang có hai tham số này mà bạn không gửi kèm, token vẫn đúng cú pháp nhưng server sẽ từ chối:
// Extract action from the page
function extractTurnstileAction(html) {
const match = html.match(
/data-action=["']([^"']+)["']|action\s*:\s*["']([^"']+)["']/
);
return match ? match[1] || match[2] : null;
}
// Solve with action
const token = await solver.solve(sitekey, pageurl, {
action: "login",
cdata: "session_abc123",
});
Xác minh token ở phía server của bạn
Chiều ngược lại: nếu bạn là bên nhúng Turnstile, server phải gọi siteverify của Cloudflare để kiểm tra token trước khi chấp nhận form:
async function verifyTurnstileToken(token, ip) {
const resp = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
secret: "YOUR_TURNSTILE_SECRET_KEY",
response: token,
remoteip: ip,
}),
}
);
const data = await resp.json();
return data.success;
}
Bảng lỗi thường gặp và cách xử lý
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
Sitekey bắt đầu bằng 6Le |
Trang dùng reCAPTCHA chứ không phải Turnstile | Chuyển sang method=userrecaptcha |
| Token bị server từ chối | Sitekey sai, hoặc token đã hết hạn trước khi submit | Trích xuất lại sitekey và submit ngay sau khi giải |
| Không tìm thấy sitekey trong HTML | Widget được render bằng JavaScript | Đọc DOM đã render bằng Puppeteer/Playwright |
ERROR_BAD_PARAMETERS |
Thiếu sitekey hoặc pageurl trong request |
Kiểm tra cả hai tham số trước khi POST |
| Nhận 403 ngay sau khi submit form | Header request không giống trình duyệt thật | Gửi kèm User-Agent và Content-Type đầy đủ |
ERROR_ZERO_BALANCE |
Hết số dư hoặc gói đã hết hạn | Kiểm tra số dư trên dashboard trước khi chạy job dài |
Ước lượng thread cho một job thực tế
Tình huống quen thuộc với team dữ liệu ở TP.HCM hay Hà Nội: bạn theo dõi giá sản phẩm trong danh mục của chính mình trên các sàn thương mại điện tử, và một nguồn bật Turnstile ở form tìm kiếm. Job chạy đêm cần 4.000 lượt giải trong 6 tiếng.
Vì Turnstile thường xong trong dưới 10 giây, một thread xử lý được khoảng 6 lượt/phút. Tính thêm biên cho retry, 5–15 thread là đủ: BASIC ($15/tháng, 5 thread) hoặc STANDARD ($30/tháng, 15 thread). Do tính theo thread, chạy thêm lượt giải trong cùng cửa sổ không làm hóa đơn tăng.
Về tuân thủ, Nghị định 13/2023/NĐ-CP là lý do chính đáng để chỉ lưu trường dữ liệu thật sự cần và ghi log job — hãy đưa taskId từ in.php vào log ngay từ đầu.
Câu hỏi thường gặp
Làm sao biết trang đang dùng Turnstile hay Cloudflare Challenge?
Nhìn vào sitekey. Turnstile là widget nhúng trong form, có data-sitekey bắt đầu bằng 0x ngay trong HTML. Cloudflare Challenge là trang chặn ở tầng CDN, không có form nào để gắn token và cần luồng xử lý khác.
Token Turnstile sống được bao lâu?
Đủ ngắn để bạn nên submit ngay trong cùng lượt chạy. Thiết kế code theo hướng giải xong là dùng luôn; cache token cho lần chạy sau gần như luôn dẫn tới lỗi từ chối khó debug.
Chạy Node.js 16 có được không?
Không, trừ khi bạn tự cài node-fetch. Toàn bộ ví dụ trong bài dựa vào fetch gốc, chỉ có từ Node.js 18 trở lên.
CaptchaAI có giải hCaptcha không?
Không. CaptchaAI hiện hỗ trợ reCAPTCHA v2/v3 (kể cả Enterprise), Cloudflare Turnstile, Cloudflare Challenge, GeeTest v3, image/OCR, grid-image và BLS CAPTCHA. hCaptcha và FunCaptcha không nằm trong danh sách hỗ trợ; CaptchaFox, Friendly Captcha và Lemin đang ở giai đoạn beta.
Giải Turnstile mất bao lâu và tỷ lệ thành công ra sao?
Turnstile thường được giải trong dưới 10 giây, với tỷ lệ giải thành công cao trên các loại được hỗ trợ. Con số này là mức trần theo SLA, không phải trung bình đo được, nên hãy tính biên dự phòng trong timeout của bạn.
Tóm tắt
Ba thứ quyết định việc tích hợp chạy đúng: sitekey phải bắt đầu bằng 0x, task phải gửi với method=turnstile lên in.php, và token phải được đặt vào field cf-turnstile-response khi submit. Class solver ở trên gói cả ba lại; việc còn lại chỉ là chọn số thread phù hợp với khối lượng của bạn. Xem thêm chi tiết về gói và API key tại CaptchaAI.