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:
@@ -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 = { ' ': ' ', '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" };
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user