先说一个我踩过的坑。有天下午,朋友发来一个网站,说之前写好的爬虫崩了,让我帮忙看看。我打开他的代码,requests写得挺规整,headers、cookie、代理都带了,但拿回来的HTML源码里,硬是没有他说的那个价格字段。我打开浏览器按F12看了一眼,瞬间就明白了——这是个JavaScript渲染的页面,数据根本不是服务端直接吐出来的,而是浏览器加载完页面后,由脚本再发起请求、把数据插进DOM里的。
这个场景,做爬虫的人应该都不陌生。现在前端框架越来越重,页面结构越来越“壳化”,很多网页打开就是一个空荡荡的div壳子,里面所有可见内容全靠JavaScript异步填充。你拿requests去扒源码,扒回来的只是一副没有血肉的骨架。这时候,Selenium就是绕不开的一件工具。这篇文章就把我过去处理这类页面时踩过的坑、用过的方案、沉淀下来的思路,一次性整理出来。内容偏实战,适合那种“requests已经写得挺熟、但突然面对动态页面不知道从哪下手”的朋友。
1. 先搞清楚:目标页面到底是不是JavaScript渲染的
1.1 从一次“假静态页”说起
很多人在遇到“爬不到数据”时,第一反应是加headers、加cookie、换代理,折腾一圈发现没用,最后才怀疑到动态渲染头上。其实这个问题前置一点,能省下大量无效劳动。
我那位朋友的失误在于:他拿到需求后,直接在浏览器里右键“查看网页源代码”,看到里面有部分数据,就以为整个页面是静态的。但他忽略了一点——浏览器里看到的完整页面,和服务器返回的原始HTML,根本不是一回事。浏览器会执行HTML中的script标签,拉取更多数据再渲染成最终页面,这个过程叫“客户端渲染”。而“查看网页源代码”看到的是服务器返回的原样文本,其中很多字段只是JS模板里的占位符,或者干脆只有一段加密的JSON配置文件。
所以第一件事,就是确认页面到底是怎么渲染的。别急着写Selenium代码,先花五分钟做个判断。
1.2 快速判断的四种方法
我常用的判断方式有四种,从最快到最稳排列:
-
看服务器返回的HTML里有没有目标字段。直接用requests请求目标URL,把返回的response.text存成文件,Ctrl+F搜索你要的数据字段。如果搜得到,说明是服务端渲染,直接解析就行。如果搜不到,说明数据是后续动态加载的。
-
对比“查看网页源代码”和开发者工具中的Elements。打开浏览器按F12,在Elements面板看到的内容是“渲染后的DOM”,它可能已经被JavaScript修改过。而右键查看网页源代码,是“原始HTML”。两份内容一对比,如果差别很大,基本上可以断定有脚本在起作用。
-
抓包看XHR/ Fetch请求。打开开发者工具切到Network面板,勾选Fetch/XHR,刷新页面,观察网络请求列表。如果看到几个返回JSON的接口,那思路就清晰了:Selenium顶多是帮你触发这些接口,真正要解析的数据在JSON里。这一步还能顺便拿到接口地址和参数,我后面会说“能直接调接口就别上Selenium”的逻辑,就来自这里。
-
禁用JavaScript后再打开页面。Chrome浏览器设置里可以临时把JavaScript关掉,再刷新目标页面。如果页面变成一片空白或只剩框架,说明内容几乎全依赖JS渲染。这个办法最直观,一看便知。
1.3 能直接调接口就别上Selenium
这里要插一段非常重要的话,也是我给所有新手的忠告:Selenium不是万能钥匙,它只是最后一招。
原因很简单——Selenium要启动一个完整的浏览器,加载页面、执行脚本、渲染样式,开销远大于一次普通的HTTP请求。别人300毫秒能跑完的请求,它可能要用3到5秒,性能差一个数量级。而且真实浏览器还会暴露更多指纹特征,更容易触发反爬。
所以,如果Network面板里发现了明确的JSON接口,优先试试直接用requests复现这个接口。找到需要的参数和鉴权方式,你就能用最轻量的方式拿数据。Selenium适合的是“接口参数做了签名加密、直接调用完全推导不出来”的场景,或者“数据在渲染过程中经过复杂计算才写入DOM”的场景。在这些情况里,与其死磕JS逆向,不如让浏览器替你把活干完。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Selenium是只“重剑”:环境搭建中几个讨厌的坑
2.1 ChromeDriver版本匹配:90%的新手报错源头
决定用Selenium之后,第一个坑就是环境。Selenium本身是一个自动化协议库,它不直接操作浏览器,而是通过一个驱动中间层来发指令。Chrome就对应ChromeDriver,Firefox对应GeckoDriver。驱动版本和浏览器版本必须匹配,否则直接报SessionNotCreatedException。
这是我见过最频繁的入门报错。解决办法其实很简单:
- 打开Chrome浏览器,点右上角三个点,进“帮助 -> 关于Google Chrome”,记下完整版本号,比如“120.0.6099.130”。
- 去ChromeDriver的官方下载站,找Major Version(也就是版本号第一个数字)相同的版本。比如浏览器是120.x,就下载120开头的驱动。
- 下载后把驱动文件放到一个固定目录,或者直接放进Python的Scripts目录,确保系统PATH里能找到。
一个很容易忽略的细节是:Chrome会自动更新,今天匹配好的版本,过两周浏览器一变,驱动就失效。所以很多时候“昨天还好好的,今天突然崩了”,一查就是Chrome偷偷升级了。建议在代码里先打印一下driver的版本,或者用webdriver-manager这个库实现驱动版本自动识别和管理。这个库会自动匹配本地浏览器版本去下载驱动,能省掉很多重复操作的麻烦。
2.2 headless模式不是加个参数就行
环境跑通之后,大多数人会立刻打开headless模式,也就是“无头浏览”。这个模式的好处是不弹出浏览器窗口,节省资源,也适合部署在服务器上。但很多人的第一版headless代码,跑起来要么被网站识别,要么页面渲染不完整。
先说基本配置:
python复制from selenium import webdriver
from selenium.webdriver.chrome.options import Options
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')
driver = webdriver.Chrome(options=options)
这几个参数里,--no-sandbox和--disable-dev-shm-usage在Docker容器里几乎是必加的,不加大概率会出现“DevToolsActivePort file doesn't exist”之类的诡异报错。--disable-gpu在Windows下曾经是必须的,后来Chrome调整了,但在Linux远程环境里还是建议加上。--window-size要重点说,headless模式下如果窗口尺寸设得太小,部分页面会把移动端样式渲染出来,元素的class名、布局都变了,导致定位失败。
另外,headless模式是反爬重点关注对象。很多网站会检查User-Agent里是否包含“HeadlessChrome”字段,或者通过一些特征判断浏览器是否真的有窗口。所以光加headless参数还不够,通常得配合下面几个操作:
python复制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')
这段操作是给浏览器补一个正常的UA标识。别小看这一行,很多页面没有它直接拒绝访问。
2.3 驱动安装路径与PATH误区
还有一个让人血压升高的场景:明明ChromeDriver已经下载好了,Python也提示找不到“chromedriver”可执行文件。原因无非两种,一是驱动没放进PATH包含的目录,二是文件名不对。Windows下驱动文件一定要叫chromedriver.exe,Linux/macOS下叫chromedriver,下载下来如果叫其他名字,需要手动重命名。
如果不想配置PATH,最简单的办法是直接把驱动路径传给webdriver.Chrome:
python复制driver = webdriver.Chrome(executable_path='/path/to/chromedriver', options=options)
不过Selenium 4.0以后,executable_path参数已经被标记为不推荐了,正确做法是用service:
python复制from selenium.webdriver.chrome.service import Service
service = Service('/path/to/chromedriver')
driver = webdriver.Chrome(service=service, options=options)
Selenium 4的API变化比较大,网上很多老教程还停留在3.x版本,照着抄容易踩版本兼容的坑。这也是我建议用webdriver-manager的原因——它把版本匹配和路径管理都封装好了。
3. 元素定位与等待机制:动态加载的真正难点
3.1 等待机制:三种写法的区别与取舍
Selenium运行的核心流程是“定位元素 -> 操作元素”。但动态页面有个特点:元素不是同时出现的,而是随着接口返回和渲染进度陆续出现。如果你在按钮还没生成时就去点击,必然报NoSuchElementException。
新手最常见的解决办法是time.sleep(5),等页面加载完再操作。这个方法不是不行,但有两个致命问题:一是浪费大量时间,网络快了等太久,网络慢了等不够;二是行为不自然,容易触发反爬的“人类行为检测”。页面加载速度和接口响应速度是动态的,固定等待时长解决不了根本问题。
正确做法是使用WebDriverWait,也就是显式等待:
python复制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.CSS_SELECTOR, '.product-price'))
)
这段代码的意思是:在10秒内,每隔一小段时间就检查一次页面中是否存在.product-price这个元素,一旦出现立刻返回,超时才报错。相比于固定sleep,它只在“该出现的时候”等待,网络快就快,网络慢就慢,不会做无用功。
至于implicitly_wait,也就是隐式等待,它作用于driver的整个生命周期,所有元素操作都会等待一段时间。这个机制适合“页面整体加载速度比较均匀”的场景,但在动态渲染中容易造成误判——页面框架出来了,但数据还没填充,隐式等待依然会找到元素,可元素的text是空的。所以我个人的习惯是:显式等待为主,隐式等待尽量别用,或者设置一个很短的兜底时间。
3.2 定位方式怎么选:CSS Selector是首选
Selenium提供了多种定位方式,id、name、class_name、css_selector、xpath等。我的实际经验是:能用CSS Selector就别用XPath,除非XPath的场景非常明确。
为什么这么说?CSS Selector语法简单、执行效率高,而且很多浏览器开发者工具支持直接复制CSS路径。比如Chrome的Elements面板里,选中一个元素,右键“Copy -> Copy selector”,就能拿到一条可直接用于driver.find_element的路径。对新手来说,这个功能非常省事。
XPath的能力更强,能处理“根据某个子元素的文本值找父元素”“找某个条件下的兄弟节点”这类复杂场景,但性能开销略大,而且遇到页面结构改动时往往更脆弱。比如一个列表里的“加入购物车”按钮有几十个,你要精确定位第3个,XPath的index定位确实好用:
python复制driver.find_element(By.XPATH, "//div[@class='item'][3]//button[@class='buy-btn']")
总之,我的建议是:优先用CSS Selector,复杂结构再用XPath。另外,尽量不用链路特别长的定位表达式,页面稍微一动就全断了。给元素加个id或者写个更贴近业务语义的class名,是前端合作时最实用的沟通点。
3.3 滚动加载与懒加载:数据不全的隐藏原因
动态页面还有一类容易踩坑的地方是“滚动懒加载”。不少网站为了性能,不会一次性把所有数据渲染出来,而是等用户滚动到接近页面底部时,才通过IntersectionObserver或scroll事件触发加载更多内容。
这种情况直接定位元素,往往只能拿到第一屏的数据。处理思路是:让页面不断滚动到底部,同时持续等待新的数据元素出现,直到某次滚动后列表数量不再增长。
python复制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
这段逻辑的核心是“以页面高度是否变化作为终止条件”。注意,某些页面会有“加载中”的动画,即使高度没变,后续数据也可能还没加载完,所以建议再搭配一个显式等待来检查“加载中”元素是否消失。如果数据是无限流,没有明确的结束条件,那就得自己设定一个最大滚动次数或最大数据条数,避免程序无限循环下去。
4. 反爬对抗:Selenium一开就被识别怎么办
4.1 常见的检测手段
当Selenium环境稳定运行起来之后,很多人会发现下一个问题:目标网站似乎知道你是机器人。不弹验证码,也不封IP,就是返回一个异常页面,或者让你“拖动滑块验证”。
这里要理解,网站是怎么识别Selenium的。最常见的手段是检查浏览器的JS全局变量,比如:
- navigator.webdriver:Selenium驱动下这个属性默认是true,正常浏览器是undefined。
- window.chrome:很多无头浏览器或自动化环境不具备完整的window.chrome对象。
- navigator.plugins:自动化环境下plugins数组经常为空。
- navigator.languages:部分自动化环境不设置语言,或只有单一语言。
- User-Agent:headless模式的UA里通常包含“HeadlessChrome”字样。
另外还有一些更高级的检测方式,会检查canvas指纹、WebGL信息、浏览器是否真的出现在前台等。
4.2 绕过思路:JS注入与undetected-chromedriver
针对这些检测,业界已经有比较成熟的方案。一种思路是在页面加载前注入JavaScript,手动修改navigator.webdriver和其他属性。Chrome DevTools Protocol(CDP)里有一个Page.addScriptToEvaluateOnNewDocument接口,可以在每次页面加载前执行自定义脚本。Selenium对这个接口做了封装:
python复制driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {
'source': '''
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
});
Object.defineProperty(navigator, 'languages', {
get: () => ['zh-CN', 'zh']
});
Object.defineProperty(navigator, 'plugins', {
get: () => [1, 2, 3, 4, 5]
});
'''
})
这段代码能解决一部分基础检测,但对那些检测逻辑特别强的网站还是不够。更省事的方案是直接用undetected-chromedriver这个库。它本质上是对ChromeDriver的补丁,在启动时打了一些反检测补丁,让自动化浏览器在JS层面的行为更接近真实用户。
python复制import undetected_chromedriver as uc
driver = uc.Chrome()
driver.get('https://example.com')
要注意的是,undetected-chromedriver对chromedriver的版本比较敏感,建议用它的默认配置,不要手动指定版本。如果网站检测太强,还有一招更稳的——见下一条。
4.3 接管真实浏览器:debugger模式
最稳妥的绕过策略,其实是根本不启动一个“自动化浏览器”,而是接管你手动打开的、已经登录过的真实浏览器。
具体做法是:先用普通方式打开Chrome,指定一个远程调试端口:
bash复制chrome.exe --remote-debugging-port=9222 --user-data-dir=C:/temp/chrome-profile
然后用Selenium连接这个已有实例:
python复制from selenium import webdriver
from selenium.webdriver.chrome.options import Options
options = Options()
options.add_experimental_option('debuggerAddress', '127.0.0.1:9222')
driver = webdriver.Chrome(options=options)
这个方案的好处很多:首先,这是一个真实的浏览器进程,指纹完全正常;其次,你可以在里面手动登录目标网站、通过滑块验证,然后Selenium直接在这个会话里继续跑,天然免疫登录态问题。缺点是必须先手动启动浏览器,自动化程度稍低,但遇到那些“神仙级”反爬网站,这往往是最省心的方案。
5. 性能优化:让Selenium跑得更快
5.1 页面加载策略
Selenium默认会等整页完全加载完才执行下一步操作,包括所有图片、样式、脚本。但实际上我们往往只需要部分数据,完全加载是巨大的浪费。
ChromeOptions里有一个page_load_strategy参数,可以调整加载策略:
- normal:默认,等待所有资源加载完成。
- eager:等待DOM解析完成即可,不等样式表、图片和子框架。
- none:不等待任何标识,页面打开就继续。
对于绝大多数抓取场景,eager是个不错的选择,能明显缩短等待时间:
python复制options.page_load_strategy = 'eager'
不过要注意,eager模式不等子框架加载,如果目标数据在iframe里,可能还得配合显式等待来兜底。
5.2 拦截图片与无关资源
另外一个常见的性能大坑是页面加载了大量图片、字体、视频资源。爬虫不关心这些资源的加载,但浏览器会去下载它们。一个简单有效的办法是通过CDP拦截指定的资源类型:
python复制driver.execute_cdp_cmd('Network.enable', {})
driver.execute_cdp_cmd('Network.setBlockedURLs', {
'urls': ['*.jpg', '*.png', '*.gif', '*.webp', '*.css', '*.woff', '*.woff2']
})
这段代码要放在打开页面之前执行。实际测试下来,对图片密集型的页面,能省下不少时间和流量。但注意,有些网站的懒加载逻辑依赖图片的src属性来判断元素是否可见,屏蔽图片后可能导致列表加载不到更多内容,所以需要根据具体页面进行调整。
另外,ChromeOptions还支持通过prefs禁止加载图片:
python复制prefs = {'profile.managed_default_content_settings.images': 2}
options.add_experimental_option('prefs', prefs)
这两种方式各有优劣,prefs方案更彻底,但某些页面会依据图片尺寸来判断滚动位置。CDP方案的粒度更细,适合按需拦截。
5.3 requests + Selenium 混合方案
如果目标页面是“Selenium必须参与,但并不是所有数据都要通过浏览器拿”,那可以考虑混合方案。
典型做法是:用Selenium完成最复杂的登录、验证码、加密参数生成环节,拿到后续接口需要的cookies或token,然后丢给requests继续请求接口。这样一来,请求量和耗时都大幅降低,requests的高并发能力也被用上了。
python复制cookies = driver.get_cookies()
session = requests.Session()
for cookie in cookies:
session.cookies.set(cookie['name'], cookie['value'])
# 然后直接用session去请求目标接口
这种做法的高明之处在于:Selenium只做自己最擅长的事情——处理复杂交互。而高频、大量的数据请求交给性能更优的requests去完成。很多生产级别的爬虫架构都是这么设计的,兼顾了对抗复杂度的能力和抓取效率。
6. 一个完整的实战:JavaScript渲染商品列表页的抓取全流程
6.1 场景与目标
为了把前面这些知识点串起来,我拿一个相对典型的场景演示一下:抓取某个商品列表页的数据,页面是典型的JavaScript渲染,数据要等接口返回后才会显示到页面上,而且只显示第一页,往下滚动才会加载更多。
目标字段:商品名称、价格、评论数、商品链接。
6.2 核心代码实现
先搭一个完整的Python脚本框架。首先是配置driver,带上前面说过的加载策略、资源拦截和反检测脚本:
python复制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
import csv
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')
options.page_load_strategy = 'eager'
driver = webdriver.Chrome(options=options)
# 拦截图片、字体等无关资源
driver.execute_cdp_cmd('Network.enable', {})
driver.execute_cdp_cmd('Network.setBlockedURLs', {
'urls': ['*.jpg', '*.png', '*.gif', '*.webp', '*.css', '*.woff', '*.woff2']
})
# 反检测:把webdriver标记去掉
driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {
'source': '''
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
});
'''
})
driver.get('https://example.com/list')
打开页面后,用显式等待确保列表元素出现:
python复制wait = WebDriverWait(driver, 10)
wait.until(
EC.presence_of_element_located((By.CSS_SELECTOR, '.product-item'))
)
接下来是一个滚动加载循环,同时解析每次出现的新元素:
python复制products = []
last_height = driver.execute_script("return document.body.scrollHeight")
for _ in range(20): # 最多滚20次
items = driver.find_elements(By.CSS_SELECTOR, '.product-item')
for item in items:
try:
name = item.find_element(By.CSS_SELECTOR, '.name').text.strip()
price = item.find_element(By.CSS_SELECTOR, '.price').text.strip()
comment = item.find_element(By.CSS_SELECTOR, '.comment-count').text.strip()
link = item.find_element(By.CSS_SELECTOR, 'a').get_attribute('href')
products.append({
'name': name,
'price': price,
'comment': comment,
'link': link
})
except Exception:
continue
# 去重,避免重复解析旧元素
products = list({p['link']: p for p in products}.values())
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
这里有两个非常关键的细节值得展开说。
第一个是“去重”。滚动加载时,页面里的旧元素并没有被销毁,只是被新元素挤到了上方。如果你每次都全量解析,同一个商品会被重复记录。按链接去重是最稳妥的,因为链接是唯一ID。
第二个是“滚动后等待”。不能滚动完立刻去解析,得留一点时间给接口返回和DOM更新。
6.3 解析与存储
数据解析完成后,写入CSV:
python复制with open('products.csv', 'w', newline='', encoding='utf-8-sig') as f:
writer = csv.DictWriter(f, fieldnames=['name', 'price', 'comment', 'link'])
writer.writeheader()
writer.writerows(products)
driver.quit()
注意编码用了utf-8-sig而不是utf-8,否则在Windows上用Excel打开CSV时中文会乱码。这是个小细节,但很多人会卡在这里。
6.4 我的几个小技巧
最后再分享几个我在实际项目中沉淀下来的经验,不一定写在论文里,但一定好用:
第一,元素找不到时,先别急着改代码。 打开浏览器的开发者工具,在Console里手动执行document.querySelector试试,看能不能拿到元素。如果拿不到,说明页面还没渲染到这一步,优先调整等待时机。如果能拿到但Selenium拿不到,大概率是iframe或者Shadow DOM的问题。iframe要先用switch_to.frame切入,Shadow DOM要用特殊的定位方式,这两个都属于进阶问题,但遇到时要知道排查方向。
第二,日志比断点好使。 在关键节点打印页面标题、当前URL、可见元素数量,比在IDE里打断点调试好用得多。因为Selenium是远程操作浏览器,断点容易卡住页面导致状态丢失。打印日志虽然朴素,但能清晰看到整个流程每一步的进展。
第三,给浏览器设置固定的user-data-dir。 浏览器每次启动都用一个全新配置,登录状态、localStorage全都没了,大量时间浪费在重复登录上。指定user-data-dir参数后,浏览器会把缓存、cookie、登录状态都保存在本地目录里,第二次启动时自动恢复,能省下一大截时间。
第四,异常处理一定要做。 动态页面就算是同一条链路,每次运行也可能遇到不同的网络波动。给关键步骤加try/except,出异常时记录日志并重试,是保证爬虫稳定跑批的基础能力。
Selenium这条路,刚开始用会觉得笨重,用久了就会慢慢体会到:它从来不是为了“高效”而生的工具,而是为了“复杂”而生的工具。理解了这一点,你在面对JavaScript渲染页面时,就不会再被“怎么解析都拿不到数据”卡住,而是能根据页面特征,选择最匹配的那套方案。
