← 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
A .gitignoreA README.mdA classify.sqlA product-specs.liquidA push_class.py
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)