news 2026/9/18 21:35:57

招聘数据可视化实战:从Python清洗到Flask交互面板

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
招聘数据可视化实战:从Python清洗到Flask交互面板

简介:针对招聘信息可视化分析场景,这份基于Python的完整实践文档,主要面向数据分析初学者、求职者以及企业HR等读者,旨在解决如何从招聘平台获取职位信息并挖掘市场需求、薪资水平、技能要求等关键洞察的问题。文档内容源自《计算机与网络》2020年第02期,系统覆盖Python基础、Scrapy/BeautifulSoup数据抓取、Pandas数据清洗与转换、Matplotlib/Seaborn/Plotly可视化展示、结果解读以及定时动态更新等环节,并配合条形图、直方图、词云图、地理热图等图表案例,便于读者对照学习,对求职选择、企业招聘策略和行业观察均有参考价值。整个资源仅含1个docx文档,压缩包大小约198KB,轻量易读,可快速下载使用。目前已有129人学习,适合具备基础Python语法、希望掌握招聘数据全链路分析并输出可视化报告的入门与进阶学习者。

1. 为什么招聘数据分析要先解决“脏”而不是“多”

在一堆“精通Python、3-5年经验、薪资面议”的职位里,真正值得分析的往往不是岗位数量,而是岗位描述里的细微差异——薪资范围是8k-15k还是15k-30k,“熟悉”和“精通”背后对应的技能组合,以及同样标题在不同城市的薪酬带宽。招聘信息可视化分析的难点从来不在画图,而在于把非结构化的JD文本、混乱的薪资格式、多义词技能标签整理成一张能查、能算、能过滤的干净表格。这篇内容按一条常见可落地的路径展开:先从公开招聘页面抓取原始数据,再用Python清洗出结构化字段,接着用pyecharts做交互可视化,最后落到一个Flask小面板上,让非技术人员也能自己筛选条件看图。适合已经会写Python基础语法、想走一遍完整数据分析流程的人,也适合需要在简历里展示一个完整数据作品的求职者。

2. 数据获取与清洗:先用Python把“千岗千面”洗成一张表

2.1 抓到原始数据的第一步:选源与请求头

招聘数据不会凭空出现在Excel里,常见做法是爬取公开招聘平台的职位搜索页。不推荐直接抓网页正文,因为反爬严重、结构变动快;更稳的做法是先检查目标站点是否有公开API接口,很多招聘网站的前端搜索功能背后就是一个JSON接口,返回字段比解析HTML稳定得多。就算没有开放API,用requests直接请求列表页也比用Selenium轻量,至少能省掉浏览器渲染的开销。

一个最朴素的请求写法长这样:

import requests headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/json, text/plain, */*", "Referer": "https://example.com/jobs", } resp = requests.get("https://api.example.com/job/search", params={"keyword": "Python", "city": "101010100", "pn": 1}, headers=headers, timeout=10) data = resp.json()

这段代码里真正值得注意的不是requests本身,而是headers里的三个字段:User-Agent用来告诉服务器“我是一个正常浏览器”,Referer用于通过部分站点的来源校验,Accept决定了API是否愿意返回JSON而不是HTML。timeout=10是必须的,不设超时的爬虫会在网络抖动时挂死整个任务。

拿到JSON之后立刻落盘成原始文件,别直接处理。常见做法是把每次请求的结果追加到一个JSONL文件里,每行一条原始记录。这样后续清洗脚本可以反复重跑,而不用重新请求服务器。

2.2 清洗的第一步:用正则抽取薪资区间

招聘平台的“15K-25K·14薪”这类字段,看起来比“面议”好处理,但真正解析时会遇到各种形态:

  • 12-18K
  • 15k-25k·13薪
  • 8千-1.2万
  • 1-1.5万/月
  • 25-35K/月

清洗逻辑不能写死在一条正则里,正确做法是先做薪资单位归一化,再提取数字边界。我一般会先写一个函数把“万”转成“k”,再统一匹配所有数字:

import re def normalize_salary(text): if not text or "面议" in text: return None, None text = text.replace("万", "k") # 统一“15k”和“15K” text = text.lower() nums = re.findall(r"(\d+\.?\d*)k", text) if len(nums) >= 2: low = float(nums[0]) high = float(nums[1]) elif len(nums) == 1: low = high = float(nums[0]) else: return None, None return low * 1000, high * 1000

这里有几个容易踩的坑:第一,“1-1.5万/月”如果不先替换成k,正则匹配不到数字;第二,有的职位写“20-30K·14薪”,薪资范围后面跟着年终月数,用findall把所有带k的数值都取出来,正常情况下只有前两个是薪资边界;第三,解析结果建议以“元/月”为单位存两个数字字段,而不是存原始字符串,排序和区间筛选都会方便得多。

2.3 从JD文本里抽取技能标签

JD里的“熟悉Python、flask、sql”这类句子,是可视化分析里技能分布和技能-薪资交叉分析的核心原料。抽取方式看数据规模:几千条数据的量级用规则匹配就够了;如果是几万条以上,再考虑用jieba分词加词典维护。规则匹配的做法是维护一个技能词典,然后逐条去JD文本里in判断:

SKILLS = ["python", "flask", "django", "fastapi", "sql", "mysql", "postgresql", "redis", "docker", "kubernetes", "linux", "git", "spark", "hadoop", "tensorflow", "pytorch", "scrapy", "numpy", "pandas", "vue", "react"] def extract_skills(jd_text): text = jd_text.lower() matched = [skill for skill in SKILLS if skill in text] return matched

这段代码的局限在于“python”会误匹配“python开发工程师”这种职位名称,但考虑到JD本身就会写“熟练使用python”,误匹配比例不高。更可控的做法是把技能词典换成dict,给每个词加上权重,命中后按权重算技能分,但这个设计在爬虫项目里属于优化项,第一版直接返回命中列表就行。

2.4 数据落库:为什么用SQLite而不是直接存CSV

清洗完的数据存在哪,直接影响后续可视化的查询效率。几千行数据用CSV当然可以,但一旦想“按城市筛再按薪资排序”,pandas每次都要全表读一遍。我一般用Python内置的sqlite3模块落库,省去安装数据库服务的依赖,又能享受SQL的查询效率:

import sqlite3 conn = sqlite3.connect("jobs.db") conn.execute(""" CREATE TABLE IF NOT EXISTS job ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, company TEXT, city TEXT, salary_low INTEGER, salary_high INTEGER, skills TEXT, jd TEXT ) """)

建表时把skills存成逗号分隔的字符串,查询时再用pandas的str.contains做筛选。虽然这不符合数据库第三范式,但数据分析场景下这样反而简单。数据量在十万行以内,这个设计不会出现性能问题。

3. 可视化设计:用pyecharts把“岗位-城市-薪资”装进一张图

3.1 选pyecharts而不是matplotlib的理由

招贴信息分析师的核心产出是让业务方看明白“哪个城市给钱多、什么技能值钱”,这类问题天然适合交互式图表。pyecharts生成的是HTML文件,图表的tooltip、图例筛选、数据缩放都是自带交互的,导出发给任何人都能用浏览器打开,不用装Python环境。相比之下matplotlib是静态图,适合论文,不适合在团队里快速传阅。

安装直接走pip:

pip install pyecharts

注意pyecharts 2.x版本自带同步的图表类型,不需要额外装echarts包。如果你看到网上老教程里写的from pyecharts import Bar这种导入方式,那是0.5.x老版本的写法,新版本导入方式完全不同。

3.2 第一张图:城市平均薪资横向柱状图

招聘分析里最常用的第一张图,是“目标岗位各城市平均薪资Top 10”。这张图能快速回答一个基础问题:同样写Python,在哪个城市拿到的薪资更高,以及高多少。

import pandas as pd from pyecharts.charts import Bar from pyecharts import options as opts df = pd.read_sql("SELECT city, AVG((salary_low + salary_high)/2) AS avg_salary FROM job GROUP BY city ORDER BY avg_salary DESC LIMIT 10", conn) bar = ( Bar() .add_xaxis(df["city"].tolist()) .add_yaxis("平均薪资(元/月)", df["avg_salary"].round(0).tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="Python岗位城市薪资Top10"), yaxis_opts=opts.AxisOpts(name="元/月"), ) ) bar.render("city_salary.html")

这段代码的数据库查询是核心:AVG((salary_low + salary_high)/2) 用薪资中位数近似平均月薪。用AVG而不是GROUP BY之后在pandas里算,能让SQL少返回数据量,十万行以内响应速度差距不大,但养成在数据库层做聚合的习惯,对后续处理更大数据集有好处。

3.3 第二张图:技能薪资箱线图

比城市分布更能体现招聘质量分析价值的,是技能维度。把“要求会Docker的岗位薪资”和“完全不提Docker的岗位薪资”放在一起比较,能看出一个技能到底值多少钱。箱线图是合适的选择,因为它能展示中位数、四分位数和离群点,不会被少数超高薪岗位拉偏均值。

在pyecharts里,Boxplot需要先用pandas把数据整理成嵌套列表格式,每个技能对应一组薪资数组:

from pyecharts.charts import Boxplot skill_salary = [] for skill in ["docker", "spark", "flask", "django"]: salaries = df[df["skills"].str.contains(skill)]["salary_mid"].tolist() skill_salary.append(salaries) boxplot = ( Boxplot() .add_xaxis(["docker", "spark", "flask", "django"]) .add_yaxis("薪资分布", boxplot.prepare_data(skill_salary)) .set_global_opts(title_opts=opts.TitleOpts(title="技能要求与薪资分布"), yaxis_opts=opts.AxisOpts(name="元/月")) )

注意boxplot.prepare_data(skill_salary)这步是Boxplot特有的,它会把传入的原始数组列表自动计算成五个分位数值。如果忘了调用prepare_data直接传列表,图表会渲染成空白或报错。

3.4 地图组件:用Map画省份岗位密度

城市维度的数据还能用地图表达:把“每个城市的岗位数量”映射到省份地图上,颜色越深代表岗位越多。pyecharts的Map组件需要先准备省份中文名和对应数值的列表:

from pyecharts.charts import Map province_count = df.groupby("province").size().reset_index(name="count") data_pair = list(zip(province_count["province"], province_count["count"])) map_chart = ( Map() .add("岗位数", data_pair, "china") .set_global_opts( title_opts=opts.TitleOpts(title="Python岗位全国分布"), visualmap_opts=opts.VisualMapOpts(max_=int(province_count["count"].max())), ) )

地图有个常见坑:部分招聘平台返回的城市字段是“北京”“上海”这种直辖市名称,而Map组件的china地图要求的省份列表里包含的是“北京市”“上海市”。如果不做归一化,地图上对应的区域会显示为空。解决方法是准备一份城市到省份的映射字典,并且处理“市”“省”后缀。

3.5 词云:JS版本组件,别做成静态图

词云是招聘JD可视化里最讨喜的一张图,但pyecharts内置的WordCloud组件在新版本里依赖pyecharts-snapshot渲染,比较麻烦。我一般直接在HTML模板里引入echarts-wordcloud的JS文件,用pyecharts的Page()把词云和其他图合并输出,不单独渲染词云脚本。更主流、更容易搜到资料的做法是直接使用pyecharts的WordCloud类:

from pyecharts.charts import WordCloud word_freq = df["skills"].str.split(",").explode().value_counts().head(50) wc = ( WordCloud() .add("", [list(item) for item in word_freq.items()], word_size_range=[20, 100]) .set_global_opts(title_opts=opts.TitleOpts(title="JD技能标签词云")) )

这里把series展开成每个技能一行的数据,再value_counts统计频次,生成的是“技能名+出现次数”的二元组列表。词云的可视化价值是让业务方一眼看到“这个岗位最看重的技能排序”,在图旁边建议放一个文字说明,标注这是出现频次统计而不是权重分析。

4. 把可视化做成可筛选的页面:Flask + ECharts联动查询

4.1 为什么需要一层后端页面

单张HTML图表的局限是筛选条件写死在代码里。实际使用场景是:业务方打开页面,想只看“北京的前端岗位”,不想看全国数据。在用户不碰Python代码的前提下,这需要做一层Web界面。常见做法是用Flask搭一个轻量服务,提供两个能力:一个页面用于配置筛选条件,一个JSON接口用于按条件查询数据并返回图表所需的数据结构。

整个系统的技术栈是Flask做路由和API,pyecharts生成图表JSON,前端用原生ECharts渲染。所谓“Python爬虫可视化界面”的最简落地形态,就是这么一套东西。

4.2 先写好查询接口,再写页面

后端代码的核心是一个接收GET参数返回JSON的数据接口:

from flask import Flask, request, jsonify import sqlite3, json app = Flask(__name__) @app.route("/api/jobs") def jobs_api(): city = request.args.get("city", "") skill = request.args.get("skill", "") where = [] params = [] if city: where.append("city = ?") params.append(city) if skill: where.append("skills LIKE ?") params.append(f"%{skill}%") sql = "SELECT city, salary_low, salary_high, title FROM job" if where: sql += " WHERE " + " AND ".join(where) conn = sqlite3.connect("jobs.db") df = pd.read_sql(sql, conn, params=params) result = { "count": len(df), "avg_salary": round(float(df[["salary_low", "salary_high"]].mean().mean()), 2), } return jsonify(result) if __name__ == "__main__": app.run(port=5000)

这里用了参数化查询而不是f-string拼接字符串,原因很简单:招聘数据里既然包含了“skill”这种用户可输入字段,就必须防SQL注入。?占位符配合params参数列表是sqlite3标准的防注入写法,没有理由不这么做。注意df[["salary_low","salary_high"]].mean().mean()是先算列均值再算整体均值,结果是可读的月薪水平。

4.3 前端页面:一个下拉框加一张图

HTML页面里用原生fetch调上面这个接口,把返回数据渲染进ECharts:

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>招聘信息可视化面板</title> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> </head> <body> <select id="citySelect"> <option value="">全部城市</option> <option value="北京">北京</option> <option value="上海">上海</option> <option value="深圳">深圳</option> </select> <button onclick="loadData()">筛选</button> <div id="chart" style="width:800px;height:500px;"></div> <script> function loadData() { const city = document.getElementById('citySelect').value; fetch(`/api/jobs?city=${encodeURIComponent(city)}`) .then(res => res.json()) .then(data => { const chart = echarts.init(document.getElementById('chart')); chart.setOption({ title: { text: `平均薪资:${data.avg_salary} 元/月` }, series: [{ type: 'bar', data: [data.avg_salary] }] }); }); } loadData(); </script> </body> </html>

这个前端页面没引入任何框架,用原生JS就够了,避免把项目复杂化成前后端分离架构。实际项目里会把图表做得更丰富,但这个架构说明了每一层都在干什么:前端发条件、后端查库、数据库聚合、图表更新。

4.4 自动化定时更新数据的设计

招聘数据不是一次性分析完就结束的。每天抓一次数据,累计两周之后才能看出“Python岗位的平均薪资是涨了还是跌了”。定时更新的常见做法是用APScheduler在Flask里加一个后台任务,或者更轻量地在服务器上配置cron:

30 8 * * * cd /path/to/project && python crawl.py >> crawl.log 2>&1

这里两个要点:crawler脚本必须用绝对路径或先cd进项目目录,否则相对路径的SQLite库会写错位置;输出重定向到log文件是为了排查爬虫被封或数据异常。加一个简单的增量策略,每次抓取前先查数据库里最新的职位ID,只抓新ID的数据,这样不会产生重复记录。

5. 四个影响分析结论的隐藏坑

5.1 薪资字段的“迷信”数据要单独处理

招聘平台经常出现“15-18K·14薪”和“15-18K”两个看似不同的岗位,实际年总收入差异很大。按之前的正则解析逻辑,14薪的信息会被丢弃,导致分析结果有偏差。重要处理方式是增加salary_months字段:正则匹配出薪字后面的数字,削成一个月度工资乘数。

months = re.search(r"(\d{2})薪", text) salary_months = int(months.group(1)) if months else 12

分析“目标薪资”时把月薪乘以月份数,算的是“预期年收入”;分析“基本工资”时用月薪原值。两种口径分开出图,不然业务方会质疑数据真实性。这个字段的清洗逻辑不需要写进第一版,但在项目文档里必须标注“未处理多薪字段”,否则接手的人会误用。

5.2 城市字段的别名问题比想象中严重

爬虫拿到的城市字段可能是“北京市”“北京”“朝阳区”三种形态,地图组件和分组统计都对不上。处理方式不复杂,但要警惕“朝阳区”这种区级数据,这个字段在招聘平台里通常是“工作地址所在区”,不能当成城市用,需要从完整地址里反推城市名。最可靠的做法是以职位页里的城市ID为基准,用ID映射城市名,而不是解析文本地址。

5.3 词云的中心词污染

技能词云容易出现一个反直觉现象:“python”本身永远排在第一位,甚至盖过所有其他真实技能。原因很简单,招聘标题本身就带python,它的频次天然偏高,并不能说明它是岗位的核心技能。排查方法是把“python”“岗位名里的核心词”这类词从技能词典里剔除,或者在词云旁边注明“频次Top榜含标题自带词”。否则业务方会问“我已经知道要python,到底还要会什么”。

5.4 动态更新的数据先落原始库再做清洗

爬虫改成每半小时跑一次之后,清洗脚本也随频率重跑,会出现“正在处理的表被另一个进程写入”的问题。SQLite在默认事务隔离级别下可能报database is locked。稳妥做法是:爬虫写入staging表,清洗脚本把staging表读入pandas清洗后写入正式表,最后清空staging表。这个架构在数据量小时略微增加复杂度,但避免了脏数据、锁竞争、重复清洗三个问题。

最后再验证一个点:可视化面板上线后,用一条SQL检查两个数字——数据总数是否每天递增、清洗后的有效薪资占比是否稳定在90%以上。有效薪资占比突降多半是页面结构变了,我的习惯是在爬虫脚本里加一个if len(job_list) == 0: send_alert()的告警,在数据源出问题之前先收到通知。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 21:34:44

执行框架旁边,TaoToken Key 在 MiMo Desktop 中如何被调用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 21:34:42

记录 Rome 的 agent 复利实验,TaoToken 记录 Token 消耗

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 21:34:37

Android Adapter 的 getView 绕晕了?用 TaoToken 接入的 Codex 逐步拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 21:29:24

基于STM32的智能安防与燃气监测系统设计与仿真

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华