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
+23 -13
View File
@@ -1,32 +1,42 @@
# Stage 1: Vite SPA build # Stage 1: Vite SPA build
FROM node:22-alpine AS build FROM node:22-slim AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
# Stage 2: nginx (frontend) + node (canlı veri backend) tek container'da # Stage 2: nginx (frontend) + node (canlı veri backend) + Playwright/Chromium
FROM node:22-alpine # NOT: Chromium yalnızca doğrudan temin (EKAP anti-bot) ve ihale dokümanı
RUN apk add --no-cache nginx # indirme için gerekli. Sunucu darlığında DT_ENABLED=0 ile kapatılabilir;
# uygulama ve diğer tüm veri kaynakları Chromium olmadan da çalışır.
FROM node:22-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Playwright + Chromium (yalnızca chromium; sistem bağımlılıklarıyla)
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN npm init -y >/dev/null \
&& npm i --omit=dev playwright@1.62.1 \
&& npx playwright install --with-deps chromium \
&& npm cache clean --force
# Frontend # Frontend
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY --from=build /app/nginx.conf /etc/nginx/http.d/default.conf COPY --from=build /app/nginx.conf /etc/nginx/sites-available/default
# Backend (yalnızca Node builtin'leri kullanır — npm bağımlılığı yok) # Backend (Node builtin'leri + playwright)
WORKDIR /app
COPY --from=build /app/server ./server COPY --from=build /app/server ./server
# Geçmiş sonuç veritabanı (SQLite). Deploy'lar arası kalıcılık için Coolify'da # Kalıcı veri (Coolify'da /app/server/data volume olarak bağlı)
# /app/server/data yoluna bir volume bağlanmalı; bağlanmazsa tarayıcı veriyi
# her yeniden dağıtımdan sonra yeniden biriktirir.
RUN mkdir -p /app/server/data RUN mkdir -p /app/server/data
VOLUME ["/app/server/data"] VOLUME ["/app/server/data"]
# Başlangıç: Node backend'i (sabit 3001) arka planda + nginx (80) ön planda. # Başlangıç: Node backend (3001) arka planda + nginx (80) ön planda.
# Coolify container'a PORT=80 verir; backend'i 3001'e sabitleyip nginx ile
# çakışmasını önlüyoruz (nginx /api → 127.0.0.1:3001 proxy'ler).
RUN printf '#!/bin/sh\nset -e\nPORT=3001 node /app/server/index.js &\nexec nginx -g "daemon off;"\n' > /start.sh \ RUN printf '#!/bin/sh\nset -e\nPORT=3001 node /app/server/index.js &\nexec nginx -g "daemon off;"\n' > /start.sh \
&& chmod +x /start.sh && chmod +x /start.sh
+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(() => {});
}
}
+55 -5
View File
@@ -15,7 +15,7 @@
*/ */
import { import {
searchTenders, fetchResults, normalizeCompanyName, DURUM_SONUC_ILANI, searchTenders, fetchResults, getIhaleDetay, normalizeCompanyName, DURUM_SONUC_ILANI,
} from './ekap-v2.mjs'; } from './ekap-v2.mjs';
import { openDb, saveResults, hasIkn, logScan, available } from './history-store.mjs'; import { openDb, saveResults, hasIkn, logScan, available } from './history-store.mjs';
@@ -40,6 +40,7 @@ if (!bas || !son) {
const LIMIT = Number(arg('limit', 500)); const LIMIT = Number(arg('limit', 500));
const SAYFA = 50; const SAYFA = 50;
const ESZAMANLI = Number(arg('paralel', 6)); const ESZAMANLI = Number(arg('paralel', 6));
const ATLA_KALEM = has('kalemsiz'); // --kalemsiz ile OKAS çekimini kapat (daha hızlı)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function havuz(items, worker, n) { async function havuz(items, worker, n) {
@@ -56,6 +57,35 @@ async function havuz(items, worker, n) {
return sonuc; return sonuc;
} }
// --- Mevcut kayıtlara OKAS kalemi doldur ---
// Eski kayıtlarda EKAP'ın anlık `id` token'ı saklanmadığı için IKN ile yeniden
// arama yapıp taze bir id alınır, sonra detaydan kalemler çekilir.
async function kalemDoldur(db, limit) {
const eksik = db.prepare(
'SELECT ikn FROM sonuclar WHERE okas_kalemler IS NULL GROUP BY ikn LIMIT ?'
).all(limit);
console.log(`[ekap-scan] OKAS kalemi eksik ${eksik.length} ihale işlenecek`);
let ok = 0, bos = 0, hata = 0;
const upd = db.prepare('UPDATE sonuclar SET okas_kalemler=? WHERE ikn=?');
await havuz(eksik, async (row) => {
const [yil, sayi] = String(row.ikn).split('/');
if (!yil || !sayi) { bos++; return; }
const r = await searchTenders({ iknYili: Number(yil), iknSayi: Number(sayi), take: 1 });
const t = r.list[0];
if (!t) { bos++; return; }
const d = await getIhaleDetay(t.id);
const okas = d?.ihtiyacKalemiOkasList || d?.IhtiyacKalemiOkasList || [];
if (!Array.isArray(okas) || !okas.length) { bos++; return; }
upd.run(JSON.stringify(okas.map((k) => ({ kodu: k.kodu, adi: k.adi || k.koduAdi }))), row.ikn);
ok++;
if ((ok + bos) % 25 === 0) console.log(`[ekap-scan] kalem doldurma: ${ok} dolduruldu, ${bos} boş`);
}, ESZAMANLI).catch(() => { hata++; });
console.log(`[ekap-scan] KALEM DOLDURMA BİTTİ — ${ok} ihaleye kalem yazıldı, ${bos} kalemsiz, ${hata} hata.`);
}
async function main() { async function main() {
if (!available()) { if (!available()) {
console.error('[ekap-scan] node:sqlite yok — bu Node sürümünde tarama yapılamaz.'); console.error('[ekap-scan] node:sqlite yok — bu Node sürümünde tarama yapılamaz.');
@@ -63,12 +93,22 @@ async function main() {
} }
const db = openDb({ create: true }); const db = openDb({ create: true });
const baslangic = new Date().toISOString(); const baslangic = new Date().toISOString();
// Kalem doldurma modu: mevcut kayıtlara OKAS ekler, tarama yapmaz
const doldur = arg('kalem-doldur');
if (doldur) {
await kalemDoldur(db, Number(doldur) || 200);
db.close();
return;
}
console.log(`[ekap-scan] aralık ${bas} → ${son}, limit ${LIMIT}, paralel ${ESZAMANLI}`); console.log(`[ekap-scan] aralık ${bas} → ${son}, limit ${LIMIT}, paralel ${ESZAMANLI}`);
let skip = 0; let skip = 0;
let taranan = 0; let taranan = 0;
let eklenen = 0; let eklenen = 0;
let hata = 0; let hata = 0;
let kalemli = 0;
let toplamHavuz = null; let toplamHavuz = null;
while (taranan < LIMIT) { while (taranan < LIMIT) {
@@ -102,17 +142,27 @@ async function main() {
const rs = await fetchResults(t.id); const rs = await fetchResults(t.id);
if (!rs.length) return { bos: true }; if (!rs.length) return { bos: true };
for (const r of rs) r.kazananNorm = normalizeCompanyName(r.kazananFirma); for (const r of rs) r.kazananNorm = normalizeCompanyName(r.kazananFirma);
// OKAS ihtiyaç kalemleri (kategori bazlı fiyat analizi için)
if (!ATLA_KALEM) {
try {
const d = await getIhaleDetay(t.id);
const okas = d?.ihtiyacKalemiOkasList || d?.IhtiyacKalemiOkasList || [];
if (Array.isArray(okas) && okas.length) {
t.okasJson = JSON.stringify(okas.map((k) => ({ kodu: k.kodu, adi: k.adi || k.koduAdi })));
}
} catch { /* kalem alınamazsa sonucu yine de kaydet */ }
}
const n = saveResults(db, t, rs); const n = saveResults(db, t, rs);
return { kayit: n, ikn: t.ikn, firma: rs[0].kazananFirma }; return { kayit: n, ikn: t.ikn, firma: rs[0].kazananFirma, kalem: t.okasJson ? 1 : 0 };
}, ESZAMANLI); }, ESZAMANLI);
for (const s of sonuclar) { for (const s of sonuclar) {
if (s?.hata) hata++; if (s?.hata) hata++;
else if (s?.kayit) eklenen += s.kayit; else if (s?.kayit) { eklenen += s.kayit; kalemli += s.kalem || 0; }
} }
taranan += sayfa.list.length; taranan += sayfa.list.length;
skip += sayfa.list.length; skip += sayfa.list.length;
console.log(`[ekap-scan] ${taranan}/${Math.min(LIMIT, toplamHavuz)} tarandı · ${eklenen} kayıt · ${atlanan} atlandı · ${hata} hata`); console.log(`[ekap-scan] ${taranan}/${Math.min(LIMIT, toplamHavuz)} tarandı · ${eklenen} kayıt · ${kalemli} kalemli · ${atlanan} atlandı · ${hata} hata`);
if (skip >= toplamHavuz) break; if (skip >= toplamHavuz) break;
await sleep(300); // EKAP'ı yormamak için nazik ara await sleep(300); // EKAP'ı yormamak için nazik ara
@@ -124,7 +174,7 @@ async function main() {
not: `havuz=${toplamHavuz}`, not: `havuz=${toplamHavuz}`,
}); });
db.close(); db.close();
console.log(`[ekap-scan] BİTTİ — ${taranan} ihale tarandı, ${eklenen} sonuç kaydı yazıldı, ${hata} hata.`); console.log(`[ekap-scan] BİTTİ — ${taranan} ihale tarandı, ${eklenen} sonuç kaydı (${kalemli} OKAS kalemli), ${hata} hata.`);
} }
main().catch((e) => { console.error('[ekap-scan] ölümcül hata:', e); process.exit(1); }); main().catch((e) => { console.error('[ekap-scan] ölümcül hata:', e); process.exit(1); });
+38 -3
View File
@@ -39,6 +39,7 @@ CREATE TABLE IF NOT EXISTS sonuclar (
yaklasik_maliyet REAL, yaklasik_maliyet REAL,
teklif_sayisi INTEGER, teklif_sayisi INTEGER,
gecerli_teklif_sayisi INTEGER, gecerli_teklif_sayisi INTEGER,
okas_kalemler TEXT,
cekilme_zamani TEXT, cekilme_zamani TEXT,
PRIMARY KEY (ikn, kisim) PRIMARY KEY (ikn, kisim)
); );
@@ -67,6 +68,16 @@ export function openDb({ create = false } = {}) {
} }
const db = new DatabaseSync(DB_PATH); const db = new DatabaseSync(DB_PATH);
db.exec(SCHEMA); db.exec(SCHEMA);
// Geçiş: eski şemada okas_kalemler yoksa ekle
try {
const kolonlar = db.prepare('PRAGMA table_info(sonuclar)').all().map((c) => c.name);
if (!kolonlar.includes('okas_kalemler')) {
db.exec('ALTER TABLE sonuclar ADD COLUMN okas_kalemler TEXT');
console.log('[history-store] okas_kalemler kolonu eklendi');
}
} catch (e) {
console.warn('[history-store] şema geçişi atlandı:', e.message);
}
return db; return db;
} }
@@ -74,12 +85,13 @@ export function saveResults(db, tender, results) {
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO sonuclar (ikn,kisim,ihale_adi,idare_adi,il,ihale_tipi,ihale_tarihi,ilan_tarihi, INSERT INTO sonuclar (ikn,kisim,ihale_adi,idare_adi,il,ihale_tipi,ihale_tarihi,ilan_tarihi,
kazanan_firma,kazanan_norm,kazanan_adres,sozlesme_bedeli,para_birimi,sozlesme_tarihi, kazanan_firma,kazanan_norm,kazanan_adres,sozlesme_bedeli,para_birimi,sozlesme_tarihi,
yaklasik_maliyet,teklif_sayisi,gecerli_teklif_sayisi,cekilme_zamani) yaklasik_maliyet,teklif_sayisi,gecerli_teklif_sayisi,okas_kalemler,cekilme_zamani)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(ikn,kisim) DO UPDATE SET ON CONFLICT(ikn,kisim) DO UPDATE SET
kazanan_firma=excluded.kazanan_firma, kazanan_norm=excluded.kazanan_norm, kazanan_firma=excluded.kazanan_firma, kazanan_norm=excluded.kazanan_norm,
sozlesme_bedeli=excluded.sozlesme_bedeli, yaklasik_maliyet=excluded.yaklasik_maliyet, sozlesme_bedeli=excluded.sozlesme_bedeli, yaklasik_maliyet=excluded.yaklasik_maliyet,
teklif_sayisi=excluded.teklif_sayisi, gecerli_teklif_sayisi=excluded.gecerli_teklif_sayisi, teklif_sayisi=excluded.teklif_sayisi, gecerli_teklif_sayisi=excluded.gecerli_teklif_sayisi,
okas_kalemler=COALESCE(excluded.okas_kalemler, sonuclar.okas_kalemler),
cekilme_zamani=excluded.cekilme_zamani cekilme_zamani=excluded.cekilme_zamani
`); `);
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -92,6 +104,7 @@ export function saveResults(db, tender, results) {
r.kazananFirma || null, r.kazananNorm || null, r.kazananAdres || null, r.kazananFirma || null, r.kazananNorm || null, r.kazananAdres || null,
r.sozlesmeBedeli ?? null, r.paraBirimi || null, r.sozlesmeTarihi || null, r.sozlesmeBedeli ?? null, r.paraBirimi || null, r.sozlesmeTarihi || null,
r.yaklasikMaliyet ?? null, r.teklifSayisi ?? null, r.gecerliTeklifSayisi ?? null, r.yaklasikMaliyet ?? null, r.teklifSayisi ?? null, r.gecerliTeklifSayisi ?? null,
tender.okasJson ?? null,
now now
); );
n++; n++;
@@ -106,6 +119,28 @@ export function hasIkn(db, ikn) {
// --- Okuma tarafı (API için) -------------------------------------------- // --- Okuma tarafı (API için) --------------------------------------------
// OKAS kalem listesi JSON'unu tender.items biçimine çevirir.
// NOT: teklif birim fiyatları kamuya açık DEĞİL. Burada üretilen 'birimFiyat',
// sözleşme bedelinin kalem sayısına bölünmesiyle elde edilen YAKLAŞIK bir
// göstergedir; kesin birim fiyat değildir.
function okasParse(json, bedel) {
if (!json) return [];
try {
const list = JSON.parse(json);
if (!Array.isArray(list) || !list.length) return [];
const pay = bedel != null && list.length ? Number(bedel) / list.length : null;
return list.map((k, i) => ({
id: `okas-${i}`,
kod: k.kodu || null,
ad: k.adi || k.koduAdi || 'Kalem',
miktar: null,
birim: null,
birimFiyat: pay,
yaklasik: true,
}));
} catch { return []; }
}
function rowToTender(r) { function rowToTender(r) {
return { return {
id: `hist-${r.ikn}-${r.kisim}`, id: `hist-${r.ikn}-${r.kisim}`,
@@ -125,7 +160,7 @@ function rowToTender(r) {
teklifSayisi: r.teklif_sayisi, teklifSayisi: r.teklif_sayisi,
gecerliTeklifSayisi: r.gecerli_teklif_sayisi, gecerliTeklifSayisi: r.gecerli_teklif_sayisi,
durum: 'Sonuçlandı', durum: 'Sonuçlandı',
items: [], items: okasParse(r.okas_kalemler, r.sozlesme_bedeli),
// Arayüzün beklediği alanlar (geçmiş kayıtta karşılığı yok — güvenli varsayılan) // Arayüzün beklediği alanlar (geçmiş kayıtta karşılığı yok — güvenli varsayılan)
eklenmeZamani: 'arşivden', eklenmeZamani: 'arşivden',
isYeni: false, isYeni: false,
+40
View File
@@ -24,6 +24,7 @@ import { searchTed } from './ted.mjs';
import { readHistory, historyStats } from './history-store.mjs'; import { readHistory, historyStats } from './history-store.mjs';
import { startScheduler } from './scheduler.mjs'; import { startScheduler } from './scheduler.mjs';
import { searchKik, kararMetni } from './kik.mjs'; import { searchKik, kararMetni } from './kik.mjs';
import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs';
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor; // ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
// zinciri tamamlamak için PEM'i yerelden ekliyoruz. // zinciri tamamlamak için PEM'i yerelden ekliyoruz.
@@ -343,6 +344,45 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { success: true, user }); return json(res, 200, { success: true, user });
} }
// --- İhale dokümanları ---
if (url.pathname === '/api/documents/url') {
try {
const ikn = url.searchParams.get('ikn');
if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' });
const out = await documentUrl(ikn);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// İlan metni + kalemler — analiz için (tarayıcı gerektirmez)
if (url.pathname === '/api/documents/ilan') {
try {
const ikn = url.searchParams.get('ikn');
if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' });
const out = await ilanMetni(ikn);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// Doküman metni — sunucu tarafı tarayıcı gerektirir
if (url.pathname === '/api/documents/text') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
try {
const ikn = url.searchParams.get('ikn');
if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' });
const out = await documentText(ikn);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
if (url.pathname === '/api/documents/config') {
return json(res, 200, { tarayiciVar: await browserAvailable() });
}
// --- KİK kurul kararları --- // --- KİK kurul kararları ---
if (url.pathname === '/api/kik/search') { if (url.pathname === '/api/kik/search') {
try { try {
+65
View File
@@ -10,6 +10,7 @@
*/ */
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { historyStats } from './history-store.mjs'; import { historyStats } from './history-store.mjs';
@@ -54,6 +55,8 @@ function tara() {
p.on('close', (code) => { p.on('close', (code) => {
calisiyor = false; calisiyor = false;
console.log(`[scheduler] tarama bitti (çıkış kodu ${code})`); console.log(`[scheduler] tarama bitti (çıkış kodu ${code})`);
// Taramanın ardından eksik OKAS kalemlerini doldur (fiyat analizi için)
if (code === 0 && process.env.KALEM_DOLDUR !== '0') kalemDoldur();
}); });
p.on('error', (e) => { p.on('error', (e) => {
calisiyor = false; calisiyor = false;
@@ -61,6 +64,58 @@ function tara() {
}); });
} }
// Eksik OKAS kalemlerini arka planda doldurur (kategori bazlı fiyat analizi)
function kalemDoldur() {
const n = process.env.KALEM_DOLDUR_LIMIT || '150';
const p = spawn(process.execPath, [path.join(__dirname, 'ekap-scan.mjs'), '--kalem-doldur', n], {
stdio: ['ignore', 'pipe', 'pipe'],
});
p.stdout.on('data', (d) => process.stdout.write(String(d)));
p.on('error', (e) => console.warn('[scheduler] kalem doldurma başlatılamadı:', e.message));
}
// --- Doğrudan temin (Playwright/Chromium gerektirir) ---
let dtCalisiyor = false;
// Chromium ~350MB RSS kullanabilir; sunucu darsa DT'yi atla ve uyar.
function bellekYeterliMi() {
const bosMB = Math.round(os.freemem() / 1024 / 1024);
const esik = Number(process.env.DT_MIN_BOS_MB || 600);
if (bosMB < esik) {
console.warn(`[scheduler] ⚠ Sunucu belleği dar (${bosMB}MB boş < ${esik}MB) — doğrudan temin taraması bu tur atlandı. Kalıcı sorun olursa DT_ENABLED=0 ile kapatın.`);
return false;
}
return true;
}
function dtTara() {
if (process.env.DT_ENABLED === '0') return;
if (dtCalisiyor) {
console.log('[scheduler] önceki DT taraması sürüyor, bu tur atlandı');
return;
}
if (!bellekYeterliMi()) return;
dtCalisiyor = true;
const pages = process.env.DT_PAGES || '5';
const details = process.env.DT_DETAILS || '30';
console.log(`[scheduler] doğrudan temin taraması başlıyor (${pages} sayfa, ${details} detay)`);
const p = spawn(process.execPath, [path.join(__dirname, 'dt-worker.mjs'), '--pages', pages, '--details', details], {
stdio: ['ignore', 'pipe', 'pipe'],
});
p.stdout.on('data', (d) => process.stdout.write(String(d)));
p.stderr.on('data', (d) => process.stderr.write(String(d)));
p.on('close', (code) => {
dtCalisiyor = false;
if (code !== 0) console.warn(`[scheduler] ⚠ DT taraması ${code} koduyla bitti — Chromium kurulu mu / bellek yeterli mi kontrol edin.`);
else console.log('[scheduler] DT taraması bitti');
});
p.on('error', (e) => {
dtCalisiyor = false;
console.warn('[scheduler] ⚠ DT worker başlatılamadı:', e.message, '— Chromium yoksa DT_ENABLED=0 yapın.');
});
}
export function startScheduler() { export function startScheduler() {
if (process.env.SCAN_ENABLED === '0') { if (process.env.SCAN_ENABLED === '0') {
console.log('[scheduler] devre dışı (SCAN_ENABLED=0)'); console.log('[scheduler] devre dışı (SCAN_ENABLED=0)');
@@ -72,4 +127,14 @@ export function startScheduler() {
// Açılıştan 90 sn sonra ilk tur (sunucu ısınsın), sonra periyodik // Açılıştan 90 sn sonra ilk tur (sunucu ısınsın), sonra periyodik
setTimeout(tara, 90 * 1000).unref?.(); setTimeout(tara, 90 * 1000).unref?.();
setInterval(tara, ms).unref?.(); setInterval(tara, ms).unref?.();
// Doğrudan temin: sonuç taramasıyla çakışmasın diye kaydırmalı
if (process.env.DT_ENABLED === '0') {
console.log('[scheduler] doğrudan temin devre dışı (DT_ENABLED=0)');
} else {
const dtSaat = Number(process.env.DT_INTERVAL_SAAT || 4);
console.log(`[scheduler] doğrudan temin taraması her ${dtSaat} saatte bir çalışacak`);
setTimeout(dtTara, 4 * 60 * 1000).unref?.();
setInterval(dtTara, Math.max(1, dtSaat) * 3600 * 1000).unref?.();
}
} }
+13 -2
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { TenderDocuments } from './TenderDocuments';
import { import {
X, Star, Share2, Calendar, MapPin, Building2, Tag, FileText, CheckSquare, X, Star, Share2, Calendar, MapPin, Building2, Tag, FileText, CheckSquare,
DollarSign, Calculator, Download, Sparkles, AlertTriangle, ShieldCheck, DollarSign, Calculator, Download, Sparkles, AlertTriangle, ShieldCheck,
@@ -460,8 +461,16 @@ END:VCALENDAR`;
</span> </span>
</div> </div>
<div className="bg-white/10 p-4 rounded-xl border border-white/10 space-y-1"> <div className="bg-white/10 p-4 rounded-xl border border-white/10 space-y-1">
<span className="text-slate-300 text-xs font-semibold block">Tahmini Rekabet</span> <span className="text-slate-300 text-xs font-semibold block">
<span className="text-lg font-black text-blue-300">3 - 5 Firma</span> {tender.gecerliTeklifSayisi != null || tender.teklifSayisi != null ? 'Katılan Firma' : 'Rekabet Verisi'}
</span>
<span className="text-lg font-black text-blue-300">
{tender.gecerliTeklifSayisi != null
? `${tender.gecerliTeklifSayisi} geçerli teklif`
: tender.teklifSayisi != null
? `${tender.teklifSayisi} teklif`
: 'Sonuçlanınca gelir'}
</span>
</div> </div>
</div> </div>
@@ -476,6 +485,8 @@ END:VCALENDAR`;
</div> </div>
</div> </div>
<TenderDocuments tender={tender} />
<div className="bg-white p-5 rounded-xl border border-slate-200 space-y-3"> <div className="bg-white p-5 rounded-xl border border-slate-200 space-y-3">
<h4 className="font-bold text-sm text-slate-900">Kritik Yeterlik Şartları</h4> <h4 className="font-bold text-sm text-slate-900">Kritik Yeterlik Şartları</h4>
<ul className="space-y-2 text-xs text-slate-700"> <ul className="space-y-2 text-xs text-slate-700">
+117
View File
@@ -0,0 +1,117 @@
import React, { useState } from 'react';
import { useApp } from '../context/AppContext';
import { getToken } from '../services/auth';
import { FileText, ExternalLink, Loader2, Sparkles, AlertTriangle, Download } from 'lucide-react';
/**
* İhale dokümanları paneli.
* - EKAP doküman bağlantısını üretip kullanıcının tarayıcısında açar
* - İlan metnini (ilan + OKAS kalemleri) çeker ve doğrudan AI analizine verir
*/
export const TenderDocuments = ({ tender }) => {
const { analyzeSpecPdfWithGemini, setIsAiSpecAnalyzerOpen } = useApp();
const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
const [bilgi, setBilgi] = useState(null);
const ikn = tender?.ikn;
const ekapIhalesi = /^\d{4}\/\d+$/.test(String(ikn || ''));
const dokumaniAc = async () => {
setBusy('url'); setError(null);
try {
const r = await fetch(`/api/documents/url?ikn=${encodeURIComponent(ikn)}`);
const d = await r.json();
if (!d.success) throw new Error(d.error);
setBilgi({ dokumanSayisi: d.dokumanSayisi });
window.open(d.url, '_blank', 'noopener,noreferrer');
} catch (e) {
setError(e.message);
} finally { setBusy(null); }
};
const ilaniAnalizEt = async () => {
setBusy('analiz'); setError(null);
try {
const r = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(ikn)}`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
const d = await r.json();
if (!d.success) throw new Error(d.error);
if (!d.metin || d.metin.length < 100) throw new Error('İlan metni analiz için yeterli değil.');
setBilgi({ karakter: d.metin.length, ilanSayisi: d.ilanSayisi, okasSayisi: d.okasSayisi });
setIsAiSpecAnalyzerOpen(true);
await analyzeSpecPdfWithGemini({ text: d.metin, fileName: `${d.ikn} — EKAP ilan metni`, tender });
} catch (e) {
setError(e.message);
} finally { setBusy(null); }
};
return (
<div className="bg-white p-5 rounded-xl border border-slate-200 space-y-3">
<h4 className="font-bold text-sm text-slate-900 flex items-center gap-2">
<FileText className="w-4 h-4 text-blue-600" /> İhale Dokümanları
</h4>
{!ekapIhalesi ? (
<p className="text-xs text-slate-500 leading-relaxed">
Bu kayıt EKAP İKN formatında değil; doküman bağlantısı yalnızca EKAP ihaleleri için üretilebilir.
{tender?.kaynakUrl && (
<> İlanın kaynağını{' '}
<a href={tender.kaynakUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 font-semibold hover:underline">
buradan açabilirsiniz
</a>.
</>
)}
</p>
) : (
<>
<p className="text-xs text-slate-500 leading-relaxed">
İhale dokümanları EKAP üzerinde tutulur. Aşağıdaki bağlantı sizi doğrudan bu ihalenin
EKAP doküman sayfasına götürür; ilan metnini ise analiz için buradan çekebilirsiniz.
</p>
<div className="flex gap-2 flex-wrap">
<button
onClick={dokumaniAc}
disabled={busy !== null}
className="bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white font-bold text-xs px-4 py-2.5 rounded-xl flex items-center gap-2"
>
{busy === 'url' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <ExternalLink className="w-3.5 h-3.5" />}
EKAP Dokümanlarını Aç
</button>
<button
onClick={ilaniAnalizEt}
disabled={busy !== null}
className="bg-amber-400 hover:bg-amber-300 disabled:opacity-40 text-slate-950 font-black text-xs px-4 py-2.5 rounded-xl flex items-center gap-2"
>
{busy === 'analiz' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
İlan Metnini AI ile Analiz Et
</button>
</div>
{bilgi && (
<p className="text-[11px] text-slate-500">
{bilgi.dokumanSayisi != null && <>EKAP'ta {bilgi.dokumanSayisi} doküman kayıtlı. </>}
{bilgi.karakter != null && <>{bilgi.ilanSayisi} ilan · {bilgi.okasSayisi} kalem · {bilgi.karakter.toLocaleString('tr-TR')} karakter metin analize gönderildi.</>}
</p>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-2.5 flex gap-2">
<AlertTriangle className="w-3.5 h-3.5 text-red-600 shrink-0 mt-0.5" />
<p className="text-[11px] text-red-800 leading-relaxed">{error}</p>
</div>
)}
<p className="text-[10px] text-slate-400 leading-relaxed flex items-start gap-1.5">
<Download className="w-3 h-3 shrink-0 mt-0.5" />
EKAP, doküman indirmeyi kendi oturumu üzerinden yaptırır; bağlantı sizin tarayıcınızda açılır.
Şartname dosyanız elinizdeyse AI analiz penceresinden doğrudan yükleyebilirsiniz.
</p>
</>
)}
</div>
);
};
+15 -2
View File
@@ -52,7 +52,7 @@ export const PriceAnalysis = () => {
<div> <div>
<h1 className="text-xl font-black text-slate-900">Kalem Bazlı Fiyat Analizi</h1> <h1 className="text-xl font-black text-slate-900">Kalem Bazlı Fiyat Analizi</h1>
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
Toplanan ihalelerdeki gerçek birim fiyatlardan hesaplanır · <strong>{uniqueItems}</strong> farklı kalem, <strong>{itemCount}</strong> fiyat kaydı. EKAP sonuç ilanlarındaki OKAS ihtiyaç kalemleri ve sözleşme bedellerinden hesaplanır · <strong>{uniqueItems}</strong> farklı kalem kategorisi, <strong>{itemCount}</strong> kayıt.
</p> </p>
</div> </div>
</div> </div>
@@ -64,11 +64,24 @@ export const PriceAnalysis = () => {
<Download className="w-4 h-4" /> CSV İndir <Download className="w-4 h-4" /> CSV İndir
</button> </button>
</div> </div>
{/* Yöntem şeffaflığı */}
{items.length > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3.5 flex gap-2.5">
<span className="text-blue-600 font-bold shrink-0">ℹ</span>
<p className="text-[11px] text-blue-900 leading-relaxed">
<strong>Yöntem:</strong> Türkiye'de ihale <em>teklif birim fiyatları</em> kamuya açık değildir.
Buradaki tutarlar, EKAP sonuç ilanındaki <strong>sözleşme bedelinin</strong> ihalenin OKAS
ihtiyaç kalemlerine bölünmesiyle elde edilen <strong>kategori bazlı yaklaşık</strong> değerlerdir —
kesin birim fiyat değildir. Aynı OKAS kategorisindeki ihaleler arasında karşılaştırma için kullanın.
</p>
</div>
)}
{items.length === 0 ? ( {items.length === 0 ? (
<div className="bg-white rounded-xl border border-slate-200 p-10 text-center text-sm text-slate-500 flex flex-col items-center gap-2"> <div className="bg-white rounded-xl border border-slate-200 p-10 text-center text-sm text-slate-500 flex flex-col items-center gap-2">
<Inbox className="w-8 h-8 text-slate-300" /> <Inbox className="w-8 h-8 text-slate-300" />
Henüz kalem fiyat verisi birikmedi. Kalem/birim fiyat içeren ihaleler (doğrudan temin sözleşme kalemleri) geldikçe bu analiz gerçek fiyatlarla otomatik dolar. Henüz kalem verisi birikmedi. EKAP sonuç ilanları ve OKAS ihtiyaç kalemleri arşive eklendikçe bu analiz otomatik dolar.
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">