← back to Dw Wallpaper Cleanup
Job 1 tag rewriter: banned wallpaper->wallcovering in tags (PG-first + Shopify)
34415d0e5c534a6e0e9c6243931f44b638596b52 · 2026-06-22 10:34:07 -0700 · Steve
Files touched
A .gitignoreA job1_tags.py
Diff
commit 34415d0e5c534a6e0e9c6243931f44b638596b52
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jun 22 10:34:07 2026 -0700
Job 1 tag rewriter: banned wallpaper->wallcovering in tags (PG-first + Shopify)
---
.gitignore | 7 ++++
job1_tags.py | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 135 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..08240ae
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
diff --git a/job1_tags.py b/job1_tags.py
new file mode 100644
index 0000000..086c89d
--- /dev/null
+++ b/job1_tags.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""JOB 1 — fix banned 'wallpaper' word in TAGS of 238 active DW products.
+Whole-word, case-preserving: wallpaper->wallcovering, wallpapers->wallcoverings.
+PG-first mirror to dw_unified.shopify_products, then Shopify tagsAdd/tagsRemove.
+ACTIVE products only. Tag/vendor edits create NO variants.
+"""
+import json, re, sys, time, urllib.request, subprocess
+
+TOKEN = subprocess.check_output(
+ "grep -E '^SHOPIFY_ADMIN_TOKEN=' ~/Projects/secrets-manager/.env | cut -d= -f2",
+ shell=True, text=True).strip()
+DOMAIN = "designer-laboratory-sandbox.myshopify.com"
+VER = "2024-10"
+URL = f"https://{DOMAIN}/admin/api/{VER}/graphql.json"
+
+# whole-word, case-preserving replacement
+def fix_tag(t):
+ def f(m):
+ s = m.group(0)
+ rep = 'wallcoverings' if s.lower().endswith('s') else 'wallcovering'
+ if s[0].isupper():
+ rep = rep.capitalize()
+ return rep
+ return re.sub(r'[Ww]allpapers?', f, t)
+
+def gql(query, variables=None):
+ body = {"query": query}
+ if variables is not None:
+ body["variables"] = variables
+ req = urllib.request.Request(URL, data=json.dumps(body).encode(),
+ headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
+ for attempt in range(6):
+ try:
+ d = json.load(urllib.request.urlopen(req, timeout=60))
+ # throttle backoff
+ cost = d.get("extensions", {}).get("cost", {}).get("throttleStatus", {})
+ if cost.get("currentlyAvailable", 1000) < 200:
+ time.sleep(2)
+ return d
+ except urllib.error.HTTPError as e:
+ if e.code == 429:
+ time.sleep(3 * (attempt + 1)); continue
+ time.sleep(2 * (attempt + 1))
+ except Exception:
+ time.sleep(2 * (attempt + 1))
+ raise RuntimeError("gql failed")
+
+TAGS_ADD = """mutation($id:ID!,$tags:[String!]!){ tagsAdd(id:$id, tags:$tags){ userErrors{field message} } }"""
+TAGS_REMOVE = """mutation($id:ID!,$tags:[String!]!){ tagsRemove(id:$id, tags:$tags){ userErrors{field message} } }"""
+
+def fetch_product(handle):
+ q = '{ products(first:1, query:"handle:%s") { edges { node { id handle status tags } } } }' % handle
+ edges = gql(q)["data"]["products"]["edges"]
+ return edges[0]["node"] if edges else None
+
+def gid_to_numeric(gid):
+ return gid.rsplit("/", 1)[-1]
+
+def mirror_pg(shopify_numeric_id, new_tags_list):
+ new_tags_str = ", ".join(new_tags_list)
+ # match either bare numeric id or gid form stored in shopify_id
+ sql = """UPDATE shopify_products SET tags=%s
+ WHERE shopify_id=%s OR shopify_id=%s;"""
+ gid = f"gid://shopify/Product/{shopify_numeric_id}"
+ subprocess.run(
+ ["psql", "-d", "dw_unified", "-v", "ON_ERROR_STOP=1", "-c",
+ "UPDATE shopify_products SET tags = $$%s$$ WHERE shopify_id=$$%s$$ OR shopify_id=$$%s$$;"
+ % (new_tags_str.replace("$$",""), str(shopify_numeric_id), gid)],
+ check=True, capture_output=True, text=True)
+
+def process(handles, apply=False, gap=0.6):
+ results = []
+ for i, h in enumerate(handles):
+ node = fetch_product(h)
+ if not node:
+ results.append({"handle": h, "status": "NOT_FOUND"}); continue
+ if node["status"] != "ACTIVE":
+ results.append({"handle": h, "status": "SKIP_NOT_ACTIVE"}); continue
+ old_tags = node["tags"]
+ banned = [t for t in old_tags if re.search(r'[Ww]allpapers?', t)]
+ if not banned:
+ results.append({"handle": h, "status": "NO_BANNED"}); continue
+ new_for_banned = [fix_tag(t) for t in banned]
+ # final tag set: remove banned, add fixed (dedup, preserve order)
+ final = [t for t in old_tags if t not in banned]
+ for nt in new_for_banned:
+ if nt not in final:
+ final.append(nt)
+ rec = {"handle": h, "id": node["id"], "removed": banned,
+ "added": new_for_banned, "final": final, "status": "PLANNED"}
+ if apply:
+ num = gid_to_numeric(node["id"])
+ # PG FIRST
+ mirror_pg(num, final)
+ # add fixed tags
+ ra = gql(TAGS_ADD, {"id": node["id"], "tags": new_for_banned})
+ ea = ra["data"]["tagsAdd"]["userErrors"]
+ # remove banned tags
+ rr = gql(TAGS_REMOVE, {"id": node["id"], "tags": banned})
+ er = rr["data"]["tagsRemove"]["userErrors"]
+ rec["status"] = "APPLIED" if not ea and not er else "ERROR"
+ if ea or er:
+ rec["errors"] = {"add": ea, "remove": er}
+ time.sleep(gap)
+ results.append(rec)
+ if (i + 1) % 25 == 0:
+ sys.stderr.write(f" ...{i+1}/{len(handles)}\n")
+ return results
+
+if __name__ == "__main__":
+ mode = sys.argv[1] if len(sys.argv) > 1 else "plan"
+ src = json.load(open("/tmp/wp_tag_hits.json"))
+ handles = [r["handle"] for r in src]
+ if mode == "canary":
+ n = int(sys.argv[2]) if len(sys.argv) > 2 else 3
+ res = process(handles[:n], apply=True)
+ elif mode == "apply":
+ res = process(handles, apply=True)
+ elif mode == "apply-rest":
+ skip = int(sys.argv[2])
+ res = process(handles[skip:], apply=True)
+ else:
+ res = process(handles, apply=False)
+ out = f"/tmp/job1_{mode}.json"
+ json.dump(res, open(out, "w"), indent=2)
+ from collections import Counter
+ print("STATUS:", dict(Counter(r["status"] for r in res)))
+ print("AUDIT:", out)
(oldest)
·
back to Dw Wallpaper Cleanup
·
Job 1 LANDED: 238 active products wallpaper->wallcovering in 3c2f686 →