[object Object]

← back to Carnegie Import

auto-save: 2026-07-22T08:12:31 (3 files) — enrich_palette.py images/ publish_shopify.py

f07e7fc524efd2f9a015ba43d23c548f3096d9b2 · 2026-07-22 08:12:43 -0700 · Steve Abrams

Files touched

Diff

commit f07e7fc524efd2f9a015ba43d23c548f3096d9b2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 22 08:12:43 2026 -0700

    auto-save: 2026-07-22T08:12:31 (3 files) — enrich_palette.py images/ publish_shopify.py
---
 enrich_palette.py  | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 publish_shopify.py | 112 +++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 250 insertions(+)

diff --git a/enrich_palette.py b/enrich_palette.py
new file mode 100644
index 0000000..f4c9f98
--- /dev/null
+++ b/enrich_palette.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+"""Carnegie palette enrichment — free local PIL ($0). Per sellable SKU:
+download swatch image local, extract full palette (hex + coverage %), nearest
+colorway name, primary hex + bucket, color_tags; then set activation_pass per
+the 5-field + full-palette gate. Caches by image_url to avoid redundant fetches.
+"""
+import io, json, os, re, subprocess, sys, time, urllib.request
+from PIL import Image
+
+DSN = "host=/tmp dbname=dw_unified"
+IMGDIR = os.path.expanduser("~/Projects/carnegie-import/images")
+os.makedirs(IMGDIR, exist_ok=True)
+UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
+
+# compact interior-designer colorway lexicon (name, r,g,b)
+LEX = [
+ ("White",245,245,245),("Alabaster",237,234,224),("Cream",243,235,215),("Ivory",250,246,228),
+ ("Oatmeal",223,213,193),("Sand",214,196,166),("Beige",210,196,171),("Taupe",180,165,145),
+ ("Greige",176,168,156),("Khaki",160,145,110),("Camel",176,140,98),("Gold",196,160,70),
+ ("Yellow",220,200,60),("Mustard",190,150,40),("Orange",210,120,50),("Rust",150,80,45),
+ ("Terracotta",180,95,70),("Red",180,50,50),("Burgundy",110,35,45),("Pink",210,150,160),
+ ("Blush",220,190,185),("Purple",110,75,140),("Plum",95,60,90),("Navy",35,45,80),
+ ("Blue",60,95,150),("Denim",75,105,140),("Sky",150,180,205),("Teal",40,120,120),
+ ("Aqua",120,180,180),("Green",70,120,70),("Sage",150,165,140),("Olive",110,110,60),
+ ("Forest",45,80,55),("Grey",150,150,150),("Silver",190,190,192),("Slate",100,110,120),
+ ("Charcoal",70,72,78),("Graphite",55,55,58),("Black",30,30,30),("Brown",110,80,55),("Chocolate",75,55,40),
+]
+def nearest(r,g,b):
+    best=None;bd=1e9
+    for n,rr,gg,bb in LEX:
+        d=(r-rr)**2+(g-gg)**2+(b-bb)**2
+        if d<bd:bd=d;best=n
+    return best
+def bucket(r,g,b):
+    mx,mn=max(r,g,b),min(r,g,b)
+    if mx<60:return "Dark"
+    if mn>200:return "Light"
+    if mx-mn<25:return "Neutral"
+    if r>=g and r>=b:return "Warm"
+    if b>=r and b>=g:return "Cool"
+    return "Warm" if g>=b else "Cool"
+
+def palette_of(img):
+    im=img.convert("RGB");im.thumbnail((120,120))
+    q=im.quantize(colors=8,method=Image.FASTOCTREE)
+    pal=q.getpalette();counts=q.getcolors() or []
+    total=sum(c for c,_ in counts) or 1
+    out=[]
+    for cnt,idx in sorted(counts,reverse=True):
+        r,g,b=pal[idx*3:idx*3+3]
+        pct=round(100.0*cnt/total,1)
+        if pct<2:continue
+        out.append({"hex":"#%02x%02x%02x"%(r,g,b),"name":nearest(r,g,b),
+                    "bucket":bucket(r,g,b),"pct":pct,"rgb":[r,g,b]})
+    # merge same-name
+    merged={}
+    for c in out:
+        k=c["name"]
+        if k in merged:merged[k]["pct"]=round(merged[k]["pct"]+c["pct"],1)
+        else:merged[k]=c
+    return sorted(merged.values(),key=lambda x:-x["pct"])
+
+def fetch(url):
+    req=urllib.request.Request(url,headers={"User-Agent":UA})
+    with urllib.request.urlopen(req,timeout=40) as r:return r.read()
+
+def psql(sql):
+    p=subprocess.run(["psql",DSN,"-v","ON_ERROR_STOP=1","-q"],input=sql,text=True,capture_output=True)
+    if p.returncode!=0:
+        sys.stderr.write(p.stderr[:1500]);raise SystemExit("psql fail")
+
+def rows():
+    out=subprocess.run(["psql",DSN,"-tAF","\t","-c",
+        "SELECT id,dw_sku,image_url FROM carnegie_catalog WHERE palette IS NULL AND image_url IS NOT NULL ORDER BY id"],
+        text=True,capture_output=True).stdout.strip()
+    return [l.split("\t") for l in out.splitlines() if l]
+
+def q(v):
+    if v is None:return "NULL"
+    if isinstance(v,(dict,list)):return "'"+json.dumps(v).replace("'","''")+"'"
+    return "'"+str(v).replace("'","''")+"'"
+def arr(l):
+    if not l:return "NULL"
+    return "'{"+",".join('"'+str(x).replace('"','\\"')+'"' for x in l)+"}'"
+
+def main():
+    todo=rows();n=len(todo);print(f"[enrich] {n} SKUs need palette",flush=True)
+    cache={};done=0;fail=0;buf=[]
+    for rid,dwsku,url in todo:
+        try:
+            if url in cache:
+                pal,lp=cache[url]
+            else:
+                b=fetch(url);img=Image.open(io.BytesIO(b))
+                pal=palette_of(img)
+                ext=".png" if (url.lower().split("?")[0].endswith("png")) else ".jpg"
+                lp=os.path.join(IMGDIR,f"{dwsku}{ext}")
+                with open(lp,"wb") as f:f.write(b)
+                cache[url]=(pal,lp)
+                time.sleep(0.15)
+            if not pal:raise ValueError("empty palette")
+            ctags=[c["name"] for c in pal[:3]]
+            phex=pal[0]["hex"];pbucket=pal[0]["bucket"]
+            buf.append((rid,pal,ctags,phex,pbucket,os.path.basename(lp)))
+            done+=1
+        except Exception as e:
+            buf.append((rid,None,None,None,None,None));fail+=1
+        if len(buf)>=100:
+            flush(buf);buf=[]
+            print(f"[enrich] {done+fail}/{n} ok={done} fail={fail}",flush=True)
+    if buf:flush(buf)
+    # activation gate
+    psql("""UPDATE carnegie_catalog SET
+      activation_pass = (local_image IS NOT NULL AND price>0 AND description_text IS NOT NULL
+        AND array_length(style_tags,1)>=2 AND palette IS NOT NULL AND array_length(color_tags,1)>=1),
+      activation_reasons = array_remove(ARRAY[
+        CASE WHEN local_image IS NULL THEN 'no_local_image' END,
+        CASE WHEN price IS NULL OR price<=0 THEN 'no_price' END,
+        CASE WHEN description_text IS NULL THEN 'no_desc' END,
+        CASE WHEN array_length(style_tags,1)<2 THEN 'lt2_style_tags' END,
+        CASE WHEN palette IS NULL THEN 'no_palette' END,
+        CASE WHEN array_length(color_tags,1)<1 THEN 'no_color_tags' END], NULL);""")
+    print(json.dumps({"enriched":done,"failed":fail,"total":n}),flush=True)
+
+def flush(buf):
+    parts=[]
+    for rid,pal,ctags,phex,pbucket,lp in buf:
+        if pal is None:
+            parts.append(f"({rid},NULL,NULL,NULL,NULL,NULL)")
+        else:
+            parts.append(f"({rid},{q(pal)}::jsonb,{arr(ctags)},{q(phex)},{q(pbucket)},{q(lp)})")
+    sql=("UPDATE carnegie_catalog c SET palette=v.palette, color_tags=v.color_tags::text[], "
+         "primary_hex=v.primary_hex, color_bucket=v.color_bucket, local_image=v.local_image, updated_at=now() "
+         "FROM (VALUES\n"+",\n".join(parts)+
+         "\n) AS v(id,palette,color_tags,primary_hex,color_bucket,local_image) WHERE c.id=v.id;")
+    psql(sql)
+
+if __name__=="__main__":main()
diff --git a/publish_shopify.py b/publish_shopify.py
new file mode 100644
index 0000000..8086ac0
--- /dev/null
+++ b/publish_shopify.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+"""Carnegie -> LIVE Shopify publisher. One product per pattern-application,
+colorways as variants + a $4.25 memo Sample variant. Tag-scoped so the smart
+collections (carnegie-wallcovering / -wall-panel / -textile) pick each up.
+
+SAFE BY DEFAULT: dry-run unless --apply. Metered cadence (delay between writes),
+canary-first (--limit N). Only publishes activation_pass=TRUE products.
+
+Usage:
+  python3 publish_shopify.py --class Wallcovering            # dry-run, all wallcovering
+  python3 publish_shopify.py --class Wallcovering --limit 3 --apply   # canary: 3 live
+  python3 publish_shopify.py --class Wallcovering --apply --delay 1.5 # metered rollout
+"""
+import argparse, base64, json, os, subprocess, sys, time, urllib.request
+
+DSN = "host=/tmp dbname=dw_unified"
+UA  = "Mozilla/5.0"
+def env(k):
+    for line in open(os.path.expanduser("~/Projects/secrets-manager/.env")):
+        if line.startswith(k+"="): return line.split("=",1)[1].strip()
+    return None
+TOKEN = env("SHOPIFY_ADMIN_TOKEN")
+STORE = "designer-laboratory-sandbox.myshopify.com"
+API   = f"https://{STORE}/admin/api/2024-10"
+
+def api(method, path, payload=None):
+    data = json.dumps(payload).encode() if payload is not None else None
+    req = urllib.request.Request(f"{API}/{path}", data=data, method=method,
+        headers={"X-Shopify-Access-Token":TOKEN,"Content-Type":"application/json","User-Agent":UA})
+    with urllib.request.urlopen(req, timeout=60) as r:
+        return json.loads(r.read())
+
+def q(sql):
+    out = subprocess.run(["psql",DSN,"-tAF","\x1f","-c",sql],text=True,capture_output=True)
+    if out.returncode: sys.stderr.write(out.stderr); raise SystemExit("psql fail")
+    return [l.split("\x1f") for l in out.stdout.splitlines() if l]
+
+def products_for(cls):
+    # group gate-passing sellable SKUs by parent pattern-application
+    rows = q(f"""SELECT parent_sku, pattern_name, product_type, type_suffix, collection_tag,
+        max(body_html), max(product_url), array_agg(dw_sku ORDER BY color_number),
+        array_agg(color_number ORDER BY color_number), array_agg(price ORDER BY color_number),
+        array_agg(coalesce(color_tags[1],'') ORDER BY color_number),
+        array_agg(coalesce(local_image,'') ORDER BY color_number),
+        max(array_to_string(style_tags,'||'))
+      FROM carnegie_catalog
+      WHERE dw_class='{cls}' AND activation_pass IS TRUE
+      GROUP BY parent_sku, pattern_name, product_type, type_suffix, collection_tag
+      ORDER BY parent_sku""")
+    def parr(s): return [x for x in s.strip('{}').replace('"','').split(',') if x] if s and s!='{}' else []
+    out=[]
+    for r in rows:
+        out.append(dict(parent=r[0],name=r[1],ptype=r[2],suffix=r[3],ctag=r[4],
+            body=r[5],url=r[6],dwskus=parr(r[7]),colors=parr(r[8]),
+            prices=parr(r[9]),colortags=parr(r[10]),imgs=parr(r[11]),
+            styles=[x for x in (r[12] or '').split('||') if x]))
+    return out
+
+def build_payload(p):
+    tags = sorted(set([p["ctag"],"Carnegie",p["ptype"]] + p["styles"] + p["colortags"]))
+    tags = [t for t in tags if t]
+    variants=[]
+    for dwsku,color,price in zip(p["dwskus"],p["colors"],p["prices"]):
+        variants.append({"option1":f"Color {color}","sku":dwsku,"price":str(price),
+                         "inventory_management":None,"taxable":True})
+    variants.append({"option1":"Memo Sample","sku":p["parent"]+"-SAMPLE","price":"4.25"})
+    body = p["body"] or f"<p>{p['name']} by Carnegie.</p>"
+    return {"product":{
+        "title":f"Carnegie {p['name']}","body_html":body,"vendor":"Carnegie",
+        "product_type":p["ptype"] or "Wallcovering","tags":", ".join(tags),
+        "status":"active","options":[{"name":"Color"}],"variants":variants}}
+
+def add_image(pid, local):
+    if not local: return
+    path=os.path.expanduser(f"~/Projects/carnegie-import/images/{local}")
+    if not os.path.exists(path): return
+    b=base64.b64encode(open(path,"rb").read()).decode()
+    api("POST",f"products/{pid}/images.json",{"image":{"attachment":b}})
+
+def main():
+    ap=argparse.ArgumentParser()
+    ap.add_argument("--class",dest="cls",required=True)
+    ap.add_argument("--limit",type=int,default=0)
+    ap.add_argument("--delay",type=float,default=1.5)
+    ap.add_argument("--apply",action="store_true")
+    a=ap.parse_args()
+    prods=products_for(a.cls)
+    if a.limit: prods=prods[:a.limit]
+    print(f"[publish] class={a.cls} products={len(prods)} apply={a.apply} delay={a.delay}s")
+    tot_var=sum(len(p['dwskus'])+1 for p in prods)
+    print(f"[publish] variants(incl sample)={tot_var}")
+    if not a.apply:
+        for p in prods[:5]:
+            pl=build_payload(p)["product"]
+            print(f"  DRY {p['parent']:24} {pl['title'][:34]:34} variants={len(pl['variants'])} tags=[{pl['tags'][:60]}]")
+        print(f"  ... ({len(prods)} total) — dry-run, no writes. Add --apply to publish.")
+        return
+    created=0
+    for p in prods:
+        try:
+            res=api("POST","products.json",build_payload(p))
+            pid=res["product"]["id"]
+            # one product image from first colorway swatch
+            add_image(pid, p["imgs"][0] if p["imgs"] else None)
+            created+=1
+            print(f"[publish] {created}/{len(prods)} created {p['parent']} -> product {pid}")
+            time.sleep(a.delay)
+        except Exception as e:
+            print(f"[publish] FAIL {p['parent']}: {e}")
+    print(json.dumps({"created":created,"attempted":len(prods)}))
+
+if __name__=="__main__": main()

← 0d38f23 Carnegie feed-first onboarding: Magento GraphQL scraper + st  ·  back to Carnegie Import  ·  Add palette enrichment (5928 SKUs, full hex+pct) + dry-run-v 7b70ec1 →