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