This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<void> | 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<string, { price: number | null, currency: string, updatedAt: string | null }> = {};
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -29,9 +29,13 @@
|
||||
<header>
|
||||
<h2>Train-ID</h2>
|
||||
<p>Upload a photo of a model train to extract details and save.</p>
|
||||
<div class="actions" style="margin-top:8px;">
|
||||
<button id="tabItemsBtn" class="secondary">Items</button>
|
||||
<button id="tabPricesBtn" class="secondary">Price Report</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
<section id="tab-items" class="card">
|
||||
<label>Photo</label>
|
||||
<input id="file" type="file" accept="image/*" />
|
||||
<div class="row" style="align-items:flex-start; margin-top: 12px;">
|
||||
@@ -43,7 +47,7 @@
|
||||
<div id="status"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<section id="items-section" class="card">
|
||||
<h3>Items</h3>
|
||||
<div class="actions">
|
||||
<input id="search" type="text" placeholder="Search model or SKU" />
|
||||
@@ -71,6 +75,28 @@
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="tab-prices" class="card" style="display:none;">
|
||||
<h3>Price Report</h3>
|
||||
<div class="actions">
|
||||
<button id="updatePrices">Update Prices</button>
|
||||
</div>
|
||||
<div style="display:flex; gap:16px; align-items:flex-start; flex-wrap:wrap;">
|
||||
<canvas id="pricePie" width="360" height="360" style="border:1px solid #eee; border-radius:8px;"></canvas>
|
||||
<div style="flex:1; min-width:280px;">
|
||||
<table id="skuPrices">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>Price (USD)</th>
|
||||
<th class="hide-sm">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script type="module">
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const fileInput = $('#file');
|
||||
@@ -79,6 +105,27 @@
|
||||
const tableBody = document.querySelector('#items tbody');
|
||||
const searchInput = document.querySelector('#search');
|
||||
const analyzeBtn = document.querySelector('#analyze');
|
||||
const tabItemsBtn = $('#tabItemsBtn');
|
||||
const tabPricesBtn = $('#tabPricesBtn');
|
||||
const tabItems = $('#tab-items');
|
||||
const itemsSection = $('#items-section');
|
||||
const tabPrices = $('#tab-prices');
|
||||
const skuTbody = document.querySelector('#skuPrices tbody');
|
||||
const updatePricesBtn = $('#updatePrices');
|
||||
const pricePie = /** @type {HTMLCanvasElement} */ (document.getElementById('pricePie'));
|
||||
|
||||
function showTab(tab) {
|
||||
if (tab === 'items') {
|
||||
tabItems.style.display = '';
|
||||
itemsSection.style.display = '';
|
||||
tabPrices.style.display = 'none';
|
||||
} else {
|
||||
tabItems.style.display = 'none';
|
||||
itemsSection.style.display = 'none';
|
||||
tabPrices.style.display = '';
|
||||
loadPriceReport();
|
||||
}
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
const f = fileInput.files?.[0];
|
||||
@@ -119,6 +166,67 @@
|
||||
});
|
||||
}
|
||||
|
||||
function drawPie(items) {
|
||||
const ctx = pricePie.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, pricePie.width, pricePie.height);
|
||||
const total = items.reduce((sum, it) => sum + (it.totalValue || 0), 0);
|
||||
if (total <= 0) {
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.fillText('No priced items yet', 100, 180);
|
||||
return;
|
||||
}
|
||||
let start = -Math.PI / 2;
|
||||
const cx = pricePie.width / 2, cy = pricePie.height / 2, r = Math.min(cx, cy) - 8;
|
||||
items.forEach((it, i) => {
|
||||
const val = it.totalValue || 0;
|
||||
if (val <= 0) return;
|
||||
const angle = (val / total) * Math.PI * 2;
|
||||
const end = start + angle;
|
||||
const hue = (i * 47) % 360;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.arc(cx, cy, r, start, end);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = `hsl(${hue} 70% 55%)`;
|
||||
ctx.fill();
|
||||
start = end;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPriceReport() {
|
||||
const res = await fetch('/api/price-report');
|
||||
const data = await res.json();
|
||||
// table of skus
|
||||
skuTbody.innerHTML = '';
|
||||
for (const s of data.skus) {
|
||||
const tr = document.createElement('tr');
|
||||
const title = s.description || '';
|
||||
tr.innerHTML = `
|
||||
<td title="${title.replaceAll('"', '\\"')}">${s.sku}</td>
|
||||
<td>${s.price !== null ? `$${s.price.toFixed(2)}` : ''}</td>
|
||||
<td class="hide-sm">${s.updatedAt ? new Date(s.updatedAt).toLocaleString() : ''}</td>
|
||||
`;
|
||||
skuTbody.appendChild(tr);
|
||||
}
|
||||
// pie of item values
|
||||
const pricedItems = data.items.filter((it) => it.totalValue > 0);
|
||||
drawPie(pricedItems);
|
||||
}
|
||||
|
||||
updatePricesBtn.addEventListener('click', async () => {
|
||||
const prev = updatePricesBtn.textContent;
|
||||
updatePricesBtn.textContent = 'Updating...';
|
||||
updatePricesBtn.disabled = true;
|
||||
try {
|
||||
await fetch('/api/prices/update', { method: 'POST' });
|
||||
await loadPriceReport();
|
||||
} finally {
|
||||
updatePricesBtn.disabled = false;
|
||||
updatePricesBtn.textContent = prev;
|
||||
}
|
||||
});
|
||||
|
||||
analyzeBtn.addEventListener('click', async () => {
|
||||
const f = fileInput.files?.[0];
|
||||
if (!f) { alert('Choose an image first'); return; }
|
||||
@@ -155,6 +263,9 @@
|
||||
document.querySelector('#clearSearch').addEventListener('click', () => { searchInput.value=''; refresh(); });
|
||||
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') refresh(); });
|
||||
|
||||
tabItemsBtn.addEventListener('click', () => showTab('items'));
|
||||
tabPricesBtn.addEventListener('click', () => showTab('prices'));
|
||||
|
||||
refresh();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -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<string, number | null> = {};
|
||||
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<string, { sku: string, price: number | null, currency: string, updatedAt: string | null, description: string } > = {};
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
38
src/services/ebay.ts
Normal file
38
src/services/ebay.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
export async function fetchMedianSoldPriceUSDForSku(sku: string): Promise<number | null> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user