← 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
M shopify/tres-tintas-room-backups/_regen.ledgerA vendor-scrapers/quadrille-house/RESUME.mdA vendor-scrapers/quadrille-house/enrich-local.js
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 →