
我一直觉得招聘网站是最适合拿来练爬虫靶场的平台之一数据真实、字段规整、覆盖城市广尤其是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?queryPythoncity101010100page1这里面的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, paramsparams, headersself.headers, timeout10) 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_asciiFalse), }这里最值得说的是薪资字段的处理。用户看到的是“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_lowsalary_high)/2算出区间中值代表年薪中位数再乘以month_count。我通常会按城市分组统计各城市的薪资中位数和样本量这样能比较清晰地看到不同城市同一岗位的薪资水位差异。岗位画像这块我把jobLabels字段展开后做词频统计比如Python岗最常见的要求是“团队协作”“MySQL”“Redis”“分布式”这些标签的词频排序基本就是岗位能力模型的素描。还可以做交叉分析比如不同经验要求下的学历门槛对比、不同融资阶段公司的薪资差异等。这些分析用pandas的groupby加agg就能完成代码量不大但能挖出很多有意思的结论。5.3 可视化展示方案数据分析的最终呈现要靠图。我用的组合是pandas做计算pyecharts画交互图wordcloud画词云。可视化这块最大的坑是中文乱码。pyecharts默认对中文支持还行但wordcloud一定要指定中文字体路径否则画出来全是方框。其次是图表的颜色和布局不要用默认配色稍微调一下呈现效果会专业很多。6. 常见问题与排查技巧实录6.1 高频报错速查表顺着整个项目的开发过程我遇到的报错基本可以汇总成下面这张表。这里直接给大家一个排查方向能省掉不少翻代码的时间报错现象可能原因解决方案请求返回空JSONCookie失效或__zp_stoken__校验失败重新用Playwright获取最新的Cookie再回灌给requests连续几个请求后返回302触发频率限制或IP风控降低请求频率增加随机延时检查是否需要换网络环境页面跳转到安全验证行为特征被识别暂停一段时间避免同一IP高并发改用低频率采集DOM里的数字是乱码字体反爬生效改用接口返回的JSON字段别解析DOM薪资字段解析出None“面议”等特殊值统计前先过滤None或单独标记403 ForbiddenCookie过期或IP被限制检查登录态必要时重启Playwright重新获取Cookie6.2 独家避坑经验上面那些是看得见的报错下面说几个“看不见的坑”是我实际操作中总结出来的经验常规文档里很少会写。第一个坑是Cookie的失效时间比你想的快。我一开始以为从Playwright里拿到Cookie就能一劳永逸结果第二天跑就发现接口返回异常。后来我养成了一个习惯每次任务开始前先发一个轻量请求探一下Cookie是否有效无效就自动用Playwright重新获取。这个“探活”机制让整个爬虫的稳定性提升了不止一个档次。第二个坑是并发数真的别开太大。网上很多教程喜欢上来就开ThreadPoolExecutor50个并发刷页面。我试过跑不了几页IP就被风控了而且整段网络出口都受影响。后来我把并发降到2-3个配合3-6秒的随机间隔反而跑得最久最稳。爬虫的本质是慢工出细活速度是次要目标稳定才是第一位的。第三个坑是日志一定要打。个人项目很容易忽视日志但爬虫一旦报错如果没有日志你根本不知道是哪个请求失败、失败了几次、触发了什么风控。我后来加了简单的logging每次请求失败、重试、触发风控都记录一行排错效率瞬间翻了倍。这个习惯算是那次爬虫翻车之后最值钱的教训。7. 写在后面两个我还在坚持的方向7.1 从单机脚本到定时任务项目跑通之后的第一个扩展方向是把脚本从“手动运行”升级成“定时任务”。我自己的做法是用操作系统的计划任务每天固定时间跑一次增量采集凌晨的频率比较低对目标站点也更友好。定时任务跑起来以后表里的数据会一天天积累起来这时候你会发现历史数据比单次快照的价值高得多。举个例子你可以对比两个月前和现在同一批岗位的薪资中位数看看市场是涨是跌也可以看某个城市的岗位数量变化趋势判断招聘需求的冷热。这种分析完全依赖长期积累跑一天看不出什么跑两个月就有说服力了。这也是我坚持把采集脚本做成常驻任务的原因。7.2 从岗位信息到人才供需洞察第二个方向是数据挖掘的纵深拓展。岗位数据本身只是招聘市场的冰山一角但它背后能推断出很多有趣的东西某个技能标签在近半年是升温还是降温、不同城市同一岗位的薪资增速如何、中小企业和大厂的学历门槛差异有多大。这些洞察不需要高深的算法就是把爬虫采集的字段做交叉组合就能输出一份很有价值的行业观察。说实话爬虫做得越多我越觉得技术本身不是最难的最难的是知道数据从哪里来、能回答什么问题、以及边界在哪里。这个项目最让我满意的不是跑通了多少个接口而是它让我把“采集-清洗-分析-展示”这整条链路的每个环节都用滚瓜烂熟的方式理解了一遍。如果你也在折腾爬虫建议别只盯着数据本身多想想数据背后能说明什么那才是这个项目真正值钱的地方。