← back to Elitis Price 2026

exec/ws5_activate.py

159 lines

#!/usr/bin/env python3
"""WS5 - attach Elitis feed images to the Needs-Image drafts and flip them ACTIVE.
Images are PATTERN-level (Elitis publishes no per-colorway imagery) - Steve-approved
2026-07-11 (canary-then-batch). $0: Shopify fetches the CDN URL itself; Admin API free.

Per draft (from ws3_drafts ledger, status OK) matched to elitis_feed.json by
accent-normalized pattern:
  1. rank images (drop swatch-grid/nuancier; prefer ambiance/mural/detail), take top 3.
  2. productCreateMedia(originalSource=CDN url) -> wait until READY.
  3. strip 'Needs-Image' tag + set status ACTIVE.
Idempotent: skips drafts that already have an image or are already ACTIVE."""
import sys
import os
import csv
import json
import time
import unicodedata
sys.path.insert(0, "lib")
from shopify import gql

ROOT = ".."
FEED = os.path.join(ROOT, "elitis_feed.json")
DRAFTS = os.path.join("ledger", "ws3_drafts.csv")
LEDGER = os.path.join("ledger", "ws5_activate.csv")

M_MEDIA = """mutation($pid:ID!,$media:[CreateMediaInput!]!){
  productCreateMedia(productId:$pid, media:$media){
    media{ ... on MediaImage { id status } } mediaUserErrors{ field message } } }"""
M_TAGSRM = """mutation($id:ID!,$tags:[String!]!){ tagsRemove(id:$id, tags:$tags){ userErrors{ message } } }"""
M_UPD = """mutation($input:ProductInput!){ productUpdate(input:$input){
  product{ id status } userErrors{ field message } } }"""
Q_MEDIA = """query($id:ID!){ product(id:$id){ status
  media(first:10){ edges{ node{ ... on MediaImage { id status } } } } } }"""

DROP = ("grille", "nuancier", "pastille", "swatch", "-color", "colour")
BOOST = ("ambiance", "detail", "mural", "room", "deco")


def norm(s):
    s = unicodedata.normalize("NFKD", s or "")
    s = "".join(c for c in s if not unicodedata.combining(c))
    return " ".join(s.casefold().split())


def rank_images(urls):
    def score(u):
        lu = u.lower()
        s = 0
        if any(d in lu for d in DROP):
            s -= 10
        if any(b in lu for b in BOOST):
            s += 3
        return s
    return sorted(urls, key=score, reverse=True)


def gid(pid):
    return f"gid://shopify/Product/{pid}"


def log(pid, pat, dw, status):
    new = not (os.path.exists(LEDGER) and os.path.getsize(LEDGER) > 0)
    with open(LEDGER, "a", newline="") as f:
        w = csv.writer(f)
        if new:
            w.writerow(["ts", "product_id", "pattern", "dw_sku", "status"])
        w.writerow([time.strftime("%Y-%m-%dT%H:%M:%S"), pid, pat, dw, status])


def already_done(pid):
    d = gql(Q_MEDIA, {"id": gid(pid)})["product"]
    has_img = len(d["media"]["edges"]) > 0
    return d["status"], has_img


def wait_ready(pid, timeout=40):
    t0 = time.time()
    while time.time() - t0 < timeout:
        d = gql(Q_MEDIA, {"id": gid(pid)})["product"]
        st = [e["node"].get("status") for e in d["media"]["edges"] if e["node"]]
        if st and all(s == "READY" for s in st):
            return True
        if any(s == "FAILED" for s in st):
            return False
        time.sleep(2)
    return bool(st)  # timed out but media exists


def activate_one(row, feed, verbose=False):
    pid = row["product_id"]
    pat = row["pattern"]
    dw = row["dw_sku"]
    f = feed.get(norm(pat))
    if not f:
        log(pid, pat, dw, "SKIP:no-feed-match")
        return "SKIP:no-feed-match"
    status, has_img = already_done(pid)
    if has_img and status == "ACTIVE":
        log(pid, pat, dw, "SKIP:already-active-with-image")
        return "SKIP:already-active-with-image"
    imgs = rank_images(f["images"])[:3] if not has_img else []
    if imgs:
        media = [{"originalSource": u, "mediaContentType": "IMAGE",
                  "alt": row.get("colorway") or pat} for u in imgs]
        r = gql(M_MEDIA, {"pid": gid(pid), "media": media})
        ue = r["productCreateMedia"]["mediaUserErrors"]
        if ue:
            log(pid, pat, dw, "FAIL:media:" + json.dumps(ue)[:120])
            return "FAIL:media"
        wait_ready(pid)
    # strip Needs-Image, go ACTIVE
    gql(M_TAGSRM, {"id": gid(pid), "tags": ["Needs-Image"]})
    r = gql(M_UPD, {"input": {"id": gid(pid), "status": "ACTIVE"}})
    ue = r["productUpdate"]["userErrors"]
    if ue:
        log(pid, pat, dw, "FAIL:activate:" + json.dumps(ue)[:120])
        return "FAIL:activate"
    log(pid, pat, dw, "OK")
    if verbose:
        print(json.dumps({"pid": pid, "pattern": pat, "colorway": row.get("colorway"),
                          "dw_sku": dw, "images_attached": len(imgs),
                          "img_urls": imgs}, indent=2, ensure_ascii=False))
    return "OK"


def load():
    feed = json.load(open(FEED))
    rows = [r for r in csv.DictReader(open(DRAFTS)) if r["status"] == "OK"]
    return feed, rows


if __name__ == "__main__":
    feed, rows = load()
    if "--canary" in sys.argv:
        pid = sys.argv[sys.argv.index("--canary") + 1]
        row = next(r for r in rows if r["product_id"] == pid)
        print("=== WS5 CANARY:", row["pattern"], row["colorway"], "===")
        print("RESULT:", activate_one(row, feed, verbose=True))
        sys.exit(0)
    done = set()
    if os.path.exists(LEDGER):
        for r in csv.DictReader(open(LEDGER)):
            if r["status"].startswith("OK") or r["status"].startswith("SKIP:already"):
                done.add(r["product_id"])
    n_ok = n_skip = n_fail = 0
    todo = [r for r in rows if r["product_id"] not in done]
    print(f"WS5 batch: {len(todo)} drafts to activate ({len(done)} already done)")
    for i, r in enumerate(todo):
        st = activate_one(r, feed)
        if st == "OK":
            n_ok += 1
        elif st.startswith("FAIL"):
            n_fail += 1
        else:
            n_skip += 1
        if (i + 1) % 20 == 0:
            print(f"  ...{i+1}/{len(todo)} ok={n_ok} skip={n_skip} fail={n_fail}")
    print(f"WS5 DONE: ok={n_ok} skip={n_skip} fail={n_fail}")