Python爬虫实战:requests+parsel抓取招聘数据并写入CSV

发布时间:2026/10/24 16:55:46
Python爬虫实战:requests+parsel抓取招聘数据并写入CSV

简介:这份资源面向具备Python基础、希望入门网络爬虫的开发者与数据分析初学者,以猎聘网为例,完整演示如何抓取招聘岗位信息并落地为CSV文件。内容围绕Requests发送GET请求、Parsel结合CSS选择器解析页面、请求头伪装、字典组织字段、csv模块追加写入等环节展开,同时涉及异常处理与robots协议等合规要点,帮助读者理解从请求到存储的完整链路。资源包共1个docx文档,约18KB,以图文笔记形式呈现关键代码与解析思路,便于对照练习。目前已有4903人学习下载,说明该案例在爬虫入门场景中具有较高的参考价值。读者可借此掌握职位标题、城市、薪资、经验学历、公司标签等字段的提取方法,并理解反爬与数据清洗的常见处理方向,为后续采集其他站点或做岗位数据分析打下基础。

1. 从一份招聘 CSV 说起:为什么我还在用 requests + parsel 这套老组合

上周帮一个做数据分析的朋友跑岗位薪酬分布,他丢过来一份 CSV,字段整整齐齐:标题、城市、薪资、经验要求、学历要求、职位标签、公司名称、公司标签、详情页。我问他怎么弄的,他说照着一段 Python 爬虫代码改的,核心就三样东西——requests 发请求、parsel 解析页面、csv 落盘。这套组合放到今天不算新,甚至有人觉得"过时",但真到要快速抓一批结构化岗位数据、还要能直接喂给 Excel 或 pandas 的场景,它依然是最短路径。

这份资源解决的就是这件事:以招聘网站为例,用 GET 请求拿到搜索页 HTML,用 CSS 选择器把列表项里的字段一个个抠出来,再以追加模式写进 CSV。适合两类人——刚学完 requests 和选择器、想找一个完整闭环练手的;以及手头有临时数据需求、不想上 Scrapy 这种重框架的从业者。下面我按"能跑起来"的顺序拆一遍,重点讲参数怎么定、选择器为什么这么写、以及那些不跑一次根本不知道的坑。

2. 请求层拆解:URL、params 与 headers 到底怎么定

2.1 先搞清楚请求方式和参数从哪来

很多人卡在第一步不是不会写代码,而是不知道该往params里塞什么。正确做法是打开目标搜索页,按 F12 进开发者工具,切到 Network 面板,筛选 Doc 类型请求,然后刷新页面。在请求列表里找那个返回 HTML 的文档请求,点进去看两个地方:Headers 里的 Request Method,以及 Payload 里的查询参数。

以招聘搜索页为例,请求方式是 GET,参数挂在 URL 问号后面,形如?inputFrom=www_index&workYearCode=0&key=python&scene=input&ckId=xxx。这些就是你要放进params字典的东西。注意key就是搜索关键词,改它就能换岗位;ckId这类看着像会话标识的字段,有时不带也能返回数据,有时会触发校验,先原样带上最稳。

import requests url = 'https://www.liepin.com/zhaopin/' # params 对应 URL 问号后的查询参数,key 控制搜索的岗位关键词 data = { 'inputFrom': 'www_index', 'workYearCode': '0', 'key': 'python', 'scene': 'input', 'ckId': 'kfpt1emhvkrshc9o4h2xquxd74pfihmb', } # User-Agent 必须伪装成真实浏览器,否则容易被直接拒绝 headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/99.0.4844.74 Safari/537.36 Edg/99.0.1150.46' } response = requests.get(url=url, headers=headers, params=data) print(response.status_code)

逻辑说明:requests.get会把params自动拼接到 URL 上,等价于你手动拼字符串,但更安全(会自动做 URL 编码)。headers里最关键的是User-Agent,它告诉服务器"我是浏览器"。参数说明:workYearCode=0一般表示不限工作年限,scene=input表示来自搜索框输入,这些值直接照抄抓包结果即可,不要自己臆造。

2.2 判断请求是否真的成功

写完上面几行,别急着往下解析。先看response.status_code是不是 200,再看len(response.text)有没有几万字符。如果返回的是几百字符的短页面,八成是被拦了或者参数不对。我一般会顺手把response.text存成debug.html,用浏览器打开肉眼确认列表项在不在。

# 快速自检:状态码 + 页面长度 + 是否含目标关键词 print(response.status_code) print(len(response.text)) print('job-list-item' in response.text) # 返回 True 说明列表结构在

如果job-list-item不在返回内容里,先别改选择器,问题多半在请求层——要么 UA 被识别,要么参数缺了关键项,要么该页面本身需要先访问首页拿 Cookie。常见做法是先用requests.Session()访问一次首页,再带着同一个 session 去请求搜索页。

3. 解析层实战:CSS 选择器逐字段提取与容错

3.1 用 parsel 定位列表容器

拿到 HTML 后,用parsel.Selector包一层,就能用 CSS 或 XPath 选择器提取。核心思路是"先抓大容器,再在容器内逐字段抠",这样比全局选择器稳得多,因为列表项之间的结构是重复的。

import parsel selector = parsel.Selector(response.text) # 先定位每一个职位卡片,这是最外层重复单元 lis = selector.css('.left-list-box .job-list-item') print('抓到卡片数:', len(lis))

逻辑说明:.left-list-box .job-list-item是后代选择器,意思是"left-list-box 里面所有 job-list-item"。参数说明:类名必须和抓包看到的 DOM 完全一致,差一个字符就是空列表。如果len(lis)为 0,回到上一步确认 HTML 里到底有没有这个类名。

3.2 逐字段提取与列表拼接

每个卡片内部再分别取标题、城市、薪资、标签、公司信息、详情页链接。这里有个高频坑:标签是一个列表,前两个是经验和学历,剩下的才是技能标签,需要用切片和join处理。

for li in lis: title = li.css('.job-title-box div:nth-child(1)::attr(title)').get() city = li.css('.job-dq-box .ellipsis-1::text').get() money = li.css('.job-detail-header-box .job-salary::text').get() # 标签是列表,前两项固定为经验和学历,其余合并成技能标签 tag_list = li.css('.job-labels-box .labels-tag::text').getall() exp = tag_list[0] if len(tag_list) > 0 else '' edu = tag_list[1] if len(tag_list) > 1 else '' tag = ','.join(tag_list[2:]) company_name = li.css('.company-name::text').get() company_list = li.css('.company-tags-box span::text').getall() company_tag = ','.join(company_list) href = li.css('.job-detail-box a:nth-child(1)::attr(href)').get() print(title, city, money, exp, edu, tag, company_name, company_tag, href)

逻辑说明:::text取文本,::attr(title)取属性值,.get()取第一个匹配,.getall()取全部匹配返回列表。参数说明:tag_list[0]和[1]前加了长度判断,这是血泪经验——不是每条岗位都带满标签,直接索引会抛IndexError让整个循环崩掉。','.join()把列表拼成逗号分隔字符串,方便写进 CSV 单格。

提示:选择器里的nth-child(1)依赖 DOM 顺序,网站改版时最容易失效。如果某天标题全空了,优先怀疑这里,而不是怀疑请求。

4. 落盘与工程化:CSV 写入、翻页与异常处理

4.1 DictWriter 写 CSV 的正确姿势

用csv.DictWriter比手拼字符串靠谱,字段顺序由fieldnames决定,字典的键必须和它对齐,否则会报ValueError。打开文件时newline=''不能省,否则 Windows 下每行之间会多出空行;encoding='utf-8'保证中文不乱码。

import csv f = open('招聘.csv', mode='a', encoding='utf-8', newline='') csv_write = csv.DictWriter(f, fieldnames=[ '标题', '城市', '薪资', '经验要求', '学历要求', '职位标签', '公司名称', '公司标签', '详情页', ]) # 首次写入时写表头,重复运行会重复写,建议加判断 csv_write.writeheader() dit = { '标题': title, '城市': city, '薪资': money, '经验要求': exp, '学历要求': edu, '职位标签': tag, '公司名称': company_name, '公司标签': company_tag, '详情页': href, } csv_write.writerow(dit) f.close()

逻辑说明:mode='a'是追加模式,多次运行会往同一个文件后面加数据,适合边抓边存。参数说明:writeheader()只在文件为空时调用一次比较稳妥,否则每次运行都会插一行表头。writerow(dit)按fieldnames的顺序把字典值写进去,键名对不上会直接报错,这其实是好事——能帮你早发现字段拼写问题。

4.2 翻页与请求节奏

单页数据量有限,要抓多页就得改params里的分页参数(常见是curPage或page),循环递增。但别裸奔式狂请求,加个time.sleep控制节奏,既降低被封风险,也更接近真人行为。

import time for page in range(1, 6): data['curPage'] = str(page) # 分页参数名以实际抓包为准 resp = requests.get(url=url, headers=headers, params=data) # ...解析与写入逻辑... time.sleep(2) # 每页间隔 2 秒

逻辑说明:把分页参数放进循环,每轮重新请求。参数说明:curPage只是常见命名,实际字段名必须看你抓包结果,写错会导致每页返回相同内容。time.sleep(2)是经验值,太短容易触发限制,太长效率低。

4.3 异常处理别省

网络请求和解析都可能出意外,一个try-except能让程序在个别页面失败时继续跑,而不是整体崩掉。

for li in lis: try: title = li.css('.job-title-box div:nth-child(1)::attr(title)').get() # ...其余字段提取... csv_write.writerow(dit) except Exception as e: print('单条解析失败:', e) continue

逻辑说明:把单条记录的提取和写入包在try里,出错就打印并跳过,不影响后续记录。参数说明:捕获Exception是粗粒度做法,生产环境可以细化到IndexError、AttributeError,但练手阶段先保证不中断更重要。

5. 避坑排查:那些不跑一次根本不知道的问题

5.1 现象:返回内容里没有列表项,选择器全空

原因:请求被识别为非浏览器,或缺少必要 Cookie/参数。解决:补全User-Agent,用requests.Session()先访问首页再请求搜索页,必要时把抓包看到的Referer也带上。

5.2 现象:标签字段报 IndexError,程序中断

原因:部分岗位没有经验或学历标签,tag_list长度不足 2。解决:索引前判断长度,用if len(tag_list) > 0 else ''兜底,别假设每条数据都完整。

5.3 现象:CSV 用 Excel 打开中文乱码

原因:Excel 默认按本地编码解析,不认 UTF-8。解决:写入时用encoding='utf-8-sig',它会带 BOM 头,Excel 就能正确识别中文。

5.4 现象:多次运行后 CSV 里出现多行表头

原因:writeheader()每次运行都执行。解决:先判断文件是否为空或是否已存在,只在首次写入时调用。

5.5 现象:翻页后每页数据一模一样

原因:分页参数名写错,或参数没真正生效。解决:回到 Network 面板对比第一页和第二页请求的 Payload 差异,找到真正变化的那一项。

注意:抓取前先看目标站点的 robots.txt,控制请求频率,只取公开的岗位信息,不要碰任何需要登录才能看到的个人数据。

6. 进阶技巧:把 CSV 变成能直接分析的数据

跑通基础版之后,真正拉开差距的是数据清洗。原始薪资字段往往是"15-25k·13薪"这种字符串,直接做统计没法用。我一般会写个小函数把它拆成最低、最高两个数值列,再算个中位数。

import re def parse_salary(text): """把 '15-25k·13薪' 解析成 (15, 25),解析失败返回 (None, None)""" if not text: return None, None nums = re.findall(r'(\d+)', text) if len(nums) >= 2: return int(nums[0]), int(nums[1]) return None, None # 用法:读回 CSV 后逐行处理 import csv with open('招聘.csv', encoding='utf-8-sig') as f: rows = list(csv.DictReader(f)) for r in rows: low, high = parse_salary(r['薪资']) r['薪资下限'] = low r['薪资上限'] = high

逻辑说明:re.findall抓出字符串里所有数字,前两个当作区间上下限。参数说明:正则(\d+)匹配连续数字,遇到"面议"这类无数字的返回(None, None),后续统计时过滤掉即可。这样处理后,用 pandas 一行df.groupby('城市')['薪资上限'].mean()就能出城市薪酬对比。

另一个实用技巧是去重。同一岗位可能在不同页重复出现,用详情页链接做唯一键去重最可靠:

seen = set() unique_rows = [] for r in rows: key = r['详情页'] if key and key not in seen: seen.add(key) unique_rows.append(r) print(f'去重前 {len(rows)} 条,去重后 {len(unique_rows)} 条')

逻辑说明:用集合记录已出现的详情页链接,重复的跳过。参数说明:key为空时也跳过,避免把无效记录算进去。

从那以后我每次抓完数据,都强制先跑一遍去重和薪资解析,再谈分析——不然拿着带重复、带字符串薪资的原始表做图,结论全是错的。这套 requests + parsel + csv 的组合不花哨,但把请求、解析、落盘、清洗四步走完,一份能直接用的岗位数据集就到手了。希望帮到你。

本文还有配套的精品资源,点击获取