
简介这是一份基于 Python 的北京市招聘数据分析与可视化实战项目覆盖网络爬虫、数据清洗、统计分析到前端展示的完整链路。项目以 Boss 直聘为采集目标演示了 Scrapy 与 requests 等工具在真实网页结构下的应用并借助 pandas 完成缺失值处理与聚合统计最终用图表呈现岗位地域、薪资区间等趋势。面向大数据分析与爬虫开发的初学者、进阶者也适合作为毕业设计或课程项目的参考。压缩包共 111 个文件大小 6.58MB主体包含 4 个 py 源码文件、csv/db/xls 格式的原始及清洗数据、html/css/js 构建的仪表盘页面以及配套图片与样式资源目录结构清晰便于按模块学习。项目已有 727 人学习通过阅读完整爬虫逻辑与可视化实现可直观理解从数据采集到结论产出的每一步为独立开展类似项目提供可复用的工程思路。1. 北京市大数据岗位招聘分析这份源码包把数据、爬虫和可视化串成了什么做大数据岗位分析第一步永远不是画图而是手上有没有一份能吃进 Python 的数据。手工复制招聘信息一百条就累了网上现成数据集多半过期这时候一套基于 Python 实现的北京市大数据岗位招聘数据分析及可视化展示项目就派得上用场爬虫把岗位信息抓下来清洗成规整表格再做岗位需求、技能词频、薪资区间这类分析最后用图表和大屏展示源码、数据、爬虫三样都在一套流程里。适合大数据专业做毕业设计的人、想在本行摸行情底的求职者以及需要给团队定薪酬的招聘负责人——三类人拿它当起点都合适前提是你愿意把数据重新跑一遍而不是拿现成结论直接抄。2. 爬虫部分采集岗位数据的选型与最小实现2.1 选型requestsBeautifulSoup 还是 selenium招聘网站的列表页大致分两类。老一点的站点返回服务端渲染的 HTMLrequests 拿页面源码BeautifulSoup 解析即可新一些的站点走前后端分离页面数据藏在接口 JSON 里直接用 requests 请求接口更省事。这套项目里我优先选 requests BeautifulSoup原因很直接跑得快、依赖少、断点续爬好控制一台普通笔记本就能在十几分钟内抓完几百条有效岗位数据。selenium 的适用场景要窄得多。只有当页面数据完全靠 JavaScript 动态渲染、接口又加密到没法直接复现的时候才需要拉一个浏览器环境去渲染页面。代价是内存占用高、启动慢、中间还可能被浏览器验证弹窗打断。我的建议是先打开开发者工具看 Network 面板如果找到了返回 JSON 的接口就完全没必要上 selenium这是 python爬虫项目里最常见的过度设计。2.2 最小实现抓取列表页并提取字段以招聘网站的搜索接口为例先写一个请求封装。这里的核心参数是 headers 里的 User-Agent 和 Referer很多第一次写爬虫的人栽在默认 UA 上被服务器直接拒绝。import time import random import requests KEYWORD 大数据 CITY 北京 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://search.example.com/jobs, Accept: application/json, text/plain, */*, Accept-Language: zh-CN,zh;q0.9, } def fetch_job_list(page: int 1): 拉取招聘搜索接口的岗位列表返回 JSON 字典。 url ( fhttps://search.example.com/api/joblist f?keyword{KEYWORD}city{CITY}page{page} ) resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() return resp.json()这段代码里有两个容易被忽略的参数。timeout10是给每个请求设上限避免某个接口卡住时整个脚本停在那里干等raise_for_status()会在返回 4xx 或 5xx 时直接抛异常让失败尽早暴露好过把错误页面当成正常数据存进 CSV。请求头里的Accept按接口实际返回类型写JSON 接口填application/jsonHTML 页面则不需要这一行。拿到 JSON 之后要做的不是直接存下来而是把嵌套字段拍平成一行一列的表格结构。招聘数据的常见坑是「技能标签」这一项接口里是一个数组直接存会变成 Python 的列表对象后面没法分析我用竖线拼成字符串分析阶段再拆开。def parse_jobs(payload: dict): 把接口返回的原始 JSON 拍平成 DataFrame 行。 rows [] for item in payload.get(data, {}).get(list, []): rows.append({ job_title: item.get(jobName, ), # 岗位名称 company: item.get(brandName, ), # 公司名称 salary: item.get(salary, ), # 原始薪资文本 location: item.get(area, ) item.get(subway, ), experience: item.get(jobExperience, ), # 经验要求 education: item.get(eduLevel, ), # 学历要求 skills: |.join(item.get(skillTagList, [])), # 技能标签 publish_date: item.get(updateDate, ), }) return rows字段名统一成下划线风格后边所有清洗和分析都用这一套名字省去来回映射的麻烦。item.get(jobName, )这种写法保证某个字段缺失时拿到空字符串而不是抛 KeyError招聘数据里字段缺失太常见了宁可空了后边统一处理也不能让一条坏数据中断整个抓取。2.3 分页、去重与保存策略搜索接口一般限制最大翻页数在 20 页左右一页 15 到 30 条实际上北京大数据岗位翻到第 10 页就已经能看到重复内容了。分页抓取时最需要的不是代码技巧是请求间隔——连续高频请求会被反爬系统标记轻则当前 IP 被限流重则直接返回验证码页面。我一般把间隔设在 1.5 到 3.5 秒之间随机抖动既能跑完数据又不至于触发风控。import pandas as pd all_rows [] for page in range(1, 11): try: payload fetch_job_list(pagepage) rows parse_jobs(payload) if not rows: break all_rows.extend(rows) except requests.RequestException as e: print(f第 {page} 页抓取失败: {e}) continue time.sleep(random.uniform(1.5, 3.5)) # 随机间隔避免规律性请求 df pd.DataFrame(all_rows) df df.drop_duplicates(subset[job_title, company, salary]) df.to_csv(beijing_bigdata_jobs_raw.csv, indexFalse, encodingutf-8-sig)保存 CSV 时必须用utf-8-sig而不是utf-8这个编码会写入 BOM 头Excel 打开才不乱码后文避坑章节会再强调一遍。drop_duplicates按岗位、公司、薪资三个字段组合判断重复因为同一家公司同岗位不同招聘渠道会有不同的发布日期只按标题去重会误删。保存原始数据这一步千万别省清洗分析翻车时原始 CSV 就是你回滚的“后悔药”。3. 字段规整与薪资解析把脏数据洗成可分析的样子3.1 先处理空值和重复记录原始数据落盘之后不要急着画图先用df.info()和df.isna().sum()看一眼每列的缺失情况。招聘数据分析的脏数据比例远高于想象学历留空的、经验写「在校生/应届生」的、薪资写「面议」的全都要在这一步给定口径否则后边统计全是黑匣子。import pandas as pd df pd.read_csv(beijing_bigdata_jobs_raw.csv, encodingutf-8-sig) print(df.shape) print(df.isna().sum()) print(df.duplicated(subset[job_title, company, salary]).sum()) # 删除完全重复的记录保留首条 df df.drop_duplicates(subset[job_title, company, salary])处理空值的方式分三种。education为空但job_title里有「高级」字样的时候可以留空不进学历分析薪资为「面议」或者缺失直接删掉这部分记录因为它们进不了薪资分位数计算skills为空则填充空字符串技能词频统计时会自动忽略。我的原则是数值型字段缺失就删除类别型字段缺失就按未知归类绝不随便填一个默认值进去。3.2 薪资文本解析K、万、薪怎么统一成数值薪资字段是招聘数据里最想用、也最容易洗错的列。原始文本常见三种形态「20-30K·14薪」「2-3万/月」「1-1.5万·15薪」。要统一转换成两个数值列月薪下限、月薪上限单位都折算成 K。正则里最容易漏的是「万」这个单位它和 K 差了 10 倍漏掉之后薪资统计整体偏低一个数量级。import re def parse_salary(s): 把薪资文本解析成 (月薪下限K, 月薪上限K, 月薪数)。 if pd.isna(s) or not isinstance(s, str): return None, None, None s s.strip() match re.match(r(\d(?:\.\d)?)-(\d(?:\.\d)?)([Kk万]), s) if not match: return None, None, None low, high, unit match.groups() if unit.lower() k: low_k, high_k float(low), float(high) else: # 单位是「万」, 折算成K直接乘以10 low_k, high_k float(low) * 10, float(high) * 10 # 找「N薪」, 没写默认为12薪 months_match re.search(r(\d)薪, s) months int(months_match.group(1)) if months_match else 12 return low_k, high_k, months df[[salary_low_k, salary_high_k, salary_months]] ( df[salary].apply(parse_salary).apply(pd.Series) )正则(\d(?:\.\d)?)同时兼容整数和小数1-1.5万这种带小数的文本不会被拆错。单位按[Kk万]匹配一篇文章里 K 和 k 混用的情况很常见。处理「万」的时候乘以 10 而不是乘以 10,000是因为这里统一以 K 为基准1 万等于 10K这样后边算中位数、画箱线图时单位一致。salary_months这个字段一定要保留后边如果想算年薪才有依据只留月薪会丢掉 15 薪、16 薪这批高薪岗位的信息。这个解析函数有一个已知边界30K以上、20-40万/年这类非标文本会被直接丢弃。我的处理方式是把这些无法解析的文本单独导出到salary_unparsed.csv人为抽样看一眼占比。占比小于 5% 可以直接忽略大于 10% 说明正则写得不够全得补规则。3.3 经验与学历字段的归类规则experience 字段原始值是「3-5年」「5-10年」「不限」「应届生」这类文本直接做分析没法排序。我把它转换成年份区间和中值两个字段「3-5年」转成 (3, 5, 4)「不限」转成 (0, 99, 0)。这样既能做区间分布又能算平均经验年限。def parse_experience(x): if pd.isna(x) or not isinstance(x, str): return None, None, None x x.strip() if 不限 in x: return 0, 99, 0 if 应届 in x: return 0, 1, 0.5 m re.search(r(\d)-(\d)年, x) if m: lo, hi int(m.group(1)), int(m.group(2)) return lo, hi, (lo hi) / 2 return None, None, None学历字段看起来规整实际上「本科及以上」「统招本科」「硕士/双学士」混在一起。我一般只保留最高学历关键词用映射表清洗EDU_MAP { 博士: 博士, 硕士: 硕士, 双学士: 硕士, 本科: 本科, 统招本科: 本科, 本科及以上: 本科, 大专: 大专, 专科: 大专, 不限: 不限, } def normalize_education(x): if pd.isna(x) or not isinstance(x, str): return 未知 for key, value in EDU_MAP.items(): if key in x: return value return 其他 df[education_level] df[education].apply(normalize_education)这里有个顺序细节映射表里「统招本科」必须排在「本科」前面因为 Python 字典遍历是有序的先命中的先返回。如果把「本科」写在前面遇到「统招本科」会先被「本科」匹配掉语义上没错但你会丢失「统招」这一层信息。学历归类宁可粗不能细最终分析报告里写「本科及以上占比」的时候口径才站得住。4. 分析维度与指标口径岗位需求、技能、薪资怎么算才有说服力4.1 三个必做维度需求量、技能词频、薪资区间清洗完的数据要回答三个问题哪些公司招大数据岗位招得多、岗位都要求什么技能、薪资集中在什么区间。这是招聘数据分析项目里最经典的三板斧也是招聘决策时最常被追问的三组数据。维度一岗位需求量按公司分组计数筛出用人需求最旺盛的公司。维度二把 skills 列按竖线拆开做词频统计得到技能需求排行这个排行比任何简历模板都诚实因为它是企业在招聘广告里实际写出来的要求。维度三用salary_mid (salary_low_k salary_high_k) / 2算出每个岗位的中位月薪再按学历、经验分组看薪资分布。4.2 分组聚合与描述性统计的代码落地分析之前先补两个派生列月薪中值和经验中值。然后按学历分组看薪资这是项目里最核心的代码块。# 派生两个数值列用于聚合 df[salary_mid] (df[salary_low_k] df[salary_high_k]) / 2 df[exp_mid] df[experience].apply( lambda x: (int(x.split(-)[0]) int(x.split(-)[1].replace(年, ))) / 2 if isinstance(x, str) and - in x else 0 ) # 按学历分组岗位数、月薪中位数、平均经验 edu_pivot df.groupby(education_level).agg( job_count(job_title, count), salary_median(salary_mid, median), exp_mean(exp_mid, mean), ).sort_values(job_count, ascendingFalse) print(edu_pivot) # 按经验区间分组看薪资 exp_bins [0, 1, 3, 5, 10, 99] df[exp_band] pd.cut(df[exp_mid], binsexp_bins, rightFalse) exp_salary df.groupby(exp_band, observedFalse)[salary_mid].median() print(exp_salary)groupby().agg()是这一步的核心agg里可以同时算多个指标的写法比groupby后逐个mean()再合并要清晰得多。pd.cut把经验切成 0-1、1-3、3-5、5-10、10 年以上五档rightFalse表示区间左闭右开避免 3 年经验同时落在「1-3」和「3-5」两个区间里。这段代码有个容易翻车的地方exp_mid的 lambda 里用了x.split(-)如果前面步骤没把「不限」「应届生」处理成数值这里会直接把字符串拆出脏数字。所以经验字段的解析必须在分析前完成任何半成品的文本字段进入机械运算都会悄悄变成垃圾值。4.3 指标口径为什么用中位数不用平均值薪资统计里中位数比平均值诚实得多。这个项目的数据里偶尔会出现「50-80K·16薪」的架构师岗位直接把平均值拉高两三万让整份报告失真。平均值对极端值敏感中位数则稳定反映“一个普通的北京大数据岗位能拿到多少钱”。对外汇报的时候我会同时给 P25、P50、P75 三个分位数招聘方关心的是中位数求职者关心的是 P75——那是跳一跳够得着的薪资。quantiles df[salary_mid].quantile([0.25, 0.5, 0.75]) print(薪资分位数(K/月):, quantiles.to_dict())4.4 保存中间结果给可视化留好输入分析结果要落盘成中间表格可视化阶段直接读不用重跑计算。我一般会把三份中间结果分别导出清洗后的明细数据、学历薪资透视表、技能词频统计。这样做的好处是可视化调试调颜色、调布局的时候不需要每次重复执行前面的爬虫和数据清洗。df.to_csv(beijing_bigdata_jobs_clean.csv, indexFalse, encodingutf-8-sig) edu_pivot.to_csv(analysis_edu_salary.csv, encodingutf-8-sig) skill_counter Counter() for tags in df[skills].dropna(): for tag in str(tags).split(|): tag tag.strip() if tag: skill_counter[tag] 1 skill_top skill_counter.most_common(15)Counter的most_common(15)只取前 15 个技能后边词云图和柱状图都用这份数据避免把长尾低频技能画成密密麻麻一团。中间结果文件名带上前缀analysis_和原始数据区分开这是个人表达习惯也是给第二个接手项目的人留的线索。5. 避坑爬虫反爬、薪资清洗和可视化的五个翻车点5.1 第二页开始全部 403反爬不只是频率问题现象第一页数据正常翻到第二页开始请求失败返回 403 Forbidden换 IP 重试还是一样。原因招聘网站的搜索接口普遍做了分页签名第二页及以后的请求需要带上前一页响应里下发的 token 或签名参数。直接改page参数是模拟不出真实翻页行为的请求头里缺这个动态参数风控系统一验一个准。解决先不要急着加代理、降频次打开浏览器开发者工具手动翻页观察请求 URL 的变化。常见做法是 URL 里多了sign、token这类参数那就得先用第一页请求拿响应里的签名值拼到下一页的请求参数里。我在项目里用的是简单方案请求前先 GET 一次列表首页拿到payload[data][cursor]翻页时带上这个值。5.2 薪资字段「15-25K·15薪」被拆丢现象清洗后检查发现一大批高薪岗位的salary_low_k、salary_high_k是空值岗位数量凭空少了三分之一。原因正则只写了\d-\dK的匹配规则遇到「15-25K·15薪」时·后面的内容不影响开头匹配问题不在这真正翻车的是「薪资面议」「15-25K·15薪」这类文本里如果混入了全角字符「·」和「-」以外的符号匹配就被打断了。更隐蔽的坑是「1-1.5万/月」里数字是小数而正则漏掉了小数部分。解决按 3.2 节的正则升级为(\d(?:\.\d)?)-(\d(?:\.\d)?)([Kk万])并且每次清洗后输出未解析名单。我习惯 debug 时写一行not_parsed df[df[salary_low_k].isna()][salary]把它存成独立文件看一眼比例。这套血泪经验的核心就是清洗函数写完之后先跑一遍打印解析覆盖率低于 95% 就停下来补正则不要直接往后做分析。5.3 CSV 用 Excel 打开一片乱码现象df.to_csv(xxx.csv)之后用 Excel 打开中文全变成「锟斤拷」或拼音乱码但用记事本打开是正常的。原因pandas 默认保存编码是 utf-8而 Excel 打开无 BOM 的 utf-8 文件时按本地化编码GBK解析中日韩字符全部错乱。解决所有to_csv统一写成encodingutf-8-sig这是最终的后悔药。另外写数据时用indexFalse别把行号也写进去否则后续用 pandas 读回来时多一列Unnamed: 0还要再删一次。5.4 可视化图表中文全变方块现象用 pyecharts 或 matplotlib 画图图正常出但所有中文标签变成一个个「口」形方块。原因pyecharts 底层输出 HTML/JS 渲染一般不会方块但 matplotlib 的中文字体路径没有配置系统里找不到中文字体就回退成了默认的 DejaVu Sans。解决matplotlib 里每次开头固定一段配置代码不用每次翻文档import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei, PingFang SC] plt.rcParams[axes.unicode_minus] Falseaxes.unicode_minus这个参数经常被漏掉不关掉的话纵轴的负号会显示成方块。如果这行配置之后汉字还是方块说明系统里确实没有这几个字体需要显式指定字体文件路径。5.5 词云被「大数据」三个字霸屏现象技能词云出来之后画面上最醒目的是「大数据」三个字接着是「分析」「开发」Hadoop、Spark、Flink 这些真正的技能反而不显眼。原因岗位标题里普遍含「大数据」三个字而技能标签里顿多岗位又都把「大数据」当作标签重复填写词频统计把标题词和技能标签混在一起了。词云基于纯词频画大小标题词霸屏以后词云的信息量接近于零。解决统计技能词频的字段只取skills列不拼接job_title同时建一个常用停用词表把「大数据」「分析」「开发」「工程师」这类高频率低区分度的词过滤掉。这样词云展示的才是 Hadoop、Spark、Flink、Kafka、ClickHouse 这类真正能指导学习方向的关键技能。6. 可视化展示从静态图表到一键更新的大屏6.1 三张核心图的 ECharts 参数可视化大屏不需要花哨关键是信息密度。我用 pyecharts 做三张主图技能词云图、学历薪资箱线图、公司需求 Top10 柱状图。词云参数里shape选circle更省空间word_size_range控制在 12 到 50 之间太小看不出主次太大溢出画布。from pyecharts.charts import WordCloud from pyecharts import options as opts wc WordCloud() wc.add( series_name技能需求, data_pairskill_top, # 来自4.4的 [(词, 频次), ...] word_size_range[12, 50], shapecircle, width100%, height400px, ) wc.set_global_opts(title_optsopts.TitleOpts(title北京大数据岗位技能需求词云)) wc.render(wordcloud_skills.html)data_pair的格式必须是[(词1, 频次1), (词2, 频次2)]很多人传成字典直接报错。词云背后是 echarts-wordcloud 插件本地渲染时需要联网加载 JS如果要在内网做展示需要把依赖的 JS 一并下载放到同级目录否则打开 HTML 图表空白。6.2 本地大屏与数据自动更新多张图表分散的 HTML 不方便汇报常见做法是用一个主页面通过 iframe 嵌多个图表文件或者用 Flask 起一个本地服务统一托管。我更推荐后者因为能顺带解决自动更新的问题把爬虫和分析脚本写进同一个run.py每晚定时跑一次可视化服务读的是最新中间结果。run.py — 一键更新数据并生成图表 import subprocess import schedule import time def job(): print(开始更新招聘数据...) subprocess.run([python, crawler.py], checkTrue) subprocess.run([python, clean_analyze.py], checkTrue) subprocess.run([python, visualize.py], checkTrue) print(更新完成) schedule.every().day.at(02:00).do(job) # 每天凌晨2点跑 if __name__ __main__: job() # 先手动跑一次 while True: schedule.run_pending() time.sleep(3600)用schedule库做定时比写死time.sleep(86400)强在可读性和可维护性机器休眠错过任务后下次启动会重新执行。这里提醒一句凌晨 2 点爬招聘信息是为了避开服务高峰但反爬系统可不管你是不是善意请求间隔不能省。6.3 验证一份数据是否可信的土办法可视化做得再漂亮数据错了也是白搭。我每次出报告之前都会做两件事第一从清洗后的数据里随机抽 20 条记录人工到招聘网站核对岗位名称、薪资区间、学历要求是否一致第二统计company字段的去重比例如果前 10 家公司占了 80% 以上的岗位数说明爬虫大概率只抓到了几个头部公司的招聘页样本代表性不够需要扩大关键词范围或者增加翻页深度。这套流程走到现在我养成的唯一习惯是原始数据 CSV 永远不删清洗脚本永远加注释。因为每次翻车都不是因为计算复杂而是因为隔了两周再回来看代码想不起来当时为什么把某个字段置成空值。数据分析和可视化本身不难难的是把你的处理过程留痕到让第二个人三分钟能接手。希望帮到你。本文还有配套的精品资源点击获取