简介:这是一个面向爬虫与数据分析入门学习者的综合性实战项目,以北京市大数据岗位招聘数据为具体对象,串联起从网络数据采集、网页解析、数据清洗、预处理、统计聚合到可视化图表展示的完整分析链路。项目中的爬虫部分演示了如何请求网页并处理动态内容,数据分析部分从平均薪资、学历要求、经验年限、职位供需关系等维度展开统计与解读,前端部分则通过可视化页面让数据结论更直观。资源包共111个文件,核心包括4个Python源程序、原始数据表格文件、数据库文件,以及20余个网页版可视化页面和统计图表图片,并配有页面样式与交互脚本等前端资源,整体大小仅6.58兆,目录结构适合按阶段参考学习。此项目已有726人学习下载,对于希望构建数据分析项目经验、完成课程作业或毕业设计的读者,是一份从代码到数据再到展示形态都很完整的参考资源。
1. 北京大数据岗位招聘数据:为什么值得用Python爬一遍
也许你在Boss直聘上搜“大数据”岗位时,会看到一个几十页的列表,每页都有不同的薪资、经验、技能要求。想弄清“北京哪个区职位最多”“3年经验能拿多少钱”这类问题,手动翻页根本做不到。这个项目把整条链路打通:用Scrapy爬取Boss直聘北京站的大数据相关职位,requests和BeautifulSoup做辅助解析,pandas清洗出结构化表,再用matplotlib与seaborn把结果画成图,最终打包成一套可部署的HTML可视化页面。
无论你是正在准备大数据方向毕业设计的学生,还是想跳槽前摸清市场行情的开发者,都能直接复用这份代码。它给出的不是一张静态报表,而是一份随时可以重跑、调整口径的岗位数据分析工具。整个项目涉及爬虫、数据清洗、特征提取、可视化和数据库存储,正是目前大数据岗位最常要求的技能组合。
2. Scrapy + requests 双通道采集:从HTML到结构化的第一公里
招聘网站不像开放API一样提供干净数据,爬虫的职责是把网页变成结构化字段。这个项目里其实有两条取数路径:一条是Scrapy框架,适合跑批量任务;另一条是requests+BeautifulSoup,用于快速分析单个页面的DOM结构。我的建议是主爬虫走Scrapy,因为它的调度器自带URL去重和并发控制,而requests适合在调试阶段用。
2.1 为什么用Scrapy而不是自己写requests循环
自己写requests循环并不难,但要处理的问题很多:重复请求怎么去重,请求失败重试几次,并发量怎么控制,日志怎么记录。Scrapy把这些基础设施都做好了。它的Request对象会进入调度器,相同指纹的URL默认丢弃;DownloaderMiddleware统一给每个请求附加UA和Cookie;Item Pipeline把解析结果按顺序清洗、去重、入库。对于Boss直聘这种有反爬策略的站点,Scrapy的RetryMiddleware还能在遇到503或验证码时自动延迟重试,省去自己写异常处理的代码。
从实际效果看,用Scrapy写一个Spider,代码量大约是自己写requests循环的一半。而且项目里需要抓多个搜索词(“数据挖掘”“数据分析”“大数据开发”),Scrapy可以通过start_urls或start_requests轻松生成多任务。这里有一个最小Spider示例:
# spider.py import scrapy from jobs.items import JobItem class JobSpider(scrapy.Spider): name = 'jobs' start_urls = [ 'https://www.zhipin.com/web/geek/job?query=大数据&city=101010100', 'https://www.zhipin.com/web/geek/job?query=数据挖掘&city=101010100', ] def parse(self, response): for card in response.css('.job-card-wrapper'): item = JobItem() item['job_title'] = card.css('.job-name::text').get() item['salary'] = card.css('.salary::text').get() item['company'] = card.css('.company-name::text').get() item['info'] = card.css('.job-info span::text').getall() yield item说明:job-card-wrapper是职位卡片容器的class,不同时期Boss直聘的页面结构会变,需要先用浏览器开发者工具确认。info字段用getall()返回的是学历、经验、标签的列表,后面在Pipeline里用','.join()合并。如果想单独调试某个页面,可以用requests获取HTML后用BeautifulSoup跑选择器,调试通过再写回Scrapy。
| 字段 | CSS选择器 | 处理方式 |
|---|---|---|
| job_title | .job-name::text | 直接提取文本 |
| salary | .salary::text | 原始字符串,后处理 |
| company | .company-name::text | 去空格 |
| experience | .job-info span::text | getall()后拼接 |
| district | .job-area::text | 可能为空,需回退 |
2.2 Item Pipeline与字段预处理
Scrapy把“解析”和“存储”分开。Spider只负责提取原始文本,Pipeline负责清洗。比如info字段可能是['3-5年','本科','北京朝阳区'],在Pipeline中按位置拆开。示例:
# pipelines.py class CleanPipeline: def process_item(self, item, spider): info = item.get('info', []) if len(info) >= 3: item['experience'] = info[0] item['education'] = info[1] item['district'] = info[2] else: item['experience'] = '' item['education'] = '' item['district'] = '' # 技能标签需要从职位描述中提取,这里先留空 item['skills'] = '' return item这个Pipeline把列表字段拆分成了结构化字段,同时处理了解析不到数据的异常情况。为什么不直接在Spider里拆?因为Pipeline更便于做数据校验和日志记录,而且如果未来改用爬虫API,逻辑可以复用。
2.3 反爬参数:请求头、下载延迟与Cookie策略
Boss直聘对无头爬虫的识别比较敏感。我一般会在settings.py里做三件事:设置DOWNLOAD_DELAY = 1.5,让请求间隔至少1.5秒;启用Scrapy自带的RandomUserAgentMiddleware,并准备一个几十组UA的列表;在请求头中携带从浏览器复制的Cookie,避免触发登录跳转。代码:
# settings.py DOWNLOAD_DELAY = 1.5 RANDOM_UA_TYPES = ['chrome', 'firefox'] COOKIES_ENABLED = False # 改为False,手动在headers放Cookie DEFAULT_REQUEST_HEADERS = { 'User-Agent': 'Mozilla/5.0 ...', 'Cookie': 'your_cookie_here', 'Referer': 'https://www.zhipin.com/', }注意:COOKIES_ENABLED = False可以避免Scrapy的Cookie处理器干扰手动Cookie。如果还遇到验证码,就要检查是不是DOWNLOAD_DELAY太小,或者需要代理IP。项目里附带的数据是已经爬完的结果,所以实际运行时可以先拿少量页面测试,确认稳定后再全量抓取。
3. pandas 数据清洗与薪资解析:把“15-30K·14薪”变成数字
爬下来的是原始文本,要做分析必须先清洗。这一章用pandas完成三个核心任务:去重、缺失值处理、把“15-30K·14薪”拆成数字字段。这三个操作占整个数据预处理工作量的70%。
3.1 重复数据与缺失值:先看再决定
读入CSV后,不要急着删行,先用df.info()和df.describe()了解数据概况。重复行往往来自分页爬取时的重复记录,例如同一职位出现在“大数据”和“数据分析”两个搜索词下。可以用subset指定精确去重:
import pandas as pd df = pd.read_csv('jobs_raw.csv') print(df.shape) df = df.drop_duplicates(subset=['job_title', 'company', 'salary']) print(f'去掉重复后剩余 {df.shape[0]} 行')为什么用这三个字段做subset?因为岗位名称、公司、薪资组合起来几乎能唯一标识一条招聘广告;比单用job_title更严格,又比全行列去重更宽容,能保留不同职位的相似文本。
缺失值看df.isnull().sum()。字段district如果缺失,可能是Boss直聘的职位卡片没有展示区域信息,可以暂时填“未知”;salary缺失必须处理,因为后续分析依赖薪资。
3.2 薪资区间正则解析:从字符串到数值
薪资文本有多种变体:“15-30K”“20-40K·14薪”“面议”。下面这段代码提取区间下限和上限,并计算中位数;对“面议”直接记为None:
import re def split_salary(text): if not isinstance(text, str): return pd.Series([None, None, None]) m = re.search(r'(\d+)-(\d+)K', text) if m: low = int(m.group(1)) high = int(m.group(2)) mid = (low + high) / 2 return pd.Series([low, high, mid]) return pd.Series([None, None, None]) df[['salary_low', 'salary_high', 'salary_mid']] = df['salary'].apply(split_salary)apply(split_salary)在这里逐行处理,返回的Series会自动对齐到新列。也可以改用zip(*df['salary'].apply(split_salary)),但可读性不如直接赋值。注意salary_mid用于画箱线图,因为它比“最低薪资”“最高薪资”更稳定,能代表招聘方的平均报价。
如果还想计算“14薪”的影响,可以再加一个函数:re.search(r'(\d+)薪', text)提取薪期,然后salary_mid * months / 12折算成月均。不过多数岗位描述里的薪资已经是月薪,所以这一步可选。
3.3 学历与经验编码:把文本变成可排序的数值
学历文本一般是“大专”“本科”“硕士”“博士”,用map做有序编码。经验文本有“1-3年”“3-5年”“5-10年”,提取最低年限作为特征。
edu_map = {'大专': 1, '本科': 2, '硕士': 3, '博士': 4} df['edu_code'] = df['education'].map(edu_map) def extract_exp(text): m = re.search(r'(\d+)', text) if isinstance(text, str) else None return int(m.group(1)) if m else None df['exp_year'] = df['experience'].apply(extract_exp)这里把学历从定类变量转成定序变量,便于后面做相关性分析;经验年限直接用数字表示,能跟薪资做散点图。需要留意:map遇到未识别值会返回NaN,比如“学历不限”,这时可以根据分析目标填0或删除。
清洗后的数据表格如下:
| 原始salary | salary_low | salary_high | salary_mid | edu_code | exp_year |
|---|---|---|---|---|---|
| 15-30K·14薪 | 15 | 30 | 22.5 | 2 | 3 |
| 20-40K·15薪 | 20 | 40 | 30.0 | 3 | 5 |
| 面议 | NaN | NaN | NaN | NaN | NaN |
经过这步,原始数据已经从“能看”变成“能算”。下一步所有图表都基于salary_mid、edu_code、exp_year这几个数字列。
4. matplotlib + seaborn 可视化:用图表回答“北京需要什么样的大数据人才”
数据清洗完,下一步是可视化。项目里用matplotlib和seaborn生成静态图,再嵌入到HTML模板中展示。可视化不是随便画几个柱状图,而是要回答具体问题:哪个区岗位最多?薪资和年限怎么变?技能要求集中在哪些词上?
| 图表 | 数据字段 | 回答的问题 |
|---|---|---|
| 区域分布柱状图 | district + job_title | 岗位集中在哪个区 |
| 经验薪资箱线图 | exp_year + salary_mid | 经验对薪资的影响 |
| 技能词频图 | skills 计数 | 哪些技能最热门 |
4.1 区域岗位数量分布
直接对district做value_counts,画水平条形图,便于区名阅读。注意要先设置中文字体,否则标题和坐标轴会变成方块。代码:
import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False district_counts = df['district'].value_counts().head(10) district_counts.sort_values().plot(kind='barh', figsize=(10, 6)) plt.xlabel('岗位数量') plt.ylabel('区域') plt.title('北京大数据岗位区域分布 Top10') plt.tight_layout() plt.savefig('district.png')head(10)只取前10,避免海淀区单个大柱子盖住其他区。sort_values()让条形图从低到高排列,长条在下方,视觉上更顺。保存成PNG而不是直接show,是为了后续嵌入到HTML报告中。
4.2 经验要求与薪资的箱线图
箱线图能同时展示分布、中位数和异常值。这里用seaborn,按exp_year分组(0年、1年、3年、5年、10年)画salary_mid的分布:
import seaborn as sns sns.boxplot(data=df, x='exp_year', y='salary_mid') plt.xlabel('工作经验(年)') plt.ylabel('月薪中位数(K)') plt.title('不同经验要求下的薪资分布') plt.xticks(rotation=45) plt.savefig('exp_salary_box.png')注意exp_year如果是连续的,最好先离散化:df['exp_group'] = pd.cut(df['exp_year'], bins=[0,1,3,5,10], labels=['0-1','1-3','3-5','5-10']),否则箱线图会把每个唯一的年数值都画一箱。离散化后能看到明显的阶梯效应:1-3年到3-5年有一个大的薪资跃升,这是分析报告中值得强调的发现。
4.3 技能关键词词频提取
职位描述里会写“熟悉Python、Spark、Hadoop”,技能字段可能是从标签里抓的。简单的做法是用字符串计数,先定义一个技能字典,再对skills列做str.contains统计:
skills = ['Python', 'Spark', 'Hadoop', 'SQL', 'Java', 'Scala', 'Flink', 'Hive', 'Kafka'] counts = {s: df['skills'].str.contains(s, na=False).sum() for s in skills} counts = pd.Series(counts).sort_values()这里na=False让缺失值不参与匹配,.sum()统计包含该技能的行数。把结果画成柱状图,就能直观看到Python是绝对第一,Spark与Hive紧随其后。如果想更精细,可以用jieba分词后统计词频,但对于这种预定义的技能清单,直接匹配既快又不易出噪声。
可视化部分的输出直接对接项目里的HTML页面,bootstrap.css和aos.css只是给页面加样式和滚动动效,真正承载数据的是这些PNG图和后面要讲的MySQL表。
5. 数据导出与入库:从CSV到MySQL的工程化细节
分析结果要给别人用,CSV文件不够友好。项目里把清洗后的数据导入MySQL,这样可以用SQL做临时查询,也可以供可视化后端调用。这一章记录三个关键点:连接方式、类型映射、增量更新。
5.1 用SQLAlchemy建立连接
pandas的to_sql不直接接受原生连接,需要通过SQLAlchemy的create_engine。示例:
from sqlalchemy import create_engine engine = create_engine( 'mysql+pymysql://root:password@localhost:3306/jobs_db?charset=utf8mb4' ) df.to_sql('bigdata_jobs', engine, if_exists='replace', index=False)charset=utf8mb4是关键,否则职位描述里的中文表情或特殊字符会报错。if_exists='replace'适合全量重建表;如果只是追加,改成'append',但前提是表结构一致。
5.2 字段类型与索引设计
pandas自动推断类型可能不准,to_sql可以用dtype参数指定SQL类型,尤其是字符串字段要显式设成VARCHAR或TEXT,避免默认的VARCHAR(255)截断长文本。
| 字段 | SQL类型 | 说明 |
|---|---|---|
| job_title | VARCHAR(128) | 职位名 |
| salary_mid | FLOAT | 月薪中位数 |
| education | VARCHAR(16) | 学历原文 |
| exp_year | INT | 最低经验年限 |
| skills | TEXT | 技能标签 |
同时在建表SQL里给job_title+company建立联合唯一索引,配合INSERT ... ON DUPLICATE KEY UPDATE实现更新。
5.3 增量更新:用jobid做分批抓取
爬虫每天跑一次,不能每次都全量重抓。常见做法是:每次抓取时带上当天日期或职位ID,SQL中先SELECT已有的ID集合,再对新增的插入。具体到Scrapy,可以在Pipeline里维护一个已见ID的set,如果item.get('jobid')已存在,直接drop_item。
这里有一个简单的去重Pipeline:
from scrapy.exceptions import DropItem class DuplicatePipeline: def __init__(self): self.seen = set() def process_item(self, item, spider): jobid = item.get('jobid') if jobid in self.seen: raise DropItem(f'重复职位: {jobid}') self.seen.add(jobid) return item但这个seen只在单次爬取内有效,重启爬虫后会丢失。更可靠的做法是先查MySQL里已有的jobid集合,初始化到self.seen中,实现“断点续爬”。在下一章我会专门讲怎么校验这套去重逻辑不会漏数。
6. 让爬虫和数据管线可维护:去重、断点续爬与质量校验
项目交付不能只跑一次,要能重跑、能检查数据有没有问题。这一章给三个具体技巧,都是我在类似项目中会直接落地的。
6.1 用jobid实现断点续爬与增量合并
Boss直聘每一条职位详情页的URL里有唯一ID,例如/job_detail/123456789.html。在Spider里把这个ID提取出来放到item中,然后在Pipeline里用它做全局去重:
# pipelines.py import pymysql from scrapy.exceptions import DropItem class UniquePipeline: def __init__(self): self.conn = pymysql.connect(host='localhost', user='root', password='123456', db='jobs_db') self.seen = self._load_seen() def _load_seen(self): with self.conn.cursor() as cur: cur.execute('SELECT jobid FROM bigdata_jobs') return {row[0] for row in cur.fetchall()} def process_item(self, item, spider): if item['jobid'] in self.seen: raise DropItem(f'已存在: {item["jobid"]}') self.seen.add(item['jobid']) # 继续保存到MySQL... return item_load_seen在初始化时把已有ID装进集合,内存占用不大;每次新jobid插入后,内存集合和数据库保持同步。这样即使爬到一半中断,下次启动也会自动跳过已完成的部分。
6.2 数据质量校验:断言与阈值
清洗完数据后,我习惯写一段校验脚本,不达标直接抛异常,防止脏数据流入报表。这里用一个简单表格列出校验项和阈值:
| 检查项 | 阈值 | 说明 |
|---|---|---|
| salary_mid 缺失数 | < 5% | 缺失过多则爬取或解析有问题 |
| 重复比例 | 0 | 去重不彻底需重跑 |
| district 覆盖率 | > 90% | 区域缺失影响地图可视化 |
| exp_year 最小值 | >= 0 | 负值来自异常解析 |
校验代码:
def validate(df): assert df['salary_mid'].isna().mean() < 0.05, '薪资缺失过多' assert df.duplicated(subset=['jobid']).sum() == 0, '存在重复jobid' assert df['district'].notna().mean() > 0.9, '区域覆盖不足' assert (df['exp_year'] >= 0).all(), '经验年限有负值' return True validate(df) print(f'数据校验通过,共 {df.shape[0]} 条记录')这些断言跑完之后,再生成图表和HTML报告。项目里的bootstrap.css、aos.css这些文件都是报告页面的皮肤,你完全可以用静态站点把这些图表和表单一页展示出来,交给业务方或面试官当作作品集。
本文还有配套的精品资源,点击获取