← back to Fentucci Naturals

scripts/create-drafts.py

92 lines

#!/usr/bin/env python3
"""GATED importer — creates DRAFT products on the LIVE DW Shopify store from
data/shopify-drafts.jsonl. DO NOT RUN without Steve's approval of
~/.claude/yolo-queue/pending-approval/fentucci-tokiwa-shopify-import.md.

Resumable: appends to data/created.jsonl and skips SKUs already there.
Day-cap aware: --limit N products per run (504 products x 2 variants = 1,008
variants total → run as --limit 490 day 1, remainder day 2 to respect the
1k variants/day cap). 90s pause every BATCH products per bulk-push rule.

Usage:  source ~/Projects/secrets-manager/.env
        python3 scripts/create-drafts.py --limit 490
"""
import argparse, base64, json, os, sys, time, urllib.request

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
DRAFTS = os.path.join(ROOT, "data", "shopify-drafts.jsonl")
CREATED = os.path.join(ROOT, "data", "created.jsonl")
SHOP = "designer-laboratory-sandbox.myshopify.com"
API = "2024-10"
BATCH = 25          # products between 90s pauses
PAUSE = 90

TOKEN = os.environ.get("SHOPIFY_ADMIN_TOKEN")
if not TOKEN:
    sys.exit("SHOPIFY_ADMIN_TOKEN not set — source ~/Projects/secrets-manager/.env")

def api(path, method="GET", body=None):
    req = urllib.request.Request(
        f"https://{SHOP}/admin/api/{API}/{path}", method=method,
        data=json.dumps(body).encode() if body else None,
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
    for attempt in range(5):
        try:
            with urllib.request.urlopen(req, timeout=60) as r:
                return json.loads(r.read() or "{}")
        except urllib.error.HTTPError as e:
            if e.code == 429:
                time.sleep(4 * (attempt + 1)); continue
            raise
    raise RuntimeError(f"429 storm on {path}")

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--limit", type=int, default=490)
    args = ap.parse_args()

    done = set()
    if os.path.exists(CREATED):
        done = {json.loads(l)["sku"] for l in open(CREATED)}

    rows = [json.loads(l) for l in open(DRAFTS)]
    todo = [r for r in rows if r["sku"] not in done][: args.limit]
    print(f"{len(done)} already created, importing {len(todo)} this run")

    with open(CREATED, "a") as log:
        for i, r in enumerate(todo):
            # leak tripwire — belt & suspenders
            assert "tokiwa" not in json.dumps(r["product"]).lower(), r["sku"]
            assert r["product"]["status"] == "draft", r["sku"]
            img = base64.b64encode(open(r["local_image_path"], "rb").read()).decode()
            payload = dict(r["product"])
            # HARD GATE: this importer creates DRAFTS ONLY, forever. It must
            # never set status ACTIVE — activation goes through the gated
            # rotation activator (image + width metafield + 5-field checks).
            payload["status"] = "draft"
            assert payload["status"] == "draft" and payload["status"] != "active", \
                f"REFUSED: create-drafts.py can never create an ACTIVE product ({r['sku']})"
            payload["images"] = [{"attachment": img, "filename": os.path.basename(r["local_image_path"])}]
            resp = api("products.json", "POST", {"product": payload})
            pid = resp["product"]["id"]
            # record the create BEFORE metafields — a metafield failure must
            # never orphan a created product outside created.jsonl (dup risk;
            # bit us on run 1: custom.material type 422 killed the run after
            # the product landed, leaving it untracked)
            log.write(json.dumps({"sku": r["sku"], "product_id": pid}) + "\n"); log.flush()
            for mf in r["metafields"]:
                try:
                    api(f"products/{pid}/metafields.json", "POST", {"metafield": mf})
                except urllib.error.HTTPError as e:
                    print(f"  METAFIELD FAIL {r['sku']} {mf['namespace']}.{mf['key']} "
                          f"HTTP {e.code}: {e.read().decode()[:200]}", flush=True)
            print(f"  [{i+1}/{len(todo)}] {r['sku']} -> {pid}", flush=True)
            time.sleep(1)
            if (i + 1) % BATCH == 0 and i + 1 < len(todo):
                print(f"  batch pause {PAUSE}s"); time.sleep(PAUSE)
    print("run complete — remember: PG mirror re-syncs from Shopify; keep drafts until activation gate passes")

if __name__ == "__main__":
    main()