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

317 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 招聘网站自动抓取技能
当你获取到一个"目标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&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 等自建官网。
```python
# 列表页:解析 <a href="/jobdetail/{id}"> 获取岗位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 不是预期值 | 不一定是连续数字 | 必须实际点击实习分类,从抓包确认真实值 |