← back to AbramsEgo
lib/spend-reviews/adapters/amazon.js
137 lines
'use strict';
/**
* Amazon-orders ingestion adapter — there is NO personal Amazon orders API, so we
* drive Steve's real logged-in Chrome via the `openclaw` CLI (real-Chrome) to
* READ his order history. READ-ONLY: navigate + evaluate only, never orders,
* never posts. The ONE place Steve must act is a one-time Amazon login in the
* openclaw Chrome window — surfaced as meta.needsAction.
*
* interface: module.exports = { ingest }; ingest(opts) -> {ok, items, meta}
* opts.year optional (e.g. 2026) — defaults to the current year page
* opts.maxOrders optional cap (default 50)
*/
const { execFile } = require('child_process');
const cfg = require('../config');
const ORDERS_URL = 'https://www.amazon.com/your-orders/orders';
function openclaw(args, timeoutMs = 45000) {
return new Promise((resolve) => {
execFile(cfg.OPENCLAW_BIN, args, { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
resolve({ ok: !err, code: err ? (err.code || 1) : 0, stdout: stdout || '', stderr: stderr || (err ? err.message : '') });
});
});
}
// openclaw prints log lines around its JSON; extract the last JSON value in stdout.
function extractJson(stdout) {
if (!stdout) return null;
const trimmed = stdout.trim();
try { return JSON.parse(trimmed); } catch (e) {}
// find the last {...} or [...] block
const m = trimmed.match(/[\[{][\s\S]*[\]}]\s*$/);
if (m) { try { return JSON.parse(m[0]); } catch (e) {} }
return null;
}
// The page-side scraper (runs inside the real Chrome via `openclaw browser evaluate`).
// Resilient-but-best-effort: Amazon markup varies, so it tries a few card shapes
// and pulls ASIN from any /dp/<ASIN> or /gp/product/<ASIN> link. Adjust selectors
// here if Amazon changes its order-history DOM.
const SCRAPE_FN = `() => {
const out = [];
const login = !!document.querySelector('form[name="signIn"], #ap_email, input[name="email"]') || /\\/ap\\/signin/.test(location.href);
if (login) return { login: true, orders: [] };
const cards = document.querySelectorAll('.order-card, .js-order-card, .order, [class*="order-card"]');
cards.forEach(card => {
const txt = card.innerText || '';
const idM = txt.match(/ORDER\\s*#?\\s*([0-9-]{10,})/i) || (card.querySelector('[dir="ltr"]') && (card.querySelector('[dir="ltr"]').innerText||'').match(/([0-9]{3}-[0-9]{7}-[0-9]{7})/));
const dateM = txt.match(/(?:ORDER PLACED|Ordered on)\\s*([A-Za-z]+ \\d{1,2}, \\d{4})/i);
const totalM = txt.match(/\\$\\s?([0-9][0-9,]*\\.[0-9]{2})/);
const links = card.querySelectorAll('a[href*="/dp/"], a[href*="/gp/product/"]');
links.forEach(a => {
const href = a.href || '';
const asinM = href.match(/\\/(?:dp|gp\\/product)\\/([A-Z0-9]{10})/);
const title = (a.innerText || '').trim();
if (!asinM || !title) return;
const sellerM = txt.match(/Sold by\\s*:?\\s*([^\\n]+)/i);
out.push({
merchant: (sellerM && sellerM[1].trim()) || 'Amazon',
product: title.slice(0, 200),
asin: asinM[1],
seller: sellerM ? sellerM[1].trim() : null,
order_id: idM ? (idM[1] || idM[0]) : null,
date: dateM ? dateM[1] : null,
amount: totalM ? parseFloat(totalM[1].replace(/,/g,'')) : 0,
url: 'https://www.amazon.com/dp/' + asinM[1],
});
});
});
return { login: false, orders: out };
}`;
function normalizeDate(d) {
if (!d) return null;
const dt = new Date(d); return isNaN(dt) ? null : dt.toISOString().slice(0, 10);
}
async function ingest(opts) {
const maxOrders = (opts && Number(opts.maxOrders)) || 50;
// 1. openclaw available?
const status = await openclaw(['browser', 'status'], 15000);
if (!status.ok) {
return { ok: false, items: [], meta: { source: 'amazon', needsAction: 'openclaw-unavailable',
note: 'openclaw CLI not reachable — run `openclaw browser status` (expect enabled:true) then re-run ingest.',
detail: status.stderr } };
}
const st = extractJson(status.stdout);
if (st && st.enabled === false) {
return { ok: false, items: [], meta: { source: 'amazon', needsAction: 'openclaw-disabled',
note: 'openclaw is installed but disabled — enable it, then re-run ingest.' } };
}
// 2. open order history
const url = (opts && opts.year) ? `https://www.amazon.com/your-orders/orders?timeFilter=year-${opts.year}` : ORDERS_URL;
const open = await openclaw(['browser', 'open', url], 45000);
if (!open.ok) {
return { ok: false, items: [], meta: { source: 'amazon', needsAction: 'openclaw-open-failed',
note: 'openclaw could not open the order-history page.', detail: open.stderr } };
}
// 3. scrape via evaluate — the orders page is an SPA that renders cards AFTER a
// redirect + async load, so poll a few times (settle) before giving up on an
// empty read. Stop early once we see the login wall OR real orders.
let data = null; let lastRaw = '';
for (let attempt = 0; attempt < 6; attempt++) {
await new Promise((r) => setTimeout(r, attempt === 0 ? 2500 : 2000));
const evalRes = await openclaw(['browser', 'evaluate', '--fn', SCRAPE_FN], 45000);
lastRaw = evalRes.stdout || '';
data = extractJson(lastRaw);
if (data && (data.login || (data.orders && data.orders.length))) break;
}
if (!data) {
// degrade: could not read structured data (markup changed / eval blocked)
return { ok: true, items: [], meta: { source: 'amazon',
note: 'opened order history but could not read structured orders (Amazon DOM may have changed) — adjust SCRAPE_FN selectors.',
raw: lastRaw.slice(0, 400) } };
}
if (data.login) {
return { ok: false, items: [], meta: { source: 'amazon', needsAction: 'amazon-login',
loginCmd: `openclaw browser open "${ORDERS_URL}"`,
note: 'Not logged in to Amazon. Steve: log in ONCE in the openclaw Chrome window, then re-run ingest. (This is the only manual step.)' } };
}
const orders = (data.orders || []).slice(0, maxOrders).map((o) => ({
merchant: o.merchant || 'Amazon',
product: o.product || null,
order_id: o.order_id || null,
asin: o.asin || null,
seller: o.seller || null,
date: normalizeDate(o.date),
amount: Number(o.amount) || 0,
currency: 'USD',
url: o.url || (o.asin ? 'https://www.amazon.com/dp/' + o.asin : null),
raw: o,
}));
return { ok: true, items: orders, meta: { source: 'amazon', note: `scraped ${orders.length} order line(s)`, scanned: orders.length } };
}
module.exports = { ingest, extractJson, SCRAPE_FN };