Khi một job board chặn truy vấn thứ ba mươi của bạn bằng Cloudflare Turnstile, bạn không cần đổi kiến trúc crawler — chỉ cần thêm một nhánh: phát hiện thử thách, bóc sitekey, gửi task tới CaptchaAI, nhận token rồi gửi lại form. Cả vòng đó thường xong trong dưới 10 giây và nằm gọn trong hàm request sẵn có.
Bài này viết cho đội dữ liệu đang dựng bảng khảo sát lương — chẳng hạn một agency ở TP.HCM chuẩn hóa dải lương cho 40 chức danh IT trước mùa tuyển. Rào cản luôn giống nhau: dữ liệu công khai, nhưng cổng bật CAPTCHA khi bạn tra cứu có hệ thống.
Cổng lương bật CAPTCHA ở đâu và vì sao
Mỗi nhóm nguồn có một trigger khác nhau, và điều đó quyết định bạn gọi method=turnstile hay method=userrecaptcha.
| Loại nguồn | CAPTCHA | Trình kích hoạt |
|---|---|---|
| Các trang so sánh lương | Cloudflare Turnstile | Truy vấn tìm kiếm lặp lại |
| Bộ lọc lương trên job board | reCAPTCHA v2 | Tra cứu lương nhiều lần |
| Thống kê lao động của cơ quan nhà nước | CAPTCHA hình ảnh | Yêu cầu tải xuống dữ liệu |
| Trang lương của doanh nghiệp | Cloudflare Challenge | Lượt xem trang hàng loạt |
| Nền tảng khảo sát nhân sự | reCAPTCHA v3 | Gửi biểu mẫu |
Cả năm loại trong bảng đều được CaptchaAI hỗ trợ, nên bạn dùng chung một API key cho toàn bộ pipeline. Điều thay đổi giữa các nguồn chỉ là tham số method và trường token phải gắn vào form khi gửi lại.
Collector cho một nguồn (Python)
Class dưới đây gọi trang tìm kiếm, kiểm tra phản hồi có phải thử thách Turnstile không, giải nếu có, rồi parse bảng lương thành SalaryRecord. Phần đáng chú ý là _solve_turnstile_and_retry: bóc data-sitekey, gửi task tới in.php, polling res.php mỗi 3 giây, gắn cf-turnstile-response vào request gửi lại.
import requests
import time
import re
from dataclasses import dataclass
@dataclass
class SalaryRecord:
title: str
location: str
min_salary: float
max_salary: float
median_salary: float
sample_size: int
source: str
class SalaryCollector:
def __init__(self, api_key):
self.api_key = api_key
self.session = requests.Session()
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
def collect_salary_data(self, portal_url, job_title, location):
"""Search for salary data, solving CAPTCHAs as needed."""
response = self.session.get(portal_url, params={
"title": job_title,
"location": location
})
if self._is_turnstile_challenge(response):
response = self._solve_turnstile_and_retry(response, portal_url)
return self._parse_salary_data(response.text, portal_url)
def collect_bulk(self, portal_url, job_titles, locations):
"""Collect salary data for multiple job title + location combos."""
results = []
for title in job_titles:
for location in locations:
try:
data = self.collect_salary_data(
portal_url, title, location
)
results.extend(data)
# Respectful delay between requests
time.sleep(2)
except Exception as e:
print(f"Failed for {title} in {location}: {e}")
return results
def _is_turnstile_challenge(self, response):
return (
response.status_code == 403 or
"cf-turnstile" in response.text or
"challenges.cloudflare.com" in response.text
)
def _solve_turnstile_and_retry(self, response, url):
match = re.search(r'data-sitekey="(0x[^"]+)"', response.text)
if not match:
raise ValueError("Turnstile sitekey not found")
resp = requests.post("https://ocr.captchaai.com/in.php", data={
"key": self.api_key,
"method": "turnstile",
"sitekey": match.group(1),
"pageurl": url,
"json": 1
})
task_id = resp.json()["request"]
for _ in range(60):
time.sleep(3)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": self.api_key,
"action": "get",
"id": task_id,
"json": 1
})
data = result.json()
if data["status"] == 1:
return self.session.post(url, data={
"cf-turnstile-response": data["request"]
})
raise TimeoutError("Turnstile solve timed out")
def _parse_salary_data(self, html, source):
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
records = []
def text_or_empty(node):
return node.text.strip() if node and node.text else ""
for row in soup.select(".salary-row, .compensation-entry, tr[data-salary]"):
try:
records.append(SalaryRecord(
title=text_or_empty(row.select_one(".job-title, .title")),
location=text_or_empty(row.select_one(".location")),
min_salary=self._parse_amount(
text_or_empty(row.select_one(".min-salary, .low"))
),
max_salary=self._parse_amount(
text_or_empty(row.select_one(".max-salary, .high"))
),
median_salary=self._parse_amount(
text_or_empty(row.select_one(".median, .mid"))
),
sample_size=int(
text_or_empty(row.select_one(".count, .sample")).replace(",", "") or 0
),
source=source
))
except (AttributeError, ValueError):
continue
return records
def _parse_amount(self, text):
if not text:
return 0.0
cleaned = re.sub(r'[^\d.]', '', text)
return float(cleaned) if cleaned else 0.0
# Usage
collector = SalaryCollector("YOUR_API_KEY")
data = collector.collect_bulk(
"https://salary.example.com/search",
job_titles=["Software Engineer", "Data Analyst", "Product Manager"],
locations=["San Francisco", "New York", "Austin"]
)
for record in data:
print(f"{record.title} in {record.location}: "
f"${record.min_salary:,.0f}–${record.max_salary:,.0f} "
f"(median: ${record.median_salary:,.0f})")
Kết quả là danh sách bản ghi đã chuẩn hóa dải min/max/median kèm nguồn, đủ để đổ vào bảng so sánh nội bộ.
Gộp nhiều nguồn để giảm sai lệch (JavaScript)
Một cổng lương duy nhất hầu như luôn lệch: mẫu nhỏ, thiên về một nhóm công ty, hoặc cập nhật chậm. Đáng tin hơn là truy vấn ba đến năm nguồn cho cùng một chức danh rồi lấy median của các median. Class dưới đây xử lý Turnstile riêng cho từng nguồn và trả về số nguồn thành công — nếu chỉ một nguồn trả dữ liệu, bạn biết ngay là chưa nên tin con số đó.
class SalaryAggregator {
constructor(apiKey) {
this.apiKey = apiKey;
this.sources = [];
}
addSource(name, searchUrl) {
this.sources.push({ name, searchUrl });
}
async collectForRole(jobTitle, location) {
const results = [];
for (const source of this.sources) {
try {
const data = await this.querySource(source, jobTitle, location);
results.push({ source: source.name, ...data });
} catch (error) {
results.push({ source: source.name, error: error.message });
}
}
return this.aggregateResults(results, jobTitle, location);
}
async querySource(source, jobTitle, location) {
const url = `${source.searchUrl}?title=${encodeURIComponent(jobTitle)}&location=${encodeURIComponent(location)}`;
const response = await fetch(url);
const html = await response.text();
if (html.includes('cf-turnstile') || response.status === 403) {
return this.solveAndRetry(source.searchUrl, html, jobTitle, location);
}
return this.parseSalaryData(html);
}
async solveAndRetry(baseUrl, html, jobTitle, location) {
const match = html.match(/data-sitekey="(0x[^"]+)"/);
if (!match) throw new Error('Turnstile sitekey not found');
const submitResp = await fetch('https://ocr.captchaai.com/in.php', {
method: 'POST',
body: new URLSearchParams({
key: this.apiKey,
method: 'turnstile',
sitekey: match[1],
pageurl: baseUrl,
json: '1'
})
});
const { request: taskId } = await submitResp.json();
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 3000));
const result = await fetch(
`https://ocr.captchaai.com/res.php?key=${this.apiKey}&action=get&id=${taskId}&json=1`
);
const data = await result.json();
if (data.status === 1) {
const response = await fetch(baseUrl, {
method: 'POST',
body: new URLSearchParams({
'cf-turnstile-response': data.request,
title: jobTitle,
location: location
})
});
return this.parseSalaryData(await response.text());
}
}
throw new Error('Turnstile solve timed out');
}
aggregateResults(results, jobTitle, location) {
const valid = results.filter(r => !r.error && r.median);
if (valid.length === 0) return null;
const medians = valid.map(r => r.median);
return {
jobTitle,
location,
avgMedian: medians.reduce((a, b) => a + b, 0) / medians.length,
sources: valid.length,
range: { min: Math.min(...medians), max: Math.max(...medians) }
};
}
}
// Usage
const aggregator = new SalaryAggregator('YOUR_API_KEY');
aggregator.addSource('SalaryDB', 'https://salarydb.example.com/search');
aggregator.addSource('PayScale', 'https://payscale.example.com/lookup');
const result = await aggregator.collectForRole('Software Engineer', 'San Francisco');
console.log(`Median salary: $${result.avgMedian.toLocaleString()} (${result.sources} sources)`);
Chọn nhịp thu thập theo khối lượng
Tần suất CAPTCHA tỷ lệ thuận với mức độ dồn dập của request. Ba nhịp dưới đây là điểm khởi đầu; hãy đo lại trên cổng bạn dùng.
| Cách tiếp cận | Khối lượng mỗi ngày | Tần suất CAPTCHA | Phù hợp với |
|---|---|---|---|
| Tuần tự, giãn 2–5 giây mỗi request | 100–500 truy vấn | Thấp | Khảo sát nhỏ, một thành phố |
| Chia lô theo giờ, nghỉ giữa các lô | 500–2.000 truy vấn | Trung bình | Phân tích theo vùng |
| Nhiều phiên chạy song song | 2.000–10.000 truy vấn | Cao | Bộ dữ liệu toàn diện |
Nhịp thứ ba là nơi số thread quan trọng: mỗi thread là một CAPTCHA đang giải cùng lúc. BASIC ($15/tháng, 5 thread) đủ cho crawler tuần tự; chạy hàng nghìn chức danh song song thì ADVANCE ($90/tháng, 50 thread) thực tế hơn. CaptchaAI tính tiền theo thread chứ không theo lượt giải, nên hóa đơn không nhảy lên khi cổng bật CAPTCHA dày hơn.
Tuân thủ dữ liệu trước khi mở rộng quy mô
Dữ liệu lương thường đi kèm chức danh, công ty và khu vực. Nghị định 13/2023/NĐ-CP về bảo vệ dữ liệu cá nhân là lý do đủ tốt để làm ba việc ngay từ đầu: chỉ lấy trường thực sự cần, ghi log nguồn và thời điểm thu thập, và không lưu trường nào lần ngược được về một cá nhân. Đây không phải tư vấn pháp lý, nhưng là thói quen giúp báo cáo qua vòng rà soát nội bộ. Cũng nên đọc robots.txt của từng cổng.
Khắc phục sự cố thường gặp
| Vấn đề | Nguyên nhân | Cách xử lý |
|---|---|---|
| Turnstile xuất hiện ở mọi lần tìm kiếm | Phiên đã hết hạn | Dùng chung một requests.Session() cho cả vòng lặp |
| Kết quả trả về "Yêu cầu đăng nhập" | Cổng bắt buộc xác thực | Đăng nhập trước, rồi mới chạy vòng truy vấn |
| Trang trắng sau khi giải CAPTCHA | Thiếu tham số POST | Gửi kèm đủ các trường hidden của form |
| Số liệu lệch giữa các lần chạy | Cổng đổi dải hiển thị theo tham số | Cố định query string và ghi lại phiên bản truy vấn |
ERROR_WRONG_SITEKEY |
Bóc nhầm sitekey của widget khác trên trang | Lọc theo tiền tố 0x như trong ví dụ Python |
Câu hỏi thường gặp
Giải Turnstile trên cổng lương mất bao lâu?
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ợ. Với time.sleep(2) giữa các truy vấn, CAPTCHA hiếm khi là nút thắt — giới hạn tần suất của chính cổng mới quyết định tốc độ.
Chạy 5.000 truy vấn lương mỗi ngày thì cần gói nào?
Số CAPTCHA giải đồng thời mới quyết định, không phải số truy vấn. Crawler tuần tự chạy tốt trên BASIC ($15/tháng, 5 thread); 20–50 worker song song thì cần ADVANCE ($90/tháng, 50 thread). Mọi gói đều không giới hạn lượt giải.
CaptchaAI có giải được hCaptcha trên một số cổng nhân sự không?
Không. hCaptcha và FunCaptcha chưa được hỗ trợ, còn GeeTest v4 mới ở trạng thái sắp ra mắt. Nếu một nguồn dùng đúng các loại này, hãy chuyển nguồn khác hoặc dùng API chính thức của họ.
Có cần thu thập dữ liệu lương theo thời gian thực không?
Không cần. Phần lớn cổng lương cập nhật theo tháng hoặc quý, nên job chạy hằng tuần đã dư sức bắt kịp — lịch thưa cũng đồng nghĩa ít CAPTCHA hơn.
Nên lưu HTML thô hay chỉ lưu dữ liệu đã chuẩn hóa?
Lưu cả hai. HTML thô cho phép parse lại khi cổng đổi cấu trúc DOM mà không phải chạy lại toàn bộ vòng thu thập — mỗi lần chạy lại là thêm một loạt CAPTCHA.
Bài viết liên quan
- Thu thập dữ liệu cho nghiên cứu thị trường
- So sánh GeeTest và Cloudflare Turnstile
- Xử lý lỗi 403 của Turnstile sau khi đã có token
Bước tiếp theo
Đưa CAPTCHA ra khỏi danh sách việc thủ công — lấy API key CaptchaAI và cho crawler lương tự vượt Turnstile ngay từ lần chạy tới.