☰
Python爬虫实战:51job招聘数据采集与可视化分析
2026/10/2 14:40:01 网站建设 项目流程

我最近一直在琢磨一件事:招聘网站上的岗位数据,能不能从中看出行业风向?于是顺手用 Python 爬虫对 51job(前程无忧)做了一次完整的实战采集,把岗位、薪资、学历要求、城市分布这些字段拉下来做分析。这篇文章就是这次实战的全过程记录,从 URL 构造、反爬应对、字段解析,到数据清洗和可视化,一条线讲清楚。适合刚学完 Python 基础、想拿真实项目练手的朋友,也适合已经在写爬虫但想规范采集流程的开发者。

先说结论:51job 这种老牌招聘网站,反爬不算变态,但坑一点都不少。请求头少一个字段就是安全校验,翻页参数看错一个就是重复数据,薪资字段不洗直接分析就是灾难。这篇文章不是给你一段能跑的代码就完事,而是把我踩过的坑、试出来的稳定方案、以及为什么这么做的底层逻辑全部交代清楚。

1. 项目拆解:爬 51job 到底在爬什么

1.1 这个项目的真正目标不是爬虫,而是数据洞察

很多人做爬虫项目,上来就写代码,这是本末倒置。我这次的目标很明确:采集 51job 上与 Python 相关的岗位数据,然后回答三个问题——哪些城市岗位需求量最大、学历和经验门槛集中在什么水平、薪资区间怎么分布。

这三个问题对应三个核心字段组:

字段组包含字段分析用途
岗位基础信息岗位名称、公司名称、发布时间了解岗位结构和市场活跃度
任职要求学历、经验年限、技能标签绘制人才准入门槛画像
薪资福利薪资文本、工作地点、福利标签薪资水平与地域分布透视

把目标拆成字段需求后再写代码,效率完全不一样。你不会把时间浪费在抓一堆用不上的 HTML 节点上,也不会在清洗阶段手忙脚乱。爬虫这活,数据结构设计永远是第一步。

1.2 为什么数据源选 51job 而不是其它平台

选 51job 有这么几个考虑。第一,它的职位覆盖面够广,尤其传统行业、制造业、外企的岗位数量明显多于纯互联网招聘平台,拿来观察整个就业市场比只看垂直平台更有代表性。第二,它的页面结构相对规整,岗位列表页和详情页的 DOM 层级清晰,字段位置比较有规律,对新手来说容错率更高。第三,它的反爬强度处于“有防御但不是铁桶”的状态,既能让你学到反爬对抗的思路,又不至于一上来就被打懵,正适合做实战项目。

对比其它平台:Boss 直聘的岗位信息大部分藏在接口里,需要签名校验,门槛高;拉勾网对爬虫的检测比较敏感,封 IP 也快。对比下来,51job 是想练手又不愿意折腾半天还拿不到数据的人的最优选择。当然,前提是你只是做技术研究和个人学习用,不涉及大批量商业化采集。

1.3 合规边界:爬虫不是想怎么爬就怎么爬

我习惯在项目开头先划清楚合规红线。这次采集的范围仅限于公开页面上展示的岗位信息,不碰用户个人隐私数据;请求频率控制在合理区间,不给服务器造成压力;数据只用于个人学习和统计分析,在博客和分享中只展示聚合结果,不暴露任何可定位到个人的信息。

另外要提一句 robots 协议。虽然 51job 的 robots.txt 并没有完全禁止爬虫路径,但尊重平台的访问规则是底线。我的做法是:单线程加延时,每个请求间隔 1 到 3 秒,单次任务采集量控制在数百条量级,跑完就停。做技术研究没问题,拿数据去倒卖或者做商业产品,那是另一回事,我不建议,也请你自己斟酌。

2. 核心技术选型与请求方案解析

2.1 requests 还是 selenium,这是个战略问题

一开始我差点选 selenium 直接上浏览器自动化,因为 51job 的页面确实做了登录校验和访问频率检测。但仔细分析后发现,搜索页和详情页的大部分数据其实都能通过直接构造 HTTP 请求拿到,selenium 是最后手段。

这就引出一个选型原则:能走 HTTP 请求就别上浏览器模拟。浏览器自动化开销大、速度慢,而且容易被检测到 WebDriver 特征。51job 的反爬主要做在 Cookie 校验和请求头校验上,只要把浏览器里的有效 Cookie 和完整的 Header 带齐,用 requests 完全可以稳定拿到数据。实测下来,我用 requests 加随机延时跑了两百多个页面,成功率接近百分之百。

当然,如果你发现某个平台的数据必须经过复杂的 JavaScript 动态渲染才能拿到,那时候再上 selenium 也不迟。工具永远是为数据服务的,不是越高级越好。

2.2 搜索请求的 URL 构造与关键参数

先看 51job 搜索列表页的 URL 规律。搜索关键词为 python 时,请求地址长这样:

https://we.51job.com/pc/search?keyword=python&searchType=2&sortType=0&pageNum=1&pageSize=50

我实际测下来,这几个参数里真正起作用的是 keyword、pageNum 和 pageSize。keyword 控制搜索词,pageNum 是页码,pageSize 每页返回的岗位条数。pageSize 设为 50 比较合适,设太大会导致接口响应异常,设太小又增加翻页次数。sortType 控制排序,0 是默认综合排序。

重点来了:请求这串 URL 之前,必须带上浏览器环境里的完整 Cookie。51job 的安全校验会检查你是否携带了合法的访问标识,如果裸请求,大概率会被重定向到一个安全验证页面,返回的不是岗位数据而是一堆验证逻辑。我第一次跑的时候就是栽在这上面,返回的 HTML 里怎么都找不到岗位列表节点,排查了二十分钟才发现是 Cookie 缺失。

2.3 从浏览器复制 Header 的正确姿势

请求头里哪些字段不能省?以我的经验,下面这几个必须齐全:

headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept-Encoding": "gzip, deflate, br", "Connection": "keep-alive", "Upgrade-Insecure-Requests": "1", "Cookie": "你的浏览器Cookie", }

这里特别强调两个细节。第一,Cookie 一定要复制完整,从浏览器开发者工具里直接拷贝整个字符串,不用手动删减,删掉任何一个可能有用的键都可能导致请求失败。第二,User-Agent版本要和你的浏览器版本一致,否则也会触发风控。完整去除注释后其实就是一套浏览器指纹,任何一部分不匹配都会让服务器怀疑你不是真人。

拿到这些参数后,用requests.Session()来维持会话,这样 Cookie 会在整个会话期间保持一致,不需要每次请求都重新手动塞 Cookie。这一步看起来简单,但决定你后面是顺利拿到数据还是反复被验证页面拦截。

3. 核心代码实现:从采集到入库的完整流程

3.1 初始化会话与公共请求头

写代码前先搭好骨架。我用requests.Session创建会话对象,把公共请求头挂在 session 上,这样每次请求都自动带上全量头信息,不用每个接口重复写。

import requests import time import random from bs4 import BeautifulSoup session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "keep-alive", "Upgrade-Insecure-Requests": "1", "Cookie": "你的浏览器Cookie", }) def fetch_page(url): """请求页面并返回 HTML 文本,失败自动重试 3 次""" for attempt in range(3): try: resp = session.get(url, timeout=15) if resp.status_code == 200: return resp.text else: print(f"请求失败,状态码: {resp.status_code},第 {attempt+1} 次重试") except requests.RequestException as e: print(f"请求异常: {e},第 {attempt+1} 次重试") time.sleep(random.uniform(2, 4)) return None

这里做了两个实用设计。超时时间设为 15 秒,避免网络抖动时无限挂起;失败自动重试 3 次,每次重试前随机等 2 到 4 秒。实际跑下来,这个重试机制能把临时性网络问题的影响降到可以忽略的程度,而不是一遇到超时就整个脚本崩溃。

3.2 搜索页解析与岗位数据提取

拿到搜索页 HTML 之后,解析逻辑才是核心。51job 的搜索页结构里,岗位列表项通过class属性区分,每个列表项中包含职位名称、公司名称、薪资、工作地点、经验学历要求、发布时间和详情页链接。

下面这段代码是我实际用的解析逻辑,用 BeautifulSoup 定位列表项,再从每个列表项里提取各字段:

def parse_search_page(html): """从搜索页 HTML 中提取岗位列表""" soup = BeautifulSoup(html, "lxml") jobs = [] # 搜索页中岗位列表项通用 class 名称,实际使用时按页面结构调整 items = soup.select(".job-list-item") for item in items: title_node = item.select_one(".job-title") company_node = item.select_one(".company-name") salary_node = item.select_one(".job-salary") place_node = item.select_one(".job-location") req_node = item.select_one(".job-requirement") date_node = item.select_one(".job-date") link_node = item.select_one("a.job-title-link") job = { "岗位名称": title_node.get_text(strip=True) if title_node else "", "公司名称": company_node.get_text(strip=True) if company_node else "", "薪资文本": salary_node.get_text(strip=True) if salary_node else "", "工作地点": place_node.get_text(strip=True) if place_node else "", "经验学历": req_node.get_text(strip=True) if req_node else "", "发布时间": date_node.get_text(strip=True) if date_node else "", "详情链接": "https://we.51job.com" + link_node["href"] if link_node else "", } jobs.append(job) return jobs

这段代码里最关键的习惯是每个字段都做空值兜底。招聘页面经常有岗位信息不完整的情况,薪资缺失、地点缺失都很常见,如果不做空值处理,解析一遇到None就直接抛异常,整个脚本就断了。给每个字段写一个if node else ""的兜底,看起来啰嗦,实际能救你无数次。

需要提醒的是,51job 的前端改版过多次,不同时期的class名称不一样。你手动跑的时候如果定位不到节点,打开浏览器开发者工具,按Ctrl+Shift+C检查元素,把实际 class 名称替换进去就行。这是爬虫项目的常态:一切以页面当下的结构为准。

3.3 翻页循环与去重逻辑

搜索结果的翻页逻辑其实就是一个循环。先请求第一页拿到总页数,再按页数依次请求。51job 的搜索结果里通常会标记总页数,但为了稳妥,我习惯采用“遍历到拿不到数据为止”的策略:

def crawl_keyword(keyword, max_pages=10): base_url = "https://we.51job.com/pc/search?keyword={}&searchType=2&sortType=0&pageNum={}&pageSize=50" all_jobs = [] seen_links = set() # 详情页链接去重 for page in range(1, max_pages + 1): url = base_url.format(keyword, page) html = fetch_page(url) if not html: break page_jobs = parse_search_page(html) if not page_jobs: break new_count = 0 for job in page_jobs: link_key = job["详情链接"] if link_key and link_key not in seen_links: seen_links.add(link_key) all_jobs.append(job) new_count += 1 print(f"关键词 [{keyword}] 第 {page} 页,新增 {new_count} 条,累计 {len(all_jobs)} 条") time.sleep(random.uniform(1.5, 3)) return all_jobs

去重逻辑放在写入数据之前很有必要。51job 的排序在不同页码之间可能出现偏移,也就是同一岗位在上一页和下一页重复出现,如果不去重,最终数据里会有不少重复记录,后面的统计口径就不准了。我拿详情页链接作为唯一键做去重,实际运行中效果很好。

还有个细节:单关键词的岗位数量是有限的,如果想扩大数据量,可以定义一组关键词轮询采集,比如["Python", "爬虫", "数据分析", "Java"]。每个关键词单独调用上面的函数,最后合并结果。轮询时在每个关键词之间加一个较长的停顿,避免连续请求触发风控。

3.4 字段清洗与 Excel 落盘

原始数据拿到手之后不能直接用,还得做清洗。这一步的工作量往往比爬虫本身还大。我遇到的脏数据主要有几类:薪资文本格式不统一,比如“1-1.4万/月”“4.5-6千/月”“20-40万/年”;经验学历字段包含“在校生/应届生”“3-4年经验”“大专”等混合信息;工作地点字段带着城市和区县甚至还有“异地招聘”这种特殊值。

清洗的逻辑是把薪资文本解析成最低月薪和最高月薪两个数值字段,单位全部统一成“元/月”。规则如下:

def parse_salary(text): """把薪资文本解析为 (最低月薪, 最高月薪),单位:元/月""" if not text: return (0, 0) text = text.replace("月薪", "").replace("/月", "").replace("万", "*10000").replace("千", "*1000") # 这里做实际计算前需要先处理特殊符号 import re nums = re.findall(r"([\d.]+)\s*[-~—]\s*([\d.]+)", text) if nums: low, high = nums[0] low_val = float(low) high_val = float(high) if "万" in text: low_val *= 10000 high_val *= 10000 elif "千" in text: low_val *= 1000 high_val *= 1000 return (low_val, high_val) return (0, 0)

这个解析函数处理了“万”和“千”两种单位,兼容了“1-1.4万/月”“4.5-6千/月”这类常见写法。对于“20-40万/年”这类按年计薪的岗位,我的处理是额外标注一个薪资周期字段,不做简单换算,因为年薪和月薪本身反映的岗位层级不一样,混在一起统计反而不科学。

清洗完成后,用 pandas 直接写 Excel:

import pandas as pd df = pd.DataFrame(all_jobs) df["最低月薪"] = df["薪资文本"].apply(lambda x: parse_salary(x)[0]) df["最高月薪"] = df["薪资文本"].apply(lambda x: parse_salary(x)[1]) df.to_excel("51job_python_jobs.xlsx", index=False)

写文件这步倒没什么技术含量,但建议每个字段都用英文命名或者加上注释,避免图方便用中文列名导出后在其它工具里出现编码问题。Excel 文件用 pandas 写出来默认是 UTF-8 编码,大多数情况没问题。

4. 数据分析:把岗位数据变成看得懂的行业趋势

4.1 从采集到分析的三个核心维度

数据落盘之后就到了最有意思的部分:分析。我用 pandas 做了三个维度的聚合统计,每个维度回答一个具体问题。

城市维度:从“工作地点”字段里提取城市信息,统计每个城市的岗位数量。这个数据能直观反映出哪个地区对相关岗位的需求最旺盛。我采集的这批数据里,岗位集中度非常明显,一线城市仍然是需求主力。如果你关注的是二线城市的机会,也可以把字段拆细一点单独看。

学历维度:把“经验学历”字段里的学历信息单独抽取出来,统计各个学历层次的需求比例。这里注意,字段里经常混着经验和学历两种信息,比如“3-4年经验 | 本科”,直接拿文本做分类会不干净,需要按分隔符拆分后再取学历部分。

经验维度:类似的逻辑,从字段里抽取经验年限。统计结果基本符合直觉,3 到 5 年的岗位需求占比最高,应届生和 10 年以上经验的需求相对较少。这个分布形态就是人才市场典型的橄榄型结构。

4.2 一段可以直接跑的统计代码

下面这段代码是我实际用过的聚合统计逻辑,字段名和上面的采集代码对应:

import pandas as pd df = pd.read_excel("51job_python_jobs.xlsx") # 城市维度 df["城市"] = df["工作地点"].str.split("·").str[0] city_stats = df.groupby("城市").size().reset_index(name="岗位数").sort_values("岗位数", ascending=False) # 学历维度 df["学历"] = df["经验学历"].apply(lambda x: x.split("|")[1].strip() if "|" in str(x) else "") edu_stats = df.groupby("学历").size().reset_index(name="岗位数").sort_values("岗位数", ascending=False) # 薪资维度:清洗后直接取中位数 df["薪资中位数"] = (df["最低月薪"] + df["最高月薪"]) / 2 salary_stats = df.groupby("城市")["薪资中位数"].median().reset_index().sort_values("薪资中位数", ascending=False)

这一段跑下来,你手里就有了三张表:城市热度排行、学历需求分布、城市薪资中位数排行。做一个招聘市场的小型报告绰绰有余。

4.3 用 matplotlib 画可视化图表

数据分析不加可视化,说服力少一半。我习惯用 matplotlib 画两类图:横向条形图展示城市岗位量排行,饼图展示学历需求占比。这两类图的信息密度高,而且代码量很少。

import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] # 解决中文乱码 plt.rcParams["axes.unicode_minus"] = False # 城市岗位量 Top 10 top_cities = city_stats.head(10) plt.figure(figsize=(10, 6)) plt.barh(top_cities["城市"][::-1], top_cities["岗位数"][::-1]) plt.xlabel("岗位数量") plt.title("51job Python相关岗位城市分布 Top10") plt.tight_layout() plt.savefig("city_distribution.png", dpi=150)

这里有两个容易踩的坑。第一,matplotlib 默认字体不支持中文,必须通过plt.rcParams["font.sans-serif"] = ["SimHei"]指定中文字体,否则图上的所有中文标签都会变成方块。第二,barh画横向条形图时,数据顺序和显示顺序是反的,需要用[::-1]先反转再传入,否则排名第一的城市会显示在最下方。

5. 常见问题与避坑实录

5.1 一张表说清楚高频问题与解决方案

我这次实战下来,整理了六个最高频的问题,按出现概率排列如下。这个表可以直接当成排查手册用:

问题现象根本原因解决方案
搜索页返回验证页而非岗位列表缺少有效 Cookie,或 User-Agent 与浏览器不一致从浏览器复制完整 Cookie 和对应 UA,用 Session 保持
岗位列表解析后全是空值页面改版导致 class 名称变化打开开发者工具检查实际 class,更新选择器
翻页后出现大量重复岗位排序变化导致岗位跨页重复用详情页 URL 作为唯一键去重
薪资计算成 0文本格式特殊如“薪水面议”解析前过滤特殊值,统一落入不可用分类
请求间歇性失败请求频率过高被临时限制增加随机延时,设置失败自动重试
Excel 文件里中文乱码pandas 写入编码与读取工具不匹配用utf-8-sig编码写入,或改用 CSV

5.2 请求频率控制是最容易被忽略的细节

爬虫写完后,我特意加了一行随机延时,控制在 1.5 到 3 秒之间。这个数字是我测出来的平衡点:低于 1 秒,连续请求几十条之后必然触发风控;高于 5 秒,采集几百条数据要等太久,效率无法接受。随机延时而不是固定延时也很重要,固定时间间隔本身就是一个容易被识别的机器行为特征。

有个容易被忽略的点:同一会话内不要频繁更换 IP。如果你是个人电脑直连网络,开机后 IP 一般不变,问题不大。如果你用了代理池,反而要小心,频繁切换 IP 导致每次请求的访问来源都不同,风控系统更容易判定异常。

5.3 写好日志,问题排查才有依据

最后一个建议,也是我这次项目里收益最大的一个习惯:给脚本加日志输出。不用引入 loguru 这样的第三方库,print 加时间戳就够用。每条日志记录当前在爬哪个关键词、第几页、获取了多少条、耗时多久。脚本跑挂的时候,翻日志就能定位是网络问题、解析问题还是频率限制,而不是凭感觉猜。

from datetime import datetime def log(msg): print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}") log(f"开始采集关键词: {keyword}") log(f"第 {page} 页解析完成,获取 {len(page_jobs)} 条原始数据") log(f"当前共 {len(all_jobs)} 条有效数据")

我第二次跑这个项目的时候,脚本在第三个小时突然大量返回空数据。如果没有日志,我只能从头排查;有了日志,我一眼就看出失败集中在某一个时间点之后,再对照请求间隔,确认是风控阈值到了。调整延时策略后,后续再没有出现这个问题。别小看这几行日志,它是整个爬虫项目里性价比最高的代码。

我在实际跑这批数据之后最大的体会是:爬虫写得好不好,不在于你能不能把页面抓下来,而在于你能不能稳定地把数据洗干净、存好、分析出价值。51job 这个项目难度适中,反爬机制够你学东西,数据量也够你做分析,是一个能让你把 requests、BeautifulSoup、pandas、matplotlib 一整条链路全部串起来的完整实战。你照着上面的方案跑一遍,遇到问题再回来翻翻这张避坑表,基本能顺下来。下一步如果你想玩得更深,可以把采集范围扩大到更多岗位关键词,或者把分析维度细化到具体技能标签,再往前一步,就是一个小型人才洞察系统了。

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

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

立即咨询