← back to Sanderson Onboard
scripts/extract.py
151 lines
#!/usr/bin/env python3
"""Feed-first Sanderson extractor: plain-fetch product pages, parse JSON-LD, emit CSV for PG staging. $0 (no proxy/API)."""
import json, os, re, sys, time, urllib.request, csv, html
BASE = "https://www.sanderson.design"
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36"
LIMIT = int(sys.argv[1]) if len(sys.argv) > 1 else 0
URLFILE = sys.argv[2] if len(sys.argv) > 2 else "all_prod_urls.txt"
OUTFILE = sys.argv[3] if len(sys.argv) > 3 else "rows.csv"
RETRY = "--retry" in sys.argv
def load_map(path):
m = {}
with open(path) as f:
for line in f:
parts = line.rstrip("\n").split("\t")
if len(parts) == 2:
m[parts[0].upper()] = parts[1]
return m
pattern_map = load_map("pattern_map.tsv") if os.path.exists("pattern_map.tsv") else {}
if os.path.exists("settlement_urls.txt"):
with open("settlement_urls.txt") as _sf: settlement = set(l.strip() for l in _sf if l.strip())
else:
settlement = set()
if not os.path.exists(URLFILE):
sys.exit(f"ERROR: url file not found: {URLFILE}")
with open(URLFILE) as _uf: urls = [l.strip() for l in _uf if l.strip()]
urls = [u if u.startswith("/") else "/wallpaper/"+u for u in urls]
if LIMIT:
urls = urls[:LIMIT]
def fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode("utf-8", "replace")
def norm(w):
return re.sub(r"[^a-z0-9]", "", w.lower())
def strip_code(tok):
# strip design-code tokens like Dcavad101 / DCAVAE103
return re.sub(r"\b[A-Za-z]{3,6}\d{2,4}\b", "", tok).strip()
def derive_color(name, pattern):
n = strip_code(name)
nwords = n.split()
if pattern:
pwords = [norm(w) for w in pattern.split()]
j = 0
# strip leading name-words that match the pattern words (order-tolerant, punct/case-insensitive)
while j < len(nwords) and norm(nwords[j]) in pwords:
j += 1
nwords = nwords[j:]
color = re.sub(r"\s+", " ", " ".join(nwords)).strip(" -/")
return color or None
rows = []
JSONLD = re.compile(r'<script[^>]*application/ld\+json[^>]*>(.*?)</script>', re.S)
IMG = re.compile(r'(static/media/catalog/product/[A-Za-z0-9/_.\-]+\.jpg)')
# FIX (TK-11256, 2026-09-04): the old gallery extraction (IMG.findall(h) over the WHOLE
# page) swept up EVERY image referenced anywhere on the PDP -- including the "shop other
# colourways" swatch carousel and cross-sell "shop the room" panels, which show OTHER
# products' images at real (non-thumbnail) resolution. That's the dw-image-identity-canary
# foreign_colorway/foreign_pattern contamination (Sanderson foreign_products 6->24).
# Verified live (2026-09-04): Sanderson's own gallery/detail shots consistently render at
# srcset max-width 2560, while "other colourway" swatches cap at 64w and cross-sell/
# "shop the room" images cap at 1080w -- a clean, reproducible separation on every PDP
# checked (SAW0094-01/-03, SAW0224-01). Scope gallery_images to that >=1500w band only.
GALLERY_MIN_W = 1500
SRCSET = re.compile(r'srcset="([^"]*static/media/catalog/product/[A-Za-z0-9/_.\-]+\.jpg[^"]*)"')
FNAME = re.compile(r'([A-Za-z0-9_]+\.jpg)')
PATHFNAME = re.compile(r'(static/media/catalog/product/[A-Za-z0-9/]+/([A-Za-z0-9_]+\.jpg))')
def own_gallery(h):
best_w = {}
for m in SRCSET.finditer(h):
srcset = m.group(1)
widths = [int(w) for w in re.findall(r'(\d+)w', srcset)]
if not widths:
continue
w = max(widths)
for fm in FNAME.finditer(srcset):
fname = fm.group(1)
best_w[fname] = max(best_w.get(fname, 0), w)
keep = {f for f, w in best_w.items() if w >= GALLERY_MIN_W}
if not keep:
return []
fname_to_path = {}
for pm in PATHFNAME.finditer(h):
fname_to_path.setdefault(pm.group(2), pm.group(1))
return sorted(fname_to_path[f] for f in keep if f in fname_to_path)
for i, path in enumerate(urls):
url = BASE + path
prod = None
for attempt in range(3):
try:
h = fetch(url)
except Exception as e:
sys.stderr.write(f"ERR {path}: {e}\n"); time.sleep(1); continue
for blk in JSONLD.findall(h):
try:
d = json.loads(blk.strip())
except Exception:
continue
if isinstance(d, dict) and d.get("@type") == "Product":
prod = d; break
if prod: break
time.sleep(1.2) # cold-cache shell: pause lets SSR prerender warm
if not prod:
sys.stderr.write(f"NOLD {path}\n"); continue
name = html.unescape(prod.get("name", "").strip())
sku = prod.get("sku", "").strip().upper()
pref = (re.match(r"(SAW\d{4})", sku) or [None, None])[1] if sku else None
pattern = pattern_map.get(pref) or (name.split()[0] if name else None)
color = derive_color(name, pattern)
imgs = prod.get("image") or []
if isinstance(imgs, str): imgs = [imgs]
image = imgs[0] if imgs else None
offers = prod.get("offers") or {}
price = offers.get("price"); cur = offers.get("priceCurrency")
gallery_paths = own_gallery(h)
if gallery_paths:
gallery = sorted(set(BASE + "/" + m for m in gallery_paths))
else:
# fallback: no >=1500w own-gallery images found on this PDP shape -- better to
# carry only the product's own primary image than to fall back to the old
# whole-page sweep (which is exactly the contamination bug this fixes).
gallery = [image] if image else []
settle = "review" if path in settlement else "clear"
rows.append({
"mfr_sku": sku, "pattern_name": pattern, "color_name": color,
"collection": pattern, "image_url": image, "product_url": url,
"product_type": "Wallcovering", "price_gbp": price, "currency": cur,
"settlement_flag": settle,
"gallery_images": "{" + ",".join('"'+g+'"' for g in gallery[:8]) + "}" if gallery else "{}",
})
if (i+1) % 50 == 0:
sys.stderr.write(f" ...{i+1}/{len(urls)}\n")
time.sleep(0.12)
cols = ["mfr_sku","pattern_name","color_name","collection","image_url","product_url",
"product_type","price_gbp","currency","settlement_flag","gallery_images"]
with open(OUTFILE, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=cols)
w.writeheader()
for r in rows: w.writerow(r)
sys.stderr.write(f"DONE: {len(rows)} rows -> {OUTFILE}\n")