基于Django的招聘数据分析可视化实战:从数据模型到ECharts图表
简介这是一份基于 Django 的招聘数据分析可视化系统毕业设计源码包面向计算机相关专业的学生适用于毕业设计、课程设计或期末大作业。项目以 Python 和 Django 为核心整合了招聘数据爬取、数据清洗、MySQL 存储与可视化展示等完整流程能帮助学习者快速理解 Web 项目从数据采集到前端呈现的整体架构。资源包共 59 个文件压缩后仅 10.31MB包含 Python 源码及编译文件、SQL 数据库脚本、演示文稿 PPT以及前端所需的 JS/CSS/HTML、图片和配置文件目录划分清晰便于按模块阅读和二次开发。当前已有 210 人学习下载。除可直接运行的系统源码外还附带数据库初始数据、演示文稿和说明文档覆盖项目答辩和功能演示的主要材料适合需要参考完整高分开题或毕业设计项目的开发者。1. 招聘数据分析可视化Django生态下的毕设切入点把招聘网站几万条岗位数据拉下来再用Django搭一个后台、几张图表展示地域和薪资分布听起来像一个标准的大作业模板。但这个项目里真正值钱的部分不是爬虫而是那些一眼看不出来的细节job51表里的字段怎么设计才能兼容不同城市的薪资格式聚合统计口径怎么统一图表接口返回的数据结构如何贴合前端组件。本文不会把源码逐行念一遍而是沿着“数据落库 → 聚合口径 → 接口对接 → 图表验证”这条线把能直接抄走的代码和容易翻车的边界条件讲清楚。适合正在做Python毕业设计、课程设计或者想快速搭建一个可视化分析Demo的开发者。2. 数据模型与MySQL落库先把job51的字段理顺2.1 从采集数据到关系模型的映射项目里带了两份数据job51_data2.sql和job51_data2用这个数据量更多.sql后者体量更大建议优先导入。在动手建表前先打开SQL文件看几行确认原始字段长什么样。常见的51job采集数据会包含职位名称、公司名、薪资文本、工作地点、发布时间、学历要求、经验要求、公司规模、融资阶段等字段。推荐的Django模型设计不需要把所有字段都变成独立列而是把“薪资范围”拆成salary_min和salary_max两个整数列把“工作地点”拆成city和district两列。原因很简单可视化做平均薪资、城市分布、区间直方图时数值列远比字符串列好用。# jobs/models.py from django.db import models class RecruitmentPost(models.Model): job_id models.CharField(max_length64, uniqueTrue, verbose_name原始职位ID) title models.CharField(max_length128, verbose_name职位名称) company models.CharField(max_length128, verbose_name公司名称) city models.CharField(max_length32, db_indexTrue, verbose_name城市) district models.CharField(max_length64, blankTrue, verbose_name区/县) salary_min models.IntegerField(nullTrue, blankTrue, verbose_name月薪下限/K) salary_max models.IntegerField(nullTrue, blankTrue, verbose_name月薪上限/K) salary_avg models.FloatField(nullTrue, blankTrue, verbose_name月薪均值/K) experience models.CharField(max_length32, verbose_name经验要求) education models.CharField(max_length32, verbose_name学历要求) company_size models.CharField(max_length48, blankTrue, verbose_name公司规模) publish_date models.DateField(nullTrue, blankTrue, verbose_name发布日期) crawled_at models.DateTimeField(auto_now_addTrue, verbose_name入库时间) class Meta: db_table recruitment_post indexes [ models.Index(fields[city, salary_avg]), ] def __str__(self): return self.title这段模型里最需要留意的不是CharField而是salary_min、salary_max、salary_avg三列的取舍。原始数据可能是“1.5-2.5万/月”或“8千-1.2万”如果直接存文本后续所有图表都要在视图层解析拆成整数并存一份均值虽然入库时要多做一步解析但查询时可以直接用AVG(salary_avg)或BETWEEN减少很多麻烦。job_id加唯一约束是为了重复采集时做去重或更新而不是一味追加数据。2.2 MySQL初始化与批量导入命令拿到job51_data2.sql后先建库再导入注意字符集要统一。如果直接用Django的migrate生成空表再导入SQL很可能因为表名前缀不一致导致导入失败。常见做法是先让Django按模型建表再把SQL里的INSERT语句清洗出来单独执行或者直接用SQL文件覆盖式导入。# 1. 登录MySQL创建数据库指定utf8mb4 mysql -uroot -p -e CREATE DATABASE IF NOT EXISTS jobs_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; # 2. 导入项目自带的数据文件注意调整文件路径 mysql -uroot -p jobs_db job51_data2用这个数据量更多.sql # 3. Django项目里配置数据库连接然后生成迁移 python manage.py makemigrations jobs python manage.py migrate --run-syncdb这里的逻辑是先用SQL脚本把原始数据完整灌入再用migrate --run-syncdb保证Django的django_migrations表与模型状态同步。如果SQL文件里的表名和模型不一致不要急着手动改库建议先用inspectdb反向生成modelspython manage.py inspectdb --databasedefault inspected_models.py把生成的文件和手写模型对比哪边字段全就以哪边为准。很多毕设项目翻车在“Django连上了库但ORM查询不到数据”本质是表名或字段名对不上。inspectdb是排查这种问题最快的路径它能直接把数据库里的列定义映射成模型类省去手动猜字段的过程。3. 聚合查询与统计口径用SQL和pandas校准薪资中位数3.1 按城市和职位维度做聚合可视化页面最常见的三个图表是城市岗位量TOP10柱状图、平均薪资城市排行、学历要求饼图。这三个图不需要在Python里写for循环统计直接用Django ORM的values加annotate即可。# jobs/views.py from django.db.models import Count, Avg from django.http import JsonResponse from .models import RecruitmentPost def city_job_count(request): # 统计每个城市的岗位数量并筛选掉空城市 qs ( RecruitmentPost.objects .exclude(city) .values(city) .annotate(job_countCount(id)) .order_by(-job_count)[:10] ) result [{city: item[city], count: item[job_count]} for item in qs] return JsonResponse({code: 0, data: result}) def city_salary_rank(request): # 每个城市平均薪资注意salary_avg的单位是K qs ( RecruitmentPost.objects .exclude(salary_avg__isnullTrue) .values(city) .annotate(avg_salaryAvg(salary_avg)) .order_by(-avg_salary)[:15] ) result [ {city: item[city], avg_salary: round(item[avg_salary], 1)} for item in qs ] return JsonResponse({code: 0, data: result})这两段接口的返回结构是data数组每个元素都是字典正好匹配ECharts的dataset或data字段。注意round(avg_salary, 1)这一步很关键如果直接把浮点原样返回前端展示会出现一堆小数点如果要做Y轴刻度最好统一一位小数否则图表label过长影响排版。3.2 薪资文本解析的兜底策略从MySQL导入的字段不一定都是清洗干净的。比如“2-2.5万/月”和“1.5-3万/年”混在一起直接按规则取前两个数字会得到错误结果。更稳妥的做法是写一个解析函数在导入阶段就处理而不是在查询时临时算。# jobs/utils.py import re def parse_salary(text): 解析薪资文本返回 (min_k, max_k, avg_k)单位统一为K/月 if not text: return None, None, None text str(text).strip() # 统一转换成以“月”为基准如果是年薪除以12 unit_ratio 12 if 年 in text else 1 nums re.findall(r([\d.]), text.replace(,, )) if len(nums) 0: return None, None, None if len(nums) 1: single float(nums[0]) * unit_ratio return single, single, single min_k float(nums[0]) * unit_ratio max_k float(nums[1]) * unit_ratio if min_k max_k: min_k, max_k max_k, min_k return min_k, max_k, (min_k max_k) / 2这里的逻辑分三层第一层判断单位年薪要在月薪基础上乘以12第二层用正则提取所有数字兼容“8千-1.2万”“1.5-2.5万”等写法第三层处理只有一个数字的特殊情况比如“1.5万以上”此时上下限都取同一个值。如果数据里出现了k、K、千这些单位可以再额外加一层换算但多数毕设数据源不会复杂到那个程度。3.3 用pandas做交叉统计对比有些统计不适合用ORM直接表达比如“按城市学历两个维度交叉查看岗位需求量”。这时候可以先把聚合结果取出来再用pandas的pivot_table生成宽表方便前端做堆叠柱状图。import pandas as pd from django.core.serializers import serialize def edu_city_cross(request): qs ( RecruitmentPost.objects .exclude(education) .exclude(city) .values(city, education) .annotate(countCount(id)) ) df pd.DataFrame(list(qs)) if df.empty: return JsonResponse({code: 1, msg: no data}) pivot df.pivot_table(indexcity, columnseducation, valuescount, aggfuncsum).fillna(0) pivot pivot.sort_values(bypivot.columns.tolist(), ascendingFalse).head(8) result { cities: pivot.index.tolist(), # 把每一列转成ECharts可用的series series: [ {name: col, data: [int(v) for v in pivot[col].tolist()]} for col in pivot.columns ], } return JsonResponse({code: 0, data: result})要注意pivot_table的索引和列值必须是字符串否则图例会显示成数字。前端接到cities作为X轴series数组里的每一项直接映射到ECharts的bar系列不需要在JavaScript里再做数据重组。这种方法比手工嵌套字典直观但前提是数据量不要超过几万行否则每次请求都跑pandas会拖慢响应。4. ECharts接口与Django JsonResponse的对接细节4.1 视图层统一返回结构给图表用的接口最好统一成{code, data, msg}的结构。虽然看起来死板但前端处理异常时会少写很多判断。下面是一个示例接口同时返回岗位量趋势和薪资趋势两个维度。def trend_chart(request): # 按发布日期做聚合统计每日岗位数和平均薪资 qs ( RecruitmentPost.objects .exclude(publish_date__isnullTrue) .values(publish_date) .annotate( cntCount(id), avg_salaryAvg(salary_avg), ) .order_by(publish_date) ) dates [] counts [] salaries [] for item in qs: dates.append(item[publish_date].strftime(%m-%d)) counts.append(item[cnt]) salaries.append(round(item[avg_salary] or 0, 1)) data { dates: dates, counts: counts, salaries: salaries, } return JsonResponse({code: 0, data: data})这里把日期格式化成%m-%d是为了让X轴标签短一些。如果直接把完整日期放上去图表横轴会出现重叠如果保留%Y-%m-%d还会因为跨年度数据太多导致视觉拥挤。round放在视图层而不是前端是因为Django的JSON序列化对Decimal或float处理可能出现精度不一致。4.2 Django Admin的二次开发价值项目里自带的Django Admin不只是后台管理入口对于招聘数据分析场景来说它是最好的数据质量校验工具。进入admin.py注册模型用列表页直接看每一条原始记录比如城市字段里是否混入了“异地招聘”“国外”等非标准值。# jobs/admin.py from django.contrib import admin from .models import RecruitmentPost admin.register(RecruitmentPost) class RecruitmentPostAdmin(admin.ModelAdmin): list_display (title, company, city, salary_min, salary_max, salary_avg, publish_date) list_filter (city, education, experience) search_fields (title, company) list_per_page 50list_filter可以按城市过滤配合search_fields搜索公司和职位名这在检查脏数据时非常高效。比如你发现某个图表里“学历要求”出现“本科及以下”“大专/本科”这类复合值直接在admin里搜索后批量修正比重写清洗脚本更快。这段配置的目的是强调可视化项目里代码只占一半另一半在于能把数据看得见、改得动。4.3 echarts.init的常见误区前端页面如果用的是echarts最容易出问题的不是图表配置而是初始化时机。当图表容器在display:none的父级里时echarts.init会得到宽度为0的容器导致图表渲染成一条线。常见做法是在页面加载后手动调用resize。!-- templates/charts.html -- div idmain stylewidth:100%;height:480px;/div// static/js/chart.js var chart echarts.init(document.getElementById(main)); function loadCityData() { fetch(/api/city_job_count/) .then(resp resp.json()) .then(res { if (res.code ! 0) return; var xAxisData res.data.map(item item.city); var seriesData res.data.map(item item.count); chart.setOption({ xAxis: { type: category, data: xAxisData }, yAxis: { type: value }, series: [{ type: bar, data: seriesData, name: 岗位数 }] }); }); } window.addEventListener(resize, function () { chart.resize(); }); loadCityData();fetch的路径是/api/city_job_count/对应Django中的path(api/city_job_count/, views.city_job_count)。这里用相对路径加上末尾斜杠如果漏了Django会返回301跳转前端也能拿到数据但会产生一次额外请求。另一个关键点是chart.resize()当你从后台管理页切回图表Tab或者弹窗缩放时必须重绘一次不然图表会保持初始渲染时的宽度。5. 答辩前必查的五个图表正确性验证点5.1 用SQL手工核对聚合结果当图表和预期差异较大时不要看前端代码先在MySQL里跑一条原生查询对比接口返回的数字。-- 城市岗位量TOP10核对 SELECT city, COUNT(*) AS cnt FROM recruitment_post WHERE city ! GROUP BY city ORDER BY cnt DESC LIMIT 10;如果Django接口显示“北京招聘岗位1500个”而这条SQL显示的是1550个说明exclude(city)和其他过滤条件不一致。最常见原因是数据里混入了全角空格、不可见字符此时city ! 和ORM的exclude(city)行为一致但SQL里TRIM(city)才能查出来。5.2 验证薪资单位是否混用在admin里筛选salary_avg 100的记录如果数量很多大概率是单位错误比如把“年薪”数据当成“月薪”。用下面的SQL快速定位SELECT title, city, salary_avg, publish_date FROM recruitment_post WHERE salary_avg 100 OR salary_max 1;一个合格的项目应该把单位全部统一成K/月超过100的数据要么是数据源本身异常要么是解析函数漏判了“年薪”关键词。发现后直接更新该批数据而不是在前端加一个单位换算开关否则面试官问你数据可信度时会很被动。5.3 验证发布日期的时间跨度趋势图如果出现断崖下跌先确认是不是采集周期本身只有一个月而不是程序bug。用手工查询看日期分布SELECT MIN(publish_date), MAX(publish_date), COUNT(DISTINCT publish_date) FROM recruitment_post;如果只有两三天数据折线图会很难看建议改用柱状图按“采集时间”而不是“发布日期”来展示趋势。这是毕设答辩时最容易被追问的细节提前准备好口径解释比现场编理由好得多。5.4 验证前端点Figure容器初始化顺序打开浏览器开发者工具控制台如果看到dom readyState相关的警告多半是脚本在DOM加载完成前执行。把loadCityData()放在window.onload里或者把script标签移到/body之前。这个坑在带tab切换的页面里特别常见因为隐藏容器的offsetWidth是0图表初始化失败后再次setOption也不会自动补渲染。5.5 验证数据量对图表性能的影响job51_data2用这个数据量更多.sql如果导入后有10万条以上每次请求直接Count和Avg在未加索引的字段上会明显变慢。检查模型里city字段是否加了db_indexTruepublish_date是否有普通索引。如果还是慢就把聚合结果缓存到Redis或Django缓存中设定5分钟过期保证答辩现场多次点击按钮不会卡死。python manage.py shell手工测试接口响应时间时用浏览器无痕窗口打开图表页面打开Network面板重点关注/api/city_job_count/的耗时。如果超过500ms先看SQL查询计划再决定是否加索引。维护好图表正确性和查询性能这两条底线剩下的就是如何在答辩时把数据洞察讲清楚。本文还有配套的精品资源点击获取