← back to Hollywood Import

innov-reconcile.mjs

108 lines

// Innovations-wide reconciliation: for every Innovations pattern we carry, probe the
// authed live catalog (one representative per pattern — prices are uniform per pattern),
// pull the list price, compute cost = list × 0.90 (10% trade discount), and stage.
// READ-ONLY on vendor + LOCAL stage only — NO Shopify/canonical write (gated).
import { chromium } from 'playwright';
import fs from 'node:fs';

const BASE = 'https://www.innovationsusa.com';
const DISCOUNT = 0.90; // 10% trade discount → cost = list × 0.90
const sleep = (ms) => new Promise(r => setTimeout(r, ms));

const rows = fs.readFileSync(new URL('innov-universe.tsv', import.meta.url), 'utf8')
  .trim().split('\n').map(l => {
    const [dw_sku, mfr_sku, pattern_name, color_name, product_url] = l.split('\t');
    return { dw_sku, mfr_sku, pattern_name, color_name, product_url };
  });

// derive a live item URL + pattern slug per row
const slugify = (s) => (s || '').toLowerCase()
  .replace(/\s*\|\s*innovations usa\s*$/i, '')
  .replace(/\s+wallcovering\s*$/i, '')
  .replace(/\s+\d+\s*$/,'')            // trailing "Damier 2" → "damier"
  .replace(/\(.*?\)/g,'').trim()
  .replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const liveItem = (r) => {
  const m = (r.product_url || '').match(/innovationsusa\.com\/item\/([^/]+)\/([^/?#]+)/i);
  if (m) return { slug: m[1].toLowerCase(), url: `${BASE}/item/${m[1]}/${m[2]}`.toLowerCase() };
  const slug = slugify(r.pattern_name);
  const code = (r.mfr_sku || '').toLowerCase();
  if (!slug || !/^[a-z]{2,5}-?\d/i.test(code)) return { slug, url: null };
  return { slug, url: `${BASE}/item/${slug}/${code}` };
};

// group rows by pattern slug
const groups = new Map();
for (const r of rows) {
  const { slug, url } = liveItem(r);
  const key = slug || ('?' + r.pattern_name);
  if (!groups.has(key)) groups.set(key, { slug: key, rows: [], probeUrls: [] });
  const g = groups.get(key);
  g.rows.push(r);
  if (url && !g.probeUrls.includes(url)) g.probeUrls.push(url);
}

const browser = await chromium.launch({ channel: 'chrome', headless: true });
const ctx = await browser.newContext({ storageState: './innov-auth.json', viewport: { width: 1400, height: 1000 } });
const page = await ctx.newPage();

const out = { ranAt: new Date().toISOString(), discount: '10% (cost = list × 0.90)', patterns: [] };
let priced = 0, disc = 0;

const priceItem = async (u) => {
  const resp = await page.goto(u, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => null);
  await sleep(1400);
  if (!resp || resp.status() >= 400) return null;
  return await page.evaluate(() => {
    const t = document.body.innerText;
    const h1 = (document.querySelector('h1,h2')?.innerText || '').trim();
    const prices = [...t.matchAll(/\$\s?(\d[\d,]*\.\d{2})/g)].map(m => parseFloat(m[1].replace(/,/g, '')));
    return { title: h1, prices };
  });
};

for (const g of groups.values()) {
  let listPrice = null, allPrices = [], hitUrl = null, title = '', resolvedBy = '';

  // (a) try our stored/constructed vendor URLs first
  for (const u of g.probeUrls.slice(0, 4)) {
    const r = await priceItem(u);
    if (r && r.prices.length) { allPrices = r.prices; title = r.title; hitUrl = u; listPrice = Math.max(...r.prices); resolvedBy = 'stored-url'; break; }
  }

  // (b) fallback: SEARCH the pattern name; accept only results whose slug matches THIS pattern's slug
  if (listPrice == null && g.slug && !g.slug.startsWith('?')) {
    const q = g.slug.replace(/-/g, ' ');
    await page.goto(`${BASE}/search/${encodeURIComponent(q)}`, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
    await sleep(1800);
    const links = await page.evaluate(() => [...document.querySelectorAll('a[href*="/item/"]')]
      .map(a => a.getAttribute('href')).filter((v, i, a) => a.indexOf(v) === i).slice(0, 8));
    // slug-match guard: only trust a result on OUR pattern's slug (avoids anjuna→coast false positives)
    const matched = links.filter(h => new RegExp(`/item/${g.slug}/`, 'i').test(h));
    for (const h of matched.slice(0, 3)) {
      const u = h.startsWith('http') ? h : BASE + h;
      const r = await priceItem(u);
      if (r && r.prices.length) { allPrices = r.prices; title = r.title; hitUrl = u; listPrice = Math.max(...r.prices); resolvedBy = 'search-slug-match'; break; }
    }
  }
  const status = listPrice != null ? 'current' : 'discontinued';
  if (status === 'current') priced += g.rows.length; else disc += g.rows.length;
  const cost = listPrice != null ? Math.round(listPrice * DISCOUNT * 100) / 100 : null;
  out.patterns.push({
    slug: g.slug, pattern_name: g.rows[0].pattern_name, colorways: g.rows.length,
    status, resolvedBy, hitUrl, title, listPrice, allPrices, cost,
    dw_skus: g.rows.map(r => r.dw_sku)
  });
  console.log(`${status === 'current' ? '✓' : '✗'} ${g.slug.padEnd(22)} cw:${String(g.rows.length).padStart(2)}  list:${listPrice ?? '—'}  cost:${cost ?? '—'}  ${resolvedBy.padEnd(16)} ${hitUrl ? hitUrl.replace(BASE, '') : 'no-url'}`);
}

out.summary = {
  patterns: out.patterns.length,
  current: out.patterns.filter(p => p.status === 'current').length,
  discontinued: out.patterns.filter(p => p.status === 'discontinued').length,
  colorways_priced: priced, colorways_discontinued: disc
};
fs.writeFileSync(new URL('innov-reconciliation.json', import.meta.url), JSON.stringify(out, null, 2));
console.log(`\n=== ${out.summary.patterns} patterns · ${out.summary.current} current / ${out.summary.discontinued} discontinued · ${priced} colorways priced, ${disc} discontinued ===`);
await browser.close();