← back to Designer Wallcoverings
Alan Campbell: reclassify wallcovering->fabric (type/title/tags/desc)
55858e6432a8dbc7d59facb0fe2e4cae4477f7dc · 2026-07-07 10:06:11 -0700 · Steve
Files touched
A scripts/alan-campbell-fabric-fix.py
Diff
commit 55858e6432a8dbc7d59facb0fe2e4cae4477f7dc
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jul 7 10:06:11 2026 -0700
Alan Campbell: reclassify wallcovering->fabric (type/title/tags/desc)
---
scripts/alan-campbell-fabric-fix.py | 136 ++++++++++++++++++++++++++++++++++++
1 file changed, 136 insertions(+)
diff --git a/scripts/alan-campbell-fabric-fix.py b/scripts/alan-campbell-fabric-fix.py
new file mode 100644
index 00000000..9d0479c3
--- /dev/null
+++ b/scripts/alan-campbell-fabric-fix.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+"""
+Alan Campbell: Wallcovering -> Fabric reclassification (LIVE Shopify).
+
+Steve's rule: all Alan Campbell products are FABRICS, not wallcoverings.
+A prior migration half-finished this (flipped 121 type+title but left tags dirty;
+never touched the other 246). This does the complete, idempotent fix:
+
+ 1. product_type Wallcovering -> Fabric
+ 2. title "<Pattern> Wallcovering(s) | Alan Campbell" -> "<Pattern> | Alan Campbell"
+ 3. tags drop "Wallcovering", add "Fabric",
+ "Trending Wallcovering Collection 2026" -> "Trending Fabric Collection 2026"
+ (any tag containing "Wallcovering" -> "Fabric")
+ 4. descriptionHtml case-preserving wallcovering(s) -> fabric(s)
+
+Idempotent: recompute from current state each run; only PATCH what actually changes.
+Usage: python3 alan-campbell-fabric-fix.py [--apply] (default = dry run)
+"""
+import os, sys, re, json, time, urllib.request
+
+STORE = os.environ["SHOPIFY_STORE_DOMAIN"]
+TOK = os.environ["SHOPIFY_ADMIN_TOKEN"]
+VER = "2024-10"
+APPLY = "--apply" in sys.argv
+
+def gql(query, variables=None):
+ body = json.dumps({"query": query, "variables": variables or {}}).encode()
+ req = urllib.request.Request(
+ f"https://{STORE}/admin/api/{VER}/graphql.json", data=body,
+ headers={"X-Shopify-Access-Token": TOK, "Content-Type": "application/json"})
+ for attempt in range(6):
+ try:
+ d = json.load(urllib.request.urlopen(req))
+ except urllib.error.HTTPError as e:
+ if e.code == 429:
+ time.sleep(2 * (attempt + 1)); continue
+ raise
+ if d.get("errors") and any("THROTTLED" in json.dumps(x) for x in d["errors"]):
+ time.sleep(2 * (attempt + 1)); continue
+ return d
+ raise RuntimeError("throttled repeatedly")
+
+def scrub_prose(s):
+ if not s: return s
+ s = s.replace("Wallcoverings", "Fabrics").replace("wallcoverings", "fabrics")
+ s = s.replace("Wallcovering", "Fabric").replace("wallcovering", "fabric")
+ return s
+
+def fix_title(t):
+ # strip a trailing "Wallcovering(s)" from the segment before the first "|"
+ if "|" in t:
+ head, rest = t.split("|", 1)
+ head = re.sub(r"\s*Wallcoverings?\s*$", " ", head).rstrip() + " "
+ return head + "|" + rest
+ return re.sub(r"\s*Wallcoverings?\s*$", "", t)
+
+def fix_tags(tags):
+ out = []
+ for t in tags:
+ if t == "Wallcovering":
+ continue # dropped; Fabric added below
+ elif "Wallcovering" in t:
+ out.append(t.replace("Wallcovering", "Fabric"))
+ else:
+ out.append(t)
+ if "Fabric" not in out:
+ out.append("Fabric")
+ return out
+
+# ---- fetch all Alan Campbell products ----
+Q = """query($cursor:String){ products(first:100, query:"vendor:'Alan Campbell'", after:$cursor){
+ pageInfo{hasNextPage endCursor}
+ edges{ node{ id title productType tags descriptionHtml } } } }"""
+prods, cur = [], None
+while True:
+ d = gql(Q, {"cursor": cur})
+ pg = d["data"]["products"]
+ prods += [e["node"] for e in pg["edges"]]
+ if not pg["pageInfo"]["hasNextPage"]: break
+ cur = pg["pageInfo"]["endCursor"]
+print(f"fetched {len(prods)} Alan Campbell products")
+
+# ---- compute diffs ----
+changes = []
+for p in prods:
+ new_type = "Fabric"
+ new_title = fix_title(p["title"])
+ new_tags = fix_tags(p["tags"])
+ new_desc = scrub_prose(p["descriptionHtml"])
+ diff = {}
+ if p["productType"] != new_type: diff["productType"] = new_type
+ if p["title"] != new_title: diff["title"] = new_title
+ if sorted(p["tags"]) != sorted(new_tags): diff["tags"] = new_tags
+ if (p["descriptionHtml"] or "") != (new_desc or ""): diff["descriptionHtml"] = new_desc
+ if diff:
+ changes.append((p, diff))
+
+print(f"products needing update: {len(changes)} / {len(prods)}")
+from collections import Counter
+fields = Counter(k for _, d in changes for k in d)
+print("field change counts:", dict(fields))
+print("\n--- sample diffs ---")
+for p, d in changes[:4]:
+ print(f" {p['title']!r}")
+ if "title" in d: print(f" title -> {d['title']!r}")
+ if "productType" in d: print(f" type {p['productType']} -> {d['productType']}")
+ if "tags" in d:
+ rm = set(p["tags"]) - set(d["tags"]); add = set(d["tags"]) - set(p["tags"])
+ print(f" tags -{sorted(rm)} +{sorted(add)}")
+ if "descriptionHtml" in d: print(f" desc scrubbed wallcovering->fabric")
+ print()
+
+if not APPLY:
+ print("DRY RUN. Re-run with --apply to write to the LIVE store.")
+ sys.exit(0)
+
+# ---- apply ----
+M = """mutation($input:ProductInput!){ productUpdate(input:$input){
+ product{ id } userErrors{ field message } } }"""
+ok = err = 0
+for i, (p, d) in enumerate(changes, 1):
+ inp = {"id": p["id"]}
+ if "productType" in d: inp["productType"] = d["productType"]
+ if "title" in d: inp["title"] = d["title"]
+ if "tags" in d: inp["tags"] = d["tags"]
+ if "descriptionHtml" in d: inp["descriptionHtml"] = d["descriptionHtml"]
+ r = gql(M, {"input": inp})
+ ue = r.get("data", {}).get("productUpdate", {}).get("userErrors") if r.get("data") else r.get("errors")
+ if ue:
+ err += 1; print(f" [{i}] ERR {p['title']}: {ue}")
+ else:
+ ok += 1
+ if i % 25 == 0:
+ print(f" ...{i}/{len(changes)} (ok={ok} err={err})")
+ time.sleep(0.25)
+print(f"\nDONE. updated ok={ok} err={err} of {len(changes)}")
← e4711d86 Parameterize room push (CATALOG/VIEW) — serves Carl Robinson
·
back to Designer Wallcoverings
·
enrich: replace LLM naming with deterministic ~115-color int 75188a05 →