[object Object]

← back to Designer Wallcoverings

Normalize all 1422 GRS grasscloth widths to 36" (kill 52/54" vinyl default)

d183fe68a437f7946bdae00769892fd19070a341 · 2026-07-06 17:17:18 -0700 · Steve

Files touched

Diff

commit d183fe68a437f7946bdae00769892fd19070a341
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 6 17:17:18 2026 -0700

    Normalize all 1422 GRS grasscloth widths to 36" (kill 52/54" vinyl default)
---
 shopify/scripts/fix-grs-width.py | 115 +++++++++++++++++++++++++++++++++++++++
 1 file changed, 115 insertions(+)

diff --git a/shopify/scripts/fix-grs-width.py b/shopify/scripts/fix-grs-width.py
new file mode 100644
index 00000000..1c469485
--- /dev/null
+++ b/shopify/scripts/fix-grs-width.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+"""
+Normalize width metafields for ALL Phillipe Romano GRS (grasscloth) products.
+
+Hard rule (Steve): GRS = 36" material ONLY. A block of ~217 GRS SKUs carried a
+bad commercial-vinyl default ("52/54\"" / "54 Inches"); ~269 were blank; ~936
+were correct-36" but spelled 15+ ways. This rewrites the three width metafields
+the storefront reads (global.width, dwc.width, custom.width) to ONE canonical
+string on every GRS product.
+
+Customer-facing store write. Steve authorized scope=all-1422, value=canonical.
+
+Usage:
+  fix-grs-width.py --canary grs-20311   # one product, live
+  fix-grs-width.py --limit 1            # first product only
+  fix-grs-width.py                      # full sweep (all GRS-*)
+"""
+import os, sys, json, time, urllib.request, urllib.error
+
+CANONICAL = '36" Wide (trim to 34")'
+NAMESPACES = [("global", "width"), ("dwc", "width"), ("custom", "width")]
+MFTYPE = "single_line_text_field"
+
+STORE = os.environ.get("SHOPIFY_STORE_DOMAIN") or "designer-laboratory-sandbox.myshopify.com"
+TOKEN = (os.environ.get("SHOPIFY_ADMIN_API_TOKEN") or os.environ.get("SHOPIFY_ADMIN_TOKEN")
+         or os.environ.get("SHOPIFY_ACCESS_TOKEN") or os.environ.get("SHOPIFY_API_TOKEN")
+         or os.environ.get("SHOPIFY_ADMIN_API_ACCESS_TOKEN"))
+API = "2024-10"
+
+def gql(query, variables=None, retries=6):
+    body = json.dumps({"query": query, "variables": variables or {}}).encode()
+    for attempt in range(retries):
+        req = urllib.request.Request(
+            f"https://{STORE}/admin/api/{API}/graphql.json", data=body,
+            headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
+        try:
+            d = json.load(urllib.request.urlopen(req))
+        except urllib.error.HTTPError as e:
+            if e.code in (429, 502, 503):
+                time.sleep(2 * (attempt + 1)); continue
+            raise
+        errs = d.get("errors")
+        throttled = errs and any(
+            (e.get("extensions", {}) or {}).get("code") == "THROTTLED" for e in errs)
+        if throttled:
+            time.sleep(2 * (attempt + 1)); continue
+        return d
+    raise RuntimeError("exhausted retries")
+
+def collect_gids(only=None):
+    q = '''query($c:String){ products(first:250, query:"sku:GRS-*", after:$c){
+      pageInfo{hasNextPage endCursor} nodes{ id handle } } }'''
+    cur, out = None, []
+    while True:
+        d = gql(q, {"c": cur})
+        p = d["data"]["products"]
+        for n in p["nodes"]:
+            if only and n["handle"] not in only:
+                continue
+            out.append((n["id"], n["handle"]))
+        if p["pageInfo"]["hasNextPage"]:
+            cur = p["pageInfo"]["endCursor"]
+        else:
+            break
+    return out
+
+SET = '''mutation($mf:[MetafieldsSetInput!]!){
+  metafieldsSet(metafields:$mf){
+    metafields{ id } userErrors{ field message } } }'''
+
+def set_batch(products):
+    """products: list of (gid, handle). Up to 8 per call (8*3=24 metafields<=25)."""
+    mf = []
+    for gid, _ in products:
+        for ns, key in NAMESPACES:
+            mf.append({"ownerId": gid, "namespace": ns, "key": key,
+                       "type": MFTYPE, "value": CANONICAL})
+    d = gql(SET, {"mf": mf})
+    ue = d.get("data", {}).get("metafieldsSet", {}).get("userErrors") or []
+    if d.get("errors"):
+        ue += [{"message": str(d["errors"])}]
+    return ue
+
+def main():
+    if not TOKEN:
+        print("ERROR: no Shopify admin token in env"); sys.exit(1)
+    args = sys.argv[1:]
+    only = None; limit = None
+    if "--canary" in args:
+        only = {args[args.index("--canary") + 1]}
+    if "--limit" in args:
+        limit = int(args[args.index("--limit") + 1])
+    print(f"store={STORE} token=...{TOKEN[-4:]}  canonical={CANONICAL!r}")
+    print("collecting GRS-* product ids ...")
+    gids = collect_gids(only=only)
+    if limit:
+        gids = gids[:limit]
+    print(f"targets: {len(gids)} products x 3 metafields = {len(gids)*3} writes")
+    ok = 0; errs = 0
+    B = 8
+    for i in range(0, len(gids), B):
+        batch = gids[i:i + B]
+        ue = set_batch(batch)
+        if ue:
+            errs += len(batch)
+            print(f"  [{i+len(batch)}/{len(gids)}] userErrors: {ue[:3]}")
+        else:
+            ok += len(batch)
+            if (i // B) % 5 == 0 or len(gids) <= B:
+                print(f"  [{i+len(batch)}/{len(gids)}] ok ({batch[0][1]}..{batch[-1][1]})")
+        time.sleep(0.4)  # gentle on the cost-based throttle
+    print(f"\nDONE. products written ok={ok} errored={errs}")
+
+if __name__ == "__main__":
+    main()

← 783b84d8 chore: version bump (session close) — WallQuest→PR naturals  ·  back to Designer Wallcoverings  ·  Guard GRS grasscloth width to 36" in command54 push (never i ec3bf17e →