商品详情页的评论区,是很多做电商分析、选品调研、用户口碑监测的人绕不开的一块数据。但真到动手的时候,大部分人会发现:淘宝的评论接口不像普通网页那样直接返回HTML,而是走异步加载,参数里还带着一串加密签名,翻页逻辑也跟常规分页不一样。这篇内容就是把我自己在做商品评论采集时踩过的坑、试过的方案、最后跑通的思路完整梳理一遍,适合有一定Python基础、想自己动手抓评论数据的朋友参考,也适合做数据分析、竞品调研、选品工具开发的同行对照着看。
1. 先搞清楚淘宝评论到底藏在哪一层
很多人一上来就打开商品详情页,右键查看源代码,然后发现页面里根本没有评论内容。这不是你操作错了,而是淘宝的商品详情页本身就是一个壳,评论数据是通过异步请求单独拉取的。所以第一步不是写代码,而是搞清楚数据从哪个请求里出来。
1.1 评论数据的真实来源:异步接口而非页面HTML
打开一个商品详情页,按F12进入开发者工具,切到Network面板,筛选XHR或Fetch请求,然后往下滚动评论区。你会看到有一个请求的响应里带着大量JSON结构的数据,里面包含用户昵称、评论内容、评分、时间、追评、图片等字段。这个请求的URL通常带有rate或comment之类的路径关键词,返回的是标准JSON。
这里有个关键认知:你抓的不是页面,而是接口。页面只是把接口返回的数据渲染出来而已。所以整个采集的核心工作,是模拟这个接口请求,而不是去解析HTML。这一点想明白了,后面所有问题都会顺很多。
1.2 接口请求里那几个绕不开的参数
把那个评论请求的完整信息复制出来看,重点关注几类参数:
- 商品ID(itemId或auctionNumId):标识你抓的是哪个商品的评论,这个直接可见。
- 分页参数(currentPage或pageNo):控制翻页,通常从1开始。
- 每页条数(pageSize):一般默认20条,部分场景可以调整。
- 签名参数(sign或_token):这是最麻烦的一个,它是由前端JS根据其他参数计算出来的,每次请求都可能不同。
- 时间戳(t或timestamp):配合签名使用,防止请求被重放。
- Cookie中的身份标识:部分接口需要登录态才能返回完整数据。
签名参数是整件事的门槛。它不是一个固定值,而是前端用一段JavaScript逻辑,把商品ID、页码、时间戳等拼在一起,经过某种哈希或加密运算得到的。你如果直接拿浏览器里看到的那个sign去请求,过一会儿就失效了。
1.3 为什么不能简单用requests直接怼
新手最容易犯的错,就是复制请求URL和headers,用requests发一次,发现能返回数据,就以为搞定了。然后写个循环翻页,跑到第二页或第三页就开始返回空或者报错。原因通常有三个:
第一,签名是跟时间戳绑定的,你复用同一个签名翻页,服务端校验不过。第二,Cookie里的某些令牌有有效期,跑一段时间就过期。第三,请求频率一高,会触发风控,返回验证码或者直接拒绝。
所以,纯requests方案只适合极小规模的试探,真正要稳定采集,必须解决签名动态生成和会话维持这两个问题。
2. 签名逆向:整件事里最硬的一块骨头
签名怎么来,是决定你用什么技术路线的分水岭。这里我给两条路,一条是硬啃JS逆向,一条是借助浏览器环境自动生成,各有适用场景。
2.1 定位签名生成的那段JS代码
在开发者工具里,对着评论请求右键,选择"Copy as cURL",或者直接在Sources面板里全局搜索签名参数的名字(比如搜sign)。通常你会被带到某个被压缩混淆过的JS文件里。这时候别慌,用开发者工具的"Pretty print"格式化一下,然后在这个函数附近下断点,重新触发一次评论请求,就能看到调用栈。
顺着调用栈往上找,你会找到真正计算签名的那段逻辑。它一般长这样:把若干参数按key排序,拼成一个字符串,再拼接一个固定的盐值(salt),最后做MD5或者类似运算。找到这个规律之后,你就可以用Python复现同样的计算过程。
import hashlib import time def gen_sign(params: dict, salt: str) -> str: # 按key字典序排序后拼接 sorted_items = sorted(params.items()) raw = "".join(f"{k}{v}" for k, v in sorted_items) raw += salt return hashlib.md5(raw.encode("utf-8")).hexdigest() params = { "itemId": "123456789", "currentPage": "1", "pageSize": "20", "t": str(int(time.time() * 1000)), } sign = gen_sign(params, "这里填你逆向出来的盐值") print(sign)注意:上面这段只是演示签名计算的通用结构,真实的盐值和拼接规则每个时期可能不同,必须以你实际逆向出来的为准。而且平台会不定期更新这套逻辑,今天跑通不代表下个月还能用。
2.2 逆向的成本与维护问题
硬啃JS逆向的优点是纯后端、跑得快、资源占用低。但缺点也很明显:维护成本高。平台一旦调整签名算法,你的代码就全废了,得重新逆向一遍。如果你只是做一次性的数据采集,逆向一次够用;但如果是长期跑的项目,这个维护负担要认真评估。
我自己的经验是,如果采集频率不高、数据量不大,逆向一次能用挺久;但如果是每天都要跑、量还大,那不如考虑下面这条路线。
2.3 用浏览器自动化绕过签名难题
另一条路是用Playwright或Selenium这类浏览器自动化工具,让真实的浏览器去加载页面、执行JS、生成签名,你只需要从网络响应里把数据截下来。这样做的好处是签名永远是对的,因为它就是浏览器自己算的。
from playwright.sync_api import sync_playwright import json def crawl_comments(item_id: str, max_pages: int = 5): results = [] with sync_playwright() as p: browser = p.chromium.launch(headless=False) context = browser.new_context() page = context.new_page() captured = [] def on_response(response): if "rate" in response.url or "comment" in response.url: try: data = response.json() captured.append(data) except Exception: pass page.on("response", on_response) page.goto(f"https://item.taobao.com/item.htm?id={item_id}") page.wait_for_timeout(3000) # 滚动到评论区触发加载 for _ in range(max_pages): page.mouse.wheel(0, 2000) page.wait_for_timeout(2000) browser.close() return captured这段代码的核心思路是:监听网络响应,而不是自己去构造请求。浏览器负责处理签名、Cookie、风控,你只负责收数据。代价是速度慢、资源占用高,但稳定性好很多。
2.4 两条路线的取舍对照
| 维度 | JS逆向方案 | 浏览器自动化方案 |
|---|---|---|
| 速度 | 快,可并发 | 慢,单页面串行 |
| 资源占用 | 低 | 高,需要浏览器进程 |
| 维护成本 | 高,签名变了要重写 | 低,签名由浏览器处理 |
| 稳定性 | 依赖逆向准确度 | 较高,接近真人操作 |
| 适用场景 | 一次性、大批量 | 长期、中小批量 |
| 风控触发概率 | 较高 | 较低 |
选哪条,取决于你的采集规模和持续时间。我的建议是:先跑通浏览器方案验证数据结构和字段,再决定要不要投入精力做逆向优化。
3. 翻页、去重与字段清洗的实操细节
拿到数据只是第一步,真正让数据能用,还得处理翻页边界、重复数据和字段格式。这几件事看起来琐碎,但直接决定你最后拿到的数据能不能用。
3.1 翻页的终止条件不能只看"返回为空"
很多人判断翻页结束的逻辑是"这一页返回空就停"。但实际跑下来你会发现,有时候某一页因为网络抖动返回空,下一页其实还有数据。更稳妥的做法是结合多个信号判断:
- 返回的评论列表长度为0,且连续两次都是0。
- 返回的总页数或总条数字段已经达到上限。
- 当前页码超过了接口返回的
totalPage。
另外,淘宝评论接口的翻页有时候不是严格线性的,某些页码可能被跳过。所以建议记录每一页实际拿到的评论ID,最后统一去重,而不是假设页码连续就一定能拿全。
3.2 评论去重的正确姿势
评论数据里通常有一个唯一标识字段,可能是评论ID,也可能是"用户ID+时间+内容"的组合。去重时优先用官方给的评论ID,如果没有,就用组合键。
seen = set() unique_comments = [] for comment in all_comments: cid = comment.get("id") or comment.get("rateId") if not cid: cid = f"{comment.get('userId')}_{comment.get('date')}_{hash(comment.get('content',''))}" if cid in seen: continue seen.add(cid) unique_comments.append(comment)提示:不要用评论内容本身做唯一键,因为不同用户可能发一模一样的内容(比如"好评"),会被误去重。
3.3 字段清洗:把JSON变成能分析的表
接口返回的JSON字段名往往很隐晦,而且嵌套层级深。你需要把它拍平成一个规整的表格。常见的字段映射关系大致如下:
| 原始字段(示例) | 含义 | 清洗后建议 |
|---|---|---|
| rateContent | 评论正文 | 去除首尾空白,保留原文 |
| rateDate | 评论时间 | 统一转为标准时间格式 |
| score | 评分 | 转为整数 |
| auctionSku | SKU信息 | 拆分为颜色、尺码等 |
| userNick | 用户昵称 | 脱敏或哈希处理 |
| pics | 评论图片 | 提取URL列表 |
| appendContent | 追评内容 | 单独一列 |
清洗的时候有个细节要注意:评论正文里可能包含换行符、表情符号的编码、以及HTML实体,导出CSV之前要统一处理,否则打开表格会串行。
import re def clean_text(text: str) -> str: if not text: return "" text = re.sub(r"<[^>]+>", "", text) # 去HTML标签 text = text.replace("\n", " ").replace("\r", " ") text = re.sub(r"\s+", " ", text).strip() return text3.4 时间字段的坑
评论时间有时候返回的是相对时间(比如"3天前"),有时候是绝对时间戳。如果是相对时间,你得结合抓取时刻反推真实日期。这个反推逻辑要小心跨月、跨年的边界。我的做法是:能拿到绝对时间就优先用绝对时间,拿不到就记录抓取时刻,把相对时间作为辅助字段保留,不要强行换算,避免引入错误。
4. 风控规避与采集节奏控制
这部分是很多人不愿意明说但实际最重要的一环。技术方案再漂亮,一跑就被封,等于零。风控规避的核心不是"对抗",而是"让请求看起来像正常用户"。
4.1 请求频率:慢就是快
最朴素也最有效的策略就是控制频率。我的经验值是:单账号每分钟请求不超过10次,每次请求之间随机间隔1到3秒。不要用固定间隔,固定间隔本身就是机器特征。
import random import time def polite_sleep(): time.sleep(random.uniform(1.0, 3.0))如果你用浏览器自动化方案,还可以模拟一些人类行为:滚动页面、偶尔停顿、移动鼠标。这些动作看起来多余,但能显著降低被识别为自动化的概率。
4.2 会话与Cookie的维护
评论接口对登录态有要求,所以Cookie必须维持。用浏览器方案时,建议持久化用户数据目录,这样每次启动都复用同一个会话,不用反复登录。
context = browser.new_context( storage_state="taobao_state.json" # 复用已保存的登录态 )用requests方案时,Cookie过期是常态,需要有一套检测机制:发现返回登录页或错误码时,暂停任务并提示重新获取Cookie,而不是硬跑到被封。
4.3 代理与IP轮换的边界
当采集量确实很大时,单IP肯定不够用。这时候会涉及IP轮换。但这里要提醒一句:IP轮换不是万能药,频率控制才是根本。如果频率不控制,换再多IP也会被识别。而且代理质量参差不齐,低质量代理反而会拖慢整体速度、增加失败率。
我的建议是:先把单IP的频率和会话管理做到位,确认单IP能稳定跑通,再考虑是否需要扩展IP。很多中小规模的采集需求,单IP配合合理节奏完全够用。
4.4 遇到验证码怎么办
跑着跑着弹出验证码,说明风控已经注意到了。这时候正确的做法是立即暂停,不要硬刚。硬刚的结果通常是账号被限制或IP被拉黑。暂停之后,降低频率、更换会话、间隔一段时间再继续。
如果验证码频繁出现,说明你的采集模式已经被识别,需要回头检查:是不是频率太高、是不是请求头太假、是不是行为太规律。从模式上找问题,而不是从对抗上找方案。
5. 数据落库与后续分析衔接
数据抓下来之后,存哪里、怎么存,直接影响后续能不能顺利做分析。这一步很多人草草了事,结果分析的时候发现数据格式乱七八糟,又得回头重来。
5.1 存储格式的选择
小规模数据(几千条以内),直接存CSV或JSON就够了,简单直接。中等规模(几万到几十万条),建议上SQLite,单文件、免配置、查询方便。大规模(百万级以上),再考虑MySQL或PostgreSQL。
import sqlite3 conn = sqlite3.connect("comments.db") conn.execute(""" CREATE TABLE IF NOT EXISTS comments ( comment_id TEXT PRIMARY KEY, item_id TEXT, user_nick TEXT, content TEXT, score INTEGER, comment_time TEXT, sku TEXT, crawl_time TEXT ) """) conn.commit()用comment_id做主键,天然去重,重复插入直接忽略,省去手动去重的麻烦。
5.2 增量采集的设计
如果你要长期跟踪某个商品的评论变化,增量采集是必须的。核心思路是:每次采集前先查库里已有的最大评论时间或最新评论ID,只抓比它更新的部分。这样既省请求,又避免重复。
但要注意,淘宝评论的排序有时候不是严格按时间倒序,新评论可能夹杂在中间。所以增量采集不能只抓第一页就停,得结合时间过滤,把新于阈值的评论都收进来。
5.3 从评论数据能挖出什么
评论数据本身只是原料,真正有价值的是从里面提炼出的信息。常见的分析方向包括:
- 情感倾向:正面、负面、中性评论的占比和趋势。
- 关键词提取:用户最常提到的产品特性、问题点。
- SKU维度对比:不同颜色、尺码的评论差异。
- 时间趋势:评论量随时间的变化,判断产品热度周期。
- 追评分析:追评往往反映长期使用体验,价值高于首评。
这些分析用Python的pandas配合jieba分词、snownlp情感分析就能快速跑出初步结果。评论正文清洗干净之后,直接喂给这些工具即可。
5.4 一个容易忽略的点:评论图片和追评
很多人只抓文字评论,忽略了图片和追评。但图片评论往往信息量更大,追评则反映真实使用后的反馈。接口返回里通常有图片URL列表和追评字段,抓的时候顺手带上,后续分析维度会丰富很多。图片URL可以直接下载存档,追评内容单独存一列,分析时和首评对比着看。
6. 我踩过的几个真实坑
最后这部分不讲方法论,只讲我自己实际踩过的坑,都是文档里不会写、但一踩就耽误半天的东西。
第一个坑是以为签名是固定的。早期我复制了浏览器里的sign,写死到代码里,跑了一下午都正常,第二天全挂。后来才明白sign跟时间戳绑定,必须动态生成。这个坑让我白白浪费了一天。
第二个坑是翻页用同一个会话跑太久。跑到几百页之后,接口开始返回空数据,但换一个商品又能正常抓。排查半天发现是会话被限流了,不是代码问题。解决办法是分段跑,每跑一段换一次会话或暂停一会儿。
第三个坑是评论内容里的表情符号导致CSV错乱。有些表情在CSV里会破坏列结构,打开表格全是乱的。后来统一在清洗阶段把非标准字符过滤掉,问题才解决。
第四个坑是忽略了评论的排序参数。默认排序和按时间排序返回的数据不一样,我一开始没注意,导致增量采集漏了很多。后来固定用按时间排序,增量逻辑才稳定。
第五个坑是追评和首评混在一起。接口有时候把追评作为独立条目返回,有时候作为首评的附加字段。如果不区分,去重和统计都会出错。处理办法是:先判断条目类型,追评单独标记,统计时分开算。
这些坑说到底都指向一件事:评论采集不是写个循环就完事,它是一套需要持续观察和调整的工程。接口会变、风控会变、数据格式也会变,能跑通一次不代表能一直跑。保持对返回数据的敏感,发现异常及时停下来分析,比闷头跑量重要得多。
如果你也在做类似的事情,我的建议是先把浏览器方案跑通,把数据结构和字段摸清楚,再根据实际需求决定要不要投入做逆向优化。别一上来就追求速度和规模,先把稳定性和数据质量做扎实,后面扩展起来才不痛苦。