← back to Eur Recrawl
write_reonboard.py
102 lines
#!/usr/bin/env python3
"""
EUR- reonboard live write — GATED (Steve runs). Prices each priceable product's
sellable roll to retail = trade x1.810 (from the authed price list).
Per product (looked up by roll_sku):
* if a roll variant (sku == roll_sku) already exists -> UPDATE its price
(this fixes the live BELOW-COST prices — many rolls are priced under cost).
* if the product is Sample-only -> CREATE a roll variant
on the "Size" option (value "Roll"), inventoryPolicy CONTINUE, tracked=false,
Sample preserved (proven on canary EUR-71216).
SAFETY: DRY_RUN=1 default (prints planned change, writes nothing). Smoke-test:
DRY_RUN=0 LIMIT=1 python3 write_reonboard.py (verify in admin, then full run)
Never lowers a price below trade cost. Skips anything already at the target.
"""
import os, csv, json, time, subprocess, 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 not found")
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(8):
try:
d = json.load(urllib.request.urlopen(req, timeout=90))
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
options{name values}
variants(first:15){edges{node{id sku price selectedOptions{name value}}}}}}}}"""
VUPDATE = """mutation($pid:ID!,$v:[ProductVariantsBulkInput!]!){
productVariantsBulkUpdate(productId:$pid,variants:$v){userErrors{field message}}}"""
VCREATE = """mutation($pid:ID!,$v:[ProductVariantsBulkInput!]!){
productVariantsBulkCreate(productId:$pid,variants:$v){userErrors{field message}}}"""
def main():
rows = list(csv.DictReader(open(os.path.join(HERE, "reonboard.csv"))))
if LIMIT: rows = rows[:LIMIT]
updated = created = skipped = 0; errs = []; results = []
print(f"{'DRY-RUN' if DRY_RUN else 'LIVE'}: {len(rows)} products -> roll @ trade x1.810")
for i, r in enumerate(rows):
roll_sku, target, trade = r["roll_sku"], float(r["retail"]), float(r["trade_price"])
d = gql(FIND, {"q": "sku:" + roll_sku})
e = (d.get("data") or {}).get("products", {}).get("edges", [])
if not e:
errs.append({"sku": roll_sku, "err": "product not found"}); continue
node = e[0]["node"]; pid = node["id"]
vmap = {v["node"]["sku"]: v["node"] for v in node["variants"]["edges"]}
roll = vmap.get(roll_sku)
# roll variant option: use the product's ACTUAL first option name (some are
# "Size", some are the default "Title") — hardcoding "Size" 422'd 5 products.
opt = node["options"][0] if node["options"] else None
opt_name = opt["name"] if opt else "Title"
roll_val = next((v for v in (opt["values"] if opt else [])
if v.lower() not in ("sample", "default title")), "Roll")
if roll:
cur = float(roll["price"])
if abs(cur - target) < 0.02:
skipped += 1; continue
action = "UPDATE"; op = (VUPDATE, [{"id": roll["id"], "price": f"{target:.2f}"}])
else:
action = "CREATE"; op = (VCREATE, [{
"optionValues": [{"optionName": opt_name, "name": roll_val}],
"price": f"{target:.2f}", "inventoryItem": {"tracked": False, "sku": roll_sku},
"inventoryPolicy": "CONTINUE"}])
if DRY_RUN:
if i < 6: print(f" {action} {roll_sku}: ${roll['price'] if roll else '—'} -> ${target:.2f} (cost ${trade})")
updated += action == "UPDATE"; created += action == "CREATE"; continue
mut, variants = op
res = gql(mut, {"pid": pid, "v": variants})
key = "productVariantsBulkUpdate" if action == "UPDATE" else "productVariantsBulkCreate"
ue = ((res.get("data") or {}).get(key) or {}).get("userErrors") or []
if ue: errs.append({"sku": roll_sku, "err": ue[:2]})
else:
updated += action == "UPDATE"; created += action == "CREATE"
results.append({"sku": roll_sku, "action": action, "price": f"{target:.2f}"})
if i % 25 == 0: print(f" ...{i}/{len(rows)} upd={updated} new={created} err={len(errs)}")
time.sleep(0.3)
json.dump({"updated": updated, "created": created, "skipped": skipped, "errors": errs[:25]},
open(os.path.join(HERE, "write-results.json"), "w"), indent=2)
print(f"\nDONE {'(DRY-RUN)' if DRY_RUN else ''}: UPDATE={updated} CREATE={created} skip={skipped} err={len(errs)}")
if errs: print("errs:", json.dumps(errs[:3]))
if __name__ == "__main__":
main()