Use Cases

用于工资和薪酬数据收集的验证码处理

薪酬对标的难点不在分析,在于把数据拉全。单查一个“后端工程师 + 杭州”几乎碰不到验证码;几百个“职位 × 城市”组合排进队列,Cloudflare Turnstile 就会成片弹出。它拦的是查询密度,换出口 IP 没用,把识别接进采集器即可。国内薪酬页多是 GeeTest(极验)滑块,海外以 Turnstile 为主,CaptchaAI 两类都能识别(GeeTest 仅 v3)。

先按数据量选采集节奏

采集方式 每天查询量 验证码频率 适用场景
顺序 + 延迟 100–500 次 小样本
自有服务器 500–2,000 次 中等 区域分析
多会话并行 2,000–10,000 次 全量数据

举例:出海公司梳理“北上广深杭 + 新加坡”六地、20 个岗位的薪酬区间,一轮 120 个组合,加 2 秒延迟顺序跑就够。

薪资门户在哪些动作上弹验证码

数据源类型 验证码类型 触发
薪资对比站 Cloudflare Turnstile 反复搜索
招聘平台筛选 reCAPTCHA v2 多次查询
劳工统计门户 图片验证码 下载数据
企业薪酬页 Cloudflare Challenge 批量翻页
HR 调研平台 reCAPTCHA v3 提交表单

前四类看密度,降速并复用会话即可;v3 看行为,给的是分数。

Python:让采集器自动处理验证码

链路五步:判断挑战、提取 sitekey、提交任务、轮询、重发。

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})")

time.sleep(2) 是压低验证码频率最省事的手段;轮询超时抛异常交给上层重试。

多来源交叉验证:JavaScript 聚合

单站中位数偏差很大,职级口径各家不同,同时查三四个来源取交叉区间。

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)`);

各家中位数差超过 30%,先查职级口径。

采集薪酬数据的合规边界

只采公开的聚合数据:区间、中位数、样本量。国内采集请对照《网络安全法》《数据安全法》和 PIPL。

故障对照表

现象 原因 处理方式
每次搜索都弹 Turnstile 会话没保持 复用 Session
识别成功但结果为空 少了隐藏字段 补齐表单字段
两次运行对不上 按会话给区间 固定查询参数
轮询 60 次仍超时 sitekey 是旧值 重新解析

常见问题

薪资门户的 Turnstile 用哪个 method 提交?

turnstile,带上 sitekeypageurl,结果写回 cf-turnstile-response 再重发。reCAPTCHA v2 走 userrecaptcha

CaptchaAI 能识别国内薪酬页面的极验滑块吗?

GeeTest(极验)v3 可以,用 geetest 方法。v4 官方标注为即将支持,hCaptcha 与 FunCaptcha 不支持。

500 个“职位 × 城市”组合要买多少线程?

按并发线程计费,套餐内识别次数不限。顺序采集用 BASIC($15/月,5 线程)就够,十几个会话并行再考虑 ADVANCE($90/月,50 线程)。

相关文章

下一步

先用一个职位跑通链路——获取 CaptchaAI API Key,再排进定时任务。

该文章已禁用评论。