← back to Dw Collection Banner Audit
dryrun_apply.py
119 lines
#!/usr/bin/env python3
"""
DRY-RUN collection-banner remediation planner for the LIVE DW Shopify store.
READ-ONLY. Produces a proposed before/after banner list. Writes NOTHING to Shopify.
For each MISSING / LOW-RES collection that has products:
- paced fetch of /collections/<handle>/products.json (bot-filter safe: sequential + backoff)
- pick the LARGEST product image with min-dimension >= MIN_DIM, skipping logo files
- if none >= MIN_DIM -> flag NO_HIRES_SOURCE (needs a hand-picked asset; do NOT auto-fill)
Empty (0-product) collections are skipped (hide/populate decision, not an image fix).
"""
import json, os, time, urllib.request, csv
HERE = os.path.dirname(os.path.abspath(__file__))
HOST = "designerwallcoverings.com"
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
MIN_DIM = 1200 # a banner needs at least this on the short edge to stay sharp
PER_COLLECTION = 24 # scan up to N products for the best image
SLEEP = 1.1 # polite pacing to dodge the 403 bot-filter
MAX_RETRY = 4
cols = json.load(open(os.path.join(HERE, "dw_collections.json")))
probe = {r["handle"]: r for r in json.load(open(os.path.join(HERE, "dw_col_probe.json")))}
def is_problem(c):
if not c.get("image"):
return "MISSING", None, None
p = probe.get(c["handle"])
if p and p["w"] and (p["w"] < 1000 or p["ht"] < 600):
return f"LOWRES", p["w"], p["ht"]
return None, None, None
def fetch_products(handle):
url = f"https://{HOST}/collections/{handle}/products.json?limit={PER_COLLECTION}"
for attempt in range(MAX_RETRY):
try:
r = urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"}), timeout=30)
return json.load(r).get("products", [])
except urllib.error.HTTPError as e:
if e.code in (403, 429):
time.sleep(2.0 * (attempt + 1)) # backoff on throttle
continue
return None
except Exception:
time.sleep(1.5)
return None
def best_image(products):
"""largest product image with min-dim >= MIN_DIM, skipping logo files; returns (src,w,h) or None, plus best-any fallback."""
best = None; best_any = None
for p in products:
for im in p.get("images", []):
src = im.get("src", "")
if "logo" in src.lower():
continue
w, h = im.get("width") or 0, im.get("height") or 0
area = w * h
if best_any is None or area > best_any[3]:
best_any = (src, w, h, area)
if w >= MIN_DIM and h >= MIN_DIM:
if best is None or area > best[3]:
best = (src, w, h, area)
return best, best_any
problems = []
for c in cols:
kind, w, h = is_problem(c)
if kind:
problems.append((c, kind, w, h))
print(f"problem collections: {len(problems)} (MIN_DIM={MIN_DIM}, per-collection scan={PER_COLLECTION})")
rows = []
n_ok = n_nosrc = n_empty = 0
for i, (c, kind, w, h) in enumerate(problems, 1):
handle = c["handle"]; pc = c.get("products_count") or 0
cur = c["image"]["src"] if c.get("image") else None
cur_dim = f"{w}x{h}" if w else ("none" if kind == "MISSING" else "?")
if pc == 0:
rows.append({"handle": handle, "title": c["title"], "products": pc, "issue": kind,
"current_dim": cur_dim, "current_src": cur, "new_src": None,
"new_dim": None, "status": "EMPTY_SKIP"})
n_empty += 1
continue
prods = fetch_products(handle)
time.sleep(SLEEP)
if prods is None:
rows.append({"handle": handle, "title": c["title"], "products": pc, "issue": kind,
"current_dim": cur_dim, "current_src": cur, "new_src": None,
"new_dim": None, "status": "FETCH_FAIL"})
continue
best, best_any = best_image(prods)
if best:
rows.append({"handle": handle, "title": c["title"], "products": pc, "issue": kind,
"current_dim": cur_dim, "current_src": cur, "new_src": best[0],
"new_dim": f"{best[1]}x{best[2]}", "status": "READY"})
n_ok += 1
else:
ba = f"{best_any[1]}x{best_any[2]}" if best_any else "none"
rows.append({"handle": handle, "title": c["title"], "products": pc, "issue": kind,
"current_dim": cur_dim, "current_src": cur,
"new_src": best_any[0] if best_any else None, "new_dim": ba,
"status": "NO_HIRES_SOURCE"})
n_nosrc += 1
if i % 25 == 0:
print(f" ...{i}/{len(problems)} ready={n_ok} no-source={n_nosrc}")
json.dump(rows, open(os.path.join(HERE, "proposal.json"), "w"), indent=1)
with open(os.path.join(HERE, "proposal.csv"), "w", newline="") as f:
wcsv = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
wcsv.writeheader(); wcsv.writerows(rows)
print("\n=== DRY-RUN SUMMARY (NO LIVE WRITES) ===")
print(f" READY (good >= {MIN_DIM}px in-collection image found): {n_ok}")
print(f" NO_HIRES_SOURCE (needs hand-picked asset): {n_nosrc}")
print(f" EMPTY_SKIP (0 products - hide/populate decision): {n_empty}")
print(f" FETCH_FAIL: {sum(1 for r in rows if r['status']=='FETCH_FAIL')}")
print(f" proposal.json / proposal.csv written to {HERE}")