← back to Greenland Onboard
tag-updater: tags-only (drop colliding custom.* metafields)
fc66c8b47543054aff8d3b2d5ffedda6af4932d9 · 2026-07-12 11:54:04 -0700 · Steve Abrams
Files touched
A scripts/cork-enrich.pyM scripts/update-tags.mjs
Diff
commit fc66c8b47543054aff8d3b2d5ffedda6af4932d9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jul 12 11:54:04 2026 -0700
tag-updater: tags-only (drop colliding custom.* metafields)
---
scripts/cork-enrich.py | 275 ++++++++++++++++++++++++++++++++++++++++++++++++
scripts/update-tags.mjs | 6 +-
2 files changed, 278 insertions(+), 3 deletions(-)
diff --git a/scripts/cork-enrich.py b/scripts/cork-enrich.py
new file mode 100644
index 0000000..a406576
--- /dev/null
+++ b/scripts/cork-enrich.py
@@ -0,0 +1,275 @@
+#!/usr/bin/env python3
+"""Cork enrichment — mirrors full-02-enrich.py for the 213 Greenland CORK SKUs.
+Inputs (local, $0):
+ data/dwgl-map.json mfr <-> Cork-500xxx DW# assignment (canonical scheme)
+ data/greenland_cork.ndjson specs + raw color per mfr_sku
+ public/img/Cork-500xxx.jpg pre-downloaded colorway images (PIL median-cut)
+Output:
+ data/enriched-cork.json one record per Cork-500xxx, SAME schema as enriched.json (naturals)
+Deterministic PIL median-cut + interior-designer lexicon. NO paid APIs.
+"""
+import json, os, re, sys
+from collections import Counter
+from PIL import Image
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+IMG_DIR = os.path.join(ROOT, "public", "img")
+
+MAP = json.load(open(os.path.join(ROOT, "data", "dwgl-map.json")))
+# cork specs keyed by stripped mfr_sku (9 rows have trailing whitespace)
+SPECS = {}
+for line in open(os.path.join(ROOT, "data", "greenland_cork.ndjson")):
+ r = json.loads(line)
+ SPECS[r["mfr_sku"].strip()] = r
+
+# mfr-series prefix -> coastal-city style (canonical, from SKILL)
+CITY = {
+ "S300": "Montauk", "H171": "Nantucket", "G0174": "Newport",
+ "S176": "Sag Harbor", "G0181": "Cape May", "G0155": "Palm Beach",
+ "G0111": "Kiawah", "G0110": "Hilton Head", "G0182": "Chatham",
+ "G0154": "Southampton", "G0148": "Amagansett", "H192": "Martha's Vineyard",
+}
+
+def series_of(mfr):
+ m = re.match(r"([A-Z]0?\d+)", mfr)
+ return m.group(1) if m else ""
+
+def city_of(mfr):
+ return CITY.get(series_of(mfr), "Cork")
+
+# ---- Colorway lexicon: name -> RGB anchor (interior-designer vocabulary) ----
+LEXICON = {
+ "Alabaster": (237,234,224), "Bone": (227,218,201), "Oatmeal": (214,201,178),
+ "Cream": (245,238,220), "Ivory": (240,234,214), "Linen White": (246,241,229),
+ "Greige": (188,178,162), "Mushroom": (169,157,140), "Fawn": (196,176,148),
+ "Camel": (176,138,92), "Tan": (200,170,120), "Sand": (210,190,150),
+ "Wheat": (222,196,140), "Honey": (214,170,96), "Amber": (196,140,60),
+ "Caramel": (170,120,64), "Toffee": (140,98,54), "Chestnut": (120,80,48),
+ "Chocolate": (74,52,38), "Espresso": (58,44,34), "Walnut": (92,66,44),
+ "Umber": (98,72,50), "Terracotta": (188,104,72), "Rust": (160,82,48),
+ "Clay": (176,120,92), "Brick": (150,72,56), "Sienna": (150,90,56),
+ "Oatmeal Grey": (198,192,182), "Dove": (196,194,190), "Silver": (196,198,200),
+ "Pewter": (140,140,142), "Slate": (100,110,120), "Charcoal": (64,66,70),
+ "Graphite": (54,56,60), "Ink": (32,36,44), "Onyx": (24,24,26),
+ "Ebony": (30,28,28), "Jet": (18,18,20),
+ "Fog": (200,204,206), "Mist": (210,214,214), "Ash": (150,150,148),
+ "Stone": (168,162,152), "Flint": (120,120,116), "Smoke": (128,128,130),
+ "Celadon": (168,190,168), "Sage": (150,168,138), "Moss": (108,124,90),
+ "Fern": (96,120,80), "Olive": (120,116,72), "Sagebrush": (140,148,120),
+ "Eucalyptus": (150,172,150), "Pine": (58,88,64), "Forest": (44,72,52),
+ "Hunter": (48,80,60), "Verdigris": (100,150,130), "Jade": (80,140,110),
+ "Wedgwood": (108,144,180), "Slate Blue": (96,120,150), "Denim": (78,104,140),
+ "Chambray": (140,164,190), "Sky": (168,196,220), "Powder Blue": (188,208,224),
+ "Cornflower": (120,150,200), "Indigo": (52,64,110), "Navy": (36,48,84),
+ "Midnight": (24,34,60), "Prussian": (40,60,90), "Teal": (44,120,130),
+ "Peacock": (34,108,120), "Aegean": (66,110,140), "Marine": (48,80,120),
+ "Aubergine": (88,58,84), "Plum": (110,72,110), "Mauve": (168,140,160),
+ "Heather": (150,132,158), "Lilac": (188,170,198), "Wisteria": (176,160,196),
+ "Amethyst": (120,90,150), "Orchid": (170,120,170), "Wine": (110,44,58),
+ "Burgundy": (96,40,52), "Merlot": (86,36,48), "Claret": (120,44,56),
+ "Blush": (224,196,190), "Rose": (206,150,150), "Dusty Rose": (196,158,156),
+ "Coral": (222,140,120), "Salmon": (228,158,138), "Apricot": (228,180,140),
+ "Peach": (240,200,168), "Champagne": (232,214,186), "Buff": (222,204,168),
+ "Gold": (198,158,72), "Brass": (176,140,72), "Bronze": (140,110,66),
+ "Copper": (168,104,68), "Antique Gold": (168,138,84),
+ "Snow": (248,248,246), "White": (250,250,248), "Pearl": (238,236,230),
+ "Taupe": (170,156,140), "Khaki": (176,160,120), "Putty": (196,186,168),
+}
+
+def hex_to_rgb(h):
+ h = h.lstrip("#")
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
+
+def rgb_to_hex(rgb):
+ return "#%02x%02x%02x" % tuple(int(c) for c in rgb)
+
+def nearest_name(rgb):
+ best, bd = None, 1e9
+ for name, anchor in LEXICON.items():
+ d = sum((a - b) ** 2 for a, b in zip(rgb, anchor))
+ if d < bd:
+ bd, best = d, name
+ return best
+
+def dominant_and_top(path):
+ """median-cut dominant hex + top-3 colors with %."""
+ im = Image.open(path).convert("RGB")
+ im.thumbnail((160, 160))
+ q = im.quantize(colors=8, method=Image.MEDIANCUT)
+ pal = q.getpalette()
+ counts = Counter(q.getdata())
+ total = sum(counts.values())
+ ranked = counts.most_common()
+ tops = []
+ for idx, cnt in ranked[:3]:
+ rgb = (pal[idx*3], pal[idx*3+1], pal[idx*3+2])
+ tops.append({"hex": rgb_to_hex(rgb), "pct": round(100*cnt/total, 1)})
+ dom = tops[0]["hex"] if tops else "#888888"
+ return dom, tops
+
+def color_family(hexv):
+ r, g, b = hex_to_rgb(hexv)
+ mx, mn = max(r, g, b), min(r, g, b)
+ light = (r + g + b) / 3
+ sat = mx - mn
+ if light > 225 and sat < 24:
+ return "White"
+ if light < 55:
+ return "Black"
+ if sat < 28:
+ return "Neutral"
+ if r >= g and r >= b:
+ if g > b and abs(r - g) < 40 and g > 110:
+ return "Warm"
+ if b > g:
+ return "Purple" if b > 90 and r - b < 80 else "Red"
+ return "Red" if r - g > 40 else "Warm"
+ if g >= r and g >= b:
+ return "Green"
+ if b >= r and b >= g:
+ return "Purple" if r > g and r > 90 else "Blue"
+ return "Neutral"
+
+TYPO = {
+ "pruple": "Purple", "dusry": "Dusty", "dusdy": "Dusty", "gery": "Grey",
+ "gray": "Gray", "beigh": "Beige", "biege": "Beige", "slver": "Silver",
+ "chacoal": "Charcoal", "chaocoal": "Charcoal", "brnze": "Bronze",
+ "goldan": "Golden", "bluu": "Blue", "grean": "Green", "greeen": "Green",
+ "brwon": "Brown", "brownn": "Brown", "natual": "Natural", "naturel": "Natural",
+ "ivroy": "Ivory", "creme": "Cream", "taupw": "Taupe", "olvie": "Olive",
+ "steal": "Steel", "sliver": "Silver", "champange": "Champagne",
+ "wheit": "White", "whte": "White", "blak": "Black", "blck": "Black",
+}
+DIM_RE = re.compile(r"(width\s*:|cm\b|meter|\d+\s*(cm|m|mm)\b|/\s*\d+|\d+['\"]|colour\s*:.*\d)", re.I)
+
+def is_dimension_junk(c):
+ if not c or not c.strip():
+ return True
+ s = c.strip()
+ if DIM_RE.search(s):
+ return True
+ letters = sum(ch.isalpha() for ch in s)
+ if letters < 2:
+ return True
+ # bare mfr code (letters+digits, no space) is not a color name
+ if re.fullmatch(r"[A-Z]{1,4}\d{2,}[A-Z0-9-]*", s):
+ return True
+ return False
+
+def title_case(s):
+ small = {"of", "and", "the", "in", "on", "with", "a"}
+ words = re.split(r"(\s+|-|/)", s.strip())
+ out = []
+ for w in words:
+ if not w or w.isspace() or w in "-/":
+ out.append(w); continue
+ lw = w.lower()
+ out.append(lw if (lw in small and out) else lw.capitalize())
+ return "".join(out)
+
+def clean_color(raw, dom_hex):
+ raw = (raw or "").replace("\xa0", " ") # kill non-breaking spaces
+ raw = re.sub(r"\s+", " ", raw).strip() # collapse doubled spaces
+ if is_dimension_junk(raw):
+ return nearest_name(hex_to_rgb(dom_hex)), "derived"
+ s = raw
+ toks = re.split(r"(\s+)", s)
+ fixed = []
+ for t in toks:
+ key = re.sub(r"[^a-z]", "", t.lower())
+ if key in TYPO:
+ fixed.append(TYPO[key])
+ else:
+ fixed.append(t)
+ s = "".join(fixed)
+ # drop a trailing/standalone "Cork" so titles don't read "Cork Cork Wallcovering"
+ s = re.sub(r"\bcork\b", "", s, flags=re.I)
+ s = re.sub(r"\s+", " ", s).strip()
+ if not s: # color was literally just "Cork" -> derive from hex
+ return nearest_name(hex_to_rgb(dom_hex)), "derived"
+ s = title_case(s)
+ return s, "cleaned"
+
+def packaging(min_order):
+ """Cork = $59.50/yд in 6-yard increments (min 6 / step 6)."""
+ mo = (min_order or "").strip()
+ m = re.search(r"(\d+)\s*yard", mo, re.I)
+ if m:
+ n = int(m.group(1))
+ return n, n
+ return 6, 6
+
+# cork color name lives in pattern_name (leaf) or color_name (child colorway)
+def raw_color_of(spec):
+ c = spec.get("color_name") or spec.get("pattern_name") or ""
+ return c.replace("\xa0", " ").strip()
+
+def build_title(city, color):
+ return f"{city}-{color} Cork Wallcovering | Phillipe Romano"
+
+def main():
+ out = []
+ missing_img = 0
+ for r in MAP:
+ dwSku = r["dwsku"]
+ mfr = r["mfr"]
+ spec = SPECS.get(mfr, {})
+ path = os.path.join(IMG_DIR, f"{dwSku}.jpg")
+ try:
+ dom_hex, tops = dominant_and_top(path)
+ except Exception:
+ dom_hex, tops = "#888888", [{"hex": "#888888", "pct": 100.0}]
+ missing_img += 1
+ raw = raw_color_of(spec)
+ color, csrc = clean_color(raw, dom_hex)
+ fam = color_family(dom_hex)
+ city = city_of(mfr)
+ mn, st = packaging(spec.get("minimum_order", ""))
+ title = build_title(city, color)
+ alt = f"{title} — {dwSku}"
+ # tags >=2: material(Cork), color-family, city style, brand, quotes
+ tags = ["Cork", fam, city, "Phillipe Romano", "quotes"]
+ tags = [t for t in dict.fromkeys(tags) if t]
+ rec = {
+ "dwSku": dwSku, "joinKey": mfr, "prefix": "Cork",
+ "material": "Cork", "city": city,
+ "rawColor": raw, "cleanColor": color, "colorSource": csrc,
+ "hex": dom_hex, "topColors": tops, "colorFamily": fam,
+ "title": title, "alt": alt, "tags": tags,
+ "min": mn, "step": st, "minimumOrder": spec.get("minimum_order", ""),
+ "mfrSku": mfr, "mfrModel": mfr,
+ "collectionCode": series_of(mfr), "collectionName": spec.get("collection", ""),
+ "width": spec.get("width", ""), "use": spec.get("use", ""),
+ "repeat": spec.get("pattern_repeat", ""), "fireRating": spec.get("fire_rating_us", ""),
+ "backing": spec.get("backing", ""), "weight": spec.get("weight", ""),
+ "installUrl": "", "catalogUrl": "",
+ "productUrl": spec.get("product_url", ""),
+ "imageLocal": f"public/img/{dwSku}.jpg",
+ }
+ out.append(rec)
+ json.dump(out, open(os.path.join(ROOT, "data", "enriched-cork.json"), "w"), indent=1)
+ fams = Counter(x["colorFamily"] for x in out)
+ csrc = Counter(x["colorSource"] for x in out)
+ cities = Counter(x["city"] for x in out)
+ pk = Counter((x["min"], x["step"]) for x in out)
+ # coverage
+ cov = {
+ "records": len(out),
+ "hex_populated": sum(1 for x in out if x["hex"] and x["hex"] != "#888888"),
+ "topColors_3": sum(1 for x in out if len(x["topColors"]) == 3),
+ "color_nonempty": sum(1 for x in out if x["cleanColor"]),
+ "title_nonempty": sum(1 for x in out if x["title"]),
+ "alt_nonempty": sum(1 for x in out if x["alt"]),
+ "tags_ge2": sum(1 for x in out if len(x["tags"]) >= 2),
+ "missing_image": missing_img,
+ }
+ print(json.dumps({
+ "coverage": cov,
+ "colorFamilies": dict(fams.most_common()),
+ "colorSource": dict(csrc),
+ "cities": dict(cities.most_common()),
+ "packaging": {f"{k[0]}/{k[1]}": v for k, v in pk.items()},
+ }, indent=2))
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/update-tags.mjs b/scripts/update-tags.mjs
index 1c980ea..dc6dca9 100644
--- a/scripts/update-tags.mjs
+++ b/scripts/update-tags.mjs
@@ -42,10 +42,10 @@ for (const r of rows) {
r.material, r.city, 'Phillipe Romano', 'quotes',
`Style: ${r.style}`, `Color: ${r.cleanColor}`, `Hue: ${r.hue}`, `Hex: ${r.hex}`,
].filter(Boolean).join(', ');
- const mf = (key, value) => ({ namespace: 'custom', key, type: 'single_line_text_field', value: String(value || '') });
try {
- await put(`/products/${pid}.json`, { product: { id: pid, tags,
- metafields: [mf('hex', r.hex), mf('hue', r.hue), mf('style', r.style), mf('color', r.cleanColor)] } });
+ // tags only — Hex/Style/Color/Hue live in the tag set; skip custom.* metafields
+ // (a pre-existing store def types one of them product_reference → 422s the whole PUT)
+ await put(`/products/${pid}.json`, { product: { id: pid, tags } });
ok++; if (ok % 50 === 0) console.log(` ...updated ${ok}`);
await sleep(520);
} catch (e) { fail++; console.log(` FAIL ${r.dwSku}: ${String(e.message).slice(0, 120)}`); await sleep(700); }
← 2698f6b auto-save: 2026-07-12T11:48:02 (1 files) — data/published.nd
·
back to Greenland Onboard
·
Add cork enrichment: 213 Cork-500xxx locally enriched (PIL m 6ab7e31 →