简介:基于Flask与Python构建的全国招聘岗位就业可视化系统,是一份面向计算机相关专业学生、数据分析初学者及毕业设计者的完整项目资料。项目覆盖招聘数据采集、清洗、存储与可视化展示全流程,包含省级、地区等多级岗位分布视图,可直接运行观察效果,也便于在此基础上拓展其他行业或城市维度的分析功能。资源共54个文件,以txt数据及说明、Python脚本、JS交互逻辑、CSS/HTML前端页面与Markdown文档为主,压缩包整体约364KB;Python脚本承担数据获取、清洗与Flask服务搭建,静态资源实现图表交互与页面展示,结构清晰,适合对照学习或二次开发,已有202人学习下载。源码均已测试通过,项目内附README.md说明文档,便于快速启动与理解整体架构,是课程设计、毕业设计或初期项目演示的实用参考。
1. 招聘数据不落地,就业分析就只是拍脑袋
打开任意一家招聘网站,你能看到的永远是一页 20 条的岗位列表。搜索页面背后隐藏着城市供需结构和薪资分布的变动趋势,但这些信息散落在上万条记录里,靠人工翻页很难拼出完整图景。这套基于 Flask 和 Python 的全国招聘岗位就业可视化系统,把数据采集、清洗、存储和前端展示串成一条完整链路,运行后在浏览器里就能看到按区域、岗位、薪资等维度聚合的就业数据面板。源码包里从 data_collection.py 的数据抓取到 data_store.py 的入库逻辑,再到 app.py 的路由和 templates/main.html 的页面渲染,每一层职责都划分得很清楚,适合计算机相关专业的毕设项目、课程设计,也适合想快速上手 Flask 与 pandas 组合开发的开发者拿来拆解和二次改造。
2. 数据采集与清洗:data_collection.py 到 data_clean.py 的职责边界
在可视化之前,得先让数据可用。这套系统的代码把数据工作拆成两个独立模块:data_collection.py 负责把公开招聘页面的岗位信息抓下来,data_clean.py 负责把抓下来的原始记录处理成结构化的标准表。两个模块分开写而不是揉在一个文件里,最大的好处是数据来源更换时,清洗逻辑不用重写。
2.1 采集层该做什么和不该做什么
data_collection.py 的常见做法是定义若干采集任务函数,每个函数对应一个数据源页面,控制请求频率后把岗位名称、公司、城市、学历要求、经验要求、薪资范围、发布时间这类字段抽出来保存成 CSV 或 JSON 的中间文件。采集层要注意一点:不应该做业务清洗,比如薪资范围"8k-12k"这种字符串应该原样保留到原始字段里,解析工作放在清洗阶段。这样设计是因为不同数据源的格式差异太大,采集层做过多推断容易丢失信息,而清洗层可以对同一字段做统一处理。
职责边界如果在写代码前没有想清楚,后面很容易出现采集脚本里同时混着正则解析和字段清洗逻辑,一旦换一个采集源,整段代码都要返工。我一般会把两个模块的输入输出格式固定下来:采集层输出原始字段的 CSV,清洗层读取该 CSV,输出标准化后的宽表,层与层之间靠文件解耦。
| 职责 | 采集层 | 清洗层 |
|---|---|---|
| 请求与解析 | 负责 | 不涉及 |
| 字段抽取 | 基础抽取 | 标准化处理 |
| 格式转换 | 保留原始 | 负责转换 |
| 数据校验 | 仅去重 | 完整校验 |
| 输出 | CSV/JSON 中间文件 | 标准化结构化宽表 |
2.2 招聘原始数据里最常见的三类脏数据
从招聘平台拿到的数据有几个典型问题。第一是岗位名称不规范,同一个"Java开发工程师"能出现"java开发"、"Java工程师"、"JAVA开发(中级)"等几十种写法,直接按原始文本分组,统计结果会碎成几百个低频项。第二是薪资字段是字符串,区间和单位都不一样,有的按年算有的按月算,还有"面议"这种无法量化的值。第三是区域字段缺失,很多岗位信息里只写了"远程"或"全国"而没有具体城市,这部分记录要么补全要么在区域统计时单独聚合。
2.3 清洗规则落地:pandas 与正则的组合实现
data_clean.py 的核心是逐字段标准化函数,主清洗函数读取采集文件,对每个字段调用对应的清理函数,最后输出标准 DataFrame。下面是一段和源码实现思路基本一致的可运行代码:
# data_clean.py 核心清洗流程 import pandas as pd import re def clean_position_name(raw_name: str) -> str: # 去掉括号内容和“高级/中级/资深/急聘”等修饰词,保留核心岗位名 text = raw_name.strip() text = re.sub(r"[\((\[].*?[\))\]]", "", text) text = re.sub(r"(高级|中级|初级|资深|急聘|实习生)", "", text) return text.strip() def parse_salary(raw_salary: str): # 将 "8k-12k"、"8千-1.2万/月" 解析为最低月薪、最高月薪、平均月薪 if not raw_salary: return 0, 0, 0 text = raw_salary.replace("万", "*10000").replace("千", "*1000").replace("k", "000").replace("K", "000") digit_part = re.findall(r"[\d.]+", text) if len(digit_part) == 1: current = float(digit_part[0]) low = high = round(current, 2) elif len(digit_part) >= 2: low = float(digit_part[0]) high = float(digit_part[1]) else: return 0, 0, 0 avg = round((low + high) / 2, 2) return low, high, avg def run_clean(input_path: str, output_path: str): df = pd.read_csv(input_path) df["position_clean"] = df["position_name"].apply(clean_position_name) df[["salary_low", "salary_high", "salary_avg"]] = df["salary_range"].apply( lambda x: pd.Series(parse_salary(x)) ) df.drop_duplicates(subset=["position_clean", "company", "city"], keep="first") df.to_csv(output_path, index=False, encoding="utf-8-sig")这段代码里有几个参数值得关注。clean_position_name 的正则删掉的是常见后缀词,但不同行业差异比较大,金融行业常带"保代""分析师"这类本身有语义的词,直接用这套规则会误删,所以要根据自己数据集调整正则字符串。parse_salary 里用字符串替换把"千""万"换算成数字,但"30-50万/年"这类按年计的格式不能直接套用,需要加一个 annual 转 monthly 的换算系数,常见做法是先判断"年"关键字再除以 12。drop_duplicates 的 subset 用了岗位名、公司、城市三列组合,同一公司同一岗位在不同日期抓取时不会被误删。
提示:清洗阶段尽量不要用 map 直接替换整列字符串,除非你能确认字段值已穷尽所有情况,否则用函数配合正则的方式更便于后续维护。
2.4 清洗后的质量检查
框架搭好后怎么确认结果是否可靠?我的固定检查有三项:统计城市字段缺失占比,超过 5% 时调区域清洗规则;计算薪资平均值为 0 的记录数,排查是否来源于"面议"类字段;抽样 20 条 position_clean 看岗位名有没有被过度清理,避免把"C++开发工程师"拆成只剩"工程师"。清洗是可视化系统里最耗时但最不该跳过的环节,后续所有图表分布都依赖这一层的准确性。
3. 区域维度与存储设计:data_store.py 的数据组织方式
可视化系统里数据存储常常被忽略。清洗后的数据是一个扁平的宽表,如果每次图表的请求都在这个宽表上全表扫描聚合,数据量一旦上万,页面响应就会明显变慢。data_store.py 做的事情就是把扁平数据按维度和度量重新组织,但侧重的是数据怎么存、怎么索引、怎么被上层查询调用。
3.1 为什么区域做多级划分
源码包里的"区域划分、省级划分、地区划分"对应的是树形维度设计。可视化时先从全国总览进入省份视图,点某个省份下钻到城市,再展开到具体地区,这种逐层缩窄数据范围的方式在 BI 系统里叫钻取。三级划分最大的好处是有限画面内不会因为柱子太多而失去可读性,也为横向对比留出扩展空间。
三层维度对应的聚合粒度不同。省级划分适合看宏观分布,比如哪几个省份的岗位发布量占全国的一半以上;市级划分适合对比一线和新一线城市的岗位结构;地区级划分更贴近本地化分析,比如求职者想看某个片区周边有哪些匹配岗位。数据存储时保留三级编码而不是只存一个省字段,是为了让聚合查询能直接定位层级,不需要在查询时做字符串匹配。地区表单独维护一份维表,和事实表用代码关联。
3.2 核心表结构与批量写入逻辑
data_store.py 里的存储方案可以用 SQLite,岗位数据量通常不会太大,SQLite 单文件部署起来最简单,直接依赖 Python 标准库 sqlite3,不用额外启数据库服务。下面是岗位主表的最低结构设计:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PRIMARY KEY | 自增主键 |
| position | TEXT | 清洗后岗位名 |
| province_code | TEXT | 省级行政区划编码 |
| city_code | TEXT | 市级行政区划编码 |
| district_code | TEXT | 地区级行政区划编码 |
| salary_low | REAL | 最低月薪 |
| salary_high | REAL | 最高月薪 |
| salary_avg | REAL | 平均月薪 |
| education | TEXT | 学历要求 |
| work_exp | TEXT | 经验要求 |
| publish_date | TEXT | 发布日期 |
| source | TEXT | 数据来源标识 |
写入流程通常是两步:先根据区域维表解析出省市区编码,再批量插入岗位主表。批量插入比逐条 insert 快一到两个数量级,sqlite3 的 executemany 方法可以直接接收列表参数,commit 一次性提交能显著减少磁盘 IO。每次插入前建议先按发布时间清理旧数据,把历史采集任务当成增量任务处理,避免重复采集导致记录数翻倍。
# data_store.py 建表与批量写入 import sqlite3 import pandas as pd def create_tables(db_path: str): conn = sqlite3.connect(db_path) conn.execute(""" CREATE TABLE IF NOT EXISTS job_post ( id INTEGER PRIMARY KEY AUTOINCREMENT, position TEXT, province_code TEXT, city_code TEXT, district_code TEXT, salary_low REAL, salary_high REAL, salary_avg REAL, education TEXT, work_exp TEXT, publish_date TEXT, source TEXT ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_province ON job_post(province_code)") conn.execute("CREATE INDEX IF NOT EXISTS idx_position ON job_post(position)") conn.commit() conn.close() def batch_insert_jobs(db_path: str, df: pd.DataFrame): conn = sqlite3.connect(db_path) data_rows = [ (row.position, row.province_code, row.city_code, row.district_code, row.salary_low, row.salary_high, row.salary_avg, row.education, row.work_exp, row.publish_date, row.source) for row in df.itertuples(index=False) ] conn.executemany( "INSERT INTO job_post(position, province_code, city_code, district_code, " "salary_low, salary_high, salary_avg, education, work_exp, publish_date, source) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", data_rows ) conn.commit() conn.close()注意 batch_insert_jobs 中参数顺序和 SQL 语句里的问号占位符必须一一对应,否则会出现列错位这种隐蔽 bug。itertuples 返回命名元组,用 row.字段名 取值,比 iterrows 快不少。大批量写入时如果发现 executemany 仍不够快,可以检查 SQLite 的同步模式,测试环境下临时把PRAGMA synchronous = OFF能提速,但生产环境不建议关闭。
索引只建了 province_code 和 position 两列,原因是可视化里最高频的查询维度就是区域和岗位。索引过多会拖慢写入速度,像 SQLite 这种文件型数据库,几百万行以上的复合索引收益也不明显,保持单列索引就够了。
3.3 查询层如何支撑可视化接口
可视化系统最常见的查询是两种:按区域聚合岗位数和平均薪资,按岗位类型聚合数量趋势。这两类查询建议封装成函数而不是让前端直接拼 SQL,查询参数和 SQL 集中管理,后续换数据库或加缓存都方便。典型的区域统计查询函数如下:
def query_region_stats(db_path: str, level: str = "city", province_code: str = None): conn = sqlite3.connect(db_path) if level == "province": group_col, params = "province_code", [] elif level == "city" and province_code: group_col, params = "city_code", [province_code] else: group_col, params = "district_code", [] sql = f""" SELECT {group_col} AS region, COUNT(*) AS job_count, ROUND(AVG(salary_avg), 1) AS avg_salary FROM job_post WHERE 1=1 """ if province_code: sql += " AND province_code = ?" sql += f" GROUP BY {group_col} ORDER BY job_count DESC LIMIT 20" if params: df = pd.read_sql_query(sql, conn, params=params) else: df = pd.read_sql_query(sql, conn) conn.close() return df查询函数把 level 抽象成参数,可视化层只需要传省份编码和聚合层级就能拿到结果。这里用 f-string 拼接 group_col,但 group_col 的值来自函数内部白名单,不是用户直接输入,不受前端参数控制,所以不存在 SQL 注入风险。LIMIT 20 是防止前端一次性渲染几百根柱子导致图表堆叠,真正需要全量数据时可以单独写接口。
4. 可视化层装配:app.py 路由与 main.html 的页面架构
存储层准备好后,整个系统最直观的部分就是 Flask 后端路由把数据库里的数据取出,渲染到 templates/main.html 模板里,再由 static 下的 JS 和 CSS 完成图表渲染和交互。app.py 是这一环节的入口,也是整个 Flask 应用的启动文件。
4.1 Flask 主应用的路由组织方式
这个项目规模不需要拆蓝图,直接在 app.py 里定义三到四个路由即可,一个渲染页面,其余返回 JSON 数据供前端异步获取。下面是一段和源码思路一致的定义:
# app.py 核心路由 import sqlite3 from flask import Flask, render_template, jsonify, request import pandas as pd import data_store app = Flask(__name__) DB_PATH = "jobs.db" @app.route("/") def index(): return render_template("main.html") @app.route("/api/region_stats") def region_stats(): level = request.args.get("level", "city") province_code = request.args.get("province", "") df = data_store.query_region_stats( DB_PATH, level=level, province_code=province_code or None ) result = { "categories": df["region"].tolist(), "job_counts": df["job_count"].tolist(), "avg_salaries": df["avg_salary"].tolist() } return jsonify(result) @app.route("/api/position_trend") def position_trend(): conn = sqlite3.connect(DB_PATH) df = pd.read_sql_query(""" SELECT position, publish_date, COUNT(*) AS cnt FROM job_post WHERE publish_date >= date('now', '-30 day') GROUP BY position, publish_date """, conn) conn.close() return jsonify(df.to_dict(orient="records")) if __name__ == "__main__": app.run(host="127.0.0.1", port=5000, debug=True)index 路由本身不传数据,页面加载后由页面内的 JS 发起异步请求拉取 /api/region_stats,这种方式能减少首屏等待时间,图表数据是陆续加载的。request.args.get 用来读查询参数,level 默认是 city 对应城市级聚合,province 参数为空时忽略省份过滤。jsonify 是 Flask 自带的 JSON 响应函数,把 DataFrame 转成 list 后可以直接序列化。debug=True 在开发阶段可以热重载代码,改完保存后 Flask 服务自动重启,但线上部署时一定要关掉并换用 waitress 或 gunicorn。
4.2 main.html 的模板结构
templates/main.html 是整个可视化页面的骨架,Flask 默认从 templates 目录下寻找模板文件,render_template("main.html") 才能正确解析。模板里主要分三段:筛选项区、图表容器和统计信息区域。图表容器用 div 做占位,id 供 JS 引用,CSS 文件通过 static/css 目录引入:
<!-- templates/main.html 关键结构 --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>全国招聘就业可视化系统</title> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> </head> <body> <div class="filter-bar"> <select id="level-select"> <option value="province">省级</option> <option value="city" selected>城市</option> <option value="district">地区</option> </select> <button id="load-btn" type="button">更新视图</button> </div> <div class="chart-row"> <div id="chart-region" class="chart-box"></div> <div id="chart-salary" class="chart-box"></div> </div> <script src="{{ url_for('static', filename='js/echarts.min.js') }}"></script> <script src="{{ url_for('static', filename='js/main.js') }}"></script> </body> </html>阅读 README.md 时要注意它通常会写 Python 版本要求、依赖安装方式和启动命令。如果模板引用的静态文件 404,先检查 echarts.min.js 是否真的放在 static/js 下,Flask 默认静态目录是应用根目录下的 static 文件夹,路径写错是常见的小毛病。这个模板里没有 footer 或其他复杂布局,属于典型的管理面板风格,扩展时在 chart-row 后加新的 div 容器即可。
4.3 图表联动与交互参数的实现细节
前端 main.js 负责请求数据接口并渲染图表。图表联动是最能体现可视化交互能力的地方:点击某个省份的柱状图后,把省份编码传给图表,对应数据重新请求,形成从全国总览下钻到省份再到城市的递进关系。
// static/js/main.js var regionChart = echarts.init(document.getElementById("chart-region")); var salaryChart = echarts.init(document.getElementById("chart-salary")); function loadRegionStats(level, provinceCode) { var params = new URLSearchParams({ level: level }); if (provinceCode) params.set("province", provinceCode); fetch("/api/region_stats?" + params.toString()) .then(function (resp) { return resp.json(); }) .then(function (data) { regionChart.setOption({ xAxis: { data: data.categories }, series: [{ name: "岗位数量", type: "bar", data: data.job_counts }] }); salaryChart.setOption({ tooltip: { trigger: "axis" }, xAxis: { data: data.categories }, series: [{ name: "平均薪资", type: "line", data: data.avg_salaries }] }); }); } regionChart.on("click", function (params) { var regionName = params.name; loadRegionStats("city", regionName); }); document.getElementById("load-btn").addEventListener("click", function () { var level = document.getElementById("level-select").value; loadRegionStats(level, ""); });点击事件里用 params.name 拿到柱子对应的分类轴值,这个值是后端返回的 categories 成员。如果后端返回的是省份名称而不是省份编码,前端拿到的名称没法直接作为过滤参数传给 API,所以建议在后端返回结构里同时带上编码字段,比如 objects 数组而不是纯字符串数组,这是前后端联调时最容易踩的坑。setOption 不是覆盖模式,已经渲染的图表状态会保留,切换数据时如果结构差异较大,先手动调用 clear() 再重新 setOption。整个可视化性能瓶颈通常是数据序列化和网络请求,Flask 的 jsonify 默认会把中文转成 unicode 转义,不影响浏览器解析但响应体会明显膨胀,数据量大时建议在初始化后配置 JSON_AS_ASCII 相关参数来关闭转义。
5. 部署排错与二次开发:把源码跑起来再改出自己的版本
源码包能顺利跑起来是第一步,真正体现价值的动作在二次开发和排错环节。我从拿到这份源码到跑通整个流程,踩过几个比较典型的坑,梳理在这一章。
5.1 项目运行的最短路径
完整运行一个 Flask 数据可视化项目,核心操作只有四步:解压源码包、创建虚拟环境、安装依赖、启动 app.py。README.md 里如果写了 requirements.txt 就优先用文件安装,没有的话手动安装 Flask 和 pandas:
unzip Flask_mining_visualization-main.zip cd Flask_mining_visualization-main python -m venv venv source venv/bin/activate pip install flask pandas # 先执行数据存储模块完成建表和导入 python -c "import data_store; data_store.create_tables('jobs.db')" # 启动 Flask 开发服务 python app.py浏览器打开 http://127.0.0.1:5000 看到页面说明启动成功。如果页面没有数据,先去确认 jobs.db 里是否存在 job_post 表,以及是否已经执行过采集和清洗脚本往表里写入过数据。源码包如果包含了示例数据文件,执行 data_store 的批量导入脚本即可;如果没有,要按第 2 章的方式先准备数据。虚拟环境是必须的,不创建的话不同项目的依赖版本容易冲突,特别是 pandas 这种底层库。
5.2 高频报错与排查顺序
| 报错信息 | 排查方向 |
|---|---|
| jinja2.exceptions.TemplateNotFound | templates 目录或 main.html 不在预期位置 |
| sqlite3.OperationalError: no such table | 未先执行建表函数或数据库路径错误 |
| ModuleNotFoundError: No module named pandas | 虚拟环境未激活或依赖未安装 |
| fetch 返回 500 | 查看 Flask 控制台堆栈,多数是查询 SQL 或字段名错误 |
| echarts.min.js 404 | static 目录路径不对或文件未放入 |
最常见的是数据库路径问题。app.py 里 DB_PATH 写相对路径时,从项目根目录启动和从其他目录启动解析结果不一样,最好用绝对路径。我一般会在文件头部加上:
import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DB_PATH = os.path.join(BASE_DIR, "jobs.db")这样无论从哪里执行 python app.py,数据库文件都会固定在源码包根目录下,避免"数据写入了但查询不到"这类难排查的问题。另一个值得关注的是前端图表不显示但接口返回正常,这种情况先按 F12 打开浏览器开发者工具,看控制台报错是 ECharts 版本不支持还是 DOM 容器尺寸为 0,后者只要给 chart-box 加一个 min-height 就能解决。
5.3 在源码基础上加一个"行业-城市"交叉维度
这套系统目前能按区域聚合展示岗位数量和薪资,如果希望再深入一层,比如回答"某个行业在哪些城市招人最密集",可以在此基础上扩展。改动只需要动三个文件:data_clean.py 增加行业提取函数,data_store.py 的表结构加一个 industry 字段,前端 main.html 的下拉框增加行业筛选项。
# 在 data_clean.py 中追加行业提取 def extract_industry(position: str, company_desc: str = "") -> str: industry_map = { "计算机软件": ["java", "python", "c++", "前端", "测试"], "电子信息": ["嵌入式", "硬件", "fpga"], "金融": ["风控", "投行", "量化"], "医疗": ["医药", "临床", "生物"], "教育": ["教师", "课程", "教研"] } text = (position + company_desc).lower() for industry, keys in industry_map.items(): for key in keys: if key in text: return industry return "其他"行业字段提取准确率和关键词表的质量直接相关,初始版本先按大类分 5 到 6 类,跑一批数据看看"其他"占比,超过 30% 再补充行业词库。数据存储层加字段后要把之前的清洗数据重跑一遍,接口层加 industry 过滤条件,改动量很小但能立刻让系统从一个"岗位分布图"升级成"行业用工需求分析工具"。进阶一点的做法是把这个下钻逻辑做成可配置的字段维度映射表,前端不用改代码,后端接口接收任意维度和度量的组合参数,这部分留给有兴趣的读者继续扩展。
本文还有配套的精品资源,点击获取