招聘数据采集与可视化系统实战:从请求构造到图表展示
简介这是一套面向计算机相关专业学生与项目实战学习者的Boss直聘在线爬虫及数据分析可视化系统源码可作为期末大作业、课程设计或毕业设计参考评审分达99分。项目覆盖数据爬取、清洗、存储到展示的完整流程后端基于Django与Django REST framework搭建API服务分析可视化部分结合matplotlib、seaborn与pandas完成图表呈现并附有README.md与文档说明讲解环境搭建、依赖安装、服务启动与接口访问方式。资源包共665个文件约7.29MB以js、html、css等前端资源为主辅以py源码、png与jpg图片、json配置及pickle数据文件另含附赠内容压缩包便于对照学习。目前已有96人学习下载适合具备一定Python基础、希望掌握爬虫与数据分析完整链路的学习者参考实践。1. 招聘数据采集这件事难点从来不在“爬”而在“稳”招聘网站的在线数据采集很多人第一反应是“写个请求把页面拉下来解析就行”真上手才发现翻车点密集得离谱。Boss直聘这类平台的数据价值在于岗位、薪资、城市、经验要求、公司规模这些结构化字段它们能直接支撑薪资分布分析、技能词频统计、城市岗位热力图这类可视化系统。但平台的反爬策略、动态渲染、登录态校验、请求频率限制会让一个“能跑通”的脚本在半小时内变成一堆空数据。这篇笔记面向的是想自己搭一套招聘数据采集加可视化系统的开发者从请求构造、字段解析、数据落库到前端图表把每一步的参数和坑讲清楚。核心结论先放这采集的稳定性靠的是请求节奏控制和字段容错不是靠堆并发。2. 请求层怎么搭从列表页到详情页的最小可用链路2.1 先搞清楚数据在哪一层返回招聘平台的页面结构通常分两种列表页返回岗位摘要职位名、公司、薪资区间、城市、经验学历标签详情页返回完整描述岗位职责、技能要求、公司介绍。很多新手直接抓列表页就以为拿到了全部数据结果做技能词频分析时发现描述字段是空的。常见做法是列表页只用来收集岗位 ID 和基础字段详情页再按 ID 逐个补全。这里有个关键判断如果列表页的接口返回体里已经带了岗位描述摘要那详情页请求可以降频甚至跳过如果描述是懒加载的就必须走详情页。我一般会先用浏览器开发者工具的网络面板筛选 XHR/Fetch 请求看列表滚动时哪个接口在返回 JSON。找到之后复制请求头重点看 Cookie、Referer、User-Agent 和可能的签名参数。签名参数是最容易翻车的地方有些平台会对时间戳加参数做哈希这个哈希逻辑如果藏在混淆过的 JS 里硬啃成本很高。实操中更稳的做法是控制请求频率让签名参数在有效期内复用而不是每次请求都重新计算。2.2 用 Python 构造一个带重试和限速的请求器下面这段代码是一个最小可用的请求封装核心是会话复用、随机延迟和失败重试。注意这里的 URL 和参数是示意结构实际要替换成你自己抓到的接口地址。import requests import time import random from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry # 会话复用保持 Cookie 和连接池 session requests.Session() # 重试策略最多重试3次退避因子0.5秒 retry Retry( total3, backoff_factor0.5, status_forcelist[429, 500, 502, 503, 504], allowed_methods[GET, POST] ) adapter HTTPAdapter(max_retriesretry, pool_connections10, pool_maxsize10) session.mount(https://, adapter) HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://www.example.com/web/geek/job, Accept: application/json, text/plain, */*, # Cookie 需要从浏览器登录后复制注意时效性 Cookie: 你的登录态Cookie } def fetch_page(url, paramsNone, delay_range(1.5, 3.5)): 带随机延迟的请求delay_range 控制请求间隔 time.sleep(random.uniform(*delay_range)) try: resp session.get(url, headersHEADERS, paramsparams, timeout10) resp.raise_for_status() return resp.json() except requests.exceptions.RequestException as e: print(f请求失败: {url}, 错误: {e}) return None这段代码的逻辑说明Retry对象处理的是网络层面的临时故障比如 429 限流和 5xx 服务端错误退避因子让重试间隔逐次拉长。delay_range参数是控制采集节奏的核心我一般设 1.5 到 3.5 秒的随机区间太短容易触发风控太长采集效率低。timeout10是必须的否则某个请求卡死会拖垮整个采集进程。Cookie 的时效性是个大坑登录态通常几小时到一天就过期采集脚本需要能检测到 401 或跳转登录页时自动停止并告警而不是继续空跑。2.3 分页参数和翻页边界怎么处理列表接口一般用page和pageSize两个参数控制分页。pageSize不要贪大常见上限是 30 或 50设太大可能被截断或直接拒绝。翻页终止条件不能只看返回列表是否为空因为有些平台在超出范围时会返回上一页的缓存数据。更可靠的做法是记录已采集的岗位 ID 集合当某一页的新增 ID 占比低于阈值比如 20%时停止。另外城市和关键词组合会显著影响结果总量做全量采集前先用小样本估算每个组合的页数上限避免无限翻页。3. 字段解析与数据落库把脏数据挡在入库之前3.1 薪资、经验、学历字段的清洗规则招聘数据里最脏的就是薪资字段格式五花八门“15-25K·13薪”、“20-40K”、“8千-1.2万”、“面议”。做可视化之前必须统一成数值区间。我一般用正则先提取数字和单位再按单位换算成月薪中位数。经验字段类似“3-5年”、“5-10年”、“经验不限”、“应届生”要映射成数值区间或分类标签。学历字段相对规整但“大专”、“本科”、“硕士及以上”需要统一成有序类别。import re def parse_salary(salary_str): 解析薪资字符串返回 (最低月薪, 最高月薪) 单位元 if not salary_str or 面议 in salary_str: return None, None # 统一单位万 - 10000千 - 1000 salary_str salary_str.replace(万, *10000).replace(千, *1000) # 提取所有数字 nums re.findall(r(\d\.?\d*), salary_str) if len(nums) 2: return None, None low, high float(nums[0]), float(nums[1]) # 如果带“万”单位乘以10000 if *10000 in salary_str: low * 10000 high * 10000 elif *1000 in salary_str: low * 1000 high * 1000 # 处理“·13薪”这类按13个月折算成月均 if 薪 in salary_str: match re.search(r·(\d)薪, salary_str) if match: months int(match.group(1)) low low * 12 / months high high * 12 / months return round(low), round(high)这个函数的参数说明输入是原始薪资字符串输出是月薪区间的整数元值。注意“·13薪”的处理逻辑是把年薪摊到 13 个月再折算回 12 个月这样不同薪资结构的岗位才能横向对比。实际数据里还有“15K-25K”这种不带“·薪”的直接返回区间即可。清洗规则一定要写成可测试的函数因为后续可视化系统的所有薪资图表都依赖这个字段的准确性。3.2 用 SQLite 做本地存储和去重采集阶段用 SQLite 足够轻量、无需额外服务、支持唯一索引去重。表结构设计上岗位 ID 做主键或唯一索引采集时间戳单独一列方便后续做增量更新。import sqlite3 def init_db(db_pathjobs.db): conn sqlite3.connect(db_path) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS job_posts ( job_id TEXT PRIMARY KEY, title TEXT, company TEXT, salary_low INTEGER, salary_high INTEGER, city TEXT, experience TEXT, education TEXT, description TEXT, crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ) # 城市和薪资字段建索引加速后续分析查询 cursor.execute(CREATE INDEX IF NOT EXISTS idx_city ON job_posts(city)) cursor.execute(CREATE INDEX IF NOT EXISTS idx_salary ON job_posts(salary_low, salary_high)) conn.commit() return conn def upsert_job(conn, job_dict): 插入或更新岗位数据job_id 冲突时更新描述和采集时间 cursor conn.cursor() cursor.execute( INSERT INTO job_posts (job_id, title, company, salary_low, salary_high, city, experience, education, description) VALUES (:job_id, :title, :company, :salary_low, :salary_high, :city, :experience, :education, :description) ON CONFLICT(job_id) DO UPDATE SET description excluded.description, crawl_time CURRENT_TIMESTAMP , job_dict) conn.commit()ON CONFLICT子句是 SQLite 3.24 以上版本支持的语法作用是当 job_id 已存在时只更新描述和采集时间避免重复插入。这个设计让采集脚本可以反复跑增量补全缺失的描述字段。索引建在 city 和 salary 上是因为后续可视化最常用的筛选维度就是城市和薪资区间没有索引的话几万条数据做聚合查询会明显变慢。3.3 采集主循环的组装方式把请求器、解析器和存储层串起来的主循环核心是异常隔离单个岗位解析失败不能中断整个批次。我一般用 try-except 包住每条记录的处理失败记录写入单独的日志表或文件方便事后排查。def crawl_list_page(conn, keyword, city, max_pages50): 按关键词和城市采集列表页逐页解析并入库 seen_ids set() for page in range(1, max_pages 1): params {query: keyword, city: city, page: page, pageSize: 30} data fetch_page(https://www.example.com/api/job/list, params) if not data or not data.get(jobList): break new_count 0 for item in data[jobList]: job_id item.get(jobId) if job_id in seen_ids: continue seen_ids.add(job_id) new_count 1 try: salary_low, salary_high parse_salary(item.get(salaryDesc, )) upsert_job(conn, { job_id: job_id, title: item.get(jobName, ), company: item.get(brandName, ), salary_low: salary_low, salary_high: salary_high, city: item.get(cityName, ), experience: item.get(jobExperience, ), education: item.get(jobDegree, ), description: item.get(jobDesc, ) }) except Exception as e: print(f解析失败 job_id{job_id}: {e}) # 新增占比低于20%时认为已到尾部 if new_count / len(data[jobList]) 0.2: break这个主循环里seen_ids集合做页内去重new_count占比判断做翻页终止。max_pages50是安全上限防止参数异常导致无限循环。每条记录的处理被 try-except 包住解析失败的记录不会影响同批次其他记录入库。实际跑的时候建议把 print 换成 logging 模块输出到文件方便回溯。4. 可视化系统怎么接从 SQLite 到前端图表4.1 用 Flask 暴露三个核心查询接口可视化后端不需要太重Flask 加 SQLite 就能撑起中小规模的数据展示。核心接口就三个薪资分布直方图数据、城市岗位数量排名、技能词频统计。每个接口对应一条 SQL 聚合查询。from flask import Flask, jsonify import sqlite3 app Flask(__name__) def query_db(sql, args()): conn sqlite3.connect(jobs.db) conn.row_factory sqlite3.Row cursor conn.cursor() cursor.execute(sql, args) rows [dict(row) for row in cursor.fetchall()] conn.close() return rows app.route(/api/salary_distribution) def salary_distribution(): 按薪资区间分桶统计岗位数量 sql SELECT CASE WHEN salary_low 10000 THEN 10K以下 WHEN salary_low 20000 THEN 10-20K WHEN salary_low 30000 THEN 20-30K WHEN salary_low 50000 THEN 30-50K ELSE 50K以上 END AS salary_bucket, COUNT(*) AS cnt FROM job_posts WHERE salary_low IS NOT NULL GROUP BY salary_bucket ORDER BY MIN(salary_low) return jsonify(query_db(sql)) app.route(/api/city_ranking) def city_ranking(): sql SELECT city, COUNT(*) AS cnt FROM job_posts WHERE city ! GROUP BY city ORDER BY cnt DESC LIMIT 15 return jsonify(query_db(sql))salary_distribution接口用 CASE WHEN 做分桶桶边界可以根据实际数据分布调整。city_ranking限制前 15 个城市避免图表过于拥挤。这两个接口返回的都是 JSON 数组前端 ECharts 或 Chart.js 可以直接消费。注意 SQL 里的WHERE salary_low IS NOT NULL过滤掉了薪资面议的岗位做薪资分析时这部分数据不应该混入。4.2 前端图表的最小配置前端用 ECharts 举例核心是把接口返回的数据映射成图表需要的格式。薪资分布用柱状图城市排名用横向条形图技能词频用词云或柱状图。下面是一个柱状图的配置片段。// 假设接口返回 [{salary_bucket: 10-20K, cnt: 320}, ...] fetch(/api/salary_distribution) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(salaryChart)); chart.setOption({ title: { text: 薪资区间分布 }, tooltip: { trigger: axis }, xAxis: { type: category, data: data.map(item item.salary_bucket) }, yAxis: { type: value, name: 岗位数量 }, series: [{ type: bar, data: data.map(item item.cnt), itemStyle: { color: #5470c6 } }] }); });这段代码的关键点是data.map把接口返回的对象数组拆成 x 轴标签和 y 轴数值两个数组。ECharts 的trigger: axis让 tooltip 在悬停时显示整列数据比单点触发更适合柱状图。图表容器需要提前在 HTML 里定义好宽高否则 ECharts 初始化时会因为容器尺寸为零而渲染空白这是新手最常踩的坑之一。4.3 技能词频统计的简单实现技能词频不需要上 NLP 模型用关键词词典加正则匹配就能覆盖大部分场景。维护一个技能词列表对岗位描述做匹配计数。SKILL_KEYWORDS [Python, Java, Go, React, Vue, MySQL, Redis, Docker, Kubernetes, 机器学习, 数据分析, 微服务] def skill_frequency(conn): cursor conn.cursor() cursor.execute(SELECT description FROM job_posts WHERE description ! ) rows cursor.fetchall() freq {kw: 0 for kw in SKILL_KEYWORDS} for (desc,) in rows: for kw in SKILL_KEYWORDS: if kw.lower() in desc.lower(): freq[kw] 1 return sorted(freq.items(), keylambda x: x[1], reverseTrue)这个实现的边界在于关键词匹配是精确子串匹配“Go”会误匹配到“Google”、“Django”里的“go”。实际使用时要么用词边界正则\bGo\b要么把容易误匹配的词单独处理。词频统计的结果适合做横向对比但不适合做绝对数量断言因为描述文本的长度和详细程度差异很大。5. 避坑与排查采集系统最容易翻车的五个地方5.1 请求返回 200 但数据为空现象脚本不报错但入库数量为零日志显示请求成功。原因通常是登录态过期后平台返回了一个空列表或跳转页的 HTML而不是 401。解决方式是在解析前先判断返回体的结构如果缺少预期的jobList字段就打印返回体前 200 字符并暂停采集人工检查 Cookie 是否失效。5.2 翻页到后面全是重复数据现象采集到第 20 页之后新增岗位 ID 越来越少但接口仍然返回数据。原因是平台对深分页做了限制超出范围后返回的是推荐结果或缓存数据。解决办法是用seen_ids做新增占比判断低于 20% 就停止不要依赖页码上限。另外可以按城市和关键词拆分采集任务每个组合的页数上限通常更低。5.3 薪资解析把“15-25K·13薪”算错现象薪资区间明显偏高或偏低和实际不符。原因是“·13薪”的折算逻辑写反了或者“万”和“千”的单位换算漏了。排查方式是拿一批已知薪资的岗位做单元测试覆盖“15-25K”、“8千-1.2万”、“20-40K·14薪”、“面议”这几种格式确保每种都返回预期结果。5.4 SQLite 并发写入报 database is locked现象采集脚本多线程写入时频繁报锁错误。原因是 SQLite 默认的写锁是库级别的多线程同时写会冲突。解决方式要么改成单线程写入加队列要么在连接时设置timeout参数让写操作排队等待。我一般用单线程采集加批量提交每 50 条 commit 一次兼顾效率和稳定性。5.5 可视化图表数据为空但接口有返回现象接口用浏览器直接访问能看到 JSON但图表渲染空白。原因通常是前端 fetch 的路径不对或者 ECharts 容器没有设置高度。排查步骤先看浏览器控制台有没有跨域或 404 报错再检查图表容器的 CSS 高度是否为零。Flask 开发模式下可以用flask-cors解决跨域生产环境建议前后端同源部署。6. 增量采集与数据新鲜度的维护技巧采集系统跑通之后真正决定它能不能长期用的是增量更新策略。全量重采既慢又容易触发风控我一般用“城市加关键词”的组合做任务分片每个分片记录最后采集时间下次只采新增页。具体做法是在 SQLite 里加一张crawl_meta表记录每个分片的关键词、城市、最后采集时间戳和已采集页数。每次启动时先查这张表从上次中断的页码继续而不是从第一页重来。另一个实用技巧是给采集脚本加一个“数据新鲜度检查”统计最近 24 小时入库的岗位数量如果低于历史均值的 30%就发告警。这个检查能帮你发现 Cookie 过期、接口变更、风控升级这类静默故障。告警方式用最简单的邮件或 webhook 就行不需要上监控系统。验证数据质量的方法也很直接随机抽 20 条入库记录人工打开对应岗位页面比对薪资、城市、经验三个字段。如果准确率低于 90%说明解析规则需要调整。这个抽检习惯我建议每次修改解析逻辑后都做一遍比写单元测试更贴近真实数据分布。最后说一个我踩过的坑有段时间采集脚本跑得好好的突然某天开始入库量暴跌排查了半天发现是平台把列表接口的pageSize上限从 50 改成了 30而我的翻页逻辑是按固定 50 条算的导致每页都漏采。从那以后我养成了一个习惯所有分页参数都不写死而是从第一页的返回体里读实际条数动态调整翻页步长。这个习惯帮我省了很多次后悔药。希望帮到你。本文还有配套的精品资源点击获取