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