news 2026/11/3 16:55:31

Python岗位数据采集与分析可视化:从接口抓包到定时跑批的完整实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python岗位数据采集与分析可视化:从接口抓包到定时跑批的完整实践

简介:一套面向求职者、招聘者与数据分析学习者的Boss直聘岗位数据采集及分析可视化项目资源,基于Scrapy框架爬取全国热门城市大数据、数据分析、数据挖掘、机器学习等岗位信息,并提供源代码、数据文件与项目文档。资源压缩包共38个文件,整体仅237KB,包含13个Python脚本、12个JavaScript交互文件、4个XML配置,以及CSV数据表、PNG图表、HTML页面和Markdown说明文档等,结构清晰,便于按模块学习复用。目前已有582人学习下载。借助这套资源,读者可查看采集好的全国热门城市岗位数据,复用爬虫与数据清洗脚本,并结合前端可视化代码搭建岗位薪资、学历要求、技能需求的分析看板;项目文档同步提供运行指导与实现思路,适合希望掌握数据采集到分析展示全流程的入门及进阶开发者。

1. 基于Python做Boss直聘岗位数据采集与分析可视化:先把整个项目拆成能落地的三块

如果你做过招聘数据分析,一定遇到过这种尴尬:想统计某座城市Python岗位的真实薪资区间、学历门槛和技能要求,却发现Boss直聘网页版只能一页一页手动翻,既不能批量导出,也没法按条件把几千条数据一次性拉下来。这时候用Python做岗位数据采集与分析可视化,就是一条非常务实的路子。这个项目的本质是三条线:采集层用requests/selenium维持登录态并抓取搜索接口,存储层把JSON清洗成结构化数据,分析层再对薪资、城市、技能做聚合统计和图表展示。适合的人群很明确:想验证某个城市岗位供需的求职者、做行业调研的产品运营,以及刚入门Python爬虫和数据可视化、需要一份完整可复现项目来练手的开发者。下面从接口设计讲到落地代码,把坑和参数一次说清。

2. Boss直聘采集方案设计:为什么不能直接requests GET然后碰运气

2.1 数据从哪来:先抓包看清接口,再谈怎么写代码

做这个项目的第一个动作不是写代码,而是打开浏览器开发者工具,手动搜索一次“Python”岗位,观察网络面板里到底发了哪些请求。Boss直聘的网页版搜索结果是异步渲染的,HTML源码里根本没有岗位数据,真正的数据藏在XHR请求里。常见做法是找到一个形如zhipin.com/wapi/zpgeek/search/joblist.json的接口,GET参数带query、city、page、limit,返回体是标准JSON,岗位列表在zpData.jobList字段下。

我一般会先用curl或者Postman把返回体整个看一遍,确认字段名再动代码。一个最小请求长这样:

import requests headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://www.zhipin.com/web/geek/job?query=python&city=100010000" } url = "https://www.zhipin.com/wapi/zpgeek/search/joblist.json" params = { "query": "python", "city": "100010000", "page": 1, "limit": 15 } resp = requests.get(url, headers=headers, params=params, timeout=10) print(resp.status_code) print(resp.json().keys())

这里最关键的是参数city,它是一串城市编码,比如北京100010000、上海100020000,不是城市拼音。如果不带Referer,部分请求头校验严格的接口会直接拒绝服务。这个脚本第一次跑大概率不会成功,因为登录态还没带上。

2.2 登录态与Cookie:为什么第一次请求就进了“安全验证”

如果你刚打开过Boss直聘首页,通过扫码登录了账号,再直接跑上面的代码,很可能收到一个302跳转到登录页,或者JSON里code不等于0,消息写着“登录已过期”。原因是搜索接口要求请求携带有效的登录Cookie,而requests本身不维护任何浏览器状态。

常见做法是用requests.session()维持会话,并手动把浏览器里登录后的Cookie塞进去。Cookie可以从开发者工具Network面板任意一个请求的Request Headers里复制,以Cookie字段的完整文本粘贴到本地配置文件,不要硬编码在源码里:

import requests import json with open("cookie.json", "r", encoding="utf-8") as f: cookie_str = json.load(f)["cookie"] session = requests.session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://www.zhipin.com/web/geek/job?query=python" }) # 逐行解包Cookie,转成requests的CookieJar for item in cookie_str.split(";"): item = item.strip() if not item: continue if "=" in item: key, value = item.split("=", 1) session.cookies.set(key, value)

这里有个取舍:为什么不直接headers["Cookie"] = cookie_str?因为requests里手动构造Cookie字符串在重定向时容易出现丢失,用session.cookies.set逐条写入更稳。Cookie的有效期通常是一周到一个月不等,过期后重新扫码一次,更新本地文件即可,这也是项目文档里必须写清楚的维护项。

2.3 反爬边界与合规用量:一份个人分析项目的数据摄入量该控制在多少

实现了登录态以后,你可能会顺手把page从1试到100。这里要泼一盆冷水:Boss直聘对请求频率有比较明显的风控,连续高频翻页会触发滑块验证,严重时账号会被限制搜索。个人的经验是,这个项目定位应该是“低频个人分析”,不要追求把全站岗位都抓下来。

我通常把请求间隔控制在3到5秒之间,单次任务最多抓10页、约150条数据;一次跑5到8个关键词,总量控制在1000条以内。频率上每天跑一次,选在低峰时段。你可能会觉得这个量太少,但做薪资分布和技能词云分析,1000条样本已经足够看出趋势,再多反而容易被风控盯上。

控制频率的实现很简单,在翻页循环里加随机等待即可:

import time import random time.sleep(random.uniform(3, 5))

注意这里的间隔是“每次请求前”等待,不是“每页等待”。如果加上解析时间,一轮下来自然节奏更慢,也更接近真人浏览的间隔。

3. 登录态模拟与岗位搜索:把“搜索Python岗位”跑通并拿到结构化数据

3.1 用requests维持会话:最小可用的登录态装配

上一章的session装配已经解决了登录问题,这里把它封装成一个函数,方便后续所有请求复用。一个比较顺手的方式是单独建一个session_manager.py,对外只暴露一个已经带好Cookie和Header的session对象。这样采集脚本里不用反复复制那串Cookie解析代码。

# session_manager.py import requests import json def load_cookie(path="cookie.json"): with open(path, "r", encoding="utf-8") as f: return json.load(f)["cookie"] def build_session(cookie_path="cookie.json") -> requests.Session: session = requests.session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "application/json, text/plain, */*", "Referer": "https://www.zhipin.com/web/geek/job", }) cookie_str = load_cookie(cookie_path) for item in cookie_str.split(";"): item = item.strip() if "=" in item: key, value = item.split("=", 1) session.cookies.set(key, value) return session if __name__ == "__main__": s = build_session() # 拿搜索接口做一次探测 url = "https://www.zhipin.com/wapi/zpgeek/search/joblist.json" resp = s.get(url, params={"query": "python", "city": "100010000", "page": 1, "limit": 15}, timeout=10) print(resp.status_code, resp.text[:200])

这段代码里的build_session返回的是带着完整登录态的对象,探测请求能直接打到接口层。一个值得注意的参数是Accept,如果接口返回的是JSON,这个头能避免部分情况下服务端返回HTML错误页。如果你在浏览器里复制Cookie时还看到了__zp_seo_uuid__这类特殊字段,一并写进Cookie文件即可,requests不会挑字段。

3.2 解析岗位列表:jobList里的三层嵌套与字段映射

拿到搜索接口的返回体之后,下一步就是把JSON里的岗位数据解析出来。jobList是一个数组,每个元素是一个岗位对象,常见字段包括jobId、jobName、salaryDesc、brandName、city、areaDistrict、businessDistrict、jobExperience、jobDegree和skills。其中salaryDesc是“10k-15k·13薪”这样的文本,需要后期清洗。

import json import pprint def parse_joblist(resp_json: dict): zp_data = resp_json.get("zpData", {}) job_list = zp_data.get("jobList", []) parsed = [] for job in job_list: item = { "job_id": job.get("jobId"), "job_name": job.get("jobName"), "salary_desc": job.get("salaryDesc"), "brand": job.get("brandName"), "city": job.get("city"), "area": job.get("areaDistrict"), "biz_district": job.get("businessDistrict"), "experience": job.get("jobExperience"), "degree": job.get("jobDegree"), "skills": ",".join(job.get("skills", []) or []), "link": job.get("jobDetail", {}).get("link") if job.get("jobDetail") else None, } parsed.append(item) return parsed # 调用示例 resp_json = json.loads(resp.text) rows = parse_joblist(resp_json) pprint.pprint(rows[:2])

这里的jobId是整个项目最重要的字段,后续去重和数据库主键都靠它。skills是一个列表,在采集阶段先转成逗号分隔字符串存起来,后面做词云时再拆开聚合。注意部分岗位对象没有jobDetail,所以取link时要加个if job.get("jobDetail")保护,否则会直接抛KeyError。这种字段缺失在实际数据里很常见,解析时不能假设每个字段都存在。

3.3 翻页循环与请求间隔:把“搜1000条”变成可复现的采集任务

单页解析跑通后,写一个翻页循环把关键词和城市组合起来。一个完整的采集任务通常这样组织:外层遍历关键词列表,中层遍历城市列表,内层遍历页码。参数page从1开始,limit固定在15或30。

import time import json import random import pandas as pd from session_manager import build_session SESSION = build_session() def fetch_jobs(keyword: str, city_code: str, max_page: int = 10): all_rows = [] url = "https://www.zhipin.com/wapi/zpgeek/search/joblist.json" for page in range(1, max_page + 1): params = { "query": keyword, "city": city_code, "page": page, "limit": 15 } try: resp = SESSION.get(url, params=params, timeout=10) if resp.status_code != 200: print(f"[跳过] {keyword} 第{page}页, HTTP {resp.status_code}") continue data = resp.json() if data.get("code") != 0: print(f"[警告] {keyword} 第{page}页 code={data.get('code')}") break rows = parse_joblist(data) if not rows: break all_rows.extend(rows) print(f"[OK] {keyword} 第{page}页, 累计{len(all_rows)}条") except Exception as e: print(f"[异常] {keyword} 第{page}页: {e}") time.sleep(10) time.sleep(random.uniform(3, 5)) return all_rows if __name__ == "__main__": result = fetch_jobs("python", "100010000", max_page=10) df = pd.DataFrame(result) df.to_csv("raw_jobs.csv", index=False, encoding="utf-8-sig")

这里的code != 0判断非常关键。接口返回code=0才是正常数据,返回其他值通常是登录态失效或触发了风控,此时继续翻页没有意义,应该跳出循环。我一般会在code非0后直接终止整个任务,而不是只跳一页,因为连续两页异常说明账号状态已经不可用了。CSV里的utf-8-sig是给Excel用户看的,避免直接打开乱码。这段代码跑完之后,你会得到一个原始岗位表,接下来的活就是清洗。

4. 数据清洗与落地存储:把嵌套JSON整理成能直接做可视化的DataFrame

4.1 把salaryDesc拆成数字区间:正则处理“10k-15k·13薪”

采集到的薪资字段长这样:“10k-15k·13薪”、“8k-12k”、“面议”。做可视化时我们需要的是数字区间,所以第一步是用正则从salaryDesc里提取下限和上限,并算出中位数作为后续统计的基准值。这个环节看着简单,实际上一碰数据就有各种写法,比如“20-40K”“10k以上”“300-500元/天”等。

import re def parse_salary(desc: str): if not desc or desc == "面议": return None, None, None # 匹配数字+字母k/k,单位统一为k/月 pattern = r"(\d+(?:\.\d+)?)k?-(\d+(?:\.\d+)?)k" match = re.search(pattern, desc, flags=re.IGNORECASE) if match: low = float(match.group(1)) high = float(match.group(2)) return low, high, (low + high) / 2 # 处理单值写法,如 "10k" single = re.search(r"(\d+(?:\.\d+)?)k", desc, flags=re.IGNORECASE) if single: val = float(single.group(1)) return val, val, val return None, None, None # 测试 for s in ["10k-15k·13薪", "20-40K", "8k-12k", "面议", "300-500元/天"]: print(s, "->", parse_salary(s))

正则(\d+(?:\.\d+)?)k?-(\d+(?:\.\d+)?)k里最需要注意的地方是第一个数字后面紧跟的k?是可选的,因为部分岗位写的是“20-40K”而不是“20k-40k”,直接把范围数字取出来即可。对于“300-500元/天”这种日薪岗位,个人建议在采集阶段直接过滤掉,或者单独标注,不要和月薪混在一起算平均值,否则出来的图表没有参考价值。

4.2 城市与区域归一化:为什么同一个城市会有三种写法

Boss直聘接口里的city字段大多数是“北京”“上海”这种规范名,但areaDistrict和businessDistrict偶尔会出现“朝阳区”“海淀区”和“CBD·国贸”“望京”这类混合写法。做城市维度的分析时,city可以直接用,但如果你要分析“杭州滨江区有多少Python岗”,就需要对区域字段做一次清洗。

def normalize_area(area: str): if not area: return "未知" # 去掉常见的后缀,保留主区域名 area = area.replace("区", "").strip() area = re.sub(r"[·|\-|/]", "_", area) return area

这张清洗表不需要做得特别复杂,把“区”字去掉,把分隔符统一成下划线就够了。真正需要注意的反而是businessDistrict里的商圈名,比如同一个“望京”,有的岗位写“望京”,有的写“望京·SOHO”,分析时可以按前缀把商圈名归到街道这一级,或者干脆不分析这个字段,只看areaDistrict。

4.3 落地到SQLite:为什么我不用CSV直接做可视化

采集脚本里用CSV做中间存储没问题,但进入分析阶段,我强烈建议换成SQLite。原因有三:第一,重复跑任务时CSV会累积重复行,而SQLite可以给job_id建唯一约束;第二,SQLite支持SQL聚合,后续按城市、关键词、经验字段做分组统计时一条SQL就能完成;第三,可视化工具直接读SQLite比读几千行CSV更干净。

import sqlite3 import uuid def init_db(db_path="boss_jobs.db"): conn = sqlite3.connect(db_path) conn.execute(""" CREATE TABLE IF NOT EXISTS jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT UNIQUE, job_name TEXT, salary_desc TEXT, salary_low REAL, salary_high REAL, salary_mid REAL, brand TEXT, city TEXT, area TEXT, biz_district TEXT, experience TEXT, degree TEXT, skills TEXT, keyword TEXT, crawl_time TEXT ) """) conn.commit() return conn def batch_insert(conn, rows): sql = """ INSERT OR IGNORE INTO jobs (job_id, job_name, salary_desc, salary_low, salary_high, salary_mid, brand, city, area, biz_district, experience, degree, skills, keyword, crawl_time) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) """ conn.executemany(sql, rows) conn.commit()

INSERT OR IGNORE配合job_id UNIQUE约束是这里防重的核心。同一岗位在多次采集里都会出现,用job_id做唯一键可以直接跳过重复记录,这是CSV做不到的。crawl_time字段记录采集时间,后续做时间维度分析时有用。如果同一天跑了两次任务,第一次的1000条和第二次的1000条有600条重复,最终库里只会有1400条而不是2000条,数据可信度完全不一样。

5. 避坑:采集与可视化落地中的5个常见现场问题

5.1 第一个请求就302跳转登录页

现象:运行采集脚本,发现返回的resp.url已经变成了登录页地址,而不是joblist.json,状态码是200但内容是一段HTML。

原因:Cookie过期或没有正确写入session。最典型的错误是复制Cookie时把Cookie:这个前缀一起复制进了文件,导致requests解析出名叫“Cookie:”的空字段。

解决:检查Cookie文件是否是纯JSON文本;在build_session里给session加上一条探测请求,输出resp.status_code和resp.url判断是否被重定向。我习惯在采集脚本里写一个check_login()函数,跑任务前先探一次,省得翻到第三页才发现登录态已经失效。

5.2 翻到第三页突然出现滑块验证

现象:前两页正常,到第三页开始接口返回的HTML里出现“安全验证”字样,或者直接返回code=4000之类非0错误。

原因:请求频率超过风控阈值,不换IP的前提下只能降低速度。这个“阈值”并没有公开标准,与账号历史行为有关,但连续快速翻页一定是触发条件之一。

解决:把请求间隔从3至5秒放宽到8到12秒,同时单次任务页数限制在10页以内。如果已经被限制了,停掉任务半小时后再跑。这里我不会推荐换IP或者多账号绕过的方案,低频、少量、个人使用是这类项目能持续跑下去的边界。

5.3 薪资正则把“10k-15k·14薪”的薪数当成薪资取出来

现象:解析结果里出现salary_high=15k,但salary_mid=29k这种明显不对的值。

原因:正则没有做边界约束,部分写法里“14薪”中的“14”被当成了薪资范围的第二个数。

解决:上面的parse_salary函数里用了(\d+(?:\.\d+)?)k?-(\d+(?:\.\d+)?)k,第二个数字后面必须紧跟k,所以“14薪”不会匹配上。如果你拿到的数据里有“10k-15k·13薪”这种格式,正则末尾的k正好拦住这个问题。优先用我前面给的解析函数,不要自己随手写r"(\d+)-(\d+)",这种必须翻车。

5.4 matplotlib画图时中文全部变成方块

现象:用matplotlib画薪资分布图,标题和坐标轴上的中文全部显示为豆腐块。

原因:matplotlib默认字体不支持中文,需要手动指定中文字体。

解决:在绘图脚本最前面加上字体设置,并且要确认字体实际存在:

import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "PingFang SC"] plt.rcParams["axes.unicode_minus"] = False

这里第二行axes.unicode_minus是为了让坐标轴上的负号正常显示,很多人在设完字体后漏了这行,结果数字轴上的负号变成方块。

5.5 SQLite里数据重复翻倍

现象:跑了两次任务后,SELECT COUNT(*)显示的数量是第一次加第二次的总和,而不是去重后的值。

原因:建表时没有写job_id UNIQUE,或者插入用的是INSERT INTO而不是INSERT OR IGNORE。

解决:参考第4章的建表语句,给job_id加UNIQUE约束,插入语句改成INSERT OR IGNORE。如果已经建了错表,直接删除数据库文件重新采集最快,没必要写迁移脚本。

6. 进阶:把“能跑”变成“可信”——定时任务与抽样自检

项目跑通之后,下一步我建议做三件事:给采集脚本加一个定时任务,让数据每周自动更新一次;加一段抽样自检逻辑,确认库里的数据量和字段分布是合理的;最后把可视化图表从“能出图”升级成“固定模板每周出一份”。

定时任务在Windows上用计划任务,在Linux上用crontab,但两者的前提都是采集脚本本身能无人值守运行。我的习惯是把清洗和入库也纳入采集脚本,保证一次执行完整个链路。一个简单的定时入口可以这样组织:

# run_weekly.py import subprocess import sqlite3 import datetime today = datetime.date.today().isoformat() # 依次执行:采集 -> 清洗入库 -> 生成报告 subprocess.run(["python", "fetch_jobs.py"], check=True) subprocess.run(["python", "analyze_report.py", "--date", today], check=True) print(f"[完成] {today} 的数据已更新")

自检逻辑里我一般会查三件事:一是当天新增记录数,如果为0说明登录态可能过期了;二是每个关键词的岗位数量是否在合理范围,如果某个关键词骤降到个位数,多半是风控拦截了部分页;三是薪资中位数的分布是否符合常识,比如“北京Python岗中位数低于5k”这种结果大概率是数据清洗出了问题。

自检通过后,用matplotlib或pyecharts生成固定模板的图表,包含薪资中位数Top10城市、岗位需求趋势、技能词云三个图。最终产物是一份带时间戳的HTML报告,既方便自己回溯,也能直接分享给团队。这套流程稳定运行两个月后,你会慢慢积累出真正有参考价值的岗位数据,而不是每次需要分析时临时抓一次,拿到一份来源不明、质量不一的脏数据。希望这个从接口抓包到定时跑批的落地路径能帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 16:55:28

GeoServer安装部署与跨域配置全攻略:从零搭建到CORS实战

1. 为什么一个地图服务要先解决跨域这档子事搞GIS后端的人大概都有过这种体验:本地把地图服务跑起来了,图层预览一切正常,结果前端页面一调接口,浏览器控制台红字一片,提示跨域请求被拦截。明明服务是活的,…

作者头像 李华
网站建设 2026/11/3 16:51:37

HarmonyOS Ability生命周期详解与优化实践

1. Ability生命周期基础概念解析 在HarmonyOS应用开发中,Ability作为应用的基本组成单元,其生命周期管理直接影响应用的稳定性和性能表现。简单来说,Ability生命周期指的是Ability从创建到销毁的完整过程,系统通过预定义的回调方法…

作者头像 李华
网站建设 2026/11/3 16:51:28

工业质检双料王:弱监督缺陷分割与边缘端部署实战

拿到这个标题时,我第一反应是“双料王”三个字背后肯定不只是论文中稿这么简单。ECCV 2020是计算机视觉领域的A类会,能被它收录本身就够写一篇复盘了,再加上工业质检这个具体场景,意味着这套东西一定是从真实产线需求里长出来的&a…

作者头像 李华
网站建设 2026/11/3 16:51:05

技术社区专家坐诊模式与问题诊断实践

1. 社区专家问答模式解析乐维社区的"专家坐诊"栏目已经持续运营308期,这种定期开展的专家互动机制在技术社区中形成了独特的价值生态。作为一个长期参与各类技术社区的老兵,我发现这种固定频次、主题明确的问答形式,相比传统的论坛…

作者头像 李华
网站建设 2026/11/3 16:49:54

SpringBoot+微信小程序驾校报名系统开发实践

1. 项目背景与核心价值 驾校报名系统在移动互联网时代迎来了数字化转型的关键节点。传统驾校报名流程中,学员需要亲自前往驾校填写纸质表格、排队缴费、手动预约培训时间,整个过程效率低下且容易出错。而基于SpringBoot后端和微信小程序的驾校报名系统&a…

作者头像 李华
网站建设 2026/11/3 16:48:24

AI项目开发实战:资源、组件与临时方案的工程化之道

上个月帮朋友收尾一个人工智能期末大作业,我第一眼看到他的项目目录就有点绷不住:训练脚本躺在桌面上,数据集还在网盘里没下完,前端用 uni-app 写着视频展示页,打包时反复提示“未添加 VideoPlayer 模块”,…

作者头像 李华