← back to Dw Material Reclassify
apply_tags.py
95 lines
#!/usr/bin/env python3
"""
Wave-1 WRITE: apply a `material-group-<bucket>` tag to each FREE-resolved
New-Arrival Wallcovering (tier1 Material: line + tier1.5 body statement).
Boost SD filters on tags natively, so this tag becomes the Material facet source.
Adding a tag is INVISIBLE on the storefront until the Boost filter is configured to
use it, and is fully reversible (remove the tag).
python3 apply_tags.py # DRY RUN: print the write set + tag counts
python3 apply_tags.py --canary 3 # WRITE to first 3 products, then verify
python3 apply_tags.py --apply # WRITE to all resolved products
Idempotent: skips a product that already carries its material-group tag.
Reads SHOPIFY_ADMIN_TOKEN from ~/Projects/secrets-manager/.env. No cost (Admin API
writes are free).
"""
import os, re, sys, json, time, subprocess, urllib.request, urllib.error
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import classify as C
DOM = "designer-laboratory-sandbox.myshopify.com"
ENV = os.path.expanduser("~/Projects/secrets-manager/.env")
def token():
for line in open(ENV):
if line.startswith("SHOPIFY_ADMIN_TOKEN="):
return line.split("=",1)[1].strip().strip('"').strip("'")
sys.exit("no SHOPIFY_ADMIN_TOKEN")
def slug(bucket):
return "material-group-" + re.sub(r"[^a-z0-9]+","-",bucket.lower()).strip("-")
def resolved_set():
"""[(numeric_id, title, bucket, tag)] for FREE-resolved rows only."""
out=[]
for r in C.load_rows(scope_all=("--all" in sys.argv)):
tier,bucket,ev = C.classify(r)
if bucket and tier in ("tier1_material_line","tier15_body"):
gid=r.get("shopify_id") or ""
nid=gid.rsplit("/",1)[-1]
if nid.isdigit():
out.append((nid, r["title"], bucket, slug(bucket)))
return out
def api(method, path, tok, body=None):
req=urllib.request.Request(f"https://{DOM}/admin/api/2024-10/{path}",
method=method, headers={"X-Shopify-Access-Token":tok,"Content-Type":"application/json"},
data=json.dumps(body).encode() if body else None)
return json.load(urllib.request.urlopen(req))
def apply_one(nid, tag, tok):
p=api("GET", f"products/{nid}.json?fields=id,tags", tok)["product"]
tags=[t.strip() for t in (p.get("tags") or "").split(",") if t.strip()]
if tag in tags:
return "skip(present)"
tags.append(tag)
api("PUT", f"products/{nid}.json", tok, {"product":{"id":int(nid),"tags":", ".join(tags)}})
# verify
v=api("GET", f"products/{nid}.json?fields=id,tags", tok)["product"]
return "OK" if tag in (v.get("tags") or "") else "FAIL(verify)"
def main():
rows=resolved_set()
from collections import Counter
by_tag=Counter(t for _,_,_,t in rows)
print(f"Wave-1 tag write set: {len(rows)} products")
for t,n in by_tag.most_common(): print(f" {t:28s} {n}")
canary = None
if "--canary" in sys.argv:
canary = int(sys.argv[sys.argv.index("--canary")+1])
do_write = ("--apply" in sys.argv) or (canary is not None)
if not do_write:
print("\nDRY RUN — no writes. Re-run with --canary N or --apply."); return
tok=token()
target = rows[:canary] if canary is not None else rows
print(f"\nWRITING to {len(target)} products (canary={canary})...")
res=Counter()
for nid,title,bucket,tag in target:
try:
r=apply_one(nid,tag,tok)
except urllib.error.HTTPError as e:
r=f"HTTP{e.code}"
res[r]+=1
if canary is not None:
print(f" {r:16s} {tag:26s} {title[:40]}")
time.sleep(0.3) # gentle on the 2 req/s REST limit
print("result:", dict(res))
print("Cost: $0 (Admin API writes are free).")
if __name__=="__main__":
main()