实战:招聘职位数据采集
招聘数据是行业趋势分析的好素材——哪些城市在招什么岗位、薪资区间怎么变化、学历与经验要求是否在抬高。但这个场景的合规红线比资讯类更硬:职位页面旁边就是招聘者联系方式与简历入口,一步越界就从「采集公开职位信息」变成「处理个人信息」。
字段设计
| 字段 | 类型 | 说明 |
|---|---|---|
job_id | string | 职位唯一标识(优先用页面 ID,而非标题) |
title | string | 职位名称,归一「高级/资深/Junior」等前缀 |
company / city | string | 公司名称;城市归一「北京市」→「北京」 |
salary_min / salary_max | int | 月薪区间(元),解析自薪资文本 |
salary_months | int | 年薪月数,如 ·13薪 记为 13 |
exp_min / exp_max | int | 经验区间(年),「经验不限」记为 0~99 |
edu | string | 学历枚举:不限/大专/本科/硕士/博士 |
publish_date | date | 发布时间,增量断点依据 |
url / collect_date | string / date | 详情链接(唯一键)与采集日期(按天快照) |
注意表里没有招聘者姓名、电话、邮箱、简历字段——这不是遗漏,而是设计边界。
薪资与经验学历的文本归一
| 原文 | 解析结果 | 说明 |
|---|---|---|
15-25K·13薪 | 15000~25000,13 薪 | 最常见形态 |
1-1.5万 | 10000~15000 | 单位「万」需换算 |
200-300元/天 | 按 21.75 天折算月薪 | 日薪必须标注口径 |
15K以上 | 15000~NULL | 单边区间 |
面议 | NULL | 不能记为 0 |
8千-1.2万 | 8000~12000 | 千与万混用,易出现区间倒挂 |
# salary.py —— Python 3.10+,可直接运行
import re
UNIT = {"k": 1000, "K": 1000, "千": 1000, "万": 10000, "w": 10000, "W": 10000}
MONTHS_RE = re.compile(r"[·\*x×]?\s*(\d{2})\s*薪")
NUM_RE = re.compile(r"(\d+(?:\.\d+)?)\s*(k|K|千|万|w|W)?")
EXP_RE = re.compile(r"(\d+)\s*-\s*(\d+)\s*年|(\d+)\s*年(以上)?|应届|经验不限")
EDU_MAP = {"不限": "不限", "学历不限": "不限", "大专": "大专", "专科": "大专",
"本科": "本科", "大学本科": "本科", "硕士": "硕士", "研究生": "硕士", "博士": "博士"}
def parse_salary(text: str) -> dict:
"""「15-25K·13薪」「1-1.5万」「200-300元/天」「面议」→ 统一月薪区间"""
result = {"salary_min": None, "salary_max": None, "salary_months": None, "unit": "month"}
text = (text or "").strip().replace(",", "")
if not text or "面议" in text or "待遇从优" in text:
return result # 面议保持 NULL,不做任何猜测
if months := MONTHS_RE.search(text):
result["salary_months"] = int(months.group(1))
if "/天" in text:
result["unit"] = "day" # 日薪单独标口径,避免与月薪混算
values = [float(v) * UNIT.get(u or "k", 1) for v, u in NUM_RE.findall(text) if v]
if not values:
return result
if result["unit"] == "day":
values = [round(v * 21.75) for v in values] # 按 21.75 天折算为月薪
if len(values) == 1:
result["salary_min"] = int(values[0]) if "以上" in text else None
result["salary_max"] = None if "以上" in text else int(values[0])
return result
low, high = min(values[:2]), max(values[:2])
low = int(low * 10) if high / low > 10 else int(low) # 千/万混用时补量级,避免倒挂
result["salary_min"], result["salary_max"] = low, int(high)
return result
def parse_experience(text: str) -> tuple[int, int]:
"""经验要求归一为年区间;「经验不限」记为 (0, 99)"""
text = (text or "").strip()
if not text or "不限" in text:
return 0, 99
if "应届" in text:
return 0, 1
match = EXP_RE.search(text)
if not match:
return 0, 99
if match.group(1) and match.group(2):
return int(match.group(1)), int(match.group(2))
years = int(match.group(3))
return (years, 99) if match.group(4) else (years, years)
if __name__ == "__main__":
for raw in ["15-25K·13薪", "1-1.5万", "200-300元/天", "15K以上", "面议", "8千-1.2万"]:
print(raw, "→", parse_salary(raw))
print("经验归一:", parse_experience("5年以上"), "学历归一:", EDU_MAP.get("大学本科"))
抓取策略:别让筛选条件组合爆炸
招聘站通常支持「城市 × 关键词 × 经验 × 学历 × 薪资」多维筛选,若每个维度取 5~10 个值做笛卡尔积,请求量会瞬间涨到几十万,既没必要也不礼貌。推荐骨架是按城市分片 + 时间增量:只在稳定的单一维度上枚举,城市内部按发布时间倒序翻页,遇到已处理过的 publish_date 或 job_id 就停止;job_id 作为唯一键兜底去重,避免多城市重复命中同一职位。有官方开放接口时优先用它,通常带明确授权与配额。
# job_spider.py —— 单一维度分片 + job_id 去重
import time
import requests
from bs4 import BeautifulSoup
from salary import parse_salary, parse_experience, EDU_MAP
CITIES = ["北京", "上海", "深圳"] # 城市列表稳定且覆盖完整,比关键词枚举更可控
seen: set[str] = set()
def crawl_city(city: str):
for page in range(1, 51):
resp = requests.get(f"https://example.com/jobs?city={city}&page={page}", timeout=10)
for card in BeautifulSoup(resp.text, "html.parser").select("div.job-card"):
job_id = card.get("data-job-id", "")
if job_id in seen: # 唯一键兜底去重
continue
seen.add(job_id)
exp = parse_experience(card.select_one("span.exp").get_text(strip=True))
yield {"job_id": job_id, "city": city,
**parse_salary(card.select_one("span.salary").get_text(strip=True)),
"exp_min": exp[0], "exp_max": exp[1],
"edu": EDU_MAP.get(card.select_one("span.edu").get_text(strip=True), "其他")}
time.sleep(2.0) # 每个城市每页间隔 2 秒
合规重点
| 禁止项 | 原因 |
|---|---|
| 采集招聘者姓名、电话、邮箱、微信 | 属于个人信息,处理需单独同意,采集中通常不具备合法性基础 |
| 批量获取或保存简历 | 简历含大量个人信息,属于高敏感处理活动 |
| 绕过登录、验证码、风控 | 违反平台服务条款,可能触犯法律 |
| 采集需登录或付费才能看到的职位 | 越过访问控制即越界 |
| 训练或发布「个人画像」「人才库」 | 将职位数据反向关联到个人,隐私风险最高的用法 |
同时要遵守目标站 robots.txt 与服务条款,控制频率与并发(建议单线程、每次请求间隔 2~3 秒),只采集公开可见的职位信息。用途应限定在行业趋势统计(岗位数量、薪资区间分布、城市与学历要求变化)而非个体画像;相关处理需符合《个人信息保护法》《数据安全法》,再分发职位内容需符合《著作权法》并取得授权;优先使用官方 API、开放数据或与平台合作获取授权数据。
常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 薪资统计均值离谱 | 「面议」记为 0 或日薪未折算 | 面议留 NULL,日薪折算并标注口径 |
| 同一职位出现多次 | 多城市/多关键词重复命中 | 用 job_id 唯一键去重 |
| 经验字段无法聚合 | 文本未归一 | 统一转成 (exp_min, exp_max) 数值区间 |
小结:招聘数据的技术难点在薪资与经验学历的文本归一,业务难点在筛选组合的控制——按城市分片加时间增量通常就够;合规上必须明确「采职位、不采人」:不碰招聘者联系方式与简历,不做自动化投递,不绕过登录与风控,用途限定为行业趋势统计,并遵守 robots.txt、平台条款与《个人信息保护法》《数据安全法》《著作权法》。