← 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 };