做爬虫的朋友应该都有过这种经历:requests一把梭把网页HTML拿回来,XPath或者BeautifulSoup一解析,结果发现数据全是空的,或者干脆整个页面的结构跟你预想的完全不一样。这不是你代码写错了,而是你碰到了一类非常典型的页面——数据根本不是写在原始HTML里的,而是浏览器执行完JavaScript之后才动态生成的。这类页面背后通常是一套SPA架构,或者至少用了Ajax异步加载的渲染方案,纯requests根本拿不到东西。这时候,最稳的兜底方案就是标题里说的这个组合:Selenium。
这篇文章我想把“用Selenium处理JavaScript渲染页面”这条链路完整聊一遍:先说清楚为什么现在有这么多页面要靠JS渲染,再把Selenium怎么搭、怎么写、怎么避开那些坑都过一遍,最后用一个真实可复现的案例把整个流程串起来。不管你是刚开始学爬虫的新手,还是已经在用requests、Scrapy跑项目但被动态页面卡住的老手,这篇都值得耐心看完。我尽量不写废话,该给代码给代码,该讲原理讲原理,读完你就可以照着落地。
1. 内容整体设计与思路拆解
1.1 为什么现在越来越多页面要被JavaScript渲染
早期的Web页面是这样的:服务器端把整张HTML拼好,里面直接带着所有数据,浏览器拿到HTML解析渲染就行。那时候爬虫很简单,requests去打接口,拿回来的HTML源码里就有你想要的内容。
后来前端框架React、Vue、Angular大规模普及,页面的开发模式从“服务端渲染”变成了“客户端渲染”。举个例子,你打开一个网页,服务器返回的HTML可能长这样:
<div id="app"></div> <script src="app.js"></script>整个页面就一个空壳,内容全靠app.js去请求后端接口,拿到数据之后在浏览器里渲染出真实的DOM节点。理解这个你就明白为什么直接抓HTML抓不到数据了:你没有执行JS,浏览器里那个“渲染后的DOM”根本不会出现。
从网站运营方的角度考虑,这么做有几个实际好处:页面响应更快、前后端分离开发效率高、可以做更多复杂的交互。有些网站甚至刻意把部分核心数据塞进JS逻辑里,或者在页面里搞一套复杂的加载流程,目的就是为了增加爬虫抓取的难度,保护自己的内容资产。作为爬虫开发者,遇到这类页面,你必须有一个办法让JS真正跑起来,再去提取数据。
1.2 处理JS渲染的三条路线,怎么选
面对这种动态页面,业内主流有几种思路:
第一种,直接逆向接口。打开浏览器的开发者工具,找到页面发起的Ajax请求,把请求参数摸清楚,然后直接用requests去调这个接口拿JSON数据。这是性能最好、最稳定的方案,因为接口返回的都是结构化数据,不用跟HTML解析较劲。但问题也很明显,很多接口会做加密参数、签名校验、风控验证,逆向成本高,而且接口结构一换就得跟着改,维护起来费精力。
第二种,用无头浏览器方案,比如Selenium、Playwright、Puppeteer。它们本质是启动一个真实的浏览器内核,帮你把JS执行完、把页面渲染好,然后再从渲染后的DOM里去取值。这种方案的好处是“所见即所得”,不管页面逻辑多复杂,只要浏览器能正常显示,爬虫就能拿到数据;代价是速度慢、资源占用高,比直接请求接口慢十倍甚至几十倍。
第三种,折中方案,用Selenium先拿到动态加载后生成的HTML或者接口数据,必要的时候配合requests去做并发。比如你在Selenium里登录,拿到Cookie后,把Cookie灌给requests,之后批量请求就交给requests去跑。
选型上我的建议很简单:能逆向到接口,优先逆向接口;逆向成本高、时间紧,就上Selenium兜底。Selenium不是最优解,但在很多场景下是最实用的解——它足够成熟,社区资料多,遇到问题基本都能查得到答案。
1.3 Selenium的工作原理:浏览器驱动怎么接管页面的
Selenium的核心是一个叫WebDriver的协议。你写的Python脚本通过Selenium客户端库,把指令封装成标准化的HTTP请求,发送给对应浏览器的驱动(比如ChromeDriver、Geckodriver),驱动再通过浏览器原生的自动化接口去控制浏览器执行操作。
这个过程相当于你在脚本里告诉浏览器:“打开这个网址”“往下滚动800像素”“点击这个按钮”“把这一段文字的textContent取出来”。浏览器完全按照指令执行,跟真人操作几乎没有区别。这也是Selenium能应对各种复杂JS渲染的原因——它执行的逻辑和用户行为一致,页面任何由交互触发的内容,它都能等到。
很多人会把Selenium和“爬虫”绑定在一起,其实它本身是一个通用测试工具,只是被爬虫领域用得最多。理解了这一层,你在调参数、排查问题的时候,思路会清晰很多:你是在操作一个真实浏览器,而不是在发HTTP包。
2. 环境搭建与Selenium核心概念
2.1 搭建最小可用环境,注意浏览器驱动版本
这一步没什么玄学,就是装库、下驱动、写个最小启动脚本。
pip install selenium然后去下载对应的浏览器驱动。Chrome用ChromeDriver,Firefox用Geckodriver。这里要重点提醒一个每个人都会踩的坑:驱动版本必须跟你本机浏览器的版本严格匹配。Chrome后来搞了个自动适配方案,你在命令行执行chrome --version看一下版本号,然后去ChromeDriver官网下载对应大版本号的驱动就行。版本不匹配的时候,启动会直接报SessionNotCreatedException,一看错你就知道怎么回事了。
下载驱动后可以放到项目目录里,也可以放到系统PATH下,然后在代码里指定路径:
from selenium import webdriver from selenium.webdriver.chrome.options import Options options = Options() options.add_argument("--window-size=1920,1080") driver = webdriver.Chrome(options=options) # 如果驱动不在PATH,传executable_path参数 driver.get("https://example.com") print(driver.title) driver.quit()跑起来之后,浏览器窗口会弹出来,打印出页面标题,说明你的环境已经通了。这一步是整个Selenium开发的基础,后面所有操作都是在这个driver对象上展开的。
2.2 元素定位:XPath里text()函数的妙用
页面加载出来之后,怎么拿到你想要的数据?Selenium提供了一整套元素定位方法,常用的包括find_element(By.ID)、find_element(By.CLASS_NAME)、find_element(By.CSS_SELECTOR)、find_element(By.XPATH)。
日常写爬虫,我用XPath最多,因为它的表达力最强,尤其适合处理各种复杂的DOM结构。这次热词里有人提到“python xpath爬虫 text函数”,我展开说一下:XPath里有一套专用于文本匹配的函数,最核心的就是text()和contains()。
text()用来取一个节点的文本内容,比如:
driver.find_element(By.XPATH, "//h1[text()='文章标题']")这个写法会定位到文本内容正好是“文章标题”的h1标签。更常用的是配合contains做模糊匹配:
driver.find_element(By.XPATH, "//span[contains(text(), '加载更多')]")这是Selenium处理动态加载场景的经典用法,按钮的文本是“加载更多”,你不想精确匹配整段文本,用contains做子串匹配就行。还有获取元素文本内容的场景,我经常写:
title = driver.find_element(By.XPATH, "//div[@class='article-title']//text()")如果你想把页面里某个区块下所有直接文本收集出来,XPath的text()配合//路径会很顺手。总之记住一个原则:能用稳定的属性(id、class)定位就用属性,属性不稳定或者元素靠文本区分时才用text()和contains组合。
2.3 等待机制:为什么不能用固定sleep
Selenium爬虫新手最容易犯的毛病,是定位不到元素之后第一反应加sleep(5)。固定休眠有致命短板:页面加载快的时候白白等5秒,加载慢的时候5秒可能还不够。而JavaScript渲染加载,尤其是异步请求场景,加载时间完全没有固定规律。
正确的做法是显式等待。Selenium的WebDriverWait配合expected_conditions,可以在指定时间内轮询页面状态,直到目标元素出现或者满足某条件:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By wait = WebDriverWait(driver, 10) element = wait.until( EC.presence_of_element_located((By.XPATH, "//div[@class='content']")) )这段代码的意思是:最多等10秒,每500毫秒检查一次页面,一旦出现class="content"的div就立刻返回,不浪费时间。除了presence_of_element_located,常用的还有visibility_of_element_located(元素可见)、element_to_be_clickable(元素可点击)、text_to_be_present_in_element(指定文本出现)。
还有一个隐性等待driver.implicitly_wait(10),它设置每次查找元素的全局兜底等待时间。我通常两个都用:隐式等待兜底,显式等待处理关键节点。但不要混合用同一套逻辑,否则会出现等待时间叠加、莫名其妙的超时问题。这块的经验后面在“踩坑实战”里再细说。
3. 实操案例:抓取一个JavaScript动态加载的列表页
3.1 先看页面结构,再写代码
说了半天概念,下面用一个完整的案例把整个流程串起来。假设我要从一个新闻类网站抓取文章列表,这个网站的前端用了Vue,首页HTML里只有一个空div,所有新闻条目都是页面加载后Ajax请求数据再渲染出来的。
第一步不是写代码,而是打开浏览器开发者工具按F12,干三件事:
- 看网络请求。切到Network选项卡,刷新页面,观察是否有XHR类型的请求,请求返回的JSON里是不是带有需要的数据。
- 看真实DOM。切到Elements选项卡,找到列表容器的结构,确认每条新闻在DOM里长什么样,用什么class包裹。
- 确认加载状态。如果数据是滚动加载的,需要模拟滚动;如果是点击“加载更多”,需要模拟点击;如果是进入页面就自动渲染完的,直接等元素出现就行。
这一步习惯很多人会跳过,但恰恰是最关键的。你连页面结构都没看清就写代码,写出来的定位表达式大概率是不稳定的。
3.2 完整的Selenium爬虫代码示例与逐段解读
下面这段代码模拟的是一个“进入页面就异步加载数据”的列表,我在本地用类似结构验证过逻辑,你可以根据自己目标页面的实际结构调整XPath。
import time 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 def main(): options = Options() options.add_argument("--window-size=1920,1080") # 想无头运行就放开下面这行 # options.add_argument("--headless=new") driver = webdriver.Chrome(options=options) wait = WebDriverWait(driver, 15) try: driver.get("https://example-news.com/list") # 等待列表项出现 wait.until( EC.presence_of_element_located((By.XPATH, "//div[contains(@class, 'news-item')]")) ) items = driver.find_elements(By.XPATH, "//div[contains(@class, 'news-item')]") results = [] for item in items: title_el = item.find_element(By.XPATH, ".//h2") summary_el = item.find_element(By.XPATH, ".//p[@class='summary']") link_el = item.find_element(By.XPATH, ".//a") results.append({ "title": title_el.text, "summary": summary_el.text, "link": link_el.get_attribute("href"), }) for r in results: print(r) finally: driver.quit() if __name__ == "__main__": main()这段代码有几个关键地方需要注意。
相对路径XPath的写法,我用了.//h2而不是//h2。这个圆点前缀非常重要:它表示从当前元素出发查找子元素,不加圆点的话,XPath会从整个文档根节点开始查找,很可能把所有h2都捞出来,导致每条新闻的标题全是一样的。在循环遍历列表项时,这是一个非常容易掉进去的坑。
取文本用.text属性,取链接用get_attribute("href"),这两个是Selenium最常用的取值方式。另外页面滚动后有些元素可能因为懒加载还没渲染出来,后续的操作中需要格外注意。
3.3 处理滚动加载和点击加载更多
很多JS动态页面不只是加载一次,而是用户向下滚动的时候不断追加数据,或者点击“加载更多”按钮才能翻页。这两种交互在Selenium里都有对应的处理方式。
滚动加载的思路是,不断让浏览器执行window.scrollTo,把页面滚到底部,触发新数据的加载。我之前写过比较实用的循环滚动代码:
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这个循环的原理很直白:把页面滚到底部,等一会儿,对比滚动前后的页面高度。高度变了说明有新内容加载出来,继续滚;高度没变说明到底了,退出循环。操作滚动加载页面时,用execute_script是最省事的方式,比模拟按键更快更稳。
点击“加载更多”按钮的话,核心是先找到按钮,然后循环点击直到按钮消失或者不再出现新的列表项:
while True: try: button = wait.until(EC.element_to_be_clickable((By.XPATH, "//span[contains(text(), '加载更多')]"))) button.click() time.sleep(1) except Exception: print("没有更多内容了") break解析这段逻辑你就能理解,为什么我不建议用固定sleep了:如果数据加载很快,1秒明显浪费;如果某次网络慢,1秒不够就会点到还没插入的按钮,导致元素状态事件报错。处理交互场景时,显式等待的优先级永远高于固定休眠。
4. 进阶玩法:隐藏特征、提速与分布式改造
4.1 Selenium特征隐藏:别让网站一眼认出你
正常用Selenium打开的浏览器,页面里执行JavaScript的时候,navigator.webdriver这个属性会被设置为true。不少网站的防爬脚本就是检测这个属性,一旦发现就直接拦截,甚至把你拉进黑名单。这类检测在反爬圈子里很常见,属于“浏览器指纹检测”里最基本的一环。
我一般会做几个基础的隐藏措施:
options = Options() options.add_argument("--disable-blink-features=AutomationControlled") options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option("useAutomationExtension", False)disable-blink-features=AutomationControlled可以移除一部分自动化控制痕迹;excludeSwitches=["enable-automation"]去掉地址栏下方的“Chrome正在受到自动软件的控制”提示。配合一个正常的User-Agent,大部分基础检测能过。
不同网站的检测手段不一样,有些会检查webdriver属性,有些会检查浏览器窗口尺寸、点击轨迹、时间延迟等行为指纹。这些都可以做更精细的规避,但说实话,这是一个猫鼠游戏,水太深,我也不建议新手一上来就奔着对抗去。写爬虫首先是数据合法获取,其次是控制访问频率,不要在反爬对抗里踩红线。
4.2 性能优化三板斧:无头模式、资源屏蔽、接口配合
Selenium最被人诟病的就是慢,一个页面动辄几秒,跑一千条数据猴年马月。优化手段主要有三个方向。
第一,无头模式。options.add_argument("--headless=new"),浏览器不在界面上显示窗口,能省下一部分渲染开销。但要注意,有些网站会检测无头模式,必要时需要配合别的参数调整。
第二,屏蔽不必要的资源加载。最典型的是禁止加载图片和CSS,这些资源对数据提取没帮助,却会拖慢加载。用ChromeOptions的prefs可以做到:
options.add_experimental_option( "prefs", { "profile.managed_default_content_settings.images": 2, "profile.managed_default_content_settings.stylesheets": 2, } )第三,让Selenium“干一半,分一半”。这是我在实际项目中收益最大的优化:用Selenium处理登录、拿到Cookie和必要的请求参数,然后转手交给requests,用requests的Session去批量请求接口或者渲染后的HTML。因为requests的数据获取速度比浏览器渲染快几十倍,只要Cookie保持有效,这种混合模式既绕开了登录难题,又绕开了Selenium的速度瓶颈。
我举一个实际例子。某个后台系统,数据都在接口里,但接口需要登录态和加密的请求头。我的做法是:用Selenium打开系统登录页,账号密码走正常流程,登录成功后从浏览器里把localStorage和Cookie提取出来,然后在requests.Session里手动把Cookie塞进去,打接口拿JSON。那一次跑下来的速度,比全员用Selenium快了差不多20倍。
4.3 Selenium在分布式爬虫里该怎么摆
分布式爬虫架构里,Selenium不是不能用,关键看你把它放在什么位置。如果你把Selenium当作主抓取引擎,每个任务都拉起一个浏览器实例,那对机器的内存和CPU都是灾难。我只会把真正需要渲染的任务交给Selenium。
常见的架构是:主节点负责任务调度,使用消息队列把待抓取URL派发给多个Worker,Worker里跑Selenium只负责渲染和提取初始数据,拿到数据后立刻关闭浏览器进程。遇到需要并发的情况,用多进程而不是多线程,因为Python的多线程面对Selenium这种IO密集场景收益有限,而且浏览器实例之间容易互相干扰。
这种做法能把需要JS渲染的页面规模化处理,但代价是机器成本。另一个思路是把Selenium封装成服务,部署在独立的浏览器渲染集群上,其他爬虫节点通过HTTP接口请求渲染服务,这样整个爬虫集群的复杂度会低很多。这个方向比较重,适合数据量级再往上走一步的场景。
5. 常见问题与排查技巧实录
5.1 高频报错速查表
Selenium开发里最耗时间的环节就是跟各种异常做斗争。我把这几年高频遇到的报错和解决思路整理成了一份速查表,仅供参考。
| 报错信息 | 触发原因 | 排查方向 |
|---|---|---|
SessionNotCreatedException | 驱动版本和浏览器版本不匹配 | 检查浏览器版本,更新对应版本的driver |
NoSuchElementException | 元素定位条件不满足,或页面还没渲染完 | 改用显式等待;打开F12确认元素在DOM的真实位置和属性 |
ElementNotInteractableException | 元素存在但不可见、被遮挡或处于禁用状态 | 先滚动到可见区域,或者等待element_to_be_clickable |
StaleElementReferenceException | 页面更新导致先前拿到的元素引用失效 | 把元素定位的步骤挪到操作之前,或者重新查询一次元素 |
TimeoutException | 显式等待超时,条件一直不满足 | 检查选择器是否正确;确认页面是否真的会渲染出该元素 |
InvalidArgumentException | 调用方式错误,传参类型不符 | 检查传入的是By.XXX还是字符串 |
这张表里StaleElementReferenceException值得多聊一句。这个异常在我处理动态页面时特别常见,根本原因是:页面在某个时刻发生了DOM更新,之前定位到的元素对象已经指向一个不存在的节点,再去调用它就会报错。解决思路是不要久存元素引用,需要操作的时候重新find_element一次。
5.2 我踩过的几个真实坑
第一个坑,是关于等待条件用错导致偶发超时。有一次我写爬虫,用了presence_of_element_located等待元素出现,结果元素出现了但还没渲染完成,紧接着去点它,又报ElementNotInteractableException。后来我把条件换成了element_to_be_clickable,这个问题再没出现过。
第二个坑,是text属性取到了空字符串。刚开始以为是定位问题,排查了半天,发现页面是懒加载的,元素在视口外,text属性只返回了空字符串。解决办法是定位前先driver.execute_script("arguments[0].scrollIntoView();", element)把元素滚动到屏幕内。
第三个坑,是浏览器升级后导致整个任务全部失败。这个最无奈,非技术原因,纯粹是Chrome自动更新了,但Driver还停留在老版本,一把跑起来全挂。后来我养成了一个习惯:生产环境的爬虫代码,浏览器自动更新必须彻底关闭,或者用一个锁定版本的专用浏览器实例,否则你永远不知道哪天任务会全体掉线。
5.3 关于合规使用的一点提醒
聊到这儿,有个话题还是想认真提一嘴。爬虫技术的边界不在于工具本身,在于你用它的方式和目的。我处理这类项目时有几个底线:不抓取需要登录才能访问的个人隐私数据、不绕过网站的支付和权限校验、不抓取明确禁止爬取的内容、控制请求频率不给目标网站造成过大压力。
如果你的项目是为了学习、做研究、或者抓取自己有权使用的数据,那Selenium这套技术方案对你是完全正当的工具。如果你想把整个网站的数据拖下来做商业化使用,一定要去确认目标网站的robots.txt和用户协议。这不是场面话,是我踩过坑之后实实在在的体会。做技术的人,先学会守住边界,再谈技术有多强。
结尾想说的几句体己话
从第一次被动态页面折磨得抓狂,到后来熟练地用Selenium和多种方案配合处理各类渲染场景,我的经验其实就两条:能用接口解决的就不上浏览器,必须上浏览器的时候就老老实实用显式等待,别走捷径。Selenium是个好工具,但它不是银弹。你在实际项目里如果能把页面结构分析、等待策略、性能优化这三件事做到位,这套方案会非常可靠。每次跑任务之前,花三分钟打开开发者工具,看清楚数据到底怎么来的,再动手写代码,省下的时间比写代码本身还多。这个习惯我一直保持到现在,也算分享给所有读到这里的朋友。