
先讲个真实场景。你写了个爬虫用requests把页面源码拉回来配上正则或者 XPath 一顿操作结果发现关键字段全是None。打开浏览器一看数据明明好端端地在页面上表格、价格、评论、翻页全部都渲染得漂漂亮亮。这不是你的解析写错了而是你拉到的只是页面最初的静态骨架真正的数据在浏览器执行 JavaScript 之后才出现。这篇文章要解决的就是这一类“页面看得见源码抓不到”的 JS 渲染问题。我会用 Selenium 把它拆开揉碎讲清楚怎么判断页面是不是动态渲染的、为什么选 Selenium、环境怎么搭、滚动加载和点击展开怎么写以及怎么在稳定性、性能和反识别之间找到平衡。适合那些已经能用 requests 写基本爬虫、但一碰到动态页面就束手无策的读者。1. 先别急着上 Selenium判断页面到底是不是 JS 渲染1.1 两个工具的验证流程在决定使用 Selenium 之前第一步永远是做“静态抓取验证”这一步很多人会跳过。因为 Selenium 意味着要额外启动一个真实浏览器内存占用轻松超过 300MB打开页面后还要等 JS 跑完时间和资源成本都远高于一次普通的 HTTP 请求。如果能用静态方式拿到数据就没必要动用它。验证方法很简单写一段最基础的程序把页面源码抓下来然后搜索你要的数据关键字。import requests resp requests.get(https://example.com/list, timeout10) html resp.text # 直接搜你要的数据关键字 print(目标关键词是否存在, 具体商品名或价格字段 in html) print(源码长度, len(html))如果打印出来是False基本可以断定这个页面是动态渲染的。此时再打开浏览器开发者工具切换到 Network 面板刷新一次页面通常会看到两类情况一是页面加载后发起了额外的 XHR/fetch 请求数据通过接口返回二是数据直接藏在 HTML 源码里的某个script标签中等页面运行时再用 JS 解析并拼装到 DOM 里。前者就是后端接口动态返回数据后者是前后端在浏览器端完成了渲染。只有确认这两种情况之一存在Selenium 才有上场的必要。1.2 数据可能出现的位置在真实项目里同一个页面上的不同字段来源可能完全不一样。我处理过一个报价类网站列表页第一屏的字段藏在 HTML 源码的 JSON 里滚动加载出来的第二屏数据来自一个接口而最底部的图表数据又是第三个接口返回的。如果不做区分直接开浏览器虽然也能抓全但速度和稳定性都会打折扣。所以拿到一个页面后我建议按下面这个顺序排查先看静态 HTML 里能不能直接找到目标字段。搜字符串即可不需要写复杂正则。然后在源码里搜索window.、__INITIAL_STATE__、__NUXT__等常见的关键字这些通常是页面初始数据的挂载点数据往往以 JSON 字符串形式存在于script标签中。最后配合开发者工具看页面滚动、点击时实际请求了哪些接口接口的 JSON 结构往往比 DOM 更干净。如果第 1 步就有结果直接用requests加正则就能搞定不必上浏览器。如果数据主要来自第 2 步也可以考虑用正则或 JSON 解析在静态层面提取。只有当数据必须等 JS 执行完、并且分散在 DOM 里的时候才真正需要 Selenium。2. 方案对比与选型Selenium、Playwright 还是硬解析2.1 主流方案对比处理 JS 渲染的爬虫方案目前市面上主要有四条路线。我第一次接触动态页面时因为不了解走了不少弯路这里把它们的差异列出来方便你对号入座。方案原理上手难度执行速度稳定程度适用场景requests 接口模拟直接请求页面背后的 JSON 接口低极快依赖接口签名稳定性能找到纯净接口时优先选Selenium驱动完整浏览器Chrome/Firefox低慢高页面结构复杂、必须模拟真人操作Playwright类似 Selenium协议更现代中中高需要拦截请求、多页面并发PyppeteerPython 封装 Puppeteer偏高中一般老项目维护新项目不建议入坑说实话很多动态页面的数据最终还是来源于接口如果你能在 Network 面板里找到那个 JSON 接口并且它的签名没有加密或者只在 Cookie 里做鉴权用requests直接模拟请求才是效率最高的做法。接口方案的速度比 Selenium 快了一个数量级资源消耗更是没法比。所以我的建议是先花十分钟找接口找不到接口或者接口参数被加密了再考虑浏览器方案。2.2 我最终选 Selenium 的三个理由虽然 Playwright 从技术上讲更新、有自动等待、有拦截请求等高级功能但我自己大部分项目仍是用 Selenium原因有三个第一Selenium 使用 WebDriver 标准协议所有主流浏览器都支持团队里不管谁接手都能快速熟悉第二它的文档、社区案例、踩坑记录是最多的遇到一个罕见缺陷时搜索出来的解决方案通常都是现成的第三Selenium 的学习成本最低对于刚入门动态爬虫的开发者几句话就能讲清楚核心用法。另外如果你需要把整个页面“截图留存”或者需要操作页面里那些交互复杂的组件时Selenium 的生态也相当顺手。选工具这件事不用过度纠结能解决问题、团队能维护、代码能跑稳定就是好方案。3. 环境搭建和第一个可复用的动态爬虫3.1 环境准备浏览器驱动匹配Selenium 并不是一个独立的渲染引擎它的本质是“指挥真实浏览器干活”。所以除了安装 Selenium 库你还需要一个对应版本的浏览器驱动。以 Chrome 为例驱动叫chromedriver它负责在 Selenium 和 Chrome 之间传递协议指令。需要特别注意的是驱动的版本必须和浏览器主版本号匹配否则启动时会直接报SessionNotCreatedException之类的错误。手动下载驱动这件事最烦人因为每次浏览器自动升级驱动就失效了。我现在都用webdriver-manager来管理它会自动读取你本机浏览器的版本然后去下载匹配的驱动省掉了大量环境问题。pip install selenium webdriver-managerfrom selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager service Service(ChromeDriverManager().install()) driver webdriver.Chrome(serviceservice)这段代码第一次运行时会自动下载驱动下载完成后会和浏览器版本匹配。团队里有新人加入时新人只需要安装浏览器和 Python 环境不必手动折腾驱动文件体验会好很多。3.2 代码带条件等待的基础模板有了环境我们写出第一个真正能处理 JS 渲染的基础爬虫。目标页面不管多复杂核心逻辑都是一样的打开页面等待关键元素出现再提取内容。from selenium import webdriver from selenium.webdriver.chrome.service import Service from selenium.webdriver.chrome.options import Options from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By from webdriver_manager.chrome import ChromeDriverManager options Options() options.add_argument(--headlessnew) # 无头模式不弹出浏览器窗口 service Service(ChromeDriverManager().install()) driver webdriver.Chrome(serviceservice, optionsoptions) try: driver.get(https://example.com/list) # 关键步骤等待页面渲染完成 # 等到 .item 这个元素出现说明 JS 已经把数据渲染上来了 WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CSS_SELECTOR, .item)) ) items driver.find_elements(By.CSS_SELECTOR, .item) for item in items: title item.find_element(By.CSS_SELECTOR, .title).text price item.find_element(By.CSS_SELECTOR, .price).text print(title, price) finally: driver.quit()这段代码里最核心的是WebDriverWait。它做的事情不是固定等待三秒五秒而是每隔一小段时间去检查一次目标条件是否成立超时后再抛异常。这个“轮询判断”的过程在 Selenium 里叫显式等待。3.3 为什么条件等待比 sleep 靠谱不少新手习惯在driver.get()之后直接time.sleep(3)只要等的时间够长页面总能加载完。这种做法在小规模项目里勉强能用但在生产爬虫里就是定时炸弹。因为机器的网络状况、目标服务器的响应速度、页面 JS 的执行时间是动态变化的固定 sleep 3 秒可能在某个时刻足够下个时刻页面却还在转加载动画于是你拿到一堆空数据如果 sleep 设得太长又会拖慢采集速度。显式等待的思路完全不同我不关心你花了 1 秒还是 8 秒我只要“页面里出现某个指定元素”这个结果。条件满足就立刻继续执行条件不满足就继续等最长等到 10 秒。这样写出来的代码对网络波动有天然容忍度比 sleep 高效得多。另外要稍微注意一个细节presence_of_element_located只要求元素被挂载到 DOM 上有时元素已经存在但内容还没填充完。遇到这种情况可以把条件改成等待元素的文本不再变化或者等待某个子元素出现。这类问题在后面的调试章节里还会遇到。4. 高频率场景实战滚动加载、点击展开、隐藏数据提取4.1 滚动加载型列表页现在很多列表页都采用“无限滚动”的设计页面只渲染第一屏内容滚动到底部时才继续加载。这类页面在 Selenium 里处理的核心思路是滚动到底部等待新的元素出现重复这个过程直到页面高度不再变化。SCROLL_PAUSE_TIME 2 items driver.find_elements(By.CSS_SELECTOR, .item) print(初始数量, len(items)) last_height driver.execute_script(return document.body.scrollHeight) while True: # 滚动到底部触发加载逻辑 driver.execute_script(window.scrollTo(0, document.body.scrollHeight);) # 等待页面高度变化说明新内容加载完成 WebDriverWait(driver, 5).until( lambda d: d.execute_script(return document.body.scrollHeight) last_height ) new_height driver.execute_script(return document.body.scrollHeight) if new_height last_height: break last_height new_height # 再次统计页面元素数量 items driver.find_elements(By.CSS_SELECTOR, .item) print(当前数量, len(items))这里用execute_script直接执行 JavaScript 是 Selenium 非常高效的能力避免去模拟滚轮操作既稳定又快速。每次滚动后要等页面高度真的有变化如果我写的等待条件在 5 秒内没有满足说明这次滚动没有触发新内容加载循环就可以结束了。有一点我踩过坑某些懒加载页面只监听scroll事件滚动到底部后还要再“使劲滚一次”才能触发。如果遇到页面高度不变、但你明显感觉还有内容的情况可以尝试滚动到接近底部的位置再往回滚一点点模拟真实用户的操作路径。4.2 点击加载更多按钮比无限滚动更老派的是“加载更多”按钮。这类按钮的特点是每次点击后页面会追加渲染一批新数据并且按钮可能一直存在直到所有数据加载完才会消失。from selenium.common.exceptions import NoSuchElementException while True: try: more_btn driver.find_element(By.CSS_SELECTOR, .load-more) except NoSuchElementException: print(没有加载更多按钮已到最后一页) break # 用 JS 点击比直接 click 更稳防止按钮被遮挡 driver.execute_script(arguments[0].scrollIntoView();, more_btn) driver.execute_script(arguments[0].click();, more_btn) # 等新内容出现记录旧的元素数量等待数量增加 old_count len(driver.find_elements(By.CSS_SELECTOR, .item)) WebDriverWait(driver, 5).until( lambda d: len(d.find_elements(By.CSS_SELECTOR, .item)) old_count )用execute_script的click()是实战里很实用的技巧。因为页面元素经常被悬浮层、广告条、弹窗给挡住普通.click()会报ElementClickInterceptedException而用 JS 直接触发点击事件相当于跳过了“元素是否可见、是否被遮挡”的检查在绝大多数场景下都能点动。当然这也意味着它偶尔会点到不该点的东西使用时要明确知道自己为什么要这么做。4.3 藏在 window.INITIAL_STATE里的数据有一类页面很有意思你打开源码一看明明整段数据都在script标签里但是页面的 DOM 却是一堆空壳子。数据要等 JS 运行起来才被一个个填回到界面里。面对这种页面很多人第一反应是开 Selenium 然后逐个元素.text。这其实可以但不是最优解。更聪明的办法是直接从page_source里把 JSON 提取出来因为你已经用浏览器做了“模拟执行”这件事数据已经在 HTML 源码里了没必要再等 JS 把数据渲染到 DOM 里。import re import json html driver.page_source match re.search(rwindow\.__INITIAL_STATE__\s*\s*(\{.*?\});, html) if match: data json.loads(match.group(1)) # 后续直接操作 data print(data.keys())这种“浏览器加载 源码里抓 JSON”的混合方案是我处理动态页面时最偏爱的一种。它兼顾了稳定性与性能浏览器只负责让服务端的 JS 跑一遍后续的数据处理完全交给 Python速度比逐个 DOM 节点读取快很多。需要注意正则里的.*?非贪婪匹配因为页面里的 JSON 可能非常大如果直接用贪婪匹配正则会把后面所有内容都吞进去。另外如果数据不在script标签里而是被 JS 加密后动态拼装那就要回到 DOM 读取的老路线上。5. 稳定性与性能headless、防识别和资源回收5.1 headless 模式的配置参数Selenium 跑起来默认会弹出一个真实浏览器窗口这在本地调试时很直观但在服务器上部署就完全不合适了。所以生产环境一般用无头模式。Chrome 的新无头模式在兼容性和崩溃率方面要好得多推荐显式指定--headlessnew。options Options() options.add_argument(--headlessnew) options.add_argument(--window-size1920,1080) options.add_argument(--disable-gpu) options.add_argument(--no-sandbox) # Linux 服务器上经常需要 options.add_argument(--disable-dev-shm-usage)其中--window-size容易被忽略但影响很大。无头模式下默认窗口很窄页面里的元素布局会发生变化一些只有在宽屏下才显示的内容会被当作不存在。加上一个 1920x1080 的窗口尺寸能避免很多不必要的定位问题。5.2 性能优化关图片、限制并发、善用 quitSelenium 的慢很大一部分浪费在加载图片、字体和其他无关资源上。图片对爬虫来说通常没有价值直接关闭能明显提升加载速度。prefs {profile.managed_default_content_settings.images: 2} options.add_experimental_option(prefs, prefs)除了资源开销还有一个常见的性能陷阱是忘记调用driver.quit()。quit()会关闭浏览器进程并释放内存如果不调用每次运行都会残留一个后台 Chrome 进程跑几次之后内存就满了。所以不管代码逻辑走到哪一步都建议把driver.quit()放在finally块里执行。并发方面我的经验是不要开太多浏览器实例。一个无头 Chrome 大约占用 200MB 到 400MB 内存开五个以上机器就很容易吃紧。更好的做法是控制并发数在 2 到 4 个之间每个浏览器实例内部用“队列 顺序访问”的方式抓取多个页面既能保证速度又不至于把机器资源耗尽。5.3 混合抓取requests 与 Selenium 搭配在长期运行的采集任务里我会尽量把 Selenium 的参与范围压缩到最小。比如一个页面只有登录后的 Token 需要经过一段复杂的 JS 计算那我只用 Selenium 完成一次完整加载拿到 Token 之后立刻关闭浏览器后续所有请求都用 chances 去发。这种方式既绕过了 JS 计算又避免了每一页都开浏览器的巨大开销。再比如某个列表页的第一屏数据已经在初始 HTML 里只有后续翻页要用 Selenium。那就用 Selenium 只做翻页动作在翻页过程中把每一次的 HTML 源码全部page_source保存到一个列表里最后统一用正则或 BeautifulSoup 解析。这样 Selenium 只负责“模拟执行翻页”不做字符串解析职责单一速度也会快不少。6. 高频报错与调试技巧6.1 三个高频异常解读Selenium 写多了你会发现报错就那么几类。我整理了一份高频异常对照表排查时可以直接对号入座。异常原因应对方式NoSuchElementException元素选择器写错或元素还没渲染出来先用显式等待再用driver.find_elements检查数量StaleElementReferenceException页面 DOM 已更新之前的元素引用失效在循环里重新获取元素不要缓存引用跨页面使用TimeoutException等待条件一直没满足检查选择器是否正确、等待目标是否在 iframe 内StaleElementReferenceException是我早期最常遇到的坑。比如你拿到一个元素列表然后遍历它在遍历过程中页面自己刷新了一部分 DOM之前保存的那些元素对象就全部失效了。解法其实很简单每次循环都重新find_elements不要图省事在外面一次性缓存。6.2 调试三板斧页面定位不到元素时与其一遍遍猜选择器不如直接用 Selenium 的调试三板斧几乎能解决 80% 的问题。第一截图。driver.save_screenshot(debug.png)会把当前页面的渲染结果保存成图片你一眼就能看出 JS 到底有没有跑成功页面停留在什么状态。第二输出当前页面源码。driver.page_source会返回当前 DOM 的完整 HTML把它保存到本地文件里用编辑器搜索你想要的数据关键字确认数据是否存在、以及它在什么样的 DOM 结构里。第三查看当前 URL 和标题。有些页面跳转会改变 URL而某些数据只有在特定路由下才会显示。driver.current_url和driver.title是最容易被忽略、却最能说明页面状态的指标。还有一个小技巧如果怀疑是等待条件出了问题可以在代码里临时打印driver.execute_script(return document.readyState)。返回complete说明页面加载流程已经走完了如果一直停在interactive说明还有资源或者 JS 在持续运行你就应该调整等待策略。6.3 一些个人经验做动态爬虫这两年多我最大的体会是Selenium 不是万能的但它在“模拟真人操作”这件事上依然无可替代。使用它时要时刻记着两条原则第一能静态拿到的数据绝不开浏览器第二所有资源都要记得释放。抓取速度慢不可怕可怕的是抓取过程中网页改版了、元素变了之后你的代码还在盲目重试拼命请求目标服务器。另外任何爬虫项目都请务必遵守目标网站的使用条款和服务协议只抓取你有权访问、且对方允许公开访问的信息。爬虫本身是中性的工程工具但使用边界一定要想清楚。设置合理的采集频率、控制并发数既是对目标服务器的尊重也是让自己的爬虫能长期稳定运行的前提。最后分享一个个人习惯每次给页面写定位选择器时我都会优先使用语义化的 CSS 类名或 ID而不是用那种div div span:nth-child(3)的绝对路径。因为前者在页面改版时更容易容错后者稍有 CSS 微调整条链路就断了。这个习惯让我后续维护代码时省下了大量时间也少掉了许多半夜被线上任务告警吵醒的烦恼。