招聘网站数据分析实战:从数据采集清洗到可视化大屏的完整落地路径
简介这是一套面向计算机相关专业学生与Python初学者的招聘网站数据分析与可视化实战源码已通过教师指导并获评高分毕业设计同样适合作为期末大作业或课程设计参考。项目围绕招聘信息采集、清洗、统计与图表呈现展开覆盖数据获取、数据清洗、数据分析、数据可视化及Echarts大屏展示等完整环节帮助读者理解从原始数据到可视化结论的全流程。压缩包共54个文件约6.68MB包含4个py脚本与4个ipynb笔记用于爬取和分析8个csv与8个xml承载招聘数据另有html、js、css等前端文件及png、jpg图表素材结构清晰便于按模块查阅。目前已有573人学习下载。读者可据此掌握招聘数据的清洗思路、词云与饼图等可视化实现方式并借鉴大屏展示的目录组织与排错经验快速完成自己的项目。1. 招聘网站数据分析项目从岗位数据到可视化大屏的完整落地路径招聘网站的数据分析很多人第一反应是「爬下来、画个图」但真正做过的人知道坑从数据采集那一刻就开始了。岗位名称不统一、薪资写成「8k-15k·13薪」、城市字段混着「北京」和「北京·朝阳区」、同一家公司反复发布相似岗位——这些问题不解决后面画出来的图表全是噪音。这个项目要解决的核心问题是把招聘网站上散落的岗位信息变成一套可复用的分析流水线最终输出能直接看的可视化结果。适合谁正在找数据分析练手项目的学生、想用真实数据做作品集的转行者、以及需要快速搭建招聘数据监控的HR技术岗。技术栈上Python负责采集和清洗pandas做分析ECharts或Pyecharts做可视化整个流程不依赖付费工具本地就能跑通。2. 数据采集与清洗招聘网站字段解析的五个关键决策2.1 采集方案选型requestsBeautifulSoup还是Selenium招聘网站分两类一类是服务端渲染的静态页面岗位信息直接嵌在HTML里另一类是前端渲染数据通过接口异步加载。选型逻辑很简单——打开目标网站右键查看网页源代码如果能搜到岗位名称用requests就够了如果搜不到说明是JS渲染要么找XHR接口直接请求JSON要么上Selenium模拟浏览器。我一般优先找接口。以某招聘网站为例按F12打开Network面板筛选XHR请求翻几页列表就能看到类似/api/job/list?city北京page1的请求返回的是结构化JSON。这种方式比解析HTML稳定得多字段清晰也不容易触发反爬。import requests import pandas as pd import time import random def fetch_jobs(keyword, city, pages10): 通过接口分页获取招聘数据 keyword: 搜索关键词如Python city: 城市编码如北京对应101010100 pages: 抓取页数 base_url https://example.com/api/job/list headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://example.com/jobs/, } all_jobs [] for page in range(1, pages 1): params { query: keyword, city: city, page: page, pageSize: 30, } try: resp requests.get(base_url, paramsparams, headersheaders, timeout10) resp.raise_for_status() data resp.json() jobs data.get(data, {}).get(list, []) if not jobs: break all_jobs.extend(jobs) # 随机延时降低被封风险 time.sleep(random.uniform(1.5, 3.5)) except requests.RequestException as e: print(f第{page}页请求失败: {e}) continue return all_jobs raw_data fetch_jobs(Python, 101010100, pages5) df pd.DataFrame(raw_data) print(f共获取 {len(df)} 条岗位数据)这段代码的核心逻辑是构造带参数的GET请求逐页拉取JSON数据累积到列表后转成DataFrame。几个参数需要根据实际情况调整——pageSize通常有上限常见是30或50设太大可能被截断time.sleep的随机区间是反爬的基本功固定间隔反而容易被识别headers里的Referer不能省很多接口会校验来源。如果目标网站没有公开接口那就得回到HTML解析。BeautifulSoup配合lxml解析器定位岗位卡片的CSS选择器逐条提取。这种方式维护成本高网站改版就得重写选择器但胜在通用。2.2 薪资字段清洗从「8k-15k·13薪」到可计算的数值薪资是招聘数据分析里最脏的字段。常见格式有「8k-15k」「8000-15000元/月」「15-25万/年」「200元/天」还有「8k-15k·13薪」这种带年终奖系数的。不统一处理后面算平均值、画分布图全是错的。清洗策略分三步先提取数字区间再统一单位最后折算成年薪。import re import numpy as np def parse_salary(salary_str): 解析薪资字符串返回(最低月薪, 最高月薪, 年薪月数) 无法解析时返回(None, None, None) if not salary_str or pd.isna(salary_str): return None, None, None salary_str str(salary_str).strip().lower() # 提取薪资格式中的数字和单位 # 匹配 8k-15k 8000-15000 15-25万 等 pattern r(\d\.?\d*)\s*[k千]?\s*[-~到]\s*(\d\.?\d*)\s*[k千万]? match re.search(pattern, salary_str) if not match: # 尝试匹配单一数值 10k single re.search(r(\d\.?\d*)\s*[k千], salary_str) if single: val float(single.group(1)) if k in salary_str or 千 in salary_str: val * 1000 return val, val, 12 return None, None, None low, high float(match.group(1)), float(match.group(2)) # 单位换算k/千 - 乘1000万 - 乘10000 if 万 in salary_str: low * 10000 high * 10000 elif k in salary_str or 千 in salary_str: low * 1000 high * 1000 # 判断是年薪还是月薪 if 年 in salary_str or 万/年 in salary_str: low low / 12 high high / 12 # 提取薪月数如13薪表示13个月 months_match re.search(r(\d)\s*薪, salary_str) months int(months_match.group(1)) if months_match else 12 return round(low, 0), round(high, 0), months # 应用清洗 df[[salary_low, salary_high, salary_months]] df[salary].apply( lambda x: pd.Series(parse_salary(x)) ) # 计算平均月薪 df[salary_avg] (df[salary_low] df[salary_high]) / 2 # 过滤掉解析失败的记录 df_valid df.dropna(subset[salary_avg]).copy() print(f有效薪资记录: {len(df_valid)} / {len(df)})这里有几个容易翻车的点。第一正则里的[k千]和[k千万]要分开写因为「万」和「k」的换算倍率不同混在一起会算错。第二「15-25万/年」这种格式正则匹配到的是15和25但单位是万需要先乘10000再除以12才是月薪。第三「·13薪」的提取要在单位换算之后做否则会影响数值判断。第四解析失败的记录不要直接丢弃先看看是什么格式漏了补进正则里否则样本量会莫名其妙少一大截。2.3 城市与经验字段的标准化处理城市字段的脏法很统一「北京·朝阳区」「上海-浦东新区」「深圳南山区」分隔符五花八门。处理方式是用正则提取第一个分隔符之前的内容作为城市名。def extract_city(location): 从北京·朝阳区提取北京 if not location or pd.isna(location): return None # 按常见分隔符切分取第一部分 parts re.split(r[·\-—\s], str(location).strip()) city parts[0] if parts else None # 去掉市后缀统一格式 if city and city.endswith(市): city city[:-1] return city df_valid[city_clean] df_valid[location].apply(extract_city)经验字段通常是「3-5年」「1年以内」「经验不限」这几种。统一映射成数值区间方便后续做分组统计。def parse_experience(exp_str): 将经验要求转为(最低年限, 最高年限) if not exp_str or pd.isna(exp_str): return None, None exp_str str(exp_str) if 不限 in exp_str: return 0, 99 if 应届 in exp_str or 在校 in exp_str: return 0, 1 if 以内 in exp_str: num re.search(r(\d), exp_str) return 0, int(num.group(1)) if num else 1 match re.search(r(\d)\s*[-~]\s*(\d), exp_str) if match: return int(match.group(1)), int(match.group(2)) single re.search(r(\d), exp_str) if single: return int(single.group(1)), int(single.group(1)) return None, None df_valid[[exp_min, exp_max]] df_valid[experience].apply( lambda x: pd.Series(parse_experience(x)) )标准化之后数据才算真正可用。这一步的产出是一张干净的宽表包含岗位名称、公司、城市、薪资区间、经验要求、学历要求、技能标签等字段。后续所有分析都基于这张表。3. 分析维度设计招聘数据里真正值得挖的四条线3.1 薪资分布分析按城市、经验、学历分层对比薪资分析最忌讳只看平均值。招聘数据里薪资分布通常是右偏的少数高薪岗位会把均值拉高中位数反而更能反映真实水平。我一般会同时输出均值、中位数和分位数25%、50%、75%画箱线图或小提琴图来展示分布形态。按城市分组的对比是高频需求。一线城市和二三线城市的薪资差距、同一城市不同区域的差异都能从分组统计里看出来。# 按城市统计薪资 city_stats df_valid.groupby(city_clean)[salary_avg].agg([ (岗位数, count), (平均月薪, mean), (中位数月薪, median), (25分位, lambda x: x.quantile(0.25)), (75分位, lambda x: x.quantile(0.75)), ]).round(0).sort_values(岗位数, ascendingFalse) # 只保留岗位数大于10的城市避免小样本偏差 city_stats city_stats[city_stats[岗位数] 10] print(city_stats.head(10))按经验分层时要注意「经验不限」的岗位占比。如果这类岗位太多会稀释各经验段的对比效果。我的做法是单独统计「经验不限」的薪资水平再和明确要求经验的岗位做对比往往能发现一些有意思的现象——比如某些公司挂「经验不限」但实际给的是初级薪资。学历维度的分析类似本科、硕士、大专的薪资梯度以及「学历不限」岗位的薪资分布都值得单独看。3.2 技能关键词提取从岗位描述里挖出高频技术栈岗位描述JD里藏着大量信息但很多人只拿它当文本不做结构化提取。我的做法是用jieba分词加自定义词典把技能关键词抽出来统计词频和共现关系。import jieba import jieba.analyse from collections import Counter # 自定义技能词典提高分词准确率 skill_dict [Python, Java, Go, MySQL, Redis, Docker, Kubernetes, 机器学习, 深度学习, 数据分析, 爬虫, Django, Flask, Spark, Hadoop, Linux, Git, Nginx, MongoDB] for skill in skill_dict: jieba.add_word(skill) def extract_skills(desc): 从岗位描述中提取技能关键词 if not desc or pd.isna(desc): return [] # 用TF-IDF提取关键词取前10个 keywords jieba.analyse.extract_tags(str(desc), topK10) # 只保留在技能词典里的词 return [kw for kw in keywords if kw in skill_dict] df_valid[skills] df_valid[description].apply(extract_skills) # 统计技能词频 all_skills [] for skills in df_valid[skills]: all_skills.extend(skills) skill_counts Counter(all_skills) print(skill_counts.most_common(20))这里的关键是自定义词典。jieba默认的分词对技术名词识别很差「机器学习」可能被切成「机器」和「学习」「Kubernetes」可能被切成单个字母。把常见技术栈加进词典提取准确率会大幅提升。共现分析也很有价值——比如「Python」和「Django」经常一起出现「Spark」和「Hadoop」是绑定关系。用itertools的组合函数统计共现频次能画出技能关联网络图。3.3 公司维度分析招聘量与薪资的散点关系公司维度的分析容易被忽略但对求职者来说很实用。统计每家公司的在招岗位数、平均薪资、岗位类型分布能快速识别出「大量招人但薪资偏低」和「少量招人但薪资很高」两类公司。company_stats df_valid.groupby(company).agg( 岗位数(salary_avg, count), 平均月薪(salary_avg, mean), 最高月薪(salary_avg, max), ).round(0) # 筛选在招岗位数大于等于3的公司 company_stats company_stats[company_stats[岗位数] 3] company_stats company_stats.sort_values(平均月薪, ascendingFalse) print(company_stats.head(15))散点图是展示公司维度的好方式——X轴是岗位数Y轴是平均薪资每个点代表一家公司。这样能直观看到哪些公司是「高薪少量」型哪些是「低薪大量」型。如果数据量够大还能按行业给点上色观察不同行业的分布差异。3.4 时间趋势分析如果有多期数据怎么对比单次采集的数据只能看截面如果每隔一段时间采集一次就能做趋势分析。比如某类岗位的招聘量是在增加还是减少薪资是在涨还是在跌。实现方式很简单每次采集时加一个crawl_date字段存成CSV时带上日期。分析时按日期分组对比不同时间点的岗位数和薪资中位数。# 假设有多个日期的数据文件 import glob files glob.glob(data/jobs_*.csv) dfs [] for f in files: temp pd.read_csv(f) # 从文件名提取日期如 jobs_2024-01-15.csv temp[date] f.split(_)[1].replace(.csv, ) dfs.append(temp) df_all pd.concat(dfs, ignore_indexTrue) # 按日期统计岗位数和薪资中位数 trend df_all.groupby(date).agg( 岗位数(salary_avg, count), 薪资中位数(salary_avg, median), ).round(0) print(trend)趋势分析的价值在于发现拐点。比如某个月Python岗位突然增多可能是某个行业在扩张薪资中位数连续下降可能说明市场供给过剩。这些信号对求职和招聘都有参考意义。4. 可视化落地用Pyecharts搭一个能交互的招聘数据看板4.1 图表选型什么数据配什么图可视化不是把数据随便画成图选错图表类型比不画还糟糕。招聘数据的常见图表对应关系如下分析目标推荐图表不推荐原因城市薪资对比横向柱状图饼图饼图不适合比较数值大小薪资分布箱线图/小提琴图折线图折线图适合趋势不适合分布技能词频词云/横向柱状图散点图散点图无法表达频次公司岗位数与薪资散点图柱状图散点图能同时展示两个维度时间趋势折线图柱状图折线图更适合连续时间序列学历与经验交叉热力图饼图热力图能展示二维交叉分布Pyecharts的优势是生成的图表是HTML格式可以直接嵌入网页支持交互悬停显示数值、点击筛选。相比matplotlib的静态图Pyecharts更适合做数据看板。4.2 用Pyecharts生成薪资分布箱线图from pyecharts import options as opts from pyecharts.charts import Boxplot # 准备数据按城市分组每个城市一个薪资列表 cities city_stats.index.tolist()[:8] # 取岗位数前8的城市 box_data [] for city in cities: salaries df_valid[df_valid[city_clean] city][salary_avg].dropna().tolist() box_data.append(salaries) boxplot ( Boxplot(init_optsopts.InitOpts(width900px, height500px)) .add_xaxis(cities) .add_yaxis(薪资分布, boxplot.prepare_data(box_data)) .set_global_opts( title_optsopts.TitleOpts(title各城市Python岗位薪资分布), yaxis_optsopts.AxisOpts(name月薪元), xaxis_optsopts.AxisOpts(name城市), toolbox_optsopts.ToolboxOpts(is_showTrue), ) ) boxplot.render(salary_boxplot.html)prepare_data是Pyecharts箱线图的专用方法它会把原始数据列表自动计算成箱线图需要的五数概括最小值、25分位、中位数、75分位、最大值。toolbox_opts开启工具箱后图表右上角会出现下载、缩放等按钮方便导出图片。4.3 技能词云与柱状图的组合展示词云适合做概览柱状图适合做精确对比。我一般两个都做词云放在看板顶部吸引注意力柱状图放在下方提供具体数值。from pyecharts.charts import WordCloud, Bar from pyecharts.charts import Grid # 词云数据 wordcloud_data [(skill, count) for skill, count in skill_counts.most_common(50)] wordcloud ( WordCloud() .add(, wordcloud_data, word_size_range[12, 60], shapecircle) .set_global_opts( title_optsopts.TitleOpts(title岗位技能关键词词云), ) ) # 柱状图数据 top_skills skill_counts.most_common(15) bar ( Bar() .add_xaxis([s[0] for s in top_skills]) .add_yaxis(出现次数, [s[1] for s in top_skills]) .reversal_axis() .set_global_opts( title_optsopts.TitleOpts(titleTop15技能词频), xaxis_optsopts.AxisOpts(name出现次数), ) ) # 分别渲染 wordcloud.render(skill_wordcloud.html) bar.render(skill_bar.html)词云的word_size_range控制字体大小范围shape参数可以选circle、cardioid等形状。柱状图的reversal_axis()把柱子横过来适合技能名称较长的情况避免X轴标签挤在一起。4.4 把多个图表拼成一个HTML看板Pyecharts支持用Page类把多个图表拼成一个页面按顺序排列。from pyecharts.charts import Page page Page(layoutPage.DraggablePageLayout) page.add( boxplot, wordcloud, bar, ) page.render(recruitment_dashboard.html)DraggablePageLayout允许在浏览器里拖动调整图表位置适合做原型。确定布局后可以改成SimplePageLayout固定排列。生成的HTML文件可以直接用浏览器打开不需要服务器。如果需要更专业的看板效果可以用Pyecharts的Grid类做组合图表或者用Tab类做标签页切换。但要注意图表不是越多越好一个看板放3-5个核心图表就够了信息过载反而没人看。5. 避坑与排查招聘数据项目里最容易翻车的五个地方5.1 采集频率过高导致IP被封现象前几页正常返回后面全部超时或返回403。原因请求间隔太短触发了网站的频率限制。有些网站还会记录IP的请求历史短时间内大量请求会被临时封禁。解决请求间隔设为1.5-3.5秒的随机值不要用固定间隔。如果还是被封加大间隔到5-10秒或者换用代理池注意合规使用。另外单次采集量不要太大分批次、分时段采集降低单IP的请求密度。5.2 薪资解析正则漏掉特殊格式现象清洗后有效薪资记录只有一半大量记录被丢弃。原因正则只覆盖了「8k-15k」这种标准格式漏掉了「15-25万/年」「200-300元/天」「面议」等变体。解决先把所有解析失败的薪资字符串打印出来人工看一遍把新格式补进正则。对于「面议」这类无法解析的单独标记不要混入数值统计。日薪格式需要乘以工作日数通常按21.75天折算成月薪。5.3 技能提取把非技能词也算进去现象词频统计里出现「负责」「熟悉」「具备」等动词或者「公司」「团队」等通用词。原因jieba的TF-IDF提取的是文本中权重高的词不区分词性。动词和通用名词在JD里出现频率也很高会被误提取。解决在自定义词典的基础上加一个停用词表把「负责」「熟悉」「具备」「良好」「能力」等词过滤掉。另外可以结合词性标注只保留名词和英文单词。更严格的做法是维护一个技能白名单只提取白名单里的词。5.4 可视化图表中文显示为方块现象Pyecharts生成的图表里中文标题和标签显示成方框。原因Pyecharts默认使用的字体不包含中文字符或者浏览器加载字体失败。解决Pyecharts的HTML图表通常不会有这个问题因为浏览器会自动 fallback 到系统字体。如果确实出现方块检查InitOpts里是否指定了不支持的字体。matplotlib才需要手动设置plt.rcParams[font.sans-serif] [SimHei]。Pyecharts用户遇到这个问题大概率是浏览器缓存或字体缺失换个浏览器试试。5.5 数据量太大导致pandas内存溢出现象采集了几十万条数据后pd.read_csv报MemoryError。原因pandas默认把字符串列存为object类型内存占用是实际字符数的好几倍。几十万条JD文本很容易吃掉几个G内存。解决读取时指定dtype参数把数值列设为int32或float32把低基数的字符串列设为category类型。对于JD文本这种长字符串如果不需要全文分析读取后立即提取关键词然后drop掉原始列。分批读取用chunksize参数每次处理一万条处理完就释放。# 优化内存的读取方式 dtypes { salary_low: float32, salary_high: float32, salary_avg: float32, city_clean: category, company: category, } df pd.read_csv(jobs.csv, dtypedtypes) # 处理完文本后删除原始列 df[skills] df[description].apply(extract_skills) df.drop(columns[description], inplaceTrue)6. 进阶技巧用Streamlit把分析脚本变成可交互的Web应用Pyecharts生成的是静态HTML每次数据更新都要重新跑脚本。如果想做成一个能实时查询、动态筛选的工具Streamlit是更合适的选择。它不需要写前端代码用Python就能搭出一个带下拉框、滑块、搜索框的交互页面。核心思路是把前面的分析逻辑封装成函数用Streamlit的组件接收用户输入动态过滤数据并重新计算。import streamlit as st import pandas as pd import plotly.express as px st.set_page_config(page_title招聘数据分析看板, layoutwide) st.title(招聘网站数据分析看板) st.cache_data def load_data(): 加载并缓存数据避免每次交互都重新读取 df pd.read_csv(data/jobs_clean.csv) return df df load_data() # 侧边栏筛选器 st.sidebar.header(筛选条件) cities st.sidebar.multiselect( 选择城市, optionsdf[city_clean].dropna().unique().tolist(), defaultdf[city_clean].value_counts().head(5).index.tolist() ) salary_range st.sidebar.slider( 月薪范围元, min_value0, max_valueint(df[salary_avg].max()), value(5000, 30000), step1000 ) # 根据筛选条件过滤数据 filtered df[ (df[city_clean].isin(cities)) (df[salary_avg].between(salary_range[0], salary_range[1])) ] # 展示关键指标 col1, col2, col3 st.columns(3) col1.metric(岗位总数, f{len(filtered)}) col2.metric(平均月薪, f{filtered[salary_avg].mean():.0f} 元) col3.metric(薪资中位数, f{filtered[salary_avg].median():.0f} 元) # 薪资分布图 fig px.box(filtered, xcity_clean, ysalary_avg, title各城市薪资分布, labels{city_clean: 城市, salary_avg: 月薪元}) st.plotly_chart(fig, use_container_widthTrue) # 技能词频图 skill_series filtered[skills].dropna().apply(eval).explode() skill_counts skill_series.value_counts().head(15) fig2 px.bar(xskill_counts.values, yskill_counts.index, orientationh, titleTop15技能词频, labels{x: 出现次数, y: 技能}) st.plotly_chart(fig2, use_container_widthTrue)st.cache_data装饰器是关键——它把数据加载结果缓存起来用户调整筛选条件时不会重新读取CSV响应速度从几秒降到毫秒级。st.sidebar里的组件会自动生成在左侧边栏multiselect做多选城市slider做薪资范围筛选。st.columns把页面分成三列展示关键指标px.box和px.bar用Plotly生成交互式图表鼠标悬停能看到具体数值。运行方式很简单在终端执行streamlit run app.py浏览器会自动打开localhost:8501。如果要部署到服务器Streamlit支持直接部署到Streamlit Cloud或者用Docker打包后部署到任意支持Python的云平台。一个实际使用中的技巧把skills列存成JSON字符串而不是Python列表读取时用json.loads解析比eval安全。另外如果数据量超过10万条explode操作会消耗较多内存可以先用value_counts在原始数据上做聚合再传给图表。我自己做这类项目最大的教训是别一上来就追求大而全。先把采集和清洗跑通用100条数据验证流程再逐步加分析维度和可视化。很多翻车不是因为技术难而是因为数据没洗干净就急着画图最后返工的成本远高于一开始多花半小时做数据校验。希望帮到你。本文还有配套的精品资源点击获取