← 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
M scripts/wallquest-refresh/activate-publish-v2.cjsA scripts/wallquest-refresh/build-la-drafts.cjsA scripts/wallquest-refresh/build-pi-drafts.cjsA scripts/wallquest-refresh/onboard-wq-generic.cjsA scripts/wallquest-refresh/update-la-titles.cjs
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 →