← 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()