招聘数据分析实战:从爬虫到可视化的完整流程 简介面向Python爬虫、数据分析与可视化初学者以及准备求职大数据岗位的毕业生这份资源完整实践了从Boss直聘采集北京地区大数据岗位信息、数据清洗聚合、统计分析与可视化展示的全流程。压缩包共111个文件大小6.58MB包含4个Python源码文件、xls/csv数据源、db数据库以及可视化展示页面的html/css/js等前端资源目录结构清晰便于按爬虫、处理、展示三个模块对照学习。目前已有727人学习下载。学习者可掌握requests/Scrapy爬虫、BeautifulSoup网页解析、pandas清洗聚合等核心技能并借助matplotlib、seaborn或前端图表呈现薪资区间、岗位数量、经验要求等分布趋势。资源自带可直接运行的数据与脚本省去重复采集流程适合作为课程设计、毕业项目或数据分析入门练手实战价值高。1. 北京大数据岗位招聘数据分析爬虫、清洗到可视化的完整闭环这个项目我在本地跑通的时候第一反应是“它比大多数教学 Demo 都完整”。它不是那种只给你一个 Jupyter Notebook 让你点点运行就出图的演示而是从 Boss 直聘抓取北京市大数据相关岗位的招聘信息开始一路走到数据清洗、统计分析、可视化展示的完整项目。换句话说你拿到的不只是代码是一整条“从网页到图表”的生产链路。它的核心价值在于第一爬虫部分不是玩具级的单页抓取而是带请求头伪装、翻页逻辑和网页解析的真实采集脚本第二数据处理直接面对招聘网站最常见的脏数据——薪资写成“20-40K·14薪”、经验要求写成“3-5年”、学历写成“本科”这种非结构化文本第三可视化输出覆盖了静态图表和交互式图表两种形态。适合谁正在学 Python 数据分析、想找一份能写进简历的综合实战项目的人以及已经能写简单爬虫但不知道怎么把数据做成分析结论的从业者。你不需要先会大数据框架pandas matplotlib pyecharts 这套组合足够支撑整个项目跑完。2. 招聘数据爬虫requests 加 BeautifulSoup 抓取 Boss 直聘的关键实现2.1 请求构造为什么不能直接 requests.getBoss 直聘的页面结构不算特别复杂但它对请求头的校验比一般站点严格。直接用 requests.get 不带任何请求头去访问大概率返回一个安全验证页面HTML 里根本没有职位列表数据。这个项目里的爬虫思路是先手动从浏览器复制一份完整的请求头重点是 User-Agent、Referer 和 Cookie 三个字段。User-Agent 用来伪装成真实浏览器Referer 告诉服务器你是从搜索页跳转过来的Cookie 则是维持会话状态的关键——Boss 直聘的搜索接口依赖 Cookie 里的登录态和会话标识。import requests from bs4 import BeautifulSoup 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/web/geek/job?query大数据city101010100, Cookie: 你的浏览器 Cookie建议从实际登录后的页面复制 } url https://www.zhipin.com/web/geek/job params { query: 大数据, city: 101010100, # 北京的城市编码 page: 1 } resp requests.get(url, headersheaders, paramsparams, timeout10) resp.encoding utf-8 print(resp.status_code, len(resp.text))这段代码的逻辑是先构造一个带完整请求头的 GET 请求然后把搜索关键词和城市编码放在 query 参数里。city101010100 是北京的城市编码这个值是从 Boss 直聘前端页面的请求参数里抓出来的。timeout10 是超时保护防止某个请求卡住导致整个爬虫停摆。拿到响应后先打印 status_code 和文本长度这是爬虫调试的第一步如果状态码是 200 但文本长度只有几千字符基本就是被安全策略拦截了返回的是验证页。2.2 解析职位列表把 HTML 变成结构化字段拿到 HTML 之后需要定位职位卡片所在的 DOM 节点。Boss 直聘的职位列表在 class 为 job-card-wrapper 的 div 里每条职位卡片包含职位名称、薪资、公司名、城市、经验学历要求等字段。用 BeautifulSoup 的 find_all 方法可以一次取出所有卡片再逐个提取字段。soup BeautifulSoup(resp.text, html.parser) cards soup.find_all(div, class_job-card-wrapper) for card in cards: job_name card.find(span, class_job-name).text.strip() salary card.find(span, class_salary).text.strip() company card.find(div, class_company-name).text.strip() # 经验、学历、城市在 job-card-footer 里的 ul 标签下 footer_li card.find(ul, class_tag-list).find_all(li) tags [li.text.strip() for li in footer_li] print(job_name, salary, company, tags)这里有一个很容易踩的细节经验、学历、城市这些信息不在同一个 class 里它们在职位卡片底部的一个无序列表里标签可能包含“3-5年”“本科”“北京·海淀区”等信息。用 find_all(li) 提取后再根据关键词做分类比如包含“年”的字段归为经验包含“本科”或“硕士”的归为学历。解析完成后每一行数据就是一个职位的结构化记录。把这些记录追加到列表里最后统一转成 DataFrame比边爬边写 CSV 要高效得多。2.3 翻页与多关键词抓取控制节奏才能拿全数据Boss 直聘的搜索结果最多只能看到前几页这是平台的反爬限制。项目里处理翻页的方式是循环修改 params 里的 page 参数同时每次请求之间加一个随机延时模拟人类操作节奏。import time import random all_jobs [] for page in range(1, 11): params[page] page resp requests.get(url, headersheaders, paramsparams, timeout10) soup BeautifulSoup(resp.text, html.parser) cards soup.find_all(div, class_job-card-wrapper) if not cards: print(f第 {page} 页无数据停止抓取) break for card in cards: # 提取字段逻辑同上一节 pass all_jobs.extend(cards_data) time.sleep(random.uniform(2, 5))翻页逻辑的核心是“没数据就停”不要盲目爬满 10 页。因为平台对翻页深度有控制超过一定页数后返回的卡片列表是空的此时继续请求只会增加被封风险。random.uniform(2, 5) 是每页之间 2 到 5 秒的随机等待这个间隔在多数招聘网站上是安全的。我的习惯是先把单页解析跑通、字段确认无误后再加循环否则翻页时如果解析报错很难判断是页面结构变了还是翻页参数错了。3. 数据清洗与预处理薪资文本拆分、缺失值处理和字段规整3.1 薪资字段是所有坑的源头从网页上直接抓下来的薪资字段长这样“20-40K·14薪”“10-15K”“8千-1.2万”。这些文本没法直接参与计算需要拆成数字。项目里的处理方式是用正则表达式提取数字范围再统一单位。import re def parse_salary(salary_text): if not salary_text: return None, None, None # 先统一单位将千转成 K万转成 K text salary_text.replace(千, K).replace(万, K) nums re.findall(r(\d\.?\d*), text) if len(nums) 1: # 只有单个值比如 20K取上下限相同 low high float(nums[0]) elif len(nums) 2: low float(nums[0]) high float(nums[1]) else: return None, None, None # 如果原始文本里出现 万需要乘以 10 转成 K if 万 in salary_text: low, high low * 10, high * 10 return low, high, salary_text这个函数的处理逻辑分三步先把“千”“万”统一成 K然后提取所有数字最后根据数字个数判断是区间还是单值。这里有个细节值得注意如果原始文本里写了“万”提取出来的数字是以“万”为单位的必须先乘以 10 转成 K否则 8千-1.2万 会被错误解析成 8 到 1.2。转成 K 之后在后续分析里统一用 K 作单位计算均值时就不会出现量级混乱。解析完成后把 low 和 high 存成两个新列还可以直接算出中位数作为“平均薪资”的替代指标。招聘数据里薪资大多是一个范围直接用上限或下限都偏离真实情况用 (low high) / 2 作为单点值做排序和对比最稳妥。3.2 经验与学历字段的文本规整经验和学历字段抓下来后也不干净。“3-5年”这种格式是混在标签列表里的有的职位写“经验不限”有的写“在校生/应届生”学历字段会出现“本科”“硕士”“学历不限”等。处理方式是把文本映射成统一的等级数字方便后续排序和分组。exp_map { 经验不限: 0, 在校生/应届生: 1, 1年以内: 1, 1-3年: 2, 3-5年: 3, 5-10年: 4, 10年以上: 5 } def parse_experience(tags): for tag in tags: if 经验 in tag or 在校生 in tag or 应届生 in tag: return exp_map.get(tag, -1) return -1这是一个典型的“有限字段枚举映射”思路先把网站可能出现的所有经验值收集齐然后建一个映射字典。为什么不用正则直接提取数字因为“经验不限”“在校生/应届生”这类文本里没有数字正则提取不到。把字段映射成语义化的等级数字后pandas 的 groupby 就可以按等级排序展示图表轴上的顺序也是对的。学历字段同理建立“大专”:1、“本科”:2、“硕士”:3、“博士”:4 的映射学历不限单独作为 0。3.3 缺失值处理与第一轮统计分析抓下来的原始数据会有各种缺失有的职位没写薪资、有的公司名称为空。pandas 的 dropna 不能直接乱用因为删除整行会导致样本量大幅缩水。我的处理习惯是先看各列缺失比例如果某列缺失超过 30%就考虑这列不参与分析如果只是个别行缺失才用 dropna 或填充默认值。import pandas as pd df pd.DataFrame(all_jobs) df[salary_low] df[salary].apply(lambda x: parse_salary(x)[0]) df[salary_high] df[salary].apply(lambda x: parse_salary(x)[1]) df.loc[df[salary_low].isna(), salary_low] df[salary_low].median() df.loc[df[salary_high].isna(), salary_high] df[salary_high].median() # 经验字段映射 df[exp_level] df[tags].apply(parse_experience) # 第一轮统计按经验等级分组看职位数量和平均薪资 stats df.groupby(exp_level).agg( 职位数量(job_name, count), 平均薪资下限(salary_low, mean), 平均薪资上限(salary_high, mean) ).reset_index() print(stats)这段代码里有个关键的 pandas 用法groupby 配合 agg 传入多个聚合函数一次性算出职位数量和薪资均值。apply 配合 lambda 把每一行的薪资文本拆成上下限拆出来是 None 的用整列中位数填充避免后续计算出现空值。缺失值处理的原则是能用中位数填充就不用均值因为薪资分布是右偏的个别超高薪职位会把均值拉高中位数更能代表整体水平。4. 数据可视化展示从统计数字到可读图表的完整过程4.1 matplotlib 静态图表先让数据分布说话数据分析的产出不能只是一张统计表图表才是给非技术角色看的核心交付物。项目里用 matplotlib 输出的第一张图是“不同经验要求的职位数量分布”和“薪资区间箱线图”这两张图配合起来能回答一个招聘市场最常被问到的问题什么经验档位的岗位最多、薪资涨得最快。import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] # 解决中文乱码 plt.rcParams[axes.unicode_minus] False # 解决负号显示异常 fig, ax plt.subplots(1, 2, figsize(14, 6)) # 左图经验等级 vs 职位数量 ax[0].bar(stats[exp_level].astype(str), stats[职位数量], color#4C72B0) ax[0].set_title(不同经验要求的大数据岗位数量分布) ax[0].set_xlabel(经验等级) ax[0].set_ylabel(职位数量) # 右图经验等级 vs 平均薪资 ax[1].plot(stats[exp_level].astype(str), stats[平均薪资下限], markero, label薪资下限) ax[1].plot(stats[exp_level].astype(str), stats[平均薪资上限], markers, label薪资上限) ax[1].set_title(不同经验等级的平均薪资区间) ax[1].set_ylabel(平均薪资(K)) ax[1].legend() plt.tight_layout() plt.savefig(salary_by_experience.png, dpi200) plt.show()两个图表放在同一张画布上用 subplot 分成左右两列左侧看岗位供需、右侧看薪资变化。这里最容易翻车的点是中文乱码Windows 下需要指定 SimHei 或微软雅黑字体Mac 下则可以用 PingFang SC。如果画出来的图全是方块优先检查字体配置而不是数据。4.2 pyecharts 交互式图表地图、词云与动态报表静态图表适合嵌入报告但项目里“可视化展示”这个标题意味着还需要能交互的页面级图表。pyecharts 是实现这个目标的直接方案它生成的 HTML 文件可以在浏览器里打开支持鼠标悬停显示数值、缩放平移。from pyecharts.charts import Map, WordCloud from pyecharts import options as opts # 北京各区岗位数量分布地图 areas df[area].value_counts().reset_index() area_map Map() area_map.add( 北京大数据岗位分布, [list(z) for z in zip(areas[area], areas[count])], maptype北京 ) area_map.set_global_opts( title_optsopts.TitleOpts(title北京市大数据岗位区域分布), visualmap_optsopts.VisualMapOpts(max_areas[count].max()) ) area_map.render(beijing_job_map.html)代码里用 value_counts 统计各区域出现的频次然后转成 pyecharts 需要的 [区域名, 数值] 列表格式。maptype 参数指定了“北京”地图这个需要 pyecharts 的地图数据包支持新版本会自动下载。另外还有一个交互式的词云图从职位名称里提取关键词把“大数据”“数据分析”“Hadoop”“Spark”这些高频词按出现频率渲染成词云。词云图在汇报场景里很讨喜能一眼看出市面上需求量最大的技能方向。4.3 把图表拼成一份可视化看板项目里没有用 Flask 或 Django而是采用了更轻量的方案把多个 pyecharts 生成的 HTML 文件通过一个总页面聚合在一起用 iframe 嵌套或者直接链式渲染。这样做的优点是零后端依赖双击就能打开分享给同事也方便。如果你想把可视化做得更完整可以把统计表、柱状图、地图、词云全部串到一个 HTML 里用 bootstrap.css 和 animate.css 做页面布局和动效这也是项目里带这些前端资源文件的原因。5. 避坑与常见问题这套项目里最容易翻车的四个环节5.1 拿到 200 状态码却解析不到数据现象请求返回状态码 200但 BeautifulSoup 找 job-card-wrapper 时结果为空。原因Boss 直聘的安全策略在某些情况下会返回一个带验证逻辑的页面状态码仍然是 200但 body 里不是职位列表数据而是验证脚本或提示文案。解决打印响应文本的前 500 个字符检查是否包含“验证”或“安全”关键词。如果确认被拦截停止程序等 30 分钟以上再跑同时检查 Cookie 是否过期。更稳妥的做法是先用浏览器打开目标页手动搜索一次关键词让服务器认为你是一个活跃用户再重新复制 Cookie 启动爬虫。5.2 薪资字段拆出负数现象parse_salary 之后的 salary_low 出现负数或异常小值。原因薪资文本里出现了“15-25K·15薪”这种结构正则提取数字时把后面的“15薪”的 15 也提取了导致高低区间错位。解决提取数字之前先用 split 按“·”拆分只处理第一个分段里的内容也就是“15-25K”部分薪期信息单独存一列不参与薪资区间计算。5.3 岗位数据量刚过百可视化地图显示空白现象区域分布地图上只有零星几个点或者直接空白。原因Boss 直聘的职位卡片里“区域”字段不是统一格式有的写“北京·海淀区”有的写“北京市海淀区”直接用 value_counts 统计时相同区域被拆成多个不同条目。解决先对区域字段做归一化用正则把“北京·”和“北京市”前缀去掉只保留区名。地图上显示区名才能正确匹配到地理边界数据。5.4 pyecharts 版本升级后地图文件打不开现象用新版 pyecharts 生成的地图 HTML 在浏览器里打开提示找不到地图数据或加载失败。原因pyecharts 从 1.x 升级到 2.x 后地图数据包的引入方式变了老代码里直接 map.add 的地图名称在新版本里失效。解决确认 pyecharts 版本新版需要先安装 pyecharts-snapshot 或地图扩展包并在代码里显式注册地图数据。如果不想纠结版本问题直接锁 pyecharts1.9.0 搭配 echarts-countries-pypkg 是最省心的组合。这个坑我每次带新人做可视化学员项目时都会遇到建议拿到项目后先跑一下 requirements 里的版本不要用最新版硬碰。5.5 多关键词合并后出现大量重复职位现象爬了“大数据”“数据分析”“数据挖掘”多个关键词后去重后发现同一家公司同一个职位重复出现多次。原因同一岗位可能同时匹配多个搜索关键词Boss 直聘会把同一个职位重复展示在不同关键词的搜索结果里。解决以“职位名称 公司名 薪资区间”三个字段拼接作为唯一键去重薪资区间用小范围内近似比如误差在 5% 以内视为同一职位。6. 进阶把爬取、清洗、分析和可视化串成一条可复用流水线项目做到这里已经是一个完整的闭环但离“可复用”还差一步现在每一步都是独立脚本换一个城市或者换一个关键词需要手动改多处参数。我会把它拆成一个 config.py 加三个模块形成一条可复用的流水线。config.py 里放城市编码、搜索关键词列表、请求头模板、延时区间和输出路径。爬虫模块读取这些配置把抓到的数据统一落成 CSV清洗模块负责把薪资、经验、学历字段解析成结构化数据分析模块输出统计汇总表和图表。改城市只需要改一个配置文件不需要动任何逻辑代码。# config.py CITY_CODE 101010100 # 北京 KEYWORDS [大数据, 数据分析, 数据挖掘, Hadoop] OUTPUT_CSV output/bj_jobs.csv SLEEP_INTERVAL (2, 5) # 流水线入口 main.py from crawler import crawl from cleaner import clean from analyzer import analyze if __name__ __main__: raw_df crawl(CITY_CODE, KEYWORDS) clean_df clean(raw_df) analyze(clean_df)清洗模块里可以加一层更细的质检逻辑检查薪资字段解析成功率是否低于 90%、学历映射失败的行数是否超过阈值超过就打印警告日志。这一步是防止数据质量劣化的最后一道闸门。如果是自己做招聘市场调研这类周期性需求这套流水线可以直接定时触发。把 main.py 挂到 cron 或 Windows 计划任务里每周跑一次自动生成最新的市场薪资报告。比起每次手动改参数重新爬这个模式才是项目从“能跑”走向“能用”的分水岭。我自己在跑这个项目时最深的体会是爬虫不是这个项目的难点清洗才是。前端页面改一次结构解析代码就得跟着改薪资文本换一种写法正则又得调。所以我现在做这类项目第一件事不是写爬虫而是先手动收集 20 条真实页面数据把所有文本规律列出来再开始写解析函数——这个习惯帮我避开了大半的后续返工。希望帮到你。本文还有配套的精品资源点击获取