[object Object]

← back to Designer Wallcoverings

Anthology Phase-2 scrapers: SDG Trade Hub feed-first pull → anthology_catalog (48 staged)

35503bb00ecf34152a796c6f7f946e7cb2247cdf · 2026-07-22 08:05:29 -0700 · Steve

Files touched

Diff

commit 35503bb00ecf34152a796c6f7f946e7cb2247cdf
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 22 08:05:29 2026 -0700

    Anthology Phase-2 scrapers: SDG Trade Hub feed-first pull → anthology_catalog (48 staged)
---
 .../scripts/cadence/sdg-anthology-apicapture.mjs   |  55 ++++++++
 shopify/scripts/cadence/sdg-anthology-login.mjs    | 148 +++++++++++++++++++++
 shopify/scripts/cadence/sdg-anthology-scrape.mjs   |  66 +++++++++
 shopify/scripts/cadence/sdg-anthology-search.mjs   |  79 +++++++++++
 4 files changed, 348 insertions(+)

diff --git a/shopify/scripts/cadence/sdg-anthology-apicapture.mjs b/shopify/scripts/cadence/sdg-anthology-apicapture.mjs
new file mode 100644
index 00000000..64eae54e
--- /dev/null
+++ b/shopify/scripts/cadence/sdg-anthology-apicapture.mjs
@@ -0,0 +1,55 @@
+#!/usr/bin/env node
+/** Reuse saved SDG session; capture the catalog/search JSON API behind the
+ *  wallpaper listing while filtering for Anthology. $0 local. Steve 2026-07-22 */
+import { createRequire } from 'module';
+const require = createRequire(import.meta.url);
+let chromium;
+for (const p of ['/Users/macstudio3/Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/node_modules/playwright','playwright']) { try { ({ chromium } = require(p)); break; } catch {} }
+import fs from 'node:fs';
+const OUT='/tmp/sdg'; fs.mkdirSync(OUT+'/api',{recursive:true});
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+const log=(...a)=>console.log(new Date().toISOString().slice(11,19),...a);
+
+const browser=await chromium.launch({headless:true});
+const ctx=await browser.newContext({storageState:OUT+'/state.json',
+  userAgent:'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
+  viewport:{width:1440,height:1000}});
+const page=await ctx.newPage();
+let n=0; const seen=[];
+page.on('response', async (resp)=>{
+  try{
+    const url=resp.url(); const ct=(resp.headers()['content-type']||'');
+    if(!/json/i.test(ct)) return;
+    if(!/api|search|catalog|product|graphql|algolia|_next\/data|elastic|find|list/i.test(url)) return;
+    let body=''; try{ body=await resp.text(); }catch{return;}
+    if(body.length<40) return;
+    const hasAnth=/anthology/i.test(body);
+    n++; const f=`${OUT}/api/${String(n).padStart(3,'0')}${hasAnth?'-ANTH':''}.json`;
+    fs.writeFileSync(f, url+'\n'+body.slice(0,200000));
+    seen.push({url:url.slice(0,140), bytes:body.length, anthology:hasAnth});
+  }catch{}
+});
+try{
+  log('load wallpaper listing');
+  await page.goto('https://trade.sandersondesigngroup.com/us/products/wallpaper',{waitUntil:'domcontentloaded',timeout:60000});
+  await sleep(6000);
+  // find a search input and query anthology
+  let searched=false;
+  for(const sel of ['input[type="search"]','input[placeholder*="search" i]','input[name*="search" i]','input[aria-label*="search" i]']){
+    const loc=page.locator(sel).first();
+    if(await loc.count()){ try{ await loc.click(); await loc.fill('anthology'); await loc.press('Enter'); searched=true; log('searched via',sel); break;}catch{} }
+  }
+  if(!searched){ // try a search URL param
+    for(const u of ['https://trade.sandersondesigngroup.com/us/products/wallpaper?q=anthology','https://trade.sandersondesigngroup.com/us/search?q=anthology']){
+      try{ await page.goto(u,{waitUntil:'domcontentloaded',timeout:45000}); log('tried search url',u); await sleep(5000);}catch{}
+    }
+  }
+  await sleep(6000);
+  await page.screenshot({path:`${OUT}/api-search.png`,fullPage:true});
+  // also capture visible facet labels mentioning collections/brands
+  const facets=await page.$$eval('*', els=>Array.from(els).map(e=>(e.textContent||'').trim()).filter(t=>/anthology/i.test(t)&&t.length<80)).catch(()=>[]);
+  fs.writeFileSync(`${OUT}/anthology-facets.json`, JSON.stringify([...new Set(facets)].slice(0,40),null,2));
+  fs.writeFileSync(`${OUT}/api-index.json`, JSON.stringify(seen,null,2));
+  log('captured', seen.length,'json responses;', seen.filter(s=>s.anthology).length,'mention anthology');
+  seen.filter(s=>s.anthology).forEach(s=>log('  ANTH:',s.url,`(${s.bytes}b)`));
+}catch(e){ log('ERR',e.message);} finally{ await browser.close(); }
diff --git a/shopify/scripts/cadence/sdg-anthology-login.mjs b/shopify/scripts/cadence/sdg-anthology-login.mjs
new file mode 100644
index 00000000..a306fe98
--- /dev/null
+++ b/shopify/scripts/cadence/sdg-anthology-login.mjs
@@ -0,0 +1,148 @@
+#!/usr/bin/env node
+/**
+ * SDG Trade Hub login + ANTHOLOGY recon (Steve 2026-07-22).
+ * Based on sdg-trade-login.mjs but MFA is handed off via a file
+ * (/tmp/sdg/mfa_code.txt) so the caller supplies the 6-digit code
+ * read from George MCP — the local George :9850 basic-auth is broken.
+ * Headless Chromium, NEVER touches Steve's Chrome. $0 local.
+ */
+import { createRequire } from 'module';
+const require = createRequire(import.meta.url);
+let chromium;
+for (const p of [
+  '/Users/macstudio3/Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/node_modules/playwright',
+  'playwright',
+]) { try { ({ chromium } = require(p)); break; } catch {} }
+if (!chromium) { console.error('FATAL: playwright not resolvable'); process.exit(1); }
+import fs from 'node:fs';
+import os from 'node:os';
+
+const EMAIL = 'info@designerwallcoverings.com';
+const ENV = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
+const PW = (ENV.match(/^SANDERSON_PASSWORD=(.*)$/m) || [])[1];
+if (!PW) { console.error('FATAL: SANDERSON_PASSWORD not in secrets .env'); process.exit(1); }
+
+const OUT = '/tmp/sdg'; fs.mkdirSync(OUT, { recursive: true });
+const CODE_FILE = `${OUT}/mfa_code.txt`;
+const NEED_FILE = `${OUT}/NEED_MFA`;
+try { fs.unlinkSync(CODE_FILE); } catch {}
+try { fs.unlinkSync(NEED_FILE); } catch {}
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+const log = (...a) => console.log(new Date().toISOString().slice(11,19), ...a);
+
+const browser = await chromium.launch({ headless: true });
+const ctx = await browser.newContext({
+  userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
+  viewport: { width: 1440, height: 1000 },
+});
+const page = await ctx.newPage();
+try {
+  log('goto login');
+  await page.goto('https://trade.sandersondesigngroup.com/login', { waitUntil: 'domcontentloaded', timeout: 60000 });
+  await sleep(1500);
+  try { await page.getByRole('button', { name: /^accept$/i }).click({ timeout: 4000 }); log('cookies accepted'); } catch { log('no cookie button'); }
+  try {
+    const cur = page.locator('select').first();
+    if (await cur.count()) { await cur.selectOption({ label: 'USD' }).catch(()=>cur.selectOption('USD').catch(()=>{})); log('currency→USD tried'); }
+  } catch {}
+
+  await page.fill('input[type="email"], input[name*="mail" i], input[id*="mail" i]', EMAIL);
+  await page.fill('input[type="password"], input[name*="pass" i], input[id*="pass" i]', PW);
+  log('filled email+password');
+  await page.screenshot({ path: `${OUT}/01-prelogin.png` });
+  await page.locator('input[type="password"], input[name*="pass" i]').first().press('Enter').catch(()=>{});
+  await sleep(2500);
+  if (/login/i.test(page.url())) {
+    await page.locator('form button:has-text("Log in"), button:has-text("Log in")').last().click({ timeout: 6000 }).catch(()=>{});
+  }
+  log('submitted login');
+  await sleep(4500);
+  await page.screenshot({ path: `${OUT}/02-postsubmit.png` });
+
+  let errText = '';
+  try {
+    const body = await page.innerText('body');
+    const m = body.match(/(invalid[^\n]{0,60}|incorrect[^\n]{0,60}|not (?:recognised|recognized)[^\n]{0,60}|locked[^\n]{0,60}|too many[^\n]{0,60}|does not match[^\n]{0,60})/i);
+    if (m) errText = m[1].trim();
+  } catch {}
+  if (errText) { log('LOGIN ERROR:', JSON.stringify(errText)); fs.writeFileSync(`${OUT}/result.json`, JSON.stringify({ loggedIn:false, error: errText }, null, 2)); throw new Error('login-rejected: ' + errText); }
+
+  let onLogin = /login/i.test(page.url());
+  let needMfa = false;
+  if (onLogin) {
+    const otpProbe = page.locator('[role="dialog"] input, input[maxlength="6"], input[autocomplete*="one-time" i], input[name*="otp" i], input[name*="code" i], input[inputmode="numeric"]');
+    try { needMfa = (await otpProbe.count()) > 0; } catch {}
+  }
+  log('post-submit url:', page.url(), '| onLogin:', onLogin, '| MFA required?', needMfa);
+
+  if (needMfa) {
+    fs.writeFileSync(NEED_FILE, new Date().toISOString());   // signal caller: fetch a fresh code NOW
+    log('WROTE NEED_MFA marker — waiting for caller to drop 6-digit code into', CODE_FILE);
+    let code = null;
+    for (let i = 0; i < 72; i++) {                            // ~6 min
+      await sleep(5000);
+      if (fs.existsSync(CODE_FILE)) {
+        const c = fs.readFileSync(CODE_FILE, 'utf8').trim().match(/\d{6}/);
+        if (c) { code = c[0]; break; }
+      }
+      if (i % 4 === 0) log(`waiting for MFA code file… (${i+1})`);
+    }
+    if (!code) { log('FATAL: no MFA code supplied'); await page.screenshot({ path: `${OUT}/03-nocode.png` }); throw new Error('no-mfa'); }
+    log('got MFA code from file');
+    const dlg = page.locator('[role="dialog"], [class*="modal" i], [class*="popup" i], [class*="otp" i]').last();
+    let otp = dlg.locator('input').first();
+    if (!(await otp.count())) otp = page.locator('input[maxlength="6"], input[name*="otp" i], input[name*="code" i]').first();
+    await otp.click().catch(()=>{});
+    await otp.fill('');
+    await otp.fill(code);
+    log('entered MFA code');
+    await (dlg.getByRole('button', { name: /verif/i }).first().click({ timeout: 6000 })
+            .catch(()=> page.getByRole('button', { name: /verif|submit|continue/i }).first().click({ timeout: 6000 }).catch(()=>{})));
+    await page.waitForURL(u => !/login/i.test(u.toString()), { timeout: 18000 }).catch(()=>{});
+    await sleep(2500);
+  }
+
+  await page.screenshot({ path: `${OUT}/04-landing.png`, fullPage: true });
+  const url = page.url();
+  const loggedIn = !/login/i.test(url);
+  log('post-login url:', url, '| loggedIn:', loggedIn);
+
+  const links = await page.$$eval('a[href]', as => as.map(a => ({ t: (a.textContent||'').trim().slice(0,40), h: a.getAttribute('href') }))
+    .filter(x => x.t && !/^#/.test(x.h||'')).slice(0, 200));
+  fs.writeFileSync(`${OUT}/links.json`, JSON.stringify(links, null, 2));
+  const interesting = links.filter(l => /price|order|pad|catalog|product|wallpaper|wallcover|brand|anthology|account|download|export/i.test(l.t + ' ' + l.h));
+  log('interesting links:'); interesting.forEach(l => console.log('   ', JSON.stringify(l)));
+
+  if (loggedIn) {
+    await ctx.storageState({ path: `${OUT}/state.json` });
+    log('saved session storageState → state.json (future scrapes skip MFA)');
+    // Anthology-specific recon: mirror the zoffany brand path + likely variants
+    const paths = [
+      ['anthology-wallpaper','/us/all-brands/anthology/wallpaper'],
+      ['anthology-brand','/us/all-brands/anthology'],
+      ['pricelists','/us/price-lists-technical-info'],
+      ['docbrowser','/us/document-browser'],
+    ];
+    for (const [name, p] of paths) {
+      try {
+        await page.goto('https://trade.sandersondesigngroup.com'+p, { waitUntil:'domcontentloaded', timeout:45000 });
+        await sleep(3500);
+        await page.screenshot({ path: `${OUT}/recon-${name}.png`, fullPage:true });
+        const dl = await page.$$eval('a[href]', as=>as.map(a=>({t:(a.textContent||'').trim().slice(0,60),h:a.getAttribute('href')}))
+          .filter(x=>/\.(pdf|csv|xlsx?|zip)(\?|$)|price|download|anthology|wallpaper|product/i.test((x.t||'')+' '+(x.h||''))).slice(0,120));
+        fs.writeFileSync(`${OUT}/links-${name}.json`, JSON.stringify(dl,null,2));
+        const txt = await page.innerText('body').catch(()=> '');
+        fs.writeFileSync(`${OUT}/page-${name}.txt`, txt.slice(0, 12000));
+        log(`recon ${name} [${page.url()}]: ${dl.length} relevant links`);
+      } catch(e){ log(`recon ${name} failed:`, e.message); }
+    }
+  }
+  fs.writeFileSync(`${OUT}/result.json`, JSON.stringify({ loggedIn, url }, null, 2));
+  log('DONE — artifacts in', OUT);
+} catch (e) {
+  log('ERROR:', e.message);
+  try { await page.screenshot({ path: `${OUT}/99-error.png`, fullPage: true }); } catch {}
+  process.exitCode = 2;
+} finally {
+  await browser.close();
+}
diff --git a/shopify/scripts/cadence/sdg-anthology-scrape.mjs b/shopify/scripts/cadence/sdg-anthology-scrape.mjs
new file mode 100644
index 00000000..26f9eeba
--- /dev/null
+++ b/shopify/scripts/cadence/sdg-anthology-scrape.mjs
@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+/** Phase 2: feed-first Anthology pull from the SDG Trade Hub JSON API using the
+ *  saved session (no re-login/MFA). Fetches the full wallpaper catalog by ID,
+ *  filters to the Anthology range, writes /tmp/sdg/anthology_full.json. $0 local.
+ *  Steve 2026-07-22 — SCRAPE+STAGE ONLY. */
+import { createRequire } from 'module';
+const require = createRequire(import.meta.url);
+let chromium;
+for (const p of ['/Users/macstudio3/Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/node_modules/playwright','playwright']) { try { ({ chromium } = require(p)); break; } catch {} }
+import fs from 'node:fs';
+const OUT='/tmp/sdg';
+const BASE='https://trade.sandersondesigngroup.com/us/api/n';
+const log=(...a)=>console.log(new Date().toISOString().slice(11,19),...a);
+const chunk=(a,n)=>{const o=[];for(let i=0;i<a.length;i+=n)o.push(a.slice(i,i+n));return o;};
+
+const browser=await chromium.launch({headless:true});
+const ctx=await browser.newContext({storageState:OUT+'/state.json',
+  userAgent:'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'});
+const req=ctx.request;
+try{
+  // warm a page so any anti-CSRF headers/cookies are present
+  const page=await ctx.newPage();
+  await page.goto('https://trade.sandersondesigngroup.com/us/products/wallpaper',{waitUntil:'domcontentloaded',timeout:60000}).catch(()=>{});
+  await page.waitForTimeout(3000);
+
+  log('fetch wallpaper category (all product ids)');
+  const rRes=await req.get(`${BASE}/route/products/wallpaper?pushDeps=true`,{timeout:60000});
+  const rJson=await rRes.json();
+  const ids=(rJson.catalog?.[0]?.products)||[];
+  log('total wallpaper product ids:',ids.length);
+  if(!ids.length){ throw new Error('no ids from route'); }
+
+  const all=[];
+  const batches=chunk(ids,50);
+  for(let i=0;i<batches.length;i++){
+    const url=`${BASE}/load?type=product&verbosity=2&ids=${batches[i].join(',')}`;
+    try{
+      const r=await req.get(url,{timeout:60000});
+      const j=await r.json();
+      const res=j.result||[];
+      all.push(...res);
+    }catch(e){ log('batch',i,'err',e.message); }
+    if(i%10===0) log(`  loaded ${all.length} / ${ids.length}`);
+    await page.waitForTimeout(120); // gentle
+  }
+  log('loaded products:',all.length);
+
+  const isAnth=p=>{
+    const hay=`${p.image_alt_text||''} ${p.name||''} ${p.sdb_design_name||''} ${p.sdb_collection_name||''}`.toLowerCase();
+    return /\banthology\b/.test(hay);
+  };
+  const anth=all.filter(isAnth);
+  // dedup by id
+  const seen={}; anth.forEach(p=>seen[p.id]=p);
+  const list=Object.values(seen);
+  fs.writeFileSync(`${OUT}/anthology_full.json`, JSON.stringify(list,null,1));
+
+  const byColl={}; list.forEach(p=>{const c=p.sdb_collection_name||'(none)';byColl[c]=(byColl[c]||0)+1;});
+  log('ANTHOLOGY products:',list.length);
+  log('by collection:'); Object.entries(byColl).sort((a,b)=>b[1]-a[1]).forEach(([c,n])=>console.log(`   ${n}  ${c}`));
+  const withImg=list.filter(p=>p.image).length;
+  const withPrice=list.filter(p=>p.price&&p.price>0).length;
+  log(`with image: ${withImg}/${list.length} | with price>0: ${withPrice}/${list.length}`);
+  log('saved → anthology_full.json');
+}catch(e){ log('FATAL',e.message); process.exitCode=2; }
+finally{ await browser.close(); }
diff --git a/shopify/scripts/cadence/sdg-anthology-search.mjs b/shopify/scripts/cadence/sdg-anthology-search.mjs
new file mode 100644
index 00000000..f1dbae05
--- /dev/null
+++ b/shopify/scripts/cadence/sdg-anthology-search.mjs
@@ -0,0 +1,79 @@
+#!/usr/bin/env node
+/** Authoritative Anthology set via Adobe Live Search ProductSearch (phrase=anthology),
+ *  then load full detail via session-authorized /api/n/load. $0 local. Steve 2026-07-22 */
+import { createRequire } from 'module';
+const require = createRequire(import.meta.url);
+let chromium;
+for (const p of ['/Users/macstudio3/Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/node_modules/playwright','playwright']) { try { ({ chromium } = require(p)); break; } catch {} }
+import fs from 'node:fs';
+const OUT='/tmp/sdg';
+const log=(...a)=>console.log(new Date().toISOString().slice(11,19),...a);
+const chunk=(a,n)=>{const o=[];for(let i=0;i<a.length;i+=n)o.push(a.slice(i,i+n));return o;};
+
+const browser=await chromium.launch({headless:true});
+const ctx=await browser.newContext({storageState:OUT+'/state.json',
+  userAgent:'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'});
+const page=await ctx.newPage();
+let gqlHeaders=null, gqlBase=null;
+page.on('request', req=>{
+  const u=req.url();
+  if(/catalog-service\.adobe\.io\/graphql/.test(u) && /ProductSearch/.test(decodeURIComponent(u))){
+    if(!gqlHeaders){ gqlHeaders=req.headers(); gqlBase=u.split('?')[0]; }
+  }
+});
+try{
+  log('warm search page (capture Adobe headers)');
+  await page.goto('https://trade.sandersondesigngroup.com/us/products/wallpaper',{waitUntil:'domcontentloaded',timeout:60000});
+  await page.waitForTimeout(4000);
+  // trigger a search so a ProductSearch request fires and we grab its headers
+  for(const sel of ['input[type="search"]','input[placeholder*="search" i]','input[name*="search" i]']){
+    const loc=page.locator(sel).first();
+    if(await loc.count()){ try{ await loc.click(); await loc.fill('anthology'); await loc.press('Enter'); await page.waitForTimeout(4000);}catch{} break; }
+  }
+  await page.waitForTimeout(3000);
+  if(!gqlHeaders){ throw new Error('did not capture Adobe ProductSearch headers'); }
+  log('captured Adobe headers:', Object.keys(gqlHeaders).filter(k=>/api-key|environment|store|magento|content-type|x-/i.test(k)).join(', '));
+
+  // build GraphQL and replay with phrase=anthology, big page_size
+  const query='query ProductSearch($phrase:String! $current_page:Int $page_size:Int){productSearch(phrase:$phrase current_page:$current_page page_size:$page_size){items{product{id sku name}} total_count}}';
+  const headers={}; for(const [k,v] of Object.entries(gqlHeaders)){ if(!/^:|host|content-length|accept-encoding/i.test(k)) headers[k]=v; }
+  headers['content-type']='application/json';
+  async function search(pageNo){
+    const variables={phrase:'anthology',current_page:pageNo,page_size:200};
+    const url=gqlBase+'?query='+encodeURIComponent(query)+'&variables='+encodeURIComponent(JSON.stringify(variables));
+    const r=await ctx.request.get(url,{headers,timeout:60000});
+    const j=await r.json();
+    return j.data?.productSearch;
+  }
+  let ps=await search(1);
+  if(!ps){ log('search returned nothing, raw:'); }
+  const total=ps?.total_count||0;
+  let items=(ps?.items||[]).map(i=>i.product);
+  log('anthology ProductSearch total_count:',total,'| page1 items:',items.length);
+  for(let pg=2; items.length<total && pg<=10; pg++){
+    const more=await search(pg); const mi=(more?.items||[]).map(i=>i.product); items.push(...mi);
+    log('  page',pg,'->',items.length,'/',total); if(!mi.length) break;
+  }
+  // Live Search matches "anthology" in text; confirm brand=Harlequin/Anthology via load
+  const ids=[...new Set(items.map(p=>+p.id))];
+  fs.writeFileSync(`${OUT}/anthology_search_ids.json`, JSON.stringify({total,ids},null,1));
+  log('distinct ids from search:',ids.length);
+
+  // load full detail
+  const all=[];
+  for(const b of chunk(ids,50)){
+    try{ const r=await ctx.request.get(`https://trade.sandersondesigngroup.com/us/api/n/load?type=product&verbosity=2&ids=${b.join(',')}`,{timeout:60000});
+      const j=await r.json(); all.push(...(j.result||[])); }catch(e){ log('load err',e.message); }
+    await page.waitForTimeout(120);
+  }
+  // keep only genuine Anthology (alt/name/design/collection mentions anthology)
+  const isAnth=p=>/\banthology\b/i.test(`${p.image_alt_text||''} ${p.name||''} ${p.sdb_design_name||''} ${p.sdb_collection_name||''}`);
+  const seen={}; all.filter(isAnth).forEach(p=>seen[p.id]=p);
+  const list=Object.values(seen);
+  fs.writeFileSync(`${OUT}/anthology_full.json`, JSON.stringify(list,null,1));
+  const byColl={}; list.forEach(p=>{const c=p.sdb_collection_name||'(none)';byColl[c]=(byColl[c]||0)+1;});
+  log('FINAL ANTHOLOGY products:',list.length);
+  Object.entries(byColl).sort((a,b)=>b[1]-a[1]).forEach(([c,n])=>console.log(`   ${n}  ${c}`));
+  log('price>0:',list.filter(p=>p.price>0).length,'| image:',list.filter(p=>p.image).length);
+}catch(e){ log('FATAL',e.message); process.exitCode=2; }
+finally{ await browser.close(); }

← 770e4cf5 no-image backfill: attach 18 verified vendor images (src + b  ·  back to Designer Wallcoverings  ·  cadence: onboard Maharam as quote-only wallcovering vendor ( 5d76defb →