做爬虫遇到JavaScript渲染的页面,用Selenium是最直接的解法。这些年抓过不少站点,从简单的静态HTML到复杂的单页应用,最折腾我的永远是那些用Ajax、Vue、React渲染出来的内容——你用requests能拿到HTML源码,但里面空空如也,数据全是空的。Selenium就是在这种情况下派上用场,它直接驱动真实浏览器,让页面里的JavaScript完整执行一遍,HTML里有什么,你就能拿到什么。这篇内容适合那些已经会用requests和BeautifulSoup、但遇到动态页面就抓瞎的开发者,也适合准备系统学习浏览器自动化的人。我会把从环境配置到实战抓取、再到排查反爬的完整链路都讲一遍,所有代码都基于Python版本,拿来就能跑。
1. 为什么JavaScript渲染会让传统爬虫失效
1.1 静态抓取的本质
传统爬虫的逻辑很简单:发一个HTTP请求,拿到响应内容,然后从HTML里提取数据。requests做的就是这个事,它本质上是模拟了一个HTTP客户端,跟浏览器的差别在于它不执行JavaScript。一个典型的GET请求会返回完整的HTML文档,里面包含了标题、段落、链接等静态内容。如果你的目标是那种服务端渲染的页面,比如早期的论坛、博客或者一些后端模板输出的站点,这种方式已经完全够用。
但很多人没意识到,现在的页面已经不是过去的页面了。前端工程化普及之后,越来越多的站点选择用JavaScript框架来构建用户界面。数据不再是服务端直接拼进HTML,而是通过接口异步加载,再由前端代码把数据渲染到页面上。你拿到的HTML只是一个空壳,里面可能只有几个div节点和一堆script标签。
这里有个关键点需要理解:爬虫要解决的核心问题是“内容获取”,而内容获取的前提是“内容可见”。静态抓取只能看到服务端主动给你看的东西,动态渲染的内容则必须由浏览器这个执行环境去“跑”出来。Selenium的本质就是替你打开一个真实浏览器,让所有的脚本都执行完毕,然后再从浏览器里读取最终的内容。
1.2 动态渲染的常见场景
我归纳了一下,JavaScript渲染的页面大致有这么几类场景。
第一类是数据异步加载。典型的表现是页面先出骨架屏,然后通过XHR或fetch请求从后端接口拿数据,最后渲染成列表或图表。这类页面用requests抓的时候,HTML里只有一个loading状态,数据完全抓不到。
第二类是交互后触发渲染。比如点击某个按钮、下拉选择某个选项、滚动到底部加载更多,这些操作都会触发新的请求。如果你不去操作,页面永远不会加载那个区域的内容。
第三类是前端路由单页应用。整个站点只有一个HTML页面,所有内容切换都是通过JavaScript控制的。你访问任何一个子路径,浏览器都不会重新请求整个HTML,而是通过JavaScript动态替换内容。
第四类是JS混淆或字体反爬。有些站点会故意用JavaScript生成真实的文本内容,或者把字体文件进行转换,导致静态HTML里的内容不是人类可读的。这种情况虽然不全是渲染问题,但也需要通过真实浏览器去执行那些混淆脚本才能拿到正确答案。
1.3 为什么不用requests硬刚
可能有朋友会说,数据是通过接口拿的,那我去分析接口、直接请求接口不就行了。这个思路在部分场景确实可行,但存在很多麻烦。
首先是接口分析成本高。你要打开开发者工具,找到XHR请求,分析请求参数、headers、加密逻辑、签名算法。很多站点会给接口加上时间戳、token、加密参数,每次访问都要动态生成。逆向这些算法需要额外的时间和技术栈。
其次是接口数据格式不一定是HTML。你可能拿到的是JSON,你需要自己拼接渲染逻辑,才能得到最终的呈现结果。
第三是接口可能被风控。很多站点会对接口的请求频率、请求头、referer做校验,比页面请求更严格。你辛辛苦苦分析出的接口,可能请求几次就被封了。
Selenium的方式则完全不同。它操作的是浏览器本身,页面怎么发请求、带什么参数、加密逻辑怎么执行,全部由浏览器自己处理。你要做的只是等页面渲染完,然后读取渲染好的内容。相当于你雇了一个真人帮你操作浏览器,爬虫要做的就是记录这个人的操作结果。
2. 环境准备与Selenium基础认知
2.1 安装和驱动配置
Selenium的安装非常简单,用pip就能完成。我建议直接装新版Selenium,因为新版本的API更规范,而且内置了一些管理驱动的工具。
pip install selenium装完Selenium之后,还需要一个浏览器驱动。Selenium是通过驱动和浏览器通信的,驱动相当于一个中间桥梁。我一般用Chrome和ChromeDriver,兼容性最好,资料也最多。驱动必须和你本地的Chrome版本匹配,版本号不一致就会报错。
新版Selenium提供了自动管理驱动的能力,用Selenium Manager可以省去手动下载驱动的步骤。当你的环境允许联网时,它会在第一次启动时自动下载匹配的驱动。不过我会建议手动配置一次,因为很多生产环境是断网的,手动管理驱动更可控。
下面是一个最小可运行的示例:
from selenium import webdriver options = webdriver.ChromeOptions() options.add_argument("--headless=new") driver = webdriver.Chrome(options=options) driver.get("https://example.com") print(driver.title) driver.quit()这段代码启动了Chrome浏览器,访问了一个页面,打印了标题,最后关闭。注意这里的--headless=new是新版无头模式,后面会详细讲。
2.2 浏览器自动化核心API速览
Selenium的核心API就是围绕“找元素”和“操作元素”展开的。
找元素的方式主要有两种:find_element和find_elements。前者返回第一个匹配的元素,后者返回所有匹配的元素的列表。你可以在后面加定位策略,比如By.ID、By.CLASS_NAME、By.CSS_SELECTOR、By.XPATH。我在实际工作中,最常用的是CSS选择器和XPath。CSS选择器简洁,XPath功能强大,尤其是在处理包含层级关系的节点时。
操作元素的API包括click()、send_keys()、clear()、text属性等。点击不灵敏时,可以用ActionChains模拟鼠标移动、拖拽、悬停等复杂操作。滚动页面用driver.execute_script("window.scrollTo(0, document.body.scrollHeight)"),执行任意JavaScript代码都可以通过这个接口。
还有一个很重要的API是driver.page_source,它返回当前页面的HTML源码。如果你只需要数据,可以直接拿到这个字符串然后用BeautifulSoup解析。这样做的性能比用Selenium内置的定位方法高一些,因为BeautifulSoup是纯Python解析,Selenium则需要和浏览器多次通信。我的经验是:能用page_source+BeautifulSoup解决的问题,就不要用Selenium去找元素,效率差好几倍。
2.3 无头模式与性能折中
Selenium启动真实浏览器是有界面的,会弹出一个Chrome窗口。这在调试的时候很方便,但放到服务器上跑就不太合适了。此时可以开启无头模式,让浏览器在后台运行,不显示界面。
无头模式的写法在不同版本上有差异。旧版本用--headless,新版本推荐用--headless=new。无头模式比有头模式快很多,但有些站点会检测无头模式,比如检查navigator.webdriver属性、查看User-Agent、检测某个窗口特征等。如果遇到检测,可能需要放弃无头模式,改用有头模式配合xvfb虚拟显示,或者在无头模式里通过添加参数来伪装。
另一个性能折中是加载时间。Selenium等待页面加载的默认策略是normal,也就是等待所有资源都加载完毕。但很多页面的图片、广告、第三方脚本非常耗时,而我们并不需要它们。可以设置页面加载策略为eager或none,只等待HTML加载,或者完全不等待。相关参数如下:
options.page_load_strategy = "eager"另外,还可以通过options.add_experimental_option("prefs", {...})来禁用图片、禁用JavaScript(当然禁用JavaScript就违背初衷了)、禁用Flash等。合理配置这些选项,可以让Selenium的运行速度提升一半都不止。
3. 实战案例:抓取一个JS渲染的列表页
3.1 目标页面分析
我们拿一个典型的JavaScript渲染的列表页来做例子。为了不涉及具体站点,这里用“某模拟平台”代称,这个平台的数据完全由前端动态加载,初始HTML里只有空容器。
在动手写代码之前,先打开页面,按F12进入开发者工具,切到Network面板,刷新页面。你会发现页面一开始发了很多请求,其中有一个XHR请求返回的是JSON格式的数据,里面包含了列表的主要内容。这个观察过程能帮你理解渲染流程,但你不一定需要直接去调这个接口,因为用Selenium的重点就是绕开接口分析。
我们要抓的是一个商品列表页,包含商品名称、价格、销量三个字段,一共有3页数据。页面底部有“下一页”按钮,点击后内容更新。
3.2 显式等待的正确姿势
这是Selenium新手最容易踩坑的地方。刚学Selenium的人,写完driver.get()之后就直接去找元素,经常会碰上NoSuchElementException。原因很简单:浏览器刚打开页面,JavaScript还没执行完,元素还没生成出来。解决方案是等待。
Selenium有隐式等待和显式等待两种。隐式等待设置之后,每次查找元素都会轮询一段时间,直到元素出现或超时。写法是:
driver.implicitly_wait(10)但隐式等待有个问题:它对所有查找命令生效,如果元素一直不存在,它会一直等满超时时间,拖慢整个流程。更推荐的是显式等待,针对某个条件进行等待:
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) product_link = wait.until( EC.presence_of_element_located((By.CSS_SELECTOR, ".product-item")) )这段代码的意思是:最多等10秒,等.product-item出现。元素一旦出现就立即返回,不会空等10秒。这比固定的sleep要高效得多。
我自己的习惯是优先用显式等待,尤其是等待数据加载完成时,可以配合判断某个子元素是否包含文案,或者等待某段JavaScript变量发生变化。比如有些页面加载完会在某个DOM节点上显示“共100条记录”,你可以用EC.text_to_be_present_in_element来等这个文本出现。
3.3 提取数据与翻页处理
等元素出现之后,就可以提取数据了。我用两种方式混合处理:先拿到所有商品项的列表,然后逐个提取子元素。
items = driver.find_elements(By.CSS_SELECTOR, ".product-item") for item in items: name = item.find_element(By.CSS_SELECTOR, ".item-name").text price = item.find_element(By.CSS_SELECTOR, ".item-price").text sales = item.find_element(By.CSS_SELECTOR, ".item-sales").text print(name, price, sales)这里有个细节:find_elements返回的是元素快照,页面如果重新渲染了,之前拿到的元素可能已经失效,继续操作会报StaleElementReferenceException。解决的办法是每翻一页之后,重新获取元素列表,而不是复用旧的引用。
翻页处理也不难。找到“下一页”按钮,然后点击。但点击之后页面可能不是完整的整页刷新,而是通过Ajax局部刷新,这时需要重新等待新内容出现。一个实用技巧是:先记录当前页第一项的名称,点下一页之后,等待页面中出现一个新的名称,再继续抓取。
next_btn = driver.find_element(By.CSS_SELECTOR, ".pagination .next") next_btn.click() wait.until(EC.staleness_of(next_btn))注意这里的EC.staleness_of,它会等待旧按钮变成失效状态,从而判断页面已经发生了更新。这个方法在翻页、搜索、筛选场景中非常实用。
3.4 完整代码示例
下面给一个可以完整运行的示例,抓3页商品数据,存到列表里。
import time from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC options = webdriver.ChromeOptions() options.add_argument("--headless=new") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") options.page_load_strategy = "eager" driver = webdriver.Chrome(options=options) wait = WebDriverWait(driver, 10) base_url = "https://example-market.com/list" results = [] for page in range(1, 4): driver.get(base_url + f"?page={page}") wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, ".product-item"))) items = driver.find_elements(By.CSS_SELECTOR, ".product-item") for item in items: name = item.find_element(By.CSS_SELECTOR, ".item-name").text price = item.find_element(By.CSS_SELECTOR, ".item-price").text sales = item.find_element(By.CSS_SELECTOR, ".item-sales").text results.append({"name": name, "price": price, "sales": sales}) driver.quit() for r in results: print(r)这个例子用了URL参数翻页的方式,比较简单。如果遇到只能点击按钮翻页的站点,把driver.get改成点击下一页按钮即可。
提示:实际项目中不要硬编码URL,最好通过观察页面结构确认翻页规律。有些站点是无限滚动,需要模拟滚动到底部来加载数据,这种情况下就另当别论了。
4. 常见问题与排查技巧实录
4.1 元素找不到或超时
NoSuchElementException和TimeoutException是Selenium两个最常见的报错。元素找不到,不是你的定位写错了,就是元素还没渲染出来。我一般按这个顺序排查:
第一步,确认定位表达式是否正确。在开发者工具的Console里跑一下document.querySelectorAll(".product-item"),看看能不能匹配到节点。如果匹配不到,说明选择器错了,或者元素在iframe里。
第二步,确认元素是否在iframe中。很多页面会把广告、弹窗、甚至主要功能放在iframe里。这时候需要先切换进iframe再找元素:
iframe = driver.find_element(By.TAG_NAME, "iframe") driver.switch_to.frame(iframe) # 操作iframe内部元素 driver.switch_to.default_content()第三步,确认是否被遮挡。有时候元素已经存在,但被另一个元素盖住,导致click()失败。解决办法是直接执行JavaScript点击:
driver.execute_script("arguments[0].click();", element)第四步,确认等待条件是否合理。presence_of_element_located只检查元素存在,而visibility_of_element_located检查元素可见,element_to_be_clickable检查元素可点击。如果你要点击一个按钮,最好用element_to_be_clickable。
4.2 弹窗、验证码和iframe
弹窗是自动化经常遇到的头疼事。你正跑着脚本,突然弹出一个广告、一个公告、或者一个下载提示,把页面盖住,后面的操作全部失败。
处理弹窗的思路有两种:一种是主动关闭它。定位关闭按钮,点击。另一种是绕过它。如果你根本不需要弹窗里的内容,可以忽略。但问题是弹窗遮挡了后面的元素,Selenium对不可见元素的交互会报错。
我试过最靠谱的办法是,在启动浏览器时就通过参数屏蔽掉一些可能的弹窗。比如禁用通知、禁用弹窗拦截:
prefs = { "profile.default_content_setting_values.notifications": 2, } options.add_experimental_option("prefs", prefs)遇到验证码就比较棘手了。图形验证码可以截图后用OCR识别,滑块验证码则需要调用图像识别技术,还有一些验证码本身就有极高的反自动化能力。我的经验是:如果目标站点有高强度验证码,Selenium这类模拟人工操作的方式效率并不高,考虑转向接口方案或者找找有没有无验证码的移动端接口。别把所有时间耗在验证码上,这不太划算。
4.3 反爬识别与浏览器特征隐藏
很多站点能检测出你是Selenium驱动的浏览器,然后弹出验证码、限制访问或返回假数据。检测手段主要是通过JavaScript读取navigator.webdriver属性,正常浏览器这个值应该是undefined,而Selenium设置的浏览器这个值会是true。
处理方案有几种。最直接的,在启动选项里添加排除项:
options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option("useAutomationExtension", False)再配合JavaScript执行前修改webdriver标记:
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", { "source": """ Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); """ })这段代码会在每次新文档加载前执行,把webdriver属性伪装成undefined。但我要提醒一句:这只能应付一些基础检测,高级的指纹识别还会看你的浏览器指纹、WebGL渲染器、字体列表、Canvas特征。真要针对你的站点做反爬,靠Selenium硬刚是下策。更稳的做法是选用本身就基于真实浏览器内核的方案,或者降低访问频率,让请求看起来更像正常人。
4.4 性能优化:降低资源占用
Selenium跑自动化非常吃资源,每个Chrome进程平均占用几百兆内存,跑十几个并发任务就能把一台8G内存的服务器拖垮。我这里有几个优化技巧。
第一个是开启垃圾回收和优先使用无头模式。无头模式比有头模式少渲染很多东西,内存占用少不少。
第二个是合理使用driver.quit()。每个driver用完一定要关闭,否则进程会一直留在内存里。有时脚本报错了,driver没被正确关闭,系统里会残留大量chrome进程。建议用try...finally确保退出,或者用上下文管理器。
第三个是限制加载资源。通过设置prefs禁用图片、CSS动画等:
prefs = {"profile.managed_default_content_settings.images": 2} options.add_experimental_option("prefs", prefs)禁用图片不影响文本数据,但能节省大量带宽和时间。
第四个是考虑复用浏览器实例。如果你有多个任务,不要每次都新开浏览器,可以保持一个常驻的driver,通过打开新标签页来处理不同任务。当然这需要你管理好标签页,避免串台。
5. 进阶:从Selenium走向更优方案
5.1 接管现有浏览器会话
有时候你会发现,Selenium启动的浏览器和平时自己用的浏览器表现不一样,有些网站能正常访问,启动Selenium后却被要求验证。那是因为Selenium启动的浏览器是一个全新的临时环境,没有你平时的登录Cookie、没有历史记录、没有插件。
解决方案是复用本机的浏览器profile。在启动时加载你平时的用户数据目录:
options.add_argument("--user-data-dir=/path/to/chrome/profile")这样Selenium启动时就会使用你平时那个浏览器环境,登录状态和Cookie都还在。好处是很多需要登录的页面可以省去登录流程。坏处是如果你平时浏览器开着,新启动的Selenium会冲突,所以要先关掉原来的浏览器。这个方法在抓取需要登录的站点时非常实用。
另外一个更灵活的方案是使用debugging模式接管已经打开的浏览器。先用普通方式启动Chrome并开启远程调试端口,然后Selenium通过该端口连接现有的浏览器,这样就不用复制profile,而且你还能在同一个浏览器里人工操作和自动化操作并行。具体做法是在启动Chrome时带上--remote-debugging-port=9222,然后在Selenium里用debuggerAddress指定这个端口。
5.2 无头浏览器之外的选择
聊到这里,可能会有人问,Selenium是不是处理JavaScript渲染的唯一方案?当然不是。
如果你是抓取数据,而且能确定目标页面是走接口返回JSON,那么直接分析接口往往比Selenium快得多。Selenium是重量级方案,速度慢、资源消耗大、稳定性也受浏览器本身影响。接口方案轻量、快、消耗小,但需要你做逆向。
如果你想找一个介于Selenium和接口方案之间的折中方案,可以试试Playwright。Playwright支持多种浏览器,API设计更现代,自带自动等待机制,还能录制脚本,适合快速上手。它和Selenium不同的地方在于它对浏览器自动化做了更多抽象,比如page.locator的等待机制,用起来比Selenium的显式等待顺手很多。
还有一个方向是嵌入式浏览器方案,比如用CDP协议直接控制一个浏览器实例,或者使用某些基于浏览器内核的自动化库。这些方案适合有特殊需求的场景,比如需要拦截页面请求、修改JavaScript执行结果、采集网络包等。但学习曲线会更陡。
我的建议是:普通的动态页面抓取,Selenium完全够用;如果经常做,可以把Playwright纳入工具箱;遇到强校验的站点,再去考虑接口逆向。方案没有绝对好坏,只有成本高低的区别。
5.3 最后一些经验之谈
做Selenium爬虫,最大的体会是“耐心”。你要接受一个事实:浏览器自动化天然比接口慢,而且不稳定,同样的脚本这次跑通过,下次可能就挂在某个奇怪的元素上。所以写代码的时候要尽量防御式编程,能等待的地方用等待,能重试的地方加重试,能记录日志的地方记录日志。
日志很重要。我见过太多人写爬虫不打印任何中间状态,报错之后一脸茫然。建议每个关键步骤都打一条日志,比如“正在打开页面”、“等待元素出现”、“点击翻页”、“提取到X条数据”,这样出问题的时候你能快速定位到具体环节。
另外,不要忽视robots协议和站点使用条款。技术无罪,但滥用技术会给别人带来困扰。控制请求频率、设置合理的User-Agent、不做大规模并发抓取、不给目标站点造成压力,这是我坚持的原则。爬虫的本质是数据获取,但不是所有数据都该拿。如果你抓取的是公开数据,用于非商业的个人学习或研究,问题不大;如果涉及用户隐私或商业数据,还是提前评估一下法律风险。
最后分享一个小技巧:如果你用Selenium抓取一个页面的数据,但页面里部分内容是通过Canvas或WebGL绘制的,直接用DOM提取是拿不到的。这种情况可以截图然后做OCR,或者通过JavaScript调用Canvas的toDataURL方法把画布内容导出来,再做后续处理。我在某个模拟图表站点上就遇到过这种问题,折腾了几天才发现数据是画在Canvas上的。这也提醒我们,爬虫不只是“找元素”那么简单,理解了页面真正的渲染方式,才能找到正确的提取路径。