ARTICLE DETAIL

资讯详情

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

动态网页爬取不再难:Selenium绕过JS渲染实战指南

动态网页爬取不再难:Selenium绕过JS渲染实战指南 只要写过爬虫大概率都经历过这种时刻requests 明明请求成功了状态码 200把 response.text 打印出来翻不到任何商品信息但同一个网址打开浏览器你的眼睛能看到商品名称、价格、图片、评论区一个不少。那一刻你会怀疑人生怀疑自己刚才是不是请求了个假页面。这不是代码写错了是网页的数据不再躺在 HTML 源码里了。现在大量站点都在走 JS 动态渲染路线——服务器只给一个空壳 HTML真正的业务数据靠浏览器执行 JavaScript 后再通过 Ajax 请求拉取并填充到页面。也就是标题里说的绕过JS渲染。这篇文章我从实战角度讲清楚一个最接地气的解法用 Selenium 驱动真实浏览器去执行 JS让页面自己把数据渲染出来然后我们再提取。内容覆盖环境搭建、等待策略、滚动加载、反爬对抗以及我实际踩过的坑适合有一定 Python 基础、想突破动态页面爬取瓶颈的朋友参考。1. 动态加载网页爬不动的根因数据不在源码里1.1 静态爬虫的两大翻车现场先聊聊我自己最早踩坑的过程。当时要采集某个资讯站的文章列表用 requests BeautifulSoup 二十行代码跑通过好几个站以为这次也是同一套。结果 requests 返回的 HTML 里class 为 article-list 的节点是空的只有一个占位 div。这就是典型的壳页面——服务器返回的 HTML 只是模板具体内容要等浏览器执行 JS 后动态填充。第二种翻车现场更隐蔽页面源码里有少量数据比如有个标题但正文、评论、翻页数据全是靠 JS 异步加载。初看以为有数据可以抓提取完一运行只有零星几条而页面上显示的条数是几十倍。你重新打开浏览器看着 Network 面板里哗哗哗的 XHR 请求才明白数据是分页异步拉取的。这两种现象是判断是否需要绕过 JS 渲染的最直观信号响应里没有业务数据、或者数据严重偏少。还有一个更简单的验证方法在浏览器里右键查看网页源代码注意不是检查元素如果源码里看不到你在页面上看到的关键数据基本可以确定是 JS 渲染。1.2 动态渲染的几种幕后实现动态加载并不是同一种技术常见的至少有三种实现方式传统 Ajax 渲染页面先加载框架JS 通过 XMLHttpRequest 或 fetch 去后端接口拿数据再拼成 DOM。这种最普遍也相对容易逆向出接口直接用 requests 请求。SPA 单页应用用 React、Vue、Angular 这类框架构建的站点整个页面几乎全是 JS 渲染出来的路由切换、数据渲染都发生在浏览器端。很多后台系统、数据大屏、部分排行榜站都是这种。服务端渲染 客户端二次渲染首屏 SSR 先给一部分数据后续内容还是依赖 JS 异步补齐。这种最难一眼看穿也是最容易让初学爬虫的人蒙圈的类型。理解这几种实现方式的意义在于当 Selenium 跑通之后你自然而然会想能不能绕过浏览器、直接用 requests 调接口而这个优化方向是否可行完全取决于页面属于哪种渲染模式。如果是传统 Ajax接口通常比较规整可以省掉浏览器开销如果是 SPA接口可能带着复杂的加密参数和 token逆向成本极高老老实实让浏览器跑反而是最优解。另一个快速识别技巧是禁用 JS 后刷新页面打开 Chrome DevToolsF12在设置里勾选 Disable JavaScript然后刷新目标页面。如果核心内容消失说明整个页面重度依赖 JS 渲染正是本文要处理的对象如果内容还在只是样式变了说明 HTML 本身带有数据普通爬虫就够了。2. 方案选型为什么用 Selenium 而不是 requests 直接模拟接口2.1 直接抓接口的省钱路径以及它的三个坑做爬虫的人都有一个本能能用 requests 就不用重工具。对于动态页面最省资源的方案是打开浏览器 F12找到数据接口然后直接用 requests 带上 headers 请求那个接口解析 JSON完事。速度快、内存占用低、稳定。这也是我自己做采集项目时的优先尝试路径但这需要你能完整拿到接口参数。它有绕不开的几个坑第一个坑是加密参数。很多站会带着 sign、token、ts 这类字段你不带上就返回 401 或者伪造数据。要逆向外层 JS 找出参数怎么生成的费时费力。我在实际项目里就遇到过一个 B 端客户后台列表页的请求带了一个多轮 md5 处理的签名每次请求还附加不同的随机因子最后整个逆向花了两天时间。如果只是为了快速采集这个成本完全不划算。第二个坑是接口数据与页面展示不一致。接口返回的可能是一堆状态码、编码后的字段、分页的 raw 结构你得额外写一层数据清洗逻辑。而页面上展示的内容是经过 JS 格式化过的Selenium 直接读取页面文本反而更接近最终呈现效果。第三个坑是接口加固。辛辛苦苦逆好了接口过两个月对方升级了风控加了一层参数混淆甚至全部接口走 websocket整个逻辑就要推倒重来。维护成本高得让人想摔键盘。2.2 Selenium 的核心原理让真实浏览器替你把 JS 跑完Selenium 做的事情说白了非常简单它通过 WebDriver 协议驱动真实浏览器Chrome、Firefox、Edge你写的 Python 代码告诉浏览器打开这个 URL、点击那个按钮、等待某个元素出现而浏览器会像真人操作一样去执行 JS、发 Ajax、渲染页面。等渲染完成后再用 find_element 去读取页面上的真实内容。这里的关键是等渲染完成这个动作执行 JS、等待接口返回、DOM 更新——整套流程都是浏览器内部的真实行为和真人浏览完全一致。所以它天然绕过了 JS 渲染这个大前提你不需要关心接口怎么拼、参数怎么加密页面上能看到什么原则上就能抓到什么。代价也明显速度慢、内存开销大、不稳定因素多。每启动一个浏览器实例动辄占用几百 MB 内存一个页面从打开到元素加载完成往往以秒为单位计算。在动辄上万数据的采集场景里Selenium 不是最优选但在必须渲染才能拿数据的场景里它常常是最可靠的方案。2.3 什么情况该用它什么情况不该用以我自己接的爬虫需求来划一条线该用 Selenium 的场景目标是 JS 重度渲染页面接口逆向成本高加密参数、token、风控复杂需要复杂交互比如登录、滚动、点筛选后出数据需要模拟真实用户行为比如带 Cookie 的会话型系统或者只是想快速拿到数据时间比性能重要。不该用 Selenium 的场景接口可以直接请求且返回的 JSON 干净可用需要大规模高并发采集对速度和资源有硬性要求页面有强验证码风控Selenium 同样过不去。这个经验很关键不要一上来就上 Selenium。先打开页面看 Network 面板一眼如果有干净的 XHR 接口优先试 requests。Selenium 像一把大锤什么问题都能敲但不是所有问题都值得用锤子去敲。3. 从零跑通第一个脚本环境、驱动与最小 Demo3.1 安装 Selenium 与浏览器版本匹配的坑先确保 Python 环境没问题3.8 以上都行推荐 3.10。安装 Selenium 很简单pip install selenium现在 Selenium 已到 4.x 版本API 变化比较大。你如果看网上早几年的教程里面用 find_element_by_id 这类写法在 4.x 里已经废除必须改成 find_element(By.ID, xxx)。后面我所有代码都用新写法免得你复制下来直接报错。接下来是浏览器驱动。Selenium 4 内置了 Selenium Manager很多场景下会自动下载匹配的驱动第一次运行脚本时会自动处理。但我还是建议你手动确认一下版本匹配如果你用 Chrome驱动叫 chromedriver版本号必须和浏览器主版本号一致。浏览器是 121 版本就必须下载 121 系列的驱动。版本不匹配时最常见的报错是 SessionNotCreatedException一直提示 This version of ChromeDriver only supports Chrome version ...。查看浏览器版本的路径Chrome 地址栏输入 chrome://version/记下版本号前三位即可。3.2 第一个 Demo启动浏览器抓取动态渲染后的标题准备一个最小脚本验证环境我习惯先用任意一个有动态内容的页面做冒烟测试from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.by import By options Options() options.add_argument(--headlessnew) # 无头模式不弹浏览器窗口 driver webdriver.Chrome(optionsoptions) try: driver.get(https://example.com) title driver.title print(页面标题:, title) h1 driver.find_element(By.TAG_NAME, h1) print(H1内容:, h1.text) finally: driver.quit()跑起来以后如果你能看到页面标题和 H1 内容环境就通了。这个 example.com 是静态页面只是让你验证环境。真正验证JS 渲染也能抓最简单的方式是找一个浏览器里能看到、但 requests 拿不到内容的网址用这个脚本去抓能抓到就说明 Selenium 确实帮你绕过了 JS 渲染。一个小细节终端里跑这段脚本如果中文打印出来是乱码通常是 Windows 终端编码问题把脚本头部加上# -*- coding: utf-8 -*-同时确保终端编码切到 UTF-8。我自己的习惯是在这个阶段多做一步看看 driver 加载页面的耗时。打开一个真实的动态页面观察第一次执行时浏览器要花多久。这会直接影响后面的等待策略怎么写。4. 让脚本学会等待三种等待策略与元素找不到的排查链路4.1 为什么动态页面必须等待Selenium 脚本最常见的报错是 NoSuchElementException新手第一反应是是不是选择器写错了。偷偷告诉你十次里有七次不是选择器的问题是元素还没渲染出来。你执行 find_element 的时候浏览器里的 JS 可能还在跑Ajax 请求还没返回DOM 节点还不存在自然找不到。这就是为什么要引入等待。动态页面里元素出现的时间是不确定的取决于网络、服务器响应速度、前端执行逻辑。如果你不给脚本等待机制就等于要求浏览器和你的代码保持完全同步这在现实里几乎不可能。4.2 三种等待方式的对比Selenium 里等待方式有三类我直接用对比来说清楚等待方式典型写法和本质优点致命缺陷固定休眠 time.sleep(3)进程暂停一定秒数简单粗暴休眠时间短了偶发失败长了整体变慢毫无智能可言隐式等待 driver.implicitly_wait(10)全局设置find_element 找不到元素时轮询查找一次设置全局生效只解决元素存在问题对元素可见可点击无能为力不能按元素个性调整显式等待 WebDriverWait逐条元素精确设置条件和超时精准、可控每个元素都要写代码稍繁琐实际项目里我几乎不用 time.sleep 做唯一的等待手段它最多在某个我明确知道这里必须要等 2 秒动画的场景下用一下。隐式等待适合懒人设一个 10 秒兜底大多数情况下能解决找不到元素的问题。但如果要追求稳定高效显式等待才是动态页面采集的正解from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC # 等待某元素出现在 DOM 中且可见最多等 10 秒 element WebDriverWait(driver, 10).until( EC.visibility_of_element_located((By.CLASS_NAME, goods-item)) )expected_conditions 里最常用的几个presence_of_element_located元素存在于 DOM 中不要求可见visibility_of_element_located元素存在且可见element_to_be_clickable元素可点击常用于按钮text_to_be_present_in_element元素内文本包含期望内容常用于等待列表加载到指定数量4.3 一个实战案例等待 Ajax 返回后商品列表完整渲染假设目标页面是一个商品列表Ajax 请求需要约 2 到 5 秒返回返回后页面渲染出 60 个商品卡片。如果用 time.sleep(3)网络慢时第 20 个商品还没渲染完你抓到的只有 10 个。用显式等待可以这么写# 等待商品列表中的第一个元素出现 WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CLASS_NAME, goods-item)) ) # 进一步等待直到商品数量达到期望值 WebDriverWait(driver, 10).until( lambda d: len(d.find_elements(By.CLASS_NAME, goods-item)) 60 ) items driver.find_elements(By.CLASS_NAME, goods-item) print(f一共抓到 {len(items)} 个商品)这个 lambda 写法很实用它把元素出现了但数量不够这个中间状态也纳入等待范围。你可能会问为什么不直接一次性等到 60 个再抓因为很多页面的加载是分批的——先渲染 20 个滚动后加载 20 个再滚动加载 20 个。这种场景要结合后面的滚动策略一起用。4.4 元素定位不到的完整排查链路当 NoSuchElementException 真的出现了我建议按以下链路排查先确认元素是否真的存在用浏览器 DevTools 的 Console 里跑 document.querySelector如果结果是 null说明选择器本身就不对。确认元素是否在 iframe 或 frame 中如果是必须先用 driver.switch_to.frame() 切进去才能 find_element。忘记切 frame 是新手高频翻车点。确认是否在新标签页里某些页面点击后在新 tab 打开内容需要先切换 window_handles。确认是否是 shadow DOM组件内部元素用普通 find_element 是抓不到的需要先获取 host 节点再操作 shadowRoot。最后重新审视等待策略把 presence 改成 visibility或者加长 WebDriverWait 的超时时间。这五条链路走完90% 的找不到元素都能解开。5. 动态交互处理滚动加载、加载更多与翻页的实战姿势5.1 模拟滚动加载的两种方式很多动态页面用滚动到底部自动加载代替分页按钮。处理这种场景本质就是要让浏览器产生滚动动作。Selenium 提供两种方式第一种用 execute_script 直接执行 JS# 一次性滚到底部 driver.execute_script(window.scrollTo(0, document.body.scrollHeight)) # 分多次慢慢滚动更接近真人浏览习惯 for i in range(5): driver.execute_script(fwindow.scrollTo(0, {i * 500})) time.sleep(0.5)第二种用 ActionChains 模拟鼠标滚轮from selenium.webdriver.common.action_chains import ActionChains actions ActionChains(driver) actions.scroll_by_amount(0, 500).perform()两种方式我都在用个人经验是 execute_script 更稳定直接ActionChains 更接近真人但不能在一些特殊页面里触发滚动事件。动态页面触发滚动加载靠的是 scroll 事件监听的所以在滚动后最好配合显式等待driver.execute_script(window.scrollTo(0, document.body.scrollHeight)) # 等待新加载的元素出现 WebDriverWait(driver, 5).until( lambda d: len(d.find_elements(By.CLASS_NAME, goods-item)) 60 )这样每次滚动后都能确认新内容真的出来了再决定是否继续下一轮滚动。5.2 点击加载更多按钮的交互细节另一种常见交互是加载更多按钮点击一次加载一批数据。这类页面不能光靠滚动判断你需要在有限循环里反复点击按钮直到它消失或失效while True: try: # 寻找加载更多按钮可能藏在不同容器里 btn driver.find_element(By.XPATH, //div[classload-more]/button) # 确保按钮可点击 WebDriverWait(driver, 5).until(EC.element_to_be_clickable((By.XPATH, //div[classload-more]/button))) # 判断按钮是否处于禁用状态 if btn.get_attribute(disabled): break btn.click() time.sleep(1) # 给接口返回留一点缓冲 except ElementNotInteractableException: break except NoSuchElementException: break这段代码有几个小细节值得注意按钮可能在加载完成后就消失NoSuchElementException 自动退出循环也可能变成禁用状态disabled 属性判断还可能被遮罩层挡住导致不可点击ElementNotInteractableException。把这几种情况都处理了循环才不会死掉。另外很多网站加载完一批数据后按钮文字会变比如加载更多变成已经到底了。你也可以通过文字判断退出条件if 到底 in btn.text: break5.3 翻页的处理逻辑翻页比滚动简单但有个以前踩过的坑有的网站翻页不改 URL而是通过 JS 刷新页面里的列表区域。这种情况不能直接用 driver.get 访问下一页地址只能用点击的方式。通用的翻页逻辑for page in range(1, 11): # 点击下一页按钮 next_btn driver.find_element(By.CSS_SELECTOR, a.next) next_btn.click() # 等待页面刷新后关键元素重新可见 WebDriverWait(driver, 10).until( EC.visibility_of_element_located((By.CLASS_NAME, list-item)) ) # 提取当前页数据 items driver.find_elements(By.CLASS_NAME, list-item) for item in items: title item.find_element(By.CLASS_NAME, title).text print(f第{page}页:, title) # 如果已经是最后一页next 按钮会带 disable 样式 if disabled in next_btn.get_attribute(class): break可以注意一下很多站点翻页时其实是发了一个 Ajax 请求然后重新渲染列表意味着旧的页面元素会被替换。等待列表重新可见这个动作能避免你抓到上一页残留的数据。6. 反爬对抗实录如何降低 Selenium 被识别的概率6.1 网站是怎么识别 Selenium 的Selenium 跑起来再怎么像真人浏览器和普通用户打开的浏览器还是有一些特征差异。最常见的检测手段就是看 navigator.webdriver 这个属性。正常浏览器这个值是 undefined而 Selenium 驱动的浏览器这个值是 true。一些前端的反爬 JS 会检查这个字段发现是自动化环境就直接不渲染数据或者弹出验证码。除此之外还有 User-Agent 特征、浏览器窗口大小异常、鼠标轨迹缺失、请求频率过于规律等。你在用 Selenium 时如果控制台里出现了滑块验证、要求你点击我不是机器人多半就是被识别出来了。6.2 基础伪装手段去掉自动化标志先做最基础的三板斧options Options() # 1. 禁用自动化控制提示 options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False) # 2. 去掉 webdriver 标志 options.add_argument(--disable-blink-featuresAutomationControlled)光这两步还不够比较老的网站其实会主动读取 navigator.webdriver这需要在页面加载前注入一段 JS把属性改掉。这个操作叫 CDP 注入driver webdriver.Chrome(optionsoptions) # 在每次新文档加载前执行覆盖 navigator.webdriver 属性 driver.execute_cdp_cmd(Page.addScriptToEvaluateOnNewDocument, { source: Object.defineProperty(navigator, webdriver, { get: () undefined }); })注意顺序execute_cdp_cmd 要在 driver.get() 之前调用这样新页面加载时会先执行这段改写脚本。如果顺序反了页面已经加载完改写就来不及了。6.3 行为模拟与工程化避坑伪装了浏览器特征行为特征同样要留意。真人打开一个页面通常会有停顿、滚动有节奏、鼠标有轨迹机器人的行为则非常规矩。Selenium 默认执行命令的速度极快click 和 find_element 之间几乎零间隔。反爬系统如果检测到每秒钟都在点击也会触发风控。我给几个实用建议每个操作之间加 0.3 到 1 秒的不固定延迟用 random.uniform 模拟人的节奏滚动不要一滚到底分几次每次滚 300 到 800 像素中间停一下对同一目标采集控制频率10 到 30 秒一个页面比较稳妥不要用同一个浏览器配置跑几十个线程容易连坐封 IP另外要强调一句合规底线Selenium 绕过的只是前端渲染不代表你有权采集任何数据。拿来做自己账号、自己有权限查看的数据分析是一回事大规模抓取他人交易数据、隐私数据是另一回事。做技术实践时请选择公开、允许抓取的站点并在采集前列出网站条款约束自己。7. 工程化与性能调优从能跑到稳跑7.1 Headless 模式不是万能的很多人为了省资源喜欢上 headless无头模式也就是浏览器不开窗口在后台运行。Selenium 4 之后 Chrome 的无头模式升级成了 --headlessnew渲染效果和旧版 headless 相比更接近有头模式。但我要提醒你headless 模式在一些极端页面里还是会有渲染差异。比如某些站点的懒加载图片在 headless 模式下可能不触发加载。我的做法是开发调试阶段永远用有头模式眼睛能看到页面到底发生了什么定位问题要快得多。等全部逻辑稳定了切到 headless 模式再跑一轮对比测试确认数据量一致后才投入使用。7.2 超时、异常与浏览器进程泄漏处理Selenium 脚本是典型的跑着跑着就崩了型程序最常见的异常有TimeoutException页面加载超时或等待超时WebDriverException浏览器崩溃头绪难找ElementClickInterceptedException元素被遮罩拦住StaleElementReferenceException页面刷新后还在用旧元素引用我建议把采集核心逻辑包在 try/except 里并处理好重试def fetch_page_data(url, retries3): for attempt in range(retries): try: driver.get(url) WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CLASS_NAME, list-item)) ) return extract_data(driver) except TimeoutException: if attempt retries - 1: raise time.sleep(2)另外一定要用 finally 或上下文管理器确保 driver.quit() 被执行。不然跑几十个页面后电脑内存里堆满 chrome 僵尸进程系统直接卡死。你可以试试在任务管理器里看到十几个 chrome 进程的酸爽。对于设置了超时时间的做法driver.set_page_load_timeout(30)这一步能防止某个页面加载过慢时你的脚本干等几分钟。超时后配合重试逻辑整体稳定性会好很多。7.3 并发控制与数据落盘的工程建议如果你采集量比较大单实例太慢想上并发我建议不要用一个 driver 多线程共用的方式。WebDriver 不是线程安全的多线程共享一个浏览器实例会造成各种诡异报错。稳妥的做法是给每个线程单独的 driver 实例但控制并发总数。我用过的最简方案是 concurrent.futures.ThreadPoolExecutor配合一个只跑 3 到 5 个并发实例的做法from concurrent.futures import ThreadPoolExecutor def worker(url): driver create_driver() # 每个线程自己创建和销毁 driver try: return fetch_page_data(driver, url) finally: driver.quit() with ThreadPoolExecutor(max_workers4) as pool: results list(pool.map(worker, urls))并发数别贪3 到 5 个差不多CPU 占用已经比较可观了。再高的话网站反爬的压力也大被封的风险成倍上升。数据落盘方面如果是一次性采集写 CSV 就好如果数据量大且需要后续做去重、增量更新建议直接用 SQLite 或 MongoDB别在文件系统里堆一堆 CSV。这个选择根据你的目标决定我不展开但建议从一开始就定下来别等到数据结构变了再迁移。最后聊几句实际体验这篇文章从头到尾都是我实际踩过坑之后总结出来的操作路径。Selenium 这个工具优点和缺点都很突出它的笨重换来的是几乎所有前端渲染页面都能跑通的可靠度。我在大量项目中最终选择的架构是先用 requests 尝试接口取数接口拿不到才把 Selenium 当作兜底方案。两者配合既照顾了效率也保证了覆盖面。如果在你的第一个动态页面项目里只能带走一条经验我希望是这句别急着写抓取代码先花十分钟打开 DevTools搞清楚数据到底是怎么加载出来的。这个习惯能让你的爬虫生涯少熬夜。
返回列表