首页 / 资讯中心 / 文章详情

Python爬虫实战:招聘数据抓取与薪资行情分析完整流程

Python爬虫实战:招聘数据抓取与薪资行情分析完整流程 ★ FEATURED ARTICLE
做招聘数据的爬虫我觉得是Python爬虫入门后最值得实战的一类项目。招聘网站的数据结构相对规整字段也有实际意义职位、薪资、城市、经验要求、学历要求、公司规模、行业标签抓下来之后用pandas一分析马上就能得出“某个城市、某个方向、几年经验的工程师值多少钱”这种有实际价值的结论。比单纯爬点新闻、小说有意思得多也更容易把爬虫的价值讲清楚。这篇文章把我做“招聘数据抓取与薪资行情分析”这套完整流程拆开来讲包括技术选型、环境准备、核心代码请求、解析、清洗、存储、薪资分析思路以及我在实战里踩过的反爬、编码、增量抓取相关的坑。适合已经掌握Python基础语法、想通过一个完整项目把requests、BeautifulSoup、pandas串起来的朋友也适合想了解爬虫项目从零到一全流程的初学者。1. 项目目标拆解招聘数据如何变成薪资结论1.1 招聘数据抓取的业务价值很多人学爬虫都纠结一个问题到底爬什么才有意义我的答案是招聘数据。招聘数据天然是“结构化程度高、时效性强、分析价值大”的数据源。在一个招聘网站上搜“Python开发”能拿到几百上千条职位记录每条记录里都有职位名称、薪资区间、城市、工作经验要求、学历要求、公司名称、公司规模、融资阶段、技能标签这些字段。随便组合一下就能输出有价值的结论。举个例子我抓完数据后做过一次最简单的分析把“Python后端开发”岗位按城市分组取薪资的中位数和75分位数马上就能得到一张城市薪资层次表。北京、上海明显高一档杭州、深圳紧随其后成都、武汉、南京、西安这些城市虽然均值低一点但结合房价和消费水平性价比其实不差。这种结论虽然不会精确到每一家公司但作为择业参考、行业调研素材或者课程选题完全够用。这也是我推荐拿招聘数据做爬虫项目的原因它不要求你懂很多业务背景只要能把数据抓下来、洗干净、聚合几个维度结论就自然浮出水面。整个过程能同时锻炼请求、解析、清洗、存储、分析五块能力是一条龙式的练手项目。1.2 为什么选requests而不是直接上Scrapy爬虫框架很多我见过不少朋友一上来就冲着Scrapy去结果被Item Pipeline、Middleware、Selector这些概念绕晕。做招聘数据抓取这种中小型项目我更推荐先用requests BeautifulSoup手写一遍。原因有三。第一招聘网站的数据量通常不需要分布式抓取一台机器、一个进程、控制好请求频率一两个小时就能抓完一个城市的核心岗位。用Scrapy属于杀鸡用牛刀配置成本反而拖慢进度。第二手写requests可以把HTTP请求、响应解析、数据清洗这些基本功练扎实后面再学Scrapy会有一种“原来框架只是把这些事情封装好了”的通透感。第三调试方便。写了请求头、参数、解析逻辑之后可以直接在Python交互环境里跑一下看返回结果对不对发现问题当场改不用反复折腾框架配置。如果后续数据量真的变大或者目标站点从几个扩展到几十个再把代码迁移到Scrapy或者接上爬虫管理平台也不迟。技术的选型从来不是“越复杂越好”而是“够用且可控”。1.3 业务目标拆解与字段设计开工之前强烈建议先把目标写清楚别上来就写代码。我把这个项目的目标拆成四个子任务按搜索关键词抓取岗位列表拿到职位ID、标题、公司名、薪资区间等基础字段。进入岗位详情页补全职位描述、技能要求、福利标签等结构化信息。对原始数据做清洗统一城市、薪资区间、经验要求这些字段的格式。用pandas做聚合分析用matplotlib出薪资分布图最后汇总成报告。字段设计上我给每条职位记录设计了这些核心字段字段名说明示例job_id职位唯一ID用于去重和增量100234567job_title职位名称Python后端开发工程师company_name公司名称某科技公司city城市清洗后北京salary_min月薪下限单位K15salary_max月薪上限单位K30experience经验要求3-5年education学历要求本科industry行业领域移动互联网company_size公司规模100-499人skills技能标签列表Python, Django, MySQLjob_desc职位描述文本负责后端服务开发...publish_date发布时间2025-01-10这里有个特别强调的点salary_min和salary_max一定要拆成两个数值字段不要存成“15-30K”这种字符串否则后面做聚合分析的时候还得二次清洗白白浪费工时。2. 开工前准备环境、合规与请求策略2.1 Python环境与依赖库安装这个项目的环境很简单Python 3.9以上版本就行。我自己用的是3.10建议至少3.8以上不然一些库的版本对不上容易在细节上浪费时间。依赖我用requirements.txt管理内容如下requests2.31.0 beautifulsoup44.12.2 lxml4.9.3 pandas2.0.3 matplotlib3.7.2 openpyxl3.1.2然后执行安装pip install -r requirements.txt如果你的机器上装了多个Python版本记得用virtualenv或conda建一个独立环境。我之前吃过亏系统Python环境里躺着各种旧版本的库requests是2.25pandas还是1.3一跑就是一片DeprecationWarning排查起来非常痛苦。独立环境虽然多两步操作但能让项目依赖干净可控后面换机器部署也省心。2.2 合规检查robots、公开数据与请求频率爬虫不是“能爬就爬”数据合规越来越严格。我的习惯是写任何爬虫之前先看目标网站的robots.txt弄清哪些路径允许爬、哪些明确禁止。很多招聘网站的robots.txt会标注允许访问的路径比如职位搜索页和职位详情页但用户中心、简历库这类涉及个人信息的路径基本都禁止。还需要注意爬虫只能抓取公开信息比如职位名称、公司名称、薪资范围、职位描述。绝不能抓取个人身份信息比如简历里联系人的姓名、手机号、邮箱、教育经历等敏感字段。这既是合规底线也是职业道德底线。项目实践里我把抓取范围严格限定在职位公开信息上不碰任何个人隐私数据。提醒一句职位名称、公司、薪资范围这些公开信息可以用但涉及个人身份的字段一律别碰这是爬虫项目绝对不能踩的红线。另外控制请求频率。我在代码里加了time.sleep(1)也就是每秒钟最多请求一次遇到异常响应还会自动退避。这样做既是对目标网站服务器的尊重也是防止IP被限制的有效手段。别为了追求速度用多线程并发打满对方接口这不是技术问题是想不想把项目做长久的问题。2.3 请求头与Session管理招聘网站普遍有反爬机制最基础的就是校验User-Agent和Referer。我建议用一个统一的Session管理请求把常用请求头设置好避免每次都单独写。import requests from fake_useragent import UserAgent session requests.Session() ua UserAgent() session.headers.update({ User-Agent: ua.random, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,image/apng,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive, Upgrade-Insecure-Requests: 1, })为什么用fake_useragent因为一直用同一个UA抓多了以后特征太明显容易被识别并拉黑。fake_useragent每次随机生成一个常见的浏览器UA让请求看起来更接近真实用户。这不算什么高深的反反爬技巧但确实是成本最低、效果最明显的一招。不过fake_useragent有个小坑它本地没有数据库时首次使用需要联网下载UA列表网络不通会直接报错。稳妥做法是先用它初始化一次或者干脆自己维护一个UA列表里面放上Chrome、Edge、Firefox近几个版本的UA用random.choice随机取。我在项目里两种方式都试过最终选择了内置UA列表稳定不受网络环境影响。3. 核心实现从列表页到详情页的完整抓取流程3.1 列表页请求与职位卡片解析招聘网站最常见的列表页结构是搜索关键词后返回一页一页的职位卡片每张卡片里包含大部分核心字段。我的思路是先用requests请求列表页再用BeautifulSoup解析卡片节点。先看请求部分。以某招聘网站的搜索页为例URL里通常带着关键词、城市、页码参数。我的代码如下base_url https://example.com/jobs/search params { keyword: Python, city: 101010100, # 北京的城市编码 page: 1, } resp session.get(base_url, paramsparams, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding这里有两个细节值得说。第一resp.encoding resp.apparent_encoding这句很重要因为有些招聘页面的charset声明不标准直接用resp.text会有乱码。apparent_encoding是requests根据字节内容推测的编码在中文页面里比默认编码准确得多。第二timeout一定要设置我遇到过目标站点响应卡住的情况如果不设timeout整个程序会一直等在那里看起来就像“死锁”。解析部分用BeautifulSoup。招聘网站的职位卡片通常有个共同的CSS类比如.job-list-item我据此定位卡片节点再提取字段from bs4 import BeautifulSoup soup BeautifulSoup(resp.text, lxml) cards soup.select(.job-list-item) print(f本页共解析到 {len(cards)} 条职位) for card in cards: title_tag card.select_one(.job-title) company_tag card.select_one(.company-name) salary_tag card.select_one(.salary) city_tag card.select_one(.city) exp_tag card.select_one(.exp) edu_tag card.select_one(.edu) # 实际项目中在这里做字段提取和清洗这里想强调一个实战心得不要迷信CSS选择器永远不变。网站改版是常态今天还是.job-title明天可能就变成.position-name选择器一变解析结果就是空列表。所以解析代码里最好加一道校验如果cards为空就打印页面标题或截取HTML前500字符快速判断是页面结构换了还是请求被拦截了。这个调试思路能省下大量排查时间。3.2 详情页数据补全列表页能拿到八成字段但职位描述、技能标签、福利待遇这些得进详情页才能拿到。详情页的URL通常是在职位ID基础上拼接出来的格式类似/jobs/100234567。detail_url fhttps://example.com/jobs/{job_id} resp session.get(detail_url, timeout10) soup BeautifulSoup(resp.text, lxml) job_desc soup.select_one(.job-description).get_text(stripTrue) skill_tags [tag.get_text(stripTrue) for tag in soup.select(.skill-tag)] welfare_tags [tag.get_text(stripTrue) for tag in soup.select(.welfare-tag)]这里存在一个效率问题如果每条职位都单独请求一次详情页几百条职位就是几百次请求算上间隔哪怕每秒一次也要好几分钟。能接受但更好的做法是控制并发。我后来改成了ThreadPoolExecutor线程数控制在5以内同时保留请求间隔整个抓取时间从一两个小时缩短到二十分钟左右。不过有了并发之后异常处理要更仔细不然单个线程报错可能带崩整个任务。我建议每个线程的请求和解析都包在try/except里异常只记录日志不中断整体流程。另外并发场景下Session是否线程安全要特别注意我当时给每个线程单独创建了Session避免共享Session导致Cookie串线。3.3 数据清洗与SQLAlchemy存储抓完的数据是典型的“脏数据”比如薪资是“15-30K·14薪”这种混合字符串经验是“3-5年”城市偶尔带着“·上海·徐汇区”这样的后缀。这些字段直接拿去分析肯定不行。清洗逻辑分三步走。第一步清洗薪资字段。我用正则把“15-30K”拆成两个数值import re def parse_salary(sal_str): if not sal_str: return None, None sal_str sal_str.split(·)[0] m re.search(r(\d)-(\d)K, sal_str) if m: return int(m.group(1)), int(m.group(2)) return None, None第二步统一经验字段。把“经验不限”“在校/应届”“1年以内”“3-5年”“5-10年”这些文本映射成标准分组def normalize_exp(exp_str): if 在校 in exp_str or 应届 in exp_str: return 应届生 if 不限 in exp_str: return 经验不限 if 1年以内 in exp_str: return 1年以内 if 3-5年 in exp_str: return 3-5年 return exp_str第三步存储。第一版用了CSV简单直接pandas读进来就能分析。可抓取量一上来CSV的缺点就暴露了重复抓取需要全量去重、增量更新麻烦、字段类型容易丢失。所以第二版换成了SQLite。后来数据量再大一些我又引入了SQLAlchemy来管理数据库连接和表结构ORM能让表结构清晰可控字段和类型一目了然以后要切到MySQL或PostgreSQL只改连接串业务代码几乎不用动。from sqlalchemy import create_engine, Column, Integer, String, Float from sqlalchemy.orm import declarative_base, sessionmaker engine create_engine(sqlite:///jobs.db, echoFalse) Base declarative_base() class Job(Base): __tablename__ jobs id Column(Integer, primary_keyTrue) job_id Column(String(50), uniqueTrue, indexTrue) job_title Column(String(100)) company_name Column(String(100)) city Column(String(50)) salary_min Column(Float) salary_max Column(Float) experience Column(String(50)) education Column(String(50)) job_desc Column(String(2000)) Base.metadata.create_all(engine) Session sessionmaker(bindengine)这个项目最终的存储方案就是SQLite SQLAlchemy轻量、单文件、随项目走非常适合个人实战项目。3.4 增量抓取与断点续跑不知道你有没有这种经历抓了三百条数据跑到第三百零一条的时候被网站限制或者程序崩溃重启之后只能从头再跑一遍。这个问题必须提前想清楚。我的做法是每次请求列表页之前先从数据库查出当前关键词已经抓过的职位ID集合存成一个set。解析到新职位ID时先判断是否在已抓集合里如果在就跳过详情页请求只做记录更新如果不在才进入详情页抓取流程。这样设计之后重复运行脚本不会产生重复数据也节省了不必要的请求数。断点续跑的逻辑更简单把抓取进度记录到progress.json文件里每完成一页就更新一次。程序启动时先读这个文件如果发现上次跑到了第12页这次就从第13页继续。import json from pathlib import Path progress_file Path(progress.json) if progress_file.exists(): start_page json.loads(progress_file.read_text()).get(last_page, 1) 1 else: start_page 1 last_page 20 for page in range(start_page, last_page 1): # 抓取第page页 # ... Path(progress.json).write_text(json.dumps({last_page: page}))这种设计非常朴素但实战价值极高。尤其是把爬虫挂到服务器上跑或者做成定时任务的时候“失败之后从断点继续”能省下大量时间和服务器资源。4. 薪资分析实战用pandas挖出行情结论4.1 薪资数据的标准化与统计口径数据清洗干净之后第一步是明确统计口径。招聘网站的薪资常见写法有月薪区间15-30K和年薪区间20-35万少量外包岗位还会写日薪或时薪。我统一把月薪区间转化为两个口径最低月薪和最高月薪然后取中值作为“代表薪资”。这里有个容易踩坑的地方有些公司会写月薪之外加了几薪比如“15-30K·14薪”意思是每年发14个月工资。我第一版直接把“14薪”忽略掉了结果低估了真实收入。合理的做法是把月薪中值乘以薪数除以12折算成“月均等效薪资”。比如15-30K、14薪月薪中值是22.5K折算后月均为22.5*14/1226.25K。折算完再按城市聚合结论才有参考意义。4.2 城市-经验-学历多维聚合分析部分pandas的groupby是绝对主力。我建议先做个基础透视表按城市分组计算各城市Python相关岗位的薪资均值、中位数和样本量。P50中位数比均值更抗极端值因为某城市恰好有一两家大厂开出超高薪会把均值拉得很高而中位数更能代表“大多数人能拿到的水平”。import pandas as pd df pd.read_sql(SELECT * FROM jobs, engine) df[salary_mid] (df[salary_min] df[salary_max]) / 2 city_mid df.groupby(city)[salary_mid].agg([count, median, mean]) city_mid city_mid.sort_values(median, ascendingFalse) print(city_mid.head(10))再按经验维度分析把经验字段和薪资中位数列在一起用pivot_table透视就能看到“北京3-5年经验Python工程师的中位薪资大概是多少”。这张表对求职者非常实用也适合做成课程里的案例图表。pivot pd.pivot_table(df, valuessalary_mid, indexcity, columnsexperience, aggfuncmedian) print(pivot)学历维度同样可以分析。招聘数据里“本科”是绝对主力但换个角度就能做出一张很有说服力的图同一岗位本科、大专、硕士的要求比例和薪资差异。放在职业规划类的内容里话题性很强。不过在分析前要注意样本量太小会得出不可靠结论我在脚本里会加一个过滤条件只保留样本量大于5的城市和岗位组合。4.3 可视化与Excel报告输出分析做完不能只停留在终端打印。我习惯把关键结论导出成图表和Excel报告一份交给决策者或客户一份留给自己后续参考。可视化的代码很简单用matplotlib画一个按城市排序的箱线图import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False top_cities city_mid.head(8).index df_top df[df[city].isin(top_cities)] plt.figure(figsize(10, 6)) df_top.boxplot(columnsalary_mid, bycity, showfliersFalse) plt.title(各城市Python岗位薪资分布单位K) plt.suptitle() plt.ylabel(月薪K) plt.xticks(rotation30) plt.tight_layout() plt.savefig(salary_by_city.png, dpi150)这里有一个中文字体的小坑Windows上SimHei通常能用但Linux服务器上没这个字体画出来全是方块。解决办法是检查系统装了哪些中文字体或者下载Noto Sans CJK放到matplotlib的字体目录里。我因为这个坑在服务器上浪费过半小时后来干脆把字体检查也写进了脚本没有合适的字体就直接用英文标签。图表之外用pandas把聚合结果输出到多个sheet。三个sheet分别放原始数据概览、城市维度汇总、城市x经验透视表格式友好Excel打开直接能看。输出Excel用的是openpyxl引擎pandas会自动调用。5. 翻车现场反爬、编码与增量抓取的坑5.1 请求频率限制与IP限制排查运行一段时间后很容易遇到弹验证码、响应403、或者被重定向到安全页。这类问题基本可以判断为频率限制。我的排查顺序是先看是不是单个IP请求太密集降低请求频率是最直接的方案再看是不是UA和Cookie被识别清理Session、更换UA最后才考虑要不要上代理但代理质量参差不齐个人项目不建议轻易上维护成本太高。一个实操建议把抓取程序做成可以“限速运行”的模式用命令行参数控制请求间隔。比如--delay 2代表每两次请求至少间隔2秒这样“跑得快”和“跑得稳”可以在一个程序里兼顾。python crawl_jobs.py --keyword Python --pages 20 --delay 1.55.2 动态渲染页面与接口数据利用不少招聘网站的前端是Vue或React开发的列表数据不是直接写在HTML里而是浏览器执行JavaScript后动态渲染的。用requests拿到的HTML里根本没有职位卡片这种页面就要换思路。优先方案是找页面背后的数据接口。打开浏览器开发者工具切到Network面板刷新列表页重点看XHR和Fetch类型的请求。招聘网站大多数有一个返回JSON数据的接口直接请求这个接口解析比HTML容易得多而且字段更规整。我在项目里就是通过这种方式拿到列表数据的——页面HTML只是壳真正有价值的数据在接口返回的JSON里。只有接口加密完全绕不开的时候才考虑用Selenium或Playwright这类浏览器自动化工具。但它们的资源开销很大控制不好反而容易被检测。能用接口就绝不用浏览器这是爬虫效率的重要分水岭。5.3 编码与数据库存储的坑爬虫写多了你会发现大部分时间不是花在请求上而是花在编码和存储上。中文网页最常见的坑是编码声明与内容实际编码不一致我已经养成了解析前先处理编码的习惯。另一个坑是数据库连接时的charset设置pandas的to_sql写入中文之前一定要确认连接串里带了charset参数否则入库的就是一堆问号。SQLite相对好一些它本身按UTF-8存储基本不会出现中文乱码。如果切换到MySQL建表语句和连接串都要显式声明utf8mb4否则emoji或者特殊字符又会出问题。这类问题初期看起来不起眼一旦数据量大了清洗一遍的成本远比写代码时多注意一眼要高。5.4 合规边界哪些数据不能碰最后这部分一定要说清楚。爬虫不是不能做而是要控制边界。合规的爬虫项目有几个特点只爬公开数据、不碰个人隐私、控制请求频率、遵守robots协议、抓取数据不用于非法用途。我做招聘数据抓取价值在于帮助求职者了解行情、帮助HR做薪酬调研、帮助学员做数据分析练习这些用途都是正面且合理的。反过来如果有人想爬取简历库、抓取用户隐私用于营销或骚扰那无论技术上多么“可行”都是绝对不能碰的红线。在实际项目交付时我通常会在文档里单独写一节“数据使用声明”说明数据的来源、用途、抓取范围和时限让整个项目在阳光下运行。做这个项目我最大的体会是爬虫只是手段分析才是价值。把列表页、详情页、清洗、存储、聚合、可视化这一整套流程跑完之后你会发现自己对“数据从哪来、怎么变成可用信息、最终如何支撑决策”有了完整的感知这是任何教程都教不了的东西。最后再分享一个小技巧做数据抓取时每隔一段时间把新抓的数据和历史数据对比一次常常会发现招聘供需的变化趋势比如某类岗位的薪资在涨、某个城市的需求在收缩。这种观察比单一时间点的快照有价值得多。把这套流程跑通之后完全可以换个垂直领域比如电商商品数据、房产挂牌数据复用同样的架构思路是一样的换的只是解析规则和分析角度。
阅读完成 · 觉得有帮助?
咨询建站