← back to Designer Wallcoverings

onboarding/sangetsu-lilycolor/scripts/sangetsu-width-recover.py

103 lines

#!/usr/bin/env python3
"""sangetsu-width-recover.py — recover missing width/spec from the free Store API.

The existing storeapi-enrich left 545/623 patterns with NO width. Diagnosis showed
the width is OFTEN present in the product `description` but in prose forms the
japan-enrich parse_spec() misses (e.g. `Width 52" / 132 cm`, `Width ... 91 cm`).
Some patterns genuinely have NO width in the source (e.g. "Acappella") — those stay
missing and are reported, never fabricated.

CONSERVATIVE extraction — only accept a width when it's unambiguously labelled:
  1. `<in>" / <cm> cm`     (the canonical Sangetsu spec form)  -> "<cm> cm"
  2. `Width ...<cm> cm`     (Width-labelled cm, one number)     -> "<cm> cm"
  3. `Width ...<in>"`       (Width-labelled inches, one number) -> "<in> Inches"
Multiple conflicting numbers with no clear label -> SKIP (flag), never guess.

Reversible: read-only fetch + atomic rewrite of spec.width only when currently
empty (never overwrites an existing width). $0. No dw_unified/Shopify write.
"""
import sys, os, json, re, time

HERE = os.path.dirname(os.path.abspath(__file__))
STAGING = os.path.join(os.path.dirname(HERE), "staging", "sangetsu-staging.jsonl")
sys.path.insert(0, os.path.expanduser("~/Projects/japan-enrich"))
from scrape_sangetsu_specs import get, slug_of  # noqa: E402

# canonical: 52" / 132 cm   (accept curly quotes)
RE_IN_CM = re.compile(r'(\d{2,3})\s*["”″]\s*/\s*(\d{2,3})\s*cm', re.I)
# Width-labelled single cm within 30 chars
RE_W_CM = re.compile(r'\bwidth\b[^0-9]{0,30}?(\d{2,3})\s*cm', re.I)
# Width-labelled single inch within 30 chars
RE_W_IN = re.compile(r'\bwidth\b[^0-9]{0,30}?(\d{2,3})\s*["”″]', re.I)


def extract_width(desc_html):
    txt = re.sub(r"<[^>]+>", " ", desc_html or "")
    txt = re.sub(r"\s+", " ", txt)
    m = RE_IN_CM.search(txt)
    if m:
        return f"{m.group(2)} cm", "in/cm"
    # count distinct cm numbers near a Width label; if exactly one, trust it
    cms = RE_W_CM.findall(txt)
    if cms and len(set(cms)) == 1:
        return f"{cms[0]} cm", "width-cm"
    ins = RE_W_IN.findall(txt)
    if ins and len(set(ins)) == 1:
        return f'{ins[0]} Inches', "width-in"
    return None, ("ambiguous" if cms or ins else "absent")


def main():
    dry = "--apply" not in sys.argv
    rows = [json.loads(l) for l in open(STAGING) if l.strip()]
    before = sum(1 for p in rows if str((p.get("spec") or {}).get("width") or "").strip())
    recovered = 0
    reasons = {"in/cm": 0, "width-cm": 0, "width-in": 0, "ambiguous": 0, "absent": 0, "miss": 0}
    residual = []
    for i, p in enumerate(rows):
        if str((p.get("spec") or {}).get("width") or "").strip():
            continue
        d = get(f"/products?slug={slug_of(p['source_url'])}")
        if not d:
            reasons["miss"] += 1
            residual.append((p["pattern"], "no-api"))
            continue
        w, why = extract_width(d[0].get("description", ""))
        if w:
            reasons[why] += 1
            recovered += 1
            if not dry:
                p.setdefault("spec", {})["width"] = w
                p["spec"]["_width_src"] = f"storeapi:{why}"
        else:
            reasons[why] += 1
            residual.append((p["pattern"], why))
        if i % 60 == 0:
            print(f"[{i+1}/{len(rows)}] recovered={recovered}", flush=True)
        time.sleep(0.12)

    if not dry:
        tmp = STAGING + ".tmp"
        with open(tmp, "w") as f:
            for p in rows:
                f.write(json.dumps(p, ensure_ascii=False) + "\n")
        os.replace(tmp, STAGING)
        after = sum(1 for p in rows if str((p.get("spec") or {}).get("width") or "").strip())
    else:
        after = before + recovered

    print(json.dumps({
        "mode": "DRY-RUN" if dry else "APPLIED",
        "width_before": before, "width_after_est": after,
        "recovered": recovered, "by_reason": reasons,
        "residual_no_width": len(residual),
    }, indent=2))
    # write the residual list (genuinely-absent widths) for the record
    with open(os.path.join(os.path.dirname(STAGING), "sangetsu-width-residual.txt"), "w") as f:
        for name, why in residual:
            f.write(f"{name}\t{why}\n")


if __name__ == "__main__":
    main()