[object Object]

← back to Designer Wallcoverings

Onboard Daisy Bennett Natural Wallcoverings (WallQuest→Phillipe Romano, 63 SKUs, isolated daisy_bennett_catalog)

532c47beeb2a5414ac74c1cda6cbed477f8c97bf · 2026-07-06 11:13:04 -0700 · Steve

Files touched

Diff

commit 532c47beeb2a5414ac74c1cda6cbed477f8c97bf
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 6 11:13:04 2026 -0700

    Onboard Daisy Bennett Natural Wallcoverings (WallQuest→Phillipe Romano, 63 SKUs, isolated daisy_bennett_catalog)
---
 scripts/wallquest-refresh/enrich-daisy-bennett.cjs |  73 +++++++++++
 .../wallquest-refresh/load-db-daisy-bennett.cjs    |  89 +++++++++++++
 .../wallquest-refresh/normalize-daisy-bennett.cjs  |  55 ++++++++
 .../wallquest-refresh/onboard-daisy-bennett.cjs    | 141 +++++++++++++++++++++
 4 files changed, 358 insertions(+)

diff --git a/scripts/wallquest-refresh/enrich-daisy-bennett.cjs b/scripts/wallquest-refresh/enrich-daisy-bennett.cjs
new file mode 100644
index 00000000..38dce24b
--- /dev/null
+++ b/scripts/wallquest-refresh/enrich-daisy-bennett.cjs
@@ -0,0 +1,73 @@
+// Phase 3 — Gemini vision enrichment for the Daisy Bennett naturals book.
+// Reads normalized records, enriches each primary image with colors/hex/styles/description,
+// writes an enriched JSON. Local cost logged. No DB write here (kept file-based pre-gate).
+const fs = require('fs');
+const https = require('https');
+const http = require('http');
+
+const KEY = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
+if (!KEY) { console.error('no GEMINI_API_KEY'); process.exit(1); }
+const MODEL = process.env.GEMINI_MODEL || 'gemini-3.5-flash';
+const URL_ = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${KEY}`;
+const IN = process.env.IN || '/tmp/wq-daisy-bennett-normalized.json';
+const OUT = process.env.OUT || '/tmp/wq-daisy-bennett-enriched.json';
+
+const PROMPT = `You are cataloguing a NATURAL-TEXTURE wallcovering (grasscloth, raffia, paperweave, cork, mica, bamboo, netting, sisal, etc.). Analyze the image and return STRICT JSON only:
+{"dominant_color":"plain-english name","dominant_hex":"#RRGGBB","background_hex":"#RRGGBB","all_colors":["name",...max4],"styles":["Coastal","Organic Modern",...max4],"material_texture":"grasscloth|raffia|paperweave|cork|mica|bamboo|netting|sisal|linen|other","description":"1-2 sentence luxury retail description, NO brand names","image_type":"scan_swatch|photo_full|room_scene"}
+Return ONLY the JSON object, no markdown.`;
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+function dl(url) {
+  return new Promise((resolve, reject) => {
+    const req = (u, n = 0) => {
+      if (n > 5) return reject(new Error('redirects'));
+      (u.startsWith('https') ? https : http).get(u, { timeout: 15000 }, res => {
+        if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) return req(res.headers.location, n + 1);
+        if (res.statusCode !== 200) { res.resume(); return reject(new Error('HTTP ' + res.statusCode)); }
+        const c = []; res.on('data', d => c.push(d));
+        res.on('end', () => { const b = Buffer.concat(c); resolve({ base64: b.toString('base64'), mime: (res.headers['content-type'] || 'image/jpeg').split(';')[0].trim() }); });
+        res.on('error', reject);
+      }).on('error', reject).on('timeout', function () { this.destroy(); reject(new Error('timeout')); });
+    }; req(url);
+  });
+}
+function gemini(base64, mime) {
+  const payload = { contents: [{ parts: [{ text: PROMPT }, { inline_data: { mime_type: mime, data: base64 } }] }], generationConfig: { temperature: 0.2, maxOutputTokens: 2048, responseMimeType: 'application/json' } };
+  return new Promise((resolve, reject) => {
+    const u = new URL(URL_); const body = JSON.stringify(payload);
+    const r = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 40000 }, res => {
+      let d = ''; res.on('data', c => d += c);
+      res.on('end', () => { try { const j = JSON.parse(d); if (j.error) return reject(new Error(j.error.message)); let t = j.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || ''; t = t.replace(/^```json\s*/i, '').replace(/```\s*$/, '').trim(); const m = t.match(/\{[\s\S]*\}/); resolve(JSON.parse(m ? m[0] : t)); } catch (e) { reject(e); } });
+    });
+    r.on('error', reject); r.on('timeout', () => { r.destroy(); reject(new Error('gemini timeout')); });
+    r.write(body); r.end();
+  });
+}
+
+(async () => {
+  const recs = JSON.parse(fs.readFileSync(IN, 'utf8'));
+  let ok = 0, err = 0, calls = 0;
+  for (const rec of recs) {
+    const img = (rec.images || [])[0];
+    if (!img) { rec.enrich_error = 'no image'; err++; continue; }
+    try {
+      const { base64, mime } = await dl(img);
+      const g = await gemini(base64, mime); calls++;
+      rec.ai_dominant_color = g.dominant_color;
+      rec.color_hex = g.dominant_hex;
+      rec.ai_background_color = g.background_hex;
+      rec.ai_colors = g.all_colors;
+      rec.ai_styles = g.styles;
+      rec.ai_material_texture = g.material_texture;
+      rec.ai_description = g.description;
+      rec.ai_image_type = g.image_type;
+      ok++;
+      process.stderr.write(`\r[${ok + err}/${recs.length}] ok=${ok} err=${err}  ${rec.dw_sku} ${g.dominant_color||''}          `);
+    } catch (e) { rec.enrich_error = String(e).slice(0, 100); err++; }
+    await sleep(1500); // rate limit
+  }
+  fs.writeFileSync(OUT, JSON.stringify(recs, null, 2));
+  const cost = (calls * 0.0006).toFixed(4);
+  console.error(`\nENRICH DONE ok=${ok} err=${err} calls=${calls} → ${OUT}`);
+  console.error(`COST ~$${cost} (Gemini ${MODEL}, ${calls} img calls @ ~$0.0006)`);
+})();
diff --git a/scripts/wallquest-refresh/load-db-daisy-bennett.cjs b/scripts/wallquest-refresh/load-db-daisy-bennett.cjs
new file mode 100644
index 00000000..9cfb871a
--- /dev/null
+++ b/scripts/wallquest-refresh/load-db-daisy-bennett.cjs
@@ -0,0 +1,89 @@
+// Phase 6 — load Daisy Bennett Natural Wallcoverings into dw_unified.daisy_bennett_catalog
+// with full specs + Phillipe Romano private-label routing. Dedicated table (isolated from
+// wallquest_catalog / Malibu) so the leak-scanner + weekly refresh never treat these as Malibu.
+// Idempotent upsert keyed on mfr_sku. Unlike Carl Robinson (beach-city rename), Daisy Bennett's
+// vendor pattern names (Boccali, Venezia — neutral Italian words, no vendor leak) are PRESERVED.
+// NO Shopify publish here — that stays Steve-gated (real_vendor_name never goes customer-facing).
+const fs = require('fs');
+const { Client } = require('pg');
+
+const CONN = process.env.DATABASE_URL || 'postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified';
+const ENR = process.env.ENR || '/tmp/wq-daisy-bennett-enriched.json';
+const NORM = process.env.NORM || '/tmp/wq-daisy-bennett-normalized.json';
+const recs = JSON.parse(fs.readFileSync(fs.existsSync(ENR) ? ENR : NORM, 'utf8'));
+
+const DDL = `
+CREATE TABLE IF NOT EXISTS daisy_bennett_catalog (
+  id serial PRIMARY KEY,
+  mfr_sku text UNIQUE NOT NULL,
+  dw_sku text NOT NULL,
+  pattern_name text,           -- vendor design name (Boccali Grass, Venezia Grass, …)
+  color_name text,
+  collection text,             -- pattern family bucket
+  material text,
+  product_type text DEFAULT 'Wallcovering',
+  price_retail numeric,        -- our cost
+  our_price numeric,           -- our retail (cost/0.65/0.85)
+  price_dw numeric,
+  width text, length text, repeat_v text, match_type text, backing text,
+  specs jsonb,
+  image_url text,
+  all_images jsonb,
+  local_image_path text,
+  color_hex text,
+  ai_dominant_color text,
+  ai_background_color text,
+  ai_colors jsonb,
+  ai_styles jsonb,
+  ai_description text,
+  room_setting_images jsonb DEFAULT '[]'::jsonb,
+  private_label_vendor text DEFAULT 'Phillipe Romano',
+  real_vendor_name text DEFAULT 'WallQuest',
+  source_book text DEFAULT 'Daisy Bennett - Natural Wallcoverings',
+  on_shopify boolean DEFAULT false,
+  product_url text,
+  created_at timestamptz DEFAULT now(),
+  updated_at timestamptz DEFAULT now()
+);`;
+
+(async () => {
+  const db = new Client({ connectionString: CONN });
+  await db.connect();
+  await db.query(DDL);
+  let n = 0;
+  for (const r of recs) {
+    // collection bucket = leading material word of the pattern (Grass, Weave, Cloth, …)
+    const collection = (r.pattern || '').split(/\s+/).slice(-1)[0] || 'Naturals';
+    await db.query(`
+      INSERT INTO daisy_bennett_catalog
+        (mfr_sku,dw_sku,pattern_name,color_name,collection,material,product_type,
+         price_retail,our_price,price_dw,width,length,repeat_v,match_type,backing,specs,
+         image_url,all_images,color_hex,ai_dominant_color,ai_background_color,
+         ai_colors,ai_styles,ai_description,product_url,updated_at)
+      VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,now())
+      ON CONFLICT (mfr_sku) DO UPDATE SET
+        pattern_name=EXCLUDED.pattern_name, color_name=EXCLUDED.color_name, collection=EXCLUDED.collection,
+        material=EXCLUDED.material, price_retail=EXCLUDED.price_retail, our_price=EXCLUDED.our_price,
+        price_dw=EXCLUDED.price_dw, width=EXCLUDED.width, length=EXCLUDED.length, repeat_v=EXCLUDED.repeat_v,
+        match_type=EXCLUDED.match_type, backing=EXCLUDED.backing, specs=EXCLUDED.specs,
+        image_url=EXCLUDED.image_url, all_images=EXCLUDED.all_images,
+        color_hex=EXCLUDED.color_hex, ai_dominant_color=EXCLUDED.ai_dominant_color,
+        ai_background_color=EXCLUDED.ai_background_color, ai_colors=EXCLUDED.ai_colors,
+        ai_styles=EXCLUDED.ai_styles, ai_description=EXCLUDED.ai_description,
+        product_url=EXCLUDED.product_url, updated_at=now()`,
+      [r.mfr_sku, r.dw_sku, r.pattern || '', r.color || '', collection, r.material || '', r.product_type || 'Wallcovering',
+       r.price_retail ?? null, r.our_price ?? null, r.price_retail ? +(r.price_retail / 0.55).toFixed(2) : null,
+       r.width || '', r.length || '', r.repeat || '', r.match || '', r.backing || '', JSON.stringify(r.specs || {}),
+       (r.images || [])[0] || '', JSON.stringify(r.images || []),
+       r.color_hex || null, r.ai_dominant_color || null, r.ai_background_color || null,
+       JSON.stringify(r.ai_colors || []), JSON.stringify(r.ai_styles || []), r.ai_description || null,
+       r.product_url || '']);
+    n++;
+  }
+  const { rows } = await db.query(`SELECT count(*) total, count(*) FILTER (WHERE price_retail>0) priced,
+    count(*) FILTER (WHERE ai_description IS NOT NULL) enriched,
+    count(DISTINCT collection) families FROM daisy_bennett_catalog`);
+  console.log(`loaded/updated ${n} rows into dw_unified.daisy_bennett_catalog`);
+  console.log('DB tally:', rows[0]);
+  await db.end();
+})().catch(e => { console.error('DB ERROR:', e.message); process.exit(1); });
diff --git a/scripts/wallquest-refresh/normalize-daisy-bennett.cjs b/scripts/wallquest-refresh/normalize-daisy-bennett.cjs
new file mode 100644
index 00000000..2f412271
--- /dev/null
+++ b/scripts/wallquest-refresh/normalize-daisy-bennett.cjs
@@ -0,0 +1,55 @@
+// Phase 2 — normalize the raw Daisy Bennett scrape → structured records + provisional DW SKUs.
+// RAW source = /tmp/wq-daisy-bennett.jsonl (WallQuest). TARGET private label = Phillipe Romano
+// (naturals book, same house as Carl Robinson). Mfr SKU family = BX##### (bx10121 → BX10121).
+// dw_sku prefix DWAY- is PROVISIONAL (phone-safe: A,Y allowed; not registered until gated publish).
+// This pass structures vendor data + surfaces the distinct pattern families; NO rename, NO publish.
+const fs = require('fs');
+const IN = process.env.IN || '/tmp/wq-daisy-bennett.jsonl';
+const OUT = process.env.OUT || '/tmp/wq-daisy-bennett-normalized.json';
+const PREFIX = process.env.PREFIX || 'DWAY'; // provisional; confirm at publish gate
+
+const rows = fs.readFileSync(IN, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse)
+  .filter(r => r.title && r.priceValue); // keep only fully-scraped rows
+
+const rec = rows.map(r => {
+  const slug = r.url.replace(/https?:\/\/[^/]+\//i, '').split('?')[0].replace(/\/$/, '');
+  const mfr_sku = slug.toUpperCase();                     // bx10121 → BX10121
+  const numTail = (mfr_sku.match(/\d+/) || ['0'])[0];     // 10121
+  const dw_sku = `${PREFIX}-${numTail}`;
+  const s = r.specs || {};
+  // Vendor titles read "Pattern - Color" (e.g. "Boccali Grass - Natural Tan Cream")
+  const pattern = (s['Design Name'] || (r.title.split(' - ')[0] || '')).trim();
+  const color   = (s['Colorway Name'] || (r.title.split(' - ').slice(1).join(' - ') || '')).trim();
+  const cost = parseFloat(String(r.priceValue).replace(/,/g, ''));
+  return {
+    mfr_sku, dw_sku,
+    src_title: r.title,
+    pattern, color,
+    price_retail: cost,                          // = our COST (WallQuest portal convention)
+    our_price: +(cost / 0.65 / 0.85).toFixed(2), // new-product retail formula
+    material: s['Material'] || '',
+    backing: s['Backing Material'] || '',
+    width: s['Roll Width'] || s['Width'] || '',
+    length: s['Roll Length'] || s['Length'] || '',
+    repeat: s['Design Repeat'] || s['Vertical Repeat'] || '',
+    match: s['Match'] || s['Design Match'] || '',
+    product_type: s['Product Type'] || 'Wallcovering',
+    specs: s,
+    images: r.imgs || [],
+    product_url: r.url,
+  };
+});
+
+fs.writeFileSync(OUT, JSON.stringify(rec, null, 2));
+
+// Report distinct pattern families
+const byPattern = {};
+for (const x of rec) (byPattern[x.pattern] = byPattern[x.pattern] || []).push(x.color);
+console.log(`normalized ${rec.length} rows → ${OUT}  (prefix ${PREFIX}-)`);
+console.log(`\nDISTINCT PATTERN FAMILIES (${Object.keys(byPattern).length}):`);
+for (const [p, cols] of Object.entries(byPattern).sort()) {
+  console.log(`  • ${p}  (${cols.length} colorways: ${cols.join(', ')})`);
+}
+const noPrice = rows.length - rec.length;
+if (noPrice) console.log(`\n⚠ ${noPrice} scraped rows dropped (missing title/price) — will re-check`);
+if (rec.length) console.log(`\ncost range: $${Math.min(...rec.map(r=>r.price_retail))}–$${Math.max(...rec.map(r=>r.price_retail))}`);
diff --git a/scripts/wallquest-refresh/onboard-daisy-bennett.cjs b/scripts/wallquest-refresh/onboard-daisy-bennett.cjs
new file mode 100644
index 00000000..79187b17
--- /dev/null
+++ b/scripts/wallquest-refresh/onboard-daisy-bennett.cjs
@@ -0,0 +1,141 @@
+// Daisy Bennett — Natural Wallcoverings — collection ONBOARD scraper.
+// Mirrors onboard-carl-robinson.cjs exactly (sibling-terminal convention): discovers
+// product-detail URLs from the collection listing, then login-scrapes each
+// (specs/price/images/title/sku). Captures RAW vendor (WallQuest) data only — NO rename,
+// NO SKU assignment, NO publish. Private-label routing happens in later phases.
+//   MODE=discover  → paginate listing, save product URLs (cheap; ~1 bb session)
+//   MODE=scrape    → login-scrape each discovered URL → JSONL
+const Browserbase = require('@browserbasehq/sdk').default;
+const { chromium } = require('playwright-core');
+const fs = require('fs');
+require('dotenv').config({ path: require('os').homedir() + '/.claude/skills/browserbase/.env' });
+
+const COLLECTION = 'https://www.wallquest.com/daisy-bennett-natural-wallcoverings';
+const WPW = '*Wallquestaccess911*';
+const WUSER = 'info@designerwallcoverings.com';
+const URLS_FILE = '/tmp/wq-daisy-bennett-urls.json';
+const OUT = '/tmp/wq-daisy-bennett.jsonl';
+const MODE = process.env.MODE || 'discover';
+// Any Daisy Bennett collection-landing slug is NOT a product (the priceless WQ-DAISYBENNETT* rows).
+const COLLECTION_SLUG = /daisy-bennett/i;
+
+const bb = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY });
+const goto = async (p, u) => { for (let i = 0; i < 3; i++) { try { await p.goto(u, { waitUntil: 'domcontentloaded', timeout: 45000 }); return 1; } catch (e) { await p.waitForTimeout(3000); } } return 0; };
+const isLoggedIn = (page) => page.evaluate(() =>
+  /log\s*out|sign\s*out/i.test(document.body.innerText) ||
+  !!document.querySelector('a.ico-logout, .header-links a[href*="logout"]')
+).catch(() => false);
+
+async function login(page) {
+  for (let attempt = 1; attempt <= 3; attempt++) {
+    await goto(page, 'https://www.wallquest.com/login');
+    await page.waitForTimeout(7000);
+    try {
+      await page.fill('#Email', WUSER);
+      await page.fill('#Password', WPW);
+      await page.press('#Password', 'Enter');
+    } catch (e) { /* form not ready; loop retries */ }
+    await page.waitForTimeout(9000);
+    if (await isLoggedIn(page)) { console.error(`  login OK (attempt ${attempt})`); return true; }
+    console.error(`  login attempt ${attempt} not confirmed, retrying…`);
+    await page.waitForTimeout(3000);
+  }
+  console.error('  LOGIN FAILED after 3 attempts — chunk will likely be priceless');
+  return false;
+}
+
+// Collect product-detail URLs from a nopCommerce collection listing page.
+const discoverOnPage = (p) => p.evaluate(() => {
+  const bad = /\/(login|logout|cart|wishlist|compare|register|account|search)|#/i;
+  const set = new Set();
+  document.querySelectorAll('.item-box a, .product-item a, .product-title a, .picture a, h2.product-title a').forEach(a => {
+    const href = a.href || '';
+    if (!href || bad.test(href)) return;
+    if (!/wallquest\.com\//i.test(href)) return;
+    const path = href.replace(/https?:\/\/[^/]+/i, '').split('?')[0].replace(/\/$/, '');
+    if (!path || path.split('/').length > 2) return;
+    if (/daisy-bennett/i.test(path)) return; // collection page/anchors, not products
+    set.add(href.split('?')[0]);
+  });
+  return [...set];
+});
+
+const extract = (p) => p.evaluate(() => {
+  const Q = s => [...document.querySelectorAll(s)];
+  const specs = {};
+  Q('.spec-name').forEach(n => { const v = n.nextElementSibling; if (v) specs[n.textContent.trim()] = v.textContent.trim(); });
+  const imgs = [...new Set(
+    Q('a[data-full-image-url]').map(a => a.getAttribute('data-full-image-url'))
+      .concat(Q('#cloudZoomImage,.picture img').map(i => i.src))
+  )].filter(u => u && /jpe?g|png/i.test(u));
+  const pv = ((Q('[class*=price-value]')[0] || {}).textContent || '').match(/[\d,]+\.\d{2}/);
+  const title = (document.querySelector('.product-name h1, h1.product-title, .product-essential h1, h1') || {}).textContent || '';
+  const sku = (document.querySelector('[itemprop=sku], .sku .value, .sku-number, .product-sku .value') || {}).textContent || '';
+  return { title: title.trim(), sku: sku.trim(), specs, imgs: imgs.slice(0, 20), priceValue: pv ? pv[0] : null };
+});
+
+(async () => {
+  if (MODE === 'discover') {
+    const s = await bb.sessions.create({ projectId: process.env.BROWSERBASE_PROJECT_ID, proxies: true, browserSettings: { solveCaptchas: true } });
+    const br = await chromium.connectOverCDP(s.connectUrl);
+    const page = br.contexts()[0].pages()[0] || await br.contexts()[0].newPage();
+    try {
+      await login(page);
+      const all = new Set();
+      for (let pn = 1; pn <= 10; pn++) {
+        if (!await goto(page, `${COLLECTION}?orderby=50&pagesize=100&viewmode=grid&pagenumber=${pn}`)) break;
+        await page.waitForTimeout(6000);
+        const found = await discoverOnPage(page);
+        const before = all.size; found.forEach(u => all.add(u));
+        console.error(`[page ${pn}] found=${found.length} total=${all.size}`);
+        if (all.size === before) break; // no new products → past the end
+      }
+      const urls = [...all];
+      fs.writeFileSync(URLS_FILE, JSON.stringify(urls, null, 2));
+      console.error(`DISCOVER DONE — ${urls.length} product URLs → ${URLS_FILE}`);
+      console.log(JSON.stringify({ count: urls.length, sample: urls.slice(0, 12) }, null, 2));
+    } finally { await br.close().catch(() => {}); }
+    return;
+  }
+
+  // MODE=scrape (env-tunable: SRC url-file, OUTF output, CHUNK, WAITMS)
+  const SRC = process.env.SRC || URLS_FILE;
+  const OUTF = process.env.OUTF || OUT;
+  const CHUNK = parseInt(process.env.CHUNK || '40', 10);
+  const WAITMS = parseInt(process.env.WAITMS || '9000', 10);
+  const urls = JSON.parse(fs.readFileSync(SRC, 'utf8'));
+  fs.writeFileSync(OUTF, '');
+  const out = fs.createWriteStream(OUTF, { flags: 'a' });
+  let done = 0, priced = 0;
+  for (let i = 0; i < urls.length; i += CHUNK) {
+    const chunk = urls.slice(i, i + CHUNK); let br, page, authed = false;
+    for (let sTry = 1; sTry <= 3 && !authed; sTry++) {
+      try {
+        if (br) await br.close().catch(() => {});
+        const s = await bb.sessions.create({ projectId: process.env.BROWSERBASE_PROJECT_ID, proxies: true, browserSettings: { solveCaptchas: true } });
+        br = await chromium.connectOverCDP(s.connectUrl);
+        page = br.contexts()[0].pages()[0] || await br.contexts()[0].newPage();
+        authed = await login(page);
+        if (!authed) console.error(`  session ${sTry} did not authenticate — recreating`);
+      } catch (e) { console.error('session create err', String(e).slice(0, 120)); }
+    }
+    if (!authed) { console.error(`[chunk ${Math.floor(i / CHUNK) + 1}] SKIP — no authed session`); if (br) await br.close().catch(() => {}); continue; }
+    try {
+      for (const url of chunk) {
+        try {
+          if (!await goto(page, url)) { out.write(JSON.stringify({ url, nav: 0 }) + '\n'); done++; continue; }
+          await page.waitForTimeout(WAITMS);
+          let x = await extract(page);
+          if (!x.priceValue) { await page.waitForTimeout(6000); x = await extract(page); }
+          if (x.priceValue) priced++;
+          out.write(JSON.stringify({ url, ...x }) + '\n');
+        } catch (e) { out.write(JSON.stringify({ url, err: String(e).slice(0, 120) }) + '\n'); }
+        done++;
+      }
+    } catch (e) { console.error('session err', String(e).slice(0, 160)); }
+    finally { if (br) await br.close().catch(() => {}); }
+    console.error(`[chunk ${Math.floor(i / CHUNK) + 1}] done=${done}/${urls.length} priced=${priced}`);
+  }
+  out.end();
+  console.error(`SCRAPE DONE done=${done} priced=${priced} → ${OUTF}`);
+})();

← 0eddd307 Carl Robinson: Shopify push (draft) + activate/publish-all-c  ·  back to Designer Wallcoverings  ·  Onboard Daisy Bennett Naturals Vol 2 (WallQuest→Phillipe Rom 95ef94b5 →