[object Object]

← 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

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 →