← back to Eur Recrawl

archive_disco.py

76 lines

#!/usr/bin/env python3
"""
Archive vendor-DISCONTINUED EUR- products — GATED (Steve runs).

Sets status ACTIVE -> ARCHIVED for the 188 held EUR- products whose mfr pattern
appears on the vendor DISCONTINUED lists (DiscontinuedList.pdf + the DG
discontinued list). These are already parked (no current trade price) AND
officially discontinued by the vendor. ARCHIVED hides them from the storefront
but preserves them (reversible; not deleted).

Scope note: matched at pattern (base-code) level — a product is archived if it's
parked (unpriceable) AND its base pattern is on a discontinued list. Not the
LIMITED-stock list (those stay orderable). Reads archive_list.json.

SAFETY: DRY_RUN=1 default. Smoke: DRY_RUN=0 LIMIT=1 python3 archive_disco.py
"""
import os, json, time, urllib.request

HERE = os.path.dirname(os.path.abspath(__file__))
DRY_RUN = os.environ.get("DRY_RUN", "1") != "0"
LIMIT = int(os.environ.get("LIMIT", "0")) or None

def _tok():
    for l in open(os.path.expanduser("~/Projects/secrets-manager/.env")):
        if l.startswith("SHOPIFY_ADMIN_TOKEN="): return l.split("=", 1)[1].strip().strip('"')
    raise SystemExit("token")
TOKEN = os.environ.get("AT") or _tok()
URL = "https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json"

def gql(q, v=None):
    b = json.dumps({"query": q, "variables": v or {}}).encode()
    req = urllib.request.Request(URL, b, {"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
    for a in range(6):
        try:
            d = json.load(urllib.request.urlopen(req, timeout=60))
            if "errors" in d and any("THROTTLED" in str(e) for e in d["errors"]):
                time.sleep(2 * (a + 1)); continue
            return d
        except Exception:
            time.sleep(2 * (a + 1))
    raise RuntimeError("gql failed")

FIND = 'query($q:String!){products(first:1,query:$q){edges{node{id status title}}}}'
UPD = 'mutation($id:ID!){productUpdate(input:{id:$id,status:ARCHIVED}){product{status} userErrors{message}}}'

def main():
    items = json.load(open(os.path.join(HERE, "archive_list.json")))
    if LIMIT: items = items[:LIMIT]
    done = skip = 0; errs = []
    print(f"{'DRY-RUN' if DRY_RUN else 'LIVE'}: archiving {len(items)} vendor-discontinued products")
    for i, it in enumerate(items):
        sku = it["sku"]
        d = gql(FIND, {"q": "sku:" + sku})
        e = (d.get("data") or {}).get("products", {}).get("edges", [])
        if not e:
            errs.append({"sku": sku, "err": "not found"}); continue
        node = e[0]["node"]
        if node["status"] == "ARCHIVED":
            skip += 1; continue
        if DRY_RUN:
            if i < 6: print(f"  would archive {sku} [{it['vendor']}] {node['title'][:34]}")
            done += 1; continue
        r = gql(UPD, {"id": node["id"]})
        ue = ((r.get("data") or {}).get("productUpdate") or {}).get("userErrors") or []
        if ue: errs.append({"sku": sku, "err": ue[:2]})
        else: done += 1
        if i % 25 == 0: print(f"  ...{i}/{len(items)} archived={done} err={len(errs)}")
        time.sleep(0.3)
    json.dump({"archived": done, "skipped": skip, "errors": errs[:25]},
              open(os.path.join(HERE, "archive-results.json"), "w"), indent=2)
    print(f"\nDONE {'(DRY-RUN)' if DRY_RUN else ''}: archived={done} skip={skip} err={len(errs)}")
    if errs: print("errs:", json.dumps(errs[:3]))

if __name__ == "__main__":
    main()