Files
post_crawler/crawl/post.md
T
kgod c06f595559 feat: add crawl scripts for recruitment websites
- btyy (倍特药业), fullsemi (富芯半导体): 北森平台爬虫
- hotjob (中国五矿): hotjob平台爬虫
- leinao (中科类脑): 静态HTML爬虫
- task_fetcher: 原子锁获取任务
- post.md: 抓取技能文档
- export_har: mitmproxy HAR导出工具
2026-05-27 23:48:30 +08:00

14 KiB
Raw Blame History

招聘网站自动抓取技能

当你获取到一个"目标url"和"目标要求"时,请参考如下步骤。


第一步:创建工作目录

创建目录 \crawl\{name}\(已存在则跳过),所有产生的文件都写到这个目录。

临时文件(抓包中间产物、调试输出等)统一放在 \crawl\tmp\,完成后可清理。


第二步:使用 mitmproxy 抓包

前置条件

  • mitmproxy MCP 工具已配置可用
  • mitmproxy CA 证书已安装到系统信任存储(首次使用需安装)

操作流程

  1. 启动代理:调用 start_proxy(port=8080) 确保代理运行中
  2. 清空历史数据:调用 clear_traffic 清空之前的抓包记录
  3. 启动 Playwright 浏览器(必须配置代理和忽略证书):
    browser = playwright.chromium.launch(
        proxy={"server": "http://127.0.0.1:8080"},
        args=["--ignore-certificate-errors"]
    )
    
  4. 打开目标网站,通过 search_traffic 确认已抓到目标域名的请求
  5. 操作浏览器完成所有"目标要求"中的操作(详见下方"浏览策略")
  6. 导出 HAR:使用 F:\offerpai_cw\crawl\export_har.py 导出抓包数据
    python F:\offerpai_cw\crawl\export_har.py -d "目标域名" -o "crawl\{name}\域名.har"
    

备选方案:Playwright 内置 HAR 录制

如果 mitmproxy 不可用或证书问题无法解决,可用 Playwright 自带的 HAR 录制:

context = browser.new_context(record_har_path="crawl/{name}/traffic.har")
# ... 操作浏览器 ...
context.close()  # 关闭时自动保存 HAR

也可以直接使用 Playwright 的网络请求监控面板(browser_network_requests)抓取 API 请求,适合简单场景。


浏览策略:如何抓取招聘网站的岗位数据

  1. 覆盖所有招聘类型:网站通常有校招、社招、实习等分类(可能是不同页面、Tab切换、或下拉选择)。必须逐个点击每个分类,确认该分类下是否有数据,并抓到对应的 API 请求。不能因为某个分类看起来可能为空就跳过,必须实际点击确认。
  2. 确认每个分类的实际数据量:进入每个分类后,记录页面显示的岗位总数。后续脚本开发完成后要与此数量对比验证。
  3. 岗位列表页:进入列表后观察数据加载方式:
    • API 动态加载:列表数据通过 XHR/Fetch 请求获取,通常包含岗位 ID、分页信息
    • 静态渲染:列表数据直接在 HTML 中,点击岗位后不再发起新请求
    • 混合模式:列表是 API 加载,但详情也在列表响应中(无需单独请求详情)
  4. 岗位详情页:点击至少一个岗位进入详情页,观察是否有独立的详情 API
  5. 分页:如果列表有多页,至少翻到第2页,确认分页参数格式
  6. 最终目标:确保抓到能获取所有岗位完整信息的 API 请求

第三步:分析 HAR 封包,用 requests 重现

分析要点

  1. 从 HAR 中找出关键 API 请求(通常是返回 JSON 且包含岗位数据的 POST/GET 请求)
  2. 区分哪些是必要请求,哪些是埋点/日志等无关请求
  3. 关注请求中的认证信息来源:Token、Cookie、签名参数等

最小化重现

用 Python requests 尝试最小参数请求:

import requests
resp = requests.post(url, json=payload, headers=必要headers)

如果返回与抓包一致的数据,说明重现成功。

重现失败时的排查方向

  • 认证参数Cookie、Authorization header、自定义 Token header
  • 动态参数:时间戳、签名、加密字段 — 分析 HAR 中这些参数的生成规律
  • 请求体编码:有些网站对请求体做 base64 编码或自定义加密
  • 请求顺序依赖:某些接口需要先调用初始化接口获取 session/token
  • Referer/Origin 校验:部分网站校验这些 header

有了完整的 HAR 封包,所有参数来源都可追溯,一定可以完成重现。


第四步:创建 Python 爬虫脚本

前提

在开始这一步之前,必须已经用 requests 成功重现了目标 API 的请求。

类结构设计

脚本使用类方式组织,通过 requests.Session() 自动管理 Cookie 同步:

import requests

class XxxCrawler:
    """xxx招聘网站爬虫"""

    def __init__(self):
        """初始化:建立session、获取初始cookies/token、设置公共headers"""
        self.session = requests.Session()
        self.session.headers.update({...})
        # 如需要:调用初始化接口获取token等

    def get_xiaozhao_list(self) -> dict:
        """获取校园招聘列表(内部处理分页,返回所有页数据)
        成功返回:{"total": int, "records": list, "position_ids": list}
        失败抛出异常
        """

    def get_shezhao_list(self) -> dict:
        """获取社会招聘列表(内部处理分页)"""

    def get_shixi_list(self) -> dict:
        """获取实习招聘列表(内部处理分页)"""

    def get_position_detail(self, position_id, **kwargs) -> dict:
        """获取单个岗位详情
        参数:岗位ID及其他必要参数
        成功返回:完整的岗位详情数据
        """

关键设计要求

  1. Session 管理:使用 requests.Session() 保持 Cookie 自动同步
  2. 分页处理:列表方法内部自动遍历所有页,调用方无需关心分页逻辑
  3. init 职责:所有前置依赖(Cookie获取、Token刷新、公共参数构建)都在初始化中完成
  4. 错误处理:网络错误和业务错误分开处理,失败时抛出有意义的异常
  5. 请求间隔:每次请求间加 time.sleep(0.3~0.5) 避免被封

测试标准

逐个测试每个方法:

  • 列表方法能返回完整的岗位列表和所有岗位 ID
  • 详情方法能根据 ID 返回完整的岗位信息
  • 连续调用不会因 Cookie/Token 过期而失败
  • 数据完整性对比:用 Playwright 打开页面,人工确认页面上可见的岗位数量和分类,与脚本最终获取的数量对比。如果页面上能看到但接口没返回的,排查原因:
    • recruitType/Category 值是否正确(不一定是连续数字,如实习可能是12而不是3)
    • 是否有隐藏分类或子页面未覆盖
    • 分页是否遍历完整(对比 total 和实际获取条数)
    • 筛选条件是否遗漏(如 orgCode、PortalId 等参数影响结果集)

第五步:数据清洗方法 (parse_to_db)

目标

将爬虫获取的原始数据转换为数据库 app_job_data 表所需的格式。在爬虫脚本同文件中新增 parse_to_db 方法。

app_job_data 表结构

字段 类型 必填 默认值 说明
id bigint 自增主键 - 不需要传
task_crawl_id bigint 必填 - 爬虫任务ID,关联 app_url_list
job_title varchar(255) 必填 - 岗位名称
salary varchar(128) 可选 NULL 薪资
location varchar(2048) 可选 NULL 工作地点
company_id varchar(255) 必填 - 公司标识(英文简写)
company varchar(255) 可选 NULL 公司名称(中文全称)
experience varchar(64) 可选 NULL 工作经验要求
education varchar(64) 可选 NULL 学历要求
description text 可选 NULL 岗位描述
detail_url varchar(1024) 必填 - 岗位详情链接
recruit_category tinyint 必填 3 0=社招, 1=校招, 2=实习
content_hash varchar(64) 必填 - 去重MD5
expire_at datetime 必填 - 发布日期,从岗位信息匹配,匹配不到则设为当天日期
sources tinyint(1) 不需要传 0 数据库默认
is_independent_url tinyint(1) 不需要传 1 数据库默认
check_status varchar(32) 不需要传 "pending" 数据库默认
clean_status tinyint(1) 不需要传 0 数据库默认
last_check_at datetime 不需要传 NULL 数据库默认
created_at datetime 不需要传 CURRENT_TIMESTAMP 数据库默认
updated_at datetime 不需要传 CURRENT_TIMESTAMP 数据库默认

清洗方法模板

def parse_to_db(records, task_crawl_id, company_id="xxx", company="公司中文名"):
    """
    将API返回的岗位数据清洗为 app_job_data 表所需格式
    :param records: 爬虫获取的原始岗位列表
    :param task_crawl_id: 爬虫任务ID (关联 app_url_list)
    :param company_id: 公司标识
    :param company: 公司中文名称
    :return: list[dict]
    """

清洗规则

  1. 必填字段必须返回task_crawl_idjob_titlecompany_iddetail_urlrecruit_categorycontent_hashexpire_at
  2. 可选字段有值才设置salarylocationexperienceeducationdescriptioncompany,没有就不放入dict
  3. 不需要传的字段一律不返回:数据库有默认值的字段由数据库处理
  4. content_hash 生成hashlib.md5(f"{job_title}|{company_id}|{description}".encode()).hexdigest()
  5. recruit_category 映射:根据网站的分类标识映射到 0=社招, 1=校招, 2=实习
  6. description 拼接:将职责和要求用 【工作职责】 【任职要求】 标签拼接
  7. 空值处理:原始数据为空、"/"、None 的字段不放入返回结果
  8. expire_at:优先从岗位的发布日期字段匹配,匹配不到则设为当天日期

附录:实战经验总结

平台识别与复用

特征 平台 复用策略
域名含 zhiye.com 北森招聘平台 API 结构完全一致,改域名和 company_id 即可复用
域名含 italent.cn 北森 iTalent 同上
页面底部 "Powered by Beisen" 北森 同上
域名含 hotjob.cn hotjob 平台 form 表单格式请求,recruitType 区分分类
纯静态 HTML,无 XHR 请求 自建官网 用 requests + BeautifulSoup 解析
ssdp.crc.com.cn 网关 华润系统 请求体 base64 编码,响应 RETURN_DATA 也需 base64 解码

北森平台 (zhiye.com) 通用模板

已验证适用于:btyy.zhiye.com、fullsemi.zhiye.com 等所有北森招聘站点。

# 核心接口
POST https://{domain}/api/Jobad/GetJobAdPageList

# 请求体
{"PageIndex": 0, "PageSize": 20, "Category": ["1"], "KeyWords": "", "SpecialType": 0, "PortalId": "", "DisplayFields": [...]}

# Category: "1"=社招, "2"=校招
# 响应直接包含完整岗位信息(Duty、Require),无需单独详情接口
# 分页:PageIndex 从 0 开始,通过 Count 字段判断总数

关键点:

  • 无需认证,无 Cookie/Token 依赖
  • 列表接口已包含完整岗位详情(混合模式),不需要单独请求详情页
  • Headers 只需 Content-Type、User-Agent、Referer

hotjob 平台 (wecruit.hotjob.cn) 通用模板

# 列表接口
POST https://wecruit.hotjob.cn/wecruit/positionInfo/listPosition/{SUITE_KEY}?iSaJAx=isAjax&request_locale=zh_CN
Content-Type: application/x-www-form-urlencoded
Body: recruitType=1&currentPage=1&pageSize=10&coordinateLat=&coordinateLng=&orgCode=0

# 详情接口
POST https://wecruit.hotjob.cn/wecruit/positionInfo/listPositionDetail/{SUITE_KEY}?iSaJAx=isAjax&request_locale=zh_CN
Body: postId={postId}&recruitType={recruitType}

# recruitType: 1=校招, 2=社招, 12=实习(注意不是连续数字!)

关键点:

  • 请求格式是 form 表单,不是 JSON
  • 列表只有简要信息,需要单独请求详情获取 workContent、serviceCondition
  • SUITE_KEY 从 URL 中提取
  • company 字段在每条岗位数据中(集团招聘,子公司不同)

静态 HTML 网站通用策略

适用于:leinao.ai 等自建官网。

# 列表页:解析 <a href="/jobdetail/{id}"> 获取岗位ID列表
# 详情页:逐个请求 /jobdetail/{id},用 BeautifulSoup 解析内容

关键点:

  • 没有 API,网络面板无 XHR 请求(只有埋点/统计)
  • 列表页通常只有简要信息(标题、地点、类型),详情需要单独请求
  • 需要处理 HTML 结构差异,不同网站标签不同
  • 注意 \xa0(不间断空格)等特殊字符的清理

华润系统 (ssdp.crc.com.cn) 通用模板

# 统一网关
POST https://ssdp.crc.com.cn/ssdp/sys/rf/?ssdp={base64编码的认证参数}

# 请求体:先 JSON 序列化再 base64 编码
payload = {"base64String": base64.b64encode(json.dumps({"biz": {...}}).encode()).decode()}

# 响应:RETURN_DATA 字段是 base64 编码的 JSON
data = json.loads(base64.b64decode(response["RESPONSE"]["RETURN_DATA"]))

关键点:

  • ssdp 参数包含 Api_ID、App_Sub_ID、App_Token、时间戳等
  • 注意 App_Sub_ID 要从浏览器实际请求中精确复制(容易看错字符)
  • 请求体和响应体都有 base64 编码层

常见坑与解决方案

问题 原因 解决
响应为空 body 认证参数错误 对比浏览器实际 ssdp 参数,逐字符核对
"不限" 出现在 location 网站用"不限"表示无地点限制 过滤掉"不限"、"面议"等占位值
Windows 终端中文乱码 控制台编码非 UTF-8 数据本身正确,用 Read 工具或文件验证
SPA 页面刷新后抓不到请求 hash 路由不触发新请求 新开标签页重新加载
列表页已包含详情 混合模式网站 不需要单独请求详情接口,直接从列表提取
分页参数从 0 还是 1 开始 不同平台不同 看抓包中第一页的 PageIndex/pageNum 值
实习 recruitType 不是预期值 不一定是连续数字 必须实际点击实习分类,从抓包确认真实值