实战:招聘职位数据采集

招聘数据是行业趋势分析的好素材——哪些城市在招什么岗位、薪资区间怎么变化、学历与经验要求是否在抬高。但这个场景的合规红线比资讯类更硬:职位页面旁边就是招聘者联系方式与简历入口,一步越界就从「采集公开职位信息」变成「处理个人信息」。

字段设计

字段类型说明
job_idstring职位唯一标识(优先用页面 ID,而非标题)
titlestring职位名称,归一「高级/资深/Junior」等前缀
company / citystring公司名称;城市归一「北京市」→「北京」
salary_min / salary_maxint月薪区间(元),解析自薪资文本
salary_monthsint年薪月数,如 ·13薪 记为 13
exp_min / exp_maxint经验区间(年),「经验不限」记为 0~99
edustring学历枚举:不限/大专/本科/硕士/博士
publish_datedate发布时间,增量断点依据
url / collect_datestring / 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_datejob_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、平台条款与《个人信息保护法》《数据安全法》《著作权法》。

笔记加载中…