← back to Whatsmystyle
scripts/clothing-apis/awin.js
122 lines
/**
* Awin affiliate-feed adapter — bulk product CSVs.
*
* Awin (awin.com) is the affiliate network behind 8,000+ brands including
* Reformation, Madewell, Anthropologie, ASOS, Net-a-Porter, Boden, Hush.
* Free publisher signup. After approval per-advertiser, Awin gives you a
* stable CSV/XML URL like:
* https://productdata.awin.com/datafeed/download/apikey/<KEY>/language/en/fid/<ADVERTISER_ID>/format/csv/...
*
* For now this adapter ingests pre-downloaded CSVs from data/awin/<id>.csv
* so we don't depend on a live fetch (Awin's URL pattern shifts and the
* publisher contract needs explicit per-brand approval). Once Steve has
* the key + approved advertisers, add the fetch leg.
*
* CSV column mapping (Awin's canonical fashion schema):
* aw_product_id, merchant_name, product_name, search_price,
* aw_image_url, aw_deep_link, category_name, brand_name, currency
*
* Env (for live fetch — phase 2):
* AWIN_API_KEY — your publisher api key
* AWIN_PUBLISHER_ID — your publisher id
*/
const fs = require('fs');
const path = require('path');
const FEED_DIR = path.join(__dirname, '..', '..', 'data', 'awin');
const CATEGORY_MAP = [
[/dress|gown|jumpsuit|romper/i, 'dress'],
[/coat|jacket|parka|trench|blazer|puffer/i, 'outerwear'],
[/shoe|sneaker|boot|sandal|loafer|heel|flat$/i, 'shoes'],
[/bag|tote|purse|backpack|crossbody|clutch/i, 'bag'],
[/scarf|hat|belt|sunglasses|jewelry|earring|necklace|ring$|bracelet|accessor/i, 'accessory'],
[/pant|trouser|jean|chino|legging|short|skirt/i, 'bottom'],
[/sweater|knit|cardigan|hoodie/i, 'top'],
[/t.?shirt|tee|tank|cami|blouse|shirt|button[- ]?down|top/i, 'top'],
];
function normalizeCategory(text) {
for (const [re, cat] of CATEGORY_MAP) if (re.test(text)) return cat;
return null;
}
/**
* Minimal CSV parser — handles quoted fields with embedded commas + quotes.
* Good enough for Awin's well-formed fashion feeds; not RFC 4180 perfect.
*/
function parseCsv(text) {
const rows = [];
let row = [], field = '', inQuotes = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQuotes) {
if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
else if (c === '"') { inQuotes = false; }
else field += c;
} else {
if (c === '"') inQuotes = true;
else if (c === ',') { row.push(field); field = ''; }
else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
else if (c === '\r') { /* skip */ }
else field += c;
}
}
if (field.length || row.length) { row.push(field); rows.push(row); }
if (!rows.length) return [];
const headers = rows.shift().map(h => h.trim());
return rows.filter(r => r.length === headers.length).map(r =>
Object.fromEntries(r.map((v, i) => [headers[i], v])));
}
function rowFromAwin(advertiserId, sustainTier, proGrade, r) {
const cents = Math.round(Number(r.search_price || r.RRP_price || 0) * 100);
const image = r.aw_image_url || r.merchant_image_url || r.large_image;
if (!image || !cents) return null;
const text = `${r.product_name || ''} ${r.category_name || ''}`;
const category = normalizeCategory(text);
if (!category) return null;
return {
source: `awin:${advertiserId}`,
external_id: `awin:${r.aw_product_id || r.merchant_product_id}`,
title: (r.product_name || '').slice(0, 200),
brand: r.brand_name || r.merchant_name || advertiserId,
category,
price_cents: cents,
currency: r.currency || 'USD',
image_url: image,
product_url: r.aw_deep_link || r.merchant_deep_link,
tags: JSON.stringify([r.category_name].filter(Boolean)),
pro_grade: proGrade,
};
}
async function importAdvertiser(db, advertiserEntry) {
const { id, advertiser_id, name, sustain_tier, pro_grade } = advertiserEntry;
const file = path.join(FEED_DIR, `${advertiser_id}.csv`);
if (!fs.existsSync(file)) {
return { kind: 'awin', id, ok: false, skipped: `no feed at data/awin/${advertiser_id}.csv` };
}
const text = fs.readFileSync(file, 'utf8');
const rows = parseCsv(text);
const insert = db.prepare(`
INSERT OR IGNORE INTO items
(source, external_id, title, brand, category, price_cents, currency, image_url, product_url, tags, pro_grade)
VALUES
(@source, @external_id, @title, @brand, @category, @price_cents, @currency, @image_url, @product_url, @tags, @pro_grade)
`);
let imported = 0, skipped = 0;
const tx = db.transaction(() => {
for (const r of rows) {
const mapped = rowFromAwin(advertiser_id, sustain_tier, pro_grade, r);
if (!mapped) { skipped++; continue; }
const res = insert.run(mapped);
if (res.changes > 0) imported++; else skipped++;
}
});
tx();
return { kind: 'awin', id, ok: true, fetched: rows.length, imported, skipped };
}
module.exports = { importAdvertiser, parseCsv };