news 2026/10/20 8:42:31

Selenium无头浏览器实战:搞定51job动态页面数据采集

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Selenium无头浏览器实战:搞定51job动态页面数据采集

前阵子需要研究技术岗位的市场行情,数据源锁定了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无头模式这种“重武器”。它不完美,速度慢、吃资源,但能把数据从一个动态站点里稳定地挖出来,这就够了。

最后再送一个小技巧:调试无头爬虫的时候,先去有头模式把页面走通,再切无头跑批量。这一步能帮你把无数个“看不见的报错”变成“看得见的逻辑问题”。希望这篇实战记录能让你少走点弯路,搞定自己的动态页面采集需求。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 8:42:28

LeGO-LOAM全面解析:地面优化与轻量化激光SLAM的工程实践

LeGO-LOAM 这篇论文,我前后读了不下三遍,每次在自己做的机器人平台上遇到定位漂移、建图抖动这类问题,都会翻回来重新对照一遍它的设计思路。论文全称是Lightweight and Ground-Optimized Lidar Odometry and Mapping on Variable Terrain&am…

作者头像 李华
网站建设 2026/10/20 8:41:32

OpenClaw机械爪实战课:STM32嵌入式闭环控制从0到1

简介:本资源是一份面向AI开发者与技术讲师的《AI Agent与OpenClaw实战》结构化课件,聚焦人工智能在自动化任务执行中的落地实践,尤其适配教学授课、内部技术分享及本地Agent系统入门学习。课件以AI Agent原理为起点,系统拆解OpenC…

作者头像 李华
网站建设 2026/10/20 8:41:11

免安装拖拽式多软件一键部署工具实战:Tauri+Scoop构建方案

简介:这是一款面向IT运维人员、系统管理员及批量装机需求用户的免安装型多软件一键部署工具,专为解决新系统快速配置常用软件、办公套件或开发环境组件的重复性操作问题。工具无需安装依赖或配置Python环境,双击main.exe即可运行,…

作者头像 李华
网站建设 2026/10/20 8:41:06

多通道打断逻辑实战:基于Fay的数字人对话系统优化

1. 从一次翻车现场说起前阵子调试一个数字人项目,我用的是Fay这个开源框架做交互底座。功能都跑通了,唯独一个场景反复出问题:我把数字人挂在展厅里,用户一边听它讲解一边随口问问题。结果数字人像是没听见一样,自顾自…

作者头像 李华
网站建设 2026/10/20 8:40:47

LeetCode 71简化路径:用栈模型攻克路径字符串规范化

刷 LeetCode 的人迟早会在字符串和栈的分水岭碰到这道leetcode71:简化路径。说实话我第一次做这道题的时候,觉得它不过是个字符串处理的小题,结果用正则、replace 一顿操作,最后被连续斜杠和..的组合拳打蒙了。这道题表面是路径字…

作者头像 李华
网站建设 2026/10/20 8:40:38

从语音识别到会议纪要:用腾讯云API搭建自动转写工具

1. 为什么我盯上了"会议小帮手"这个需求先说个真实场景。我平时要参加不少技术评审会和需求对齐会,一场会短则四十分钟,长则一个多小时。会上大家你一句我一句,信息密度其实很高,但真正落到"谁负责什么、什么时候交…

作者头像 李华