← back to Designer Wallcoverings
dedup v3: ID-based, 5xx-retry, resumable
24ea6876eae9f1be493e36919ee9cbe8058265b1 · 2026-07-07 13:27:57 -0700 · Steve
Files touched
M scripts/dedup-collections.py
Diff
commit 24ea6876eae9f1be493e36919ee9cbe8058265b1
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jul 7 13:27:57 2026 -0700
dedup v3: ID-based, 5xx-retry, resumable
---
scripts/dedup-collections.py | 98 ++++++++++++++++++--------------------------
1 file changed, 41 insertions(+), 57 deletions(-)
diff --git a/scripts/dedup-collections.py b/scripts/dedup-collections.py
index 3416021a..ee20192f 100644
--- a/scripts/dedup-collections.py
+++ b/scripts/dedup-collections.py
@@ -1,14 +1,13 @@
#!/usr/bin/env python3
"""
-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.
+Dedup DW collections — v3, ID-based + resumable (Steve-approved no-redirect, 2026-07-07).
+Fixes v2 crash: retries on 5xx, and operates on COLLECTION IDs (never handles) so a
+resume can't mistake a renamed keeper for a deletion target. Reads each keeper's CURRENT
+handle at runtime. Fully idempotent — safe to re-run until clean.
-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.
+Per group: delete the redundant collection(s) by id; then the surviving keeper adopts the
+SHORTEST clean (non-ugly) handle available (its own or a just-freed one) so the big
+collection keeps the most-canonical URL. Then 5 type-split/title-bug retitles.
Usage: python3 dedup-collections.py [--apply]
"""
@@ -16,70 +15,55 @@ 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):
- for a in range(6):
+ for a in range(8):
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))
+ try: return json.load(urllib.request.urlopen(req))
except urllib.error.HTTPError as e:
- if e.code==429: time.sleep(2*(a+1)); continue
+ if e.code in (429,500,502,503,504): time.sleep(2*(a+1)); continue
raise
- 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",[]))
+ raise RuntimeError("gql failed after retries")
+NODE='query($id:ID!){ node(id:$id){ ... on Collection { id handle } } }'
+def node(cid):
+ d=gql(NODE,{"id":cid}); return (d.get("data") or {}).get("node")
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"))
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
+UPD="mutation($id:ID!,$h:String!){ collectionUpdate(input:{id:$id,handle:$h}){ collection{handle} userErrors{message} } }"
+RET="mutation($id:ID!,$t:String!){ collectionUpdate(input:{id:$id,title:$t}){ collection{title} userErrors{message} } }"
+dok=derr=skip=ren=rerr=0
for title,keep_id,keep_handle,dlist in plan["plan"]:
- clean_freed=[]
+ freed_clean=[]
for del_id,del_handle in dlist:
- 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 node(del_id) is None: skip+=1; continue # already deleted
+ if not APPLY: print(f" DELETE {del_handle} [{title}]"); dok+=1
+ else:
+ r=gql(DEL,{"id":del_id}); ue=(r.get("data",{}).get("collectionDelete") or {}).get("userErrors") if r.get("data") else r.get("errors")
+ if ue: derr+=1; print(f" DEL ERR {del_handle}: {ue}"); continue
+ dok+=1; time.sleep(0.25)
+ if not ugly(del_handle): freed_clean.append(del_handle)
+ k=node(keep_id)
+ if not k: print(f" !! keeper missing [{title}]"); continue
+ kh=k["handle"]
+ cands=list(freed_clean)+([kh] if not ugly(kh) 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
+ target=sorted(cands,key=len)[0]
+ if target!=kh:
+ if not APPLY: print(f" RENAME {kh} -> {target} [{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)
-
-# retitles (type-split / title-bug)
+ r=gql(UPD,{"id":keep_id,"h":target}); ue=(r.get("data",{}).get("collectionUpdate") or {}).get("userErrors") if r.get("data") else r.get("errors")
+ if ue: rerr+=1; print(f" RENAME ERR {kh}->{target}: {ue}")
+ else: ren+=1; print(f" renamed keeper -> {target} [{title}]"); time.sleep(0.25)
+# retitles
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}"})
- 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
+ q=gql(BYH,{"q":f"handle:{handle}"}); n=next((e["node"] for e in (q.get("data",{}).get("collections",{}) or {}).get("edges",[]) if e["node"]["handle"]==handle),None)
+ if not n: 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")
+ r=gql(RET,{"id":n["id"],"t":newt}); ue=(r.get("data",{}).get("collectionUpdate") or {}).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.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}")
+ else: tok+=1; time.sleep(0.25)
+print(f"\n{'DRY RUN' if not APPLY else 'DONE'}. deleted={dok} skipped={skip} renames={ren} retitles={tok} | errs del={derr} ren={rerr} ret={terr}")
← 5579a5c4 dedup v2: no-redirect mode, keeper adopts shortest-clean han
·
back to Designer Wallcoverings
·
auto-save: 2026-07-07T13:30:45 (15 files) — pending-approval 5cc0714b →