商品多规格采集实战:完整获取 SKU、价格与库存

用 Playwright 遍历商品规格组合,等待与所选组合对应的数据,区分无效组合与零库存 SKU,导出带规格关系的记录。

亿牛云技术团队2026年10月28日5 分钟阅读

商品页默认显示的价格,通常只属于当前选中的一个 SKU。若只读取初始页面,再把父商品名称作为唯一键,就可能漏掉其他规格、混用库存,或者把无效组合当成售罄。

本文用一个完整的内嵌教学商品页,演示颜色与尺寸组合的遍历。最终输出三条真实存在的 SKU 和一个无效组合,并保留各自选择条件。页面材料在正文内,不依赖另一个下载项目。

安装与数据合同

使用 Python 3.12:

python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install playwright
python -m playwright install chromium

一个 SKU 记录包含父商品 ID、颜色、尺寸、SKU 编号、当前价格、币种与库存。库存零表示这个 SKU 存在但没有可用库存;组合无效表示没有对应 SKU,它们不能混为一谈。

本例两种颜色乘两种尺寸共四种候选,不保证真实网站的所有维度都能自由组合。选项由界面依赖生成时,应按前一步选择后实际可用的选项继续发现,不能一次读取全局值后盲目做笛卡尔积。

完整材料与采集逻辑

创建 variants.py:

import asyncio
import itertools
import json
from pathlib import Path
from playwright.async_api import async_playwright
 
VARIANTS = {
    "red-S": {"sku": "RS", "price_minor": 1290, "stock": 5},
    "red-M": {"sku": "RM", "price_minor": 1490, "stock": 0},
    "blue-S": {"sku": "BS", "price_minor": 1390, "stock": 3},
}
HTML = """
<h1>教学商品</h1>
<label>颜色<select id="color"><option value="red">红</option><option value="blue">蓝</option></select></label>
<label>尺寸<select id="size"><option value="S">S</option><option value="M">M</option></select></label>
<div id="variant" data-ready="false"></div>
<script>
const variants = __VARIANTS__;
let revision = 0;
function update() {
  const key = document.querySelector('#color').value + '-' + document.querySelector('#size').value;
  const current = ++revision;
  const box = document.querySelector('#variant');
  box.dataset.ready = 'false';
  setTimeout(() => {
    if (current !== revision) return;
    const item = variants[key];
    box.dataset.key = key;
    box.dataset.status = item ? 'valid' : 'invalid';
    box.innerHTML = item
      ? '<span class="sku">' + item.sku + '</span><span class="price">' + item.price_minor +
        '</span><span class="stock">' + item.stock + '</span>'
      : '<p>该组合不存在</p>';
    box.dataset.ready = 'true';
  }, 80);
}
document.querySelectorAll('select').forEach(node => node.addEventListener('change', update));
update();
</script>
""".replace("__VARIANTS__", json.dumps(VARIANTS))
 
async def main():
    records, invalid, seen = [], [], set()
    async with async_playwright() as pw:
        browser = await pw.chromium.launch()
        try:
            page = await browser.new_page()
            await page.set_content(HTML)
            colors = await page.locator("#color option").evaluate_all(
                "nodes => nodes.filter(n => !n.disabled && n.value).map(n => n.value)"
            )
            sizes = await page.locator("#size option").evaluate_all(
                "nodes => nodes.filter(n => !n.disabled && n.value).map(n => n.value)"
            )
            for color, size in itertools.product(colors, sizes):
                key = color + "-" + size
                await page.select_option("#color", color)
                await page.select_option("#size", size)
                await page.wait_for_function(
                    """key => {
                      const node = document.querySelector('#variant');
                      return node.dataset.ready === 'true' && node.dataset.key === key;
                    }""",
                    arg=key,
                )
                box = page.locator("#variant")
                if await box.get_attribute("data-status") == "invalid":
                    invalid.append({"color": color, "size": size})
                    continue
                sku = (await box.locator(".sku").inner_text()).strip()
                amount = int(await box.locator(".price").inner_text())
                stock = int(await box.locator(".stock").inner_text())
                if not sku or sku in seen or amount < 0 or stock < 0:
                    raise ValueError("SKU 重复、缺失或数值不合法")
                seen.add(sku)
                records.append({
                    "product_id": "teaching-product",
                    "color": color, "size": size, "sku": sku,
                    "price_minor": amount, "currency": "CNY", "stock": stock,
                    "source_url": None,
                })
        finally:
            await browser.close()
    result = {"variants": records, "invalid_combinations": invalid}
    Path("variants.json").write_text(
        json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    print({"sku_count": len(records), "invalid_count": len(invalid)})
 
asyncio.run(main())

运行:

python variants.py

这是浏览器内的教学材料,source_url 明确为 null,不把 about:blank 伪装成真实商品来源。八十毫秒仅模拟页面更新;采集逻辑等待的是选择键与 ready 状态一致。

为什么只等待价格元素存在还会错

切换规格后,旧价格元素可能一直存在,页面在后台请求新数据。只等待 .price 出现,可能立刻读到上一规格。

本例等待两件事:数据已就绪,并且当前数据声明属于刚选中的组合。材料中的 revision 也避免旧更新覆盖新选择。真实站点需要找到等价证据,例如 SKU 编号变化、规格标记更新或与请求参数对应的响应。

不能要求所有站点都提供 data-ready。没有稳定标记时,应从网络请求和页面状态建立对应关系,而不是把本例选择器直接复制过去。Playwright 的等待方式见 官方页面 API。

核对三条 SKU 与四种选择

根据正文材料,预期 RS、RM、BS 各一条,blue-M 进入无效组合;RM 的 stock 为零,仍保留为合法 SKU。这是输入推导结果,不是实际电商站观测。

检查每个候选都有“合法记录、无效组合或明确失败”去向。程序异常导致某个组合未完成时,不应把缺少记录自动解释为无效。

价格单位为分、币种为 CNY,是材料合同的一部分。迁移真实商品站时重新确定是否包含税费、会员条件和地区;页面只有“起价”时不能直接当成所有 SKU 的准确价格。

迁移到真实站点

把 set_content 改成进入真实 URL,保留程序取得的最终来源。先识别规格控件及其依赖关系,再决定遍历顺序。点击颜色后可能改变尺寸集合,此时应在颜色内部重新读取尺寸。

不可选项通常有 disabled、aria-disabled 或库存文案,但它们不一定都表示同一含义。结合站点规则区分组合不存在、暂时缺货和购买限制。

相同 SKU 可能由多个展示选项进入,也可能是代码读到了旧状态。先保留选择路径并核对原因,再决定是否合并;不能用一个 seen 集合悄悄掩盖错误。本文的教学合同明确一组选择对应一个不同 SKU,所以重复即报错。

常见问题与修复

所有组合价格相同。 先判断商品是否确实同价,再检查数据是否仍属于上一选择。不要仅因为价格未变就认定更新没发生,应同时查看 SKU 与选择状态。

缺货 SKU 被丢掉。 检查是否把 stock=0 当作空值。零库存是有效信息,不能用 if stock 过滤。

候选数量很大。 检查维度依赖与可选状态,不先生成所有理论组合。必要时按产品接口中的实际变体列表获取候选,并保留与界面含义的映射。

选择器命中隐藏旧价格。 将字段限定在当前有效商品区域,核对可见状态与规格标识。扩大等待时间不会修复错误范围。

中途失败后没有新文件。 本例只在全部组合处理完成后导出;旧 variants.json 不代表本轮成功。教学材料修复后可整批重跑。接入大型商品时,另外持久保存已完成结果与失败组合,再补采并统一核对覆盖。

重复 SKU 越来越多。 查看是否读到旧响应、商品是否有合法别名选择,以及业务键是否还缺少地区维度。先解释重复,再做去重。

多规格采集的核心是选择与数据的一一对应。把这层对应关系保存下来,才有可能判断价格、库存与遗漏是否正确。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。