[object Object]

← back to Dw Product Class

DW product_class: classifier + bulk pusher + storefront specs snippet (Phase 1 live: 79,691 products classified)

5fb836bcf3b54fbef7688c0a65d638fa545f1d61 · 2026-07-25 11:01:49 -0700 · Steve Abrams

Files touched

Diff

commit 5fb836bcf3b54fbef7688c0a65d638fa545f1d61
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Jul 25 11:01:49 2026 -0700

    DW product_class: classifier + bulk pusher + storefront specs snippet (Phase 1 live: 79,691 products classified)
---
 .gitignore           |  5 ++++
 README.md            | 27 +++++++++++++++++++++
 classify.sql         | 25 ++++++++++++++++++++
 product-specs.liquid | 63 +++++++++++++++++++++++++++++++++++++++++++++++++
 push_class.py        | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 186 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ff2422c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6655277
--- /dev/null
+++ b/README.md
@@ -0,0 +1,27 @@
+# DW Product Class + Specs
+
+Adds a clean top-level classification to every active Designer Wallcoverings
+Shopify product and surfaces product specs on the storefront.
+
+## Phase 1 — Data (DONE)
+- Metafield `custom.product_class` (single-line text) = **Wallcovering | Fabric | Other**.
+- `classify.sql` builds the `dw_unified.dw_product_class` staging table from the
+  `shopify_products` mirror (ACTIVE only) via a deterministic product_type map
+  with sample-tag inheritance.
+- `push_class.py` bulk-writes the metafield to LIVE Shopify via GraphQL
+  `metafieldsSet` (BATCH env, default 25; canary + repair at BATCH=1).
+  `metafieldsSet` is transactional per call, so one deleted owner rejects its
+  whole batch — the BATCH=1 repair pass isolates the ghosts.
+
+Result: 79,691 products classified (Wallcovering 60,342 / Fabric 18,103 /
+Other 1,246); 8 stale owners deleted-from-Shopify, skipped.
+
+## Phase 2 — Storefront (pending theme access)
+- `product-specs.liquid` — right-column block: the class badge + a
+  Specifications `<dl>` (Pattern, Collection, Brand, Width, Repeats, Content,
+  Durability, Cleaning Code, Use, Direction, Country of Origin, Weight,
+  Prop 65, CA TB117). Renders only populated rows. Reads `custom.product_class`
+  + legacy `global.*` spec metafields.
+- Requires `write_themes` scope on the custom app (or manual paste in the theme editor).
+
+Store: `designer-laboratory-sandbox.myshopify.com` (the LIVE DW store).
diff --git a/classify.sql b/classify.sql
new file mode 100644
index 0000000..3b356c1
--- /dev/null
+++ b/classify.sql
@@ -0,0 +1,25 @@
+-- Rebuild dw_product_class staging from the shopify_products mirror (ACTIVE only).
+-- 3-way top-level class: Wallcovering | Fabric | Other. Deterministic map + sample-tag inheritance.
+DROP TABLE IF EXISTS dw_product_class;
+CREATE TABLE dw_product_class (
+  shopify_id text PRIMARY KEY, handle text, product_type text,
+  product_class text NOT NULL, source text,
+  pushed boolean DEFAULT false, pushed_at timestamptz, push_error text
+);
+INSERT INTO dw_product_class (shopify_id, handle, product_type, product_class, source)
+SELECT shopify_id, handle, product_type,
+  CASE
+    WHEN product_type ~* 'fabric|upholst|drapery|multipurpose' THEN 'Fabric'
+    WHEN product_type ~* 'wallcover|wallpaper|mural|acoustic|ceiling tile|upholstered walls|metallic wallcovering|xorel.*wallcovering' THEN 'Wallcovering'
+    WHEN product_type IN ('Memo Sample','Sample') THEN CASE
+        WHEN tags ~* 'wallcover|wallpaper|mural|grasscloth' AND tags !~* 'fabric|upholstery|drapery' THEN 'Wallcovering'
+        WHEN tags ~* 'fabric|upholstery|drapery|multipurpose' AND tags !~* 'wallcover|wallpaper|mural' THEN 'Fabric'
+        ELSE 'Other' END
+    WHEN product_type IN ('Pillow','Trim','Others','Windows','Museum Display Cases','Privacy','Dividers','Digital Download') THEN 'Other'
+    ELSE 'Other'
+  END,
+  CASE WHEN product_type IN ('Memo Sample','Sample') THEN 'sample-tag-inherit'
+       WHEN trim(coalesce(product_type,''))='' THEN 'blank->other'
+       ELSE 'product_type-map' END
+FROM shopify_products
+WHERE upper(status)='ACTIVE' AND shopify_id IS NOT NULL AND shopify_id <> '';
diff --git a/product-specs.liquid b/product-specs.liquid
new file mode 100644
index 0000000..597ce53
--- /dev/null
+++ b/product-specs.liquid
@@ -0,0 +1,63 @@
+{%- comment -%}
+  DW product right-column: Product Class badge + Specifications panel.
+  Drop into the product template's right/info column (e.g. main-product.liquid,
+  inside the product info <div>, near the price/description block).
+
+  Data sources:
+    - custom.product_class  (Wallcovering | Fabric | Other)  -- the new 3-way class
+    - global.* legacy spec metafields  (Width, Repeat, Content, Durability, ...)
+  Rows render ONLY when the metafield has a value, so blanks never show.
+{%- endcomment -%}
+
+{%- assign pc = product.metafields.custom.product_class.value -%}
+
+<div class="dw-specs">
+  {%- if pc != blank -%}
+    <span class="dw-class-badge dw-class-{{ pc | downcase }}">{{ pc }}</span>
+  {%- endif -%}
+
+  <h3 class="dw-specs__title">Specifications</h3>
+  <dl class="dw-specs__list">
+    {%- comment -%} label | global metafield key {%- endcomment -%}
+    {%- assign rows = "Pattern|Pattern,Collection|Collection,Brand|Brand,Width|Width,Vertical Repeat|Vert. Repeat,Horizontal Repeat|Horz. Repeat,Match|Match,Content|Content,Durability|Durability,Cleaning Code|Clean Code,Use|Use,Direction|Direction,Country of Origin|Country of Origin,Weight|Weight,Prop 65|Prop 65,CA TB117|CA TB117" | split: "," -%}
+    {%- for row in rows -%}
+      {%- assign pair = row | split: "|" -%}
+      {%- assign label = pair[0] -%}
+      {%- assign key = pair[1] -%}
+      {%- assign val = product.metafields.global[key].value -%}
+      {%- if val != blank -%}
+        <div class="dw-specs__row">
+          <dt>{{ label }}</dt>
+          <dd>
+            {{ val }}
+            {%- if key == "Width" and product.metafields.global["Width UOM"].value != blank %} {{ product.metafields.global["Width UOM"].value | downcase }}{% endif -%}
+            {%- if key == "Weight" and product.metafields.global["Weight UOM"].value != blank %} {{ product.metafields.global["Weight UOM"].value | downcase }}{% endif -%}
+          </dd>
+        </div>
+      {%- endif -%}
+    {%- endfor -%}
+  </dl>
+</div>
+
+<style>
+  .dw-specs { margin-top: 1.25rem; }
+  .dw-class-badge {
+    display: inline-block; font-size: .72rem; letter-spacing: .12em;
+    text-transform: uppercase; padding: .28em .8em; border-radius: 2px;
+    border: 1px solid currentColor; margin-bottom: 1rem;
+  }
+  .dw-class-wallcovering { color: #7a5c1e; }
+  .dw-class-fabric       { color: #6a2540; }
+  .dw-class-other        { color: #555; }
+  .dw-specs__title {
+    font-size: .8rem; letter-spacing: .16em; text-transform: uppercase;
+    margin: 0 0 .6rem; color: #666;
+  }
+  .dw-specs__list { margin: 0; }
+  .dw-specs__row {
+    display: flex; justify-content: space-between; gap: 1rem;
+    padding: .45rem 0; border-bottom: 1px solid #ececec; font-size: .9rem;
+  }
+  .dw-specs__row dt { color: #888; margin: 0; }
+  .dw-specs__row dd { margin: 0; text-align: right; color: #222; }
+</style>
diff --git a/push_class.py b/push_class.py
new file mode 100644
index 0000000..34015ba
--- /dev/null
+++ b/push_class.py
@@ -0,0 +1,66 @@
+import os, sys, json, time, urllib.request
+
+API   = os.environ["SHOP_API"]
+TOKEN = os.environ["TOKEN"]
+INFILE= os.environ["INFILE"]
+OKF   = os.environ["OKFILE"]
+ERRF  = os.environ["ERRFILE"]
+BATCH = int(os.environ.get("BATCH","25"))
+
+MUT = ("mutation($mf:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mf){"
+       "metafields{id} userErrors{field message code}}}")
+
+def post(payload):
+    req = urllib.request.Request(API, data=json.dumps(payload).encode(),
+        headers={"X-Shopify-Access-Token":TOKEN,"Content-Type":"application/json"})
+    with urllib.request.urlopen(req, timeout=60) as r:
+        return json.load(r)
+
+rows=[]
+with open(INFILE) as f:
+    for line in f:
+        line=line.rstrip("\n")
+        if not line: continue
+        gid,cls=line.split("\t")
+        rows.append((gid,cls))
+
+ok=open(OKF,"a"); err=open(ERRF,"a")
+done=0; failed=0
+i=0
+while i < len(rows):
+    chunk=rows[i:i+BATCH]
+    mf=[{"ownerId":g,"namespace":"custom","key":"product_class",
+         "type":"single_line_text_field","value":c} for g,c in chunk]
+    attempt=0
+    while True:
+        attempt+=1
+        try:
+            res=post({"query":MUT,"variables":{"mf":mf}})
+        except Exception as e:
+            if attempt<=5:
+                time.sleep(2*attempt); continue
+            for g,c in chunk: err.write(f"{g}\t{c}\tHTTP:{e}\n"); failed+=1
+            res=None; break
+        if "errors" in res and res["errors"]:
+            msg=json.dumps(res["errors"])[:200]
+            if "THROTTLED" in msg and attempt<=8:
+                time.sleep(1.5*attempt); continue
+            for g,c in chunk: err.write(f"{g}\t{c}\tGQL:{msg}\n"); failed+=1
+            break
+        ue=res["data"]["metafieldsSet"]["userErrors"]
+        if ue:
+            for g,c in chunk: err.write(f"{g}\t{c}\tUE:{json.dumps(ue)[:150]}\n"); failed+=1
+        else:
+            for g,c in chunk: ok.write(g+"\n"); done+=1
+        # throttle guard
+        thr=res.get("extensions",{}).get("cost",{}).get("throttleStatus",{})
+        avail=thr.get("currentlyAvailable",4000)
+        if avail<600: time.sleep(1.0)
+        break
+    i+=BATCH
+    if i % 500 == 0:
+        ok.flush(); err.flush()
+        print(f"  ...{i}/{len(rows)} processed (ok={done} fail={failed})", flush=True)
+
+ok.close(); err.close()
+print(f"DONE processed={len(rows)} ok={done} failed={failed}", flush=True)

(oldest)  ·  back to Dw Product Class  ·  (newest)