diff --git a/Dockerfile b/Dockerfile
index 71b5699..7b28168 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,32 +1,42 @@
# Stage 1: Vite SPA build
-FROM node:22-alpine AS build
+FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
-# Stage 2: nginx (frontend) + node (canlı veri backend) tek container'da
-FROM node:22-alpine
-RUN apk add --no-cache nginx
+# Stage 2: nginx (frontend) + node (canlı veri backend) + Playwright/Chromium
+# NOT: Chromium yalnızca doğrudan temin (EKAP anti-bot) ve ihale dokümanı
+# 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
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)
-WORKDIR /app
+# Backend (Node builtin'leri + playwright)
COPY --from=build /app/server ./server
-# Geçmiş sonuç veritabanı (SQLite). Deploy'lar arası kalıcılık için Coolify'da
-# /app/server/data yoluna bir volume bağlanmalı; bağlanmazsa tarayıcı veriyi
-# her yeniden dağıtımdan sonra yeniden biriktirir.
+# Kalıcı veri (Coolify'da /app/server/data volume olarak bağlı)
RUN mkdir -p /app/server/data
VOLUME ["/app/server/data"]
-# Başlangıç: Node backend'i (sabit 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).
+# Başlangıç: Node backend (3001) arka planda + nginx (80) ön planda.
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
diff --git a/server/documents.mjs b/server/documents.mjs
new file mode 100644
index 0000000..ab948df
--- /dev/null
+++ b/server/documents.mjs
@@ -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(/
/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(() => {});
+ }
+}
diff --git a/server/ekap-scan.mjs b/server/ekap-scan.mjs
index 9031907..e5c4383 100644
--- a/server/ekap-scan.mjs
+++ b/server/ekap-scan.mjs
@@ -15,7 +15,7 @@
*/
import {
- searchTenders, fetchResults, normalizeCompanyName, DURUM_SONUC_ILANI,
+ searchTenders, fetchResults, getIhaleDetay, normalizeCompanyName, DURUM_SONUC_ILANI,
} from './ekap-v2.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 SAYFA = 50;
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));
async function havuz(items, worker, n) {
@@ -56,6 +57,35 @@ async function havuz(items, worker, n) {
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() {
if (!available()) {
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 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}`);
let skip = 0;
let taranan = 0;
let eklenen = 0;
let hata = 0;
+ let kalemli = 0;
let toplamHavuz = null;
while (taranan < LIMIT) {
@@ -102,17 +142,27 @@ async function main() {
const rs = await fetchResults(t.id);
if (!rs.length) return { bos: true };
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);
- 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);
for (const s of sonuclar) {
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;
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;
await sleep(300); // EKAP'ı yormamak için nazik ara
@@ -124,7 +174,7 @@ async function main() {
not: `havuz=${toplamHavuz}`,
});
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); });
diff --git a/server/history-store.mjs b/server/history-store.mjs
index 5559240..4a71e02 100644
--- a/server/history-store.mjs
+++ b/server/history-store.mjs
@@ -39,6 +39,7 @@ CREATE TABLE IF NOT EXISTS sonuclar (
yaklasik_maliyet REAL,
teklif_sayisi INTEGER,
gecerli_teklif_sayisi INTEGER,
+ okas_kalemler TEXT,
cekilme_zamani TEXT,
PRIMARY KEY (ikn, kisim)
);
@@ -67,6 +68,16 @@ export function openDb({ create = false } = {}) {
}
const db = new DatabaseSync(DB_PATH);
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;
}
@@ -74,12 +85,13 @@ export function saveResults(db, tender, results) {
const stmt = db.prepare(`
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,
- yaklasik_maliyet,teklif_sayisi,gecerli_teklif_sayisi,cekilme_zamani)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ yaklasik_maliyet,teklif_sayisi,gecerli_teklif_sayisi,okas_kalemler,cekilme_zamani)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(ikn,kisim) DO UPDATE SET
kazanan_firma=excluded.kazanan_firma, kazanan_norm=excluded.kazanan_norm,
sozlesme_bedeli=excluded.sozlesme_bedeli, yaklasik_maliyet=excluded.yaklasik_maliyet,
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
`);
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.sozlesmeBedeli ?? null, r.paraBirimi || null, r.sozlesmeTarihi || null,
r.yaklasikMaliyet ?? null, r.teklifSayisi ?? null, r.gecerliTeklifSayisi ?? null,
+ tender.okasJson ?? null,
now
);
n++;
@@ -106,6 +119,28 @@ export function hasIkn(db, ikn) {
// --- 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) {
return {
id: `hist-${r.ikn}-${r.kisim}`,
@@ -125,7 +160,7 @@ function rowToTender(r) {
teklifSayisi: r.teklif_sayisi,
gecerliTeklifSayisi: r.gecerli_teklif_sayisi,
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)
eklenmeZamani: 'arşivden',
isYeni: false,
diff --git a/server/index.js b/server/index.js
index b1daed5..3323ce3 100644
--- a/server/index.js
+++ b/server/index.js
@@ -24,6 +24,7 @@ import { searchTed } from './ted.mjs';
import { readHistory, historyStats } from './history-store.mjs';
import { startScheduler } from './scheduler.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;
// 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 });
}
+ // --- İ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ı ---
if (url.pathname === '/api/kik/search') {
try {
diff --git a/server/scheduler.mjs b/server/scheduler.mjs
index e7c6613..519f608 100644
--- a/server/scheduler.mjs
+++ b/server/scheduler.mjs
@@ -10,6 +10,7 @@
*/
import { spawn } from 'node:child_process';
+import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { historyStats } from './history-store.mjs';
@@ -54,6 +55,8 @@ function tara() {
p.on('close', (code) => {
calisiyor = false;
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) => {
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() {
if (process.env.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
setTimeout(tara, 90 * 1000).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?.();
+ }
}
diff --git a/src/components/TenderDetailModal.jsx b/src/components/TenderDetailModal.jsx
index 29de7ae..55e547f 100644
--- a/src/components/TenderDetailModal.jsx
+++ b/src/components/TenderDetailModal.jsx
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useApp } from '../context/AppContext';
+import { TenderDocuments } from './TenderDocuments';
import {
X, Star, Share2, Calendar, MapPin, Building2, Tag, FileText, CheckSquare,
DollarSign, Calculator, Download, Sparkles, AlertTriangle, ShieldCheck,
@@ -460,8 +461,16 @@ END:VCALENDAR`;
+ 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ı{' '} + + buradan açabilirsiniz + . + > + )} +
+ ) : ( + <> ++ İ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. +
+ ++ {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.>} +
+ )} + + {error && ( +{error}
+
+
- Toplanan ihalelerdeki gerçek birim fiyatlardan hesaplanır · {uniqueItems} farklı kalem, {itemCount} fiyat kaydı. + EKAP sonuç ilanlarındaki OKAS ihtiyaç kalemleri ve sözleşme bedellerinden hesaplanır · {uniqueItems} farklı kalem kategorisi, {itemCount} kayıt.
+ Yöntem: Türkiye'de ihale teklif birim fiyatları kamuya açık değildir. + Buradaki tutarlar, EKAP sonuç ilanındaki sözleşme bedelinin ihalenin OKAS + ihtiyaç kalemlerine bölünmesiyle elde edilen kategori bazlı yaklaşık değerlerdir — + kesin birim fiyat değildir. Aynı OKAS kategorisindeki ihaleler arasında karşılaştırma için kullanın. +
+