This commit is contained in:
@@ -19,6 +19,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"cheerio": "^1.0.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"pdfkit": "^0.17.2"
|
"pdfkit": "^0.17.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/cheerio": "^0.22.35",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.4",
|
"@types/express": "^5.0.4",
|
||||||
"@types/express-fileupload": "^1.5.1",
|
"@types/express-fileupload": "^1.5.1",
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ async function ensureSchema() {
|
|||||||
// add image_data and image_mime if missing
|
// 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_data longblob null` as any);
|
||||||
await pool.query(`alter table items add column if not exists image_mime varchar(100) 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;
|
let schemaEnsured: Promise<void> | null = null;
|
||||||
@@ -126,6 +138,29 @@ export const db = {
|
|||||||
async deleteAll() {
|
async deleteAll() {
|
||||||
await getReady();
|
await getReady();
|
||||||
await pool.query('truncate table items');
|
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>
|
<header>
|
||||||
<h2>Train-ID</h2>
|
<h2>Train-ID</h2>
|
||||||
<p>Upload a photo of a model train to extract details and save.</p>
|
<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>
|
</header>
|
||||||
|
|
||||||
<section class="card">
|
<section id="tab-items" class="card">
|
||||||
<label>Photo</label>
|
<label>Photo</label>
|
||||||
<input id="file" type="file" accept="image/*" />
|
<input id="file" type="file" accept="image/*" />
|
||||||
<div class="row" style="align-items:flex-start; margin-top: 12px;">
|
<div class="row" style="align-items:flex-start; margin-top: 12px;">
|
||||||
@@ -43,7 +47,7 @@
|
|||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section id="items-section" class="card">
|
||||||
<h3>Items</h3>
|
<h3>Items</h3>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<input id="search" type="text" placeholder="Search model or SKU" />
|
<input id="search" type="text" placeholder="Search model or SKU" />
|
||||||
@@ -71,6 +75,28 @@
|
|||||||
</table>
|
</table>
|
||||||
</section>
|
</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">
|
<script type="module">
|
||||||
const $ = (sel) => document.querySelector(sel);
|
const $ = (sel) => document.querySelector(sel);
|
||||||
const fileInput = $('#file');
|
const fileInput = $('#file');
|
||||||
@@ -79,6 +105,27 @@
|
|||||||
const tableBody = document.querySelector('#items tbody');
|
const tableBody = document.querySelector('#items tbody');
|
||||||
const searchInput = document.querySelector('#search');
|
const searchInput = document.querySelector('#search');
|
||||||
const analyzeBtn = document.querySelector('#analyze');
|
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', () => {
|
fileInput.addEventListener('change', () => {
|
||||||
const f = fileInput.files?.[0];
|
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 () => {
|
analyzeBtn.addEventListener('click', async () => {
|
||||||
const f = fileInput.files?.[0];
|
const f = fileInput.files?.[0];
|
||||||
if (!f) { alert('Choose an image first'); return; }
|
if (!f) { alert('Choose an image first'); return; }
|
||||||
@@ -155,6 +263,9 @@
|
|||||||
document.querySelector('#clearSearch').addEventListener('click', () => { searchInput.value=''; refresh(); });
|
document.querySelector('#clearSearch').addEventListener('click', () => { searchInput.value=''; refresh(); });
|
||||||
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') refresh(); });
|
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') refresh(); });
|
||||||
|
|
||||||
|
tabItemsBtn.addEventListener('click', () => showTab('items'));
|
||||||
|
tabPricesBtn.addEventListener('click', () => showTab('prices'));
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from 'path';
|
|||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import { analyzeImageToMetadata } from '../services/openaiVision.js';
|
import { analyzeImageToMetadata } from '../services/openaiVision.js';
|
||||||
import { db } from '../db/client.js';
|
import { db } from '../db/client.js';
|
||||||
|
import { fetchMedianSoldPriceUSDForSku } from '../services/ebay.js';
|
||||||
import { generatePdfForItem } from '../services/pdf.js';
|
import { generatePdfForItem } from '../services/pdf.js';
|
||||||
|
|
||||||
export const router = Router();
|
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