require('dotenv').config(); const express = require('express'); const cors = require('cors'); const path = require('path'); const { PrismaClient } = require('@prisma/client'); const scraper = require('./scraper'); const prisma = new PrismaClient(); const app = express(); const PORT = process.env.PORT || 3000; app.use(cors()); app.use(express.json()); // Request logging middleware app.use((req, res, next) => { console.log(`[HTTP] ${req.method} ${req.url}`); next(); }); // Serve static frontend files app.use(express.static(path.join(__dirname, '../public'))); // SSE clients for live scraping updates let sseClients = []; // Helper to broadcast state to SSE clients function broadcastState() { const stats = scraper.getStats(); const data = JSON.stringify(stats); sseClients.forEach(client => { client.res.write(`data: ${data}\n\n`); }); } // Set up periodic stats broadcast to active frontends setInterval(() => { if (sseClients.length > 0) { broadcastState(); } }, 1000); // API: SSE Stream for Scraper Updates app.get('/api/scrape/stream', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); res.write('\n'); const clientId = Date.now(); sseClients.push({ id: clientId, res }); // Send initial state const stats = scraper.getStats(); res.write(`data: ${JSON.stringify(stats)}\n\n`); req.on('close', () => { sseClients = sseClients.filter(client => client.id !== clientId); }); }); // API: Initialize URL database queue from HTML file app.post('/api/scrape/initialize', async (req, res) => { try { // Fire-and-forget initialization in background so API doesn't timeout scraper.initializeQueue().then(() => { broadcastState(); }); res.json({ success: true, message: 'Queue initialization started in the background.' }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // API: Get current scraper status and logs app.get('/api/stats', async (req, res) => { try { await scraper.updateCounts(); const stats = scraper.getStats(); res.json(stats); } catch (error) { res.status(500).json({ error: error.message }); } }); // API: Start the scraper app.post('/api/scrape/start', async (req, res) => { const { concurrency, delayMs } = req.body; try { const threadCount = parseInt(concurrency) || 5; const delay = parseInt(delayMs) !== undefined ? parseInt(delayMs) : 500; scraper.startScraping(threadCount, delay); broadcastState(); res.json({ success: true, message: 'Scraper started successfully.' }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // API: Pause the scraper app.post('/api/scrape/pause', (req, res) => { try { scraper.pauseScraping(); broadcastState(); res.json({ success: true, message: 'Scraper paused.' }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // API: Reset failed items to retry them app.post('/api/scrape/retry', async (req, res) => { try { const resetCount = await scraper.retryFailed(); broadcastState(); res.json({ success: true, message: `Reset ${resetCount} failed items to pending.` }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // API: Get products (Search & Pagination) app.get('/api/products', async (req, res) => { try { const { q, page = 1, limit = 20, category, company } = req.query; const pageNum = parseInt(page); const limitNum = parseInt(limit); const offset = (pageNum - 1) * limitNum; // Build Prisma query filter const where = {}; if (q) { where.OR = [ { name: { contains: q } }, { barcode: { contains: q } }, { company: { contains: q } }, { category: { contains: q } } ]; } if (category) { where.category = category; } if (company) { where.company = company; } const [products, total] = await Promise.all([ prisma.product.findMany({ where, orderBy: { updatedAt: 'desc' }, skip: offset, take: limitNum }), prisma.product.count({ where }) ]); // Get categories and companies for frontend filters // Optimize: run these queries only when page is 1 let categories = []; let companies = []; if (pageNum === 1) { const catResults = await prisma.product.groupBy({ by: ['category'], _count: { category: true }, where: { category: { not: null } }, orderBy: { _count: { category: 'desc' } } }); categories = catResults.map(c => ({ name: c.category, count: c._count.category })); const compResults = await prisma.product.groupBy({ by: ['company'], _count: { company: true }, where: { company: { not: null } }, orderBy: { _count: { company: 'desc' } }, take: 30 // Top 30 companies }); companies = compResults.map(c => ({ name: c.company, count: c._count.company })); } res.json({ products, pagination: { total, page: pageNum, limit: limitNum, totalPages: Math.ceil(total / limitNum) }, filters: { categories, companies } }); } catch (error) { res.status(500).json({ error: error.message }); } }); // API: Get a single product by ID or Barcode app.get('/api/products/:idOrBarcode', async (req, res) => { const { idOrBarcode } = req.params; try { let product = null; if (isNaN(idOrBarcode)) { product = await prisma.product.findFirst({ where: { OR: [{ barcode: idOrBarcode }, { url: idOrBarcode }] } }); } else { product = await prisma.product.findUnique({ where: { id: parseInt(idOrBarcode) } }); if (!product) { product = await prisma.product.findUnique({ where: { barcode: idOrBarcode } }); } } if (!product) { return res.status(404).json({ error: 'Product not found.' }); } res.json(product); } catch (error) { res.status(500).json({ error: error.message }); } }); // API: Add a product manually app.post('/api/products', async (req, res) => { const { barcode, name, cleanName, amountRaw, amount, unit, image, category, subcategory, company, url } = req.body; if (!barcode || !name) { return res.status(400).json({ error: 'Barcode and Name are required fields.' }); } try { const existing = await prisma.product.findUnique({ where: { barcode } }); if (existing) { return res.status(400).json({ error: 'A product with this barcode already exists.' }); } // Set clean name from utils if not provided const parsedName = cleanName ? { cleanName, amountRaw, amount, unit } : require('./utils').parseName(name); const product = await prisma.product.create({ data: { barcode, name, cleanName: parsedName.cleanName, amountRaw: parsedName.amountRaw, amount: parsedName.amount, unit: parsedName.unit, image: image || null, category: category || null, subcategory: subcategory || null, company: company || null, url: url || `manual-${Date.now()}` } }); res.status(201).json(product); } catch (error) { res.status(500).json({ error: error.message }); } }); // API: Update a product app.put('/api/products/:id', async (req, res) => { const { id } = req.params; const { name, cleanName, amountRaw, amount, unit, image, category, subcategory, company, barcode } = req.body; try { const productId = parseInt(id); const existing = await prisma.product.findUnique({ where: { id: productId } }); if (!existing) { return res.status(404).json({ error: 'Product not found.' }); } const updateData = { name, cleanName, amountRaw, amount: amount !== undefined ? (amount ? parseFloat(amount) : null) : undefined, unit, image, category, subcategory, company, barcode }; // Clean undefined values Object.keys(updateData).forEach(key => updateData[key] === undefined && delete updateData[key]); const updated = await prisma.product.update({ where: { id: productId }, data: updateData }); res.json(updated); } catch (error) { res.status(500).json({ error: error.message }); } }); // API: Delete a product app.delete('/api/products/:id', async (req, res) => { const { id } = req.params; try { const productId = parseInt(id); await prisma.product.delete({ where: { id: productId } }); res.json({ success: true, message: 'Product deleted successfully.' }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Start express server app.listen(PORT, async () => { console.log(`Server running at http://localhost:${PORT}`); // Auto initialize queue in background on startup if empty try { await scraper.initializeQueue(); } catch (e) { console.error('Auto initialization of queue failed on startup:', e); } });