← 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
A shopify/scripts/tres-tintas-spec-recrawl.js
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 →