很多人学爬虫到一定阶段都会撞上一堵墙:requests 明明把页面 HTML 拿下来了,正则和 XPath 也写对了,可解析出来的结果就是空的。刚开始我以为是页面结构看错了,反复核对才发现,浏览器里看到的页面和 requests 拿到的源码根本不是一回事——数据全是用 JavaScript 动态渲染出来的。这篇文章就是来解决这个问题的,我会结合 Selenium 这套浏览器自动化方案,把处理 JavaScript 渲染页面的完整思路、核心代码和踩坑记录都理一遍。适合那些已经会用 requests 写简单爬虫、但面对动态页面无从下手的同学参考。
1. 为什么 requests 拿不到 JS 渲染后的数据
1.1 静态请求与浏览器渲染的本质差异
requests 本质上就是一个 HTTP 客户端,它做的唯一一件事就是发送请求、接收响应。你拿到的 HTML 是服务器返回的原始文档,这个文档里可能只有一堆空的 <div id="app"></div>,真正的数据需要浏览器加载完 JavaScript 之后才动态生成。而 Selenium 是直接驱动一个真实的浏览器内核去访问页面,浏览器会完整执行 HTML、CSS、JavaScript,就像真人打开浏览器一样,最终你拿到的是渲染完成之后的 DOM 树。
可以这样理解:requests 是站在门口看快递单上的寄件人信息,Selenium 是进屋把包裹拆开一件件清点。两种方式拿到的信息层级完全不同。
现在主流的 SPA(单页应用)框架,比如 Vue、React,基本都是这个套路:首次请求只返回一个空壳 HTML 和一堆 JS 文件,数据是通过 Ajax 接口异步加载的。在 HTML 层面你能看到的只有 <script src="app.js">,真正有价值的数据在 app.js 执行之后才会出现在页面上。
1.2 动态渲染页面的常见特征与识别方法
判断一个页面是不是 JS 渲染的,有两个很直接的办法。第一个:在浏览器里右键查看网页源代码,如果发现页面关键数据不在里面,说明是 JS 动态渲染。注意这里要看“网页源代码”,不是开发者工具里的 Elements 面板,Elements 面板经过运行时修改,是“渲染后”的 DOM,两者有本质区别。
第二个:直接关掉浏览器 JavaScript 再访问页面,如果页面内容大面积空白或者关键数据消失,基本可以确定是 JS 渲染。具体操作方法:Chrome 开发者工具 -> 右上角设置 -> Debugger -> 禁用 JavaScript。刷新页面,观察内容变化。
还有一种情况是页面用了 Ajax 异步加载,打开开发者工具的 Network 面板,刷新页面,如果看到 XHR 请求返回的是 JSON 数据,说明页面数据是接口动态获取的。这种情况下除了用 Selenium,更轻量的做法是直接找接口、模拟请求。这也是我后面会讲的选型关键。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Selenium 处理 JS 渲染的完整思路
2.1 为什么选择 Selenium 而不是其他方案
处理 JavaScript 渲染的爬虫方案远不止 Selenium 一种,常见的还有 Pyppeteer、Playwright。而前两年 Selenium 一直处于比较尴尬的状态。但 Selenium 胜在生态成熟、资料齐全、上手门槛低,遇到问题搜索解决方案确实更容易找到可用的信息。特别是网页结构稳定的场景,Selenium 的稳定性和可靠性经过大量验证。
不过我得说实话:如果从零开始一个新项目,我会优先考虑 Playwright 或 Pyppeteer,因为它们在速度和资源占用上更有优势,而且 Pyppeteer 的异步支持几乎是天然的。但如果团队里其他人都在用 Selenium,或者目标网站的反爬策略对 Selenium 比较友好,直接用 Selenium 也完全没问题。
2.2 关键技术点分类与流程设计
用 Selenium 处理动态页面的标准流程大致是四步:启动浏览器 -> 访问页面 -> 等待渲染完成 -> 提取数据。说起来简单,实际操作时难点集中在第三步的“等待”和第四步的“提取”上。
等待渲染完成有三种方式:固定等待(time.sleep())、隐式等待(implicitly_wait)、显式等待(WebDriverWait)。我强烈建议用显式等待,后面我会解释为什么前两种在实战中不靠谱。
提取数据方面,Selenium 提供了 find_element 和 find_elements 系列方法,支持通过 ID、Class Name、XPath、CSS Selector 等方式定位元素。其中最灵活的是 XPath 和 CSS Selector,尤其是处理复杂的嵌套结构时,XPath 的高级定位方式能让你少写很多代码。
2.3 环境准备与浏览器驱动匹配
在开始写代码之前,环境必须准备好。除了 pip install selenium,Chrome 浏览器和对应版本的 ChromeDriver 缺一不可。ChromeDriver 和浏览器版本不匹配是最常见的启动报错。
查看方法:Chrome 浏览器地址栏输入 chrome://version/ 查完整版本号,然后去 ChromeDriver 官方站点下载对应版本。从 Chrome 115 版本开始,官方推出了 Selenium Manager,可以在没有手动配置 ChromeDriver 的情况下自动匹配驱动版本,省心不少。这个功能集成在 Selenium 4.11 及以上的版本中,老项目如果要升级需要注意代码兼容性问题。
Firefox 对应的是 geckodriver,思路一样,我没单独踩过坑。Edge 的话直接下载对应的 msedgedriver 即可。如果你用的是 Selenium 4.11+ 版本,大部分情况下驱动匹配的问题会自动完成,不再需要手动下载和对版本号。
3. 核心实操:数据定位与交互操作
3.1 元素定位 API 的灵活运用
Selenium 的定位方式很丰富,从常见的 find_element(By.ID, "user")、find_element(By.CLASS_NAME, "list-item"),到进阶的 XPath、CSS Selector,每种都有不同的适用场景。
我用得最多的还是 XPath,尤其是在层级嵌套特别深的页面结构里,XPath 的轴向定位能精准锁定目标元素。举个典型例子:
python复制from selenium.webdriver.common.by import By
from selenium import webdriver
driver = webdriver.Chrome()
driver.get("https://example.com")
# 绝对路径,不推荐,页面一改就失效
# driver.find_element(By.XPATH, "/html/body/div[1]/div[2]/div[3]/span")
# 相对路径 + 属性定位,推荐
items = driver.find_elements(By.XPATH, "//div[@class='product-item']")
# 包含匹配,处理动态 class 特别有用
elements = driver.find_elements(By.XPATH, "//div[contains(@class, 'item')]")
# 文本精确定位
button = driver.find_element(By.XPATH, "//button[text()='立即购买']")
contains(@class, 'item') 这种方式非常实用,因为现代前端框架经常生成动态 class 名称,比如 _product_item_1fk2d,每次刷新都可能变化。如果用等于匹配必然翻车,用包含匹配就能规避这个问题。
3.2 三种等待方式的对比与选择
等待是 Selenium 处理 JS 渲染的核心难点,网络快慢、服务器响应、前端渲染逻辑都会影响页面元素出现的时间。等得太短,元素没渲染出来;等得太长,又拖慢整体抓取速度。
先说 time.sleep(20) 这种写死的固定等待。有次抓一个电商页面,脚本跑了一两百条数据后某个图片接口卡住了,结果每条数据都固定睡 20 秒,速度慢得让人崩溃。固定等待最大的问题就是不确定性,无法感知页面实际状态。
implicitly_wait(10) 是设置全局等待时间,每次调用 find_element 都会在指定的时间内反复尝试匹配元素。它的问题是只能处理元素存在性,无法处理元素可见、可点击这类状态,而且设置的是全局参数,无法针对某个特定元素做精细化等待。
真正推荐的是 WebDriverWait 配合预期条件,也就是显式等待:
python复制from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
wait = WebDriverWait(driver, 10)
# 等待元素可见
wait.until(EC.visibility_of_element_located((By.XPATH, "//div[@class='product-list']")))
# 等待元素可点击
wait.until(EC.element_to_be_clickable((By.XPATH, "//button[text()='下一页']")))
# 等待元素存在
element = wait.until(EC.presence_of_element_located((By.CLASS_NAME, "product-item")))
显式等待的好处是精准、可控,每个关键元素都能设置独立的等待条件。页面元素没出现就继续等,出现就立刻继续执行,不浪费时间。这在实际项目中带来的提速效果非常明显。
3.3 常用交互操作与分页处理
动态页面通常不是一次性渲染完所有内容,常见的两种加载模式:分页按钮点击和滚动翻页。分页按钮比较简单,找到按钮、点击即可:
python复制next_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[text()='下一页']")))
next_button.click()
# 等新内容加载出来后再次抓取
wait.until(EC.staleness_of(old_element)) # 或者等待新页面特征元素出现
注意一个问题:点击下一页之后,如果直接立刻抓取数据,可能拿到的是上一页的旧数据。所以要重新等待目标元素出现,或者判断当前页码发生变化。建议页面里如果存在当前页码标识元素,就通过断言页码变更来确认切换成功。
滚动翻页更麻烦一点,很多是无限滚动加懒加载。处理方式是用 driver.execute_script() 执行 JavaScript 操作滚动条:
python复制# 滚动到底部
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
# 再等待新内容出现
wait.until(EC.presence_of_all_elements_located((By.CLASS_NAME, "stream-item")))
有些网站固定只加载两三轮,有些会一直加载到几千条,需要结合具体网站的加载规则来控制循环次数。如果抓的是瀑布流信息流,建议每次滚动前记录当前元素数量,滚动后对比数量是否增长,没有增长就说明到底了,直接跳出循环。
3.4 下拉框、弹窗等特殊元素操作
表单里的下拉框比较特殊,原生 <select> 元素可以直接用 Select 类处理:
python复制from selenium.webdriver.support.ui import Select
select_element = Select(driver.find_element(By.NAME, "city"))
select_element.select_by_value("beijing")
如果是自定义的下拉框,本质就是点击触发一个隐藏列表,然后再点击列表项。这种情况没有捷径,老老实实分析 DOM 结构,找到列表项之后定位点击。
弹窗有两个系统级处理方法:alert.accept() 处理确认框,alert.dismiss() 处理取消框。还有一类是页面内部的模态弹窗,这种其实是普通 DOM,定位后点击关闭按钮即可。有次抓某个页面,死活定位不到关闭按钮,后来发现弹窗里有个 iframe 嵌套,需要先切进 iframe 里定位,定位完还要切回来,这个知识点我会在后面排查部分展开讲。
4. Selenium 在上手阶段的合规细节
4.1 页面加载策略
Selenium 在打开页面时,会等所有资源全部加载完成才继续执行后续代码。但有些页面的主内容早就渲染完毕了,剩下的广告、统计脚本、图片却在拖慢速度。这种情况下可以修改页面加载策略:
python复制from selenium.webdriver.chrome.options import Options
options = Options()
options.page_load_strategy = "eager" # 等待 DOM 访问就绪,不等待所有资源
# 或 "none" 完全不等待
driver = webdriver.Chrome(options=options)
eager 策略在抓取内容类页面时能明显提速,实测大概提升 20%~30%。注意有些极端场景下,eager 策略可能导致部分 Ajax 请求还没触发,需要配合显式等待兜底。
4.2 无头模式与合理限速
无头模式就是浏览器不弹出界面,在后台运行。这个模式在服务器上部署爬虫时几乎是必需的。但无头模式也会引入新的风控特征,比如 navigator.webdriver 属性为 true。很多时候正常模式能过,切到无头反而被识别。
处理方案有两个层面。第一是修改启动参数,去掉明显的自动化特征,比如 --disable-blink-features=AutomationControlled:
python复制options.add_argument("--disable-blink-features=AutomationControlled")
options.add_experimental_option("excludeSwitches", ["enable-automation"])
第二是合理规划抓取频率,加入随机延时,避免高频密集请求。爬虫为了效率过度追求速度,往往是最容易触发反爬的原因。
4.3 限速与延时策略
我在实际项目中经常用下面的方式模拟人工操作节奏:
python复制import time
import random
def human_delay():
time.sleep(random.uniform(1.5, 3.5))
另外一个细节是 driver.set_window_size() 模拟正常浏览器窗口尺寸,如果窗口设置成极小值或者默认 800x600,也可能触发风控。建议设成 1920, 1080。
4.4 会话保持与登录态处理
有些网站需要登录才能看到目标数据,Selenium 处理登录有三种思路。
第一种是把登录步骤写成代码,自动填账号密码。这是最直接的方式,但账号密码存代码里有安全风险,而且验证码很难处理。
第二种是读取本地浏览器的 Cookie 灌入 Selenium 会话。这种方式适合目标网站登录后有效期比较长的场景。先从浏览器开发者工具复制 Cookie,保存成 Python 字典,再用 driver.add_cookie() 注入。
第三种是直接使用已有的 Chrome 用户数据目录:
python复制options.add_argument(r"--user-data-dir=C:\Users\用户名\AppData\Local\Google\Chrome\User Data")
options.add_argument(r"--profile-directory=Default")
这个方法能直接复用本地浏览器的登录态和 Cookie,很多需要扫码登录的网站就这么过。但注意:用这个方式启动 Chrome 前要先关闭本地所有 Chrome 窗口,否则启动会失败。
5. 完整案例:用 Selenium 抓取一个 JS 渲染的列表页并翻页
为了把上面这些知识点串起来,我写一个完整的抓取流程:目标是一个用 Vue 渲染的新闻列表页,数据动态加载,需要翻页抓取。
python复制import time
import random
import csv
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
def create_driver():
options = Options()
options.page_load_strategy = "eager"
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_argument("--window-size=1920,1080")
# 部署到服务器时再打开下面这行
# options.add_argument("--headless=new")
driver = webdriver.Chrome(options=options)
return driver
def fetch_news():
driver = create_driver()
wait = WebDriverWait(driver, 10)
base_url = "https://example-news.com/list?page={}"
results = []
try:
for page in range(1, 6):
driver.get(base_url.format(page))
# 等待列表项出现
wait.until(EC.presence_of_all_elements_located((By.CLASS_NAME, "news-item")))
items = driver.find_elements(By.CLASS_NAME, "news-item")
for item in items:
title = item.find_element(By.CSS_SELECTOR, ".news-title").text
# 有跳转链接的话取 href
link = item.find_element(By.CSS_SELECTOR, "a").get_attribute("href")
results.append({"title": title, "link": link})
print(f"第 {page} 页抓取完成,累计 {len(results)} 条")
# 随机延时,模拟人工浏览
time.sleep(random.uniform(1.5, 3.0))
finally:
driver.quit()
# 保存结果
with open("news.csv", "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=["title", "link"])
writer.writeheader()
writer.writerows(results)
print(f"全部完成,共保存 {len(results)} 条数据")
if __name__ == "__main__":
fetch_news()
这个脚本里有几个关键细节需要注意:
page_load_strategy="eager" 配合 WebDriverWait 使用,速度与稳定性兼顾。
try...finally 确保浏览器进程能正常关闭,防止内存泄漏。
encoding="utf-8-sig" 是为了让 CSV 在 Excel 中打开不乱码。
random.uniform(1.5, 3.0) 模拟人工操作间隙,避免高频请求触发反爬。
6. 常见问题与排查技巧实录
6.1 元素定位不到的超时问题
这是 Selenium 最常见的报错,TimeoutException 或者 NoSuchElementException 出现时,我会按下面顺序排查:
第一,首先确定等待的元素是不是在 iframe 里。iframe 里的元素在未切入之前,Selenium 无法定位。排查方法是在浏览器开发者工具里搜索元素,如果代码中包含 <iframe 标签包裹,基本可以确定。
python复制# 切入 iframe
driver.switch_to.frame("iframe_id_or_name")
# 定位元素
driver.find_element(By.XPATH, "//div[@class='content']")
# 操作完切回主文档
driver.switch_to.default_content()
第二,检查元素渲染是否真的完成。有些页面先渲染外壳,再异步加载内部数据,外壳元素存在但内部内容是空的,此时需要用预期条件判断内容的完整性,比如某个文本是否包含特定字符串。
第三,检查元素是否在 Shadow DOM 中,这种情况比较少见,但确实存在,需要用 driver.execute_script() 配合原生的 DOM 方法查找。
6.2 页面元素能定位但点击无效
遇到点击按钮没什么反应的场景,优先考虑元素被遮挡。页面可能会有悬浮层、广告遮罩盖在目标按钮上,Selenium 会提示 ElementClickInterceptedException。处理思路:
python复制# 方式一:JS 强制点击
driver.execute_script("arguments[0].click();", element)
# 方式二:先滚动到元素可视区域再点击
driver.execute_script("arguments[0].scrollIntoView(true);", element)
time.sleep(0.5)
element.click()
这两种方式我在实际项目中都用过,JS 强制点击属于兜底方案,简单粗暴但有效。
6.3 浏览器被识别为自动化工具的提示
很多网站会在前端埋设检测脚本,通过 navigator.webdriver 判断是否为自动化控制。遇到验证码、滑块、甚至直接封号时,除了上文提到的 excludeSwitches 参数,还可以试试:
python复制# 通过 execute_script 修改 webdriver 标记
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
"source": """
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
});
"""
})
注意这段代码必须在每次新开页面时执行,所以最好放在打开页面的动作之前。这个技巧能绕过部分检测,但现在的风控体系越来越复杂,更多的反爬手段是行为分析,单纯靠这行代码不可能解决所有问题。
6.4 内存占用过高与页面崩溃
长时间运行的 Selenium 脚本最容易遇到内存爆炸。一个 5 小时的爬虫任务,即使 driver.quit() 正常执行,浏览器内核有时候也不会完全释放资源。建议策略:
- 每抓取一定数量页面执行一次
driver.delete_all_cookies()和driver.refresh(),释放页面资源 - 每完成一批任务直接关闭浏览器重组实例,不能无限复用同一个 driver
- 监控系统内存,异常偏高时自动重启脚本
6.5 问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 启动报 SessionNotCreatedException | ChromeDriver 与浏览器版本不匹配 | 下载匹配版本的驱动,或升级到 Selenium 4.11+ 自动管理 |
| 元素定位超时 | iframe 嵌套 / 渲染未完成 | 切换 iframe 或增加显式等待条件 |
| 点击无效 | 元素被遮挡或不可见 | 用 JS 强制点击或先滚动到可视区域 |
| 登录状态丢失 | Cookie 未正确保留 | 使用 add_cookie 注入或复用用户数据目录 |
| 页面识别为自动化 | navigator.webdriver 特征暴露 | 修改启动参数 + 注入反检测脚本 |
| 数据重复抓取 | 翻页后未等待新内容刷新 | 断言页码变化或等待旧元素失效 |
7. 几个小建议
实际用下来,我最大的体会是 Selenium 不是银弹。它的优势是简单、直观、稳定,缺点也很明显:慢、耗资源、容易被检测。在动手写代码之前,建议你先花 10 分钟看一下目标网站有没有直接的 Ajax 接口。如果一个页面能通过 requests 直接请求到 JSON 数据,完全没必要用 Selenium 杀鸡用牛刀。很多时候用抓包工具找到接口,手动构造 POST 请求,速度能快上百倍。
如果目标页面确实是 SPA 且没有公开 API,再考虑 Selenium,或者更现代的 Playwright。这套思路不仅适用于 Selenium,换成 Playwright 也差不多,核心的等待策略、元素定位、防检测思路是通用的。技术选型没有绝对的好坏,只有合不合适。希望这篇文章能帮你把动态页面的处理思路理清楚,少交一点学费。
