[object Object]

← back to Designer Wallcoverings

WallQuest->PR: Lillian August 140 live (material titles) + Paper & Ink 15 clean textiles live; 65 prints/vinyl filtered; generic scraper, robust title-fix, exact-SKU activate

d1369ad4a1b210d2988124a1d97616848ac63a1c · 2026-07-06 15:49:31 -0700 · Steve

Files touched

Diff

commit d1369ad4a1b210d2988124a1d97616848ac63a1c
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 6 15:49:31 2026 -0700

    WallQuest->PR: Lillian August 140 live (material titles) + Paper & Ink 15 clean textiles live; 65 prints/vinyl filtered; generic scraper, robust title-fix, exact-SKU activate
---
 scripts/wallquest-refresh/activate-publish-v2.cjs |  12 +-
 scripts/wallquest-refresh/build-la-drafts.cjs     |  95 ++++++++++++++
 scripts/wallquest-refresh/build-pi-drafts.cjs     |  98 +++++++++++++++
 scripts/wallquest-refresh/onboard-wq-generic.cjs  | 143 ++++++++++++++++++++++
 scripts/wallquest-refresh/update-la-titles.cjs    |  46 +++++++
 5 files changed, 392 insertions(+), 2 deletions(-)

diff --git a/scripts/wallquest-refresh/activate-publish-v2.cjs b/scripts/wallquest-refresh/activate-publish-v2.cjs
index 5b47cc06..d7f06229 100644
--- a/scripts/wallquest-refresh/activate-publish-v2.cjs
+++ b/scripts/wallquest-refresh/activate-publish-v2.cjs
@@ -30,8 +30,16 @@ function gql(query, variables) {
   console.log('sales channels:', pubs.map(p => p.name).join(', '));
 
   const db = new Client({ connectionString: CONN }); await db.connect();
-  // V2 = mfr DB##### (V1 is BX#####). Only rows this session registered + pushed.
-  const { rows } = await db.query("SELECT dw_sku, mfr_sku, shopify_product_id FROM dw_sku_registry WHERE mfr_sku LIKE 'DB%' AND shopify_product_id IS NOT NULL ORDER BY dw_sku");
+  // Precise selectors: SKUS env (exact dw_sku CSV) takes precedence; else SCOPE (mfr regex).
+  let rows;
+  if (process.env.SKUS) {
+    const skus = process.env.SKUS.split(',').map(s => s.trim()).filter(Boolean);
+    rows = (await db.query("SELECT dw_sku, mfr_sku, shopify_product_id FROM dw_sku_registry WHERE dw_sku = ANY($1) AND shopify_product_id IS NOT NULL ORDER BY dw_sku", [skus])).rows;
+    console.log(`activating by EXACT sku list (${skus.length} requested, ${rows.length} matched)`);
+  } else {
+    const SCOPE = process.env.SCOPE || '^DB[0-9]';
+    rows = (await db.query("SELECT dw_sku, mfr_sku, shopify_product_id FROM dw_sku_registry WHERE mfr_sku ~ $1 AND shopify_product_id IS NOT NULL ORDER BY dw_sku", [SCOPE])).rows;
+  }
   console.log(`activating + publishing ${rows.length} V2 products to ${pubs.length} channels…`);
 
   let act = 0, pub = 0, err = 0;
diff --git a/scripts/wallquest-refresh/build-la-drafts.cjs b/scripts/wallquest-refresh/build-la-drafts.cjs
new file mode 100644
index 00000000..802976d1
--- /dev/null
+++ b/scripts/wallquest-refresh/build-la-drafts.cjs
@@ -0,0 +1,95 @@
+// Build Lillian August (real non-vinyl textile) → Phillipe Romano DRAFT payloads.
+// Material-based SKU prefixes + invented PR names (distinct from Daisy Bennett/Carl Robinson) +
+// material descriptions. Reads /tmp/wq-la-all.jsonl (priced). All naturals → settlement OK.
+// Drops any vinyl/print (Steve: "only real non-vinyl items"). Emits /tmp/la-PR-drafts.json.
+const fs = require('fs');
+const IN = process.env.IN || '/tmp/wq-la-all.jsonl';
+const OUT = '/tmp/la-PR-drafts.json';
+
+// fiber → material prefix + invented PR pattern name (NOT reusing DB Livia/Savona… or CR beach-cities)
+const FIBER = [
+  { rx: /sisal/i,                 pfx: 'SIS',  name: 'Talia' },
+  { rx: /paperweave|paper weave|linen/i, pfx: 'PWV', name: 'Elowen' },
+  { rx: /raffia/i,                pfx: 'RAF',  name: 'Neroli' },
+  { rx: /abaca/i,                 pfx: 'ABA',  name: 'Cassia' },
+  { rx: /jute/i,                  pfx: 'JUT',  name: 'Marlowe' },
+  { rx: /cork/i,                  pfx: 'CORK', name: 'Fiora' },
+  { rx: /grasscloth|rushcloth|boodle|natural/i, pfx: 'GRS', name: 'Marisol' },
+];
+const DESC = {
+  SIS: 'Finely woven sisal with a delicate horizontal weave, adding understated natural texture and a subtle sheen.',
+  PWV: 'Artisanal paperweave with a crisp, linen-like hand that brings tailored natural texture to refined interiors.',
+  RAF: 'Natural raffia in a relaxed basketweave, offering rich tactile depth and organic character.',
+  ABA: 'Softly woven abaca with a pale, airy hand that lends light natural texture to refined spaces.',
+  JUT: 'Natural jute in a substantial open weave, bringing earthy texture and casual sophistication.',
+  CORK: 'Natural cork with mineral flecks, delivering organic warmth and a subtle shimmer.',
+  GRS: 'Hand-woven natural grasscloth with fine tonal striations that bring organic texture and quiet warmth to sophisticated interiors.',
+};
+const VINYL = /vinyl/i;
+const cleanWP = s => !s ? '' : s.replace(/\bWalls Wallpaper\b/gi, 'Wallcoverings').replace(/\bWallpapers\b/gi, 'Wallcoverings').replace(/\bWallpaper\b/gi, 'Wallcovering');
+const SMALL = new Set(['a','an','the','and','but','or','for','in','on','at','of','to','with']);
+const tc = s => !s ? '' : s.split(/\s+/).map((w,i)=>(i>0&&SMALL.has(w.toLowerCase()))?w.toLowerCase():w.charAt(0).toUpperCase()+w.slice(1)).join(' ');
+
+const rows = fs.readFileSync(IN, 'utf8').trim().split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return {}; } }).filter(r => r.title && r.priceValue);
+const byUrl = new Map(); rows.forEach(r => byUrl.set(r.url, r)); const uniq = [...byUrl.values()];
+
+const drafts = []; const dropped = []; const leaks = [];
+const FORBIDDEN = /wallquest|lillian august|chesapeake|brewster|seabrook|voyage/i;
+for (const r of uniq) {
+  const s = r.specs || {};
+  const mat = s.Material || '';
+  const dn = (s['Design Name'] || r.title || '');
+  if (VINYL.test(mat) || VINYL.test(dn)) { dropped.push(r.url); continue; } // no vinyl
+  const fiber = FIBER.find(f => f.rx.test(dn)) || FIBER.find(f => f.rx.test(mat)) || FIBER[FIBER.length - 1];
+  const num = (r.url.split('/').pop().match(/\d+/) || ['0'])[0];
+  const dw_sku = `${fiber.pfx}-${num}`;
+  const mfr = r.url.split('/').pop().toUpperCase();
+  const color = tc((s['Colorway Name'] || (r.title.split(' - ')[1]) || '').trim());
+  const cost = parseFloat(String(r.priceValue).replace(/,/g, ''));
+  const ourPrice = +(cost / 0.65 / 0.85).toFixed(2);
+  const perYard = (Math.round((ourPrice / 8) * 100) / 100).toFixed(2); // sold per yard, 8-yd bolt
+  const MATWORD = { SIS: 'Sisal', PWV: 'Paperweave', GRS: 'Grasscloth', CORK: 'Cork', RAF: 'Raffia', ABA: 'Abaca', JUT: 'Jute' };
+  const material = MATWORD[fiber.pfx] || '';
+  const title = cleanWP(tc(`${fiber.name}${material ? ' ' + material : ''}${color ? ' ' + color : ''} | Phillipe Romano`));
+  const images = (r.imgs || []).filter(u => {
+    // own-colorway images only: filename should encode this colorway (drop related-carousel _400s)
+    const fn = u.split('/').pop().split('?')[0].toLowerCase().replace(/[^a-z0-9]/g, '');
+    const cslug = (color || '').toLowerCase().replace(/[^a-z0-9]/g, '');
+    return !cslug || fn.includes(cslug) || !/_400/i.test(u);
+  }).slice(0, 8);
+  const width = s['Roll Width'] || '';
+  const tags = [fiber.name, mat || 'Natural Texture', 'Grasscloth & Naturals', 'Natural Texture', 'Phillipe Romano', color, 'display_variant'].filter(Boolean).filter((v,i,a)=>a.indexOf(v)===i);
+  drafts.push({
+    dw_sku, title, body_html: `<p>${DESC[fiber.pfx] || ''}</p>`, vendor: 'Phillipe Romano', product_type: 'Wallcovering',
+    tags, images: images.length ? images : (r.imgs || []).slice(0, 1),
+    variants: [ { sku: `${dw_sku}-Sample`, price: '4.25' }, { sku: dw_sku, price: perYard } ],
+    metafields: [
+      { namespace:'custom', key:'manufacturer_sku', value:mfr, type:'single_line_text_field' },
+      { namespace:'dwc', key:'manufacturer_sku', value:mfr, type:'single_line_text_field' },
+      { namespace:'private_label', key:'real_vendor_name', value:'WallQuest', type:'single_line_text_field' },
+      { namespace:'private_label', key:'source_book', value:'Lillian August Natural Textured', type:'single_line_text_field' },
+      { namespace:'custom', key:'color', value:color, type:'single_line_text_field' },
+      { namespace:'global', key:'width', value:String(width||''), type:'single_line_text_field' },
+      { namespace:'specs', key:'material', value:mat, type:'single_line_text_field' },
+      // full 8-yard-bolt enforcement set (theme-read)
+      { namespace:'global', key:'unit_of_measure', value:'Priced Per Yard', type:'single_line_text_field' },
+      { namespace:'global', key:'Sold-Per', value:'Yard', type:'single_line_text_field' },
+      { namespace:'global', key:'v_prods_quantity_order_min', value:'8', type:'single_line_text_field' },
+      { namespace:'global', key:'v_prods_quantity_order_units', value:'8', type:'single_line_text_field' },
+      { namespace:'dwc', key:'order_unit', value:'8', type:'single_line_text_field' },
+      { namespace:'global', key:'Minimum', value:'8', type:'single_line_text_field' },
+      { namespace:'global', key:'packaged', value:'Packaged in 8 yard bolts only', type:'single_line_text_field' },
+    ],
+    _mfr: mfr, _cost: cost, _fiber: fiber.pfx, _name: fiber.name,
+  });
+  const cust = [title, ...tags].join(' | ');
+  if (FORBIDDEN.test(cust)) leaks.push(dw_sku);
+}
+fs.writeFileSync(OUT, JSON.stringify(drafts, null, 2));
+const byFiber = {}; drafts.forEach(d => (byFiber[d._fiber] = byFiber[d._fiber] || { n:0, name:d._name }).n++);
+console.log(`built ${drafts.length} LA drafts → ${OUT}  (dropped vinyl/print: ${dropped.length})`);
+console.log('leak-check:', leaks.length ? 'FAIL ' + leaks.join(',') : 'PASS');
+console.log('\nfiber → prefix → PR name → count:');
+for (const [p, o] of Object.entries(byFiber).sort()) console.log(`  ${p.padEnd(5)} ${o.name.padEnd(9)} ${o.n}`);
+console.log('\nSAMPLE TITLES:');
+drafts.slice(0, 5).forEach(d => console.log(`  ${d.dw_sku}  "${d.title}"  $${d.variants[1].price}/yd  ${d.images.length}img`));
diff --git a/scripts/wallquest-refresh/build-pi-drafts.cjs b/scripts/wallquest-refresh/build-pi-drafts.cjs
new file mode 100644
index 00000000..30e58f3a
--- /dev/null
+++ b/scripts/wallquest-refresh/build-pi-drafts.cjs
@@ -0,0 +1,98 @@
+// Build paper ink (real non-vinyl textile) → Phillipe Romano DRAFT payloads.
+// Material-based SKU prefixes + invented PR names (distinct from Daisy Bennett/Carl Robinson) +
+// material descriptions. Reads /tmp/wq-pi-textile-keep.jsonl (priced). All naturals → settlement OK.
+// Drops any vinyl/print (Steve: "only real non-vinyl items"). Emits /tmp/pi-PR-drafts.json.
+const fs = require('fs');
+const IN = process.env.IN || '/tmp/wq-pi-textile-keep.jsonl';
+const OUT = '/tmp/pi-PR-drafts.json';
+
+// fiber → material prefix + invented PR pattern name (NOT reusing DB Livia/Savona… or CR beach-cities)
+const FIBER = [
+  { rx: /sisal/i, pfx: 'SIS', name: 'Della' },
+  { rx: /paperweave|paper weave/i, pfx: 'PWV', name: 'Juno' },
+  { rx: /cotton|linen/i, pfx: 'LIN', name: 'Alba' },
+  { rx: /cork/i, pfx: 'CORK', name: 'Wren' },
+  { rx: /raffia/i, pfx: 'RAF', name: 'Iris' },
+  { rx: /abaca/i, pfx: 'ABA', name: 'Faye' },
+  { rx: /jute/i, pfx: 'JUT', name: 'Cleo' },
+  { rx: /grasscloth|rushcloth|boodle|natural/i, pfx: 'GRS', name: 'Serena' },
+];
+const DESC = {
+  LIN: 'A refined cotton-linen weave with a soft natural hand and subtle texture, tailored for elevated interiors.',
+  SIS: 'Finely woven sisal with a delicate horizontal weave, adding understated natural texture and a subtle sheen.',
+  PWV: 'Artisanal paperweave with a crisp, linen-like hand that brings tailored natural texture to refined interiors.',
+  RAF: 'Natural raffia in a relaxed basketweave, offering rich tactile depth and organic character.',
+  ABA: 'Softly woven abaca with a pale, airy hand that lends light natural texture to refined spaces.',
+  JUT: 'Natural jute in a substantial open weave, bringing earthy texture and casual sophistication.',
+  CORK: 'Natural cork with mineral flecks, delivering organic warmth and a subtle shimmer.',
+  GRS: 'Hand-woven natural grasscloth with fine tonal striations that bring organic texture and quiet warmth to sophisticated interiors.',
+};
+const VINYL = /vinyl/i;
+const cleanWP = s => !s ? '' : s.replace(/\bWalls Wallpaper\b/gi, 'Wallcoverings').replace(/\bWallpapers\b/gi, 'Wallcoverings').replace(/\bWallpaper\b/gi, 'Wallcovering');
+const SMALL = new Set(['a','an','the','and','but','or','for','in','on','at','of','to','with']);
+const tc = s => !s ? '' : s.split(/\s+/).map((w,i)=>(i>0&&SMALL.has(w.toLowerCase()))?w.toLowerCase():w.charAt(0).toUpperCase()+w.slice(1)).join(' ');
+
+const rows = fs.readFileSync(IN, 'utf8').trim().split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return {}; } }).filter(r => r.title && r.priceValue);
+const byUrl = new Map(); rows.forEach(r => byUrl.set(r.url, r)); const uniq = [...byUrl.values()];
+
+const drafts = []; const dropped = []; const leaks = [];
+const FORBIDDEN = /wallquest|paper ink|chesapeake|brewster|seabrook|voyage/i;
+for (const r of uniq) {
+  const s = r.specs || {};
+  const mat = s.Material || '';
+  const dn = (s['Design Name'] || r.title || '');
+  if (VINYL.test(mat) || VINYL.test(dn)) { dropped.push(r.url); continue; }
+  if (/palm|tropical|leaf|floral|bird|silhouette/i.test(dn)) { dropped.push(r.url+' (settlement-hold)'); continue; } // 5 tropical gated
+  const fiber = FIBER.find(f => f.rx.test(mat)) || FIBER.find(f => f.rx.test(dn)) || FIBER[FIBER.length - 1];
+  const num = (r.url.split('/').pop().match(/\d+/) || ['0'])[0];
+  const dw_sku = `${fiber.pfx}-${num}`;
+  const mfr = r.url.split('/').pop().toUpperCase();
+  const color = tc((s['Colorway Name'] || (r.title.split(' - ')[1]) || '').trim());
+  const cost = parseFloat(String(r.priceValue).replace(/,/g, ''));
+  const ourPrice = +(cost / 0.65 / 0.85).toFixed(2);
+  const perYard = (Math.round((ourPrice / 8) * 100) / 100).toFixed(2); // sold per yard, 8-yd bolt
+  const MATWORD = { SIS: 'Sisal', PWV: 'Paperweave', GRS: 'Grasscloth', CORK: 'Cork', RAF: 'Raffia', ABA: 'Abaca', JUT: 'Jute' };
+  const material = MATWORD[fiber.pfx] || '';
+  const title = cleanWP(tc(`${fiber.name}${material ? ' ' + material : ''}${color ? ' ' + color : ''} | Phillipe Romano`));
+  const images = (r.imgs || []).filter(u => {
+    // own-colorway images only: filename should encode this colorway (drop related-carousel _400s)
+    const fn = u.split('/').pop().split('?')[0].toLowerCase().replace(/[^a-z0-9]/g, '');
+    const cslug = (color || '').toLowerCase().replace(/[^a-z0-9]/g, '');
+    return !cslug || fn.includes(cslug) || !/_400/i.test(u);
+  }).slice(0, 8);
+  const width = s['Roll Width'] || '';
+  const tags = [fiber.name, mat || 'Natural Texture', 'Grasscloth & Naturals', 'Natural Texture', 'Phillipe Romano', color, 'display_variant'].filter(Boolean).filter((v,i,a)=>a.indexOf(v)===i);
+  drafts.push({
+    dw_sku, title, body_html: `<p>${DESC[fiber.pfx] || ''}</p>`, vendor: 'Phillipe Romano', product_type: 'Wallcovering',
+    tags, images: images.length ? images : (r.imgs || []).slice(0, 1),
+    variants: [ { sku: `${dw_sku}-Sample`, price: '4.25' }, { sku: dw_sku, price: perYard } ],
+    metafields: [
+      { namespace:'custom', key:'manufacturer_sku', value:mfr, type:'single_line_text_field' },
+      { namespace:'dwc', key:'manufacturer_sku', value:mfr, type:'single_line_text_field' },
+      { namespace:'private_label', key:'real_vendor_name', value:'WallQuest', type:'single_line_text_field' },
+      { namespace:'private_label', key:'source_book', value:'Paper & Ink', type:'single_line_text_field' },
+      { namespace:'custom', key:'color', value:color, type:'single_line_text_field' },
+      { namespace:'global', key:'width', value:String(width||''), type:'single_line_text_field' },
+      { namespace:'specs', key:'material', value:mat, type:'single_line_text_field' },
+      // full 8-yard-bolt enforcement set (theme-read)
+      { namespace:'global', key:'unit_of_measure', value:'Priced Per Yard', type:'single_line_text_field' },
+      { namespace:'global', key:'Sold-Per', value:'Yard', type:'single_line_text_field' },
+      { namespace:'global', key:'v_prods_quantity_order_min', value:'8', type:'single_line_text_field' },
+      { namespace:'global', key:'v_prods_quantity_order_units', value:'8', type:'single_line_text_field' },
+      { namespace:'dwc', key:'order_unit', value:'8', type:'single_line_text_field' },
+      { namespace:'global', key:'Minimum', value:'8', type:'single_line_text_field' },
+      { namespace:'global', key:'packaged', value:'Packaged in 8 yard bolts only', type:'single_line_text_field' },
+    ],
+    _mfr: mfr, _cost: cost, _fiber: fiber.pfx, _name: fiber.name,
+  });
+  const cust = [title, ...tags].join(' | ');
+  if (FORBIDDEN.test(cust)) leaks.push(dw_sku);
+}
+fs.writeFileSync(OUT, JSON.stringify(drafts, null, 2));
+const byFiber = {}; drafts.forEach(d => (byFiber[d._fiber] = byFiber[d._fiber] || { n:0, name:d._name }).n++);
+console.log(`built ${drafts.length} LA drafts → ${OUT}  (dropped vinyl/print: ${dropped.length})`);
+console.log('leak-check:', leaks.length ? 'FAIL ' + leaks.join(',') : 'PASS');
+console.log('\nfiber → prefix → PR name → count:');
+for (const [p, o] of Object.entries(byFiber).sort()) console.log(`  ${p.padEnd(5)} ${o.name.padEnd(9)} ${o.n}`);
+console.log('\nSAMPLE TITLES:');
+drafts.slice(0, 5).forEach(d => console.log(`  ${d.dw_sku}  "${d.title}"  $${d.variants[1].price}/yd  ${d.images.length}img`));
diff --git a/scripts/wallquest-refresh/onboard-wq-generic.cjs b/scripts/wallquest-refresh/onboard-wq-generic.cjs
new file mode 100644
index 00000000..ad9d470f
--- /dev/null
+++ b/scripts/wallquest-refresh/onboard-wq-generic.cjs
@@ -0,0 +1,143 @@
+// Daisy Bennett — Naturals VOLUME 2 — collection ONBOARD scraper.
+// Mirrors onboard-carl-robinson.cjs / onboard-daisy-bennett.cjs exactly: 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 = process.env.COLLECTION;
+const WPW = process.env.WQ_PASS || require('child_process').execSync("PGPASSWORD=$PGPASSWORD /opt/homebrew/opt/postgresql@14/bin/psql -h 127.0.0.1 -U dw_admin -d dw_unified -tAc \"SELECT trade_password FROM vendor_registry WHERE vendor_name='\''WallQuest'\'' LIMIT 1\"",{encoding:'utf8'}).trim();
+const WUSER = 'info@designerwallcoverings.com';
+const URLS_FILE = `/tmp/wq-${process.env.SLUG}-urls.json`;
+const OUT = `/tmp/wq-${process.env.SLUG}.jsonl`;
+const MODE = process.env.MODE || 'discover';
+const SLUGGUARD = process.env.SLUGGUARD || (process.env.SLUG||"").replace(/-/g,"[-]?");
+// Any Daisy Bennett collection-landing slug is NOT a product (the priceless WQ-DAISYBENNETT* rows).
+const COLLECTION_SLUG = new RegExp(process.env.SLUGRE||process.env.SLUG,"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((guard) => {
+  const bad = /\/(login|logout|cart|wishlist|compare|register|account|search)|#/i;
+  const grx = new RegExp(guard, '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 (grx.test(path)) return; // collection anchors
+    set.add(href.split('?')[0]);
+  });
+  return [...set];
+}, SLUGGUARD);
+
+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}`);
+})();
diff --git a/scripts/wallquest-refresh/update-la-titles.cjs b/scripts/wallquest-refresh/update-la-titles.cjs
new file mode 100644
index 00000000..2b3576ec
--- /dev/null
+++ b/scripts/wallquest-refresh/update-la-titles.cjs
@@ -0,0 +1,46 @@
+// Rewrite Lillian August product titles to include the material (Steve: "add material names into
+// the titles"). Reads /tmp/la-PR-drafts.json (dw_sku -> new title), PUTs title on each live LN product.
+const fs = require('fs');
+const https = require('https');
+const { Client } = require('pg');
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+const CONN = process.env.DATABASE_URL || 'postgresql://dw_admin@127.0.0.1:5432/dw_unified';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const drafts = JSON.parse(fs.readFileSync('/tmp/la-PR-drafts.json', 'utf8'));
+const titleBySku = {}; drafts.forEach(d => { titleBySku[d.dw_sku] = d.title; });
+
+// rate-limit-robust: returns {status, json}; caller handles 429 with backoff
+function rest(method, path, body) {
+  return new Promise((res) => { const data = body ? JSON.stringify(body) : null;
+    const req = https.request({ hostname: STORE, path: `/admin/api/2024-10/${path}`, method,
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', ...(data?{'Content-Length':Buffer.byteLength(data)}:{}) } },
+      r => { let d=''; r.on('data',c=>d+=c); r.on('end',()=>{ let j={}; try{j=d?JSON.parse(d):{};}catch{} res({ status:r.statusCode, json:j }); }); });
+    req.on('error', () => res({ status: 0 })); if (data) req.write(data); req.end(); });
+}
+async function putTitle(pid, title) {
+  for (let attempt = 0; attempt < 6; attempt++) {
+    const r = await rest('PUT', `products/${pid}.json`, { product: { id: Number(pid), title } });
+    if (r.status >= 200 && r.status < 300) return true;
+    if (r.status === 429 || r.status === 0 || r.status >= 500) { await sleep(1200 * (attempt + 1)); continue; } // backoff
+    return false; // hard error (4xx other than 429)
+  }
+  return false;
+}
+(async () => {
+  const db = new Client({ connectionString: CONN }); await db.connect();
+  const { rows } = await db.query("SELECT dw_sku, shopify_product_id FROM dw_sku_registry WHERE mfr_sku ~ '^LN[0-9]' AND shopify_product_id IS NOT NULL ORDER BY dw_sku");
+  console.log(`Updating titles on ${rows.length} LA products (PUT-only, 429-backoff, 550ms)…`);
+  let ok = 0, skip = 0, err = 0;
+  for (const r of rows) {
+    const t = titleBySku[r.dw_sku];
+    if (!t) { skip++; continue; }
+    const done = await putTitle(r.shopify_product_id, t);
+    if (done) { ok++; if (ok <= 3 || ok % 25 === 0) console.log(`  ✅ ${r.dw_sku} → "${t}"`); }
+    else { err++; console.log(`  ❌ ${r.dw_sku}`); }
+    await sleep(550); // stay under Shopify 2 req/s
+  }
+  console.log(`\nTITLES UPDATED — ok=${ok} no-draft/skip=${skip} err=${err}`);
+  await db.end();
+})().catch(e => { console.error('FATAL', e.message); process.exit(1); });

← 9c4de69d wallquest import rule: no price on a loaded page = discontin  ·  back to Designer Wallcoverings  ·  LN colorway decollapse tool (image-hex → distinct names) 63611c9a →