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