first commit
This commit is contained in:
1
.antigravitycli/59367b61-22d4-4dcf-b177-4fc49d7323c6.json
Symbolic link
1
.antigravitycli/59367b61-22d4-4dcf-b177-4fc49d7323c6.json
Symbolic link
@@ -0,0 +1 @@
|
||||
/home/hOLOlu/.gemini/config/projects/59367b61-22d4-4dcf-b177-4fc49d7323c6.json
|
||||
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal 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
86
README.md
Normal 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ı aşı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
366393
adresler.html
Normal file
File diff suppressed because it is too large
Load Diff
1706
package-lock.json
generated
Normal file
1706
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal 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"
|
||||
}
|
||||
38
prisma/migrations/20260531102503_init/migration.sql
Normal file
38
prisma/migrations/20260531102503_init/migration.sql
Normal 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;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
40
prisma/schema.prisma
Normal 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
800
public/app.js
Normal 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
357
public/index.html
Normal 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
1230
public/style.css
Normal file
File diff suppressed because it is too large
Load Diff
446
src/scraper.js
Normal file
446
src/scraper.js
Normal 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
351
src/server.js
Normal 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
74
src/utils.js
Normal 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
5
yapi.md
Normal 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.
|
||||
|
||||
Reference in New Issue
Block a user