# 招聘网站自动抓取技能 当你获取到一个"目标url"和"目标要求"时,请参考如下步骤。 --- ## 第一步:创建工作目录 创建目录 `\crawl\{name}\`(已存在则跳过),所有产生的文件都写到这个目录。 临时文件(抓包中间产物、调试输出等)统一放在 `\crawl\tmp\`,完成后可清理。 --- ## 第二步:使用 mitmproxy 抓包 ### 前置条件 - mitmproxy MCP 工具已配置可用 - mitmproxy CA 证书已安装到系统信任存储(首次使用需安装) ### 操作流程 1. **启动代理**:调用 `start_proxy(port=8080)` 确保代理运行中 2. **清空历史数据**:调用 `clear_traffic` 清空之前的抓包记录 3. **启动 Playwright 浏览器**(必须配置代理和忽略证书): ```python 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` 导出抓包数据 ```bash python F:\offerpai_cw\crawl\export_har.py -d "目标域名" -o "crawl\{name}\域名.har" ``` ### 备选方案:Playwright 内置 HAR 录制 如果 mitmproxy 不可用或证书问题无法解决,可用 Playwright 自带的 HAR 录制: ```python 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 尝试最小参数请求: ```python 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 同步: ```python 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 | 数据库默认 | ### 清洗方法模板 ```python 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_id`、`job_title`、`company_id`、`detail_url`、`recruit_category`、`content_hash`、`expire_at` 2. **可选字段有值才设置**:`salary`、`location`、`experience`、`education`、`description`、`company`,没有就不放入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 等所有北森招聘站点。 ```python # 核心接口 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) 通用模板 ```python # 列表接口 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¤tPage=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 等自建官网。 ```python # 列表页:解析 获取岗位ID列表 # 详情页:逐个请求 /jobdetail/{id},用 BeautifulSoup 解析内容 ``` 关键点: - 没有 API,网络面板无 XHR 请求(只有埋点/统计) - 列表页通常只有简要信息(标题、地点、类型),详情需要单独请求 - 需要处理 HTML 结构差异,不同网站标签不同 - 注意 `\xa0`(不间断空格)等特殊字符的清理 ### 华润系统 (ssdp.crc.com.cn) 通用模板 ```python # 统一网关 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 不是预期值 | 不一定是连续数字 | 必须实际点击实习分类,从抓包确认真实值 |