前阵子需要研究技术岗位的市场行情,数据源锁定了51job。说实话,刚上手的时候我的第一反应还是老一套:先找内部接口,拿requests直接怼。结果折腾了半天,发现这条路比想象中难走得多——搜索页面的职位列表是动态渲染的,直接抓回来的HTML基本是个空壳,里面只有一层框架代码,真正的职位数据全部由JavaScript异步填充。后来果断切到Selenium无头模式,整个采集流程一下就顺了。
这篇文章我把完整方案展开聊一聊,包括为什么API路线在51job这类动态站点上不好走、Selenium无头模式该怎么配置、具体代码怎么写,以及我在实战里踩过的四个坑。适合有一定Python基础、正在被动态页面采集问题卡着脖子的人参考。如果你恰好是要做招聘数据统计分析的同学,这篇也能给你一个能直接跑的底子。
1. 为什么放弃API硬啃,转向无头浏览器?
1.1 API路线的三道坎
招聘类站点和一般的内容站不同,它的核心职位数据是由前端框架动态渲染出来的。用requests直接抓,拿到的HTML里只有骨架、没有肉。这种时候,很多人的第二反应是去开发者工具里翻内部接口。这个思路对不少站点是有效的,但放在51job这种平台上,API路线会遇到三道坎。
第一道坎是接口没有公开文档。招聘平台的数据涉及求职者隐私和客户商业信息,官方开放出来的接口极少,而且通常只对合作企业开放。个人开发者拿不到合法授权,就只能从浏览器请求里翻找内部接口。可内部接口本身就不是给外部人准备的,用起来处处受限。
第二道坎是内部接口的签名校验。就算在开发者工具里看到类似/api/search的请求,也未必能直接复用。这类接口一般在请求头或URL参数里带着动态签名字段,由前端脚本生成,还会结合时间戳、cookie、行为轨迹做校验。你手动复制一次能用,脚本里自动跑就是另一套结果。
第三道坎是字段不稳定。API返回值虽然结构清晰,但招聘平台经常调整页面版本,接口路径、字段名、返回层级说变就变。一旦接口改版,解析逻辑就要跟着重写。相比之下,页面DOM结构虽然也会改,但只要页面能正常渲染,总能从视觉上定位到数据位置。
所以我在这个项目里做了一个取舍:放弃和接口死磕,改用Selenium无头模式,把浏览器当成“数据管道”,直接读渲染完成后的页面。这样做虽然请求重量变大,但胜在稳定、直观,对页面改版有更强的容错能力。
1.2 无头浏览器与传统请求的本质差异
要理解为什么Selenium能解决这个问题,得先搞懂浏览器和requests的差别。你可以把requests想象成一个只看文字的读者,它拿到HTML源码就完事了,不执行脚本、不渲染样式、不加载异步数据。而Selenium世界里你获得的是一个“真实浏览器”,它会执行HTML里的脚本、发起后续XHR请求、等待图片和资源加载,最终把页面变成你肉眼看到的样子。
51job的职位列表从异步接口拉数据、渲染DOM节点,这些动作全部依赖JavaScript在浏览器环境里的执行。requests抓到的“空壳HTML”里根本没有这些执行结果,自然提取不到职位数据。Selenium控制的浏览器会原原本本把整个渲染过程走完,脚本执行完成后再去读取DOM,就能拿到完整数据了。
一句话概括:requests拿的是源码,Selenium拿的是渲染后的页面。前者适合静态HTML,后者对付前端框架站点有先天优势。
1.3 什么时候该用Selenium,什么时候不该用
强调一点:Selenium不是银弹。它体积大、速度慢、吃资源,很多时候我们用requests甚至更轻的方案就够了。我一般这样区分场景:
| 场景 | 用requests | 用Selenium |
|---|---|---|
| 静态HTML | 推荐,速度快 | 不必要 |
| 接口可直接返回JSON | 优先 | 备用 |
| 动态渲染页面 | 拿不到数据时再考虑 | 推荐 |
| 需要登录态、复杂交互 | 一般很难处理 | 推荐 |
| 高并发大量采集 | 需要配合合规评估 | 不推荐 |
具体到我的51job需求,页面是重前端渲染,又没有稳定的公开API,所以Selenium无头模式成了最靠谱的选择。它虽然请求速度慢点,但能稳定拿到真实页面数据,换句话就是可靠性优先。这个思路和我后面要讲的代码配置是一脉相承的。
2. 环境搭建:把Selenium无头模式调试到能跑
2.1 依赖安装与浏览器驱动
在写代码之前,先把环境准备好。这个项目只需要三个Python库:selenium、webdriver-manager和pandas。selenium是Selenium本体,webdriver-manager是替我们自动下载和匹配ChromeDriver的小工具,pandas用来做数据导出和分析。如果你采用这个方案,强烈建议用webdriver-manager,不要手动下驱动。
为什么这么强调驱动匹配?因为Chrome浏览器和ChromeDriver之间要求版本严格对应,比如Chrome 120.x必须配ChromeDriver 120.x。版本对不上,Selenium启动浏览器时直接报SessionNotCreatedException,连进入页面的机会都没有。早期手工管理驱动时,光是版本不匹配这种问题就能卡一晚上,人眼去比对主版本号、小版本号、修订号,特别容易漏掉一位数字。
安装命令如下:
pip install selenium webdriver-manager pandas第一次运行的时候,webdriver-manager会自动查询本地Chrome版本,去对应地址下载驱动并缓存到系统目录。之后的运行直接复用缓存,不会每次都重新下载,这一个环节就省掉了大量环境问题。如果你已经手动下载了正确的ChromeDriver,也可以直接通过Service指定路径,代码如下:
from selenium.webdriver.chrome.service import Service service = Service("/path/to/chromedriver") driver = webdriver.Chrome(service=service, options=options)但老老实实用webdriver-manager,省心得多。
2.2 无头模式的参数怎么配
无头(Headless)指的是浏览器不显示界面窗口、在后台运行的状态。配置方式是通过Options对象给Chrome传启动参数。这个项目的核心配置我整理成一张表,每一行参数我都会说一下背景:
| 参数 | 作用 | 说明 |
|---|---|---|
| --headless=new | 开启无头模式 | 新版Chrome推荐用法,兼容性更好 |
| --disable-gpu | 关闭GPU硬件加速 | 无头模式没有显示器,GPU没有意义,省资源 |
| --no-sandbox | 关闭沙箱 | 服务器和容器环境常见,权限受限时不开会启动失败 |
| --disable-dev-shm-usage | 改用临时文件存储 | 防止/dev/shm空间过小导致页面崩溃 |
| --window-size=1920,1080 | 指定窗口大小 | 影响页面渲染布局,窄屏会漏数据 |
| --user-agent | 覆盖UA字符串 | 避免被识别成无浏览器头的爬虫 |
这里面有两处容易被忽略的细节。
第一处是--headless=new而不是旧版--headless。Chrome从109版本之后推荐新版无头模式,旧模式在一些场景下存在渲染不完整、WebGL不支持等问题。51job这类依赖大量交互脚本的站点,新版无头模式渲染出来能和有头模式保持一致。
第二处是--window-size。无头模式默认窗口尺寸是800x600,很多网页会根据视口宽度做响应式裁剪,窄屏下职位卡片可能隐藏部分字段,甚至不加载列表。把窗口设为1920x1080,能确保页面按桌面版完整渲染,后续脚本解析字段才能稳定存在。
把这些参数拼成Python配置就长这样:
options.add_argument("--headless=new") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") options.add_argument("--window-size=1920,1080") options.add_argument( "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" )2.3 先有头后无头的调试节奏
这里分享一条我个人坚持的调试经验:哪怕是最终要跑无头模式的项目,我也一定先在“有头模式”下把脚本跑通,再切无头。原因很简单,有头模式下你能亲眼看到浏览器打开了哪个页面、加载到了哪一步、元素有没有出现。一旦切到无头,所有画面都变成了黑盒,出问题只能靠日志和print输出排查,效率低得多。
具体做法是:写代码时先不写--headless=new那行参数,直接默认启动有头浏览器,跑一段逻辑,确认页面加载、字段提取、翻页都正常了,再把无头参数加回去。很多人一上来就直接无头,结果元素定位报错,又看不到页面,只能猜原因,折腾半天。这个小习惯能帮你省掉一堆调试时间。
3. 核心代码拆解:从页面加载到数据落表
3.1 页面加载与等待策略
用Selenium操作浏览器时,最容易踩坑的点就是页面还没加载完就去抓元素。51job的职位数据是异步渲染的,你driver.get(url)之后,页面可能只加载了框架和导航,职位列表还在等待接口请求返回。此时直接find_element基本必挂NoSuchElementException。
解决办法是使用显式等待:通过WebDriverWait设置一个超时时间,期间不断轮询页面,直到目标元素出现,再继续执行;如果超过时间还没出现,就抛超时异常。相比sleep硬等,显式等待的好处是能动态适配网络状况——网络快的时候马上往下走,网络慢的时候多等一会儿,不会因为固定等待时间不足而失败。
我在核心逻辑里等待的是.joblist-item这个职位列表容器,它出现基本代表职位列表已经渲染出来了:
WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CSS_SELECTOR, ".joblist-item")) )这里选择presence_of_element_located而不是visibility_of_element_located,是因为列表项只要出现在DOM里,就能被定位和提取了,不必等它完全可见。如果你要拿的元素是页面滚动后才出现的,那就得配合滚动触发,这个后面会讲。
3.2 职位卡片字段解析
页面加载完,接下来就是把职位信息从卡片里提取出来。这一步的思路是:先按选择器拿到所有职位卡片元素列表,再逐个解析子元素。选择器的命名在页面改版时会变化,但结构通常类似——职位名有专属class、公司名有独立区域、薪资有格式化文本。
我把解析方法封装成一个函数,并用safe_find做兜底:单个字段解析失败时返回空字符串,而不是让整个脚本崩溃。这个细节很重要,招聘页面上偶尔会出现数据不完整的卡片,比如没有薪资、没有地点,如果直接抛异常,整个循环就废了。
def safe_find(item, selector, default=""): try: return item.find_element(By.CSS_SELECTOR, selector).text.strip() except Exception: return default def parse_job_item(item): return { "职位": safe_find(item, ".jname"), "公司": safe_find(item, ".cname"), "薪资": safe_find(item, ".salary"), "地点": safe_find(item, ".area"), "经验学历": safe_find(item, ".tags"), }需要特别说明的是,这些class名是根据当时页面版本总结的常见写法,页面改版后可能失效。在实际操作中,你应该打开浏览器F12,找到职位卡片的DOM节点,把对应class或属性替换进去就行。掌握“定位元素的思路”比死记选择器更重要。我的习惯是:优先用class名定位,如果class名不唯一,再配合层级或属性选择器。
3.3 翻页逻辑与停止条件
51job搜索结果列表是分页展示的。翻页我一般有两种写法。
第一种是直接改URL参数。观察浏览器地址栏可以发现,搜索结果页的路径后面带有keyword和分页参数,比如keyword=python&pageNum=2。我只要在循环里改变pageNum的值,就能逐页加载。这种方式的好处是逻辑简单、稳定,不需要去找“下一页”按钮。
第二种是模拟点击“下一页”按钮。如果网站页面结构复杂、URL分页参数不好直接构造,或者需要在数据流里触发某些脚本,那就用Selenium点击分页器里的下一页按钮。点击后同样要用显式等待确认新一页列表加载出来,再继续解析。
这两种方式在实际项目中我推荐第一种,原因只有一个:可控性更强。点击方式受页面元素状态影响,按钮被禁用、被遮挡、被拦截都可能导致点击无效。而循环构造URL,只要参数对,每次get都是全新页面加载,状态干净。
翻页停止条件也要想清楚。最简单的方式是让代码里的max_page控制总页数。在示例里,我在构造URL之前判断当前页码是否超过设置的最大值。更严谨的做法是检查下一页按钮是否处于禁用状态:每次翻页前先读按钮的disabled属性,没有再翻。不过这个判断需要具体按钮元素,代码耦合度更高,对新手来说先控制总页数就够了。
3.4 数据导出与字段清洗
采集到的数据最后要落盘保存。我用pandas把列表转成DataFrame,再导出CSV。一个容易踩的坑是编码问题。直接用df.to_csv("jobs.csv")默认保存为UTF-8编码,但Excel默认用GBK打开,结果中文全变乱码。
解决办法是导出时指定编码为utf-8-sig,它会自动在文件头加上BOM标记,让Excel正确识别UTF-8编码:
df.to_csv("51job_jobs.csv", index=False, encoding="utf-8-sig")字段清洗也要顺手做。比如薪资字段可能是“1.5-2.5万/月”,如果要做统计分析,最好拆出最低、最高值;经验学历字段可能是“3-4年经验 | 本科”,也需要拆开。这些清洗逻辑可以根据自己的分析目标去扩展,在示例代码里我保留了原始字段文本,方便后续按需处理。
4. 实战中踩过的坑:无头模式四大翻车现场
4.1 ChromeDriver版本不匹配,启动即报错
这是Selenium玩家最常见的启动期崩溃。报错信息通常是SessionNotCreatedException,提示ChromeDriver只支持特定版本的Chrome浏览器。我以前手动管理驱动时,每隔一阵就要去更新一次,很烦。后来换成webdriver-manager之后,这个坑基本消失,因为它在启动时会自动匹配并下载对应版本。
如果因为网络环境等原因还是想手动下载,记住一个原则:驱动主版本号必须和浏览器主版本号一致。比如Chrome版本是128,就找128.x的ChromeDriver,尽量不要让主版本之间有偏差。
4.2 一进无头模式就找不到元素
“有头模式跑得好好的,切到无头就报错找不到元素”,这个问题很有代表性。原因通常不是无头模式本身,而是无头模式下的默认运行环境和有头模式不一样。
最常见的是窗口尺寸问题。无头模式默认视口只有800x600,响应式布局直接变成移动版,移动版的职位卡片DOM结构和桌面版完全不同。解决方案就是我前面反复强调的--window-size=1920,1080,手动把窗口撑大。
其次是懒加载问题。职位列表可能分成两段加载,页面刚渲染完只有前几个卡片,往下滚动才加载剩下的。所以我会在页面加载后主动执行脚本滚动到底部,再等一秒,让后续数据渲染完成。
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") time.sleep(1)把这两个点处理好,无头模式下找不到元素的问题能解决一大半。
4.3 页面加载慢、超时和假死
Selenium控制浏览器时的页面加载时间受网络影响很大。如果某个元素在15秒内一直没出现,显式等待会抛出TimeoutException,整个程序就中断了。我有两种思路处理。
第一种是设置page_load_timeout,例如设成30秒,防止页面加载过长导致脚本挂死。第二种是更细致的异常捕获:在等待元素的地方包一层try-except,超时后记录日志并跳过当前页,继续下一页。这比一超时就把整个程序终止掉要稳健得多,特别是批量采集多个关键词时,单页失败不应该影响整体进度。
我还遇到过浏览器进程假死的情况,代码表面上看卡在某一步,没有任何异常,就是不往下走。针对这个,最直接的办法是定期“换页”——每解析完一页主动重新发起driver.get,而不是依赖点击翻页,新页面加载会重置浏览器内部状态。这也是我推荐用URL翻页的另一个原因。
4.4 访问频率、User-Agent与合规底线
如果你把爬虫当成一个长期运行的任务,控制请求频率是保命的底线。请求频率过高会触发服务的限流,甚至有更大风险。我平时会用random.sleep(2, 4)做随机延迟,模拟真实用户浏览节奏,避免短时间高频访问。
除此之外,user-agent也建议改一下。Selenium默认请求头里带有HeadlessChrome特征,有些站点会根据UA特征对无头浏览器做限制。覆盖成一个常见Chrome浏览器UA,至少能减少一部分被提前识别的概率。
这里必须把合规的事情讲清楚:采集公开职位信息作为个人学习分析和市场调研,属于常见技术实践,但使用数据时必须尊重平台的用户协议和robots约定。数据只用于个人分析,不去批量转录、转卖,不涉及用户隐私字段,也不对目标服务造成明显压力。我在代码里加入了访问间隔和合理的分页上限,也是对这种边界的一种尊重。
5. 完整源码与使用建议
5.1 完整可运行代码
把前面所有要点整合起来,就是一份可以直接运行的完整代码。我建议先在有头模式下跑通,确认选择器和字段都对了之后,再切换到无头模式批量采集。
import time import random import pandas as pd from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager def create_headless_driver(): options = Options() options.add_argument("--headless=new") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") options.add_argument("--window-size=1920,1080") options.add_argument( "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" ) return webdriver.Chrome( service=Service(ChromeDriverManager().install()), options=options ) def safe_find(item, selector, default=""): try: return item.find_element(By.CSS_SELECTOR, selector).text.strip() except Exception: return default def parse_job_item(item): return { "职位": safe_find(item, ".jname"), "公司": safe_find(item, ".cname"), "薪资": safe_find(item, ".salary"), "地点": safe_find(item, ".area"), "经验学历": safe_find(item, ".tags"), } def scroll_to_bottom(driver, times=2): for _ in range(times): driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") time.sleep(random.uniform(0.5, 1)) def crawl_51job(keyword, max_page=3): driver = create_headless_driver() driver.set_page_load_timeout(30) all_data = [] try: for page in range(1, max_page + 1): url = f"https://we.51job.com/pc/search?keyword={keyword}&pageNum={page}" print(f"正在抓取第 {page}/{max_page} 页") driver.get(url) WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CSS_SELECTOR, ".joblist-item")) ) scroll_to_bottom(driver, times=2) items = driver.find_elements(By.CSS_SELECTOR, ".joblist-item") page_data = [parse_job_item(item) for item in items] all_data.extend(page_data) print(f"第 {page} 页解析完成,拿到 {len(page_data)} 条数据") time.sleep(random.uniform(2, 4)) finally: driver.quit() return all_data if __name__ == "__main__": results = crawl_51job("Python", max_page=3) df = pd.DataFrame(results) df.to_csv("51job_jobs.csv", index=False, encoding="utf-8-sig") print(f"共采集 {len(df)} 条职位记录,已保存至 51job_jobs.csv")几个使用要点再补充一下。
第一,选择器不是永久的。51job前端改版时,职位卡片的class名很可能发生变化。脚本跑不通第一步,先开F12看新版页面结构,替换选择器即可。
第二,首次运行webdriver-manager会自动下载ChromeDriver,需要几十秒网络时间。之后再去抓页会快很多。
第三,如果你是服务器环境,里面没有装Chrome,只有Chromium或者其他浏览器,把webdriver.Chrome换成对应的浏览器驱动即可。
5.2 使用建议与后续扩展
脚本跑通只是第一步,真正常用的是把它扩展成一个稳定的采集任务。几个方向供参考。
第一是断点续采设计。如果一次采集几千条数据,中途网络抖动或者服务器重启,已经解析完的数据都会丢。可以在每解析完一页后,把数据增量写入CSV或数据库,而不是等全部结束再一次性导出。这样失败了也能接着跑,不浪费前面的进度。
第二是去重机制。翻页翻多了或者重复跑脚本,同一个职位可能被抓多次。可以根据职位ID或者“公司+职位+地点”作为唯一键,导入时去掉重复记录。这也是数据分析里的基本功,数据质量比数据量重要得多。
第三是数据可视化。职位数据到手之后,可以按城市统计岗位数量、按薪资区间做直方图、按经验要求画堆积条形图。这一整套做下来,原本只是“爬虫”的脚本就变成了一个挺完整的招聘市场分析工具。我之前做完这一步,把结论整理给身边做运营规划的朋友参考,大家都觉得比一个个翻页面靠谱得多。
再往后,如果数据量需求变大,还可以把Selenium换掉,用更底层的浏览器控制方案来提升采集效率。但那是另一个话题了,先把动态页面的无头方案玩明白,遇到别的动态站点也能举一反三。
最后说点个人体会。我最初抓51job的时候,也抱着“能不能纯requests搞定”的执念,后来撞了几次墙才发现,技术选型最忌讳的就是为了炫技而硬上某一种方案。API能用就优先用API,页面确实复杂、没有公开数据通道的时候,再拿出Selenium无头模式这种“重武器”。它不完美,速度慢、吃资源,但能把数据从一个动态站点里稳定地挖出来,这就够了。
最后再送一个小技巧:调试无头爬虫的时候,先去有头模式把页面走通,再切无头跑批量。这一步能帮你把无数个“看不见的报错”变成“看得见的逻辑问题”。希望这篇实战记录能让你少走点弯路,搞定自己的动态页面采集需求。