Flask+Python招聘数据可视化系统实战:从爬虫清洗到ECharts地图部署
简介基于Flask与Python构建的全国招聘岗位就业可视化系统是面向计算机相关专业学生的毕设/课设参考项目也适合初级开发者作为Flask实战入门。系统覆盖数据采集、清洗、存储、后端服务与前端可视化完整链路支持按省级、地区等维度展示岗位数据分布。资源共54个文件核心为5个Python脚本数据采集、清洗、存储、主程序等36个txt文件按区域划分、省级划分等整理数据另有10个js、1个html和1个css构成前端图表界面配套README说明文档整体压缩包仅364KB目录结构清晰便于定位模块学习目前已有202人学习浏览。代码经作者毕设环境完整测试答辩平均分达96分可放心下载下载后遇到运行问题还可私聊远程教学适合直接用于课程设计、毕业设计演示也可在此基础上扩展其他招聘数据维度或可视化组件。1. 为什么招聘可视化系统会用 FlaskPython 这套组合来做求职者刷招聘网站时看到的只是单条岗位信息而隐藏在背后的行业岗位分布、城市薪酬梯队、技能需求排行其实是更值得关注的数据。把这些数据从招聘网站的 HTML 页面里抽出来清洗后存进数据库再通过页面上的地图、柱状图、词云呈现出来这就是一个典型的就业可视化系统。这类系统之所以偏爱 FlaskPython是因为数据抓取、清洗、分析本身是 Python 的强项而 Flask 又能用最少的代码把数据以 API 形式暴露给前端不需要像 Spring 那样铺垫大量工程结构。这套方案对两类人特别实用一类是要做软件综合实践选题的学生另一类是关注就业市场变化的工程师。系统不复杂但覆盖了爬虫、数据库设计、Web API、ECharts 可视化、部署上线这一整条链路拿它练手比零散地学 Flask 教程更有体感。本文就按数据层、接口层、展示层、部署这条线把一个可运行的全国招聘岗位就业可视化系统的完整做法讲清楚只要环境里装了 Python 3.8 和 MySQL跟着步骤就能跑起来。2. 全国招聘数据采集与清洗决定可视化效果的上限2.1 招聘数据从哪来字段怎么设计常见的做法是从主流招聘网站按城市、岗位关键词搜索抓取搜索结果列表页和详情页。出于合规考虑这里不讲具体网站的绕过策略只说明数据获取的通用思路用 requests 请求页面用 lxml 或 BeautifulSoup 解析 HTML把结构化字段写入 MySQL。更稳妥的方式是优先寻找公开数据集或官方 API如果都没有再考虑用爬虫且严格遵守目标网站的 robots 协议和访问频率限制。无论数据来源是爬虫还是离线数据集落地到 MySQL 前都要先定好表结构。下表是我在类似项目中验证过的一套字段设计足够支撑城市分布、薪资分析、技能词云等核心功能字段名类型说明idBIGINT主键自增job_titleVARCHAR(200)岗位名称company_nameVARCHAR(200)公司名称cityVARCHAR(50)工作城市salary_min / salary_maxINT月薪上下限单位千元experienceVARCHAR(50)经验要求educationVARCHAR(50)学历要求skill_tagsVARCHAR(500)技能标签逗号分隔publish_dateDATE发布日期industryVARCHAR(100)所属行业建表语句里要给 city、skill_tags 加上索引因为后续可视化查询几乎都是按城市聚合和按技能筛选没有索引的 COUNT 和 GROUP BY 在高数据量下会明显变慢。CREATE TABLE job_post ( id BIGINT AUTO_INCREMENT PRIMARY KEY, job_title VARCHAR(200) NOT NULL, company_name VARCHAR(200), city VARCHAR(50) NOT NULL, salary_min INT, salary_max INT, experience VARCHAR(50), education VARCHAR(50), skill_tags VARCHAR(500), publish_date DATE, industry VARCHAR(100), KEY idx_city (city), KEY idx_skill (skill_tags(32)) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里把 skill_tags 建成前缀索引而不是全文索引是因为词云场景只需要等值或模糊匹配前缀索引在占用空间和查询速度上更均衡。utf8mb4 是必要的招聘标题和技能标签里会出现特殊字符和 emojiutf8mb4 才能完整存储。2.2 清洗薪资字段处理“面议”和区间值招聘数据里最脏的字段就是薪资。不同网站格式差异很大常见的写法有“10-15K”“8k-12k·14薪”“面议”“25-40K·16薪”。如果直接拿字符串展示后续按薪资排序、统计平均薪资、做区间分布时全部都会出问题。因此入库前必须把薪资解析成数值。我一般用一个正则加判断的清洗函数import re def parse_salary(text: str): if not text or 面议 in text: return None, None nums re.findall(r\d(?:\.\d)?, text) if len(nums) 2: return None, None low float(nums[0]) high float(nums[1]) if 万 in text: low, high low * 10, high * 10 elif k in text.lower() or k in text: pass return int(low), int(high)逻辑分三层第一层处理“面议”和空值第二层用正则提取所有数字第三层判断单位是“万”还是“k”统一换算成千元。这样数据库里只有两个整数查询时可以用 (salary_min salary_max)/2 计算平均薪资用 salary_max 排序找高薪岗位。清洗时最容易忽略的是“14薪”“16薪”这类倍数表达它们影响的是年薪估算而不是月薪比较本系统里不纳入计算如果后续要加年终奖维度再去单独解析。城市字段同样要做映射比如把“北京·朝阳区”“北京-海淀”统一归为“北京”否则地图上会多出几十个脏类别。2.3 可视化查询要预聚合别让页面卡在 SQL 上可视化系统的数据量通常在几万到几十万条直接在前端渲染这么多点是没意义的。地图、柱状图、词云需要的都是聚合结果。正确的做法是启动时或定时执行聚合查询把结果写入独立的 summary 表前端接口只查这张小表。CREATE TABLE job_city_stats ( city VARCHAR(50) PRIMARY KEY, job_count INT, avg_salary FLOAT, top_skills VARCHAR(500) );聚合语句按城市分组统计并用 GROUP_CONCAT 拼出出现频次最高的几个技能标签。这样接口响应时间从秒级降到毫秒级也为后续部署减轻压力。3. Flask 后端架构与 API 接口设计3.1 用蓝图拆分模块不把路由全堆在一个文件里Flask 项目最常见的失败方式是把所有路由写在一个 app.py 里最终变成一个几千行的文件。对于这类可视化系统推荐按数据域拆分蓝图api 负责 JSON 接口main 负责渲染页面模板analysis 负责聚合数据的查询逻辑。这虽然不会让功能变多但在加接口、排查错误时的体验差别很大。项目结构如下job_visualization/ ├── app.py ├── config.py ├── models/ │ ├── __init__.py │ └── db.py ├── views/ │ ├── __init__.py │ ├── analysis_api.py │ └── page_view.py ├── static/ │ ├── echarts.min.js │ ├── china.json │ └── style.css └── templates/ └── index.htmlconfig.py 里集中放数据库连接参数和一个 SECRET_KEY。app.py 只做三件事创建实例、注册蓝图、启动。这是 Flask 官方推荐的应用工厂模式简化版对中小项目足够。# app.py from flask import Flask from config import Config from views.analysis_api import analysis_bp from views.page_view import page_bp def create_app(): app Flask(__name__) app.config.from_object(Config) app.register_blueprint(page_bp) app.register_blueprint(analysis_bp, url_prefix/api) return app if __name__ __main__: app create_app() app.run(host0.0.0.0, port8000, debugFalse)注册 api 蓝图时指定 url_prefix/api下面蓝图里的所有路由就自动带上了前缀后面加版本号 /api/v1 也很方便。host 设为 0.0.0.0 是为了让局域网内其他机器也能访问这在部署到服务器时尤其重要——只监听 127.0.0.1 会导致外部请求全部被拒绝。3.2 数据查询接口参数与分页怎么设计接口设计要贴合前端可视化组件的需求。地图组件需要一次拿到所有城市的岗位数柱状图需要按岗位类型或城市排序后的 Top N词云需要技能及其出现频次。因此最少需要三个核心接口城市统计、岗位 Top 榜、技能词云数据。# views/analysis_api.py from flask import Blueprint, request, jsonify from models.db import query_db analysis_bp Blueprint(analysis, __name__) analysis_bp.route(/city) def city_stats(): top_n request.args.get(top, default30, typeint) result query_db( SELECT city, job_count, avg_salary FROM job_city_stats ORDER BY job_count DESC LIMIT %s, (top_n,) ) return jsonify({code: 0, data: result}) analysis_bp.route(/salary) def salary_rank(): city request.args.get(city, default全国, typestr) result query_db( SELECT job_title, avg_salary FROM job_post WHERE city%s AND salary_min IS NOT NULL ORDER BY (salary_minsalary_max)/2 DESC LIMIT 10, (city,) ) return jsonify({code: 0, data: result})参数说明参数类型默认值作用topint30控制返回的城市数量地图只显示前 30 个热门城市citystr全国薪资榜的过滤条件传城市名时只看该城市岗位pageint1岗位列表分页页码详情页使用sizeint20每页条数限制单次返回的数据量使用 typeint 对请求参数做类型转换是防止恶意传非数字参数最简的方法不会因为非法参数导致 500 错误。换届里没有用 try/except 包住查询实际项目要包并返回统一的错误码结构方便前端处理。3.3 CORS 与 JSON 中文编码两个必踩的坑前端如果用 fetch 直接访问 Flask 接口而两者不在同一个域名下比如前端是 Node 服务或本地文件打开浏览器会拦截跨域请求。解决方式有两种简单的方式是在 app 上配置 CORS 允许全部来源规范的方式是用 Nginx 反向代理让前后端同源。JSON 中文编码是另一个高频问题。Flask 的 jsonify 默认会保证中文不转义但如果你自己用 json.dumps 构造响应需要显式指定 ensure_asciiFalse否则前端拿到的是 \uXXXX 转义文本虽然能正常显示但调试时很难读。我通常会在返回前统一通过 Flask 的 jsonify 处理避免这类隐性差异。4. ECharts 全国地图与岗位可视化页面实现4.1 分层渲染先地图后图表让数据故事有递进感可视化页面不能把地图、柱状图、词云堆在一屏要按“总览-城市-岗位”的逻辑逐层展开。顶层是全国地图用颜色深浅表示各城市岗位数量点击某个城市后下方区域切换成该城市的 Top 岗位薪资榜和技能词云。这个交互逻辑用 ECharts 的事件 API 很容易实现而且交互本身会显著降低页面同时渲染多个图表的卡顿问题。页面骨架用模板渲染图表部分用 EChartsdiv idmap styleheight:480px;/div div idrank styleheight:320px;/div div idcloud styleheight:320px;/div4.2 全国地图注册 GeoJSON 的正确姿势ECharts 从 5.0 开始不再内置地图数据绘制全国地图需要手动注册 GeoJSON。常见的做法是从公开的 GeoJSON 数据源下载 china.json 放到 static 目录然后通过 fetch 加载后注册到 ECharts。注册代码要放在图表初始化之前。fetch(/static/china.json) .then(res res.json()) .then(geoJson { echarts.registerMap(china, geoJson); initMapChart(); }); function initMapChart() { const chart echarts.init(document.getElementById(map)); chart.setOption({ tooltip: { trigger: item }, visualMap: { min: 0, max: 10000, left: left, text: [高, 低], inRange: { color: [#e0f3f8, #abd9e9, #74add1, #4575b4] }, calculable: true }, series: [{ type: map, map: china, roam: true, data: cityData }] }); chart.on(click, function(params) { loadCityDetail(params.name); }); }visualMap 参数是整个地图配色的关键max 值如果不设置或设置过小全国地图的高值城市会全部显示为深色视觉上失去区分度。更好的做法是接口返回城市数据时同时返回 job_count 的最大值前端把这个值动态赋给 visualMap.max而不是写死。roam 开启后用户可以用滚轮缩放和拖拽配合 tooltip 在悬浮时展示岗位数字。4.3 词云和薪资榜的图表配置细节词云组件 ECharts 官方没有内置需要引入 echarts-wordcloud 扩展。这个插件在 ECharts 5 下有单独的版本不能用旧版的 script 直接替换。词云数据接口返回的是技能名称和词频写进 series 的 data 数组即可。fetch(/api/skill?city${currentCity}) .then(res res.json()) .then(data { cloudChart.setOption({ series: [{ type: wordCloud, shape: circle, sizeRange: [14, 60], rotationRange: [0, 0], textStyle: { color: function() { return rgb( [64, 160, Math.round(Math.random() * 160)].join(,) ); } }, data: data.skills }] }); });rotationRange 设为 [0, 0] 是为了保证所有词横向排列。水平排列在中文词云里可读性最好竖排文字会让标签被截断的几率变大。sizeRange 的两个值分别对应词频最小和最大时的字体尺寸词频差异大时把最大值调小可以避免个别词占用过多空间。4.4 页面加载优化数据预取与按需渲染页面一打开就同时发三个请求地图数据和词云数据会争抢带宽在小带宽服务器上表现为白屏时间长。常见的优化方式是先请求地图和城市统计地图渲染完成后再根据城市数据把柱状图和词云的数据源准备好技能词云只在用户点击城市时才请求。如果城市数据总量不大也可以把技能聚合表一次请求回来前端用 Map 对象按城市名取数据这样后续点击城市完全不需要请求接口。两者选哪种取决于聚合表数据量超过 10000 条就建议按城市懒加载。接口请求失败时页面不能静默失败。要写一个统一的错误弹层把接口返回的 code 非 0 的情况和网络错误区分开提示用户刷新而不是误以为系统数据为空。5. 系统部署与性能调优的实用经验5.1 开发环境多久跑一次生产环境用什么启动Flask 自带的开发服务器是 Werkzeug调试时非常好用改动代码后自动重载但它只适合单机单进程开发吞吐量很低。生产环境我一般用 Gunicorn 启动命令是三行pip install gunicorn gunicorn -w 4 -b 0.0.0.0:8000 app:create_app()-w 4 表示开 4 个 worker 进程能利用多核 CPU 并发处理请求-b 指定监听地址和端口0.0.0.0:8000 表示对所有网卡开放app:create_app() 是 Flask 应用工厂的入口Gunicorn 会调用工厂函数获得应用实例worker 数不是越多越好一般设置为 CPU 核心数的 2 倍加 1。对于 MySQL 连接来说每个 worker 持有独立连接池worker 数过多会造成数据库连接数暴涨反而拖慢整体性能。如果机器是 2 核4 个 worker 是合理值。5.2 Nginx 反向代理与静态资源缓存策略Gunicorn 只处理 Python 请求静态文件ECharts 的 JS、GeoJSON、CSS如果也从 Gunicorn 走会白白消耗 Python 进程的资源。正确做法是在 Gunicorn 前面套一层 Nginx把 /static 路径直接交给 Nginx 处理API 请求才转发到 Gunicorn。server { listen 80; server_name your_domain; location /static/ { root /opt/job_visualization/; expires 7d; add_header Cache-Control public; } location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; add_header Cache-Control no-store; } location / { proxy_pass http://127.0.0.1:8000; } }这段配置里最关键的是两个 Cache-Control 的区分静态资源设置 7 天缓存让浏览器第二次加载页面时直接读本地缓存不再发请求API 数据设置 no-store确保每次刷新页面拿到的都是最新数据否则用户会看到前天缓存下来的岗位数据误以为是实时的。GeoJSON 这种不常变的大文件如果更新了要手动改文件名或在 Nginx 里加版本号查询参数来强制刷新。5.3 首次部署最常踩的三个坑这三件事是我帮别人排查 Flask 项目部署问题时最常见的原因第一个坑是找不到模板或静态目录。本地跑正常、部署到服务器后 404多半是文件路径用了绝对路径或者 Flask 实例化时没指定 template_folder 和 static_folder。用相对路径创建 Flask 应用时工作目录不同就会找不到建议在 app.py 开头显式拼接 BASE_DIRimport os BASE_DIR os.path.dirname(os.path.abspath(__file__)) app Flask(__name__, static_folderos.path.join(BASE_DIR, static), template_folderos.path.join(BASE_DIR, templates))第二个坑是 MySQL 的字符集不对存入的中文变成问号。建库时就要指定 utf8mb4建表语句也要带上 DEFAULT CHARSETutf8mb4并且连接字符串里配置 charsetutf8mb4三者缺一不可。已经建好的表可以修改字符集但历史数据如果是乱码就救不回来了。第三个坑是防火墙和安全组没放开端口。服务器上 Flask 正常启动本机 curl 127.0.0.1:8000 有响应但浏览器访问不了这时先检查云服务商的安全组规则是否放行了对应端口再检查本机防火墙。启动完成后验证系统是否正常不能只看页面能否打开要在服务器上执行几个关键接口的检查用命令行确认数据正确性curl http://127.0.0.1:8000/api/city?top5 curl http://127.0.0.1:8000/api/salary?city上海第一个接口返回前 5 个城市的岗位数量第二个接口返回上海的 Top 薪资岗位。只要这两个 JSON 结构正确前端的图表就一定能跑起来剩下的样式问题都好排查。在生产环境中遇到“当前不会命中断点”这类调试问题要确认 Python 进程是以哪种方式启动的Gunicorn 默认不附加调试器通过 IDE 远程调试需要额外配置普通排查场景直接看日志文件的异常堆栈更高效。本文还有配套的精品资源点击获取