☰
Python爬虫实战:60行代码抓取招聘网站岗位数据并保存CSV
2026/10/14 4:34:38 网站建设 项目流程

简介:这份资源面向具备Python基础、希望入门网络爬虫的开发者与数据分析初学者,以猎聘网为例,完整演示如何抓取招聘岗位信息并落地为CSV文件。内容围绕Requests发送GET请求、Parsel结合CSS选择器解析页面、请求头伪装、字典组织字段、csv模块追加写入等核心环节展开,并涉及异常处理与robots协议等合规要点,帮助读者打通从请求到结构化存储的完整链路。资源包共1个docx文档,约18KB,以图文笔记形式呈现关键代码与解析思路,便于对照练习。目前已有4901人学习下载,适合想快速掌握爬虫采集与数据保存流程、并希望理解反爬与合规边界的读者参考借鉴。

1. 从一份招聘 CSV 说起:这套爬虫脚本到底能扛什么活

前阵子帮一个做数据分析的朋友整理岗位市场行情,他甩过来一份 CSV,里面躺着几百条招聘信息,标题、城市、薪资、经验要求、学历、公司标签一应俱全。我问他哪来的,他说自己用 Python 爬的,核心代码不到 60 行。这件事让我意识到,很多人对「爬虫」的想象还停留在「要写一大堆框架、要处理登录、要对抗验证码」,但真实场景里,一个 requests + parsel + csv 的组合,就能把招聘网站上的结构化信息稳稳落到本地文件。

这套方案解决的核心问题很具体:你有一个岗位关键词(比如 python),想批量拿到当前在招的职位列表,并且希望结果能直接用 Excel 打开、能排序、能筛选。它适合两类人:一是刚学完 Python 基础语法、想找一个「有真实产出」的练手项目的新手;二是做招聘分析、薪资调研、竞品岗位监控的从业者,需要快速搭一个能跑的数据采集脚本。不适合的场景也很明确——需要登录才能看的职位详情、需要翻页到几百页之后的深度数据、以及任何涉及个人隐私的简历信息,这些都不在本文讨论范围内。

我拿到那份 CSV 之后做的第一件事,是把脚本从头到尾拆了一遍,发现里面有几个设计选择值得说清楚:为什么用 GET 而不是 POST、为什么用 CSS 选择器而不是 XPath、为什么 CSV 要用追加模式打开。这些细节决定了脚本能不能稳定跑、跑出来的数据能不能直接用。接下来我会按「先看懂请求、再拆解析逻辑、最后处理落盘和异常」的顺序,把这份资源里的技术点逐个展开。

2. 请求层拆解:GET 参数、请求头与 URL 的配合逻辑

2.1 为什么招聘列表页通常走 GET

打开目标招聘网站的搜索页,按 F12 进开发者工具,切到 Network 面板,筛选 Doc 类型的请求,然后刷新页面。你会看到浏览器发出的第一个文档请求,它的 URL 长这样:

https://www.liepin.com/zhaopin/?inputFrom=www_index&workYearCode=0&key=python&scene=input&ckId=xxxxx

问号后面的部分就是 GET 参数。GET 请求的本质是把筛选条件直接编码在 URL 里,服务器根据这些参数返回对应的 HTML 页面。招聘网站的列表页天然适合 GET,因为「搜索关键词、工作年限、城市」这些条件本身就是无状态的查询参数,不需要像登录那样维持会话。

这里有一个容易翻车的点:很多人看到浏览器地址栏里只有https://www.liepin.com/zhaopin/,就以为请求不需要参数。实际上地址栏会被前端路由美化,真正的请求参数藏在 Network 面板里。判断方法很简单:在 Network 面板里找到返回 HTML 的那个请求,看它的 Payload 或 Query String Parameters 区域,那里列出的键值对就是你需要用params传进去的东西。

2.2 用 requests.get 构造带参数的请求

原始代码里把参数放在一个字典里,然后传给requests.get的params形参:

import requests url = 'https://www.liepin.com/zhaopin/' data = { 'inputFrom': 'www_index', 'workYearCode': '0', 'key': 'python', 'scene': 'input', 'ckId': 'kfpt1emhvkrshc9o4h2xquxd74pfihmb', } 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) print(response.url)

这段代码的逻辑是:requests会自动把data字典里的键值对拼接到url后面,生成完整的请求地址。你可以打印response.url来验证拼接结果,确认参数有没有被正确编码。headers里的User-Agent是必须的,没有它,很多网站会直接返回 403 或者一个空页面。原始代码里用的是一个 Edge 浏览器的 UA 字符串,你可以换成自己浏览器里复制出来的,保持版本号接近当前主流即可。

参数说明:key是搜索关键词,改成java、产品经理就能换岗位;workYearCode控制工作年限筛选,0通常表示不限;ckId看起来像是一个会话标识,实际测试中不传也能拿到数据,但保留它更接近真实浏览器行为。如果你发现返回的 HTML 里没有职位列表,第一个要检查的就是params和headers是否和 Network 面板里看到的一致。

2.3 请求头里哪些字段真正影响返回结果

除了User-Agent,有些网站还会检查Referer、Accept-Language、Cookie。原始代码只放了 UA,说明目标网站在这个场景下对请求头的校验不算严格。但如果你换一个招聘网站,或者过一段时间发现脚本突然拿不到数据了,优先补上Referer(设为搜索页的 URL)和Accept-Language: zh-CN,zh;q=0.9,这两个字段能显著降低被识别为脚本的概率。

我一般会这样写一个更完整的请求头:

headers = { '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', 'Referer': 'https://www.liepin.com/zhaopin/', 'Accept-Language': 'zh-CN,zh;q=0.9', }

注意不要一次性把所有浏览器字段都抄进去,有些字段(比如Content-Length)是 POST 请求才需要的,硬塞进 GET 请求反而可能触发异常。请求头的原则是:Network 面板里有什么就抄什么,但只抄和当前请求相关的。

3. 解析层实战:parsel 的 CSS 选择器怎么定位到每个字段

3.1 先找到「最大标签」,再在里面逐层提取

拿到response.text之后,原始代码用parsel.Selector把它包装成一个可查询的对象:

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这个选择器的含义是:先找到 class 为left-list-box的容器,再找它内部所有 class 为job-list-item的元素。每一个job-list-item对应列表里的一条职位记录。打印len(lis)可以快速判断有没有解析到数据——如果结果是 0,要么是页面结构变了,要么是请求没拿到真正的列表 HTML。

这一步是整个解析流程的锚点。不要一上来就写li.css('.job-title-box div:nth-child(1)::attr(title)')这种长选择器,先确认最大标签能选中,再往里逐层拆。我见过太多新手直接抄一长串选择器,结果因为中间某一层 class 名变了,整个提取逻辑全挂掉,还找不到是哪一层出的问题。

3.2 逐字段提取:标题、城市、薪资、标签的写法差异

在for li in lis循环里,每个字段的提取方式略有不同,这些差异对应着 HTML 结构的不同:

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:]) if len(tag_list) > 2 else '' 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()

几个关键点:

  • ::attr(title)表示提取元素的title属性值,而不是文本内容。职位标题经常放在title属性里,因为页面上显示的可能被截断。
  • ::text提取的是元素的直接文本。如果文本在子元素里,::text可能拿不到,需要用::text配合getall()或者改用xpath('string(.)')。
  • get()返回第一个匹配结果,getall()返回所有匹配结果的列表。标签类字段通常有多个,所以用getall()。
  • tag_list[0]和tag_list[1]分别对应经验要求和学历要求,这是基于页面结构的固定顺序。如果目标网站调整了标签顺序,这里就会错位,所以我在代码里加了长度判断,避免列表为空时直接报IndexError。

3.3 用 join 把列表拼成字符串,方便写入 CSV

tag_list[2:]取的是标签列表中从第三个开始的所有元素,然后用','.join()拼成一个字符串。这样做的原因是 CSV 的每一列最好是一个标量值,而不是一个列表。如果你直接把列表写进 CSV,Python 会把它变成['标签1', '标签2']这种形式,后续用 Excel 打开会很别扭。

同样的处理也用在公司标签上:

company_tag = ','.join(company_list)

这里没有做长度判断,因为join对空列表返回空字符串,不会报错。但要注意,如果company_list里的元素本身包含逗号,拼接后会产生歧义。更稳妥的做法是用分号或竖线作为分隔符,比如'|'.join(company_list),这样在 Excel 里分列时不会误切。

4. 落盘与异常:CSV 写入模式、编码和翻页的坑

4.1 追加模式与表头重复写入的问题

原始代码用open('招聘.csv', mode='a', ...)打开文件,a表示追加模式。追加模式的好处是每次运行脚本不会覆盖之前的数据,但有一个明显的坑:如果每次循环都调用writerow(dit),表头会被重复写入。原始代码里注释写着「写入表头」,但实际上writerow(dit)写的是数据行,不是表头。

正确的做法是:在循环开始之前,先判断文件是否为空,如果为空才写入表头。可以这样改:

import csv import os file_exists = os.path.isfile('招聘.csv') f = open('招聘.csv', mode='a', encoding='utf-8', newline='') csv_write = csv.DictWriter(f, fieldnames=[ '标题', '城市', '薪资', '经验要求', '学历要求', '职位标签', '公司名称', '公司标签', '详情页', ]) if not file_exists: csv_write.writeheader() for li in lis: # ... 提取字段 ... dit = { '标题': title, '城市': city, '薪资': money, '经验要求': exp, '学历要求': edu, '职位标签': tag, '公司名称': company_name, '公司标签': company_tag, '详情页': href, } csv_write.writerow(dit) f.close()

writeheader()只在文件不存在时调用一次,这样多次运行脚本也不会出现重复表头。newline=''是为了避免 Windows 下 CSV 出现空行,这个参数在官方文档里有明确说明,不加的话每行数据之间会多一个空行。

4.2 编码选择:utf-8 与 utf-8-sig 的区别

原始代码用的是encoding='utf-8'。用 Excel 打开这个 CSV 时,中文可能会显示乱码。原因是 Excel 默认按系统编码(简体中文 Windows 下是 GBK)读取 CSV,而文件实际是 UTF-8。解决办法是把编码改成utf-8-sig,它会在文件开头写入 BOM(字节顺序标记),Excel 看到 BOM 就会自动用 UTF-8 解码。

f = open('招聘.csv', mode='a', encoding='utf-8-sig', newline='')

这个改动很小,但能省掉「打开 CSV 全是乱码」的排查时间。如果你后续要用 pandas 读取这个文件,utf-8-sig也能被正确识别,不会把 BOM 当成数据的一部分。

4.3 翻页逻辑:为什么原始代码只拿到第一页

原始代码没有处理翻页,所以只能拿到搜索结果的第一页。招聘网站通常用curPage或page参数控制页码。你可以在 Network 面板里点击下一页,观察 URL 或 Payload 里哪个参数变了。假设参数名是curPage,可以这样改:

for page in range(1, 6): data = { 'inputFrom': 'www_index', 'workYearCode': '0', 'key': 'python', 'scene': 'input', 'ckId': 'kfpt1emhvkrshc9o4h2xquxd74pfihmb', 'curPage': str(page), } response = requests.get(url=url, headers=headers, params=data) selector = parsel.Selector(response.text) lis = selector.css('.left-list-box .job-list-item') for li in lis: # ... 提取和写入 ... pass time.sleep(2) # 每页之间停 2 秒

time.sleep(2)是必要的,连续快速请求容易触发网站的频率限制,轻则返回空数据,重则临时封 IP。2 秒是一个比较保守的间隔,实际可以根据网站响应速度调整,但不要低于 1 秒。

4.4 异常处理:网络超时和字段缺失

原始代码没有try-except,一旦某个字段提取失败(比如tag_list为空导致tag_list[0]报错),整个脚本就会中断。建议在提取字段时加上默认值,并在请求层加上超时和重试:

import time for page in range(1, 6): try: response = requests.get(url=url, headers=headers, params=data, timeout=10) response.raise_for_status() except requests.RequestException as e: print(f'第 {page} 页请求失败: {e}') continue selector = parsel.Selector(response.text) lis = selector.css('.left-list-box .job-list-item') if not lis: print(f'第 {page} 页没有解析到职位,可能被限制或页面结构变化') continue for li in lis: 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:]) if len(tag_list) > 2 else '' # ... 其余字段 ... time.sleep(2)

timeout=10表示 10 秒内没有响应就抛异常,避免脚本卡死。raise_for_status()会在 HTTP 状态码不是 200 时主动报错,方便你及时发现 403 或 500。这些改动看起来琐碎,但决定了脚本能不能无人值守地跑完多页数据。

5. 避坑与排查:五个真实翻车现场

5.1 返回的 HTML 里没有职位列表

现象:response.status_code是 200,但selector.css('.left-list-box .job-list-item')返回空列表。

原因:最常见的是请求头不完整,网站返回了一个验证页面或空壳页面;其次是参数名写错,比如把key写成了keyword;还有一种情况是网站改版,class 名变了。

解决:先把response.text保存到本地文件,用浏览器打开看实际返回了什么。如果是验证页面,补全Referer和Cookie;如果是参数问题,对照 Network 面板逐个核对;如果是 class 名变了,用浏览器的「检查元素」重新定位选择器。

5.2 薪资字段提取到的是空字符串

现象:money变量是None或空字符串,但页面上明明显示了薪资。

原因:薪资可能不在::text直接文本里,而是嵌套在子元素中,或者用了伪元素显示。另外,有些网站对未登录用户隐藏薪资,显示「面议」。

解决:把选择器改成li.css('.job-detail-header-box .job-salary *::text').getall(),用getall()拿到所有子文本再拼接。如果是登录限制,考虑在请求头里带上有效的Cookie。

5.3 CSV 用 Excel 打开中文乱码

现象:用记事本打开 CSV 中文正常,用 Excel 打开全是乱码。

原因:Excel 默认按 GBK 解码,而文件是 UTF-8 无 BOM。

解决:把encoding='utf-8'改成encoding='utf-8-sig'。如果文件已经生成了,可以用记事本打开,另存为时选择「UTF-8 with BOM」编码。

5.4 脚本跑几页之后被限制访问

现象:前两页正常,第三页开始返回 403 或空数据。

原因:请求频率过高,触发了网站的反爬机制。

解决:在每页之间加time.sleep(2)到time.sleep(5)的随机延迟,不要用固定值。更稳妥的做法是准备多个User-Agent,每次请求随机选一个。如果仍然被限制,需要降低并发或改用更慢的采集节奏。

5.5 追加模式下数据重复

现象:多次运行脚本后,CSV 里出现大量重复行。

原因:追加模式不会清空文件,每次运行都会把当前页的数据再写一遍。

解决:如果希望每次运行都重新采集,把mode='a'改成mode='w',但这样会覆盖历史数据。更好的做法是在写入前用「标题 + 公司名称」做去重判断,或者每次运行生成一个带时间戳的新文件名,比如招聘_20250101.csv。

6. 进阶技巧:把 CSV 变成可分析的数据表

脚本能跑通之后,下一步是让数据真正可用。我一般会在爬取完成后加一段清洗逻辑,用 pandas 做快速验证:

import pandas as pd df = pd.read_csv('招聘.csv', encoding='utf-8-sig') print(df.shape) print(df['城市'].value_counts().head(10)) print(df['薪资'].str.extract(r'(\d+)-(\d+)K').head())

df.shape确认行数和列数,value_counts()看城市分布,正则提取薪资区间。这一步能帮你快速判断数据质量——如果城市字段里出现了「北京·朝阳区」这种带区域的格式,说明提取时没有做清洗,后续按城市分组会不准。

另一个实用技巧是把详情页 URL 补全。原始代码提取的href可能是相对路径,比如/job/123456.shtml,需要拼上域名:

base = 'https://www.liepin.com' href = li.css('.job-detail-box a:nth-child(1)::attr(href)').get() full_url = base + href if href and href.startswith('/') else href

这样在 CSV 里点击链接就能直接跳转到职位详情页。注意不要对详情页做进一步爬取,除非你确认目标网站允许,并且做好了频率控制。

最后说一个我自己的习惯:每次改完选择器或参数,先只跑第一页,把结果打印出来肉眼检查一遍,确认字段没有错位、没有空值、编码正常,再放开翻页。这个习惯帮我省掉了很多「跑完几百页才发现某一列全是空」的后悔药。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询