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.
181 lines
6.6 KiB
JavaScript
181 lines
6.6 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* EKAP geçmiş sonuç tarayıcısı — kazanan firma + sözleşme bedeli + teklif sayısı biriktirir.
|
||
*
|
||
* Kullanım:
|
||
* node server/ekap-scan.mjs --gun 7 # son 7 günün sonuçlananları
|
||
* node server/ekap-scan.mjs --bas 2026-01-01 --son 2026-06-30
|
||
* node server/ekap-scan.mjs --gun 1 --limit 200 # cron için (günlük artımlı)
|
||
*
|
||
* Notlar:
|
||
* - EKAP arama indeksi sonuç ilanlarını kapsamaz; bu yüzden sonuçlanmış ihaleler
|
||
* sayfalanır ve HER ihalenin ilanları ayrıca çekilir (N+1 kaçınılmaz).
|
||
* - Eşzamanlılık 6 (repo referansı 8) — EKAP'ı yormamak için.
|
||
* - Zaten kayıtlı IKN'ler atlanır, böylece tekrar çalıştırmak ucuzdur.
|
||
*/
|
||
|
||
import {
|
||
searchTenders, fetchResults, getIhaleDetay, normalizeCompanyName, DURUM_SONUC_ILANI,
|
||
} from './ekap-v2.mjs';
|
||
import { openDb, saveResults, hasIkn, logScan, available } from './history-store.mjs';
|
||
|
||
const args = process.argv.slice(2);
|
||
const arg = (n, d = null) => {
|
||
const i = args.indexOf(`--${n}`);
|
||
return i >= 0 && args[i + 1] ? args[i + 1] : d;
|
||
};
|
||
const has = (n) => args.includes(`--${n}`);
|
||
|
||
const iso = (d) => d.toISOString().slice(0, 10);
|
||
|
||
const gun = Number(arg('gun', 0));
|
||
let bas = arg('bas');
|
||
let son = arg('son');
|
||
if (!bas || !son) {
|
||
const bugun = new Date();
|
||
const geri = new Date(bugun.getTime() - (gun || 7) * 86400000);
|
||
bas = bas || iso(geri);
|
||
son = son || iso(bugun);
|
||
}
|
||
const LIMIT = Number(arg('limit', 500));
|
||
const SAYFA = 50;
|
||
const ESZAMANLI = Number(arg('paralel', 6));
|
||
const ATLA_KALEM = has('kalemsiz'); // --kalemsiz ile OKAS çekimini kapat (daha hızlı)
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
async function havuz(items, worker, n) {
|
||
const sonuc = [];
|
||
let i = 0;
|
||
const calisanlar = Array.from({ length: Math.min(n, items.length) }, async () => {
|
||
while (i < items.length) {
|
||
const idx = i++;
|
||
try { sonuc.push(await worker(items[idx])); }
|
||
catch (e) { sonuc.push({ hata: e.message }); }
|
||
}
|
||
});
|
||
await Promise.all(calisanlar);
|
||
return sonuc;
|
||
}
|
||
|
||
// --- Mevcut kayıtlara OKAS kalemi doldur ---
|
||
// Eski kayıtlarda EKAP'ın anlık `id` token'ı saklanmadığı için IKN ile yeniden
|
||
// arama yapıp taze bir id alınır, sonra detaydan kalemler çekilir.
|
||
async function kalemDoldur(db, limit) {
|
||
const eksik = db.prepare(
|
||
'SELECT ikn FROM sonuclar WHERE okas_kalemler IS NULL GROUP BY ikn LIMIT ?'
|
||
).all(limit);
|
||
console.log(`[ekap-scan] OKAS kalemi eksik ${eksik.length} ihale işlenecek`);
|
||
let ok = 0, bos = 0, hata = 0;
|
||
|
||
const upd = db.prepare('UPDATE sonuclar SET okas_kalemler=? WHERE ikn=?');
|
||
|
||
await havuz(eksik, async (row) => {
|
||
const [yil, sayi] = String(row.ikn).split('/');
|
||
if (!yil || !sayi) { bos++; return; }
|
||
const r = await searchTenders({ iknYili: Number(yil), iknSayi: Number(sayi), take: 1 });
|
||
const t = r.list[0];
|
||
if (!t) { bos++; return; }
|
||
const d = await getIhaleDetay(t.id);
|
||
const okas = d?.ihtiyacKalemiOkasList || d?.IhtiyacKalemiOkasList || [];
|
||
if (!Array.isArray(okas) || !okas.length) { bos++; return; }
|
||
upd.run(JSON.stringify(okas.map((k) => ({ kodu: k.kodu, adi: k.adi || k.koduAdi }))), row.ikn);
|
||
ok++;
|
||
if ((ok + bos) % 25 === 0) console.log(`[ekap-scan] kalem doldurma: ${ok} dolduruldu, ${bos} boş`);
|
||
}, ESZAMANLI).catch(() => { hata++; });
|
||
|
||
console.log(`[ekap-scan] KALEM DOLDURMA BİTTİ — ${ok} ihaleye kalem yazıldı, ${bos} kalemsiz, ${hata} hata.`);
|
||
}
|
||
|
||
async function main() {
|
||
if (!available()) {
|
||
console.error('[ekap-scan] node:sqlite yok — bu Node sürümünde tarama yapılamaz.');
|
||
process.exit(1);
|
||
}
|
||
const db = openDb({ create: true });
|
||
const baslangic = new Date().toISOString();
|
||
|
||
// Kalem doldurma modu: mevcut kayıtlara OKAS ekler, tarama yapmaz
|
||
const doldur = arg('kalem-doldur');
|
||
if (doldur) {
|
||
await kalemDoldur(db, Number(doldur) || 200);
|
||
db.close();
|
||
return;
|
||
}
|
||
|
||
console.log(`[ekap-scan] aralık ${bas} → ${son}, limit ${LIMIT}, paralel ${ESZAMANLI}`);
|
||
|
||
let skip = 0;
|
||
let taranan = 0;
|
||
let eklenen = 0;
|
||
let hata = 0;
|
||
let kalemli = 0;
|
||
let toplamHavuz = null;
|
||
|
||
while (taranan < LIMIT) {
|
||
let sayfa;
|
||
try {
|
||
sayfa = await searchTenders({
|
||
ihaleDurumIdList: [DURUM_SONUC_ILANI],
|
||
ihaleTarihBaslangic: bas,
|
||
ihaleTarihBitis: son,
|
||
orderBy: 'ihaleTarihi',
|
||
siralamaTipi: 'desc',
|
||
skip,
|
||
take: Math.min(SAYFA, LIMIT - taranan),
|
||
});
|
||
} catch (e) {
|
||
console.error('[ekap-scan] arama hatası:', e.message);
|
||
hata++;
|
||
break;
|
||
}
|
||
if (toplamHavuz == null) {
|
||
toplamHavuz = sayfa.totalCount;
|
||
console.log(`[ekap-scan] aralıkta ${toplamHavuz} sonuçlanmış ihale bulundu`);
|
||
}
|
||
if (!sayfa.list.length) break;
|
||
|
||
// Zaten kayıtlı olanları atla (IKN kalıcı anahtar)
|
||
const yeni = sayfa.list.filter((t) => !hasIkn(db, t.ikn));
|
||
const atlanan = sayfa.list.length - yeni.length;
|
||
|
||
const sonuclar = await havuz(yeni, async (t) => {
|
||
const rs = await fetchResults(t.id);
|
||
if (!rs.length) return { bos: true };
|
||
for (const r of rs) r.kazananNorm = normalizeCompanyName(r.kazananFirma);
|
||
// OKAS ihtiyaç kalemleri (kategori bazlı fiyat analizi için)
|
||
if (!ATLA_KALEM) {
|
||
try {
|
||
const d = await getIhaleDetay(t.id);
|
||
const okas = d?.ihtiyacKalemiOkasList || d?.IhtiyacKalemiOkasList || [];
|
||
if (Array.isArray(okas) && okas.length) {
|
||
t.okasJson = JSON.stringify(okas.map((k) => ({ kodu: k.kodu, adi: k.adi || k.koduAdi })));
|
||
}
|
||
} catch { /* kalem alınamazsa sonucu yine de kaydet */ }
|
||
}
|
||
const n = saveResults(db, t, rs);
|
||
return { kayit: n, ikn: t.ikn, firma: rs[0].kazananFirma, kalem: t.okasJson ? 1 : 0 };
|
||
}, ESZAMANLI);
|
||
|
||
for (const s of sonuclar) {
|
||
if (s?.hata) hata++;
|
||
else if (s?.kayit) { eklenen += s.kayit; kalemli += s.kalem || 0; }
|
||
}
|
||
taranan += sayfa.list.length;
|
||
skip += sayfa.list.length;
|
||
console.log(`[ekap-scan] ${taranan}/${Math.min(LIMIT, toplamHavuz)} tarandı · ${eklenen} kayıt · ${kalemli} kalemli · ${atlanan} atlandı · ${hata} hata`);
|
||
|
||
if (skip >= toplamHavuz) break;
|
||
await sleep(300); // EKAP'ı yormamak için nazik ara
|
||
}
|
||
|
||
logScan(db, {
|
||
baslangic, bitis: new Date().toISOString(),
|
||
aralikBas: bas, aralikSon: son, taranan, eklenen, hata,
|
||
not: `havuz=${toplamHavuz}`,
|
||
});
|
||
db.close();
|
||
console.log(`[ekap-scan] BİTTİ — ${taranan} ihale tarandı, ${eklenen} sonuç kaydı (${kalemli} OKAS kalemli), ${hata} hata.`);
|
||
}
|
||
|
||
main().catch((e) => { console.error('[ekap-scan] ölümcül hata:', e); process.exit(1); });
|