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.
This commit is contained in:
2026-08-28 22:31:40 +03:00
parent e2512be9f7
commit 1838de95f5
9 changed files with 500 additions and 25 deletions
+134
View File
@@ -0,0 +1,134 @@
/**
* İhale dokümanları — EKAP doküman URL'i üretimi + (Playwright varsa) metin çıkarma.
*
* Neden Playwright: doküman URL'i EKAP v2 API'sinden imzalı olarak üretilebiliyor,
* ancak eski EKAP host'u (ekap.kik.gov.tr) otomatik istemcilere bağlantıyı kesiyor
* (BIG-IP anti-bot → ECONNRESET). Kullanıcı tarayıcısında link sorunsuz açılır;
* sunucu tarafında metin çıkarmak için gerçek tarayıcı gerekir.
*
* Chromium yoksa: URL yine döner (kullanıcı kendi tarayıcısında açar),
* yalnızca sunucu tarafı metin çıkarma devre dışı kalır.
*/
import { searchTenders, ekapPost, getIhaleDetay } from './ekap-v2.mjs';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36';
/** IKN'den taze bir EKAP kaydı bulur (id token anlıktır, saklanamaz). */
export async function findByIkn(ikn) {
const [yil, sayi] = String(ikn || '').split('/');
if (!yil || !sayi) throw new Error('Geçersiz İKN');
const r = await searchTenders({ iknYili: Number(yil), iknSayi: Number(sayi), take: 1 });
const t = r.list[0];
if (!t) throw new Error('İhale EKAP’ta bulunamadı');
return t;
}
/** İhale dokümanlarının EKAP görüntüleme/indirme adresini üretir. */
export async function documentUrl(ikn) {
const t = await findByIkn(ikn);
const d = await ekapPost('/b_ihalearama/api/EkapDokumanYonlendirme/GetDokumanUrl', {
islemId: '1',
ihaleId: t.id,
});
if (!d?.success || !d?.url) {
throw new Error(d?.message || 'Bu ihale için doküman bağlantısı üretilemedi');
}
return {
url: d.url,
ikn: t.ikn,
ihaleAdi: t.ihaleAdi,
idareAdi: t.idareAdi,
dokumanSayisi: t.dokumanSayisi ?? 0,
dokumanListe: t.dokumanListe || [],
};
}
/** İhale ilan metni + kalem listesi (dokümana gerek kalmadan analiz için). */
export async function ilanMetni(ikn) {
const t = await findByIkn(ikn);
const d = await getIhaleDetay(t.id);
const bilgi = d?.ihaleBilgi || {};
const okas = d?.ihtiyacKalemiOkasList || [];
const parcalar = [
`İhale adı: ${d?.ihaleAdi || t.ihaleAdi || ''}`,
`İKN: ${d?.ikn || t.ikn || ''}`,
`İdare: ${d?.idareAdi || t.idareAdi || ''}`,
`Usul: ${bilgi.ihaleUsulAciklama || d?.ihaleUsul || ''}`,
`Tür: ${bilgi.ihaleTipiAciklama || ''}`,
`İhale tarihi: ${bilgi.ihaleTarihSaat || ''}`,
`İşin yapılacağı yer: ${bilgi.isinYapilacagiYer || ''}`,
`İhale yeri: ${bilgi.ihaleYeri || ''}`,
bilgi.itirazenSikayetBasvuruBedeli ? `İtirazen şikâyet bedeli: ${bilgi.itirazenSikayetBasvuruBedeli}` : '',
okas.length ? `İhtiyaç kalemleri (OKAS):\n` + okas.map((k) => ` - ${k.kodu} ${k.adi || k.koduAdi}`).join('\n') : '',
Array.isArray(d?.ihaleOzellikList) && d.ihaleOzellikList.length
? 'Özellikler:\n' + d.ihaleOzellikList.map((o) => ` - ${o.ihaleOzellik}`).join('\n') : '',
].filter(Boolean);
// İlan HTML'i varsa düz metne çevir (en zengin kaynak)
const ilanlar = d?.ilanList || [];
for (const il of ilanlar) {
if (il.veriHtml) {
parcalar.push(`\n--- ${il.ilanTipAciklama || 'İLAN'} (${il.ilanTarihi || ''}) ---`);
parcalar.push(htmlToText(il.veriHtml));
}
}
return {
ikn: t.ikn,
ihaleAdi: d?.ihaleAdi || t.ihaleAdi,
idareAdi: d?.idareAdi || t.idareAdi,
ilanSayisi: ilanlar.length,
okasSayisi: okas.length,
metin: parcalar.join('\n'),
};
}
const ENT = { '&nbsp;': ' ', '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'" };
function htmlToText(html) {
return String(html || '')
.replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, ' ')
.replace(/<\s*\/\s*(tr|p|div|h\d)\s*>/gi, '\n')
.replace(/<\s*\/\s*t[dh]\s*>/gi, ' | ')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/&[a-z#0-9]+;/gi, (m) => ENT[m.toLowerCase()] ?? ' ')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/** Chromium var mı? (DT worker ile aynı bağımlılık) */
export async function browserAvailable() {
try {
await import('playwright');
return true;
} catch {
return false;
}
}
/**
* Doküman sayfasını gerçek tarayıcıyla açıp metnini çıkarır.
* Chromium yoksa anlaşılır hata verir.
*/
export async function documentText(ikn, { timeout = 60000 } = {}) {
let chromium;
try {
({ chromium } = await import('playwright'));
} catch {
throw new Error('Sunucuda tarayıcı bileşeni yok — doküman metni çıkarılamıyor. Bağlantıyı kendi tarayıcınızda açabilirsiniz.');
}
const { url, ihaleAdi } = await documentUrl(ikn);
const browser = await chromium.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage'] });
try {
const ctx = await browser.newContext({ userAgent: UA, locale: 'tr-TR' });
const page = await ctx.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded', timeout });
await page.waitForTimeout(1500);
const metin = await page.evaluate(() => document.body?.innerText || '');
return { ikn, ihaleAdi, url, metin: String(metin).replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim() };
} finally {
await browser.close().catch(() => {});
}
}