← back to Whatsmystyle
yolo tick 22: SPA selector calibrator (BB fetch + cheerio probe + paste-ready output) + paid-API budget pill on embed-chip (color-coded vs budget_cents_per_user) + /admin/drifts viewer (recent events + most-drifted items)
c0bf68a032168f06e5c39a162690d5f9a33b524b · 2026-05-12 12:03:52 -0700 · SteveStudio2
Files touched
A public/admin-drifts.htmlM public/css/app.cssM public/index.htmlM public/js/app.jsA scripts/clothing-apis/calibrate-spa.jsM server.js
Diff
commit c0bf68a032168f06e5c39a162690d5f9a33b524b
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 12:03:52 2026 -0700
yolo tick 22: SPA selector calibrator (BB fetch + cheerio probe + paste-ready output) + paid-API budget pill on embed-chip (color-coded vs budget_cents_per_user) + /admin/drifts viewer (recent events + most-drifted items)
---
public/admin-drifts.html | 98 +++++++++++++++++++++++++++
public/css/app.css | 12 ++++
public/index.html | 4 ++
public/js/app.js | 13 ++++
scripts/clothing-apis/calibrate-spa.js | 120 +++++++++++++++++++++++++++++++++
server.js | 43 ++++++++++++
6 files changed, 290 insertions(+)
diff --git a/public/admin-drifts.html b/public/admin-drifts.html
new file mode 100644
index 0000000..3636488
--- /dev/null
+++ b/public/admin-drifts.html
@@ -0,0 +1,98 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="robots" content="noindex,nofollow" />
+ <title>WhatsMyStyle — Embedding-drift ledger</title>
+ <link rel="stylesheet" href="/css/app.css" />
+ <style>
+ body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background: #faf7f2; color: #1d1d1f; max-width: 1100px; margin: 40px auto; padding: 0 24px; }
+ h1 { font-size: 32px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 8px; }
+ .sub { color: #707070; margin-bottom: 32px; font-size: 14px; }
+ .card { background: #fff; border: 1px solid #e6e1d8; border-radius: 16px; padding: 18px 22px; margin: 14px 0; }
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
+ th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #f0eadf; }
+ th { font-weight: 600; color: #707070; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; }
+ .nav { margin-bottom: 24px; }
+ .nav a { color: #707070; text-decoration: none; font-size: 14px; }
+ .nav a:hover { color: #1d1d1f; }
+ .thumb { width: 36px; height: 44px; object-fit: cover; border-radius: 6px; border: 1px solid #e6e1d8; }
+ .sim { font-variant-numeric: tabular-nums; }
+ .sim-low { color: #b87a13; font-weight: 600; }
+ .sim-very-low { color: #b91c1c; font-weight: 700; }
+ .muted { color: #707070; }
+ .pill { display: inline-block; background: #faf7f2; border: 1px solid #e6e1d8; padding: 3px 10px; border-radius: 999px; font-size: 11px; color: #555; }
+ .empty { text-align: center; padding: 60px 20px; color: #707070; font-style: italic; }
+ h2 { font-size: 18px; font-weight: 600; margin: 0 0 14px; letter-spacing: -0.01em; }
+ </style>
+</head>
+<body>
+ <div class="nav"><a href="/admin-config">← admin config</a> · <a href="/">app home</a></div>
+ <h1>Embedding-drift ledger</h1>
+ <p class="sub">Every catalog item whose llava-projected vector deviated >20% cosine from its stored value. Populated by the 24h drift cron + manual drift sweeps.</p>
+
+ <div class="card">
+ <h2>Summary</h2>
+ <p><strong id="total">0</strong> drift events recorded · most-drifted item: <span id="top-item" class="muted">none yet</span></p>
+ </div>
+
+ <div class="card">
+ <h2>Most-drifted items (top 50 by drift count)</h2>
+ <table id="per-item-tbl">
+ <thead><tr><th></th><th>Item</th><th>Drift count</th><th>Min similarity</th><th>Last seen</th></tr></thead>
+ <tbody></tbody>
+ </table>
+ </div>
+
+ <div class="card">
+ <h2>Recent events (last 500)</h2>
+ <table id="recent-tbl">
+ <thead><tr><th></th><th>Item</th><th>Similarity</th><th>Checked at</th></tr></thead>
+ <tbody></tbody>
+ </table>
+ </div>
+
+ <script>
+ const $ = (s) => document.querySelector(s);
+ function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
+ function simClass(s) {
+ if (s == null) return '';
+ if (s < 0.5) return 'sim-very-low';
+ if (s < 0.7) return 'sim-low';
+ return '';
+ }
+ async function load() {
+ const r = await fetch('/api/admin/drifts');
+ if (!r.ok) { document.body.innerHTML = '<p class="empty">admin gate failed</p>'; return; }
+ const j = await r.json();
+ $('#total').textContent = j.total;
+ if (j.total === 0) {
+ $('#per-item-tbl tbody').innerHTML = '<tr><td colspan="5" class="empty">No drift events yet — the 24h cron writes rows when similarity drops below 0.80.</td></tr>';
+ $('#recent-tbl tbody').innerHTML = '';
+ return;
+ }
+ $('#top-item').innerHTML = j.per_item.length ? `item #${j.per_item[0].item_id} (${j.per_item[0].c} drifts)` : 'none';
+ $('#per-item-tbl tbody').innerHTML = j.per_item.map(r => `
+ <tr>
+ <td></td>
+ <td><strong>#${r.item_id}</strong></td>
+ <td>${r.c}</td>
+ <td class="sim ${simClass(r.min_sim)}">${(r.min_sim ?? 0).toFixed(3)}</td>
+ <td class="muted">${escapeHtml(r.last_seen)}</td>
+ </tr>
+ `).join('');
+ $('#recent-tbl tbody').innerHTML = j.recent.map(r => `
+ <tr>
+ <td>${r.image_url ? `<img class="thumb" src="${escapeHtml(r.image_url)}" loading="lazy" alt="">` : ''}</td>
+ <td>${r.product_url ? `<a href="${escapeHtml(r.product_url)}" target="_blank" rel="noopener">${escapeHtml(r.title || ('item #' + r.item_id))}</a>` : escapeHtml(r.title || ('item #' + r.item_id))}<br><span class="pill">${escapeHtml(r.brand || r.source || '')}</span></td>
+ <td class="sim ${simClass(r.similarity)}">${(r.similarity ?? 0).toFixed(3)}</td>
+ <td class="muted">${escapeHtml(r.checked_at)}</td>
+ </tr>
+ `).join('');
+ }
+ load();
+ setInterval(load, 60 * 1000);
+ </script>
+</body>
+</html>
diff --git a/public/css/app.css b/public/css/app.css
index 6f66216..9516130 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -778,6 +778,18 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
width: 0%;
transition: width 0.6s ease-out;
}
+.embed-chip-budget {
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px solid rgba(255,255,255,0.08);
+ font-size: 11px;
+ color: #c7c2b8;
+}
+.embed-chip-budget .muted-sm { color: #707070; }
+.embed-chip-budget.budget-warn { color: #f59e0b; }
+.embed-chip-budget.budget-warn .muted-sm { color: #b87a13; }
+.embed-chip-budget.budget-over { color: #ef4444; font-weight: 600; }
+.embed-chip-budget.budget-over .muted-sm { color: #ef4444; }
/* ---- Production credit badge (tick 19) ---- */
.card { position: relative; } /* anchor for the absolute-positioned badge */
diff --git a/public/index.html b/public/index.html
index dbcd20f..84d2c09 100644
--- a/public/index.html
+++ b/public/index.html
@@ -584,6 +584,10 @@
<span class="embed-chip-mac1" id="embed-chip-mac1">Mac1: …</span>
</div>
<div class="embed-chip-track"><div class="embed-chip-fill" id="embed-chip-fill"></div></div>
+ <!-- Tick 22: paid-API burn vs budget. Color goes amber > 75%, red > 100%. -->
+ <div class="embed-chip-budget" id="embed-chip-budget" title="Paid-API burn this month">
+ <span id="embed-chip-spent">$0.00</span> <span class="muted-sm">of $<span id="embed-chip-budget-cap">5.00</span> · </span><span id="embed-chip-burn-by"></span>
+ </div>
</div>
<script src="/js/app.js"></script>
diff --git a/public/js/app.js b/public/js/app.js
index e6d687e..b3c353d 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -164,6 +164,19 @@ async function refreshEmbedChip() {
if (!j.drainer_enabled) {
mac1.textContent = label + ' · paused';
}
+ // Tick 22: paid-API budget row. Color-code on burn percentage.
+ if (j.budget) {
+ const spent = (j.budget.month_cents / 100);
+ const cap = (j.budget.budget_cents / 100);
+ const pct = cap > 0 ? (spent / cap) : 0;
+ $('#embed-chip-spent').textContent = '$' + spent.toFixed(2);
+ $('#embed-chip-budget-cap').textContent = cap.toFixed(2);
+ const budgetEl = $('#embed-chip-budget');
+ budgetEl.classList.toggle('budget-warn', pct >= 0.75 && pct < 1);
+ budgetEl.classList.toggle('budget-over', pct >= 1);
+ const top = j.budget.by_provider?.[0];
+ $('#embed-chip-burn-by').textContent = top ? `${top.provider} $${(top.c/100).toFixed(2)}` : 'no burn yet';
+ }
} catch {
chip.hidden = true;
}
diff --git a/scripts/clothing-apis/calibrate-spa.js b/scripts/clothing-apis/calibrate-spa.js
new file mode 100644
index 0000000..7335785
--- /dev/null
+++ b/scripts/clothing-apis/calibrate-spa.js
@@ -0,0 +1,120 @@
+#!/usr/bin/env node
+/**
+ * SPA selector calibrator.
+ *
+ * Usage:
+ * node scripts/clothing-apis/calibrate-spa.js <brand-id>
+ *
+ * Steps:
+ * 1. Load the brand entry from brands.json (must exist under headless_spa[]).
+ * 2. Open the products page via Browserbase, wait for network idle, pull
+ * the rendered HTML, cache to /tmp/spa-<id>.html.
+ * 3. Probe a battery of common card selectors. Print the count for each.
+ * 4. For the best-scoring card selector, probe likely title/price/image/link
+ * sub-selectors inside the first card.
+ * 5. Print a ready-to-paste `selectors` block.
+ *
+ * No interactive prompt — pure batch mode so it can run from CI / cron.
+ * Once selectors look good, paste them into brands.json + flip `disabled: false`.
+ *
+ * Idempotent: re-running uses the cached HTML if it's < 1 hour old.
+ */
+const fs = require('fs');
+const path = require('path');
+const { fetchRenderedHtml, available } = require('./headless-spa');
+
+const BRANDS = JSON.parse(fs.readFileSync(path.join(__dirname, 'brands.json'), 'utf8'));
+
+// Common card selectors across the e-commerce SPA ecosystem.
+const CARD_SELECTORS = [
+ '.product-card', '.product', '.ProductCard', '.product-tile', '.product-item',
+ '[data-test-id="product-card"]', '[data-testid="product-card"]', '[data-test="product-card"]',
+ '[data-product-card]', '[data-product]', 'article.product',
+ '.grid__item--product', '.collection-product', '.collection-product-card',
+ 'li.product', '.product-grid-item', '.shop-item', '.shop-card',
+];
+
+// Within a card, candidate selectors per field.
+const TITLE_SELECTORS = ['.product-card__title', '.product__title', 'h2', 'h3', '.title', '[data-test*="title"]', '.name', '.product-name'];
+const PRICE_SELECTORS = ['.product-card__price', '.product__price', '.price', '[data-test*="price"]', '.amount', '.product-price'];
+const IMAGE_SELECTORS = ['img'];
+const LINK_SELECTORS = ['a'];
+
+async function main() {
+ const brandId = process.argv[2];
+ if (!brandId) {
+ console.error('usage: node scripts/clothing-apis/calibrate-spa.js <brand-id>');
+ console.error('available: ' + (BRANDS.headless_spa || []).map(b => b.id).join(', '));
+ process.exit(1);
+ }
+ const brand = (BRANDS.headless_spa || []).find(b => b.id === brandId);
+ if (!brand) {
+ console.error(`brand ${brandId} not in brands.json headless_spa`);
+ process.exit(1);
+ }
+ if (!available()) {
+ console.error('BROWSERBASE_API_KEY / BROWSERBASE_PROJECT_ID not set in env or skill .env');
+ process.exit(1);
+ }
+
+ const cacheFile = `/tmp/spa-${brandId}.html`;
+ const cacheAgeMs = fs.existsSync(cacheFile) ? Date.now() - fs.statSync(cacheFile).mtimeMs : Infinity;
+ let html;
+ if (cacheAgeMs < 60 * 60 * 1000) {
+ console.log(`[cache hit] reusing ${cacheFile} (age ${Math.round(cacheAgeMs/1000)}s)`);
+ html = fs.readFileSync(cacheFile, 'utf8');
+ } else {
+ const url = `https://${brand.domain}${brand.products_path || '/products'}`;
+ console.log(`[fetch] ${url} via Browserbase (this takes ~10-30s)`);
+ html = await fetchRenderedHtml(url, { waitMs: 5000 });
+ fs.writeFileSync(cacheFile, html);
+ console.log(`[cache] wrote ${cacheFile} (${(html.length / 1024).toFixed(0)} KB)`);
+ }
+
+ let cheerio;
+ try { cheerio = require('cheerio'); }
+ catch { console.error('cheerio not installed — run `npm i cheerio`'); process.exit(1); }
+ const $ = cheerio.load(html);
+
+ // Probe 1 — which card selector matches a sensible number of cards?
+ console.log('\n=== card selector candidates ===');
+ const cardScores = CARD_SELECTORS.map(s => ({ s, n: $(s).length }));
+ cardScores.sort((a, b) => b.n - a.n);
+ cardScores.slice(0, 8).forEach(c => console.log(` ${String(c.n).padStart(4)} × ${c.s}`));
+ const best = cardScores.find(c => c.n >= 4 && c.n <= 500) || cardScores[0];
+ if (!best || best.n === 0) {
+ console.log('\nNo card selector matched. The page may be lazy-rendered (try a longer waitMs in headless-spa.js fetchRenderedHtml) or use a fully custom DOM structure (open /tmp/spa-' + brandId + '.html and craft selectors by hand).');
+ process.exit(2);
+ }
+ console.log(`\n[pick] card = "${best.s}" (${best.n} matches)`);
+
+ // Probe 2 — inside the first card, which sub-selectors hit?
+ const firstCard = $(best.s).first();
+ function probe(name, candidates) {
+ const hits = candidates.map(c => ({ c, txt: firstCard.find(c).first().text().trim().slice(0, 60), exists: firstCard.find(c).length > 0 })).filter(x => x.exists);
+ console.log(`\n=== ${name} candidates inside first card ===`);
+ hits.forEach(h => console.log(` ${h.c.padEnd(35)} → ${JSON.stringify(h.txt)}`));
+ return hits[0]?.c || candidates[0];
+ }
+ const titleSel = probe('title', TITLE_SELECTORS);
+ const priceSel = probe('price', PRICE_SELECTORS);
+
+ // image + link probes (different — they want attr not text)
+ const imgEl = firstCard.find('img').first();
+ const imgSrc = imgEl.attr('src') || imgEl.attr('data-src') || imgEl.attr('srcset')?.split(',')[0]?.trim().split(' ')[0];
+ const linkEl = firstCard.find('a').first();
+ const linkHref = linkEl.attr('href');
+ console.log(`\n=== image inside first card ===`);
+ console.log(` ${imgSrc ? 'FOUND: ' + (imgSrc.slice(0, 90)) : 'no <img> with src/data-src/srcset'}`);
+ console.log(`\n=== link inside first card ===`);
+ console.log(` ${linkHref ? 'FOUND: ' + linkHref.slice(0, 90) : 'no <a href>'}`);
+
+ // Final paste-ready block
+ console.log(`\n=== READY-TO-PASTE selectors for brands.json → headless_spa[${brandId}] ===`);
+ console.log(JSON.stringify({
+ selectors: { card: best.s, title: titleSel, price: priceSel, image: 'img', link: 'a' }
+ }, null, 2));
+ console.log(`\nIf the values above look right, flip "disabled": false in brands.json and run:\n curl -sS -X POST -H 'content-type: application/json' -d '{"kind":"headless","id":"${brandId}"}' http://127.0.0.1:9777/api/admin/import-catalog`);
+}
+
+if (require.main === module) main().catch(e => { console.error(e); process.exit(1); });
diff --git a/server.js b/server.js
index f024496..ca83ba7 100644
--- a/server.js
+++ b/server.js
@@ -816,6 +816,33 @@ app.get('/admin-config', (req, res) => {
app.get('/privacy-policy', (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'privacy-policy.html'));
});
+
+// Tick 22: drift-ledger viewer — surfaces embedding_drifts so admin can audit
+// which catalog items have shifted in their llava-projected vector.
+app.get('/admin/drifts', (req, res) => {
+ if (!adminGate(req)) return res.status(403).send('admin only');
+ res.sendFile(path.join(__dirname, 'public', 'admin-drifts.html'));
+});
+
+app.get('/api/admin/drifts', (req, res) => {
+ if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
+ // Recent drift events, joined to items for context.
+ const rows = db.prepare(`
+ SELECT d.id, d.item_id, d.similarity, d.checked_at,
+ i.title, i.brand, i.source, i.image_url, i.product_url
+ FROM embedding_drifts d
+ LEFT JOIN items i ON i.id = d.item_id
+ ORDER BY d.checked_at DESC, d.id DESC
+ LIMIT 500
+ `).all();
+ // Roll-up: drift count per item.
+ const perItem = db.prepare(`
+ SELECT item_id, COUNT(*) c, MIN(similarity) min_sim, MAX(checked_at) last_seen
+ FROM embedding_drifts GROUP BY item_id ORDER BY c DESC LIMIT 50
+ `).all();
+ const total = db.prepare('SELECT COUNT(*) c FROM embedding_drifts').get().c;
+ res.json({ total, recent: rows, per_item: perItem });
+});
app.put('/api/admin/config', express.json({ limit: '8kb' }), (req, res) => {
if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
const updates = req.body || {};
@@ -868,10 +895,26 @@ app.get('/api/admin/catalog-status', (req, res) => {
const r = db.prepare("SELECT value FROM app_config WHERE key='mac1_status'").get();
if (r) mac1 = JSON.parse(r.value);
} catch {}
+ // Tick 22: paid-API budget rollup. Sum provider_costs by month + by provider.
+ // Floating chip displays running monthly burn against budget_cents_per_user.
+ const monthStart = new Date();
+ monthStart.setUTCDate(1);
+ monthStart.setUTCHours(0, 0, 0, 0);
+ const monthCents = db.prepare(
+ `SELECT COALESCE(SUM(cost_cents), 0) c FROM provider_costs WHERE ts >= ?`
+ ).get(monthStart.toISOString()).c || 0;
+ const byProvider = db.prepare(
+ `SELECT provider, COALESCE(SUM(cost_cents), 0) c FROM provider_costs WHERE ts >= ? GROUP BY provider ORDER BY c DESC`
+ ).all(monthStart.toISOString());
res.json({
total, embedded, unembedded: total - embedded, by_source: bySource,
drainer_enabled: configValue('embed_drainer_enabled', false),
mac1,
+ budget: {
+ month_cents: monthCents,
+ budget_cents: configValue('budget_cents_per_user', 500),
+ by_provider: byProvider,
+ },
});
});
← 380a952 yolo tick 21: Browserbase headless-SPA adapter (Reformation/
·
back to Whatsmystyle
·
yolo tick 23: color+material facet pills on /recs (server-si fcfd422 →