ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Django招聘数据实战:爬虫采集、清洗分析与可视化展示全流程

Django招聘数据实战:爬虫采集、清洗分析与可视化展示全流程 最近抽空把之前做的一个 Django 招聘数据分析项目完整梳理了一遍顺便把源码整理成了可以直接跑通的版本。这个项目说白了就是三件事抓 Boss 直聘上真实的职位数据、用 Python 做一轮清洗和指标分析、最后通过 Django 搭一个可视化看板把结论展示出来。标题里的 “31467” 是我个人代码库里的项目编号方便后续扩展迭代时追溯版本。为什么要做这个项目招聘数据本身是很好的分析素材城市、薪资、学历要求、经验年限、行业分布、技能关键词每一维都能讲出故事。但直接从招聘平台复制粘贴显然不现实所以爬虫采集就成了一步绕不开的活。再加上 Django 本身是 Python 生态里最成熟的 Web 框架拿它做数据展示层比写一堆零散的脚本要有说服力得多。这套内容适合谁如果你是刚接触 Django 的开发者想搞清楚“怎么把一个爬虫采集的数据落库、再通过网页展示出来”那这篇会很对胃口如果你想练手数据分析又不想面对 Kaggle 上那些已经处理到“没脾气”的干净数据集用自己爬下来的真实脏数据做清洗会更有代入感当然如果你只是想要个现成的项目起步模板那顺着下面的实操步骤跑一遍也能得到一个完整的可演示作品。整篇文章会按“设计思路 - 核心细节 - 代码实操 - 常见问题 - 扩展建议”的顺序来写没有特别晦涩的内容但每一步我都会多说一些“为什么这么做”的理由和实际踩过的坑尽量让看完的你直接能上手改着用。1. 项目概述与整体设计思路1.1 项目整体定位与功能模块拆解做这类数据展示项目最容易犯的毛病是“贪多求全”一上来就想着爬全站所有岗位结果反爬限制、存储结构、字段口径全都乱了套。这个项目从一开始就克制地圈定了范围按岗位关键词和城市两个条件做定向采集分析维度也集中在全国范围内的职位数量分布、城市薪资对比、学历和工作年限要求占比这几块。痛点很明确——求职者想看看行业真实行情招聘网站不会直接给你统计图所以项目要解决的是“数据从哪儿来、怎么处理、如何直观呈现”这条完整链路。功能模块大致可以拆成四个块数据采集模块负责从 Boss 直聘的职位列表页和详情页抓取信息保留原始 JSON 和页面字段落成 CSV 作为中间层。数据处理模块用 Pandas 清洗字段把“14-17K·13薪”这类模糊薪资文本拆成最值把“1-3年”拆成可排序的数字区间把学历描述统一成“大专 / 本科 / 硕士 / 博士”。数据入库模块通过 Django 的 ORM 把清洗后的记录写入 SQLite 数据库。为什么不直接用 MySQL因为项目定位是个人作品和学习样例SQLite 零配置、单文件、随项目走演示和迁移都方便。等你有并发需求再切 PostgreSQL 也不迟。数据展示模块Django 后台提供查询接口前端页面用 ECharts 渲染柱状图、饼图和散点图实现“打开页面就看到分析结果”的效果。这四个模块各管一段彼此之间通过统一的字段约定来衔接也就是把“职位ID”作为主键贯穿抓取、清洗、入库、展示的始终。1.2 技术选型解析为什么是 Django 而不是 Flask先说结论——用 Flask 同样能做出展示页面但 Django 的 ORM、Admin 后台、模板系统对于“数据管理类项目”来说几乎是量身定制的。你在 Flask 里要自己配 SQLAlchemy、自己写表单校验、自己处理迁移工具而在 Django 里这些都是内置的省下的时间足够把可视化页面打磨得更细致。再补一个容易被新手忽略的点Django 的模型迁移机制makemigrations migrate能让你在开发中随时改表结构而不至于手忙脚乱。数据分析项目的数据结构经常要调整比如给职位表加一个“所属行业”字段如果用纯 SQL 脚本管理改来改去会很痛苦用 Django ORM 的迁移记录一个命令就能把数据库结构同步到最新状态。可视化方面我选的是 ECharts原因一是图表类型全、交互效果好二是它支持纯前端通过 JSON 数据灌入后端只需要提供一个标准的 JSON 接口前端想怎么改图表样式都方便。比硬套 Django 模板语法拼 SVG 要灵活得多。1.3 数据流设计全过程一条数据从落网到上页面的完整流程是爬虫程序请求职位搜索接口拿到职位列表页的 JSON 或 HTML。解析出职位ID、职位名称、薪资文本、城市、学历要求、工作经验等字段先存成原始 CSV。Pandas 读取 CSV做缺失值填充、薪资区间拆解、文本归一化输出一份“干净版”CSV。在 Django 项目中写一个自定义 management commandpython manage.py import_jobs --csv xxx.csv读取干净版 CSV逐条写入 JobInfo 表。前端页面发起 Ajax 请求Django 视图执行 ORM 聚合查询返回按城市、按薪资段等维度的 JSON。ECharts 拿到 JSON 并渲染分析图表。这六步看着平淡但每一步都决定了最终展示能不能站得住。第4步容易出问题CSV 里有重复职位ID会导致主键冲突所以入库脚本里必须做去重判断第5步容易踩性能坑数据量只要到几万条ORM 层直接all()再在 Python 里分组就会卡应该用values().annotate(Count())在数据库层面完成分组统计。2. 核心细节解析与实操要点2.1 数据库模型设计一张表承载多维分析这个项目的数据表设计不用太复杂核心就是一张职位信息表字段设计如下字段名类型说明job_idCharField(uniqueTrue)职位唯一标识去重依据job_nameCharField职位名称比如“Python开发工程师”company_nameCharField发布公司名称cityCharField工作城市统一为“北京”“上海”这种标准城市名salary_minIntegerField最低月薪单位Ksalary_maxIntegerField最高月薪单位Ksalary_monthsIntegerField发薪月数一般为12或13educationCharField学历要求不限 / 大专 / 本科 / 硕士 / 博士experienceCharField经验要求不限 / 1-3年 / 3-5年 / 5-10年 / 10年以上industryCharField公司所属行业publish_timeDateTimeField职位发布日期skillsTextField职位描述里的技能关键词逗号分隔这个设计的核心思想是“把分析中需要的所有维度扁平化存储”。只要你在写入前把字段拆好后面对应的 SQL 分组查询会变得非常直接。比如要算“城市的平均最高薪资”就是JobInfo.objects.values(city).annotate(Avg(salary_max))。有一个我一开始没注意、后来改了很久的点salaryMonths 单独设计成字段是很有价值的。很多职位写的是“13薪”或“14薪”如果不拆出来后续算“期望年薪”时就得从文本里扣数字容易出错。把它拆出来之后年薪 月薪均值 * 月份数一个表达式就算完。2.2 爬虫采集策略与合规思考爬虫部分是这个项目里最有“现场感”的环节。很多招聘平台都有反爬机制直接硬怼很容易被校验拦截。我这里的策略是自定义 User-Agent并周期性更换避免同一个 UA 高频访问触发风控。请求频率控制两次请求之间至少 sleep 2-5 秒甚至可以加一个随机抖动让访问节奏更像真实用户。优先使用官方移动端页面接口部分信息通过 JSON 返回解析成本低但要对接口字段做容错处理因为平台改版频率不低。采集范围保持克制每个关键词只抓前面5-10页数据不追求“爬完所有数据”。这里要明确一点任何爬虫都应该遵守目标网站的 robots.txt、用户协议和隐私条款。个人学习研究请在合理频次内使用数据不要用于商业用途也不要对目标网站造成压力。做数据分析的前提是合规拿到数据而不是把站点搞挂。实际抓的时候Boss 直聘的职位列表页数据往往是动态加载的。第一次尝试直接解析静态 HTML 会发现列表区域是空的后来是通过分析它的 XHR 接口拿到 JSON。这里我的做法是打开页面后F12 切到 Network 面板筛选 XHR 请求找到返回职位列表数据的那个接口然后把它的参数query、city、page抽出来用 Python 的requests库模拟请求。核心采集代码大致长这样import requests import json import time from fake_useragent import UserAgent ua UserAgent() session requests.Session() def fetch_page(keyword, city_code, page): url https://www.zhipin.com/wapi/zpgeek/search/joblist.json params { query: keyword, city: city_code, page: page, } headers { User-Agent: ua.random, Referer: https://www.zhipin.com/, } resp session.get(url, headersheaders, paramsparams, timeout10) if resp.status_code 200: return resp.json() return None def parse_jobs(data): jobs [] if not data or data not in data: return jobs job_list data[data].get(jobList, []) for item in job_list: job { job_id: item.get(jobId), job_name: item.get(jobName), company_name: item.get(brandName), city: item.get(cityName), salary_text: item.get(salaryDesc), education: item.get(jobDegree), experience: item.get(jobExperience), skills: |.join(item.get(skills, [])), } jobs.append(job) return jobs请求频率这里尤其要提醒一下不要开多线程暴力爬。你可能是想更快拿到数据但被临时封 IP 的代价远远大于省下的那几分钟。我自己实测下来单线程3秒间隔抓几百条数据整个过程也就十几分钟完全够用。2.3 数据分析指标体系与清洗规则数据清洗是决定分析可信度的关键。因为招聘网站的口径并不统一有些文本脏到让你怀疑人生。我遇到最多的几个情况“14-17K·13薪”这种薪资字符串需要提取最低值14、最高值17和月数13。城市字段有的是“北京”有的是“北京-海淀区”需要统一成“北京”。学历字段有的是“本科”有的是“本科及以上”还有的是“学历不限”。经验字段有的是“1-3年”有的是“3年以上”需要统一成区间编号。写一个薪资解析函数是我觉得整个清洗过程中最有技术含量、也最有成就感的部分import re def parse_salary(salary_text): salary_text salary_text.replace( , ) # 匹配类似 14-17K 或 8-10K·13薪 pattern r(\d(?:\.\d)?)[Kk]?-(\d(?:\.\d)?)[Kk] match re.search(pattern, salary_text) if not match: return None, None, 12 salary_min float(match.group(1)) salary_max float(match.group(2)) # 匹配 13薪/14薪 month_match re.search(r(\d)薪, salary_text) months int(month_match.group(1)) if month_match else 12 return salary_min, salary_max, months这里的核心是用正则表达式做两段匹配第一段匹配薪资区间第二段匹配发薪月数。实际清洗时你会发现有些岗位写的是“20-30K·16薪”这种一定要把16单独提取出来不然年薪少算30%。经验字段的处理也可以用映射表def normalize_experience(exp_text): mapping { 经验不限: 不限, 在校/应届: 应届, 1年以内: 0-1年, 1-3年: 1-3年, 3-5年: 3-5年, 5-10年: 5-10年, 10年以上: 10年以上, } return mapping.get(exp_text, exp_text)清洗逻辑总结下来就是一句话把人类友好的描述转换成程序友好的结构字段。你后面每少踩一个“字符串截取”的坑都是这步换来的。3. 实操过程与核心代码实现3.1 环境搭建与 Django 项目初始化先交代我的开发环境参考版本方便你适配Python 3.10Django 4.2pandas 2.0requests 2.31sqlite3Django 内置Django 4.2 是目前比较稳定的长期支持版本用它不会碰到 5.x 里一些新语法不兼容的麻烦。创建项目的步骤没有悬念python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install django pandas requests fake_useragent django-admin startproject boss_analysis cd boss_analysis python manage.py startapp jobs到这里已经有 Django 的基础结构了。准备工作做好之后第一个工种就是“定模型”——把表格中设计的字段转成 Django Model。不要跳过模型设计直接写视图我见过太多人先写页面再补模型最后来回改 ORM 的场景费时费力。from django.db import models class JobInfo(models.Model): job_id models.CharField(max_length64, uniqueTrue) job_name models.CharField(max_length255) company_name models.CharField(max_length255, blankTrue) city models.CharField(max_length50, db_indexTrue) salary_min models.IntegerField(nullTrue, blankTrue) salary_max models.IntegerField(nullTrue, blankTrue) salary_months models.IntegerField(default12) education models.CharField(max_length32) experience models.CharField(max_length32) industry models.CharField(max_length128, blankTrue) publish_time models.DateTimeField(nullTrue, blankTrue) skills models.TextField(blankTrue) class Meta: db_table job_info indexes [ models.Index(fields[city, salary_max]), models.Index(fields[education]), ]需要提醒的是city字段加了db_indexTrue因为后面按城市分组的查询非常频繁。如果你没有建索引数据一到几万条聚合查询的耗时差异会非常明显。3.2 将清洗后的 CSV 数据导入库中Django 里最规范的数据导入方式不是写个脚本直接跑而是写成自定义 management command这样可以复用项目的环境和配置。在jobs/management/commands/import_jobs.py里这样写import csv from django.core.management.base import BaseCommand from jobs.models import JobInfo class Command(BaseCommand): help 从清洗后的CSV导入职位数据 def add_arguments(self, parser): parser.add_argument(--csv, requiredTrue, helpCSV文件路径) def handle(self, *args, **options): csv_path options[csv] count 0 with open(csv_path, r, encodingutf-8-sig) as fp: reader csv.DictReader(fp) for row in reader: _, created JobInfo.objects.update_or_create( job_idrow[job_id], defaults{ job_name: row[job_name], company_name: row[company_name], city: row[city], salary_min: int(row[salary_min] or 0) or None, salary_max: int(row[salary_max] or 0) or None, salary_months: int(row[salary_months] or 12), education: row[education], experience: row[experience], industry: row[industry], skills: row[skills], }, ) if created: count 1 self.stdout.write(f新增 {count} 条记录)这里用update_or_create而不是get_or_create好处是重复执行导入脚本不会因为主键冲突报错已有数据会被原地更新非常适合爬虫多次补充抓取的场景。导入时有个编码的坑Windows 上用 Excel 保存的 CSV 会是gbk编码但 Python 默认用系统编码读文件容易报错。我习惯统一用encodingutf-8-sig读这个编码能自动跳过 UTF-8 的 BOM 头兼容性最好。3.3 实现后端视图与聚合查询Django 后端要做的事就三件接收前端请求、按条件从数据库聚合、返回 JSON。我设计了两个核心接口/api/summary/返回整体统计量总职位数、平均最低薪资、平均最高薪资、学历占比。/api/city_salary/按城市分组返回职位数量和平均薪资。视图代码如下from django.db.models import Count, Avg from django.http import JsonResponse from jobs.models import JobInfo def summary(request): total JobInfo.objects.count() avg_min JobInfo.objects.filter(salary_min__gt0).aggregate(Avg(salary_min))[salary_min__avg] avg_max JobInfo.objects.filter(salary_max__gt0).aggregate(Avg(salary_max))[salary_max__avg] edu_data list( JobInfo.objects.values(education).annotate(countCount(id)).order_by(-count) ) result { total: total, avg_min_salary: round(avg_min or 0, 1), avg_max_salary: round(avg_max or 0, 1), education: edu_data, } return JsonResponse(result) def city_salary(request): rows list( JobInfo.objects.exclude(city) .values(city) .annotate(countCount(id), avg_salaryAvg(salary_max)) .filter(count__gte3) .order_by(-avg_salary)[:20] ) return JsonResponse(rows, safeFalse)这个接口的 SQL 层面会把聚合操作丢给数据库执行而不是把几万行数据拉到内存里再 Python 分组。如果你发现页面加载变慢可以用print(QuerySet.query)输出实际执行的 SQL 检查一下看是不是出现了“扫描全表”的问题。3.4 前端页面与 ECharts 图表渲染Django 的模板系统在这里的工作量不大主要是一个index.html页面。页面结构分为三块顶部统计卡片区总职位数、平均最低薪资、平均最高薪资、中间学历占比饼图、下方城市薪资排行柱状图。在templates/jobs/index.html里用原生 JavaScript 加 ECharts 渲染div ideduChart styleheight: 360px;/div div idcityChart styleheight: 420px;/div script srchttps://cdn.jsdelivr.net/npm/echarts5.4.3/dist/echarts.min.js/script script async function initCharts() { const resp await fetch(/api/city_salary/); const data await resp.json(); const cityChart echarts.init(document.getElementById(cityChart)); cityChart.setOption({ title: { text: 城市职位数量与平均薪资TOP20 }, tooltip: {}, xAxis: { type: category, data: data.map(item item.city) }, yAxis: { type: value }, series: [ { name: 职位数, type: bar, data: data.map(item item.count), itemStyle: { color: #5470c6 }, } ] }); } initCharts(); /script这里一个实际细节是fetch返回的数据默认是被 Django 的 JSON 响应体包裹的如果你的视图返回的是 list 而不是 dictDjango 可能会报“In order to allow non-dict objects to be serialized set the safe parameter”的错误。所以上面代码里我写了safeFalse这是很多人第一次写 Ajax 接口会漏掉的小坑。3.5 URL 路由与页面调试在boss_analysis/urls.py里配置路由from django.contrib import admin from django.urls import path from jobs.views import index, summary, city_salary urlpatterns [ path(admin/, admin.site.urls), path(, index, nameindex), path(api/summary/, summary, nameapi_summary), path(api/city_salary/, city_salary, nameapi_city_salary), ]跑起来之后直接在浏览器访问http://127.0.0.1:8000/看到页面和图表就说明整个数据链路已经打通。如果有任何一行代码报错大部分情况都能在终端日志里直接定位不用瞎猜。4. 常见问题与排查技巧实录4.1 爬虫请求被拒绝或返回验证码这是爬虫阶段最常见的问题。表现形式是用浏览器打开页面数据正常但用requests请求返回的 HTML 里没有职位列表或者直接返回一个验证码页面。我的排查顺序是检查 User-Agent 是否完整。不要只设一个假的浏览器UA最好把Accept-Language、Referer也补上。检查请求频次。如果你在1秒内发了多个请求基本会被瞬时拦截。把间隔拉到3秒以上再配合随机time.sleep(2 random.random() * 3)会好很多。不要每次请求都用同一个 Session 发太多的包。如果抓取量大就分批跑隔一段时间再继续。有几次我碰到返回200但数据为空的诡异情况最后发现是请求参数里的city编码格式不对应该用城市编码而不是中文名。遇到这种问题一定要先把接口返回的原始 JSON 打印出来看看不要想当然。4.2 Django 页面样式和图表加载不出来这个问题的根源是 Django 在 DEBUGFalse 时不处理静态文件但很多初学者在本地 DEBUGTrue 也会碰见图片或脚本加载 404。最简单的方案是在开发阶段把所有第三方库用 CDN 引入如图中的 ECharts 链接这样就可以绕开静态文件托管这件事。如果你一定要本地化静态文件需要先python manage.py collectstatic然后在settings.py里配STATIC_ROOT和STATIC_URL。注意Django 默认不会在templates目录里直接找静态文件你需要确保引用的路径前缀和你STATIC_URL匹配。我之前因为把/static/写成/assets/白查了好几分钟。4.3 CSV 文件导入时中文乱码或字段错位我的经验是CSV 导入用utf-8-sig编码读取最稳而且最好在 Excel 里导出时就选 UTF-8 CSV。乱码往往是因为 Windows 下 Excel 保存文件默认用的gbkPython 按utf-8读自然不行。字段错位的问题一般发生在手工编辑过 CSV 行、列数不一致的情况下。解决办法是导入前先做一个“空值检查”如果某一行的job_id为空直接跳过并打印该行行号这样能最快定位到问题行。4.4 数据库聚合结果出现明显偏差比如按城市分组后总数对不上十有八九是因为有脏数据。招聘平台的有些职位城市字段为空或填了“异地”这些数据在聚合时如果没有过滤就会形成莫名其妙的“其他”分组。我最终的策略是在所有统计接口里统一过滤掉city或city异地的记录并且保证salary_max 0这样分析口径才一致。避免每个页面写不同的过滤条件因为以后维护时必然会有页面漏改。4.5 知名“坑”汇总速查表现象原因解决方案爬虫返回空数据请求参数 city 编码不对打印参数和响应排查Django 接口报错 non-dict object视图返回列表没有加 safeFalseJsonResponse(data, safeFalse)页面图表空白请求接口 404检查 urls 配置和前面路径结尾的/导入 CSV 中文乱码文件编码不是 utf-8统一用 encodingutf-8-sig聚合数字偏差大存在空值和异常值统一过滤空字段、0 值后再统计5. 项目扩展与复用建议5.1 扩展方向轮询采集与定时任务如果不想只跑一次希望数据能“自动更新”可以给 Django 项目加一个定时任务。实现上不需要引入 Celery 这种重组件简单场景用系统cron或 Python 的schedule库就够了。我的建议是这样组织的30 2 * * * cd /path/to/boss_analysis python manage.py run_crawler --keyword Python --pages 10定时任务每天凌晨2点半采集一次然后接着执行导入命令15 3 * * * cd /path/to/boss_analysis python manage.py import_jobs --csv data/latest.csv这样时间一久你积累的就是一份有“时间纵深感”的数据集可以做“薪资随时间波动”这种更有趣的分析。5.2 分析维度扩展技能与行业交叉分析目前的展示还停留在“城市薪资”“学历占比”这些基础维度。如果你手里有更丰富的职位描述文本完全可以把技能关键词字段利用起来做“Python岗位最常见的10个技能要求”和“不同行业对Python技能差异”的对比分析。核心实现思路是在清洗阶段把职位描述里的“Java”“Django”“Pandas”等词拆出来写入技能表然后用多对多关系做关联。分析时按技能做聚合Frontend 用词云图展示也比较有冲击力。5.3 部署到线上要注意的事项如果你想把项目部署到服务器上给朋友看有几个细节值得提前处理settings.py里把DEBUG设为FalseALLOWED_HOSTS改成你的域名或公网 IP。关闭 Django 内置静态文件服务改用 Nginx 代理静态资源文件用collectstatic集中收集。数据库从 SQLite 迁到 PostgreSQL 是值得的因为并发访问能力完全是两个量级。Django ORM 的接口保持不变只要改settings.py的DATABASES配置就能切换。5.4 把这个项目包装成简历项目的小建议如果你是想拿这个项目找工作用我建议不要只停留在“我抓了什么数据、展示了什么图表”这个层面。面试官更愿意听到的是你在反爬限制下如何设计采集策略、你在数据清洗时处理了哪几类脏数据、你如何通过索引和聚合查询优化接口性能。这三条在文中都有对应章节你把自己的实践细节说清楚比单纯背项目脚本的流程强得多。另外代码里尽量保留几个单元测试。哪怕只是针对parse_salary函数的几种输入做断言也能体现工程化意识。我当时的测试大概长这样from django.test import SimpleTestCase from jobs.utils import parse_salary class SalaryParseTests(SimpleTestCase): def test_normal_range(self): self.assertEqual(parse_salary(14-17K·13薪), (14.0, 17.0, 13)) def test_no_months(self): self.assertEqual(parse_salary(8-10K), (8.0, 10.0, 12))这套测试本身不难但能让看到代码的人觉得你考虑得比较周全。最后再分享一个小技巧项目里的import_jobs命令和爬虫脚本一定要分开写。很多初学者喜欢把所有逻辑塞到一个manage.py runscript里看似方便但一旦抓取逻辑和导入逻辑互相影响排错会非常痛苦。分开之后爬虫侧只要保证输出“干净的CSV”Django 侧只关心“怎么入库存取”各自能独立测试整个项目的可维护性完全不一样。
返回列表