
做竞品分析想抓天猫商品评论我第一反应是 requests 直接打接口。结果返回的 HTML 里根本没有评论数据只有一个空壳。后来换成 Selenium 模拟浏览器评论倒是出来了可页面会自动加载越滚越长导出的数据里同一句好评重复出现。如果你也被 Python Selenium 爬取天猫商品评论时的异步加载、动态滚动和去重折磨过这篇内容就是按我这次完整踩坑后的思路写的。文章会覆盖异步加载的前端机制、Selenium 环境配置、动态滚动策略、评论字段提取、多层去重方案以及让采集任务稳定跑完的细节。适合做电商竞品分析、选品调研和评论情感分析的读者参考。提示这里说的“异步加载”是指网页前端通过异步请求拉取并渲染数据和 Python 异步编程里的 asyncio 没有关系。前者是我们观察到的页面行为后者是代码执行模型别混在一起。1. 评论列表不在源码里天猫异步加载的前端机制与Selenium选型1.1 前端加载评论的三段式流程先从最底层的问题说起为什么直接 requests 拿不到评论现代电商商品页普遍是首屏框架先返回HTML 里只包含页面骨架和基础商品信息。你打开商品详情页看到“累计评价”区域其实是一个占位容器里面没有数据。当浏览器执行 JavaScript 后代码会拿着当前商品 ID 去请求一个评论接口接口返回 JSON前端再把 JSON 翻译成 DOM 节点插到评论区。这个“先请求、后渲染”的过程就是异步加载。具体拆开是三段页面加载完成后发起 XHR/Fetch 请求服务端根据参数返回评论列表 JSON前端回调函数遍历 JSON 并生成评论 DOM。整个过程不是一次性完成尤其在天猫评论场景中还会配合滚动触发翻页每滚一段就发一次请求。所以你打开页面后快速拖动滚动条能看到评论区不断往下“生长”。我习惯把这个过程类比成自助餐厅requests 相当于站在门口拍菜单而 Selenium 是进去坐下的客人能看着服务员一道一道把菜端上来并把每一道菜记下来。这也是为什么很多人第一时间用 requests 抓不到任何评论数据的根本原因。1.2 为什么早期方案都耗在了逆向接口上既然评论是接口返回的不少人会想那我直接在浏览器开发者工具里找到 XHR 请求拿到接口地址再用 requests 模拟参数不就行了理论上可以但现实很骨感。天猫评论接口不是简单的 GET 请求它带有签名参数、时间戳、Cookie 校验部分参数由 JavaScript 动态加密生成。你可以在某一瞬间把参数完整复制下来构造出一次成功请求但过一段时间参数算法一变或者某个 token 过期脚本立刻失效。维护这种逆向代码的成本往往比采集任务本身高得多。另外requests 直接打接口还有一个问题频率特征非常明显。同一个 IP 在几秒内连续请求几十次接口返回状态会立刻异常很容易触发风控。而 Selenium 驱动真实浏览器有页面渲染、资源加载、事件触发的完整过程虽然慢一些但评论这种体量不算巨大的数据综合成本反而最低。1.3 为什么是Selenium而不是Playwright或Pyppeteer主流的浏览器自动化工具有 Selenium、Playwright、Pyppeteer。我最终选 Selenium 的原因很现实生态最成熟报错时搜索引擎里能直接搜到大量历史解决方案。而且本次任务的核心难点其实不在浏览器自动化本身而在对异步加载节奏的判断和去重策略用哪个框架都差不多。三者的横向对比可以参考我实测后的感受工具上手难度反自动化指纹社区资料量资源占用Selenium低中等需要处理 webdriver 标记极大高Playwright中较好默认更接近真实浏览器中高Pyppeteer中一般少高结论是如果只是抓评论、跑短周期任务Selenium 最合适如果要做大规模、长期稳定的采集体系可以再考虑 Playwright。下面所有代码都以 Selenium 为例。2. 环境准备里最容易被忽略的三件事Driver匹配、iframe切换与显式等待2.1 依赖安装、Driver匹配与启动参数先从安装说起。Selenium 现在的安装很直接pip install selenium但很多人接着就卡在 chromedriver 版本不匹配上。Chrome 浏览器会自动更新而 chromedriver 必须和浏览器内核版本严格对应否则直接报 SessionNotCreatedException。我不建议手动下载对应版本更推荐用 Webdriver Manager 自动管理from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager driver webdriver.Chrome(serviceService(ChromeDriverManager().install()))我之前为了图省事装了一个固定版本的 chromedriver结果 Chrome 一自动更新整套脚本就废了。后来换成 Webdriver Manager至少不用每次手动找版本。启动时我会加这几类参数from selenium.webdriver.chrome.options import Options options Options() options.add_argument(--headlessnew) options.add_argument(--disable-gpu) options.add_argument(--no-sandbox) options.add_argument(--window-size1920,1080) options.add_argument(--disable-blink-featuresAutomationControlled) options.add_experimental_option(excludeSwitches, [enable-automation]) driver webdriver.Chrome(optionsoptions)简单解释几个关键参数--window-size必须显式设置否则无头模式下默认视口可能被识别为窄屏天猫会返回移动端布局评论 DOM 结构完全不一样。--disable-blink-featuresAutomationControlled和excludeSwitches用来降低“自动化测试”指纹但别指望这几行能百分百绕过检测真实项目中还会有滑块验证后面会说应对方式。2.2 切换iframe很多评论就是在这里丢的天猫商品评论页有一个很容易踩的坑评论区可能被包在 iframe 子框架里。你在主页面里怎么都定位不到评论节点不是选择器写错了而是你还没进入那个 frame。判断方法很简单在浏览器开发者工具里查看评论区域的 DOM 树看外层是不是iframe。如果是先切进去再操作frame driver.find_element(By.CSS_SELECTOR, iframe[id*comment], iframe[class*iframe]) driver.switch_to.frame(frame)评论处理完再切回主文档driver.switch_to.default_content()现在很多页面为了隔离样式和数据层会把列表类模块都塞进 iframe。我写完这套流程后也复用到其他平台发现 90% 的“定位不到元素”问题都出在 iframe 这一层。排查时第一件事要先检查 iframe。2.3 显式等待别再用time.sleep碰运气页面加载完成不等于评论渲染完成。异步请求的响应时间受网络影响波动很大固定 sleep 5 秒可能有时还没渲染完有时又白白浪费 3 秒。用显式等待才是稳定做法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, 15) comment_area wait.until( EC.presence_of_element_located((By.CSS_SELECTOR, .tm-list-container)) )我总结一个经验整个采集循环里只有两类等待是必要的一类是等待某个元素出现用presence_of_element_located另一类是等待数量条数变化用循环加条件判断。其余地方尽量少用固定 sleep这样程序更稳也不容易被误判成机器行为。3. 动态滚动不是一把梭滚动容器、加载节奏与结束条件的设计3.1 先判断页面是滚动加载还是点击加载天猫评论模块在不少商品页里并不是自动滚动加载而是底部有一个“查看更多评价”或“点击加载更多”按钮。如果没判断清楚就执行 scrollTo拉到页面最底部也不会触发任何新数据。我的处理顺序是先看评论区域底部有没有按钮。有就优先处理按钮点击没有再走滚动逻辑。判断按钮可以这样写buttons driver.find_elements( By.XPATH, //button[contains(text(),查看更多)] | //a[contains(text(),查看更多)] ) if buttons: buttons[0].click() else: scroll_and_wait()这个判断很重要。我最初直接按通用滚动方案写结果跑了半天只抓到第一屏还以为是天猫改了接口最后发现就是没有触发加载入口。后来在代码里先把“点击加载”的情况排除掉才真正稳定下来。3.2 滚动容器、步长与停止条件如果确认是滚动加载接下来还有一个隐蔽的坑滚动并不一定是 window 滚动。有些评论列表外层有一个 overflow:auto 的容器你滚动 window 毫无反应数据永远不加载。所以我在滚动函数里做了一个开关支持滚动指定容器def scroll_container(driver, containerNone): if container: driver.execute_script( arguments[0].scrollTo(0, arguments[0].scrollHeight);, container ) else: driver.execute_script( window.scrollTo(0, document.documentElement.scrollHeight); )容器不是自己猜的而是先找到评论列表的父节点看它有没有滚动条。判断方式是在浏览器控制台执行arguments[0].scrollHeight arguments[0].clientHeight如果为 true说明这个元素自身可滚动就把它作为滚动目标传进去。滚动触发后数据加载需要时间。所以每次滚动后我不会立即继续而是先等待再统计当前评论数。停止条件是“连续 N 轮数量无变化且页面滚动条已到底”。N 我一般取 5太少容易在接口响应慢的时候误判太多又浪费时间。3.3 加载失败的兜底逻辑与频率控制动态加载最常见的失败是触发平台风控提示比如页面突然弹出滑块验证或者评论区变成“暂时加载失败请刷新重试”。这些情况如果不去处理脚本会一直傻乎乎地滚下去。我加了两个兜底。第一每次滚动前快速检查页面有没有出现验证码或异常提示元素出现了就停止滚动等一段时间后刷新重试。第二滚动间隔不能固定。这里不是为了绕什么规则而是真实用户的滚动速度本来就有随机性固定间隔反而像程序。我会用random.uniform(1.5, 3.0)作为间隔。顺便回应一下你在热搜里看到的“selenium 网页左右滑动”横向滚动的逻辑和纵向一样只是把 scrollTo 的坐标从 scrollHeight 换成 scrollLeft 和 scrollWidth。天猫评论这个场景不涉及但如果是抓横向图片列表可以沿用同一个函数思路改个参数就行。另外这套滚动判断逻辑不仅适用于天猫京东的评论页、动态加载的社区页也基本都是同一个套路。4. 从评论节点到结构化字典选择器定位、字段拆分与文本清洗4.1 如何找到稳定的评论根节点定位方式天猫评论的 class 名改版比较频繁每次抓的时候我都要重新审查元素不建议把过时的 class 写死。更稳妥的做法是先观察所有评论节点的公共父结构然后在多个候选选择器里做 fallback。我第一次写的时候直接用了一个具体 class结果隔了一周再跑就全失效了。后来改成多选择器 fallback 的写法items driver.find_elements( By.CSS_SELECTOR, [class*review-item], [class*comment-item], [class*tm-list-item] ) if not items: items driver.find_elements( By.XPATH, //div[contains(class, item) and .//span[contains(text(), 星评)]] )不管选择器怎么变核心思路是先定位到每一条评论的根节点后续所有字段都在这个根节点下找避免从全页面暴力检索导致串行数据。4.2 字段拆分内容、评分、时间、SKU与追评拿到评论根节点后字段提取就清晰了。每一个字段都做 try/except因为部分评论没有追评部分用户不显示 SKU部分评论被折叠。我的提取逻辑大致如下results [] for item in items: try: nickname item.find_element(By.CSS_SELECTOR, .user-nick).text.strip() except Exception: nickname try: content item.find_element(By.CSS_SELECTOR, .review-content).text.strip() except Exception: content try: sku item.find_element(By.CSS_SELECTOR, .sku-info).text.strip() except Exception: sku try: rate item.find_element(By.CSS_SELECTOR, .rate-star).get_attribute(class) except Exception: rate results.append({ nickname: nickname, content: content, sku: sku, rate: rate, })评分字段在天猫不一定以文字形式出现很多是用星级 class 控制比如“star-5”“star-4”。所以get_attribute(class)比拿文字更稳。如果你能直接拿到文字数字优先提取数字方便后面做评分分布统计。4.3 文本清洗、数据归一化与编码问题提取到的原始文本通常带换行、空格和 HTML 实体比如 “ ”。这些不整洁数据如果直接拿去去重会出现“同一句话因为空格位置不同被判成两条”所以清洗必须在去重之前完成。import re def clean_text(s): if not s: return s s.replace(\n, ).replace(\r, ).replace(\u3000, ) s s.replace(nbsp;, ).replace(amp;, ) s re.sub(r\s, , s).strip() return s另外写 CSV 到 Windows 环境时记得用encodingutf-8-sig否则 Excel 打开会乱码。我见过不少人抓下来的评论在记事本里正常在 Excel 里全是乱码最后重跑一遍浪费不少时间。评论时间字段如果是相对时间比如“三天前”建议在清洗阶段统一换算成具体日期不然排序和去重都会出问题。5. 同一句评论出现三次去重键设计、哈希生成与SQL兜底去重5.1 为什么滚动采集很难避开重复评论动态滚动加载过程中重复几乎是必然的。原因有几个一是同一个接口可能被前端的加载逻辑多次触发返回的数据区间有重叠。二是滚动带动 DOM 更新时旧节点没有及时清空导致同一个评论被渲染了两份。三是我们在判断“加载完成”时可能卡了两轮同一批数据被解析了两遍。知道了原因去重策略就不应该只在最后“手工删除重复项”而是要在采集过程中实时去重同时把“新增评论数”作为滚动停止条件的依据之一。如果某一轮新增为 0再判断是否到底这样既做了去重又避免在底部空转。5.2 去重键怎么设计文本、哈希还是评论ID这是整个去重设计里最关键的部分。直接拿评论内容做 key 是不行的电商里“默认好评”这种复制粘贴内容太常见不同用户可以发一模一样的话。只拿昵称做 key 更不行同一用户可以买多个 SKU、发多条评价。最理想的是每条评论有一个独立 ID。如果页面节点上有>comment_id item.get_attribute(data-id) or item.get_attribute(data-comment-id)如果没有 ID就用组合键“昵称 评论时间 SKU 内容哈希”import hashlib def make_key(nickname, comment_time, sku, content): raw |.join([ clean_text(nickname), clean_text(comment_time), clean_text(sku), clean_text(content) ]) return hashlib.md5(raw.encode(utf-8)).hexdigest()注意一点内容在做哈希前必须已经经过统一清洗归一化否则空格、换行差异会让同一个评论生成不同哈希去重就是白做了。5.3 三层去重的实现与对比这套流程里我用了三层去重每一层管一段互相补充。第一层是内存实时去重。维护一个seen集合每分析完一个节点就把 key 放进去下一次先判断 key 是否已存在。Python 的 set 本质就是数组去重的标准思路在几万条评论的场景下速度完全够用。第二层是文件落盘去重。实时把带 key 的记录追加写入 JSONL 或 CSV下次重跑时先读取历史 key 集合。很多爬虫最后数据出错都是因为只有内存 set程序一崩全部重来第二层就是解决这个问题的。第三层是 SQL 兜底去重。即使前两层有漏网的最后入库时再用 SQL 清理一次。比如把数据先写进 SQLite再用这类语句删除重复值DELETE FROM comments WHERE id NOT IN ( SELECT MIN(id) FROM comments GROUP BY comment_key );如果你用的是 MySQL还可以直接给 comment_key 字段加 UNIQUE 索引写入时用 INSERT IGNORE最省事。三种方式的使用场景对比去重层级使用场景优点缺点内存 Set采集过程实时判断速度快程序退出即丢失文件/历史 key断点续抓可恢复文件需同步维护SQL 唯一索引最终入库保证最终干净发现重复时可能已晚6. 稳定跑完整个采集任务断点续抓、限速与多实例并行6.1 异常捕获、验证码处理与断点续抓Selenium 脚本跑起来容易但要稳定跑完一大片商品需要处理很多异常。我遇到最多的有三个元素瞬时报错、滚动超时、验证码弹窗。元素瞬时报错的解法是每个字段用 try/except 包住定位不到就填空值。滚动超时的原因是等待条件设得太短可以适当拉长并在超时后刷新页面重新尝试。验证码最麻烦我目前的策略是检测到验证码元素后立刻停止当前商品抓取把商品 ID 写入待重试文件然后继续下一个。不要在同一页面上反复重试很容易变成死循环。断点续抓的核心是“每次成功解析一条评论就立刻把记录写盘”。我之前习惯最后一次性写文件结果一次崩溃让整批数据全部丢失重新跑又是一两个小时。后来改成边解析边写每条记录写入后立即 flush 一次脚本崩了下次运行时加载历史记录继续。6.2 多实例并行、限速与个人经验再说一个很多人会踩的坑Selenium 的 WebDriver 不是线程安全的你很难在一个浏览器实例里开十几个线程同时滚动。我在第一版里想过用 ThreadPoolExecutor 并发操作同一个 driver结果是各种乱序、重复等待、超时完全不可控。后来换成了多进程的方式每个进程独立启动一个浏览器实例负责不同商品再把中间结果通过文件或队列汇总。这种方式最稳定。如果你不想写进程池也可以直接多开几个脚本每个脚本分配不同商品 ID 区间最后统一合并。这也是“异步加载”在采集端的正确打开思路网页异步加载是一回事但我们的采集任务多个商品之间完全可以并行。无论哪种方式限速都别省。我的建议是每个商品之间的间隔至少 5-10 秒两次滚动间隔随机分布在 1.5-3 秒抓完一批后暂停更久。限速的意义不只是降低风险更是给页面异步加载留出真实响应时间数据反而抓得更全。IP 代理那些我建议先不碰正常频率下基本用不到真要用也要在请求侧做而不是在 Selenium 侧盲目叠加。做完以上这些这套采集流程已经能比较稳定地跑通天猫商品评论。最后再说一个我一直沿用的小技巧抓完所有数据后先不要急着开始做分析先用 SQL 去重语句把最终数据扫一遍同时在本地保留一份原始未去重的 JSONL。我起初去重后就把原始文件删了后来分析中发现某个字段全部为空才发现去重键里包含了脏数据根本无法回查。保留原始文件你的去重操作才是可逆的。这套流程跑通之后后续不管是换商品还是换平台核心逻辑都可以复用成本基本只剩修改选择器和等待节奏。