diff --git a/package.json b/package.json index 6cd11e0..e4d319c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "type": "module", "dependencies": { "cors": "^2.8.5", + "cheerio": "^1.0.0", "dotenv": "^17.2.3", "exceljs": "^4.4.0", "express": "^5.1.0", @@ -29,6 +30,7 @@ "pdfkit": "^0.17.2" }, "devDependencies": { + "@types/cheerio": "^0.22.35", "@types/cors": "^2.8.19", "@types/express": "^5.0.4", "@types/express-fileupload": "^1.5.1", diff --git a/src/db/client.ts b/src/db/client.ts index b2e689a..cdc3033 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -36,6 +36,18 @@ async function ensureSchema() { // add image_data and image_mime if missing await pool.query(`alter table items add column if not exists image_data longblob null` as any); await pool.query(`alter table items add column if not exists image_mime varchar(100) null` as any); + + // price cache per SKU + const priceSql = ` + create table if not exists sku_prices ( + sku varchar(64) not null primary key, + price decimal(10,2) null, + currency varchar(3) not null default 'USD', + source varchar(32) not null default 'ebay', + updated_at timestamp null + ) engine=InnoDB; + `; + await pool.query(priceSql); } let schemaEnsured: Promise | null = null; @@ -126,6 +138,29 @@ export const db = { async deleteAll() { await getReady(); await pool.query('truncate table items'); + }, + async listDistinctSkus() { + await getReady(); + const [rows] = await pool.query(`select distinct sku from items where sku is not null and sku <> ''`); + return (rows as any[]).map(r => r.sku as string); + }, + async upsertSkuPrice(sku: string, price: number | null, currency: string = 'USD', source: string = 'ebay') { + await getReady(); + await pool.query( + `insert into sku_prices (sku, price, currency, source, updated_at) + values (?,?,?,?, current_timestamp) + on duplicate key update price = values(price), currency = values(currency), source = values(source), updated_at = current_timestamp`, + [sku, price, currency, source] + ); + }, + async getSkuPriceMap() { + await getReady(); + const [rows] = await pool.query(`select sku, price, currency, updated_at as updatedAt from sku_prices`); + const map: Record = {}; + for (const r of rows as any[]) { + map[r.sku] = { price: r.price !== null ? Number(r.price) : null, currency: r.currency, updatedAt: r.updatedAt }; + } + return map; } }; diff --git a/src/public/index.html b/src/public/index.html index b1cc2a9..a4165e0 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -29,9 +29,13 @@

Train-ID

Upload a photo of a model train to extract details and save.

+
+ + +
-
+
@@ -43,7 +47,7 @@
-
+

Items

@@ -71,6 +75,28 @@
+ + diff --git a/src/routes/api.ts b/src/routes/api.ts index f128830..28f8e98 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -3,6 +3,7 @@ import path from 'path'; import fs from 'fs/promises'; import { analyzeImageToMetadata } from '../services/openaiVision.js'; import { db } from '../db/client.js'; +import { fetchMedianSoldPriceUSDForSku } from '../services/ebay.js'; import { generatePdfForItem } from '../services/pdf.js'; export const router = Router(); @@ -172,4 +173,71 @@ router.delete('/items', async (_req, res) => { } }); +// Update cached prices for all distinct SKUs (on-demand only) +router.post('/prices/update', async (_req, res) => { + try { + const skus = await db.listDistinctSkus(); + const out: Record = {}; + for (const sku of skus) { + try { + const price = await fetchMedianSoldPriceUSDForSku(sku); + await db.upsertSkuPrice(sku, price, 'USD', 'ebay'); + out[sku] = price; + // small delay to be gentle + await new Promise(r => setTimeout(r, 400)); + } catch (e) { + console.error('Failed to update price for', sku, e); + out[sku] = null; + } + } + res.json({ updated: out }); + } catch (err: any) { + console.error(err); + res.status(500).json({ error: err.message || 'Price update failed' }); + } +}); + +// Price report data: items with value slices and sku price list +router.get('/price-report', async (_req, res) => { + try { + const [items, priceMap] = await Promise.all([ + db.listItems(), + db.getSkuPriceMap() + ]); + const itemValues = items.map(it => { + const p = it.sku ? priceMap[it.sku]?.price ?? null : null; + const unitPrice = p !== null ? Number(p) : null; + const totalValue = unitPrice !== null ? Number((unitPrice * it.quantity).toFixed(2)) : 0; + return { + id: it.id, + sku: it.sku || null, + description: it.description, + quantity: it.quantity, + unitPrice, + totalValue + }; + }); + // Build sku list with one description (first encountered) + const skuListMap: Record = {}; + for (const it of items) { + if (!it.sku) continue; + if (!skuListMap[it.sku]) { + const p = priceMap[it.sku]; + skuListMap[it.sku] = { + sku: it.sku, + price: p ? (p.price !== null ? Number(p.price) : null) : null, + currency: p ? p.currency : 'USD', + updatedAt: p ? p.updatedAt : null, + description: it.description + }; + } + } + const skuList = Object.values(skuListMap).sort((a, b) => (a.sku || '').localeCompare(b.sku || '')); + res.json({ items: itemValues, skus: skuList }); + } catch (err: any) { + console.error(err); + res.status(500).json({ error: err.message || 'Failed to build price report' }); + } +}); + diff --git a/src/services/ebay.ts b/src/services/ebay.ts new file mode 100644 index 0000000..b149546 --- /dev/null +++ b/src/services/ebay.ts @@ -0,0 +1,38 @@ +import * as cheerio from 'cheerio'; + +export async function fetchMedianSoldPriceUSDForSku(sku: string): Promise { + const query = encodeURIComponent(`"${sku}"`); + const url = `https://www.ebay.com/sch/i.html?_nkw=${query}&LH_Sold=1&LH_Complete=1&rt=nc`; + const res = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' + } as any + } as any); + if (!res.ok) return null; + const html = await res.text(); + const $ = cheerio.load(html); + const prices: number[] = []; + $('li.s-item').each((_i, el) => { + const priceText = $(el).find('.s-item__price').first().text().trim(); + if (!priceText) return; + // Accept only USD (has $) + if (!priceText.includes('$')) return; + // Remove ranges like "$10.00 to $20.00" + const single = priceText.split(' to ')[0]; + const num = single.replace(/[^0-9.]/g, ''); + if (!num) return; + const value = Number(num); + if (!Number.isFinite(value) || value <= 0) return; + prices.push(value); + }); + if (prices.length === 0) return null; + prices.sort((a, b) => a - b); + const mid = Math.floor(prices.length / 2); + if (prices.length % 2 === 0) { + return Number(((prices[mid - 1] + prices[mid]) / 2).toFixed(2)); + } else { + return Number(prices[mid].toFixed(2)); + } +} + +