简介:这是一套面向计算机、通信、人工智能、自动化等专业学生与教师的Python毕业设计完整源码,围绕招聘网站数据爬取、清洗、分析与可视化展开,可用于毕业设计、期末课程设计或课程大作业,也适合作为小白进阶练手项目。压缩包共54个文件,约6.67MB,包含py与ipynb脚本、csv与xml数据文件、html与js可视化页面、png与jpg图表素材以及css、less样式文件,覆盖数据获取、数据清洗、数据分析、数据可视化与Echarts大屏展示等模块,结构清晰便于按流程学习。目前已有381人学习下载。项目代码经过调试测试,答辩评审分达98分,读者可参考完整实现思路,掌握爬虫编写、数据预处理、词云与饼图等图表生成及大屏展示方法,并在此基础上修改调整以扩展新功能。
1. 招聘网站爬虫+数据分析+可视化:一套能写进毕业设计的技术闭环
每年到了毕设选题季,招聘类网站的数据采集与分析几乎是出现频率最高的题目之一。原因很直接:数据源公开、业务含义清晰、可视化效果好看,而且从爬虫到分析再到图表展示,一条完整的技术链路能覆盖 Python 课程的大部分知识点。但真正动手做的时候,很多人会卡在几个具体的地方——招聘网站的反爬策略怎么绕、几万条数据存哪里、薪资字段是「15-25K·13薪」这种脏数据怎么清洗、可视化用什么工具能快速出效果。
这套方案要解决的就是这条完整链路:用 requests 做招聘网站的爬虫,用 SQLAlchemy 把数据落到数据库,用 pandas 做清洗和分析,最后用 ECharts 或 Pyecharts 做可视化展示。适合正在做毕业设计、需要一套可运行源码参考的本科生,也适合想练手 Python 爬虫和数据分析的入门开发者。下面按实际开发顺序,把每个环节的关键代码和踩坑点讲清楚。
2. 招聘网站爬虫:从请求构造到反爬应对
2.1 目标网站分析与请求构造
招聘网站的数据结构通常分两类:列表页和详情页。列表页给出职位名称、公司、薪资范围、城市、学历要求等概要信息,详情页补充岗位职责、任职要求、公司介绍等。做毕业设计时,如果时间有限,只采集列表页数据也能支撑大部分分析需求。
以常见的招聘网站为例,列表页的 URL 结构一般是https://example.com/jobs?city=北京&page=1这种形式,翻页通过 page 参数控制。先打开浏览器的开发者工具,切到 Network 面板,刷新页面,找到返回职位列表的 XHR 请求或 HTML 文档请求,把请求头里的 User-Agent、Referer、Cookie 复制下来。
import requests import time import random 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://example.com/", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch_page(url, retry=3): """带重试的页面请求,返回 HTML 文本""" for i in range(retry): try: resp = requests.get(url, headers=HEADERS, timeout=10) if resp.status_code == 200: return resp.text print(f"状态码 {resp.status_code},第 {i+1} 次重试") except requests.RequestException as e: print(f"请求异常:{e},第 {i+1} 次重试") time.sleep(random.uniform(2, 5)) return None这段代码的核心逻辑是:每次请求带上伪装头,设置 10 秒超时,失败后随机等待 2 到 5 秒再重试,最多重试 3 次。timeout参数很关键,不设的话遇到慢响应会一直挂着。random.uniform做随机延迟是为了降低被识别为爬虫的概率——固定间隔的请求节奏太机械,容易被风控标记。
2.2 页面解析与字段提取
拿到 HTML 之后,用 BeautifulSoup 或 lxml 做解析。招聘网站的列表页通常是服务端渲染的,职位卡片放在固定的 class 下面。先手动在浏览器里检查一个职位卡片的 DOM 结构,找到包裹每个职位的标签和内部各字段的 class 名。
from bs4 import BeautifulSoup def parse_job_list(html): """解析列表页,返回职位字典列表""" soup = BeautifulSoup(html, "lxml") jobs = [] cards = soup.select(".job-card-wrapper") # 根据实际 class 调整 for card in cards: job = { "title": card.select_one(".job-name").get_text(strip=True), "company": card.select_one(".company-name").get_text(strip=True), "salary": card.select_one(".salary").get_text(strip=True), "city": card.select_one(".city").get_text(strip=True), "education": card.select_one(".edu-level").get_text(strip=True), "experience": card.select_one(".exp").get_text(strip=True), } jobs.append(job) return jobsselect_one返回第一个匹配元素,get_text(strip=True)去掉首尾空白。这里最容易翻车的地方是 class 名写错——招聘网站改版频繁,class 名可能带哈希后缀,比如job-name__abc123。遇到这种情况用[class*='job-name']这种属性包含选择器更稳。另外,有些字段可能为空(比如某些职位不写学历要求),直接调.get_text()会报 AttributeError,稳妥的做法是加一层判断:
def safe_text(element): return element.get_text(strip=True) if element else ""2.3 翻页策略与请求频率控制
翻页有两种常见做法:一种是构造 page 参数的 URL 列表,另一种是模拟点击「下一页」按钮。对于毕业设计级别的采集量(通常几千到几万条),构造 URL 列表就够了。
def crawl_all_pages(base_url, max_pages=50): """采集多页职位数据""" all_jobs = [] for page in range(1, max_pages + 1): url = f"{base_url}&page={page}" html = fetch_page(url) if not html: print(f"第 {page} 页请求失败,跳过") continue jobs = parse_job_list(html) if not jobs: print(f"第 {page} 页无数据,可能已到末尾") break all_jobs.extend(jobs) print(f"第 {page} 页采集到 {len(jobs)} 条,累计 {len(all_jobs)} 条") time.sleep(random.uniform(3, 8)) # 页间延迟 return all_jobs页间延迟设在 3 到 8 秒,这个范围是血泪经验——太快容易被封 IP,太慢采集效率低。如果目标网站有每日请求上限,建议把采集任务拆成多次执行,每次采一部分,中间隔几小时。另外注意,有些网站翻到一定页数后会要求登录,这时候要么注册账号带 Cookie,要么就只采前面几十页——毕业设计的数据量不需要覆盖全站。
3. 数据存储:SQLAlchemy 建表与去重入库
3.1 用 SQLAlchemy 定义职位表结构
采集到的数据需要持久化存储。CSV 文件适合快速验证,但数据量大了之后查询和去重都不方便。用 SQLAlchemy 操作 SQLite 或 MySQL 是更工程化的做法,也是毕业设计里比较加分的点。
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime from sqlalchemy.orm import declarative_base, sessionmaker from datetime import datetime Base = declarative_base() class JobPosting(Base): __tablename__ = "job_postings" id = Column(Integer, primary_key=True, autoincrement=True) title = Column(String(200), nullable=False) company = Column(String(200)) salary_raw = Column(String(100)) # 原始薪资文本 salary_min = Column(Float) # 解析后的最低月薪(K) salary_max = Column(Float) # 解析后的最高月薪(K) city = Column(String(50)) education = Column(String(50)) experience = Column(String(50)) crawl_time = Column(DateTime, default=datetime.now) def __repr__(self): return f"<Job {self.title} @ {self.company}>" engine = create_engine("sqlite:///jobs.db", echo=False) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine)表结构设计里,薪资字段拆成salary_raw、salary_min、salary_max三列是有意为之。原始文本保留下来方便回溯,解析后的数值列用于后续的统计和可视化。crawl_time记录采集时间,方便做增量采集时判断哪些是新增数据。
3.2 薪资字段解析与数据清洗
薪资文本的格式五花八门:「15-25K」「15-25K·13薪」「8千-1.2万」「200-300元/天」。解析函数需要覆盖这些常见格式。
import re def parse_salary(salary_text): """解析薪资文本,返回 (最低月薪K, 最高月薪K),解析失败返回 (None, None)""" if not salary_text: return None, None text = salary_text.replace("·13薪", "").replace("·14薪", "").strip() # 匹配 "15-25K" 或 "15-25k" match = re.search(r"(\d+\.?\d*)-(\d+\.?\d*)[Kk千]", text) if match: return float(match.group(1)), float(match.group(2)) # 匹配 "8千-1.2万" match = re.search(r"(\d+\.?\d*)千-(\d+\.?\d*)万", text) if match: return float(match.group(1)), float(match.group(2)) * 10 # 匹配 "1.2-2万" match = re.search(r"(\d+\.?\d*)-(\d+\.?\d*)万", text) if match: return float(match.group(1)) * 10, float(match.group(2)) * 10 # 匹配 "200-300元/天" 按 22 天折算月薪 match = re.search(r"(\d+)-(\d+)元/天", text) if match: return float(match.group(1)) * 22 / 1000, float(match.group(2)) * 22 / 1000 return None, None这个函数的处理顺序很重要:先去掉「·13薪」这类后缀,再按从具体到宽泛的顺序匹配。日薪折算月薪按 22 个工作日算,这是行业惯例。解析失败的返回(None, None),后续入库时可以选择跳过或保留原始文本。
3.3 去重入库与增量采集
直接往数据库里塞数据会导致重复——同一职位多次采集就会出现多条记录。去重策略用「职位名称+公司名称+城市」作为联合唯一标识。
def save_jobs(jobs, session): """去重入库,返回新增条数""" new_count = 0 for job in jobs: exists = session.query(JobPosting).filter_by( title=job["title"], company=job["company"], city=job["city"] ).first() if exists: continue salary_min, salary_max = parse_salary(job.get("salary", "")) record = JobPosting( title=job["title"], company=job["company"], salary_raw=job.get("salary", ""), salary_min=salary_min, salary_max=salary_max, city=job["city"], education=job.get("education", ""), experience=job.get("experience", ""), ) session.add(record) new_count += 1 session.commit() return new_count逐条查询去重的效率在数据量上万后会明显下降。优化方案是先把已有数据的标识集合一次性查出来放到内存里,用集合判断代替数据库查询。不过对于毕业设计的数据规模(通常不超过 5 万条),逐条查也能接受,代码更直观。
4. 数据分析:pandas 做薪资分布与技能词频
4.1 从数据库读取数据并做基础统计
数据入库后,用 pandas 做分析。先从 SQLite 读数据到 DataFrame,然后做基础统计。
import pandas as pd from sqlalchemy import create_engine engine = create_engine("sqlite:///jobs.db") df = pd.read_sql("SELECT * FROM job_postings", engine) # 过滤掉薪资解析失败的行 df_valid = df.dropna(subset=["salary_min", "salary_max"]).copy() df_valid["salary_avg"] = (df_valid["salary_min"] + df_valid["salary_max"]) / 2 print(f"总职位数:{len(df)},有效薪资数据:{len(df_valid)}") print(f"平均月薪:{df_valid['salary_avg'].mean():.1f}K") print(f"中位数月薪:{df_valid['salary_avg'].median():.1f}K")这里用平均薪资(min+max)/2作为单条职位的薪资代表值。注意dropna会丢掉薪资解析失败的行,如果丢失比例超过 30%,需要回头检查解析函数是不是漏了某些格式。
4.2 按城市和学历维度做分组分析
分组分析是毕业设计里最常被要求的部分。按城市统计平均薪资和职位数量,按学历统计薪资分布。
# 按城市统计 city_stats = df_valid.groupby("city").agg( job_count=("id", "count"), avg_salary=("salary_avg", "mean"), median_salary=("salary_avg", "median") ).sort_values("job_count", ascending=False).head(10) print(city_stats) # 按学历统计 edu_stats = df_valid.groupby("education").agg( job_count=("id", "count"), avg_salary=("salary_avg", "mean") ).sort_values("avg_salary", ascending=False) print(edu_stats)groupby之后用agg做多列聚合,比循环计算简洁得多。sort_values按职位数量降序排列,取前 10 个城市。学历维度里如果出现「不限」「学历不限」这类值,可以在分组前统一替换成「不限」,避免同一含义被拆成多组。
4.3 技能关键词提取与词频统计
从职位标题和详情描述里提取技能关键词,是招聘数据分析里比较有亮点的部分。常见做法是维护一个技能词典,然后统计每个技能出现的频次。
from collections import Counter SKILL_KEYWORDS = [ "Python", "Java", "Go", "C++", "JavaScript", "SQL", "MySQL", "Redis", "Linux", "Docker", "Kubernetes", "机器学习", "深度学习", "数据分析", "爬虫", "后端", "前端", "全栈", "算法", "运维", "测试", ] def extract_skills(text): """从文本中提取命中的技能关键词""" if not text: return [] return [kw for kw in SKILL_KEYWORDS if kw.lower() in text.lower()] # 假设 df 里有 title 列 df["skills"] = df["title"].apply(extract_skills) all_skills = [] for skills in df["skills"]: all_skills.extend(skills) skill_counts = Counter(all_skills).most_common(20) for skill, count in skill_counts: print(f"{skill}: {count}")技能词典需要根据目标招聘网站的实际职位来调整。如果做的是互联网行业招聘分析,上面这个词典基本够用;如果做的是传统行业,需要换成对应的技能词。Counter的most_common直接给出排序结果,省去手动排序的代码。
5. 数据可视化:ECharts 与 Pyecharts 出图
5.1 用 Pyecharts 生成薪资分布柱状图
Pyecharts 是 ECharts 的 Python 封装,适合在毕业设计里快速出图。生成的 HTML 文件可以直接在浏览器打开,也可以嵌入 Flask 或 Django 项目。
from pyecharts.charts import Bar from pyecharts import options as opts # 准备数据 cities = city_stats.index.tolist() salaries = [round(x, 1) for x in city_stats["avg_salary"].tolist()] bar = ( Bar() .add_xaxis(cities) .add_yaxis("平均月薪(K)", salaries, color="#5470c6") .set_global_opts( title_opts=opts.TitleOpts(title="各城市平均薪资对比"), xaxis_opts=opts.AxisOpts(name="城市", axislabel_opts=opts.LabelOpts(rotate=30)), yaxis_opts=opts.AxisOpts(name="月薪(K)"), toolbox_opts=opts.ToolboxOpts(is_show=True), ) ) bar.render("salary_by_city.html")add_xaxis和add_yaxis分别设置横轴和纵轴数据。rotate=30让城市名倾斜 30 度,避免标签重叠。toolbox_opts开启工具箱,支持下载图片和切换视图,答辩演示时比较实用。
5.2 学历要求与薪资的饼图与散点图
饼图适合展示学历分布占比,散点图适合展示工作经验与薪资的关系。
from pyecharts.charts import Pie, Scatter # 学历分布饼图 edu_counts = df["education"].value_counts() pie = ( Pie() .add("", [list(z) for z in zip(edu_counts.index.tolist(), edu_counts.values.tolist())]) .set_global_opts(title_opts=opts.TitleOpts(title="学历要求分布")) .set_series_opts(label_opts=opts.LabelOpts(formatter="{b}: {c} ({d}%)")) ) pie.render("education_pie.html") # 经验-薪资散点图 exp_salary = df_valid.groupby("experience")["salary_avg"].mean().reset_index() scatter = ( Scatter() .add_xaxis(exp_salary["experience"].tolist()) .add_yaxis("平均月薪(K)", [round(x, 1) for x in exp_salary["salary_avg"].tolist()]) .set_global_opts( title_opts=opts.TitleOpts(title="工作经验与平均薪资关系"), xaxis_opts=opts.AxisOpts(name="经验要求"), yaxis_opts=opts.AxisOpts(name="月薪(K)"), ) ) scatter.render("exp_salary_scatter.html")饼图的formatter="{b}: {c} ({d}%)"表示显示名称、数值和百分比。散点图这里用的是分组聚合后的均值,如果想看原始数据点的分布,直接把df_valid的experience和salary_avg两列传进去即可,但要注意数据量大时渲染会变慢。
5.3 用 Flask 把图表串成一个可视化看板
单独生成的 HTML 文件看起来比较散,用 Flask 做一个简单的看板页面把图表整合起来,答辩时展示效果更好。
from flask import Flask, render_template from pyecharts.charts import Bar from pyecharts import options as opts app = Flask(__name__) @app.route("/") def index(): bar = ( Bar() .add_xaxis(city_stats.index.tolist()) .add_yaxis("平均月薪(K)", [round(x, 1) for x in city_stats["avg_salary"].tolist()]) .set_global_opts(title_opts=opts.TitleOpts(title="各城市平均薪资")) ) return render_template("dashboard.html", bar_chart=bar.render_embed()) if __name__ == "__main__": app.run(debug=True, port=5000)render_embed()返回图表的 HTML 片段,可以直接嵌入 Jinja2 模板。模板里用{{ bar_chart | safe }}渲染。Flask 的debug=True在开发阶段方便热重载,部署时记得关掉。
6. 避坑与排查:招聘网站爬虫最常见的 5 个翻车点
6.1 请求返回 403 或验证码页面
现象:程序运行几分钟后,请求开始返回 403 状态码,或者返回的 HTML 里出现验证码图片。
原因:请求频率过高被风控识别,或者 User-Agent 被标记。有些网站还会检查 Cookie 中的会话标识。
解决:把页间延迟从 3 秒提高到 8 到 15 秒;每次请求随机切换 User-Agent;如果网站要求登录,手动登录后把 Cookie 复制到请求头里。采集量大的话,分多个时段执行,每次不超过 200 页。
6.2 解析到的字段全是空值
现象:parse_job_list返回的字典里所有字段都是空字符串,但 HTML 确实拿到了。
原因:网站的 class 名带了动态哈希后缀,比如job-name__3xK9,写死的job-name匹配不到。
解决:用属性包含选择器[class*='job-name']代替精确匹配。或者用 XPath 按 DOM 层级定位,比如//div[@class='job-list']//a[1]/text(),层级关系比 class 名稳定。
6.3 薪资解析结果大量为 None
现象:入库后发现salary_min和salary_max有超过一半是空的。
原因:薪资文本格式比预想的多,比如「面议」「薪资面议」「15K以上」「底薪+提成」这些格式没有被解析函数覆盖。
解决:先把所有解析失败的salary_raw值打印出来,人工看一遍有哪些格式,然后逐个补充正则。对于「面议」这类确实无法量化的,标记为特殊值而不是 None,分析时单独处理。
6.4 数据库写入速度越来越慢
现象:前 1000 条入库很快,到后面每条要等好几秒。
原因:逐条查询去重时,每次session.query().filter_by().first()都在做全表扫描,数据量大了之后查询变慢。
解决:先把已有数据的(title, company, city)组合查出来放到 Python 的 set 里,入库前用 set 判断是否已存在。或者给这三个字段建联合索引,加速查询。
6.5 Pyecharts 图表在浏览器里显示空白
现象:生成的 HTML 文件打开后页面是空白的,控制台报 JavaScript 错误。
原因:Pyecharts 默认从 CDN 加载 ECharts 的 JS 文件,如果网络环境访问不了 CDN,图表就渲染不出来。
解决:在Bar()初始化时指定init_opts=opts.InitOpts(js_host="本地路径/echarts.min.js"),把 ECharts 的 JS 文件下载到本地引用。或者用pyecharts的离线资源模式,具体做法是安装pyecharts时带上--upgrade确保版本支持离线模式。
7. 让这套源码真正能跑起来:环境配置与调试技巧
把代码跑通只是第一步,让它在不同机器上都能稳定运行,还需要注意环境配置的细节。Python 版本建议用 3.9 到 3.11,太新的版本有些库的兼容性还没跟上。依赖管理用requirements.txt,把核心库的版本固定下来:
requests==2.31.0 beautifulsoup4==4.12.2 lxml==4.9.3 SQLAlchemy==2.0.23 pandas==2.1.3 pyecharts==2.0.4 Flask==3.0.0版本号写死是为了避免「在我电脑上能跑,换台机器就报错」的经典问题。安装时用pip install -r requirements.txt,如果遇到 lxml 编译失败,Windows 上可以直接装预编译的 wheel 包,Linux 上先apt install libxml2-dev libxslt1-dev再装。
调试爬虫的时候,我习惯先把fetch_page返回的 HTML 存到本地文件,然后用解析函数去读本地文件。这样调解析逻辑的时候不用反复请求网站,速度快很多,也不会触发风控。具体做法是在fetch_page里加一行open(f"debug/page_{page}.html", "w", encoding="utf-8").write(resp.text),调通之后再注释掉。
数据分析阶段,pandas 的df.info()和df.describe()是两个必看的命令。info()看每列的数据类型和非空数量,describe()看数值列的分布情况。如果发现salary_min的最大值是 999,那多半是解析时把「999元/天」这种日薪当成了月薪,需要回头检查解析逻辑。
可视化部分,Pyecharts 的render()生成的是完整 HTML,适合单独看。如果要嵌入 Flask 模板,用render_embed()拿 HTML 片段。注意render_embed()返回的字符串里包含<script>标签,Jinja2 模板里要用| safe过滤器,否则会被转义成纯文本显示。
最后说一个我踩过的坑:SQLite 数据库文件默认放在项目根目录,如果 Flask 的app.run()工作目录变了,数据库路径就对不上。稳妥的做法是用绝对路径,或者把数据库文件路径写成配置项,通过环境变量传入。这个坑在本地开发时不容易发现,部署到服务器上才暴露出来,排查起来很费时间。
希望帮到你。
本文还有配套的精品资源,点击获取