#!/usr/bin/env python3 """Build the LogShip import catalog for the lead "Hess" (Livipur Kinder. Spiel. Versand GmbH, https://www.livipur.de — plentymarkets / plentyShop LTS) from the storefront's own IO feed `GET /rest/io/variations?itemsPerPage=500&page=N` (answers unauthenticated, 3 pages ≈ 1 227 variations of 916 items) plus `GET /rest/io/categorytree` for the category names. Output: catalog.json — one entry per shop variation for import-merchant-catalog.sh (+ `parentValue` = key of its master article when the shop item has 2+ variations) and masters.json — the master ("summary") articles for create-master-articles.sh: { value (= shop article number), name, grossPrice, netPrice, upc, sku, weight, length, width, height, group1 (brand = manufacturer), group2 (category path), classification (top category), description, documentNote, imageUrl, stock } The feed exposes neither barcodes nor weights/dimensions: `upc` is a GS1 restricted-circulation ("in-store", prefix 20) EAN-13 derived from the variation id, weights/dims are estimates per top category and price. Articles the shop does not sell any more (filter.isSalable = false) are kept with stock 0, salable ones get a deterministic opening stock of 3…80. python3 build-catalog.py [catalog-raw.json] catalog.json # fetches the feed when the raw file is missing """ import html, json, os, re, sys, time, urllib.request BASE = "https://www.livipur.de" UA = {"User-Agent": "Mozilla/5.0"} args = sys.argv[1:] out_path = args[-1] if args else "catalog.json" raw_path = args[0] if len(args) > 1 else os.path.join(os.path.dirname(out_path) or ".", "catalog-raw.json") tree_path = os.path.join(os.path.dirname(raw_path) or ".", "cattree.json") def get_json(url): with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=120) as r: return json.load(r) if not os.path.exists(raw_path): docs, page = [], 1 while True: d = get_json(f"{BASE}/rest/io/variations?itemsPerPage=500&page={page}")["data"] docs += [x["data"] for x in d["documents"]] print(f"page {page}: {len(docs)}/{d['total']}") if len(docs) >= d["total"] or not d["documents"]: break page += 1; time.sleep(1) json.dump(docs, open(raw_path, "w", encoding="utf-8"), ensure_ascii=False) if not os.path.exists(tree_path): json.dump(get_json(f"{BASE}/rest/io/categorytree"), open(tree_path, "w", encoding="utf-8"), ensure_ascii=False) raw = json.load(open(raw_path, encoding="utf-8")) tree = json.load(open(tree_path, encoding="utf-8"))["data"] # category id -> (name, parent id) cats = {} def walk(nodes, parent=None): for n in nodes: name = next((d["name"] for d in n.get("details") or [] if d.get("lang") == "de"), None) or (n.get("details") or [{}])[0].get("name", "") cats[n["id"]] = (name, parent) walk(n.get("children") or [], n["id"]) walk(tree) def cat_path(cid): path = [] while cid in cats and len(path) < 6: name, cid = cats[cid]; path.insert(0, name) return path SLUG_NAMES = {"moebel": "Möbel", "spielen": "Spielen", "entdecken": "Entdecken", "jahreszeiten": "Jahreszeiten", "angebote": "Angebote", "baby": "Baby", "marken": "Marken", "geburtstag": "Geburtstag", "lernen-handarbeiten": "Lernen & Handarbeiten", "neuheiten": "Neuheiten"} # (kg, L, W, H in cm) by top category; furniture additionally scaled by price SIZE = {"Möbel": (6.0, 60, 40, 15), "Spielen": (0.6, 30, 20, 8), "Baby": (0.3, 25, 20, 5), "Jahreszeiten": (0.2, 15, 10, 6), "Jahreszeitentisch": (0.2, 15, 10, 6), "Jahresfeste": (0.25, 18, 12, 8), "Lernen & Handarbeiten": (0.4, 30, 22, 4), "Geburtstag": (0.3, 20, 15, 8), "Entdecken": (0.5, 30, 20, 8)} def size_for(top, price): kg, l, w, h = SIZE.get(top, (0.5, 30, 20, 10)) if top == "Möbel": if price >= 400: return 28.0, 120, 70, 20 if price >= 150: return 14.0, 90, 50, 15 elif price >= 120: return round(kg * 3, 2), l * 2, w * 2, h * 2 elif price >= 40: return round(kg * 1.6, 2), int(l * 1.4), int(w * 1.4), int(h * 1.4) return kg, l, w, h def strip_html(s): s = re.sub(r"<[^>]+>", " ", s or "") return html.unescape(re.sub(r"\s+", " ", s)).strip() def ean_instore(n): body = f"20{int(n):010d}"[:12] chk = (10 - sum(int(c) * (3 if i % 2 else 1) for i, c in enumerate(body)) % 10) % 10 return body + str(chk) def stock_for(vid, salable): if not salable: return 0 return 3 + (int(vid) * 7919) % 78 # deterministic 3…80 items, seen = [], set() for d in raw: v, t, it = d["variation"], d["texts"], d["item"] price = (d.get("prices") or {}).get("default") or {} gross = float(((price.get("price") or {}).get("value")) or 0) net = float(((price.get("data") or {}).get("priceNet")) or (gross / 1.19)) vat = ((price.get("vat") or {}).get("value")) attrs = ", ".join(a["value"]["names"]["name"] for a in d.get("attributes") or [] if (a.get("value") or {}).get("names")) name1 = (t.get("name1") or "").strip() name = (v.get("name") or "").strip() or name1 # the shop's variation name often lacks the distinguishing attribute (size, motif …) → append what is missing missing = [a["value"]["names"]["name"].strip() for a in d.get("attributes") or [] if (a.get("value") or {}).get("names") and a["value"]["names"]["name"].strip().lower() not in name.lower()] if missing: name = f"{name}, {', '.join(missing)}" brand = ((it.get("manufacturer") or {}).get("externalName") or "Livipur").strip() cat_id = (d.get("defaultCategories") or [{}])[0].get("id") path = cat_path(cat_id) if cat_id else [] if not path: slugs = (t.get("urlPath") or "").split("/")[:-1] path = [SLUG_NAMES.get(s, s.replace("-", " ").title()) for s in slugs] if (not path or path[0] in ("Angebote", "Neuheiten")): # promo categories: prefer the real one from the URL slugs = (t.get("urlPath") or "").split("/")[:-1] if slugs and slugs[0] not in ("angebote", "neuheiten", "marken"): path = [SLUG_NAMES.get(s, s.replace("-", " ").title()) for s in slugs] top = path[0] if path else "Sonstiges" kg, l, w, h = size_for(top, gross) value = (v.get("number") or "").strip() or f"LV-{v['id']}" if value in seen: value = f"{value}-{v['id']}" seen.add(value) imgs = (d.get("images") or {}).get("variation") or (d.get("images") or {}).get("all") or [] img = imgs[0].get("urlMiddle") if imgs else None salable = bool((d.get("filter") or {}).get("isSalable")) short = strip_html(t.get("shortDescription")) items.append({ "value": value, "name": name[:120], "grossPrice": round(gross, 2), "netPrice": round(net, 2), "vat": vat, "upc": ean_instore(v["id"]), "sku": value, "weight": kg, "length": l, "width": w, "height": h, "group1": brand, "group2": " > ".join(path)[:255], "classification": top[:255], "description": (short or f"{brand} · {' > '.join(path)}")[:255], "documentNote": strip_html(t.get("description"))[:1900], "imageUrl": img, "stock": stock_for(v["id"], salable), "shopItemId": it.get("id"), "shopVariationId": v["id"], "shopUrl": f"{BASE}/{t.get('urlPath')}_{it.get('id')}_{v['id']}/", "salable": salable, }) # master ("summary") articles: one per shop item with 2+ variations — M_Product.IsSummary = Y, not # stocked/sold/purchased; the variations reference the master's search key in M_Product.ParentId. # Master key = the article number shared by all variations (1227812 for 1227812-1 / -2), else LV-. by_item = {} for it in items: by_item.setdefault(it["shopItemId"], []).append(it) values = {it["value"] for it in items} masters = [] for item_id, vs in by_item.items(): if len(vs) < 2: continue stems = {re.sub(r"-\d+$", "", v["value"]) for v in vs} key = next(iter(stems)) if len(stems) == 1 and next(iter(stems)) not in values else f"LV-{item_id}" src = next((v for v in vs if v["salable"]), vs[0]) name = next((d["texts"].get("name1") for d in raw if d["item"]["id"] == item_id), None) or src["name"] masters.append({ "value": key, "name": name.strip()[:120], "shopItemId": item_id, "variants": [v["value"] for v in vs], "group1": src["group1"], "group2": src["group2"], "classification": src["classification"], "description": (f"Stammartikel · {len(vs)} Varianten: " + ", ".join(v["value"] for v in vs))[:255], "documentNote": src["documentNote"], "imageUrl": src["imageUrl"], "shopUrl": f"{BASE}/{next(d['texts'].get('urlPath') for d in raw if d['item']['id'] == item_id)}_{item_id}/", }) for v in vs: v["parentValue"] = key json.dump(masters, open(os.path.join(os.path.dirname(out_path) or ".", "masters.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=0) print(f"{len(masters)} master articles for {sum(len(m['variants']) for m in masters)} variations -> masters.json") json.dump(items, open(out_path, "w", encoding="utf-8"), ensure_ascii=False, indent=0) import collections print(f"{len(items)} articles from {len({i['shopItemId'] for i in items})} items -> {out_path}") print("salable", sum(1 for i in items if i["salable"]), "| no image", sum(1 for i in items if not i["imageUrl"]), "| price 0", sum(1 for i in items if not i["grossPrice"]), "| vat", collections.Counter(i["vat"] for i in items)) print("top categories", collections.Counter(i["classification"] for i in items).most_common()) print("brands", len({i["group1"] for i in items}), collections.Counter(i["group1"] for i in items).most_common(8))