简介:这是一份面向数据分析初学者与爬虫爱好者的完整实战项目包,围绕智联招聘岗位信息采集与分析展开,覆盖网络爬虫、Pandas数据清洗、统计分析及PyEcharts可视化等关键环节,适合希望系统锻炼数据采集到洞察转化能力的读者。压缩包共25个文件,约7.3MB,包含Python爬虫脚本、Jupyter Notebook分析文档、清洗后的CSV数据、19个交互式HTML可视化图表及说明文档,目录结构清晰,便于对照学习。项目不仅演示了请求发送、HTML解析和反爬应对等爬虫技巧,还深入分析了不同城市、学历和工作经验下的薪资差异,并展示如何用地图、柱状图和散点图呈现结论。通过动手运行源码并查看图表,读者可直观理解从原始数据到业务洞察的完整流程。该资源已有1629人学习下载,难度适中,是一份兼具理论讲解与实操代码的入门进阶好材料。
1. 招聘数据爬下来之后,真正的成本在清洗和分析
拿到一份号称“爬取智联招聘数据进行分析”的压缩包,打开发现里面是bosszp.py和一堆html图表文件时,第一反应可能是货不对板。但跑完整个流程就会明白:招聘网站的反爬策略和页面结构差异没有想象中那么大,智联、Boss直聘、前程无忧的职位列表本质都是「请求—解析—存储」三段式。真正拉开差距的,是爬完之后那堆学历要求占比.html、各城市工资.html背后的数据加工链路。这个项目把爬虫、Pandas 清洗、PyEcharts 可视化串成一条完整流水线,适合刚学完 Python 基础、想看看真实数据处理长什么样的开发者,也适合准备做城市薪资分析的运营或 HR 数据岗。下文会按爬虫、清洗、分析、可视化的顺序拆开讲,重点说明每个环节的参数怎么定、坑在哪。
2. 爬虫端:用 requests + BeautifulSoup 拆解招聘页面的硬骨头
2.1 为什么不用 Scrapy 而选 requests 组合
项目里的bosszp.py是典型的轻量级爬虫写法:requests拿 HTML,BeautifulSoup解析。相比 Scrapy,这种组合的启动成本几乎为零,不需要建工程、写 pipeline、折腾中间件,适合一次性抓取几万条以内的数据。Boss直聘的职位列表是服务端渲染的,翻页参数直接挂在 URL 的query和page上,用 requests 就能拿到完整 HTML,比分析 XHR 接口省事。
但这也意味着要自己处理限速、重试和会话保持。代码里常见的一段初始化是这样的:
import requests from bs4 import BeautifulSoup import time session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://www.zhipin.com/" }) def fetch_job_list(keyword, city_code, page): url = f"https://www.zhipin.com/web/geek/job?query={keyword}&city={city_code}&page={page}" try: resp = session.get(url, timeout=10) resp.raise_for_status() return resp.text except requests.RequestException as e: print(f"第{page}页请求失败: {e}") return None这里Session的作用是复用底层 TCP 连接,同时自动携带第一次请求后服务器下发的 Cookie。headers里的User-Agent必须设置,否则八成概率拿到的是一段包含「验证码」的跳转页。Referer指向招聘站点首页,部分反爬规则会校验来源。timeout=10是必要参数,防止某个页面卡死导致整个脚本挂起。
2.2 解析职位卡片:从 HTML 到结构化字段
拿到页面后,需要定位职位卡片所在节点。Boss直聘的列表页每个职位是一个li.job-card-wrapper,职位名称、薪资、公司名、地点、学历经验分别在不同的span或li里。用 BeautifulSoup 的select方法按 CSS 类名提取,比逐层find更抗页面微调:
def parse_job_page(html): soup = BeautifulSoup(html, "html.parser") jobs = [] for card in soup.select("li.job-card-wrapper"): title = card.select_one(".job-name").text.strip() salary = card.select_one(".salary").text.strip() company = card.select_one(".company-name").text.strip() info = card.select_one(".job-info").text.strip() # 类似 "3-5年·本科·成都" jobs.append({ "title": title, "salary": salary, "company": company, "info": info }) return jobsselect_one只取第一个匹配元素,text会拼接所有子节点文本,对“职位名称”这种单层标签够用;但如果某个字段包含链接或图标,建议用get_text(separator=" ", strip=True)避免文字粘在一起。info字段里通常混着经验、学历、城市,后边在 Pandas 里再拆,爬虫层先整块保存。
需要注意两个高频异常:一是页面里出现“请完成安全验证”,此时返回的 HTML 里没有卡片节点,select结果为空;二是薪资显示为“面议”,salary字段非xxK-xxK格式。常见做法是在解析后加一个数判断,低于预期卡片数就直接退出重试,而不是继续翻页。
2.3 翻页与限速:控制节奏比写循环更重要
Boss直聘的翻页参数page从 1 开始递增,但网页端最多能看到第 10 页左右。强制翻到第 11 页大概率触发风控。项目里抓几千条数据的做法是控制请求间隔:
def crawl(keyword, city_code, max_page=10): all_jobs = [] for page in range(1, max_page + 1): html = fetch_job_list(keyword, city_code, page) if not html: continue jobs = parse_job_page(html) if not jobs: print(f"第{page}页无数据,可能被拦") break all_jobs.extend(jobs) time.sleep(random.uniform(2, 5)) # 随机延时 2~5 秒 return all_jobs这里的time.sleep(random.uniform(2, 5))是核心。固定间隔 3 秒反而容易被识别为脚本,随机区间模拟人工浏览节奏。如果同一关键词、多个城市并行爬取,建议在session外再加一个请求锁,或直接用单线程串行,避免单位时间内请求数过高。爬完记得把all_jobs保存成 CSV,方便后边Pandas读取。
3. 数据清洗:把“面议”“15-20K·14薪”变成可计算的数值
3.1 用 Pandas 拆解复合字段
爬下来的原始数据里,info字段类似"3-5年·本科·成都",salary类似"15-20K·14薪"。直接拿去做图表没有意义,必须拆成独立列。常规做法是用str.split("·")切开,再映射正则:
import pandas as pd import re df = pd.read_csv("job_data_raw.csv") def split_info(info): parts = info.split("·") parts = [p.strip() for p in parts] if len(parts) >= 3: return parts[0], parts[1], parts[2] return None, None, None df["experience"], df["education"], df["city"] = zip(*df["info"].apply(split_info)) def parse_salary(s): if s == "面议": return None, None match = re.search(r"(\d+)[Kk]-(\d+)[Kk]", s) if match: return float(match.group(1)), float(match.group(2)) match = re.search(r"(\d+)[Kk]以上", s) if match: return float(match.group(1)), None return None, None df["salary_low"], df["salary_high"] = zip(*df["salary"].apply(parse_salary)) df["salary_avg"] = df[["salary_low", "salary_high"]].mean(axis=1)zip(*df["info"].apply(...))把返回的元组列表拆成三列,避免用apply生成整行。salary_low和salary_high分别存区间上下限,salary_avg取均值。这里mean(axis=1)会自动忽略None,但如果你用的是旧版 Pandas,None会被当成NaN还是None需要确认,稳妥做法是先fillna(0)再算,否则某些统计函数会直接报错。
3.2 处理缺失值、去重和类型转换
从招聘网站拿到的数据经常有重复——同一职位被推荐流和搜索流各展示一次。去重不能只看标题,要按(title, company, city, salary_low, salary_high)五列联合判断:
df.drop_duplicates(subset=["title", "company", "city", "salary_low", "salary_high"], inplace=True) df.dropna(subset=["title", "company"], inplace=True) # 标题和公司不能为空 df["education"] = df["education"].fillna("学历不限") df["experience"] = df["experience"].fillna("经验不限")drop_duplicates不指定keep时默认保留第一条,这对后续统计影响很小。education和experience的缺失值填成“不限”比直接删行更合理,因为招聘平台确实存在大量未标注学历经验的岗位。之后把清洗结果写入新文件:
df.to_csv("job_data_clean_price.csv", index=False, encoding="utf-8-sig")encoding="utf-8-sig"很关键,直接写utf-8生成的 CSV 用 Excel 打开会乱码,utf-8-sig会带 BOM 头,兼容 Excel 和 Pandas 重读。
3.3 异常值过滤:让平均数不被超高薪拉偏
某些岗位写“50-80K·16薪”,可能是招合伙人或者挂着玩的。做城市薪资对比前,建议按分位数截断:
low_q = df["salary_avg"].quantile(0.02) # 2% 分位 high_q = df["salary_avg"].quantile(0.98) # 98% 分位 df_filtered = df[(df["salary_avg"] >= low_q) & (df["salary_avg"] <= high_q)]quantile(0.02)和(0.98)表示保留中间 96% 的数据,把极端低薪(实习生误标)和极端高薪(技术合伙人)排除。注意这里用的是salary_avg,如果单看salary_high,会把“20K-35K”里正常的高分位截掉。清洗后的df_filtered才是后续所有统计图表的输入。
4. 分析逻辑与 PyEcharts 可视化:从一堆行变成一张能讲出结论的图
4.1 分组统计:学历、经验、城市三个维度的标准聚合
项目里学历要求占比.html、各城市工资.html这一批图表,底层都是 Pandas 的groupby加agg。以“学历要求占比”为例:
edu_count = df_filtered.groupby("education").size().reset_index(name="count") edu_count["percent"] = (edu_count["count"] / edu_count["count"].sum() * 100).round(2)groupby("education").size()得到每个学历的职位数,reset_index(name="count")把结果转成 DataFrame。percent列保留两位小数。这里有个细节:size()和count()在无缺失值时结果相同,但size()会包含NaN分组,count()不会。我们在清洗阶段已经fillna过,所以用哪个都行。若没有填充,建议用count()避免统计出“学历不限”之外的未知组。
“各城市工资”需要按城市算平均薪资。因为同一职位有salary_low和salary_high两列,先算区间均值再分组:
city_salary = df_filtered.groupby("city")["salary_avg"].agg(["mean", "median", "count"]) city_salary = city_salary[city_salary["count"] >= 5].sort_values("mean", ascending=False).head(20)agg(["mean", "median", "count"])一次性拿到平均、中位数和样本量,比分三次groupby高效。过滤count >= 5是为了排除只出现一次的冷门城市,否则乌鲁木齐一条超高薪数据会排到榜首,图就没意义了。用median做佐证,能看出平均薪资是否被高端岗位拉高——如果mean明显大于median,说明这个城市存在头部高薪岗位拉高均值。
4.2 用 PyEcharts 生成可直接打开的 HTML 图表
项目里的输出全部是.html文件,说明用的是pyecharts的render()方法。PyEcharts 的特点是链式调用,配置项直观,生成的图表自带交互(悬浮提示、图例开关)。地图和柱状图代码如下:
from pyecharts.charts import Bar, Pie from pyecharts import options as opts bar = Bar() bar.add_xaxis(city_salary.index.tolist()) bar.add_yaxis( "平均薪资(K)", city_salary["mean"].round(1).tolist(), itemstyle_opts=opts.ItemStyleOpts(color="#2f7ed8") ) bar.set_global_opts( title_opts=opts.TitleOpts(title="主要城市平均薪资Top20"), xaxis_opts=opts.AxisOpts(axislabel_opts=opts.LabelOpts(rotate=45)), yaxis_opts=opts.AxisOpts(name="K / 月") ) bar.render("各城市工资.html")add_xaxis的参数必须是列表,不能直接传 Series;索引转list()是惯用写法。axislabel_opts=opts.LabelOpts(rotate=45)让城市名称倾斜 45 度,否则两三个城市名挤压在一起看不清。render()会生成一个独立的 HTML 文件,内部引用了 PyEcharts 的 CDN,所以打开时需要联网。如果需要离线查看,可以bar.render_embed()生成内嵌 JS 的 HTML,但文件体积会变大不少。
饼图用来画学历占比更直观:
pie = Pie() pie.add( "学历占比", [list(z) for z in zip(edu_count["education"], edu_count["percent"])], radius=["30%", "70%"], ) pie.set_global_opts(title_opts=opts.TitleOpts(title="数据相关岗位学历要求占比")) pie.render("学历要求占比.html")[list(z) for z in zip(...)]把两列数据拼成[["本科", 60.5], ["硕士", 30.2]...]的嵌套列表,这是 Pie 的data_pair标准格式。radius=["30%", "70%"]做环形效果,中间留白区域可以再塞一个总职位数。如果zip后直接传 tuple,PyEcharts 1.x 会报参数格式错误,写成list(z)更稳。
4.3 多图表组合:用Tab或Grid组织报告
项目里有一堆单独的 html,其实可以合并成一个带 Tab 切换的报告页:
from pyecharts.commons.utils import JsCode from pyecharts.charts import Tab tab = Tab() tab.add(bar, "城市薪资") tab.add(pie, "学历占比") tab.render("analysis_report.html")Tab的add参数第一个是图表实例,第二个是 Tab 标题。合并后所有图表共用同一个页面,切换时不需要打开多个文件。这里有一个注意点:Tab内部每个图表的render()会被自动调用,所以不要在前面手动调用bar.render(),否则会多生成一份无用的单图表 HTML。
5. 把项目打包成 zip 发布前要处理的四件事
5.1 路径与编码:换机器后最容易炸掉的环节
项目里.DS_Store说明压缩包是从 macOS 打包的,代码里如果用了绝对路径,换到 Windows 上必然报错。发布前应把所有读写路径改成相对于脚本目录的写法:
import os from pathlib import Path BASE_DIR = Path(__file__).parent RAW_CSV = BASE_DIR / "job_data_raw.csv" CLEAN_CSV = BASE_DIR / "job_data_clean_price.csv"写成Path(__file__).parent后,无论压缩包解压到哪个目录,都能正确定位文件。同时确认 CSV 输出用utf-8-sig,否则 Windows Excel 直接打开analysisResult.docx里的图表数据会乱码。analysisResult.docx如果是从analysis_data里的 html 截图插入的 Word 文档,建议把嵌入的图片导出发到根目录,避免解压后图片丢失。
5.2 代码注释与依赖声明:让接手的人能跑通
bosszp.py里import requests, bs4, pandas, pyecharts这些库,发布时一定要附requirements.txt,写清版本范围:
requests>=2.25.0 beautifulsoup4>=4.9.0 pandas>=1.3.0 pyecharts>=1.9.0不锁死版本是保险做法,因为pyecharts 2.x的配置项 API 和1.x差异较大,锁>=1.9,<2.0更稳妥。同时在被爬网站改了页面结构时,select_one的选择器会失效。建议在parse_job_page里加一个断言,卡片数量为 0 时主动抛出异常,而不是默默吐回空列表。
5.3 数据时效与合规边界
招聘数据是强时效性的,两个月前的薪资统计可能已经偏离市场。所以在 zip 的README.md里建议标注抓取日期和关键词列表。另外,虽然职位信息属于公开数据,但仍应遵守网站服务条款,不对页面做超高频率抓取,也不把清洗后的数据直接打包二次售卖。个人学习与商业使用对数据量的容忍度不同,限制爬取频率和页面数量既是技术问题也是合规问题。
5.4 从单机脚本到可复用工具的最小改动
如果想把bosszp.py扩展成多城市、多关键词的批量分析,不需要重写。把搜索关键词和城市码抽成循环,爬完一个城市就写一个 CSV,最后用pd.concat合并。延时代可以改为:每个城市第 1 页延时 1~2 秒,第 2 页起递增到 5~8 秒,模拟用户逐页浏览的耐心。这样既不影响单城数据量,又能降低触发风控的概率。整个项目的核心价值不在爬虫本身,而是你拿到杂乱的 HTML 后,能否把“岗位”“薪资”“学历”这些非结构化文本变成结构化、可对比、可取舍的数据结论——这套流程放诸任何招聘平台都成立。
本文还有配套的精品资源,点击获取