Trường Hợp Sử Dụng

Xử lý CAPTCHA để thu thập dữ liệu tiền lương và thù lao

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

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.

Os comentários estão desativados para este artigo.