简介:这是一份基于Python开发的51job前程无忧招聘信息爬取与分析项目源码,面向爬虫学习者和招聘数据分析人员,解决从前程无忧网站自动抓取职位、清洗整理、统计可视化的一整套实践需求。整套资源共29个文件,压缩包约18.04MB,主体为Python爬虫源码,配有CSV数据文件、PNG图表、TXT文本及依赖说明;CSV用于存储结构化职位数据,PNG直观呈现职位分布、薪资区间等分析结果,文本和依赖说明帮助快速了解项目并重建运行环境。当前已有493人学习浏览,适合从零理解网页请求、页面解析、异常处理和图表呈现的完整链路。用户拿到后可直接运行查看抓取结果,也能围绕城市、岗位关键词、薪资水平等维度进行二次开发,服务于求职决策、行业观察和人力资源调研。
1. 为什么这个 51job 爬虫项目值得拆开看
很多人以为前程无忧这类招聘网站的爬虫难度只在于请求频率被限制,真正跑过之后才知道,麻烦全在后半程:薪资字段写成「6-8千/月」「15-20万/年」这种不规则文本,职位描述里全是「Java」「C++」「应届生」这类分词器不认识的专业词,再加上 51job 每隔一两年就改一次页面结构,一个能完整跑通「抓取 → 清洗 → 分析 → 出图」的源码,比单独一个爬虫脚本值钱得多。这个项目就是完整闭环:job_spider.py 负责抓取,10 个 CSV 存中间结果,9 张 PNG 和 1 张词云图做可视化,readme.txt 和 requirements.txt 把环境与使用说明补齐。适合正在做招聘数据分析、想复现一遍从网页到决策图表全流程的开发者,也适合拿来做课程设计的同学。
2. job_spider.py:请求、解析与异常处理的实现细节
2.1 请求构造与翻页参数
51job 的职位列表页走的是标准 GET 请求,关键参数在 URL 的 query string 里。项目里的 job_spider.py 最早适配的是 51job 的「高级搜索」接口,核心参数包括keyword、searchType、jobArea和pageno。翻页不是改 URL 路径,而是改pageno的值,这一点和很多一次性爬虫脚本不同——它把翻页逻辑独立出来了,便于后续改成多线程或异步抓取。
常见的请求头配置我一般这么处理:
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html,application/xhtml+xml", "Accept-Language": "zh-CN,zh;q=0.9", } session = requests.Session() session.headers.update(headers) def fetch_list_page(keyword: str, page: int) -> str: params = { "keyword": keyword, "searchType": "2", # 2 表示按关键字搜索 "jobArea": "000000", # 全国范围 "pageno": page, } resp = session.get("https://search.51job.com/list/000000,000000,0000,00,9,99,{},2,{}.html".format( quote(keyword), page), params=params, timeout=10) resp.raise_for_status() resp.encoding = "gbk" # 51job 历史页面用过 gbk 编码 return resp.text这段代码里有两个容易踩的细节。第一,quote(keyword)必须做 URL 编码,否则关键字里有中文或者空格时 51job 会返回 400;第二,resp.encoding = "gbk"是历史版本页面必需的,新版页面切到 utf-8 后如果还强制 gbk,解析出来的中文全是乱码。建议在实际运行时先打印resp.apparent_encoding确认页面实际编码,再做硬编码。
pageno的最大值可以通过解析列表页底部的分页导航拿到,常见做法是用 BeautifulSoup 抓.pagination里的最后一个数字。项目源码里没有对最大页数做硬编码,而是循环抓取直到当前页没有新增职位 ID 才停止,这种设计在反爬策略变化时更稳——如果某天 51job 对超出范围的页码返回空列表,硬编码的range(1, 100)就会白白多打几十个空请求。
2.2 职位详情解析与字段抽取
列表页拿到的是职位卡片,真正有价值的信息在详情页。项目抽出来的字段包括职位名称、公司名称、薪资文本、工作地点、发布时间、职位描述。这里有个容易忽略的点:51job 的列表页本身已经带了一部分字段(薪资、地点、公司),但职位描述必须在详情页里抓,而详情页的结构比列表页更容易变。
详情页解析的典型写法:
def parse_detail(html: str) -> dict: soup = BeautifulSoup(html, "lxml") job_name = soup.select_one(".tHeader h1") salary = soup.select_one(".cn .lname") # 新版页面可能不同 desc = soup.select_one(".bmsg.job_msg") if not job_name or not desc: return None return { "职位": job_name.get_text(strip=True), "薪资": salary.get_text(strip=True) if salary else "面议", "描述": desc.get_text(" ", strip=True), }select_one返回 None 的情况必须处理,因为 51job 有些职位已经下线,详情页会跳到「该职位已删除」的提示页。项目里对解析失败返回 None,由上层循环跳过,不会中断整个抓取流程。选择器本身是跟随页面改版的,拿到源码后如果发现tHeader已经被改成别的 class,需要用 devtools 重新定位。
2.3 异常处理与存储逻辑
抓取过程中的异常主要分三类:请求超时、解析字段缺失、页面反爬拦截。项目里的策略是重试 + 跳过,不是无限重试——连续失败超过 3 次就把当前页记录到日志然后继续下一页,这个阈值控制得当的话,完整抓取 2000 个职位大概会有 10 到 20 个失败样本,对后续统计分析影响不大。
| 异常类型 | 表现 | 处理策略 |
|---|---|---|
| requests 超时 | 抛 Timeout / ConnectionError | 重试 3 次,指数退避,间隔 2/4/8 秒 |
| CNZZ 反爬验证 | 返回页面无职位卡片,出现验证码 | sleep 30 秒后重试,仍失败则记录页码 |
| 详情页字段缺失 | select_one 返回 None | 返回 None,上层跳过 |
| 编码异常 | 中文乱码 | 动态检测 apparent_encoding,修剪后重试 |
数据存储用的是标准库csv,每条职位记录一个字典,写入前统一处理字段缺失——薪资为空填「面议」,地点为空填「未知」。CSV 文件指定的编码是utf-8-sig,这个细节很重要:Excel 直接打开 utf-8 编码的 CSV 会出现中文乱码,utf-8-sig会带上 BOM 头,Excel 能正确识别。
3. 从 CSV 到词云:数据清洗、分词与薪资统计
3.1 CSV 文件体系
项目里的 10 个 CSV 文件不是随意导出的,而是按「中间结果」和「最终统计」分层的。理解这套文件组织方式,比单纯跑通脚本更重要,因为它决定了你拿到新数据后从哪一步开始分析。
| 文件 | 内容 | 用途 |
|---|---|---|
| post_salary.csv | 每个职位的薪资文本 + 解析后的数值 | 薪资分析的基础表 |
| post_salary_locate.csv | 城市 + 薪资数值 | 城市薪资交叉分析 |
| post_other_salary.csv | 未被标准模式匹配的薪资文本 | 检查解析遗漏 |
| post_salary_counter1.csv | 薪资区间计数 | 画柱状图 |
| post_pre_counter.csv | 职位名称词频统计 | 职位类型分布 |
| post_desc_counter.csv | 职位描述高频词统计 | 技能需求分析 |
| post_pre_desc_counter.csv | 职位名称+描述合并词频 | 词云数据源 |
| post_desc_lg_counter.csv | 描述字段长度统计 | 数据质量校验 |
跑完job_spider.py后,这些文件会按顺序生成,每一个文件都基于前一个文件做进一步加工。拿到源码后先别改爬虫部分,直接拿现成的 CSV 跑分析脚本,确认整条链路通畅后再回头调抓取逻辑,调试成本低很多。
3.2 薪资字段解析
51job 的薪资文本格式五花八门:「6-8千/月」「1-1.5万/月」「15-30万/年」「200元/天」,甚至还有「面议」。项目里没有用 pandas 的to_numeric硬转,而是先用正则把文本拆成数字和单位,再统一换算成「月薪」口径。
import re def parse_salary(text: str) -> float: if not text or text == "面议": return None m = re.search(r"(\d+(?:\.\d+)?)\s*[-—~至]\s*(\d+(?:\.\d+)?)\s*([万千]?)/?([月年天]?)", text) if not m: return None low, high = float(m.group(1)), float(m.group(2)) unit, period = m.group(3), m.group(4) factor = {"万": 10000, "千": 1000, "": 1}.get(unit, 1) if period == "年": factor /= 12 elif period == "天": factor *= 21 # 按月平均工作日折算 return round((low + high) / 2 * factor, 2)解析逻辑分三步:先用正则从文本里抠出薪资区间和单位,再按单位换算成元,最后把「年」「天」统一成月薪。这里没取区间上限也没取下限,而是取中位数,因为招聘网站的薪资区间通常是 HR 拍脑袋填的,中位数比端点更有代表性。匹配不上的文本统一返回 None,项目里单独存到post_other_salary.csv,供人工检查——这一步千万别省,它直接暴露正则的覆盖死角。
3.3 jieba 分词与自定义词典
职位描述是纯文本,做词频统计前必须先分词。jieba 默认词典对「C++」「.NET」「应届生」「五险一金」这类词处理得不好,会把「C++」切成「C」和「++」,把「五险一金」切成「五险」和「一金」。项目里的user_dict.txt就是干这个的,它是 jieba 自定义词典,每个词一行,格式为「词 词频 词性」。
import jieba from collections import Counter jieba.load_userdict("user_dict.txt") stopwords = set() with open("stopwords.txt", "r", encoding="utf-8") as f: stopwords = {line.strip() for line in f if line.strip()} def tokenize(text: str) -> list: words = jieba.lcut(text) return [w for w in words if len(w) > 1 and w not in stopwords and not w.isdigit()] # 对 post_desc_counter.csv 的生成示例 with open("post_pre_desc_counter.csv", "r", encoding="utf-8-sig") as f: all_desc = [line.strip() for line in f] counter = Counter() for desc in all_desc: counter.update(tokenize(desc))停用词表是必备的,否则「我们」「进行」「以及」这类高频无意义词会霸占词云的核心位置。过滤条件里len(w) > 1把单字词全部丢弃,w.isdigit()把纯数字丢弃,这样「3年经验」会被切成「年经验」,比切成「3」「年」「经验」更容易统计出有效信息。用户词典里除了专业名词,建议把公司常见福利词也加进去,比如「弹性工作」「扁平管理」,这些词对判断团队氛围很有价值。
3.4 词云生成与中文字体
项目里那张worldcloud.jpg是用 wordcloud 库生成的,但直接跑 wordcloud 的中文文本会输出一堆方框,因为默认字体不支持中文。所以项目仓库里带了一个msyh.ttf(微软雅黑字体文件),在 WordCloud 构造函数里通过font_path指定,中文才能正常渲染。
from wordcloud import WordCloud wc = WordCloud( font_path="msyh.ttf", width=1200, height=800, background_color="white", max_words=150, collocations=False, # 关闭二元词组,避免重复词 ) with open("post_pre_desc_counter.csv", "r", encoding="utf-8") as f: text = f.read() wc.generate(text) wc.to_file("worldcloud.jpg")注意collocations=False这个参数。默认情况下 wordcloud 会把相邻两个词合成一个词组,比如「深度学习」出现 20 次,它可能会生成「深度 学习」和「深度学习」两个条目,导致词云上同一个概念出现两遍,统计词频也有重复计算的嫌疑。关闭二元词组后,虽然会丢失一部分组合语义,但对于招聘文本的技能统计来说,单字词的独立性更重要——「Java」「Spring」本身就是完整的技术名词。
4. 薪资-城市-描述的多维分析与出图
4.1 城市分组与薪资交叉
post_salary_locate.csv是按城市聚合后的薪资数据,但城市字段有脏数据:51job 的职位地点可能是「上海-浦东新区」「深圳-南山区」「广州」这种带后缀的文本,也可能是「异地招聘」。项目里的处理方式是按城市前缀做映射,而不是精确匹配。
import pandas as pd city_map = { "上海": "上海", "北京": "北京", "深圳": "深圳", "广州": "广州", "武汉": "武汉", "成都": "成都", "杭州": "杭州", "南京": "南京", "异地": "异地", "其他": "其他", } def normalize_city(text: str) -> str: if not text: return "未知" for key in city_map: if text.startswith(key): return city_map[key] return "其他" df = pd.read_csv("post_salary_locate.csv", encoding="utf-8-sig") df["城市"] = df["地点"].apply(normalize_city) city_salary = df.groupby("城市")["月薪"].agg(["mean", "count", "median"]) city_salary = city_salary[city_salary["count"] >= 10].sort_values("median", ascending=False) print(city_salary)分组后用median排序而不是mean,是因为个别后端岗位的薪资可能开到 10 万/月,均值被拉高后整个排名失真,中位数对这种长尾分布更稳健。count >= 10的过滤条件是必需的——样本量小于 10 的城市统计出来没有置信度,画在图上只会误导阅读者。
4.2 图表输出
项目里的 9 张 PNG 图表对应不同的分析维度,每张图都对应一个具体的决策问题:
| 图表文件 | 分析维度 | 回答的问题 |
|---|---|---|
| job_1.png | 职位数量 Top 15 的职能分布 | 什么岗位需求量最大 |
| job_2.png | 薪资中位数 Top 15 的城市 | 哪里给得最多 |
| job_3.png | 经验要求分布 | 市场主要招什么人 |
| job_4.png | 学历要求分布 | 门槛集中在哪一档 |
| shanghai.png / beijing.png / shenzhen.png / guangzhou.png | 各城市的岗位职能分布 | 一线城市结构差异 |
| other.png | 其余城市合并后的职能分布 | 非一线城市特征 |
绘图代码是标准 matplotlib,但有个细节值得注意:项目在所有图的开头都调用了plt.rcParams['font.sans-serif'] = ['Microsoft YaHei'],用来解决 matplotlib 中文负号显示为方块的问题。如果你在自己环境里跑,需要确认系统里有微软雅黑这个字体,否则要改成SimHei或者把字体文件路径指定为msyh.ttf。
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["Microsoft YaHei"] plt.rcParams["axes.unicode_minus"] = False fig, ax = plt.subplots(figsize=(10, 6)) city_salary.head(15).plot(kind="barh", y="median", ax=ax, legend=False) ax.set_xlabel("月薪中位数(元)") ax.set_title("各城市招聘月薪中位数 Top 15") plt.tight_layout() plt.savefig("job_2.png", dpi=150)kind="barh"用的是横向柱状图,城市名称较长时横向排列更易读。tight_layout()防止标题和图例被截断,dpi=150保证图片在屏幕上放大后依然清晰——这两行虽然不起眼,但直接决定图表能否放进 PPT 或报告里。
4.3 图表与 CSV 的对应校验
拿到源码后建议先做一个校验:重新跑一遍分析脚本,确认图表文件的内容与 CSV 统计结果一致。具体做法是打开post_salary_counter1.csv看薪资区间的分布,再对比job_3.png的柱状图——如果图里显示「6-8千」占比最高,CSV 里对应行的计数也应该是最大,两个数据源对不上就说明图表脚本还在用旧数据。
这种校验的意义在于,爬虫抓取的数据每天都在变,但分析脚本的输入输出可能没同步更新。我在实际项目里就遇到过:CSV 里明明有 5000 条记录,图表脚本却只读了前 1000 条,最后出图结果误导了整个汇报。用wc -l post_salary.csv快速核对行数,用df.describe()看薪资分布的均值和分位数,两个命令就能发现大部分数据不一致问题。
5. 51job 改版后的适配与增量抓取技巧
5.1 定位失效的排查
51job 近年对页面结构做过多次调整,class 名称从语义化变成了混淆型字符串,项目源码里的选择器在未来某天可能失效。遇到抓取到的字段全是空值、图表数据骤降时,按这个顺序排查:先用浏览器打开一个职位详情页,按 F12 检查目标字段的最新 DOM 结构;再对比源码里的 select_one 路径,找出 class 或标签层级的变化;最后用一小段测试代码确认新选择器能正确抽取,再批量重跑。
搜索列表页的接口也换过几次,从早年的search.51job.com/list/...到后来的we.51job.com/pc/search,如果请求返回的是空列表或者统一跳转到首页,说明 URL 构造方式已经变了。这时候不要盲目加大pageno去试,先看首页搜索框提交后实际请求的接口地址,把新接口的 query 参数映射到脚本里。
5.2 增量更新与断点续抓
全量抓取一次 51job 的职位数据在本地网络环境下可能要跑 20 到 40 分钟,而且中间大概率会出现几次超时。简单的任务队列配合断点续抓,能避免失败后从头再来。
import pickle, os TODO_FILE = "pending_urls.pkl" def load_todo() -> list: if os.path.exists(TODO_FILE): with open(TODO_FILE, "rb") as f: return pickle.load(f) return [] def save_todo(urls: list) -> None: with open(TODO_FILE, "wb") as f: pickle.dump(urls, f) pending = load_todo() or build_detail_urls() while pending: url = pending.pop(0) try: detail = parse_detail(fetch(url)) if detail: append_to_csv(detail) except Exception as e: pending.append(url) # 失败放回队尾 if len(pending) > 1000: break save_todo(pending) # 每处理一个就持久化一次这段代码的核心是save_todo(pending)放在循环内部,每处理完一个 URL 就更新一次待抓取队列。即使程序中途崩溃,下次启动时加载pending_urls.pkl就能从断点继续,而不是重新抓一遍。失败 URL 放回队尾而不是丢弃,配合重试计数可以避免因临时网络抖动丢掉有效数据。用 pickle 存中间状态对单机脚本足够轻量,数据量大了再换 SQLite 或 Redis 队列。
最后再提醒一个容易忽略的点:修改job_spider.py里的请求间隔时,不要只改time.sleep()的参数,还要注意请求失败的退避策略。51job 对短时间高频请求的封禁阈值并不是固定的,职责是先用小流量测试出当前 IP 的可用频率——比如先抓 50 页看是否有验证码出现,再逐步缩短间隔到 0.5 秒,找到既不触发反爬又能跑完任务的最优节奏,这个阈值记录下来写进配置项,比在代码里硬编码更实用。
本文还有配套的精品资源,点击获取