perf: canlı ihale havuzunun tamamı yükleniyor

Sorun: /api/tenders yalnızca 1.060 kayıt döndürüyordu — üç sabit sınır yüzünden
(ilan.gov.tr 8 sayfa=160, DT 300, arşiv 600). Havuz ise 5.000+.

Çözüm:
- ilan.gov.tr sayfaları artık PARALEL çekiliyor (6 eşzamanlı), sıralı değil
- İlk yanıt için 6 sayfa yeter; kalan sayfalar ARKA PLANDA doldurulur, kullanıcı
  beklemez (ölçüm: ilk yanıt 0.05s, havuz arka planda 120 → 3.059 ilana çıktı)
- Sayfa sınırı 8 → 300 (6.000 ilan), DT 300 → 5.000, arşiv 600 → 5.000
- Hepsi env ile ayarlanabilir: ILAN_MAX_PAGES, ILAN_ILK_PARTI, ILAN_ESZAMANLI,
  DT_LIMIT, HISTORY_LIMIT
- totalCount artık arşivi de içeriyor; 'yüklü' sayısı 'havuz'u geçmiyor

Not: yanıt nginx tarafından gzip'leniyor (8.5x), tüm havuz ~600 KB — boyut
darboğaz değildi, sayfalama darboğazdı.
This commit is contained in:
2026-08-29 02:30:49 +03:00
parent f45afbfa3d
commit 03d5a04c91
+82 -17
View File
@@ -36,7 +36,13 @@ const PORT = process.env.PORT || 3001;
const ILAN_BASE = 'https://www.ilan.gov.tr';
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 dk
const LIST_SIZE = 20; // ilan.gov.tr sayfa başına 20 kayıtla sınırlıyor
const MAX_PAGES = 8; // 8 sayfa × 20 = 160 ilan
// ilan.gov.tr sayfa başına 20 kayıt veriyor; havuzun tamamı için çok sayfa gerekir.
// Sayfalar paralel çekilir, ilk yanıt beklemesin diye kalanı arka planda dolar.
const MAX_PAGES = Number(process.env.ILAN_MAX_PAGES || 300); // 300 × 20 = 6000 ilan
const ILK_PARTI = Number(process.env.ILAN_ILK_PARTI || 6); // ilk yanıt için yeterli sayfa
const ES_ZAMANLI = Number(process.env.ILAN_ESZAMANLI || 6); // paralel istek sayısı
const DT_LIMIT = Number(process.env.DT_LIMIT || 5000);
const HISTORY_LIMIT = Number(process.env.HISTORY_LIMIT || 5000);
const ILAN_HEADERS = {
accept: 'application/json',
@@ -257,28 +263,85 @@ function htmlToText(htmlStr) {
const cache = { tenders: null, fetchedAt: 0, totalCount: 0, todayCount: 0, detail: new Map() };
let arkaPlanDoluyor = false;
// Bir sayfa grubunu paralel çeker
async function ilanSayfalari(baslangic, adet) {
const istekler = [];
for (let i = 0; i < adet; i++) {
const page = baslangic + i;
istekler.push(
ilanSearch({ ats: [3] }, page * LIST_SIZE, LIST_SIZE).catch((e) => {
console.warn(`[ihaleist-api] sayfa ${page} alınamadı: ${e.message}`);
return { ads: [] };
})
);
}
return Promise.all(istekler);
}
function ilanlariBirlestir(mevcut, yeniAds) {
const seen = new Set(mevcut.map((t) => t.id));
const eklenen = [];
for (const ad of yeniAds) {
const t = mapAd(ad);
if (seen.has(t.id)) continue;
seen.add(t.id);
eklenen.push(t);
}
return eklenen;
}
// Kalan sayfaları arka planda çeker; kullanıcı beklemez, havuz zamanla dolar.
async function arkaPlandaTamamla(numFound) {
if (arkaPlanDoluyor) return;
arkaPlanDoluyor = true;
try {
const gerekliSayfa = Math.min(MAX_PAGES, Math.ceil(numFound / LIST_SIZE));
let page = ILK_PARTI;
while (page < gerekliSayfa) {
const grup = Math.min(ES_ZAMANLI, gerekliSayfa - page);
const sonuclar = await ilanSayfalari(page, grup);
const ads = sonuclar.flatMap((r) => r.ads || []);
if (!ads.length) break;
const eklenen = ilanlariBirlestir(cache.tenders, ads);
if (eklenen.length) cache.tenders = [...cache.tenders, ...eklenen];
page += grup;
if (page % 60 === 0) {
console.log(`[ihaleist-api] arka plan: ${cache.tenders.length}/${numFound} ilan yüklendi`);
}
await new Promise((r) => setTimeout(r, 120)); // kaynağı yormamak için
}
console.log(`[ihaleist-api] havuz dolduruldu: ${cache.tenders.length}/${numFound} ilan`);
} catch (e) {
console.warn('[ihaleist-api] arka plan doldurma hatası:', e.message);
} finally {
arkaPlanDoluyor = false;
}
}
async function getTenders(force = false) {
const fresh = cache.tenders && Date.now() - cache.fetchedAt < CACHE_TTL_MS;
if (fresh && !force) return cache;
const allAds = [];
let numFound = 0;
for (let page = 0; page < MAX_PAGES; page++) {
const result = await ilanSearch({ ats: [3] }, page * LIST_SIZE, LIST_SIZE);
const ads = result.ads || [];
numFound = result.numFound || numFound;
if (ads.length === 0) break;
allAds.push(...ads);
if (allAds.length >= numFound) break;
}
// İlk parti: paralel, hızlı — kullanıcı bunu bekler
const ilk = await ilanSayfalari(0, ILK_PARTI);
const numFound = ilk.find((r) => r.numFound)?.numFound || 0;
const ads = ilk.flatMap((r) => r.ads || []);
const seen = new Set();
cache.tenders = allAds
cache.tenders = ads
.filter((ad) => (seen.has(ad.id) ? false : seen.add(ad.id)))
.map(mapAd);
cache.totalCount = numFound || allAds.length;
cache.totalCount = numFound || cache.tenders.length;
cache.todayCount = cache.tenders.filter((t) => t.isYeni).length;
cache.fetchedAt = Date.now();
console.log(`[ihaleist-api] ${cache.tenders.length} ihale yüklendi (toplam havuz: ${cache.totalCount})`);
console.log(`[ihaleist-api] ${cache.tenders.length} ihale yüklendi (havuz: ${cache.totalCount}) — kalanı arka planda`);
// Kalanı arka planda doldur (yanıtı bloklamaz)
if (cache.totalCount > cache.tenders.length) {
arkaPlandaTamamla(cache.totalCount);
}
return cache;
}
@@ -517,14 +580,14 @@ const server = http.createServer(async (req, res) => {
// Doğrudan temin (EKAP worker) verisini varsa birleştir
let dt = { tenders: [], totalCount: 0, available: false };
try {
dt = readDtTenders(300);
dt = readDtTenders(DT_LIMIT);
} catch (e) {
console.warn('[ihaleist-api] DT okuma hatası:', e.message);
}
// Geçmiş sonuç ilanları (kazanan firma + bedel + teklif sayısı)
let hist = { tenders: [], totalCount: 0, available: false };
try {
hist = readHistory(600);
hist = readHistory(HISTORY_LIMIT);
} catch (e) {
console.warn('[ihaleist-api] geçmiş okuma hatası:', e.message);
}
@@ -535,7 +598,9 @@ const server = http.createServer(async (req, res) => {
success: true,
source: sources.join(' + '),
fetchedAt: new Date(c.fetchedAt).toLocaleString('tr-TR'),
totalCount: c.totalCount + dt.totalCount,
// Havuz = canlı ilan + doğrudan temin + sonuç arşivi (yüklü sayısıyla tutarlı olsun)
totalCount: c.totalCount + dt.totalCount + hist.totalCount,
ilanCount: c.totalCount,
todayCount: c.todayCount,
dtCount: dt.totalCount,
historyCount: hist.totalCount,