尧图精选

Selenium实战:破解JavaScript渲染页面的动态数据抓取

🕒 发布时间:2026/9/28 13:30:40 📁 来源:尧图网络
不知道你有没有遇到过这种场景用 requests 把网页拉下来正则或者 XPath 一顿操作结果发现目标数据根本不在 HTML 里。翻遍源码只看到一堆空壳 div数据全在页面加载之后才被 JavaScript 动态塞进去的。遇到这种情况很多人的第一反应是“上 Selenium”但 Selenium 不是银弹用得不对照样踩坑。这篇文章我打算从一个实际项目出发聊聊用 Selenium 处理 JavaScript 渲染页面的完整思路。包括什么时候必须上浏览器渲染、环境搭建里最容易翻车的版本问题、定位元素和等待页面加载的核心操作、还有无头模式、反爬、异常排查这些实战中绕不开的细节。内容面向的是有一定爬虫基础、想进阶处理动态渲染页面的开发者读完你应该能独立把一个 JS 渲染的页面完整地抓下来并且知道怎么存、怎么优化、怎么排错。1. 先说结论什么时候非得上 Selenium 不可很多人把 Selenium 当成动态页面的万能解药其实这是误解。浏览器渲染开销大、速度慢、资源占用高能用更轻量的方式解决就不要轻易开浏览器。我一般会先花几分钟做判断确认目标页面到底是不是真的需要 Selenium。1.1 静态请求拿不到数据的三种典型场景第一种情况是数据由前端脚本发起异步请求获取拿到响应后再渲染到页面上。你在开发者工具的 Network 面板里能看到明显的 XHR 或 Fetch 请求返回的是 JSON 数据。页面初始 HTML 里只有一个加载动画或者空的容器。第二种情况是用了现代前端框架比如 React、Vue。这些框架的页面 HTML 结构跟最终渲染出来的页面完全不同数据往往绑定在组件的状态里通过虚拟 DOM 更新到页面。直接解析返回的 HTML 会看到大量自定义标签和绑定事件但看不到真实数据。第三种情况是数据需要交互操作后才会出现比如滚动加载、点击按钮展开更多、悬停显示详情。这类交互触发的请求和服务端返回的逻辑跟直接访问页面完全不同requests 根本没法模拟。1.2 先试试接口直连别上来就开浏览器这是我想强调的第一点拿到一个目标页面先别急着写 Selenium 代码。打开 Chrome 的开发者工具切到 Network 面板按 XHR 过滤刷新页面观察发起了哪些请求。很多看似动态渲染的页面其实底层的接口是直接暴露的参数也规规矩矩地放在请求头里。如果接口能直接拿到 JSON那用 requests 模拟请求头拿到数据后直接处理速度和稳定性都比 Selenium 高一个量级。我之前抓过某个行情网站页面看着是 JS 渲染的但其实有一个简单的 get 接口加个签名参数就能直接返回全量数据。真正需要 Selenium 的场景是那种接口参数经过了复杂加密、签名逻辑写在混淆 JS 里解不开或者数据必须经过浏览器执行环境才能生成的场景。这时候再用 Selenium属于“不得不”。2. 环境搭建Selenium 和浏览器的版本匹配是第一道坎Selenium 本身只是协议层和驱动封装真正干活的是浏览器。所以环境配置的核心矛盾就是 Selenium 版本、浏览器驱动版本、浏览器主版本三者之间的对应关系。这三个只要有一个不匹配Selenium 启动浏览器就会报错最常见的错误是 SessionNotCreatedException。2.1 安装步骤与版本对应关系先装 Selenium 包这个简单pip install selenium但难点在于 ChromeDriver。它的版本必须和你本地 Chrome 浏览器的大版本一致。比如 Chrome 是 128那 ChromeDriver 也得是 128 的对应小版本。版本差一位启动时会报“This version of ChromeDriver only supports Chrome version xxx”。我之前有一次 Chrome 自动更新了没注意第二天跑脚本直接挂掉排查了半天才发现是驱动版本过期了。现在我不再手动下载驱动了直接用 webdriver_manager 自动管理pip install webdriver-manager它会在第一次启动时自动检测浏览器版本下载匹配的驱动并缓存到本地。用起来非常简单from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager driver webdriver.Chrome(ChromeDriverManager().install())2.2 浏览器驱动的三种获取方式对比除了手动下载和 webdriver_manager新版 Selenium 4.11 之后内置了 Selenium Manager会在找不到驱动时自动去官方源拉取匹配版本你甚至不需要额外装 webdriver-manager 就能直接跑。不过初次运行需要联网下载驱动网络环境不好的话可能会卡住。我整理了下三种方式的对比方式优点缺点推荐场景手动下载可控性强可固定版本浏览器升级后容易失效对版本有严格要求的项目webdriver-manager自动匹配省心多装一个依赖包大多数日常项目Selenium Manager内置零配置首次下载依赖网络快速验证脚本时提示无论用哪种方式生产环境最好固定浏览器版本不要开自动更新。否则半夜脚本突然挂掉是常事。3. 核心操作三板斧定位、等待、执行脚本Selenium 操作页面的逻辑绕不开三件事怎么找到元素、怎么等元素出现、怎么执行 JavaScript 辅助操作。这三板斧练熟了大部分 JS 渲染页面都能处理。3.1 元素定位XPath 和 CSS 选择器怎么选Selenium 提供了多种定位方式ID、Name、Class、Tag、LinkText、XPath、CSS。实际使用中我用得最多的是 XPath 和 CSS。XPath 的好处是能通过元素属性、文本内容、层级关系精确定位尤其是没有稳定 ID 的页面。比如这样定位一个包含“排行榜”文字的按钮from selenium.webdriver.common.by import By button driver.find_element(By.XPATH, //button[contains(text(), 排行榜)])CSS 选择器语法更简洁性能也好一些适合按照 class、id、属性组合来选。但 CSS 没法像 XPath 那样按文本内容选元素所以在页面结构复杂时灵活度略低item driver.find_element(By.CSS_SELECTOR, div.list-item span.name)我的经验是元素有稳定 id 就优先用 ID页面结构层级复杂、需要依赖文本或兄弟节点关系时用 XPath纯样式类选择时用 CSS。另外如果页面里有 iframe需要先切进去才能定位里面的元素这个特别容易漏新手经常在这里踩坑半小时driver.switch_to.frame(iframe_id) # 操作完再切回来 driver.switch_to.default_content()3.2 显式等待与隐式等待别再无脑 sleep 了页面是 JS 渲染的元素出现的时间是不确定的。很多人图省事直接time.sleep(5)要么等不够元素没出来要么等多了浪费时间。正确做法是用 WebDriverWait 显式等待等某个条件满足后继续执行from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC # 等待某个元素可见最多10秒 element WebDriverWait(driver, 10).until( EC.visibility_of_element_located((By.XPATH, //div[classdata])) )WebDriverWait 还支持until_not、可以设置轮询间隔poll_frequency以及忽略特定异常ignored_exceptions。处理那种“先出现加载动画然后数据渲染”的页面我通常用两种条件组合presence_of_element_located元素在 DOM 树中出现不一定可见visibility_of_element_located元素可见且非隐藏另外Selenium 默认的隐式等待和显式等待不要混用。隐式等待设置的是全局的轮询超时显式等待是针对特定条件的两个叠加会让等待逻辑变得混乱容易出现等待时间翻倍或者定位到半加载状态元素的问题。我现在只用显式等待逻辑更清晰。3.3 execute_script处理滚动加载和复杂交互有些页面的数据是滚动到可视区域后才触发的。这类页面用 Selenium 模拟滚动是家常便饭。直接执行 JS 是最快的方式driver.execute_script(window.scrollTo(0, document.body.scrollHeight);)如果需要循环下滑直到出现某个元素可以配合等待循环来实现position 0 while True: # 先尝试查找目标元素 try: driver.find_element(By.XPATH, //div[idload_more]) break except Exception: driver.execute_script(window.scrollTo(0, document.body.scrollHeight);) time.sleep(1) # 防止死循环设定最大滚动次数 position 1 if position 30: breakexecute_script 还能做更多事情比如给 disabled 的输入框赋真实值、读取元素的内部属性、把 canvas 画的图表数据取出来。我之前处理过一个图表数据页面所有数据都画在 canvas 上DOM 里根本没有数据节点。最后就是靠 execute_script 调用页面内部的图表实例方法把数据扣出来的。4. 实战抓取一个 JS 渲染的排行榜页面理论讲完了用一个完整案例走一遍流程。假设我们要抓取某个排行榜页面这个页面的数据通过 JS 异步加载页面初始 HTML 里没有榜单内容。4.1 需求拆解与页面分析先打开页面按 F12 看 Network会发现有一个排行榜列表的 XHR 请求返回 JSON 数据。虽然理论上可以直接调接口但接口带了一个动态 token是通过 JS 内部加密生成的短时间内没法逆向出来所以这里用 Selenium 是合理选择。页面结构方面榜单每行是一个div.rank-item里面有span.rank-num代表排名span.rank-name代表名称span.rank-score代表分数。总共有 10 页点击页码按钮会重新加载数据。4.2 完整实现代码直接上代码我是按“初始化、取列表页、翻页、关闭”这个流程组织的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 from selenium.webdriver.chrome.options import Options # 初始化浏览器 options Options() options.add_argument(--headlessnew) # 无头模式 options.add_argument(--window-size1920,1080) options.add_argument(--disable-blink-featuresAutomationControlled) driver webdriver.Chrome(optionsoptions) try: # 打开目标页面 driver.get(https://example.com/rank/list) all_data [] for page in range(1, 11): # 等待榜单元素渲染完成 items WebDriverWait(driver, 10).until( EC.presence_of_all_elements_located((By.CSS_SELECTOR, div.rank-item)) ) # 每页是一个快照直接取数据 for item in items: rank item.find_element(By.CSS_SELECTOR, span.rank-num).text.strip() name item.find_element(By.CSS_SELECTOR, span.rank-name).text.strip() score item.find_element(By.CSS_SELECTOR, span.rank-score).text.strip() all_data.append({ rank: int(rank), name: name, score: float(score) }) print(f第{page}页完成累计{len(all_data)}条数据) # 点击下一页最后一页不需要点 if page 10: next_btn WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.XPATH, //button[contains(text(), 下一页)])) ) next_btn.click() time.sleep(1) # 等待新数据渲染这里用1秒轮询足够也可以用显式等待 finally: driver.quit() # 打印前五行确认 for d in all_data[:5]: print(d)这段代码的细节都值得注意。比如为什么要用presence_of_all_elements_located而不是visibility_of_all_elements_located因为排行榜首屏元素可能在可视区内但后面的元素在屏幕下方不会被判定为可见。再比如翻页点击之后需要重新等待榜单元素加载完成而不是直接取旧数据否则每页抓到的都是第一页的内容。4.3 数据存储SQLAlchemy 写库数据抓下来只是第一步还得存进数据库。我习惯用 SQLAlchemy 管理数据库表结构。这里直接用 ORM 模式建一张排行榜表from sqlalchemy import create_engine, Column, Integer, String, Float from sqlalchemy.orm import declarative_base, sessionmaker Base declarative_base() class RankItem(Base): __tablename__ rank_items id Column(Integer, primary_keyTrue, autoincrementTrue) rank Column(Integer, uniqueTrue, nullableFalse) name Column(String(128), nullableFalse) score Column(Float, nullableFalse) engine create_engine(sqlite:///rank.db) Base.metadata.create_all(engine) Session sessionmaker(bindengine) session Session() for d in all_data: session.merge(RankItem(rankd[rank], named[name], scored[score])) session.commit() print(数据写入完成) session.close()这里用session.merge而不是session.add是有讲究的。排行榜数据每次抓取是快照同一排名可能对应不同名称和分数。如果直接 add数据库里会出现重复记录用 merge 则能根据唯一字段rank自动判断是更新还是插入。这个小技巧在处理增量快照数据时很实用。5. 性能与反爬无头模式、加载策略、指纹伪装Selenium 能干的活都干完了接下来就是怎么干得又快又不容易被发现。这一章没有标准答案全是实战权衡。5.1 无头模式与页面加载策略的取舍无头模式不显示浏览器界面脚本在后台运行资源占用低适合部署在服务器上。Chrome 的无头模式经历了新旧两代新无头模式headlessnew和完整浏览器几乎没区别稳定性也比旧版好很多。但要明确的是无头模式不等于“不会被检测”。很多站点的反爬脚本会检测navigator.webdriver属性、window.chrome对象是否存在、浏览器指纹是否完整。无头模式在这些方面反而更容易暴露。除了无头还要关注页面加载策略。Selenium 支持三种加载策略策略行为适用场景normal等待页面所有资源加载完成数据依赖完整 DOM 的页面eager等 DOM 解析完即可不等图片样式大部分列表页和后台接口none不等任何资源页面一有内容就返回配合 execute_script 手动等待我抓数据类页面一般用 eager能省不少时间。设置方式from selenium.webdriver.chrome.options import PageLoadStrategy options.page_load_strategy PageLoadStrategy.EAGER5.2 反检测的几个细节Selenium 驱动的浏览器在 JS 环境里有一个明显的特征——window.navigator.webdriver的值是 true。很多网站靠这一条就能直接拦住爬虫。我用过比较有效的方案是启动参数加--disable-blink-featuresAutomationControlledoptions.add_argument(--disable-blink-featuresAutomationControlled)这会禁止 Blink 渲染引擎自动暴露自动化特征配合自定义 User-Agent 使用效果更好options.add_argument(user-agentMozilla/5.0 ...)更硬核的做法是启动后注入 JS 修改navigator.webdriver属性driver.execute_cdp_cmd(Page.addScriptToEvaluateOnNewDocument, { source: Object.defineProperty(navigator, webdriver, { get: () undefined }); })但要记住反爬是动态对抗没有一劳永逸的方案。我的原则是先跑通业务再逐步加固别一开始就堆一堆伪装参数出了问题反而没法定位。5.3 并发控制别把目标站点搞挂有些朋友爬取速度快了容易失控一个循环里开五六个浏览器实例目标站点直接响应超时。这里我给一个朴素但管用的建议把请求频率控制在目标站点大流量时段的 1/10 左右。具体实现上我用一个简单的信号量控制并发浏览器实例数import threading semaphore threading.Semaphore(2) # 最多同时跑2个浏览器实例 def crawl_page(page_no): with semaphore: # 实际爬取逻辑 pass并发不是越高越好。单个浏览器实例处理 JS 渲染页面已经很快了开太多反而会因为机器 CPU、内存不足导致等待时间拉长。我一般先跑单实例统计单页耗时再根据机器资源估算并发上限。6. 踩坑实录超时、元素不存在、窗口异常最后说说我在这条路上踩过的最多的几个坑。有些坑浪费了我一整天写出来希望大家绕过。6.1 三个高频异常及排查链路TimeoutException是出现频率最高的。排查思路是先确认是等待条件不合理还是页面本身没加载出来。我的做法是对页面发一个简单的 JS 查询看 DOM 里到底有没有目标元素html driver.page_source with open(debug.html, w, encodingutf-8) as f: f.write(html)把页面源码保存下来手动搜一下目标元素。如果源码里压根没有说明是定位条件写错了或者数据接口被风控如果源码里有但定位不到多半是等待条件用错了。NoSuchElementException也很常见。除了定位表达式写错还有两个容易被忽略的原因元素在 iframe 里面或者页面有多个同名元素而你用了 find_element 而不是 find_elements。前者切换到 iframe 就能解决后者要检查页面结构确定是不是该用索引或遍历items driver.find_elements(By.CLASS_NAME, rank-item) if len(items) 1: print(有多个同类型元素需要遍历)WebDriverException类错误则大多数是环境问题。最常见的是浏览器和驱动版本不匹配、浏览器崩溃导致会话失效、以及使用代理或防火墙导致驱动无法访问。这类错误排查思路比较简单看错误消息的第一行基本上就是版本号或者端口问题。6.2 最后分享几个调试技巧调试 Selenium 脚本我最常用的是三个手段。第一个是截图。在关键步骤后调用driver.save_screenshot(step.png)能快速看到当前浏览器到底是什么状态。这个在无头模式下特别有用因为你看不到浏览器窗口截图是唯一的直观反馈。第二个是打印当前 URL 和部分文本。有时页面跳转了或者弹出了登录框通过打印能快速发现print(当前URL:, driver.current_url) print(页面标题:, driver.title)第三个技巧是处理弹窗。很多页面会在加载后自动弹出广告、活动弹窗遮挡元素导致点击失败。我会在初始化后写一个通用方法遇到弹窗先关掉再继续try: close_btn driver.find_element(By.XPATH, //div[contains(class,popup)]//button[contains(text(),关闭)]) close_btn.click() except Exception: pass # 没有弹窗正常继续这个“试着关一下关不掉就算了”的逻辑在处理杂七杂八的页面时特别省心。我曾经在一个电商页面上因为没关弹窗定位了一个可见元素但始终点不中浪费了一个小时。后来养成习惯新页面先过一遍弹窗逻辑再进入主流程。关于 Selenium 处理 JavaScript 渲染页面值得聊的还有很多比如如何结合 CDP 协议直接监听网络请求、如何用curl_cffi等更轻量的方案绕过部分场景。但核心思路还是那几条先判断有没有必要上浏览器、环境版本要管稳、等待和定位的逻辑要扎实、调试手段要齐全、始终对目标站点的负载保持克制。把这些基本功练好无论页面渲染方式怎么变你都有应对的底气。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →