Python爬虫实战:逆向分析猎聘网API接口高效获取招聘数据
1. 项目概述:为什么选择猎聘网作为数据源
最近在帮一个做人力资源分析的朋友做点数据支持,他需要了解特定岗位在市场上的薪资分布、技能要求和地域差异。市面上招聘平台不少,但综合来看,猎聘网在定位中高端职位、岗位描述的专业性以及薪资信息的相对透明性上,有它独特的优势。对于数据分析师、算法工程师、产品总监这类岗位,猎聘上的信息质量通常比一些综合性平台要高,样本价值也更大。所以,这次的目标就很明确了:写一个Python爬虫,从猎聘网上抓取结构化的招聘信息,为后续的分析工作打好数据基础。
这活儿听起来简单,不就是发请求、解析网页、存数据嘛。但真上手了你会发现,现代招聘网站的反爬机制已经相当成熟,不再是十年前那种随便写几行
requests
加
BeautifulSoup
就能畅通无阻的时代了。猎聘网作为主流平台,在数据保护和反爬虫上做了不少功课,比如动态加载、请求参数加密、频繁访问限制等等。所以,这个项目不仅仅是“爬取数据”,更是一次对
动态网页数据抓取、反爬策略应对以及数据清洗规整
的综合性实战。无论你是想自己做市场调研,还是学习Python爬虫的进阶技巧,这个案例都有不少值得深挖的地方。
2. 核心思路与技术选型:绕过障碍,高效获取
直接去猎聘网搜索一个岗位,比如“Python开发”,你会发现页面上的内容并不是一次性加载完毕的。滚动页面时,新的招聘信息会不断出现,这就是典型的 AJAX动态加载 。这意味着,你直接去请求搜索结果的URL,拿到的HTML源码里很可能没有我们想要的招聘列表数据,数据是通过后续的JavaScript请求异步获取的。
因此,我们的核心思路不能停留在解析静态HTML上,而需要 模拟浏览器行为,或者直接找到数据接口 。这里主要有两条技术路径:
- 使用Selenium等浏览器自动化工具 :这种方式模拟真实用户操作浏览器,可以完整地渲染出JavaScript动态生成的内容。优点是简单直观,能看到什么就能抓到什么,绕过前端加密逻辑。缺点是速度慢、资源消耗大,不适合大规模、高频次的抓取,且容易被检测到自动化行为。
-
通过浏览器开发者工具分析网络请求,找到真正的数据API接口
:这是更高效、更专业的方法。我们打开浏览器的“开发者工具”(F12),切换到“Network”(网络)标签页,然后在猎聘网进行搜索、翻页操作。观察期间发起的XHR(Fetch)请求,找到那个返回了JSON格式招聘列表数据的请求。分析这个请求的URL、请求头(Headers)和请求参数(Query String Parameters 或 Payload),然后用Python的
requests库去模拟这个请求。
对于这个项目,我强烈推荐
第二条路
。理由很充分:首先,效率天差地别。一个
requests.get()
可能几十毫秒就拿到数据,而Selenium启动浏览器、加载页面可能需要好几秒甚至十几秒。其次,稳定性更高。直接调用后端API,拿到的就是结构化的JSON数据,解析起来比从复杂的HTML里抽取要稳定和干净得多。最后,资源消耗小,更适合在服务器或后台长期运行。
所以,我们的技术栈就明确了:
-
请求库
:
requests,用于发送HTTP请求。 -
数据解析
:目标接口返回的是JSON,直接用Python内置的
json库解析即可,无需HTML解析器。 -
参数构造与反爬
:需要仔细分析并还原API请求的必要参数,特别是那些看起来像加密或经过编码的参数。同时,需要设置合理的请求头(如
User-Agent,Referer等)来模拟浏览器。 -
数据存储
:初步存储为
pandas的DataFrame,然后可以方便地导出为CSV或Excel文件,或者存入数据库。 -
节奏控制
:必须加入延时(
time.sleep)来避免请求过快导致IP被封。
注意 :任何爬虫行为都必须遵守网站的
robots.txt协议,并尊重网站的服务压力。本案例仅用于技术学习和个人研究,请务必控制抓取频率,避免对目标网站造成负担。大规模商业用途的数据抓取可能涉及法律风险,请谨慎评估。
3. 关键步骤拆解与实战:从分析到落地
3.1 第一步:定位核心数据接口
这是整个项目最关键也最具技术含量的一步。打开猎聘网,进入搜索页面。以搜索“上海 Python开发”为例。
- 打开开发者工具 :按F12,切换到 Network 标签页。记得勾选上 “Preserve log” (保留日志),防止页面跳转时请求记录被清空。
- 执行搜索操作 :在搜索框输入条件,点击搜索。
- 筛选请求 :在Network面板中,点击 “Fetch/XHR” 过滤器,这样可以快速过滤出可能的数据接口请求。
-
寻找目标请求
:在请求列表中,寻找名称或响应内容看起来像招聘列表的请求。通常,这类请求的URL会包含
search、list、position等关键词。点击一个可疑的请求,查看其 “Preview” 或 “Response” 标签页。如果你看到了结构化的JSON数据,里面包含positionId、companyName、salary等字段,恭喜你,找到了! -
分析请求详情
:点击找到的请求,我们需要重点关注三个部分:
-
Headers
:特别是
User-Agent(用户代理)和Referer(来源页)。User-Agent用来告诉服务器我们是什么浏览器,Referer告诉服务器我们是从哪个页面跳转过来的。这两个是反爬虫最基本的验证,必须带上。 -
Payload
或
Query String Parameters
:如果是
POST请求,参数在Payload里(通常是form-data或json);如果是GET请求,参数在URL的问号后面。这里包含了我们的搜索条件,比如关键词、城市、页码、每页数量等。 特别注意那些长串的、无规律的参数 (例如xxxxxx,_t等),它们很可能是时间戳、签名或加密参数,需要分析其生成规律。
-
Headers
:特别是
经过分析,猎聘网(以某一时期为例,接口可能变动)的招聘列表数据通常通过一个
POST
请求到类似
https://www.liepin.com/zhaopin/
的接口获取,请求参数是
form-data
格式,包含了
key
(关键词)、
dqs
(城市代码)、
currentPage
(当前页)等。
3.2 第二步:用Python模拟请求并解析数据
找到接口后,我们就可以用
requests
库来模拟这个请求了。首先,把从浏览器里复制出来的
Headers
信息整理出来,尤其是
User-Agent
。
import requests
import json
import time
import pandas as pd
from typing import Dict, List, Optional
def fetch_one_page(keyword: str, city: str, page: int) -> Optional[Dict]:
"""
抓取猎聘网单页招聘数据
:param keyword: 搜索关键词,如 ‘Python开发’
:param city: 城市名,用于映射城市代码,如 ‘上海’
:param page: 页码,从0开始
:return: 包含招聘信息的字典,或None(如果请求失败)
"""
# 基础URL (示例,实际需根据分析结果调整)
url = "https://www.liepin.com/zhaopin/"
# 请求头,从浏览器复制并精简关键字段
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Referer': 'https://www.liepin.com/',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
# 可能还需要其他header,如Cookie,需根据实际情况添加
}
# 请求参数 (Payload) - 这是核心,需要根据实际接口分析
# 城市代码需要映射,例如上海可能是‘020’,北京是‘010’
city_code_map = {'上海': '020', '北京': '010', '深圳': '050090'}
dqs = city_code_map.get(city, '')
data = {
'key': keyword,
'dqs': dqs,
'currentPage': page,
# 以下参数名称和值需要根据实际抓包分析确定
'pageSize': 40, # 每页数量
'siTag': 'xxx', # 可能的标签参数
'ckId': 'xxx', # 可能的ID参数
# ... 可能还有其他加密或校验参数
}
try:
# 发送POST请求
response = requests.post(url, headers=headers, data=data, timeout=10)
response.raise_for_status() # 检查请求是否成功
# 解析JSON响应
result_json = response.json()
# 通常数据在 result_json 的某个键下,如 ‘data’ -> ‘list’
if result_json.get('code') == 0 and 'data' in result_json:
return result_json['data']
else:
print(f"第{page}页请求成功,但数据格式异常或为空。")
return None
except requests.exceptions.RequestException as e:
print(f"请求第{page}页时发生错误: {e}")
return None
except json.JSONDecodeError as e:
print(f"解析第{page}页JSON响应时发生错误: {e}")
return None
实操心得
:这里的
data
字典里的参数名和值(如
siTag
,
ckId
)
不是固定的
,它们很可能由前端JavaScript动态生成,具有一定的时效性或唯一性。直接复制一次抓包的结果可能只能用于当次请求。要稳定爬取,可能需要进一步分析这些参数的生成算法,或者采用更复杂的方式(如使用
execjs
执行JS代码生成参数)。一个更务实的折中方案是:
在单次会话中,先通过一个初始请求获取这些动态参数,然后在后续翻页请求中复用或微调它们
。这比破解完整算法要简单。
3.3 第三步:数据清洗与结构化存储
接口返回的JSON数据虽然结构化,但字段可能很多很杂,我们需要从中提取出对我们分析有用的核心信息。
def parse_job_list(data: Dict) -> List[Dict]:
"""
从接口返回的数据中解析出招聘信息列表
:param data: fetch_one_page 返回的 data 字段
:return: 清洗后的招聘信息字典列表
"""
job_list = []
# 假设招聘列表在 data['list'] 中
raw_list = data.get('list', [])
for job in raw_list:
# 提取核心字段,注意字段名需根据实际响应调整
job_info = {
'职位ID': job.get('positionId'),
'职位名称': job.get('title'),
'公司名称': job.get('companyName'),
'薪资范围': job.get('salary'), # 通常是“20-30k·14薪”这种格式
'工作地点': job.get('cityName'),
'学历要求': job.get('education'),
'工作经验': job.get('experienceName'),
'职位福利': ', '.join(job.get('welfareList', [])), # 福利可能是列表
'公司规模': job.get('companySize'),
'公司类型': job.get('companyType'),
'发布日期': job.get('createTime'), # 可能是时间戳或字符串
'职位详情页链接': f"https://www.liepin.com/job/{job.get('positionId')}.shtml" if job.get('positionId') else '',
}
# 对薪资进行初步拆分,便于后续分析
salary_str = job_info['薪资范围']
if salary_str:
# 简单处理,例如“20-30k·14薪”
job_info['薪资下限(k)'], job_info['薪资上限(k)'], job_info['薪资月数'] = parse_salary(salary_str)
job_list.append(job_info)
return job_list
def parse_salary(salary_str: str) -> (float, float, int):
"""
一个简单的薪资字符串解析函数(示例,实际需要更健壮的逻辑)
:param salary_str: 如 “20-30k·14薪”
:return: (下限, 上限, 月数)
"""
# 这是一个简化的示例,真实情况可能更复杂,如“面议”、“20k以上”、“20-30k”
try:
import re
# 匹配 “数字-数字k” 的模式
match = re.search(r'(\d+)[kK]?-?(\d+)?[kK]', salary_str)
if match:
low = float(match.group(1))
high = float(match.group(2)) if match.group(2) else low
else:
low = high = None
# 匹配 “·数字薪” 的模式
match_months = re.search(r'·(\d+)薪', salary_str)
months = int(match_months.group(1)) if match_months else 12 # 默认12薪
return low, high, months
except:
return None, None, 12
有了解析函数,我们就可以组织主循环,进行多页抓取,并将数据存入
DataFrame
和
CSV
。
def main():
keyword = "Python开发"
city = "上海"
total_pages = 5 # 计划抓取的页数,请根据实际情况调整,切勿过度抓取
all_jobs = []
for page in range(total_pages):
print(f"正在抓取第 {page+1} 页...")
data = fetch_one_page(keyword, city, page)
if data:
jobs = parse_job_list(data)
all_jobs.extend(jobs)
print(f"第 {page+1} 页抓取到 {len(jobs)} 条数据。")
else:
print(f"第 {page+1} 页抓取失败,停止。")
break
# 非常重要的延时,避免请求过快
time.sleep(2 + random.random()) # 随机延时2-3秒
# 转换为DataFrame
if all_jobs:
df = pd.DataFrame(all_jobs)
print(f"总共抓取到 {len(df)} 条招聘信息。")
# 数据清洗(示例)
# 1. 去重(根据职位ID)
df.drop_duplicates(subset=['职位ID'], inplace=True)
# 2. 处理空值
df['薪资下限(k)'].fillna(0, inplace=True)
df['薪资上限(k)'].fillna(0, inplace=True)
# 保存到CSV
filename = f"liepin_{keyword}_{city}_{time.strftime('%Y%m%d')}.csv"
df.to_csv(filename, index=False, encoding='utf-8-sig') # utf-8-sig解决Excel打开中文乱码
print(f"数据已保存到文件: {filename}")
# 简单预览
print(df[['职位名称', '公司名称', '薪资范围', '工作地点']].head())
else:
print("未抓取到任何数据。")
if __name__ == '__main__':
main()
4. 进阶策略与深度优化
4.1 应对动态加密参数
如前所述,
siTag
、
ckId
这类参数是最大的挑战。如果直接使用固定值,可能第一页成功,第二页就失败了。解决方案有几种:
-
会话维持(Session)
:使用
requests.Session()对象。首次访问猎聘网主页,获取一个初始的Cookie。然后在这个会话(Session)内进行搜索和翻页,许多校验参数会通过Cookie或会话上下文传递,无需我们手动生成。session = requests.Session() # 首先访问一次主页,获取必要的Cookie session.get('https://www.liepin.com/', headers=headers) # 后续的 fetch_one_page 使用 session.post 而不是 requests.post -
逆向分析JS
:如果参数生成逻辑在网页的JS文件里,可以使用
execjs或PyExecJS库来执行JavaScript代码,计算出正确的参数值。这需要一定的前端逆向工程能力。 -
使用无头浏览器获取参数
:折中方案。用
Selenium打开页面,执行一次搜索,然后从页面或网络请求中提取出这些动态参数,再用requests去进行高效的翻页抓取。这样既绕过了参数生成,又保持了requests的高效率。
4.2 提升抓取健壮性
- 代理IP池 :如果抓取量很大或频率较高,单一IP很容易被封锁。需要集成代理IP服务,在请求失败或达到一定次数后自动切换IP。
-
异常重试机制
:网络请求不稳定,需要加入重试逻辑。可以使用
tenacity或retrying库,或者自己写try-except和循环。 -
随机化请求间隔
:固定的
time.sleep(2)仍然有规律可循。最好使用随机延时,模拟人类操作的不确定性。import random delay = random.uniform(1.5, 4.0) # 随机延时1.5到4秒 time.sleep(delay) -
完善请求头
:除了
User-Agent和Referer,有时Cookie、Accept-Encoding、Connection等字段也会被检查。尽量从浏览器复制完整的请求头。可以准备多个User-Agent轮流使用。
4.3 数据解析的精细化
我们之前只解析了列表页的基础信息。列表页的“职位描述”通常是缩略的。要获取完整的职位描述(JD),需要进一步抓取 职位详情页 。
-
从列表页获取每个职位的
positionId和详情页链接。 -
构造详情页URL(通常是固定的格式,如
https://www.liepin.com/job/{positionId}.shtml)。 -
请求详情页,解析完整的HTML,从中提取详细的职位描述、任职要求、团队介绍等信息。这里可能又需要用到
BeautifulSoup或lxml来解析HTML。 - 注意 :详情页的抓取频率要更低,间隔要更大,因为对网站造成的负载更重。
5. 常见问题与排查技巧实录
在实际操作中,你几乎一定会遇到下面这些问题。这里把我踩过的坑和解决方法记录下来。
| 问题现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
返回空数据或
{'code': 403}
|
1. 请求头不完整或错误。
2. 关键动态参数(如
ckId
)失效或缺失。
3. IP被限制或封禁。 |
1.
核对Headers
:确保
User-Agent
、
Referer
、
Cookie
(如果需要)与浏览器一致。使用
Session
对象保持会话。
2. 检查参数 :确认
data
中的参数名和值是否与当前抓包结果一致。尝试从首次请求的响应中获取这些参数。
3. 降低频率 :大幅增加请求间隔时间(如5-10秒)。 4. 更换IP :使用代理IP。 |
| 只能抓到第一页,第二页开始失败 |
翻页参数(如
currentPage
)或关联的动态参数未正确更新。
|
1.
分析翻页请求
:在浏览器中手动点击第二页,对比Network中两个请求的
Payload
差异,找出变化的参数。
2. 使用Session :确保翻页请求在同一个
requests.Session()
内进行,以维持会话状态。
3. 参数溯源 :变化参数可能来自第一页响应的某个字段,需要提取并用于第二页的请求。 |
| 返回的数据是HTML而不是JSON | 请求被重定向到了登录页或验证页,说明爬虫行为被识别。 |
1.
检查请求头
:
User-Agent
是否像真实浏览器?
Accept
是否包含
application/json
?
2. 检查Referer :翻页时
Referer
应该是上一页的URL。
3. 验证Cookie :可能需要先访问首页获取有效的会话Cookie。 4. 查看响应内容 :将返回的HTML保存下来,打开看看是不是验证码页面。如果是,说明需要进一步模拟人工行为或使用打码平台,这通常意味着爬取难度极大,应考虑放弃或寻找其他数据源。 |
JSONDecodeError
| 服务器返回的不是合法的JSON,可能是错误页面、验证页面或空响应。 |
1.
打印响应状态码和文本前500字符
:
print(response.status_code, response.text[:500])
,先看看到底返回了什么。
2. 异常处理 :在
json.loads()
外做好
try-except
,并记录下错误的响应内容,便于分析。
|
| 抓取速度很慢 |
1. 单线程顺序请求。
2. 延时设置过长。 |
1.
考虑并发
:对于大量详情页抓取,可以使用
concurrent.futures.ThreadPoolExecutor
进行有限的并发(如3-5个线程),但务必注意总请求速率,避免被封。
2. 优化延时 :在遵守道德和
robots.txt
的前提下,找到不被封的最低延时阈值。列表页间隔可稍长(如3秒),详情页间隔需更长(如5-8秒)。
|
最后的叮嘱
:爬虫技术是一把双刃剑。在动手之前,务必仔细阅读目标网站的
robots.txt
文件(通常在网站根目录,如
https://www.liepin.com/robots.txt
),尊重其中关于爬虫行为的约定。始终将抓取频率控制在极低的水平,最好在非高峰时段进行。这个项目的核心价值在于学习如何分析动态网站、处理反爬机制和清洗数据,而不是无限制地获取数据。把这些技术用在正当的学习和研究目的上,才是长久之道。
更多推荐
所有评论(0)