← back to Sanderson Onboard
scripts/parse_brand_portal.py
137 lines
#!/usr/bin/env python3
"""Parse a BRAND+category trade-portal PLP innerText dump -> JSONL rows (TK-10877).
Current SDG US trade PLP block format (one product):
<Brand> e.g. "Morris & Co"
<Collection ... <code-num> <color>> e.g. "Pure Morris North Fabrics 236810 Honeycomb"
(<MFR_CODE>) e.g. "(DMPUR236810)"
<Status> Live | Discontinued | Limited Stock | New In | New | Coming Soon
<Type> Wallpaper | Fabric
$<SSP> higher price (retail suggested)
$<Trade> lower price (trade/wholesale) <-- SSP == 2*Trade invariant
<stock line> e.g. "30 Roll available" (optional)
Compare
PRICING: trade portal -> price shown is TRADE. trade_usd = trade price.
retail_usd = trade_usd / 0.65 / 0.85 (round 2dp). ssp_usd recorded when visible.
NEVER fabricate a price -> NULL on any miss.
Usage: parse_brand_portal.py <raw-file> <brand-match-regex> <product_type> <out.jsonl>
brand-match-regex e.g. 'morris' (case-insensitive, matched against the brand line)
product_type 'wallcovering' | 'fabric' (canonical, stamped on every row)
"""
import re, sys, json, os
RAW, BRAND_RE, PTYPE, OUT = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
brand_pat = re.compile(BRAND_RE, re.I)
CODE = re.compile(r'^\(([A-Z0-9][A-Z0-9/\-]{3,20})\)$') # (DMPUR236810)
PRICE = re.compile(r'^\$([\d,]+(?:\.\d+)?)$')
STATUS = {'Live', 'Discontinued', 'Limited Stock', 'New In', 'New', 'Coming Soon', 'Made To Order'}
TYPE = {'Wallpaper', 'Fabric', 'Trimming', 'Wallpaper Accessory', 'Paint'}
def num(s): return float(s.replace(',', ''))
with open(RAW) as fh:
L = [x.strip() for x in fh.read().split('\n')]
rows = []
seen = set()
dropped = []
n = len(L)
i = 0
while i < n:
line = L[i]
m = CODE.match(line)
if not m:
i += 1
continue
code = m.group(1)
# brand + collection/color are on the two most-recent non-empty lines above the code
prev = [x for x in L[max(0, i - 6):i] if x]
if len(prev) < 2:
i += 1
continue
brand = prev[-2]
coll_color = prev[-1]
if not brand_pat.search(brand):
i += 1
continue
if code in seen:
i += 1
continue
# forward window: status, type, prices, stock
seg = [x for x in L[i + 1:i + 12] if x]
status = next((x for x in seg if x in STATUS), None)
ptype_seen = next((x for x in seg if x in TYPE), None)
prices = [num(pm.group(1)) for x in seg for pm in [PRICE.match(x)] if pm]
stock = next((x for x in seg if re.search(r'\b(available|out of stock|no stock)\b', x, re.I)), None)
trade = ssp = None
if len(prices) >= 2:
ssp = max(prices[0], prices[1])
trade = min(prices[0], prices[1])
elif len(prices) == 1:
# single price on a trade portal = trade price
trade = prices[0]
# parse collection + color out of coll_color: "<Collection> <codenum> <Color words>"
pattern = coll_color
color = None
cm = re.search(r'^(.*?)\s+(\d{4,7})\s+(.+)$', coll_color)
if cm:
pattern = cm.group(1).strip()
color = cm.group(3).strip()
retail = round(trade / 0.65 / 0.85, 2) if trade is not None else None
seen.add(code)
rows.append({
'base_code': code,
'mfr_sku': code,
'product_type': PTYPE,
'pattern': pattern,
'color': color,
'collection': pattern,
'width': None,
'length': None,
'ssp_usd': ssp,
'trade_usd': trade,
'retail_usd': retail,
'image': None,
'status': status,
'stock': stock,
'raw_label': coll_color,
})
if trade is None:
dropped.append(f"{code} {coll_color} (no price)")
i += 1
# append (resume-safe) — dedupe against existing checkpoint on base_code
existing = {}
if os.path.exists(OUT):
with open(OUT) as fh:
for ln in fh:
ln = ln.strip()
if ln:
try:
r = json.loads(ln)
existing[r['base_code']] = r
except Exception:
pass
for r in rows:
existing[r['base_code']] = r
with open(OUT, 'w') as fh:
for r in existing.values():
fh.write(json.dumps(r) + '\n')
total = len(existing)
priced = sum(1 for r in existing.values() if r.get('trade_usd') is not None)
# SSP == 2*trade invariant check
inv_ok = inv_bad = 0
for r in existing.values():
if r.get('ssp_usd') and r.get('trade_usd'):
if abs(r['ssp_usd'] - 2 * r['trade_usd']) <= max(0.02, 0.01 * r['ssp_usd']):
inv_ok += 1
else:
inv_bad += 1
print(f"[{BRAND_RE}/{PTYPE}] parsed {len(rows)} this run; checkpoint total={total} priced={priced}")
print(f" SSP=2xTrade invariant: ok={inv_ok} bad={inv_bad} (of {inv_ok + inv_bad} with both prices)")
if dropped:
print(f" {len(dropped)} priced-miss (NULL trade): " + "; ".join(dropped[:5]))