基于Flask与Python的招聘数据可视化:从采集清洗到就业分析

发布时间:2026/9/14 4:14:38
基于Flask与Python的招聘数据可视化:从采集清洗到就业分析 简介基于Flask与Python构建的全国招聘岗位就业可视化系统是一份面向计算机相关专业学生、数据分析初学者及毕业设计者的完整项目资料。项目覆盖招聘数据采集、清洗、存储与可视化展示全流程包含省级、地区等多级岗位分布视图可直接运行观察效果也便于在此基础上拓展其他行业或城市维度的分析功能。资源共54个文件以txt数据及说明、Python脚本、JS交互逻辑、CSS/HTML前端页面与Markdown文档为主压缩包整体约364KBPython脚本承担数据获取、清洗与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], keepfirst) df.to_csv(output_path, indexFalse, encodingutf-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不用额外启数据库服务。下面是岗位主表的最低结构设计字段名类型说明idINTEGER PRIMARY KEY自增主键positionTEXT清洗后岗位名province_codeTEXT省级行政区划编码city_codeTEXT市级行政区划编码district_codeTEXT地区级行政区划编码salary_lowREAL最低月薪salary_highREAL最高月薪salary_avgREAL平均月薪educationTEXT学历要求work_expTEXT经验要求publish_dateTEXT发布日期sourceTEXT数据来源标识写入流程通常是两步先根据区域维表解析出省市区编码再批量插入岗位主表。批量插入比逐条 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(indexFalse) ] 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 11 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, paramsparams) 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, levellevel, province_codeprovince_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(orientrecords)) if __name__ __main__: app.run(host127.0.0.1, port5000, debugTrue)index 路由本身不传数据页面加载后由页面内的 JS 发起异步请求拉取 /api/region_stats这种方式能减少首屏等待时间图表数据是陆续加载的。request.args.get 用来读查询参数level 默认是 city 对应城市级聚合province 参数为空时忽略省份过滤。jsonify 是 Flask 自带的 JSON 响应函数把 DataFrame 转成 list 后可以直接序列化。debugTrue 在开发阶段可以热重载代码改完保存后 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 langzh-CN head meta charsetUTF-8 title全国招聘就业可视化系统/title link relstylesheet href{{ url_for(static, filenamecss/style.css) }} /head body div classfilter-bar select idlevel-select option valueprovince省级/option option valuecity selected城市/option option valuedistrict地区/option /select button idload-btn typebutton更新视图/button /div div classchart-row div idchart-region classchart-box/div div idchart-salary classchart-box/div /div script src{{ url_for(static, filenamejs/echarts.min.js) }}/script script src{{ url_for(static, filenamejs/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 和 pandasunzip 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.TemplateNotFoundtemplates 目录或 main.html 不在预期位置sqlite3.OperationalError: no such table未先执行建表函数或数据库路径错误ModuleNotFoundError: No module named pandas虚拟环境未激活或依赖未安装fetch 返回 500查看 Flask 控制台堆栈多数是查询 SQL 或字段名错误echarts.min.js 404static 目录路径不对或文件未放入最常见的是数据库路径问题。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 过滤条件改动量很小但能立刻让系统从一个岗位分布图升级成行业用工需求分析工具。进阶一点的做法是把这个下钻逻辑做成可配置的字段维度映射表前端不用改代码后端接口接收任意维度和度量的组合参数这部分留给有兴趣的读者继续扩展。本文还有配套的精品资源点击获取