我一直觉得招聘网站是最适合拿来练爬虫靶场的平台之一,数据真实、字段规整、覆盖城市广,尤其是BOSS直聘这种岗位更新极快的站点,爬下来就是一份现成的就业市场样本。这个项目我从萌生想法到跑通全链路,前后花了差不多两周,中间翻过车、被风控过、也对着字体反爬头痛过,最后总算是把“从接口分析到数据洞察”整条链路跑顺了,这篇就把完整过程记下来。
先说清楚这个项目到底干了什么:用Python采集BOSS直聘公开搜索结果页里的岗位信息,抽取岗位名称、公司、薪资区间、经验要求、学历要求、城市区域、技能标签等结构化字段,清洗后存入SQLite,再做薪资分布、城市对比、岗位画像之类的可视化分析。它适合两类人看:一类是Python爬虫学到requests基础、想挑战一个真实反爬项目的同学;另一类是想了解招聘市场行情、手里又缺数据的求职者。但有个大前提必须放在最前面:整个过程只碰公开数据,只做个人学习研究,频率控制在合理范围内。
1. 项目背景与合规边界
1.1 项目需求拆解
触发我做这个项目的直接原因是帮朋友做一次行业技术调研,需要对比几个城市Python开发岗的薪资中位数。手动翻网页也能看,但数据零散,没法统计,翻半小时就烦了。招聘网站的搜索页本身就在公开返回岗位数据,把它们采集下来、整理成表格,本质上就是把散落的信息变成结构化样本,这就是项目的核心需求。
拆开来看,这个爬虫要解决的子问题其实很清晰:
- 采集对象:BOSS直聘搜索页里公开回报的岗位列表,关键词可以指定,比如“Python”“Java”“前端”。
- 字段提取:岗位名、薪资描述、公司名、融资阶段、经验要求、学历要求、城市、区县、技能标签。
- 技术难点:接口的加密参数、字体映射反爬、访问频率限制,这三个是绕不开的坎。
- 产出物:一张干净的岗位信息表,再加几张能讲清楚市场行情的图。
别小看最后那个“洞察”环节,爬虫只是手段,数据清洗和分析才是让这个项目区别于普通练手脚本的关键。你爬下来的原始字段其实很脏,薪资是“20-30K·14薪”这种字符串,城市是一大串编码,标签是数组,不处理根本没法做统计。
1.2 爬虫的合规红线
爬虫这门手艺,技术是一回事,合规是另一条线,而且这条线比技术更值得花时间想清楚。我自己的原则很简单:只采集公开页面本身就能看到的数据,不抓个人简历、不碰联系方式、不破解登录鉴权、不做商业用途。BOSS直聘的岗位列表属于面向所有访客公开的信息,采集这类公开数据的争议相对小,但我依然会严格遵守三点。
第一,频率必须克制。把并发开到几十个线程去刷一个网站,无论数据是否公开,都会给对方服务器造成压力,这是任何爬虫项目都不该做的事。第二,不去绕过登录墙。凡是需要登录后才能看的信息,默认不碰,这是底线。第三,数据只做个人学习研究。这篇文章里所有代码示例都只是演示思路,别拿去跑商业化服务。行业里因为爬虫翻车的案例并不少,尤其是盗取简历数据和恶意刷接口那类,性质完全不同,别踩那条线。
我一直跟朋友说,爬虫最考验人的不是能不能爬到,而是知道该在哪里停下。这个项目就是一个典型的“公开数据、低频采集、个人研究”场景,合规边界清晰,技术难度又足够有代表性。
2. 技术选型与整体架构
2.1 为什么优先选择 requests 直连接口
写爬虫的第一步其实是选路:是直接调底层接口,还是用浏览器自动化框架模拟操作。我最终选择以requests直连接口为主,Playwright为辅,这个决定是在对比了两种方案的优劣势之后做的。
直连接口最大的优势是效率。一个请求过去,服务器直接返回JSON,解析几百个字段比从HTML里抠数据快得多,也干净得多。BOSS直聘的搜索页虽然渲染出来是花花绿绿的卡片,但底层走的XHR接口返回的结构其实非常规整,就是个标准的JSON对象,字段名都给你起好了:jobName、salaryDesc、brandName。这种数据喂给pandas,几乎不需要预清洗。另外,直连接口的资源开销也小,单机跑十几个并发毫无压力,不像是浏览器自动化,开几个Chromium实例内存就报警了。
直连接口的代价也很明确:你得和对方的加密参数正面交锋。BOSS直聘的搜索请求里带有一个叫做__zp_stoken__的校验参数,这个参数是通过前端脚本根据Cookie等材料动态生成的,服务端会校验它的合法性。如果参数不过关,接口会返回错误码或者干脆给你重定向到验证码页。这块是requests方案的主要难点,但也不是无解的,思路在第三章详细说。
2.2 Playwright 作为兜底方案
那什么时候用Playwright?我的经验是:当requests方案陷进去半天搞不定、或者网站的风控已经强到要滑块验证的时候,别硬刚,直接上浏览器自动化兜底。Playwright和“直接解码参数”的路线最本质的区别是:它不再需要你逐行逆向对方的加密逻辑,而是让一个真实的Chromium浏览器替你去执行那些加密脚本,服务端看到的是一台完整的、行为接近真人的浏览器环境。
这带来两个非常实际的好处。第一,只要你在浏览器里能正常看到数据,Playwright基本就能拿下来,页面加载完以后直接解析DOM就行,那些麻烦的token、字体反爬都不再是障碍。第二,它天然带指纹伪装,正常浏览器的Canvas、WebGL特征它都有,比一堆裸requests请求看起来“像人”得多。
缺点也很明显:慢、重、费资源。每个任务起一个浏览器实例,内存随随便便就是几百兆;采集速度也比接口慢一个数量级。所以我说的“兜底方案”是这个意思:requests跑大流量,Playwright补难点。批量采集用接口,某个接口彻底废了或者登录态失效时,再让Playwright上去顶一阵。二者的关系不是替代,是配合。
2.3 数据链路与模块拆分
整个项目的架构我把它拆成了五层,每一层各管一件事,互不干扰,这对后面排错特别重要。
- 采集层:负责发请求、处理重试、控制访问间隔、随机切换User-Agent。
- 解析层:把接口返回的JSON里的字段提取出来,做初步的类型转换。
- 清洗层:对薪资字符串做拆分、对城市编码做翻译、对空值和重复记录做处理。
- 存储层:使用SQLite,轻量、单文件、无需额外服务,适合个人项目。
- 分析层:pandas做统计,pyecharts和wordcloud做可视化。
这个分层的思路,说白了就是让每一层的失败都只影响自己这一层。比如清洗层发现某个薪资格式没解析出来,我只需要改清洗函数,不用动采集代码;存储出问题也同理。个人项目虽然不必上微服务那套,但明确的模块边界能救你于水火之中。
3. 接口分析与加密参数拆解
3.1 从浏览器开发者工具出发
任何爬虫的第一步都不是写代码,而是打开浏览器,用开发者工具观察网络请求。先开一个无痕窗口,避免登录态干扰,然后访问BOSS直聘的职位搜索页,输入关键词“Python”,回车,等搜索结果渲染出来后,按下F12切到Network面板,筛选XHR类型请求。
你会看到页面陆续发了一大堆异步请求,但你需要关注的其实是那个名字里带joblist的接口,它的URL大概是这样的结构:
/zpgeek/search/joblist.json?query=Python&city=101010100&page=1
这里面的query是搜索关键词,city是城市编码,page是页码。点开这个请求的Preview标签页,你能直接看到返回的JSON结构,最外层是zpData节点,里面有个jobList数组,数组里每个元素就是一条完整的岗位数据。这个过程没有太多技巧,就是耐心点一个个翻请求,找准主线。
接口找出来以后,别急着写代码,先手动在浏览器里重复执行两三次请求,观察哪些参数每次都变、哪些参数固定不变。我当时的结论是:query、city、page属于业务参数,好构造;但请求头里的Cookie和请求参数里的__zp_stoken__是动态的,和服务端校验逻辑强相关,这是后面重点处理的对象。
3.2 关键请求与返回结构
把joblist接口的请求头和响应体摸清楚,项目就算成功了三分之一。我总结了一下这个接口最值得记录的几个特征点,做成了一张速查表,供大家参考:
| 维度 | 内容说明 |
|---|---|
| 请求方式 | GET |
| 业务参数 | query(关键词)、city(城市编码)、page(页码) |
| 校验参数 | zp_stoken,随Cookie动态生成 |
| 关键请求头 | User-Agent、Referer(需为BOSS直聘站点内地址) |
| 返回结构 | zpData.jobList 数组,内含岗位信息 |
| 风险特征 | 高频访问会触发安全验证,返回302或验证码页 |
接下来说返回结构。jobList里的每个元素字段特别全,直接省去了解析HTML的麻烦。我最常用的字段是这些:jobName(岗位名)、salaryDesc(薪资描述,比如“20-30K·14薪”)、jobExperience(经验要求)、jobDegree(学历要求)、brandName(公司名)、brandStageInfo(融资阶段)、cityName(城市名)、areaDistrict(区县)、jobLabels(岗位标签数组)、jobId(唯一标识)。只要拿到jobId,后续做增量更新就有了去重依据。
这里有个容易踩的坑:接口返回的字段名在不同页面版本下可能变化,比如有的版本叫salaryDesc,有的版本叫salaryString。最稳妥的做法是先打印一条完整的返回数据看一遍字段名,再写解析逻辑,不要凭记忆写字段名。
3.3 加密参数与反爬策略
这一节是整个项目里最绕的部分,也是你为什么直连接口会遇到门槛的核心原因。__zp_stoken__这个参数,如果你直接去掉它发请求,大概率会得到类似“token校验失败”的错误提示。这个token的生成逻辑在网站前端的JavaScript脚本里,通常是多个Cookie值经过某种编码和哈希算法拼接生成的,而且脚本版本会不定期更新。
我处理这个参数的思路有两个方向。第一个方向是逆向JS,找到生成__zp_stoken__的函数。这个方法技术上限高,但成本也高,每次脚本更新你可能都要跟着翻一遍。第二个方向是曲线救国:用Playwright先启动一个浏览器上下文,打开搜索页让前端脚本自然生成合法的Cookie和token,然后用这个会话的Cookie去驱动requests。这不算是绕过鉴权,而是让前端代码替你完成了它该完成的校验材料准备。
除了token,还有两个反爬点要注意。一个是字体反爬:BOSS直聘页面上部分数字会使用自定义字体渲染,DOM里看到的字符和实际显示的数字不一致。早期版本需要下载字体文件、解析映射关系才能还原,但接口返回的JSON里薪资字段往往是明文,所以直连接口时字体反爬反而影响不大,只有走Playwright解析DOM时才会遇到。另一个是行为风控:短时间大量请求会触发安全验证,页面会跳转到验证码页。应对方案就是低频率、随机延时、控制并发,我个人实测下来,每秒不超过2个请求、每页间隔3-6秒,稳定性会好很多。
4. 代码落地与爬虫实现
4.1 请求封装与异常重试
整层代码的第一步是把请求封装好。我当时的做法是写一个通用的get_json函数,把请求头、超时、重试、Cookie都放在里面,接口调用方只需要传入URL和参数。这样即使后续要换代理或者加日志,也只改一处。
import requests import time from random import uniform from typing import Dict, Optional class JobClient: def __init__(self, cookies: Dict[str, str], max_retries: int = 3): self.session = requests.Session() self.session.cookies.update(cookies) self.max_retries = max_retries self.headers = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/122.0.0.0 Safari/537.36" ), "Referer": "https://www.zhipin.com/web/geek/job", "Accept": "application/json, text/plain, */*", } def get_json(self, url: str, params: Optional[Dict] = None) -> Dict: for attempt in range(1, self.max_retries + 1): try: resp = self.session.get(url, params=params, headers=self.headers, timeout=10) if resp.status_code == 200: return resp.json() # 遇到 429/403 等状态码,说明可能触发风控 if resp.status_code in (429, 403): time.sleep(uniform(10, 20)) except requests.RequestException: pass time.sleep(uniform(2, 5)) raise RuntimeError(f"请求失败: {url}")这段封装里有几个细节是经验教训的产物。第一,Referer一定要带,BOSS直聘部分接口会校验Referer来源,不带Referer直接请求很容易被拒。第二,重试时要有指数退避或者随机睡眠,不要失败后立刻重试,那样反而更容易触发风控。第三,429和403要区别处理,429代表频率太高,多等一会儿就能恢复,403可能代表IP或者Cookie被封,等再久也没用,需要换材料。
封装好请求之后,采集层的骨架就出来了:循环请求页码、解析数据、随机停顿。整个过程用一个for循环就能表达,但真正的功力都在循环里面那些“不起眼的细节”里。
4.2 数据解析与字段抽取
拿到joblist接口返回的JSON之后,解析本身并不难,就是循环jobList,把需要的字段取出来。我习惯写一个专门的parse_job函数,这个函数只负责“提取”,不做任何业务判断,这样测试起来最方便。
import re import json def parse_job(item: Dict) -> Dict: # 薪资格式: "20-30K·14薪" 或 "15-20K" 或 "30-50K·15薪" salary_text = item.get("salaryDesc", "") nums = re.findall(r"(\d+\.?\d*)", salary_text) salary_low = float(nums[0]) if len(nums) > 0 else None salary_high = float(nums[1]) if len(nums) > 1 else salary_low month_match = re.search(r"(\d+)薪", salary_text) month_count = int(month_match.group(1)) if month_match else 12 return { "job_id": item.get("jobId"), "job_name": item.get("jobName"), "salary_text": salary_text, "salary_low": salary_low, "salary_high": salary_high, "month_count": month_count, "experience": item.get("jobExperience"), "education": item.get("jobDegree"), "company": item.get("brandName"), "finance_stage": item.get("brandStageInfo"), "city": item.get("cityName"), "district": item.get("areaDistrict"), "labels": json.dumps(item.get("jobLabels", []), ensure_ascii=False), }这里最值得说的是薪资字段的处理。用户看到的是“20-30K·14薪”这种人类友好格式,但统计时要的是数值。我通过正则把“20-30”拆成salary_low和salary_high,再把“14薪”拆出来单独存成month_count字段,这样后面既能算月薪区间,又能算年薪中位数,非常灵活。
有个我踩过的坑是:有些岗位的薪资是“面议”,这种字符串用上面的正则会解析出空列表,导致salary_low和salary_high为None。所以统计时一定要过滤掉None值,否则pandas一算平均值就给你返回NaN,你还得回头追查半天。
4.3 数据入库与增量更新
数据解析出来之后,存哪里?我选了SQLite。原因很简单:个人项目不需要MySQL这种重量级服务,SQLite单文件、零配置、支持SQL语法,数据量几万条完全够用。建表语句如下:
CREATE TABLE IF NOT EXISTS jobs ( job_id TEXT PRIMARY KEY, job_name TEXT, salary_text TEXT, salary_low REAL, salary_high REAL, month_count INTEGER, experience TEXT, education TEXT, company TEXT, finance_stage TEXT, city TEXT, district TEXT, labels TEXT, crawl_time TEXT DEFAULT (datetime('now', 'localtime')) );主键直接用job_id,这样天然支持增量更新。入库的时候用INSERT OR IGNORE,如果job_id已经存在就直接跳过,不会重复插入。我一般还会加一个crawl_time字段记录采集时间,因为同一岗位过几天薪资可能会变,靠这个字段可以做历史对比。
增量更新这块,我的逻辑其实很简单:每次采集前先查一下库里已有的job_id集合,如果某页数据全部命中,就说明这个关键词的采集已经到头了,直接停止翻页,省流量也省频率。这种做法在个人项目中很实用,尤其是你要长期追踪某个岗位的薪资变化时,它能把每天采集的数据控制在一个很小的增量范围内。
5. 数据清洗、分析与可视化
5.1 数据清洗要点
爬虫真正让人头疼的不是爬到数据,而是爬完之后的清洗。原始数据里藏着各种小坑:空值、重复值、格式不统一、个别字段解析失败。我清洗的流程一般分四步,每一步都有明确的检查标准。
第一步是去重。虽然入库时已经按job_id做了主键,但分析前我还是会再用pandas的drop_duplicates跑一遍,防止历史数据里混进脏记录。第二步是处理薪资空值,salary_low为None的记录,要么直接丢弃,要么用同一岗位条件下的中位数填充,我倾向于丢弃,因为样本量足够大时,丢掉几条不会影响整体分布。第三步是统一城市和行政区划的命名,接口返回的数据里有重复别名的情况,需要做一层映射。第四步是转换标签字段,数据库里存的labels是JSON字符串,分析时要用json.loads还原成列表,再做展开。
做完这四步,才算得到一张可以放心分析的干净表。这一步不能省,直接拿原始表做统计,你会发现各种诡异的结论,比如某城市平均薪资100K,细查发现是几条“面议”被正则解析成了异常值。
5.2 薪资分析与岗位画像
清洗完之后,就可以做真正的“数据洞察”了。我最常做的两个分析是薪资分布和岗位画像。
薪资分布的核心指标是月薪中位数和年薪中位数。月薪中位数可以由(salary_low+salary_high)/2算出区间中值代表,年薪中位数再乘以month_count。我通常会按城市分组,统计各城市的薪资中位数和样本量,这样能比较清晰地看到不同城市同一岗位的薪资水位差异。
岗位画像这块,我把jobLabels字段展开后做词频统计,比如Python岗最常见的要求是“团队协作”“MySQL”“Redis”“分布式”,这些标签的词频排序基本就是岗位能力模型的素描。还可以做交叉分析,比如不同经验要求下的学历门槛对比、不同融资阶段公司的薪资差异等。这些分析用pandas的groupby加agg就能完成,代码量不大,但能挖出很多有意思的结论。
5.3 可视化展示方案
数据分析的最终呈现要靠图。我用的组合是pandas做计算,pyecharts画交互图,wordcloud画词云。
可视化这块最大的坑是中文乱码。pyecharts默认对中文支持还行,但wordcloud一定要指定中文字体路径,否则画出来全是方框。其次是图表的颜色和布局,不要用默认配色,稍微调一下,呈现效果会专业很多。
6. 常见问题与排查技巧实录
6.1 高频报错速查表
顺着整个项目的开发过程,我遇到的报错基本可以汇总成下面这张表。这里直接给大家一个排查方向,能省掉不少翻代码的时间:
| 报错现象 | 可能原因 | 解决方案 |
|---|---|---|
| 请求返回空JSON | Cookie失效或__zp_stoken__校验失败 | 重新用Playwright获取最新的Cookie,再回灌给requests |
| 连续几个请求后返回302 | 触发频率限制或IP风控 | 降低请求频率,增加随机延时,检查是否需要换网络环境 |
| 页面跳转到安全验证 | 行为特征被识别 | 暂停一段时间,避免同一IP高并发,改用低频率采集 |
| DOM里的数字是乱码 | 字体反爬生效 | 改用接口返回的JSON字段,别解析DOM |
| 薪资字段解析出None | “面议”等特殊值 | 统计前先过滤None,或单独标记 |
| 403 Forbidden | Cookie过期或IP被限制 | 检查登录态,必要时重启Playwright重新获取Cookie |
6.2 独家避坑经验
上面那些是看得见的报错,下面说几个“看不见的坑”,是我实际操作中总结出来的经验,常规文档里很少会写。
第一个坑是Cookie的失效时间比你想的快。我一开始以为从Playwright里拿到Cookie就能一劳永逸,结果第二天跑就发现接口返回异常。后来我养成了一个习惯:每次任务开始前先发一个轻量请求探一下Cookie是否有效,无效就自动用Playwright重新获取。这个“探活”机制让整个爬虫的稳定性提升了不止一个档次。
第二个坑是并发数真的别开太大。网上很多教程喜欢上来就开ThreadPoolExecutor,50个并发刷页面。我试过,跑不了几页,IP就被风控了,而且整段网络出口都受影响。后来我把并发降到2-3个,配合3-6秒的随机间隔,反而跑得最久最稳。爬虫的本质是慢工出细活,速度是次要目标,稳定才是第一位的。
第三个坑是日志一定要打。个人项目很容易忽视日志,但爬虫一旦报错,如果没有日志,你根本不知道是哪个请求失败、失败了几次、触发了什么风控。我后来加了简单的logging,每次请求失败、重试、触发风控都记录一行,排错效率瞬间翻了倍。这个习惯,算是那次爬虫翻车之后最值钱的教训。
7. 写在后面:两个我还在坚持的方向
7.1 从单机脚本到定时任务
项目跑通之后的第一个扩展方向,是把脚本从“手动运行”升级成“定时任务”。我自己的做法是用操作系统的计划任务,每天固定时间跑一次增量采集,凌晨的频率比较低,对目标站点也更友好。定时任务跑起来以后,表里的数据会一天天积累起来,这时候你会发现历史数据比单次快照的价值高得多。
举个例子,你可以对比两个月前和现在同一批岗位的薪资中位数,看看市场是涨是跌;也可以看某个城市的岗位数量变化趋势,判断招聘需求的冷热。这种分析完全依赖长期积累,跑一天看不出什么,跑两个月就有说服力了。这也是我坚持把采集脚本做成常驻任务的原因。
7.2 从岗位信息到人才供需洞察
第二个方向是数据挖掘的纵深拓展。岗位数据本身只是招聘市场的冰山一角,但它背后能推断出很多有趣的东西:某个技能标签在近半年是升温还是降温、不同城市同一岗位的薪资增速如何、中小企业和大厂的学历门槛差异有多大。这些洞察不需要高深的算法,就是把爬虫采集的字段做交叉组合,就能输出一份很有价值的行业观察。
说实话,爬虫做得越多,我越觉得技术本身不是最难的,最难的是知道数据从哪里来、能回答什么问题、以及边界在哪里。这个项目最让我满意的不是跑通了多少个接口,而是它让我把“采集-清洗-分析-展示”这整条链路的每个环节都用滚瓜烂熟的方式理解了一遍。如果你也在折腾爬虫,建议别只盯着数据本身,多想想数据背后能说明什么,那才是这个项目真正值钱的地方。