写爬虫最怕遇到什么?不是反爬策略有多变态,而是你用requests把页面源码拉回来,发现它和你浏览器里看到的内容完全是两个世界——列表是空的、数据不在HTML里、只剩一堆<script>和<div id="app">的空壳。这就是典型的JavaScript渲染页面。
解决这类页面的主力工具,我用了很长时间的还是Selenium。它解决的核心问题只有一个:让爬虫真正“看到”浏览器渲染之后的DOM,而不是原始源代码。这篇文章不聊基础语法,直接围绕JavaScript渲染这个场景,讲Selenium的实际用法、元素定位、等待机制和那些文档里不会告诉你的坑。适合已经能写requests爬虫、但一遇到动态页面就抓瞎的开发者。
1. 为什么静态爬虫搞不定JavaScript渲染页面
1.1 你看到的页面不是requests看到的页面
很多现代网页用的是Vue、React这类前端框架,数据根本不在服务端直接输出的HTML里。页面加载过程是这样的:服务端先吐出一个HTML骨架,浏览器拿到之后执行JS,再通过Ajax请求接口拿数据,最后在浏览器端把DOM节点动态创建出来。
requests做的事情是第一步就止步了——它拿到的是那个空骨架HTML。你打印response.text,里面可能只有几个root节点,真正的数据要么在接口里还没请求,要么是JS执行后才拼进DOM的。
生活化类比:requests像拿着外卖菜单上门的客人,只看得见厨房写在黑板上的“今日菜单”,但菜品得后厨现场做,你不坐进去等,就永远拿不到成菜。Selenium就是那个坐进餐厅的客人,不但能看到菜单,还能盯着后厨,等菜端上桌再动筷子。
1.2 Selenium是什么:给浏览器装一个“遥控器”
Selenium最初是自动化测试工具,原理是通过WebDriver协议控制真实浏览器执行操作。它启动一个浏览器实例(Chrome、Firefox、Edge都行),然后让浏览器自己完成所有JavaScript的加载、执行、渲染过程。等它执行完了,你再去读取页面里的DOM就是和肉眼看到的一模一样的。
和Puppeteer、Playwright对比一下,三者定位有细微差别。Puppeteer只支持Chromium,但API相当顺手。Playwright是微软家的,支持多浏览器、自动等待做得很好,近年风头很盛。Selenium最大的优势是老牌、成熟、资料多、踩坑答案满网都是,初学动态页面爬虫用它上手最稳。
| 特性 | Selenium | Puppeteer | Playwright |
|---|---|---|---|
| 浏览器支持 | Chrome/Firefox/Edge等 | 仅Chromium | Chrome/Firefox/WebKit |
| 上手难度 | 中等,资料多 | 较低 | 中等 |
| 自动等待 | 需手动配置显式/隐式等待 | 需自行处理 | 内置自动等待 |
| 适用场景 | 兼容性要求高、老项目、跨语言 | 偏Node.js技术栈 | 新一代自动化、录制回放 |
1.3 场景判断:什么时候必须上Selenium
不是所有页面都需要上Selenium,这个判断很关键。如果你打开开发者工具,Network面板里能看到一个JSON接口直接返回全部数据,那用requests直接请求接口是最高效的方案。很多网站的移动端API、内部接口就是这么暴露的。
真正必须用Selenium的场景有以下几种特征:页面数据由JS异步加载,且接口地址是动态拼接的;列表通过滚动懒加载,滚到底部才发请求;页面里的元素点击后才出现,且交互逻辑复杂;数据是用Canvas或WebGL渲染出来的,普通请求根本拿不到文字内容。
我之前抓过一个数据可视化大屏页面,数字全是Canvas画出来的,requests拿到的就是一张canvas标签,什么信息都没有。最后是用Selenium截屏加截图OCR才搞定。这种场景就是“高级爬虫”的日常——用最合适的工具解决最难啃的骨头。
2. 环境搭建:驱动、浏览器与第一个Selenium脚本
2.1 从pip install到WebDriver版本匹配
环境搭建是Selenium新手翻车率最高的一步。核心三件套:Python环境、浏览器本体、对应版本的WebDriver。
安装Selenium本身很简单:
pip install selenium麻烦的是WebDriver。Selenium不是直接控制浏览器的,它通过WebDriver这个中间翻译官和浏览器通信。Chrome对应的叫ChromeDriver,Firefox对应的是GeckoDriver。
这里有个硬性规则:ChromeDriver的大版本号必须和Chrome浏览器版本号完全一致。比如你电脑装的Chrome是126版本,那ChromeDriver也必须下载126.x,否则启动时报错:
SessionNotCreatedException: This version of ChromeDriver only supports Chrome version 1xx手动下载的流程是这样:先在浏览器设置里看自己的Chrome版本,然后去ChromeDriver的镜像站下载对应版本的压缩包,解压后把chromedriver可执行文件放到系统PATH目录里,或者直接在代码里指定路径。
现在更推荐的做法是用webdriver-manager这个库自动管理驱动,它会自动检测浏览器版本并下载匹配的驱动,省掉了手动维护的烦恼:
pip install webdriver-manager2.2 第一个脚本:打开页面,拿到真正渲染后的内容
装好了写第一个脚本,目标就是验证一件事:我们真的拿到了JS渲染后的页面。
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) driver.get("https://example.com") print(driver.title) print(driver.page_source[:500]) driver.quit()这段代码做了四件事:初始化驱动、打开指定网页、打印页面标题、打印渲染后源码的前500个字符。运行成功就说明环境配置无误,而且能看到源码里包含JS渲染后的内容。
driver.quit()一定要写。很多人调试的时候只写driver.get()”,直接关掉脚本,结果Chrome进程一直驻留在后台占内存。我之前排查一个内存问题,发现后台堆了十几个孤儿Chrome进程,就是脚本报错提前退出导致的。建议用try/finally或者with`语法确保浏览器实例能正常关闭。
额外说一点:webdriver-manager首次运行会下载驱动到本地缓存,第二次运行不再需要联网。如果是离线服务器环境,还是手动下载驱动放到指定路径更踏实。
2.3 驱动管理的两种方案与踩坑记录
手动方案和自动方案我都用过,分别说一下适用场景。
手动下载适合目标机器网络受限、或者需要锁定驱动版本保证团队环境一致的情况。做法是把chromedriver放到/usr/local/bin/这类PATH目录下,代码里不用指定路径,Selenium会自动去PATH里找。Windows上则需要把exe放到Python脚本目录或单独指定绝对路径。
自动方案适合个人开发和快速迭代。用webdriver-manager的好处是不会出现“换浏览器忘了更新驱动”的破事。我踩过的坑是:有次Chrome自动更新到128,手动下载的ChromeDriver还是126的,脚本全部报SessionNotCreatedException,排查了半小时才反应过来是版本号没对上。
如果你是用Docker部署爬虫服务,建议直接在镜像里装固定版本的浏览器和驱动,这样环境完全可控,不会出现宿主机和容器内浏览器版本不一致的问题。
3. 元素定位与等待机制:从XPath到显式等待
3.1 XPath的text()函数你真的会用吗
拿到渲染后的DOM,下一步就是怎么精准地提取需要的数据。Selenium提供了一整套定位API,包括ID、Class、Name、CSS选择器、XPath,其中XPath的灵活性最高,尤其是处理文本内容时。
很多新手把text()函数用成了[text()="xxx"],这是精确匹配,要求节点文本和内容完全一致。但页面元素的文本经常带着换行、空格、前后空白,精确匹配就容易翻车:
# 精确匹配,要求span的文本必须完全等于"加载" driver.find_element(By.XPATH, "//span[text()='加载']") # 包含匹配,更常用,只要span文本里包含"加载"两个字就行 driver.find_element(By.XPATH, "//span[contains(text(),'加载')]") # 模糊匹配处理空白的好帮手 driver.find_element(By.XPATH, "//span[normalize-space(text())='加载']")normalize-space()是XPath里的一个函数,它会自动去掉文本首尾空白、把中间连续的空格合并成一个。实际爬虫里页面文本十有八九带换行,你不用这个函数,匹配不上就傻眼了。
还有一个实用组合,先定位到包含目标文本的节点,再顺着DOM结构往上找父级、往下找同级。比如抓商品列表时,先找到商品名称所在元素,再定位到最近的商品卡:
# 找到文本包含"无线键盘"的div,然后向上找两层 product_card = driver.find_element(By.XPATH, "//div[contains(text(),'无线键盘')]/ancestor::div[contains(@class,'product-item')]")3.2 隐式等待 vs 显式等待:别再用sleep硬扛
动态页面的核心难点就是“时序”。JS没执行完,元素还没渲染出来,代码就去find_element,结果当然是NoSuchElementException。
最简单的办法是sleep(5)硬等,但固定等5秒有个致命问题:页面慢的时候5秒不够,页面快的时候白白浪费5秒。爬虫跑批量任务时,每个页面浪费几秒,累计下来就是几个小时的无谓等待。
正确的做法是显式等待,它的逻辑是“一直等到条件满足,或者超时抛异常”。Selenium的WebDriverWait配合expected_conditions可以精准控制:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC # 等待id为content的元素出现在DOM中 element = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, "content")) ) # 等待元素可见且可点击 button = WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.XPATH, "//button[contains(text(),'加载更多')]")) ) # 等待某个元素消失,比如loading动画 WebDriverWait(driver, 10).until( EC.invisibility_of_element_located((By.CLASS_NAME, "loading")) )显式等待比sleep强在哪?页面1秒渲染出来,就等1秒;页面5秒渲染出来,就等5秒。不是傻等,是条件触发。
再来说隐式等待。driver.implicitly_wait(5)的意思是每次执行find_element时,如果元素找不到,最多轮询查找5秒。它最经典的坑是:只对find_element生效,不对元素状态变化生效。比如元素在DOM里存在但被遮挡、不可点击,隐式等待不会帮任何忙。所以我的习惯是:全局设置一个隐式等待兜底,关键交互用显式等待精确控制。
3.3 元素定位不到,先检查这五件事
用Selenium遇到NoSuchElementException是最常见的事,吐槽一句“明明浏览器里能看到”也确实常见。但实际上多数情况不是Selenium的锅,是这几件事没排查过:
元素是不是在
iframe里。页面里嵌了iframe的话,默认的搜索范围是当前frame,你直接去找iframe里面的元素就是找不到。必须先switch_to.frame()切进去。元素是不是在新弹出的窗口里。点击按钮开了新Tab后,原窗口引用还在,但新窗口内容必须
switch_to.window()切换过去才能操作。页面是不是正在渲染中。JS还没跑完,元素还没生成,需要在等待机制上下工夫。
class名是不是带空格。
class="btn btn-primary"这种复合class,用CSS选择器时要写成.btn.btn-primary,用XPath时要用contains(@class, "btn"),直接按完整class名匹配会失败。元素是不是动态生成的ID。有些前端框架每次刷新都会重新生成一段随机ID,今天爬的时候能匹配,明天就匹配不上了。这种情况优先考虑用XPath的文本、结构关系来定位,尽量不要依赖动态ID。
4. 模拟用户操作:点击、滚动、iframe与下拉框
4.1 点击、输入和键盘事件
动态页面的交互操作是Selenium的拿手戏。最基础的动作是输入和点击,但里面藏着不少细节。
from selenium.webdriver.common.keys import Keys search_box = driver.find_element(By.NAME, "keyword") search_box.clear() # 先清空再输入 search_box.send_keys("机械键盘") search_box.send_keys(Keys.ENTER) # 直接回车提交这里的clear()容易被忽略。搜索框里可能有默认值、上次的缓存值,直接send_keys会把新内容和旧内容拼接在一起,导致搜索词完全错误。
点按钮的时候也常遇到一个经典报错:ElementClickInterceptedException——元素被别的节点挡住了,比如弹窗、悬浮层、遮罩。Selenium按规矩点不了。这时候我通常绕过去,用JavaScript直接触发点击事件:
element = driver.find_element(By.XPATH, "//button[contains(text(),'确认')]") driver.execute_script("arguments[0].click();", element)这个方法不模拟鼠标轨迹,直接调用元素的click方法,虽然少了点“真实感”,但胜在稳定。尤其是在弹窗遮挡、懒加载未完成这类场景下,比正常点击可靠得多。
4.2 处理下拉加载与“加载更多”按钮
现在的主流前端框架都流行“无限滚动”,列表滚到底部自动加载更多数据,或者给一个“加载更多”按钮让你手动触发。
处理“加载更多”按钮的核心是循环点击,直到按钮消失或者列表数量不再变化。这里要注意显式等待的配合,否则点击太快会被当成异常操作:
button_visible = True while button_visible: try: load_more = WebDriverWait(driver, 5).until( EC.element_to_be_clickable((By.XPATH, "//button[contains(text(),'加载更多')]")) ) load_more.click() # 点击后等待新的内容渲染出来 WebDriverWait(driver, 5).until( EC.presence_of_element_located((By.CSS_SELECTOR, ".item-list .item")) ) # 加个短暂停顿,让界面稳定一下 time.sleep(0.5) except TimeoutException: # 按钮不见了,说明到底了 button_visible = False滚动懒加载的处理更简单,用JavaScript把页面滑到底部,触发前端滚动监听事件:
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") time.sleep(1) # 等待数据加载多次滚动重复执行这段就行。但注意滚动太快的时候,有些前端框架的懒加载会来不及触发,所以每滚一次最好停顿一下,给请求留出时间。
4.3 iframe里被藏起来的元素
一个很让人沮丧的场景是:明明用XPath能匹配到元素,但代码跑起来就找不到。我之前抓一个嵌套页面的数据,页面结构是一个iframe嵌着另一个iframe,目标数据在最内层。不切frame直接find_element,报错报到怀疑人生。
理解一下就通了:iframe相当于页面里的一个独立文档,Selenium默认是在最外层文档里搜索元素。要操作iframe里的内容,必须先切进去:
# 方法一:通过索引切换,0表示第一个iframe driver.switch_to.frame(0) # 方法二:通过id或name切换 driver.switch_to.frame("main_frame") # 方法三:通过元素定位切换 frame_element = driver.find_element(By.XPATH, "//iframe[@src='content.html']") driver.switch_to.frame(frame_element)操作完iframe里的内容,想回去操作外层页面,记得切回默认内容:
driver.switch_to.default_content()这个忘了的话,接下来所有外层元素定位都会报错。多层iframe嵌套时,注意“切几层就得进几层”,返回来也可能需要一层层跳出或者直接回到默认。
5. 提升效率与稳定性:无头模式、执行JavaScript与并发取舍
5.1 无头模式与页面加载策略
Selenium默认启动是有界面的,调试时方便观察,但跑批量任务时每个页面都要弹出一个浏览器窗口,既干扰又浪费资源。无头模式(headless)让浏览器在后台运行,不显示窗口,性能好了不少。
from selenium.webdriver.chrome.options import Options options = Options() options.add_argument("--headless") options.add_argument("--window-size=1920,1080") # 设置窗口大小,有些页面会按分辨率渲染不同内容 driver = webdriver.Chrome(options=options)设置窗口大小这个容易被忽略。很多前端框架会根据窗口宽度决定渲染手机版还是PC版页面,无头模式默认可能是800x600的窗口,正好触发移动端渲染,那抓回来的页面结构可能完全不一样。我的习惯是一律设为1920x1080,确保走桌面端渲染路径。
页面加载策略也值得说。page_load_strategy有三个值,影响很大:
| 值 | 行为 | 适用场景 |
|---|---|---|
| normal | 默认,等待整个页面加载完 | 稳健,但慢 |
| eager | 等DOM就绪就返回,不管图片等资源 | 追求速度时首选 |
| none | 不等任何资源加载 | 极快,但容易误判 |
动态爬虫的核心本来就是DOM数据,图片加载不加载其实无所谓,可以放心大胆用它:
options.page_load_strategy = "eager"实测下来,相比默认的normal,eager模式下页面打开速度快了不止一点点。尤其是一些图片巨多但数据在DOM里的页面,normal模式会傻等图片全部加载完毕,eager模式DOM一就绪就返回,效率提升明显。
5.2 在Selenium里执行JavaScript:execute_script的花式用法
execute_script是Selenium的一个隐藏神器,它的本质是让你在浏览器的JavaScript环境里执行任意代码。
最常见的用法是滚动:
# 滚动到底部 driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") # 滚动到指定元素位置 element = driver.find_element(By.XPATH, "//div[@id='footer']") driver.execute_script("arguments[0].scrollIntoView(true);", element)另一个高频用法是直接从JS环境里拿数据。有时候元素被隐藏在页面里,通过DOM操作可以一次性把所有目标文本捞出来:
texts = driver.execute_script(""" let items = document.querySelectorAll('.product-name'); return Array.from(items).map(item => item.innerText); """)这里说一个经典笔误:JavaScript的方法名是大小写敏感的,getElementById和getelementsbyclassname完全是两回事。我见过好几个报错都是因为把getElementsByClassName的E写成了小写。是getElements,E大写,ByClassName,三个地方的大写B、C。写错了浏览器控制台会直接报TypeError,说这不是一个函数。
还有处理数值时,JS自带方法也常用。比如数据里有大段带小数点的价格,在JS环境里可以直接parseFloat(price.toFixed(2))保留两位小数再返回。虽然Python侧也能做,但页面里的字符串格式五花八门,在JS环境里先清洗一遍,返回的数据更干净。
canvas元素渲染出来的内容,execute_script也无能为力——画布上画的是位图,不是DOM结构。这种场景要么用Selenium截屏,配合OCR识别文字,要么找找页面源里有没公开的原始数据接口。
5.3 并发与效率:别盲目开多线程
很多人一提到爬虫效率就想到多线程、多开浏览器。但Selenium的并发有个天然短板:每个浏览器实例都会吃至少几百MB内存,开5个浏览器实例,电脑就开始喘了。而且ChromeDriver对并发的稳定性要求很高,我没少见过多线程跑着跑着突然一个实例卡死、剩下几个全部跟着超时的场面。
我的建议是:如果目标网站本身有可用的JSON接口,优先用requests异步请求接口,效率和稳定性都好得多。Selenium只负责那些必须浏览器渲染才能拿到的页面,占比应该控制在所有爬取任务的小部分。
如果确实需要多个Selenium实例配合,建议用线程池控制并发数2到3个,任务队列分发,并且每个线程使用独立的浏览器实例和driver对象。千万别共享同一个driver——那是灾难。
还有一点务必提醒:爬虫是采集公开数据的手段,合规永远是红线。学习自动化测试场景下使用没问题,但真实抓取时应当尊重目标网站的robots协议、控制合理频率、不绕过登录鉴权和访问控制、只采集合法授权范围内的数据。别为了省事用Selenium去模拟登录绕过验证,这已经越界了。做爬虫的人应该比写业务的人更懂边界在哪里。
6. 常见问题与排查技巧实录
6.1 异常速查表
Selenium跑久了,遇到的各种异常会越来越多。这里列一个速查表,碰到类似报错先按这个思路排查:
| 异常类型 | 典型场景 | 解决思路 |
|---|---|---|
| SessionNotCreatedException | 驱动和浏览器版本不匹配 | 使用webdriver-manager自动匹配,或手动下载对应版本 |
| NoSuchElementException | 元素定位不到 | 检查iframe、新窗口、等待时间、class名 |
| TimeoutException | 显式等待超时 | 确认元素是否真的会渲染,确认等待条件是否正确 |
| ElementClickInterceptedException | 元素被遮挡无法点击 | 用execute_script调click、等待遮罩消失 |
| StaleElementReferenceException | 元素引用失效,页面刷新过 | 重新查询元素,页面跳转后重新定位 |
| WebDriverException | 浏览器崩溃、连接断开 | 检查浏览器版本、系统资源、driver路径 |
最让人头疼的是StaleElementReferenceException。它的原因是:你第一次定位到了元素,存进了变量,但页面后续做了局部刷新,这个DOM节点被替换了,旧的引用就失效了。解决办法很简单但很容易忘——在每次操作前重新find_element,不要缓存元素对象长期使用。
6.2 三个提升排障效率的实用技巧
先说浏览器复用。Selenium默认每次启动都是全新浏览器,没有Cookie、没有登录状态,爬需要登录的网站每次都要重新走一遍登录流程。一个技巧是用debuggerAddress连接到一个手动打开的浏览器:
chrome --remote-debugging-port=9222然后在代码里:
options.add_experimental_option("debuggerAddress", "127.0.0.1:9222") driver = webdriver.Chrome(options=options)这样Selenium会接管你已经打开的那个浏览器,登录状态、Cookie、代理设置全都保留。这个技巧在调试复杂页面时也能实时观察脚本的每一步操作,比无头模式好用太多了。
第二个技巧是调试时把页面源码存下来。定位不到元素时,别光看报错信息,先执行driver.page_source存成HTML文件,放到浏览器里打开看结构和预期有什么差异。往多了不说,这一个操作能解决一半以上的定位疑惑。
第三个技巧是设置用户目录。Chrome的user-data-dir参数可以指定用户的浏览器数据目录,这样每次启动同一Chrome实例时,会带上之前的浏览历史、Cookie、登录状态:
options.add_argument("--user-data-dir=/tmp/chrome-profile")做爬虫的时候,Cookie管理经常会写一堆代码,这个参数反而能以物理方式解决登录状态保留的问题。
6.3 异步加载太慢的页面怎么处理
Selenium最怕的是“页面打开很久,数据才慢慢出来”的网站。显式等待已经够了,但数据量太大了,前端渲染一批就要卡一会,后续操作又超时。
我的做法是“小步快跑”:把整个采集过程拆分成多次访问,每次只处理一部分数据。比如分页爬取时,每页访问一次,数据到手就进入下一页,不让浏览器长期驻留。不过听起来简单,实操时有个反直觉的点经常坑人——每页访问结束后,别急着driver.quit(),而在同一浏览器实例内继续driver.get()下一页,因为每次新开浏览器都要重新加载一堆公共资源,反而更慢。
还有一种情况是页面JS报错导致前端逻辑崩溃,数据永远渲染不出来。这时候可以用execute_script往页面里注入辅助脚本,强制刷新某个组件的状态。当然这是下策,更实际的做法是绕过这个页面,直接找它的XHR请求地址。很多前端框架的接口地址就是数据源,拿这个地址配合requests请求,效率和稳定性都远胜Selenium。
最后分享个细节:处理完成后,建议主动调用driver.close()关闭当前窗口,再用driver.quit()退出浏览器。只quit()的话,在Windows上偶尔会残留进程,下次启动的Chrome实例就可能卡住初始化。
我自己的经验是:Selenium从来不是第一选择,但掌握了它,爬虫这行的边界一下子宽了很多。现在不少网站while用前端框架渲染,调和requests拿接口调数据不畅通时,Selenium就是那条兜底的船。学会判断什么时候上、什么时候不上,比学会怎么敲代码更重要——这大概是做爬虫几年下来最真实的体会了。