← back to Whatsmystyle

scripts/parse-receipts.js

182 lines

/**
 * Receipt parser — turns retailer shipping/order emails into closet rows.
 *
 * Two inputs supported:
 *   1. A directory of .eml files (data/uploads/eml/) — what Gmail Takeout exports
 *   2. A list of {subject, from, body} objects piped in via stdin JSON — what
 *      the Gmail API returns
 *
 * Heuristics per retailer (kept simple — pluggable adapter pattern):
 *   - Nordstrom            — "Your order has shipped"   / "$XX.XX"
 *   - Net-a-Porter         — "Your NET-A-PORTER order"
 *   - The RealReal         — "Your TRR purchase"
 *   - Saks                 — "Your Saks order has shipped"
 *   - Mr Porter / Matches  — luxury mens
 *   - Anthropologie / Zara / J.Crew — mass
 *   - Etsy / eBay / Poshmark / Vestiaire — resale
 *
 * Each adapter returns:  { brand, items: [{ title, sku, price_cents, image_url }], order_id, ordered_at }
 *
 * The wallet:
 *   parsed items → INSERT INTO closet (..., acquired_via='gmail', source_meta=JSON)
 *
 * v0.1 ships with regex adapters. v0.2 swaps in an Ollama qwen3:14b "extract
 * brand+SKU+price from this email body" pass for unknown senders.
 */
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');

const ADAPTERS = {
  nordstrom: {
    senderMatch: /@(emails\.)?nordstrom\.com/i,
    subjectMatch: /(your order|has shipped|tracking)/i,
    parse(body) {
      const items = [];
      // Nordstrom emails list items as: "Brand Name\nItem Title\n$XX.XX\nSize: ..."
      const re = /([A-Z][a-zA-Z0-9 .&'-]{2,30})\n([^\n]{6,80})\n\$([0-9,]+\.[0-9]{2})/g;
      let m;
      while ((m = re.exec(body))) {
        items.push({ brand: m[1].trim(), title: m[2].trim(), price_cents: Math.round(Number(m[3].replace(/,/g, '')) * 100) });
      }
      return { brand_default: null, items };
    },
  },
  netaporter: {
    senderMatch: /@(emails\.)?net-a-porter\.com/i,
    subjectMatch: /(your NET-A-PORTER|your order|has been dispatched)/i,
    parse(body) {
      const items = [];
      const re = /([A-Z][a-zA-Z0-9 .&'-]{2,30})\s+\n([^\n]{6,80})\s+\n(?:USD|US\$)\s?([0-9,]+(?:\.[0-9]{2})?)/g;
      let m;
      while ((m = re.exec(body))) items.push({ brand: m[1].trim(), title: m[2].trim(), price_cents: Math.round(Number(m[3].replace(/,/g, '')) * 100) });
      return { brand_default: null, items };
    },
  },
  realreal: {
    senderMatch: /@(emails\.)?therealreal\.com/i,
    subjectMatch: /(your TRR|your order|your purchase)/i,
    parse(body) {
      const items = [];
      const re = /([A-Z][A-Za-z0-9 .&'-]{2,30})\s+([A-Za-z][^\n]{6,80})\s+\$([0-9,]+\.[0-9]{2})/g;
      let m;
      while ((m = re.exec(body))) items.push({ brand: m[1].trim(), title: m[2].trim(), price_cents: Math.round(Number(m[3].replace(/,/g, '')) * 100) });
      return { brand_default: null, items };
    },
  },
  poshmark: {
    senderMatch: /@poshmark\.com/i,
    subjectMatch: /(you bought|order confirmation|posh order)/i,
    parse(body) {
      const items = [];
      const re = /([A-Z][a-zA-Z0-9 .&'-]{2,30})\n([^\n]{4,80})\n\$([0-9,]+\.[0-9]{2})/g;
      let m;
      while ((m = re.exec(body))) items.push({ brand: m[1].trim(), title: m[2].trim(), price_cents: Math.round(Number(m[3].replace(/,/g, '')) * 100) });
      return { brand_default: null, items };
    },
  },
  ssense: {
    senderMatch: /@ssense\.com/i,
    subjectMatch: /(order|shipped)/i,
    parse(body) {
      const items = [];
      const re = /([A-Z][A-Za-z0-9 .&'-]{2,30})\s+([^\n]{6,80})\s+\$([0-9,]+(?:\.[0-9]{2})?)/g;
      let m;
      while ((m = re.exec(body))) items.push({ brand: m[1].trim(), title: m[2].trim(), price_cents: Math.round(Number(m[3].replace(/,/g, '')) * 100) });
      return { brand_default: null, items };
    },
  },
  generic: {
    // fallback — only used when no specific adapter hits. Tags brand as 'unknown'.
    senderMatch: /.*/,
    subjectMatch: /(order|shipped|receipt|invoice|confirmation)/i,
    parse(body) {
      const items = [];
      const re = /([A-Z][a-zA-Z .'-]{4,40})[\s—\-:]+\$([0-9,]+\.[0-9]{2})/g;
      let m;
      while ((m = re.exec(body))) items.push({ brand: null, title: m[1].trim(), price_cents: Math.round(Number(m[2].replace(/,/g, '')) * 100) });
      return { brand_default: 'unknown', items: items.slice(0, 6) };
    },
  },
};

function pickAdapter({ from, subject }) {
  for (const [name, ad] of Object.entries(ADAPTERS)) {
    if (name === 'generic') continue;
    if (ad.senderMatch.test(from || '') && ad.subjectMatch.test(subject || '')) return { name, adapter: ad };
  }
  return { name: 'generic', adapter: ADAPTERS.generic };
}

function parseEml(raw) {
  // very lightweight RFC-822 split — good enough for receipt subjects/bodies
  const headersEnd = raw.indexOf('\n\n');
  const headerBlock = raw.slice(0, headersEnd);
  const body = raw.slice(headersEnd + 2);
  const headers = {};
  headerBlock.split('\n').forEach(l => {
    const m = l.match(/^([\w-]+):\s*(.+)/);
    if (m) headers[m[1].toLowerCase()] = m[2];
  });
  return { from: headers.from, subject: headers.subject, body };
}

function parseEmail({ from, subject, body }) {
  const { name, adapter } = pickAdapter({ from, subject });
  const out = adapter.parse(body || '');
  return { adapter: name, ...out };
}

function insertCloset(db, userId, email, parsed) {
  const stmt = db.prepare(`INSERT INTO closet (user_id, category, color, vendor_guess, acquired_via, source_meta)
    VALUES (?, NULL, NULL, ?, 'gmail', ?)`);
  let n = 0;
  for (const it of parsed.items) {
    stmt.run(userId, it.brand || parsed.brand_default || 'unknown',
      JSON.stringify({ title: it.title, price_cents: it.price_cents, from: email.from, subject: email.subject, adapter: parsed.adapter }));
    n++;
  }
  return n;
}

async function main() {
  const cmd = process.argv[2];
  if (cmd === 'eml-dir') {
    const dir = process.argv[3];
    if (!dir || !fs.existsSync(dir)) { console.error('usage: parse-receipts.js eml-dir <path>'); process.exit(1); }
    const dbPath = path.join(__dirname, '..', 'data', 'whatsmystyle.db');
    const db = new Database(dbPath);
    const userId = Number(process.argv[4] || 1);
    const results = [];
    for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.eml'))) {
      const e = parseEml(fs.readFileSync(path.join(dir, f), 'utf8'));
      const parsed = parseEmail(e);
      const n = insertCloset(db, userId, e, parsed);
      results.push({ file: f, adapter: parsed.adapter, items: parsed.items.length, inserted: n });
    }
    console.log(JSON.stringify({ ok: true, results }, null, 2));
    return;
  }
  if (cmd === 'stdin') {
    let buf = '';
    for await (const c of process.stdin) buf += c;
    const emails = JSON.parse(buf);
    const dbPath = path.join(__dirname, '..', 'data', 'whatsmystyle.db');
    const db = new Database(dbPath);
    const userId = Number(process.argv[3] || 1);
    const results = emails.map(e => {
      const parsed = parseEmail(e);
      const n = insertCloset(db, userId, e, parsed);
      return { adapter: parsed.adapter, items: parsed.items.length, inserted: n };
    });
    console.log(JSON.stringify({ ok: true, results }, null, 2));
    return;
  }
  console.error('usage: node parse-receipts.js eml-dir <path> [user_id]');
  console.error('       node parse-receipts.js stdin [user_id]  < emails.json');
  process.exit(1);
}

if (require.main === module) main().catch(e => { console.error(e); process.exit(1); });
module.exports = { parseEmail, parseEml, pickAdapter, ADAPTERS };