← 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();