← 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
A shopify/scripts/cadence/sdg-anthology-apicapture.mjsA shopify/scripts/cadence/sdg-anthology-login.mjsA shopify/scripts/cadence/sdg-anthology-scrape.mjsA shopify/scripts/cadence/sdg-anthology-search.mjs
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 →