简介:这是一套面向计算机与数据科学专业学生的学术实践项目,聚焦招聘平台数据分析岗位的信息挖掘与智能研究,可用于课程设计、毕业项目筹备与实战技能提升。项目以BOSS直聘数据分析师职位为对象,完整覆盖网络爬虫构建、多维统计解析、交互式可视化与机器学习薪资预测全流程,并在高校课程考核中获得98分。压缩包共43个文件,约1.36MB,包含3个py爬虫与处理脚本、3个ipynb分析及建模笔记、26张png可视化结果图、1个csv城市数据以及zbak备份与md说明文档,解压后可直接部署运行。技术架构采用分层设计:自适应爬虫引擎采集职位信息、薪资范围与能力要求,统计分析挖掘学历、经验与薪酬的关联规律,动态图表展示地域分布与技能热度,集成学习算法构建薪资预测器并量化特征重要性。目前已有54人学习,代码遵循模块化规范并配备详细注释与技术文档,适合作为数据采集清洗、特征工程、可视化分析与回归预测的完整方法论范本。
1. 从BOSS直聘数据分析师岗位看:一份招聘数据能挖出多少东西
打开BOSS直聘搜“数据分析师”,你会看到什么?薪资从8K到45K不等,要求从“熟练Excel”到“精通Python机器学习”,公司从十几个人的创业团队到几万人的大厂。这些信息散落在几千条招聘帖里,靠人眼一条条翻,翻到第一百条就麻木了。但如果你把这几千条数据抓下来,用结构化字段存好,再拿机器学习模型跑一遍,你就能回答一些很有意思的问题:哪些技能组合能把薪资抬上去?北京和杭州的数据分析师岗位要求差在哪?什么样的公司愿意给应届生开20K?
这套爬虫系统加机器学习预测模型的方案,解决的就是这个问题。它适合有Python基础、想做一个完整数据项目练手的人,也适合正在找工作、想用数据反向指导自己技能树的人。整个链路分三段:第一段用爬虫把BOSS直聘上数据分析师岗位的招聘信息批量抓下来,第二段做清洗和特征工程,第三段用回归模型预测薪资、用分类模型判断岗位所属层级。中间会踩不少坑,比如反爬策略、字段缺失、薪资区间怎么转成数值,这些后面都会细说。
2. 爬虫系统怎么搭:从请求构造到数据落库
2.1 目标字段拆解与请求链路设计
先想清楚要抓什么。BOSS直聘的岗位卡片上,核心字段包括:岗位名称、薪资区间、公司名称、公司规模、融资阶段、行业、城市、区域、经验要求、学历要求、技能标签、岗位描述全文。这些字段分布在列表页和详情页两个层级。列表页能拿到岗位名称、薪资、公司名、城市、经验学历等概要信息,详情页才有完整的岗位描述和技能标签。
请求链路的设计思路是:先请求列表页拿到岗位ID和概要字段,再根据岗位ID构造详情页请求,补充描述和标签。列表页的URL通常带有城市编码和页码参数,比如https://www.zhipin.com/c101010100/?query=数据分析师&page=1,其中c101010100是北京的城市编码。翻页时page参数递增,但要注意BOSS直聘的列表页有翻页深度限制,一般到第10页左右就会触发验证。
import requests import time import random from urllib.parse import urlencode CITY_CODES = { "北京": "101010100", "上海": "101020100", "广州": "101280100", "深圳": "101280600", "杭州": "101210100", } def build_list_url(city: str, keyword: str, page: int) -> str: """构造BOSS直聘列表页URL""" city_code = CITY_CODES.get(city, "101010100") params = { "query": keyword, "page": page, } return f"https://www.zhipin.com/c{city_code}/?{urlencode(params)}" def fetch_page(url: str, headers: dict, retry: int = 3) -> str: """带重试的页面请求""" for i in range(retry): try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: return resp.text elif resp.status_code == 403: print(f"触发反爬,第{i+1}次重试") time.sleep(random.uniform(5, 10)) except requests.RequestException as e: print(f"请求异常: {e}") time.sleep(random.uniform(3, 6)) return ""这段代码里,CITY_CODES字典把城市名映射到BOSS直聘内部的城市编码,这是构造URL的前提。build_list_url负责拼接列表页地址,fetch_page做了三件事:设置超时、检测403状态码、失败后随机等待重试。参数retry=3表示最多重试三次,每次等待时间在5到10秒之间随机浮动,避免固定间隔被识别为机器人。
请求头是另一个关键点。BOSS直聘会检查User-Agent、Referer、Cookie等字段。User-Agent建议用真实浏览器的,Referer设为https://www.zhipin.com/,Cookie需要从浏览器登录后手动复制。注意Cookie有有效期,一般几小时到一天不等,过期后需要重新获取。
2.2 列表页与详情页的解析逻辑
拿到HTML之后,解析工作分两步走。列表页用BeautifulSoup或lxml提取岗位卡片,每个卡片对应一个li元素,里面包含岗位名称、薪资、公司信息等。详情页则需要提取岗位描述全文和技能标签。
from bs4 import BeautifulSoup import re def parse_list_page(html: str) -> list: """解析列表页,返回岗位概要信息列表""" soup = BeautifulSoup(html, "lxml") jobs = [] for card in soup.select(".job-card-wrapper"): job = {} job["title"] = card.select_one(".job-name").get_text(strip=True) job["salary"] = card.select_one(".salary").get_text(strip=True) job["company"] = card.select_one(".company-name").get_text(strip=True) job["city"] = card.select_one(".job-area").get_text(strip=True) # 经验和学历要求通常在标签行 tags = card.select(".tag-list li") job["experience"] = tags[0].get_text(strip=True) if len(tags) > 0 else "" job["education"] = tags[1].get_text(strip=True) if len(tags) > 1 else "" # 岗位详情链接 link = card.select_one("a.job-card-left") job["detail_url"] = "https://www.zhipin.com" + link["href"] if link else "" jobs.append(job) return jobs def parse_detail_page(html: str) -> dict: """解析详情页,提取岗位描述和技能标签""" soup = BeautifulSoup(html, "lxml") detail = {} desc_div = soup.select_one(".job-sec-text") detail["description"] = desc_div.get_text(strip=True) if desc_div else "" # 技能标签 skill_tags = soup.select(".job-keyword-list li") detail["skills"] = [tag.get_text(strip=True) for tag in skill_tags] return detailparse_list_page遍历每个岗位卡片,提取标题、薪资、公司名、城市、经验学历和详情页链接。parse_detail_page则从详情页中提取岗位描述全文和技能标签列表。这里有个细节:BOSS直聘的页面结构会不定期调整,class名称可能变化,所以解析逻辑需要定期维护。建议把选择器集中写在一个配置字典里,方便统一修改。
薪资字段的格式是“15-25K·13薪”或“20-35K”这样的字符串,后续需要转成数值。经验要求可能是“3-5年”“5-10年”“经验不限”,学历要求可能是“本科”“硕士”“大专”。这些都需要在清洗阶段做标准化处理。
2.3 反爬应对与数据落库
BOSS直聘的反爬机制主要有三层:请求频率检测、Cookie有效性校验、行为特征分析。应对策略也分三层:控制请求频率、维护Cookie池、模拟真实浏览行为。
请求频率方面,建议每次请求间隔3到8秒随机,每抓取50条休息30秒。Cookie方面,可以准备多个账号的Cookie轮换使用,但注意不要用同一IP登录多个账号。行为特征方面,可以在请求之间随机插入一些对首页或搜索页的访问,模拟正常浏览路径。
import sqlite3 import json def init_db(db_path: str = "boss_jobs.db"): """初始化SQLite数据库""" conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, salary_raw TEXT, salary_min REAL, salary_max REAL, company TEXT, city TEXT, experience TEXT, education TEXT, skills TEXT, description TEXT, crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) conn.commit() return conn def save_jobs(conn, jobs: list): """批量写入岗位数据""" cursor = conn.cursor() for job in jobs: cursor.execute(""" INSERT INTO jobs (title, salary_raw, company, city, experience, education, skills, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( job.get("title", ""), job.get("salary", ""), job.get("company", ""), job.get("city", ""), job.get("experience", ""), job.get("education", ""), json.dumps(job.get("skills", []), ensure_ascii=False), job.get("description", ""), )) conn.commit()数据库用SQLite就够了,单机跑几千条数据完全没问题。表结构里salary_raw存原始字符串,salary_min和salary_max留空,等清洗阶段再填充。skills字段用JSON字符串存储列表,读取时用json.loads还原。crawl_time自动记录抓取时间,方便后续做增量更新。
注意:抓取频率不要太高,建议单IP每分钟不超过10次请求。如果遇到验证码,不要硬闯,停下来换Cookie或等一段时间再试。
3. 数据清洗与特征工程:把招聘帖变成模型能吃的表
3.1 薪资字段的解析与标准化
薪资字段是回归模型的目标变量,必须转成数值。BOSS直聘的薪资格式有几种常见形态:“15-25K”“20-35K·13薪”“8-12K”“面议”。前两种需要拆出最小值和最大值,第三种只有一个区间,第四种直接丢弃。
import re def parse_salary(salary_str: str) -> tuple: """解析薪资字符串,返回(最小值, 最大值),单位K""" if not salary_str or "面议" in salary_str: return None, None # 匹配 "15-25K" 或 "15-25K·13薪" match = re.search(r"(\d+)-(\d+)K", salary_str) if match: return float(match.group(1)), float(match.group(2)) # 匹配 "15K" 单值情况 match = re.search(r"(\d+)K", salary_str) if match: val = float(match.group(1)) return val, val return None, Noneparse_salary先用正则匹配区间格式,如果匹配到就返回两个数值;如果只匹配到单值,就把最小值和最大值设为相同;如果都匹配不到,返回None, None。后续在DataFrame里过滤掉这些空值行。注意“·13薪”不影响月薪区间,但可以在特征工程里加一个“是否13薪”的布尔特征,这可能是薪资水平的信号。
3.2 经验、学历与技能标签的编码
经验要求需要转成有序数值。常见的取值有“经验不限”“1年以内”“1-3年”“3-5年”“5-10年”“10年以上”。可以映射成0到5的整数,0表示不限,5表示10年以上。学历要求类似:“不限”“大专”“本科”“硕士”“博士”映射成0到4。
EXPERIENCE_MAP = { "经验不限": 0, "1年以内": 1, "1-3年": 2, "3-5年": 3, "5-10年": 4, "10年以上": 5, } EDUCATION_MAP = { "不限": 0, "大专": 1, "本科": 2, "硕士": 3, "博士": 4, } def encode_experience(exp_str: str) -> int: for key, val in EXPERIENCE_MAP.items(): if key in exp_str: return val return 0 def encode_education(edu_str: str) -> int: for key, val in EDUCATION_MAP.items(): if key in edu_str: return val return 0技能标签的处理稍微复杂。原始数据里每个岗位的技能是一个列表,比如["Python", "SQL", "机器学习", "数据分析"]。需要先做去重和归一化,比如“Python”和“python”统一成“Python”,“机器学习”和“ML”统一成“机器学习”。然后用MultiLabelBinarizer转成0/1矩阵,每个技能一列。
from sklearn.preprocessing import MultiLabelBinarizer def normalize_skills(skills_list: list) -> list: """技能标签归一化""" alias_map = { "python": "Python", "sql": "SQL", "excel": "Excel", "机器学习": "机器学习", "ml": "机器学习", "深度学习": "深度学习", "dl": "深度学习", "数据分析": "数据分析", "数据挖掘": "数据挖掘", "tableau": "Tableau", "power bi": "Power BI", "spss": "SPSS", "r语言": "R", "r": "R", } normalized = set() for skill in skills_list: key = skill.strip().lower() if key in alias_map: normalized.add(alias_map[key]) else: normalized.add(skill.strip()) return list(normalized) mlb = MultiLabelBinarizer() skills_matrix = mlb.fit_transform(df["skills_normalized"]) skills_df = pd.DataFrame(skills_matrix, columns=mlb.classes_)normalize_skills把常见别名统一,MultiLabelBinarizer把技能列表转成二进制矩阵。最终的特征表里,每个技能占一列,值为0或1。如果技能种类太多,可以只保留出现频率最高的前30个,其余归入“其他”。
3.3 城市与行业的特征处理
城市是分类变量,但不同城市之间有薪资水平差异,可以用目标编码(Target Encoding)或者直接做One-Hot。如果城市数量不多(比如只抓了5个城市),One-Hot就够了。如果抓了全国几十个城市,建议用目标编码,把城市映射成该城市的平均薪资。
def target_encode_city(df, city_col="city", target_col="salary_avg", min_samples=10): """城市目标编码,用城市平均薪资替代城市名""" city_stats = df.groupby(city_col)[target_col].agg(["mean", "count"]) city_stats = city_stats[city_stats["count"] >= min_samples] city_map = city_stats["mean"].to_dict() global_mean = df[target_col].mean() df["city_encoded"] = df[city_col].map(city_map).fillna(global_mean) return dftarget_encode_city先按城市分组算平均薪资和样本数,样本数少于min_samples的城市不单独编码,直接用全局平均薪资填充。这样做的好处是避免了高基数分类变量的维度爆炸,同时保留了城市间的薪资差异信息。
行业字段类似处理。BOSS直聘的行业分类有几十种,可以合并成几个大类:互联网、金融、教育、医疗、制造业、其他。合并规则可以用关键词匹配,比如包含“互联网”“电商”“社交”的归为互联网,包含“银行”“保险”“证券”的归为金融。
4. 机器学习预测模型:回归与分类两条线
4.1 薪资回归模型:XGBoost与特征重要性
薪资预测是一个回归问题,目标变量是salary_avg(薪资区间中值)。特征包括:经验编码、学历编码、城市编码、技能标签矩阵、公司规模、融资阶段、是否13薪。模型选XGBoost,因为它在表格数据上表现稳定,对缺失值和异常值有一定鲁棒性,而且能输出特征重要性。
import xgboost as xgb from sklearn.model_selection import train_test_split from sklearn.metrics import mean_absolute_error, r2_score # 准备特征矩阵 feature_cols = ["experience_encoded", "education_encoded", "city_encoded", "is_13th_salary"] + list(mlb.classes_) X = df[feature_cols] y = df["salary_avg"] # 划分训练集和测试集 X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) # 训练XGBoost回归模型 model = xgb.XGBRegressor( n_estimators=300, max_depth=6, 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) mae = mean_absolute_error(y_test, y_pred) r2 = r2_score(y_test, y_pred) print(f"MAE: {mae:.2f}K, R2: {r2:.3f}")参数说明:n_estimators=300是树的数量,太少欠拟合,太多容易过拟合;max_depth=6控制每棵树的深度,6层在几千条数据上比较合适;learning_rate=0.05是学习率,配合300棵树收敛比较平稳;subsample=0.8和colsample_bytree=0.8分别对样本和特征做随机采样,增加模型泛化能力。评估指标用MAE和R2,MAE表示预测薪资平均偏差多少K,R2表示模型解释了目标变量多少方差。
特征重要性可以直接从模型里拿:
importance = model.feature_importances_ feat_imp = pd.Series(importance, index=feature_cols).sort_values(ascending=False) print(feat_imp.head(15))通常经验编码、城市编码、Python和机器学习技能会排在前列。如果发现某个技能的重要性异常高,可以单独看一下这个技能对应的薪资分布,验证是否符合直觉。
4.2 岗位层级分类:用逻辑回归做可解释性判断
除了预测薪资,还可以做一个分类任务:判断岗位是“初级”“中级”还是“高级”。层级标签可以从薪资区间和岗位描述里推断,比如薪资中值低于15K且经验要求3年以下的标为初级,15K到30K且经验3-5年的标为中级,30K以上或经验5年以上的标为高级。
from sklearn.linear_model import LogisticRegression from sklearn.metrics import classification_report def label_level(row): """根据薪资和经验打层级标签""" if row["salary_avg"] < 15 and row["experience_encoded"] <= 2: return 0 # 初级 elif row["salary_avg"] < 30 and row["experience_encoded"] <= 4: return 1 # 中级 else: return 2 # 高级 df["level"] = df.apply(label_level, axis=1) X_cls = df[feature_cols] y_cls = df["level"] X_train_c, X_test_c, y_train_c, y_test_c = train_test_split(X_cls, y_cls, test_size=0.2, random_state=42) clf = LogisticRegression(max_iter=1000, multi_class="multinomial") clf.fit(X_train_c, y_train_c) y_pred_c = clf.predict(X_test_c) print(classification_report(y_test_c, y_pred_c, target_names=["初级", "中级", "高级"]))逻辑回归的好处是系数可以直接解释。比如“机器学习”技能的系数为正且绝对值大,说明会机器学习的岗位更可能被归为高级。max_iter=1000确保优化算法收敛,multi_class="multinomial"处理多分类。分类报告会输出每个类别的精确率、召回率和F1值,如果某个类别F1偏低,可能是标签定义有问题,需要调整阈值。
4.3 模型验证与交叉验证的坑
单次train_test_split的结果波动可能很大,尤其是数据量只有几千条的时候。更可靠的做法是K折交叉验证。
from sklearn.model_selection import cross_val_score cv_scores = cross_val_score(model, X, y, cv=5, scoring="neg_mean_absolute_error") print(f"CV MAE: {-cv_scores.mean():.2f}K (+/- {cv_scores.std():.2f})")cv=5表示5折交叉验证,scoring="neg_mean_absolute_error"返回负MAE,取负号后得到正常MAE。标准差反映模型在不同数据子集上的稳定性,如果标准差超过2K,说明模型对数据划分敏感,可能需要增加数据量或简化模型。
注意:交叉验证时不要对全量数据做目标编码,否则会引入数据泄露。正确做法是在每折训练集上计算编码映射,再应用到验证集。
5. 避坑与排查:爬虫和模型里最容易翻车的地方
5.1 爬虫被403或验证码拦截
现象:请求返回403状态码,或者页面跳转到验证码页面。原因:请求频率过高、Cookie过期、User-Agent被识别。解决:降低请求频率到每分钟5次以下,更换Cookie,检查User-Agent是否包含“python”等关键词。如果验证码出现,不要尝试自动识别,直接暂停抓取,等几小时后再试。
5.2 薪资解析出现大量空值
现象:parse_salary返回None, None的比例超过20%。原因:薪资格式比预想的复杂,比如“15-25K·14薪”“200-300元/天”“底薪8K+提成”。解决:先统计所有薪资字符串的格式分布,针对每种格式写解析规则。对于“元/天”的格式,可以按每月22天折算成月薪。对于“底薪+提成”的格式,只取底薪部分。
5.3 技能标签矩阵过于稀疏
现象:MultiLabelBinarizer生成的矩阵有几百列,大部分列只有个位数样本。原因:技能标签没有做归一化,同一个技能有多种写法。解决:先做词频统计,把出现次数少于10次的技能合并成“其他”,或者只保留前30个高频技能。归一化时注意大小写和空格,比如“Power BI”和“PowerBI”要统一。
5.4 模型在测试集上R2为负
现象:XGBoost回归的R2小于0,说明模型预测还不如直接用均值。原因:特征里混入了噪声,或者目标变量分布极端。解决:检查特征里是否有ID类字段被误加入,检查薪资是否有异常值(比如误解析出1000K)。对薪资做截断,把超过100K的按100K处理。另外,如果数据量少于500条,XGBoost容易过拟合,可以改用线性回归或决策树。
5.5 交叉验证结果波动大
现象:5折交叉验证的MAE标准差超过3K。原因:数据里某些类别的样本太少,折与折之间分布差异大。解决:改用分层交叉验证,按岗位层级或城市分层,确保每折的类别分布一致。如果数据量实在太小,考虑合并类别或收集更多数据。
6. 进阶技巧:用Prophet看薪资时间趋势与模型部署
前面做的都是横截面分析,如果把抓取时间拉长,比如每周抓一次,连续抓三个月,就能做时间序列分析。Prophet是Facebook开源的时序预测工具,适合有季节性和趋势变化的数据。用它可以看数据分析师岗位的平均薪资是否在涨、哪些技能的需求在上升。
from prophet import Prophet import pandas as pd # 假设df_time有ds和y两列,ds是日期,y是当天抓取的平均薪资 df_time = df.groupby("crawl_date")["salary_avg"].mean().reset_index() df_time.columns = ["ds", "y"] model_prophet = Prophet( yearly_seasonality=False, weekly_seasonality=True, daily_seasonality=False, changepoint_prior_scale=0.05, ) model_prophet.fit(df_time) future = model_prophet.make_future_dataframe(periods=30) forecast = model_prophet.predict(future) fig = model_prophet.plot(forecast)changepoint_prior_scale=0.05控制趋势变化的灵活度,值越大越容易捕捉突变,但也容易过拟合。weekly_seasonality=True捕捉一周内的波动,因为招聘帖的发布量在工作日和周末差异明显。预测结果可以帮你在找工作前判断薪资走势,如果趋势向上,可以适当提高期望薪资。
模型部署方面,最简单的做法是用Flask写一个API,接收岗位特征,返回预测薪资。
from flask import Flask, request, jsonify import pickle app = Flask(__name__) model = pickle.load(open("xgb_salary_model.pkl", "rb")) @app.route("/predict", methods=["POST"]) def predict(): data = request.get_json() features = [[ data["experience_encoded"], data["education_encoded"], data["city_encoded"], data["is_13th_salary"], ] + data["skills_vector"]] pred = model.predict(features)[0] return jsonify({"predicted_salary": round(float(pred), 2)}) if __name__ == "__main__": app.run(port=5000)部署时注意特征顺序必须和训练时一致,skills_vector的长度要和mlb.classes_一致。如果某个技能在训练集里没有,对应位置填0。这个API可以集成到自己的求职工具里,输入岗位要求就能看到预估薪资。
我自己做这个项目最大的教训是:爬虫部分花的时间远超预期,反爬策略三天两头变,后来干脆把抓取频率降到很低,用时间换稳定性。模型部分反而比较顺,因为XGBoost对特征工程的要求没那么苛刻,只要薪资解析对了,R2一般能到0.7以上。如果你也想做类似的项目,建议先把爬虫跑通,数据量到1000条以上再开始建模,否则模型学不到什么规律。希望帮到你。
本文还有配套的精品资源,点击获取