[object Object]

← 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

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 →