招聘网站抓取被拦,是触发了机器人检测——请求频率或翻页行为让 Indeed、领英(LinkedIn)判定这是自动化流量。国内猎头、薪酬调研团队做跨境对标时常要采集这类数据。下面先回答三个高频问题,再给出可复用的抓取器实现。
常见问题
抓取 Indeed、LinkedIn 这类海外招聘网站合规吗?
条款通常不鼓励自动化访问,执行力度各家不同。国内团队跨境采集建议只采集职位、薪资等公开信息,参照网络安全法与 PIPL 处理。
LinkedIn 的 Cloudflare 验证要怎么处理?
命中后页面会带 cf-turnstile 标志,下文 _solve_and_retry 会自动识别并用 turnstile 方法提交 sitekey,拿到 token 写入 cf-turnstile-response 重新提交。
抓取 CareerBuilder 这类用 reCAPTCHA v3 的网站要注意什么?
reCAPTCHA v3 不弹验证框,而是按行为打分,分数低于站点阈值时会静默拦截。solve_captcha 把 method 换成 userrecaptcha 同样能拿到 token,但请求节奏和 User-Agent 一致性比 v2 更关键。
主流海外招聘网站会遇到什么验证码
| 网站 | 验证码类型 | 触发条件 | 可抓取数据 |
|---|---|---|---|
| Indeed | reCAPTCHA v2 | 请求频率过高 | 职位列表、薪资 |
| 领英 | Cloudflare 验证 | 机器人检测 | 职位、公司信息 |
| Glassdoor | reCAPTCHA v2 | 抓取行为触发 | 点评、薪资、职位 |
| ZipRecruiter | Cloudflare Turnstile | 自动化访问 | 职位列表 |
| Monster | reCAPTCHA v2 | 搜索页 | 职位列表 |
| CareerBuilder | reCAPTCHA v3 | 登录、搜索 | 职位列表、简历检索 |
抓取招聘网站的标准配置建议
- 轮换自有服务器基础设施——请求分散在真实 IP 上
- 页面间隔 3–5 秒——模拟真实浏览节奏
- 每会话固定 User-Agent——避免特征不一致
- 接受 cookie——招聘网站靠 cookie 追踪会话
- 打乱搜索顺序——避免翻页规律模式
- 每域名每天限 200 页——保持在检测阈值以下
带验证码处理的招聘网站抓取器
solve_captcha 提交 sitekey、pageurl 到 in.php,轮询 res.php 拿结果;JobBoardScraper 自动区分 reCAPTCHA 和 Turnstile,写入对应字段后重新提交:
import requests
import time
import re
from bs4 import BeautifulSoup
CAPTCHAAI_KEY = "YOUR_API_KEY"
CAPTCHAAI_URL = "https://ocr.captchaai.com"
def solve_captcha(method, sitekey, pageurl, **kwargs):
data = {
"key": CAPTCHAAI_KEY,
"method": method,
"googlekey": sitekey,
"pageurl": pageurl,
"json": 1,
}
data.update(kwargs)
resp = requests.post(f"{CAPTCHAAI_URL}/in.php", data=data)
task_id = resp.json()["request"]
for _ in range(60):
time.sleep(5)
result = requests.get(f"{CAPTCHAAI_URL}/res.php", params={
"key": CAPTCHAAI_KEY, "action": "get",
"id": task_id, "json": 1,
})
r = result.json()
if r["request"] != "CAPCHA_NOT_READY":
return r["request"]
raise TimeoutError("Solve timeout")
class JobBoardScraper:
def __init__(self, proxy=None):
self.session = requests.Session()
if proxy:
self.session.proxies = {"http": proxy, "https": proxy}
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36",
"Accept-Language": "en-US,en;q=0.9",
})
def search_jobs(self, base_url, query, location, pages=5):
"""Search job listings across multiple pages."""
all_jobs = []
for page in range(pages):
url = f"{base_url}/jobs?q={query}&l={location}&start={page * 10}"
resp = self.session.get(url, timeout=30)
# Check for CAPTCHA
if self._has_captcha(resp.text):
resp = self._solve_and_retry(resp.text, url)
if resp.status_code == 200:
jobs = self._parse_listings(resp.text)
all_jobs.extend(jobs)
print(f"Page {page + 1}: {len(jobs)} jobs found")
else:
print(f"Page {page + 1}: Request failed ({resp.status_code})")
time.sleep(3) # Rate limit
return all_jobs
def _has_captcha(self, html):
indicators = [
'data-sitekey=',
'g-recaptcha',
'cf-turnstile',
'captcha-delivery',
]
return any(ind in html.lower() for ind in indicators)
def _solve_and_retry(self, html, url):
# Try reCAPTCHA first
match = re.search(r'data-sitekey="([^"]+)"', html)
if match:
sitekey = match.group(1)
# Detect Turnstile vs reCAPTCHA
if 'cf-turnstile' in html:
token = solve_captcha("turnstile", sitekey, url)
field = "cf-turnstile-response"
else:
token = solve_captcha("userrecaptcha", sitekey, url)
field = "g-recaptcha-response"
return self.session.post(url, data={field: token})
return self.session.get(url)
def _parse_listings(self, html):
soup = BeautifulSoup(html, "html.parser")
jobs = []
for card in soup.select(".job_seen_beacon, .jobsearch-ResultsList > li"):
title_el = card.select_one("h2 a, .jobTitle a")
company_el = card.select_one(".companyName, [data-testid='company-name']")
location_el = card.select_one(".companyLocation, [data-testid='text-location']")
salary_el = card.select_one(".salary-snippet, .estimated-salary")
if title_el:
jobs.append({
"title": title_el.get_text(strip=True),
"company": company_el.get_text(strip=True) if company_el else "",
"location": location_el.get_text(strip=True) if location_el else "",
"salary": salary_el.get_text(strip=True) if salary_el else "",
"url": title_el.get("href", ""),
})
return jobs
# Usage
scraper = JobBoardScraper(
proxy="http://user:pass@residential.proxy.com:5000"
)
jobs = scraper.search_jobs(
base_url="https://jobs.example.com",
query="python developer",
location="New York",
pages=10,
)
print(f"Total jobs collected: {len(jobs)}")
批量采集薪资数据做市场对标
做薪酬报告可外包一层批处理:按职位 × 城市循环调用 search_jobs,样本写入 CSV:
import csv
def collect_salary_data(titles, locations, output_file):
"""Collect salary data across job titles and locations."""
scraper = JobBoardScraper(
proxy="http://user:pass@residential.proxy.com:5000"
)
results = []
for title in titles:
for location in locations:
try:
jobs = scraper.search_jobs(
"https://jobs.example.com",
title, location, pages=3,
)
salaries = [j["salary"] for j in jobs if j["salary"]]
results.append({
"title": title,
"location": location,
"listings": len(jobs),
"with_salary": len(salaries),
"salary_samples": "; ".join(salaries[:5]),
})
time.sleep(5)
except Exception as e:
results.append({
"title": title,
"location": location,
"error": str(e),
})
with open(output_file, "w", newline="") as f:
writer = csv.DictWriter(
f, fieldnames=["title", "location", "listings",
"with_salary", "salary_samples", "error"],
)
writer.writeheader()
writer.writerows(results)
return results
# Collect salary data for market analysis
collect_salary_data(
titles=["Data Engineer", "ML Engineer", "DevOps Engineer"],
locations=["San Francisco", "New York", "Austin", "Remote"],
output_file="salary_data.csv",
)
常见故障排查
| 问题 | 原因 | 处理方式 |
|---|---|---|
| 每次搜索都触发验证码 | IP 已标记或频率超限 | 换 IP,拉长间隔 |
| 结果为空 | 返回的是拦截页 | 解析前先判断是否命中验证码 |
| 提示“请证明你不是机器人” | 触发机器人检测 | 换 IP + 真实 UA |
| 薪资数据需登录 | 内容受限 | 走已登录会话流程 |
| 结果和浏览器不一样 | 地理位置或 cookie 不一致 | 对齐 Accept-Language 与代理地理位置 |
选择合适的 CaptchaAI 套餐
CaptchaAI 按并发线程计费,线程内可无限次识别。小规模测试用 STANDARD($30/月,15 线程)够用,并发更大时升级 ADVANCE($90/月,50 线程)。
相关指南
- 自有服务器基础设施配置
- 代理质量与解决率
- 粘性会话 vs 轮换会话
大规模采集招聘市场数据——获取你的 CaptchaAI 密钥,把验证码处理接入自动化流程。