1) İhale evrakları (yeni)
- server/documents.mjs: EKAP doküman URL üretimi (imzalı), ilan metni +
OKAS kalem çıkarımı, Playwright ile doküman metni (varsa)
- /api/documents/url · /ilan · /text · /config uçları
- TenderDocuments paneli: 'EKAP Dokümanlarını Aç' + 'İlan Metnini AI ile
Analiz Et' (11K+ karakter gerçek ilan metni doğrudan AI'ya gider)
- NOT: eski EKAP host'u bot isteklerine bağlantı kesiyor; bağlantı
kullanıcının tarayıcısında açılır, sunucu tarafı metin için Chromium gerekir
2) Kalem verisi gerçek oldu
- ekap-scan: her ihale için IhaleDetay'dan OKAS ihtiyaç kalemleri çekilir
- history-store: okas_kalemler kolonu + otomatik şema geçişi
- --kalem-doldur modu: mevcut kayıtlara IKN'den çözerek kalem ekler
- scheduler: tarama sonrası kalem doldurmayı zincirler
- PriceAnalysis: yöntem şeffaflığı notu (birim fiyatlar kamuya açık değil;
kategori bazlı yaklaşık değer olduğu açıkça yazıyor)
3) Doğrudan temin (Playwright)
- Dockerfile: node:22-slim + nginx + Playwright/Chromium
- scheduler: DT worker periyodik (DT_INTERVAL_SAAT, varsayılan 4 saat)
- Bellek koruması: boş bellek DT_MIN_BOS_MB altındaysa tarama atlanır ve
uyarı loglanır; DT_ENABLED=0 ile tamamen kapatılabilir
4) Kalıcı arşiv
- Coolify'da /app/server/data volume bağlandı; arşiv artık deploy'lar
arası korunuyor
Ayrıca: TenderDetailModal'daki sabit '3-5 Firma' tahmini kaldırıldı, yerine
gerçek geçerli teklif sayısı gösteriliyor.
141 lines
5.4 KiB
JavaScript
141 lines
5.4 KiB
JavaScript
/**
|
||
* Sunucu içi zamanlayıcı — geçmiş sonuç ilanlarını periyodik olarak biriktirir.
|
||
* Container'da ayrı cron kurmaya gerek kalmaz; server/index.js başlatır.
|
||
*
|
||
* Env:
|
||
* SCAN_ENABLED=0 → kapat (varsayılan açık)
|
||
* SCAN_INTERVAL_SAAT=6 → tarama sıklığı (saat)
|
||
* SCAN_GUN=3 → her turda geriye kaç günü tara
|
||
* SCAN_LIMIT=300 → tur başına maksimum ihale
|
||
*/
|
||
|
||
import { spawn } from 'node:child_process';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { historyStats } from './history-store.mjs';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
|
||
let calisiyor = false;
|
||
|
||
// Arşiv boş/küçükse geniş geçmiş dolumu, doluysa artımlı günlük tarama.
|
||
function tarataParams() {
|
||
const s = historyStats();
|
||
const mevcut = s.available ? (s.toplam || 0) : 0;
|
||
const esik = Number(process.env.SCAN_BACKFILL_ESIK || 2000);
|
||
if (mevcut < esik) {
|
||
return {
|
||
gun: process.env.SCAN_BACKFILL_GUN || '180',
|
||
limit: process.env.SCAN_BACKFILL_LIMIT || '1500',
|
||
tip: `geçmiş dolumu (arşivde ${mevcut} kayıt)`,
|
||
};
|
||
}
|
||
return {
|
||
gun: process.env.SCAN_GUN || '3',
|
||
limit: process.env.SCAN_LIMIT || '300',
|
||
tip: `artımlı güncelleme (arşivde ${mevcut} kayıt)`,
|
||
};
|
||
}
|
||
|
||
function tara() {
|
||
if (calisiyor) {
|
||
console.log('[scheduler] önceki tarama sürüyor, bu tur atlandı');
|
||
return;
|
||
}
|
||
calisiyor = true;
|
||
const { gun, limit, tip } = tarataParams();
|
||
console.log(`[scheduler] EKAP sonuç taraması — ${tip}: son ${gun} gün, limit ${limit}`);
|
||
|
||
const p = spawn(process.execPath, [path.join(__dirname, 'ekap-scan.mjs'), '--gun', gun, '--limit', limit], {
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
p.stdout.on('data', (d) => process.stdout.write(String(d)));
|
||
p.stderr.on('data', (d) => process.stderr.write(String(d)));
|
||
p.on('close', (code) => {
|
||
calisiyor = false;
|
||
console.log(`[scheduler] tarama bitti (çıkış kodu ${code})`);
|
||
// Taramanın ardından eksik OKAS kalemlerini doldur (fiyat analizi için)
|
||
if (code === 0 && process.env.KALEM_DOLDUR !== '0') kalemDoldur();
|
||
});
|
||
p.on('error', (e) => {
|
||
calisiyor = false;
|
||
console.warn('[scheduler] tarama başlatılamadı:', e.message);
|
||
});
|
||
}
|
||
|
||
// Eksik OKAS kalemlerini arka planda doldurur (kategori bazlı fiyat analizi)
|
||
function kalemDoldur() {
|
||
const n = process.env.KALEM_DOLDUR_LIMIT || '150';
|
||
const p = spawn(process.execPath, [path.join(__dirname, 'ekap-scan.mjs'), '--kalem-doldur', n], {
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
p.stdout.on('data', (d) => process.stdout.write(String(d)));
|
||
p.on('error', (e) => console.warn('[scheduler] kalem doldurma başlatılamadı:', e.message));
|
||
}
|
||
|
||
// --- Doğrudan temin (Playwright/Chromium gerektirir) ---
|
||
let dtCalisiyor = false;
|
||
|
||
// Chromium ~350MB RSS kullanabilir; sunucu darsa DT'yi atla ve uyar.
|
||
function bellekYeterliMi() {
|
||
const bosMB = Math.round(os.freemem() / 1024 / 1024);
|
||
const esik = Number(process.env.DT_MIN_BOS_MB || 600);
|
||
if (bosMB < esik) {
|
||
console.warn(`[scheduler] ⚠ Sunucu belleği dar (${bosMB}MB boş < ${esik}MB) — doğrudan temin taraması bu tur atlandı. Kalıcı sorun olursa DT_ENABLED=0 ile kapatın.`);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function dtTara() {
|
||
if (process.env.DT_ENABLED === '0') return;
|
||
if (dtCalisiyor) {
|
||
console.log('[scheduler] önceki DT taraması sürüyor, bu tur atlandı');
|
||
return;
|
||
}
|
||
if (!bellekYeterliMi()) return;
|
||
dtCalisiyor = true;
|
||
const pages = process.env.DT_PAGES || '5';
|
||
const details = process.env.DT_DETAILS || '30';
|
||
console.log(`[scheduler] doğrudan temin taraması başlıyor (${pages} sayfa, ${details} detay)`);
|
||
|
||
const p = spawn(process.execPath, [path.join(__dirname, 'dt-worker.mjs'), '--pages', pages, '--details', details], {
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
p.stdout.on('data', (d) => process.stdout.write(String(d)));
|
||
p.stderr.on('data', (d) => process.stderr.write(String(d)));
|
||
p.on('close', (code) => {
|
||
dtCalisiyor = false;
|
||
if (code !== 0) console.warn(`[scheduler] ⚠ DT taraması ${code} koduyla bitti — Chromium kurulu mu / bellek yeterli mi kontrol edin.`);
|
||
else console.log('[scheduler] DT taraması bitti');
|
||
});
|
||
p.on('error', (e) => {
|
||
dtCalisiyor = false;
|
||
console.warn('[scheduler] ⚠ DT worker başlatılamadı:', e.message, '— Chromium yoksa DT_ENABLED=0 yapın.');
|
||
});
|
||
}
|
||
|
||
export function startScheduler() {
|
||
if (process.env.SCAN_ENABLED === '0') {
|
||
console.log('[scheduler] devre dışı (SCAN_ENABLED=0)');
|
||
return;
|
||
}
|
||
const saat = Number(process.env.SCAN_INTERVAL_SAAT || 6);
|
||
const ms = Math.max(1, saat) * 3600 * 1000;
|
||
console.log(`[scheduler] geçmiş sonuç taraması her ${saat} saatte bir çalışacak`);
|
||
// Açılıştan 90 sn sonra ilk tur (sunucu ısınsın), sonra periyodik
|
||
setTimeout(tara, 90 * 1000).unref?.();
|
||
setInterval(tara, ms).unref?.();
|
||
|
||
// Doğrudan temin: sonuç taramasıyla çakışmasın diye kaydırmalı
|
||
if (process.env.DT_ENABLED === '0') {
|
||
console.log('[scheduler] doğrudan temin devre dışı (DT_ENABLED=0)');
|
||
} else {
|
||
const dtSaat = Number(process.env.DT_INTERVAL_SAAT || 4);
|
||
console.log(`[scheduler] doğrudan temin taraması her ${dtSaat} saatte bir çalışacak`);
|
||
setTimeout(dtTara, 4 * 60 * 1000).unref?.();
|
||
setInterval(dtTara, Math.max(1, dtSaat) * 3600 * 1000).unref?.();
|
||
}
|
||
}
|