ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

拉勾与BOSS直聘数据采集分析实战:从爬虫到可视化看板

拉勾与BOSS直聘数据采集分析实战:从爬虫到可视化看板 简介拉勾网与BOSS直聘网招聘信息大数据分析项目是一份面向数据工程初学者的完整实战资料包覆盖数据获取、数据清洗、数据分析与数据可视化全流程适合学习爬虫、数据预处理、挖掘分析及可视化展示的读者。压缩包共97个文件大小约13.27MB核心内容以31个Java源码、11个SQL脚本、18个文本配置、10个HTML页面为主配合JSP、JS脚本、属性配置及Maven项目文件构成可运行的爬虫与数据分析工程同时包含5份doc文档、项目演讲PPT、Hadoop相关exe/dll等运行依赖便于理解Windows环境下的部署调试。已有80人学习下载资料包内还提供SQL数据样例、城市与员工辅助数据、概要设计文档及运行说明可直接用于课题研究、课程设计或毕业设计参考也可帮助求职者梳理企业招聘需求与技能趋势。1. 招聘数据项目最贵的时间不在分析而在数据获取这个标题把“数据获取、数据清洗、数据分析、数据可视化”四个环节全列齐了实际操作过的人都知道整条链路里最耗时间的不是最后那张图表而是前两步。拉勾和BOSS直聘虽然都是招聘产品但接口形态和校验策略完全是两套思路直接写个 requests 脚本去请求拿到的多半是验证页或空列表。项目价值恰恰体现在这里把两个不同数据源拉通成一张口径统一的宽表后面的统计才有意义。适合准备做岗位薪资调研、城市需求分析或者想完整走一遍爬虫加数据分析流程的从业者。2. 数据获取拉勾与BOSS直聘两套差异化的请求方案2.1 先看清两个站的防御差异再决定怎么写采集拉勾的岗位搜索接口是典型的“反爬靠 Cookie 和频控”路线接口路径和参数都比较直观页面打开多少数据接口基本就返回多少难点在请求头要伪装得像真实浏览器以及访问频率不能过高。BOSS直聘则不然它的分页数据接口依赖一段动态签名参数签名种子藏在页面加载的 JS 文件里过期快、参数名随版本变动纯 Python 直接构造请求很难稳定复现。两个站放在同一个项目里意味着采集模块不能写成一个“换个 URL 就能跑”的通用函数而是要做两层封装一层是站点共用的任务调度和落盘逻辑另一层是各自独立的请求构造模块。先做一个对比表后面写代码时心里有数。对比项拉勾BOSS直聘数据入口直接 POST 接口页面 HTML 加 JS 计算签名主要校验Cookie、Referer、访问频率动态签名参数、IP 与账号关联返回格式JSON 结构相对规整JSON 嵌套深需要多步拼装实现成本requests 即可通常要调 Node 或浏览器内核2.2 拉勾岗位接口的最小请求示例拉勾的岗位搜索请求在实践中通常长这样注意第一次要用 GET 访问搜索页让 session 先拿到服务器下发的 Cookie再发 POST。直接跳过这一步去请求接口返回的常是跳转验证页面。import requests import time import random session requests.Session() base_headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://www.lagou.com/wn/zhaopin?kd数据分析city北京, } # 第一次 GET 搜索页目的是把 Cookie 种进 session session.get( https://www.lagou.com/wn/zhaopin?kd数据分析city北京, headersbase_headers, timeout15, ) for pn in range(1, 4): resp session.post( https://www.lagou.com/jobs/positionAjax.json, data{first: false, pn: pn, kd: 数据分析}, headers{**base_headers, Referer: https://www.lagou.com/wn/zhaopin}, timeout15, ) data resp.json() result data[content][positionResult][result] for item in result: print(pn, item[positionName], item[salary], item[city]) time.sleep(random.uniform(3, 6))代码里有几个要点。firstfalse表示翻页请求第一页可以传 true但后续翻页必须传 false否则接口行为不可预期。kd是关键词拉勾对 URL 编码和中文混用在某些场景下会做不同处理建议请求前用urllib.parse.quote统一编码。pn是页码分页上限视城市和关键词而定过深翻页会触发风控。POST 参数用data而非json这是该接口一直以来的约定。注意接口路径和参数名应以你自己 DevTools 里抓到的实际请求为准招聘站点改版频繁位置和名称都有可能调整。2.3 BOSS直聘数据获取签名参数的来源与更新BOSS直聘的数据接口形态和拉勾不同它的分页数据来自类似/wapi/zpgeek/search/joblist.json的接口但关键参数不是固定写死的通常依赖页面里加载的某个 seed 文件和一段加密逻辑。常见做法是在浏览器里打开搜索页看 Network 面板里第一个 JS 请求的响应那个响应体里往往藏着初始化数据。处理这类签名的通用思路是用 Python 把 seed 取出来再把计算签名的工作交给 Node。因为前端加密函数更新快在 Python 里重新实现一遍反而维护成本高遇到版本更新就要跟着改。典型流程如下。import requests import subprocess import json def get_boss_search(url, page_no): html requests.get(url, headersheaders).text seed_url extract_seed_url(html) # seed_url 对应一段 JS响应内容参与签名计算 seed_content requests.get(seed_url, headersheaders).text # 将种子内容交给本地 Node 执行得到当前请求要用的签名串 node_code const fs require(fs); const seed process.argv[1]; // 这里执行的是浏览器端同样逻辑得到签名串 // 具体函数名可能随版本变化 const sign calcSign(seed, Date.now()); console.log(sign); proc subprocess.run( [node, -e, node_code, seed_content], capture_outputTrue, textTrue, timeout10, ) sign proc.stdout.strip() api_params { query: 数据分析, city: 北京, page: page_no, sign: sign, } resp requests.get(API_URL, paramsapi_params, headersapi_headers) return resp.json()这段代码的价值在思路。extract_seed_url需要根据当时页面里的引用来写正则匹配script src...即可calcSign那段在真实项目里往往不用自己实现直接把浏览器端那个函数体原样放进 Node 执行就好。维护这个模块时的重点是记录“站点改了哪个 JS 文件名、哪个函数参数”否则一期大版本升级后旧代码会静默失效。2.4 采集落盘与限频重试两个站的数据集中落到本地时文件名建议带上站点、城市、关键词和日期例如lagou_beijing_analyst_20241012.csv。这样后面做增量更新时有明确的文件边界也方便回滚重跑某个城市的数据。落盘字段建议一次性就把两站共有的关键字段对齐岗位名称、公司名、城市、区县、薪资、工作经验、学历、发布时间、技能标签。请求频率控制上拉勾单 IP 连续高并发很容易触发验证建议每页之间随机 sleep 3 到 6 秒BOSS直聘侧则重点控制同一批账号的并发数以及签名参数的复用次数。采集脚本里要加一个通用重试装饰器网络抖动或返回 403 时等 30 秒重试连续失败超过 3 次就写日志退出不要无限循环。3. 数据清洗从双站JSON到一张可统计的宽表3.1 招聘数据清洗规则先列脏数据形态再写代码数据治理要先采集再清洗但采集策略直接决定清洗量大小。拉勾和BOSS直聘的字段命名有差异拉勾用positionIdBOSS直聘用jobId两者都没有跨站唯一标识。因此跨站整体分析时业务主键通常用“公司名 岗位名称 城市”组合单纯按某个 ID 去重会把两边的数据都留下来。实际数据里的典型脏数据如下清洗就是在处理这些规则字段原始数据形态示例清洗目标salary“20-40K·13薪”拆成 low/high/monthscity“北京·朝阳区”拆成 city/districteducation“本科及以上”、“统招本科”归一到枚举值workYear“3-5年”、“经验不限”拆成 min/maxpositionName“数据分析师(北京)”去掉括号内城市后缀3.2 pandas清洗流程去重、拆字段、转类型清洗阶段最大的误区是一上来就对着整张表写正则。我一般先把原始 JSON 压平存为 CSV再在 pandas 里按“去重 - 拆字段 - 类型转换”的顺序逐列处理。不同顺序导致的结果差异很大先拆字段再去重同一个岗位可能因为薪资格式不同而出现两条记录。import pandas as pd df_lagou pd.read_csv(lagou_beijing_analyst_20241012.csv) df_boss pd.read_csv(boss_beijing_analyst_20241012.csv) # 统一列名BOSS直聘的 jobId 对齐到 positionId 语义 df_boss df_boss.rename(columns{jobId: positionId}) # 两站合并后按业务主键去重保留最新一条 df pd.concat([df_lagou, df_boss], ignore_indexTrue) df df.drop_duplicates(subset[companyShortName, positionName, city], keeplast)去重的keeplast意味着后合并进去的 BOSS直聘数据优先这在实际项目里通常合理因为两边同一岗位并发发布时间不同保留较新的一条更贴近当前市场。之后把workYear里“经验不限”这类特殊值先统一成None避免正则提取出错误的区间。字段类型也要顺手检查发布时间统一转成datetime64薪资字段先保持字符串下一步专门处理。3.3 薪资字段的处理细节招聘数据的薪资描述非常不规范常见格式有“20-40K·14薪”、“12K-18K”、“3-4.5千/月”这几种。拉勾和BOSS直聘在薪资单位上基本都以 K 为主但“·14薪”这类后缀会把整列变成字符串。处理方案是先用正则提出区间上限和下限再单独提取月薪月份数。import re salary_pattern re.compile( r(?Plow\d(?:\.\d)?)\s*[-~—~]\s*(?Phigh\d(?:\.\d)?)\s*K, re.IGNORECASE, ) salary_part df[salary].str.extract(salary_pattern) df[salary_low] pd.to_numeric(salary_part[low], errorscoerce) df[salary_high] pd.to_numeric(salary_part[high], errorscoerce) df[salary_mid] (df[salary_low] df[salary_high]) / 2 # 解析“·14薪”等月份后缀提取数字部分 month_part df[salary].str.extract(r(\d)\s*薪) df[salary_months] pd.to_numeric(month_part[0], errorscoerce).fillna(12)errorscoerce用于把无法解析的异常值变成 NaN后面统计时统一过滤。salary_mid是区间中值作为后续分析的主字段。之所以不用区间下限或上限是因为两个站对同一岗位的薪资描述宽窄不同中值更具备可比性。如果出现“3-4.5千/月”这类单位需要在正则前先做一次单位换算把千/月乘以 1 变成 K把万/月乘以 10 变成 K这一步骤漏掉的话薪资统计会整体偏低。3.4 清洗结果检查清洗收尾时不能只看前几行数据我习惯输出一份字段质量报告每个字段的缺失率、唯一值数量、数据类型以及薪资字段的极值分布。这一步相当于数据清洗和预处理环节的验收问题字段在这一步暴露出来比在分析阶段发现再返工要省时间。report pd.DataFrame({ dtype: df.dtypes, missing: df.isna().mean(), unique: df.nunique(), }) print(report) # 找出薪资明显偏离常识的记录 abnormal df[(df[salary_mid] 3) | (df[salary_mid] 200)] print(abnormal[[positionName, salary, salary_mid]].head(20))如果异常值占比超过 1%大概率是正则漏掉了某种新格式回到salary列做一次值分布查看把新格式补进正则。清洗完成的宽表导出为analyst_clean.csv后续所有分析操作基于这个文件不再反复读原始 JSON。4. 数据分析与可视化岗位分布、薪资规律和看板4.1 先定分析口径再谈数据分析案例数据分析案例做得好不好往往不是看用了多高深的算法而是看口径定义是否清晰。招聘数据的薪资统计最容易掉进“平均值陷阱”少数高级管理岗会把均值拉高两倍以上因此本项目核心统计量都使用中位数。城市维度上BOSS直聘返回的是城市名拉勾返回的可能是“北京·朝阳区”清洗阶段已拆好这里直接聚合即可。经验字段同样要统一口径。拉勾的workYear是字符串BOSS直聘分得更细统一转成数值型的workYear_min和workYear_max才能按“3 到 5 年”做分组统计。学历字段也是一样“统招本科”和“本科及以上”在统计时都归到“本科”这一档否则分组会碎掉。4.2 用pandas做聚合透视城市、薪资、学历交叉分析环节优先做三个维度城市岗位量排行、城市薪资中位数、学历与薪资的关系。下面这段代码基于清洗后的宽表全部使用中位数口径。# 城市维度的岗位数与薪资中位数 city_agg ( df.groupby(city) .agg( job_count(positionId, count), salary_median(salary_mid, median), ) .reset_index() .sort_values(job_count, ascendingFalse) ) # 学历与经验的交叉透视 edu_exp df.pivot_table( indexeducation, columnsworkYear_min, valuessalary_mid, aggfuncmedian, ) # 岗位关键词出现频次用于技能需求分析 from collections import Counter skills Counter() for tags in df[skill_tags].dropna(): skills.update(str(tags).split(、)) print(city_agg.head(10)) print(edu_exp) print(skills.most_common(20))pivot_table默认聚合函数是均值这里必须显式传入aggfuncmedian否则薪资结果会被少数高薪岗位带偏。workYear_min是数值透视时会自然排序从 0 到 10 逐列看下来能直观看出薪资随经验增长的趋势。技能标签的 Counter 统计适合用来做词云词云展示时建议过滤掉“沟通”、“团队”这类通用词只保留 Java、Python、SQL 等具体技能。4.3 用pyecharts做招聘数据可视化看板可视化阶段我直接用 pyecharts它是 Python 生态对接 echarts 数据可视化的常用封装不写前端代码就能生成 HTML。城市数据用地图岗位量排行用柱状图学历分布用饼图三者拼成一个页面。地图的坑在于城市名匹配echarts 地图里是“北京市”数据里是“北京”直接传入会显示不出来。from pyecharts.charts import Map, Bar, Pie from pyecharts import options as opts # 城市名统一加“市”后缀保证地图匹配 map_data city_agg.head(20).copy() map_data[city_label] map_data[city] 市 map_chart ( Map() .add( 岗位数, [list(z) for z in zip(map_data[city_label], map_data[job_count])], china, ) .set_global_opts( title_optsopts.TitleOpts(title数据分析岗位城市分布), visualmap_optsopts.VisualMapOpts(min_0, max_int(map_data[job_count].max())), ) ) bar_chart ( Bar() .add_xaxis(city_agg[city].head(10).tolist()) .add_yaxis(岗位数, city_agg[job_count].head(10).tolist()) .set_global_opts(title_optsopts.TitleOpts(title岗位数 Top 10 城市)) ) map_chart.render(city_map.html) bar_chart.render(city_job_bar.html)VisualMap 的max_如果写死数据更新后地图颜色深浅会失真所以取当前数据的最大值。地图看不出来时用柱状图做补充两个图都生成 HTML 文件后续可以合并进一个总览页面做成可交互看板。企业级数据可视化的核心不是图表花样多而是数据口径一致、刷新自动化这点放到下一章具体落地。5. 让采集与分析每天自动跑定时增量与数据质量校验招聘数据是时效性数据今天抓的岗位明天可能就下线了所以项目落地阶段要有一个简单的定时增量机制而不是每次手动全量重跑。做法是每天采集时把岗位 ID 列表和上一次抓取的对比新增的插入已有的更新状态不再需要重新请求全部历史页。def sync_incremental(new_path, old_path): old_df pd.read_csv(old_path) new_df pd.read_csv(new_path) old_keys set(old_df[positionId]) new_keys set(new_df[positionId]) # 新增岗位直接取新数据 insert_rows new_df[~new_df[positionId].isin(old_keys)] # 已有岗位保留新数据可能包含联系方式等字段更新 update_rows new_df[new_df[positionId].isin(old_keys)] final_df pd.concat([old_df[~old_df[positionId].isin(new_keys)], update_rows, insert_rows]) final_df.to_csv(old_path, indexFalse)增量更新跑完后要做数据质量校验避免某天接口升级导致数据全量为空第二天才发现。下面这张表是校验规则的最小集足够覆盖多数异常。校验项触发条件处理动作行数骤减今日采集行数小于昨日 50%立即停止入库检查接口返回字段缺失率positionName 缺失率大于 5%回退清洗步骤检查解析函数城市覆盖数覆盖城市少于历史值 80%检查请求是否带上全部城市参数薪资异常值salary_mid 出现 999 或 0检查正则是否匹配到脏数据校验逻辑封装成一个check_quality()函数采集脚本结束前自动执行失败时推送消息到企业微信或钉钉机器人不用人工盯日志。整个流程用系统 crontab 就能撑住凌晨 1 点采集、凌晨 2 点清洗、早上 8 点前刷新看板代码和调度全部落地后才算把标题里那条链路真正闭环了。本文还有配套的精品资源点击获取
返回列表