
简介面向Python爬虫与招聘数据分析学习者这套基于51job前程无忧的源码实现了招聘信息抓取、清洗与分析全流程可辅助HR招聘决策、就业市场调研或学生就业指导。资源包共29个文件压缩包约18.04MB核心是job_spider.py爬虫引擎完整覆盖网页请求、数据提取、异常处理与数据存储另含10个CSV数据文件用于结构化保存9个PNG图表直接呈现职位薪资、城市分布等统计结果5个TXT文件涵盖说明文档、依赖列表与词库配置便于快速复现和二次开发。已有493人学习下载。通过源码可掌握requests数据采集、CSV持久化与Matplotlib可视化等关键操作也能体会requirements依赖管理、.gitignore代码规范等工程实践系统设计模块化、代码可扩展性强便于针对其他招聘平台定制开发适合想用Python落地招聘数据项目的初中级开发者对求职者了解市场行情同样有参考价值。1. 基于Python的51job前程无忧招聘信息爬取与分析设计源码先搞清楚这行代码动的是什么打开前程无忧搜索“数据分析”页面里的职位名称、薪资、发布时间都在但用右键查看网页源代码却找不到职位数据。这不是网页出错了而是61job的职位列表在搜索页加载后额外触发了一次异步请求实际的招聘信息被后端以接口的形式返回浏览器拿到后再渲染成表格。所以基于Python做51job的爬取与分析第一关不是写解析正则而是搞清楚数据从哪里来、返回格式是什么、访问频率控制在多少以内。这类任务最常遇到的坑有两个一是直接把当前网页的HTML交给BeautifulSoup去解析结果拿到空列表二是不管分页参数只抓几十条样本就开始分析最后做出来的薪资、城市分布完全失真。可靠的做法是先去浏览器开发者工具的Network面板里找到真正的职位列表接口再用requests构造同样的请求把返回的JSON转成结构化数据最后用Pandas清洗并做岗位分布、薪资区间、学历要求的统计。整个过程适合想入门Python爬虫、又希望项目能落地的从业者也适合正在做招聘数据观察的运营和分析人员。2. 51job数据获取的关键看清接口参数、请求头与反爬边界2.1 职位列表的数据链路与动态加载方式前程无忧的职位搜索页目前采用的是半动态渲染方案。直接访问https://we.51job.com/pc/search这种搜索地址拿到的是页面骨架职位列表由JavaScript脚本再调用一次后端接口加载。常见做法是打开Chrome DevTools的Network面板过滤XHR请求刷新搜索页后能看到一个类似/api/job/search-pc的请求返回体是结构化JSON里面包含jobName、companyName、providesalary_text等字段。确认数据链路后爬虫的设计就能避开两个误区一是不要用Selenium或pyppeteer去渲染完整页面那会让请求量放大数倍拉起整个浏览器只为了拿一个接口的数据二是不要用正则整页匹配JSON本身就是结构化数据直接json.loads后按字典取值速度和稳定性都比HTML解析高很多。判断该走接口还是走页面HTML看Network里的载荷和响应类型接口返回是JSON时就按接口方案实现。如果换一台机器或换一个网络环境时接口响应不是JSON而是跳转页面则需要先访问一次搜索首页把生成的Cookie带进后续请求。这套验证逻辑在爬虫开发里很常见先请求首页拿指纹再请求接口取数据两步缺一不可。2.2 请求参数与Headers的必备配置搜索接口的请求方式一般为POST需要携带的参数至少有keyword、searchType、pageIndex、pageSize。我一般会额外传一个jobArea用来限定搜索范围默认值000000代表全国如果需要限定到某个城市就换成行政区划编码。分页的pageIndex从1开始递增pageSize建议一次拉50条拉得太多容易触发服务端的频率限制。接口对请求头并不算苛刻但以下三个字段会影响请求是否成功。Headers常用配置如下请求头字段推荐值作用说明User-Agent当前使用浏览器版本UA伪装成真实浏览器避免被按默认UA拦截Referer职位搜索页地址标识请求来源是站内页面部分接口校验该字段Acceptapplication/json告诉服务端期望的返回格式Cookie登录后或首访后生成的会话标识维持同一个会话的身份状态翻页时不要频繁更换请求头构造方式统一在一个get_headers()函数中返回不要把Header对象在每个请求里重复写一遍。开发调试时可以先在Python终端里用requests.post(url, dataparams, headersheaders)做最小验证打印返回内容和状态码确认字段名正确后再进入下发解析循环。2.2.1 POST请求最小可用代码import requests import json def fetch_job_list(keyword: str, page_index: int, page_size: int 50) - dict: url https://we.51job.com/api/job/search-pc headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36, Referer: https://we.51job.com/pc/search, Accept: application/json, text/plain, */*, Content-Type: application/x-www-form-urlencoded; charsetUTF-8 } params { keyword: keyword, searchType: 2, jobArea: 000000, pageIndex: page_index, pageSize: page_size } resp requests.post(url, headersheaders, dataparams, timeout15) resp.raise_for_status() return resp.json() if __name__ __main__: data fetch_job_list(数据分析, 1) print(data.keys()) print(data.get(result) and data[result].get(page_total))这段代码先把接口请求封装成函数调用方只需要传搜索关键词和页码即可拿到字典形式的返回结果。timeout15防止网络抖动时请求挂死resp.raise_for_status()会在返回非2xx状态时直接抛异常便于在日志里定位失败原因。页面总页数一般藏在result.page_total或total_count里解析前先打印一次确认字段层级。2.3 选型requests够用为什么还要考虑httpx纯数据接口场景下requests完全够用。但51job的这种搜索接口没有实现连接复用每请求一次相当于新建一个TCP连接。如果后续要并发爬取多个关键词、多个城市requests直连实现比较啰嗦需要手动做连接池配置。httpx从设计上支持HTTP/2和异步线程池模式下能把并发请求量提高一倍且请求头、参数构造方式与requests几乎一样迁移成本极低。还有一点容易被忽略部分网络环境下51job接口响应较慢requests默认不会自动重试而httpx配合httpcore的重试配置可以实现“首次超时后再补一次”。对爬虫项目来说请求失败重试是刚需不是锦上添花。选型建议很简单只爬三五百条数据用requests要做批量采集、需要稳定重试和并发控制直接换httpx。import httpx def fetch_job_list_httpx(keyword: str, page_index: int, page_size: int 50) - dict: with httpx.Client(timeout15, follow_redirectsTrue) as client: resp client.post( https://we.51job.com/api/job/search-pc, headers{User-Agent: Mozilla/5.0, Accept: application/json}, data{ keyword: keyword, searchType: 2, jobArea: 000000, pageIndex: page_index, pageSize: page_size } ) return resp.json()注意这里的follow_redirectsTrue有些环境在Cookie缺失时会先302跳到一个验证页关闭自动跳转会导致拿不到最终JSON。Httpx的写法把请求生命周期收敛进with块连接自动释放长时间运行时不会积累大量TIME_WAIT状态的连接。3. 编写51job最小爬虫字段提取、分页循环与反爬避坑3.1 项目结构与依赖安装把爬虫和分析拆成两个模块spider/目录放请求与解析逻辑analysis/目录放Pandas数据处理和可视化脚本。这样拆的一个直接原因是51job取数频率不高但分析往往是反复做的每跑一次分析就要爬一次接口效率和体验都很差。依赖安装只装四个库requests、beautifulsoup4、pandas、matplotlib。首先需要解析HTML虽然数据主源是接口JSON但职位详情页里有部分扩展字段是接口不直接返回的例如福利标签其次数据清洗和图表输出依赖后两个库。安装完成后立即在交互式终端里验证import是否正常避免后面脚本运行时才发现环境问题。pip install requests beautifulsoup4 pandas matplotlibbeautifulsoup4在本题中承担的是兜底解析工作有的搜索页如果只拿到HTML页面BeautifulSoup可以把岗位卡片、公司名、薪资这些DOM节点提取出来。接口JSON和HTML解析并存等于上了双重保险接口结构变动时不会整个程序瘫痪。3.2 分页抓取与停止条件51job的职位列表接口有总页数限制遍历时不应该用无限循环。停止条件写两处一是当前页码大于接口返回的总页数二是当前页返回的职位列表为空。这样即使某次接口字段变更导致page_total取不到值第二道判空逻辑依然能兜底。3.2.1 分页爬虫完整循环import time import random from typing import List, Dict def crawl_jobs(keyword: str, max_pages: int 20) - List[Dict]: jobs [] for page in range(1, max_pages 1): data fetch_job_list_httpx(keyword, page) result data.get(result) if not result: break items result.get(list) or [] if not items: break for item in items: jobs.append({ 职位: item.get(jobName), 公司: item.get(companyName), 薪资: item.get(providesalary_text), 地点: item.get(jobAreaString), 学历: item.get(degreefrom_text), 经验: item.get(workyear_text), 发布日期: item.get(issueDate), }) time.sleep(random.uniform(2, 5)) return jobs if __name__ __main__: data_list crawl_jobs(Python开发, max_pages5) print(f共抓取 {len(data_list)} 条职位) for job in data_list[:3]: print(job)循环里每个字段都从JSON字典中按Key取值并统一转成最基础的类型。职位、薪资、地点等都是文本字段在先阶段不做什么转换直接把原始值塞进列表最终交给Pandas清洗。random.uniform(2, 5)给每次请求间插入随机延迟延迟时间不用太长但必须有尤其抓取页数多时能有效降低被临时性封控的概率。这里有一个值得说透的细节51job的发布领域字段issueDate给的可能是“昨天发布”或“2025-06-18”两种形式代码层不需要处理等等在分析模块用正则和日期解析统一转换。3.3 HTML兜底解析用BeautifulSoup解析职位卡片接口偶尔变更结构时直接用接口解析会让程序返回空列表。为了日常运维稳定我会保留一个备用解析器先用requests拉取职位搜索的HTML页面再用BeautifulSoup定位职位卡片所在的选择器。from bs4 import BeautifulSoup def parse_job_html(html: str) - List[Dict]: soup BeautifulSoup(html, html.parser) jobs [] for card in soup.select(.joblist-item): job { 职位: card.select_one(.job-title).get_text(stripTrue) if card.select_one(.job-title) else , 公司: card.select_one(.company-name).get_text(stripTrue) if card.select_one(.company-name) else , 薪资: card.select_one(.salary).get_text(stripTrue) if card.select_one(.salary) else , 地点: card.select_one(.job-area).get_text(stripTrue) if card.select_one(.job-area) else , } jobs.append(job) return jobsselect_one返回第一个匹配节点select返回全部匹配节点。加if判断是为了避免空指针某条职位如果缺失job-title节点select_one会返回None直接调用get_text()就会抛异常。这个兜底解析器的意义在于换场景应急上线主数据源仍以接口为主。3.4 反爬避坑与异常日志51job在请求频率过高时会返回包含安全验证的页面此时resp.json()会抛出JSONDecodeError。处理方式是在请求函数里捕获非JSON响应并把响应原文写入debug/目录下方便排查是验证码、IP限制还是参数错误。被封的典型特征是请求返回200但页面内容明显不是JSON、接口状态字段值出现异常、连续多个页码返回相同数据。日常开发的节奏应该是单页调试、小页数试采、最后才做完整爬取。先把请求频率降下来再谈并发。切分请求时按分钟控制1分钟内请求数稳定在20个以下基本无需处理复杂验证。4. 招聘数据分析薪资区间解析、城市与学历分布的Pandas实现4.1 数据落地到CSV与去重抓下来的职位列表先转成DataFrame然后立即去重。去重键取职位公司地点薪资这个组合能识别同一条招聘信息被多个分页重复返回的情况。剩余多出的一条要保留发布日期以便后续分析岗位发布趋势。import pandas as pd df pd.DataFrame(data_list) df df.drop_duplicates(subset[职位, 公司, 地点, 薪资], keepfirst) df.to_csv(51job_python_jobs.csv, indexFalse, encodingutf-8-sig) print(f去重后共 {len(df)} 条职位数据)写入CSV时推荐用utf-8-sig而不是utf-8。这个编码会在文件头部加入BOMExcel直接打开不会出现中文乱码算是数据落地阶段一个很实际的小技巧。CSV文件同一目录下之后分析模块直接从这个文件读不再次请求网络面对长时间的数据观察比较方便。4.2 薪资字符串的清洗与解析薪资字段从页面来的时候是形如“1.2-1.8万/月”或“1.5-2万/年”的文本。Pandas里直接统计没有任何意义需要把它拆成每月薪资的数值。统一处理策略是先提取最低和最高数值再把单位统一为“元/月”遇到“/年”就除以12。import re def parse_salary(text: str) - tuple: if pd.isna(text): return None, None text str(text) nums re.findall(r([\d.])-([\d.]), text) if not nums: num re.findall(r([\d.]), text) low high float(num[0]) if num else None else: low, high float(nums[0][0]), float(nums[0][1]) if 万 in text: low, high low * 10000, high * 10000 if 年 in text: low round(low / 12, 2) high round(high / 12, 2) return low, high df[月薪下限], df[月薪上限] zip(*df[薪资].apply(parse_salary)) df[平均月薪] (df[月薪下限] df[月薪上限]) / 2正则([\d.])-([\d.])匹配最低和最高薪资数值万和年的字符串判断完成单位和期间换算。这样一来薪资从文本转换为两个数值列后面做平均、中位数、分组都方便。需要留意的是少量职位会写成“面议”这类数据在转换后月薪下限为空统计分析时应筛选或单独成组。4.3 城市提取与岗位分布透视表地点字段类似于“上海-浦东新区”用split(-)[0]取到城市一级。接下来可以用pandas按城市聚合出岗位数量的分布这一步骤输出一个统计结果表df[城市] df[地点].astype(str).str.split(-).str[0] city_stat df.groupby(城市)[职位].count().sort_values(ascendingFalse).head(10).reset_index() city_stat.columns [城市, 职位数量] print(city_stat.to_string(indexFalse))对应的结果可能长这样城市职位数量上海218北京176深圳132广州97杭州68这张透视表展现的是51job数据源中Python开发类岗位的城市分布结构。若想继续细化还可加入学历要求做透视观察不同城市对硕士以上学历需求占比。4.4 中文字体与图表输出直接调用matplotlib画柱状图时另一个顽疾会出现坐标轴中文标签全部显示为小方块这是因为默认字体不包含中文字形。处理办法是强制指定一个系统中文字体import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei] plt.rcParams[axes.unicode_minus] False plt.figure(figsize(10, 6)) plt.bar(city_stat[城市], city_stat[职位数量], color#4C72B0) for idx, val in enumerate(city_stat[职位数量]): plt.text(idx, val 2, str(val), hacenter) plt.title(51job Python岗位城市分布Top10) plt.ylabel(职位数量) plt.tight_layout() plt.savefig(city_top10.png, dpi150)font.sans-serif传入列表形式Python会按顺序在系统里查找可用字体第一顺位缺失就用第二顺位。unicode_minus设False解决负号显示为方块的问题。用savefig而不是plt.show()是因为在服务器或SSH环境下没有图形界面直接保存PNG文件更通用。5. 给51job批量抓取加一道安全护栏断点续采与数据验收5.1 断点续采把进度写进本地状态文件抓取一旦超过几百页网络抽风或代码异常导致中断是常态。每次重头开始不仅浪费请求还可能因请求过于集中增加风险。我给分页循环加了进度记录逻辑把已完成页码写进progress.json。脚本启动时先读这个文件从中断页码继续跑。import json import os PROGRESS_FILE progress.json def load_progress() - int: if os.path.exists(PROGRESS_FILE): with open(PROGRESS_FILE, r) as fp: return json.load(fp).get(completed_page, 0) return 0 def save_progress(page: int): with open(PROGRESS_FILE, w) as fp: json.dump({completed_page: page}, fp, ensure_asciiFalse, indent2) start_page load_progress() 1 for page in range(start_page, max_pages 1): # 抓取单页数据解析... save_progress(page)这一实现的主要优点是中断消费非常低。save_progress在每页完成后立即写一个很小文件写坏概率低即使写到一半进程崩溃最多损失一条页码记录。相比写数据库文件方案不依赖额外服务也用不上ORM来回转换如果从数据验收的可恢复性着手比把全部数据放在内存里再落库要牢靠得多。5.2 数据验收的三项检查批量采集结束后不要直接拿去分析先验证数据是否满足基本条件。第一项检查总量合理性在51job搜索某关键词得到的职位总数量是明确的抓取条目数应与接口展示数量量级吻合第二项检查字段完整性职位和公司两个字段的空值比例不应超过1%null_ratio df[职位].isna().mean() assert null_ratio 0.01, f职位字段空值比例过高: {null_ratio:.2%}第三项检查相邻页面是否出现重复数据。把职位公司去重的损耗率计算出来如果去重损耗率超过3%说明有大量重复抓取或接口返回异常数据需要回溯分页参数。这三项都我看着通过后再将处理好的干净数据写入最终的analysis_data.csv保留一份原始抓取CSV方便后续追溯。5.3 分钟级限速模板最后分享一个我个人常用的限速小模板直接用时间戳列表维护请求节奏不引入消息队列一类额外的服务import time class RateLimiter: def __init__(self, max_calls: int, period: int 60): self.max_calls max_calls self.period period self.calls [] def wait_if_needed(self): now time.time() self.calls [t for t in self.calls if now - t self.period] if len(self.calls) self.max_calls: sleep_time self.period - (now - self.calls[0]) 1 time.sleep(sleep_time) self.calls.append(time.time()) limiter RateLimiter(max_calls20, period60) for page in range(1, 30): limiter.wait_if_needed() data fetch_job_list_httpx(数据分析, page)这个RateLimiter类的核心是用滚动时间窗判断当前计数窗口内的请求数超过阈值就休眠到最老请求滑出窗口。把限流对象单独拆出来主循环代码不用夹塞time.sleep后续调整每分钟请求数只需要改max_calls一个参数。拿它替换前面在循环里写死的random.uniform采集节奏会平稳不少。做51job这类站点单机、限速、断点续采就是确保爬虫和分析功能长期可用的最短路径。本文还有配套的精品资源点击获取