← back to Designer Wallcoverings
TK-10055: free-promote hero script + gated plan (DTD-B; scan shows ~2% promotable)
281f46f7c65e6a299de0774fb7b2187936849704 · 2026-07-30 10:49:08 -0700 · collection-agent
Files touched
A shopify/collection-hero-fix/tk10055-free-promote/promote-heroes.py
Diff
commit 281f46f7c65e6a299de0774fb7b2187936849704
Author: collection-agent <steve@designerwallcoverings.com>
Date: Thu Jul 30 10:49:08 2026 -0700
TK-10055: free-promote hero script + gated plan (DTD-B; scan shows ~2% promotable)
---
.../tk10055-free-promote/promote-heroes.py | 140 +++++++++++++++++++++
1 file changed, 140 insertions(+)
diff --git a/shopify/collection-hero-fix/tk10055-free-promote/promote-heroes.py b/shopify/collection-hero-fix/tk10055-free-promote/promote-heroes.py
new file mode 100644
index 00000000..1c1df38c
--- /dev/null
+++ b/shopify/collection-hero-fix/tk10055-free-promote/promote-heroes.py
@@ -0,0 +1,140 @@
+#!/usr/bin/env python3
+"""
+TK-10055 — Free-promote a hero-worthy hi-res PRODUCT image to collection.image so
+a mosaic collection flips to the sharp single-flagship-hero path (theme gate: width>=1400).
+
+DTD-B verdict (2026-07-30) with the two Cody-forced guardrails baked in:
+ * QUALITY GATE — only promote a >=1400px image that is LANDSCAPE (ratio 1.2-2.6).
+ A square/portrait swatch blown up full-bleed looks worse than the
+ 2x2 mosaic, so those are LEFT on the mosaic.
+ * STORAGE — setting collection.image creates a NEW /collections/ file (verified),
+ so this is cheap-not-free; --canary measures the real per-write cost,
+ and promotion is scoped to the hero-worthy subset only (~2% of the pool)
+ so the cap impact is a handful of files, not hundreds.
+
+Modes (READ-ONLY by default; writes are gated and explicit):
+ --scan read-only: classify EVERY low-res/no-image collection, write report.json
+ --canary <handle> GATED: set ONE collection image, then GET files.json to measure the
+ storage delta a promotion actually costs. Prints the new file size.
+ --promote [--limit N] GATED: promote the hero-worthy subset (from report.json), N at a time.
+ Backs up each prior collection.image to backups/ first (reversible).
+
+Token: SHOPIFY_ADMIN_TOKEN (products+collections scope) from secrets-manager/.env.
+"""
+import sys, os, json, time, urllib.request, urllib.error
+
+STORE = "designer-laboratory-sandbox.myshopify.com"; API = "2024-10"
+SEC = os.path.expanduser("~/Projects/secrets-manager/.env")
+HERE = os.path.dirname(os.path.abspath(__file__))
+MIN_W = 1400; LAND_LO, LAND_HI = 1.2, 2.6
+
+def tok():
+ for line in open(SEC):
+ if line.startswith("SHOPIFY_ADMIN_TOKEN="):
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
+ sys.exit("no SHOPIFY_ADMIN_TOKEN")
+H = {"X-Shopify-Access-Token": tok(), "Content-Type": "application/json"}
+
+def req(method, path, body=None):
+ url = f"https://{STORE}/admin/api/{API}/{path}"
+ data = json.dumps(body).encode() if body else None
+ for i in range(5):
+ try:
+ r = urllib.request.urlopen(urllib.request.Request(url, data=data, headers=H, method=method))
+ return json.load(r)
+ except urllib.error.HTTPError as e:
+ if e.code == 429 and i < 4: time.sleep(2); continue
+ raise
+
+def all_collections(res):
+ out = []; url = f"{res}.json?limit=250&fields=id,handle,title,image"
+ full = f"https://{STORE}/admin/api/{API}/{url}"
+ while full:
+ rq = urllib.request.Request(full, headers=H)
+ r = urllib.request.urlopen(rq); out += json.load(r).get(res, [])
+ nxt = None
+ for p in r.headers.get("Link", "").split(","):
+ if 'rel="next"' in p: nxt = p[p.find("<")+1:p.find(">")]
+ full = nxt
+ return out
+
+def best_hero(cid):
+ """Return (width, ratio, landscape, src) of the best product image, or None."""
+ p = req("GET", f"collections/{cid}/products.json?limit=25&fields=images")
+ best = None
+ for prod in p.get("products", []):
+ for im in prod.get("images", []):
+ w = im.get("width") or 0; h = im.get("height") or 1
+ if w >= MIN_W:
+ ratio = w / h; land = LAND_LO <= ratio <= LAND_HI
+ cand = (w, round(ratio, 2), land, im.get("src"))
+ if best is None or (land and not best[2]) or (land == best[2] and w > best[0]):
+ best = cand
+ return best
+
+def scan():
+ pool = []
+ for res in ("custom_collections", "smart_collections"):
+ for c in all_collections(res):
+ img = c.get("image")
+ if (not img) or (img.get("width") or 0) < MIN_W:
+ pool.append({"res": res, "id": c["id"], "handle": c["handle"]})
+ print(f"pool (low-res/no-image collections): {len(pool)}")
+ hero, swatch, none_ = [], [], []
+ for i, c in enumerate(pool):
+ b = best_hero(c["id"])
+ if b is None: none_.append(c["handle"])
+ elif b[2]: hero.append({**c, "w": b[0], "ratio": b[1], "src": b[3]})
+ else: swatch.append(c["handle"])
+ if i % 25 == 0: print(f" scanned {i}/{len(pool)}")
+ time.sleep(0.15)
+ report = {"hero_worthy": hero, "hires_swatch": swatch, "no_hires": none_,
+ "counts": {"hero_worthy": len(hero), "hires_swatch": len(swatch), "no_hires": len(none_)}}
+ json.dump(report, open(os.path.join(HERE, "report.json"), "w"), indent=2)
+ print("\nREPORT:", report["counts"])
+ print(f"-> {len(hero)} collections promotable to a real landscape hero (the free win)")
+ print(f"-> {len(swatch)} have hi-res but square/portrait swatches: LEFT on mosaic (would downgrade)")
+ print(f"-> {len(none_)} have no >=1400px image: mosaic (or future generation)")
+
+def canary(handle):
+ c = req("GET", f"custom_collections.json?handle={handle}&fields=id,image") or {}
+ cc = c.get("custom_collections") or []
+ if not cc:
+ cc = (req("GET", f"smart_collections.json?handle={handle}&fields=id,image") or {}).get("smart_collections", [])
+ if not cc: sys.exit(f"collection {handle} not found")
+ cid = cc[0]["id"]
+ b = best_hero(cid)
+ if not b or not b[2]: sys.exit("no hero-worthy image to canary with")
+ before = req("GET", "files.json?limit=1") # existence check only
+ print(f"CANARY: setting {handle} image -> {b[3][:80]} ({b[0]}px ratio {b[1]})")
+ res_key = "custom_collections" if cc else "smart_collections"
+ # NOTE: this is a PROD write — only runs when explicitly invoked with --canary
+ req("PUT", f"custom_collections/{cid}.json", {"custom_collection": {"id": cid, "image": {"src": b[3]}}})
+ time.sleep(3)
+ after = req("GET", f"custom_collections/{cid}.json?fields=image")
+ newimg = after.get("custom_collection", {}).get("image", {})
+ print("new collection.image src:", newimg.get("src"))
+ print("new collection.image w/h:", newimg.get("width"), "x", newimg.get("height"))
+ print(">> Confirm in Shopify admin Files whether a NEW /collections/ asset was created and its size.")
+
+def promote(limit):
+ rep = json.load(open(os.path.join(HERE, "report.json")))
+ todo = rep["hero_worthy"][:limit] if limit else rep["hero_worthy"]
+ os.makedirs(os.path.join(HERE, "backups"), exist_ok=True)
+ for c in todo:
+ cur = req("GET", f"collections/{c['id']}.json?fields=image")
+ json.dump(cur, open(os.path.join(HERE, "backups", f"{c['handle']}.json"), "w"))
+ req("PUT", f"custom_collections/{c['id']}.json",
+ {"custom_collection": {"id": c["id"], "image": {"src": c["src"]}}})
+ print(f"promoted {c['handle']} ({c['w']}px ratio {c['ratio']})")
+ time.sleep(0.6)
+ print(f"done: promoted {len(todo)} collections")
+
+if __name__ == "__main__":
+ a = sys.argv[1:]
+ if not a or a[0] == "--scan": scan()
+ elif a[0] == "--canary" and len(a) > 1: canary(a[1])
+ elif a[0] == "--promote":
+ lim = int(a[a.index("--limit")+1]) if "--limit" in a else 0
+ promote(lim)
+ else: print(__doc__)
← 46b9ce9f auto-save: 2026-07-30T10:47:06 (1 files) — shopify/scripts/d
·
back to Designer Wallcoverings
·
nav script: handle auth-error string + last-wins env loader 0eadbd60 →