简介:基于BOSS直聘“数据分析师”职位信息构建的完整数据项目,涵盖爬虫实现、数据清洗、可视化分析与机器学习预测四大环节,适合数据相关专业学生用于期末大作业、课程设计或毕业设计。资源包共35个文件,包含3个Python脚本、3个Jupyter Notebook分析文档、1份CSV职位数据集、26张可视化结果图及README说明,压缩包仅1.31MB,模块划分清晰,便于按目录针对性学习。目前已有732人学习下载,代码带有详细注释,新手也能读懂爬虫、分析与建模思路。简单部署后即可复现从职位信息采集、城市分布统计到薪资回归预测的完整流程,具有较强的完整性,可作为课程设计和综合项目的高分参考范例。
1. 从一条JD反推BOSS直聘爬虫与预测任务的边界
第一次用requests去抓BOSS直聘的职位列表时,我得到的HTML里只有几个固定的div,职位数据一条都没有。这不是你写错了,而是列表页由前端框架异步渲染,真正的内容在浏览器Network面板里的XHR请求中。这个项目把“数据分析师”作为目标职位,走通从接口采集、字段清洗、可视化到机器学习预测平均薪资的完整链路。你会看到一份JD里的“20-30K·14薪”如何变成模型里的数值特征,也会看到最后的预测结果为什么不能直接当薪资谈判依据。整个方案基于公开接口做低频访问,适合期末项目、数据分析作品集,以及想研究招聘市场技能要求的在职工程师。
2. 爬虫实现:找到数据分析师职位列表的真实接口
2.1 为什么直接抓页面源码是错的
BOSS直聘职位列表页的HTML在首次加载时只有页面骨架,职位数据由JavaScript向服务端异步请求后动态填充。直接在Python里用requests请求列表页URL,得到的response里找不到任何“数据分析师”职位,最多能看到一些初始化变量。这也是很多新手把爬虫写成Selenium的根本原因——不是requests不行,而是你请求错了资源。
常见做法是把页面当作一个前端应用来调试:打开开发者工具,切到Network面板,筛选XHR请求,然后刷新或滚动列表页,观察哪几个请求返回了包含jobList或jobId的JSON。复制这个请求的URL和参数,再用requests改写。这个方法不依赖浏览器,响应快、机器开销低,也更容易控制采集频率。代价是接口的字段名是内部约定,可能随版本调整,代码里要预留容错。
我一般会把抓到的接口参数先记下来,重点看这几个:query、city、page、pageSize。query是搜索关键词,city是国家行政区划代码或城市编号,page从1开始递增,pageSize控制每页返回数量。确定这些参数后,一个最小采集函数就能工作。
2.2 用requests写一个最小采集函数
先准备一组User-Agent和请求头,避免固定UA被服务端识别:
import random import time import requests UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36", ] def fetch_job_page(query: str, page: int) -> list: url = "https://www.zhipin.com/wapi/zpgeek/search/joblist.json" params = { "query": query, "city": "100010000", "page": page, "pageSize": 30, } headers = { "User-Agent": random.choice(UA_POOL), "Referer": "https://www.zhipin.com/web/geek/job", "Accept": "application/json", } resp = requests.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() data = resp.json() return data.get("zpData", {}).get("jobList", [])这段代码里,params中的query是“数据分析师”,city是一个城市编号,实际接口路径以你本地抓到的为准,上面这个路径是这类接口常见的形态,字段名也可能不同。timeout设置成10秒,避免某个请求把脚本挂死。resp.raise_for_status()会在响应码为4xx或5xx时抛出异常,让后续重试逻辑接管。
得到jobList后,定义一个解析函数,把嵌套字段拍平:
def parse_job(job: dict) -> dict: return { "job_id": job.get("jobId"), "job_name": job.get("jobName"), "salary": job.get("salary"), "city": job.get("cityName"), "experience": job.get("jobExperience"), "education": job.get("jobDegree"), "company": job.get("brandName"), "industry": job.get("brandIndustry"), "skills": "|".join(job.get("jobLabels", []) or []), "update_time": job.get("updateTime"), }skills字段在原始JSON里可能是列表,比如[“Python”, “SQL”],写入CSV时直接存列表会变成字符串表示,所以这里用竖线拼接。后续分析时按竖线拆分就能还原。
2.3 请求频率控制的三个必调参数
BOSS直聘这类站点对高频访问比较敏感,最简单的保护墙就是请求频率。很多人一上来就写for page in range(1, 100),结果跑了十几页就被中断。常见做法是把三个参数写成一个配置:
| 参数 | 推荐设置 | 作用 |
|---|---|---|
| interval | 1-3秒随机 | 让请求间隔不规律,避免机械节奏 |
| retry | 3次,指数退避 | 处理超时、连接重置和429响应 |
| total_pages | 先跑3页验证 | 验证字段和入库逻辑后再放量 |
配合一个带重试的请求函数:
def request_with_retry(url: str, params: dict, headers: dict, retries: int = 3) -> dict: for attempt in range(retries): try: resp = requests.get(url, params=params, headers=headers, timeout=10) if resp.status_code == 429: time.sleep(5 * (2 ** attempt)) continue resp.raise_for_status() return resp.json() except requests.RequestException: time.sleep(5 * (2 ** attempt)) return {}指数退避的逻辑是:每次失败后等待时间翻倍,第1次5秒,第2次10秒,第3次20秒。这样既不会在服务端拒绝时继续猛冲,也不会因为一次网络抖动就丢数据。
提示:只采集登录后也能看到的公开列表页数据,不要带上登录态去抓权限范围外的内容,也不要对页面做高频压测。用于学习的数据项目,低频请求足够。
3. 数据分析与特征工程:薪资文本如何变成可建模字段
3.1 正则解析薪资区间
从接口拿到的salary字段是文本,常见格式有“15-20K”、“20-30K·14薪”、“面议”、“1-1.5万”。建模前要先把这些字符串变成数字。一个可靠的做法是保留前两个数字作为上下限,计算他们的平均值为区间中点。
import re def parse_salary(s: str): if not s or "面议" in s: return None, None, None s = s.replace("K", "").replace("k", "") scale = 10 if "万" in s else 1 nums = [float(x) for x in re.findall(r"\d+(?:\.\d+)?", s)] if len(nums) >= 2: low, high = nums[0] * scale, nums[1] * scale return low, high, (low + high) / 2 return None, None, None这里先replace掉K再找数字,是为了让“20-30K”变成“20-30”,避免正则把K后面的内容也带进来。字符串里如果有“·14薪”,findall也会抓到14,但由于我们只取前两个数字,14会被忽略。另一个细节是“万”要乘10,而不是乘1000,“1-1.5万”的意思是1万到1.5万,转换成K单位就是10-15K。
解析完可以加一列校验:如果salary_low大于salary_high,说明源数据格式异常,直接丢弃。确认解析逻辑正确后,再批量应用到整个DataFrame。
3.2 经验学历的离散映射
经验字段是文本,比如“1-3年”、“3-5年”、“经验不限”。学历字段是“大专”、“本科”、“硕士”。树模型可以直接吃标签编码,但标签编码的顺序最好有业务含义。经验映射成年数,学历按等级映射,让单调顺序成立。
| 原始字段 | 原始值 | 映射值 | 含义 |
|---|---|---|---|
| experience | 经验不限 / 在校/应届 | 0 | 0年 |
| experience | 1-3年 | 2 | 取中位数 |
| experience | 3-5年 | 4 | 取中位数 |
| education | 大专 | 1 | 学历等级 |
| education | 本科 | 2 | 学历等级 |
| education | 硕士 | 3 | 学历等级 |
EXP_MAP = { "经验不限": 0, "在校/应届": 0, "1年以内": 1, "1-3年": 2, "3-5年": 4, "5-10年": 7, "10年以上": 10, } EDU_MAP = { "学历不限": 0, "大专": 1, "本科": 2, "硕士": 3, "博士": 4, } df["exp_num"] = df["experience"].map(EXP_MAP) df["edu_num"] = df["education"].map(EDU_MAP)用map映射的优点是未命中值变成NaN,后续用缺失值统计一眼就能看出哪些原始字段写法没覆盖到。比如有的JD写“1年经验”而不是“1年以内”,映射后是NaN,这时需要去查原始数据,把新值补进字典。
3.3 去重、缺失与异常值检查
采集到的数据要先过一次质量检查,再做特征。最少要做三步:按job_id去重、丢弃薪资为空的记录、过滤薪资上下限反转的异常行。
import pandas as pd df = pd.read_csv("jobs.csv") print("原始记录数:", len(df)) df = df.drop_duplicates(subset=["job_id"]) df = df.dropna(subset=["salary_low", "salary_high"]) df = df[df["salary_low"] < df["salary_high"]] df["skill_num"] = df["skills"].fillna("").apply( lambda x: len(x.split("|")) if x else 0 )skill_num表示JD里写了几个技能标签。这个特征虽然简单,但在招聘数据里往往比“是否要求Python”更能反映岗位的专业深度,因为JD列出的技能数量通常和薪资有正相关。
这里有一个容易忽略的点:drop_duplicates默认保留第一条,如果爬虫分多天跑,同一职位在不同天出现,应该保留最近一次update_time的记录。操作上要先按update_time排序再drop_duplicates,否则可能把最新的薪资信息丢掉。
4. 数据可视化:用pyecharts展示职位分布与薪资结构
4.1 为什么要选交互式图表
这一步的目标是让结论能被非技术同事直接看。matplotlib的静态图适合写报告,但筛选交互弱;pyecharts生成HTML,基于ECharts,打开浏览器就能缩放、悬停、点击,做数据可视化大屏也很方便。安装只需要pip install pyecharts,图表导出是独立的HTML文件,不依赖Jupyter环境。
4.2 不同学历的平均薪资条形图
先做最稳的一张图:学历等级对应的薪资平均值。这里要用之前解析出的salary_mid,按education分组。
from pyecharts import options as opts from pyecharts.charts import Bar edu_order = ["学历不限", "大专", "本科", "硕士", "博士"] avg_salary = df.groupby("education")["salary_mid"].mean().reindex(edu_order).round(1) bar = ( Bar() .add_xaxis(edu_order) .add_yaxis("平均薪资(K)", avg_salary.fillna(0).tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="不同学历的数据分析师平均薪资"), xaxis_opts=opts.AxisOpts(name="学历"), yaxis_opts=opts.AxisOpts(name="平均薪资(K)"), ) ) bar.render("edu_salary.html")groupby之后用reindex是为了固定顺序,否则pyecharts会按字母排序,图表顺序就会变成“本科、大专、硕士”这种没有业务含义的排列。fillna(0)是为了防止某个学历类别没有数据时图表报错。render函数会把图表写入一个独立的HTML文件。
4.3 四张图表覆盖分析主线
一个完整的可视化页面不需要堆十几张图,能回答四个问题就够了。
| 图表 | 使用字段 | 回答的问题 |
|---|---|---|
| 薪资分布直方图 | salary_mid | 数据分析师的薪资集中在哪个区间 |
| 城市平均薪资条形图 | city, salary_mid | 哪些城市薪资明显更高 |
| 经验要求占比饼图 | experience | 市场更接受有几年经验的人 |
| 技能标签词云 | skills | 招聘JD里出现最多的技能 |
把单张图表组合成看板,用pyecharts的Page:
from pyecharts.charts import Page page = Page(layout=Page.SimplePageLayout) page.add(bar, bar_city, pie_exp, wordcloud_skills) page.render("analysis_dashboard.html")Page.SimplePageLayout会把多个图表按行列自动排列,免去手动设置grid坐标。做数据可视化大屏时,这个组合比单图更接近交付形态。
注意:图表的作用是暴露问题,不是装饰。比如城市平均薪资图如果显示“北京远高于其他城市”,下一步就要回数据里看北京样本量是不是太少,避免用小样本画大结论。
5. 机器学习预测与结果分析:从薪资建模到特征重要性解读
5.1 先建一个基线再谈模型
预测“薪资区间中点”是一个回归问题。不少项目一上来就跑梯度提升,最后R2只有0.3,却说模型不行。问题往往不在模型,而在于没有基线。DummyRegressor用训练集均值做预测,代表“不看任何特征”的下限。
from sklearn.dummy import DummyRegressor from sklearn.metrics import mean_absolute_error, r2_score from sklearn.model_selection import train_test_split X = df[["exp_num", "edu_num", "skill_num"]] y = df["salary_mid"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) dummy = DummyRegressor(strategy="mean") dummy.fit(X_train, y_train) y_base = dummy.predict(X_test) print("Baseline MAE:", mean_absolute_error(y_test, y_base))Mean策略下,MAE大约等于薪资中点的标准差。如果后续随机森林的MAE只比基线低5%,说明特征的预测能力很弱,应该先做特征分析,而不是继续调参。
5.2 城市编码与随机森林训练
城市是字符串,“北京”、“上海”、“杭州”这样几十个类别直接进模型不行。树模型可以用LabelEncoder,因为决策边界不受编码顺序影响;线性回归则要用OneHot编码。先把城市转成数值:
from sklearn.ensemble import RandomForestRegressor from sklearn.preprocessing import LabelEncoder city_enc = LabelEncoder() df["city_code"] = city_enc.fit_transform(df["city"].astype(str)) X = df[["exp_num", "edu_num", "city_code", "skill_num"]] y = df["salary_mid"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) model = RandomForestRegressor( n_estimators=200, max_depth=10, min_samples_leaf=3, random_state=42, ) model.fit(X_train, y_train) y_pred = model.predict(X_test) print("RandomForest MAE:", mean_absolute_error(y_test, y_pred)) print("RandomForest R2:", r2_score(y_test, y_pred))n_estimators决定树的数量,200棵足够捕捉数据规律,再大收益递减;max_depth限制树的深度,防止单棵记住噪声;min_samples_leaf=3要求叶子节点至少3个样本,减小方差。对于几千条职位数据,这几个参数不需要激进调优。
如果还要加行业、公司规模等特征,记得先把缺失值填掉。RandomForestRegressor不接受NaN,常见做法是用“未知”或众数填充,而不是删行。
5.3 特征重要性与结果分析
训练完之后,结果分析不是只看MAE和R2,还要看特征重要性。
importance = sorted( zip(X.columns, model.feature_importances_), key=lambda x: x[1], reverse=True, ) for feat, imp in importance: print(f"{feat}: {imp:.4f}")输出结果大致能回答这些问题:经验年数是否最影响薪资;城市贡献是否大于学历;技能数量是否几乎不重要。下面是一张常见现象对照表:
| 现象 | 可能的解释 | 下一步 |
|---|---|---|
| exp_num重要性最高 | 数据分析师薪资随经验线性增长 | 可做分经验段的均值对比 |
| city_code重要性高 | 城市间薪资差异大 | 分城市建模或把城市换成level |
| skill_num重要性很低 | 技能种类比数量更重要 | 改用是否包含特定技能 |
| R2低于0.2 | JD薪资噪声大,特征覆盖不足 | 检查缺失率,补充字段 |
这里的核心结论是:模型预测值是JD上写的薪资区间中点,不是真实offer。企业写JD时会结合预算和岗位定级,这个数天然存在大量噪声。所以预测结果的价值在于分析相对排序,比如“5年经验且在一线的数据分析师,JD薪资普遍在30K以上”,而不是把它当成谈薪的精确答案。
如果R2实在低,检查两个地方:一是特征矩阵里是否混进了太多缺失值填充的占位;二是薪资分布是否严重右偏,如果是,对salary_mid取log后再训练,MAE的报告口径也要改回原单位。
6. 并发采集与增量更新:把爬虫脚本接到最后一个环节
6.1 用ThreadPoolExecutor做可控并发
单线程跑300页职位列表会很慢,但并发设计不是num_workers越大越好。对BOSS直聘这类接口,合理做法是4个线程、每页间隔1-2秒。用ThreadPoolExecutor包一层:
from concurrent.futures import ThreadPoolExecutor, as_completed import random import time def safe_fetch(page: int): try: return fetch_job_page(query="数据分析师", page=page) except Exception: return [] with ThreadPoolExecutor(max_workers=4) as pool: futures = [pool.submit(safe_fetch, page) for page in range(1, 11)] for f in as_completed(futures): jobs = f.result() for job in jobs: save_row(parse_job(job)) time.sleep(random.uniform(1, 2))safe_fetch把异常吞掉并返回空列表,避免单个页面失败导致整个线程池退出。as_completed谁先完成先处理谁,不用等待前一个页面完成。sleep放在处理完每个future后,让请求节奏有一个随机波动。
6.2 增量更新:用job_id去重
爬虫如果只跑一次,价值有限。增量更新的思路很简单:把已采集的job_id放进一个set,新请求返回的job_id如果已经在set里就跳过,否则写入并加入set。
import os csv_path = "jobs.csv" if os.path.exists(csv_path): seen = set(pd.read_csv(csv_path)["job_id"].astype(str)) else: seen = set() # 在save_row里检查 def save_row(job_dict): if str(job_dict["job_id"]) in seen: return pd.DataFrame([job_dict]).to_csv(csv_path, mode="a", header=not os.path.exists(csv_path), index=False) seen.add(str(job_dict["job_id"]))这里用追加写入而不是反复重写整个CSV,大数据量下省很多时间。header参数只在文件不存在时写入。
6.3 跑完后的三个数据校验
增量任务跑到一半,最怕静默失败。我会在采集结束后做这样一个检查:
check = { "总记录数": len(df), "缺失率": df.isna().mean().round(4), "薪资字段数量": df["salary_mid"].notna().sum(), } print(check)如果总记录数没增加,去看接口返回是否为空;如果薪资字段缺失率超过5%,先查parse_salary的正则,而不是继续堆数据。跑完检查脚本,确认缺失率和重复率都正常,模型才可以直接拿去做下一轮训练。同时把seen集合和CSV里的job_id做一次全量比对,发现不一致就说明增量逻辑里漏了去重,需要单独修正后再继续采集。
本文还有配套的精品资源,点击获取