简介:面向Python爬虫与数据分析学习者,这是一份以重庆大学毕业生就业数据为实例的完整项目资源,覆盖从Scrapy框架爬虫采集、Pandas/NumPy数据清洗到Matplotlib可视化展示的全流程,适合作课程设计、毕业设计或就业数据研究参考。压缩包共23个文件,以16个Python脚本为主体,包含爬虫核心模块(spiders、pipelines、settings等)和多份可视化脚本(宣讲会行业/性质饼图、校园招聘趋势线图、就业单位去向饼图等),另附3张结果图片及README说明文档,整体仅116KB,轻量紧凑、便于快速定位代码与成果。已有634人学习浏览,资源中展示的爬虫框架搭建思路、数据提取规则以及针对就业率、行业分布、起薪水平等指标的可视化方法,可直接参考或复用,帮助读者快速掌握从网页数据获取到业务洞察的完整工程实践。
1. 毕业生就业数据:这活儿水有多深
前阵子有位读者问我:能不能把学校就业网上的毕业生去向数据自动抓下来,做成一份能直接汇报的可视化报告?真正上手后会发现,这个需求远比“爬虫+画图”复杂——数据源口径不统一、专业名称分层混乱、薪资字段动不动就缺失,最后清洗花掉的时间往往是爬虫的三倍。这篇文章就沿着“数据源选型 → 抓取 → 清洗 → 分析 → 可视化”这条主线,给出一套在本地就能跑通、能应对真实脏数据的方案。适合想练手爬虫和数据分析的初级开发者,也适合需要定期产出就业分析报告的从业者。
2. 定数据源与字段设计:先定落点再写爬虫
2.1 数据源对比:选择灵活但口径统一的数据入口
常见做法是从学校就业信息网、招聘平台公开数据或政府公开数据平台抓取。对练手和校内场景,建议优先选学校就业信息网的“毕业生去向”栏目,页面结构相对简单。但有两点必须提前确认:第一,页面是否由前端渲染,如果是前后端分离的页面,直接请求 HTML 拿不到表格,需要找背后的 JSON 接口;第二,字段是否稳定,如果不同年份的字段名不一致,后期清洗会很痛苦。
我一般会先打开开发者工具的 Network 面板,筛选 Fetch/XHR 请求,找到返回数据的接口,然后在写正式代码前先用一个最小化请求验证。这里以某高校就业信息网的接口为例:
import requests import pandas as pd # 先用最小请求确认接口是否能直接返回 JSON url = "https://job.example-school.edu.cn/api/graduation/stat" params = { "year": 2024, "page": 1, "page_size": 50 } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } resp = requests.get(url, params=params, headers=headers, timeout=10) print(resp.status_code) print(resp.text[:500])这段代码的思路是先看返回状态码和数据格式,而不是一口气写完整个爬虫。如果返回的是 HTML,则说明真实接口另有其处;如果返回 JSON,再进一步看字典层级。timeout=10是必设参数,避免某个请求卡死拖垮整个抓取流程。params里的page_size要根据接口文档调整,一次性拉取过多容易被服务端拒绝。
2.2 字段设计:把“就业数据”拆成可分析的维度
在写爬虫前,先设计一张目标字段表,这能避免反复返工。我习惯把字段分成四类:
| 字段类别 | 示例字段 | 用途 |
|---|---|---|
| 身份维度 | 学号、专业、学历、性别、毕业年份 | 做分组和切片 |
| 去向维度 | 就业、升学、待就业、创业 | 计算就业率和去向构成 |
| 单位维度 | 单位名称、单位性质、行业类别、单位所在地 | 分析行业和地域流向 |
| 收入维度 | 税前月薪、年薪、签约薪资 | 薪资分析(常常缺失) |
一个容易忽略的点是“专业”字段在不同年份可能写法不同,例如“计算机科学与技术”在某一年可能被写成“计算机科学”。如果不在抓取阶段把映射关系处理好,分析阶段就得回头补数据。更稳妥的做法是额外维护一张专业别名表,字段值只保留统一后的专业名。下面是一个简化版存储和归一化方案:
import json def normalize_major(raw): mappings = { "计算机科学": "计算机科学与技术", "计科": "计算机科学与技术", "软件工程": "软件工程", "软工": "软件工程", } return mappings.get(raw.strip(), raw.strip()) raw_data = [] # 假设这是从接口收集到的原始记录 cleaned_rows = [] for item in raw_data: row = { "year": item.get("毕业年度"), "major": normalize_major(item.get("专业")), "degree": item.get("学历"), "monthly_salary": item.get("税前月薪"), "industry": item.get("行业类别"), "destination": item.get("去向"), } cleaned_rows.append(row) df = pd.DataFrame(cleaned_rows) print(df.info())这段代码把字段名统一成英文小写,并把专业别名映射到标准名。normalize_major的设计思路是“白名单映射”,无法匹配的值原样返回,这样既保留原始数据,又不阻断清洗流程。真正的爬虫主体应加入异常捕获和增量存储,避免临时内存里积累太多脏记录。
2.3 爬虫策略:限速、重试与增量抓取三件套
爬取网站时最怕的不是被拒,而是写出的代码把对方服务器压垮。常见做法是给每次请求加随机延时,同时用requests.Session维持会话状态。对需要登录后可见的数据,还要在请求头里带Cookie,但注意不要把 Cookie 硬编码进公开仓库。
另一个值得一开始就做的设计是增量抓取:把已经抓到的记录主键(例如“学号+毕业年份”)存入本地,抓取前先查重。这样后续重跑脚本不会重复请求已抓取页面。这里给出一个带重试和限速的抓取函数:
import time import random from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def build_session(): session = requests.Session() retry = Retry(total=3, backoff_factor=1, status_forcelist=[500, 502, 503]) adapter = HTTPAdapter(max_retries=retry) session.mount("http://", adapter) session.mount("https://", adapter) return session def fetch_json(url, params, session): try: resp = session.get(url, params=params, timeout=10) resp.raise_for_status() return resp.json() except Exception as e: print(f"抓取失败: {url} | {e}") return None finally: # 限速:随机停留 1~3 秒,避免高频请求 time.sleep(random.uniform(1, 3))Retry里的backoff_factor=1表示重试间隔按 1 秒、2 秒、4 秒递增;status_forcelist只对服务端异常状态码做重试。这里没有对 404 重试,因为 404 大概率是数据不存在,重试只会浪费时间。限速放在finally块里,可以保证每次请求都留出固定间隔。
3. 清洗与薪资口径归一:分析前的必修课
3.1 缺失值与异常值:先统计再决定取舍
拿到数据后不要直接分析,第一步永远是df.info()和df.describe(include="all")。缺失值处理要看字段性质:就业去向缺失往往是因为学生未离校或未填报,不能简单删行;薪资缺失比较常见,更适合单独归为一档进行分析。也就是说,不把缺失值一律填充成 0,否则会把“无数据”和“真实低收入”混在一起。
我常用的缺失值处理框架如下:
def handle_missing(df): df_copy = df.copy() # 去向字段:按“未填报”填充 df_copy["destination"] = df_copy["destination"].fillna("未填报") # 薪资字段:单独标记 df_copy["salary_flag"] = df_copy["monthly_salary"].isna().map(lambda x: "缺失" if x else "有值") df_copy["monthly_salary"] = df_copy["monthly_salary"].fillna(-1) # 行业字段:无法判断,用“未知行业”填充 df_copy["industry"] = df_copy["industry"].fillna("未知行业") # 去除极端值:月薪低于 1000 或高于 100000 的记录视为异常 df_copy = df_copy[(df_copy["monthly_salary"] >= 1000) | (df_copy["monthly_salary"] == -1)] df_copy = df_copy[(df_copy["monthly_salary"] <= 100000) | (df_copy["monthly_salary"] == -1)] return df_copy这里的关键不是把缺失值填出一个“假数据”,而是让缺失值在后续统计里可以被单独识别。把薪资缺失值填成 -1,再通过salary_flag来区分,比直接删除或用均值填充更诚实。异常值过滤的边界则要根据行业常识调整:如果分析对象以一线城市研发岗为主,100 万上限可能偏低;如果对象以文职岗为主,这个边界可能又太高。
3.2 薪资口径归一:月薪、年薪、实习薪资别混着算
这是毕业生就业数据分析里最容易翻车的环节。不同数据源的薪资字段口径差异极大:有的写“税前月薪”,有的写“年薪”,有的写“实习日薪”。如果不做归一化,统计结果毫无意义。常见做法是统一折算成“税前月薪”:年薪除以 12,时薪乘以 160(按每月工作 160 小时估算),日薪乘以 21.75(劳动法约定月计薪天数)。这里给出一个可复用的处理函数:
def normalize_salary(row): raw = row.get("salary_raw", "") salary_type = row.get("salary_type", "月薪") try: if salary_type == "年薪": return round(float(raw) / 12, 0) elif salary_type == "日薪": return round(float(raw) * 21.75, 0) elif salary_type == "月薪": return float(raw) else: return None except (TypeError, ValueError): return None使用这个函数前,务必先确认原始数据的单位是“元”而不是“千元”或“万元”。实际项目里见过不少把“8K”直接当成 8000 处理的翻车现场,也有把“w”后缀当成普通数字的情况。我的经验是:抓取时连同“薪资单位”“薪资周期”一起存下来,清洗阶段再统一换算,不要在地层就丢掉原始信息。
3.3 专业与行业字段的归类:别把 Excel 的事留给 Python 硬扛
专业归类可以交给别名映射表,行业归类则更复杂。公开数据里的行业分类常常是“信息传输、软件和信息技术服务业”这类长名称,直接作为可视化标签会非常拥挤。常见做法是建立“大类映射表”,把细分行业归入若干大类。这个过程宁可手动维护 Excel 映射表,也不要在代码里写一堆 if else,否则每次新增数据都要改代码。
INDUSTRY_GROUP = { "信息传输、软件和信息技术服务业": "IT/互联网", "互联网和相关服务": "IT/互联网", "软件和信息技术服务业": "IT/互联网", "教育": "教育", "高等教育": "教育", "制造业": "制造", "汽车制造业": "制造", "医药制造业": "制造", } def group_industry(industry_name): if not industry_name: return "未知" return INDUSTRY_GROUP.get(industry_name.strip(), "其他")归类时要注意字符串两端的空格和全半角差异,“信息传输、软件和信息技术服务业”与“信息传输,软件和信息技术服务业”是两个不同字符串。如果映射表里都没命中,先归为“其他”,不要直接报错中断整个流程,避免新数据源出现时爬虫直接罢工。
4. 分析模型与指标选择:把“就业率”拆成能下决策的维度
4.1 就业率与去向构成:绕开单一指标陷阱
毕业生就业分析里最常见的指标是“就业率”,但单一就业率信息量很低——它无法回答“就业质量如何”“去了哪些行业”“薪资分布是否合理”这些问题。我一般会把指标拆成三层:总体去向构成、专业去向差异、薪资分布形态。去向构成用分组计数即可,重点在于可视化时如何避免误导性表达。
dest_distribution = df.groupby(["year", "destination"]).size().reset_index(name="count") total_by_year = df.groupby("year")["学号"].count().reset_index(name="total") merged = pd.merge(dest_distribution, total_by_year, on="year") merged["percentage"] = merged["count"] / merged["total"] * 100 print(merged.head())groupby后算的是绝对值,与总数合并后才有意义。这里没有直接用value_counts(normalize=True),原因是后续还要按年份、学院、专业多个维度钻取,先保留明细再聚合更灵活。计算百分比时注意分母不要选错:在就业去向分析中,分母一般是有明确去向记录的人数,而不是全体应届生,否则“未填报”也会拉低就业率。
4.2 薪资分析:中位数比平均值更可靠,分组也别只看均值
薪资数据普遍右偏,少数高薪岗位会把平均值拉得很高,这时用中位数描述典型水平更合适。按“学历+行业”分组后,同时输出均值、中位数、样本量和缺失率,缺失率过高时直接放弃该组的薪资分析。这里给出一段分组统计代码:
salary_stats = ( df[df["salary_flag"] == "有值"] .groupby(["学历", "industry"]) .agg( 样本数=("学号", "count"), 平均月薪=("monthly_salary", "mean"), 中位月薪=("monthly_salary", "median"), 最低月薪=("monthly_salary", "min"), 最高月薪=("monthly_salary", "max"), ) .reset_index() ) # 过滤掉样本数过少的组,避免统计结论失真 salary_stats = salary_stats[salary_stats["样本数"] >= 5]筛选样本数至少为 5 是一个很保守的下限。如果某个学历+行业组合只有 2 条记录,算出来的均值没有任何代表性。实际项目中可以把阈值提高到 10 或 20。一个更合理的输出是同时显示“样本数”和“缺失率”,如果缺失率超过 40%,就应当在报告中标注“本组薪资数据参考价值有限”。
4.3 地域流向与行业变化:把可视化结论做成“可追问”的
分析地域流向时,常见做法是解析单位所在地字段中的城市信息,然后按省份聚合。这里要特别注意“省份”和“城市”的层级混用问题:有的记录写“广东省”,有的写“深圳市”,不能直接按字符串分组。常见做法是维护一份“城市→省份”映射表,先用城市名匹配,匹配不到再按省份名处理。
行业年度变化分析更适合用透视表输出,按年份查看各行业占比变化。实现如下:
industry_cross = pd.pivot_table( df, values="学号", index="industry", columns="year", aggfunc="count", fill_value=0 ) # 转成占比 industry_ratio = industry_cross.div(industry_cross.sum(axis=0), axis=1) * 100 print(industry_ratio.round(1))div的方向容易写错,industry_cross.sum(axis=0)得到的是每年各行业的签约总人数,按列对齐后得到“当年行业内占比”,而不是“行业内部年份占比”,这两者的解读完全相反。建议生成这样的表之后人工抽查一两个数字,用原始明细验证,避免透视表操作出错却不自知。
5. 爬取与分析常见问题排查:五个踩坑记录
5.1 页面表格结构对不上:改版后字段顺序变了
现象:某个月份抓回来的数据,专业列和学历列串位,分析时发现“学历”里出现专业名称。原因:就业信息网站改版后调整了表格列顺序,而爬虫还是按旧列索引取值。解决:解析表格时不要死记列序号,改用表头文本匹配列名,找不到对应列时直接报警告并跳过该行。
def extract_table_row(headers, cells): row = {} header_index = {h.strip(): i for i, h in enumerate(headers)} row["专业"] = cells[header_index.get("专业", -1)].text.strip() row["学历"] = cells[header_index.get("学历", -1)].text.strip() return row这段代码先建立“表头文本→列索引”的映射,再取值。表头一旦变化,使用get的默认值 -1 会出现cells[-1],这种错误反而更容易暴露问题。实际项目里应该加一条校验逻辑:如果某个关键字段取到空值,就把整行打印出来人工检查,而不是默默填充。
5.2 中文编码乱码:requests 拿回来的是 GBK
现象:打印出来的响应文本是大量乱码,但浏览器里显示正常。原因:网站使用的是 GBK/GB2312 编码,而requests默认按ISO-8859-1或utf-8猜测编码。解决:从响应头或 HTML 的meta标签里确认编码,并显式设置resp.encoding。
resp = requests.get(url, headers=headers, timeout=10) # 手动指定编码,避免乱码 resp.encoding = "gbk" print(resp.text[:200])还有一种更稳的方式是使用apparent_encoding来自动检测,但检测结果并非总是正确。尤其是页面本身混合了多种字符时,直接指定通常比自动检测更可靠。读取 CSV 时也容易踩同样的坑,建议在pd.read_csv里显式传encoding="utf-8"或encoding="gbk"。
5.3 速率过快触发反爬,请求间歇性失败
现象:前几百条数据抓取正常,随后出现大量超时和 403,重试也救不回来。原因:请求频率超过了站点阈值,IP 被临时限流。解决:限速间隔从time.sleep(1)调整为随机1~3秒,并观察响应头里是否有Retry-After字段;如果使用多线程抓取,不限制并发数也会导致同样的问题。
A同学曾用ThreadPoolExecutor(max_workers=20)并发抓取,半小时后被网站封禁。后来改成单线程限速抓取,虽然慢,但再也没有丢数据。对这种内部页面,抓取速度不重要,稳定才是第一位。
5.4 生成的图表中文全是方块
现象:用 matplotlib 画图时,中文标签显示为方框。原因:系统默认字体不包含中文字符。解决:在绘图中显式指定中文字体,例如 Windows 下的SimHei或 macOS 下的Arial Unicode MS。
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False # 解决负号显示异常axes.unicode_minus这个参数很容易遗漏,坐标轴出现负号时默认字体也显示不出来。换用其他中文字体时,font.sans-serif需要提供一个列表,matplotlib 会按顺序匹配第一个可用的字体。
5.5 薪资字段出现“面议”,类型转换直接把整列变成 NaN
现象:pd.to_numeric整列转换后,薪资列原本有值的数据也变成 NaN。原因:列里混入“面议”“年薪面议”等非数字文本,errors="raise"会直接报错,errors="coerce"则把非数字全部变成 NaN。解决:转换前先识别非数字取值,单独标记为“面议”,再对数字部分做数值转换。
import numpy as np df["salary_numeric"] = pd.to_numeric( df["salary_raw"].astype(str).str.replace(r"[元/月薪]", "", regex=True), errors="coerce" ) df.loc[df["salary_raw"].astype(str).str.contains("面议"), "salary_numeric"] = np.nan df.loc[df["salary_numeric"].isna(), "salary_flag"] = "面议或缺失"正则替换要小心,不要把所有非数字字符都删掉,比如“万元”中的“万”就包含单位语义。如果遇到“8万/年”,astype(str)后应先处理成月薪,再降为数值。常见顺序是:先统一单位,再做文本清洗,最后转数值类型,顺序颠倒就会出现虚高或虚低的数据。
6. 多报表自动化:一套代码跑通周报
当数据清洗和分析流程稳定后,下一步是模板化输出的周报。目标是一份独立可发布的 HTML 报告,包含总就业率、分专业去向表、行业占比柱状图和薪资分布箱线图。这里分享一个模板化生成技巧,比一次性写脚本更利于复用:用pandas聚合数据写入模板变量,再用str.replace替换 HTML 模板中的占位符。
def render_report(df, template_path, output_path): # 汇总关键指标 total_count = len(df) employment_rate = ( df[df["destination"].isin(["就业", "升学"])].shape[0] / total_count * 100 ) # 行业分布,转为 JSON 供前端图表使用 industry_stats = ( df.groupby("industry")["学号"].count() .sort_values(ascending=False) .head(10) .to_json(force_ascii=False) ) with open(template_path, "r", encoding="utf-8") as f: html_template = f.read() html_content = ( html_template .replace("{{TOTAL_COUNT}}", str(total_count)) .replace("{{EMPLOYMENT_RATE}}", f"{employment_rate:.2f}%") .replace("{{INDUSTRY_JSON}}", industry_stats) ) with open(output_path, "w", encoding="utf-8") as f: f.write(html_content)用模板字符串而不是在代码里拼接 HTML,好处是页面结构调整时不用动 Python 逻辑,让负责风格的人去改模板文件就行。to_json(force_ascii=False)保证中文不被转成\uXXXX,前端拿到后可以直接渲染。每周只要重新跑一遍数据抓取和清洗流程,然后执行render_report,一份新周报就完成了。
实际运行的验证手段很直接:抓取阶段检查每个年份的记录数与上年差异,清洗阶段打印缺失率统计,分析阶段抽查几个已知结论。比如上个月某专业就业率明显下降,新跑的数据是否还是同样趋势,如果趋势变了,先怀疑清洗逻辑而不是数据本身。我的习惯是保留每个阶段的中间结果到文件里(原始 JSON、清洗后 CSV、分析中间表),任何一步出问题可以回溯,不用重新抓一遍网页。
这套流程的维护成本并不在代码,而在数据源改版和新字段的持续迭代。把字段映射表、专业别名表、行业归类表做成外部配置文件,而不是写死在代码里,后续维护会轻松很多。希望这一整套做法能在你实际做就业数据分析时少走几步弯路,帮到你少加班,多产出。
本文还有配套的精品资源,点击获取