[object Object]

← back to Designer Wallcoverings

add collection dedup script (backup+delete+301 redirect+retitle)

3e87194c99cca2dae85a9af9ecb7c3b4c17261c0 · 2026-07-07 13:15:44 -0700 · Steve

Files touched

Diff

commit 3e87194c99cca2dae85a9af9ecb7c3b4c17261c0
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 7 13:15:44 2026 -0700

    add collection dedup script (backup+delete+301 redirect+retitle)
---
 scripts/dedup-collections.py | 87 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 87 insertions(+)

diff --git a/scripts/dedup-collections.py b/scripts/dedup-collections.py
new file mode 100644
index 00000000..2ee8d047
--- /dev/null
+++ b/scripts/dedup-collections.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+"""
+Dedup DW collections (Steve-approved "Full" 2026-07-07).
+Keep the most-populated collection per duplicate title; delete the redundant ones;
+301-redirect each removed handle -> the keeper so no URL 404s. Plus 5 non-destructive
+retitles for the type-split / title-bug collections.
+
+SAFETY: backs up every deleted collection's full definition (title/handle/rules/etc)
+to backups/deleted-collections-<ts>.json BEFORE deleting, so any delete is recreatable.
+Order per item: backup -> collectionDelete -> urlRedirectCreate (path free only after delete).
+
+Usage:  python3 dedup-collections.py [--apply]   (default = dry run)
+"""
+import os, sys, json, time, urllib.request, urllib.error
+
+STORE=os.environ["SHOPIFY_STORE_DOMAIN"]; TOK=os.environ["SHOPIFY_ADMIN_TOKEN"]; VER="2024-10"
+APPLY="--apply" in sys.argv
+def gql(q,v=None):
+    for a in range(6):
+        req=urllib.request.Request(f"https://{STORE}/admin/api/{VER}/graphql.json",
+            data=json.dumps({"query":q,"variables":v or {}}).encode(),
+            headers={"X-Shopify-Access-Token":TOK,"Content-Type":"application/json"})
+        try: d=json.load(urllib.request.urlopen(req))
+        except urllib.error.HTTPError as e:
+            if e.code==429: time.sleep(2*(a+1)); continue
+            raise
+        if d.get("errors") and any("THROTTLED" in json.dumps(x) for x in d["errors"]): time.sleep(2*(a+1)); continue
+        return d
+    raise RuntimeError("throttled")
+
+plan=json.load(open("/tmp/dedup-plan.json"))
+# collect deletions: (del_id, del_handle, keep_handle)
+dels=[]
+for title,keep_id,keep_handle,dlist in plan["plan"]:
+    for del_id,del_handle in dlist:
+        dels.append((title,del_id,del_handle,keep_handle))
+print(f"{len(dels)} deletions planned; {len(plan['retitles'])} retitles.")
+if not APPLY:
+    for t,di,dh,kh in dels: print(f"  DELETE {dh}  -> 301 {dh} to {kh}   [{t}]")
+    print("DRY RUN."); sys.exit(0)
+
+# 1) BACKUP full defs of all deletions
+FULL="""query($id:ID!){ collection(id:$id){ id title handle descriptionHtml sortOrder templateSuffix
+  ruleSet{ appliedDisjunctively rules{ column relation condition } } } }"""
+backup=[]
+for t,di,dh,kh in dels:
+    d=gql(FULL,{"id":di}); backup.append(d.get("data",{}).get("collection"))
+import subprocess
+os.makedirs("backups",exist_ok=True)
+bpath=f"backups/deleted-collections-20260707.json"
+json.dump({"deleted":backup,"plan":plan}, open(bpath,"w"), indent=2)
+print(f"backed up {len(backup)} defs -> {bpath}")
+
+# 2) delete + redirect
+DEL="mutation($id:ID!){ collectionDelete(input:{id:$id}){ deletedCollectionId userErrors{message} } }"
+RED="""mutation($r:UrlRedirectInput!){ urlRedirectCreate(urlRedirect:$r){ urlRedirect{id} userErrors{message} } }"""
+dok=derr=rok=rerr=0
+for t,di,dh,kh in dels:
+    r=gql(DEL,{"id":di})
+    ue=r.get("data",{}).get("collectionDelete",{}).get("userErrors") if r.get("data") else r.get("errors")
+    if ue: derr+=1; print(f"  DEL ERR {dh}: {ue}"); continue
+    dok+=1
+    rr=gql(RED,{"r":{"path":f"/collections/{dh}","target":f"/collections/{kh}"}})
+    rue=rr.get("data",{}).get("urlRedirectCreate",{}).get("userErrors") if rr.get("data") else rr.get("errors")
+    if rue: rerr+=1; print(f"  REDIR ERR {dh}->{kh}: {rue}")
+    else: rok+=1
+    time.sleep(0.3)
+print(f"deletes ok={dok} err={derr} | redirects ok={rok} err={rerr}")
+
+# 3) retitles
+UPD="mutation($input:CollectionInput!){ collectionUpdate(input:{id:$id,title:$title}){ userErrors{message} } }"
+UPD="""mutation($id:ID!,$title:String!){ collectionUpdate(input:{id:$id,title:$title}){ collection{id title} userErrors{message} } }"""
+# resolve retitle ids by handle
+BYH="""query($q:String!){ collections(first:1, query:$q){ edges{ node{ id handle title } } } }"""
+tok=terr=0
+for desc,handle,newt in plan["retitles"]:
+    q=gql(BYH,{"q":f"handle:{handle}"})
+    edges=q.get("data",{}).get("collections",{}).get("edges",[])
+    node=next((e["node"] for e in edges if e["node"]["handle"]==handle), None)
+    if not node: terr+=1; print(f"  RETITLE skip (not found): {handle}"); continue
+    r=gql(UPD,{"id":node["id"],"title":newt})
+    ue=r.get("data",{}).get("collectionUpdate",{}).get("userErrors") if r.get("data") else r.get("errors")
+    if ue: terr+=1; print(f"  RETITLE ERR {handle}: {ue}")
+    else: tok+=1
+    time.sleep(0.3)
+print(f"retitles ok={tok} err={terr}")
+print(f"\nDONE. deleted={dok} redirected={rok} retitled={tok} (errs: del={derr} red={rerr} ret={terr})")

← 904dc48f add color/motif Wallcovering-collection rename script (DTD v  ·  back to Designer Wallcoverings  ·  dedup v2: no-redirect mode, keeper adopts shortest-clean han 5579a5c4 →