ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Selenium爬虫实战:破解JavaScript动态页面数据抓取难题

Selenium爬虫实战:破解JavaScript动态页面数据抓取难题 写爬虫的朋友应该都有过这种经历requests 把网页 HTML 抓下来仔细一找页面里明明显示的商品价格、评论数量、用户列表源码里一个都搜不到。原因就是页面用了 JavaScript 渲染——数据不是服务端直接写在 HTML 里的而是浏览器加载完脚本之后由 JS 动态请求接口、再塞进页面的。这种场景下Selenium 是处理起来最直接的工具之一。Selenium 本身是浏览器自动化测试框架但爬虫圈早就把它当“动态页面收割机”用了。它的核心思路很简单用脚本驱动一个真实浏览器打开页面等 JavaScript 跑完再去读取渲染之后的 DOM。管你是 XHR 异步加载、Vue/React 前端框架、还是点击按钮后才出现的弹窗内容只要浏览器能显示出来Selenium 基本就能拿到。适合两类人一类是已经会用 requests 写基础爬虫、但被动态页面卡住的新手另一类是需要模拟登录、翻页、购物车加购这类复杂交互的开发者。下面这篇文章我把从环境搭建到元素定位、从等待机制到问题排查的完整链路拆开讲全程按我实际踩坑的经验来。1. 为什么 requests 拿不到数据先搞懂 JavaScript 渲染的本质1.1 静态页面和动态页面的本质区别传统静态页面的逻辑是“服务端把菜做好端上来”服务器拿到请求后在后台把数据填充进 HTML 模板拼成一个完整的页面字符串返回给浏览器。你用 requests 去抓拿到的东西和浏览器里看到的几乎一样。但现在的网站早就不是这个套路了。很多页面返回的 HTML 其实是一个空壳子里面只有几个div idapp或者div classcontent真正的内容是页面加载后 JavaScript 再去调接口获取的。这个过程可以理解成服务端只给了你一份菜单和厨房的电话你自己得打电话点菜厨房做好后再给你端上来。你用 requests 只拿到了菜单当然看不到菜。这个过程里面前端框架Vue、React、Angular 等起了很大作用。它们把数据绑定和 DOM 操作做成了自动化的开发者只需要在 JS 里写一句data.list res.data页面上的列表就会自动刷新。对爬虫来说麻烦点就在这数据不是初始 HTML 的一部分而是脚本运行的结果。1.2 怎么快速判断一个页面是不是 JavaScript 渲染在动手写 Selenium 之前先花两分钟判断页面类型能省很多事。最简单的办法浏览器里右键“查看网页源代码”注意不是“检查”然后在源代码里 CtrlF 搜索你眼睛能看到的关键文字。如果在源代码里能搜到说明这是服务端渲染的数据requests 直接能拿到完全不需要上 Selenium。如果源代码里搜不到但页面明明显示着这些文字那就是 JS 动态渲染。还有一个更专业的判断方法打开浏览器开发者工具F12切到 Network 面板刷新页面筛选 XHR/Fetch 请求。如果看到一堆getGoodsList、getUserInfo之类的接口请求而且点开响应能直接看到 JSON 数据那说明页面数据来自这些异步接口。这种情况其实还有更轻量的方案直接用 requests 去调这些 XHR 接口绕过页面直接拿 JSON比 Selenium 快得多。但现实往往是接口有签名、有加密参数、有时间戳校验你扒接口逻辑可能要扒一天或者数据是前端通过复杂计算渲染出来的根本不是一个 JSON 就能搞定的。这时候 Selenium 的价值就体现出来了。1.3 处理 JS 渲染的几条技术路线我为什么最后选 Selenium市面上能处理动态页面的方案不止一种我在不同项目里都试过简单对比一下方案原理优点缺点requests 逆向接口直接请求后端 XHR 接口速度快、资源占用低需要逆向签名和加密维护成本高requests_html内置 Chromium 渲染代码轻、易上手功能弱复杂交互支持差社区生态小Selenium驱动真实浏览器功能全、文档多、支持复杂交互启动慢、吃内存、容易被站点识别Playwright / Puppeteer驱动浏览器支持 CDP 协议现代、快、支持多浏览器生态相对新老系统兼容偶尔有坑我自己的项目里如果是“公开 JSON 接口 简单参数”的情况我首选 requests 直接调接口但如果目标是内部系统、需要登录态的运营后台或者页面交互流程很长比如加购、结算、查询订单我基本直接上 Selenium。原因很实在Selenium 生态太成熟了遇到问题搜一下几乎都有答案团队接手也快。Playwright 我也用但在一些老版 Chromium 内核的定制浏览器上兼容性不如 Selenium 稳。2. Selenium 环境搭建版本匹配这一步卡住了很多新手2.1 安装 Selenium 库安装本身很简单pip install selenium如果你用的是 Selenium 4.x 版本要注意 API 和 3.x 差别不小。最典型的变化就是 3.x 时代常用的find_element_by_id()、find_element_by_xpath()这类方法在 4.x 里被移除了统一改成find_element(By.ID, xxx)的写法。网上很多教程还是老的写法你直接复制会报 AttributeError。看到报错别慌大概率是版本语法问题。还有一点确认一下你的 Python 不是古董版本建议 3.7 以上。Selenium 4 对 Python 3.7 以下基本不维护了装的时候容易出现依赖冲突。2.2 浏览器驱动下载与版本匹配Selenium 的机制是Python 代码通过 WebDriver 协议控制浏览器。所以光装库还不够你得有一个“翻译官”——chromedriver或 geckodriver、edgedriver它负责把 Selenium 的指令转成浏览器能懂的操作。这里有个几乎所有人都会踩的坑chromedriver 的版本必须和本机 Chrome 浏览器的大版本号一致。比如你 Chrome 是 120 版本驱动也得是 120.x用 119 或 121 都会报session not created或者This version of ChromeDriver only supports Chrome version 119。怎么看自己 Chrome 版本地址栏输入chrome://version/或者右上角菜单 - 帮助 - 关于 Google Chrome第一行就是版本号。记住大版本号就行比如 120.0.6099.109 这种你的驱动只要是 120 开头就能匹配。驱动去哪下载Chrome 的话去 Chrome for Testing 的官方页面或者 chromedriver.chromium.org 下载Firefox 用 geckodriverEdge 用 Microsoft Edge WebDriver。下载后解压得到一个可执行文件比如chromedriver.exeWindows或chromedrivermacOS/Linux。2.3 环境变量配置与快速验证驱动文件有两种用法。第一种是把驱动所在目录加进系统 PATH 环境变量这样 Selenium 能自动找到它第二种是不配环境变量直接指定路径from selenium import webdriver from selenium.webdriver.chrome.service import Service service Service(/usr/local/bin/chromedriver) driver webdriver.Chrome(serviceservice) driver.get(https://example.com) print(driver.title) driver.quit()配好之后先跑一个最简单的验证脚本能打开页面、输出标题说明环境基本通了。我第一次跑的时候卡了很久结果发现是 PATH 里有个旧的 chromedriver 抢占了导致版本一直对不上。后来改用上面这种显式指定路径的方式彻底解决了。如果你不想手动管驱动版本也可以试试webdriver-manager这个库它可以自动检测浏览器版本并下载对应驱动懒人福音pip install webdriver-managerfrom selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager driver webdriver.Chrome(serviceService(ChromeDriverManager().install()))不过我还是建议新手手动做一次版本匹配因为自动管理工具在某些内网环境里没法用而且出了问题你都不知道底层发生了什么。2.4 优雅封装把启动浏览器做成可复用函数实际写爬虫的时候不要每次 new 一个裸的 driver而是封装成一个函数把常用参数都配好from selenium import webdriver from selenium.webdriver.chrome.options import Options def create_driver(headlessFalse): options Options() if headless: options.add_argument(--headlessnew) options.add_argument(--disable-gpu) options.add_argument(--window-size1920,1080) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage) options.add_experimental_option(excludeSwitches, [enable-automation]) driver webdriver.Chrome(optionsoptions) driver.set_page_load_timeout(30) return driverheadless参数我强烈建议做成开关。调试的时候用有头模式headlessFalse能亲眼看到浏览器在干什么跑正式采集的时候再用无头模式速度快很多也不占桌面。有一点要注意无头模式下个别页面的 JS 行为会有差异比如有些懒加载插件在不可见区域不加载图片或者某些组件做了视口判断。如果你发现有头模式正常、无头模式拿不到数据优先怀疑这里可以尝试固定窗口大小来缓解。3. 核心实操元素定位与页面交互从入门到实战3.1 八种定位方式到底怎么选Selenium 里定位元素的 API 换汤不换药归结起来就是这几种定位方式示例写法适用场景注意点IDfind_element(By.ID, username)元素有唯一 id最高效页面里重复 id 会导致定位到第一个Namefind_element(By.NAME, password)表单字段常用不唯一时慎用Class Namefind_element(By.CLASS_NAME, price)一类元素的公共样式特征类名可能包含空格需取其中一个Tag Namefind_element(By.TAG_NAME, div)批量取某类标签极少单独用Link Textfind_element(By.LINK_TEXT, 登录)定位 a 链接的完整文字必须完全匹配Partial Link Textfind_element(By.PARTIAL_LINK_TEXT, 登)链接文字长时模糊匹配匹配到第一个包含文字的元素XPathfind_element(By.XPATH, //div[classprice])最灵活支持文本、层级、属性写复杂了性能差、代码难维护CSS Selectorfind_element(By.CSS_SELECTOR, .price span)简洁高效适合前端熟悉的同学无法直接按文本内容定位我的个人建议是能用 CSS Selector 就不用 XPath因为 CSS 在选择器解析上性能更好、写法也更简洁但如果你要按文字内容定位——比如找个按钮叫“立即购买”——那 XPath 最顺手后面会讲。还有个小细节find_element找不到元素会直接抛NoSuchElementException如果你不确定元素是否存在用find_elements注意是复数会返回空列表不会抛异常。这在写判断逻辑时非常好用。3.2 等页面加载三大等待机制的取舍很多人写 Selenium 第一版遇到“元素找不到”的报错二话不说在代码前面塞一个time.sleep(5)。我承认我也干过但睡得多了就会发现问题要么等不够页面还没加载完就继续操作了要么等太久一个页面平均多花好几秒跑几千个页面能慢到怀疑人生。Selenium 提供了三种更优雅的方式强制等待time.sleep无脑睡固定时间。优点是简单缺点是傻不适合正式项目。隐式等待implicitly_wait设置一个全局最长等待时间比如driver.implicitly_wait(10)之后每次find_element时如果元素没出现Selenium 会轮询等待直到超时。它解决的是“元素是否出现在 DOM 里”的问题但解决不了“元素在 DOM 里却不可见、不可点击”的问题。显式等待WebDriverWait针对某个条件等待最灵活也是我推荐的主力方式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) button wait.until(EC.element_to_be_clickable((By.XPATH, //button[contains(text(),加入购物车)]))) button.click()expected_conditions里有很多现成的条件presence_of_element_located出现在 DOM、visibility_of_element_located可见、element_to_be_clickable可见且可点击、text_to_be_present_in_element元素文本包含某文字等等。基本能满足 90% 的场景。我的习惯是这样的页面刚打开时用显式等待等关键元素出现涉及点击操作时等元素可点击数据校验阶段再判断特定文本是否出现。把time.sleep压低到最少速度能快一倍以上。3.3 实战场景一按菜单名/按钮文字定位元素很多读者的热词里都有“selenium根据菜单名定位元素”这确实是高频需求。举个实际场景一个后台管理页面左侧菜单叫“订单管理”鼠标悬停后弹出一个子菜单“退款列表”你要点这个子菜单。按文字定位XPath 是最直接的方式# 精确匹配完整文字 driver.find_element(By.XPATH, //span[text()订单管理]) # 模糊匹配包含“订单”的元素 driver.find_element(By.XPATH, //span[contains(text(),订单)]) # 匹配按钮元素且包含“加入购物车” driver.find_element(By.XPATH, //button[contains(text(),加入购物车)])这里有两个坑。第一个坑如果页面里有多个元素都包含“订单”字样find_element只返回第一个。你得先确认目标元素在 DOM 里的位置或者改用find_elements后按索引取再或者加上父级限制driver.find_element(By.XPATH, //div[classsidebar]//span[text()订单管理])第二个坑菜单的悬停子项。很多下拉菜单是鼠标悬停hover才会出现的直接用click()点父菜单没用得用 ActionChains 模拟鼠标操作from selenium.webdriver.common.action_chains import ActionChains menu driver.find_element(By.XPATH, //span[text()订单管理]) ActionChains(driver).move_to_element(menu).perform() time.sleep(1) # 等子菜单渲染 sub_menu driver.find_element(By.XPATH, //span[text()退款列表]) sub_menu.click()有人会问为什么这里用了time.sleep(1)因为悬停之后子菜单的展开动画很难用等待条件精确判断简单睡一秒反而是最稳的。这也是一个很典型的“不要神化显式等待”的场景。3.4 实战场景二模拟购物车页面的完整操作流购物车这个场景能串联起定位、等待、交互、数据提取一整条链路非常适合拿来练手。以下是一个演示例子选择器以你实际目标页面的 DOM 为准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 driver webdriver.Chrome() wait WebDriverWait(driver, 10) try: # 1. 打开站点 driver.get(https://your-demo-site.com) # 2. 搜索商品 search_input wait.until(EC.presence_of_element_located((By.ID, search-input))) search_input.send_keys(无线耳机) search_input.submit() # 3. 等待搜索结果渲染出来 product_items wait.until(EC.presence_of_all_elements_located((By.CLASS_NAME, product-item))) print(f搜索结果数量: {len(product_items)}) # 4. 点击第一个商品进入详情页 product_items[0].click() # 5. 等待“加入购物车”按钮可点击然后点击 add_cart_btn wait.until(EC.element_to_be_clickable((By.XPATH, //button[contains(text(),加入购物车)]))) add_cart_btn.click() # 6. 打开购物车 cart_icon wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, .cart-icon))) cart_icon.click() # 7. 校验购物车商品信息 cart_items wait.until(EC.presence_of_all_elements_located((By.CLASS_NAME, cart-item))) for item in cart_items: name item.find_element(By.CSS_SELECTOR, .item-name).text price item.find_element(By.CSS_SELECTOR, .item-price).text print(f商品: {name}, 价格: {price}) finally: driver.quit()这段代码的逻辑非常典型每做一步操作前都先等目标元素出现再执行操作每次从列表页跳详情页之前都用显式等待确认页面已经切换完成。这里有个特别容易踩的坑点击商品之后如果是在新标签页打开的你的 driver 还停留在原来的页面上。如果你发现点击之后怎么都定位不到详情页元素先检查是不是窗口句柄变了# 点击前记录当前窗口 current_window driver.current_window_handle # 点击... # 点击后获取所有窗口句柄 all_windows driver.window_handles for handle in all_windows: if handle ! current_window: driver.switch_to.window(handle) break真实项目里电商网站还会面对登录态、滑块验证、接口签名等问题这些不建议通过 Selenium 硬碰硬解决原因后面说。3.5 实战场景三滚动加载与翻页处理无限滚动页面是前端动态渲染的另一种常见形态。往下滚的过程中页面底部会出现 loading然后新的内容被追加进 DOM。用 Selenium 处理这种页面核心思路是“模拟滚动 - 判断高度是否变化 - 继续滚直到没有新内容”import time last_height driver.execute_script(return document.body.scrollHeight) while True: driver.execute_script(window.scrollTo(0, document.body.scrollHeight)) time.sleep(2) 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, .feed-item) print(f共加载 {len(items)} 条数据)判断依据就是document.body.scrollHeight如果滚动后页面高度没有变化说明已经没有新内容加载。这个方案在绝大多数无限滚动页面上都有效但要注意如果页面里有“加载中”动画最好再加一个判断等待加载动画元素消失后再继续。翻页相对简单优先看 URL 是否有规律比如page1、page2。这种情况直接改 URL 循环访问比模拟点击“下一页”按钮快得多也不会遇到按钮状态变化。如果翻页是靠 JS 生成的URL 没规律那就老老实实用find_element(By.XPATH, //a[contains(text(),下一页)]).click()。4. 常见问题与排查技巧实录4.1 “程序运行完 exit code 0 却什么都没有”怎么排查这是热词里出现频率极高的问题“python爬虫无法 爬虫程序运行不出内容只显示proceed finished with exit code 0”。exit code 0 说明程序没有报错、正常退出了但你以为它会输出的数据一个都没打印出来。这种“不报错但没结果”的问题排除思路比报错还麻烦。我一般按下面这个顺序排查看是否是选择器问题find_elements返回空列表程序不会报错循环体不执行看起来就跟“没运行”一样。用无头模式最容易出现这种情况因为你看不见页面实际内容。看是否是页面没有加载完成虽然请求返回了但 JS 还没把数据填进去。解决方法是把定位改成显式等待等核心元素出现后再提取。看是否是页面被拦截或者跳转服务端识别到自动化脚本后可能返回一个验证页面或者一个登录跳转状态码还是 200但你实际抓到的页面和预期完全不是同一个。排查时我建议做一件事出问题的时候把现场留下来。在程序里加一层保护代码try: data wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, .target-data))) print(data.text) except Exception as e: print(定位失败:, e) print(当前URL:, driver.current_url) driver.save_screenshot(debug.png) with open(debug.html, w, encodingutf-8) as f: f.write(driver.page_source)save_screenshot和page_source两个方法能帮你还原现场。看到截图和 HTML你立刻就知道是页面没加载、选择器错了、还是遇到了拦截比盲猜效率高一个量级。4.2 “a javascript error occurred in the main process” 是什么情况这个报错我在处理一些桌面端应用时见过它其实是 Electron 框架的提示信息意思是“主进程里发生了 JavaScript 错误”。如果你用 Selenium 操作的是普通网页大概率不会遇到这个但如果目标网站本身是个 Electron 壳应用或者你本机有 Electron 程序在后台运行就有可能出现。遇到这类报错先看它的上下文。如果是浏览器页面内的 JS 报错Selenium 操作一般不受影响顶多某个功能模块不能正常用。但如果是主进程级别的错误页面可能直接白屏、无法交互这时候要考虑是不是页面本身在你当前环境跑不起来或者需要更新应用版本。我在实际项目中遇到过类似情况最后发现是因为目标应用版本太旧底层 Chromium 内核不支持某些新特性导致 JS 运行时报错。另外提醒一点网页里的 JS 报错和页面是否渲染成功是两回事。有的页面有 JS 报错但 DOM 已经渲染完了数据照样能提取有的页面 JS 报错严重直接导致 React/Vue 挂载失败整页空白。你要抓的是 DOM而不是报错本身所以别一看到控制台有 JS 报错就慌先看页面结构和数据有没有出来。4.3 NoSuchElementException 和 ElementNotInteractableException 两大巨头NoSuchElementException是最常见的异常本质是元素根本不在 DOM 里。常见原因有几个iframe 没切入。页面里嵌了 iframe里面的元素对主文档来说是不可见的。解决方法是先切进 iframedriver.switch_to.frame(iframe_id) # 也可以用索引或 WebElement # 操作完出来 driver.switch_to.default_content()元素在 Shadow DOM 里。现在很多组件库用 Shadow DOM 封装普通定位方式穿透不进去。处理方式是用driver.execute_script去访问 shadowRootelement driver.execute_script(return document.querySelector(my-component).shadowRoot.querySelector(.inner-class))元素在另一个窗口/标签页。前面提过用switch_to.window切换。ElementNotInteractableException则相反元素在 DOM 里但不可交互。可能是因为元素被其他遮罩层挡住了可能是因为它当前处于隐藏状态也可能是因为它的宽高为 0。排查顺序是先判断元素是否可见is_displayed()再判断是否被遮挡把页面滚动到元素位置如果这些都没问题但就是点不动可以考虑用 JS 强制点击button driver.find_element(By.XPATH, //button[contains(text(),提交)]) driver.execute_script(arguments[0].click();, button)这个方法能绕过 Selenium 的可见性检查但这是“绕过”而不是“解决”用了之后一定要关注后续流程是否真的走了。4.4 浏览器闪退、session not created、页面空白这几个是环境问题的高频代表。session not created十有八九是驱动和浏览器版本不匹配按前面说的版本匹配规则处理即可。无头模式闪退在 Linux 服务器上特别常见。通常需要加两个参数options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage)--no-sandbox是因为很多服务器环境的沙箱权限受限--disable-dev-shm-usage是因为/dev/shm分区太小Chrome 内存不够用会直接崩。这两个参数不加在 Docker 或 CI 环境里跑 Selenium崩得你是毫无脾气。页面空白的问题常见原因有两个一个是目标页面本身需要登录你不带 Cookie 或者没做登录态页面跳到了空壳另一个是网络环境里加载不了页面依赖的 CDN 资源。排查思路还是那句先截图看现场。4.5 关于自动化识别和数据采集几句实在话既然文章标题是“高级爬虫技巧”这个避不开的话题还是得说清楚。很多网站做了反自动化机制比如检测navigator.webdriver标记、检测鼠标轨迹规律、行为频率分析等。这从站方的角度是正常的防护手段毕竟谁也不希望自己的数据被机器批量抓走。从爬虫开发者的角度我的建议是先用官方 API再用合规授权最后才是自己写采集。Selenium 适合用在你拥有授权的站点上比如你自己公司的后台、内部系统、已经获得使用许可的第三方平台或者纯粹是个人学习和测试的演示页面。用来爬电商平台、社交媒体这类有明确服务条款的平台请先确认你做的事是否符合对方条款不要突破访问控制、不要恶意高频请求、不要试图破解验证码。合规性问题的边界比技术问题更需要重视。顺带说一句有些站点会通过 Apache/Nginx 配置屏蔽垃圾爬虫本质上就是服务端在做流量清洗。这种防御是合理的你如果发现自己的采集请求被拒先检查你的频率是不是太高、UA 是不是太可疑而不是想着怎么绕。最后分享一个小技巧我做 Selenium 项目这么多年最常用的一个调试组合是有头模式 显式等待 截图兜底。有头模式能看到页面真实状态显式等待避免无脑 sleep截图兜底保证出问题能还原现场。这套组合帮我解决过大量“元素找不到”“页面没数据”的疑难杂症。如果你刚接触 Selenium先别急着优化速度、上分布式把这些基本功练扎实建立起一套自己的调试方法论后面写复杂的采集任务会顺很多。技术这东西踩过的坑往往是成长最快的地方希望你也能少踩几个。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进