← back to Japan Enrich

scrape_sangetsu_backfill.py

128 lines

#!/usr/bin/env python3
"""
Sangetsu colorway/image BACKFILL — fills the 257 patterns whose original scrape
left colorway_skus:[] (they render as blank cards on japan.).

Source: sangetsu-goodrich.co.th is WooCommerce; its Store API is public
(the WP REST proper is locked by Kadence security, but /wc/store/v1 is open):
  GET /wc/store/v1/products?slug=<slug>              → parent (attributes = SKU list)
  GET /wc/store/v1/products?type=variation&parent=ID → variation images (filename = SKU)

A variable product's "Full Collection" attribute holds the clean colorway SKUs
(A222-10, A222-35, …); each variation's image filename embeds its SKU. We map
SKU→image by "basename starts with SKU" so the SKU's own trailing -NN isn't
confused with WordPress's -1 dedup suffix.

SAFE: only fills rows currently missing colorways; never touches populated rows.
Writes a SEPARATE merged file by default (staging/sangetsu-staging.backfilled.jsonl);
pass --apply to atomically replace staging/sangetsu-staging.jsonl (temp+rename).

Usage: scrape_sangetsu_backfill.py [--limit N] [--apply]
"""
import json, os, re, sys, time, urllib.request, urllib.error

ROOT = os.path.dirname(__file__)
STAGING = os.path.join(ROOT, "staging", "sangetsu-staging.jsonl")
OUT = os.path.join(ROOT, "staging", "sangetsu-staging.backfilled.jsonl")
API = "https://www.sangetsu-goodrich.co.th/wp-json/wc/store/v1"
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) japan-enrich/sangetsu-backfill"

LIMIT = int(sys.argv[sys.argv.index("--limit") + 1]) if "--limit" in sys.argv else None
APPLY = "--apply" in sys.argv

DIM = re.compile(r"-\d+x\d+(?=\.\w+$)")           # WP thumbnail dimension suffix
def full_src(src):                                 # strip -150x150 → full-size upload
    return DIM.sub("", src or "")
def stem(src):
    return os.path.basename(full_src(src)).rsplit(".", 1)[0]
def slug_of(url):
    return (url or "").rstrip("/").rsplit("/", 1)[-1]


def get(path, tries=4):
    for i in range(tries):
        try:
            req = urllib.request.Request(API + path, headers={"User-Agent": UA, "Accept": "application/json"})
            with urllib.request.urlopen(req, timeout=45) as r:
                return json.loads(r.read().decode("utf-8"))
        except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e:
            if i == tries - 1:
                return None
            time.sleep(1.2 * (i + 1))
    return None


def backfill_one(url):
    """Return (colorway_skus, sku_images, mfr_prefix) or (None,None,None)."""
    slug = slug_of(url)
    plist = get(f"/products?slug={slug}")
    if not (isinstance(plist, list) and plist):
        return None, None, None
    p = plist[0]
    # colorway SKUs: the attribute with the most term entries (the "Full Collection")
    skus = []
    for a in p.get("attributes") or []:
        terms = [t.get("name", "").strip() for t in (a.get("terms") or []) if t.get("name")]
        if len(terms) > len(skus):
            skus = terms
    # variation images: filename stem embeds the SKU
    var = get(f"/products?type=variation&parent={p['id']}&per_page=100") or []
    srcs = []
    for v in (var if isinstance(var, list) else []):
        for im in (v.get("images") or []):
            if im.get("src"):
                srcs.append(full_src(im["src"]))
    # parent gallery images too (some variations reuse the parent image)
    for im in (p.get("images") or []):
        if im.get("src"):
            srcs.append(full_src(im["src"]))
    srcs = list(dict.fromkeys(srcs))               # dedupe, keep order
    # fallback: if no attribute SKUs, derive from image stems that look like SKUs
    if not skus:
        cand = [stem(s) for s in srcs]
        skus = [c for c in dict.fromkeys(cand) if re.match(r"^[A-Z0-9]+(-[A-Z0-9]+)*$", c) and any(ch.isdigit() for ch in c)]
    imgs = {}
    for sku in skus:
        # prefer exact "<sku>.jpg", else a stem that starts with the sku (WP -1 dedup)
        exact = next((s for s in srcs if stem(s) == sku), None)
        pref = exact or next((s for s in srcs if stem(s).startswith(sku)), None)
        if pref:
            imgs[sku] = pref
    pm = re.match(r"^[A-Za-z]+", skus[0]) if skus else None
    prefix = pm.group(0) if pm else None
    return skus, imgs, prefix


def main():
    rows = [json.loads(l) for l in open(STAGING) if l.strip()]
    empties = [r for r in rows if not (r.get("colorway_skus"))]
    todo = empties[:LIMIT] if LIMIT else empties
    print(f"sangetsu backfill: {len(rows)} rows, {len(empties)} imageless, doing {len(todo)}", flush=True)
    filled = still = 0
    for i, r in enumerate(todo, 1):
        skus, imgs, prefix = backfill_one(r.get("source_url"))
        if skus and imgs:
            r["colorway_skus"] = skus
            r["colorway_count"] = len(skus)
            r["sku_images"] = imgs
            if prefix:
                r["mfr_prefix"] = prefix
            r["backfilled"] = True
            filled += 1
            print(f"  [{i}/{len(todo)}] {(r.get('pattern') or '?'):28} +{len(skus)} colorways / {len(imgs)} imgs", flush=True)
        else:
            still += 1
            print(f"  [{i}/{len(todo)}] {(r.get('pattern') or '?'):28} — no data ({slug_of(r.get('source_url'))})", flush=True)
        time.sleep(0.25)                            # polite
    dest = STAGING if APPLY else OUT
    tmp = dest + ".tmp"
    with open(tmp, "w") as f:
        for r in rows:
            f.write(json.dumps(r, ensure_ascii=False) + "\n")
    os.replace(tmp, dest)
    print(f"\nbackfilled {filled}, still-empty {still}  → {dest}{'  (APPLIED to staging)' if APPLY else '  (dry-run; --apply to merge)'}", flush=True)


if __name__ == "__main__":
    main()