Python爬虫+Django:BOSS直聘数据分析与可视化实战
最近好几个朋友跑来问我现在做数据分析、做Python开发市场上到底什么薪资水平。与其一个个招聘平台挨着翻不如直接搭一套东西把公开职位信息抓下来算清楚。这也是我看到“djangoboss直聘数据分析--附源码”这套项目时第一反应觉得值得拆开讲讲的原因。它就是把BOSS直聘上的公开职位数据采集下来做清洗和统计最后用Django页面和图表把结果展示出来。整套流程把Python爬虫、数据处理、Django Web开发和前端可视化串成了一个完整闭环既适合刚学完Python基础想上点实战的也适合课设、毕设正在找选题的同学。1. 项目拆解搞清楚“djangoboss直聘数据分析”在做什么1.1 这套项目到底解决什么问题先说一个最常见的场景你想知道“Python开发工程师在北京的平均月薪大概是多少”常规做法是打开招聘软件搜索关键词然后一页一页翻职位看到合适的手动记下来。翻个两三百条之后再用Excel手动汇总算平均、看分布。这个过程极其耗时而且容易漏数据。这个项目换了一种玩法写一个脚本自动去采集职位列表里公开的信息包括职位名称、公司、城市、薪资范围、学历要求、经验要求、技能标签等采完入库再用统计方法算出平均值、分布、占比最后打开一个网页就能看到“北京Python开发平均月薪”、“哪些公司需求量大”、“学历门槛集中在哪个档位”这类结果。所以说它解决的不只是“爬数据”这一个点而是把“采集→清洗→入库→分析→展示”整条链路打通了。市面上的教程大多单独讲爬虫、单独讲数据分析、单独讲Django而这个项目把这些都绑在一起正好是一个典型的全栈入门实战案例。从学习角度看它比单写一个爬虫脚本有价值得多。1.2 技术选型背后的三个理由第一框架选Django而不是Flask。原因是这个项目天然需要“后台管理数据”的能力。爬下来的职位数据需要人工查看、筛选、删除Django自带的Admin后台可以直接管理模型数据不需要额外写一堆管理页面。另外Django的ORM确实方便查询“某城市平均薪资”这类聚合操作几句话就能完成比手写SQL要舒服很多。第二数据处理选Pandas。原始数据从网页上抓下来之后格式很乱比如薪资字段是“20-35K”学历字段有“本科”、“本科及以上”、“统招本科”等五花八门的写法。Pandas在数据清洗、去重、拆分、统计方面效率极高配合Python做数据分析和后续的可视化链路最顺。第三图表展示选ECharts。ECharts是百度开源的前端图表库文档全社区案例多柱状图、饼图、折线图、词云都能做。虽然pyecharts也能用但它在Django模板里的嵌入方式比较笨重我建议直接用ECharts写前端图表后端把统计数据封装成JSON接口返回前端拉取后渲染这种方式更灵活也是实际项目里比较常见的做法。1.3 整体模块划分与数据流向整个项目从代码组织上可以分成四个模块。采集模块负责请求网页或接口解析出职位列表返回结构化字段。清洗模块把原始字段标准化比如把薪资文本拆成最低值和最高值把城市统一成标准名称把学历、经验归一到少量分类。分析模块基于清洗后的数据做聚合计算比如按城市算平均薪资、按学历算占比、按技能标签做词频统计。展示模块就是Django的视图、模板和前端图表负责把分析结果用网页呈现给用户。数据流向是BOSS直聘网页 → 采集脚本 → 原始DataFrame → 清洗后DataFrame → Django Model → SQLite或MySQL数据库 → Django查询聚合 → JSON接口 → 前端ECharts渲染。这套流程里最花时间的不是Django那部分而是前面的采集和清洗。我实际跑下来的体验是如果采集脚本写得好后面分析、展示基本就是照着数据“画画”如果爬下来的数据脏、字段对不上后面每一步都会返工。所以这个项目能不能顺利跑通很大程度上取决于第2章的内容。2. 数据采集与清洗一半的工作量都耗在这里2.1 BOSS直聘数据拿不到先找数据接口而不是硬解析很多人一上来就写requests直接请求网页HTML然后发现拿回来的页面里根本没有职位信息。原因很简单BOSS直聘这类网站是动态渲染的职位数据是浏览器里通过Ajax请求后台接口后再动态渲染到页面上的。直接用requests抓静态HTML抓到的只是一个空壳。正确做法是先打开浏览器开发者工具切到Network面板在页面上搜索“Python开发”观察浏览器发出了哪些XHR请求。通常能找到一个名字类似search或者job/list的接口返回的是JSON格式数据里面就包含了职位ID、职位名称、薪资文本、城市、学历要求等字段。拿到接口之后用requests模拟请求时一定要注意请求头。我当时第一次跑只加了User-Agent结果返回数据一直为空后来补上Referer和Cookie再把请求间隔控制在3到5秒数据就稳定了。下面这个脚本思路是项目里最常见的实现import requests import time HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), Referer: https://www.zhipin.com/web/geek/job, Accept: application/json, text/plain, */*, } def fetch_jobs(query, city_code, pages5): url https://www.zhipin.com/wapi/zpgeek/search/joblist.json results [] for page in range(1, pages 1): params { query: query, city: city_code, page: page, } resp requests.get(url, headersHEADERS, paramsparams, timeout10) data resp.json() jobs data.get(zpData, {}).get(jobList, []) if not jobs: break for job in jobs: results.append({ title: job.get(jobName), company: job.get(brandName), city: job.get(cityName), salary_text: job.get(salaryDesc), education: job.get(jobDegree), experience: job.get(jobExperience), skills: ,.join(job.get(skills, [])), }) time.sleep(3 page % 2) return results这里有几个关键点页码参数需要自己从接口响应里确认不同版本的接口参数名可能会有差异请求频率必须控制别一口气把几百页全抓完容易被限制sleep的时长可以加一点随机性比如time.sleep(3 page % 2)本质上还是太规律建议用random.uniform(3, 6)模拟得更自然一些。提示这个项目里的爬虫脚本建议仅用于学习研究和少量数据演示不要高频请求不要并发抓取更不要拿数据去做商业用途。尊重网站的robots协议和公开数据使用规范是每个做数据项目的基本底线。2.2 薪资字段清洗别让“20-35K”坑了你BOSS直聘的薪资字段非常不统一常见格式有“20-35K”、“20K-35K”、“15-20K·14薪”、“面议”等。如果直接把原始文本拿去做统计你会发现计算出来的平均数完全不对因为字符串没法参与数值运算。常规清洗思路是先用正则把数字全部提取出来遇到区间则取最低值和最高值都存下来再算一个中间值。中间值是后续分析排序的主角因为它比单独用最低值或最高值更接近真实水平。比如“20-35K”取中间值27.5统计城市平均薪资时用27.5去聚合会比用20或者35更合理。代码如下import re def parse_salary(text): if not text or 面议 in text: return None, None, None nums re.findall(r(\d), text) if not nums: return None, None, None if len(nums) 1: low high int(nums[0]) else: low, high int(nums[0]), int(nums[1]) mid (low high) / 2 return low, high, mid df[salary_min] df[salary_text].apply(lambda x: parse_salary(x)[0]) df[salary_max] df[salary_text].apply(lambda x: parse_salary(x)[1]) df[salary_mid] df[salary_text].apply(lambda x: parse_salary(x)[2])除了薪资城市字段也要处理。接口返回的城市名有时带区划信息比如“北京·海淀区”或者干脆写成“北京-海淀”。在分析时如果不处理相同城市就会被拆成多个条目。我习惯直接用split把前一段切出来统一成“北京”。学历和经验字段也需要归一化。比如学历有“本科”、“本科及以上”、“统招本科”、“硕士”等如果直接分组class太多图表很乱。我会做一个映射函数所有含“硕士”的归为“硕士”含“本科”的归为“本科”其他归为“大专及以下”或“学历不限”。经验字段同理按“1年以内”、“1-3年”、“3-5年”、“5-10年”分档统计时把字符串存起来就可以不需要转数字。2.3 字段设计与数据库建模清洗之后的数据要存进Django的模型里。我建议不要直接出现原始薪资文本而是将解析后的salary_min、salary_max、salary_mid单独建字段这样查询聚合时可以直接参与数值运算不用反复解析。Django模型可以这样设计from django.db import models class Job(models.Model): title models.CharField(max_length100, verbose_name职位名称) company models.CharField(max_length150, verbose_name公司名称) city models.CharField(max_length50, db_indexTrue, verbose_name城市) salary_min models.IntegerField(nullTrue, blankTrue, verbose_name最低薪资K) salary_max models.IntegerField(nullTrue, blankTrue, verbose_name最高薪资K) salary_mid models.FloatField(nullTrue, blankTrue, verbose_name薪资中位K) education models.CharField(max_length50, verbose_name学历要求) experience models.CharField(max_length50, verbose_name经验要求) skills models.TextField(blankTrue, verbose_name技能标签) created_at models.DateTimeField(auto_now_addTrue, verbose_name抓取时间) class Meta: db_table job_info verbose_name 职位信息 verbose_name_plural 职位信息 def __str__(self): return self.title字段里我特别加了db_index因为后续按城市分组、按薪资排序是高频查询加索引能明显加快聚合速度。如果数据量只有几千条效果不明显但养成好习惯总没错。skills字段用TextField存逗号分隔的技能标签后续做词频统计时再拆分不需要额外建关联表因为技能是低价值的展示型数据不是结构化主数据。建完模型后执行python manage.py makemigrations python manage.py migrate迁移成功之后在项目的admin.py里注册一下Job模型后台就能直接看到数据。from django.contrib import admin from .models import Job admin.register(Job) class JobAdmin(admin.ModelAdmin): list_display [title, company, city, salary_mid, education] list_filter [city, education, experience]这样抓下来的脏数据如果某条职位名称明显异常在后台直接删除就行不用写SQL。2.4 抓取合规与反爬的尺度这部分有必要给初次接触爬虫的人说清楚。很多人听说“反爬”两个字就兴奋以为项目值钱的地方在于怎么绕过风控。实际上作为个人学习项目最稳的思路恰恰是“伪装得像正常人但绝不硬刚”。现在很多网站的防护策略是识别请求频率和指纹特征。对学习项目来说把请求头写规范、控制频率、随机等待、设置超时就足够支撑一个小规模数据演示了。如果某一个接口拿不到数据我的建议是先换一个搜索关键词或者城市而不是去研究各种过激方式。我做这个项目时采集规模控制在每个关键词200到500条总共几千条数据足够做出稳定好看的图表。数据量再大也不是Django加SQLite扛不住而是合规风险和学习收益都不划算。爬虫在这个项目里的定位是给数据分析准备原料不是核心技术难点别被带偏。3. 数据分析怎么下手用Pandas和SQL把行情算明白3.1 核心指标怎么计算数据入库之后第一步就是确定“要展示什么”。这个项目最常见的统计维度有四个城市薪资排行、学历要求占比、经验要求分布、技能词频。城市薪资排行可以直接在Django的ORM里用聚合查询实现from django.db.models import Avg from .models import Job def city_salary_rank(request): rows ( Job.objects .exclude(salary_mid__isnullTrue) .values(city) .annotate(avg_salaryAvg(salary_mid)) .order_by(-avg_salary)[:10] ) data { cities: [r[city] for r in rows], avg_salaries: [round(r[avg_salary], 1) for r in rows], } return JsonResponse(data)注意exclude是必须的因为有些职位薪资写“面议”解析之后salary_mid是空值如果不过滤聚合结果会被污染。另外排行榜只取前10名避免页面图表堆得太满。学历占比计算关键是做归一化之后的分组计数。比如把所有包含“本科”的记录归为“本科”包含“硕士”的归为“硕士”然后统计占比def education_distribution(request): rows Job.objects.values(education).order_by(education) total rows.count() data {} for row in rows: edu row[education] if 硕士 in edu: key 硕士 elif 本科 in edu: key 本科 elif 大专 in edu: key 大专 else: key 学历不限 data[key] data.get(key, 0) 1 return JsonResponse(data)这个接口返回的JSON适合前端ECharts画饼图。3.2 技能关键词与词云分析技能标签是BOSS直聘职位里很有价值的信息因为它直接反映了企业当前需要的技术栈。比如搜“数据分析”岗位时技能字段里高频出现的可能有SQL、Python、Excel、Tableau、FineBI、机器学习等。处理方法是把skills字段用逗号拆开全部变成小写再用jieba分词后做词频统计。如果标签本身已经是标准词条其实不需要jieba直接拆分统计即可只有在处理长文本时才需要分词。下面这段是纯Pandas统计思路from collections import Counter import pandas as pd df pd.DataFrame(list(Job.objects.values(skills))) counter Counter() for skills_text in df[skills].dropna(): for skill in skills_text.split(,): skill skill.strip() if skill: counter[skill] 1 top_skills counter.most_common(20) print(top_skills)对于想做出词云的同学可以用wordcloud库生成词云图片再保存到Django的static目录页面中直接引用图片。需要注意中文词云必须要指定中文字体文件比如SimHei或微软雅黑不指定字体会出现满屏方框。3.3 数据分析的坑我替你踩过第一是字段缺失。采集脚本写了一段时间后可能因为页面改版部分字段取不到值。如果直接把空值写入模型后面图表会出现空分类。解决方法是入库前做一次缺失值丢弃或者把关键字段为空的记录全部过滤掉。第二是“薪资中位数”和“平均值”的选择。平均值容易被极端高薪拉高比如一条“80-100K”的职位会明显拉高城市均值。为了更贴近市场真实水平建议排名时优先看中位数或者同时展示两个指标。前端图表里柱状图用平均值再加一条中位数折线会显得更专业。第三是统计维度不能叠加太多。我见过一些同学把城市、学历、经验、技能全放在同一个图表里结果表又密又乱。好的做法是一个图表只讲一个维度的问题。城市就比薪资学历就看占比经验看分布技能看词频页面多放几个图比硬塞一张图清楚得多。4. Django展示层从数据库到图表的完整链路4.1 视图、URL与模板的组织方式整个展示层不复杂但一开始容易把“前端直连数据库”和“后端封装接口”搞混。正确做法是Django负责提供JSON接口或页面前端图表通过fetch请求接口拿到数据再做渲染。这样方式职责清晰后续改动也不会影响页面结构。主要视图可以分成两种。一种是返回页面本身比如首页from django.shortcuts import render def index(request): stats { total_jobs: Job.objects.count(), cities: Job.objects.values(city).distinct().count(), avg_salary: Job.objects.exclude(salary_mid__isnullTrue).aggregate(avgAvg(salary_mid)), } return render(request, index.html, {stats: stats})另一种是返回JSON数据给前端图表调用比如第3章里的city_salary_rank、education_distribution和skill_frequency。URL配置只需要一个列表from django.urls import path from . import views urlpatterns [ path(, views.index, nameindex), path(api/city_salary_rank/, views.city_salary_rank, nameapi_city_salary_rank), path(api/education_distribution/, views.education_distribution, nameapi_education_distribution), path(api/skill_frequency/, views.skill_frequency, nameapi_skill_frequency), ]模板里用一个容器放图表比如div idcityChart stylewidth: 100%; height: 400px;/div div ideduChart stylewidth: 100%; height: 400px;/div div idskillChart stylewidth: 100%; height: 400px;/div然后在模板末尾引入ECharts的CDN和自己写的图表初始化脚本。4.2 ECharts接入的正确姿势首页连接图表我建议用官方CDN引入ECharts比如script srchttps://cdn.jsdelivr.net/npm/echarts5/dist/echarts.min.js/script然后写脚本把第3章的接口串起来async function renderCityChart() { const res await fetch(/api/city_salary_rank/); const data await res.json(); const chart echarts.init(document.getElementById(cityChart)); chart.setOption({ title: { text: 城市平均薪资TOP10 }, xAxis: { type: category, data: data.cities }, yAxis: { type: value, name: 月薪(K) }, series: [{ type: bar, data: data.avg_salaries }], }); } renderCityChart();注意一个问题fetch请求的URL路径一定要和Django路由一致。如果Django项目部署在子路径下比如域名后面带了前缀这里最好用相对路径或者从模板变量里读取配置好的前缀不然接口会404。另外一个高频坑echarts.init必须在容器有实际宽高之后执行。如果容器初始属性是width: 100%而它的父容器塌陷了图表就会显示不出来或宽度是0。最简单的方案是给图表容器设置固定高度比如height: 400px再配合页面加载后再初始化的逻辑。饼图配置同理学历占比接口返回的是对象前端要转换成ECharts需要的数组格式const eduData Object.entries(data).map(([name, value]) ({ name, value })); chart.setOption({ series: [{ type: pie, data: eduData }], });如果页面有多个图表建议每个图表封装一个async函数再在window.onload里统一调用不要把所有逻辑堆在一个函数里后面维护会很难受。4.3 静态文件加载是Django新手最容易卡住的一关热词里有条叫“vscode写img标签在django的static文件中显示不了”这个我太熟悉了。第一次做Django项目时我也在静态文件上卡了一晚上。Django默认不会直接暴露static目录下的文件模板里的img标签不能写src/static/logo.png完事必须用static标签处理{% load static %} img src{% static img/logo.png %} altlogo同时项目的settings.py里要配置STATIC_URL /static/ STATICFILES_DIRS [BASE_DIR / static]其中STATICFILES_DIRS是开发环境下自定义静态文件目录的参数。如果这个配置没写Django只会寻找每个App下的static目录以及Django自带的后台静态文件放在项目根目录static里的图片自然找不到。还有一个容易被忽略的点修改静态文件配置后开发服务器需要重启才能生效。如果改了代码和图片还显示不出来先按CtrlC停掉runserver再重新启动。5. 源码部署与上线把项目跑起来的完整步骤5.1 环境准备与依赖安装这个项目网上流传的源码版本很多题目标注“31467编号”的那一版我实际跑过整体可用。拿到源码包后第一件事不是直接点运行而是先看有没有requirements.txt。因为项目依赖Django、pandas、requests等几个包少一个都可能报坑。建议创建虚拟环境来跑项目免得污染系统Python环境cd django_boss_project python -m venv venv source venv/bin/activate # Windows下执行: venv\Scripts\activate pip install -r requirements.txt如果没有requirements.txt就手动安装pip install django pandas requests jieba安装完成后检查一下Django版本。不同版本的项目模板可能存在差异比如Django 3.x和4.x的url配置方式完全兼容但settings里的某些写法会有微调。如果项目是Django 3.2写的老代码用最新Django 5.x跑偶尔会碰到需要改动的地方。5.2 数据库初始化与数据导入Django默认使用SQLite文件型数据库不需要额外安装服务非常适合这个项目。数据库初始化的命令是python manage.py makemigrations python manage.py migrate如果源码包里带了job_info的表结构makemigrations之后可能会出现“No changes detected”的提示这是正常的说明模型和迁移文件已经匹配。接着创建后台管理员账号python manage.py createsuperuser数据导入这一步我建议优先使用源码包里自带的SQL文件或者JSON导入脚本。如果没有数据文件就需要自己跑一遍采集清洗流程。手动跑采集时可以直接写一个独立的Python脚本放在项目根目录脚本里通过Django的ORM把数据插入数据库。关键是要设置Django环境变量否则脚本无法识别模型import os import django os.environ.setdefault(DJANGO_SETTINGS_MODULE, boss_project.settings) django.setup() from app.models import Job # 这里接你自己的清洗后数据 for item in clean_data_list: Job.objects.create( titleitem[title], companyitem[company], cityitem[city], salary_minitem[salary_min], salary_maxitem[salary_max], salary_miditem[salary_mid], educationitem[education], experienceitem[experience], skillsitem[skills], )导入完成后打开后台页面http://127.0.0.1:8000/admin/输入刚才创建的管理员账号就能看到全部职位数据。如果发现某条数据明显异常直接在后台删除即可。5.3 启动项目与访问效果开发环境启动很简单python manage.py runserver启动显示“Starting development server at http://127.0.0.1:8000/”在浏览器打开这个地址就能看到首页。首页会显示几个关键卡片比如总职位数、覆盖城市数、平均薪资下方就是各图表的区域。第一次访问时可能会遇到两个小问题。一是数据为空页面图表空白这说明数据库里没有数据回第5.2节把导入流程走一遍。二是图表区域显示一片灰色控制台打印“Cannot read properties of undefined”多半是某个接口返回的JSON字段名和前端脚本不一致。对照第4.2节的字段定义逐一排查。如果打算部署到云服务器用uwsgi或gunicorn都能跑但这部分对学习项目不是必要步骤。更实用的做法是先用django的runserver把整个流程理解透再谈上线。6. 常见问题速查、避坑清单与我的实操心得6.1 高频问题速查表现象可能原因解决办法页面能打开但图表空白数据库无数据或接口返回字段名与前端不一致先访问接口地址看JSON输出确认字段名后台显示中文乱码数据库连接未使用utf8mb4或终端编码问题MySQL设置character-set-serverutf8mb4SQLite无此问题图片资源加载不出来模板里没写{% load static %}模板开头加static标签路径写成{% static ... %}采集脚本返回空列表请求头不完整或Cookie失效补上Referer、Cookie降低请求频率模型迁移报字段冲突Django版本升级导致字段类型改版或迁移文件与模型不同步删除冲突迁移文件重新makemigrations再migrate图表刷新后消失设置宽度为100%但父容器高度塌陷给图表容器设置固定高度比如height: 400px薪资统计数值偏高salary_mid未过滤空值或极端高薪拉高均值exclude空值优先展示中位数这类问题我在折腾这个项目时基本都遇到过其中“字段名对不上”最隐蔽。前端脚本里写的是fields后端接口返回的是items没有任何报错但图表就是没数据。所以调试思路一定要沿着“页面→接口→数据库”逐层定位不要看一眼页面空白就急着改前端。6.2 几条实用的避坑建议建议一采集数据时字段名最好和Django模型字段完全一致。比如接口返回的jobName存进模型前就转成title清洗脚本统一用模型字段名后面导入直接Job.objects.create(**item)省去一堆手动映射。建议二所有原始数据保留一份CSV备份。万一导入数据库后想换一种清洗策略或者发现统计数据不对还能回到原始数据重新计算不用再去重新采集。这个习惯在后续做数据分析项目时特别重要。建议三图表不要只堆数量要讲清楚结论。页面除了图表建议在图下方加一句简短结论比如“数据样本共1200条本科岗位占比58%”。这样整个页面更像一个完整的数据分析报告而不是一堆图表的陈列。建议四Django Admin后台真的非常实用。爬下来的脏数据、重复数据、测试数据如果直接在后台管理学习成本极低还能快速看清数据分布。请不要跳过Admin注册这一步。6.3 如果再给我一次机会我会怎么改进个人体会是这个项目最大的提升空间在“时间维度”。现在大多版本都是抓一次数据生成一张静态时间点的快照。但如果隔一段时间再跑一次把两次的数据对比起来看就能分析出薪资变化趋势、岗位需求趋势那就更接近真正的数据分析项目了。另一个改进方向是增加筛选功能。比如页面上加一个下拉框选择城市后学历饼图和技能词云按该城市重新计算。实现起来不复杂就是前端多传一个筛选参数到接口Django后端加一个filter条件。这样项目演示的时候互动性会强很多。最后一个让我印象深刻的点是这个项目能够顺利跑通关键不是某段代码有多厉害而是每一步都留有回头的余地。采集留备份清洗留备份导入前先打印前几条确认格式任何一步不对就停下来检查。这套严谨的工作方法比项目本身的技术细节更值得保留到以后所有的数据项目里。