← back to Sanderson Onboard
scripts/harvest_sdg_feed.mjs
152 lines
#!/usr/bin/env node
// Generalized SDG .design feed harvester — FULL catalog (wallpaper AND fabric), $0, staging-only.
//
// USAGE: node harvest_sdg_feed.mjs --domain sanderson.design --brand sanderson [--limit N]
//
// Method (feed-first, verified):
// 1. ENUMERATE the full SKU universe via /us/api/n/find?type=product&verbosity=1&limit=1000&skip=N
// (paginates by `skip`; `result.catalog[].sku` = colorway SKUs, prefix xxW=wallpaper-code / xxF=fabric-code).
// We collapse to DISTINCT priceable colorway base codes ^[A-Z]{2}[WF][0-9]{4}-[0-9]{2}.
// 2. PRICE each base code at verbosity=3 via filter={"sku":"<base>"}: returns real US SSP in USD (`price`)
// + type discriminator sdb_product_group_code_data[].label (Wallpaper|Fabric) + category_path + metadata.
// INVARIANT (verified 1948/1948): SSP = 2 x TRADE => trade = SSP/2 ; retail = trade/0.65/0.85. Money -> 2dp.
//
// HARD RULES: NEVER fabricate a price — NULL on miss (price<=0 or no US price). Checkpoint continuously to
// pilot/<brand>_feed_harvest.jsonl (resumes, loses nothing on rate-limit/crash). Staging only — writes NO DB here;
// emits the JSONL checkpoint which a loader turns into <brand>_catalog rows.
import https from 'https';
import fs from 'fs';
const args = Object.fromEntries(process.argv.slice(2).reduce((a,v,i,arr)=>{ if(v.startsWith('--')) a.push([v.slice(2), arr[i+1]&&!arr[i+1].startsWith('--')?arr[i+1]:true]); return a; }, []));
const DOMAIN = args.domain; const BRAND = args.brand;
if (!DOMAIN || !BRAND) { console.error('need --domain and --brand'); process.exit(1); }
const PAGE = 1000;
const DIR = new URL('..', import.meta.url).pathname;
const CKPT = `${DIR}pilot/${BRAND}_feed_harvest.jsonl`;
const ENUM_CACHE = `${DIR}pilot/${BRAND}_enum.json`;
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36';
const sleep = ms => new Promise(r => setTimeout(r, ms));
function get(url) {
return new Promise(resolve => {
const req = https.get(url, { headers: { 'User-Agent': UA, 'Accept': 'application/json' } }, res => {
let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve(null); } });
});
req.on('error', () => resolve(null));
req.setTimeout(60000, () => { req.destroy(); resolve(null); });
});
}
const enc = o => encodeURIComponent(JSON.stringify(o));
const money = n => Math.round(n * 100) / 100;
// group label -> normalized product_type. Robust to object|string|json-string shapes.
function typeOf(it) {
let g = it.sdb_product_group_code_data;
if (typeof g === 'string') { try { g = JSON.parse(g); } catch {} }
let label = Array.isArray(g) && g[0] ? g[0].label : (g && g.label);
if (!label) {
const cp = String(it.category_path || '').toLowerCase();
if (cp.includes('wallpaper')) label = 'Wallpaper';
else if (cp.includes('fabric')) label = 'Fabric';
}
const l = String(label || '').toLowerCase();
if (l.includes('wallpaper') || l.includes('wallcovering')) return 'wallcovering';
if (l.includes('fabric')) return 'fabric';
// fall back to SKU letter: xxW = wallcovering, xxF = fabric
const m = String(it.sku || '').match(/^[A-Z]{2}([WF])/);
if (m) return m[1] === 'W' ? 'wallcovering' : 'fabric';
return null;
}
async function enumerate() {
if (fs.existsSync(ENUM_CACHE)) {
const cached = JSON.parse(fs.readFileSync(ENUM_CACHE, 'utf8'));
console.log(`[${BRAND}] using cached enumeration: ${cached.length} base codes`);
return cached;
}
// Collect PATTERN-level base codes: xxW####/xxF#### (design patterns). The feed enumerates both
// CN_<PATTERN> pattern rows AND <PATTERN>-<NN>[UC] colorway rows; both collapse to the same pattern base.
// Each pattern base is then fetch-expanded (verbosity=3) into ALL its priced colorway SKUs downstream.
const base = new Set(); let skip = 0, rows = 0;
while (true) {
const d = await get(`https://www.${DOMAIN}/us/api/n/find?type=product&verbosity=1&limit=${PAGE}&skip=${skip}`);
const cat = d && Array.isArray(d.catalog) ? d.catalog : [];
if (!cat.length) break;
rows += cat.length;
for (const c of cat) {
const s = String(c.sku||'').replace(/^CN_/, '');
const m = s.match(/^([A-Z]{2}[WF][0-9]{4})/); // pattern base, colorway/CN suffix stripped
if (m) base.add(m[1]);
}
skip += PAGE;
process.stdout.write(`\r[${BRAND}] enumerated ${rows} rows -> ${base.size} distinct pattern codes`);
if (cat.length < PAGE) break;
await sleep(150);
}
console.log('');
const arr = [...base].sort();
fs.writeFileSync(ENUM_CACHE, JSON.stringify(arr));
return arr;
}
(async () => {
const patterns = await enumerate(); // pattern-level base codes (xxW####/xxF####)
const limit = args.limit ? Math.min(Number(args.limit), patterns.length) : patterns.length;
// resume by PATTERN: skip a pattern only if we already checkpointed at least one row for it
const donePat = new Set();
if (fs.existsSync(CKPT)) for (const l of fs.readFileSync(CKPT,'utf8').split('\n').filter(Boolean)) {
try { const r = JSON.parse(l); if (r.pattern_base) donePat.add(r.pattern_base); } catch {}
}
const todo = patterns.slice(0, limit).filter(p => !donePat.has(p));
console.log(`[${BRAND}] ${patterns.length} pattern codes, ${donePat.size} checkpointed, ${todo.length} to harvest`);
const out = fs.createWriteStream(CKPT, { flags: 'a' });
let patn=0, cwCount=0, hit=0, miss=0, wp=0, fab=0;
for (const pbase of todo) {
patn++;
// Expand the pattern into ALL its colorway rows. The feed's sku-filter matches the CN_<pattern>
// parent row, which returns every colorway child (verified: bare pattern base returns 0). limit=60
// covers the widest colorway sets.
const d = await get(`https://www.${DOMAIN}/us/api/n/find?type=product&verbosity=3&filter=${enc({sku:'CN_'+pbase})}&limit=60`);
const cat = d && Array.isArray(d.catalog) ? d.catalog : [];
// real colorway rows = SKU form xxW####-## / xxF####-## (drop the CN_ pattern parent & any non-colorway).
// De-dup by colorway code, preferring the PRICED (non-UC) variant over a $0/discontinued duplicate.
const byCw = new Map();
for (const x of cat) {
const sku = String(x.sku||'');
const m = sku.match(/^([A-Z]{2}[WF][0-9]{4}-[0-9]{2})/);
if (!m) continue; // skip CN_ parent + malformed
const cw = m[1];
const priced = typeof x.price==='number' && x.price>0;
const prev = byCw.get(cw);
if (!prev || (priced && !(typeof prev.price==='number' && prev.price>0))) byCw.set(cw, x);
}
if (byCw.size === 0) miss++; // pattern yielded no colorway rows at all
for (const [cw, it] of byCw) {
cwCount++;
let ssp=null, trade=null, retail=null;
if (typeof it.price==='number' && it.price>0) { ssp=money(it.price); trade=money(ssp/2); retail=money(trade/0.65/0.85); hit++; }
else miss++;
const ptype = typeOf(it);
if (ptype==='wallcovering') wp++; else if (ptype==='fabric') fab++;
out.write(JSON.stringify({
base_code: cw, // colorway code = unique staging key
pattern_base: pbase,
mfr_sku: it.sku || cw,
product_type: ptype,
pattern: it.sdb_design_name || it.name || null,
color: it.sdb_desc_colour || it.sdb_design_colour_description || it.sdb_colour_variant || null,
collection: it.sdb_collection_name || it.sdb_product_collection || null,
width: it.sdb_useable_width || it.sdb_usable_width_inches || null,
length: it.sdb_standard_length_inches || it.sdb_standard_length || null,
ssp_usd: ssp, trade_usd: trade, retail_usd: retail,
image: it.image || null
}) + '\n');
}
if (patn%25===0 || patn===todo.length) console.log(`[${BRAND}] pat ${patn}/${todo.length} colorways=${cwCount} priced=${hit} miss=${miss} wp=${wp} fab=${fab}`);
await sleep(120);
}
out.end();
console.log(`[${BRAND}] DONE patterns=${patn} colorways=${cwCount} priced=${hit} miss=${miss} wp=${wp} fab=${fab}. Checkpoint: ${CKPT}`);
})();