☰
招聘薪资爬虫实战:从requests到pyecharts的全链路解析
2026/10/9 20:57:15 网站建设 项目流程

先说个挺真实的场景。上个月我想评估一下不同城市Python开发岗的真实薪资水平,招聘App上翻了几十页,职位信息倒是多,但全挤在手机屏幕里,城市、经验要求、薪资范围这些字段根本没法横向对比。我当时脑子里冒出的第一个念头就是:这活儿该交给爬虫干。于是就有了这个项目——手搓一个针对招聘平台的薪资数据抓取工具,最终聚合出一份可以按城市、经验、薪资区间交叉分析的“行业薪资报告”。

这个项目适合谁参考?一是刚学完Python基础、想找真实项目练手的爬虫初学者,二是做招聘数据观察、人力分析这类工作,想快速低成本获取一批公开职位数据的从业者。技术栈很朴素:requests发请求管理会话,XPath选节点,pandas做清洗,pyecharts出图。难度控制在中等偏下,重点不在代码多高级,而在完整走通“请求-解析-清洗-分析-可视化”这条链路。下面我从选型、合规边界到实战细节一条条说清楚。

1. 为什么选择手搓爬虫:现成工具与真实需求的落差

1.1 通用爬虫工具在招聘网站面前的优势与短板

很多人一听说要抓招聘数据,第一反应是找个现成的可视化爬虫工具——后羿采集器、八爪鱼这类。这些工具对付静态页面确实省事,鼠标点几下就能把表格数据导出来。但遇到招聘网站这种有登录态、有动态接口、有反爬校验的站点,其配置过程会变得非常痛苦:你得在界面里一步步点出“登录后采集”“翻页规则”“字段映射”,稍有不慎就抓漏一列,排查起来反而比写代码更麻烦。

更关键的是,通用工具很难处理“薪资范围”这类非标准字段。同一页面上“20-40K·15薪”和“面议”并存,工具只能原样导出,后续还得自己加工。而用代码写,解析规则完全可控,遇到异常字段随时可以加逻辑兜底。对于“最终要生成报告”这种需求来说,灵活性就是核心竞争力。

1.2 我的技术选型:requests + lxml + pandas 的理由

选型这件事,我建议别贪多。起初我也动过用Scrapy的念头,框架成熟、调度器完善,但对于单机、小规模的爬取任务,Scrapy的组件和中间件体系反而显得重。最终我选了四件套:

  • requests:处理HTTP请求和会话保持。它足够轻量,Cookie管理也比较顺手,一个Session对象就能维持登录态。
  • lxml + XPath:解析HTML和提取字段。相比BeautifulSoup,lxml的解析速度明显快,XPath的表达式系统在处理“要找某一类节点”的场景时很直观。
  • pandas:清洗和聚合数据。薪资文本转数值、按城市分组算中位数,这些操作用DataFrame做几行代码就搞定。
  • pyecharts / matplotlib:可视化输出,生成柱状图、饼图、城市薪资对比图。

这套组合的好处是每一层都可以独立替换。比如你不想用pyecharts,换成Plotly也行,不影响前面的采集和清洗逻辑。

1.3 最终目标拆解:从网页到“行业薪资报告”的关键路径

整个项目看起来复杂,但拆开其实就五个环节:

  1. 定位数据接口:找到列表页背后的XHR请求,拿到返回JSON的地址和参数。
  2. 构造请求:带上必要的Headers,维持会话,控制请求频率。
  3. 字段提取:从JSON或HTML中取出岗位名称、城市、薪资、工作经验等字段。
  4. 数据清洗:把“20-40K·15薪”这种文本拆成可计算的数值。
  5. 聚合分析:按城市/经验维度算中位数或平均值,出图表。

把这条路径理清楚,后面每一步都是在填具体的坑。

2. 动手之前的合规边界:把项目当学习项目而不是风险项目

2.1 不碰个人敏感信息,只取公开职位字段

在敲第一行代码之前,我花了不少时间想清楚什么能抓、什么不能碰。招聘网站上的职位名称、薪资范围、公司所在城市、工作经验要求,属于企业公开发布的职位信息,用于求职者筛选,属于合理的公开数据范畴。但求职者的简历、联系方式、个人主页、用户ID这类信息,属于个人数据,绝对不碰。这个边界必须在一开始就立住。

我给自己定的采集范围就是一个白名单:岗位标题、薪资描述、城市、工作经验要求、学历要求。连公司名称我都尽量做脱敏处理,只在内部关联分析时用,不会存到任何公开仓库里。

2.2 控制节奏和总量,避免对目标站点造成压力

爬虫对目标站点的压力是实实在在的。一个正常的招聘网站每天有几十万次访问,你多几十次没问题,但如果你用并发20、间隔0.1秒去刷,服务器日志里很快就露馅了。

我做项目时给自己设了几个硬指标:

  • 请求间隔不低于0.8秒,最好随机到1到2秒之间。
  • 不设多线程,单线程跑,最多加一个简单的限速器。
  • 采集总量控制在一个较小样本范围,比如只抓前5页、每页15条,总共几百条数据。
  • 只在工作时间之外的低峰时段跑,避免影响正常用户访问。

这些限制不会让项目“不够酷”,但能让它不惹事。学习爬虫的目的是理解HTTP、理解网络数据交换,不是把目标站点的资源耗尽。

2.3 学习角度看待反爬:理解防护才能真正学会防护

网上经常有人问“Java的Controller层如何防爬虫”“前端怎么防止查看源码”,这些问题背后其实是同一种诉求——理解爬虫的行为特征,才知道从哪拦截。我对这个项目的定位是安全学习视角:分析招聘网站为什么设置反爬、它是怎么识别机器行为的,这比单纯“抓到数据”有价值得多。

所以本文后面讲到的Headers伪装、频率控制、验证码触发条件等,我都尽量从“如果我写后端接口,我会怎么拦”的角度来讲。理解了攻击者的思路,才能设计出更稳的防护策略。

3. 请求层的攻防细节:Headers伪装、会话保持与接口定位

3.1 浏览器是怎么发起请求的,爬虫就要怎么发

爬虫最容易翻车的地方不在解析,而在请求阶段就被识别。招聘网站的服务器会在请求到达Controller层之前就做一次基础体检:User-Agent是不是常见的浏览器、Referer是不是本站页面、请求头里有没有关键的Accept-Language。

我第一次跑项目时,直接用默认的requests头,几秒钟就被拒了——返回的response里连登录页都不是,直接给了一个安全校验页。后来我把浏览器开发者工具里Network面板看到的请求头完整复制过来,问题就解决了。核心代码如下:

import requests from requests.adapters import HTTPAdapter session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://www.zhipin.com/web/geek/job", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }) session.mount("https://", HTTPAdapter(max_retries=2))

有人觉得Referer这种细节无所谓,其实它恰恰是后端判断“请求从哪来”的重要依据。招聘平台的搜索页跳转到列表页时,真实请求的Referer一定是站内页面,不会是空的。后端Controller层拿到一个Referer为空的请求,可信度立刻下降一个层级。

3.2 绕过列表页:直接在XHR接口里拿结构化JSON

很多招聘网站的职位列表不是后端渲染的HTML,而是前端通过XHR请求获取JSON数据再渲染。这意味着我们不需要去解析难看懂的动态HTML,直接找到背后的XHR接口就行。

操作方法很简单:打开浏览器开发者工具的Network面板,切到Fetch/XHR分类,在网页上点下一页,观察哪个接口跟随页码变化返回数据。通常这类接口的响应体里就藏着岗位名、薪资字段、城市ID等结构化的JSON数据,而且字段名往往比页面DOM更规范。

我从接口拿到的数据长这样:

{ "code": 0, "data": { "list": [ { "jobName": "Python高级开发工程师", "salaryDesc": "20-40K·15薪", "cityName": "上海", "jobExperience": "3-5年", "jobDegree": "本科" } ], "total": 100 } }

直接解析JSON,避免了XPath在小程序页面里的复杂度,这是本项目的首选路径。不过要提醒一句:接口字段名可能会随时调整,上次还是salaryDesc,下次更新可能变成salaryShow。代码里要考虑字段不存在时的兜底逻辑。

3.3 安全防护者视角:如果我是平台方,我会怎么拦

站在后端开发的角度,我接到“如何防护爬虫”这个问题时,第一时间会想几个点。Controller层最基础的防护是频率限制——用Guava RateLimiter或者Redis记录每个IP单位时间内的请求次数,超过阈值就直接返回503或者弹验证码。然后是Headers校验:User-Agent是否合法、Referer是否站内、Cookie中是否有真实的登录会话。

招聘平台比较狠的一招是“行为特征分析”:正常用户会在某个页面停留几十秒甚至几分钟,爬虫则是毫秒级跳转。所以即使Headers伪装得天衣无缝,只要请求间隔过短,后端从访问日志里也能识别出异常。理解了这些,你在写爬虫时就会主动控制频率——这恰恰是最高效的“保命”手段。

4. 页面解析的正确姿势:XPath文本提取与JSON解析的取舍

4.1 XPath的text()与contains()实际用法

虽然接口JSON是首选,但总有些数据只有页面渲染后才有,例如某些单独的职位详情页,字段是写在HTML里的。这时候XPath就派上用场了。

XPath里最常用的两个函数是text()和contains()。text()用于提取节点内的纯文本,contains()用于按文本内容模糊匹配节点。比如我要提取岗位标题和薪资:

from lxml import html doc = html.fromstring(page_source) job_title = doc.xpath("//div[contains(@class, 'job-title')]//text()")[0].strip() salary = doc.xpath("//span[contains(@class, 'salary')]/text()")[0].strip()

注意一个问题:XPath返回的是列表,哪怕只有一个元素也是列表。直接取下标的写法在元素不存在时会抛IndexError,我一般会先判断列表长度,或者用try/except包一层。这个坑我踩过很多次,尤其在不同页面结构不一致时。

4.2 接口JSON解析:比XPath更省事,但依赖字段稳定性

对列表页这种有XHR接口的场景,我强烈建议优先解析JSON,理由有三点。第一,JSON是键值对结构,不需要关心页面样式变化;第二,接口返回速度远快于下载整个HTML;第三,JSON里的数值字段往往是干净的原始值,不需要像HTML那样做文本清洗。

但JSON解析也有隐患:字段名不稳定。同一个招聘平台,版本升级后可能把cityName换成city_name,也可能整体结构从list套一层变成list2。所以我在解析JSON时写了一个兼容函数,用get方法逐个尝试候选字段:

def extract_field(item, candidates): for key in candidates: if item.get(key): return item.get(key) return None

这样即使平台改了字段名,代码也能尽量保持稳定。

4.3 招聘页面的“反爬混淆”:字体加密与字段错位

招聘类网站里有一类比较典型的反爬手段叫“字体加密”——页面里的数字和关键文字显示正常,但复制出来是乱码,因为页面使用了自定义字体,把看到的“20K”映射到另一个字符编码上。直接从HTML里用XPath提取文本,拿到的是被映射后的乱码。

遇到这种情况,我的处理原则是:不硬刚。字体加密这种手段的目标就是防止机器直接读取页面文本,除非你花大精力去分析字体映射文件,否则性价比极低。更聪明的做法是回到XHR接口——字体加密主要作用于HTML渲染层,接口JSON里的原始数字一般还是正常的。如果接口也被加密,那说明平台确实下了血本,这时候我会考虑换一个数据源,而不是跟它死磕。

5. 反爬拦截的实战应对:验证码、访问频率与登录态

5.1 频率控制:最容易被忽略但最有效的“保命”手段

很多人被抓了以后第一反应是“换个IP”,但实际上对小型学习项目来说,频率控制比换IP重要得多。招聘平台的封禁策略通常是先警告后降级,频繁触发会从“验证码偶尔出现”升级到“所有请求强制验证”,这个坑我踩得很实在。

我的实现方式是写一个简单的限速装饰器,保证任意两次请求之间至少间隔1秒,并在此基础上加一点随机抖动。随机抖动很重要,因为固定1秒的间隔是机器行为特征,服务器完全可以通过时间戳分析识别出来。真实用户的操作间隔不会有那么精确的节拍。

import time import random def throttle(func): def wrapper(*args, **kwargs): time.sleep(random.uniform(1.0, 2.0)) return func(*args, **kwargs) return wrapper

5.2 验证码与安全验证:不硬刚,摸清触发条件

招聘平台的反爬体系里,验证码和安全验证是常见的“中级惩罚”。触发条件通常有这么几类:请求频率超过阈值、User-Agent或Headers明显异常、缺少登录态却高频访问需要登录的接口。

我在项目里遇到过一次验证码,当时的处理不是去找识别方案,而是反推触发原因。排查之后发现是我在测试时开了两段代码同时跑,原本设置的间隔被打破了。修正频率后,验证码就再也没有出现过。所以我给读者的建议是:遇到验证码先别想着怎么绕过,停下手里的循环,看看自己是不是违反了“人的操作节奏”。硬刚验证码只会加重平台对你的限制,最后连正常访问都进不去。

5.3 从后端防护者视角看爬虫:Controller层限流与校验

我在热搜词里看到“java controller层如何防护防止爬虫”,这个话题值得展开。作为后端开发者,我在写接口时经常会做以下几层校验:

  • 频率维度:针对IP做单位时间内的请求计数,超过阈值直接丢弃或降级。
  • Headers维度:校验User-Agent的合法性、Referer是否来自本站页面。
  • 登录态维度:核心接口强制校验Cookie或Token,未登录请求一律403。
  • 行为维度:记录相邻请求的间隔分布、页面停留时长,通过统计特征识别机器行为。

理解这些之后,你在构建爬虫时自然会反过来约束自己:Headers做得像浏览器,请求间隔模拟真实用户,没有登录态就不去碰需要登录的接口。这种“攻防双方互相理解”的视角,才是爬虫学习最值钱的地方。

6. 从数据到报告:薪资清洗、区间换算与可视化

6.1 薪资字段的“脏数据”处理:从“20-40K·15薪”到数值

抓下来的数据很少是干净的数值。招聘网站里的薪资描述五花八门,“20-40K·15薪”“10-15K”“5千-8千”“面议”混在一起。要分析,必须先统一格式。

我的清洗思路是:先把文本里的数字区间提取出来,计算中位值,作为该岗位的“代表薪资”。比如“20-40K”取中位30K,“10-15K”取中位12.5K。对于“5千-8千”这种中文数字,需要先做一次单位换算,统一转成K为单位。用正则表达式处理这类文本非常方便:

import re import pandas as pd def parse_salary(text): if not text or "面议" in text: return None match = re.search(r"(\d+)(?:-(\d+))?([Kk万])?", text) if not match: return None low = int(match.group(1)) high = int(match.group(2)) if match.group(2) else low unit = match.group(3) or "K" if unit == "万": # 假设单位为月薪,转成K low, high = low * 10, high * 10 return (low + high) / 2

这个函数处理不了所有情况,比如年薪和月薪混写,但作为MVP完全够用。我建议你也在自己的项目里从简单规则开始,看到新格式再补充规则,而不是一开始就写一个完美万能的正则。

6.2 面议数据的处理策略

“面议”这个字段很麻烦。如果直接剔除,样本量会缩水;如果当0处理,统计结果又会被严重拉低。我采用的是“双轨制”:计算中位数和平均值时剔除面议,但在报告中单独统计“面议岗位占比”,用来观察哪些城市或岗位档位更倾向于面议。

实际分析中就发现,部分城市的高级岗位里“面议”占比明显偏高,这说明面议不等于低薪,反而可能代表高薪定制化。所以千万不能把面议直接记为0,那是典型的统计失真的反面教材。

6.3 可视化:中位数、城市对比与岗位分布

数据清洗完之后,我用pandas做了一次简单的分组聚合,按城市计算薪资中位数和岗位数量:

df = pd.DataFrame(records) df["salary_mid"] = df["salaryDesc"].apply(parse_salary) valid_df = df.dropna(subset=["salary_mid"]) city_stats = valid_df.groupby("cityName")["salary_mid"].median().sort_values(ascending=False).head(15)

然后直接用pyecharts画柱状图,中位数排前15的城市一张图就看明白了。pyecharts的优势是交互性好,鼠标悬停能看到具体数值。如果你偏好静态图片,matplotlib也够用。

6.4 横坐标太密之类的绘图小坑

热搜词里有人问“python画图横坐标太密集”,这个问题在画城市对比图时特别常见。城市名最多十几个,柱状图横坐标还可以承受;但如果你画的是“岗位名对比”,几十个标题挤在一起,x轴会糊成一团。

解决办法有两个:一是旋转标签,plt.xticks(rotation=45);二是采样显示,只展示前10或每隔N个显示一个。我更推荐后者,因为旋转只能缓解拥挤,信息量太大时依然看不清。

7. 踩坑实录:三个真实问题与完整排查链路

7.1 翻页参数“加密”导致的重复数据

项目做到一半,我发现翻页抓取时数据大量重复,后20页返回的岗位和前5页完全一样。起初我以为是解析逻辑出错了,后来把原始响应保存下来对比,才发现问题出在翻页参数上——列表接口的翻页参数不是简单的页码,而是由某个JS脚本动态计算出来的加密签名,直接传page=2会被服务端忽略,默默返回第一页的数据。

排查链路是:先怀疑解析层,再怀疑请求层,最后定位到参数构造层。这个问题的教训是:在动手大规模抓取之前,先用小样本验证“翻页之后数据内容是否真的变了”,别等到跑完几千条数据才发现全是一页的重复。

7.2 Cookie过期后请求返回登录页

还有一个坑是登录态过期。招聘网站的主要列表页默认需要登录,Session里的Cookie在几小时后会失效。失效之后,接口依然能返回状态码200,但响应体变成了登录页的HTML。由于请求对话没有报错,代码里如果不对内容做校验,你会拿到一堆无用的登录页数据。

我的排查方法是:在解析之前先检查响应内容里是否有“登录”这种关键字,或者直接检查响应URL是否被重定向到了登录地址。一旦检测到,就暂停采集,提醒自己手动刷新Cookie,而不是继续跑浪费时间的循环。

7.3 数据量不足时的统计失真与“面议”占比过高

最后一个坑来自数据分析层。最初我抓的数据量比较少,只有几十条有效薪资记录,画出来的城市中位数分布图特别离谱——某个城市只有一条45K的数据,就成了“全国最高薪城市”。这显然是统计失真。

解决办法是加了一个最小样本量过滤:每个城市至少有5条有效薪资记录才参与排序,不足5条的归入“其他”。同时把“面议”占比一起展示出来,避免读者只看中位数而忽略数据覆盖范围。这个小改动让报告的可信度高了一大截。

做完整个项目,我最深的体会是:爬虫的核心不是代码,而是对目标系统工作方式的理解——理解它怎么发请求、怎么存储数据、怎么设防,你才能真正平稳地把数据拿回来。建议你把抓取范围控制在一个很小的样本内,只做技术验证和个人学习使用,跑通链路后把代码里的关键参数抽成配置项,方便下次快速复用。最后分享一个小技巧:把每次请求的原始响应保存成文件,排查问题时效率能翻倍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询