Files

150 lines
6.0 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.
/**
* İ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';
import { officialDateFields } from './tender-quality.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';
const documentCount = (record, fallback = 0) => {
const declared = Number(record?.dokumanSayisi);
const listed = Array.isArray(record?.dokumanListe) ? record.dokumanListe.length : 0;
const fallbackCount = Number(fallback);
return Math.max(Number.isFinite(declared) ? declared : 0, listed, Number.isFinite(fallbackCount) ? fallbackCount : 0);
};
/** IKN'den taze bir EKAP kaydı bulur (id token anlıktır, saklanamaz). */
export async function findByIkn(ikn) {
const [yil, sayi] = String(ikn || '').replace(/[\s\u00a0]+/g, '').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);
// Arama yanıtındaki doküman sayısı gecikebilir; detay kaydı güncel
// doküman listesini taşıyorsa kullanıcıya onu göster.
let detail = null;
try { detail = await getIhaleDetay(t.id); } catch { /* yönlendirme yine üretilebilir */ }
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: documentCount(detail, t.dokumanSayisi),
dokumanListe: detail?.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 officialDate = officialDateFields(bilgi.ihaleTarihSaat);
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,
dokumanSayisi: documentCount(d, t.dokumanSayisi),
dokumanListe: d?.dokumanListe || t.dokumanListe || [],
...officialDate,
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(() => {});
}
}