ARTICLE DETAIL

资讯详情

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

51job爬虫实战:从招聘数据采集到Python岗位趋势分析

51job爬虫实战:从招聘数据采集到Python岗位趋势分析 我最早动手写这个51job爬虫不是因为想炫技而是被一次找工作的经历刺激到了。当时我在某招聘平台搜Python岗位杭州同城、同经验要求的两家公司薪资一个标10-15K、一个标20-30K排在前面的居然是薪资低的那个。平台展示的排序和真实市场之间存在一条谁都不愿意明说的信息差。与其一条条手动翻几百个岗位做对比不如写个Python爬虫把数据全部拉下来自己算。这个项目做到最后产出的不是一堆岗位记录而是一份能回答哪个城市机会多、什么技能被点名最多、学历和经验到底卡多重的行业趋势小报告。整篇文章就从零到一复盘整个过程环境与请求策略、列表页和详情页解析、反爬应对、数据清洗、趋势分析与可视化。想做爬虫入门实战、或者想用数据辅助职业决策的读者都可以直接照着走一遍。1. 为什么选51job做数据源招聘信息背后的人才风向1.1 招聘数据能读出什么岗位描述就是市场需求说明书每一份岗位JD本质上都是企业用真金白银写出来的需求说明书。JD里写着什么技能说明市场上缺什么样的人薪资区间标在哪个位置说明这个岗位在雇主心里值多少钱。单看一份JD是招聘广告把同一个关键词下的几千份JD拉在一起看就变成了人才市场的抽样调查。我当时关心的核心问题有三个。第一Python相关的岗位究竟集中在哪些城市薪资中位数是多少既然网上到处都说Python火那火到底体现在哪些岗位类别上第二技能关键词的出现频率比如爬虫、数据分析、机器学习、后端开发这些词在岗位描述里被点名的次数直接反映了技能的市场热度第三学历和经验门槛的分布同一个岗位在深圳要求本科起步在成都可能大专就行这种差异对职业规划很有参考价值。这些结论如果靠人肉翻网页效率低且容易带偏见因为你只会注意自己关心的岗位。爬虫加统计则能把印象变成数据这也是这个项目最核心的价值。1.2 为什么选51job而不是其他平台我选51job有三个很实际的原因。第一是数据量大且行业覆盖全。51job上的岗位覆盖互联网、制造业、金融、零售等大量行业搜一个关键词能同时看到传统行业和新兴行业对Python的不同用法这对行业趋势这个目标很关键。第二是页面结构相对规整。51job的搜索结果页以服务端渲染为主直接拿requests就能拿到完整的HTML不需要模拟浏览器执行JavaScript。对用requests入门爬虫的人来说这是最友好的数据源之一。相比之下有的招聘平台是纯前端渲染列表数据藏在XHR接口里爬取时要先分析接口和参数学习成本高不少。第三是字段完整。岗位名称、公司、薪资、城市、经验要求、学历要求、发布时间这些关键字段在列表页就能拿到大部分详情页再补职位描述字段覆盖度足够支撑趋势分析。当然51job不是没有缺点。它的页面改版比较频繁class命名也经常变这一点我后面专门用一节来讲。但作为练手加数据分析的数据源它依然是很合适的选择。2. 开工前的准备环境、请求策略与爬虫礼仪2.1 依赖清单与环境版本环境方面我用的Python 3.8Windows和Linux跑都可以依赖如下pip install requests lxml pandas matplotlib jieba openpyxlrequests负责发HTTP请求lxml负责XPath解析pandas做数据清洗和统计分析matplotlib画图jieba做中文分词。这些都是爬虫加数据分析的标准组合。这里想多说两句选型逻辑。解析HTML我为什么用lxml的XPath而不是正则表达式因为岗位列表的结构是卡片重复出现天然适合用XPath按层级定位写起来直观页面结构小改动时也容易调整。正则在面对嵌套HTML时容易写出又长又脆的表达式调试成本高。至于为什么不直接用BeautifulSoup主要是我个人习惯lxml的XPath在定位复杂列表结构时更精确解析速度也更快。BeautifulSoup同样能用看个人习惯。为什么不直接上Scrapy这个问题我一开始也纠结过。后来想通了这次的目标是抓一个小时间窗口内的数据用来分析数据量在几千条量级requests加循环完全够用。Scrapy的优点是分布式、管道、中间件但这套机制对单站点小规模采集来说属于过度设计反而拖慢开发速度。爬虫项目的工具选型第一原则是匹配数据量级。2.2 请求头与Session别让服务器一眼识破爬虫第一次跑不动的最大原因不是代码逻辑错而是请求头被识别了。我用requests发的默认User-Agent是python-requests/2.x服务器看到这个标识基本等于看到我是爬虫。我的通用请求配置是这样的import requests 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: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, Connection: keep-alive, Upgrade-Insecure-Requests: 1, }) # 先访问一次首页让服务器下发必要的Cookie try: session.get(https://www.51job.com/, timeout10) except Exception as e: print(首页访问异常:, e)为什么用Session而不是每次新建requests.get因为Session会自动保持Cookie而51job这类站点在搜索查询时服务器端会校验会话状态。没有会话直接请求搜索页很容易被判定为异常流量。先访问首页拿Cookie再带着会话去搜索页行为上更接近真实用户。这里还有个容易忽略的点Accept-Language。如果不设置这个头服务器返回的可能是默认语言版本内容结构和字段名都可能不一样。另外像51job历史上部分页面用GBK编码如果直接按UTF-8解码会出现乱码我后面在解析那一节会再提。2.3 频率控制爬得快不是本事爬得久才是这个项目里我给自己定了一条铁律每抓一页至少休息2到3秒并且间隔随机化。import time import random def polite_sleep(): time.sleep(random.uniform(2.0, 3.5))为什么这么保守因为我需要的不只是能跑一次而是能持续跑完几千条数据。很多新手爬虫挂在半路就是前几十个请求太快触发了服务器的频率阈值后面全部返回验证码或空页面。把频率降下来不仅是对目标站点的尊重也是保证任务能完整跑完的最简单手段。另外一个建议抓取前先看一眼目标站点的robots.txt大致了解规则。爬虫不是黑客行为能体面地拿数据就不要搞突袭式抓取。这个习惯我后面在合规部分还会展开讲。3. 核心采集实现列表页、详情页与数据落盘3.1 先抓包再写代码不看页面结构写XPath都是赌写爬虫最容易犯的错是凭记忆写选择器。51job改了版类名早就换了老代码跑上去全是空列表。正确流程是用浏览器开发者工具打开51job搜索页搜索Python然后看请求列表里的HTML文档请求确认搜索URL的参数结构再在Elements面板里检查DOM结构结合HTML调整XPath。我当时观察到的搜索URL结构大致是这样的以实际页面为准51job的URL参数经常调整from urllib.parse import quote def build_search_url(keyword: str, page: int) - str: kw quote(keyword) return fhttps://search.51job.com/list/000000,000000,0000,00,9,99,{kw},2,{page}.htmlURL里那段由逗号分隔的数字分别代表职位区域、行业、职位类别等筛选条件。000000表示不限区域2代表某种排序方式最后的{page}是页码。这些参数的含义不一定需要全部弄懂但要对URL结构有敏感度知道哪一段是页码、哪一段是关键词翻页和换关键词的代码就很容易写。3.2 列表页解析XPath定位与字段抽取列表页拿回来之后核心工作是从HTML里抽出每条岗位记录的字段。我用lxml解析from lxml import etree resp session.get(url, timeout15) resp.encoding resp.apparent_encoding # 关键先探测页面实际编码 tree etree.HTML(resp.text) job_items tree.xpath(//div[contains(class, joblist)]//div[contains(class, e)]) records [] for item in job_items: record { title: extract_text(item, .//span[contains(class, jname)]), company: extract_text(item, .//a[contains(class, cname)]), salary: extract_text(item, .//span[contains(class, sal)]), city: extract_text(item, .//span[contains(class, add)]), experience: extract_text(item, .//span[contains(class, exp)]), education: extract_text(item, .//span[contains(class, edu)]), tags: extract_tags(item, .//div[contains(class, tags)]/span), detail_url: extract_url(item, .//a[contains(class, jname)]), } records.append(record)注意这段代码里的class名是我采集时看到的真实运行时必须自己去页面里确认。写一个extract_text辅助函数来容错对XPath取不到的字段返回空字符串而不是报错这是列表页解析稳定运行的基础。def extract_text(item, xpath): nodes item.xpath(xpath /text()) if not nodes: return return nodes[0].strip() def extract_tags(item, xpath): nodes item.xpath(xpath) return |.join(n.text.strip() for n in nodes if n.text) def extract_url(item, xpath): nodes item.xpath(xpath /href) return nodes[0] if nodes else 列表页能拿到的字段和标注大致如下后续分析时按这个结构往DataFrame里塞就行字段名内容数据样例title岗位名称Python开发工程师company公司名称某科技有限公司salary薪资区间1.5-2.5万/月city工作城市杭州experience经验要求3-4年经验education学历要求本科tags福利/类型标签五险一金|带薪年假detail_url详情页链接https://jobs.51job.com/...3.3 翻页逻辑循环加终止条件列表页通常是分页的。翻页逻辑不复杂但有个细节很关键不要写死抓100页而是设置合理的终止条件。我的做法是当某页的岗位数为0或者连续两页抓到的岗位完全没有新增时自动停止。all_records [] for page in range(1, 51): url build_search_url(Python, page) page_records fetch_job_list(url) if not page_records: break all_records.extend(page_records) print(f第{page}页完成累计{len(all_records)}条) polite_sleep()如果想按城市分别抓取比如北京、上海、深圳、杭州各抓若干页做法是把URL里的区域参数换掉其他逻辑完全一致。这种一个函数、多组参数的设计让整个采集循环变得很干净。3.4 详情页补全与数据落盘列表页给的字段已经能支撑大部分分析但职位描述是技能关键词统计的关键必须进详情页抓。详情页解析和列表页同理区别在于URL不是翻页而是从列表记录里拿到的detail_url直接访问。落盘格式我选了两个原始数据存JSON结构化数据存CSV。JSON保留了字段全貌后面想重新分析还有原始档CSV方便pandas直接读入做DataFrame。代码如下import json import csv with open(jobs_raw.json, w, encodingutf-8) as f: json.dump(all_records, f, ensure_asciiFalse, indent2) with open(jobs.csv, w, encodingutf-8-sig, newline) as f: writer csv.DictWriter(f, fieldnameslist(all_records[0].keys())) writer.writeheader() writer.writerows(all_records)CSV这里有个编码细节用utf-8-sig而不是utf-8这样用Excel打开时中文不会乱码。这些小细节决定了数据后续用起来顺不顺手。4. 反爬与封禁实测踩过的连环坑4.1 状态码200但内容是验证码一次典型的伪成功我第一次跑批量采集时前50条数据都很顺利第60条左右开始不对劲接口返回的HTTP状态码依然是200但解析出来的岗位数是0。打印HTML片段才发现页面内容变成了一个验证码页面。这是爬虫最容易踩的坑也是最容易让人困惑的坑不是请求失败而是请求成功地拿到了一个假页面。解决思路很简单解析前先做一次页面有效性校验比如检查页面里是否包含岗位列表容器的标识或者是否出现验证码关键词。不通过就暂停休息、降低频率而不是继续空跑。def is_valid_page(html: str) - bool: return joblist in html and 验证码 not in html这个校验函数看起来简单但它在整个采集循环里扮演了安全阀的角色能避免大量无效请求也省得后面浪费时间去清理脏数据。4.2 被封之后先降频再谈其他策略遇到封禁我的处理顺序很明确。第一步停下所有请求让脚本休息30秒到1分钟。有不少所谓的封禁其实是瞬时频率过高站点只是一段时间内拒绝该会话的搜索请求过了窗口期就恢复。第二步检查是不是单IP请求量太大。如果只是几千条数据把请求间隔拉到3秒以上通常可以避免被风控。我不建议一上来就上代理池或换IP那会把问题复杂化而且代理质量不可控反而引入更多变量。第三步如果确实需要更大的数据量再考虑上代理池。这块属于进阶方案需要额外的代理管理和验证机制成本不低。对这个项目来说控制频率加合理休息已经完全够用。还有个小技巧尽量让请求流量分布得更像真人。比如把抓取时间分散到一个时间段而不是集中在几秒内页面请求之间加随机延迟而不是固定3秒。固定间隔看起来规律反而更容易被识别因为真人浏览网页的间隔从来不是均匀的。4.3 页面结构变动选择器必须容错51job的页面结构改版频率不低。我遇到过class名调整、薪资字段从sal变成其他命名、详情页某些字段位置移动的情况。改版之后之前写好的XPath会全部失效列表解析出来全是空。应对方法有三层。第一层是前面说的extract_text容错函数字段取不到返回空串而不是报错这样脚本不会因为单条数据异常而整体崩溃。第二层是在解析完一页后打印抽样结果肉眼确认字段没丢。第三层是如果发现结构变了不要急着改代码先用浏览器再看一次新页面的DOM结构重新推导XPath。这段经验很重要爬虫不是写一次就完事的脚本它本质上是一个需要维护的小系统。页面结构变了、URL参数变了、反爬策略变了都需要跟着调整。把变化当成常态才能心平气和地debug。5. 从数据到结论清洗、分词与趋势分析5.1 薪资字段的解析抓下来的薪资字段长这样1.5-2.5万/月、8千-1.2万/月、25-40万/年、以及若干面议。分析前必须转成数值。我的解析逻辑是按单位归一化为年薪万import re def parse_salary(s): if not s or 面议 in s: return None, None parts re.split(r[-–至], s.replace(月薪, ).replace(年薪, )) if len(parts) 2: return None, None try: low _to_annual(parts[0]) high _to_annual(parts[1]) except (ValueError, IndexError): return None, None return min(low, high), max(low, high) def _to_annual(text): text text.strip() nums re.findall(r[\d.], text) if not nums: raise ValueError val float(nums[0]) if 千 in text or text.lower().count(k) 0: val val / 10 if 万 not in text and 千 not in text and text.lower().count(k) 0: val val / 10000 # 直接写数字的按元处理 if 年 in text: return val return val * 12这里的单位换算逻辑要对着真实数据反复校准。我抓回来的数据里既有万/月也有千/月还有直接写数字不带单位的清洗函数必须把各种写法都覆盖到。统一量纲之后后续的所有聚合统计才不会出偏差。5.2 岗位描述清洗与技能关键词提取详情页的职位描述里包含大量HTML标签、空格和换行。清洗时用正则去掉标签和多余空白再交给jieba分词。关键是自定义词典要包含领域词否则K8s会被切成奇怪的碎片数据分析也容易分词不准统计就失真了。import jieba from collections import Counter jieba.add_word(Kubernetes) jieba.add_word(爬虫) jieba.add_word(数据分析) jieba.add_word(机器学习) text .join(df[description].astype(str)) seg_list jieba.lcut(text.lower()) word_count Counter(w for w in seg_list if len(w.strip()) 1)技能提取还有一种做法不去分词而是维护一个技能词表直接在原始文本里统计词表词的出现次数。好处是不受分词错误影响坏处是词表需要人工维护。我实际用的是词表和分词结合的方式先按词表统计主技能再用分词结果补充发现新词。5.3 城市、学历、经验与薪资的交叉分析数据进了pandas之后分析就灵活多了。比如看不同城市的Python岗位数量和薪资中位数city_group df.groupby(city)[salary_mid].agg([count, median]) city_group city_group.sort_values(count, ascendingFalse)又比如看学历要求对薪资的影响edu_salary df.groupby(education)[salary_mid].median()经验字段通常是1-3年这种区间可以取区间中点或下限作为年份再和薪资做分组对比。这一步的目的不是做严格的计量分析而是看趋势哪些城市是明显的高薪聚集地哪些学历是硬门槛哪些经验段是薪资跳涨点。这些信息就是人才风向标。5.4 可视化用图表讲清楚人才需求matplotlib画图时记得设置中文字体否则图里全是方块import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei] plt.rcParams[axes.unicode_minus] False # 热门技能横向条形图 top_skills word_count.most_common(15) skills, counts zip(*top_skills) plt.figure(figsize(10, 6)) plt.barh(skills, counts) plt.xlabel(出现次数) plt.title(Python岗位描述中的高频技能词) plt.tight_layout() plt.savefig(hot_skills.png, dpi150)实测下来这种图呈现的效果非常直观爬虫、数据分析、机器学习、后端开发基本稳居前列而不同城市的Top技能排序会有差异。把技能词频和城市薪资两张图放在一起看就能回答这个城市值不值得去这类问题。数据分析的最终产出不是统计数字而是能指导决策的结论。6. 复盘与扩展这套方案的边界和可复用性6.1 迁移到其他招聘平台改什么、不改什么这个项目的核心方法论是通用的确定数据源、分析URL结构、解析列表、补全详情、清洗分析。换到另一个招聘平台需要重新做的有三件事抓包看新平台的URL和参数规则、在开发者工具里确认新的页面结构和XPath、重新写页面有效性校验和反爬应对。不需要改的是整个项目的分层架构采集、解析、存储、分析各模块独立一个模块出了问题只动那一个模块。这种模块化思路对爬虫工程的意义比具体某个选择器重要得多。我见过很多新手把解析逻辑和采集逻辑写在一个大循环里结构一变化就得全部重来。拆开之后维护成本低一个量级。比如这次51job改版我只需要改fetch_job_list里的XPath统计分析和可视化代码一行都不用动。另外提一句不同平台的难点不一样。有的平台接口做了签名加密有的平台需要登录后才能看全部岗位还有的平台对单IP的阈值压得很低。迁移时先花时间把平台的脾气摸清楚再动手写代码比盲目套模板高效得多。6.2 数据使用的边界与底线聊到数据合规我的原则很简单个人学习和分析用不批量对外发布原始数据控制请求频率不影响目标站点正常服务不抓取个人隐私信息。招聘信息本身是公开的市场信息但不代表可以无限制地采集和传播。做爬虫项目技术能力重要使用边界同样重要。守住底线这个技能才能长期用、安心用。我自己的实际操作是分析结果里只留下聚合统计和图表比如杭州Python岗位中位数月薪约20K这种结论而不是把抓到的岗位JD原文打包发出来。报告的颗粒度到城市、行业、技能维度就够了没必要精确到具体公司和招聘联系人。这样既达到了分析目的也不会给目标平台和求职者带来困扰。6.3 后续还能怎么玩定时采集与趋势追踪数据只有和时间放在一起才有趋势。我当时跑完这一轮之后最大的感想是一个时间点的横截面数据只能说明现状不能说明趋势。后续的升级方向很明确把采集脚本打包成定时任务每隔一段时间跑一次用岗位URL或内容哈希做去重存进SQLite或MySQL再把多轮数据放在一起画时间序列图就能看到技能热度的变化曲线、薪资的涨跌趋势。甚至还可以做城市迁移预警当某城市Python岗位数量连续下降、而另一个城市连续上升时背后大概率是产业转移或公司结构变动。这种信号对求职者选城市、对培训机构定课程方向都是很有价值的参考。我个人实际跑下来的体会是这类项目的难点从来不在写爬虫而在想清楚要回答什么问题、怎么保证数据质量、怎么把数据变成结论。51job爬虫只是工具真正的产出是那份让你对行业心里有数的分析报告。你掌握的这套思路换个数据源、换个分析目标可以一直复用下去。
返回列表