简介:围绕淘宝天猫商品详情抓取整理的示例代码包,面向具备一定编程基础、希望了解电商页面数据获取流程的开发者,可用于学习商品名称、价格、描述与评价等信息的请求发送、内容解析和结果保存思路。压缩包共2个文件,均为Java源码,整体约4KB,体量轻便,适合作为爬虫入门与流程梳理的参考实现,便于读者对照理解请求构造、HTML/JSON解析及数据落盘等关键环节。资源描述还涉及动态加载、验证码与IP限制等反爬场景的应对思路,以及将结果整理为CSV或JSON以便后续分析,对希望快速建立商品详情抓取知识框架的读者较有价值。目前已有2988人学习,可作为了解淘宝天猫商品详情爬取基本流程的轻量示例。
1. 商品详情爬取里最贵的一课:能跑通的不是解析代码,是那一次带着正确会话的请求
很多人第一次做商品详情爬取,都会把时间花在解析上:正则、XPath、BeautifulSoup、JSON 路径,写得不亦乐乎。真正卡住人的从来不是解析——是请求本身根本拿不到数据。我把一个详情页采集链路拆开重做了三遍之后才认账:详情页的价值密度全在结构化字段里(标题、主图、SKU 矩阵、价格区间、库存状态、类目、店铺),而这些字段在 PC 页面里是半残的,在 h5 网关接口里才是完整的。也就是说,这份资源真正要解决的是「怎么稳定、可复现地拿到那个返回完整 JSON 的响应」,解析只是收尾动作。适合已经把 Python 请求玩熟、但在电商详情页上反复翻车的人:手工拼参数拼不通、Cookie 一过期全线崩、并发一开就迎来限流和滑块。接下来按我实际落地的顺序拆:先定位接口,再解决会话,再做结构化,最后讲我踩过的坑和验收标准。
2. 定位详情接口:从页面源码走到 h5 网关的五个必填参数
详情页的字段分布是有层次差的。先把这一点想明白,后面所有工作才有方向:抓错层,你会在一个字段永远缺一半的接口上白耗两天。
2.1 三层数据源的取舍:PC 页面、h5 页面、h5 网关接口
我做过的采集里,同一个商品能拿到三种形态的数据,差别非常明显:
| 数据源 | 拿到的字段 | 稳定性 | 主要问题 |
|---|---|---|---|
| PC 详情页 DOM | 标题、主图、部分属性 | 差 | 大量字段懒加载,SKU 价格要交互才出 |
| h5 详情页 DOM | 标题、主图、区间价 | 中 | 结构随版本改,SSR 与 CSR 混用 |
| h5 网关接口(JSON) | 标题、主图、SKU 全矩阵、区间价、库存状态、类目、店铺 | 好 | 需要正确会话与签名参数 |
选择很明确:以网关 JSON 为主,h5 DOM 作为兜底校验(用来核对价格显示是否一致)。PC 页面基本只用来做一次人工对照,不进流水线。这个取舍的理由不是「JSON 好解析」,而是字段完整性——SKU 组合的库存状态只有网关接口给全,DOM 上永远只有当前选中项。
需要提前说清边界:只取页面上对普通用户公开可见的字段,不做登录态数据、不做下单链路、不碰验证码。遇到滑块就停下来记录,别想着绕。采集频率按站点条款和 robots 约束来,这条是底线不是建议。
2.2 一次真实请求里必须抄下来的五个参数
打开浏览器开发者工具,过滤 Fetch/XHR,刷新详情页,你会看到一条形如/h5/xxx/detail/xxx/1.0/的请求。把它当成模板,逐个抄参数,别凭记忆拼:
api:接口名,固定值,抄下来即可,别自己猜命名规律。v:接口版本号,同上,改动通常意味着字段结构变了,值得单独记一笔。data:业务参数,一般是 URL 编码后的 JSON,核心是商品 ID 和几个展示开关。t:毫秒时间戳,和本机时间偏差过大会直接被判非法访问。sign:签名,由页面 JS 用t与 Cookie 里的令牌算出来,是最麻烦的一个。
另外两个不写在 query 里但生死攸关的:Cookie 中的_m_h5_tk(令牌)与_m_h5_tk_enc(配套加密值),以及Referer。少了Referer,很多网关会直接返回一个错误码而不是数据。
提示:抄参数时把完整的请求头一起复制成 curl,用
curl -v先跑通一次,确认是参数问题还是会话问题,再写 Python。这一步能省掉后面一半的排查时间。
2.3 用一次最小请求验证网关放行
先不写框架,用最笨的方式验证链路:
import json import time import urllib.parse import requests # 从浏览器复制的完整 Cookie 串,先硬编码验证链路,跑通后再做池化 COOKIE = "_m_h5_tk=xxxxxxxx; _m_h5_tk_enc=yyyyyyyy; 其他项=..." HEADERS = { "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) " "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", "Referer": "https://example-h5-host/", # 必须带,且要和接口同源 "Accept": "application/json", } def build_query(item_id: str, token: str) -> dict: data = {"itemId": item_id, "detail_v": "3.3.2", "exParams": "{}"} return { "api": "mtop.xxx.detail.get", # 抄下来的接口名 "v": "1.0", # 抄下来的版本号 "t": str(int(time.time() * 1000)), # 毫秒时间戳,注意本机时钟要准 "sign": "占位值,下一步替换", # 由会话算出来 "data": json.dumps(data, ensure_ascii=False, separators=(",", ":")), } def fetch_detail(item_id: str, token: str) -> dict: url = "https://example-h5-host/h5/mtop.xxx.detail.get/1.0/" query = build_query(item_id, token) resp = requests.get( url, params=query, headers={**HEADERS, "Cookie": COOKIE}, timeout=10, ) # 网关有时候返回 JSONP 包裹,需要剥掉 callback(...) 外壳 text = resp.text.strip() if text.startswith("callback("): text = text[len("callback("):-2] return json.loads(text) if __name__ == "__main__": print(json.dumps(fetch_detail("600000000000", "token-from-cookie"), ensure_ascii=False)[:500])这段代码的重点不在能不能跑,而在于它把「变量」压缩到了最少。timeout=10是硬要求,网关偶发挂起,不设超时会把整个队列拖死。JSONP 剥壳那段不是多余——同一个接口在不同入口下返回格式会变,我遇到过白天返回纯 JSON、晚上返回callback(...)的情况。
跑这一次你会看到三种结果之一:正常 JSON、令牌过期类错误码、非法访问类错误码。第一种说明参数对了;第二种说明_m_h5_tk失效,需要重新握手;第三种八成是sign或时间戳不对。把错误码原样记录下来,这是后面排查的唯一线索——网关的错误信息极少,它就是个黑匣子,你不记录就永远在猜。
3. 会话与签名:硬算签名和借用浏览器,我为什么选后者
第二章跑通之后,很多人会立刻卡在同一个地方:sign怎么算。这一步的路线选择,决定了这个项目是三天上线还是三周还在调试。
3.1 两条路线的成本对比
| 路线 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 硬算签名 | 逆向页面 JS,还原sign计算函数,用 Python 复刻 | 纯 HTTP,速度快,资源占用低 | JS 混淆,函数随版本变;一旦改版全部重来 |
| 借用浏览器会话 | 用无头浏览器打开页面,让页面自己的 JS 算签名,在页面上下文里发请求 | 抗改版,签名永远最新 | 单实例吞吐低,内存占用高 |
我选第二条为主、第一条为辅。理由是维护成本:签名函数的混淆程度和更新频率,意味着你复刻出来的版本平均寿命很短,而采集任务是要长期跑的。借用会话的本质是「让专业的 JS 引擎干专业的活」——我不需要知道sign怎么算,我只需要让页面替我算,然后把它算出来的结果拿去发请求。
这个选择有个前提:并发不高。如果你的目标是万级商品,浏览器实例会成为瓶颈,那时才值得投入去做签名复刻,并且必须给复刻版本留好降级开关。我的做法是先上会话借用把业务跑通,再按实际吞吐需求决定要不要投入。
3.2 在页面上下文里发请求
import asyncio import json from playwright.async_api import async_playwright DETAIL_API = "https://example-h5-host/h5/mtop.xxx.detail.get/1.0/" async def fetch_by_context(page, item_id: str) -> dict: """在页面上下文里发 fetch,签名与 Cookie 由页面自己带""" data = {"itemId": item_id, "detail_v": "3.3.2", "exParams": "{}"} # 用 evaluate 在页面里拼接 query,页面会自己补 t 与 sign result = await page.evaluate( """async ({api, data}) => { const qs = new URLSearchParams({ api: api.split('/')[0], v: '1.0', data: JSON.stringify(data), // 关键:让页面自己的工具函数生成 t 和 sign t: String(Date.now()), sign: window.__sign_helper ? window.__sign_helper(data) : '', }); const resp = await fetch(api + '?' + qs.toString(), { credentials: 'include', // 带上页面已有的 Cookie headers: {'Accept': 'application/json'}, }); return await resp.text(); }""", {"api": DETAIL_API, "data": data}, ) return json.loads(result) async def worker(item_ids, concurrency=2): sem = asyncio.Semaphore(concurrency) # 单个浏览器上下文并发别超过 3 async with async_playwright() as p: browser = await p.chromium.launch(headless=True) context = await browser.new_context( user_agent="Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) " "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", viewport={"width": 390, "height": 844}, locale="zh-CN", ) page = await context.new_page() # 先访问一次详情页,让页面把令牌写进 Cookie await page.goto("https://example-h5-host/item/600000000000", wait_until="domcontentloaded") async def one(item_id): async with sem: try: return item_id, await fetch_by_context(page, item_id) except Exception as exc: return item_id, {"_error": repr(exc)} results = await asyncio.gather(*(one(i) for i in item_ids)) await context.close() await browser.close() return results几个参数值得掰开说。concurrency=2不是保守,是实测结论:同一个浏览器上下文并发超过 3,网关侧容易出现排队和限流,反而比串行还慢。wait_until="domcontentloaded"而不是networkidle,因为详情页有长轮询和图片懒加载,等networkidle会白等十几秒。credentials: 'include'是让 fetch 自动带上页面 Cookie,省掉手工管理 Cookie 串。
注意window.__sign_helper那段是占位思路:不同站点的签名入口位置不一样,有的是全局函数,有的挂在某个模块对象上。我的做法是先在页面里搜索几次真实请求,找到拼接 query 的那段逻辑,把入口路径记下来再填。找不到入口时,更稳的办法是直接复用一个已经发过请求的页面,用page.route拦截真实请求,把它的 URL 抄下来再重放——这样连签名入口都不用找。
3.3 令牌刷新与错峰调度
会话借用最常遇到的失败是令牌过期。表现很有意思:第一次请求返回令牌过期错误,同一个请求重发一次就正常了,因为网关在第一次响应里通过Set-Cookie下发了新令牌。所以重试策略必须是「失败后先刷新 Cookie 再重试」,而不是原样重试。
async def fetch_with_retry(page, item_id, retries=3): for attempt in range(retries): raw = await fetch_by_context(page, item_id) ret = str(raw.get("ret", "")) if "SUCCESS" in ret: return raw if "TOKEN" in ret.upper(): # 令牌类错误:刷一次详情页让 Cookie 更新,退避后重试 await page.reload(wait_until="domcontentloaded") await asyncio.sleep(1.5 * (attempt + 1)) continue if "TRAFFIC" in ret.upper() or "LIMIT" in ret.upper(): await asyncio.sleep(5 * (attempt + 1)) # 限流退避要更狠 continue break # 其他错误不重试,直接记录 raise RuntimeError(f"fetch failed after {retries} attempts")退避系数别拍脑袋。令牌类错误 1.5 秒起步足够,限流类我给到 5 秒起步并且翻倍,因为限流的惩罚窗口通常比令牌过期长得多。另外这段代码里有个隐含约定:ret字段一定要解析出来判断,不能只看 HTTP 状态码——网关限流时经常返回 200 加一个错误码,只看状态码会以为成功,把错误数据写进库。
4. 解析与落库:把 SKU 矩阵从嵌套 JSON 里捞出来
拿到 JSON 之后,真正麻烦的是 SKU。详情接口的结构大概分三块:商品主体(标题、主图、类目、店铺)、规格定义(有哪些颜色、哪些尺码)、SKU 明细(每个组合对应的价格与库存状态)。前两块是平铺的,第三块是嵌套的,而且键名不直观。
4.1 规格定义与 SKU 明细的对齐逻辑
规格定义一般是skuBase.props结构:每个 prop 有pid(规格 ID)和values(候选值列表,每个值有vid和name)。SKU 明细是skuCore.sku2info,键是vid1;vid2拼接串,值是价格和库存。
拼装的坑在于:sku2info的键顺序不保证和props顺序一致。我第一版直接按props顺序拼键去查,结果一半商品查不到,血泪经验。正确做法是先把每个 SKU 的键拆开,映射成pid -> vid的字典,再和规格定义对齐。
def parse_sku(detail: dict) -> dict: item = detail.get("data", {}).get("item", {}) or {} props = (item.get("skuBase") or {}).get("props") or [] sku2info = (item.get("skuCore") or {}).get("sku2info") or {} # vid -> (pid, 规格名, 值名),用于反查每个 vid 属于哪个规格 vid_map = {} for prop in props: for val in prop.get("values") or []: vid_map[str(val.get("vid"))] = ( str(prop.get("pid")), prop.get("name"), val.get("name"), ) skus = [] for key, info in sku2info.items(): if key == "0": # 键为 0 时是全量价格区间,不是具体 SKU continue attrs = {} for vid in key.split(";"): pid, prop_name, val_name = vid_map.get(vid, ("?", "?", "?")) attrs[prop_name] = val_name skus.append({ "sku_key": key, "attrs": attrs, "price": (info.get("price") or {}).get("priceText"), "quantity": (info.get("quantity") or "").strip(), }) return { "item_id": str(item.get("itemId")), "title": item.get("title"), "images": [img.get("url") for img in (item.get("images") or [])][:6], "skus": skus, }key == "0"那个分支必须跳过:它存的是整个商品的区间价,格式和其他条目不一样,混进去会让你的 SKU 列表多出一条属性全是问号的脏数据。quantity字段有时候是数字字符串、有时候是「有货」这类文案,落库前统一成字符串,别在这层做类型转换,转换放到入库前统一处理,方便回溯原始值。
4.2 表结构与幂等写入
采集任务一定会重跑,所以写入必须幂等。我的表结构分两张:商品主表按item_id唯一,SKU 明细表按(item_id, sku_key)唯一。
CREATE TABLE item_detail ( item_id VARCHAR(32) NOT NULL PRIMARY KEY, title VARCHAR(512) NOT NULL DEFAULT '', shop_id VARCHAR(32) NOT NULL DEFAULT '', category_id VARCHAR(32) NOT NULL DEFAULT '', main_images JSON NULL, price_low DECIMAL(10,2) NULL, price_high DECIMAL(10,2) NULL, fetched_at DATETIME NOT NULL, raw_hash CHAR(32) NOT NULL DEFAULT '' -- 原始 JSON 的 md5,用于判断是否变更 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE item_sku ( item_id VARCHAR(32) NOT NULL, sku_key VARCHAR(64) NOT NULL, attrs JSON NULL, price DECIMAL(10,2) NULL, quantity VARCHAR(64) NOT NULL DEFAULT '', fetched_at DATETIME NOT NULL, PRIMARY KEY (item_id, sku_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;raw_hash这个字段是我加了两次才留下来的。作用是判断内容有没有变:哈希没变就只更新fetched_at,不写明细,能省掉大量无意义的写放大。明细表用INSERT ... ON DUPLICATE KEY UPDATE写入,别用先删后插——中途失败会留下半截数据,第二天你对账时完全看不出是采集问题还是写入问题。
attrs用 JSON 列而不是拆成多列,是因为不同类目的规格维度数量差别很大,服装是颜色加尺码,家电可能是型号加容量加能效。拆列的话每接一个类目就要改表,不现实。
价格统一转DECIMAL,别用FLOAT。划线价和活动价在 JSON 里都是字符串,带货币符号和千分位,清洗规则要写在一处并加单测,我在这个点上翻过车:某个类目的价格带「起」字,float()直接抛异常,把整批任务带崩了。
5. 避坑与排查:五个把我按在地上摩擦的现场
这一章是我认为最值钱的部分。前面三章的代码你照着抄能跑通,但下面这些情况只有真跑过才会遇到,而且每一个都会让你怀疑是代码写错了。
5.1 现象:本地跑通,换台机器全部返回非法访问
原因基本是三件事叠加:本机时间偏差、User-Agent 不一致、出口环境变化。时间戳t参与签名计算,机器时钟差超过几分钟就会被判非法;UA 从桌面换成移动端而 Cookie 是桌面端拿的,也会触发校验。
解决:上机第一件事是校时(ntpdate或容器里挂时间同步),UA 和视口在配置文件里固定下来不要随手改,Cookie 与 UA 必须同源——用移动端 UA 抓的 Cookie 就只配移动端 UA 用。
5.2 现象:同一个商品第一次请求报令牌错误,重发就好了
原因:网关的令牌是「半失效」状态,第一次请求的作用是握手,响应头里带新令牌,第二次才给数据。这不是 bug,是设计。
解决:把「令牌类错误自动重试一次」写进重试策略的最前面,重试前先刷新 Cookie。判断依据是响应里的ret字段包含令牌关键字,不要靠 HTTP 状态码。同时把这类重试单独计数,如果某个会话连续触发超过阈值,说明这个会话该废弃了,换下一个。
5.3 现象:采集到的价格和页面显示对不上,差几十块
原因:详情接口里的价格字段有多个来源。区间价节点、SKU 明细节点、活动价字段,三者优先级不同,页面最终展示的是前端按活动规则算出来的结果。
解决:不要试图在采集端复刻活动规则,那是无底洞。做法是把多个价格字段全存下来(区间价、SKU 原价、页面展示价各一列),下游用哪个由业务决定。核对时以页面展示价为准,但保留原价用于对比。这一步的后悔药就是多存字段,存储成本远低于事后重算。
5.4 现象:并发提到 5 就开始大面积失败,还弹出滑块
原因:网关侧对单会话、单出口都有速率限制,而且限制是滑动窗口不是固定窗口,短时间密集请求一定触发。滑块出现意味着已经超过容忍阈值。
解决:令牌桶限流,全局 QPS 压到 1 到 2,请求间隔加 800 到 1500 毫秒的随机抖动(固定间隔反而更容易被识别出规律)。一旦出现滑块,立刻停掉整个任务并记录商品 ID,不要继续试探——继续打只会让限制窗口变长。等一段时间后从断点恢复。这条经验我交了两次学费。
5.5 现象:断点续爬之后数据库里出现重复 SKU,价格还是旧的
原因:重跑时先删后插,中途异常导致明细表只剩部分数据;或者唯一键没建对,sku_key里拼了时间戳。
解决:唯一键只包含业务字段(item_id, sku_key),fetched_at只做更新不做主键;写入用 upsert;每次任务开始前先查一遍主表里最近成功的时间,跳过 N 小时内已采过的商品。另外给任务加一个「批次 ID」字段,出问题能按批次回滚,比全表清理安全得多。
6. 稳定性验收:三个指标 + 一份自检清单
链路能跑通和能长期跑,中间隔着一整套自检。我现在的习惯是每个采集任务上线前必须过一遍固定检查,不通过就不排进调度。
第一层指标看采集成功率。按批次统计成功数 / 总数,健康值在 95% 以上。低于这个数先别急着加并发,去看错误分布:令牌类错误占比高说明会话池太小或刷新策略有问题;限流类占比高说明 QPS 超标;非法访问类占比高说明参数或环境不一致。这三种错误对应的修法完全不同,混在一起看只会瞎调。
第二层指标看字段完整率。抽样 50 条,检查标题非空、主图不少于 3 张、SKU 数量与页面一致、价格区间下限不大于上限。SKU 数量对不上是最常见的隐性故障——接口返回了,但解析时sku2info的键顺序没对齐,导致部分 SKU 被静默丢弃,成功率看着 100%,数据却是残的。所以完整率必须独立于成功率统计。
第三层指标看数据变更率。用raw_hash统计每日变更比例,正常情况下应该很低。如果某天变更率突然飙到 80%,大概率不是商品真的都改价了,而是你的解析把某个不稳定字段(比如带时间戳的推荐位)算进了哈希。这个指标是数据质量的哨兵。
下面是我实际在用的自检清单,跑任务前逐项确认:
| 检查项 | 通过标准 | 不通过时的典型表现 |
|---|---|---|
| 机器时钟偏差 | 小于 30 秒 | 全量非法访问 |
| UA 与 Cookie 同源 | 同一端形态 | 请求被降级到空数据 |
| 令牌刷新路径 | 连续 20 次无令牌错误 | 每两次请求失败一次 |
| 全局 QPS | 不超过 2 | 短时大量限流码 |
| 滑块触发计数 | 0 | 任务中途集体失败 |
| SKU 完整率 | 抽样 50 条全部对齐 | 成功率正常但明细缺项 |
| 幂等写入 | 同批重跑两次行数不变 | 明细表行数翻倍 |
最后说一个具体技巧:给每个任务加「影子校验」。抽 1% 的商品,同时走网关接口和 h5 页面 DOM 两条路,把两边拿到的标题、主图数量、区间价做比对,不一致就告警。这个机制帮我抓到过两次接口字段悄悄改名的事故——接口没报错,字段名从 A 换成 B,解析代码拿到None当空值处理,数据静默变空,纯看成功率永远发现不了。
从那以后我每次接新的采集目标,都强制走一遍「curl 验证 → 单条跑通 → 令牌重试 → 抽样比对 → 限流压测」这五步,一步不跳。跳过哪一步,后面就会在哪一步上多花三倍时间还回来。这套流程不新鲜,但真能省掉大量返工。希望帮到你。
本文还有配套的精品资源,点击获取