简介:一套基于Python实现的北京市大数据岗位招聘数据分析与可视化项目资料包,面向计算机、通信、人工智能、自动化等专业的学生、教师与从业者,可作为期末课程设计、课程大作业或毕业设计参考,也适合作为Python大数据入门到进阶的练手项目。项目完整覆盖网络爬虫、数据清洗、数据存储、分析建模与可视化展示全流程,源码经过调试测试可直接运行,据项目介绍曾获答辩评审98分,模块拆分清晰,便于按需学习。资源共110个文件,压缩包大小6.64MB,核心包含4个Python爬虫与处理脚本、CSV/XLS数据文件、SQLite数据库,以及由23个HTML页面、16个CSS和15个JS组成的可视化前端,另附地图、图片、字体等辅助资源。目前已有88人学习或浏览,适合希望掌握招聘数据采集、分析与可视化完整链路,或需要一套可运行项目作为课设/毕设蓝本的读者。
1. 北京市大数据岗位招聘数据里藏着什么:为什么自己抓远比看平台报表靠谱
在北京做招聘数据分析,最简单的方式是打开主流招聘平台刷筛选结果,或者看各平台自己出的行业报告。但真正拿过数据的人会告诉你,这两条路都走不通。平台报表用的是它自己的样本抽样逻辑,热门岗位排序和薪资区间都做过平滑处理,拿到手的永远是别人想让你看到的那份汇总;而人工刷页面只能看到前几页,翻到后面要么被要求登录,要么数据质量和前面完全不同。用爬虫把北京市大数据方向的岗位信息从头到尾抓一遍,再用可视化把分布画出来的时候,你才会看清市场真实的技能需求、薪资带和教育门槛长什么样。
这个项目的完整链路是:爬虫采集岗位数据,清洗薪资和学历这些脏字段,聚合分析岗位分布,最后用词云和图表做可视化展示。适合想转行大数据、准备校招或者做行业调研的人作为动手项目,也可以作为求职时展示数据能力的敲门砖。标题里提到的源代码、爬虫和数据,本质上是一个「数据采集+分析+展示」的最小闭环。这一篇我会把每一环的做法、参数选择和踩过的坑都展开讲,代码可以直接照着改。
2. 从零写爬虫:抓取北京市大数据岗位的列表页与详情页
2.1 选型:为什么用 requests+BeautifulSoup 而不是一上来就上框架
写一个针对单城市、单岗位方向的招聘爬虫,最常被问的问题就是:为什么不用 Scrapy?我一般会反问一句:你的数据量级是多少?北京市大数据岗位一次性抓全,撑死也就几千条,加上详情页不到几万个请求。这个量级下,Scrapy 的异步并发和中间件体系体现不出优势,反而因为要配 Item Pipeline、Selector 和项目目录结构,把简单事情复杂化。用requests做请求、BeautifulSoup做解析、pandas做存储,五十行左右就能跑通主流程。
真实场景里,招聘平台的反爬强度集中在登录态校验和频率控制上,访问频率控制住、请求头做规范,requests完全够用。等以后你需要每天抓几十万条数据、需要分布式调度的时候,再迁移到 Scrapy 也不迟。判断工具选型是否合理,看的不是工具本身多高级,而是它在你当前数据规模和处理周期内是不是最省事的那一个。
2.2 列表页抓取:请求头、随机延时与断点续爬
抓取的第一步是拿到岗位列表页。以某综合招聘平台的搜索结果页为例,URL 通常带有城市 ID、关键词和页码这三个关键参数。下面的代码是我常用的列表页抓取骨架,重点在于请求头伪装、随机延时和解析失败时的重试策略。
import requests import time import random from bs4 import BeautifulSoup HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", } session = requests.Session() session.headers.update(HEADERS) def fetch_list_page(city_id, keyword, page): url = f"https://example.com/jobs?city={city_id}&kw={keyword}&pn={page}" for attempt in range(3): try: resp = session.get(url, timeout=10) if resp.status_code == 200: return resp.text elif resp.status_code in (403, 429): print(f"[警告] 第{page}页返回{resp.status_code},等待后重试") time.sleep(30 + random.random() * 15) except requests.RequestException as e: print(f"[错误] 第{page}页请求异常:{e}") return None这段代码的关键点在三个地方:第一,用同一session实例保持连接,避免每次请求重新握手;第二,timeout必须设置,否则遇到慢响应时程序会一直挂住;第三,403 和 429 分别代表被拒绝和请求过快,此时不能立刻重试,必须退避等待。延时策略我会放在调用端,每次都随机取 3~6 秒,避免出现规律性的访问间隔——平台的风控系统对固定频率的请求识别非常准,随机延时是成本最低的绕过方式。
还有一个细节:尽量把页码作为函数参数传入。这样一旦程序中断,你可以从上次失败的页码继续跑,而不是从头开始。我习惯把已完成的页码记在一个文本文档里,每抓完一页就追加一行,效果等同于一个极简的断点续爬。
2.3 详情页解析:从一屏 HTML 里把岗位要素拆出来
列表页通常只包含岗位名称、公司名和薪资范围,而学历要求、经验要求、技能标签和职位描述藏在详情页里。解析详情页的关键是先搞清楚目标页面的 HTML 结构,再用 BeautifulSoup 的选择器把字段逐个取出来。
def parse_detail(html): soup = BeautifulSoup(html, "html.parser") job_title = soup.select_one(".job-title") salary_raw = soup.select_one(".salary") edu_require = soup.select_one(".edu-require") exp_require = soup.select_one(".exp-require") tags = soup.select(".job-tags a") desc_text = soup.select_one(".job-desc") return { "岗位名称": job_title.text.strip() if job_title else None, "薪资原始字段": salary_raw.text.strip() if salary_raw else None, "学历要求": edu_require.text.strip() if edu_require else None, "经验要求": exp_require.text.strip() if exp_require else None, "技能标签": [t.text.strip() for t in tags], "职位描述": desc_text.text.strip() if desc_text else None, }这里有个常见误区:直接用正则表达式去匹配文本。招聘页面的 HTML 结构经常调整,正则一改就要连带改好几个匹配模式,而select_one基于 CSS 选择器,页面大结构调整时只需换一个选择器路径,维护成本低得多。另外注意,select(".job-tags a")返回的是一个列表,后续需要遍历取值;如果目标页面的标签不是链接而是span,选择器要跟着改成.job-tags span。
字段取出来之后不能直接入库,因为这时拿到的还是「20K-40K·14薪」「本科及以上」「3-5年」这种给人看的格式,后续做统计之前必须先清洗。我习惯在解析函数里就把原始字符串和后端字段一起保存,原始字段留作核对,结构化字段留给分析阶段用。
2.4 数据落盘:清洗前置,入库前先看一眼字段长什么样
抓到的数据先存到本地 CSV 还是直接进数据库?我的习惯是第一阶段全部落 CSV,原因很简单:招聘数据是典型的一次性调研数据,几条 CSV 文件完全装得下,而且 CSV 可以用 Excel 直接打开检查,肉眼确认字段比任何调试工具都直观。
import pandas as pd def save_to_csv(records, filename): df = pd.DataFrame(records) df.to_csv(filename, index=False, encoding="utf-8-sig") print(f"[完成] 共保存 {len(df)} 条记录,列明:{list(df.columns)}") # 调用方式 records = [] for page in range(1, 50): html = fetch_list_page(city_id="北京", keyword="大数据", page=page) if html is None: continue # 解析列表页拿到详情页链接,再逐个请求详情页 records.append(parse_detail(html)) time.sleep(random.uniform(3, 6)) save_to_csv(records, "beijing_bigdata_jobs.csv")落盘时我强调用utf-8-sig编码而不是默认的utf-8,原因是 Windows 上的 Excel 默认用 GBK 读取 CSV,utf-8-sig带 BOM 头,Excel 打开中文才不乱码。这一步看起来小,实际影响非常大——很多人在这一步翻车,数据抓了一堆,打开一看全是乱码,白白浪费一次抓取周期。保存完成后先输出列名和记录数,目的就是确认字段有没有抽全,记录数是否符合预期,提前发现问题比后面分析时才发现要好得多。
3. 数据清洗与岗位分析:把字段变成能画图的结构化数据
3.1 薪资字段解析:处理「20K-40K·15薪」这类字符串
北京市大数据岗位的薪资字段,平台展示格式一般是「月薪范围 + 年终薪数」。看似简单,批量处理起来问题不少:有的写「20K-40K·14薪」,有的写「20k-40k·15薪」,还有的只写「面议」。解析的目标是把乱七八糟的字符串拆成三个数值:月薪下限、月薪上限、月薪倍数。
import re def parse_salary(salary_raw): if not salary_raw or "面议" in salary_raw: return None, None, None salary_raw = salary_raw.upper().replace("K", "K") values = re.findall(r"(\d+)K", salary_raw) if len(values) >= 2: low, high = int(values[0]), int(values[1]) elif len(values) == 1: low = high = int(values[0]) else: return None, None, None annual_match = re.search(r"(\d+)薪", salary_raw) months = int(annual_match.group(1)) if annual_match else 12 return low, high, months处理逻辑分三步:第一步把单位统一成大写,省得大小写混着用影响正则匹配;第二步用正则按数字+K的格式提取薪资边界;第三步单独匹配「多少薪」,匹配不到就默认 12。这里要特别注意:有些岗位薪资写的是「2.5-3万/月」或者「300元/天」,这类格式需要单独处理,因为正则模式完全不同。我通常在做数据清洗之前先做一次全量样本探察,把出现过的薪资格式列一遍,再决定要写几个分支模式。
为什么不直接用split("-")拆?因为中间连字符有全角半角之分,而且「20-30K」和「20K-30K」两种写法在真实数据里都存在。正则容错率远高于字符串切割,这是处理招聘数据的血泪经验。
3.2 学历与经验归一化:把零散表述映射成统一档位
学历字段的原始值一般是「本科」「硕士」「大专」「学历不限」等固定枚举,处理起来相对简单。经验字段则麻烦一些,「经验不限」「1年以内」「1-3年」「3-5年」「5-10年」「10年以上」,偶尔还有「应届生」这种特殊类别。归一化的目的不是去掉信息,而是把表述化成有序的数值,让后续可以做排序和分组。
def normalize_edu(edu_raw): mapping = { "学历不限": 0, "大专": 1, "本科": 2, "硕士": 3, "博士": 4, } if not edu_raw: return -1 for key, value in mapping.items(): if key in edu_raw: return value return -1 def normalize_exp(exp_raw): if exp_raw is None: return -1 if "经验不限" in exp_raw or "应届" in exp_raw: return 0 match = re.search(r"(\d+)-(\d+)年", exp_raw) if match: return int(match.group(2)) if "10年以上" in exp_raw: return 10 return -1这里我把学历映射成数值而不是文字,是为了后面做相关性分析时可以直接参与计算。经验字段是取区间的上限值,比如「3-5年」归一化成 5,这样在画「经验年限 vs 薪资」散点图时就有数值可用。归一化函数一定要对空值返回 -1,而不是直接抛异常,因为真实抓取的数据中总有一部分字段缺失,缺失值过滤到分析阶段处理比在清洗阶段中断整个流程要好。
最后把归一化的结果拼回 DataFrame,注意每一列都要检查唯一值分布——比如df["学历归一化"].value_counts(),这一步能很快发现映射遗漏的取值,比如出现「MBA」或者「统招本科」这种没进映射表的词,就需要回去补充 mapping。
3.3 技能词提取与基本情况统计:先看整体再看分组
完成字段清洗后,就要开始分析。第一个问题是:北京市大数据岗位整体薪资水平如何,学历和经验怎么看,以及不同岗位方向的技能要求差异。技能词提取我采用词典匹配而不是 TF-IDF 词频统计,原因是岗位 JD 里同一个技能有不同的表述方式,「Hadoop」和「hadoop」是同一个意思,TF-IDF 分词后还要再做合并,成本较高。直接维护一个大数据库技能词典,在职位描述和技能标签里做包含匹配,效率高且容易控制。
def extract_skills(desc_text, skill_dict): if not desc_text: return [] lowered = desc_text.lower() matched = [] for skill in skill_dict: if skill.lower() in lowered: matched.append(skill) return list(set(matched)) # 示例技能词典,可按需扩充 skill_dict = ["Hadoop", "Spark", "Flink", "Hive", "Kafka", "Python", "Java", "SQL", "ClickHouse", "HBase", "Scala", "数据挖掘", "机器学习", "ETL"] df["技能列表"] = df["职位描述"].apply(lambda x: extract_skills(x, skill_dict))这段代码跑完之后,每一岗位都对应一个技能列表。统计技能频度就变成了解压列表再计数的操作,最后可以输出最高频的 20 个技能词。分析到这里已经能从数据里回答问题了:北京大数据岗位到底要什么技术栈、技能数量与薪资有没有关系、哪些技能组合是高频出现的。回答这些问题不靠猜,数据已经给出答案。
4. 可视化展示:静态图、交互图和词云怎么配合
4.1 技能词云:从 JD 文本里找出真正的高频技能
抓完数据、做完清洗之后,第一个可视化几乎都是技能词云。词云的受众直觉最好——一眼看过去字越大代表出现的岗位越多,能快速感知市场技术需求。
from wordcloud import WordCloud import matplotlib.pyplot as plt def draw_skill_wordcloud(skill_freq_dict, font_path="C:/Windows/Fonts/msyh.ttc"): wc = WordCloud( width=1200, height=800, background_color="white", font_path=font_path, max_words=200, colormap="viridis", ) wc.generate_from_frequencies(skill_freq_dict) plt.figure(figsize=(12, 8)) plt.imshow(wc, interpolation="bilinear") plt.axis("off") plt.savefig("北京市大数据岗位技能词云.png", dpi=200) plt.close()注意font_path参数——中文词云必须指定中文字体路径,Windows 系统一般用msyh.ttc也就是微软雅黑,Linux 服务器上路径通常是/usr/share/fonts/truetype/wqy/wqy-microhei.ttc。不指定字体的话,词云里所有中文都会变成方块,这是用词云做中文可视化的头号翻车点。generate_from_frequencies接收的字典格式是「词:频次」,我一般直接从技能统计结果里构造这个字典。
4.2 薪资与经验、学历的交叉分析图
词云是感性认知,交叉分析图才是能写进报告里的实际结论。三张图基本覆盖核心问题:学历与薪资的关系、经验与薪资的关系、技能数量与薪资的关系。用 matplotlib 画箱线图最直观,因为箱线图能同时展示数据的中位数、四分位和离群点。
import pandas as pd import matplotlib.pyplot as plt def draw_salary_boxplot(df, group_col, value_col): plt.rcParams["font.sans-serif"] = ["Microsoft YaHei"] plt.rcParams["axes.unicode_minus"] = False # 计算月薪中位数作为代表值 df["月薪中位"] = (df["月薪下限"] + df["月薪上限"]) / 2 grouped = [df[df[group_col] == val]["月薪中位"].dropna().values for val in df[group_col].unique()] labels = [str(val) for val in df[group_col].unique()] fig, ax = plt.subplots(figsize=(12, 6)) ax.boxplot(grouped, labels=labels, showmeans=True) ax.set_xlabel(group_col) ax.set_ylabel("月薪中位数(K)") plt.xticks(rotation=45) plt.tight_layout() plt.savefig(f"北京市大数据岗位薪资与{group_col}关系.png", dpi=150)这段代码的画图逻辑很简单,但有两处必须提前设置:plt.rcParams["font.sans-serif"]设成中文字体,plt.rcParams["axes.unicode_minus"]设成 False,否则图例和坐标轴上的中文变方块、负号显示成错乱字符。ax.boxplot的showmeans参数标出均值,这比只看中位数更能反映出高薪岗位拉高均值的情况——大数据岗位的经验与薪资并非线性关系,而是随着经验超过一定年限出现明显分化。
4.3 用交互图让结论能给别人看
matplotlib 适合自己分析,交付给别人看效果比较有限:静态图片不能缩放、不能悬停看具体数值,别人看的时候会追问数据细节,然后又得回到代码里去查。这种时候我会用 pyecharts 生成 HTML 交互图表,它生成的图表可以缩放、拖拽,鼠标悬停时显示具体数值。
from pyecharts.charts import Bar from pyecharts import options as opts def draw_echarts_bar(categories, values): bar = ( Bar() .add_xaxis(categories) .add_yaxis("平均月薪(K)", values) .set_global_opts( title_opts=opts.TitleOpts(title="北京市大数据岗位平均月薪排名"), yaxis_opts=opts.AxisOpts(name="月薪 K"), xaxis_opts=opts.AxisOpts(axislabel_opts=opts.LabelOpts(rotate=45)), ) ) bar.render("北京市大数据岗位薪资排名.html")pyecharts 的图表默认输出成独立 HTML 文件,用浏览器打开即用,不需要本地搭建服务,也没有环境依赖问题。图表交互带来的好处是,你在现场演示时可以直接指着一根柱子说,这个是除了技术栈外还要求「具备数据敏感度」的岗位分类,它的平均薪资比纯技术要求型岗位高出约 30%——有图有数据,解释成本会低很多。
5. 避坑指南:从被封 IP 到中文乱码的五个实战问题
5.1 请求频率过高被拉入黑名单
现象:爬到中途返回的状态码开始变成 403,重试还是 403。严重的时候访问招聘平台首页也会出现验证码。
原因:请求过于频繁。即使设置了 3 秒延时,单个 IP 长期高频访问仍然会被风控标记。如果程序跑了几千个请求没有中断过,触发风控几乎是必然事件。
解决:访问间隔从固定延时长改成随机延时,time.sleep(random.uniform(3, 6))是最低配置;再进一步就是把每次请求的 User-Agent 做成一个轮换列表,每次随机取一个。注意:不要用公共代理池,招聘平台对代理 IP 段的识别更严格,普通家庭宽带 IP 反而问题不大。爬虫不要追求极限速度,跑得慢但能完成的任务才算完成任务。
5.2 薪资字段解析出现大量空值
现象:清洗完薪资后,月薪中位这一列有大量空值,占比超过 20%,导致后续的薪资分析样本量严重不足。
原因:薪资格式多样化超出预期。「1.5万-2万/月」「30元/时」「8000元/月」这些格式都不包含字母 K,单靠数字+K的正则匹配不到。
解决:抓完数据先打印薪资字段的所有唯一值再做解析,这一步花十分钟看完就知道有多少种格式了。然后针对这些格式分别写解析分支:以「万」为单位的先转成 K(乘以 10),以「元/月」为单位的直接除以 1000。「面议」单独保留状态,不做数据填充,因为用平均值填充面议薪资会扭曲真实分布。
5.3 HTML 结构变动导致字段大面积缺失
现象:某一天重新跑爬虫,发现岗位描述字段全是空的,其他字段正常。
原因:目标平台的网页改版了,CSS 选择器对应的 class 名称发生变化,旧的select_one选到了不存在的元素,返回None。
解决:把解析函数抽出来单独维护,每次抓取前先用一个已知页面测试解析函数是否正确再开始全量跑。另外,在解析函数里加字段完整性检查:如果进入详情页解析却发现标题和薪资都为空,打印一条带 URL 的警告,方便快速定位是单个页面问题还是全站结构变动。这个检查只需要三行代码,但能帮你节省一整天排错时间。
5.4 Excel 打开 CSV 全部乱码
现象:用 pandas 存了 CSV,记事本打开正常,Excel 打开全是乱码。
原因:CSV 文件编码用的是utf-8,而 Windows 版 Excel 默认以 GBK 编码读取,不支持无 BOM 的 UTF-8。
解决:to_csv(filename, index=False, encoding="utf-8-sig"),使用带 BOM 的 UTF-8 编码存储即可。这个坑出现过太多次了,几乎每个做数据分析的人都会踩一遍,最省事的方法就是在保存 CSV 时形成肌肉记忆,所有 CSV 统一用utf-8-sig。
5.5 抽取的岗位数据不完整,关键词单一导致偏差
现象:只搜「大数据」一个关键词,抓出来的数据集中在数据开发岗位,数据分析、数据挖掘、算法方向的岗位明显偏少。
原因:招聘平台的关键词匹配逻辑和搜索引擎类似,搜索结果只会覆盖包含「大数据」字样的岗位。很多公司 JD 写的是「数据工程师」「BI 工程师」「数据仓库工程师」,不会带上大数据这个字。
解决:多关键词覆盖,比如同时搜「大数据」「数据分析」「数据挖掘」「数据开发」「数据仓库」等一组词,然后按岗位名称去重。一次调研的数据量如果只有几百条,结论很容易被个别大公司的批量岗位带偏,样本量上去之后分布才稳定。标题里强调北京市大数据岗位,实际做的时候要多想想「大数据」这三个字在招聘市场里到底对应哪几类岗位角色。
6. 进阶:从一次性任务变成持续运行的岗位监控工具
6.1 增量抓取与定时调度
跑通一次性爬虫后,一个自然的升级方向是做增量抓取。招聘数据一直在变,今天抓的岗位下个月可能就被下架,新岗位也在不断产生。增量抓取的核心是两个字段:岗位 ID 和发布时间。每次抓取时把已见过的岗位 ID 存进一个集合或者数据库表,新抓到的岗位先比对再入;发布时间字段则用来过滤「只抓最近 7 天」的岗位,这样每天定时跑一次,数据量不大,但能持续积累一个时间序列,最终可以画出岗位数量和薪资随月份的变化趋势。
定时调度我用的方案是轻量级的方式:写一个主函数,按顺序执行抓取、清洗、入库、出图,然后用系统的计划任务或者简单的循环调度器来跑。招聘数据的时效性不需要每秒都监控,一天一次已经完全足够。把这个项目从「一次性脚本」变成「持续运行的任务」之后,它的价值会明显提升,因为你手里攒的不再是某个时间点的截图,而是一段可对比的历史。
6.2 找个数据看板的形态
从数据分析到可视化,最后一步是找一个长期使用的展示形态。早期我用 CSV 加 matplotlib 出图,问题在于每看一次就要跑一次脚本;后来改用 SQLite 存储历史数据,再用适合看板的图表库把常用统计结果固化到本地页面,每天定时刷新数据后自动重新出图。这样做的好处是每天打开就是最新的结果,不用再手动操作。
最后一个技巧:任何自动化流程跑完都要人工抽检。我习惯让脚本每次完成后打印三行随机抽取的原始记录,肉眼确认字段看起来正常确认无误后才继续。挣工时的是自动化,兜底的是人眼——自动化程度越高的流程,越要留一个人工抽查的步骤兜底。这套方法已经帮我排查过至少十次因为页面结构变动引起的采集异常。希望帮到你。
本文还有配套的精品资源,点击获取