← back to Sanderson Onboard
scripts/extract.py
111 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)')
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 = sorted(set(BASE + "/" + m for m in IMG.findall(h)))
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")