← back to Designer Wallcoverings
dedup v2: no-redirect mode, keeper adopts shortest-clean handle
5579a5c4d09fcf3e4866382e74c56c4aedc8f637 · 2026-07-07 13:22:00 -0700 · Steve
Files touched
M scripts/dedup-collections.py
Diff
commit 5579a5c4d09fcf3e4866382e74c56c4aedc8f637
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jul 7 13:22:00 2026 -0700
dedup v2: no-redirect mode, keeper adopts shortest-clean handle
---
scripts/dedup-collections.py | 114 +++++++++++++++++++++----------------------
1 file changed, 56 insertions(+), 58 deletions(-)
diff --git a/scripts/dedup-collections.py b/scripts/dedup-collections.py
index 2ee8d047..3416021a 100644
--- a/scripts/dedup-collections.py
+++ b/scripts/dedup-collections.py
@@ -1,18 +1,18 @@
#!/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.
+Dedup DW collections — v2, NO-REDIRECT mode (Steve-approved 2026-07-07).
+Token lacks write_online_store_navigation, so 301s aren't possible. To minimize
+dead-link harm without them: keep the most-populated collection per title; delete
+the redundant one(s); and when the keeper's handle is "ugly" (-1/-copy/™) while a
+deleted sibling had a CLEAN handle, RENAME the keeper onto that freed clean handle
+so the canonical URL keeps resolving and only the ugly URL 404s.
-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).
+Idempotent/resumable: a delete of an already-gone handle is skipped.
+Backup of all deletions already written by v1 run to backups/deleted-collections-20260707.json.
-Usage: python3 dedup-collections.py [--apply] (default = dry run)
+Usage: python3 dedup-collections.py [--apply]
"""
-import os, sys, json, time, urllib.request, urllib.error
-
+import os, sys, re, 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):
@@ -24,64 +24,62 @@ def gql(q,v=None):
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
+ if d.get("errors") and any("THROTTLED" in json.dumps(x) for x in d.get("errors") or []): time.sleep(2*(a+1)); continue
return d
raise RuntimeError("throttled")
+def exists(handle):
+ d=gql('query($q:String!){ collections(first:1, query:$q){ edges{ node{ id handle } } } }',{"q":f"handle:{handle}"})
+ return any(e["node"]["handle"]==handle for e in d.get("data",{}).get("collections",{}).get("edges",[]))
+def ugly(h): return bool(re.search(r'-\d+$',h)) or '-copy' in h or '™' in h
plan=json.load(open("/tmp/dedup-plan.json"))
-# collect deletions: (del_id, del_handle, keep_handle)
-dels=[]
+DEL="mutation($id:ID!){ collectionDelete(input:{id:$id}){ deletedCollectionId userErrors{message} } }"
+UPD="mutation($id:ID!,$h:String!){ collectionUpdate(input:{id:$id,handle:$h}){ collection{id handle} userErrors{message} } }"
+RET="mutation($id:ID!,$t:String!){ collectionUpdate(input:{id:$id,title:$t}){ collection{id title} userErrors{message} } }"
+
+dok=derr=ren=rerr=skip=0
for title,keep_id,keep_handle,dlist in plan["plan"]:
+ clean_freed=[]
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}")
+ if not exists(del_handle):
+ skip+=1; continue
+ if not APPLY:
+ print(f" DELETE {del_handle} [{title}]"); dok+=1
+ if not ugly(del_handle): clean_freed.append(del_handle)
+ continue
+ r=gql(DEL,{"id":del_id})
+ ue=r.get("data",{}).get("collectionDelete",{}).get("userErrors") if r.get("data") else r.get("errors")
+ if ue: derr+=1; print(f" DEL ERR {del_handle}: {ue}"); continue
+ dok+=1
+ if not ugly(del_handle): clean_freed.append(del_handle)
+ time.sleep(0.25)
+ # keeper adopts the SHORTEST clean (non-ugly) handle in the group (root-preference),
+ # so the surviving big collection keeps the most-canonical URL; only ugly/longer URLs 404.
+ cands=list(clean_freed)+([keep_handle] if not ugly(keep_handle) else [])
+ if cands:
+ newh=sorted(cands,key=len)[0]
+ if newh!=keep_handle:
+ if not APPLY:
+ print(f" RENAME keeper {keep_handle} -> {newh} [{title}]"); ren+=1
+ else:
+ r=gql(UPD,{"id":keep_id,"h":newh})
+ ue=r.get("data",{}).get("collectionUpdate",{}).get("userErrors") if r.get("data") else r.get("errors")
+ if ue: rerr+=1; print(f" RENAME ERR {keep_handle}->{newh}: {ue}")
+ else: ren+=1; print(f" renamed keeper -> {newh} [{title}]")
+ time.sleep(0.25)
-# 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 } } } }"""
+# retitles (type-split / title-bug)
tok=terr=0
+BYH='query($q:String!){ collections(first:1, query:$q){ edges{ node{ id handle } } } }'
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})
+ node=next((e["node"] for e in q.get("data",{}).get("collections",{}).get("edges",[]) if e["node"]["handle"]==handle),None)
+ if not node: terr+=1; print(f" RETITLE not found: {handle}"); continue
+ if not APPLY: print(f" RETITLE {handle} -> {newt!r}"); tok+=1; continue
+ r=gql(RET,{"id":node["id"],"t":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})")
+ time.sleep(0.25)
+
+print(f"\n{'DRY RUN' if not APPLY else 'DONE'}. deleted={dok} skipped(already gone)={skip} keeper-renames={ren} retitles={tok} | errs del={derr} ren={rerr} ret={terr}")
← 3e87194c add collection dedup script (backup+delete+301 redirect+reti
·
back to Designer Wallcoverings
·
dedup v3: ID-based, 5xx-retry, resumable 24ea6876 →