☰
Selenium实战指南:解决JavaScript渲染页面爬取难题
2026/10/9 10:38:36 网站建设 项目流程

做爬虫做到后期,基本都会撞上同一种尴尬:requests写得很溜,正则和XPath也都顺手,结果拿到某个网站一看,response.text里干干净净,连数据的影子都没有。这不是你代码写错了,是页面压根没把数据放在初始HTML里——所有内容都是网页里的JavaScript脚本在你打开页面的瞬间去请求接口、拼装DOM、再填进去的。我自己的习惯是先从浏览器里按F12看一眼“网络”面板,如果发现大量XHR请求在页面加载之后才发起,那就基本可以判断这个页面需要渲染,光靠requests是搞不定的。

这篇文章就围绕“怎么用Selenium搞定JavaScript渲染的网页”来说。先说原理和选型,再给一套可以直接复用的代码骨架,然后拆几个实际开发中高频遇到的动态处理场景,最后把我和团队这两年踩过的坑整理成问题排查清单。适合已经会基础爬虫、现在正被动态页面折磨的朋友参考。

1. 为什么需要处理JavaScript渲染:requests的局限与动态网页的真相

1.1 动态页面的真相:浏览器里到底发生了什么

一个现代网页在你浏览器里显示出来,至少要经历这么几步:浏览器先拿到一份HTML骨架,然后按里面的script标签去加载JavaScript文件,JS执行之后再去请求后端接口(也就是XHR或fetch),拿到JSON数据之后,再通过DOM操作把数据渲染成你看到的文字、图片、表格。整个过程里,初始的HTML可能只是一个空壳,真正有价值的数据全部发生在“JS执行之后”。

requests这类库的本质是什么?是模拟浏览器发一个HTTP请求,拿回服务器响应的原始内容。它不会执行JavaScript,不会等异步回调,也不会帮你做DOM渲染。所以当页面里的数据是JS动态拼出来的,你requests到的HTML里自然什么都没有,只能在那一堆script代码里看到几个接口地址,拿不到实际内容。

打个简单的比方:requests是直接去饭店后厨拿菜单看一眼原材料,而浏览器是等厨师做完菜、端上桌之后你再动筷子。动态页面的数据,往往是“端上桌”之后才出现的,你要么自己照着菜单(接口列表)手动做一遍(模拟请求),要么就让浏览器把整桌菜端出来你再直接吃(渲染页面)。

1.2 requests的短板和哪些页面必须上Selenium

在实际项目里,我判断一个页面要不要上Selenium,基本看三个特征。

第一个是懒加载。典型表现是页面滚动到底部才加载下一批数据,最直接的现象就是requests拿到的HTML里图片和列表项全是空的或者占位符。第二个是Ajax翻页,点击下一页时URL没变,页面局部刷新,这种用requests模拟非常麻烦,因为你得先逆向找接口、分析参数加密方式,成本往往比写个Selenium脚本还高。第三个是高度依赖用户交互的场景,比如先登录、再拖拽、再点击某个按钮才能看到的数据,这种业务逻辑本身就是一种“页面流程”,Selenium这种真实浏览器驱动的方式天然能覆盖。

当然,我并不是说Selenium是唯一解。很多情况下逆向接口才是更省资源、更高性能的方案。但如果接口参数加密复杂、验证逻辑多、或者你拿不到接口格式,那就别硬刚了,直接交付渲染方案。我个人的判断标准是:如果逆向接口的成本预计超过一天,而页面结构不复杂,那就直接上Selenium。后者虽然慢,但开发周期短、稳定性高、维护起来也直观。

2. Selenium原理与选型:让浏览器替你去“看”

2.1 WebDriver的工作方式

Selenium的核心机制是WebDriver。WebDriver不是Selenium自己发明的魔法,而是一套浏览器自动化的协议标准,相当于“遥控器”。它通过浏览器厂商提供的驱动程序(比如ChromeDriver、GeckoDriver)和浏览器真实通信:你写Selenium代码,告诉WebDriver“打开这个URL”“点击这个按钮”“把页面截图给我”,WebDriver把这些指令翻译成浏览器能懂的底层命令,然后浏览器真的去执行。

这里面有个关键点:Selenium操作的是真实的浏览器实例。也就是说,页面里的JavaScript是浏览器内核自己执行的,不是Selenium模拟的。你执行JS、发Ajax、改DOM、跳转页面,统统都是浏览器自己的行为,所以它在“对抗”动态渲染时,效果等同于人工操作浏览器。

现在做浏览器自动化,除了Selenium,还有Playwright和Puppeteer这两个选择。Puppeteer是Node.js生态的,主要绑定Chromium;Playwright是微软出的,支持多浏览器和多语言,API设计也更现代。为什么我这篇还坚持以Selenium为例?因为它在Python爬虫生态里最普及,网上资料多,团队协作时找人接手最容易。如果你是从零开始且没有历史包袱,也可以了解一下Playwright;但如果你用的是Python、项目里又要连老代码,Selenium依然是稳妥的选择。

2.2 环境搭建与驱动版本匹配

Selenium的环境搭建看起来简单,实际上最容易出幺蛾子的就是浏览器驱动版本不匹配。简单说,你的Chrome浏览器升了级,旧版ChromeDriver可能就驱动不了它了,运行时会直接报SessionNotCreatedException。

我现在的标准做法是两步走。第一步,直接用pip安装selenium库:

pip install selenium

第二步,不要手动去下载ChromeDriver,直接用第三方库webdriver-manager自动处理版本匹配。这个库会检测你本机浏览器版本,然后自动去下载对应的驱动,还能缓存起来,省得每次换版本都要折腾。

from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(service=service)

如果你所在的网络环境下载驱动比较慢,也可以手动去网站下载对应版本,然后指定路径:

service = Service("/path/to/chromedriver") driver = webdriver.Chrome(service=service)

这里有个我在实战中总结的版本对应关系:ChromeDriver的大版本号必须和Chrome浏览器的大版本号一致,比如浏览器是120.x,驱动也得是120.x,小版本可以略有出入。还有一点,如果你跑脚本在服务器上,服务器通常自带的是无图形界面的Linux环境,那就必须用无头模式,这个我在下一节详细说。

3. 手把手写一个可复用的渲染爬虫

3.1 启动配置:无头模式与稳定性调优

很多人第一次用Selenium,写出来的代码是能跑,但一部署到服务器就崩。原因十有八九是没处理无头模式和资源占用。我先把一套我一直在用的通用配置贴出来,再逐行解释关键点。

from selenium import webdriver from selenium.webdriver.chrome.options import Options options = Options() options.add_argument("--headless=new") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") options.add_argument("--disable-gpu") 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") options.add_experimental_option("excludeSwitches", ["enable-automation"]) driver = webdriver.Chrome(options=options) driver.set_page_load_timeout(30) driver.set_script_timeout(10)

这里面的参数逐个说。headless=new是无头模式,有的老教程写的是headless,新版Chrome推荐--headless=new,兼容性更好。no-sandbox和disable-dev-shm-usage是Linux服务器上必备的,否则经常报错,因为容器的共享内存太小;disable-gpu是为了避免GPU相关的崩溃,虽然浏览器现在已经是软件渲染为主,但加上这个参数在某些环境下能少很多坑。window-size很重要,很多页面会判断视口宽度来决定是否加载移动端页面,为了模拟桌面访问,我一般固定成1920x1080。user-agent设置成真实浏览器的默认UA,能挡掉一部分憨憨的爬虫检测。

set_page_load_timeout是页面加载超时时间,有些网站某个资源卡住了会导致整个脚本卡死,设个30秒就能及时跳过。set_script_timeout是执行JS脚本的超时时间,后面用execute_script的时候有用。

3.2 定位元素:find_element与XPath的选择

拿到渲染完成的页面之后,剩下的就是定位元素、提取数据。Selenium定位元素的方式很多,find_element和find_elements是最常用的两个方法。前者返回第一个匹配的元素,后者返回所有匹配的元素列表。选哪个看你需求,批量抓列表的时候用find_elements,点单个按钮的时候用find_element。

定位策略我基本只用两种:XPath和CSS Selector。CSS Selector比较简洁,比如div.product-card .title,适合结构清晰的静态页面。XPath虽然啰嗦,但灵活性碾压CSS,尤其是在结构混乱、语义模糊的动态页面里,XPath可以通过“包含文本”“找父节点”“按位置取元素”等方式绕过很多坑。

举个例子:你要找页面上“加载更多”那个按钮,但这个按钮的class是动态变化的,只有英文文本是“Load More”稳定不变。用CSS基本没法写,用XPath就很简单:

button = driver.find_element(By.XPATH, "//button[contains(text(), 'Load More')]")

contains(text(), ...)可以在XPath里做模糊匹配,对付那些“总是带个数字角标”的按钮非常有效。还有一个我经常用的组合://div[contains(@class, 'product')]//span[@class='price'],意思是先定位所有包含product关键词的div,再在它们下面找价格span,这样定位每一行商品数据非常自然。

用XPath定位之后,提取数据我一般优先拿元素的text属性,而不是直接解析HTML字符串。因为Selenium的element.text拿到的是浏览器渲染之后“真正可见的文字”,省去了清洗一堆隐藏标签的麻烦。如果你更习惯用XPath直接提取文本,也可以用element.find_element(By.XPATH, ".//span").text这种写法,注意//前要加个点,表示在当前元素内部查找。

3.3 等待机制:别再用死等

新手最容易犯的错是定位不到元素就直接time.sleep(5)硬等。我不推荐这种做法,原因很简单:网络快慢是动态的,固定等待要么等久了浪费大量时间,要么等短了就飘。正确做法是使用Selenium的显式等待WebDriverWait。

显式等待的核心思路是“轮询直到满足某个条件,否则超时”。我举两个高频场景。

场景一,等某个元素出现:

from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC element = WebDriverWait(driver, 20).until( EC.presence_of_element_located((By.XPATH, "//div[@class='item']")) )

场景二,等某个元素可点击(比如弹窗确认按钮):

button = WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.XPATH, "//button[text()='确定']")) ) button.click()

还有一类更难搞的,有的网站先是显示一个加载动画,动画结束才出现真实数据。这时候如果只等元素出现,可能等到的是loading占位符。我通常的做法是配合EC.invisibility_of_element_located等加载动画消失,或者直接等我们真正关心的那个“内容节点”出现。判断“真正关心”的节点,标准看数据是不是已经渲染进去了,比如某个容器内的子元素数量大于0。

用显式等待还能顺带解决页面渲染太快和太慢的矛盾:等不到就超时报错,等到了就立即往下执行,不用人为估时间。

3.4 获取渲染后的页面数据

整个页面渲染完成之后,如果你想自己做解析,可以用driver.page_source拿到当前完整的HTML源码,然后再丢给BeautifulSoup处理。这个方式的优势是可以用你熟悉的BeautifulSoup的API,劣势是Selenium拿到的源码和你在浏览器开发者工具里看到的DOM可能略有差异,因为页面又额外执行了脚本更新了DOM。

如果你想拿的是JSON数据,也可以继续用Selenium执行JS去读取某个全局变量或者接口返回的数据。我用过一种思路:在页面里翻页、滚动、点击之后,直接执行JS把某个接口响应缓存从全局变量里捞出来。这一步放到下一章的execute_script里细讲。

另外提醒一下,page_source拿到的是“此时此刻的DOM”,不是初始HTML。你要是想保存“刚打开没等渲染”的版本,必须在浏览器发出请求后立刻获取,这个意义不大,因为我们要的就是渲染后的内容。所以不要纠结源码差异,重点永远放在你要提取的业务数据上。

4. 处理动态内容的几种硬核实战技巧

4.1 无限滚动加载

现在很多信息流页面不做翻页按钮,滚动到底部自动加载下一批数据。Selenium处理这种场景的思路很直白:模拟滚动,触发加载,等待新元素出现,再统计数量。

last_height = driver.execute_script("return document.body.scrollHeight") while True: driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") time.sleep(2) new_height = driver.execute_script("return document.body.scrollHeight") if new_height == last_height: break last_height = new_height

我简单说下这段代码的逻辑:先记录当前页面的滚动高度,然后滚动到底部触发加载,等几秒让页面渲染出新的内容,再重新读取滚动高度。如果高度没变,说明没有新的内容加载出来了,循环结束。time.sleep(2)在这里是有意的,因为它要等的是一个“异步过程”,不是某个元素出现,所以显式等待不太好写,我就用短停顿轮询。

有一点要注意:如果页面存在“加载更多”按钮而不仅是滚动加载,你可以把滚动操作替换成按钮点击,然后加上显式等待。还有一些页面滚动到最低端时会出现遮罩层和弹窗,需要在滚动之后做个判断,如果出现了弹窗就关掉再继续。

另外,滚动加载很容易触发反爬限流,建议每滚动几次就随机停顿一下,不要保持固定频率,像人操作一样有快有慢,请求频率更自然。

4.2 iframe里的内容怎么抓

还有一种动态页面让人抓狂:数据在一个iframe子框架里,你直接在driver.page_source里搜根本搜不到,定位元素也报找不到。这是因为iframe是独立的文档,Selenium默认操作的是主文档,必须先把上下文切进去。

iframe = driver.find_element(By.XPATH, "//iframe") driver.switch_to.frame(iframe) # 此时可以正常查找iframe里面的元素 data = driver.find_elements(By.XPATH, "//div[@class='inner']") # 用完切回主文档 driver.switch_to.default_content()

这里有个细节:iframe的定位和普通元素一样可以用XPath,但有些iframe加载是动态的,你切之前最好先等待它出现。切完之后,你要清楚当前在哪个文档里,如果iframe里又有嵌套iframe,还得一层一层切,千万别切乱了。

我处理iframe的一个习惯是,先花两分钟在浏览器里手动看下这个页面有哪些iframe,它们的id或者class是什么。只要能确定一个稳定的定位方式,代码就好写了;反而最忌讳上来就写//iframe不带任何筛选条件,页面里要是同时有好几个iframe,跟你期待的那个根本不是同一个。

4.3 弹窗和窗口切换

点击按钮触发新窗口的场景也常见,尤其是那种点击之后跳到一个新的Tab页展示详情,而且新页面还是异步加载的。这时候如果你直接driver.find_element去新页面的元素,会发现找不到,因为Selenium还在旧的窗口上下文里。

处理方法是先记录当前窗口句柄,点击新窗口后,切换到新窗口:

current_handle = driver.current_window_handle driver.find_element(By.XPATH, "//button[text()='查看详情']").click() WebDriverWait(driver, 10).until(EC.number_of_windows_to_be(2)) new_handle = [h for h in driver.window_handles if h != current_handle][0] driver.switch_to.window(new_handle)

number_of_windows_to_be(2)是等新窗口出现,这是处理这类问题的关键。如果你不等就直接去取window_handles,很可能取到的还是旧的那一个。

还有一种弹窗是JavaScript的alert框,它和HTML元素不一样,普通定位是抓不到的,要用driver.switch_to.alert处理,然后可以调用它的accept或dismiss方法。老实说,这种alert弹窗在现在的互联网页面上已经比较少了,但偶尔在旧系统或者一些后台管理系统里还是会碰上。

4.4 直接执行JS获取渲染数据

Selenium有一个隐藏利器:execute_script。它可以让你在浏览器的当前页面上下文中执行任意JavaScript代码,这意味着你可以直接读取JS全局变量、修改DOM属性、触发自定义事件,还能拿到一些常规定位方式很难获取的数据。

比如有些页面把渲染数据放在一个全局变量里,比如window.__INIT_STATE__,里面包含了完整的JSON数据。如果用XPath去逐个提取,可能得写几十行代码;直接一条JS把它取出来,方便很多:

data = driver.execute_script("return window.__INIT_STATE__;")

拿回来之后你会发现,这往往是一段JSON字符串或者一个对象,用Python的json模块或者json.loads处理一下就变成了字典列表,后续想怎么清洗就怎么清洗。

execute_script还有一个用途:动态触发事件。有的按钮不是普通click能触发的,比如React里的事件绑定比较复杂,你直接.click()没反应;这时候用element.click()配合execute_script("arguments[0].click();", element)往往就能顺利触发。这个技巧在对付某些用框架写的后台页面时非常管用,我把它列为“爬虫必会的救命招数”之一。

5. 高频踩坑问题与排查实录

5.1 元素找不到:先分清楚In Web和In DOM的差异

大家最常遇到的就是NoSuchElementException。不少人的第一反应是加等待时间,但我要说的是,先别急,按照下面这个顺序排查。

第一步,确认你要找的元素是不是在iframe里,如果是,先切框架。第二步,确认元素是不是在新窗口里,如果是,先切窗口。第三步,确认元素是不是隐藏状态的,有些元素要等展开后才存在;第四步,复制控制台里元素的XPath,在Selenium代码里试试。

最后,还要检查一个非常隐蔽的问题:页面里有没有多个相同文本的元素。XPath的text()匹配到多个值时,find_element默认只取第一个,往往不是你要的那个。这种情况建议把XPath写得更精确一点,比如加上层级限定,或者直接用find_elements遍历筛选。

我自己的习惯是,在写定位脚本之前,先花几分钟用浏览器开发者工具确认一下页面结构,而不是急着写代码。页面结构看得越清楚,后面调脚本的时间就越少。

5.2 等待失效和超时:别把锅全甩给Selenium

有些时候你已经用了WebDriverWait,也设置了20秒,但元素就是一直不出来。这时候不一定是等待机制的问题,可能是这个页面的加载策略和你预期不同。

一种常见情况是页面初加载特别慢,甚至白屏好几秒钟,这时候你就算怎么等,元素也还没生成。解决办法是启动参数里加上set_page_load_timeout,让页面加载阶段不要卡死;同时把等待条件换成“页面某个基准元素先出现”,再基于它等目标元素。

另一种情况是页面元素一直存在,但被遮罩层盖住,导致点击不了。这种用element_to_be_clickable等待时,Selenium会检查元素是否可见和可点击,如果被挡住就会一直等。这种时候可以先判断页面有没有遮罩,有就先把遮罩关掉,或者用execute_script强制触发点击。

还有,注意显式等待的超时时间不要设得太长。我一般主体逻辑用15到20秒,超过这个时间基本可以判定页面结构变了,而不是渲染太慢。宁可让它超时报错,也不要让单页等待两三分钟拖垮整个采集任务。

5.3 资源占用与稳定性:防止脚本跑着跑着就崩

Selenium开着真实浏览器,内存和CPU的消耗比普通请求大得多。如果爬虫要跑大量页面,长期不退出浏览器实例,内存会被占满,最后进程被系统杀掉或者浏览器无响应。这里我有一个实用的管理方式:每处理完一批URL,就主动清理页面缓存,并且定期重启驱动。

# 定期清理缓存 driver.delete_all_cookies() driver.execute_script("window.localStorage.clear();") driver.execute_script("window.sessionStorage.clear();")

如果任务本身跑得很久,可以考虑在一个采集任务中循环创建新的浏览器实例,比如每抓100个页面就退出重启一次。虽然创建实例有开销,但稳定性提升非常明显。而且很多网站对长时间同一个浏览器实例的采样行为比较敏感,定期换新实例反而能降低被识别和限制的概率。

另外,无头模式也不是一定就不会崩。如果是在Linux服务器上跑,建议加上--disable-dev-shm-usage参数;如果容器内存本身就小,尽量把浏览器并发实例控制在两三个以内,不要贪多。我以前就遇到过开了8个Chrome实例直接吃满服务器内存的情况,后来限制成3个,任务总时长反而因为稳定性上去了。

5.4 自动化特征识别:做一个低调的“访客”

有些网站会检测你是不是自动化工具,常见的特征包括浏览器窗口里显示了“Chrome正在受到自动测试软件的控制”这种提示条、window.navigator.webdriver属性为true、检测你的鼠标轨迹是否太规律等等。

我能分享的规避思路是合规范围内的常规做法。一是启动参数里排除自动化提示:

options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option("useAutomationExtension", False)

二是设置一个正常的User-Agent。三是尽量模拟人类操作节奏,点击、滚动之间加一点随机间隔,不要每次间隔都一样。至于更加复杂的手段,比如打补丁、改指纹这些,我不建议也不展开聊,一方面这些操作可能违反使用条款,另一方面也会增大被反制的风险。

其实更重要的还是心态:爬虫这件事,技术只是其中一环,合规才是底线。大规模采集前一定要先看清楚目标网站的服务条款和robots协议,控制请求频率,不要给目标服务器造成压力。做爬虫是为了获取信息、提升效率,不是为了打一场攻防战。

收尾

最后还是分享一个我个人的体会:Selenium这类工具,入门容易,精通难,难就难在“页面永远在变化”。今天你写好的XPath,下周网站改版可能就全部失效,所以代码结构一定要留好维护余地和日志记录。我自己的做法是把定位策略尽量写得“宽容”一些,比如能用contains的就不要用绝对文本匹配,能用相对路径的就不要用绝对路径,这样页面小改动时脚本还能继续跑。

另外,如果你的爬虫任务量特别大,Selenium并不是最优选择,它更适合中小体量、强交互逻辑的场景;真到了大规模采集那一步,还是要去分析接口、做分布式爬虫。但作为一整套动态页面的处理方案,Selenium依然是我现在遇到“什么都抓不到”的页面时,最想先试的那把锤子。希望这篇文章能让你少踩几个坑,把时间花在更有价值的数据分析上。

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

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

立即咨询