简介这是一套面向计算机与数据科学专业学生的学术实践项目聚焦招聘平台数据分析岗位的信息挖掘与智能研究可用于课程设计、毕业项目筹备及实战技能提升。项目以BOSS直聘数据分析师职位为对象完整覆盖网络爬虫构建、多维统计解析、交互式可视化与机器学习薪资预测全流程在高校课程考核中获得98分。压缩包共43个文件约1.36MB以26张png图表、3个py爬虫脚本、3个ipynb分析建模笔记为主辅以csv数据、md说明文档及zbak备份文件解压后可直接部署运行。已有54人学习。技术架构采用分层设计自适应爬虫引擎采集职位信息、薪资范围与能力要求统计分析挖掘学历、经验与薪酬的关联规律动态图表展示地域分布与技能热度集成学习算法构建薪资预测器并量化特征重要性。所有代码遵循模块化规范注释与技术文档齐备多环境测试通过为学术研究提供可靠范本。1. 从 BOSS 直聘数据分析师岗位看爬虫与预测模型的真实落地链路BOSS 直聘上搜「数据分析师」同一座城市能刷出几百条岗位薪资从 8K 到 45K 都有JD 里反复出现 SQL、Python、Tableau、A/B 实验、用户画像这些词。手动翻二十页就眼花更别说回答「哪个行业给钱多」「经验几年后涨幅最快」「哪些技能组合溢价最高」这类问题。这套爬虫系统加机器学习预测模型要解决的就是把岗位列表变成结构化数据再用回归模型预测薪资区间用时间序列模型看招聘需求的月度走势。适合有 Python 基础、想做一个完整数据闭环项目的分析师或转行选手也适合拿它当机器学习实战项目案例练手。整条链路分四段抓取、清洗、特征、建模每段都有翻车点下面按顺序拆。2. 爬取 BOSS 直聘岗位数据请求构造、翻页逻辑与反爬应对2.1 先搞清楚页面数据从哪来再决定用 requests 还是浏览器BOSS 直聘的岗位列表页是服务端渲染加接口异步加载的混合结构。打开开发者工具 Network 面板筛选 XHR/Fetch翻一页就能看到返回 JSON 的接口里面带 jobName、salaryDesc、jobLabels、skills、experience、degree、cityName、brandName、brandIndustry 这些字段。能直接拿到 JSON 就别去解析 DOM省掉一大半维护成本。常见做法是先用 requests 带完整请求头请求接口如果返回 403 或空数据再退回 Selenium 或 Playwright 渲染页面。请求头里最关键的是 Cookie 里的zp_stoken和 User-Agent。前者是平台的风控令牌有效期通常几十分钟到几小时过期后接口返回 code 非 0 或直接跳验证页。我一般把 Cookie 放在单独配置文件里跑之前手动更新一次不硬编码进脚本。import requests, time, random, json HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, Cookie: __zp_stoken__替换成你抓到的值; 其他cookie字段..., Referer: https://www.zhipin.com/web/geek/job?query数据分析师city101010100, Accept: application/json, text/plain, */*, } def fetch_page(query, city, page): url https://www.zhipin.com/wapi/zpgeek/search/joblist.json params {query: query, city: city, page: page, pageSize: 30} resp requests.get(url, headersHEADERS, paramsparams, timeout10) if resp.status_code ! 200: raise RuntimeError(fHTTP {resp.status_code}) data resp.json() if data.get(code) ! 0: raise RuntimeError(f业务码异常: {data.get(code)} {data.get(message)}) return data[zpData][jobList]这段代码做了三件事拼请求、校验 HTTP 状态、校验业务返回码。参数说明上query 是搜索词city 是城市编码北京 101010100上海 101020100广州 101280100深圳 101280600page 从 1 开始pageSize 一般固定 30。timeout 设 10 秒超过就重试。业务码校验不能省平台风控触发时 HTTP 仍是 200但 code 会变成非 0只看状态码会拿到一堆空数据还以为抓成功了。2.2 翻页节奏与断点续爬别让一次跑崩丢掉全部进度翻页不是越快越好。实测间隔低于 1.5 秒连续请求十几页后大概率触发验证。我一般设 2 到 4 秒随机间隔每抓 5 页休息 10 秒。翻页终止条件有两个返回列表为空或者当前页岗位 ID 与上一页重复率超过 80%说明平台开始返回重复数据。断点续爬靠一个进度文件实现记录已完成的 (query, city, page) 组合。脚本启动时先读进度跳过已完成页。这样即使中途被封换 Cookie 后能从断点继续不用从头再来。import os, json PROGRESS_FILE progress.json def load_progress(): if os.path.exists(PROGRESS_FILE): with open(PROGRESS_FILE, r, encodingutf-8) as f: return set(tuple(x) for x in json.load(f)) return set() def save_progress(done): with open(PROGRESS_FILE, w, encodingutf-8) as f: json.dump([list(x) for x in done], f, ensure_asciiFalse) def crawl(query, city, max_page50): done load_progress() all_jobs [] for page in range(1, max_page 1): key (query, city, page) if key in done: continue try: jobs fetch_page(query, city, page) except RuntimeError as e: print(f第{page}页失败: {e}暂停60秒) time.sleep(60) continue if not jobs: break all_jobs.extend(jobs) done.add(key) save_progress(done) time.sleep(random.uniform(2, 4)) return all_jobs进度文件用 JSON 存键是三元组。失败时暂停 60 秒再试同一页不直接跳过避免数据缺口。max_page 设 50 是经验值多数城市数据分析师岗位不超过 40 页设太大浪费请求。抓完的原始 JSON 按raw/{query}_{city}_{page}.json落盘不要只存内存后面清洗出问题还能回溯。2.3 字段抽取薪资、经验、学历、技能标签怎么拆成结构化列原始 JSON 里 salaryDesc 是「15-25K·13薪」这种字符串experience 是「3-5年」degree 是「本科」skills 是字符串数组。要建模就得拆成数值列。薪资拆成 salary_min、salary_max、salary_months 三列单位统一为千元每月。经验拆成 exp_min、exp_max「经验不限」记为 0 到 0「10年以上」记为 10 到 99。学历映射成有序整数不限 0、大专 1、本科 2、硕士 3、博士 4。import re def parse_salary(desc): m re.match(r(\d)-(\d)K(?:·(\d)薪)?, desc) if not m: return None, None, 12 lo, hi int(m.group(1)), int(m.group(2)) months int(m.group(3)) if m.group(3) else 12 return lo, hi, months def parse_exp(exp): if 不限 in exp: return 0, 0 if 以上 in exp: n int(re.search(r(\d), exp).group(1)) return n, 99 m re.search(r(\d)-(\d), exp) return (int(m.group(1)), int(m.group(2))) if m else (0, 0) DEGREE_MAP {不限: 0, 大专: 1, 本科: 2, 硕士: 3, 博士: 4} def parse_degree(d): return DEGREE_MAP.get(d, 0)parse_salary 返回月薪下限、上限和发薪月数后续算年薪就是(minmax)/2 * months。parse_exp 把区间统一成两个整数方便做特征。DEGREE_MAP 用有序编码而不是独热是因为学历本身有高低序关系树模型能直接利用。技能标签先不做多值展开存成逗号分隔字符串建模阶段再用 CountVectorizer 或 TF-IDF 处理。3. 数据清洗与特征工程让薪资预测模型有东西可学3.1 清洗四步走去重、异常值、缺失值、文本归一化抓下来的原始数据先按 jobId 去重同一岗位可能在多个搜索词下重复出现。薪资异常值处理上月薪低于 3K 或高于 100K 的记录直接剔除这类多半是「面议」被误解析或极端 outlier。缺失值方面salary_min 或 salary_max 为空的行占比通常不到 5%直接删brandIndustry 为空用「未知」填充。文本归一化针对 cityName 和 brandIndustry。城市统一去掉「市」后缀行业把「互联网」「互联网/电子商务」这类合并成「互联网」。技能标签统一小写、去空格把「sql」和「SQL」合并。import pandas as pd def clean(df): df df.drop_duplicates(subsetjobId) df df.dropna(subset[salary_min, salary_max]) df df[(df[salary_min] 3) (df[salary_max] 100)] df[city] df[city].str.replace(市, , regexFalse) df[industry] df[industry].fillna(未知).str.split(/).str[0] df[skills] df[skills].fillna().str.lower().str.replace( , ) df[salary_avg] (df[salary_min] df[salary_max]) / 2 return df.reset_index(dropTrue)清洗后加一列 salary_avg 作为回归目标。注意 salary_avg 用的是月薪均值如果要做年薪预测就乘 salary_months。清洗完先df.describe()看一眼分布薪资中位数一般在 12 到 18K 之间如果偏离太多说明解析逻辑有问题。3.2 特征编码城市、行业、技能标签怎么变成模型能吃的数字数值特征直接用exp_min、exp_max、degree、salary_months。类别特征城市和行业用目标编码target encoding即用该类别下薪资均值替代类别名比独热编码更适合高基数类别。技能标签用 TF-IDF 转成稀疏矩阵取 top 100 个高频技能作为特征列。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.model_selection import KFold def target_encode(df, col, target, n_splits5): df[f{col}_te] 0.0 kf KFold(n_splitsn_splits, shuffleTrue, random_state42) for tr, va in kf.split(df): means df.iloc[tr].groupby(col)[target].mean() df.loc[df.index[va], f{col}_te] df.iloc[va][col].map(means) global_mean df[target].mean() df[f{col}_te] df[f{col}_te].fillna(global_mean) return df def build_features(df): df target_encode(df, city, salary_avg) df target_encode(df, industry, salary_avg) tfidf TfidfVectorizer(max_features100, token_patternr[^,]) skill_mat tfidf.fit_transform(df[skills]) return df, skill_mat, tfidftarget_encode 用 K 折交叉的方式做避免用全量均值导致标签泄漏。每折用训练折的均值映射到验证折最后用全局均值填缺失。TF-IDF 的 token_pattern 改成[^,]是因为技能是逗号分隔的默认按空格分词会把「机器学习」拆坏。skill_mat 是稀疏矩阵和数值特征拼接时用 scipy.sparse.hstack。3.3 训练集与测试集划分时间切分比随机切分更贴近真实场景如果数据带抓取日期按时间切分前 80% 日期做训练后 20% 做测试。这样能模拟「用历史数据预测未来岗位」的真实场景。如果没日期就随机切但要注意分层保证测试集里各城市、各经验段都有样本。from sklearn.model_selection import train_test_split import scipy.sparse as sp def split_data(df, skill_mat): num_cols [exp_min, exp_max, degree, salary_months, city_te, industry_te] X_num df[num_cols].values X sp.hstack([sp.csr_matrix(X_num), skill_mat]).tocsr() y df[salary_avg].values X_tr, X_te, y_tr, y_te train_test_split(X, y, test_size0.2, random_state42) return X_tr, X_te, y_tr, y_tenum_cols 里放的是数值和目标编码列skill_mat 是 TF-IDF 矩阵hstack 拼成一个大稀疏矩阵。train_test_split 的 random_state 固定住保证每次跑结果可复现。如果数据量大测试集比例可以降到 0.1。4. 薪资预测模型选型与训练XGBoost 回归的调参与评估4.1 为什么选 XGBoost 而不是线性回归或深度学习薪资预测是个典型的表格数据回归任务特征里有类别、有稀疏文本、有数值样本量通常几千到几万。线性回归拟合不了特征间的交互比如「硕士加 3 年经验」的溢价不是两个系数简单相加。深度学习在几千样本上容易过拟合调参成本也高。XGBoost 对表格数据友好能自动处理特征交互内置正则化训练快特征重要性还能解释。常见做法是先跑一版 XGBoost 基线再和 LightGBM 对比多数情况下两者差距在 1% 以内。4.2 训练脚本与五个必调参数import xgboost as xgb from sklearn.metrics import mean_absolute_error, r2_score def train_xgb(X_tr, y_tr, X_te, y_te): model xgb.XGBRegressor( n_estimators800, max_depth6, learning_rate0.05, subsample0.8, colsample_bytree0.8, reg_alpha0.1, reg_lambda1.0, random_state42, n_jobs-1, ) model.fit(X_tr, y_tr, eval_set[(X_te, y_te)], early_stopping_rounds50, verboseFalse) pred model.predict(X_te) print(MAE:, mean_absolute_error(y_te, pred)) print(R2:, r2_score(y_te, pred)) return model参数说明n_estimators 设 800 配合 early_stopping_rounds50验证集 50 轮不提升就停实际有效树可能只有 300 到 500 棵。max_depth6 控制树深超过 8 容易过拟合。learning_rate0.05 是学习率调小到 0.01 需要更多树但泛化更好。subsample 和 colsample_bytree 都设 0.8行采样和列采样各 80%增加随机性防过拟合。reg_alpha 和 reg_lambda 是 L1 和 L2 正则数据噪声大时调高到 1 到 5。评估指标看 MAE 和 R2。MAE 是平均绝对误差单位是千元比如 MAE3.2 表示预测薪资平均偏差 3.2K。R2 在 0.6 到 0.75 之间算正常低于 0.5 说明特征不够高于 0.85 要怀疑标签泄漏。4.3 特征重要性解读哪些因素真正影响数据分析师薪资训练完打印model.feature_importances_按重要性排序。经验段exp_min、exp_max通常排前二城市目标编码排第三学历第四技能里 Python、SQL、机器学习、Tableau 权重靠前。如果发现某个技能权重异常高检查是不是该技能样本量太少导致目标编码过拟合。import numpy as np def show_importance(model, num_cols, tfidf, top_n15): names num_cols list(tfidf.get_feature_names_out()) imp model.feature_importances_ idx np.argsort(imp)[::-1][:top_n] for i in idx: print(f{names[i]}: {imp[i]:.4f})names 是数值列名加 TF-IDF 词表按重要性降序打印前 15 个。这个输出直接能写进分析报告回答「什么技能最值钱」。5. 招聘需求时序预测与避坑排查5.1 用 Prophet 预测岗位数量月度走势薪资是截面预测岗位数量是时序预测。把抓取数据按「城市 月份」聚合统计每月新增岗位数用 Prophet 拟合趋势和季节性。Prophet 对缺失月份和异常值容忍度高适合这种非平稳的招聘数据。from prophet import Prophet import pandas as pd def forecast_jobs(df, city): sub df[df[city] city].copy() sub[ds] pd.to_datetime(sub[crawl_date]).dt.to_period(M).dt.to_timestamp() ts sub.groupby(ds).size().reset_index(namey) m Prophet(yearly_seasonalityTrue, weekly_seasonalityFalse, daily_seasonalityFalse, changepoint_prior_scale0.1) m.fit(ts) future m.make_future_dataframe(periods6, freqM) fc m.predict(future) return fc[[ds, yhat, yhat_lower, yhat_upper]]changepoint_prior_scale0.1 控制趋势变化敏感度调大更容易捕捉突变调小更平滑。yearly_seasonality 开年周期招聘旺季通常在 3 月和 9 月。输出带置信区间的预测yhat_lower 和 yhat_upper 给决策留余量。5.2 避坑排查五条血泪经验现象接口返回 code 非 0但 HTTP 是 200。原因Cookie 里zp_stoken过期或请求头缺 Referer。解决更新 Cookie补全 Referer 和 Accept 头重试前先 sleep 60 秒。现象翻到第 10 页后返回数据与第 1 页重复。原因平台对未登录或低频账号限制深度翻页超过阈值返回缓存数据。解决降低翻页速度到 4 秒以上或换城市编码分片抓取把单次任务拆成多个小任务。现象薪资解析出大量 None。原因salaryDesc 格式不统一有「15-25K」「15-25K·13薪」「200-300元/天」多种。解决正则加分支日薪单独处理按 22 天折算月薪解析失败的行打日志人工抽查。现象模型 R2 高达 0.95 但线上预测离谱。原因目标编码用了全量数据标签泄漏。解决改成 K 折交叉编码确保验证折的编码只用训练折均值。现象Prophet 预测曲线几乎水平。原因训练数据月份太少不足 12 个月年季节性学不出来。解决至少积累 18 个月数据再跑或关掉 yearly_seasonality 只留趋势。6. 把模型跑成可复用的预测脚本参数固化与增量更新模型训练完不能只躺在 notebook 里。我一般把训练好的 XGBoost 用model.save_model(xgb_salary.json)存下来TF-IDF 用 joblib 存目标编码的映射表存成 CSV。预测脚本加载这三样输入一条岗位 JSON输出预测薪资区间。import joblib, json import xgboost as xgb import scipy.sparse as sp import pandas as pd def load_artifacts(): model xgb.XGBRegressor() model.load_model(xgb_salary.json) tfidf joblib.load(tfidf.pkl) te_maps pd.read_csv(target_encode_maps.csv) return model, tfidf, te_maps def predict_one(job, model, tfidf, te_maps): city_te te_maps[te_maps[col] city].set_index(value)[mean].get(job[city], 15.0) ind_te te_maps[te_maps[col] industry].set_index(value)[mean].get(job[industry], 15.0) num [[job[exp_min], job[exp_max], job[degree], 12, city_te, ind_te]] skill_vec tfidf.transform([job[skills]]) X sp.hstack([sp.csr_matrix(num), skill_vec]).tocsr() pred model.predict(X)[0] return round(pred - 2, 1), round(pred 2, 1)te_maps 存的是每个类别对应的薪资均值预测时直接查表查不到用全局均值 15.0 兜底。预测区间用 ±2K 近似比单点估计更有参考价值。增量更新策略是每月重抓一次数据追加到历史表重新跑清洗和训练模型文件覆盖前先备份一份带日期的出问题能回滚。这套链路我从第一版跑通到稳定输出前后改了十几版最大的教训是别在爬虫阶段追求一次抓全先把 100 条数据跑通清洗和建模再回头扩量。数据质量比模型调参重要得多薪资解析错一行后面所有分析都偏。希望帮到你。本文还有配套的精品资源点击获取
