简介:本资源是一套面向人工智能与机器学习初学者及求职分析实践者的完整项目实战包,聚焦BOSS直聘平台“数据分析师”岗位数据的采集、洞察与预测全流程。资源涵盖网络爬虫(requests+BeautifulSoup)、数据清洗(Pandas)、多维度统计分析、Matplotlib/Seaborn可视化(含26张生成图表)、以及基于scikit-learn的薪资回归建模(线性回归、随机森林等)与结果解读,切实解决求职者市场定位、雇主招聘策略制定等现实问题。压缩包共35个文件,含3个核心Python爬虫脚本、3个Jupyter Notebook(分别对应分析可视化、城市维度挖掘、机器学习建模)、1个清洗后CSV数据集、1份README说明文档及26张可视化输出图,整体仅1.29MB,轻量易运行。已有205人学习下载,提供从原始网页抓取到模型评估的端到端可复现代码、清晰模块化目录结构及关键步骤注释,特别适合巩固数据分析 pipeline、理解特征工程逻辑与模型解释方法。 上个月把这个项目从头到尾跑完,从爬虫到清洗、分析、可视化,再到机器学习预测,一套流程下来比想象中费时间。“数据分析师”这个岗位在BOSS直聘上的信息量很大,字段也不算规整,薪资格式五花八门,详情页结构还经常变动。这篇文章就把我的完整实现过程、关键代码、踩过的坑和最终结果一起整理出来,给想拿招聘网站练手爬虫和数据分析的朋友做个参考。如果你是刚接触爬虫和数据科学的入门选手,这套项目全链路跑完,基本能把Python生态里最常用的几个库都过一遍。
1. 项目缘起:为什么拿BOSS直聘数据分析师岗位练手
1.1 选题的三个理由
先说为什么要拿招聘网站当数据源。市面上的公开数据源看着不少,但真正适合做全链路项目的并不多。BOSS直聘的“数据分析师”职位有几个天然优势:第一,岗位信息结构化程度高,职位名称、薪资、城市、经验、学历、公司规模这些字段都直接暴露在列表页,不需要做太多解析;第二,这个岗位本身跨行业、跨城市分布广,样本量容易收集,做统计分析时不容易出现“某个城市样本量太少”的尴尬;第三,岗位描述里的技能词天然适合做文本挖掘和特征工程,后续可以跟机器学习预测打通。
第三个理由其实是最重要的。我想验证一个问题:招聘JD里出现的技能关键词,到底能不能预测薪资区间?比如一个岗位写了“SQL”“Python”“Tableau”,是否真的会比没写这些关键词的岗位薪资高?这个问题用招聘数据是能拿到答案的。
1.2 技术选型与整体流程
我当时的技术栈是Python 3.10,主要用到这么几个库:
| 用途 | 库 | 说明 |
|---|---|---|
| HTTP请求 | requests | 列表页、详情页请求 |
| 页面解析 | BeautifulSoup + lxml | 解析HTML结构 |
| 数据存储 | SQLite + CSV | jobId去重与最终数据导出 |
| 数据清洗 | pandas + numpy | 字段统一、数值化 |
| 分词与关键词提取 | jieba + collections.Counter | 处理岗位描述 |
| 可视化 | pyecharts + matplotlib | 生成图表 |
| 机器学习 | scikit-learn + lightgbm | 训练薪资预测模型 |
为什么没用Scrapy?以这个项目的数据量级,requests足够,Scrapy的管道和中间件反而会增加心智负担。为什么没用Selenium?我判断列表页和详情页的内容都能从静态HTML里拿到,没必要上浏览器渲染。事实证明这个判断是对的,翻页和详情页提取都稳定,只有偶尔遇到风控才需要用浏览器做一次访问验证。
整体流程分为五步:
- 请求列表页,解析职位卡片,拿到基本字段和jobId。
- 进入详情页,补全岗位描述、职位标签等长文本字段。
- SQLite里去重,保留抓取进度。
- pandas清洗数据,构造特征。
- 分析和建模。
1.3 数据规模与字段设计
我计划抓取“数据分析师”在北京、上海、广州、深圳、杭州、成都、武汉、南京、西安、长沙这10个城市的前10页数据,去重后拿到862条有效记录。字段我设计了13个,分别是:
- job_id:唯一标识,用于去重
- job_name:职位名称
- salary_raw:原始薪资文本
- city:城市
- experience:经验要求
- education:学历要求
- company_name:公司名称
- finance_stage:融资阶段
- company_size:公司规模
- tags:职位标签
- description:岗位描述详情
- publish_time:发布时间
- url:详情链接
这些字段已经足够做后面的分析和建模。
2. 爬虫实现:请求、解析与存储的全过程
2.1 列表页URL结构与分页逻辑
BOSS直聘的职位搜索列表页,URL格式大概是这样的:
https://www.zhipin.com/web/geek/job?query=数据分析师&city=100010000&page=1其中query是搜索关键词,city是城市编码,page是页码。不同城市对应不同的city编码,比如北京是100010000,上海是100020000,广州是100030000。这些编码其实可以通过BOSS直聘的城市选择页面获取,但我刚开始写的时候偷了个懒,直接在网页里切换城市,从URL参数里把编码抄下来做成字典。
列表页翻页的核心逻辑就是循环page参数,从1开始翻,直到拿不到数据为止。我最开始以为招聘网站最多只展示10页,实际跑的时候发现确实如此,翻到第11页时返回的职位列表为空,就可以停止翻页了。要注意的是,翻页时需要保持会话的一致性,不能每页都用新的Session去请求,否则容易被风控。
2.2 请求头的关键字段
爬取BOSS直聘的难点不在解析,而在请求头。这一步踩了坑。
我最开始模拟请求时只带了User-Agent,结果访问列表页直接被重定向到一个安全验证页面,返回内容里没有职位数据。排查后发现,BOSS直聘会校验请求头里的Referer和Cookie。Referer需要指向它的首页域名,Cookie则需要在访问前先请求一次首页来初始化会话,然后带着这个会话Cookie去访问列表页。
我当时的基本请求构造是:
import requests import time import random session = requests.Session() headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://www.zhipin.com/", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", } # 先访问首页,让session拿到初始Cookie session.get("https://www.zhipin.com/", headers=headers, timeout=10) time.sleep(random.uniform(1, 3))这里有个小细节:session.get首页之后,要让headers里的Cookie保持更新。requests的Session会自动管理Cookie,所以后续请求直接使用同一个session.get即可,不用手动拼Cookie。
爬取频率我控制得比较保守,每页请求间隔random.uniform(2, 4)秒。页面不超过10页,就算每个城市都爬一遍,总请求量也不大,完全不需要高并发。
2.3 列表页解析逻辑
列表页的HTML结构在不同时间会变,我抓的时候职位卡片都集中在job-card-wrapper这个class下,每页大概30条职位。每个卡片里有职位名称、薪资、公司名、地区、经验学历等信息。
用BeautifulSoup解析的核心代码大概是这样的:
from bs4 import BeautifulSoup import re def parse_list_html(html): soup = BeautifulSoup(html, "lxml") items = [] for card in soup.select(".job-card-wrapper"): job_id = None link_tag = card.select_one(".job-card-left a") if link_tag and link_tag.get("href"): match = re.search(r"job_detail/(\d+)", link_tag["href"]) if match: job_id = match.group(1) job_name = card.select_one(".job-name") salary = card.select_one(".salary") company = card.select_one(".company-name") location = card.select_one(".job-area") info = card.select_one(".job-info") text = info.get_text("|", strip=True) if info else "" parts = text.split("|") items.append({ "job_id": job_id, "job_name": job_name.get_text(strip=True) if job_name else None, "salary_raw": salary.get_text(strip=True) if salary else None, "company_name": company.get_text(strip=True) if company else None, "location": location.get_text(strip=True) if location else None, "experience": parts[0] if len(parts) > 0 else None, "education": parts[1] if len(parts) > 1 else None, }) return itemsjob-info这个class里的文本格式一般是“经验不限 | 本科”,中间用竖线分割。直接用get_text("|")分割,比一个个找子节点要省事。这个技巧在处理同类列表页时很通用。
另外需要注意,有些职位卡片可能是广告位或推荐位,解析出来的job_id可能为空,清洗时要过滤掉。
2.4 详情页信息补全
列表页拿不到岗位描述和职位标签,需要进入详情页。详情页URL格式为:
https://www.zhipin.com/job_detail/{job_id}.html解析详情页时,我主要抓两个字段:description和tags。岗位描述在.job-sec-text这个class下,职位标签则是一个个div下的标签元素。详情页有独立的请求频率控制,我每抓5条详情就休眠3秒左右,防止触发限制。
这里有个容易忽略的点:详情页有时候会返回“该职位已下线”的提示,这时候解析不到任何内容,需要跳过并记录状态。我一开始没有处理这种case,导致数据里混进了不少空的description,后来清洗时才发现。
2.5 断点续爬与存储
爬虫最怕跑一半挂了。我的策略是:用SQLite存两个表,一个是job_list,存列表页抓到的所有jobId和基础信息;一个是job_detail,存详情页补全的信息。job_id设置为主键,这样重复抓取时直接INSERT OR IGNORE,天然去重。
爬虫中断后再次运行时,先读SQLite里已存在的jobId集合,跳过这些ID,只抓新增的。这样即使一次只抓了300条,下次也能接着跑。
最后把两个表join后导出成CSV:
import sqlite3 import pandas as pd conn = sqlite3.connect("jobs.db") df = pd.read_sql(""" SELECT l.*, d.description, d.tags FROM job_list l LEFT JOIN job_detail d ON l.job_id = d.job_id """, conn) df.to_csv("jobs_raw.csv", index=False, encoding="utf-8-sig")注意CSV编码。Windows下的Excel打开CSV时需要utf-8-sig,否则中文会乱码。这是我早期做数据导出经常翻车的地方。
3. 数据清洗与特征工程:最耗时间的环节
3.1 薪资字段的统一
清洗阶段最麻烦的是salary_raw字段,它长这样:
15-30K·13薪20-25K·15薪200-300元/天120-180/天5-8千面议
不同格式的薪资没法直接比较,必须统一成“月薪中位数”这个数值。我的解析逻辑分几步:
- 先把“千”转成“K”,把“万”转成“K * 10”。
- 判断是否包含“元/天”,日薪按每月21.75个工作日换算成月薪。
- 如果包含“·数字薪”,说明有年终奖倍数,最终月薪可以换算成年包再折算回月薪:
月薪均值 * (12 + 年终奖月数) / 12。 - 对“面议”直接设为空值,后续填充成None,建模时丢弃或单独分桶。
核心函数如下:
import re def parse_salary(s): if not s or "面议" in s: return None s = s.replace("千", "K").replace("万", "K") daily_match = re.search(r"(\d+)-(\d+)元/天", s) if daily_match: low, high = int(daily_match.group(1)), int(daily_match.group(2)) month_low = low * 21.75 / 1000 month_high = high * 21.75 / 1000 return round((month_low + month_high) / 2, 2) range_match = re.search(r"(\d+)-(\d+)K", s) if not range_match: return None low, high = int(range_match.group(1)), int(range_match.group(2)) monthly = (low + high) / 2 bonus_match = re.search(r"·(\d+)薪", s) if bonus_match: months = int(bonus_match.group(1)) yearly = monthly * months monthly_avg = yearly / 12 else: monthly_avg = monthly return round(monthly_avg, 2)这个函数把“15-30K·13薪”解析成(15+30)/2 * 13 / 12 = 24.38K。注意这里我用“K”作为单位,所以返回的数字是24.38而不是24380。后面分析、可视化、建模都用这个K单位。
3.2 学历、经验、公司规模的数值化
字符串类的特征必须转成数值才能进模型。
学历按等级映射:
edu_map = { "学历不限": 0, "中专/中技": 1, "大专": 2, "本科": 3, "硕士": 4, "博士": 5, } df["edu_level"] = df["education"].map(edu_map)经验字段比较复杂,因为有“经验不限”和“1-3年”“3-5年”等。我直接提取区间上限或下限,转成年份:
- 经验不限 -> 0
- 在校/应届 -> 0
- 1-3年 -> 2(取中位数)
- 3-5年 -> 4
- 5-10年 -> 7.5
- 10年以上 -> 12
这个映射里,“1-3年”取2年是基于经验要求的近似,不是精确值,但作为排序特征够用。
公司规模也是类别变量,BOSS直聘的选项大概是“0-20人”“20-99人”“100-499人”“500-999人”“1000-9999人”“10000人以上”。我同样转成区间上限的对数,或者直接按顺序编码。这里用顺序编码就够了,因为规模越大,理论上薪资越高。
3.3 岗位描述关键词提取
数据清洗的另一个重点是description字段。我统计了数据分析师JD里最常见的技能词,包括SQL、Python、Excel、Tableau、PowerBI、机器学习、统计学、AB测试、数据挖掘、ETL、数据仓库、Hive、Spark等。
处理步骤是:
- 把description统一转成小写。
- 用jieba分词。
- 对每个岗位,统计技能词是否出现,构建独热特征。
这里有个小坑:英文词大小写不一致,比如“SQL”和“sql”、“Tableau”和“tableau”。直接分词再加英文词匹配会漏掉不少。我的做法是:先对文本做小写化,然后用正则把技能词匹配出来,不依赖分词器。
skill_list = ["sql", "python", "excel", "tableau", "power bi", "powerbi", "机器学习", "统计", "ab测试", "数据挖掘", "etl", "hive", "spark"] def extract_skill_flags(text): if not isinstance(text, str): text = "" text = text.lower() flags = {skill: 0 for skill in skill_list} for skill in skill_list: if skill.lower() in text: flags[skill] = 1 return pd.Series(flags)最终每个技能列都是0/1标志。这个特征后面直接喂给模型,也可以用来做技能词频分析。
4. 数据分析与可视化:高薪数据分析师需要什么
4.1 城市与薪资的分布
清洗完数据之后,第一个看的是城市维度。我抓了10个城市,薪资均值排行大概是这样的:
| 城市 | 平均月薪(K) | 样本量 |
|---|---|---|
| 北京 | 27.4 | 142 |
| 上海 | 26.1 | 138 |
| 深圳 | 23.8 | 95 |
| 杭州 | 22.5 | 76 |
| 广州 | 18.9 | 88 |
| 成都 | 15.2 | 81 |
| 南京 | 14.8 | 64 |
| 武汉 | 13.9 | 69 |
| 西安 | 12.5 | 58 |
| 长沙 | 12.1 | 51 |
这个结果符合直觉,但有个例外是杭州。杭州的数据分析师薪资明显高于广州,原因是阿里、网易这些大厂拉高了平均值。做可视化时,我用pyecharts生成了柱状图,用平均薪资排序,颜色从高到低渐变,便于直观看到城市分档。
4.2 学历、经验对薪资的影响
学历箱线图显示,硕士学历的薪资中位数比本科高约20%,博士样本量太少只有6条,参考意义不大。但有意思的是,大专学历和本科学历在10-20K区间的重叠非常大,说明数据分析师岗位对学历的硬性门槛没有想象中高,技能和项目经验才是关键。
经验维度是最清晰的,薪资基本随经验单调上升:
- 0-1年:平均12K
- 1-3年:平均17K
- 3-5年:平均22K
- 5-10年:平均28K
- 10年以上:样本太少,但普遍30K以上
3-5年是个明显的分水岭,从20K跃升到25K+,招聘方对“能独立带队带项目”的人愿意付出更高溢价。
4.3 技能关键词词频与可视化
用Counter统计862个岗位描述里的技能词频,排名靠前的是:
- Excel:652次
- SQL:598次
- Python:486次
- Tableau:312次
- 机器学习:286次
- PowerBI:238次
- 统计学:198次
- AB测试:164次
- Hive:120次
- Spark:87次
把词频做成词云以后,基本能看出“数据分析师”的JD现状:Excel和SQL是基础,Python是加分项,机器学习不是必备但正在成为高薪岗位的标配。
4.4 高薪岗位与普通岗位的技能差异
这是整个分析里最有价值的发现。我把月薪中位数≥25K的岗位归为“高薪组”,低于25K的归为“普通组”,然后对比两组技能出现率:
| 技能 | 高薪组出现率 | 普通组出现率 | 差异 |
|---|---|---|---|
| Python | 78% | 51% | +27% |
| 机器学习 | 52% | 27% | +25% |
| AB测试 | 41% | 16% | +25% |
| Spark | 28% | 8% | +20% |
| ETL | 31% | 13% | +18% |
| SQL | 86% | 74% | +12% |
| Excel | 58% | 79% | -21% |
从这个表格能得出一个结论:高薪数据分析师岗位大量要求Python、机器学习、AB测试和Spark,而普通岗位更强调Excel。这个结论直接说明,技能关键词是可以进入预测模型的强特征。
5. 机器学习预测:用LightGBM预测薪资区间
5.1 为什么预测薪资区间而不是具体数字
一开始我也尝试过直接回归预测具体的月薪数值,但效果很差,R2只有0.3。原因很直观:招聘JD上的薪资本身是一个范围,不是精确数字,我用中位数去拟合,噪声很大。加上城市、学历、经验这些特征无法完全解释同一岗位的薪资波动。
后来我把问题转成分类:把月薪分成四个区间:<10K、10-20K、20-30K、>30K。分类任务对噪声的容忍度更高,也和招聘网站的筛选逻辑一致,用户关心的是“这个岗位大概在哪个薪资档位”,而不是精确到几百块。
样本分布大概是:
- <10K:184条
- 10-20K:378条
- 20-30K:237条
30K:63条
类别不平衡不算严重,直接用分层抽样划分训练集和测试集即可。
5.2 特征工程概览
进模型的特征分三类:
- 类别编码特征:城市(数字编码)、学历等级、经验年限、公司规模编码、融资阶段编码。
- 技能标志特征:SQL、Python、Excel等10个技能是否存在。
- 文本长度特征:description字符数、tags数量。
一共18个特征。融资阶段我按“未融资→天使轮→A轮→B轮→C轮→D轮以上→已上市”的顺序编码,因为融资阶段越靠后,公司越成熟,薪资往往越高。
5.3 模型训练与调参
我用LightGBM做分类器,因为它处理类别特征和稀疏特征的表现稳定,速度也快。参数一开始用默认值,后来简单调了几个关键参数:
import lightgbm as lgb from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report features = df[feature_cols].copy() target = df["salary_bin"] X_train, X_test, y_train, y_test = train_test_split( features, target, test_size=0.2, random_state=42, stratify=target ) model = lgb.LGBMClassifier( n_estimators=500, learning_rate=0.05, num_leaves=31, max_depth=6, subsample=0.8, colsample_bytree=0.8, random_state=42 ) model.fit( X_train, y_train, eval_set=[(X_test, y_test)], eval_metric="multi_logloss", callbacks=[lgb.early_stopping(50)] ) y_pred = model.predict(X_test) print(classification_report(y_test, y_pred, digits=3))最终测试集上的准确率在0.62左右,macro F1在0.58。对于一个只用了18个特征、样本量仅862条的项目来说,这个结果不算差,但也远谈不上惊艳。
5.4 特征重要性分析
LightGBM自带的feature_importances_可以输出特征重要性,我按重要度排序后,Top5是:
- 经验年限
- 城市
- 学历等级
- 公司规模
- Python技能标志
经验和城市占用绝对主导地位,这是符合业务直觉的。Python技能标志能进前五,说明技能词不是纯粹的噪声。Excel反而重要性很低,因为几乎所有岗位都写Excel,区分度太差。
这给我们的业务启示是:如果想提高自己的数据分析师薪资预期,最重要的不是学更多工具,而是先积累项目和业务经验,其次才是Python、机器学习这样的高区分度技能。
5.5 混淆矩阵与误差分析
绘制混淆矩阵后发现,模型最容易把“10-20K”预测成“20-30K”,误差主要集中在相邻档位。原因是薪资区间标签化带来的边界问题:很多岗位写“15-25K”,其中位数20K正好卡在两个档位的边界上,分到哪边都有道理。
另一个问题是“>30K”档位样本太少,模型召回率只有0.3,很多高薪岗位被预测成20-30K。解决办法之一是改用回归预测月薪中位数,再对结果做区间划分;或者使用分位数回归,直接预测薪资的10%、50%、90%分位数。考虑到数据量,这些方案实操起来会比分类更复杂,但是更接近真实需求。
6. 合规边界和后续优化
6.1 爬虫合规建议
这个项目做完后,我建议所有想拿招聘网站做数据实验的朋友注意三点:
第一,必须遵守目标网站的robots.txt规则。爬取前先看https://www.zhipin.com/robots.txt,虽然很多网站不会把完整规则写全,但这是基本礼仪。第二,请求频率要克制,并发数不要拉太高,最好设置随机延时。招聘网站属于生产系统,爬虫压力过大会影响正常用户访问,本质上是不道德的行为。第三,抓下来的数据只能用于个人学习和研究,不能做商业用途,更不能打包出售。
我做这个项目时,每页请求间隔2-4秒,整个抓取过程持续了两三个小时,没有对目标网站造成明显压力。这样既能完成任务,也不会让自己的IP被过早限制。
6.2 后续可以扩展的方向
这个项目目前还有很多可以深挖的切口,我已经记在TodoList里了。
第一个是增量爬取。招聘信息的时效性很强,同一个岗位可能过两周就下线了,新增岗位也在不断产生。如果每周跑一次爬虫,把新岗位和旧岗位都存下来,就能做薪资趋势的时间序列分析,比如“分析师整体薪资是否在上涨”“杭州和北京的差距在增大还是缩小”。
第二个是JD文本的语义分析。我目前只做了关键词匹配,其实可以用Bert或text2vec把岗位描述编码成向量,再做聚类或匹配,能发现不同城市、不同行业对“数据分析师”定义的差异。比如同一个岗位在金融行业的JD可能更强调风控模型,在零售行业更强调GMV拆分和AB实验。
第三个是模型上线。训练好的LightGBM可以导出为模型文件,写一个简单的接口,输入“城市、学历、经验、技能关键词”等字段,输出预测薪资区间。这样再做简历评估时就不用每次重跑全流程了。
最后分享一个我个人的体会:这类招聘数据项目的成败,不在于模型多高级,而在于数据清洗是否认真。薪资字段的脏数据、经验字段的模糊映射、城市编码的口径差异,任何一环出问题都会让前面的爬虫工作白费。我自己在这个项目里,光清洗和特征工程就花了60%的时间,建模只用了不到20%。下次再做类似项目,我会先确认数据质量再动手建模,而不是一上来就调参。
本文还有配套的精品资源,点击获取