简介一份基于 Python 的 Boss直聘岗位数据爬虫分析可视化项目面向希望入门爬虫与数据可视化的学习者也适合作为毕业设计、课程设计或工程实训的参考模板。项目围绕 Scrapy 框架展开覆盖从创建爬虫项目、配置解析规则到将热门城市岗位数据落地的完整链路并对爬取结果中常见的脏数据与高耦合问题给出清洗思路帮助读者理解真实数据场景下的处理方式。压缩包内共39个文件包含 Python 源码、JavaScript 脚本、XML 配置、CSV 数据表、说明文档及少量图片样式文件等包体仅262KB目录结构清晰便于按模块查阅调试。已有662人学习下载。资料的价值不在于直接照搬而在于提供一条可跟进的实施路径从开发环境搭建、Scrapy 项目生成、爬虫编写到数据清洗与可视化分析均有对应代码和文件可供参考。适合有一定 Python 基础、希望独立调试并扩展功能的中初级学习者通过自行修改爬虫规则和分析维度能较快提升项目实践能力。1. Boss直聘岗位数据爬虫分析可视化一条把招聘信息变成决策图表的完整链路月底做招聘复盘老板突然问我「Python 岗的薪资中位数是多少、哪个城市给得最高」我盯着收藏夹里几十个招聘页面答不上来。后来我把这条链路跑通requests 抓取 Boss直聘岗位数据、xpath 解析出字段、pandas 清洗聚合、pyecharts 出图前后不到两百行代码就能在十分钟内回答这类问题。这个方案的样本来自实时公开列表时效性比任何行业报告都好而且完全免费。适合三类人正在挑城市和岗位的求职者、要做薪酬调研的 HR、以及想练手 python 爬虫分析与可视化全流程的数据初学者。2. 合规前提与数据特征采集Boss直聘前先弄清这几件事2.1 列表页的字段分布薪资、经验、技能藏在哪些节点里Boss直聘的职位列表页是典型的服务端渲染加部分动态加载结构。直接对列表页 URL 发请求能拿到职位名称、薪资文本、城市、经验要求、学历要求、公司名称、技能标签这些核心字段但完整的岗位描述JD往往要进入详情页才有。第一次做这个方向的人容易犯一个错一上来就想着把详情页全量抓下来实际上先跑通列表页、把字段结构摸清楚已经能回答七八成的统计问题。以浏览器开发者工具看到的节点为准常见字段的分布规律大致如下字段列表页是否可见解析位置注意点职位名称是职位卡片标题节点注意「资深」「实习」等前缀影响归类薪资文本是卡片内独立标签如「15-25K·14薪」是文本字符串必须二次解析才能计算城市与区域是卡片下方地址信息城市名可能与期望值不完全一致需要归一化经验与学历是紧邻地址的标签组常见取值1-3年、3-5年、本科、大专公司名称是卡片主体区域同一公司多个职位统计时要考虑去重技能标签是卡片底部标签组通常每个职位 36 个是词频分析的原料职位描述列表页部分可见详情页正文词频分析更推荐用它但采集成本更高解析时不要照着网上旧教程的 class 名硬套。Boss直聘的页面结构改版过很多次class 名带有混淆性质每次都略有不同。我一般是在浏览器里复制一段真实 HTML 存成 demo.html用 xpath 在本地反复试调通后再放回采集脚本。这样能省下大量联调时间也避免频繁请求线上页面触发风控。2.2 合规底线robots、采集频率与数据使用边界做爬虫分析的人最容易忽略的是「边界」两个字。Boss直聘有明确的 robots 协议和用户协议个人学习场景下我只建议采集公开可访问的列表数据并且严格遵守三个原则控制频率、不碰非公开信息、数据只用于统计不用于二次分发。具体到参数我一般把每页请求间隔设在 58 秒单次跑不超过 20 页跑完一轮就歇一段时间。这个频率远低于人工浏览速度触发验证码和 IP 临时受限的概率会小很多。如果你发现响应里频繁出现验证码页面说明频率踩线了正确的做法是立刻停止脚本、拉长间隔而不是换着法子去对抗。凡是需要登录才能看到的字段比如求职者联系方式、完整简历坚决不碰。这类数据属于个人敏感信息采集和存储都有法律风险。另一个容易被忽略的边界是数据的使用范围。拿采集结果做「Python 岗薪资中位数」这种行业统计属于正当的个人学习用途但如果把数据打包卖给第三方或者在公司内网里做商业报告性质就完全不同了。我自己的习惯是脚本和数据都放在本地图表只用于个人决策不公开发布包含真实公司名的明细数据。3. 抓取与解析用 requests 和 xpath 把岗位数据拆成本地表格3.1 最小可跑的采集脚本请求头、翻页与状态检查采集部分不需要上 scrapy用 requests 就足够。核心是两件事让请求看起来像正常浏览器访问以及每抓一页都检查响应是不是真的职位列表。下面这个脚本是我个人常用的起点只抓公开列表页不涉及任何登录态。import time import requests from lxml import html 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/, Accept-Language: zh-CN,zh;q0.9, } session requests.Session() session.headers.update(headers) base_url https://www.zhipin.com/web/geek/job params { query: python, city: 100010000, # 北京其他城市码需自行查 page: 1, } for page in range(1, 6): params[page] page resp session.get(base_url, paramsparams, timeout10) print(fpage{page}, status{resp.status_code}, length{len(resp.text)}) if resp.status_code ! 200: print(请求被拦截停止采集) break # 此处先落盘 HTML解析在下一步单独做 with open(fhtml/job_page_{page}.html, w, encodingutf-8) as f: f.write(resp.text) time.sleep(6) # 每页间隔 6 秒控制在低速采集区间代码里我用了 Session 复用连接避免每页都重新握手。params 里的 query、city、page 三个参数是翻页的核心query 控制搜索词city 是城市编码page 从 1 递增。需要说明的是城市编码和页面参数结构可能随平台改版变化跑之前先在浏览器里手动翻一页把地址栏参数记下来对一遍。响应状态码 200 不代表拿到了职位列表。平台在风控时会返回一个 200 状态的验证页页面里没有职位卡片此时检查 text 长度会发现明显异常。我在脚本里把每页 HTML 落盘再人工抽查一页就是为了区分「正常页面」和「风控页面」。这是爬虫里最常见的黑匣子问题状态码正常内容却是空的。3.2 xpath 解析与字段清洗从 HTML 片段到干净字段拿到 HTML 之后解析环节决定了数据质量。这里用 lxml 的 xpath 来定位节点比正则更稳因为 xpath 能直接按标签层级取文本。下面的代码用简化的页面结构做演示实际节点的 class 以你自己抓到的 HTML 为准。from lxml import html def parse_job_list(page_source): tree html.fromstring(page_source) jobs [] # 假设每个职位卡片是 div.job-card内部字段按以下路径取 # 实际 class 名以浏览器开发者工具为准不要照抄 cards tree.xpath(//div[contains(class, job-card)]) for card in cards: title card.xpath(.//span[contains(class, job-name)]/text()) salary card.xpath(.//span[contains(class, salary)]/text()) company card.xpath(.//div[contains(class, company-name)]/text()) tags card.xpath(.//div[contains(class, tag-list)]/span/text()) # xpath 返回的是列表取不到时是空列表需要兜底 job { title: title[0].strip() if title else , salary: salary[0].strip() if salary else , company: company[0].strip() if company else , tags: |.join([t.strip() for t in tags]) if tags else , } if job[title] and job[salary]: jobs.append(job) return jobsxpath 的规则是先找到所有职位卡片再在卡片内部相对定位字段。这个写法比一次性写超长绝对路径健壮得多页面局部结构调整时只需要改卡片层的 class 匹配字段层基本不受影响。取文本时我做了两重防护一是 xpath 返回列表必须判断是否为空再取下标二是 strip 去掉首尾空白避免脏数据进 CSV。这里要特别提醒一个坑有些字段的文本是「懒加载」的列表页 HTML 里根本没有必须请求接口或进详情页。如果你的 xpath 路径确认无误但始终取不到值优先检查响应里是否真的有这个文本而不是反复改路径。把 HTML 存下来用浏览器打开搜索关键词一眼就能判断是结构问题还是数据缺失。3.3 落盘方案CSV 与 SQLite 的取舍解析出来的数据要落盘。数据量小、单次跑完就分析CSV 足够如果打算每天定时跑、增量积累SQLite 更省心。我自己的做法是两者都留CSV 给快速查看SQLite 给后续分析和去重。import csv import sqlite3 # CSV 落盘utf-8-sig 防止 Excel 打开乱码 with open(jobs.csv, a, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[title, salary, company, tags]) if f.tell() 0: writer.writeheader() writer.writerows(jobs) # SQLite 落盘带主键便于增量去重 conn sqlite3.connect(jobs.db) conn.execute( CREATE TABLE IF NOT EXISTS jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, city TEXT, title TEXT, salary TEXT, company TEXT, tags TEXT, crawled_at TEXT DEFAULT CURRENT_TIMESTAMP ) ) for job in jobs: conn.execute( INSERT INTO jobs (city, title, salary, company, tags) VALUES (?, ?, ?, ?, ?), (city, job[title], job[salary], job[company], job[tags]), ) conn.commit() conn.close()CSV 写入用追加模式同时处理表头文件为空才写 header。编码用 utf-8-sig 是血泪经验直接用 utf-8 存的文件Excel 打开全是乱码还得手动转码。SQLite 建表时加了 crawled_at 时间戳方便统计「今天新增了多少职位」这个字段在增量采集章节会用到。两个落盘方案不冲突建议先写 SQLite 作为主存储再从 SQLite 导出 CSV 做临时分析。直接混用也行但要注意去重逻辑只能以一边为准否则第二天重跑时会发现同一个职位被存了十几遍。4. 岗位数据分析从薪资清洗到技能词频的三步关键处理4.1 薪资字段清洗「15-25K·14薪」拆成下限、上限与月薪采集下来的 salary 是「15-25K·14薪」「20-30K」「薪资面议」这类原始文本不处理没法算中位数。清洗的核心是用正则拆出数字部分再区分 K 和万。下面的函数处理了大多数情况import re import pandas as pd def parse_salary(s): if not isinstance(s, str): return None, None, None s s.strip() if 面议 in s: return None, None, None # 匹配数字范围兼容 15-25K、1.5-2.5万、15K以上 nums re.findall(r([\d.])\s*-\s*([\d.]), s) if nums: low, high float(nums[0][0]), float(nums[0][1]) else: single re.findall(r([\d.]), s) if len(single) 1: low high float(single[0]) else: return None, None, None # 统一换算成月薪元万要乘以 10000K 要乘以 1000 if 万 in s: low, high low * 10000, high * 10000 else: low, high low * 1000, high * 1000 # 14薪表示一年发 14 个月工资月薪等价于年薪 / 12 months 12 m re.search(r(\d)薪, s) if m: months int(m.group(1)) avg (low high) / 2 / 12 * months return low, high, round(avg, 1) df[salary_low], df[salary_high], df[salary_avg] zip( *df[salary].apply(parse_salary) )正则拆薪资本质上是把「文本范围」转成「可计算数字」。K 和万的换算必须放在最后统一处理顺序错了会把 1.5-2.5万 解析成 1.5-2.5K整列数据直接失真。14薪的处理逻辑是等价月薪按年薪换算到 12 个月口径否则 14薪和 12薪的岗位直接比月薪会低估前者。「薪资面议」这类文本直接置空不要强行填 0——填 0 会让中位数被拉低统计结果失真。清洗完最好打印一下 df[salary_avg].describe()看一眼 min、max、count 是否符合常识。比如最小值低于 3000 或最大值高于 15万基本可以断定有脏数据混进来了。4.2 多维分组统计城市、经验、学历对薪资的影响清洗完薪资就到了真正出结论的阶段不同维度下薪资中位数是多少。pandas 的 groupby 加 median 是一行代码的事但要注意中位数和平均数的选择。薪资分布右偏明显少数高管岗会把平均值拉高中位数更能代表「大多数人的水平」。city_salary ( df.groupby(city)[salary_avg] .median() .sort_values(ascendingFalse) .reset_index() ) exp_salary ( df.groupby(exp)[salary_avg] .median() .reset_index() ) pivot pd.pivot_table( df, valuessalary_avg, indexcity, columnsexp, aggfuncmedian, ) print(city_salary.head(10)) print(exp_salary) print(pivot.head(5))groupby 之后要 reset_index否则结果里 city 是索引不便于后续画图。pivot_table 的作用是把城市和经济要求组合成矩阵行是城市、列是经验段单元格是中位数薪资。这个表是可视化看板的直接数据源。做多维统计时最怕的是某一组样本量太小。比如「北京-在校生」只有两条数据中位数就没有参考意义。我一般会在分组后加一行 count 列样本量小于 5 的组直接过滤掉。这不是统计学上的严格处理但作为业务决策依据已经够用。4.3 技能词频统计从岗位描述里拆出出现最多的技术栈薪资只能回答「值多少钱」回答不了「市场要什么」。技能词频统计是把所有 JD 文本拼在一起统计指定技术栈的出现次数。先做一个技能词表再逐条统计比 TF-IDF 之类的大模型方案简单得多也足够解释「Python 岗到底要求什么」。skills [python, java, sql, spark, flink, hadoop, django, flask, fastapi, docker, kubernetes, redis, mysql, mongodb, 机器学习, 深度学习] text .join(df[description].fillna().tolist()).lower() skill_count {} for skill in skills: skill_count[skill] text.count(skill.lower()) skill_df ( pd.DataFrame(skill_count.items(), columns[skill, count]) .sort_values(count, ascendingFalse) .reset_index(dropTrue) ) print(skill_df.head(15))词频统计的粒度是子串匹配所以词表顺序有讲究先匹配长的、复合的词再匹配短的。比如「kubernetes」要在「k8s」之前匹配否则「k8s」匹配不上。另一个坑是「python」会同时匹配到「python3」「python开发」这不是坏事但会让你高估这个词的热度。想更精确可以用正则加词边界比如 \bpython\b。description 字段可能为空先 fillna() 再拼接否则整条 text 变成 NaN后面所有 count 全报错。这个位置的报错信息不太直观新手经常排查半天才发现是空值问题。5. 可视化输出与问题排查把分析结果做成能汇报的图表5.1 用 pyecharts 做薪资中位数柱状图与城市分布地图分析结果只有数字还不够汇报时图表比表格有说服力。pyecharts 是我个人最常用的可视化库基于 echarts 的渲染能力生成的图表自带交互鼠标悬停能看到具体数值。第一步先把第 4 章得到的 city_salary 画成柱状图。from pyecharts.charts import Bar, Map from pyecharts import options as opts city_data [ [row[city], row[salary_avg]] for _, row in city_salary.head(15).iterrows() ] bar ( Bar() .add_xaxis([x[0] for x in city_data]) .add_yaxis(薪资中位数元, [x[1] for x in city_data]) .set_global_opts( title_optsopts.TitleOpts(titlePython岗城市薪资中位数 TOP15), yaxis_optsopts.AxisOpts(name月薪元), xaxis_optsopts.AxisOpts(axislabel_optsopts.LabelOpts(rotate30)), ) ) bar.render(city_salary_bar.html)柱状图画城市排名关键在于 x 轴标签旋转 30 度否则城市名挤成一团根本看不清。薪资数值单位是元动辄一两万y 轴名字写清楚「月薪元」能减少读图误会。pyecharts 渲染出来是一个 html 文件浏览器直接打开就能交互不需要额外起服务。地图适合展示区域差异但 pyecharts 的地图数据要求城市名是标准名称不能是「北京-朝阳」这种带后缀的文本。先对 city 字段做归一化把「北京」和「北京市」统一成同一个名字否则地图上会缺省份。5.2 搭建一个简单的招聘看板把多张图组合成一份 HTML 报告单张图适合自己看汇报时需要多图组合。pyecharts 的 Page 组件可以把柱状图、饼图、词云按顺序拼到一个页面里生成一份完整看板。我一般会放四张图城市薪资柱状图、经验段薪资对比图、学历占比饼图、技能词频条形图。from pyecharts.charts import Page, Pie, Bar pie_data df[edu].value_counts().reset_index().values.tolist() pie ( Pie() .add(, pie_data, radius[40%, 70%]) .set_global_opts(title_optsopts.TitleOpts(title学历分布)) ) skill_bar ( Bar() .add_xaxis(skill_df.head(10)[skill].tolist()) .add_yaxis(出现次数, skill_df.head(10)[count].tolist()) .reversal_axis() .set_series_opts(label_optsopts.LabelOpts(positionright)) ) page Page(layoutPage.SimplePageLayout) page.add(bar, pie, skill_bar) page.render(job_dashboard.html)Page 的布局默认是纵向堆叠四张图依次往下排适合手机查看。如果要拼成可视化大屏那种多列布局可以用 Grid 组件手动指定每个图的位置但 Grid 的坐标参数比较繁琐我一般是先纵向出报告确认结论没问题后再花时间调大屏布局。学历占比用饼图radius 参数控制了内径外径画出来是环形图比实心饼图好看。技能词频用横向条形图reversal_axis 把坐标轴翻转技能名在 y 轴、次数在 x 轴长名字也不会被截断。看板的价值在于把第 4 章的多个统计结果集中到一个文件里一次打开全貌都在。5.3 常见问题排查图表空白、字段丢失与乱码的五条踩坑记录可视化阶段的问题多数不是出在画图代码上而是上游数据或运行环境的问题。这五条是我反复踩过的坑按出现频率排序列出来。现象一柱状图渲染出来是空白只有坐标轴原因传给 add_yaxis 的数据列表里有 NaN。pandas 清洗时保留的空值在转 list 后变成 nanecharts 无法识别这个值整张图就空了。解决画图前执行 df df.dropna(subset[salary_avg])或者在构造绘图表时把 NaN 替换为 0。替换为 0 要谨慎会让该城市薪资显示为 0干扰阅读。现象二xpath 在浏览器里能匹配到脚本里却返回空列表原因requests 拿到的 HTML 和浏览器渲染后的 HTML 不一致。列表页部分字段由 JavaScript 动态填充requests 拿到的是渲染前的源码。解决先确认字段是服务端渲染还是客户端渲染。把 resp.text 存成 html 文件用浏览器打开搜索关键词搜不到就是客户端渲染需要找接口或用无头浏览器方案而不是继续改 xpath。现象三CSV 用 Excel 打开中文全部乱码原因写入时用了 utf-8 编码Excel 默认按 GBK 解码。解决写入 CSV 时统一用 encodingutf-8-sig只改这一个参数就行。已经乱码的文件重新导出不要手动改编码改过的文件可能彻底损坏。现象四地图显示时部分城市缺失原因pyecharts 地图组件要求城市名是标准名称采集数据里带有「市」「区」等后缀或者使用了简称导致匹配不上。解决建一个城市名映射表把采集到的名称统一归一化为标准名。比如「深圳」「深圳市」「深圳福田」都映射为「深圳市」再传入地图。现象五刷新页面时图表闪烁后消失原因pyecharts 生成的 html 引用了远程 echarts 脚本本地打开没问题但放到内网或离线环境就加载不到。解决用 bar.render_embed() 把图表内容内嵌进 HTML而不是只保留引用。这样文件体积会大一些但彻底摆脱外部依赖。6. 让采集流程每天自己跑定时调度、增量去重与结果校验前面几章都是在手动触发脚本真实使用时你会希望它每天自动跑一次早上打开电脑就能看到前一天的新增岗位和薪资变化。我常用的方案是 Python 的 schedule 库不依赖系统 crontab跨平台都能用适合放在开发机上长期运行。import schedule import time def daily_job(): # 1. 采集新数据 # 2. 按 title company city 去重后写入 SQLite # 3. 重新生成 JSON 数据文件 # 4. 重绘看板 HTML crawl_all_cities() dedup_and_store() rebuild_charts() print(daily job finished) schedule.every().day.at(08:30).do(daily_job) while True: schedule.run_pending() time.sleep(60)增量去重的逻辑要放在写入之前而不是写入之后。我一般按 title、company、city 三个字段做联合唯一键插入用 INSERT OR IGNORE这样同一个职位重复抓到会自动跳过。注意平台的职位 ID 字段如果能拿到用它做唯一键最准确三个字段组合在极少数情况下仍然会有误差。结果校验是我最看重的环节跑完一轮要自动检查三件事新增了多少条、总条数和昨天比是否异常、薪资中位数是否在合理范围内。中位数如果出现几万元的跳变不是数据质量出问题就是页面结构改了需要人工介入。这一步是防止自动流程悄悄产出垃圾数据的后悔药。这套流程真正稳定运行的前提是单次采集量小、频率低、时刻关注平台反馈。我曾因为贪图数据量把间隔压到两秒结果整个采集任务被风控后面整整三天什么都抓不到前面的调度逻辑也跟着罢工。后来学乖了把频率看成不可触碰的红线宁可数据少一点也绝不冒进。如果你打算长期维护这个方向记住一句话采集是手段分析是目的能稳定跑一年的方案永远比能跑一天的方案值钱。希望帮到你。本文还有配套的精品资源点击获取