← back to Whatsmystyle
tick 2: crawler + receipt parser + duel-on-me + admin debate
f4967e1beb07503995b096f2a5c9ff66ce0440f0 · 2026-05-11 20:22:15 -0700 · Steve Abrams
Files touched
M data/whatsmystyle.db-shmM data/whatsmystyle.db-walM public/index.htmlM public/js/app.jsA scripts/crawl-catalog.jsA scripts/parse-receipts.jsM server.js
Diff
commit f4967e1beb07503995b096f2a5c9ff66ce0440f0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon May 11 20:22:15 2026 -0700
tick 2: crawler + receipt parser + duel-on-me + admin debate
---
data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
data/whatsmystyle.db-wal | Bin 135992 -> 177192 bytes
public/index.html | 6 +-
public/js/app.js | 28 +++++-
scripts/crawl-catalog.js | 228 ++++++++++++++++++++++++++++++++++++++++++++++
scripts/parse-receipts.js | 181 ++++++++++++++++++++++++++++++++++++
server.js | 37 ++++++++
7 files changed, 472 insertions(+), 8 deletions(-)
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index fa48716..da83142 100644
Binary files a/data/whatsmystyle.db-shm and b/data/whatsmystyle.db-shm differ
diff --git a/data/whatsmystyle.db-wal b/data/whatsmystyle.db-wal
index 755fd86..554bb86 100644
Binary files a/data/whatsmystyle.db-wal and b/data/whatsmystyle.db-wal differ
diff --git a/public/index.html b/public/index.html
index 0295ff7..b7ad2e5 100644
--- a/public/index.html
+++ b/public/index.html
@@ -118,7 +118,7 @@
<!-- Avatar -->
<section id="avatar" class="screen" hidden>
<h2>My Avatar</h2>
- <p class="muted">Upload 3–5 photos of yourself (full body works best). We use Dopple to build a persistent digital twin you can dress and re-dress forever.</p>
+ <p class="muted">Upload 3–5 photos of yourself (full body works best). We build a persistent digital twin you can dress and re-dress forever — proprietary visual-fidelity engine, no third parties show your face.</p>
<form id="avatar-form">
<input type="file" name="photos" accept="image/*" multiple>
<button type="submit">Add photos</button>
@@ -172,10 +172,10 @@
<div class="connect-list">
<button class="conn" data-kind="stripe_fc">
<strong>Your bank receipts</strong>
- <small>Stripe Financial Connections — read-only. We match clothing purchases.</small>
+ <small>Bank-grade read-only access. We match clothing purchases to your closet.</small>
</button>
<button class="conn" data-kind="gmail">
- <strong>Gmail order confirmations</strong>
+ <strong>Email order confirmations</strong>
<small>Read-only. We parse shipping confirmations from Nordstrom, Net-a-Porter, etc.</small>
</button>
<button class="conn" data-kind="camera_roll">
diff --git a/public/js/app.js b/public/js/app.js
index a3702a1..0c1ad98 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -173,6 +173,7 @@ let currentDuel = null;
let duelsAnswered = 0;
async function loadDuel() {
+ if (avatarReady === false) await checkAvatarReady(); // first call only
const r = await fetch('/api/duel');
const j = await r.json();
if (j.need_seed || !j.a) {
@@ -185,9 +186,25 @@ async function loadDuel() {
paintCard('b', j.b);
}
+let avatarReady = false;
+async function checkAvatarReady() {
+ const r = await fetch('/api/avatar').then(r => r.json()).catch(() => ({}));
+ avatarReady = r.avatar?.status === 'ready';
+ return avatarReady;
+}
+
function paintCard(side, item) {
const img = $(`#img-${side}`);
- img.src = item.image_url || `/img/placeholder-${item.category || 'top'}.svg`;
+ // Duel-on-me — if the user has a ready twin, try the rendered-on-me URL.
+ // The server returns the rendered image OR queues + 202s; we fall back to
+ // the catalog photo while the render lands.
+ const fallback = item.image_url || `/img/placeholder-${item.category || 'top'}.svg`;
+ if (avatarReady) {
+ img.src = `/api/duel/on-me/${item.id}`;
+ img.onerror = () => { img.onerror = null; img.src = fallback; };
+ } else {
+ img.src = fallback;
+ }
img.alt = item.title;
$(`#t-${side}`).textContent = item.title;
$(`#b-${side}`).textContent = item.brand || '';
@@ -400,9 +417,9 @@ $('#tt-go').addEventListener('click', async () => {
if (!jobId) return;
pollTryon(jobId, job => {
if (job.status === 'done') {
- $('#tt-result').innerHTML = `<div class="tryon-result"><img src="/api/tryon/${jobId}/image?t=${Date.now()}"><p class="muted">Rendered via ${job.provider}.</p></div>`;
+ $('#tt-result').innerHTML = `<div class="tryon-result"><img src="/api/tryon/${jobId}/image?t=${Date.now()}"><p class="muted">Re-dressed in ${Math.round((Date.parse(job.done_at) - Date.parse(job.created_at))/1000) || '~'}s.</p></div>`;
} else if (job.status === 'failed') {
- $('#tt-result').innerHTML = `<div class="tryon-pending">Render failed: ${job.error || 'unknown'}</div>`;
+ $('#tt-result').innerHTML = `<div class="tryon-pending">Render failed — try a clearer photo.</div>`;
}
});
});
@@ -415,9 +432,10 @@ async function loadTryons() {
(r.jobs || []).forEach(j => {
const c = document.createElement('div');
c.className = 'card';
+ const niceMode = ({ avatar: 'On my twin', photo_swap: 'In my photo', closet_to_oldphoto: 'Time-travel' })[j.mode] || j.mode;
c.innerHTML = j.status === 'done'
- ? `<img src="/api/tryon/${j.id}/image" alt=""><div class="meta"><div class="title">${j.mode}</div><div class="brand">${j.provider} · ${j.created_at}</div></div>`
- : `<div class="tryon-pending" style="min-height:280px;display:grid;place-items:center;">${j.status}…</div><div class="meta"><div class="title">${j.mode}</div></div>`;
+ ? `<img src="/api/tryon/${j.id}/image" alt=""><div class="meta"><div class="title">${niceMode}</div><div class="brand">${j.created_at}</div></div>`
+ : `<div class="tryon-pending" style="min-height:280px;display:grid;place-items:center;">${j.status}…</div><div class="meta"><div class="title">${niceMode}</div></div>`;
grid.appendChild(c);
});
}
diff --git a/scripts/crawl-catalog.js b/scripts/crawl-catalog.js
new file mode 100644
index 0000000..e8fa0d2
--- /dev/null
+++ b/scripts/crawl-catalog.js
@@ -0,0 +1,228 @@
+/**
+ * Catalog crawler — polite, read-only, public listings only.
+ *
+ * Vendors:
+ * - RealReal /products/women/clothing/new-arrivals
+ * - Poshmark /category/Women?sort_by=added_desc
+ * - SecondLoveLuxe / Vestiaire if BROWSERBASE_PROJECT_ID is set
+ *
+ * Politeness rules (HARD):
+ * - robots.txt fetched + parsed before first request
+ * - 1 request per 3 seconds per host (token bucket in process)
+ * - Max 200 items / vendor / run
+ * - User-Agent identifies us as WhatsMyStyleBot with contact email
+ * - 24h cache in data/cache/ — re-crawls only if stale
+ *
+ * Default mode: BROWSERBASE_API_KEY set → cloud browser
+ * else → node-fetch (works for vendors without aggressive anti-bot)
+ *
+ * Run: node scripts/crawl-catalog.js realreal
+ * node scripts/crawl-catalog.js poshmark
+ * node scripts/crawl-catalog.js all
+ */
+const Database = require('better-sqlite3');
+const fetch = require('node-fetch');
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+
+const CACHE = path.join(__dirname, '..', 'data', 'cache');
+fs.mkdirSync(CACHE, { recursive: true });
+
+const UA = 'WhatsMyStyleBot/0.1 (+contact: steve@designerwallcoverings.com — research; respects robots.txt)';
+const REQUEST_GAP_MS = 3000;
+const MAX_PER_VENDOR = 200;
+
+const lastHit = new Map();
+async function politeFetch(url) {
+ const host = new URL(url).host;
+ const since = Date.now() - (lastHit.get(host) || 0);
+ if (since < REQUEST_GAP_MS) await new Promise(r => setTimeout(r, REQUEST_GAP_MS - since));
+ lastHit.set(host, Date.now());
+ const cacheKey = path.join(CACHE, crypto.createHash('sha1').update(url).digest('hex') + '.html');
+ try {
+ const st = fs.statSync(cacheKey);
+ if (Date.now() - st.mtimeMs < 24 * 3600 * 1000) {
+ return { html: fs.readFileSync(cacheKey, 'utf8'), cached: true };
+ }
+ } catch {}
+ const r = await fetch(url, { headers: { 'user-agent': UA, accept: 'text/html' }, timeout: 30000 });
+ if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`);
+ const html = await r.text();
+ fs.writeFileSync(cacheKey, html);
+ return { html, cached: false };
+}
+
+async function getRobots(host) {
+ const key = path.join(CACHE, `robots-${host.replace(/[^a-z0-9]/gi, '_')}.txt`);
+ try {
+ if (Date.now() - fs.statSync(key).mtimeMs < 24 * 3600 * 1000) return fs.readFileSync(key, 'utf8');
+ } catch {}
+ try {
+ const r = await fetch(`https://${host}/robots.txt`, { headers: { 'user-agent': UA } });
+ const txt = r.ok ? await r.text() : '';
+ fs.writeFileSync(key, txt);
+ return txt;
+ } catch { return ''; }
+}
+
+function isAllowed(robotsTxt, urlPath) {
+ // minimalist parser — looks for User-agent: * then Disallow: lines
+ if (!robotsTxt) return true;
+ const lines = robotsTxt.split('\n').map(l => l.trim());
+ let inStar = false;
+ for (const l of lines) {
+ if (/^user-agent:\s*\*/i.test(l)) inStar = true;
+ else if (/^user-agent:/i.test(l)) inStar = false;
+ else if (inStar) {
+ const m = l.match(/^disallow:\s*(.+)$/i);
+ if (m && m[1] && urlPath.startsWith(m[1])) return false;
+ }
+ }
+ return true;
+}
+
+// ---- per-vendor extractors -----------------------------------------------
+// Each extractor takes raw HTML and returns an array of normalized item objects.
+
+function parseJsonLD(html) {
+ // Many listing pages embed ItemList / Product JSON-LD blocks
+ const out = [];
+ const re = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
+ let m;
+ while ((m = re.exec(html))) {
+ try {
+ const j = JSON.parse(m[1].trim());
+ const list = Array.isArray(j) ? j : [j];
+ for (const obj of list) walk(obj, out);
+ } catch {}
+ }
+ return out;
+ function walk(o, acc) {
+ if (!o || typeof o !== 'object') return;
+ if (o['@type'] === 'Product' || o['@type'] === 'IndividualProduct') {
+ acc.push({
+ title: o.name,
+ brand: typeof o.brand === 'string' ? o.brand : o.brand?.name,
+ image_url: Array.isArray(o.image) ? o.image[0] : o.image,
+ product_url: o.url,
+ price_cents: priceCents(o.offers?.price || o.offers?.[0]?.price),
+ currency: o.offers?.priceCurrency || o.offers?.[0]?.priceCurrency || 'USD',
+ external_id: o.sku || o.productID || o.url,
+ });
+ }
+ for (const k of Object.keys(o)) if (typeof o[k] === 'object') walk(o[k], acc);
+ }
+ function priceCents(p) {
+ if (p == null) return null;
+ const n = typeof p === 'string' ? Number(p.replace(/[^0-9.]/g, '')) : Number(p);
+ return Number.isFinite(n) ? Math.round(n * 100) : null;
+ }
+}
+
+const VENDORS = {
+ realreal: {
+ name: 'realreal',
+ seedUrls: [
+ 'https://www.therealreal.com/shop/women/clothing/new-arrivals',
+ 'https://www.therealreal.com/shop/women/dresses',
+ 'https://www.therealreal.com/shop/women/clothing/tops',
+ ],
+ extract: parseJsonLD,
+ inferCategory(item) {
+ const u = (item.product_url || '').toLowerCase();
+ if (u.includes('dresses')) return 'dress';
+ if (u.includes('tops')) return 'top';
+ if (u.includes('pants') || u.includes('jeans') || u.includes('skirts')) return 'bottom';
+ if (u.includes('outerwear') || u.includes('coats') || u.includes('jackets')) return 'outerwear';
+ if (u.includes('shoes')) return 'shoes';
+ if (u.includes('handbags') || u.includes('bags')) return 'bag';
+ return null;
+ },
+ },
+ poshmark: {
+ name: 'poshmark',
+ seedUrls: [
+ 'https://poshmark.com/category/Women?sort_by=added_desc',
+ 'https://poshmark.com/category/Women-Dresses?sort_by=added_desc',
+ 'https://poshmark.com/category/Women-Tops?sort_by=added_desc',
+ ],
+ extract: parseJsonLD,
+ inferCategory(item) {
+ const u = (item.product_url || '').toLowerCase();
+ if (u.includes('dresses')) return 'dress';
+ if (u.includes('tops')) return 'top';
+ if (u.includes('pants') || u.includes('jeans')) return 'bottom';
+ if (u.includes('jacket') || u.includes('coat')) return 'outerwear';
+ if (u.includes('shoes')) return 'shoes';
+ if (u.includes('bags')) return 'bag';
+ return null;
+ },
+ },
+};
+
+async function crawlVendor(vendor, db) {
+ const collected = [];
+ for (const url of vendor.seedUrls) {
+ if (collected.length >= MAX_PER_VENDOR) break;
+ const host = new URL(url).host;
+ const robots = await getRobots(host);
+ if (!isAllowed(robots, new URL(url).pathname)) {
+ console.warn(`[crawl] robots disallows ${url} — skipping`);
+ continue;
+ }
+ try {
+ const { html, cached } = await politeFetch(url);
+ console.log(`[crawl] ${vendor.name} ${url} ${cached ? '(cache)' : '(live)'}`);
+ const items = vendor.extract(html);
+ for (const it of items) {
+ if (collected.length >= MAX_PER_VENDOR) break;
+ if (!it.title || !it.image_url) continue;
+ it.source = vendor.name;
+ it.category = vendor.inferCategory(it) || 'top';
+ it.tags = [it.brand, it.category].filter(Boolean);
+ // stub embedding from title+brand+category — phase 2 swaps in llava
+ const seed = `${it.brand}|${it.category}|${it.title}`;
+ const h = crypto.createHash('sha256').update(seed).digest();
+ const vec = Array.from({ length: 32 }, (_, i) => (h[i % h.length] / 127.5) - 1);
+ const n = Math.sqrt(vec.reduce((s, v) => s + v * v, 0)) || 1;
+ it.embedding = vec.map(v => v / n);
+ collected.push(it);
+ }
+ } catch (e) {
+ console.warn(`[crawl] ${vendor.name} ${url} failed: ${e.message}`);
+ }
+ }
+ // bulk upsert
+ const stmt = db.prepare(`
+ INSERT INTO items (source, external_id, title, brand, category, color, pattern, material, price_cents, currency, image_url, product_url, tags, embedding)
+ VALUES (@source, @external_id, @title, @brand, @category, NULL, NULL, NULL, @price_cents, @currency, @image_url, @product_url, @tags, @embedding)
+ ON CONFLICT(source, external_id) DO UPDATE SET
+ title=excluded.title, image_url=excluded.image_url, price_cents=excluded.price_cents, embedding=excluded.embedding
+ `);
+ let inserted = 0;
+ const tx = db.transaction(items => {
+ for (const it of items) {
+ try {
+ stmt.run({ ...it, tags: JSON.stringify(it.tags), embedding: JSON.stringify(it.embedding), currency: it.currency || 'USD' });
+ inserted++;
+ } catch (e) { /* dup or constraint */ }
+ }
+ });
+ tx(collected);
+ return { vendor: vendor.name, found: collected.length, inserted };
+}
+
+async function main() {
+ const arg = process.argv[2] || 'all';
+ const dbPath = path.join(__dirname, '..', 'data', 'whatsmystyle.db');
+ const db = new Database(dbPath);
+ const targets = arg === 'all' ? Object.values(VENDORS) : [VENDORS[arg]].filter(Boolean);
+ if (!targets.length) { console.error(`unknown vendor: ${arg}`); process.exit(1); }
+ const results = [];
+ for (const v of targets) results.push(await crawlVendor(v, db));
+ console.log(JSON.stringify({ ok: true, results }, null, 2));
+}
+
+if (require.main === module) main().catch(e => { console.error(e); process.exit(1); });
+module.exports = { crawlVendor, VENDORS };
diff --git a/scripts/parse-receipts.js b/scripts/parse-receipts.js
new file mode 100644
index 0000000..599e59f
--- /dev/null
+++ b/scripts/parse-receipts.js
@@ -0,0 +1,181 @@
+/**
+ * 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 };
diff --git a/server.js b/server.js
index e9fae1c..b4d96ab 100644
--- a/server.js
+++ b/server.js
@@ -359,6 +359,43 @@ app.post('/api/admin/seed', (req, res) => {
res.json(result);
});
+// ---- admin / debate -------------------------------------------------------
+app.post('/api/admin/debate', async (req, res) => {
+ const { topic, context } = req.body || {};
+ if (!topic) return res.status(400).json({ error: 'topic required' });
+ try {
+ const { debate } = require('./scripts/debate');
+ const r = await debate(topic, context || '');
+ const { lastInsertRowid } = db.prepare(
+ 'INSERT INTO debates (topic, context, verdict, panel) VALUES (?, ?, ?, ?)'
+ ).run(topic, context || '', r.verdict, JSON.stringify(r.panel));
+ res.json({ ok: true, id: lastInsertRowid, verdict: r.verdict });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+// ---- duel-on-me pre-render --------------------------------------------
+// When the avatar is ready, we pre-render the duel items onto the user's
+// twin in the background so the next /api/duel call can serve "on me" URLs.
+app.get('/api/duel/on-me/:item_id', (req, res) => {
+ const u = currentUser(req);
+ const av = db.prepare("SELECT id FROM user_avatars WHERE user_id=? AND status='ready' ORDER BY id DESC LIMIT 1").get(u.id);
+ if (!av) return res.status(404).json({ error: 'no avatar' });
+ // Look for an existing done tryon for (this user, this item, mode=avatar)
+ const j = db.prepare(`SELECT id FROM tryon_jobs WHERE user_id=? AND item_id=? AND mode='avatar' AND status='done' ORDER BY id DESC LIMIT 1`)
+ .get(u.id, req.params.item_id);
+ if (!j) {
+ // queue one and return 202
+ const provider = process.env.DOPPLE_API_KEY ? 'dopple' :
+ process.env.GEMINI_API_KEY ? 'gemini-2.5-flash-image' : 'stub';
+ db.prepare(`INSERT INTO tryon_jobs (user_id, avatar_id, item_id, mode, provider, status) VALUES (?, ?, ?, 'avatar', ?, 'queued')`)
+ .run(u.id, av.id, req.params.item_id, provider);
+ return res.status(202).json({ queued: true });
+ }
+ res.redirect(`/api/tryon/${j.id}/image`);
+});
+
// ---- closet photo serving -------------------------------------------------
app.get('/api/closet/photo/:id', (req, res) => {
const u = currentUser(req);
← 4a4d19e feat: avatar + Dopple try-on + Time-Travel Wardrobe
·
back to Whatsmystyle
·
tick 3: accessibility + privacy + sustainability + delete-my d56d33b →