[object Object]

← back to Elitis Price 2026

auto-save: 2026-07-11T10:12:52 (2 files) — exec/ledger/ws5_activate.csv exec/ws5_activate.py

b6af203f5569b320d6a99ca1fb40bfcc40a61b8c · 2026-07-11 10:12:54 -0700 · Steve Abrams

Files touched

Diff

commit b6af203f5569b320d6a99ca1fb40bfcc40a61b8c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Jul 11 10:12:54 2026 -0700

    auto-save: 2026-07-11T10:12:52 (2 files) — exec/ledger/ws5_activate.csv exec/ws5_activate.py
---
 exec/ledger/ws5_activate.csv | 129 ++++++++++++++++++++++++++++++++++++
 exec/ws5_activate.py         | 153 +++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 282 insertions(+)

diff --git a/exec/ledger/ws5_activate.csv b/exec/ledger/ws5_activate.csv
new file mode 100644
index 0000000..8640678
--- /dev/null
+++ b/exec/ledger/ws5_activate.csv
@@ -0,0 +1,129 @@
+ts,product_id,pattern,dw_sku,status

+2026-07-11T10:01:38,7880940748851,Cesteria,DWEL-RM-1017-02,OK

+2026-07-11T10:02:42,7880939175987,Empreinte,DWEL-RM-250-04,OK

+2026-07-11T10:02:46,7880939274291,Empreinte,DWEL-RM-250-05,OK

+2026-07-11T10:02:49,7880939307059,Empreinte,DWEL-RM-250-06,OK

+2026-07-11T10:02:56,7880939339827,Empreinte,DWEL-RM-250-10,OK

+2026-07-11T10:03:02,7880939372595,Empreinte,DWEL-RM-250-01,OK

+2026-07-11T10:03:08,7880939405363,Volutes,DWEL-RM-253-01,OK

+2026-07-11T10:03:11,7880939438131,Volutes,DWEL-RM-253-02,OK

+2026-07-11T10:03:15,7880939470899,Volutes,DWEL-RM-253-12,OK

+2026-07-11T10:03:21,7880939503667,Aladin,DWEL-RM-254-09,OK

+2026-07-11T10:03:28,7880939536435,Aladin,DWEL-RM-254-07,OK

+2026-07-11T10:03:31,7880939569203,Aladin,DWEL-RM-254-01,OK

+2026-07-11T10:03:36,7880939601971,Aladin,DWEL-RM-254-11,OK

+2026-07-11T10:03:42,7880939634739,Allégorie,DWEL-RM-256-02,OK

+2026-07-11T10:03:48,7880939667507,Kimono,DWEL-RM-257-02,OK

+2026-07-11T10:03:54,7880939700275,Ecorce,DWEL-RM-631-94,OK

+2026-07-11T10:04:00,7880939733043,Ecorce,DWEL-RM-631-71,OK

+2026-07-11T10:04:04,7880939765811,Ecorce,DWEL-RM-631-20,OK

+2026-07-11T10:04:08,7880939798579,Ecorce,DWEL-RM-631-80,OK

+2026-07-11T10:04:14,7880939831347,Ecorce,DWEL-RM-631-72,OK

+2026-07-11T10:04:18,7880939864115,Ecorce,DWEL-RM-631-92,OK

+2026-07-11T10:04:24,7880939896883,Etincelle,DWEL-RM-987-01,OK

+2026-07-11T10:04:28,7880939929651,Etincelle,DWEL-RM-987-80,OK

+2026-07-11T10:04:31,7880939962419,Etincelle,DWEL-RM-987-72,OK

+2026-07-11T10:04:37,7880939995187,Labyrinthe,DWEL-RM-988-01,OK

+2026-07-11T10:04:41,7880940027955,Labyrinthe,DWEL-RM-988-20,OK

+2026-07-11T10:04:45,7880940060723,Labyrinthe,DWEL-RM-988-32,OK

+2026-07-11T10:04:49,7880940093491,Labyrinthe,DWEL-RM-988-72,OK

+2026-07-11T10:04:55,7880940126259,Champagne,DWEL-RM-989-72,OK

+2026-07-11T10:05:01,7880940159027,Silver,DWEL-RM-1008-91,OK

+2026-07-11T10:05:05,7880940191795,Silver,DWEL-RM-1007-01,OK

+2026-07-11T10:05:13,7880940224563,Silver,DWEL-RM-1007-80,OK

+2026-07-11T10:05:19,7880940257331,Silver,DWEL-RM-1007-90,OK

+2026-07-11T10:05:25,7880940290099,Wishlist,DWEL-RM-1010-80,OK

+2026-07-11T10:05:32,7880940322867,Elégante,DWEL-RM-1011-01,OK

+2026-07-11T10:05:38,7880940355635,Casual,DWEL-RM-1013-01,OK

+2026-07-11T10:05:41,7880940388403,Casual,DWEL-RM-1013-80,OK

+2026-07-11T10:05:48,7880940421171,L'essentiel,DWEL-RM-1014-01,OK

+2026-07-11T10:05:54,7880940453939,L'essentiel,DWEL-RM-1014-80,OK

+2026-07-11T10:06:00,7880940486707,Gentleman,DWEL-RM-1015-01,OK

+2026-07-11T10:06:06,7880940519475,L'indispensable,DWEL-RM-1016-04,OK

+2026-07-11T10:06:10,7880940552243,L'indispensable,DWEL-RM-1016-02,OK

+2026-07-11T10:06:14,7880940585011,L'indispensable,DWEL-RM-1016-01,OK

+2026-07-11T10:06:18,7880940617779,L'indispensable,DWEL-RM-1016-03,OK

+2026-07-11T10:06:21,7880940650547,L'indispensable,DWEL-RM-1016-15,OK

+2026-07-11T10:06:25,7880940683315,L'indispensable,DWEL-RM-1016-41,OK

+2026-07-11T10:06:32,7880940716083,L'indispensable,DWEL-RM-1016-72,OK

+2026-07-11T10:06:35,7880940781619,Cesteria,DWEL-RM-1017-03,OK

+2026-07-11T10:06:39,7880940814387,Cesteria,DWEL-RM-1017-35,OK

+2026-07-11T10:06:43,7880940847155,Cesteria,DWEL-RM-1017-80,OK

+2026-07-11T10:06:47,7880940879923,Cesteria,DWEL-RM-1017-01,OK

+2026-07-11T10:06:51,7880940912691,Cesteria,DWEL-RM-1017-04,OK

+2026-07-11T10:06:57,7880940945459,Cesteria,DWEL-RM-1017-21,OK

+2026-07-11T10:07:03,7880940978227,Cesteria,DWEL-RM-1017-60,OK

+2026-07-11T10:07:09,7880941010995,Entrelazados,DWEL-RM-1019-01,OK

+2026-07-11T10:07:13,7880941043763,Entrelazados,DWEL-RM-1019-04,OK

+2026-07-11T10:07:19,7880941076531,Entrelazados,DWEL-RM-1019-02,OK

+2026-07-11T10:07:26,7880941109299,Partitura,DWEL-RM-1020-60,OK

+2026-07-11T10:07:29,7880941142067,Partitura,DWEL-RM-1020-02,OK

+2026-07-11T10:07:33,7880941174835,Partitura,DWEL-RM-1020-80,OK

+2026-07-11T10:07:39,7880941207603,Impulsion,DWEL-RM-1022-90,OK

+2026-07-11T10:07:43,7880941240371,Impulsion,DWEL-RM-1022-65,OK

+2026-07-11T10:07:49,7880941273139,Impulsion,DWEL-RM-1022-72,OK

+2026-07-11T10:07:53,7880941305907,Impulsion,DWEL-RM-1022-91,OK

+2026-07-11T10:07:57,7880941338675,Impulsion,DWEL-RM-1022-92,OK

+2026-07-11T10:08:03,7880941371443,Esprit libre,DWEL-RM-1024-02,OK

+2026-07-11T10:08:07,7880941404211,Esprit libre,DWEL-RM-1024-22,OK

+2026-07-11T10:08:13,7880941436979,Esprit libre,DWEL-RM-1024-92,OK

+2026-07-11T10:08:17,7880941469747,Esprit libre,DWEL-RM-1024-67,OK

+2026-07-11T10:08:23,7880941502515,Esprit libre,DWEL-RM-1024-75,OK

+2026-07-11T10:08:27,7880941535283,Esprit libre,DWEL-RM-1024-80,OK

+2026-07-11T10:08:33,7880941568051,Naoshima,DWEL-RM-1031-06,OK

+2026-07-11T10:08:37,7880941600819,Naoshima,DWEL-RM-1031-01,OK

+2026-07-11T10:08:41,7880941633587,Naoshima,DWEL-RM-1031-05,OK

+2026-07-11T10:08:45,7880941666355,Naoshima,DWEL-RM-1031-03,OK

+2026-07-11T10:08:51,7880941699123,Jima,DWEL-RM-1033-01,OK

+2026-07-11T10:08:55,7880941731891,Jima,DWEL-RM-1033-03,OK

+2026-07-11T10:09:03,7880941764659,Jima,DWEL-RM-1033-04,OK

+2026-07-11T10:09:09,7880941797427,Jima,DWEL-RM-1033-05,OK

+2026-07-11T10:09:15,7880941830195,Tokara,DWEL-RM-1034-01,OK

+2026-07-11T10:09:21,7880941862963,Amami,DWEL-RM-1035-03,OK

+2026-07-11T10:09:26,7880941895731,Amami,DWEL-RM-1035-01,OK

+2026-07-11T10:09:32,7880941928499,Amami,DWEL-RM-1035-06,OK

+2026-07-11T10:09:35,7880941961267,Amami,DWEL-RM-1035-05,OK

+2026-07-11T10:09:41,7880941994035,Fragments,DWEL-RM-1037-80,OK

+2026-07-11T10:09:45,7880942026803,Fragments,DWEL-RM-1037-15,OK

+2026-07-11T10:09:51,7880942059571,Le roi soleil,DWEL-RM-1038-72,OK

+2026-07-11T10:09:57,7880942092339,Le roi soleil,DWEL-RM-1038-82,OK

+2026-07-11T10:10:02,7880942125107,Le roi soleil,DWEL-RM-1038-03,OK

+2026-07-11T10:10:06,7880942157875,Le roi soleil,DWEL-RM-1038-80,OK

+2026-07-11T10:10:12,7880942190643,Empreintes,DWEL-RM-1040-01,OK

+2026-07-11T10:10:18,7880942223411,Empreintes,DWEL-RM-1040-15,OK

+2026-07-11T10:10:22,7880942256179,Empreintes,DWEL-RM-1040-80,OK

+2026-07-11T10:10:25,7880942288947,Empreintes,DWEL-RM-1040-75,OK

+2026-07-11T10:10:31,7880942321715,Sève,DWEL-RM-1042-72,OK

+2026-07-11T10:10:35,7880942354483,Sève,DWEL-RM-1042-15,OK

+2026-07-11T10:10:39,7880942387251,Sève,DWEL-RM-1042-01,OK

+2026-07-11T10:10:45,7880942420019,Pilastre,DWEL-RM-1043-40,OK

+2026-07-11T10:10:49,7880942452787,Pilastre,DWEL-RM-1043-70,OK

+2026-07-11T10:10:53,7880942485555,Pilastre,DWEL-RM-1043-22,OK

+2026-07-11T10:10:57,7880942518323,Pilastre,DWEL-RM-1043-80,OK

+2026-07-11T10:11:00,7880942551091,Pilastre,DWEL-RM-1043-05,OK

+2026-07-11T10:11:05,7880942583859,Pilastre,DWEL-RM-1043-01,OK

+2026-07-11T10:11:08,7880942616627,Pilastre,DWEL-RM-1043-15,OK

+2026-07-11T10:11:12,7880942649395,Pilastre,DWEL-RM-1043-51,OK

+2026-07-11T10:11:18,7880942682163,Colonne,DWEL-RM-1044-70,OK

+2026-07-11T10:11:22,7880942714931,Colonne,DWEL-RM-1044-51,OK

+2026-07-11T10:11:26,7880942747699,Colonne,DWEL-RM-1044-05,OK

+2026-07-11T10:11:30,7880942780467,Colonne,DWEL-RM-1044-22,OK

+2026-07-11T10:11:34,7880942813235,Colonne,DWEL-RM-1044-01,OK

+2026-07-11T10:11:38,7880942846003,Colonne,DWEL-RM-1044-15,OK

+2026-07-11T10:11:42,7880942878771,Colonne,DWEL-RM-1044-40,OK

+2026-07-11T10:11:45,7880942911539,Colonne,DWEL-RM-1044-80,OK

+2026-07-11T10:11:51,7880942944307,Parthénon,DWEL-RM-1045-70,OK

+2026-07-11T10:11:55,7880942977075,Parthénon,DWEL-RM-1045-05,OK

+2026-07-11T10:11:59,7880943009843,Parthénon,DWEL-RM-1045-22,OK

+2026-07-11T10:12:03,7880943042611,Parthénon,DWEL-RM-1045-15,OK

+2026-07-11T10:12:09,7880943075379,Parthénon,DWEL-RM-1045-40,OK

+2026-07-11T10:12:13,7880943140915,Parthénon,DWEL-RM-1045-01,OK

+2026-07-11T10:12:17,7880943173683,Parthénon,DWEL-RM-1045-51,OK

+2026-07-11T10:12:20,7880943206451,Parthénon,DWEL-RM-1045-80,OK

+2026-07-11T10:12:27,7880943239219,Muse,DWEL-RM-1047-01,OK

+2026-07-11T10:12:33,7880943271987,Muse,DWEL-RM-1047-02,OK

+2026-07-11T10:12:37,7880943304755,Muse,DWEL-RM-1047-80,OK

+2026-07-11T10:12:40,7880943370291,Muse,DWEL-RM-1047-92,OK

+2026-07-11T10:12:46,7880943403059,Himalaya,DWEL-RM-1049-02,OK

+2026-07-11T10:12:50,7880943435827,Himalaya,DWEL-RM-1049-01,OK

+2026-07-11T10:12:54,7880943468595,Himalaya,DWEL-RM-1049-03,OK

diff --git a/exec/ws5_activate.py b/exec/ws5_activate.py
new file mode 100644
index 0000000..fe6625a
--- /dev/null
+++ b/exec/ws5_activate.py
@@ -0,0 +1,153 @@
+#!/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, os, csv, json, time, 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}")

← d3ccffb D: elitis.fr feed-first scraper ($0 __NUXT__ harvest) -> 559  ·  back to Elitis Price 2026  ·  WS5: attach feed images + activate 357 drafts live (356 batc 4a93e84 →