Collect Product Variants: SKUs, Prices, and Availability

Traverse explicit product options with Playwright, wait for the matching variant state, preserve zero-stock SKUs, and distinguish invalid combinations from failures.

16Yun Engineering TeamOct 28, 20263 min read

A product's initial price often belongs to only its default SKU. Reading that state once can miss variants, mix stock levels, and confuse invalid combinations with sold-out items.

This tutorial supplies a complete in-browser teaching product with two colors and two sizes. It exports three existing SKUs and one invalid combination without requiring an external demo application.

Install and define the record

Use Python 3.12:

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

Each row identifies parent product, selected options, SKU, current price, currency, and stock. Zero stock means an existing SKU with no available inventory; an invalid combination has no corresponding SKU.

The teaching options are independent. Real dependent controls may require discovering available sizes again after choosing a color rather than applying a global Cartesian product.

Run the complete material and collector

Create 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())

Run:

python variants.py

The source URL is null because this is embedded material. The artificial delay simulates an update; the collector waits for matching selection identity and readiness, not that duration.

Avoid reading the previous variant

A price element can remain present while the next variant loads. Waiting only for existence can return old data immediately.

The example requires both a ready marker and the intended option key. Its material also prevents an older update from overwriting a later choice. On a real site, identify equivalent evidence in SKU state, selection metadata, or a correlated response.

A source need not expose these specific data attributes. Establish its actual relationship rather than copying the teaching selector. See the Playwright wait API.

Verify coverage

The supplied data implies RS, RM, and BS, with blue-M invalid. RM remains a valid zero-stock SKU. These are derived expectations, not commercial observations.

Every candidate needs a valid record, an established invalid outcome, or an explicit failure. A missing record after an exception is not automatically an invalid combination.

The minor-unit CNY amount is a teaching contract. Real sources require explicit tax, membership, market, and starting-price interpretation.

Adapt to actual products

Replace set_content with real navigation and preserve provenance. Discover option dependencies before choosing traversal order.

Disabled controls can mean invalid combinations, temporary stock, or purchasing restrictions; interpret them according to the site.

Repeated SKUs may reflect aliases or stale state. Preserve option paths and investigate before merging. This example rejects duplicates because its supplied contract makes every valid combination a different SKU.

Troubleshoot

Every price matches: determine whether pricing is genuinely equal, then inspect SKU and selection state. Price changes alone are not a readiness signal.

Sold-out variants disappear: do not filter zero stock as a falsey missing value.

Candidate space explodes: inspect dependencies and actual variant availability before generating combinations.

A hidden old price is selected: restrict extraction to the current product state; longer waits do not repair wrong scope.

No new file after a failure: this example exports only after all combinations finish. An older variants.json does not represent the failed run. Repair and rerun this small teaching batch; larger integrations need persistent completed results and failed selections before targeted recovery.

Duplicate SKUs increase: investigate stale responses, legitimate aliases, and missing market identity before deduplicating.

Variant collection depends on a traceable relationship between each selection and its resulting data.

Need an enterprise proxy plan?

We can tailor architecture to your target domains, concurrency, and reliability goals.