← back to Sanderson Onboard

scripts/finalize_morris_manifest.mjs

76 lines

#!/usr/bin/env node
// TK-10881 — consolidate ALL harvested Morris rows, apply robust title-parse QA, HTTP-200 validate
// every image ($0, media CDN un-challenged), stage the final manifest-morris.json + coverage report.
import https from 'https'; import fs from 'fs';
const DIR = new URL('..', import.meta.url).pathname;
const P = f => `${DIR}pilot/${f}`;
const portal = {};
for (const l of fs.readFileSync(P('morris_feed_harvest.jsonl'), 'utf8').split('\n').filter(Boolean)) { const r = JSON.parse(l); portal[r.base_code] = r; }

// gather harvested rows from every harvest file; keep the best row per base_code (prefer image + non-404)
const toks = s => (s||'').toLowerCase().replace(/&/g,' ').split(/[^a-z0-9]+/).filter(Boolean);
const stem = w => w.replace(/s$/,'');
const stems = arr => new Set(arr.map(stem));
// robust QA: parse Morris title "<Pattern> Wallpaper|Fabric in <Color> by Morris & Co"
function qaPass(portalPattern, portalColor, title) {
  if (!title || /^\s*404/.test(title)) return false;
  const left = title.split(/\bby\b/i)[0];
  const [namePart, colorPart=''] = left.split(/\bin\b/i);
  const nameTok = toks(namePart).filter(w => !['wallpaper','fabric'].includes(w));
  const colorTok = toks(colorPart);
  const pstem = stems([...toks(portalPattern), ...toks(portalColor||'')]);
  const nameOk = nameTok.length ? nameTok.some(w => pstem.has(stem(w))) : false;
  const colorOk = colorTok.length ? colorTok.some(w => pstem.has(stem(w))) : true;
  return nameOk && colorOk;
}

// gather harvested rows; keep the best row per base_code — QA-pass dominates, then image, then non-404
const files = ['morris_image_harvest.jsonl','morris_prob_harvest.jsonl','morris_prob_harvest2.jsonl','morris_prob_harvest3.jsonl','morris_prob_harvest4.jsonl','morris_prob_harvest5.jsonl'];
const best = {};
for (const f of files) { if (!fs.existsSync(P(f))) continue;
  for (const l of fs.readFileSync(P(f),'utf8').split('\n').filter(Boolean)) { const r = JSON.parse(l);
    const p = portal[r.base_code]; if (!p) continue;
    const score = (r.image?2:0) + ((r.title&&!/404/.test(r.title))?1:0) + (r.image && qaPass(p.pattern,p.color,r.title)?8:0);
    const prev = best[r.base_code];
    if (!prev || score > prev._s) best[r.base_code] = { ...r, _s: score };
  }
}

function head(url){ return new Promise(res=>{ const req=https.request(url,{method:'GET',headers:{'User-Agent':'Mozilla/5.0','Range':'bytes=0-0'}},r=>{r.resume();res({status:r.statusCode,ct:r.headers['content-type']||''});}); req.on('error',()=>res({status:0,ct:''})); req.setTimeout(30000,()=>{req.destroy();res({status:0,ct:''});}); req.end(); }); }
const noCache = u => u ? u.replace(/\/cache\/[a-f0-9]{32}\//,'/') : u;

(async () => {
  const all = Object.keys(portal);
  const rows = all.map(s => ({ base_code:s, ...(best[s]||{}) }));
  // QA classify
  const qaPassSet = new Set(), qaFail = [];
  for (const s of all) { const b = best[s];
    if (b && b.image && qaPass(portal[s].pattern, portal[s].color, b.title)) qaPassSet.add(s);
    else if (b) qaFail.push({ base_code:s, pattern:portal[s].pattern, title:b.title||null, has_image:!!b.image });
    else qaFail.push({ base_code:s, pattern:portal[s].pattern, title:null, has_image:false, note:'not-harvested' });
  }

  // validate images for QA-pass rows ($0 parallel)
  const pass = [...qaPassSet];
  let idx=0, ok=0, bad=0; const CONC=12; const valid={};
  async function w(){ while(idx<pass.length){ const s=pass[idx++]; const full=noCache(best[s].image);
    let chosen=full,r=await head(full);
    if(!(r.status>=200&&r.status<400&&/image\//.test(r.ct))){ chosen=best[s].image; r=await head(best[s].image); }
    const good=r.status>=200&&r.status<400&&/image\//.test(r.ct);
    if(good) ok++; else bad++;
    valid[s]={image:chosen,image_full:full,http:r.status,ct:r.ct,ok:good};
  } }
  await Promise.all(Array.from({length:CONC},w));

  const manifest = pass.filter(s=>valid[s].ok).map(s=>{ const p=portal[s]; const v=valid[s];
    return { base_code:s, mfr_sku:p.mfr_sku||s, product_type:p.product_type, pattern:p.pattern, color:p.color,
      collection:p.collection, ssp_usd:p.ssp_usd, trade_usd:p.trade_usd, retail_usd:p.retail_usd,
      image:v.image, image_full:v.image_full, source_url:best[s].url, image_http:v.http }; });
  fs.writeFileSync(P('manifest-morris.json'), JSON.stringify(manifest,null,1));

  const stillMissing = all.filter(s => !manifest.find(m=>m.base_code===s));
  fs.writeFileSync(P('morris_still_missing.json'), JSON.stringify(stillMissing.map(s=>({base_code:s,product_type:portal[s].product_type,pattern:portal[s].pattern,reason: best[s]? (best[s].image? 'qa-or-validate-fail':'no-image-on-page'):'no-url-resolved'})),null,1));

  console.log(`[finalize] portal=${all.length} qa_pass=${pass.length} img_ok=${ok} img_bad=${bad} MANIFEST=${manifest.length} (${(100*manifest.length/all.length).toFixed(1)}%) still_missing=${stillMissing.length}`);
})();