shopify.py
3.7 kB · python · 128 lines
1import json2import urllib.error3import urllib.parse4import urllib.request56from env import gate, load_env, need, save_env, say, verb78load_env()910# API1112API_VERSION = "2026-07"13SHOP = need("SHOPIFY_SHOP_URL")14ADMIN_URL = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"15AUTH_URL = f"https://{SHOP}/admin/oauth/access_token"16ONLINE_STORE = "Online Store"17HEADLESS = "Headless"1819def post(url, body, headers):20 request = urllib.request.Request(url, data=body, headers=headers, method="POST")21 try:22 with urllib.request.urlopen(request, timeout=30) as response:23 return json.loads(response.read())24 except urllib.error.HTTPError as error:25 raise SystemExit(f"admin api {error.code}")2627def token():28 body = urllib.parse.urlencode({29 "client_id": need("SHOPIFY_CLIENT_ID"),30 "client_secret": need("SHOPIFY_SECRET"),31 "grant_type": "client_credentials",32 }).encode()33 headers = {"Content-Type": "application/x-www-form-urlencoded"}34 data = post(AUTH_URL, body, headers)35 if "access_token" not in data:36 raise SystemExit("admin token refused")37 return data["access_token"]3839def graphql(access, query, variables=None):40 body = json.dumps({"query": query, "variables": variables or {}}).encode()41 headers = {"Content-Type": "application/json", "X-Shopify-Access-Token": access}42 data = post(ADMIN_URL, body, headers)43 if "errors" in data:44 raise SystemExit(f"graphql: {json.dumps(data['errors'])[:300]}")45 return data["data"]4647# QUERIES4849PUBLICATIONS = """50query {51 publications(first: 50) {52 nodes { id name }53 }54}55"""5657PRODUCTS = """58query($cursor: String) {59 products(first: 250, after: $cursor) {60 pageInfo { hasNextPage endCursor }61 nodes { id title }62 }63}64"""6566DELETE = """67mutation($input: ProductDeleteInput!) {68 productDelete(input: $input) {69 deletedProductId70 userErrors { field message }71 }72}73"""7475# PUBLICATIONS7677def publications():78 if not gate("publications", [79 f"admin graphql {API_VERSION} on the shop behind SHOPIFY_SHOP_URL",80 f"list every publication, find {ONLINE_STORE} and {HEADLESS}",81 "save SHOPIFY_ONLINE_STORE_ID and SHOPIFY_HEADLESS_ID",82 ]): return83 nodes = graphql(token(), PUBLICATIONS)["publications"]["nodes"]84 store = ""85 headless = ""86 for node in nodes:87 say(f" {node['name']}")88 if node["name"] == ONLINE_STORE: store = node["id"]89 if HEADLESS in node["name"]: headless = node["id"]90 if not store:91 raise SystemExit(f"refuse: no {ONLINE_STORE} publication on this shop")92 if not headless:93 raise SystemExit(f"refuse: no {HEADLESS} publication on this shop")94 save_env("SHOPIFY_ONLINE_STORE_ID", store)95 save_env("SHOPIFY_HEADLESS_ID", headless)9697# PURGE9899def purge():100 access = token()101 ids = []102 cursor = None103 while True:104 page = graphql(access, PRODUCTS, {"cursor": cursor})["products"]105 ids += [node["id"] for node in page["nodes"]]106 if not page["pageInfo"]["hasNextPage"]: break107 cursor = page["pageInfo"]["endCursor"]108 if not gate("purge", [109 f"delete {len(ids)} products from the shop behind SHOPIFY_SHOP_URL",110 "productDelete, one call each, no undo",111 ]): return112 for count, one in enumerate(ids, 1):113 result = graphql(access, DELETE, {"input": {"id": one}})["productDelete"]114 if result["userErrors"]:115 say(f" {one} {result['userErrors']}")116 continue117 say(f" deleted {count}/{len(ids)}")118 say(f"{len(ids)} products deleted")119120# MAIN121122VERBS = {123 "publications": publications,124 "purge": purge,125}126127if __name__ == "__main__":128 VERBS[verb(list(VERBS))]()