ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Selenium解决JavaScript渲染难题:从原理到实战的完整指南

Selenium解决JavaScript渲染难题:从原理到实战的完整指南 爬虫这东西写到后面基本都会撞上一堵墙——发个请求过去明明拿到了一堆HTML但里面空空如也你想抓的数据一个都不在或者数据倒是有但全都是些看不懂的JS变量、加密签名。很多初学爬虫的朋友到了这一步就卡住了下意识以为是Header不够全、Cookie没带够实际上问题往往出在另一个维度你看到的页面压根不是服务器直接给的而是浏览器里JavaScript现场渲染出来的。这篇文章就来聊聊怎么用Selenium这种“带浏览器”的方案解决JavaScript渲染问题。会从原理层面讲清楚为什么有的数据Requests拿不到、Selenium凭什么能拿到再给出可直接抄作业的完整代码、等待策略、反爬应对方案以及我实际踩过的坑和排查思路。如果你正卡在动态页面上这篇应该能帮你省下不少折腾时间。1. 内容整体设计与思路拆解1.1 先搞清楚一个核心问题页面到底是不是JS渲染的在动手写Selenium之前最好先花两分钟判断目标页面属于哪一类这直接决定了技术选型。我的习惯是先用最简单的Requests拉一次HTML然后看一眼里面有没有目标数据。如果HTML里直接能找到那就是服务端渲染老老实实用Requests XPath或正则就行没必要上Selenium纯属杀鸡用牛刀。如果HTML里根本看不到目标数据只有一堆script标签和window.__INITIAL_STATE__之类的变量那基本可以断定是客户端渲染也就是数据要等JavaScript跑起来之后才出现在DOM里。判断方法很简单import requests resp requests.get(https://example.com/some-page) html resp.text if 你要抓的数据关键字 in html: print(服务端渲染直接用Requests) else: print(JS渲染需要考虑Selenium或接口逆向)还有一种情况更隐蔽页面HTML里确实有部分数据但核心列表是异步加载的也就是首屏HTML只给了个页面骨架真正的数据是页面加载完后再通过XHR或Fetch请求拿到的。这种情况用Requests也不是完全不行前提是你能在开发者工具的Network面板里找到那个真正的数据接口然后直接模拟那个接口的请求。但这条路的门槛在于很多站点的数据接口带着签名、时间戳、加密参数逆向成本不低这时候Selenium反而是性价比更高的选择。1.2 为什么Selenium能拿到渲染后的页面Selenium做的事情简单说就是帮你真实地打开一个浏览器内核让页面里的JavaScript在真实的浏览器环境里跑完然后把渲染之后的最终DOM交给你。这和Requests请求拿到的原始HTML是两个完全不同的东西一个是源代码一个是“画完之后的成品”。这里有个关键点值得展开说JavaScript渲染依赖浏览器引擎包括V8这类JS解释器、DOM解析器、CSS引擎还要处理网络请求、事件循环、定时器等等。Requests只是个HTTP客户端它只会把服务器返回的字节流拿回来根本不会去执行里面的JavaScript。所以哪怕你在HTML里看到了document.getElementById(data).innerHTML ...这段代码Requests也无法让它跑起来。Selenium则是直接驱动一个完整的浏览器环境JS得以上下文完整地执行于是数据自然就出现在页面上了。从架构上看Selenium通过WebDriver协议和浏览器通信。WebDriver可以理解成一套标准化的“遥控器协议”它定义了打开网址、找元素、点按钮、输入文字、读取DOM这些操作。Selenium把你要做的操作翻译成WebDriver命令浏览器内核收到命令后执行再把结果传回来。Chromedriver就是Chrome这边的翻译官GeckoDriver是Firefox那边的两边遵循同一个协议所以代码写好了换浏览器往往只需要换一个driver。1.3 使用场景与适用边界Selenium适合的场景很明确数据依赖JS渲染、需要登录后见数据、有复杂交互才能触发加载、或者数据在DOM中但经过多层嵌套。典型如电商平台的价格库存、社交平台的信息流、数据可视化大屏、大量使用Vue和React的现代网站。但Selenium也有明显的代价慢。它跑的是完整浏览器资源占用比纯HTTP请求高一个数量级并且需要维护浏览器驱动版本匹配。所以技术选型上我一直强调一个原则能用Requests解决就不要上Selenium能用接口直接拿下就不要渲染页面只有前面两条路都走不通了Selenium才作为“最后的重型武器”登场。另外一个常见替代方案是Playwright它在API设计和自动等待上比Selenium更现代但Selenium胜在生态老、资料多、坑基本都被踩平了新手学它起步会更稳妥。2. 核心细节解析与实操要点2.1 浏览器驱动的版本匹配与环境准备用Selenium第一步不是写代码而是把环境捣鼓好。这里最坑的就是版本匹配问题Chromedriver版本必须和Chrome浏览器版本大体一致否则启动时会直接报session not created或版本不匹配的错。我踩过一次很典型的坑Chrome自动更新到了新版本但Chromedriver还是旧的结果一跑就挂。后来学乖了直接查版本号再下载对应驱动。版本号可以在浏览器地址栏输入chrome://version查看然后去ChromeDriver的下载页面找对应版本即可。需要注意Chromedriver下载后不能直接运行必须把它放在一个固定目录并在代码里初始化webdriver.Chrome(executable_path/path/to/chromedriver)。现在Selenium 4.x对驱动的处理更友好了一些很多情况下会自动匹配但保险起见还是建议手动指定路径省得出幺蛾子。如果你用的是新版Chrome也可以试试webdriver.Chrome()不加参数它会自动去环境变量里找驱动或者自动下载匹配版本。实测下来这个功能确实省事但在某些网络受限的环境下依然可能失败所以我还是保留了手动指定的习惯。2.2 等待策略隐式等待与显式等待的区别Selenium最核心的一个问题就是“页面到底加载完没有”。网络请求有快有慢JS执行有早有晚如果代码执行到find_element的时候元素还没渲染出来直接就是NoSuchElementException。所以等待策略是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) element wait.until( EC.presence_of_element_located((By.CSS_SELECTOR, .item-title)) )这句话的意思就是“最多等10秒直到class为item-title的元素出现为止”。显式等待的逻辑更贴近真实使用场景因为你只需要等你要的那个东西出现而不是所有元素都加载完。我的使用习惯是干脆放弃隐式等待全程用显式等待。原因很实际——隐式等待虽然写起来省事但碰到复杂页面时很难控制等待粒度比如一个很晚才会出现的元素和一个元素不存在两种情况隐式等待无法区分而显式等待可以精确控制。实践中我还会配合EC.element_to_be_clickable和EC.visibility_of_element_located关注“可点击”和“可见”两个状态因为很多组件虽然出现在DOM中但尚未完成渲染或展示。2.3 浏览器选项配置要性能还是要稳定创建Driver实例的时候很多人直接webdriver.Chrome()就上了。但在实战中我会习惯性把options配一遍尤其注意--headless、--disable-gpu、--no-sandbox这几个参数。其中最重要的一个决定是到底要不要无头模式。无头模式Headless因为不显示浏览器窗口速度快而且不打扰人一开始我也特别喜欢用。但你得知道无头模式与有头模式在行为特征上有微妙差异某些网站会检测navigator.webdriver、window.chrome这些特征很容易识别出你是自动化工具。而且有些页面在无头模式下WebGL渲染结果不同、动画表现不同甚至某些JS功能不触发导致元素加载不出来。所以我的经验是先把代码跑通了然后再换无头模式做效率优化。不要一开始就无头否则出了问题连页面长什么样都不知道排查起来非常痛苦。我给出一份比较稳妥的配置模板细节上都注释过from selenium import webdriver from selenium.webdriver.chrome.options import Options options Options() options.add_argument(--headlessnew) # 新版无头模式旧版是 --headless options.add_argument(--disable-gpu) # GPU加速在无头模式下没有意义 options.add_argument(--no-sandbox) # Linux服务器上必需普通电脑可以不加 options.add_argument(--disable-dev-shm-usage) # 容器环境内存受限时必备 options.add_argument(--window-size1920,1080) # 设定窗口大小有时影响懒加载判断 options.add_argument(--langzh-CN) # 避免出现英文界面有些站点根据语言返回不同内容 options.add_experimental_option(excludeSwitches, [enable-automation]) # 弱化自动化标记 driver webdriver.Chrome(optionsoptions)还有一个很多人都不知道的小技巧如果你用--user-data-dir指定一个固定的浏览器用户目录就能保留登录状态、Cookie等信息省去每次扫码登录的麻烦。这个目录就是Chrome保存账号状态的地方指定之后Selenium会使用这个“用户画像”而不是每次启动一个全新用户很多需要登录态的站点会好处理很多。options.add_argument(--user-data-dir/path/to/chrome-profile)但需要注意这个目录如果被其他Chrome进程占用Selenium会启动失败所以用的时候要让那条命令成为唯一占用它的进程。3. 实操过程与核心环节实现3.1 完整案例抓取一个列表页的数据这里我用一个通用思路来讲拿一个典型的列表页作为例子。这个页面大概长这样页面加载后先有个加载动画大概一两秒后列表数据通过JS渲染到页面上每个列表项包含标题和链接。直接Requests是拿不到的。第一步创建Driver并打开目标页面path_to_chromedriver /usr/local/bin/chromedriver # 换成你本地路径 options.add_argument(--headlessnew) driver webdriver.Chrome(executable_pathpath_to_chromedriver, optionsoptions) driver.get(https://example.com/list)第二步用显式等待等列表项出现try: wait WebDriverWait(driver, 15) items wait.until( EC.presence_of_all_elements_located((By.CSS_SELECTOR, .list-item)) ) print(f列表加载成功共 {len(items)} 项) except Exception as e: print(列表加载超时页面结构可能变了或加载失败:, e) driver.quit() raise这里用presence_of_all_elements_located等待多个元素同时出现比逐个查单个元素高效得多。如果列表项很多可能需要滚动页面才能触发懒加载这我们后面单独讲。第三步遍历并提取数据data [] for item in items: try: title item.find_element(By.CSS_SELECTOR, .title).text link item.find_element(By.CSS_SELECTOR, a).get_attribute(href) data.append({title: title, link: link}) except Exception: # 单个元素解析失败跳过即可不要影响整体 continue print(data)这里有个小坑item.find_element拿到的是当前列表项下的子元素如果用了全局的driver.find_element就会一直拿到第一项的标题全漏掉。这种问题几乎不会报错但结果就是数据全错排查起来非常隐蔽。第四步收尾driver.quit()3.2 滚动加载与点击加载更多遇到过很多页面数据不一次性全渲染出来而是你滚到页面底部它才通过Ajax去拿下一批数据。这种场景Selenium处理起来有几个思路按效率从低到高排列。最简单粗暴的方式模拟滚动到底部然后等新数据出现循环这个过程直到数据不再增长。实现思路大致如下driver.get(https://example.com/infinite-list) prev_count 0 for _ in range(10): # 最多滚动10次防止死循环 # 模拟滚到底部 driver.execute_script(window.scrollTo(0, document.body.scrollHeight);) # 等新数据加载 time.sleep(2) items driver.find_elements(By.CSS_SELECTOR, .list-item) cur_count len(items) if cur_count prev_count: print(数据不再增长滚动结束) break prev_count cur_count print(f最终获取 {prev_count} 条数据)这里用了execute_script直接执行JavaScript代码window.scrollTo(0, document.body.scrollHeight)把滚动条拖到页面最底部进而触发懒加载的逻辑。time.sleep(2)等待时间不宜太长也不宜太短太短数据没加载完就继续滚了太长则拖慢整体效率。更优雅的方案是点击“加载更多”按钮思路是重复“找按钮、点击、等待新数据”的循环from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait WebDriverWait(driver, 10) for i in range(5): try: load_more wait.until( EC.element_to_be_clickable((By.CLASS_NAME, load-more-btn)) ) load_more.click() wait.until( EC.presence_of_element_located((By.CSS_SELECTOR, .new-item)) ) except Exception: print(没有更多加载按钮或已加载完所有数据) break但这里有一个实际问题如果新增的元素和旧元素选择器相同你通过presence_of_element_located等待时由于元素已经存在等待条件会立即返回导致你没等到新数据就去点下一次按钮了。这里可以用一个更精准的方式记录当前数据量等待数据量变大。prev len(items) load_more.click() wait.until(lambda d: len(d.find_elements(By.CSS_SELECTOR, .list-item)) prev)3.3 跨越难点的JavaScript执行Selenium一个很有用的特性是execute_script它允许你直接在浏览器上下文里执行任意JS代码。这个能力在几种情况下特别有用获取元素的某种状态用标准API拿不到时滚动页面触发懒加载修改元素的属性值、移除属性绕过某些前端校验直接读取JS变量或接口返回的数据。一个典型场景某些网站在表单提交前用JS做了校验禁止你填入不符合规则的内容。这时你可以用execute_script直接设置元素的value值element driver.find_element(By.ID, phone) driver.execute_script(arguments[0].value 13800138000;, element)另一个场景某些数据在DOM中找不到但存在JS全局变量中比如window.__data。这时候可以直接在JS上下文里读出来data driver.execute_script(return window.__data;)这个返回值在Python里可能会是字典、列表等基础类型直接处理起来非常方便。还有一种场景是页面里渲染的数据很深层级嵌套CSS选择器写起来又长又容易断我直接返回整块数据再做后续解析省去一层层找元素的麻烦。execute_script还有一点需要注意不能直接返回DOM元素对象它会被序列化成某种WebElement引用想要拿属性值最好在JS里处理干净再返回titles driver.execute_script( return Array.from(document.querySelectorAll(.list-item .title)).map(el el.textContent); )3.4 与Requests结合两种方案配合的混合模式Selenium不是万能的它的短板在于速度快不起来。但很多场景可以组合使用Selenium负责处理登录、获取带认证的Cookie拿到之后把Cookie交给Requests去抓接口这种“混合模式”能有效提高整体效率。具体思路是这样的先让Selenium打开登录页模拟输入账号密码等登录成功后用driver.get_cookies()拿到会话Cookie然后将这些Cookie转成Requests可用的格式之后就可以用Requests直接请求目标接口了。这样既能绕开登录限制又能获得Requests的高并发和低消耗。cookies driver.get_cookies() import requests session requests.Session() for cookie in cookies: session.cookies.set(cookie[name], cookie[value], domaincookie.get(domain)) resp session.get(https://example.com/api/data, headers{User-Agent: Mozilla/5.0})这种模式适合“登录后拦截接口”的场景比全程用Selenium翻页效率高出好几个量级。但它的前提是你能从抓包工具里找到那个数据接口且接口没有额外的签名参数。如果你不熟悉浏览器的Network面板可以顺着这个思路学一下怎么找XHR请求这是爬虫进阶路上非常关键的一环。4. 常见问题与排查技巧实录4.1 核心问题速查表这里整理了一份我在实际回答中被问得最多、自己处理最多的问题速查表每条都是踩过坑才总结出来的。现象根因解决方案找不到元素NoSuchElementException元素未渲染或选择器错误用显式等待先打开非无头模式确认元素是否存在元素存在但点击无效元素被遮挡、不是真正的可点击目标用JS点击element.click()代替或先关闭弹窗页面打开后空白/加载不出来网络受限、JS报错终止截图定位检查控制台日志加--disable-blink-featuresAutomationControlled被识别为机器人/出现验证码webdriver特征暴露排除enable-automation加真实UA尝试有头模式跑久了内存暴涨/崩溃每次Driver没quit句柄泄漏用try/finally或with确保退出每N页清理一次滚动后数据不加载懒加载只在窗口进入视口时触发滚动到具体元素位置scrollIntoView而不是一棍子滚到底4.2 设计全网环境下最常见的一个隐蔽坑等待时间与元素状态的匹配问题我的经验里新手遇到最多的不是“选择器写错”而是“等待条件选错”。比如有人用presence_of_element_located等待一个元素发现它经常能等到但等到之后一click()就报错。因为presence只代表元素存在于DOM中不代表它可见、可点击。很多前端框架渲染出来的元素最初是display:none或disabled状态要等后续JS把它变为可用状态。正确做法是等待时选对EC条件等待元素可见EC.visibility_of_element_located等待元素可点击EC.element_to_be_clickable等待元素消失EC.invisibility_of_element_located等待文本变化EC.text_to_be_present_in_element这些条件本质上是帮你做了“轮询条件判断”两件事。每个等待条件内部都是循环检测默认每0.5秒检查一次直到超时为止可以理解成一套自动的“尝试—判断—重试”机制。4.3 怎么确认页面真的渲染完了排查问题时我最常用的手段是截图和打印页面源代码。截图能直观看到页面当前长什么样打印源码能看渲染后的DOM结构。这两个手段配合起来基本能把大部分加载问题定位清楚。driver.save_screenshot(screen.png) html driver.page_source with open(page.html, w, encodingutf-8) as f: f.write(html)打开page.html搜索你要抓的关键词如果源码里有但Selenium找不到说明是选择器写错了如果源码里没有说明页面JS还没跑完需要优化等待策略如果源码里压根没有这个数据可能数据根本不在DOM里而是存在前端状态对象中需要回头用execute_script直接读或者去Network里找接口。4.4 遇到反爬时怎么调整站点识别Selenium的手段通常是检测navigator.webdriver属性、浏览器的自动化标记、CDPChrome DevTools协议调用痕迹以及行为上的异常比如鼠标轨迹、点击间隔、访问速度等。对于前两者最基础的两个手段options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False)上面两行代码可以在一定程度上弱化自动化标记。更彻底的做法是driver.execute_cdp_cmd(Page.addScriptToEvaluateOnNewDocument, { source: Object.defineProperty(navigator, webdriver, { get: () undefined }); })这行代码在页面加载前就注入JS把navigator.webdriver属性改造掉。实测下来不少站点的基础检测能被绕过。但我要强调一点如果你的目标站点验证码异常频繁、或者有非常严苛的风控策略技术上再怎么调也是打地鼠式的对抗价值不大了。遇到这种情况我的建议是调整采集策略降低频率、控制每天的总量上限、减少单次会话的持续时长。自动化工具的使命是提高效率但前提是合理合法地使用不要给自己惹上不必要的麻烦。4.5 JavaScript渲染相关的特殊坑页面里如果有WebSocket实时推送数据会发现Selenium拿到某个状态之后数据还在不断变化。这种场景下别急着把页面关掉先判断目标数据是“实时快照”还是“连续推进”。如果需要稳定抓取某个瞬时值用JS全局变量或JSON解析要比DOM文本稳得多。另一个坑是定时器和动画。比如数据已经出现在DOM上但页面动画还在移动元素位置导致你click()的时候点偏了。我遇到过点击按钮时因为页面顶部弹窗把按钮遮住而报错的情况解决办法是用scrollIntoView把目标元素滚动到可视区再加一个合适的等待时间。5. 这些坑总结起来Selenium的核心心得写到这里我脑子里翻过几个实际项目。一个朋友要抓一个前端框架做的数据报表页面上一堆图表和过滤条件我用Requests逆向了半天接口发现参数都被打包成加密串了最终老老实实用Selenium每十分钟跑一轮把页面渲染后的表格数据提取出来稳定跑了两三个月最大的麻烦反而是浏览器驱动的定时更新。另一个项目是抓一个图片站列表数据在DOM里但翻页是JS动态加载我用了滚动加载方案加上限速和随机延时几千页跑下来没出大问题。实战里我深刻体会到几个原则。第一能用显式等待就不用隐式等待和固定sleep这是Selenium入门到进阶最重要的一道坎。固定sleep的问题在于它在等待时是无条件空转网络差的时候不够用网络好的时候浪费几秒钟每个页面多几秒几千页跑下来差距就非常大了。而显式等待是根据实际条件触发返回的快的时候几毫秒慢的时候等到超时整体吞吐量会好很多。第二能用有头模式跑通再换无头模式做效率优化。有头模式最大的价值是“看得见”出了问题截图定位往往比读错误日志快得多。真正上生产环境时再切换无头同时配合--log-level3减少控制台刷屏。第三高频登录态的站点优先考虑“保持会话”策略。用固定--user-data-dir保留登录态能省去每次登录的时间和风险也是规模化采集的基础要求之一。第四设计异常处理时必须记住driver.quit()一定要执行否则浏览器进程会在后台堆积跑不了几百个页面就内存耗尽。推荐用try/finally或者with结构来保证资源释放。第五也是我最有感触的一点Selenium解决的问题是“页面是JS渲染的”但它不是唯一的答案。有些场景接口逆向比Selenium高效十倍有些场景Playwright的API设计比Selenium顺手有些场景甚至用Chrome DevTools Protocol直接写脚本更灵活。技术选型永远是在效率、稳定性和维护成本之间做权衡不要因为Selenium学得最熟练就处处用它也不要因为听说它是重型工具就避之不及。工具是死的思路是活的把Requests的轻量、Selenium的完整渲染能力和浏览器的调试能力灵活组合起来采集效率才会真正上台阶。
返回列表