← back to Interiordesignershowroom

lib/normalize.js

131 lines

// The unified product contract. EVERY network adapter MUST return objects that pass
// through normalizeProduct() before they touch the database. This is what lets 4
// completely different feed shapes (CJ XML, Amazon PA-API JSON, Rakuten CSV,
// ShareASale pipe-delimited) render as one coherent catalog.

const VALID_NETWORKS = ['cj', 'amazon', 'rakuten', 'shareasale'];

// --- Taxonomy classifiers -------------------------------------------------
// Keyword → bucket. First match wins. Deliberately simple + deterministic; the
// editorial layer (hand-picked `featured` products + guides) carries the nuance.

const ROOM_RULES = [
  ['living-room', /\b(sofa|sectional|couch|coffee table|living room|loveseat|media console|tv stand)\b/i],
  ['bedroom',     /\b(bed|nightstand|dresser|headboard|bedroom|duvet|comforter|bedding|mattress)\b/i],
  ['dining',      /\b(dining|dinette|buffet|sideboard|bar stool|counter stool|dining table)\b/i],
  ['kitchen',     /\b(kitchen|cookware|pendant|island|backsplash)\b/i],
  ['bathroom',    /\b(bath|vanity|towel|shower|bathroom|faucet)\b/i],
  ['office',      /\b(desk|office chair|bookcase|filing|home office)\b/i],
  ['outdoor',     /\b(outdoor|patio|garden|adirondack|planter)\b/i],
  ['lighting',    /\b(lamp|chandelier|sconce|light|lighting)\b/i],
  ['decor',       /\b(vase|mirror|wall art|rug|pillow|throw|decor|curtain|clock)\b/i],
];

const STYLE_RULES = [
  ['mid-century', /\b(mid.?century|mcm|eames|danish modern)\b/i],
  ['modern',      /\b(modern|contemporary|minimalist|sleek)\b/i],
  ['traditional', /\b(traditional|classic|chesterfield|ornate|tufted)\b/i],
  ['farmhouse',   /\b(farmhouse|rustic|reclaimed|shaker)\b/i],
  ['industrial',  /\b(industrial|metal|pipe|loft)\b/i],
  ['boho',        /\b(boho|bohemian|rattan|macrame|woven|jute)\b/i],
  ['coastal',     /\b(coastal|nautical|beach|seaside)\b/i],
  ['glam',        /\b(glam|velvet|brass|gold leaf|mirrored|art deco|deco)\b/i],
  ['scandinavian',/\b(scandi|scandinavian|nordic|hygge)\b/i],
];

const COLOR_RULES = [
  ['neutral', /\b(white|ivory|cream|beige|oatmeal|greige|taupe|linen|natural|sand)\b/i],
  ['gray',    /\b(gray|grey|charcoal|slate|pewter)\b/i],
  ['black',   /\b(black|ebony|onyx|noir)\b/i],
  ['brown',   /\b(brown|walnut|espresso|cognac|tan|camel|oak|wood)\b/i],
  ['blue',    /\b(blue|navy|indigo|teal|cobalt)\b/i],
  ['green',   /\b(green|sage|olive|emerald|celadon|forest)\b/i],
  ['pink',    /\b(pink|blush|rose|mauve)\b/i],
  ['yellow',  /\b(yellow|gold|mustard|ochre)\b/i],
  ['red',     /\b(red|rust|terracotta|burgundy|crimson)\b/i],
];

function firstMatch(rules, text, fallback) {
  for (const [bucket, re] of rules) if (re.test(text)) return bucket;
  return fallback;
}

function classify(title = '', category = '') {
  const text = `${title} ${category}`;
  return {
    room:  firstMatch(ROOM_RULES,  text, 'decor'),
    style: firstMatch(STYLE_RULES, text, 'modern'),
    color: firstMatch(COLOR_RULES, text, 'neutral'),
  };
}

// --- The normalizer -------------------------------------------------------
function toNumber(v) {
  if (v === null || v === undefined || v === '') return null;
  const n = parseFloat(String(v).replace(/[^0-9.]/g, ''));
  return Number.isFinite(n) ? n : null;
}

/**
 * @param {object} raw   adapter-shaped fields (see below)
 * @param {string} network  one of VALID_NETWORKS
 * @returns {object|null}  DB-ready row, or null if it fails the minimum bar
 */
function normalizeProduct(raw, network) {
  if (!VALID_NETWORKS.includes(network)) {
    throw new Error(`normalizeProduct: unknown network "${network}"`);
  }
  const title = (raw.title || '').trim();
  const affiliate_url = (raw.affiliate_url || '').trim();
  // Minimum bar: a product with no title or no tracked link is useless to us.
  if (!title || !affiliate_url || !raw.external_id) return null;

  const category = (raw.category || '').trim();
  const tax = classify(title, category);

  return {
    network,
    advertiser: (raw.advertiser || '').trim() || null,
    external_id: String(raw.external_id),
    title,
    description: (raw.description || '').trim() || null,
    brand: (raw.brand || '').trim() || null,
    category: category || null,
    room: raw.room || tax.room,
    style: raw.style || tax.style,
    color: raw.color || tax.color,
    price: toNumber(raw.price),
    sale_price: toNumber(raw.sale_price),
    currency: raw.currency || 'USD',
    image_url: (raw.image_url || '').trim() || null,
    affiliate_url,
    in_stock: raw.in_stock !== false,
    featured: !!raw.featured,
  };
}

// UPSERT helper shared by every adapter + the seeder.
const UPSERT_SQL = `
  INSERT INTO products
    (network, advertiser, external_id, title, description, brand, category,
     room, style, color, price, sale_price, currency, image_url, affiliate_url,
     in_stock, featured, price_checked_at, updated_at)
  VALUES
    ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17, now(), now())
  ON CONFLICT (network, external_id) DO UPDATE SET
    advertiser=EXCLUDED.advertiser, title=EXCLUDED.title, description=EXCLUDED.description,
    brand=EXCLUDED.brand, category=EXCLUDED.category, room=EXCLUDED.room, style=EXCLUDED.style,
    color=EXCLUDED.color, price=EXCLUDED.price, sale_price=EXCLUDED.sale_price,
    currency=EXCLUDED.currency, image_url=EXCLUDED.image_url, affiliate_url=EXCLUDED.affiliate_url,
    in_stock=EXCLUDED.in_stock, price_checked_at=now(), updated_at=now()
  RETURNING id;
`;

function upsertParams(p) {
  return [p.network, p.advertiser, p.external_id, p.title, p.description, p.brand,
    p.category, p.room, p.style, p.color, p.price, p.sale_price, p.currency,
    p.image_url, p.affiliate_url, p.in_stock, p.featured];
}

module.exports = { VALID_NETWORKS, classify, normalizeProduct, UPSERT_SQL, upsertParams };