first commit

This commit is contained in:
hOLOlu
2026-05-31 15:23:37 +03:00
commit c7b07602b7
16 changed files with 371587 additions and 0 deletions

446
src/scraper.js Normal file
View File

@@ -0,0 +1,446 @@
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const axios = require('axios');
const cheerio = require('cheerio');
const { PrismaClient } = require('@prisma/client');
const { parseName, sleep } = require('./utils');
const prisma = new PrismaClient();
// Scraper state
const state = {
isRunning: false,
concurrency: 5,
delayMs: 500,
totalUrls: 0,
completedCount: 0,
failedCount: 0,
pendingCount: 0,
logs: [],
activeWorkers: 0
};
// Log helper
function logMsg(message, type = 'info') {
const logEntry = {
timestamp: new Date().toISOString(),
message,
type // info, success, warning, error
};
state.logs.push(logEntry);
if (state.logs.length > 300) {
state.logs.shift(); // Keep last 300 logs
}
console.log(`[${type.toUpperCase()}] ${message}`);
}
/**
* Initializes the database by importing URLs from adresler.html if the queue is empty.
*/
async function initializeQueue() {
try {
const count = await prisma.scrapeQueue.count();
logMsg(`Current database queue has ${count} items. Syncing with entries in adresler.html...`, 'info');
logMsg('Initializing queue from adresler.html... This may take a moment.', 'info');
const filePath = path.join(__dirname, '../adresler.html');
if (!fs.existsSync(filePath)) {
logMsg('adresler.html file not found in the root directory!', 'error');
return;
}
const fileStream = fs.createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
const urlRegex = /href="(https:\/\/barkodist\.com\/[^"]+)"/;
let urls = [];
let lineCount = 0;
let importedCount = 0;
for await (const line of rl) {
lineCount++;
const match = line.match(urlRegex);
if (match) {
const url = match[1];
urls.push({ url, status: 'pending' });
// Insert in batches of 5000 for efficiency
if (urls.length >= 5000) {
await prisma.scrapeQueue.createMany({
data: urls,
skipDuplicates: true
});
importedCount += urls.length;
logMsg(`Imported ${importedCount} URLs...`, 'info');
urls = [];
}
}
}
// Insert remaining URLs
if (urls.length > 0) {
await prisma.scrapeQueue.createMany({
data: urls,
skipDuplicates: true
});
importedCount += urls.length;
}
logMsg(`Queue initialization complete. Total imported: ${importedCount} URLs.`, 'success');
state.totalUrls = importedCount;
await updateCounts();
} catch (error) {
logMsg(`Queue initialization failed: ${error.message}`, 'error');
}
}
/**
* Updates completed, failed, and pending counts in state.
*/
async function updateCounts() {
try {
state.totalUrls = await prisma.scrapeQueue.count();
state.completedCount = await prisma.scrapeQueue.count({ where: { status: 'completed' } });
state.failedCount = await prisma.scrapeQueue.count({ where: { status: 'failed' } });
state.pendingCount = await prisma.scrapeQueue.count({ where: { status: 'pending' } });
} catch (error) {
console.error('Failed to update counts:', error);
}
}
/**
* Scrapes a single product URL.
*/
async function scrapeUrl(queueItem) {
const { id, url } = queueItem;
try {
// Fetch page content
const response = await axios.get(url, {
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'Accept-Language': 'tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7'
}
});
const html = response.data;
const $ = cheerio.load(html);
// 1. Barcode (very critical)
let barcode = null;
// Attempt 1: From JS script JsBarcode call
const jsBarcodeMatch = html.match(/JsBarcode\("#barcodeProduct",\s*"(\d+)"\)/);
if (jsBarcodeMatch && jsBarcodeMatch[1]) {
barcode = jsBarcodeMatch[1].trim();
}
// Attempt 2: From hidden inputs (HataBildirModal form)
if (!barcode) {
const hiddenBarcodeMatch = html.match(/name="HataBarkod"\s+value="(\d+)"/) || html.match(/value="(\d+)"\s+name="HataBarkod"/);
if (hiddenBarcodeMatch && hiddenBarcodeMatch[1]) {
barcode = hiddenBarcodeMatch[1].trim();
}
}
// Attempt 3: From paragraph strong tags containing barcode numbers
if (!barcode) {
const pText = $('.BarcodeGeneratorInfo p').text() || $('p').text();
const textBarcodeMatch = pText.match(/barkod numarası\s*<strong>(\d+)<\/strong>/i) || pText.match(/barkod numarası\s+(\d+)/i);
if (textBarcodeMatch && textBarcodeMatch[1]) {
barcode = textBarcodeMatch[1].trim();
}
}
if (!barcode) {
throw new Error('Barcode number could not be found on the page.');
}
// 2. Product Name
const rawName = $('h1.barkodTitle').text().trim() || $('title').text().replace(' | Barkodist', '').replace(' Barkodu', '').trim();
if (!rawName) {
throw new Error('Product name could not be found.');
}
// Parse clean name, amount, unit
const parsed = parseName(rawName);
// 3. Product Image
let image = null;
const imgEl = $('.BarkodPageContainer img').first();
if (imgEl.length) {
const src = imgEl.attr('src');
if (src) {
// Clean double slashes e.g. barkodist.com//assets -> barkodist.com/assets
image = src.replace(/([^:]\/)\/+/g, "$1");
if (!image.startsWith('http')) {
image = 'https://barkodist.com' + (image.startsWith('/') ? '' : '/') + image;
}
}
}
// 4. Category and Subcategory
let category = null;
let subcategory = null;
// From page description paragraph
// Example: "Gıda Ürünleri kategorisi ve Makarna alt kategorisinde yer alan..."
const infoText = $('.BarcodeGeneratorInfo').text() || $('body').text();
const catMatch = infoText.match(/([a-zA-ZçıöşğüÇİÖŞĞÜ\s]+)\s+kategorisi\s+ve\s+([a-zA-ZçıöşğüÇİÖŞĞÜ\s]+)\s+alt\s+kategorisinde/i);
if (catMatch) {
category = catMatch[1].trim();
subcategory = catMatch[2].trim();
}
// 5. Company (Owner Company)
let company = null;
const companyMatch = html.match(/<b>(.*?)<\/b>\s*şirketine aittir/i) || html.match(/<b>(.*?)<\/b>\s*firmasına aittir/i);
if (companyMatch && companyMatch[1]) {
company = companyMatch[1].trim();
}
// Save product to database
await prisma.product.upsert({
where: { barcode },
update: {
name: rawName,
cleanName: parsed.cleanName,
amountRaw: parsed.amountRaw,
amount: parsed.amount,
unit: parsed.unit,
image,
category,
subcategory,
company,
url,
updatedAt: new Date()
},
create: {
barcode,
name: rawName,
cleanName: parsed.cleanName,
amountRaw: parsed.amountRaw,
amount: parsed.amount,
unit: parsed.unit,
image,
category,
subcategory,
company,
url
}
});
// Update queue status
await prisma.scrapeQueue.update({
where: { id },
data: {
status: 'completed',
error: null
}
});
logMsg(`Successfully scraped: "${parsed.cleanName}" (${barcode})`, 'success');
} catch (error) {
const errorMsg = error.response ? `HTTP ${error.response.status}` : error.message;
logMsg(`Failed to scrape ${url}: ${errorMsg}`, 'error');
await prisma.scrapeQueue.update({
where: { id },
data: {
status: 'failed',
error: errorMsg,
retryCount: { increment: 1 }
}
});
}
}
// Central queue manager in memory to avoid MySQL deadlock/transaction errors
let queueBuffer = [];
let isFetchingQueue = false;
async function getNextQueueItem() {
if (queueBuffer.length > 0) {
return queueBuffer.shift();
}
if (isFetchingQueue) {
await sleep(100);
return getNextQueueItem();
}
isFetchingQueue = true;
try {
const items = await prisma.scrapeQueue.findMany({
where: { status: 'pending' },
orderBy: { id: 'asc' },
take: 20
});
if (items.length === 0) {
isFetchingQueue = false;
return null;
}
const ids = items.map(item => item.id);
await prisma.scrapeQueue.updateMany({
where: { id: { in: ids } },
data: { status: 'processing' }
});
queueBuffer = items;
} catch (error) {
console.error('Error fetching queue batch:', error);
} finally {
isFetchingQueue = false;
}
return queueBuffer.shift() || null;
}
async function resetQueueBuffer() {
if (queueBuffer.length > 0) {
try {
const ids = queueBuffer.map(item => item.id);
queueBuffer = [];
await prisma.scrapeQueue.updateMany({
where: { id: { in: ids } },
data: { status: 'pending' }
});
console.log(`Reset ${ids.length} buffered items back to pending.`);
} catch (error) {
console.error('Error resetting queue buffer:', error);
}
}
}
/**
* Worker loop that grabs a pending item and scrapes it.
*/
async function scraperWorker() {
state.activeWorkers++;
while (state.isRunning) {
let queueItem = null;
try {
queueItem = await getNextQueueItem();
if (!queueItem) {
// No more pending items
break;
}
await scrapeUrl(queueItem);
await updateCounts();
// Be nice: wait after scraping
if (state.delayMs > 0) {
await sleep(state.delayMs);
}
} catch (error) {
console.error('Worker error:', error);
if (queueItem) {
// Reset status to pending so it can be retried
await prisma.scrapeQueue.update({
where: { id: queueItem.id },
data: { status: 'pending' }
}).catch(() => {});
}
await sleep(2000);
}
}
state.activeWorkers--;
if (state.activeWorkers === 0 && state.isRunning) {
state.isRunning = false;
logMsg('Scraping job completed or queue empty.', 'success');
}
}
/**
* Starts scraping with the specified settings.
*/
async function startScraping(concurrency = 5, delayMs = 500) {
if (state.isRunning) {
logMsg('Scraper is already running.', 'warning');
return;
}
state.isRunning = true;
state.concurrency = concurrency;
state.delayMs = delayMs;
logMsg(`Starting scraper with concurrency: ${concurrency}, delay: ${delayMs}ms`, 'info');
await updateCounts();
// Spawn workers
for (let i = 0; i < concurrency; i++) {
scraperWorker();
}
}
/**
* Pauses the scraper.
*/
async function pauseScraping() {
if (!state.isRunning) {
logMsg('Scraper is not running.', 'warning');
return;
}
state.isRunning = false;
logMsg('Pausing scraper... Active workers will finish their current URL.', 'warning');
await resetQueueBuffer();
}
/**
* Resets failed items in the queue back to pending to retry them.
*/
async function retryFailed() {
try {
const result = await prisma.scrapeQueue.updateMany({
where: { status: 'failed' },
data: { status: 'pending' }
});
logMsg(`Reset ${result.count} failed URLs to pending status.`, 'success');
await updateCounts();
return result.count;
} catch (error) {
logMsg(`Failed to reset retry queue: ${error.message}`, 'error');
}
}
/**
* Returns current scraping statistics and latest logs.
*/
function getStats() {
return {
isRunning: state.isRunning,
activeWorkers: state.activeWorkers,
concurrency: state.concurrency,
delayMs: state.delayMs,
totalUrls: state.totalUrls,
completedCount: state.completedCount,
failedCount: state.failedCount,
pendingCount: state.pendingCount,
logs: state.logs
};
}
module.exports = {
initializeQueue,
startScraping,
pauseScraping,
retryFailed,
getStats,
updateCounts
};

351
src/server.js Normal file
View File

@@ -0,0 +1,351 @@
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);
}
});

74
src/utils.js Normal file
View File

@@ -0,0 +1,74 @@
/**
* Sleep helper function
* @param {number} ms
* @returns {Promise<void>}
*/
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Parses product name to extract amount and unit from parentheses at the end of the name.
* Handles patterns like (75 g), (1200 ml), (6x 200 ml), (5 x 75 g), (90 Yaprak), etc.
*
* @param {string} fullName
* @returns {{cleanName: string, amountRaw: string|null, amount: number|null, unit: string|null}}
*/
function parseName(fullName) {
if (!fullName) {
return { cleanName: '', amountRaw: null, amount: null, unit: null };
}
// Look for text inside parentheses at the end of the name
const match = fullName.match(/\(([^)]+)\)\s*$/);
if (!match) {
return { cleanName: fullName.trim(), amountRaw: null, amount: null, unit: null };
}
const parenContent = match[1].trim(); // e.g., "75 g" or "6x 200 ml" or "90 Yaprak"
const cleanName = fullName.replace(/\(([^)]+)\)\s*$/, '').trim();
// Regex to capture numeric part + multiplier expressions (digits, spaces, x, *, ,, ., -)
// followed by the alphabetic unit part (e.g. g, ml, L, adet, Yaprak)
const amountUnitMatch = parenContent.match(/^([\d\s.x*X,-]+)\s*([a-zA-ZçıöşğüÇİÖŞĞÜ]+.*)$/);
if (amountUnitMatch) {
const amountRaw = amountUnitMatch[1].trim();
const unit = amountUnitMatch[2].trim();
let amount = null;
try {
// Clean up multiplier characters (convert x, X to *, replace Turkish decimal comma with dot)
const evalString = amountRaw.toLowerCase().replace(/x/g, '*').replace(/,/g, '.');
// Ensure only numbers, math operations, and spaces exist in the evaluation string to prevent arbitrary code execution
if (/^[0-9\s.*+-]+$/.test(evalString)) {
// Safe evaluation
amount = Function(`"use strict"; return (${evalString})`)();
} else {
amount = parseFloat(amountRaw.replace(/,/g, '.'));
}
} catch (e) {
// Fallback
amount = parseFloat(amountRaw.replace(/,/g, '.'));
}
return {
cleanName,
amountRaw,
amount: (amount && !isNaN(amount)) ? amount : null,
unit
};
}
// Fallback if structure is different
return {
cleanName,
amountRaw: parenContent,
amount: null,
unit: null
};
}
module.exports = {
sleep,
parseName
};