← back to York Reprice 2026 08
build-onboard-set.mjs
74 lines
#!/usr/bin/env node
/**
* build-onboard-set.mjs — compute the onboard-ready set (Steve-approved, DRAFT-only) and emit
* data/onboard-rows.json (phase1-enriched shape) for the 785 clean New SKUs.
*
* Onboard-ready = york_master_aug2026 sku_status='New', has image (york_catalog|brewster_catalog),
* settlement-keyword-clear, NOT in data/image-flag-exclude.json, AND NOT already live
* (vendor 'Jeffrey Stevens', keyed by dwc/custom.manufacturer_sku, collision-aware by title-word share).
*
* Reads /tmp/onboard_rows.tsv (produced by the onboard_rows.sql pull — candidate set already
* settlement-clear + imaged). Live crawl is done HERE via GraphQL (all statuses).
* Emits phase1-enriched-shape rows so build-onboard-payloads.mjs can reuse the proven builder.
*/
import fs from 'node:fs';
const SHOP='designer-laboratory-sandbox.myshopify.com',VER='2024-10';
const TOKEN=(fs.readFileSync(process.env.HOME+'/Projects/secrets-manager/.env','utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)||[])[1]?.trim();
const URL=`https://${SHOP}/admin/api/${VER}/graphql.json`;
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
const gql=async(q,v)=>{for(let a=0;a<8;a++){let j;try{const r=await fetch(URL,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})});j=await r.json();}catch(e){await sleep(1500*(a+1));continue;}if(j.errors){if(JSON.stringify(j.errors).includes('THROTTLED')){await sleep(2000*(a+1));continue;}throw new Error(JSON.stringify(j.errors));}const t=j.extensions?.cost?.throttleStatus;if(t&&t.currentlyAvailable<300)await sleep(1500);return j.data;}throw new Error('retries');};
const norm=s=>String(s||'').toUpperCase().trim();
const words=s=>new Set(String(s||'').toLowerCase().replace(/[^a-z0-9 ]/g,' ').split(/\s+/).filter(w=>w.length>2));
const share=(a,b)=>{const A=words(a),B=words(b);for(const w of A)if(B.has(w))return true;return false;};
// candidate rows (settlement-clear + imaged) — TSV columns in onboard_rows.sql order
const COLS=['export_sku','parent_brand','brand','pattern_name','colorway','product_name','book_name',
'us_msrp','us_map','uom','width_inches','length_feet','pattern_repeat','match_type',
'technical_material','backing','coverage','marketing_style','marketing_pattern','color_family',
'wholesale','dynamic_description','roll_price_single','image_url'];
const cand=fs.readFileSync('/tmp/onboard_rows.tsv','utf8').replace(/\n$/,'').split('\n').filter(Boolean).map(line=>{
const c=line.split('\t'); const o={}; COLS.forEach((k,i)=>o[k]=(c[i]===undefined||c[i]==='')?null:c[i]); return o;
});
const excl=new Set(JSON.parse(fs.readFileSync('data/image-flag-exclude.json','utf8')).mfr_skus.map(norm));
const preExcl=cand.length;
const clean=cand.filter(r=>!excl.has(norm(r.export_sku)));
console.log(`candidates: ${preExcl} | after image-flag-exclude: ${clean.length} (removed ${preExcl-clean.length})`);
// live Jeffrey Stevens crawl (ALL statuses) -> mfr -> [titles]
console.log('crawling live Jeffrey Stevens (all statuses)…');
let cur=null, liveTitles=new Map(), liveCount=0;
for(let pg=0;pg<120;pg++){
const d=(await gql(`query($c:String){products(first:80,after:$c,query:"vendor:'Jeffrey Stevens'"){pageInfo{hasNextPage endCursor} nodes{title dwc:metafield(namespace:"dwc",key:"manufacturer_sku"){value} cust:metafield(namespace:"custom",key:"manufacturer_sku"){value}}}}`,{c:cur}))?.products;
if(!d)break;
for(const p of d.nodes){const mfr=norm(p.dwc?.value||p.cust?.value); if(!mfr)continue; liveCount++; const a=liveTitles.get(mfr)||[]; a.push(p.title||''); liveTitles.set(mfr,a);}
if(!d.pageInfo.hasNextPage)break; cur=d.pageInfo.endCursor;
}
console.log(` live JS products with mfr metafield: ${liveCount} | distinct mfr: ${liveTitles.size}`);
// collision-aware subtraction: a candidate is un-onboarded only if NO live product carrying its
// code shares a title word with its master pattern (guards cross-brand code collisions).
const onboard=clean.filter(r=>{ const titles=liveTitles.get(norm(r.export_sku))||[]; return !titles.some(t=>share(t,r.pattern_name)); });
const subtracted=clean.length-onboard.length;
console.log(` already-live subtracted: ${subtracted} | ONBOARD-READY: ${onboard.length}`);
// map candidate rows -> phase1-enriched shape (build-onboard-payloads.mjs consumes this).
// pricing target is resolved in the payload builder from uom + roll_price_single + MAP.
const num=x=>x==null?null:(isNaN(+x)?null:+x);
const rows=onboard.map(r=>({
export_sku:r.export_sku, parent_brand:r.parent_brand||'', brand:r.brand||'',
pattern_name:r.pattern_name||'', colorway:r.colorway||'', product_name:r.product_name||'',
book_name:r.book_name||'',
us_msrp:num(r.us_msrp), us_map:num(r.us_map), uom:(r.uom||'').toUpperCase()||null,
width_inches:num(r.width_inches), length_feet:num(r.length_feet),
pattern_repeat:r.pattern_repeat, match_type:r.match_type,
technical_material:r.technical_material, backing:r.backing, coverage:r.coverage,
marketing_style:r.marketing_style, marketing_pattern:r.marketing_pattern,
color_family:r.color_family, wholesale:num(r.wholesale),
roll_price_single:num(r.roll_price_single),
dynamic_description:r.dynamic_description||'', image_url:r.image_url,
}));
fs.writeFileSync('data/onboard-rows.json',JSON.stringify(rows,null,1));
const uomDist={};for(const r of rows)uomDist[r.uom||'?']=(uomDist[r.uom||'?']||0)+1;
console.log(`\nwrote data/onboard-rows.json (${rows.length}) | UOM: ${JSON.stringify(uomDist)}`);
console.log(`$0 (local psql/node); Shopify reads: free`);