← back to Secrets Manager
_shopify_channel_fill.py
114 lines
#!/usr/bin/env python3
"""Publish active products to all reachable sales-channel publications.
Excludes peel-and-stick lines (Surface Stick, PS Removable Wallpaper) per Steve.
Modes: --test N (dry-ish: publish only N products, verbose)
--run (full run, cursor pagination, log progress)
"""
import os, sys, json, time, urllib.request
STORE = "designer-laboratory-sandbox"
API = f"https://{STORE}.myshopify.com/admin/api/2024-10/graphql.json"
EXCLUDE_VENDORS = {"Surface Stick", "PS Removable Wallpaper"}
def token():
with open(os.path.expanduser("~/Projects/secrets-manager/.env")) as f:
for line in f:
if line.startswith("SHOPIFY_ADMIN_TOKEN="):
return line.split("=",1)[1].strip().strip('"').strip("'")
raise SystemExit("no token")
TOKEN = token()
def gql(query, variables=None):
body = json.dumps({"query": query, "variables": variables or {}}).encode()
req = urllib.request.Request(API, data=body, headers={
"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
for attempt in range(6):
try:
with urllib.request.urlopen(req, timeout=60) as r:
d = json.loads(r.read())
if "errors" in d and any("throttled" in json.dumps(e).lower() for e in d.get("errors",[])):
time.sleep(2 + attempt*2); continue
return d
except Exception as e:
time.sleep(2 + attempt*2)
return {"errors":"exhausted"}
def get_publications():
d = gql("{ publications(first:50){ edges{ node{ id name } } } }")
return [(e["node"]["id"], e["node"]["name"]) for e in d["data"]["publications"]["edges"]]
PUBS = get_publications()
PUB_IDS = [p[0] for p in PUBS]
PAGE_Q = """
query($cursor:String){
products(first:60, after:$cursor, query:"status:active"){
pageInfo{ hasNextPage endCursor }
edges{ node{ id vendor
resourcePublications(first:30){ edges{ node{ publication{ id } } } }
} }
}
}"""
PUBLISH_M = """
mutation($id:ID!, $pubs:[PublicationInput!]!){
publishablePublish(id:$id, input:$pubs){ userErrors{ field message } }
}"""
def missing_pubs(node):
on = {e["node"]["publication"]["id"] for e in node["resourcePublications"]["edges"]}
return [pid for pid in PUB_IDS if pid not in on]
def run(limit=None, test=False):
print(f"Publications reachable: {len(PUBS)}")
for pid,name in PUBS: print(f" - {name}")
print(f"Excluding vendors: {EXCLUDE_VENDORS}\n")
cursor=None; seen=0; skipped_ps=0; already=0; published=0; errs=0; pages=0
t0=time.time()
while True:
d = gql(PAGE_Q, {"cursor": cursor})
try:
conn = d["data"]["products"]
except Exception:
print("PAGE ERROR:", json.dumps(d)[:300]); break
pages+=1
for e in conn["edges"]:
n=e["node"]; seen+=1
if n["vendor"] in EXCLUDE_VENDORS:
skipped_ps+=1; continue
miss = missing_pubs(n)
if not miss:
already+=1; continue
res = gql(PUBLISH_M, {"id": n["id"], "pubs":[{"publicationId":p} for p in miss]})
ue = res.get("data",{}).get("publishablePublish",{}).get("userErrors") if res.get("data") else None
if ue:
errs+=1
if errs<=10: print(f" userErr {n['id']}: {ue[:1]}")
else:
published+=1
if test: print(f" published {n['id']} (+{len(miss)} chans)")
if limit and (published+already+skipped_ps)>=limit and test:
print("\nTEST LIMIT reached"); _summary(seen,skipped_ps,already,published,errs,pages,t0); return
time.sleep(0.08)
if seen % 600 == 0 or test:
print(f"[{seen} seen | pub {published} | already {already} | ps-skip {skipped_ps} | err {errs} | {int(time.time()-t0)}s]")
if not conn["pageInfo"]["hasNextPage"]:
break
cursor = conn["pageInfo"]["endCursor"]
if limit and test and seen>=limit: break
_summary(seen,skipped_ps,already,published,errs,pages,t0)
def _summary(seen,ps,already,pub,errs,pages,t0):
print(f"\nSUMMARY seen={seen} ps_skipped={ps} already_full={already} published={pub} errors={errs} pages={pages} secs={int(time.time()-t0)}")
print("DONE")
if __name__=="__main__":
if "--test" in sys.argv:
i=sys.argv.index("--test"); N=int(sys.argv[i+1]) if len(sys.argv)>i+1 else 5
run(limit=N, test=True)
elif "--run" in sys.argv:
run()
else:
print("usage: --test N | --run")