Files
hikmettan 1838de95f5 feat: ihale dokümanları + OKAS kalem verisi + doğrudan temin (Playwright) + kalıcı arşiv
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.
2026-08-28 22:31:40 +03:00

181 lines
6.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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); });