← back to Designer Wallcoverings
pending-approval/twil_golive2.py
151 lines
#!/usr/bin/env python3
"""TWIL grasscloth go-live v2 (Steve-approved: clean 9 + name/enrich/activate rest, leak-free).
KEEPER -> activate+publish. DUP/leaky-twin -> archive.
RAW-ONLY '| TWIL' -> retitle 'Fentucci {Material} Grasscloth Wallcovering{ — Color}',
vendor=Fentucci, enrich (width/length/desc/color/img from sheet), then ACTIVATE iff has image+width; else DRAFT+Needs-Image.
DRY default; CONFIRM=1 executes."""
import os,sys,json,time,re,urllib.request
import gspread
from collections import defaultdict
SHOP="designer-laboratory-sandbox.myshopify.com";API="2024-10"
DRY=os.environ.get("CONFIRM","0")!="1"
TOKEN=os.environ.get("SHOPIFY_ADMIN_TOKEN","")
if not TOKEN:
for l in open(os.path.expanduser("~/Projects/secrets-manager/.env")):
if l.startswith("SHOPIFY_ADMIN_TOKEN="): TOKEN=l.strip().split("=",1)[1];break
BASE=f"https://{SHOP}/admin/api/{API}";H={"X-Shopify-Access-Token":TOKEN,"Content-Type":"application/json"}
def api(m,p,pl=None):
d=json.dumps(pl).encode() if pl is not None else None
r=urllib.request.Request(BASE+p,data=d,headers=H,method=m)
with urllib.request.urlopen(r,timeout=60) as x: return x.status,json.loads(x.read().decode())
def gql(q,v=None): return api("POST","/graphql.json",{"query":q,"variables":v or {}})[1]
def norm(s): return (s or "").strip().upper().rstrip("T")
def tc(s): return " ".join(w[:1].upper()+w[1:].lower() if w else w for w in (s or "").split())
MATS=[("HERRINGBONE","Herringbone"),("SMALL DIAMOND","Small Diamond"),("PANAMA","Panama Weave"),
("PAPERWEAVE","Paperweave"),("PAPERWEAV","Paperweave"),("BURLAP","Burlap"),("RAW JUT","Jute"),("JUTE","Jute"),
("RAFFIA","Raffia"),("ABACA","Abaca"),("ARROWROOT","Arrowroot"),("GRANITE QUARTZ","Mica"),("MICA","Mica"),
("HALEY HEMP","Hemp"),("HEMP","Hemp"),("CORK","Cork"),("BOODLE","Boodle"),("TRIANGLE GRASS","Triangle Grass"),("SISAL","Sisal")]
jraw=json.load(open("/tmp/twil_prices.json"))
def material(n):
for x in jraw:
if norm(x["code"])==n:
blob=(x.get("desc","")+" "+x.get("long","")).upper()
for kw,lab in MATS:
if kw in blob: return lab
return ""
PRICE={norm(x["code"]):round(x["list_per_sr"]*0.75,2) for x in jraw if x["list_per_sr"] is not None}
# sheet enrichment
SA="/Users/macstudio3/Projects/secrets-manager/gmc-sa-146735262.json"
gc=gspread.service_account(filename=SA);ws=gc.open_by_key("1trKNm-ymqlbs96XJfndDUz19mzH8A11ktaw2lfQ2VQo").sheet1
vals=ws.get_all_values();SH=defaultdict(dict)
def norm_w(v):
v=(v or '').strip().lower().replace('"',' inches').replace('inches','Inches')
return tc(v) if v else ''
for r in vals[1:]:
b=(r[1] or '').strip()
if not b: continue
n=norm(b);c=SH[n]
def g(i): return (r[i] or '').strip() if len(r)>i else ''
for k,i in [('img1',6),('img2',7),('alt',8),('desc',9),('color',10),('width',15),('length',16)]:
if g(i) and not c.get(k): c[k]=g(i)
SH[n]=c
# pull products
Q="""query($c:String){products(first:100,query:"vendor:Fentucci",after:$c){pageInfo{hasNextPage endCursor}
edges{node{id legacyResourceId title status
img:images(first:1){edges{node{id}}}
w:metafield(namespace:"global",key:"width"){value} w2:metafield(namespace:"custom",key:"width"){value}
mf:metafield(namespace:"custom",key:"manufacturer_sku"){value} mf2:metafield(namespace:"dwc",key:"manufacturer_sku"){value}}}}}"""
prods=[];c=None
while True:
d=gql(Q,{"c":c}).get("data",{}).get("products",{})
for e in d["edges"]: prods.append(e["node"])
if d["pageInfo"]["hasNextPage"]: c=d["pageInfo"]["endCursor"]
else: break
grp=defaultdict(list)
for p in prods:
mfr=(p.get("mf") or {}).get("value") or (p.get("mf2") or {}).get("value") or ""
n=norm(mfr)
if n not in PRICE: continue
p["_n"]=n;p["_mfr"]=mfr;p["_leak"]="| TWIL" in p["title"]
p["_named"]=("| Fentucci" in p["title"]) and not re.match(rf"^{re.escape(n)}T?\b",p["title"].upper())
p["_img"]=len(p["img"]["edges"])>0
p["_width"]=bool((p.get("w") or {}).get("value") or (p.get("w2") or {}).get("value"))
grp[n].append(p)
acts=[];arch=[];retitle=[]
for n,ps in grp.items():
keepers=[p for p in ps if p["_named"] or p["status"]=="ACTIVE"]
if keepers:
keepers.sort(key=lambda z:(z["status"]!="ACTIVE",-len(z["title"])))
keep=keepers[0]
for p in ps:
if p is keep:
if keep["status"]!="ACTIVE": acts.append(keep)
else: arch.append(p)
else:
ps.sort(key=lambda z:(not z["_img"],not z["_width"]))
retitle.append(ps[0]); arch.extend(ps[1:])
def newtitle(p):
mat=material(p["_n"]);col=tc(SH[p["_n"]].get("color",""))
return re.sub(r"\s+"," ",f"Fentucci {mat+' ' if mat else ''}Grasscloth Wallcovering{(' — '+col) if col else ''}").strip()
print("=== GO-LIVE PLAN v2 ===")
print(f"ACTIVATE clean keepers: {len(acts)}")
for p in acts: print(f" {p['_mfr']:>9} {p['title'][:48]}")
print(f"\nRETITLE+ENRICH raw-only: {len(retitle)}")
will_act=0
for p in retitle:
s=SH[p["_n"]];img= '1' if s.get('img1') else '0'
gate = bool(s.get('img1')) and bool(s.get('width') or True)
if gate: will_act+=1
print(f" {p['_mfr']:>9} -> {newtitle(p)[:52]:52} img={img} w={'Y' if s.get('width') else '-'} desc={'Y' if s.get('desc') else '-'} -> {'ACTIVATE' if gate else 'DRAFT/Needs-Image'}")
print(f" (of {len(retitle)} raw: {will_act} will ACTIVATE, {len(retitle)-will_act} stay DRAFT Needs-Image)")
print(f"\nARCHIVE dups/leaky twins: {len(arch)}")
for p in arch: print(f" {p['_mfr']:>9} [{p['status']}] {p['title'][:46]}")
if DRY:
print("\n[DRY] CONFIRM=1 to execute."); sys.exit()
# ---- EXECUTE ----
# GMC source-fix (Steve policy, 2026-07-08): exclude "Google & YouTube"
# (publication 29646651457). Shopify auto-syncs minVariantPrice ($4.25 sample)
# to Merchant Center -> price disapproval. Google is fed by the controlled TSV
# feed only. Rollback: delete the filter below.
PUBS=[e["node"]["id"] for e in gql("{publications(first:50){edges{node{id name}}}}").get("data",{}).get("publications",{}).get("edges",[]) if e["node"]["id"]!="gid://shopify/Publication/29646651457"]
def publish(pid):
return gql("""mutation($id:ID!,$in:[PublicationInput!]!){publishablePublish(id:$id,input:$in){userErrors{message}}}""",
{"id":f"gid://shopify/Product/{pid}","in":[{"publicationId":x} for x in PUBS]}).get("data",{}).get("publishablePublish",{}).get("userErrors",[])
def set_mf(pid,ns,key,val):
if not val: return
api("POST",f"/products/{pid}/metafields.json",{"metafield":{"namespace":ns,"key":key,"type":"single_line_text_field","value":str(val)}})
na=nr=nact=nar=0
for p in acts:
api("PUT",f"/products/{p['legacyResourceId']}.json",{"product":{"id":p['legacyResourceId'],"status":"active"}})
publish(p['legacyResourceId']);na+=1;print(f" activated {p['_mfr']}");time.sleep(0.4)
for p in retitle:
pid=p['legacyResourceId'];s=SH[p["_n"]];nt=newtitle(p);col=tc(s.get("color",""))
imgs=[{"src":u} for u in [s.get('img1'),s.get('img2')] if u]
width=norm_w(s.get('width')) or "36 Inches"; length=tc(s.get('length','')) or "8 Yards"
tags=["Grasscloth","Natural Wallcovering","Fentucci","TWIL Naturals","display_variant"]
gate=bool(imgs)
if not gate: tags.append("Needs-Image")
if col: tags.append(f"color:{col}")
prod={"id":pid,"title":nt,"vendor":"Fentucci","tags":", ".join(tags)}
if s.get('desc'): prod["body_html"]=s['desc']
if imgs: prod["images"]=imgs
if gate: prod["status"]="active"
api("PUT",f"/products/{pid}.json",{"product":prod})
set_mf(pid,"global","width",width); set_mf(pid,"global","length",length)
if col: set_mf(pid,"custom","color",col); set_mf(pid,"global","Color-Way",col)
set_mf(pid,"custom","manufacturer_sku",p["_mfr"]); set_mf(pid,"dwc","manufacturer_sku",p["_mfr"])
if gate: publish(pid); nact+=1
nr+=1;print(f" retitled {p['_mfr']} -> {nt} {'ACTIVE' if gate else 'DRAFT(Needs-Image)'}");time.sleep(0.5)
for p in arch:
api("PUT",f"/products/{p['legacyResourceId']}.json",{"product":{"id":p['legacyResourceId'],"status":"archived"}})
nar+=1;print(f" archived {p['_mfr']} ({p['title'][:28]})");time.sleep(0.3)
print(f"\nDONE — keepers activated {na}, retitled {nr} (of which activated {nact}), archived {nar}")