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

View File

@@ -0,0 +1 @@
/home/hOLOlu/.gemini/config/projects/59367b61-22d4-4dcf-b177-4fc49d7323c6.json

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Node dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Environment variables (sensitive data)
.env
# System files
.DS_Store
Thumbs.db
# Log files
*.log
logs/
# Scratch & test scripts
test-parser.js
check-unique.js

86
README.md Normal file
View File

@@ -0,0 +1,86 @@
# hBarkodDB - Akıllı Barkod Veritabanı ve Tarayıcı
Bu proje, `adresler.html` dosyasındaki ürün linklerini (barkodist.com) asenkron olarak tarayarak **Ürün Adı, Ürün Resmi, Kategorisi, Ait Olduğu Şirket ve Barkod Numarasını** ayıklayan ve bunları bir veritabanında toplayan akıllı bir otomasyon sistemidir.
Proje, tarama sürecini kontrol edebileceğiniz, veriler içinde arama yapıp düzenleme gerçekleştirebileceğiniz ve EAN-13 Barkod ile QR Kod üretebileceğiniz premium görünümlü, modern bir karanlık mod web arayüzüne sahiptir.
---
## 🚀 Mimaride Yapılan Son Güncelleştirmeler ve İyileştirmeler
### 1. In-Memory Batching Queue Manager (MySQL Kilitlenme Çözümü)
- **Sorun**: 5 asenkron işçi (thread) veritabanından aynı anda "pending" durumundaki URL'leri okumaya ve "processing" olarak güncellemeye çalıştığında, MySQL üzerinde satır kilitlenmeleri (deadlock) ve transaction çakışmaları meydana geliyordu.
- **Çözüm**: Sunucu belleğinde (RAM) çalışan ortak bir kuyruk yöneticisi (`getNextQueueItem`) geliştirildi. İşçiler veritabanını tek tek satır düzeyinde sorgulamak yerine, tek sorguda 20 adet pending URL'yi topluca çekerek veritabanında `processing` durumuna çeker ve RAM'de sırayla tüketir. Bu sayede veritabanı kilitlenmeleri tamamen engellenmiş ve tarama performansı maksimize edilmiştir.
### 2. HTTP Short-Polling Fallback (Anlık Gösterim Yedek Sistemi)
- **Sorun**: Server-Sent Events (SSE) protokolü, tarayıcı ayarları, kurumsal güvenlik duvarları veya yerel ağ proxy yapılandırmaları nedeniyle anlık log akışını ve istatistikleri bloke edebiliyordu. Bu durumda arayüz bağlantı aşamasında takılı kalıyordu.
- **Çözüm**: Ön yüzde [app.js](file:///D:/Calismalar/ai/hBarkodDB/public/app.js) içerisine akıllı bir yedek sistem yazıldı. SSE bağlantısı 2.5 saniye içinde kurulamadığı veya koptuğu anda, sistem otomatik olarak HTTP kısa sorgulama (Short-Polling) moduna geçerek her 1.5 saniyede bir `/api/stats` endpoint'ini sorgular. Bu sayede canlı panel her şartta kesintisiz çalışır.
### 3. Eşsiz URL Ayıklama (Kuyruk Süzgeci)
- `adresler.html` dosyası içinde toplam **91,592 adet** ürün bağlantısı yer almaktadır.
- Ancak bu linkler mükerrer (aynı ürün adresinin birden fazla kez tekrarlanması) kayıtlar barındırır.
- Veritabanı bütünlüğünü korumak ve mükerrer taramayı engellemek adına kuyruk tablosundaki `url` sütununa benzersizlik (`@unique`) kısıtı getirilmiştir.
- Yapılan analiz sonucunda dosyada tam olarak **11,439 adet benzersiz ürün** adresi olduğu tespit edilmiş ve kuyruğa başarıyla bu miktarda aktarım yapılmıştır.
### 4. Prisma `groupBy` Sıralama & Filtreleme Optimizasyonu
- Kategori ve Şirket filtre comboları doldurulurken, Prisma'nın `groupBy` sorgularında `take` filtresinin sıralama kısıtıılmıştır.
- Tüm kategori ve şirket filtreleri **ürün sayısına göre azalan sırada (en popüler olandan en aza)** listelenecek şekilde `orderBy` optimizasyonuyla güncellenmiştir.
---
## 🛠️ Kurulum ve Çalıştırma
### 1. Gereksinimler
- Node.js (v18+)
- MySQL (Varsayılan olarak XAMPP localhost:3306 ve `root` şifresiz kullanıcısı)
### 2. Veritabanı Hazırlama
MySQL sunucunuz çalışırken aşağıdaki komut veritabanını ve gerekli tabloları otomatik oluşturur:
```bash
npx prisma migrate dev --name init
```
### 3. Çalıştırma
Projeyi başlatmak için aşağıdaki komutu çalıştırın:
```bash
npm start
```
Uygulama başlatıldığında `adresler.html` dosyasını otomatik okuyarak **11,439 eşsiz adresi** veritabanı kuyruğuna yükleyecek ve tarama işlemine hazır hale getirecektir.
Web arayüzüne tarayıcınızdan şu adresten ulaşabilirsiniz:
👉 **[http://localhost:3000](http://localhost:3000)**
---
## 📁 Proje Yapısı
- [prisma/schema.prisma](file:///D:/Calismalar/ai/hBarkodDB/prisma/schema.prisma): Veritabanı modelleri (Product ve ScrapeQueue).
- [src/server.js](file:///D:/Calismalar/ai/hBarkodDB/src/server.js): API yolları, Express sunucusu, istek loglayıcı ve SSE canlı veri yayını.
- [src/scraper.js](file:///D:/Calismalar/ai/hBarkodDB/src/scraper.js): HTML okuyucu, asenkron tarayıcı işçileri (in-memory batching) ve sayfa ayıklama mantığı.
- [src/utils.js](file:///D:/Calismalar/ai/hBarkodDB/src/utils.js): Ürün adı miktar & birim temizleme algoritması.
- [public/index.html](file:///D:/Calismalar/ai/hBarkodDB/public/index.html): Dashboard ve Veritabanı sekmelerinin bulunduğu HTML5 arayüzü (app.js scripti entegre edilmiştir).
- [public/style.css](file:///D:/Calismalar/ai/hBarkodDB/public/style.css): Glassmorphism ve mikrogiriş animasyonlu modern vanilla CSS.
- [public/app.js](file:///D:/Calismalar/ai/hBarkodDB/public/app.js): Ön uç dinamik yönetimi, kısa sorgulama yedek mekanizması ve JsBarcode/QRCode.js entegrasyonu.
- [.gitignore](file:///D:/Calismalar/ai/hBarkodDB/.gitignore): Git takibine alınmaması gereken dosyaların yapılandırılması.
---
## ⚙️ Farklı Veritabanlarına Geçiş (Prisma)
Projeyi MySQL'den başka bir veritabanına geçirmek oldukça kolaydır.
### SQLite'a Geçiş:
1. `prisma/schema.prisma` dosyasındaki `provider` değerini değiştirin:
```prisma
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
```
2. `@db.VarChar` ve `@db.Text` gibi veritabanına özel tipleri şemadan kaldırın.
3. Yeniden migration oluşturun: `npx prisma migrate dev --name init_sqlite`
### PostgreSQL veya SQL Server'a Geçiş:
1. `.env` dosyasındaki `DATABASE_URL` değerini yeni sunucunuza göre düzenleyin.
2. `prisma/schema.prisma` dosyasında `provider` değerini `"postgresql"` veya `"sqlserver"` yapın.
3. `npx prisma migrate dev` komutunu çalıştırın.

366393
adresler.html Normal file

File diff suppressed because it is too large Load Diff

1706
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "hbarkoddb",
"version": "1.0.0",
"description": "Barcode scraping, database, and generation system",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"db:init": "npx prisma migrate dev --name init",
"db:generate": "npx prisma generate",
"db:studio": "npx prisma studio"
},
"dependencies": {
"@prisma/client": "^5.11.0",
"axios": "^1.6.8",
"cheerio": "^1.0.0-rc.12",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"p-limit": "^3.1.0"
},
"devDependencies": {
"nodemon": "^3.1.0",
"prisma": "^5.11.0"
},
"keywords": [
"barcode",
"scraper",
"ean13",
"qrcode",
"mysql",
"sqlite"
],
"author": "Antigravity",
"license": "ISC"
}

View File

@@ -0,0 +1,38 @@
-- CreateTable
CREATE TABLE `Product` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`barcode` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`cleanName` VARCHAR(191) NOT NULL,
`amountRaw` VARCHAR(191) NULL,
`amount` DOUBLE NULL,
`unit` VARCHAR(191) NULL,
`image` VARCHAR(1000) NULL,
`category` VARCHAR(191) NULL,
`subcategory` VARCHAR(191) NULL,
`company` VARCHAR(191) NULL,
`url` VARCHAR(500) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Product_barcode_key`(`barcode`),
UNIQUE INDEX `Product_url_key`(`url`),
INDEX `Product_barcode_idx`(`barcode`),
INDEX `Product_name_idx`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ScrapeQueue` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`url` VARCHAR(500) NOT NULL,
`status` VARCHAR(191) NOT NULL DEFAULT 'pending',
`error` TEXT NULL,
`retryCount` INTEGER NOT NULL DEFAULT 0,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `ScrapeQueue_url_key`(`url`),
INDEX `ScrapeQueue_status_idx`(`status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"

40
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,40 @@
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Product {
id Int @id @default(autoincrement())
barcode String @unique
name String
cleanName String
amountRaw String?
amount Float?
unit String?
image String? @db.VarChar(1000)
category String?
subcategory String?
company String?
url String @unique @db.VarChar(500)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([barcode])
@@index([name])
}
model ScrapeQueue {
id Int @id @default(autoincrement())
url String @unique @db.VarChar(500)
status String @default("pending") // pending, processing, completed, failed
error String? @db.Text
retryCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
}

800
public/app.js Normal file
View File

@@ -0,0 +1,800 @@
// Global State
const state = {
currentTab: 'dashboard-tab',
dbPage: 1,
dbLimit: 12,
dbSearch: '',
dbCategory: '',
dbCompany: '',
selectedProduct: null,
sseConnected: false
};
// DOM Elements
const elements = {
tabs: document.querySelectorAll('.nav-btn'),
tabContents: document.querySelectorAll('.tab-content'),
pageTitle: document.getElementById('page-title'),
pageSubtitle: document.getElementById('page-subtitle'),
// Stats elements
statTotal: document.getElementById('stat-total'),
statCompleted: document.getElementById('stat-completed'),
statPending: document.getElementById('stat-pending'),
statFailed: document.getElementById('stat-failed'),
progressPercent: document.getElementById('progress-percent'),
progressFillSuccess: document.getElementById('progress-fill-success'),
progressFillError: document.getElementById('progress-fill-error'),
activeThreadsBadge: document.getElementById('active-threads-badge'),
// Scraper Controls
inputConcurrency: document.getElementById('input-concurrency'),
inputDelay: document.getElementById('input-delay'),
btnToggleScrape: document.getElementById('btn-toggle-scrape'),
btnRetryFailed: document.getElementById('btn-retry-failed'),
consoleLogs: document.getElementById('console-logs'),
btnClearLogs: document.getElementById('btn-clear-logs'),
// Database tab elements
dbSearchInput: document.getElementById('db-search'),
filterCategory: document.getElementById('filter-category'),
filterCompany: document.getElementById('filter-company'),
btnClearFilters: document.getElementById('btn-clear-filters'),
productsGrid: document.getElementById('products-grid-list'),
dbEmptyState: document.getElementById('db-empty-state'),
dbLoadingState: document.getElementById('db-loading-state'),
paginationControls: document.getElementById('pagination-controls'),
btnPrevPage: document.getElementById('btn-prev-page'),
btnNextPage: document.getElementById('btn-next-page'),
pageNumbersContainer: document.getElementById('page-numbers-container'),
// Form tab elements
productForm: document.getElementById('product-form'),
formProductId: document.getElementById('form-product-id'),
formBarcode: document.getElementById('form-barcode'),
formName: document.getElementById('form-name'),
formCategory: document.getElementById('form-category'),
formSubcategory: document.getElementById('form-subcategory'),
formCompany: document.getElementById('form-company'),
formImage: document.getElementById('form-image'),
btnSaveProduct: document.getElementById('btn-save-product'),
btnResetForm: document.getElementById('btn-reset-form'),
// Modal elements
productDetailModal: document.getElementById('product-detail-modal'),
btnCloseModal: document.getElementById('btn-close-modal'),
modalProductImage: document.getElementById('modal-product-image'),
modalProductName: document.getElementById('modal-product-name'),
modalProductCategory: document.getElementById('modal-product-category'),
modalProductCompany: document.getElementById('modal-product-company'),
modalCleanName: document.getElementById('modal-clean-name'),
modalAmountRaw: document.getElementById('modal-amount-raw'),
modalAmount: document.getElementById('modal-amount'),
modalUnit: document.getElementById('modal-unit'),
modalBarcodeSvg: document.getElementById('modal-barcode-svg'),
modalQrcodeCanvas: document.getElementById('modal-qrcode-canvas'),
downloadButtons: document.querySelectorAll('.download-btn'),
// Toast notification
toast: document.getElementById('toast')
};
// API Base URL
const API_URL = ''; // Same host
// Toast Notification Helper
function showToast(message, isError = false) {
const toast = elements.toast;
const icon = toast.querySelector('.toast-icon');
const msg = toast.querySelector('.toast-message');
msg.textContent = message;
if (isError) {
toast.classList.add('error');
icon.className = 'fa-solid fa-circle-xmark toast-icon';
} else {
toast.classList.remove('error');
icon.className = 'fa-solid fa-circle-check toast-icon';
}
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
}, 4000);
}
/* Event Handlers & Core Functions */
// Initialize client
document.addEventListener('DOMContentLoaded', () => {
initTabs();
initScraperControl();
initDatabase();
initForm();
initModal();
connectSSE();
});
// 1. Navigation Tab Switches
function initTabs() {
elements.tabs.forEach(btn => {
btn.addEventListener('click', () => {
const targetTab = btn.getAttribute('data-tab');
state.currentTab = targetTab;
// Toggle active classes
elements.tabs.forEach(t => t.classList.remove('active'));
elements.tabContents.forEach(c => c.classList.remove('active'));
btn.classList.add('active');
document.getElementById(targetTab).classList.add('active');
// Header Content Updates
if (targetTab === 'dashboard-tab') {
elements.pageTitle.textContent = 'Kontrol Paneli';
elements.pageSubtitle.textContent = 'Veri tarama sürecini ve sunucu istatistiklerini canlı takip edin';
} else if (targetTab === 'database-tab') {
elements.pageTitle.textContent = 'Ürün Veritabanı';
elements.pageSubtitle.textContent = 'Taranan barkod verilerini arayın, inceleyin, düzenleyin veya yeni kodlar üretin';
fetchProducts();
} else if (targetTab === 'add-product-tab') {
elements.pageTitle.textContent = 'Ürün Ekle';
elements.pageSubtitle.textContent = 'Veritabanına manuel olarak yeni bir barkod kaydı girin';
resetForm();
}
});
});
}
// 2. SSE Scraper Stream Connection
let sseSource = null;
let pollingInterval = null;
function connectSSE() {
if (sseSource) {
sseSource.close();
}
const indicator = document.querySelector('.status-indicator');
// Set a timeout to switch to polling if SSE doesn't connect within 2.5 seconds
const sseTimeout = setTimeout(() => {
if (!state.sseConnected) {
console.log('SSE connection timeout. Falling back to HTTP polling...');
startPolling();
}
}, 2500);
try {
if (!window.EventSource) {
throw new Error('EventSource is not supported by this browser.');
}
sseSource = new EventSource(`${API_URL}/api/scrape/stream`);
sseSource.onopen = () => {
clearTimeout(sseTimeout);
stopPolling();
state.sseConnected = true;
indicator.className = 'status-indicator connected';
indicator.querySelector('.status-text').textContent = 'Sunucu Bağlantısı Aktif (SSE)';
};
sseSource.onerror = (e) => {
clearTimeout(sseTimeout);
state.sseConnected = false;
indicator.className = 'status-indicator disconnected';
indicator.querySelector('.status-text').textContent = 'SSE Bağlantısı Kesildi - Polling Aktif';
startPolling();
};
sseSource.onmessage = (event) => {
try {
const stats = JSON.parse(event.data);
updateScraperDashboard(stats);
} catch (error) {
console.error('Failed to parse SSE data:', error);
}
};
} catch (err) {
clearTimeout(sseTimeout);
console.warn('SSE initialization failed. Using polling fallback:', err.message);
startPolling();
}
}
function startPolling() {
if (pollingInterval) return;
const indicator = document.querySelector('.status-indicator');
indicator.className = 'status-indicator connected';
indicator.querySelector('.status-text').textContent = 'Sunucu Bağlantısı Aktif (Polling)';
// Run once immediately
fetchStats();
pollingInterval = setInterval(fetchStats, 1500);
}
async function fetchStats() {
const indicator = document.querySelector('.status-indicator');
try {
const response = await fetch(`${API_URL}/api/stats`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const stats = await response.json();
updateScraperDashboard(stats);
indicator.className = 'status-indicator connected';
if (!state.sseConnected) {
indicator.querySelector('.status-text').textContent = 'Sunucu Bağlantısı Aktif (Polling)';
}
} catch (error) {
console.error('Polling error:', error);
indicator.className = 'status-indicator disconnected';
indicator.querySelector('.status-text').textContent = 'Sunucu Bağlantısı Koptu';
}
}
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
}
// Update Dashboard Stats & Progress
let lastLogTimestamp = null;
function updateScraperDashboard(stats) {
// 1. Text values
elements.statTotal.textContent = stats.totalUrls.toLocaleString();
elements.statCompleted.textContent = stats.completedCount.toLocaleString();
elements.statPending.textContent = stats.pendingCount.toLocaleString();
elements.statFailed.textContent = stats.failedCount.toLocaleString();
elements.activeThreadsBadge.textContent = `${stats.activeWorkers} Aktif İşçi`;
// 2. Concurrency inputs update if not focused
if (document.activeElement !== elements.inputConcurrency) {
elements.inputConcurrency.value = stats.concurrency;
}
if (document.activeElement !== elements.inputDelay) {
elements.inputDelay.value = stats.delayMs;
}
// 3. Play/Pause Button State
if (stats.isRunning) {
elements.btnToggleScrape.className = 'btn btn-danger btn-large';
elements.btnToggleScrape.innerHTML = '<i class="fa-solid fa-pause"></i> Tarayıcıyı Duraklat';
} else {
elements.btnToggleScrape.className = 'btn btn-primary btn-large';
elements.btnToggleScrape.innerHTML = '<i class="fa-solid fa-play"></i> Tarayıcıyı Başlat';
}
// 4. Progress Bars
const total = stats.totalUrls || 1;
const completedPercent = (stats.completedCount / total) * 100;
const failedPercent = (stats.failedCount / total) * 100;
const totalPercent = Math.min(100, completedPercent + failedPercent);
elements.progressPercent.textContent = `${totalPercent.toFixed(1)}%`;
elements.progressFillSuccess.style.width = `${completedPercent}%`;
elements.progressFillError.style.width = `${failedPercent}%`;
// 5. Build Scrolling Logs
if (stats.logs && stats.logs.length > 0) {
const consoleBox = elements.consoleLogs;
// Clear default loading line on first actual logs
if (consoleBox.innerHTML.includes('Sistem yükleniyor')) {
consoleBox.innerHTML = '';
}
// Find logs that are newer than what is currently rendered
const existingLinesCount = consoleBox.childElementCount;
const newLogs = stats.logs.slice(existingLinesCount);
newLogs.forEach(log => {
const line = document.createElement('div');
line.className = `console-line ${log.type}`;
const time = document.createElement('span');
time.className = 'console-time';
time.textContent = new Date(log.timestamp).toLocaleTimeString();
const msg = document.createElement('span');
msg.textContent = log.message;
line.appendChild(time);
line.appendChild(msg);
consoleBox.appendChild(line);
// Limit lines in browser DOM to 300 for memory efficiency
while (consoleBox.childElementCount > 300) {
consoleBox.removeChild(consoleBox.firstChild);
}
// Scroll to bottom
consoleBox.scrollTop = consoleBox.scrollHeight;
});
}
}
// 3. Scraper Control Actions
function initScraperControl() {
// Start/Pause Button Toggle
elements.btnToggleScrape.addEventListener('click', async () => {
const isRunningText = elements.btnToggleScrape.textContent.includes('Duraklat');
const endpoint = isRunningText ? '/api/scrape/pause' : '/api/scrape/start';
const payload = isRunningText ? {} : {
concurrency: parseInt(elements.inputConcurrency.value) || 5,
delayMs: parseInt(elements.inputDelay.value) !== undefined ? parseInt(elements.inputDelay.value) : 500
};
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.success) {
showToast(data.message);
} else {
showToast(data.error || 'Tarama durumunu değiştirme işlemi başarısız oldu.', true);
}
} catch (error) {
showToast('Sunucu ile iletişim kurulamadı.', true);
}
});
// Retry Failed
elements.btnRetryFailed.addEventListener('click', async () => {
try {
const response = await fetch('/api/scrape/retry', { method: 'POST' });
const data = await response.json();
if (data.success) {
showToast(data.message);
} else {
showToast(data.error || 'Hatalı adresleri sıfırlama işlemi başarısız.', true);
}
} catch (error) {
showToast('İşlem başarısız.', true);
}
});
// Clear Logs Console View
elements.btnClearLogs.addEventListener('click', () => {
elements.consoleLogs.innerHTML = '<div class="console-line system">Konsol temizlendi. Yeni güncellemeler bekleniyor...</div>';
});
}
// 4. Product Database Tab (Search, Filter, Pagination, Render)
function initDatabase() {
// Debounce search input
let searchTimeout = null;
elements.dbSearchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
state.dbSearch = elements.dbSearchInput.value.trim();
state.dbPage = 1;
fetchProducts();
}, 400);
});
// Select filters change
elements.filterCategory.addEventListener('change', () => {
state.dbCategory = elements.filterCategory.value;
state.dbPage = 1;
fetchProducts();
});
elements.filterCompany.addEventListener('change', () => {
state.dbCompany = elements.filterCompany.value;
state.dbPage = 1;
fetchProducts();
});
// Clear Filters
elements.btnClearFilters.addEventListener('click', () => {
elements.dbSearchInput.value = '';
elements.filterCategory.value = '';
elements.filterCompany.value = '';
state.dbSearch = '';
state.dbCategory = '';
state.dbCompany = '';
state.dbPage = 1;
fetchProducts();
});
// Pagination Click Listeners
elements.btnPrevPage.addEventListener('click', () => {
if (state.dbPage > 1) {
state.dbPage--;
fetchProducts();
}
});
elements.btnNextPage.addEventListener('click', () => {
state.dbPage++;
fetchProducts();
});
}
// Fetch products from database API
async function fetchProducts() {
elements.dbLoadingState.classList.remove('hidden');
elements.productsGrid.classList.add('hidden');
elements.dbEmptyState.classList.add('hidden');
elements.paginationControls.classList.add('hidden');
// Build URL query parameters
const params = new URLSearchParams({
page: state.dbPage,
limit: state.dbLimit,
q: state.dbSearch,
category: state.dbCategory,
company: state.dbCompany
});
try {
const response = await fetch(`/api/products?${params.toString()}`);
const data = await response.json();
elements.dbLoadingState.classList.add('hidden');
if (data.products && data.products.length > 0) {
renderProductsGrid(data.products);
renderPagination(data.pagination);
// Only update filters dropdown lists on page 1 search/initial load
if (state.dbPage === 1 && data.filters) {
populateFilters(data.filters);
}
} else {
elements.dbEmptyState.classList.remove('hidden');
}
} catch (error) {
console.error('Failed to load products:', error);
elements.dbLoadingState.classList.add('hidden');
elements.dbEmptyState.classList.remove('hidden');
showToast('Ürün veritabanı yüklenirken hata oluştu.', true);
}
}
// Populates filter select dropdown arrays
function populateFilters(filters) {
// 1. Categories dropdown
const currentCategory = elements.filterCategory.value;
elements.filterCategory.innerHTML = '<option value="">Tüm Kategoriler</option>';
filters.categories.forEach(cat => {
const option = document.createElement('option');
option.value = cat.name;
option.textContent = `${cat.name} (${cat.count})`;
if (cat.name === currentCategory) option.selected = true;
elements.filterCategory.appendChild(option);
});
// 2. Companies dropdown
const currentCompany = elements.filterCompany.value;
elements.filterCompany.innerHTML = '<option value="">Tüm Şirketler</option>';
filters.companies.forEach(comp => {
const option = document.createElement('option');
option.value = comp.name;
option.textContent = `${comp.name} (${comp.count})`;
if (comp.name === currentCompany) option.selected = true;
elements.filterCompany.appendChild(option);
});
}
// Renders the Grid of Product Cards
function renderProductsGrid(products) {
const grid = elements.productsGrid;
grid.innerHTML = '';
grid.classList.remove('hidden');
products.forEach(product => {
const card = document.createElement('div');
card.className = 'product-card';
card.dataset.id = product.id;
// Fallback placeholder image URL
const imageSrc = product.image || `https://placehold.co/400x400/1e293b/cbd5e1?text=${encodeURIComponent(product.cleanName || 'Ürün Resmi')}`;
card.innerHTML = `
<div class="product-img-wrapper">
<img src="${imageSrc}" alt="${product.cleanName || 'Ürün Görseli'}" onerror="this.src='https://placehold.co/400x400/1e293b/cbd5e1?text=%C3%9Cr%C3%BCn+G%C3%B6rseli'">
</div>
<div class="product-card-body">
<span class="product-barcode"><i class="fa-solid fa-barcode"></i> ${product.barcode}</span>
<h3 class="product-title" title="${product.name}">${product.name}</h3>
<div class="product-tags">
${product.category ? `<span class="badge badge-category"><i class="fa-solid fa-tags"></i> ${product.category}</span>` : ''}
${product.company ? `<span class="badge badge-company"><i class="fa-solid fa-building"></i> ${product.company}</span>` : ''}
${product.amountRaw ? `<span class="badge badge-qty"><i class="fa-solid fa-cubes"></i> ${product.amountRaw} ${product.unit || ''}</span>` : ''}
</div>
</div>
<div class="product-actions">
<button class="btn btn-secondary btn-small view-details-btn"><i class="fa-solid fa-eye"></i> İncele</button>
<button class="btn btn-secondary btn-small edit-product-btn" title="Düzenle"><i class="fa-solid fa-pen"></i></button>
<button class="btn btn-danger btn-small delete-product-btn" title="Sil"><i class="fa-solid fa-trash"></i></button>
</div>
`;
// Card button click bindings
card.querySelector('.view-details-btn').addEventListener('click', () => openDetailModal(product));
card.querySelector('.edit-product-btn').addEventListener('click', () => editProductForm(product));
card.querySelector('.delete-product-btn').addEventListener('click', () => deleteProduct(product));
grid.appendChild(card);
});
}
// Renders Page Numbers
function renderPagination(pageInfo) {
elements.paginationControls.classList.remove('hidden');
// Enable/Disable navigation buttons
elements.btnPrevPage.disabled = pageInfo.page <= 1;
elements.btnNextPage.disabled = pageInfo.page >= pageInfo.totalPages;
const container = elements.pageNumbersContainer;
container.innerHTML = '';
const current = pageInfo.page;
const total = pageInfo.totalPages;
// Helper logic to build numbers with dots (e.g. 1 2 ... 5 6 7 ... 9 10)
let pages = [];
if (total <= 5) {
for (let i = 1; i <= total; i++) pages.push(i);
} else {
if (current <= 3) {
pages = [1, 2, 3, 4, '...', total];
} else if (current >= total - 2) {
pages = [1, '...', total - 3, total - 2, total - 1, total];
} else {
pages = [1, '...', current - 1, current, current + 1, '...', total];
}
}
pages.forEach(p => {
const span = document.createElement('span');
if (p === '...') {
span.textContent = '...';
span.className = 'dots';
} else {
span.textContent = p;
if (p === current) {
span.className = 'active';
}
span.addEventListener('click', () => {
state.dbPage = p;
fetchProducts();
});
}
container.appendChild(span);
});
}
// Delete Product Operation
async function deleteProduct(product) {
if (!confirm(`"${product.name}" adlı ürünü silmek istediğinize emin misiniz?`)) {
return;
}
try {
const response = await fetch(`/api/products/${product.id}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
showToast('Ürün başarıyla silindi.');
fetchProducts();
} else {
showToast(data.error || 'Ürün silinirken bir hata oluştu.', true);
}
} catch (error) {
showToast('İşlem gerçekleştirilemedi.', true);
}
}
// 5. Form Submissions (Add / Edit Product Manual Entry)
function initForm() {
elements.productForm.addEventListener('submit', async (e) => {
e.preventDefault();
const id = elements.formProductId.value;
const payload = {
barcode: elements.formBarcode.value.trim(),
name: elements.formName.value.trim(),
category: elements.formCategory.value.trim(),
subcategory: elements.formSubcategory.value.trim(),
company: elements.formCompany.value.trim(),
image: elements.formImage.value.trim()
};
const isEdit = id !== '';
const endpoint = isEdit ? `/api/products/${id}` : '/api/products';
const method = isEdit ? 'PUT' : 'POST';
try {
elements.btnSaveProduct.disabled = true;
elements.btnSaveProduct.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Kaydediliyor...';
const response = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
elements.btnSaveProduct.disabled = false;
elements.btnSaveProduct.innerHTML = '<i class="fa-solid fa-save"></i> Ürünü Kaydet';
if (response.ok) {
showToast(isEdit ? 'Ürün başarıyla güncellendi!' : 'Ürün başarıyla eklendi!');
resetForm();
// Switch to Database Tab
document.getElementById('btn-tab-database').click();
} else {
showToast(data.error || 'Kayıt başarısız oldu.', true);
}
} catch (error) {
elements.btnSaveProduct.disabled = false;
elements.btnSaveProduct.innerHTML = '<i class="fa-solid fa-save"></i> Ürünü Kaydet';
showToast('Sunucu bağlantı hatası.', true);
}
});
elements.btnResetForm.addEventListener('click', resetForm);
}
// Redirects and fills Form for Edit Action
function editProductForm(product) {
// Fill the inputs
elements.formProductId.value = product.id;
elements.formBarcode.value = product.barcode;
elements.formName.value = product.name;
elements.formCategory.value = product.category || '';
elements.formSubcategory.value = product.subcategory || '';
elements.formCompany.value = product.company || '';
elements.formImage.value = product.image || '';
// Update labels and display
document.getElementById('btn-tab-add').click();
elements.pageTitle.textContent = 'Ürün Düzenle';
elements.pageSubtitle.textContent = `"${product.cleanName}" barkod kaydını güncelleyin`;
elements.btnSaveProduct.innerHTML = '<i class="fa-solid fa-check"></i> Değişiklikleri Kaydet';
}
function resetForm() {
elements.productForm.reset();
elements.formProductId.value = '';
elements.btnSaveProduct.innerHTML = '<i class="fa-solid fa-save"></i> Ürünü Kaydet';
if (state.currentTab === 'add-product-tab') {
elements.pageTitle.textContent = 'Ürün Ekle';
elements.pageSubtitle.textContent = 'Veritabanına manuel olarak yeni bir barkod kaydı girin';
}
}
// 6. Product Detail Modal & Barcode Renders
function initModal() {
elements.btnCloseModal.addEventListener('click', () => {
elements.productDetailModal.classList.remove('active');
state.selectedProduct = null;
});
// Close on overlay overlay click
elements.productDetailModal.addEventListener('click', (e) => {
if (e.target === elements.productDetailModal) {
elements.productDetailModal.classList.remove('active');
state.selectedProduct = null;
}
});
// Handle code downloads (SVG for Barcode, Canvas for QR Code)
elements.downloadButtons.forEach(btn => {
btn.addEventListener('click', () => {
const type = btn.getAttribute('data-type');
if (!state.selectedProduct) return;
const barcodeVal = state.selectedProduct.barcode;
const productName = state.selectedProduct.cleanName || 'barkod';
if (type === 'barcode') {
// EAN-13 SVG Download
const svgSerializer = new XMLSerializer();
const svgString = svgSerializer.serializeToString(elements.modalBarcodeSvg);
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `EAN13_${barcodeVal}_${productName.toLowerCase().replace(/[^a-z0-9]/g, '_')}.svg`;
a.click();
URL.revokeObjectURL(blobUrl);
} else if (type === 'qrcode') {
// QR Code PNG Download
const canvas = elements.modalQrcodeCanvas;
const dataUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = dataUrl;
a.download = `QR_${barcodeVal}_${productName.toLowerCase().replace(/[^a-z0-9]/g, '_')}.png`;
a.click();
}
});
});
}
function openDetailModal(product) {
state.selectedProduct = product;
// Fill text labels
elements.modalProductName.textContent = product.name;
elements.modalCleanName.textContent = product.cleanName || '-';
elements.modalAmountRaw.textContent = product.amountRaw || 'Miktar Belirtilmemiş';
elements.modalAmount.textContent = product.amount !== null ? product.amount : '-';
elements.modalUnit.textContent = product.unit || '-';
// Badges
elements.modalProductCategory.innerHTML = product.category
? `<i class="fa-solid fa-tags"></i> ${product.category}`
: '<i class="fa-solid fa-tags"></i> Kategorisiz';
elements.modalProductCompany.innerHTML = product.company
? `<i class="fa-solid fa-building"></i> ${product.company}`
: '<i class="fa-solid fa-building"></i> Şirket Belirsiz';
// Product Image
const imageSrc = product.image || `https://placehold.co/400x400/1e293b/cbd5e1?text=${encodeURIComponent(product.cleanName)}`;
elements.modalProductImage.src = imageSrc;
// GENERATE BARKOD (EAN-13 or Code128)
const barcodeVal = product.barcode;
try {
// EAN-13 requires exactly 12 or 13 numerical digits.
// If it's something else, we fallback to CODE128 format so it successfully renders.
const isNumericOnly = /^\d+$/.test(barcodeVal);
const format = (isNumericOnly && (barcodeVal.length === 12 || barcodeVal.length === 13)) ? "EAN13" : "CODE128";
JsBarcode("#modal-barcode-svg", barcodeVal, {
format: format,
width: 2,
height: 90,
displayValue: true,
fontSize: 14,
font: 'var(--font-mono)',
background: "#ffffff",
lineColor: "#000000",
margin: 10
});
} catch (err) {
console.error('Barcode generation error:', err);
elements.modalBarcodeSvg.innerHTML = `<text x="10" y="50" fill="red">Barkod Oluşturulamadı (${err.message})</text>`;
}
// GENERATE QR CODE
try {
QRCode.toCanvas(elements.modalQrcodeCanvas, barcodeVal, {
width: 150,
margin: 1,
color: {
dark: "#000000",
light: "#ffffff"
}
}, function (error) {
if (error) console.error(error);
});
} catch (err) {
console.error('QR Code generation error:', err);
}
// Toggle Modal class
elements.productDetailModal.classList.add('active');
}

357
public/index.html Normal file
View File

@@ -0,0 +1,357 @@
<!DOCTYPE html>
<html lang="tr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>hBarkodDB | Akıllı Barkod Veritabanı ve Tarayıcı</title>
<meta name="description" content="Barkodist verilerini işleyip barkod ve ürün veritabanı oluşturan akıllı otomasyon sistemi.">
<link rel="stylesheet" href="style.css">
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
<!-- FontAwesome for Premium Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- CDNs for Barcode and QR Code Generation -->
<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.5/dist/JsBarcode.all.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
<!-- Frontend logic -->
<script src="app.js" defer></script>
</head>
<body>
<div class="app-container">
<!-- Sidebar Navigation -->
<aside class="sidebar">
<div class="logo-area">
<div class="logo-icon">
<i class="fa-solid fa-barcode-read"></i>
</div>
<div class="logo-text">
<span class="brand-name">hBarkod<span class="accent-text">DB</span></span>
<span class="brand-sub">Veri & Barkod Sistemi</span>
</div>
</div>
<nav class="nav-menu">
<button class="nav-btn active" data-tab="dashboard-tab" id="btn-tab-dashboard">
<i class="fa-solid fa-chart-line"></i> <span>Kontrol Paneli</span>
</button>
<button class="nav-btn" data-tab="database-tab" id="btn-tab-database">
<i class="fa-solid fa-database"></i> <span>Ürün Veritabanı</span>
</button>
<button class="nav-btn" data-tab="add-product-tab" id="btn-tab-add">
<i class="fa-solid fa-plus-circle"></i> <span>Ürün Ekle</span>
</button>
</nav>
<div class="sidebar-footer">
<div class="status-indicator connected">
<span class="dot"></span>
<span class="status-text">Sunucu Bağlantısı Aktif</span>
</div>
</div>
</aside>
<!-- Main Content Area -->
<main class="main-content">
<!-- Header -->
<header class="app-header">
<div class="header-title">
<h1 id="page-title">Kontrol Paneli</h1>
<p class="subtitle" id="page-subtitle">Veri tarama sürecini ve sunucu istatistiklerini canlı takip edin</p>
</div>
<div class="header-actions">
<div class="api-speed">
<i class="fa-solid fa-bolt text-yellow"></i>
<span id="active-threads-badge">0 Aktif İşçi</span>
</div>
</div>
</header>
<!-- Tab: Dashboard -->
<section id="dashboard-tab" class="tab-content active">
<!-- Stat Grid -->
<div class="stats-grid">
<div class="stat-card total">
<div class="stat-icon"><i class="fa-solid fa-link"></i></div>
<div class="stat-info">
<h3>Toplam Adres</h3>
<div class="value" id="stat-total">0</div>
</div>
</div>
<div class="stat-card completed">
<div class="stat-icon"><i class="fa-solid fa-check-double"></i></div>
<div class="stat-info">
<h3>Taranan Ürün</h3>
<div class="value" id="stat-completed">0</div>
</div>
</div>
<div class="stat-card pending">
<div class="stat-icon"><i class="fa-solid fa-clock-rotate-left"></i></div>
<div class="stat-info">
<h3>Sırada Bekleyen</h3>
<div class="value" id="stat-pending">0</div>
</div>
</div>
<div class="stat-card failed">
<div class="stat-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
<div class="stat-info">
<h3>Hatalı Deneme</h3>
<div class="value" id="stat-failed">0</div>
</div>
</div>
</div>
<!-- Progress Section -->
<div class="card progress-card">
<div class="card-header">
<h2>Tarama İlerleme Durumu</h2>
<span class="percentage" id="progress-percent">0%</span>
</div>
<div class="progress-bar-wrapper">
<div class="progress-bar">
<div class="progress-fill fill-success" id="progress-fill-success" style="width: 0%"></div>
<div class="progress-fill fill-error" id="progress-fill-error" style="width: 0%"></div>
</div>
</div>
</div>
<!-- Control and Logs Area -->
<div class="dashboard-split">
<!-- Control Settings Card -->
<div class="card control-card">
<h2>Tarayıcı Kontrolü</h2>
<p class="card-description">Eşzamanlı istek sayısını ve gecikmeyi ayarlayarak tarama işlemini yönetin.</p>
<div class="control-group">
<label for="input-concurrency">Eşzamanlı İşçi (Thread) Sayısı</label>
<div class="number-input-wrapper">
<input type="number" id="input-concurrency" min="1" max="50" value="5">
<span class="input-hint">Önerilen: 5-10</span>
</div>
</div>
<div class="control-group">
<label for="input-delay">İstekler Arası Gecikme (ms)</label>
<div class="number-input-wrapper">
<input type="number" id="input-delay" min="0" max="10000" step="100" value="500">
<span class="input-hint">Sayfayı yormamak için ideal: 500ms</span>
</div>
</div>
<div class="control-actions">
<button id="btn-toggle-scrape" class="btn btn-primary btn-large">
<i class="fa-solid fa-play"></i> Tarayıcıyı Başlat
</button>
<button id="btn-retry-failed" class="btn btn-secondary">
<i class="fa-solid fa-rotate-left"></i> Hatalıları Yeniden Dene
</button>
</div>
</div>
<!-- Live Logs Card -->
<div class="card logs-card">
<div class="card-header">
<h2>Canlı Tarama Günlüğü</h2>
<button id="btn-clear-logs" class="btn btn-text-icon" title="Konsolu Temizle">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
<div class="console-box" id="console-logs">
<div class="console-line system">Sistem yükleniyor. Bağlantı bekleniyor...</div>
</div>
</div>
</div>
</section>
<!-- Tab: Product Database -->
<section id="database-tab" class="tab-content">
<!-- Search & Filters -->
<div class="card filter-card">
<div class="filter-row">
<div class="search-box">
<i class="fa-solid fa-magnifying-glass"></i>
<input type="text" id="db-search" placeholder="Ürün adı, barkod, şirket veya kategori ara...">
</div>
<div class="filter-selects">
<select id="filter-category">
<option value="">Tüm Kategoriler</option>
</select>
<select id="filter-company">
<option value="">Tüm Şirketler</option>
</select>
<button id="btn-clear-filters" class="btn btn-secondary" title="Filtreleri Sıfırla">
<i class="fa-solid fa-filter-circle-xmark"></i> Temizle
</button>
</div>
</div>
</div>
<!-- Products Display -->
<div class="products-container">
<div class="products-grid" id="products-grid-list">
<!-- Product cards will be injected here -->
</div>
<!-- Empty State -->
<div class="empty-state hidden" id="db-empty-state">
<div class="empty-icon"><i class="fa-solid fa-box-open"></i></div>
<h2>Ürün Bulunamadı</h2>
<p>Aradığınız kriterlere uygun herhangi bir barkod kaydı mevcut değil.</p>
</div>
<!-- Loading State -->
<div class="loading-state hidden" id="db-loading-state">
<div class="spinner"></div>
<p>Ürünler yükleniyor...</p>
</div>
<!-- Pagination -->
<div class="pagination-wrapper" id="pagination-controls">
<button class="btn btn-page" id="btn-prev-page"><i class="fa-solid fa-chevron-left"></i> Önceki</button>
<div class="page-numbers" id="page-numbers-container">
<span class="active">1</span>
</div>
<button class="btn btn-page" id="btn-next-page">Sonraki <i class="fa-solid fa-chevron-right"></i></button>
</div>
</div>
</section>
<!-- Tab: Add Product -->
<section id="add-product-tab" class="tab-content">
<div class="card form-card">
<h2>Manuel Barkod & Ürün Ekle</h2>
<p class="card-description">Veritabanına manuel olarak yeni bir ürün ve barkod kaydı girmek için aşağıdaki formu kullanın.</p>
<form id="product-form" class="premium-form">
<input type="hidden" id="form-product-id" value="">
<div class="form-row">
<div class="form-group">
<label for="form-barcode">Barkod Numarası (EAN-13) *</label>
<input type="text" id="form-barcode" required placeholder="Örn: 8680908020014">
</div>
<div class="form-group">
<label for="form-name">Ürün Adı (Miktar ve Birimli) *</label>
<input type="text" id="form-name" required placeholder="Örn: Indomie Körili Hazır Noodle (75 g)">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="form-category">Kategori</label>
<input type="text" id="form-category" placeholder="Örn: Gıda Ürünleri">
</div>
<div class="form-group">
<label for="form-subcategory">Alt Kategori</label>
<input type="text" id="form-subcategory" placeholder="Örn: Makarna">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="form-company">Ait Olduğu Şirket</label>
<input type="text" id="form-company" placeholder="Örn: Indofood">
</div>
<div class="form-group">
<label for="form-image">Ürün Görsel URL'si</label>
<input type="text" id="form-image" placeholder="Örn: https://example.com/image.jpg">
</div>
</div>
<div class="form-actions">
<button type="submit" id="btn-save-product" class="btn btn-primary btn-large">
<i class="fa-solid fa-save"></i> Ürünü Kaydet
</button>
<button type="button" id="btn-reset-form" class="btn btn-secondary">
Temizle
</button>
</div>
</form>
</div>
</section>
</main>
</div>
<!-- Product Detail & Barcode Generator Modal -->
<div class="modal-overlay" id="product-detail-modal">
<div class="modal-container">
<button class="modal-close" id="btn-close-modal"><i class="fa-solid fa-xmark"></i></button>
<div class="modal-content-grid">
<!-- Left: Product info and Image -->
<div class="modal-left">
<div class="modal-product-img-box">
<img id="modal-product-image" src="" alt="Ürün Resmi" onerror="this.src='https://placehold.co/400x400/1e293b/cbd5e1?text=%C3%9Cr%C3%BCn+G%C3%B6rseli'">
</div>
<div class="modal-product-meta">
<h2 id="modal-product-name">Ürün Adı Buraya Gelecek</h2>
<div class="tag-row">
<span class="badge badge-category" id="modal-product-category"><i class="fa-solid fa-tags"></i> Kategori</span>
<span class="badge badge-company" id="modal-product-company"><i class="fa-solid fa-building"></i> Şirket</span>
</div>
<div class="parsed-details">
<h3>Birim ve Miktar Analizi</h3>
<table class="details-table">
<tr>
<td>Temiz Ürün Adı</td>
<td id="modal-clean-name">-</td>
</tr>
<tr>
<td>Miktar (Ham)</td>
<td id="modal-amount-raw">-</td>
</tr>
<tr>
<td>Sayısal Miktar</td>
<td id="modal-amount">-</td>
</tr>
<tr>
<td>Birim</td>
<td id="modal-unit">-</td>
</tr>
</table>
</div>
</div>
</div>
<!-- Right: Barcode & QR Code generators -->
<div class="modal-right">
<h2>Barkod & Kod Üretimi</h2>
<p class="modal-desc">Ürün kodunu anında EAN-13 standartlarında veya mobil uyumlu QR kod olarak üretebilir, indirebilirsiniz.</p>
<div class="code-generator-section">
<!-- EAN-13 Barcode -->
<div class="barcode-render-box">
<div class="render-title">EAN-13 Barkodu</div>
<div class="canvas-wrapper">
<svg id="modal-barcode-svg"></svg>
</div>
<button class="btn btn-small btn-secondary download-btn" data-type="barcode">
<i class="fa-solid fa-download"></i> Barkod İndir (SVG)
</button>
</div>
<!-- QR Code -->
<div class="barcode-render-box">
<div class="render-title">QR Kod</div>
<div class="canvas-wrapper">
<canvas id="modal-qrcode-canvas"></canvas>
</div>
<button class="btn btn-small btn-secondary download-btn" data-type="qrcode">
<i class="fa-solid fa-download"></i> QR Kod İndir (PNG)
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Notification Toast -->
<div class="toast" id="toast">
<i class="fa-solid fa-circle-check toast-icon"></i>
<span class="toast-message">İşlem başarıyla gerçekleştirildi.</span>
</div>
</body>
</html>

1230
public/style.css Normal file

File diff suppressed because it is too large Load Diff

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
};

5
yapi.md Normal file
View File

@@ -0,0 +1,5 @@
adresler.html dosyasındaki adreslerden ilgili ürünleri kontrol edip sayfadaki verileri işleyerek Ürün Adı, Resmi, Kategorisi, Ait Olduğu Şirket ve Barkod Numarasını çekerek bir Barkod veritabanı oluşturmak istiyorum.
Ürün adlarında Parantez içerisindeki Miktar ve birimleri ayrı ayrı alanlar olarak kaydedebilecek.
Ürün barkodlarının en az EAN13 ve QR Kod olarak üretebilmeliyim,
Veritabanı belirli değil şu anlık Mysql ama daha sonra SQLServer yada PostgreSQL dönüştürülebilir.