用 Python 搭建岗位就业数据分析系统:从招聘 JD 采集到可视化看板

📅 发布时间:2026/10/12 23:13:49
用 Python 搭建岗位就业数据分析系统:从招聘 JD 采集到可视化看板
简介基于Python的岗位就业数据分析系统完整源码与文档说明面向高校毕业设计、期末大作业及课程设计场景适合Python初学者与需要快速搭建完整项目的开发者。该项目为手写98分高分项目获得导师认可代码注释详细新手也能看懂部署简单下载后稍作配置即可运行。压缩包共54个文件约365KB以36个txt文本文件数据与说明、10个js前端逻辑、5个py核心脚本为主另含css样式、html页面和md文档覆盖数据采集、清洗、存储、后端接口到前端展示的完整环节。目前已有106人学习/浏览。项目内置区域划分、省级划分、地区划分等多维分析维度数据预处理与可视化模块清晰分离便于二次扩展配套ReadME文档能帮助读者理解整条数据处理链路是课程设计或毕业设计可直接参考的高分实现。1. 岗位就业数据分析系统用 Python 把招聘 JD 变成可量化的就业市场信号如果你正在筹划一个能写进简历的 Python 数据分析项目岗位就业数据分析系统是性价比很高的方向。它天然包含数据采集、清洗、存储、查询、分析和可视化六个环节正好覆盖了业务侧数据分析在真实工作中最常用的一整套技术栈。它不依赖任何付费数据源只要用 requests、pandas、Flask 这些开源工具就能把几万条招聘 JD 加工成城市薪资中位数、技能需求排名、岗位分布密度这类可量化信号。因为这个项目数据量和复杂度适中既可以作为毕业设计的完整选题也适合想用 Python 完整走一遍数据流程的开发者练手。下面从数据采集开始按一条可复现的链路把系统拆开讲。2. 数据采集与清洗用 requestspandas 把招聘信息变成结构化数据先说结论这套系统能不能跑出有价值的结果80% 取决于数据质量模型和图表反而是其次。采集和清洗的目的只有一个——把网页上半结构化的招聘信息变成一张每行是一条岗位、每列是一个规范字段的表格。这里面的坑集中在三处页面形态差异、反爬干扰、字段不一致。我一般会把采集模块拆成两类爬虫来处理前两个问题再用 pandas 统一处理第三类问题。2.1 爬虫模块怎么设计静态页 Ajax 接口两条路招聘网站的页面形态大致分两种。早期站点是服务端渲染打开页面源码就能看到岗位名称、公司、薪资这些字段近几年上线的站点普遍改成前端框架渲染列表数据是浏览器加载完页面之后再通过 Ajax 请求服务器拿到的。如果你只写一种解析逻辑换一个数据源就会失效所以项目里我会同时保留两套入口。先看静态页的抓取和解析。常见做法是先用 requests.Session 发请求拿 HTML再用 BeautifulSoup 的 lxml 解析器定位岗位卡片。注意 Session 要复用同一个连接对象避免频繁创建 TCP 连接被网站限流。import requests from bs4 import BeautifulSoup class JobHtmlCrawler: 静态页爬虫适配服务端渲染的招聘列表页 def __init__(self, base_url: str, headers: dict): self.base_url base_url self.session requests.Session() self.session.headers.update(headers) def fetch_list(self, params: dict) - str: 拉取列表页 HTML附带请求超时与编码修正 resp self.session.get(self.base_url, paramsparams, timeout15) resp.raise_for_status() # 老站点很多是 gbk 编码直接按 utf-8 解析会乱码用 apparent_encoding 兜底 resp.encoding resp.apparent_encoding return resp.text def parse_jobs(self, html_text: str): 用 CSS 选择器提取岗位卡片字段缺失时用 None 占位 soup BeautifulSoup(html_text, lxml) for card in soup.select(div.job-card): title card.select_one(.job-name) company card.select_one(.company-name) salary card.select_one(.salary) if title is None: continue yield { job_name: title.get_text(stripTrue), company_name: company.get_text(stripTrue) if company else None, salary: salary.get_text(stripTrue) if salary else None, city: card.get(data-city, None), source_url: card.get(data-href, None), }代码里的div.job-card、.job-name是我用来说明逻辑的通用选择器实际接站点时要以浏览器开发者工具里看到的真实 DOM 为准。card.get(data-href, None)拿的是岗位详情页链接这个字段有两个作用一是去重依据二是后续如果需要抓岗位描述里的技能要求可以直接跟进详情页。再说 Ajax 接口那条路。新版招聘网站打开列表页时浏览器会请求一个返回 JSON 的接口常见的返回结构是{ code: 0, data: { jobList: [...] } }。这类页面抓取要先用开发者工具的 Network 面板找到真正返回数据的请求把它复制成 requests 可用的格式。def fetch_ajax_jobs(api_url: str, params: dict, headers: dict) - list: 拉取 Ajax 接口返回的 JSON适配新版招聘站点 resp requests.get(api_url, paramsparams, headersheaders, timeout10) if resp.status_code ! 200: return [] payload resp.json() # 不同站点的 JSON 层级差别很大第一次对接先打印 payload 看结构再改 data payload.get(data, {}) if isinstance(data, dict): return data.get(jobList, []) return []这段代码的关键参数是headers。很多站点会校验User-Agent和Referer缺了这两个字段就返回 403 或者空数组如果出现这种情况先把浏览器请求头完整复制过来再重试。另外 Ajax 接口通常要带page、pagesize、city、query这些参数翻页时注意确认接口返回的总条数和页数别把翻页条件写死。2.2 数据清洗的核心流程去重、补全、归一化采集得到的原始数据会很脏同一岗位可能被重复抓到薪资字段是“15-25K·14薪”这种混合字符串学历字段可能同时出现“本科”“本科及以上”“统招本科”。清洗模块要做的事就是把这些不规则的输入统一成机器能算的形式。我一般按三步走先去重、再去缺失、最后做字段归一化。import pandas as pd import re def build_clean_df(raw_rows: list) - pd.DataFrame: 把爬虫产出的原始字典列表转成干净表格 df pd.DataFrame(raw_rows) # 1. 去重source_url 是同一 JD 的唯一标识保留第一次抓到的记录 if source_url in df.columns and df[source_url].notna().any(): df df.drop_duplicates(subset[source_url], keepfirst) # 2. 去缺失岗位名和薪资是分析的底线字段缺失直接丢弃 df df.dropna(subset[job_name, salary]) # 3. 过滤噪音把实习、兼职、外包这类不反映就业市场主趋势的岗位剔掉 df df[~df[job_name].str.contains(实习|兼职|外包, naFalse)] return df第 2 步的dropna(subset[job_name, salary])看着简单实际上是保底措施。宁可丢掉一部分记录也不能让后面算平均薪资时因为 NaN 报错。第 3 步的过滤条件会因为分析目标不同而调整如果你想单独研究实习岗就不要用这行过滤而是新增一个岗位类型字段做区分。薪资归一化是清洗里最绕的一个环节。招聘网站常见的写法包括“15-25K”“8千-1.2万”“面议”“30-50万/年”几种需要在清洗时统一转成数值型字段。def parse_salary_band(salary_text: str) - tuple: 把 15-25K·14薪 解析成 (下限, 上限, 月薪数)返回 None 表示无法解析 if not isinstance(salary_text, str): return (None, None, None) m re.search(r(\d)-(\d)K, salary_text, re.IGNORECASE) if not m: return (None, None, None) low int(m.group(1)) high int(m.group(2)) months 12 m2 re.search(r(\d)薪, salary_text) if m2: months int(m2.group(1)) return (low, high, months)parse_salary_band里两个正则要重点说明第一个(\d)-(\d)K只匹配以 K 为单位的薪资区间带re.IGNORECASE是因为有些站点写成 k 小写第二个(\d)薪抓的是“13薪”“14薪”这类月薪倍数匹配不到就默认 12 个月。清洗完以后我会把原始salary字符串丢弃新增salary_low、salary_high、salary_months三个数值列后面分组聚合就干净很多。3. 数据库设计与 Web 后端把数据存进 MySQL 并提供查询接口数据清洗完会得到一张结构化表格但 csv 文件不适合做频繁的条件查询也不方便多个分析模块共用。常见做法是把数据落进 MySQL再用 Flask 提供一组 REST API 给查询模块和前端图表调用。这一章讲表结构怎么设计以及后端接口的最小可用实现。3.1 表结构设计岗位表、公司表、技能标签表怎么拆如果你只做一个毕业设计级别的分析系统我建议不要过度拆表。相反我最常遇到的情况是同学把表拆得特别正规最后查询时 JOIN 了五张表性能和代码可读性都很差。合理粒度是三张表岗位主表、公司表、技能标签表。其中公司表和技能标签表是否一定要拆取决于你后续要不要做“同一家公司招聘薪资对比”这类分析如果只是看岗位维度把公司名称和技能标签直接冗余在岗位表里也行。CREATE TABLE job ( id INT PRIMARY KEY AUTO_INCREMENT, job_name VARCHAR(128) NOT NULL, company_id INT NOT NULL, salary_low INT DEFAULT 0, salary_high INT DEFAULT 0, salary_months INT DEFAULT 12, city VARCHAR(64), education VARCHAR(32), experience VARCHAR(32), skill_tags VARCHAR(512), publish_date DATE, source_url VARCHAR(512) UNIQUE, crawled_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_city (city), INDEX idx_salary (salary_low, salary_high) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE company ( id INT PRIMARY KEY AUTO_INCREMENT, company_name VARCHAR(128) NOT NULL UNIQUE, industry VARCHAR(64), financing_round VARCHAR(32), company_size VARCHAR(32) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;岗位表里的source_url UNIQUE是第二道去重防线。即使清洗阶段漏掉了一条重复记录入库时也会因为唯一索引直接报错让你发现问题而不是悄悄重复。idx_city和idx_salary两个索引是为了支撑后面按城市、按薪资区间查询的接口。engineInnoDB和charsetutf8mb4是两点硬要求前者支持事务后者能完整存中文和 Emoji。3.2 Flask 后端与 REST API让前端能按条件查岗位后端我用 Flask 而不是 Django原因很简单这个项目不需要 Django 自带的管理后台和 ORM 全家桶Flask 足够轻接口逻辑也清晰。数据库操作直接用flask-mysqldb扩展ORM 反而会给新手增加一层理解负担。下面是一个带条件查询的接口实现。from flask import Flask, request, jsonify from flask_mysqldb import MySQL app Flask(__name__) app.config[MYSQL_HOST] localhost app.config[MYSQL_USER] root app.config[MYSQL_PASSWORD] your_password app.config[MYSQL_DB] job_analysis app.config[MYSQL_CHARSET] utf8mb4 mysql MySQL(app) app.route(/api/jobs, methods[GET]) def get_jobs(): 按城市和关键词筛选岗位列表支持分页 city request.args.get(city, ) keyword request.args.get(keyword, ) page int(request.args.get(page, 1)) size int(request.args.get(size, 20)) offset (page - 1) * size sql SELECT id, job_name, salary_low, salary_high, city FROM job WHERE 11 params [] if city: sql AND city %s params.append(city) if keyword: sql AND job_name LIKE %%%s%% params.append(keyword) sql ORDER BY salary_high DESC LIMIT %s OFFSET %s params.extend([size, offset]) cursor mysql.connection.cursor() cursor.execute(sql, tuple(params)) columns [col[0] for col in cursor.description] rows [dict(zip(columns, row)) for row in cursor.fetchall()] cursor.close() return jsonify({code: 0, count: len(rows), data: rows}) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)接口里最值得说的是LIKE %%%s%%这行。MySQL 的%s是参数占位符%%是转义后的字面量%所以%%%s%%最终拼出来的是%关键词%实现模糊匹配。分页参数page和size必须做类型转换否则前端传一个非数字字符串进来int()会直接抛 500 错误。ORDER BY salary_high DESC是为了让高薪岗位排在前面实际使用时注意对薪资为 0 的脏数据做过滤否则会污染排序结果。4. 数据分析与可视化薪资分布、技能需求、地域差异三类核心分析这个项目最出彩的部分在分析环节。从就业市场的角度三类分析最常被问起薪资到底什么水平、哪些技能最值钱、哪个城市岗位最多。这三类问题分别对应三种不同的分析手法我会在下面把计算逻辑和可视化代码一并展开。4.1 薪资分析箱线图 分位数别被平均值骗了薪资分布是个典型的右偏分布——少数高薪岗位会把平均值拉得很高。如果你写一句df[salary].mean()然后拿这个数当结论很容易得出“平均月薪两万八”这种和实际感受脱节的数字。我一般会同时看中位数和分位区间用箱线图呈现分布形态。import pandas as pd import matplotlib.pyplot as plt df pd.read_csv(cleaned_jobs.csv) df[salary_mid] (df[salary_low] df[salary_high]) / 2 # 按城市算样本量和薪资中位数样本量太少的城市不参与对比 city_stats df.groupby(city)[salary_mid].agg( countcount, medianmedian, p25lambda x: x.quantile(0.25) ) valid_cities city_stats[city_stats[count] 30].sort_values(median, ascendingFalse) plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False fig, ax plt.subplots(figsize(12, 6)) ax.bar(valid_cities.index[:10], valid_cities[median][:10]) ax.set_title(Top10 城市岗位月薪中位数) plt.xticks(rotation45) plt.savefig(top10_city_salary.png, dpi150, bbox_inchestight)这段代码有两个必调参数count 30的样本量门槛和dpi150的输出分辨率。前者是为了避免某城市只有 3 条数据时中位数失真后者是为了放到论文或报告里不糊。p25lambda x: x.quantile(0.25)用了 agg 方法传匿名函数这样一次分组就能同时拿到多个统计量不用对 DataFrame 做多次 groupby。4.2 技能关键词提取jieba 分词 TF-IDF 找出热门技能岗位描述和技能标签里藏着大量关于技能需求的信息。直接统计词频有个问题像“熟悉”“负责”“岗位”这类词出现频率最高却没有任何分析价值。我一般会引入 jieba 分词加 TF-IDF 做关键词过滤把重要性权重高的词留下来。import jieba from sklearn.feature_extraction.text import TfidfVectorizer df[text_for_analysis] df[job_name] df[skill_tags].fillna() df[cut_text] df[text_for_analysis].apply( lambda x: .join([w for w in jieba.cut(x) if len(w) 1]) ) vectorizer TfidfVectorizer(max_features50, token_patternr(?u)\b\w\b) tfidf_matrix vectorizer.fit_transform(df[cut_text]) # 把 TF-IDF 权重按列求和得到每个词的全局重要度 word_weights tfidf_matrix.sum(axis0).tolist()[0] feature_names vectorizer.get_feature_names_out() word_weight_pairs list(zip(feature_names, word_weights)) word_weight_pairs.sort(keylambda x: x[1], reverseTrue) for word, weight in word_weight_pairs[:20]: print(f{word}: {weight:.4f})TF-IDF 里的max_features50限制了关键词数量避免输出几百个没有实际意义的词。len(w) 1过滤单字词因为单字在招聘语境里几乎没有区分度。还有一个细节cut_text必须先做一次“分词 空格连接”TF-IDF 才能按照空格分词去计算词频直接把原始中文文本丢给 TfidfVectorizer 会得到错误的词频结果。4.3 可视化看板用 pyecharts 生成可交互图表静态 matplotlib 图适合报告里用但如果你想要一个能筛城市、能悬停看数值的前端看板我建议用 pyecharts。它在后端生成 HTMLJavaScriptFlask 直接把这个 HTML 片段塞进模板就能展示不需要单独搭前端工程。from pyecharts.charts import Bar, Pie, Page from pyecharts import options as opts # 城市岗位量占比 city_job_count df[city].value_counts().head(10) pie Pie() pie.add( series_name岗位量占比, data_pair[(k, int(v)) for k, v in city_job_count.items()], radius[35%, 60%], ) pie.set_global_opts(title_optsopts.TitleOpts(title岗位城市分布 Top10)) pie.set_series_opts(label_optsopts.LabelOpts(formatter{b}: {c} ({d}%))) # 技能需求 Top10 skill_bar Bar() skill_bar.add_xaxis([w for w, _ in word_weight_pairs[:10]]) skill_bar.add_yaxis(关键度, [round(w, 2) for _, w in word_weight_pairs[:10]]) skill_bar.set_global_opts( title_optsopts.TitleOpts(title技能关键词重要度), xaxis_optsopts.AxisOpts(axislabel_opts{rotate: 30}), ) page Page(layoutPage.SimplePageLayout) page.add(pie, skill_bar) page.render(templates/dashboard.html)pyecharts 的data_pair接收的是(名称, 数值)元组列表不能直接传 pandas Series。formatter{b}: {c} ({d}%)里的{d}是饼图内置的百分比占位符会按所有项的总和自动计算。Page.SimplePageLayout会把两张图上下排布如果你想左右并排需要改成DraggablePageLayout或者直接手写模板布局。5. 避坑与排查数据源、编码、内存、部署的典型翻车点做这类 Python 数据分析项目最耗时间的往往不是写代码而是排错。这里有五个高频坑都是我在类似项目里实际踩过的按“现象 → 原因 → 解决”的格式列出来你可以对照排查。5.1 爬虫返回空数据的排查思路现象请求返回 200但解析出来的岗位列表是空的或者爬到的是验证码/反爬提示页面而不是招聘信息。原因最常见的两种一是网站对高频请求有限制返回了人机校验页面二是列表数据是 Ajax 异步加载的你请求的 URL 不是真正返回数据的那个接口。解决先用print(resp.text[:500])看前 500 个字符到底长什么样。如果看到验证码相关关键词基本可以确定是触发反爬了这时候检查请求头是否完整、请求频率是否过快并加上随机延时如果看到的是空 div那就是请求地址的问题回到浏览器开发者工具的 Network 面板里找真实的数据接口重点看XHR类型的请求。5.2 pandas 分组聚合结果和预期不符的边界情况现象df.groupby(city)[salary_mid].mean()输出的城市列表和预想不同或者发现某些分组的均值高得离谱。原因分组聚合结果异常八成是因为参与分组的列里混进了脏数据。比如city字段里同时存在“北京”和“北京市”groupby 会把它们当成两个独立的分组导致每组样本量虚低、均值虚高。另外如果salary_mid列里有字符串类型pandas 会做隐式类型转换而不是报错算出来的结果可能直接是错的。解决清洗阶段增加一个字段归一化步骤用 replace 方法把同义值合并比如df[city].replace({北京市: 北京, 北京 : 北京})。在聚合前显式确认类型df[salary_mid] df[salary_mid].astype(float)。每次聚合后打印groupby(...).size()先看各组的样本量是否合理再判断均值有没有参考价值。5.3 中文编码与字体问题现象代码在本地运行输出正常部署到服务器后图表里的中文全部变成方块csv 文件用 Excel 打开乱码。原因Windows 控制台默认用 GBK 编码Linux 下默认 UTF-8保存 csv 时如果用 UTF-8 编码但没加 BOMExcel 会按 ANSI 去猜导致乱码。matplotlib 在 Linux 服务器上默认没有 SimHei、Microsoft YaHei 这类中文字体所以图上的中文渲染成方块。解决写 csv 时指定encodingutf-8-sig这个编码会在文件头部加 BOMExcel 能正确识别。matplotlib 的字体问题可以在列出的/usr/share/fonts/目录下检查没有中文字体就上传一个或者直接plt.rcParams[font.sans-serif] [WenQuanYi Micro Hei]指定 Linux 自带的开源字体。5.4 MySQL 连接与事务的坑现象程序跑一段时间后数据库操作突然报Lost connection to MySQL server during query或者插入数据时中文字符变成问号。原因MySQL 连接默认有超时时间长时间空闲的连接会被服务端断开另外连接字符集没指定为 utf8mb4 时中文写入就可能丢失。使用flask-mysqldb时如果没有显式配置MYSQL_CHARSET进程也会按默认配置执行。解决在建表的CREATE TABLE和 Flask 配置里双保险指定 utf8mb4同时给连接配置加上连接池参数如pool_recorder。我是用的一个折中方案每次执行完数据库操作后马上cursor.close()避免占用连接过久在需要长时间运行的定时任务里任务开始前先执行SELECT 1测一下连接可用性不可用就重新初始化连接。5.5 图表内存溢出与渲染卡顿现象用 pyecharts 一次性画 500 个城市的数据点页面加载明显变慢matplotlib 循环生成了上百张图程序内存占用持续上涨。原因数据量大的时候图表库会把每个数据点都转成 DOM 节点或图形对象渲染开销和数据量是线性甚至超线性的关系。matplotlib 频繁打开figure但不关闭句柄会一直留在内存里。解决数据进入图表之前先做聚合比如只取 Top20 城市别让前端图表直接面对全量明细数据。matplotlib 循环画图时用plt.close(fig)显式关闭当前实例。pyecharts 如果确实需要展示大量数据点改用Scatter配合LargeTypeMode渲染或者把数据降采样后再传。6. 进阶把系统做成定时更新的小看板附验证清单项目跑通之后下一个值得做的改进是让数据自动更新。手动执行爬虫和分析脚本只能得到某一时刻的静态结果加一个调度器后系统会每周自动抓取、清洗、入库、重算指标变成一个可以长期观察就业市场变化的小看板。from apscheduler.schedulers.blocking import BlockingScheduler from datetime import datetime def daily_job(): 每天执行一次爬取新岗位 - 清洗 - 入库 - 更新分析图表 from crawler import run_crawler from cleaner import clean_new_records from report import generate_dashboard print(f[{datetime.now()}] 开始更新数据...) raw_data run_crawler() clean_data clean_new_records(raw_data) generate_dashboard(clean_data) print(f[{datetime.now()}] 更新完成新增 {len(clean_data)} 条记录) if __name__ __main__: scheduler BlockingScheduler() scheduler.add_job( daily_job, triggercron, hour2, minute30, iddaily_job, misfire_grace_time3600, coalesceTrue, ) scheduler.start()调度器里有三个参数值得讲究。hour2, minute30选在凌晨执行避开业务高峰也避免白天数据更新时被用户请求干扰。misfire_grace_time3600说的是任务应该执行时进程恰好挂掉等恢复后允许在一小时内的补跑窗口超过这个时间就直接跳过避免积压任务。coalesceTrue则保证积压多个未执行任务时只跑最新一次。如果你已经用 Flask 跑着服务推荐把BlockingScheduler换成BackgroundScheduler避免调度器阻塞 Web 服务端口。最后说一下我自己的一套验证清单跑完一遍基本可以判断系统有没有做好。第一随机抽 10 条入库记录和原始页面比对确认字段没有错位第二检查去重逻辑是否生效用SELECT COUNT(*) vs COUNT(DISTINCT source_url)对比两个数字第三核对薪资中位数是否落在招聘市场常识区间如果某城市均值比一线城市还高优先怀疑数据质量而非市场异常第四定时任务跑通后手动修改一条错误数据再调一次任务确认覆盖更新逻辑正常。这套验证花不了半小时但能在项目汇报时帮你挡掉一半质疑。我也会在部署新功能后先用验证清单跑一遍再去写文档这个习惯帮我避开了不少线上翻车。希望帮到你。本文还有配套的精品资源点击获取