最近在给某跨平台系统做数据采集时遇到个很典型的场景网页在浏览器里打开数据清清楚楚摆在列表里Network面板里也能看到接口返回的JSON但换成requests直接请求页面地址拿回来的HTML里却干干净净连一条数据都找不到。这种情况就是标准的JavaScript动态渲染页面。数据不是服务器直接输出在HTML里而是浏览器加载完页面后脚本再去请求数据、然后把内容动态塞进DOM的。单纯拿源码自然什么都拿不到。处理这类场景我最常用的方案就是Selenium——它能驱动一个真实的浏览器内核把JS执行、DOM渲染、Ajax请求全部跑完最后给你一个和肉眼看到的完全一致的页面状态。这篇文章我就把手上的实操经验整理一下从Selenium的工作原理、环境配置到元素定位、等待策略再到各种踩坑排查完整过一遍给刚开始处理JS渲染页面的朋友一个可以直接上手的参考。1. 先搞清楚JS渲染的机制才知道requests为什么失效1.1 静态HTML与动态渲染的差异很多人第一次遇到这个问题时会困惑我在浏览器里右键查看源代码明明能看到数据啊怎么用程序请求就没了这里有个关键区别浏览器开发者工具里的Elements面板展示的是实时DOM是JS执行完之后的页面状态。而“查看源代码”或requests拿到的是服务器返回的原始HTML响应体。两者在很多页面上完全不是一回事。一个典型的数据流转过程是这样的浏览器请求页面 - 服务器返回包含HTML骨架JS脚本的响应 - 浏览器开始解析HTML - 遇到script标签就开始执行JS - JS发起Ajax请求获取数据 - 拿到数据后通过DOM操作把内容渲染到页面上。所以requests拿到的只是这个流程的第一步产物。后面的JS执行、Ajax请求、DOM操作requests一概不参与。用那个原始HTML去解析数据自然什么都解不出来。我在模拟项目X里处理过一个招聘信息展示页HTML源码里有完整的页面结构但职位列表的区域是空的所有职位数据都是页面加载后通过三个接口拼接出来的。用requests抓首页只能拿到一个空壳没有任何职位信息。这就是典型的JS渲染场景。1.2 Selenium的优势真实浏览器完整执行Selenium的思路和requests完全不同。它不再模拟HTTP请求而是直接驱动一个真实的浏览器进程把页面加载、JS解析、资源请求、DOM渲染的完整流程从头到尾执行一遍。页面最终变成什么状态Selenium里看到的就是什么状态和你手动打开Chrome的体验几乎一致。这背后的核心是WebDriver协议。浏览器对外暴露一套自动化接口Selenium通过这些接口向浏览器发送指令比如“打开这个网址”“点击这个按钮”“获取这组元素的内容”浏览器接收指令后真实执行再把结果返回给Selenium。所以Selenium不是用什么魔法解析HTML而是真的有一个浏览器在里面干活。这个方案的好处很直接不管页面的JS逻辑多复杂、框架用的是Vue还是React、数据是通过什么方式和接口串联的只要页面能在真实浏览器里正常展示Selenium就能拿到那部分数据。当然也有代价。Selenium要启动完整浏览器内存占用大、执行速度比requests慢一个数量级。如果页面结构简单、数据在初始HTML里就有完全没有必要用它。它的适用场景就是前面说的那种——数据必须等JS执行完才出现的情况。2. 环境准备与配置Selenium跑起来的第一步2.1 安装基础依赖与浏览器驱动Selenium本身是一个Python库装起来很简单pip install selenium但光装库还不行。Selenium需要控制一个真实的浏览器而浏览器需要对应的驱动程序才能接收WebDriver协议指令。Chrome对应ChromeDriverFirefox对应GeckoDriverEdge对应EdgeDriver。没有驱动Selenium连浏览器都启动不了。以ChromeDriver为例下载时有个极易踩的坑驱动版本必须和浏览器版本严格匹配。版本不匹配时会直接报错常见的有SessionNotCreatedException: This version of ChromeDriver only supports Chrome version XXUnknown session ID旧驱动控制新浏览器时经常出现我在实际工作中吃过这个亏。某次Chrome自动更新到新版本我没留意继续跑之前写的脚本一启动就报版本不匹配。后来无奈做了个定期检查驱动版本的小工具才彻底解决。下载驱动时优先从各驱动官网的匹配列表里找到自己浏览器对应的版本。如果浏览器版本太新、驱动还没跟上可以把浏览器降级到和驱动兼容的版本。这个版本匹配关系是Selenium环境搭建中最关键的一环建议所有人搭环境时第一件事就确认它。2.2 浏览器参数配置Options的进阶用法启动浏览器时如果直接裸写webdriver.Chrome()会弹出一个有界面的浏览器窗口。开发调试时问题不大但如果部署在服务器或想稳定长期跑任务一般都会用无头模式。options配置是一个容易被人忽略但又很重要的细节。下面这段是我常用的配置模板from selenium import webdriver from selenium.webdriver.chrome.options import Options options Options() options.add_argument(--headless) options.add_argument(--no-sandbox) options.add_argument(--disable-gpu) options.add_argument(--disable-dev-shm-usage) options.add_argument(--window-size1920,1080) options.add_argument(--user-agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36) driver webdriver.Chrome(optionsoptions)几个参数的作用分别是--headless开启无头模式浏览器不显示界面后台运行。--no-sandbox以root权限运行Chrome时需要的参数普通用户环境加了也没坏处。--disable-dev-shm-usage解决容器或低内存环境下的共享内存不足问题。--window-size设置窗口大小。很多页面会根据窗口尺寸做响应式布局窗口太小会导致部分元素不可见或样式错位进而影响定位。--user-agent自定义UA。无头模式的默认UA里经常带HeadlessChrome字样有些网站会据此识别并拦截手动指定一个正常的UA能减少很多麻烦。有几个配置不推荐直接加。比如--disable-images这种降低加载的策略虽然能提速但在部分页面上会影响图片懒加载的逻辑导致后续滚动或解析出问题。我在一次采集商品详情页时因为禁了图片页面结构整个变了反而得不偿失。2.3 环境验证确保Selenium能正常驱动浏览器配置完成后先跑一个最简单的脚本验证环境是否正常from selenium import webdriver driver webdriver.Chrome() driver.get(https://example.com) print(driver.title) driver.quit()如果能看到浏览器打开并输出页面标题说明环境没问题。这个验证步骤虽然简单但能快速区分后面遇到的问题是环境问题还是代码问题节省大量排查时间。个人习惯是在每个爬虫项目开始前先做一个环境自检把驱动路径、版本号打印出来。这个小习惯在长期维护的项目里非常有用因为不论浏览器版本还是库版本隔几个月就可能发生变化环境变动是爬虫脚本失效的第一大原因。3. 核心实操取到JS渲染后的数据3.1 元素定位方法的选型Selenium有三种主流定位方式ID、CSS选择器、XPath。它们的优先级在我这里是这样的ID优先。页面上ID是唯一的定位速度快几乎不出错。但JS动态渲染的页面很多元素没有ID或者ID是动态生成的比如每次刷新都在变化。CSS选择器其次是较好的选择。通过class、属性组合来定位灵活度比ID高。比如div.list-item[data-index1]能根据元素自身的特征精准锁定。XPath最灵活但性能相对最差。它在遍历整个DOM树查找匹配节点页面越复杂、XPath表达式写得越复杂性能越差。但遇到层级嵌套很深的元素XPath是唯一能落地的方案。定位优先级的具体比较见下表定位方式性能稳定性适用场景ID高高带静态ID的元素CSS选择器高中class、属性特征明显的元素XPath低中层级复杂、无特征元素且其他方式无法定位的情况一个常见的实操问题是XPath写得太绝对。比如/html/body/div[3]/div[1]/div[2]/span这种页面结构稍微一变就断裂。我倾向于用相对XPath基于特征定位比如//div[contains(class, item)]//span[contains(text(), 关键词)]这种写法对页面结构的鲁棒性更好遇到改版时还容易调整。3.2 等待策略固定sleep是最大坑JS渲染最大的问题就是元素出现的时机不确定。页面加载慢一点元素可能晚几百毫秒才出现网络波动时可能要几秒。新手最容易写的是time.sleep(3)——先睡几秒再操作。这种写法的缺陷在于如果页面在1秒时就渲染完成了你白等2秒如果页面要5秒才渲染完成你等到第3秒就去找元素一样报错。等于把等待时间写死既低效又不可靠。Selenium提供了两种正经的等待方式隐式等待driver.implicitly_wait(10)设定一个全局最大等待时间。每次调find_element时如果元素没出现会在这个时间内轮询等待直到元素出现或超时。看起来方便但它的坑在于只用这一种等待时遇到复杂页面到底等多久全靠猜。显式等待from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC element WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CLASS_NAME, list-item)) )显式等待只针对特定条件轮询直到元素满足条件或超时。这才是处理动态渲染页面最精准的方式。我的习惯是显式等待为主隐式等待为辅。隐式等待设一个3-5秒的兜底重要的元素都单独用WebDriverWait显式等待。不推荐在实际项目里用time.sleep除非你明确知道要等的是某个固定接口的延迟。3.3 完整案例抓取一个JS渲染的动态列表页以模拟项目X的数据页为例页面进入后列表区是空白的需要等页面加载完JS后出现一批条目。目标是提取每条内容的标题和链接。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 options Options() options.add_argument(--headless) options.add_argument(--window-size1920,1080) options.add_argument(--user-agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36) driver webdriver.Chrome(optionsoptions) try: driver.get(https://example.com/dynamic-list) items WebDriverWait(driver, 15).until( EC.presence_of_all_elements_located((By.CSS_SELECTOR, div.data-item)) ) results [] for item in items: title_el item.find_element(By.CSS_SELECTOR, h3.title) link_el item.find_element(By.CSS_SELECTOR, a) results.append({ title: title_el.text, url: link_el.get_attribute(href) }) print(f共抓取 {len(results)} 条数据) for item in results: print(item[title], item[url]) finally: driver.quit()这段代码的核心在于presence_of_all_elements_located——它会等所有符合条件的元素都出现避免前几个元素出来了但后面还没渲染完的问题。这里有个重要细节我用EC.presence_of_all_elements_located而不是EC.presence_of_element_located前者等待所有匹配元素出现适合列表页后者只等待第一个元素适合详情页的单元素场景。用错的话列表页可能只抓到前几个元素后面的还没渲染出来就结束了。3.4 数据提取直接操作还是交给解析库拿到页面后提取数据有两种思路。一种是用Selenium的元素方法直接取比如.text取文本、.get_attribute()取属性。适合数据量小、逻辑不复杂的场景。另一种是把渲染后的页面源代码交给BeautifulSoup处理from bs4 import BeautifulSoup page_source driver.page_source soup BeautifulSoup(page_source, html.parser) items soup.select(div.data-item)两种方式各有利弊。直接操作不引入额外依赖流程清晰交给BeautifulSoup解析则可以利用已经被验证过的CSS选择器语法代码更简洁尤其在批量提取大量字段的场景下更顺手。我个人的习惯是如果只需要拿少量字段直接用Selenium元素方法如果页面里要提取的字段多、结构复杂就交给BeautifulSoup整理。Selenium驱动浏览器的开销本身已经很大用它再把所有数据都摸一遍效率不高不如把数据解析工作交给更轻量的库。4. 常见问题与排查技巧实录4.1 NoSuchElementException元素找不到的几大原因这是Selenium使用中最高频的报错。报错本身好办关键是找到原因。第一个可能原因是页面还没渲染完。元素要等JS执行完才出现而你找得太早。排查方法先试显式等待把超时时间加长看能不能解决。能解决就是时序问题。第二个原因是元素在iframe里。iframe是一个独立的文档Selenium默认只处理主文档不会进入iframe内部。driver.switch_to.frame(frame_id) # 此时才能操作iframe内的元素 driver.switch_to.default_content()如果不切换frame直接find_element就会报错。这个问题诡异的地方在于你在浏览器里肉眼能看到这个元素但在开发者工具的Elements面板里它嵌套在iframe标签下。一开始不熟悉时确实容易懵。第三个原因是元素在shadow DOM里。这种情况遇到得相对少但一旦遇到就非常棘手。shadow DOM里的元素不能用普通方式定位要用shadow root节点一层层穿透进去标准定位方式拿它没办法。第四个原因是页面结构判断错误。可能是class名带空格导致选择器写法不对可能是同一个class对应了多个元素。排查方式是先在浏览器开发者工具里验证选择器能选中再回代码里调。排查这类问题有个通用思路F12控制台里手动执行document.querySelector验证选择器对不对。如果控制台都选不中那说明压根是页面结构的问题如果控制台能选中而代码里报错那就往时序和上下文iframe/shadow DOM方向排查。4.2 页面加载慢运行效率优化方案Selenium慢是常态但可以通过几个手段让它慢得可控。第一个思路是调整页面加载策略。默认情况下Selenium会等页面所有资源加载完才返回get()。但对很多页面来说主数据只需要HTML和JS图片、广告等静态资源属于可放弃的。options.page_load_strategy eager # 只等DOM就绪不等图片等资源 # 可选值normal默认等全部加载、eagerDOM就绪即可、none立即返回eager模式在处理大量图片的页面时效果很明显可以省掉相当可观的等待时间。但要注意如果页面数据是懒加载的——比如滚动到某个位置才加载下一批——那eager可能提前返回导致数据缺失。此时配合显式等待依然能保障。第二个思路是按需才滚动。很多列表页是滚动到底部才触发加载更多。如果只是抓首页可见部分完全不需要滚动节省时间和流量。如果必须滚动用driver.execute_script(window.scrollTo(0, document.body.scrollHeight))后再配合显式等待而不是无脑循环滚动条。第三个思路是减少不必要的等待。implicitly_wait设太长会影响所有find操作空白等待过多会堆慢整体速度。网络良好时5秒就够用。4.3 反爬应对减少被识别的概率网站的检测维度通常集中在几个方面请求频率、User-Agent特征、WebDriver特征。请求频率上控制合理的延迟时间加一点随机抖动别在几秒内发出几十个请求。具体延迟多少合适应视网站而定抓得快不如抓得稳。User-Agent特征上前面提过要自定义UA去掉HeadlessChrome字样。有些网站会根据UA里的环境标识判断是否放行。我一般在浏览器里复制自己浏览器的UA字符串显得比较接近真实用户。WebDriver特征上可以通过检查脚本内的navigator.webdriver属性。正常浏览器里这个值是undefinedSelenium控制的页面里是true。网站可以检测此标志。处理思路是用stealth类技巧覆盖这个属性。这也说明了为什么尽量用真实行为模式、少用机械化的固定操作更能规避风险。还有个值得注意的点频繁清理浏览器数据以求“干净”未必高效。部分网站会在Cookie和本地存储里积累用户行为特征每次都用全新的空状态访问反而显得可疑。维护一个稳定的会话、模拟真实用户的使用节奏是我更推荐的方向。当然这里面的分寸要把控好毕竟我们做的是数据采集不是攻击行为。4.4 进程与资源释放防止内存泄漏Selenium启动的每个浏览器进程都会占用内存如果脚本中driver没有正确关闭内存泄漏会越来越严重跑两天任务后内存占用可能飙升到几个G。解决方法是确保每次用完都调用driver.quit()并且放在finally块里。driver webdriver.Chrome(optionsoptions) try: # 业务逻辑 pass finally: driver.quit()另一个选型是with语句的上下文管理器配合标准写法更优雅。但要记住如果脚本异常退出且没走到finally残留的浏览器进程依然会留在内存里。所以养成用完就关的习惯很重要。在高强度运行任务时可以定时检测并清理残留的driver进程。4.5 定位动态类名的处理技巧JS渲染的页面里class名经常带动态哈希值比如classitem_8f3k2每次刷新后缀都不同。这种情况下绝对不能写死class。我在一次采集任务中遇到过这件事上午还能跑的脚本下午报错找元素找不到了打开页面一看class全变了。处理方式有几个用稳定的父级class配合子级结构定位。div.list div.item_xxx h3可以简化成先定位div.list再在它内部找对应层级。用CSS选择器的属性匹配比如div[class^item]表示匹配class以item开头的元素。用XPath的contains函数匹配包含关系的class。动态类名是JS渲染页面的一个普遍特征处理思路不是写死样式名而是找到稳定的结构关系。定位思路的核心是寻找页面中不变的部分而不是和变化的部分硬碰硬。5. 不同方案的取舍Selenium的最终定位与补充5.1 为什么不用requests直接解析接口刚才提过Selenium处理JS渲染页面是有效的但它的“重”也让人犹豫。实际上处理这类页面还有另一个思路直接跳过页面渲染找到页面上接口请求的URL和参数用requests调用接口拿JSON数据。这样既不需要启动浏览器速度也快得多。那为什么还要用Selenium因为找接口这条路在遇到以下情况时走不通接口参数里有复杂的加密签名比如时间戳、随机数拼接后再哈希。接口返回的数据乱序或分片需要JS逻辑拼装后才能还原。接口本身做了反调试代码里对数据作了混淆。这些场景下分析接口的成本可能比跑Selenium还要高。Selenium的定位不是替代接口方案而是给那条路走不通时留一个兜底方案。它只要渲染出页面就能提取数据不依赖对接口逻辑的理解这是它最大的价值。5.2 后续扩展方向Selenium不是终点理解了它的原理后还可以向两个方向扩展。一个是使用Playwright。它和Selenium思路类似但API更现代化、性能更好。如果对渲染速度有更高要求可以考虑迁到这类工具。另一个是结合代理池、限速策略来降低反爬命中但那就是更深的工程问题了。本篇文章先把Selenium这个基础方案吃透后面再展开聊这些扩展内容。最后的实操体会我在实际项目里最常用Selenium的场景是页面数据完全依赖异步渲染、接口分析成本高、偶尔跑一次且数据量不大。这种场景下Selenium是我最省心的选择。它的逻辑简单——模拟一个人在看页面页面里有什么就抓什么——这种直观性在排查复杂问题时特别好用。跑了一段时间后我养成了一个习惯不管用什么方案我都会先想清楚页面为什么拿不到数据。如果是接口请求链路过长那找接口如果是页面渲染逻辑复杂那就直接上Selenium。先判断场景再选工具比自己一门心思钻研某个工具要省力得多。希望这篇文章能帮你在处理JS渲染页面时少踩几个坑也欢迎你在评论区聊聊自己遇到过哪些不好处理的页面场景。