#!/usr/bin/env python3 """Build the LogShip import catalog for the lead "Kazaar Fragrances" from the storefront's own GraphQL feed (https://kazaarfragrances.com/api/graphql, query `Products(collection_slug: "alle-produkte")`, pages 1..3, saved as catalog-raw.json). Output: catalog.json — one entry per sellable article for import-merchant-catalog.sh: { value (SKU), name, grossPrice, netPrice, upc, sku (shop variant sku), weight, length, width, height, group1 (brand), group2 (inspired-by), classification (kind), description, documentNote, imageUrl, stock } Matrix requested for the demo: every fragrance (kind perfume / pheromone_perfume) as 30 ml and 50 ml × 20 % / 30 % / 50 % concentration = 6 articles. Prices come from the shop's real variants; a combination the shop does not sell (30 ml / 50 %) is derived from the product's own 50 ml / 50 % price with the catalog-wide 30-vs-50 ml ratio. Oils (kind oil / pheromone_oil) keep their real 3 / 5 / 8 ml variants. Car scents, refills, bundles, samples are skipped. python3 build-catalog.py catalog-raw.json catalog.json """ import html, json, re, statistics, sys raw_path, out_path = sys.argv[1], sys.argv[2] raw = json.load(open(raw_path, encoding="utf-8")) BRAND = "Kazaar" VAT = 1.19 SIZES = ["30", "50"] CONCS = ["20", "30", "50"] WEIGHT = {"30": 0.14, "50": 0.21} # kg incl. glass bottle + box DIMS = {"30": (10, 4, 4), "50": (12, 5, 5)} # cm L × W × H OIL_WEIGHT = {"3": 0.03, "5": 0.04, "8": 0.05} def code(name): c = re.sub(r"[^A-Z0-9]", "", name.upper()) return c[:12] or "X" def strip_html(s): s = re.sub(r"<[^>]+>", " ", s or "") s = html.unescape(re.sub(r"\s+", " ", s)).strip() return s def combo(v): m = re.search(r"(\d+)\s*ml\s*/\s*(\d+)\s*%", v.get("name") or "") return (m.group(1), m.group(2)) if m else None def oil_ml(v): m = re.search(r"(\d+)\s*ml", v.get("name") or "") return m.group(1) if m else None fr = [p for p in raw if p.get("kind") in ("perfume", "pheromone_perfume", "oil", "pheromone_oil")] # catalog-wide median price per combination (fallbacks + the 30 ml / 50 % derivation) med = {} for p in fr: for v in p.get("variations") or []: c = combo(v) if c and v.get("price"): med.setdefault(c, []).append(float(v["price"])) med = {k: statistics.median(vs) for k, vs in med.items()} ratio_30_50 = med[("30", "30")] / med[("50", "30")] # same concentration, 30 ml vs 50 ml items = [] seen = set() for p in fr: name = (p.get("name") or "").strip() vendor = (p.get("vendor") or {}).get("name") or "" kind = p.get("kind") img_default = ((p.get("first_image_urls") or {}).get("original")) or p.get("first_image_url") note = strip_html(p.get("description"))[:1900] pcode = code(name) if pcode in seen: # two products sharing a code (e.g. "Nero" and "Nero Oil") – disambiguate pcode = (pcode[:9] + code(p.get("slug") or "")[-3:]) seen.add(pcode) variants = {} for v in p.get("variations") or []: c = combo(v) if c: variants[c] = v if kind in ("perfume", "pheromone_perfume"): base_scale = None if ("30", "20") in variants and variants[("30", "20")].get("price"): base_scale = float(variants[("30", "20")]["price"]) / med[("30", "20")] for size in SIZES: for conc in CONCS: v = variants.get((size, conc)) if v and v.get("price"): gross = float(v["price"]) elif (size, conc) == ("30", "50"): # never sold by the shop: 50 ml / 50 % price (real or scaled median) × 30-vs-50 ml ratio base50 = float(variants[("50", "50")]["price"]) if variants.get(("50", "50"), {}).get("price") \ else med[("50", "50")] * (base_scale or 1.0) gross = round(base50 * ratio_30_50, 1) else: gross = round(med.get((size, conc), 0) * (base_scale or 1.0), 1) gross = round(gross + 0.0, 2) img = ((v or {}).get("primaryImage") or {}).get("xlarge") if v else None if not img and conc == "50" and variants.get(("50", "50")): img = ((variants[("50", "50")].get("primaryImage")) or {}).get("xlarge") img = img or img_default stock = (v or {}).get("stock") stock = min(int(stock), 200) if isinstance(stock, int) and stock > 0 else 25 items.append({ "value": f"KZ-{pcode}-{size}-{conc}", "name": f"{name} {size} ml / {conc}%", "grossPrice": gross, "netPrice": round(gross / VAT, 2), "upc": (v or {}).get("barcode") or "", "sku": (v or {}).get("sku") or "", "weight": WEIGHT[size], "length": DIMS[size][0], "width": DIMS[size][1], "height": DIMS[size][2], "group1": BRAND, "group2": vendor, "classification": kind, "description": f"{BRAND} Duftzwilling · inspiriert von {vendor} · {conc}% Parfümöl-Konzentration · {size} ml"[:255], "documentNote": note, "imageUrl": img, "stock": stock, "shopProduct": p.get("slug"), "shopVariantId": (v or {}).get("id"), }) else: # oils: real 3/5/8 ml variants for v in p.get("variations") or []: ml = oil_ml(v) if not ml or not v.get("price"): continue gross = round(float(v["price"]), 2) stock = v.get("stock") stock = min(int(stock), 200) if isinstance(stock, int) and stock > 0 else 25 items.append({ "value": f"KZ-{pcode}-OIL-{ml}", "name": (f"{name} {ml} ml" if "Öl" in name else f"{name} Parfum Öl {ml} ml"), "grossPrice": gross, "netPrice": round(gross / VAT, 2), "upc": v.get("barcode") or "", "sku": v.get("sku") or "", "weight": OIL_WEIGHT.get(ml, 0.05), "length": 8, "width": 2.5, "height": 2.5, "group1": BRAND, "group2": vendor, "classification": kind, "description": f"{BRAND} Parfum Öl · inspiriert von {vendor} · {ml} ml Roll-on"[:255], "documentNote": note, "imageUrl": ((v.get("primaryImage") or {}).get("xlarge")) or img_default, "stock": stock, "shopProduct": p.get("slug"), "shopVariantId": v.get("id"), }) json.dump(items, open(out_path, "w", encoding="utf-8"), ensure_ascii=False, indent=0) dup = len(items) - len({i["value"] for i in items}) print(f"{len(items)} articles from {len(fr)} products ({dup} duplicate SKUs) -> {out_path}") print("price medians:", {f"{k[0]}ml/{k[1]}%": v for k, v in sorted(med.items())}, "30/50 ratio", round(ratio_30_50, 3))