[object Object]

← back to Designer Wallcoverings

Add Tres Tintas spec recrawl — fills width/repeat/length/weight from vendor pages

f59d4ade576d4cacbebf115a4f0caa2b61dcbf9b · 2026-06-23 10:30:31 -0700 · Steve

Root cause: original import only captured a 4-field embedded table
(Material/Type/Pattern ref/Origin); width/repeat/length/weight were never
scraped and the WooCommerce Store API doesn't expose them. They live on the
vendor product page as prop-name/prop-value spans. Script resolves each
product's mfr SKU (pattern-ref tag) -> vendor permalink via Store API, fetches
the page, parses specs, writes canonical custom.* keys (+ global.MATCH/Finish
to dodge the product_reference type collision on custom.match/finish), drops
Needs-Width and the banned Wallpaper tag. Only fills empty fields; idempotent
ledger. Proven live on Copacabana Ebano (BS5302-2 / DWTB-10155).

Files touched

Diff

commit f59d4ade576d4cacbebf115a4f0caa2b61dcbf9b
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jun 23 10:30:31 2026 -0700

    Add Tres Tintas spec recrawl — fills width/repeat/length/weight from vendor pages
    
    Root cause: original import only captured a 4-field embedded table
    (Material/Type/Pattern ref/Origin); width/repeat/length/weight were never
    scraped and the WooCommerce Store API doesn't expose them. They live on the
    vendor product page as prop-name/prop-value spans. Script resolves each
    product's mfr SKU (pattern-ref tag) -> vendor permalink via Store API, fetches
    the page, parses specs, writes canonical custom.* keys (+ global.MATCH/Finish
    to dodge the product_reference type collision on custom.match/finish), drops
    Needs-Width and the banned Wallpaper tag. Only fills empty fields; idempotent
    ledger. Proven live on Copacabana Ebano (BS5302-2 / DWTB-10155).
---
 shopify/scripts/tres-tintas-spec-recrawl.js | 244 ++++++++++++++++++++++++++++
 1 file changed, 244 insertions(+)

diff --git a/shopify/scripts/tres-tintas-spec-recrawl.js b/shopify/scripts/tres-tintas-spec-recrawl.js
new file mode 100644
index 00000000..62c192cc
--- /dev/null
+++ b/shopify/scripts/tres-tintas-spec-recrawl.js
@@ -0,0 +1,244 @@
+#!/usr/bin/env node
+/**
+ * tres-tintas-spec-recrawl.js
+ *
+ * Fills the MISSING physical specs (width, length, pattern repeat, weight,
+ * match, finish) on Tres Tintas products by RE-CRAWLING the vendor.
+ *
+ * Why this exists: the original Tres Tintas import only captured an embedded
+ * 4-field table (Material / Type / Pattern ref / Origin). Width, Repeat,
+ * Length, Weight were NEVER scraped — so products show "Needs-Width" with
+ * empty spec blocks. The vendor's WooCommerce Store API does NOT expose these
+ * either; they live on the product PAGE as prop-name/prop-value spans.
+ *
+ * Flow per product:
+ *   1. Read the mfr SKU from the `pattern-ref-XXX` tag (e.g. BS5302-2).
+ *   2. WooCommerce Store API ?sku=XXX  ->  product permalink.
+ *   3. Fetch the permalink HTML, parse prop-name/prop-value pairs.
+ *   4. Map -> custom.width / custom.length / custom.pattern_repeat /
+ *      custom.product_weight / custom.match / custom.finish (canonical keys
+ *      the dw-specs block reads). Only writes a field that's EMPTY (never
+ *      overwrites a human-set value).
+ *   5. Drop the `Needs-Width` tag; scrub the banned `Wallpaper` tag.
+ *
+ * DRY RUN by default. APPLY=1 to write. SKU=BS5302-2 to target one product.
+ * LIMIT=N to bound. Idempotent via a done-ledger.
+ *
+ *   node tres-tintas-spec-recrawl.js                       # dry run, all
+ *   SKU=BS5302-2 APPLY=1 node tres-tintas-spec-recrawl.js  # write one (proof)
+ *   APPLY=1 node tres-tintas-spec-recrawl.js               # full run
+ */
+const fs = require('fs');
+const path = require('path');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-01';
+const APPLY = process.env.APPLY === '1';
+const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : Infinity;
+const ONLY_SKU = process.env.SKU || null;
+const VENDOR_ROOT = 'https://trestintas.com';
+const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
+
+function envval(key) {
+  const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+  const m = env.match(new RegExp(`^${key}=["']?([^"'\\n]+)`, 'm'));
+  return m ? m[1].trim() : null;
+}
+const TOKEN = envval('SHOPIFY_DRAFT_TOKEN');
+const H = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
+
+const BACKUP_DIR = path.join(__dirname, '..', 'tres-tintas-spec-backups');
+const LEDGER = path.join(BACKUP_DIR, '_done.ledger');
+fs.mkdirSync(BACKUP_DIR, { recursive: true });
+const done = new Set(fs.existsSync(LEDGER) ? fs.readFileSync(LEDGER, 'utf8').split('\n').filter(Boolean) : []);
+
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+async function gql(query, variables) {
+  const r = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
+    method: 'POST', headers: H, body: JSON.stringify({ query, variables }) });
+  const j = await r.json();
+  if (j.errors) throw new Error(JSON.stringify(j.errors));
+  return j.data;
+}
+
+// --- vendor lookups -------------------------------------------------------
+async function vendorPermalink(sku) {
+  const r = await fetch(`${VENDOR_ROOT}/wp-json/wc/store/v1/products?sku=${encodeURIComponent(sku)}`,
+    { headers: { 'User-Agent': UA, Accept: 'application/json' } });
+  if (!r.ok) return null;
+  const a = await r.json();
+  return Array.isArray(a) && a.length ? a[0].permalink : null;
+}
+
+// Parse the prop-name/prop-value spec spans off a Tres Tintas product page.
+// The page lists shared specs (Roll size, Repeat, Made in, Match) plus one
+// block per quality (Non Woven, Vinyl). We take the shared specs and the
+// Non-Woven weight/finish (DW Tres Tintas products are the Non-Woven base).
+function parseVendorSpecs(html) {
+  const re = /class="[^"]*prop-name[^"]*"[^>]*>\s*([^<]+?)\s*<\/[^>]*>\s*<[^>]*class="[^"]*prop-value[^"]*"[^>]*>\s*([^<]+?)\s*<\//gi;
+  const pairs = []; let m;
+  while ((m = re.exec(html)) !== null) pairs.push([m[1].trim(), m[2].trim()]);
+  const specs = {};
+  // first occurrence wins for shared fields
+  for (const [k, v] of pairs) {
+    const key = k.toLowerCase();
+    if (key === 'roll size' && !specs.rollSize) specs.rollSize = v;        // "50x1000 cm"
+    else if (key === 'repeat' && !specs.repeat) specs.repeat = v;          // "50"
+    else if (key === 'made in' && !specs.origin) specs.origin = v;          // "Spain"
+    else if (key === 'match' && !specs.match) specs.match = v;              // "Straight match"
+    else if (key === 'weight' && !specs.weight) specs.weight = v;           // "160 gr/m2" (Non-Woven listed first)
+    else if (key === 'finish' && !specs.finish) specs.finish = v;          // "Matt"
+    else if (key === 'base' && !specs.base) specs.base = v;                // "Non Woven"
+  }
+  return specs;
+}
+
+// cm -> "X.X in / Y m"  (DW house format, matches existing catalog)
+function cmToWidth(cm) {
+  const inches = (cm / 2.54).toFixed(1);
+  const m = (cm / 100).toString().replace(/\.0$/, '');
+  return `${inches} in / ${m} m`;
+}
+function cmToLength(cm) {
+  const feet = (cm / 30.48).toFixed(1);
+  const m = (cm / 100).toString().replace(/\.0$/, '');
+  return `${feet} ft / ${m} m`;
+}
+function cmToRepeat(cm) {
+  const inches = (cm / 2.54).toFixed(1);
+  return `${inches} in / ${cm} cm`;
+}
+
+// Build the metafield set from vendor specs. rollSize "50x1000 cm" -> width/length.
+function mapSpecs(vs) {
+  const out = {};
+  if (vs.rollSize) {
+    const m = vs.rollSize.match(/([\d.]+)\s*x\s*([\d.]+)/i);
+    if (m) { out.width = cmToWidth(parseFloat(m[1])); out.length = cmToLength(parseFloat(m[2])); }
+  }
+  if (vs.repeat) {
+    const n = parseFloat(vs.repeat);
+    if (!isNaN(n) && n > 0) out.pattern_repeat = cmToRepeat(n);
+  }
+  if (vs.weight) out.product_weight = vs.weight.replace(/gr\/m2/i, 'g/m²');
+  // NOTE: custom.match / custom.finish are pre-defined in Shopify as
+  // product_reference (not text) — writing strings there 422s. Route the
+  // match/finish text to the global.* namespace keys the spec block also reads.
+  return out;
+}
+
+// Secondary text specs that must live in the `global` namespace (custom.* keys
+// collide with product_reference definitions).
+function mapGlobalSpecs(vs) {
+  const out = {};
+  if (vs.match) out.MATCH = vs.match;
+  if (vs.finish) out.Finish = vs.finish;
+  return out;
+}
+
+async function getMetafields(id) {
+  const r = await fetch(`https://${STORE}/admin/api/${API}/products/${id}/metafields.json`, { headers: H });
+  const j = await r.json(); const map = {};
+  for (const mf of (j.metafields || [])) map[`${mf.namespace}.${mf.key}`] = mf.value;
+  return map;
+}
+async function setMetafield(id, ns, key, value) {
+  const r = await fetch(`https://${STORE}/admin/api/${API}/products/${id}/metafields.json`, {
+    method: 'POST', headers: H, body: JSON.stringify({ metafield: { namespace: ns, key, value, type: 'single_line_text_field' } }) });
+  const j = await r.json();
+  if (!j.metafield) throw new Error(`${ns}.${key}: ${JSON.stringify(j.errors || j)}`);
+}
+async function setTags(id, tags) {
+  const r = await fetch(`https://${STORE}/admin/api/${API}/products/${id}.json`, {
+    method: 'PUT', headers: H, body: JSON.stringify({ product: { id: Number(id), tags } }) });
+  const j = await r.json();
+  if (!j.product) throw new Error(`tags: ${JSON.stringify(j.errors || j)}`);
+}
+
+function refFromTags(tags) {
+  for (const t of tags) { const m = /^pattern-ref-(.+)$/i.exec(t.trim()); if (m) return m[1]; }
+  return null;
+}
+
+async function main() {
+  if (!TOKEN) throw new Error('missing SHOPIFY_DRAFT_TOKEN');
+  console.log(`MODE: ${APPLY ? 'APPLY (live writes)' : 'DRY RUN'}${ONLY_SKU ? `  SKU=${ONLY_SKU}` : ''}${LIMIT !== Infinity ? `  LIMIT=${LIMIT}` : ''}`);
+  const q = `query($cursor:String){ products(first:100, query:"vendor:'Tres Tintas'", after:$cursor){
+    pageInfo{ hasNextPage endCursor }
+    edges{ node{ id title tags } } } }`;
+
+  let cursor = null, scanned = 0, processed = 0, filled = 0, noRef = 0, noVendor = 0;
+  while (true) {
+    const conn = (await gql(q, { cursor })).products;
+    for (const e of conn.edges) {
+      const n = e.node; scanned++;
+      const id = n.id.split('/').pop();
+      const tags = (n.tags || []).map(t => t.trim());
+      const ref = refFromTags(tags);
+
+      if (ONLY_SKU && ref !== ONLY_SKU) continue;
+      if (done.has(id)) continue;
+      if (processed >= LIMIT) continue;
+      if (!ref) { noRef++; continue; }
+
+      const permalink = await vendorPermalink(ref); await sleep(120);
+      if (!permalink) { console.log(`  !! ${id} ${n.title} — no vendor page for ${ref}`); noVendor++; continue; }
+
+      const html = await (await fetch(permalink, { headers: { 'User-Agent': UA } })).text();
+      await sleep(120);
+      const vs = parseVendorSpecs(html);
+      const specs = mapSpecs(vs);
+
+      const globalSpecs = mapGlobalSpecs(vs);
+      const existing = await getMetafields(id);
+      const toWrite = {};
+      for (const [k, v] of Object.entries(specs)) {
+        const cur = existing[`custom.${k}`];
+        if (!cur || !String(cur).trim()) toWrite[k] = v;   // only fill empties
+      }
+      const toWriteGlobal = {};
+      for (const [k, v] of Object.entries(globalSpecs)) {
+        const cur = existing[`global.${k}`];
+        if (!cur || !String(cur).trim()) toWriteGlobal[k] = v;
+      }
+
+      processed++;
+      console.log(`\n  ${id} ${n.title}  [ref ${ref}]`);
+      console.log(`     vendor: ${permalink}`);
+      console.log(`     parsed: rollSize=${vs.rollSize||'-'} repeat=${vs.repeat||'-'} weight=${vs.weight||'-'} match=${vs.match||'-'} finish=${vs.finish||'-'}`);
+      const previewAll = { ...Object.fromEntries(Object.entries(toWrite).map(([k,v])=>[`custom.${k}`,v])), ...Object.fromEntries(Object.entries(toWriteGlobal).map(([k,v])=>[`global.${k}`,v])) };
+      console.log(`     will write: ${Object.keys(previewAll).length ? Object.entries(previewAll).map(([k,v])=>`${k}="${v}"`).join('  ') : '(nothing new)'}`);
+
+      if (APPLY) {
+        for (const [k, v] of Object.entries(toWrite)) {
+          try { await setMetafield(id, 'custom', k, v); await sleep(110); console.log(`     ✓ custom.${k}`); }
+          catch (err) { console.log(`     ✗ custom.${k}: ${err.message}`); }
+        }
+        for (const [k, v] of Object.entries(toWriteGlobal)) {
+          try { await setMetafield(id, 'global', k, v); await sleep(110); console.log(`     ✓ global.${k}`); }
+          catch (err) { console.log(`     ✗ global.${k}: ${err.message}`); }
+        }
+        // tag hygiene: drop Needs-Width (if width now present) + banned Wallpaper
+        let newTags = tags;
+        const haveWidth = toWrite.width || existing['custom.width'];
+        if (haveWidth) newTags = newTags.filter(t => t.toLowerCase() !== 'needs-width');
+        newTags = newTags.filter(t => t.toLowerCase() !== 'wallpaper');
+        if (newTags.length !== tags.length) {
+          try { await setTags(id, newTags.join(', ')); await sleep(110); console.log(`     ✓ tags cleaned (${tags.length}->${newTags.length})`); }
+          catch (err) { console.log(`     ✗ tags: ${err.message}`); }
+        }
+        if (Object.keys(toWrite).length || Object.keys(toWriteGlobal).length) filled++;
+        fs.appendFileSync(LEDGER, id + '\n'); done.add(id);
+      }
+    }
+    if (conn.pageInfo.hasNextPage && processed < LIMIT && !(ONLY_SKU && processed >= 1)) cursor = conn.pageInfo.endCursor;
+    else break;
+  }
+
+  console.log(`\n--- SUMMARY ---`);
+  console.log(`scanned: ${scanned} | processed: ${processed} | filled-with-new-specs: ${filled}${APPLY ? '' : ' (dry run)'}`);
+  console.log(`skipped: no-pattern-ref=${noRef}  no-vendor-page=${noVendor}`);
+  if (APPLY) console.log(`ledger -> ${LEDGER}`);
+}
+main().catch(e => { console.error(e); process.exit(1); });

← 4f33b922 auto-save: 2026-06-23T10:21:01 (1 files) — shopify/tres-tint  ·  back to Designer Wallcoverings  ·  Fix spec-sheet Download PDF: render via hidden iframe (fetch a727c094 →