薪酬对标的难点不在分析,在于把数据拉全。单查一个“后端工程师 + 杭州”几乎碰不到验证码;几百个“职位 × 城市”组合排进队列,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,带上 sitekey 和 pageurl,结果写回 cf-turnstile-response 再重发。reCAPTCHA v2 走 userrecaptcha。
CaptchaAI 能识别国内薪酬页面的极验滑块吗?
GeeTest(极验)v3 可以,用 geetest 方法。v4 官方标注为即将支持,hCaptcha 与 FunCaptcha 不支持。
500 个“职位 × 城市”组合要买多少线程?
按并发线程计费,套餐内识别次数不限。顺序采集用 BASIC($15/月,5 线程)就够,十几个会话并行再考虑 ADVANCE($90/月,50 线程)。
相关文章
下一步
先用一个职位跑通链路——获取 CaptchaAI API Key,再排进定时任务。