[object Object]

← back to Designer Wallcoverings

auto-save: 2026-06-23T14:52:21 (4 files) — shopify/tres-tintas-room-backups/_regen.ledger vendor-scrapers/china-seas-refresh vendor-scrapers/quadrille-house/RESUME.md vendor-scrapers/quadrille-house/enrich-local.js

50a6dde44952331900ceac4ea6ac9051cb0e24fa · 2026-06-23 14:52:26 -0700 · Steve Abrams

Files touched

Diff

commit 50a6dde44952331900ceac4ea6ac9051cb0e24fa
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 23 14:52:26 2026 -0700

    auto-save: 2026-06-23T14:52:21 (4 files) — shopify/tres-tintas-room-backups/_regen.ledger vendor-scrapers/china-seas-refresh vendor-scrapers/quadrille-house/RESUME.md vendor-scrapers/quadrille-house/enrich-local.js
---
 shopify/tres-tintas-room-backups/_regen.ledger  |  39 ++++
 vendor-scrapers/quadrille-house/RESUME.md       |  38 ++++
 vendor-scrapers/quadrille-house/enrich-local.js | 239 ++++++++++++++++++++++++
 3 files changed, 316 insertions(+)

diff --git a/shopify/tres-tintas-room-backups/_regen.ledger b/shopify/tres-tintas-room-backups/_regen.ledger
index 47725cde..d89058de 100644
--- a/shopify/tres-tintas-room-backups/_regen.ledger
+++ b/shopify/tres-tintas-room-backups/_regen.ledger
@@ -339,3 +339,42 @@
 7866349256755
 7866349289523
 7866349322291
+7866349355059
+7866349387827
+7866349420595
+7866349453363
+7866349486131
+7866349518899
+7866349551667
+7866349584435
+7866349617203
+7866349649971
+7866349682739
+7866349715507
+7866349781043
+7866349813811
+7866349846579
+7866349879347
+7866349912115
+7866349944883
+7866349977651
+7866350010419
+7866350043187
+7866350075955
+7866350108723
+7866350141491
+7866350174259
+7866350207027
+7866350239795
+7866350272563
+7866350305331
+7866350338099
+7866350370867
+7866350403635
+7866350436403
+7866350469171
+7866350501939
+7866350534707
+7866350567475
+7866350600243
+7866350633011
diff --git a/vendor-scrapers/quadrille-house/RESUME.md b/vendor-scrapers/quadrille-house/RESUME.md
new file mode 100644
index 00000000..6d7d381e
--- /dev/null
+++ b/vendor-scrapers/quadrille-house/RESUME.md
@@ -0,0 +1,38 @@
+# Quadrille House + China Seas — RESUME (canonical checkpoint)
+
+Steve APPROVED full execution 2026-06-23 ("yes"). This file is the durable state
+so a rate-limit loses nothing. Update after EACH sub-step.
+
+## Ground truth (verified this run, 2026-06-23)
+- vendor_registry rows APPLIED (LOCAL Mac2 dw_unified) — confirmed present WITH
+  trailing-dash prefixes: DWQC- (Quadrille, legacy DWIY), DWAK- Alan Campbell,
+  DWHJ- Home Couture, DWCY- Charles Burger, DWUJ- Lulu DK, DWIP- Cloth and Paper,
+  DWUX- Suncloth, DWYP- Plains, DWRA- Grasscloth. All catalog_table=quadrille_house_catalog,
+  sample_price=4.25. (Earlier 0-row query was a missing-dash false negative.)
+- China Seas live = DWCW- (827 wallcovering) + DWCS- (952 fabric) in shopify_products,
+  vendor='China Seas'. NOTE registry DWCW-/DWCS- rows are STALE artifacts
+  (Intex / Cole&Son) — live store is the truth.
+- quadrille_house_catalog = 1,942 rows, 8 brands (Quadrille 615, Alan Campbell 434,
+  Suncloth 318, Home Couture 235, Plains 211, Charles Burger 83, Cloth and Paper 41,
+  Grasscloth 5). image_url 100%. cost_price=0 ALL => HELD.
+
+## STEP STATUS
+- [x] 1. Registry prefixes (LOCAL) — DONE (verified present)
+- [ ] 2. Enrich 1,942 net-new (Gemini desc+tags+colors+hex) — RUNNING (detached)
+       Runner: enrich-local.js  | log: logs/enrich-full.log | pid: logs/enrich.pid
+       Model chain: gemini-2.5-flash-lite -> flash -> 2.0-flash (free-tier first).
+       Cost est: ~$0-1.16 (free-tier models = $0; paid ballpark $0.0006/img x 1934).
+       Resume: `node enrich-local.js` (idempotent — skips rows that already have ai_tags).
+- [ ] 3. China Seas 370 confident live writes (snapshot->field-scoped write)
+       Rollback FIRST -> china-seas-refresh/data/rollback-cs-370.json
+- [ ] 3b. China Seas pass-2 recover 316 (snapshot+write)
+- [ ] 4. China Seas 129 net-new — stage DWCW- via cadence (priced via CS model)
+- [ ] 5. Net-new 1,942 — assign DW SKUs, stage -> cadence DRAFT/HELD (no cost)
+
+## How to check enrich progress
+```
+/opt/homebrew/opt/postgresql@14/bin/psql -d dw_unified -tA -c \
+ "SELECT count(*) FILTER (WHERE ai_tags IS NOT NULL AND ai_tags::text NOT IN ('','[]','null')) enriched, count(*) total FROM quadrille_house_catalog;"
+```
+
+## Cost actuals -> ~/.claude/cost-ledger.jsonl (logged after batch completes)
diff --git a/vendor-scrapers/quadrille-house/enrich-local.js b/vendor-scrapers/quadrille-house/enrich-local.js
new file mode 100644
index 00000000..75c42834
--- /dev/null
+++ b/vendor-scrapers/quadrille-house/enrich-local.js
@@ -0,0 +1,239 @@
+#!/usr/bin/env node
+// ============================================================================
+// QUADRILLE HOUSE — local enrich runner (Gemini vision: colors+tags+description)
+// ============================================================================
+// quadrille_house_catalog lives ONLY in the local Mac2 dw_unified mirror (the
+// store the cadence reads), NOT on Kamatera — so the canonical
+// /root/DW-Agents/vendor-scrapers/enrich-ai-tags.js cannot reach it. This is a
+// faithful local port of that script's prompt + aiData->DB mapping so the output
+// columns match the rest of the catalog. RESIDENTIAL mode. Crash-safe (writes
+// per-row, skips already-enriched). Banned word "wallpaper" -> "Wallcovering".
+//
+// Usage:
+//   node enrich-local.js --limit 5 --dry-run     # preview
+//   node enrich-local.js                          # full run (idempotent, resumes)
+//   node enrich-local.js --force                  # re-enrich rows with ai_tags
+// ============================================================================
+const https = require('https');
+const http = require('http');
+const url = require('url');
+const { execFileSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';
+const DB = 'dw_unified';
+const TABLE = 'quadrille_house_catalog';
+const GEMINI_HOST = 'generativelanguage.googleapis.com';
+const MODEL_CHAIN = (process.env.GEMINI_MODEL_CHAIN ||
+  'gemini-2.5-flash-lite,gemini-2.5-flash,gemini-2.0-flash').split(',').map(s => s.trim());
+let modelIdx = 0;
+let MODEL = MODEL_CHAIN[0];
+
+// Resolve Gemini key from repo .env / secrets-manager .env
+function loadKey() {
+  if (process.env.GEMINI_API_KEY) return process.env.GEMINI_API_KEY;
+  if (process.env.GOOGLE_API_KEY) return process.env.GOOGLE_API_KEY;
+  for (const f of [path.join(__dirname, '../../.env'),
+                   path.join(process.env.HOME, 'Projects/secrets-manager/.env')]) {
+    try {
+      const m = fs.readFileSync(f, 'utf8').match(/^(?:GEMINI_API_KEY|GOOGLE_API_KEY)=(.+)$/m);
+      if (m) return m[1].trim().replace(/^["']|["']$/g, '');
+    } catch (_) {}
+  }
+  throw new Error('No GEMINI_API_KEY / GOOGLE_API_KEY found');
+}
+const KEY = loadKey();
+
+const args = process.argv.slice(2);
+const DRY = args.includes('--dry-run');
+const FORCE = args.includes('--force');
+const LIMIT = (() => { const i = args.indexOf('--limit'); return i >= 0 ? parseInt(args[i + 1]) : null; })();
+const BUDGET = (() => { const i = args.indexOf('--budget'); return i >= 0 ? parseFloat(args[i + 1]) : 25; })();
+const PER_CALL = 0.0006; // Gemini Flash vision ~ $0.0006/image (paid-tier ballpark; free models = $0)
+
+const q = (sql) => execFileSync(PSQL, ['-d', DB, '-tA', '-F', '\t', '-c', sql],
+  { encoding: 'utf8', maxBuffer: 1 << 28 }).trim();
+
+function toTitleCase(s) {
+  return (s || '').replace(/\w\S*/g, t => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
+}
+
+function buildPrompt() {
+  const schema = `{
+  "backgroundColor": "the single dominant background color name (the color behind any pattern)",
+  "backgroundHex": "#XXXXXX",
+  "colors": [{"name":"color name","hex":"#XXXXXX","percentage":45}],
+  "dominantHex": "#XXXXXX",
+  "styles": ["1-2 from: Traditional, Contemporary, Art Deco, Mid-Century Modern, Victorian, Chinoiserie, Bohemian, Tropical, Coastal, Scandinavian, Industrial, Farmhouse, Minimalist"],
+  "patterns": ["1-2 from: Damask, Floral, Geometric, Botanical, Toile, Trellis, Abstract, Stripe, Solid, Textured, Chintz, Paisley, Ikat, Chevron, Herringbone"],
+  "material": "estimated material (Vinyl, Paper, Grasscloth, Silk, Non-woven, Linen, Cotton, etc.)",
+  "hasAnimals": false,
+  "animalTypes": [],
+  "description": "Exactly 3 sentences: design/pattern, mood/style, recommended use. Professional interior design tone.",
+  "imageType": "one of: scan_swatch, scan_flatbed, photo_full, photo_crop, render",
+  "physicalWidthInches": 0,
+  "physicalHeightInches": 0
+}
+COLOR RULES:
+- List ALL visible colors with hex + approx percentage of image area; percentages sum to 100
+- backgroundColor = base color behind any pattern; dominantHex = overall most prominent hex
+- Min 2, max 6 colors; specific names (Navy, Cobalt, Steel Blue — not just "Blue")`;
+  const qualityGate = `
+IMAGE QUALITY GATE — CHECK FIRST:
+- "image_rejected": true if image has marketing text overlays, brand logos, watermarks, CJK text labels, stamped product codes, furniture obscuring >30% of the pattern, promo badges, or resolution <200px in either dimension.
+- "rejection_reason": short explanation if rejected.`;
+  const imageTypeNote = `
+- "physicalWidthInches"/"physicalHeightInches": estimated physical size of the depicted material in inches (not pixels).`;
+  return `Analyze this wallcovering/fabric image. Use "Wallcovering" not "Wallpaper". Return ONLY valid JSON (no markdown):
+${schema}
+${qualityGate}
+${imageTypeNote}`;
+}
+const PROMPT = buildPrompt();
+
+function guessMime(u) {
+  const e = (u.split('?')[0].split('.').pop() || '').toLowerCase();
+  return ({ png: 'image/png', gif: 'image/gif', webp: 'image/webp', jpg: 'image/jpeg', jpeg: 'image/jpeg' })[e] || 'image/jpeg';
+}
+function fetchBuffer(u, redirects = 0) {
+  return new Promise((resolve, reject) => {
+    if (redirects > 5) return reject(new Error('too many redirects'));
+    const p = url.parse(u);
+    const mod = p.protocol === 'https:' ? https : http;
+    const req = mod.get(u, { headers: { 'User-Agent': 'Mozilla/5.0' }, timeout: 20000 }, res => {
+      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+        res.resume();
+        return fetchBuffer(url.resolve(u, res.headers.location), redirects + 1).then(resolve, reject);
+      }
+      if (res.statusCode !== 200) { res.resume(); return reject(new Error('HTTP ' + res.statusCode)); }
+      const chunks = []; res.on('data', c => chunks.push(c)); res.on('end', () => resolve(Buffer.concat(chunks)));
+    });
+    req.on('error', reject);
+    req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
+  });
+}
+function parseAllImages(v) {
+  if (!v) return [];
+  let arr = v;
+  if (typeof v === 'string') { try { arr = JSON.parse(v); } catch (_) { return v.split(',').map(x => x.trim()).filter(Boolean); } }
+  if (!Array.isArray(arr)) return [];
+  return arr.map(x => typeof x === 'string' ? x : (x && (x.url || x.src || x.image_url || x.image))).filter(Boolean);
+}
+
+const MAX_429 = 3, MAX_503 = 4, QUOTA_BREAK = 6;
+let consecutive429 = 0;
+function geminiAnalyze(candidatesIn, attempt = 0) {
+  return (async () => {
+    const candidates = (Array.isArray(candidatesIn) ? candidatesIn : [candidatesIn]).filter(Boolean);
+    let buf, resolved;
+    for (const c of candidates) { try { buf = await fetchBuffer(c); resolved = c; break; } catch (_) {} }
+    if (!buf) return { _skip: 'no fetchable image' };
+    const body = JSON.stringify({
+      contents: [{ parts: [{ inlineData: { mimeType: guessMime(resolved), data: buf.toString('base64') } }, { text: PROMPT }] }],
+      generationConfig: { temperature: 0.1, maxOutputTokens: 1024 },
+    });
+    return new Promise((resolve) => {
+      const req = https.request({
+        hostname: GEMINI_HOST, port: 443,
+        path: `/v1beta/models/${MODEL}:generateContent?key=${KEY}`,
+        method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
+      }, res => {
+        let raw = ''; res.on('data', c => raw += c);
+        res.on('end', () => {
+          if (res.statusCode === 429) {
+            if (attempt >= MAX_429) {
+              if (modelIdx < MODEL_CHAIN.length - 1) { modelIdx++; MODEL = MODEL_CHAIN[modelIdx]; consecutive429 = 0;
+                console.log(`    429 -> failover model ${MODEL}`); return resolve(geminiAnalyze(resolved, 0)); }
+              consecutive429++; console.log(`    429 exhausted (streak ${consecutive429}/${QUOTA_BREAK})`); return resolve({ _429: true });
+            }
+            return setTimeout(() => resolve(geminiAnalyze(resolved, attempt + 1)), 5000);
+          }
+          if (res.statusCode === 503 || res.statusCode === 500) {
+            // Model overload / transient — backoff + retry, then failover model.
+            if (attempt >= MAX_503) {
+              if (modelIdx < MODEL_CHAIN.length - 1) { modelIdx++; MODEL = MODEL_CHAIN[modelIdx];
+                console.log(`    ${res.statusCode} -> failover model ${MODEL}`); return resolve(geminiAnalyze(resolved, 0)); }
+              console.log(`    ${res.statusCode} exhausted on all models — skip`); return resolve({ _retry: true });
+            }
+            const wait = 2000 * (attempt + 1);
+            return setTimeout(() => resolve(geminiAnalyze(resolved, attempt + 1)), wait);
+          }
+          if (res.statusCode >= 400) { console.log(`    API ${res.statusCode}: ${raw.slice(0, 120)}`); return resolve(null); }
+          consecutive429 = 0;
+          try {
+            const env = JSON.parse(raw);
+            const text = env?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+            const m = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').match(/\{[\s\S]*\}/);
+            if (!m) return resolve(null);
+            const out = JSON.parse(m[0]);
+            out._usage = env?.usageMetadata || {};
+            resolve(out);
+          } catch (e) { console.log(`    parse err: ${e.message}`); resolve(null); }
+        });
+      });
+      req.on('error', e => { console.log(`    req err: ${e.message}`); resolve(null); });
+      req.write(body); req.end();
+    });
+  })();
+}
+
+function mapToCols(a) {
+  const bgColor = toTitleCase(a.backgroundColor || '');
+  const bgHex = (a.backgroundHex || '').toUpperCase();
+  const domHex = (a.dominantHex || '').toUpperCase();
+  const styles = (a.styles || []).map(toTitleCase);
+  const patterns = (a.patterns || []).map(toTitleCase);
+  const material = toTitleCase(a.material || '');
+  const desc = (a.description || '').replace(/wallpaper/gi, 'Wallcovering');
+  let colorDetails = [], rawColors = [];
+  const ci = a.colors || [];
+  if (ci.length && typeof ci[0] === 'object') {
+    colorDetails = ci.map(c => ({ name: toTitleCase(c.name || ''), hex: (c.hex || '').toUpperCase(), percentage: parseInt(c.percentage) || 0 })).filter(c => c.name);
+    rawColors = colorDetails.map(c => c.name);
+  } else { rawColors = ci.map(toTitleCase); colorDetails = rawColors.map(n => ({ name: n, hex: '', percentage: 0 })); }
+  const tagSet = new Set([...rawColors, ...styles, ...patterns]);
+  if (bgColor) tagSet.add(bgColor);
+  if (material) tagSet.add(material);
+  if ((a.hasAnimals || (a.animalTypes || []).length)) { tagSet.add('Animal'); (a.animalTypes || []).forEach(x => tagSet.add(toTitleCase(x))); }
+  return {
+    ai_background_color: bgColor || null,
+    ai_colors: JSON.stringify(colorDetails),
+    ai_styles: JSON.stringify(styles),
+    ai_patterns: JSON.stringify(patterns),
+    ai_tags: JSON.stringify([...tagSet].filter(Boolean)),
+    ai_description: desc || null,
+    color_hex: domHex || bgHex || (colorDetails[0] ? colorDetails[0].hex : '') || null,
+    dominant_color_hex: domHex || null,
+    image_rejected: a.image_rejected ? true : false,
+    image_rejection_reason: a.image_rejected ? (a.rejection_reason || 'unknown') : null,
+  };
+}
+
+function sqlLit(v) { return v === null ? 'NULL' : `'${String(v).replace(/'/g, "''")}'`; }
+
+async function main() {
+  const cond = FORCE ? '' : `AND (ai_tags IS NULL OR ai_tags::text IN ('','[]','null'))`;
+  const lim = LIMIT ? `LIMIT ${LIMIT}` : '';
+  const rows = q(`SELECT id, brand, mfr_sku, image_url, all_images FROM ${TABLE}
+    WHERE image_url IS NOT NULL AND image_url<>'' ${cond} ORDER BY id ${lim};`)
+    .split('\n').filter(Boolean).map(l => { const [id, brand, mfr_sku, image_url, all_images] = l.split('\t'); return { id, brand, mfr_sku, image_url, all_images }; });
+  console.log(`Enrich ${rows.length} rows (model ${MODEL}, dry=${DRY}, force=${FORCE}, budget $${BUDGET})`);
+  let done = 0, skip = 0, err = 0, spend = 0;
+  for (const r of rows) {
+    if (consecutive429 >= QUOTA_BREAK) { console.log(`QUOTA BREAK at ${done}/${rows.length} — resume later`); break; }
+    if (spend >= BUDGET) { console.log(`BUDGET CAP $${BUDGET} hit at ${done}`); break; }
+    const a = await geminiAnalyze([r.image_url, ...parseAllImages(r.all_images)]);
+    if (!a || a._skip || a._429 || a._retry) { skip++; process.stdout.write('s'); continue; }
+    const cols = mapToCols(a);
+    spend += PER_CALL;
+    if (DRY) { console.log(`\n[DRY ${r.id} ${r.brand} ${r.mfr_sku}] tags=${JSON.parse(cols.ai_tags).slice(0,6).join(',')} | hex=${cols.color_hex} | "${(cols.ai_description||'').slice(0,90)}"`); done++; continue; }
+    try {
+      const set = Object.entries(cols).map(([k, v]) => `${k}=${typeof v === 'boolean' ? v : sqlLit(v)}`).join(', ');
+      q(`UPDATE ${TABLE} SET ${set}, updated_at=now() WHERE id=${r.id};`);
+      done++; if (done % 25 === 0) process.stdout.write(`\n${done}/${rows.length} ($${spend.toFixed(3)}) `); else process.stdout.write('.');
+    } catch (e) { err++; console.log(`\n  ERR id=${r.id}: ${e.message.slice(0,100)}`); }
+  }
+  console.log(`\n\nDONE: enriched=${done} skipped=${skip} errors=${err} | est spend $${spend.toFixed(4)} (free-tier models=$0)`);
+}
+main().catch(e => { console.error(e); process.exit(1); });

← b09db983 Quadrille house Phase 0: register 8 net-new prefixes + repoi  ·  back to Designer Wallcoverings  ·  Quadrille House: launch detached enrichment + canonical RESU 78c8be65 →