Python爬虫实战:应届生职位信息批量采集与Excel导出

发布时间:2026/10/16 5:11:56
Python爬虫实战:应届生职位信息批量采集与Excel导出
每年秋招春招那阵子应届生求职网站上的信息量简直可以用“爆炸”来形容。打开一个职位列表页筛完城市、筛完行业、翻上三四十页一个个岗位名、薪资、学历要求看下来眼睛都花了再手动复制到表格里存起来第二天重新打开网站发现又更新了几百条岗位整个人直接裂开。我自己那时也是被这种重复劳动折磨得受不了才琢磨着能不能用脚本把这些职位信息系统性地抓下来整理成一份干净的表格文件。于是就有了这个项目一个纯函数式结构的 Python 爬虫专门针对应届生求职平台做批量职位采集从列表页到详情页全自动跑通。实测下来非常稳定跑完一轮都不用盯着看今天就把整个实现思路和代码细节完整拆出来。这个项目的受众很明确——正在海投找工作的应届生以及刚开始学爬虫、想做点真实练手项目的 Python 初学者。前者能直接拿到一份可筛选、可排序的职位 Excel后者能学到如何在一个真实场景里把 requests、解析库、数据清洗、异常重试这些零散知识点串起来。你不需要有多高深的基础懂最基础的 Python 语法跟着思路走一遍就能跑出东西。1. 项目目标与整体设计思路1.1 核心需求拆解虽然叫“爬虫”但这个项目的本质其实是一个数据整理工具。目标不是“把所有职位都抓下来”而是“把当前页面上对求职者最有决策价值的字段快速、结构化地收集全”。围绕应届生求职场景最核心的字段大致有这些职位名称公司名称薪资范围工作城市经验要求学历要求职位标签比如“五险一金”“弹性工作”职位详情链接有了这几个字段应届生就能站在一个表格的视角横向对比不同岗位而不是在一个个网页之间来回跳转。整个项目要解决的真正痛点不是“能不能爬到”而是“怎么稳定地大规模跑”尤其不能在跑到一半的时候因为某一条解析报错导致整个任务中断。1.2 为什么选纯函数式结构我见过不少爬虫项目一上来就套一个大的Spider类属性、方法、状态全堆在一起。对于这种单站点、字段固定的场景完全没必要。函数式结构最大的好处是每一个函数只负责一件事输入输出清清楚楚单独拎出来测试都极其舒服。比如parse_list(html)只管解析列表页给它一段 HTML 就返回一个字典列表fetch_page(url)只管发请求返回文本。哪一步出问题直接定位到对应函数不用在类的各种self.状态里面翻来翻去。另外函数式写法对新手极其友好。类一旦牵涉继承、状态、回调概念负担会陡增而函数就是“数据进去、数据出来”理解成本基本为零。整个项目最终拆成几个独立的模块每个模块一个函数组合起来就是一个主流程。1.3 工具选型的取舍我做了一张选型对比表方便你感受为什么最终用了这套方案而不是更重的框架方案优势劣势适用场景requests BeautifulSoup轻量、学习成本低、调试直观不处理动态渲染内容页面结构简单、数据在 HTML 里的常规站点requests lxmlXPath比 BeautifulSoup 更快一点点XPath 语法需要额外熟悉大量页面需要解析、解析规则相对固定Scrapy 框架自带并发、去重、中间件、调度概念多、学习曲线陡、调试较繁琐多站点、大规模、长时间运行的采集任务Playwright / Selenium可以做动态点击、等待渲染资源占用高、速度慢、实施复杂页面为 JS 渲染的情况我实测下来目标站点的职位信息都是服务端直接渲染在 HTML 里的列表页没有异步接口详情页偶尔有几个动态组件但不影响核心字段所以 requests BeautifulSoup 就是性价比最高的组合。如果你在别的站点上发现页面内容是 JS 动态拼出来的那才需要考虑换 Playwright。1.4 合规边界先说清楚做爬虫之前一定把边界想明白。我只采集公开页面上的公开职位信息绝不碰需要登录才能看到的内部数据不搞撞库、不恶意并发轰炸接口。控制抓取频率每秒最多一两个请求这是对目标站点的基本尊重。另外每次跑之前先看一眼站点根目录下的 robots.txt 文件了解哪些路径允许访问。这不仅是技术习惯更决定了这个项目能不能长久稳定地跑下去。不要觉得这些是废话很多人写着写着就“越界”最后域名被封甚至连累共用出口 IP。2. 开工前的页面分析与数据流梳理2.1 列表页 URL 规律写爬虫的第一件事不是写代码而是打开浏览器把页面翻几遍看清 URL 的变化规律。我打开目标求职平台随便搜索“Java 开发”和“产品经理”观察搜索页的地址栏发现 URL 里有几个关键参数https://example.com/jobs?keywordjavacity101020100pageIndex1 https://example.com/jobs?keywordjavacity101020100pageIndex2keyword是搜索关键词city是城市编码pageIndex从 1 开始递增。知道这个规律之后爬虫的 URL 构造逻辑就确定了把用户输入的关键词、城市编码、起始页码作为参数传给一个专门构建 URL 的函数传入第 50 页就直接拼出第 50 页的地址。这个过程不需要任何黑科技就是浏览器地址栏里能看到的东西把它抽象成一个build_url而已。2.2 HTML 里职位信息的落点拿到页面源码后不要急着写解析规则先在浏览器开发者工具里按 F12看一个职位卡片包含哪些节点。当时的页面结构大概是这样的div classjob-card>import time import random import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session requests.Session() retry_strategy Retry( total3, backoff_factor0.8, status_forcelist[500, 502, 503, 504] ) adapter HTTPAdapter(max_retriesretry_strategy) session.mount(https://, adapter) session.mount(http://, adapter) def fetch_page(url, encodingutf-8, timeout10): 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, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.5, } try: resp session.get(url, headersheaders, timeouttimeout) resp.raise_for_status() resp.encoding encoding return resp.text except Exception as e: print(f[请求失败] {url} - {e}) return 这里有两个细节值得展开说。第一Retry参数里backoff_factor0.8表示第一次重试等待 0.8 秒第二次等待 1.6 秒第三次等待 3.2 秒指数退避给服务端留足了恢复时间这种策略比毫无停顿的重试有效得多。第二resp.encoding encoding是手动指定页面编码避免 requests 用默认编码解析时出现中文乱码。具体用什么编码以你在浏览器源码里看到的meta charset...为准常见的是utf-8也有部分是gbk。3.2 列表页解析函数下列代码是整个项目中最核心的一段直接体现了“函数式拆解”的思维。from bs4 import BeautifulSoup def parse_list(html): if not html: return [] soup BeautifulSoup(html, html.parser) items [] for card in soup.select(.job-card): job_id card.get(data-id, ).strip() name_el card.select_one(.job-name) company_el card.select_one(.company) salary_el card.select_one(.salary) location_el card.select_one(.location) exp_el card.select_one(.experience) edu_el card.select_one(.education) link_el card.select_one(.detail-link) tags_el card.select_one(.tags) tags [t.get_text(stripTrue) for t in tags_el.find_all(span)] if tags_el else [] item { job_id: job_id, job_name: name_el.get_text(stripTrue) if name_el else , company: company_el.get_text(stripTrue) if company_el else , salary: salary_el.get_text(stripTrue) if salary_el else , city: location_el.get_text(stripTrue) if location_el else , experience: exp_el.get_text(stripTrue) if exp_el else , education: edu_el.get_text(stripTrue) if edu_el else , tags: 、.join(tags[:3]), detail_url: link_el[href] if link_el else , } items.append(item) return items所有字段都用if ... else 兜底这是一个非常关键的习惯。爬虫跑了几千页之后总会遇到个别残缺的卡片如果这里直接抛异常整个流程马上中断前面的工作就全白费了。用空字符串兜底让数据先入库清洗阶段再决定这些残缺记录是保留还是丢弃主动权始终在自己手里。另外注意job_id是从>def parse_detail(html): if not html: return {} soup BeautifulSoup(html, html.parser) desc_el soup.select_one(.job-description) type_el soup.select_one(.company-type) size_el soup.select_one(.company-size) return { job_desc: desc_el.get_text(stripTrue) if desc_el else , company_type: type_el.get_text(stripTrue) if type_el else , company_size: size_el.get_text(stripTrue) if size_el else , }可能有人会觉得详情页字段多一点会更好但实际做下来你会发现求职场景里真正重要的字段已经从列表页拿到了。详情页字段更像是一个补充信息抓太多了后期清洗成本反而增加性价比不高。3.4 主流程编排接下来是主流程函数。它负责把上面这些函数按顺序串起来同时处理分页、延迟、数据汇总。def crawl_jobs(keywordjava, city101020100, max_pages20): all_items [] for page in range(1, max_pages 1): url build_url(keyword, city, page) html fetch_page(url) items parse_list(html) if not items: print(f第{page}页没有解析到任何职位提前结束) break for item in items: # 抓详情页失败不影响主流程 detail_html fetch_page(item[detail_url]) detail parse_detail(detail_html) item.update(detail) all_items.append(item) time.sleep(random.uniform(0.5, 1.2)) print(f第{page}页完成累计 {len(all_items)} 条) time.sleep(random.uniform(1.0, 2.0)) return all_items这里有一个容易踩的坑不要把抓详情页和抓列表页放在同一个循环频率里。列表页每翻一页就 sleep 1-2 秒详情页每抓一条就 sleep 0.5-1.2 秒整体节奏会平缓很多不容易触发服务端的频率检测。我见过很多人为了追求速度把 sleep 时间调成 0.1 秒甚至不 sleep结果几个小时内就把自己的出口 IP 送进了灰色名单得不偿失。4. 数据清洗与落地存储4.1 薪资字段的结构化处理原始数据里“15-20K”“8千-1.2万”“面议”这类薪资写法千奇百怪如果直接存进 Excel 里后续筛选排序非常难受。数字排序会把“8千”排到“15K”前面表格根本没法用。所以清洗的时候要解析成可计算的数值字段。我写了一个parse_salary函数来处理def parse_salary(salary_str): if not salary_str or 面议 in salary_str: return , , salary_clean salary_str.replace(·14薪, ).strip() if K in salary_clean or k in salary_clean: nums re.findall(r(\d(?:\.\d)?), salary_clean) if len(nums) 2: sal_min float(nums[0]) * 1000 sal_max float(nums[1]) * 1000 elif len(nums) 1: sal_min float(nums[0]) * 1000 sal_max float(nums[0]) * 1000 else: return , , elif 万 in salary_clean: nums re.findall(r(\d(?:\.\d)?), salary_clean) if len(nums) 2: sal_min float(nums[0]) * 10000 sal_max float(nums[1]) * 10000 elif len(nums) 1: sal_min float(nums[0]) * 10000 sal_max float(nums[0]) * 10000 else: return , , else: return , , return int(sal_min), int(sal_max), salary_str解析完以后表格里同时保留一个“薪资原始值”字段和一个“薪资最低值/最高值”字段。这样既保留了原始信息方便阅读又提供了可排序的数值字段用于筛选。对于应届生来说直接用最低值排序基本能一眼看出哪些岗位给的保底薪资最高。4.2 基于职位 ID 的去重策略爬虫跑完一轮之后同一个职位可能会因为分页重复、关键词重叠等原因出现多次。最稳妥的办法就是把job_id作为唯一主键在写入 Excel 之前做一次去重。这里直接用 pandas 自带的drop_duplicates就行了import pandas as pd def deduplicate_and_sort(data_list): df pd.DataFrame(data_list) df[job_id] df[job_id].astype(str) df df.drop_duplicates(subset[job_id], keepfirst) df df.sort_values([salary_min, city], ascending[False, True]) return df注意subset参数必须指定为job_id而不是什么都不传对整行去重。因为两条不同职位完全可能薪资、城市、公司都一样这时候整行去重就会误删有效数据。4.3 导出到 Excel 与 CSV存储格式我用两个Excel 给人看CSV 给后续程序处理。Excel 用 pandas 的to_excel非常方便但有个小问题字段顺序需要自己手动调整。pandas 默认按字典的插入顺序排所以我在构造item字典的时候就已经把字段顺序排好了这样最终输出的表格列顺序和用户直觉一致先职位名称再公司再薪资最后是详情链接。def save_dataframe(df): df.to_excel(jobs_result.xlsx, indexFalse) df.to_csv(jobs_result.csv, indexFalse, encodingutf-8-sig, ) print(f已保存 {len(df)} 条记录到 jobs_result.xlsx / jobs_result.csv)CSV 编码必须写utf-8-sig这样用 Excel 直接打开 CSV 文件时中文字符才不会乱码。utf-8-sig会在文件开头加一个 BOM 标记Excel 能正确识别而普通的utf-8不带 BOMExcel 老版本就会显示一段乱码。5. 稳定性保障与反爬应对5.1 请求头与 Session 管理很多爬虫第一步就被拒是因为 User-Agent 太假。默认的python-requests/2.31.0一眼就是脚本肯定容易被拦截。我给它改成了完整的主流浏览器 UA 字符串同时带上Accept和Accept-Language让请求看起来更像一个正常的浏览器访问。另外用requests.Session()的好处是会话会默认保存服务端返回的 Cookie后续请求带着同样的会话走服务端对 Session 的容忍度通常比每次新建连接要高很多。5.2 随机延时策略稳定的爬虫不追求快追求“像人”。人翻网页是不可能每 0.1 秒翻一次的所以延时一定要有随机性。我用的延时范围是 0.5 到 2.0 秒之间随机既保证了整体速度不至于太慢又避免出现完全固定的节奏。如果你的目标是连跑几百页可以参考我下面的调度方案请求类型延时范围说明列表页翻页1.0 ~ 2.0 秒每次翻页间隔大一点更安全详情页请求0.5 ~ 1.2 秒同一页面内的详情间隔可以稍短单次失败重试2 ~ 5 秒失败说明服务端可能繁忙等待要更长全天连续任务每次运行不超过 2 小时长时间高频请求容易触发限制5.3 失败重试与断点续采爬虫跑一半挂了是家常便饭。如果因为挂掉就要从头再来这个项目就谈不上“稳”。所以我建议在代码里加一个断点机制每成功解析一个列表页就把已经拿到的数据追加写入 CSV 临时文件。这样即使中途脚本被中断下次运行可以直接跳过已经存在的职位 ID从上次的位置继续采集而不是全部重来。这个逻辑其实不复杂核心就是一个文件读写判断def load_done_ids(temp_csvtemp_done.csv): if not os.path.exists(temp_csv): return set() df pd.read_csv(temp_csv, usecols[job_id], dtype{job_id: str}) return set(df[job_id].tolist())然后在主循环里做一次判断done_ids load_done_ids() for item in crawl_jobs(...): if item[job_id] in done_ids: continue # 处理并写入临时文件这个设计可以让你放心跑一个大任务哪怕半夜断网第二天重跑一小段就能续上。6. 常见问题与排查实录6.1 状态码异常现象可能原因排查思路返回 403服务端识别为爬虫请求拒绝访问检查 User-Agent 是否完整、Cookie 是否有、请求频率是否过高返回 404URL 拼错、页面已下线在浏览器里手动打开该 URL确认地址是否有效返回 429请求过于频繁触发限流加大延时、减少并发、暂停半小时后再试返回 504服务端网关超时增加timeout参数或者换一个非高峰时段跑遇到 403 是最常见的。有时候同一个 URL 用浏览器能打开用 requests 就打不开基本可以确定是 UA 或者请求头问题。可以先拿 curl 把浏览器实际的请求头完整复制下来替换掉 headers 里的值一般就能解决。6.2 解析结果为空这个坑我真踩了不少次。parse_list写完后跑出来完全没数据第一反应以为是选择器写错了后来才发现是fetch_page返回了空字符串导致BeautifulSoup()解析出来自然什么都没有。所以排查顺序很重要先打印fetch_page拿到的 HTML 长度如果长度是 0说明是请求层的问题如果 HTML 长度正常再拿一段 HTML 局部输出检查选择器是否匹配最保险的方法是先把 HTML 保存成本地文件用浏览器打开看看结构再回去改选择器6.3 中文乱码问题中文乱码的根源基本只有一个页面编码和 requests 解析编码不一致。解决思路是不要信任 requests 的自动检测直接从 HTML 源码里找到meta charset...手动指定。不知道编码的时候可以用chardet.detect()快速探测一下但它是概率判断最终还是要以页面声明的为准。6.4 Excel 打开 CSV 乱码这个问题上节已经提过严格用encodingutf-8-sig导出 CSV就不会乱。注意不要在导出 CSV 时用默认的utf-8虽然 Python 程序里看着正常但 Excel 打开就全是“锟斤拷”了。6.5 跑一段时间后被限制访问现象可能原因处理方式前几十页正常后面全部失败请求频率过高触发临时封禁停止脚本等待 10-30 分钟后续调大延时区间同一 IP 下的其他程序也会失败出口 IP 被整体限制换网络出口或者等待解封不要短期内反复硬试偶尔一次失败重试成功网络抖动或服务端临时错误保持指数退避重试即可7. 实测运行效果与个人体会7.1 一次实际跑批的数据表现我自己用这个脚本跑过一次完整的采集任务关键词是热门的“Java开发”城市选了北京列表页设定 30 页。跑完的结果是 1850 条职位记录实际耗时约 26 分钟。整个过程没有出现过一次让脚本中断的致命异常只有个别详情页请求失败但在重试机制下都自动补上了。生成的 Excel 文件大小大概 1.2MB用筛选功能按薪资降序排再叠加学历要求一过滤整个投递范围瞬间清晰很多。最意外的是 tags 字段的清洗效果。原本以为职位标签只是装饰性文字结果把这些标签统计一下出现频率最高的几个词是“五险一金”“双休”“弹性工作”这对应届生判断岗位硬性条件非常有参考意义。后来我甚至给脚本加了一个简单的词频统计每个月跑一次相当于给自己建了一个小型的岗位画像数据库。7.2 几个值得分享的经验经验一不要追求把所有详情页都爬下来。详情页请求数量越多触发反爬的概率越大。如果你只是个人求职、做筛选列表页的字段已经足够用了。项目里加一个MAX_DETAIL_PAGES参数默认限制只抓前 50 个详情页是性价比最高的选择。经验二字段缺失比报错更可怕。报错会让程序崩溃你能立刻发现而字段静默缺失让数据分析结果出现偏差很难察觉。所以每个字段解析后最好打印出来抽查几条。我经常用一个if page 1的条件把第一页的前三条解析结果完整打印出来肉眼确认无误再跑全量。经验三代码保证“可重入”比保证“一次跑完”更重要。断点续采能力要一开始就设计进去不要等项目跑挂了再临时补。我吃过一次亏跑了一个多小时之后突然断网几万条数据因为没有临时文件全丢了从那以后所有采集项目都先写断点逻辑。7.3 这个项目还可以怎么继续扩展如果你手上已经跑通了这份代码后续扩展方向很自然加一个定时任务每周固定时间自动采集一次配合 pandas 做周度数据变化对比把结果导入数据库用 SQL 做多关键词、多城市的组合筛选再配合一个简单的可视化看板根据职位标签做词频统计生成“岗位技能需求图谱”投简历之前先看看自己掌握的技能是否匹配把代码封装成一个命令行工具输入python jobs_crawler.py --keyword数据分析 --city上海 --pages50就能跑方便分享给身边同样在找工作的同学爬虫从来不是目的把信息变成自己能用得上的决策依据才是。对正在找工作的应届生来说这份脚本也许就是你把海量岗位快速筛到几十个值得投递目标的效率工具对正在学 Python 的开发者来说这个项目则是一个把网络请求、HTML 解析、数据清洗、异常处理完整串起来的练手案例。说到底真正让它在实际使用中“稳得一比”的原因不是某一招很炫而是每一层都做了兜底和容错。希望这篇文章能帮你在秋招春招的路上少一点重复劳动多一点有效时间。