← back to Dw Dead Image Recovery

scripts/recover-versa-kamatera.mjs

101 lines

// TK-11048 — Kamatera-scoped Versa image recovery (feed-first, $0, READ-ONLY DB).
// Reuses the PROVEN pure helpers from recover-versa.mjs (importing does NOT touch the DB).
// Input: data/kamatera-broken-283.tsv (mfr_sku<TAB>product_url, mfr_sku AS-STORED on Kamatera).
// Output: data/versa-recovery-kamatera.jsonl, data/versa-restore-kamatera-map.jsonl, report to stdout.
// NO DB write, NO Shopify. old_image_url is joined from the earlier restore CSV.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { extractImage, isLiveImage, isDataUri } from './recover-versa.mjs';

const HERE = dirname(fileURLToPath(import.meta.url));
const DATA = join(HERE, '..', 'data');
const UA = { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36' };
const TIMEOUT = 25000;
const CONC = 8;

async function getText(url) {
  const ac = new AbortController(); const t = setTimeout(() => ac.abort(), TIMEOUT);
  try {
    const res = await fetch(url, { redirect: 'follow', signal: ac.signal, headers: UA });
    if (!res.ok) return { html: null, status: res.status };
    return { html: await res.text(), status: res.status };
  } catch (e) { return { html: null, status: 0, err: String(e && e.message || e) }; }
  finally { clearTimeout(t); }
}

// SKU-core exactly as extractImage computes it — to report keying honestly.
function skuCore(sku) { return (sku.match(/^[A-Za-z]+\d+-\d+/) || [sku])[0]; }
function coreMatchesHtml(html, sku) {
  const core = skuCore(sku).toLowerCase();
  const imgs = [...html.matchAll(/\/fileadmin\/[^"'\s]+?\.(?:jpg|jpeg|png|webp)/gi)].map(m => m[0].toLowerCase());
  return imgs.some(u => u.includes(core));
}

// old image_url per mfr_sku, from the restore CSV captured earlier (authoritative current broken value)
function loadOldImages() {
  const csv = join(DATA, 'versa-restore-kamatera-202609021433.csv');
  const map = new Map();
  if (existsSync(csv)) {
    const lines = readFileSync(csv, 'utf8').split('\n'); lines.shift(); // header
    for (const ln of lines) {
      if (!ln) continue;
      const i = ln.indexOf(','); if (i < 0) continue;
      map.set(ln.slice(0, i), ln.slice(i + 1));
    }
  }
  return map;
}

const rows = readFileSync(join(DATA, 'kamatera-broken-283.tsv'), 'utf8')
  .split('\n').filter(Boolean)
  .map(l => { const [mfr_sku, product_url] = l.split('\t'); return { mfr_sku, product_url }; });
const oldImages = loadOldImages();

const recovered = [];      // {mfr_sku, new_image_url, verified:true}
const restore = [];        // {mfr_sku, old_image_url}
const miss = { nopdp: [], noimg: [], notlive: [] };
let coreMatchCount = 0;
const perFamily = new Map(); // prefix -> {total, recovered, coreMatch}

function fam(sku){ const m = sku.match(/^([A-Za-z]+\d*|inv-[a-z]+|asl-\d+)/i); return (m?m[1]:sku.slice(0,6)).toUpperCase(); }

async function one(r) {
  const f = fam(r.mfr_sku);
  const pf = perFamily.get(f) || { total:0, recovered:0, coreMatch:0 }; pf.total++; perFamily.set(f, pf);
  const { html, status } = await getText(r.product_url);
  if (!html) { miss.nopdp.push({ ...r, status }); return; }
  const cm = coreMatchesHtml(html, r.mfr_sku);
  if (cm) { coreMatchCount++; pf.coreMatch++; }
  const url = extractImage(html, r.mfr_sku);
  if (!url || isDataUri(url)) { miss.noimg.push({ ...r, coreMatch: cm }); return; }
  const live = await isLiveImage(url);
  if (!live) { miss.notlive.push({ ...r, url }); return; }
  recovered.push({ mfr_sku: r.mfr_sku, new_image_url: url, verified: true });
  restore.push({ mfr_sku: r.mfr_sku, old_image_url: oldImages.get(r.mfr_sku) ?? null });
  pf.recovered++;
}

// bounded concurrency
let idx = 0;
async function worker() { while (idx < rows.length) { const r = rows[idx++]; await one(r); } }
await Promise.all(Array.from({ length: CONC }, worker));

writeFileSync(join(DATA, 'versa-recovery-kamatera.jsonl'), recovered.map(o => JSON.stringify(o)).join('\n') + (recovered.length ? '\n' : ''));
writeFileSync(join(DATA, 'versa-restore-kamatera-map.jsonl'), restore.map(o => JSON.stringify(o)).join('\n') + (restore.length ? '\n' : ''));

const N = rows.length;
console.log(`\n[recover-versa-kamatera] ${N} broken rows processed`);
console.log(`RECOVERED (verified live): ${recovered.length}/${N} = ${(recovered.length/N*100).toFixed(1)}%`);
console.log(`SKU-core matched an image on PDP: ${coreMatchCount}/${N}`);
console.log(`MISS breakdown:`);
console.log(`  nopdp (page fetch failed):        ${miss.nopdp.length}`);
console.log(`  noimg (no SKU-keyed image found): ${miss.noimg.length}`);
console.log(`  notlive (extracted but not 200/image): ${miss.notlive.length}`);
console.log(`\nPer-family (family: recovered/total, core-matched):`);
for (const [f, v] of [...perFamily.entries()].sort((a,b)=>b[1].total-a[1].total))
  console.log(`  ${f}: ${v.recovered}/${v.total}  core=${v.coreMatch}`);
if (miss.noimg.length) { console.log(`\nnoimg samples (first 6):`); miss.noimg.slice(0,6).forEach(m=>console.log(`  ${m.mfr_sku} coreMatch=${m.coreMatch}`)); }
if (miss.nopdp.length) { console.log(`\nnopdp samples (first 6):`); miss.nopdp.slice(0,6).forEach(m=>console.log(`  ${m.mfr_sku} status=${m.status}`)); }
if (miss.notlive.length) { console.log(`\nnotlive samples (first 6):`); miss.notlive.slice(0,6).forEach(m=>console.log(`  ${m.mfr_sku} ${m.url}`)); }