Boss直聘爬虫与可视化:反爬突破、数据清洗到薪资分析

发布时间:2026/10/29 7:32:43
Boss直聘爬虫与可视化:反爬突破、数据清洗到薪资分析

简介:一套面向Python期末大作业与课程设计的Boss直聘在线爬虫及数据分析可视化系统完整源码,附带详细文档说明,适合计算机专业学生及爬虫、数据分析入门者。项目基于Django与Django REST framework搭建后端,集成requests爬虫、pandas数据处理及matplotlib、seaborn可视化,覆盖数据采集、清洗、存储、API接口和图表展示全流程。资源包共665个文件,压缩后约7.29MB,主要包含25个Python脚本、107个HTML页面模板、229个JavaScript交互文件、61个CSS样式、9个JSON配置以及部分数据文件,类型分工明确,按后端逻辑、页面模板和爬虫模块分目录存储,方便按需检索。已有96人学习下载。附赠使用手册与教学辅助内容,包含环境配置说明、启动服务的详细步骤及后端API访问方法,源码注释清晰,能够帮助使用者快速跑通完整项目,完成期末展示或毕业设计参考。

1. Boss直聘在线爬虫与可视化系统:它到底解决了什么问题

做招聘数据采集的人,十有八九都在Boss直聘上碰过壁。这个站的反爬强度在招聘类目里属于第一梯队,Cookie有效期短、接口加密、请求频率稍微快一点就直接给你弹验证码。但另一边,岗位薪资、公司规模、技能要求这些数据又是做职场行情分析最值钱的原材料。把「Boss直聘在线爬虫及数据分析可视化系统」这个标题拆开看,它讲的就是三件事:一是用可控的方式把Boss直聘上的岗位数据持续抓下来,二是把脏乱的招聘信息清洗成能建模的结构化数据,三是用可视化大屏把这些数据的价值呈现出来。

这个方案适合谁?我的判断是:有Python基础、想做数据分析作品集或行业调研的人,以及公司里需要定期看竞品岗位布局的HR和产品团队。它最大的价值不是爬虫本身,而是把采集、清洗、入库、展示串成一条可复现的链路。下面我按自己做过的方案,从数据模型讲到代码落地,再讲坑和可视化收尾,全程不依赖任何商业爬虫框架。

2. 先看反爬和数据建模:字段怎么设,请求频率怎么控

2.1 Boss直聘的登录态与反爬边界:为什么Cookie是一等公民

Boss直聘对未登录用户的访问限制极严,大部分岗位列表页直接302到登录页。做在线爬虫第一步就是把登录态管理好。常见做法是用手动扫码登录拿Cookie,然后把Cookie持久化到本地文件,脚本每次启动都优先加载它。这个方案比账密登录稳妥得多,因为模拟账密登录很容易触发设备风控。

为什么Cookie是一等公民?观察请求特征就能发现,Boss直聘的职位搜索接口对请求头里的Cookie、User-Agent、Referer都有校验,缺少任何一个字段都可能返回空数据或跳到安全验证页。实际落地时,我一般会把Cookie、UA、Referer统一封装成一个请求头字典,每次请求都会随机加一个轻微的偏移。请求间隔也很关键,不要低于8秒,线上跑了一个月后我的结论是:短时间高并发是封号的最大诱因,宁可慢,不能停。

2.2 字段模型与存储结构:用MySQL还是SQLite

数据建模决定了后续分析能不能顺畅进行。岗位数据最核心的字段是:岗位名称、公司名称、城市、薪资下限、薪资上限、经验要求、学历要求、岗位标签、技能关键词、发布日期、数据抓取日期。这些字段分布在列表页和详情页两个层级,列表页能拿到大部分,技能关键词和完整岗位描述需要进详情页。

字段类型说明
job_idvarchar(32)岗位唯一标识,去重用
job_namevarchar(64)岗位名称
company_namevarchar(128)公司名称
cityvarchar(32)工作城市
salary_min / salary_maxint解析后存入,单位千元/月
experiencevarchar(32)经验要求
educationvarchar(32)学历要求
tagstext岗位标签,用逗号分隔
skillstext从描述里抽取的技能词
fetch_datedate抓取日期,增量字段

存储我选MySQL,不选SQLite。原因很简单:在线爬虫是多线程或定时调度跑的,SQLite并发写入容易锁库,MySQL在并发和后续可视化查询上都更稳。表设计时把fetch_date和job_id建联合索引,做“某天新增多少岗位”这类查询会快很多,这个细节在数据量过十万后区别非常明显。

2.3 requests+xpath为什么够用:什么时候才需要上selenium

技术选型上,requests配合lxml的xpath解析就足够。Boss直聘的列表页HTML结构相对规整,岗位卡片都挂在同一个class节点下,xpath可以直接定位。很多人一上来就写selenium,其实没必要,selenium的浏览器指纹反而更容易被风控识别,而且资源占用高,不适合长时间在线跑。

真正需要selenium的场景只有一种:接口参数看不懂、拿不到数据时,退回到浏览器渲染去观察网络面板。也就是说,selenium是排查工具,不是主力采集工具。我一般用requests拿HTML,用xpath解析,用pymysql入库,四个库打天下。这套组合的灵活度足够,源码结构也更清晰,别人拿到手能快速看懂。

3. 在线爬虫的最小可运行实现:从登录态到增量调度

3.1 登录态封装:手动扫码一次,长期复用Cookie

在线爬虫的第一步是把登录态做成可复用资源。第一次运行时用浏览器手动登录Boss直聘,登录成功后从浏览器开发者工具里复制Cookie字符串,存到项目根目录的cookie.txt。脚本启动时读取这个文件,构造请求头。

import os import requests def load_cookie(path="cookie.txt"): """从本地文件加载登录Cookie""" if not os.path.exists(path): raise FileNotFoundError("请先把浏览器里的Cookie粘贴到cookie.txt") with open(path, "r", encoding="utf-8") as f: cookie = f.read().strip() return cookie def build_headers(cookie): """构造带登录态的请求头,UA和Referer不能省""" return { "Cookie": cookie, "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://www.zhipin.com/web/geek/job?query=python&city=101010100", } cookie = load_cookie() headers = build_headers(cookie) session = requests.Session() session.headers.update(headers)

这段代码有三个关键点。第一,load_cookie做了文件存在性检查,避免首次运行直接报KeyError;第二,请求头里的Referer必须跟岗位搜索页保持一致,否则请求会异常;第三,用requests.Session而不是每次新建requests.get,Session会帮我们维护底层的TCP连接和部分状态,在连续请求场景下表现更好。

3.2 采集主流程:请求、解析、入库一次跑通

主循环的逻辑是:翻页请求列表页,解析出岗位卡片,拿到岗位详情页链接,再请求详情页补全技能和描述字段,最后入库。这里我把列表页解析和详情页解析分开写,好处是某一个阶段挂了可以单独重跑。

from lxml import etree import pymysql import time def parse_job_list(html): """从列表页HTML中提取岗位卡片信息""" root = etree.HTML(html) items = [] # 每条岗位卡片都挂在 .job-card-wrapper 下 for card in root.xpath('//*[@class="job-card-wrapper"]'): job = {} job["job_id"] = card.xpath('.//div[@class="job-title clearfix"]/a/@data-jid')[0] job["job_name"] = card.xpath('.//span[@class="job-name"]/text()')[0].strip() job["company_name"] = card.xpath('.//span[@class="company-name"]/text()')[0].strip() job["salary"] = card.xpath('.//span[@class="salary"]/text()')[0].strip() job["city"] = card.xpath('.//span[@class="job-area"]/text()')[0].strip() # 详情链接在岗位名的a标签上 detail_url = card.xpath('.//div[@class="job-title clearfix"]/a/@href')[0] job["detail_url"] = "https://www.zhipin.com" + detail_url items.append(job) return items def save_jobs(items, conn): """把列表页数据写入数据库,主键冲突时跳过""" cursor = conn.cursor() sql = "INSERT IGNORE INTO job_info (job_id, job_name, company_name, city, salary, detail_url) VALUES (%s,%s,%s,%s,%s,%s)" for job in items: cursor.execute(sql, (job["job_id"], job["job_name"], job["company_name"], job["city"], job["salary"], job["detail_url"])) conn.commit()

注意parse_job_list里我用了.//而非//,这是xpath里的细节:.表示从当前节点开始搜索,避免匹配到页面其他区域的同名class。如果不用.,某些页面会把推荐职位和搜索职位混在一起,造成重复数据。INSERT IGNORE是去重的最简单手段,前提是job_id必须建唯一索引。

接下来是详情页解析。详情页能拿到岗位描述、经验、学历要求,这些字段列表页不完整。详情页请求时要把job_id拼在URL里,解析完直接回写同一行数据。

def parse_detail(html): """解析岗位详情页,抽取经验、学历、技能标签""" root = etree.HTML(html) detail = {} info_list = root.xpath('//*[@class="job-sec-content"]/div[contains(@class, "job-primary")]//div[@class="info-primary"]//p//text()') # 经验学历通常长这样:["1-3年", "本科", "深圳南山区"] if len(info_list) >= 2: detail["experience"] = info_list[0].strip() detail["education"] = info_list[1].strip() desc_list = root.xpath('//*[@class="job-sec-content"]//div[@class="job-detail"]//text()') desc_text = "".join([t.strip() for t in desc_list]) # 从描述中匹配技能关键词 skill_keywords = ["Python", "Java", "MySQL", "Redis", "Kafka", "Docker", "K8s"] matched = [k for k in skill_keywords if k.lower() in desc_text.lower()] detail["skills"] = ",".join(matched) return detail

经验学历的解析有个坑:不同岗位的信息顺序不完全一致,有的岗位写的“在校/应届”,有的写“经验不限”。我在源码里做了顺序假设——第一个是经验、第二个是学历,实际跑数据时发现有大概5%的脏数据会颠倒。这个不影响最终统计,但会让“学历分布”图表出现少量噪音,后面在清洗环节统一处理。

3.3 增量与调度:怎么做到只抓新岗位

增量采集的核心是记住上次抓到了哪一页、哪些岗位已经入库。最简单可靠的方案是用数据库里的fetch_date字段做判断——当天抓过的不再抓,跨天则全量重抓。这样避免了维护游标文件的复杂性。

def fetch_today_jobs(session, city_code="101010100", query="Python"): """按城市和关键词抓取当天新增岗位,只跑前5页""" all_items = [] for page in range(1, 6): url = f"https://www.zhipin.com/web/geek/job?query={query}&city={city_code}&page={page}" resp = session.get(url, timeout=10) if resp.status_code == 200 and "job-card-wrapper" in resp.text: items = parse_job_list(resp.text) all_items.extend(items) print(f"第{page}页解析出{len(items)}条") else: print("请求异常,可能触发验证码,停止本轮") break time.sleep(12) # 每个请求之间固定休眠 return all_items

参数说明:city_code是Boss直聘的城市编码,北京是101010100,上海是101020100,全国是100010000;page最大翻到5页是安全线,超过之后触发验证码的概率会直线上升;time.sleep(12)是我调试了很久之后选出来的值,这个间隔既能让一晚上抓完一个城市的核心岗位,又不至于触发频率检测。如果你抓的岗位数不需要太多,可以进一步降到15秒。

调度的方案我推荐系统crontab,而不是在Python里写定时循环。用crontab的好处是进程挂了系统会自动拉起,不需要在源码里维护进程守护。一个标准的定时任务长这样:

0 2 * * * cd /opt/boss_jobs && /usr/bin/python3 run_crawler.py >> logs/crawler.log 2>&1

这段cron表达式表示每天凌晨两点执行一次采集任务。选凌晨跑是因为这个时间段站点活跃度低,反爬压力小,而且早上起来正好能看前一天的增量数据报表。

4. 数据清洗与指标口径:薪资怎么解析,哪些字段能信

4.1 薪资解析:K、千/月、14薪的归一化处理

Boss直聘的薪资字段长什么样?常见的是15-30K、20-30K·14薪、200-300元/天、30-40K·15薪。这些形态不统一,直接入库后完全没法做均值分析。我的方案是写一个解析函数,把文字拆成下限、上限、薪资类型、发薪月数四个字段。

import re def parse_salary(salary_text): """把Boss直聘的薪资文本解析成结构化字段""" if isinstance(salary_text, float) or not salary_text: return {"min_k": 0, "max_k": 0, "type": "", "months": 12} text = salary_text.strip() months = 12 m = re.search(r"(\d+)薪", text) if m: months = int(m.group(1)) if "元/天" in text: nums = re.findall(r"\d+", text) min_k = int(nums[0]) * 30 / 1000 max_k = int(nums[1]) * 30 / 1000 if len(nums) > 1 else min_k return {"min_k": round(min_k, 1), "max_k": round(max_k, 1), "type": "day", "months": 1} if "K" in text or "k" in text: nums = re.findall(r"(\d+(?:\.\d+)?)", text.replace("K", "").replace("k", "")) if len(nums) >= 2: return {"min_k": float(nums[0]), "max_k": float(nums[1]), "type": "month", "months": months} elif len(nums) == 1: return {"min_k": float(nums[0]), "max_k": float(nums[0]), "type": "month", "months": months} return {"min_k": 0, "max_k": 0, "type": "unknown", "months": 12}

拆出来之后怎么算口径?我用的指标是(min_k + max_k) / 2 * months / 12,也就是说把14薪折算成年包再还原成月均。这样不同发薪月数的岗位才能放在同一张图上比较。这里有个很多人会踩的坑:直接把15-30K·14薪当15-30K处理,导致该岗位的实际月收入被低估约17%。

4.2 岗位分类与技能标签:让分词替代人工打标

原始数据里岗位名称千奇百怪,Python开发、高级Python工程师、Python后台开发其实都是Python岗,但直接按名字分组会裂成几十个类。我的做法是用关键词映射做一个粗分类:Python、Java、前端、算法、测试、运维这几个词先匹配,匹配不到的就归入其他。

技能标签的提取更简单,先从岗位描述里匹配预置的技术词列表,匹配不到的再用jieba分词后做词频统计,取Top3作为补充。这个方案比起纯机器学习分类,胜在可解释性强:每一行数据的分类依据都能说清楚,代码也容易维护。不要一上来就整BERT模型,这个场景下性价比极低。

4.3 指标设计:月薪均值、供需比、经验分布怎么算

可视化系统需要有稳定口径的指标,我一般会维护一张日汇总表,每天采集完自动算一次。关键指标如下:

指标名计算逻辑用途
平均月薪全部岗位折算月薪的算术平均值城市薪酬基准
薪资中位数排序取中,受极端值影响小更真实的薪酬水平
供需比岗位数量 / 平均每天新增岗位数判断岗位增长速度
学历分布按本科、硕士、大专分组计数门槛可视化
技能频次skills字段的拆词计数热门技能趋势

这五个指标足以支撑一张职场行情看板。平均月薪看趋势,中位数看真实,供需比看冷热,学历和技能看门槛。再复杂的模型不是当前阶段该做的事,先把口径固定下来,后面做时间序列才有基准。

5. 避坑:Boss直聘爬虫的五个典型翻车现场

5.1 翻车现场一:Cookie明明是对的,却一直302跳登录

现象:首次运行正常,第二天再跑同一个Cookie,发现每次请求都被302到登录页。

原因:Boss直聘的Cookie有效期很短,尤其是__zp_stoken__这个字段,几乎每天都会失效。失效后Session不会自动重登,请求就一直在登录页打转。

解决:在采集主逻辑里加Cookie有效性检查,每次请求后判断响应URL是否包含/web/geek/job,若包含则说明登录态正常;若跳转到了/web/user/或登录页,就停止任务并发送告警通知。养成每天手动刷新一次Cookie的习惯,或者做一个Cookie失效自动通知机制。

5.2 翻车现场二:同一个岗位采集到几百条重复数据

现象:数据库里出现大量相同job_id的记录,导致统计时岗位数量虚高。

原因:列表页请求成功后,解析逻辑没有去重;更隐蔽的原因是INSERT IGNORE依赖唯一索引,但建表时漏掉了job_id的唯一约束。

解决:建表时必须给job_id加唯一索引,不要靠应用层过滤。其次在解析函数末尾加一个seen = set()来拦截本次请求内部重复的卡片。两层兜底,能压住99%的重复数据。

5.3 翻车现场三:夜间定时任务跑了十分钟就挂

现象:crontab里设置了凌晨2点执行,看日志发现每次只跑了几十个请求就退出,日志末尾也没有报错信息。

原因:事件一触发安全验证,脚本里的time.sleep(12)没有生效,因为详情页请求里有几个没有走同一个Session,导致请求间隔失效,被风控命中直接断连。

解决:把详情页请求也统一挂到同一个requests.Session上,并且在session.get外层包一个try...except requests.exceptions.ConnectionError,遇到断连就退出而不是让堆栈信息淹没在日志里。再加一个进程退出码,crontab看到非0退出码时发邮件。

5.4 翻车现场四:平均薪资算出来高得离谱

现象:城市平均月薪统计结果比招聘平台公开报告高出30%以上。

原因:薪资解析函数对15-30K·14薪处理正确了,但对200-300元/天按30天折算月薪,导致实习岗和兼职岗被放大成高薪全职岗。

解决:在统计层面对薪资类型做筛选,只看type == "month"的记录,day和hour类型的岗单独统计。如果你的分析目标就是“全职岗位薪酬行情”,这一步必须做。这也是我在4.1节里单独加type字段的原因。

5.5 翻车现场五:可视化图表的数字跟数据库对不上

现象:ECharts大屏上显示的岗位总数比数据库SQL查询结果少了几千。

原因:后端接口直连数据库,但表里包含了全量历史数据,而前端的筛选条件默认只查近30天。两边口径不一致,一个看总量一个看增量。

解决:定一个数据接口标准——所有可视化接口必须至少接收start_date和end_date两个参数,默认区间统一为最近30天;同时在SQL查询里加fetch_date BETWEEN %s AND %s。口径统一之后,图表和明细才能对得上。

6. 可视化设计与持续迭代:用ECharts做一张能看懂行情的大屏

6.1 接口与图表选型:数据服务先行,图表是最后一公里

可视化这块我用ECharts,原因是在Python技术栈里它的生态最成熟。前端只需要四个图表:城市薪资地图用map,薪资区间分布用bar,热门技能Top10用horizontal bar,市场需求趋势用line。接口我用Flask写一个极简的JSON接口,返回清洗后的聚合数据。

from flask import Flask, jsonify, request import pymysql app = Flask(__name__) def query_db(sql, params=None): conn = pymysql.connect(host="localhost", user="root", password="123456", database="boss_jobs", charset="utf8mb4") cursor = conn.cursor() cursor.execute(sql, params or ()) cols = [col[0] for col in cursor.description] rows = [dict(zip(cols, row)) for row in cursor.fetchall()] conn.close() return rows @app.route("/api/salary_trend") def salary_trend(): """按周聚合的平均月薪趋势接口""" days = request.args.get("days", 30, type=int) sql = """ SELECT DATE_FORMAT(fetch_date, '%%Y-%%m-%%d') AS day, AVG((salary_min + salary_max) / 2) AS avg_salary FROM job_info WHERE fetch_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY) GROUP BY day ORDER BY day """ data = query_db(sql, (days,)) return jsonify(data) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=False)

这个接口就是大屏的数据底座。注意SQL里的%%Y-%%m-%%d写法,在Python的格式化场景下%需要转义,不转义会直接报SQL语法错误。参数days默认30,前端传多少就用多少,后续想看季度趋势直接改参数即可。

6.2 数据质量监控与迭代方向

可视化不只是把图做出来,更要能让人在图表里一眼发现异常。我习惯在接口层加一个简单的数据质量检查:如果今天的采集量比昨天下降超过50%,接口返回的JSON里带上"anomaly": true,前端拿到这个标记就在大屏角落展示一个黄色警告条。这一步能帮你及时察觉到账号风控或者采集进程异常。

如果你要持续运行这套系统,我建议的迭代路线是:先做到每天稳定抓一个城市、一个岗位方向的数据,跑满一个月形成时间序列;再扩展到五个核心城市;最后再把岗位描述文本做成词云或主题模型。不要一开始就追求大而全,数据的稳定性远大于数据的广度。我自己的教训是,第一版系统 пытался抓了六个城市和几十个岗位方向,结果维护成本极高,每周都在排查脏数据,后来狠心砍到只抓一个核心维度,数据质量立刻上来了。

希望这套方案能帮你在Boss直聘的数据采集和可视化分析上少走几个月的弯路。

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