最近有朋友问我,能不能用Python把Boss直聘上的招聘薪资拉下来,做一份行业分析。说实话,这个话题踩线很多,但作为爬虫技术的学习项目,其实很有代表性。我花了两个周末把整条链路跑通,从页面分析、数据解析、字段清洗到最终生成可视化报告,全部用Python手搓完。今天就完整聊聊这个“Python爬虫实战:手搓Boss直聘行业薪资报告抓取神器”的思路、代码和坑。
先给结论:这个项目能做什么?它能把某个城市、某个岗位下的公开招聘信息(岗位名、公司、薪资区间、经验要求、学历要求)批量保存成结构化数据,再自动统计分析出分位数、平均值、学历分布等,输出一份可交互的行业薪资报告。适合正在学爬虫的同学、做招聘数据观察的HR、想了解市场行情的求职者。但前提是:一切以合规为前提,我今天演示用的都是模拟公开页面,不碰登录、不改验证码、不爬个人隐私。
1. 项目思路拆解:一份薪资报告是怎样诞生的
1.1 核心需求与目标定义
很多新手拿到需求就开始写代码,结果爬了两天发现数据乱七八糟。我习惯先拿一张纸,把目标拆成五个问题:
- 数据源是哪个平台?这里指Boss直聘的职位搜索页面。
- 抓取粒度是什么?只抓“某城市+某关键词”的前N页?还是全站?
- 要保存哪些字段?岗位名、公司名、薪资、经验要求、学历要求、技能标签、发布时间。
- 报告要展示哪些维度?平均薪资、中位数、学历分布、经验分布、城市对比。
- 输出形态是什么?CSV、Excel、HTML报告,还是可视化图表?
我最终定的目标是:抓取“全国主要城市+Python开发”关键词下的公开职位列表信息,限定前20页,存储字段为岗位、公司、薪资区间、经验、学历。然后基于薪资字段做清洗,输出一份包含平均薪资、中位值、学历影响、经验分层的小报告。
为什么要做这一步?因为只有明确目标,后面才不会走偏。比如,如果只想要“平均薪资”,那么就不需要抓取职位详情页里的长描述,可以大大减小请求量和被封风险。如果想知道“学历对薪资的影响”,就必须抓取学历字段。目标越清晰,代码越简单。
1.2 技术选型:为什么是 requests + BeautifulSoup + pandas
有人会问,都用Python了,为什么不用Scrapy?我在这个项目里刻意选择了轻量方案,原因有三个:
一是数据规模不大。20个城市乘5个关键词乘20页,也就是几千条数据,用Scrapy的分布式调度完全是大炮打蚊子。requests循环请求完全跑得过来。
二是学习成本。Scrapy有框架约束,中间件、管道、Selector这些概念对刚入门的朋友不友好。我这份代码写完就是一个py文件,想看流程随时翻,每条请求干了什么都明明白白。
三是后续扩展方便。requests + BeautifulSoup + pandas 这套组合已经把“请求、解析、分析”三条链路覆盖了。真要换Scrapy,顶多是把requests部分换成Spider,解析逻辑完全复用。
需要说明的是,有些招聘页面是动态渲染的,这时候还需要Selenium或Playwright。但我发现,很多列表数据其实是AJAX接口返回的JSON,直接用requests就能拿到,不一定非得上浏览器。后文会展开讲。
1.3 法律边界与合规前提
这一节必须放在最前,因为爬虫最大的坑不是技术,是法律和道德。
我只做两件事:第一,遵守目标网站的 robots.txt 和用户协议;第二,控制请求频率,不对目标平台造成压力。Boss直聘是一个商业招聘平台,它有很完整的反爬机制,也有明确的用户协议。个人为了学习去爬公开的职位列表,可以当作技术练习,但绝对不能大规模抓取、不能突破登录机制、不能采集用户隐私、不能用于商业盈利。
所以我的代码里会有time.sleep(),会有随机User-Agent,会在遇到验证码时自动停下来,而不是去“破解”。如果你看到哪个教程教你绕过验证码、伪造登录态、搞IP池,直接关掉,那是在教你违法。
2. 核心细节解析:从网页到结构化数据的完整链路
2.1 数据结构分析与定位
写爬虫最花时间的不是写代码,而是搞懂页面数据在哪里。打开Boss直聘的搜索页面,用浏览器F12开发者工具先看Network面板,刷新一遍,一个一个请求看过去。
如果数据在HTML源码里,那用 BeautifulSoup 或 XPath 就能提取。比如职位列表中的每个卡片,结构可能类似:
<div class="job-card"> <span class="job-name">Python开发工程师</span> <span class="salary">25-40K·16薪</span> <span class="company">某科技公司</span> <span class="experience">3-5年</span> <span class="education">本科</span> </div>这里的class是模拟的,真实页面会复杂很多,但思路一样:定位包含职位卡片的父节点,然后逐个提取子节点的文本。
如果数据是AJAX加载的,Network面板里会有一个返回JSON的请求,里面可能直接是:
{ "data": { "list": [ { "jobName": "Python开发工程师", "salary": "25-40K·16薪", "experience": "3-5年", "education": "本科" } ] } }这种结构更好,直接response.json()然后循环列表就行。抓取前一定要先弄清楚这两者,避免写了半天解析HTML才发现数据在接口里。
2.2 请求头与反爬应对的基本原理
网站的服务器会检查HTTP请求头,特别是User-Agent。如果直接用Python默认的python-requests/2.x,很多站点会直接返回403。所以我们需要伪装成真实浏览器。
最基础的做法是构造一个请求头:
headers = { "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/", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9" }这里有两个点要解释。Referer告诉服务器你是从这个页面点进来的,某些接口会校验它。Accept表示你期望返回JSON格式,这能避免拿到一堆无用的HTML。
除了请求头,访问频率是另一个关键。很多反爬是基于频率的,比如同一IP每秒请求超过5次就暂时封禁。我自己的策略是:每抓一页后随机睡眠2到5秒,并把每次请求打印到控制台。这样既不会影响平台,也方便观察进度。
如果遇到更严格的反爬,比如滑动验证码,我会直接停止程序。技术只能解决技术问题,解决不了授权问题。那种“绕过验证码”的方案,风险太高,不在讨论范围内。
2.3 薪资字段的清洗与归一化
原始页面返回的薪资样式五花八门:20-30K、15-25K·14薪、8-12K、薪资面议。如果想做统计,必须先把字符串变成数字。
我的清洗规则是:
- 从字段中提取最低薪资和最高薪资,单位统一换算成“千元/月”。
- 如果有
·13薪或·14薪,计算年薪时乘以对应倍数。 - 遇到
薪资面议,作为空值处理,不参与统计。 - 如果只有单值,比如
15K以上,当作最低薪资等于最高薪资处理。
用正则表达式可以这样写:
import re import pandas as pd def clean_salary(text): if not text or '面议' in text: return None, None, None text = text.replace('K', '').replace('k', '') # 处理类似 20-30·14薪 的情况 nums = re.findall(r'\d+', text) if len(nums) >= 2: low = float(nums[0]) high = float(nums[1]) elif len(nums) == 1: low = high = float(nums[0]) else: return None, None, None # 处理月薪倍数 month_match = re.search(r'(\d+)薪', text) months = int(month_match.group(1)) if month_match else 12 return low, high, months为什么要拆成最低、最高、倍数三个字段?因为后续分析可以算“平均低薪”“平均高薪”和“平均月薪”。如果直接取中间值,也会损失分布信息。清洗完后的DataFrame我一般长这样:
| 岗位 | 公司 | 低薪(K) | 高薪(K) | 月数 | 经验 | 学历 |
|---|---|---|---|---|---|---|
| Python开发 | A公司 | 25 | 40 | 16 | 3-5年 | 本科 |
有了这个表,生成报告就是几行pandas聚合的事了。
3. 实操过程:从零手搓抓取工具
3.1 第一步:搭建环境与请求封装
我记得当时建了一个独立目录,用venv创建虚拟环境,避免污染系统Python。安装依赖:
pip install requests beautifulsoup4 lxml pandas openpyxl matplotlibopenpyxl负责写Excel,matplotlib负责画图。如果只想生成HTML报告,可以不装matplotlib,改用Pyecharts也行。
然后我写了一个简单的请求函数,集中处理超时、重试和日志:
import requests import time import random def fetch_page(url, headers, retries=3): for i in range(retries): try: resp = requests.get(url, headers=headers, timeout=8) if resp.status_code == 200: return resp elif resp.status_code in (403, 429): print(f"[Warning] 状态码 {resp.status_code},等待更长时间") time.sleep(20 * (i + 1)) else: print(f"[Info] 状态码 {resp.status_code}") except requests.exceptions.RequestException as e: print(f"[Error] 第 {i+1} 次请求失败: {e}") time.sleep(5) time.sleep(random.uniform(2, 5)) return None这里有一个我踩过的坑:timeout必须设置,否则某个请求卡住,整个程序会一直等下去。重试机制也不能少,偶尔一次网络波动很正常。
3.2 第二步:数据解析与抓取循环
封装好请求后,解析逻辑就是重头戏。如果页面返回的是HTML,我的做法是先定位所有职位卡片:
from bs4 import BeautifulSoup def parse_list_html(html): soup = BeautifulSoup(html, "lxml") items = [] cards = soup.select("div.job-card") for card in cards: job = card.select_one(".job-name") salary = card.select_one(".salary") company = card.select_one(".company") experience = card.select_one(".experience") education = card.select_one(".education") if not job or not salary: continue items.append({ "职位": job.get_text(strip=True), "薪资": salary.get_text(strip=True), "公司": company.get_text(strip=True) if company else "", "经验": experience.get_text(strip=True) if experience else "", "学历": education.get_text(strip=True) if education else "", }) return items如果平台返回的是JSON,解析就更简单了,不过要特别注意字段是否存在。很多招聘列表接口会时不时少字段,所以我在代码里都用dict.get()而不是直接dict["xxx"],否则遇到空值会直接崩溃。
抓取循环我按“城市-关键词-页码”三层来组织:
cities = ["北京", "上海", "广州", "深圳", "杭州"] keywords = ["Python", "爬虫", "数据分析"] for city in cities: for keyword in keywords: for page in range(1, 21): url = f"https://www.zhipin.com/web/geek/job?query={keyword}&city={city}&page={page}" resp = fetch_page(url, headers) if resp is None: break items = parse_list_html(resp.text) all_data.extend(items) print(f"[{city}] {keyword} 第{page}页 获取到 {len(items)} 条") # 随机睡眠,避免频率过快 time.sleep(random.uniform(3, 7))注意这里我没有写Cookie,因为如果目标是公开页面,不带Cookie也能拿到部分数据。如果你的目标需要登录,那么抱歉,需要先获得授权,而不是通过爬虫硬闯。
3.3 第三步:生成Excel和可视化报告
数据积累多了,就到了最有意思的环节。先做清洗,再算统计量。
df = pd.DataFrame(all_data) df[["低薪", "高薪", "月数"]] = df["薪资"].apply(lambda x: pd.Series(clean_salary(x))) df["平均月薪"] = (df["低薪"] + df["高薪"]) / 2 df["年薪"] = df["平均月薪"] * df["月数"]之后生成基本统计:
report = df.groupby("城市")["平均月薪"].agg(["count", "mean", "median", "min", "max"])更多时候我会按学历、经验维度再交叉统计一次,看看本科生和研究生平均薪资差多少,3年经验是不是薪资翻倍。
可视化我用matplotlib画了三个图:
- 各城市平均薪资柱状图
- 经验年限与平均薪资折线图
- 薪资分布直方图
核心代码很短:
import matplotlib.pyplot as plt city_salary = df.groupby("城市")["平均月薪"].mean().sort_values() city_salary.plot(kind="barh") plt.title("各城市Python岗位平均薪资") plt.xlabel("平均月薪(K)") plt.tight_layout() plt.savefig("salary_by_city.png")跑完之后,我把所有统计结果汇总到一个Excel的不同Sheet里,再用pandas的to_excel输出。Excel比CSV更适合给非技术朋友看,加个筛选就能自己挖数据。
3.4 第四步:调度与增量更新
一份报告只跑一次价值有限。我后来加了定时任务,让脚本每周自动跑一次,并把新数据追加到历史表。采集时用“职位+公司+薪资+城市”作为唯一键,重复的跳过,这种增量逻辑可以避免数据膨胀。
如果用Python自带的time.sleep做调度,不够优雅。推荐APScheduler:
from apscheduler.schedulers.blocking import BlockingScheduler def job(): run_spider() generate_report() scheduler = BlockingScheduler() scheduler.add_job(job, 'cron', day_of_week='mon', hour=9, minute=0) scheduler.start()定时任务跑的时间要避开平台高峰,我更建议凌晨执行。但这里有个现实问题:个人电脑不能保证7x24小时开机。所以如果你真想长期用,租一台云服务器,用nohup或crontab挂起来更靠谱。不涉及公网IP,也不涉及任何敏感操作,纯粹是定时跑脚本。
4. 常见问题与排查技巧实录
4.1 请求被拒绝或返回非200
新手最常遇到的就是403。看到这个状态码,先别急着怀疑IP被拉黑,第一件事检查请求头是否完整,尤其是User-Agent和Referer。我遇到过很多次,只加了UA却忘了Referer,导致接口认为请求来源异常。
第二个可能是请求频率太高。程序突发奇想连续发了十几条请求,触发反爬阈值。这时候程序应该自动暂停,而不是马上换API重试。我自己的经验是:休息5分钟后再继续,多半就能恢复。
如果仍然403,还有可能是页面结构变了,参数失效。打开浏览器重新看一下地址栏的URL,确认参数名、页码位置是不是还和代码里一致。这种问题没有捷径,只有对比抓包。
4.2 页面结构变化导致解析失效
爬虫的天敌不是反爬,是改版。隔三差五,页面里的CSS类名就会变,之前写的.job-card选不到任何元素。为了避免一夜之间全盘崩溃,我在解析函数里加了“空结果警告”:如果某页解析出来的职位数只有0,就打印提示,而不是继续往下跑。
更稳妥的做法是引入一个简单的巡检逻辑:每抓5页,统计当前关键词有多少条数据;如果连续2页都是0条,就发一条警告日志。这样至少能知道是平台改版还是关键词真的没职位。
结构变化的另一个应对思路是解析尽量用结构化字段,比如用>resp.encoding = resp.apparent_encoding
但这招在JSON接口上不好使,因为apparent_encoding可能把UTF-8识别成其他编码。对JSON,我一般用resp.json()之前的resp.text看一眼,如果中文正常就没问题。
脏数据主要出在薪资字段,比如“面议”“15K以上”“250元/天”这种,清洗函数不能一把梭。我会额外判断字符里有没有“元/天”或者“元/时”,如果有,先把单位换算成月薪再进入主流程。否则统计出来的平均薪资会异常低。
4.4 遇到验证码时的处理原则
这是很多爬虫新手最纠结的点。我的看法是:验证码是平台的边界,不是挑战题。如果程序跑着跑着检测到需要输入验证码,那就说明你的访问行为已经引起了平台注意。正确做法是立即停止、等待一段时间,或者干脆换一个时间再跑。
千万不要去找“打码平台”或学习“滑动验证码绕过教程”。这类操作轻则封IP,重则承担法律责任。我自己工作里做数据采集,如果是给公司做竞品分析,一定先去确认是否有官方开放平台或合作通道;如果拿不到合法数据源,就宁可放弃这个需求,也不能硬来。
验证码出现后,也可以检查是不是自己代码里某个循环写错了,导致同一个页面重复请求了上百次。先修复代码逻辑,再重新跑,往往比任何绕路技巧都有效。
5. 经验总结与扩展方向
5.1 实战中我的心得体会
这个项目跑通后,我最大的感受是:真正有价值的不是爬虫部分,而是数据清洗和分析部分。很多人以为爬下来就结束了,结果发现数据质量差、字段缺失、口径不统一,根本没法用。我在清洗阶段花的时间,比写爬虫多一倍。所以建议一开始就设计好字段结构,把低薪、高薪、月薪倍数拆开,不要糊成一锅。
还有一个体会是,日志和异常处理的重要性。第一期代码我什么日志都没打,程序跑到一半死了,完全不知道断在哪。后来加了一行print才发现是某个城市没有职位数据,列表返回为空,导致解析代码报了IndexError。现在每抓完一页都会打印当前城市、关键词、页数和条数,排查问题效率高很多。
最后想说的是:做一个“抓取神器”之前,先想清楚数据用来做什么。如果只是为了满足好奇心,随便写个小脚本就够了。如果要持续生产一份报告,那代码的稳定性、可维护性、合规边界才是核心。
5.2 后续还可以怎么扩展
现在这个项目已经能输出简单的城市薪资报告,但我看到的需求还远不止这些。
一个方向是加“岗位方向过滤”。比如Python下属有爬虫、后端、数据分析、机器学习,每个方向的薪资差异很大。可以把关键词列表细化,再对比不同方向在同一城市的薪资中位数。
另一个方向是把历史数据累积起来做趋势分析。比如每月跑一次,存到SQLite,半年后就能画出“2024年6月到2024年12月Python岗位平均薪资增速”,这对求职者选择赛道很有参考价值。
还可以做一个简单的Web展示页面。用Flask把统计结果渲染成图表,挂在内网,给朋友或同事看。前端用ECharts,后端直接从pandas生成JSON,代码量不大,但呈现效果直接提升一个档次。
如果数据量足够大,甚至可以尝试用机器学习建模,比如根据城市、学历、经验、公司规模来预测一个岗位的大致薪酬区间。这个就属于“薪资预测模型”了,但前提是你必须拥有合法采集到的、足够干净的数据。
总的来说,这类项目很适合Python学习者深入练习,但要注意安全合规的底线。技术可以酷,但不能越界。