Files
ihaleist/server/documents.mjs
T
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

135 lines
5.1 KiB
JavaScript
Raw 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.
/**
* İ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(() => {});
}
}