
简介面向希望入门网络爬虫与电商商品信息采集的开发者这份淘宝天猫商品详情爬取示例聚焦商品名称、价格、描述、评价等字段的获取流程适合具备一定编程基础、想了解请求发送、页面解析与数据落盘的学习者参考。压缩包内共2个Java源码文件整体约4KB体量轻便便于直接阅读代码结构与核心实现快速把握商品详情抓取的骨架。已有2988人学习或下载说明该主题在爬虫入门与电商数据采集练习中具有较高关注度。内容可帮助读者理解如何构造请求、解析HTML或JSON响应、提取目标字段并保存结果同时为处理动态加载、验证码、IP限制等反爬场景提供思路参考代码精简也便于按自身项目需求进行二次改造与扩展练习。1. 商品详情爬取不是抓 HTML先想清楚登录态、字段和合规边界商品详情爬取最容易翻车的地方不是正则写错而是把登录态、字段口径和合规边界放到最后才想。你拿到的这份示例工程核心不是一页 HTML而是一条可回放的链路请求参数从商品 ID 和规格维度生成会话复用登录态限速与重试兜住偶发失败解析后把 SKU、价格、库存落成可对账的表。它适合做竞品巡检、活动价监控、内部选品看板的从业者不适合想绕过验证码、硬破签名的人。常见做法是先申请合规数据接口公开页只做低频抽样遇到风控就停。2. 请求层怎么搭会话复用、限速与重试把成功率稳住请求层是整条链路的地基。很多人一上来就写解析函数结果跑到一半发现 Cookie 过期、连接数暴涨、偶发 429 把整批任务打挂。我的习惯是把请求层拆成三块会话与请求头、限速与重试、参数模板。会话负责复用 TCP 连接和登录态限速负责把频率压在可控范围参数模板负责让每个商品 ID 都按同一套语义发请求。三块都落成独立函数后面排查时才能定位到具体一层而不是对着日志猜。下面按可抄作业的顺序展开代码里的域名和字段名都是占位替换成自己的合规接口地址即可。2.1 从环境变量读取登录态和基础请求头登录态不要写进代码也不要提交到仓库。常见做法是把 Cookie 和 User-Agent 放进环境变量启动任务前由人工登录后注入本地调试可以用.env文件但.env必须进.gitignore。这样做的好处是同一份代码可以在测试环境和生产环境跑换账号只换环境变量不需要改源码。请求头里至少要带 User-Agent、Accept、Referer 和 Cookie缺 Referer 时有些详情接口会直接返回空数据缺 Accept 时可能拿到 HTML 而不是 JSON。import os import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry BASE https://example.com/api/item COOKIE os.getenv(SHOP_COOKIE, ) UA os.getenv(SHOP_UA, Mozilla/5.0 (兼容占位替换成真实浏览器UA)) session requests.Session() session.headers.update({ User-Agent: UA, Accept: application/json, text/plain, */*, Referer: https://example.com/, Cookie: COOKIE, }) # 连接层不自动重试重试交给外层限速器统一控制避免叠加放大 adapter HTTPAdapter(max_retriesRetry( total0, connect0, read0, status0, )) session.mount(https://, adapter) def fetch_item(item_id: str, timeout(3.05, 10)) - requests.Response: url f{BASE}/{item_id} return session.get(url, timeouttimeout)这段代码的关键点有三个。session复用连接避免每个商品都新建 TLS 握手timeout(3.05, 10)是连接超时和读取超时连接超时短一点读取超时长一点防止慢响应把线程池占满max_retries0是故意关掉连接库自带的重试因为外层限速器还要做退避两层叠加会让失败请求成倍放大。Cookie 为空时不要硬跑先抛一个明确错误比后面解析出一堆空字段更容易排查。提示登录态过期后有些接口仍然返回 200只是数据体变成空对象或登录提示页所以状态码不是唯一判断依据。2.2 限速器与重试器别让偶发 429 变成整批翻车限速不是越慢越好而是要稳定、可解释。我一般用最小间隔限速器把 QPS 压在 0.3 到 0.5 之间批量任务再开 2 到 4 个并发超过这个范围就容易触发服务端保护。重试只对连接错误、超时和明确的临时性 5xx 生效遇到 401、403、429 不要无限重试401 和 403 先检查登录态和权限429 先停下来看限速参数。重试次数设 3 次退避基数设 2 秒再加一点随机抖动避免同一批任务在同一秒集中重试。import time import random from functools import wraps class RateLimiter: def __init__(self, qps0.5): self.min_interval 1.0 / qps self.last 0.0 def wait(self): now time.monotonic() delta now - self.last if delta self.min_interval: time.sleep(self.min_interval - delta) self.last time.monotonic() limiter RateLimiter(qps0.5) def retry_on(exceptions(ConnectionError, TimeoutError), tries3, base2.0): def deco(fn): wraps(fn) def wrapper(*args, **kwargs): for i in range(tries): try: limiter.wait() return fn(*args, **kwargs) except exceptions: if i tries - 1: raise sleep_s base ** i random.uniform(0, 0.3) time.sleep(sleep_s) return wrapper return decoqps0.5表示每 2 秒最多发一个请求适合小批量巡检tries3表示最多尝试三次base2.0让退避间隔按 1 秒、2 秒、4 秒的节奏增长random.uniform(0, 0.3)是抖动避免重试风暴。这里没有把 429 放进自动重试是因为 429 往往说明频率已经越界继续重试只会让情况更糟。正确做法是记录命中 429 的商品 ID暂停任务调低 QPS再从断点继续。2.3 请求参数模板商品 ID、规格维度和分页语义参数模板的价值在于把“每次请求带什么”固化下来。商品详情通常围绕商品 ID 展开规格维度决定要不要拉 SKU 列表分页参数只对评价、问答这类附属数据有意义。我的习惯是用 dataclass 定义查询对象先校验再转字典避免字符串拼接把空值和数字类型混进去。item_id必须是纯数字page_size设一个示例上限scene用来区分详情页、活动页和比价页后面排查时能按场景过滤日志。from dataclasses import dataclass, asdict from typing import Optional dataclass class ItemQuery: item_id: str scene: str detail sku_mode: str all page: int 1 page_size: int 20 def validate(self): if not self.item_id.isdigit(): raise ValueError(item_id 应为纯数字) if not (1 self.page_size 50): raise ValueError(page_size 超过示例上限) if self.page 1: raise ValueError(page 从 1 开始) return self q ItemQuery(item_id100000000001, page1).validate() params {k: v for k, v in asdict(q).items() if v is not None}item_id用字符串而不是整数是因为长 ID 在 JSON 和 SQLite 里都可能被截断scene和sku_mode是业务语义不要省略page_size上限设 50 是示例约束实际按接口文档调整。如果详情接口需要动态签名不要在示例工程里硬抠算法优先走官方开放接口或让业务方提供合法数据源签名逻辑属于边界外的东西硬破既不稳定也不合规。3. 详情字段怎么落JSON 解析、SKU 合并与入库幂等请求通了之后下一层是字段落库。商品详情的数据形态通常有三种接口直接返回 JSON、页面里内嵌一段 JSON、纯 DOM 结构。优先选 JSON因为字段稳定、类型明确内嵌 JSON 次之需要正则或解析器定位DOM 最不稳定页面改版就翻车。字段口径要先定价格用分还是元库存是单 SKU 还是聚合规格用字典还是拼接字符串批次日期怎么记。口径定不下来后面做对账时全是黑匣子。3.1 识别详情页的数据承载方式我一般先看响应头里的 Content-Type再看响应体前 200 个字符。如果是application/json直接json.loads如果是 HTML就在页面源码里搜window.、__INITIAL_STATE__、__ITEM__这类标记。不要一上来写一个巨大的正则匹配整页页面结构一变就全挂。更稳的做法是用 marker 定位只取 marker 后面的一段 JSON再交给json.loads解析解析失败时把原始响应落盘方便回放。import json import re from typing import Any def extract_embedded_json(html: str, marker: str) - dict[str, Any]: # 用 marker 定位内嵌 JSON不要写死整页结构 pattern re.compile(rf{re.escape(marker)}\s*\s*(\{{.*?\}})\s*;, re.S) m pattern.search(html) if not m: raise ValueError(f未找到内嵌 JSON: {marker}) return json.loads(m.group(1)) def load_detail(raw: str) - dict: # 优先按接口 JSON 解析失败再退到页面内嵌 try: return json.loads(raw) except json.JSONDecodeError: return extract_embedded_json(raw, window.__ITEM__)marker是页面里实际存在的变量名示例用window.__ITEM__占位re.S让点号匹配换行.*?是非贪婪匹配遇到嵌套很深的 JSON 可能截断所以生产环境更推荐用 HTML 解析器拿到 script 标签内容后再json.loads。解析失败时不要静默返回空字典直接抛错并把原始响应写入debug/目录后面排查才有依据。3.2 价格、库存和 SKU 的归一化价格和库存是最容易出错的字段。接口可能返回分页面可能显示元活动价、到手价、划线价混在一起库存可能是单 SKU 库存也可能是规格聚合库存。我的做法是统一在解析层转成“元 两位小数”和“非负整数库存”SKU 规格按 key 排序后拼成字符串保证同一规格每次生成的字符串一致方便入库去重。字段来源示例归一化规则常见坑price_yuanprice_cent / 100四舍五入两位小数把分当元价格放大 100 倍stocksku.stock小于 0 时记 0负库存表示无货不是欠货specspecs 字典按 key 排序后k:v拼接字典顺序不稳定导致重复 SKUsku_idsku.sku_id统一转字符串长整数被 JSON 精度截断def normalize_sku(api: dict) - list[dict]: rows [] for sku in api.get(sku_list, []): price_cent int(sku.get(price_cent, 0)) stock int(sku.get(stock, 0)) specs sku.get(specs, {}) or {} rows.append({ sku_id: str(sku[sku_id]), price_yuan: round(price_cent / 100, 2), stock: max(stock, 0), spec: |.join(f{k}:{v} for k, v in sorted(specs.items())), }) return rowsprice_cent是分除以 100 后保留两位max(stock, 0)把负库存压成 0sorted(specs.items())是保证规格字符串稳定避免同一 SKU 因为字典顺序不同被当成两条。如果接口同时返回活动价和原价不要在解析层做“哪个更便宜”的判断先把两个字段都落下来让业务层决定用哪个口径。3.3 SQLite 入库与幂等更新小批量巡检用 SQLite 足够关键是设计好主键。我的习惯是按“商品 ID SKU ID 批次日期”做联合主键同一批次重跑时走ON CONFLICT DO UPDATE跨天保留历史快照。这样既能幂等重跑又能按天对比价格和库存变化。原始响应不要整段塞进主表存一个raw_hash就够原始文件按批次落盘数据库只保留可查询字段。CREATE TABLE IF NOT EXISTS item_snapshot ( item_id TEXT NOT NULL, sku_id TEXT NOT NULL, batch_date TEXT NOT NULL, title TEXT, price_yuan REAL, stock INTEGER, spec TEXT, raw_hash TEXT, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (item_id, sku_id, batch_date) );import sqlite3 import hashlib def save_snapshot(conn: sqlite3.Connection, item_id: str, title: str, skus: list[dict], batch_date: str, raw: str): raw_hash hashlib.sha256(raw.encode(utf-8)).hexdigest() sql INSERT INTO item_snapshot(item_id, sku_id, batch_date, title, price_yuan, stock, spec, raw_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(item_id, sku_id, batch_date) DO UPDATE SET titleexcluded.title, price_yuanexcluded.price_yuan, stockexcluded.stock, specexcluded.spec, raw_hashexcluded.raw_hash, updated_atCURRENT_TIMESTAMP with conn: for s in skus: conn.execute(sql, ( item_id, s[sku_id], batch_date, title, s[price_yuan], s[stock], s[spec], raw_hash ))联合主键保证同一批次重跑不会产生重复行ON CONFLICT后面的excluded表示本次要插入的值raw_hash用来判断原始响应有没有变化。如果某天某个商品解析出 0 个 SKU不要直接写库先走告警因为这通常意味着解析路径失效或登录态过期空结果落库会把历史数据覆盖成脏数据。4. 避坑排查五个高频翻车点与定位顺序这套链路跑通不难难的是稳定跑一周。下面五条是我在商品详情爬取任务里踩过的典型坑每条按“现象 → 原因 → 解决”写排查顺序也按这个来。遇到问题时不要先改解析代码先看请求层和登录态再看字段口径最后才怀疑页面结构。按这个顺序走大部分问题十分钟内能定位。4.1 登录态过期却返回 200现象任务没有报错但落库的标题为空、SKU 数量为 0或者价格全是 0。原因登录态过期后有些接口不返回 401而是返回一个空对象、登录提示页或降级数据状态码仍然是 200。解决在解析前加一层“业务有效性校验”标题为空、SKU 列表为空、价格小于等于 0 都记为可疑连续 N 个商品可疑就暂停任务重新注入 Cookie。日志里要打印响应体的前 300 个字符和当前 Cookie 的哈希前缀不要打印完整 Cookie。4.2 价格单位与优惠叠加误判现象同一商品今天 199 元明天 19900 元或者活动价和原价来回跳。原因接口有的字段是分有的是元有的返回原价有的返回到手价优惠券和满减在详情页和结算页口径不同。解决在解析层固定字段含义price_cent只进分字段price_yuan只进元字段不要混用把原价、活动价、到手价分三列落库对账时用“价格区间”而不是单点值超过阈值再人工看。4.3 解析路径随页面结构漂移现象昨天还能解析 20 个 SKU今天只解析出 1 个或者spec全是空字符串。原因页面把内嵌 JSON 从window.__ITEM__换成了__INITIAL_STATE__或者把 SKU 列表挪到了另一个 script 标签。解决不要写死一个 marker配置多个候选 marker按顺序尝试解析失败时把原始响应落盘用grep搜sku、price关键字定位新路径每次页面改版后跑一遍固定样本回放确认字段数量没掉。4.4 限速参数设错导致误封现象前 100 个商品正常第 101 个开始连续 429换 Cookie 也没用。原因QPS 设得太高或者重试层把 429 也当成可重试错误短时间放大请求量。解决把 QPS 降到 0.3 到 0.5重试只对连接错误和超时生效命中 429 后暂停 5 到 10 分钟记录当前商品 ID 做断点续跑不要用多账号轮换来硬扛频率问题不是换账号能解决的。4.5 重复入库与脏数据回滚现象同一批次跑了两遍表里行数翻倍或者某天库存全是 0。原因主键没设对或者空结果被当成正常数据写入。解决联合主键必须包含item_id、sku_id、batch_date写入前校验 SKU 数量0 个 SKU 直接进死信表发现脏批次时按batch_date删除后重跑不要逐行改。下面这条 SQL 用来定位重复和空批次跑之前先备份。-- 查同一商品同一 SKU 在同一天是否重复 SELECT item_id, sku_id, batch_date, COUNT(*) AS c FROM item_snapshot GROUP BY item_id, sku_id, batch_date HAVING c 1; -- 查某批次 SKU 数量异常的商品 SELECT item_id, COUNT(*) AS sku_count FROM item_snapshot WHERE batch_date 2025-01-01 GROUP BY item_id HAVING sku_count 0;5. 验证与进阶用抽样回放和字段对账把结果钉死采集任务上线前我会固定 10 个商品 ID 做抽样回放保存第一次的原始响应和解析结果之后每次改代码都重跑这 10 个对比raw_hash、SKU 数量、价格区间和库存总量。字段对账看四列SKU 数量是否突然掉到 0价格是否超出历史区间库存是否全为 0spec空值率是否超过 10%。这四列任何一列异常先停任务再查原因。抽样回放不追求覆盖全部商品追求的是“改动能被看见”。对账项基线告警阈值处理动作SKU 数量上次同商品下降超过 50%暂停并落盘原始响应价格区间近 7 天中位数偏离 3 倍人工复核字段口径库存总量上次同商品归零检查登录态和解析路径spec 空值率小于 5%大于 10%检查规格字典结构# 固定样本回放ids.txt 放 10 个商品 IDbaseline 存第一次结果 python replay.py --ids ids.txt --out runs/$(date %F) --compare runs/baselinereplay.py是示例脚本名核心逻辑就是读 ID 列表、走同一套请求和解析、把结果写成 JSON、和 baseline 做 diff。--compare指向基线目录diff 结果里重点看sku_count、price_min、price_max、stock_sum四个键。从那以后我每次上线采集任务前都强制跑一遍抽样回放和字段对账哪怕只改了一行请求头希望帮到你。本文还有配套的精品资源点击获取