简介:这份资源是一套面向计算机与数据科学专业学生的学术实践项目,围绕BOSS直聘「数据分析师」岗位的招聘信息展开,覆盖从网络爬虫采集、多维统计解析、交互式可视化到机器学习薪资预测的完整链路,适合用于课程设计、毕业项目筹备与实战技能提升。压缩包共43个文件,约1.36MB,以26张png图表、3个py爬虫脚本、3个ipynb分析笔记为主,另含csv数据、md说明及zbak备份文件,分别对应可视化结果、采集逻辑、建模流程与文档记录。项目采用分层架构:自适应爬虫引擎抓取职位、薪资与能力要求,统计分析挖掘学历、经验与薪酬的关联规律,动态图表呈现地域分布与技能热度,集成学习算法构建薪资预测器并量化特征重要性。代码遵循模块化规范,注释与技术文档齐备,经多环境测试可直接部署运行。目前已有54人学习,为招聘市场趋势研究与回归建模提供可靠范本。
1. 从 BOSS 直聘职位页里挖出薪资规律:这套爬虫加预测系统到底解决什么问题
招聘网站上的数据分析师岗位,薪资区间从 8K 到 45K 都有,同样叫「数据分析师」,有的要求会 Excel 透视表就行,有的却要精通 Python 机器学习建模。这种信息不对称让求职者很难判断一个岗位的真实价值,也让做行业研究的人拿不到结构化的薪酬数据。BOSS 直聘作为国内活跃度最高的招聘平台之一,上面沉淀了大量数据分析师岗位的薪资、学历、经验、技能标签和公司信息,但这些数据散落在各个职位详情页里,手动收集几百条就已经让人崩溃。
这套爬虫系统加机器学习预测模型的思路,核心就是解决两件事:第一,用爬虫把 BOSS 直聘上数据分析师岗位的结构化数据批量抓下来,包括职位名称、薪资区间、公司名称、融资阶段、行业、学历要求、经验要求、技能关键词等字段;第二,用这些数据训练一个薪资预测模型,输入岗位特征就能估算合理薪资范围,帮求职者判断对方开的价是否偏离市场行情。适合有 Python 基础、想做一个完整数据采集加建模项目的从业者,也适合需要批量获取招聘数据做薪酬分析或竞品调研的人。整个方案不依赖任何付费 API,用 requests 加 BeautifulSoup 就能跑通采集环节,建模部分用 XGBoost 回归和 Prophet 时序预测两条路线做对比。
2. 爬虫系统怎么搭:从请求构造到数据落库的完整链路
2.1 目标字段拆解与页面结构分析
在动手写代码之前,先要搞清楚 BOSS 直聘职位列表页和详情页分别能拿到什么。列表页通常包含职位名称、薪资范围、公司简称、行业、融资阶段、学历和经验要求,详情页则多了职位描述全文、技能标签、公司规模、具体地址等。我一般会先打开浏览器开发者工具,切到 Network 面板,刷新页面后找到返回 JSON 数据的 XHR 请求,而不是直接解析 HTML。BOSS 直聘的列表数据很多是通过接口返回的,直接请求接口比解析 DOM 稳定得多。
需要抓取的核心字段和对应位置如下:
| 字段名 | 来源 | 示例值 |
|---|---|---|
| job_title | 列表接口 | 数据分析师 |
| salary_range | 列表接口 | 15-25K |
| company_name | 列表接口 | 某科技公司 |
| industry | 列表接口 | 互联网 |
| financing_stage | 列表接口 | B轮 |
| education | 列表接口 | 本科 |
| experience | 列表接口 | 3-5年 |
| skills | 详情页 | Python, SQL, Tableau |
| job_desc | 详情页 | 负责业务数据建模... |
| city | 列表接口 | 北京 |
注意:BOSS 直聘的接口有签名参数和 Cookie 校验,直接裸请求会返回 403。常见做法是在请求头里带上完整的 Cookie 和 User-Agent,并且控制请求频率。
2.2 请求构造与反爬应对的最小实现
下面是一个最小可运行的采集脚本,用 requests 请求列表接口,解析 JSON 后提取字段并存入 CSV。代码里加了随机延时和重试机制,避免触发频率限制。
import requests import csv import time import random from retry import retry # 请求头需要替换成你自己浏览器里抓到的真实值 HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Cookie": "你的Cookie值", "Referer": "https://www.zhipin.com/web/geek/job?query=数据分析师&city=101010100" } @retry(tries=3, delay=2) def fetch_job_list(city_code, page): """请求职位列表接口,返回 JSON 数据""" url = "https://www.zhipin.com/wapi/zpgeek/search/joblist.json" params = { "scene": 1, "query": "数据分析师", "city": city_code, "page": page, "pageSize": 30 } resp = requests.get(url, headers=HEADERS, params=params, timeout=10) if resp.status_code != 200: raise Exception(f"请求失败,状态码:{resp.status_code}") return resp.json() def parse_job(item): """从单条职位数据中提取目标字段""" return { "job_title": item.get("jobName", ""), "salary_range": item.get("salaryDesc", ""), "company_name": item.get("brandName", ""), "industry": item.get("brandIndustry", ""), "financing_stage": item.get("brandStageName", ""), "education": item.get("jobDegree", ""), "experience": item.get("jobExperience", ""), "city": item.get("cityName", ""), "skills": ",".join(item.get("skills", [])), } def save_to_csv(rows, filename="boss_jobs.csv"): """追加写入 CSV 文件""" with open(filename, "a", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=rows[0].keys()) if f.tell() == 0: writer.writeheader() writer.writerows(rows) if __name__ == "__main__": all_rows = [] for page in range(1, 6): # 先抓前5页试试水 data = fetch_job_list("101010100", page) # 101010100 是北京 job_list = data.get("zpData", {}).get("jobList", []) if not job_list: break for item in job_list: all_rows.append(parse_job(item)) time.sleep(random.uniform(3, 6)) # 随机延时,降低被封风险 if all_rows: save_to_csv(all_rows) print(f"共采集 {len(all_rows)} 条职位数据")这段代码的逻辑很直接:fetch_job_list负责发请求拿 JSON,parse_job从每条记录里抽字段,save_to_csv追加写入。关键参数有三个:pageSize控制每页条数,一般设 30;time.sleep的随机区间建议 3 到 6 秒,太快容易触发风控;@retry装饰器在请求失败时自动重试三次,避免偶发网络问题导致数据丢失。
2.3 详情页技能标签的补充采集
列表页拿不到技能标签和职位描述全文,需要再请求一次详情页接口。详情页的 URL 通常形如https://www.zhipin.com/job_detail/{jobId}.html,但直接请求 HTML 解析效率低,我一般找详情页对应的 JSON 接口。拿到技能标签后,把之前 CSV 里的记录补上这一列。
def fetch_job_detail(job_id): """请求职位详情接口,提取技能标签和职位描述""" url = f"https://www.zhipin.com/wapi/zpgeek/job/detail.json" params = {"jobId": job_id} resp = requests.get(url, headers=HEADERS, params=params, timeout=10) data = resp.json() detail = data.get("zpData", {}).get("jobInfo", {}) return { "skills": ",".join(detail.get("skills", [])), "job_desc": detail.get("jobDesc", "")[:500] # 截取前500字 }这里job_desc截取前 500 字是为了控制存储体积,后续做文本特征时够用了。技能标签用逗号拼接成字符串,方便直接写入 CSV 的单列。
3. 薪资预测模型怎么选:XGBoost 回归和 Prophet 时序预测的落地对比
3.1 数据清洗与特征工程的关键步骤
爬下来的原始数据不能直接喂给模型。薪资字段是「15-25K」这种区间格式,需要拆成最低薪资和最高薪资两列,再取中位数作为回归目标。学历和经验是类别变量,要做编码。技能标签是多值字段,可以用 TF-IDF 或者简单的 one-hot 展开成多个二值列。
import pandas as pd import numpy as np from sklearn.preprocessing import LabelEncoder def clean_salary(s): """把 '15-25K' 解析成 (15, 25) 元组""" if pd.isna(s) or "-" not in str(s): return np.nan, np.nan parts = str(s).replace("K", "").replace("k", "").split("-") try: return float(parts[0]), float(parts[1]) except: return np.nan, np.nan df = pd.read_csv("boss_jobs.csv") df[["salary_min", "salary_max"]] = df["salary_range"].apply( lambda x: pd.Series(clean_salary(x)) ) df["salary_mid"] = (df["salary_min"] + df["salary_max"]) / 2 df = df.dropna(subset=["salary_mid"]) # 学历和经验做标签编码 le_edu = LabelEncoder() le_exp = LabelEncoder() df["education_enc"] = le_edu.fit_transform(df["education"].fillna("未知")) df["experience_enc"] = le_exp.fit_transform(df["experience"].fillna("未知")) # 技能标签展开成二值列 top_skills = ["Python", "SQL", "Excel", "Tableau", "机器学习", "统计学"] for skill in top_skills: df[f"skill_{skill}"] = df["skills"].fillna("").apply( lambda x: 1 if skill in x else 0 ) print(df[["salary_mid", "education_enc", "experience_enc"]].describe())clean_salary处理了「15-25K」这种格式,遇到「面议」或空值返回 NaN,后面直接 drop 掉。LabelEncoder把学历和经验转成整数编码,虽然会引入顺序关系,但在树模型里影响不大。技能标签展开成二值列后,每个技能就是一个独立特征,模型能学到「会 Python 的岗位薪资是否更高」。
3.2 XGBoost 回归建模与参数调优
XGBoost 处理表格数据的能力在业界有大量验证,适合这种混合了数值和类别特征的场景。下面是一个完整的训练和评估流程。
import xgboost as xgb from sklearn.model_selection import train_test_split from sklearn.metrics import mean_absolute_error, r2_score feature_cols = ["education_enc", "experience_enc"] + [f"skill_{s}" for s in top_skills] X = df[feature_cols] 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 = xgb.XGBRegressor( n_estimators=200, max_depth=5, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, random_state=42 ) model.fit(X_train, y_train) y_pred = model.predict(X_test) print(f"MAE: {mean_absolute_error(y_test, y_pred):.2f}K") print(f"R2: {r2_score(y_test, y_pred):.3f}")参数方面,n_estimators=200是树的数量,太少欠拟合,太多容易过拟合,可以先从 200 试起;max_depth=5控制每棵树的深度,招聘数据特征维度不高,5 层足够;learning_rate=0.05是学习率,配合 200 棵树收敛比较稳;subsample和colsample_bytree都设 0.8,增加随机性防止过拟合。评估指标看 MAE 和 R2,MAE 表示预测薪资和实际薪资平均差多少 K,R2 越接近 1 说明模型解释力越强。
3.3 Prophet 时序预测:薪资随时间变化的趋势分析
如果采集的数据带有发布时间,可以用 Prophet 做薪资随时间变化的趋势预测。Prophet 是 Facebook 开源的时序预测工具,对缺失值和异常值比较鲁棒,适合分析「数据分析师薪资在过去一年是涨还是跌」这类问题。
from prophet import Prophet # 假设 df 里有 job_date 列,格式为 '2024-01-15' daily_salary = df.groupby("job_date")["salary_mid"].mean().reset_index() daily_salary.columns = ["ds", "y"] prophet_model = Prophet( yearly_seasonality=True, weekly_seasonality=False, daily_seasonality=False, changepoint_prior_scale=0.05 ) prophet_model.fit(daily_salary) future = prophet_model.make_future_dataframe(periods=90) forecast = prophet_model.predict(future) print(forecast[["ds", "yhat", "yhat_lower", "yhat_upper"]].tail(10))changepoint_prior_scale=0.05控制趋势变化的灵活度,值越大模型对突变越敏感,招聘薪资通常变化平缓,设小一点更稳。yearly_seasonality=True捕捉年度周期性,比如金三银四跳槽季薪资是否走高。输出里的yhat是预测均值,yhat_lower和yhat_upper是置信区间,可以拿来判断某个月薪资是否异常偏高或偏低。
4. 避坑与排查:采集和建模路上最容易翻车的五个地方
4.1 请求返回 403 或验证码页面
现象:脚本跑了几页之后突然返回 403,或者响应内容变成验证码 HTML 而不是 JSON。
原因:BOSS 直聘对同一 IP 或同一 Cookie 的请求频率有阈值,超过后触发风控。另外 Cookie 过期也会导致鉴权失败。
解决:把time.sleep的随机区间拉长到 5 到 10 秒,每抓 50 条换一次 Cookie,或者用多个 Cookie 轮换。如果已经返回验证码,停一段时间再跑,不要硬刚。
4.2 薪资区间解析出 NaN
现象:clean_salary函数对某些记录返回 NaN,导致这些行被 drop 掉,数据量明显减少。
原因:薪资字段不全是「15-25K」格式,还有「15-25K·13薪」「200-300元/天」「面议」等变体。
解决:在clean_salary里加更多分支处理,比如先去掉「·13薪」后缀,遇到「元/天」的换算成月薪(乘以 21.75),遇到「面议」直接标记为缺失而不是报错。
4.3 技能标签为空导致特征全零
现象:模型训练时发现skill_Python等列大部分是 0,特征几乎没起作用。
原因:详情页采集环节没有跑,或者详情接口返回的技能字段名和预期不一致。
解决:先打印几条详情接口的原始 JSON,确认技能字段的实际路径。如果详情页采集成本太高,可以退而求其次,从职位描述文本里用关键词匹配的方式提取技能。
4.4 XGBoost 模型 R2 很低甚至为负
现象:训练完发现 R2 只有 0.1 甚至负数,MAE 高达 8K 以上。
原因:特征太少或特征与薪资的相关性太弱。只靠学历和经验编码,模型学不到太多有效信息。
解决:加入城市、行业、公司融资阶段等类别特征,把技能标签展开得更细,还可以对职位描述做 TF-IDF 提取文本特征。特征工程到位后,R2 通常能到 0.5 以上。
4.5 Prophet 预测结果波动过大
现象:Prophet 输出的yhat曲线锯齿严重,置信区间宽得没法用。
原因:changepoint_prior_scale设得太大,模型对每天的薪资均值波动过度反应。另外如果数据按天聚合后每天只有几条记录,均值本身就不稳定。
解决:把changepoint_prior_scale降到 0.01 到 0.05 之间,并且按周而不是按天聚合数据,减少噪声。如果数据量太少,Prophet 不适合,直接用简单的移动平均更靠谱。
5. 把预测模型用起来:一个输入岗位特征就能估薪的小工具
模型训练完之后,最有价值的用法是把它包成一个简单的预测函数,输入岗位特征就能输出预估薪资。下面这个函数把前面训练的 XGBoost 模型和编码器串起来,给定学历、经验、技能列表,返回预测的月薪中位数。
def predict_salary(education, experience, skills): """ 输入岗位特征,返回预测薪资(K) education: 学历字符串,如 '本科' experience: 经验字符串,如 '3-5年' skills: 技能列表,如 ['Python', 'SQL'] """ edu_enc = le_edu.transform([education])[0] if education in le_edu.classes_ else -1 exp_enc = le_exp.transform([experience])[0] if experience in le_exp.classes_ else -1 feature_dict = { "education_enc": edu_enc, "experience_enc": exp_enc } for skill in top_skills: feature_dict[f"skill_{skill}"] = 1 if skill in skills else 0 feature_df = pd.DataFrame([feature_dict])[feature_cols] pred = model.predict(feature_df)[0] return round(pred, 1) # 试一下 result = predict_salary("本科", "3-5年", ["Python", "SQL", "机器学习"]) print(f"预测薪资中位数:{result}K")这个函数的关键点在于处理未知类别:如果输入的学历或经验不在训练集的类别里,LabelEncoder会报错,所以用if ... in le_edu.classes_做保护,未知类别统一编码为 -1。技能列表里没出现的技能对应特征为 0,模型会自动处理。
验证模型是否靠谱,我一般会做两件事:一是留出 20% 的测试集看 MAE,如果 MAE 在 3K 以内,说明预测值和实际值平均差 3K,对求职者判断薪资范围来说够用了;二是拿几个真实岗位手动对比,比如「本科+3-5年+Python+SQL」在北京市场大概 18-25K,如果模型输出 15K 或 30K,就要回头检查特征工程是不是漏了什么。
还有一个实用技巧:把预测结果和实际薪资的残差画出来,看看模型在哪些区间偏差大。如果高薪岗位普遍低估,可能是训练数据里高薪样本太少,需要针对性补充采集。我自己的习惯是每次重新训练模型后都跑一遍残差分析,确认没有系统性偏差再上线使用。这套爬虫加预测的流程跑通一次之后,后续换城市、换职位关键词只需要改几个参数就能复用,比每次手动翻招聘网站高效得多。希望帮到你。
本文还有配套的精品资源,点击获取