feat: canlı veri — ilan.gov.tr açık ihale + EKAP doğrudan temin (22/d)
- server/index.js: ilan.gov.tr proxy (açık ihale + kira/satış), TLS zincir düzeltmesi - server/dt-worker.mjs: EKAP doğrudan temin toplayıcı (Playwright, imzasız oturum içi fetch) - server/dt-store.mjs + il-plaka.mjs: DT verisini tender modeline çevirme - src/services/tenderApi.js + AppContext: mock veri yerine canlı API - Doğrudan Temin sekmesi 256 gerçek 22/d kaydıyla dolu
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "ihaleist-dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -2,3 +2,10 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Canlı veri worker çıktısı
|
||||||
|
server/data/
|
||||||
|
*.sqlite
|
||||||
|
|
||||||
|
# Kişisel memanto hafızası (uygulama dışı)
|
||||||
|
MEMORY.md
|
||||||
|
|||||||
Generated
+50
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "teminmatik-app",
|
"name": "ihaleist-app",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "teminmatik-app",
|
"name": "ihaleist-app",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7"
|
||||||
}
|
}
|
||||||
@@ -2443,6 +2444,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.26",
|
"version": "8.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
|
|||||||
+3
-1
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"server": "node server/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* DT worker'ının doldurduğu SQLite'ı okuyup ihaleist tender modeline çevirir.
|
||||||
|
* Veritabanı yoksa (worker hiç koşmadıysa) sessizce boş liste döner.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DB_PATH = path.join(__dirname, 'data', 'dt.sqlite');
|
||||||
|
|
||||||
|
function parseDetail(detailJson) {
|
||||||
|
if (!detailJson) return {};
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(detailJson).dogrudanTeminDetayResult || {};
|
||||||
|
const dt = d.DogrudanTeminBilgileri || {};
|
||||||
|
const idare = d.IdareBilgileri || {};
|
||||||
|
const soz = d.SozlesmeBilgileri?.SozlesmeBilgisiList?.[0] || null;
|
||||||
|
return {
|
||||||
|
yasaMaddesi: dt.YasaKapsamiTeminMaddesi || null,
|
||||||
|
durum: dt.DtDurumu || null,
|
||||||
|
bransKodlari: dt.BransKodList || [],
|
||||||
|
kismiTeklif: dt.KismiTeklif || null,
|
||||||
|
idareTam: idare.Idare || null,
|
||||||
|
enUstIdare: idare.EnUstIdare || null,
|
||||||
|
kazananFirma: soz?.IstekliAdi || null,
|
||||||
|
sozlesmeBedeli: soz?.SozlesmeBedeli || null,
|
||||||
|
sozlesmeTarihi: soz?.SozlesmeTarihi || null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const KURUM_RULES = [
|
||||||
|
[/hastane|sağlık|başhekim|tıp merkezi|adsm|ağız ve diş/i, 'Sağlık Kurumları'],
|
||||||
|
[/ceza infaz|cezaevi|tutukevi|işyurt/i, 'Ceza İnfaz Kurumları'],
|
||||||
|
[/üniversite|rektörlü/i, 'Üniversiteler'],
|
||||||
|
[/büyükşehir belediye/i, 'Büyükşehir Belediyeleri'],
|
||||||
|
[/belediye/i, 'Belediyeler'],
|
||||||
|
[/bakanlığı|valiliği|müdürlüğü|komutanlığı/i, 'Bakanlık / Kamu Kurumları'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function classifyKurum(text) {
|
||||||
|
for (const [re, label] of KURUM_RULES) if (re.test(text || '')) return label;
|
||||||
|
return 'Diğer Kamu Kurumları';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDtRow(row) {
|
||||||
|
const det = parseDetail(row.detail_json);
|
||||||
|
const kurum = det.idareTam || row.idare_adi || '';
|
||||||
|
return {
|
||||||
|
id: `dt-${row.dogrudan_temin_id}`,
|
||||||
|
kaynak: 'EKAP · Doğrudan Temin',
|
||||||
|
kaynakUrl: 'https://ekap.kik.gov.tr/EKAP/YeniIhaleArama.aspx',
|
||||||
|
ikn: row.dtn || '',
|
||||||
|
baslik: row.isin_adi || '',
|
||||||
|
kurum,
|
||||||
|
sehir: row.ili && !row.ili.startsWith('Plaka') ? row.ili : '',
|
||||||
|
bolge: '',
|
||||||
|
tarih: row.dt_tarihi || '',
|
||||||
|
saat: '',
|
||||||
|
alimTuru: 'Doğrudan Temin',
|
||||||
|
usul: det.yasaMaddesi ? `Doğrudan Temin (4734 ${det.yasaMaddesi})` : 'Doğrudan Temin (4734 22/d)',
|
||||||
|
sektor: row.turu || 'Genel',
|
||||||
|
brans: (det.bransKodlari || []).join(', ') || row.turu || 'Genel',
|
||||||
|
kurumTipi: classifyKurum(kurum),
|
||||||
|
eklenmeZamani: row.yayim_tarihi ? `Yayım: ${row.yayim_tarihi}` : '',
|
||||||
|
isYeni: false,
|
||||||
|
isLiveFeed: true,
|
||||||
|
aciliyetBadge: null,
|
||||||
|
items: [],
|
||||||
|
aciklama: null,
|
||||||
|
yaklasikMaliyet: null,
|
||||||
|
teslimatSuresi: null,
|
||||||
|
teslimYeri: null,
|
||||||
|
yeterlikKriterleri: [],
|
||||||
|
aiMatchScore: null,
|
||||||
|
aiRiskScore: null,
|
||||||
|
aiBidRange: null,
|
||||||
|
kanbanStatus: 'incelenecek',
|
||||||
|
atananPersonel: null,
|
||||||
|
durum: det.durum || 'Aktif',
|
||||||
|
incelendi: false,
|
||||||
|
takipte: false,
|
||||||
|
// Sonuçlanmış DT'lerde kazanan/fiyat bilgisi
|
||||||
|
sozlesmeBedeli: det.sozlesmeBedeli || null,
|
||||||
|
kazananFirma: det.kazananFirma || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDtTenders(limit = 300) {
|
||||||
|
if (!fs.existsSync(DB_PATH)) return { tenders: [], totalCount: 0, available: false };
|
||||||
|
const db = new DatabaseSync(DB_PATH, { readOnly: true });
|
||||||
|
try {
|
||||||
|
const total = db.prepare('SELECT COUNT(*) c FROM dt_tenders').get().c;
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT dogrudan_temin_id, dtn, isin_adi, idare_adi, turu, ili, dt_tarihi, yayim_tarihi, detail_json
|
||||||
|
FROM dt_tenders ORDER BY rowid DESC LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(limit);
|
||||||
|
return { tenders: rows.map(mapDtRow), totalCount: total, available: true };
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
/**
|
||||||
|
* ihaleist Doğrudan Temin (22/d) Worker
|
||||||
|
*
|
||||||
|
* EKAP'ın doğrudan temin verisini toplar. İmzalı v2 API'ye ihtiyaç duymaz:
|
||||||
|
* Playwright ile YeniIhaleArama.aspx açılarak oturum (ASP.NET_SessionId +
|
||||||
|
* BIG-IP TS çerezi) kurulur, ashx çağrıları sayfanın İÇİNDEN fetch ile yapılır —
|
||||||
|
* böylece TLS parmak izi ve çerezler gerçek tarayıcıya aittir.
|
||||||
|
* (Yöntem referansı: alizorlu/ekap-dt-service)
|
||||||
|
*
|
||||||
|
* Kullanım:
|
||||||
|
* node server/dt-worker.mjs # liste + eksik detayları çek
|
||||||
|
* node server/dt-worker.mjs --pages 5 # ilk 5 liste sayfası
|
||||||
|
* node server/dt-worker.mjs --details 20 # bu turda en fazla 20 detay
|
||||||
|
*
|
||||||
|
* Veri: server/data/dt.sqlite (node:sqlite, ek bağımlılık yok)
|
||||||
|
* Bağımlılık: playwright (npm i -D playwright && npx playwright install chromium)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
|
const DB_PATH = path.join(DATA_DIR, 'dt.sqlite');
|
||||||
|
|
||||||
|
const BASE_URL = 'https://ekap.kik.gov.tr';
|
||||||
|
const SEARCH_PAGE = '/EKAP/YeniIhaleArama.aspx';
|
||||||
|
const DATA_HANDLER = '/EKAP/Ortak/YeniIhaleAramaData.ashx';
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const argVal = (name, def) => {
|
||||||
|
const i = args.indexOf(name);
|
||||||
|
return i >= 0 && args[i + 1] ? Number(args[i + 1]) : def;
|
||||||
|
};
|
||||||
|
const MAX_PAGES = argVal('--pages', 10);
|
||||||
|
const MAX_DETAILS = argVal('--details', 40);
|
||||||
|
const DELAY_BASE_MS = 2000;
|
||||||
|
const DELAY_JITTER_MS = 1500;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SQLite
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
const db = new DatabaseSync(DB_PATH);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS dt_tenders (
|
||||||
|
dogrudan_temin_id TEXT PRIMARY KEY,
|
||||||
|
idare_id TEXT,
|
||||||
|
dtn TEXT,
|
||||||
|
isin_adi TEXT,
|
||||||
|
idare_adi TEXT,
|
||||||
|
turu TEXT,
|
||||||
|
durumu TEXT,
|
||||||
|
ili TEXT,
|
||||||
|
dt_tarihi TEXT,
|
||||||
|
yayim_tarihi TEXT,
|
||||||
|
list_json TEXT,
|
||||||
|
detail_json TEXT,
|
||||||
|
detail_fetched_at TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dt_tarihi ON dt_tenders (dt_tarihi);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const upsertList = db.prepare(`
|
||||||
|
INSERT INTO dt_tenders (dogrudan_temin_id, idare_id, dtn, isin_adi, idare_adi, turu, durumu, ili, dt_tarihi, yayim_tarihi, list_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(dogrudan_temin_id) DO UPDATE SET
|
||||||
|
dtn = excluded.dtn, isin_adi = excluded.isin_adi, idare_adi = excluded.idare_adi,
|
||||||
|
turu = excluded.turu, durumu = excluded.durumu, ili = excluded.ili,
|
||||||
|
dt_tarihi = excluded.dt_tarihi, yayim_tarihi = excluded.yayim_tarihi,
|
||||||
|
list_json = excluded.list_json, updated_at = datetime('now')
|
||||||
|
`);
|
||||||
|
|
||||||
|
const setDetail = db.prepare(`
|
||||||
|
UPDATE dt_tenders SET detail_json = ?, detail_fetched_at = datetime('now'), updated_at = datetime('now')
|
||||||
|
WHERE dogrudan_temin_id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// EKAP oturumu (Playwright)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const humanDelay = () => sleep(DELAY_BASE_MS + Math.floor(Math.random() * DELAY_JITTER_MS));
|
||||||
|
|
||||||
|
async function openSession() {
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
'--disable-blink-features=AutomationControlled',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({
|
||||||
|
userAgent:
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
|
||||||
|
locale: 'tr-TR',
|
||||||
|
timezoneId: 'Europe/Istanbul',
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
extraHTTPHeaders: { 'Accept-Language': 'tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7' },
|
||||||
|
});
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||||
|
window.chrome = window.chrome || { runtime: {} };
|
||||||
|
Object.defineProperty(navigator, 'languages', { get: () => ['tr-TR', 'tr', 'en-US', 'en'] });
|
||||||
|
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
console.log(`[dt-worker] EKAP oturumu açılıyor: ${BASE_URL}${SEARCH_PAGE}`);
|
||||||
|
const resp = await page.goto(BASE_URL + SEARCH_PAGE, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
if (!resp || !resp.ok()) throw new Error(`EKAP arama sayfası açılamadı: HTTP ${resp?.status()}`);
|
||||||
|
await sleep(2500 + Math.floor(Math.random() * 1500)); // BIG-IP TS çerezinin oturması için
|
||||||
|
console.log('[dt-worker] oturum hazır');
|
||||||
|
return { browser, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchPage = (sess, i) =>
|
||||||
|
fetchAshx(sess, `?ES=&ihaleidListesi=&dtBilgiSecim=1&metot=dtAra&orderBy=10&pageIndex=${i}`);
|
||||||
|
|
||||||
|
const fetchDetail = (sess, dtId, idareId) =>
|
||||||
|
fetchAshx(
|
||||||
|
sess,
|
||||||
|
`?dogrudanTeminId=${encodeURIComponent(dtId)}&idareId=${encodeURIComponent(idareId)}&metot=dtDetayGetir`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ashx çağrısını sayfanın içinden yapar (çerezler otomatik gider).
|
||||||
|
// Ara sıra BIG-IP challenge HTML'i döner; sayfayı tazeleyip 3 kez dener.
|
||||||
|
async function fetchAshx(sess, query) {
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
let status = 0;
|
||||||
|
let text = '';
|
||||||
|
try {
|
||||||
|
const raw = await sess.page.evaluate(async (url) => {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json, text/plain, */*',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Pragma: 'no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return JSON.stringify({ status: res.status, text: await res.text() });
|
||||||
|
}, DATA_HANDLER + query);
|
||||||
|
({ status, text } = JSON.parse(raw));
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt === 3) throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 200 && text && !text.trimStart().startsWith('<')) {
|
||||||
|
return JSON.parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < 3) {
|
||||||
|
console.warn(`[dt-worker] beklenmeyen yanıt (HTTP ${status}, ${text.length} kr), oturum tazeleniyor (deneme ${attempt})`);
|
||||||
|
await refreshSession(sess);
|
||||||
|
await sleep(3000 * attempt);
|
||||||
|
} else {
|
||||||
|
throw new Error(`EKAP ashx beklenmeyen yanıt (HTTP ${status}, ${text.length} kr), denemeler tükendi`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aynı context içinde sayfayı yeniden yükleyerek oturumu tazeler.
|
||||||
|
async function refreshSession(sess) {
|
||||||
|
try {
|
||||||
|
await sess.page.goto(BASE_URL + SEARCH_PAGE, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||||
|
await sleep(2500 + Math.floor(Math.random() * 1500));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[dt-worker] oturum tazeleme hatası: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dtAra listesi alanları obfuscate edilmiş (E1..E14). Çözülmüş konum eşlemesi:
|
||||||
|
// E1=DTN E2=işin adı E3=idare adı E4=tür kodu E7=DT tarihi E8=yayım tarihi
|
||||||
|
// E10=dogrudanTeminId E11=idareId E12=il plaka kodu E13=ilan var E14=e-ihale
|
||||||
|
const TUR_KODU = { 1: 'Mal Alımı', 2: 'Hizmet Alımı', 3: 'Konaklama/Hizmet', 4: 'Danışmanlık' };
|
||||||
|
const IL_PLAKA = {
|
||||||
|
1: 'Adana', 6: 'Ankara', 7: 'Antalya', 16: 'Bursa', 21: 'Diyarbakır', 25: 'Erzurum',
|
||||||
|
26: 'Eskişehir', 27: 'Gaziantep', 34: 'İstanbul', 35: 'İzmir', 38: 'Kayseri', 41: 'Kocaeli',
|
||||||
|
42: 'Konya', 44: 'Malatya', 55: 'Samsun', 61: 'Trabzon', 65: 'Van', 63: 'Şanlıurfa',
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapListRow(r) {
|
||||||
|
const plaka = Number(r.E12);
|
||||||
|
return {
|
||||||
|
dogrudanTeminId: String(r.E10 ?? ''),
|
||||||
|
idareId: String(r.E11 ?? ''),
|
||||||
|
dtn: String(r.E1 ?? ''),
|
||||||
|
isinAdi: r.E2 ?? null,
|
||||||
|
idareAdi: r.E3 ?? null,
|
||||||
|
turu: TUR_KODU[Number(r.E4)] || (r.E4 ? `Tür ${r.E4}` : null),
|
||||||
|
durumu: null, // liste durum vermiyor; detayda geliyor
|
||||||
|
ili: IL_PLAKA[plaka] || (plaka ? `Plaka ${plaka}` : null),
|
||||||
|
dtTarihi: r.E7 ?? null,
|
||||||
|
yayimTarihi: r.E8 ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRows(listResponse) {
|
||||||
|
if (Array.isArray(listResponse)) return listResponse;
|
||||||
|
// dtAra yanıtı: { sp, yeniDogrudanTeminAramaResultList: [...] }
|
||||||
|
if (Array.isArray(listResponse?.yeniDogrudanTeminAramaResultList)) {
|
||||||
|
return listResponse.yeniDogrudanTeminAramaResultList;
|
||||||
|
}
|
||||||
|
for (const v of Object.values(listResponse || {})) {
|
||||||
|
if (Array.isArray(v) && v.length && typeof v[0] === 'object') return v;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ana akış
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function run() {
|
||||||
|
const { browser, page } = await openSession();
|
||||||
|
const sess = { browser, page };
|
||||||
|
try {
|
||||||
|
// 1) Liste taraması
|
||||||
|
let newCount = 0;
|
||||||
|
for (let i = 0; i < MAX_PAGES; i++) {
|
||||||
|
const data = await searchPage(sess, i);
|
||||||
|
const rows = extractRows(data);
|
||||||
|
if (i === 0 && rows[0]) {
|
||||||
|
const s = mapListRow(rows[0]);
|
||||||
|
console.log(`[dt-worker] ilk sayfa: ${rows.length} kayıt; örnek: ${s.dtn} / ${(s.isinAdi || '').slice(0, 40)} / ${s.ili}`);
|
||||||
|
}
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.log(`[dt-worker] sayfa ${i}: boş, tarama bitti`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let pageNew = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
const m = mapListRow(r);
|
||||||
|
if (!m.dogrudanTeminId) continue;
|
||||||
|
const existed = db
|
||||||
|
.prepare('SELECT 1 FROM dt_tenders WHERE dogrudan_temin_id = ?')
|
||||||
|
.get(m.dogrudanTeminId);
|
||||||
|
upsertList.run(
|
||||||
|
m.dogrudanTeminId, m.idareId, m.dtn, m.isinAdi, m.idareAdi,
|
||||||
|
m.turu, m.durumu, m.ili, m.dtTarihi, m.yayimTarihi, JSON.stringify(r),
|
||||||
|
);
|
||||||
|
if (!existed) {
|
||||||
|
pageNew++;
|
||||||
|
newCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[dt-worker] sayfa ${i}: ${rows.length} kayıt, ${pageNew} yeni`);
|
||||||
|
if (pageNew === 0 && i > 0) {
|
||||||
|
console.log('[dt-worker] sayfadaki tüm kayıtlar zaten mevcut, tarama durdu');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await humanDelay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Eksik detaylar
|
||||||
|
const pending = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT dogrudan_temin_id, idare_id FROM dt_tenders
|
||||||
|
WHERE detail_json IS NULL AND idare_id != '' LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(MAX_DETAILS);
|
||||||
|
console.log(`[dt-worker] ${pending.length} detay çekilecek`);
|
||||||
|
let ok = 0;
|
||||||
|
for (const row of pending) {
|
||||||
|
try {
|
||||||
|
const detail = await fetchDetail(sess, row.dogrudan_temin_id, row.idare_id);
|
||||||
|
setDetail.run(JSON.stringify(detail), row.dogrudan_temin_id);
|
||||||
|
ok++;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[dt-worker] detay hatası (${row.dogrudan_temin_id}): ${err.message}`);
|
||||||
|
}
|
||||||
|
await humanDelay();
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = db.prepare('SELECT COUNT(*) AS c FROM dt_tenders').get().c;
|
||||||
|
console.log(
|
||||||
|
`[dt-worker] bitti: ${newCount} yeni kayıt, ${ok} detay; toplam DB: ${total} doğrudan temin`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
console.error('[dt-worker] ölümcül hata:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIEjTCCA3WgAwIBAgIQDQd4KhM/xvmlcpbhMf/ReTANBgkqhkiG9w0BAQsFADBh
|
||||||
|
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
|
||||||
|
d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH
|
||||||
|
MjAeFw0xNzExMDIxMjIzMzdaFw0yNzExMDIxMjIzMzdaMGAxCzAJBgNVBAYTAlVT
|
||||||
|
MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
|
||||||
|
b20xHzAdBgNVBAMTFkdlb1RydXN0IFRMUyBSU0EgQ0EgRzEwggEiMA0GCSqGSIb3
|
||||||
|
DQEBAQUAA4IBDwAwggEKAoIBAQC+F+jsvikKy/65LWEx/TMkCDIuWegh1Ngwvm4Q
|
||||||
|
yISgP7oU5d79eoySG3vOhC3w/3jEMuipoH1fBtp7m0tTpsYbAhch4XA7rfuD6whU
|
||||||
|
gajeErLVxoiWMPkC/DnUvbgi74BJmdBiuGHQSd7LwsuXpTEGG9fYXcbTVN5SATYq
|
||||||
|
DfbexbYxTMwVJWoVb6lrBEgM3gBBqiiAiy800xu1Nq07JdCIQkBsNpFtZbIZhsDS
|
||||||
|
fzlGWP4wEmBQ3O67c+ZXkFr2DcrXBEtHam80Gp2SNhou2U5U7UesDL/xgLK6/0d7
|
||||||
|
6TnEVMSUVJkZ8VeZr+IUIlvoLrtjLbqugb0T3OYXW+CQU0kBAgMBAAGjggFAMIIB
|
||||||
|
PDAdBgNVHQ4EFgQUlE/UXYvkpOKmgP792PkA76O+AlcwHwYDVR0jBBgwFoAUTiJU
|
||||||
|
IBiV5uNu5g/6+rkS7QYXjzkwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsG
|
||||||
|
AQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMDQGCCsGAQUFBwEB
|
||||||
|
BCgwJjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEIGA1Ud
|
||||||
|
HwQ7MDkwN6A1oDOGMWh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEds
|
||||||
|
b2JhbFJvb3RHMi5jcmwwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUHAgEW
|
||||||
|
HGh0dHBzOi8vd3d3LmRpZ2ljZXJ0LmNvbS9DUFMwDQYJKoZIhvcNAQELBQADggEB
|
||||||
|
AIIcBDqC6cWpyGUSXAjjAcYwsK4iiGF7KweG97i1RJz1kwZhRoo6orU1JtBYnjzB
|
||||||
|
c4+/sXmnHJk3mlPyL1xuIAt9sMeC7+vreRIF5wFBC0MCN5sbHwhNN1JzKbifNeP5
|
||||||
|
ozpZdQFmkCo+neBiKR6HqIA+LMTMCMMuv2khGGuPHmtDze4GmEGZtYLyF8EQpa5Y
|
||||||
|
jPuV6k2Cr/N3XxFpT3hRpt/3usU/Zb9wfKPtWpoznZ4/44c1p9rzFcZYrWkj3A+7
|
||||||
|
TNBJE0GmP2fhXhP1D/XVfIW/h0yCJGEiV9Glm/uGOa3DXHlmbAcxSyCRraG+ZBkA
|
||||||
|
7h4SeM6Y8l/7MBRpPCz6l8Y=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Türkiye il plaka kodu → il adı (81 il)
|
||||||
|
export const IL_PLAKA = {
|
||||||
|
1: 'Adana', 2: 'Adıyaman', 3: 'Afyonkarahisar', 4: 'Ağrı', 5: 'Amasya', 6: 'Ankara',
|
||||||
|
7: 'Antalya', 8: 'Artvin', 9: 'Aydın', 10: 'Balıkesir', 11: 'Bilecik', 12: 'Bingöl',
|
||||||
|
13: 'Bitlis', 14: 'Bolu', 15: 'Burdur', 16: 'Bursa', 17: 'Çanakkale', 18: 'Çankırı',
|
||||||
|
19: 'Çorum', 20: 'Denizli', 21: 'Diyarbakır', 22: 'Edirne', 23: 'Elazığ', 24: 'Erzincan',
|
||||||
|
25: 'Erzurum', 26: 'Eskişehir', 27: 'Gaziantep', 28: 'Giresun', 29: 'Gümüşhane',
|
||||||
|
30: 'Hakkari', 31: 'Hatay', 32: 'Isparta', 33: 'Mersin', 34: 'İstanbul', 35: 'İzmir',
|
||||||
|
36: 'Kars', 37: 'Kastamonu', 38: 'Kayseri', 39: 'Kırklareli', 40: 'Kırşehir', 41: 'Kocaeli',
|
||||||
|
42: 'Konya', 43: 'Kütahya', 44: 'Malatya', 45: 'Manisa', 46: 'Kahramanmaraş', 47: 'Mardin',
|
||||||
|
48: 'Muğla', 49: 'Muş', 50: 'Nevşehir', 51: 'Niğde', 52: 'Ordu', 53: 'Rize', 54: 'Sakarya',
|
||||||
|
55: 'Samsun', 56: 'Siirt', 57: 'Sinop', 58: 'Sivas', 59: 'Tekirdağ', 60: 'Tokat',
|
||||||
|
61: 'Trabzon', 62: 'Tunceli', 63: 'Şanlıurfa', 64: 'Uşak', 65: 'Van', 66: 'Yozgat',
|
||||||
|
67: 'Zonguldak', 68: 'Aksaray', 69: 'Bayburt', 70: 'Karaman', 71: 'Kırıkkale', 72: 'Batman',
|
||||||
|
73: 'Şırnak', 74: 'Bartın', 75: 'Ardahan', 76: 'Iğdır', 77: 'Yalova', 78: 'Karabük',
|
||||||
|
79: 'Kilis', 80: 'Osmaniye', 81: 'Düzce',
|
||||||
|
};
|
||||||
+369
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* ihaleist API Proxy — ilan.gov.tr canlı ihale verisi
|
||||||
|
*
|
||||||
|
* Kaynak: Basın İlan Kurumu resmi ilan portalı (www.ilan.gov.tr) açık JSON API'si.
|
||||||
|
* Kapsam: 4734 açık ihaleler + 2886 kira/satış ihaleleri. (Doğrudan temin 22/d bu
|
||||||
|
* kaynakta yayınlanmaz — Faz 2'de EKAP worker'ı ile eklenecek.)
|
||||||
|
*
|
||||||
|
* Bağımlılık yok: Node 18+ built-in http + fetch.
|
||||||
|
* Çalıştır: node server/index.js (PORT env ile port değişir, varsayılan 3001)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
|
import tls from 'node:tls';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { readDtTenders } from './dt-store.mjs';
|
||||||
|
|
||||||
|
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
|
||||||
|
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const EXTRA_CA = fs.readFileSync(path.join(__dirname, 'geotrust-tls-rsa-ca-g1.pem'), 'utf8');
|
||||||
|
const CA_BUNDLE = [...tls.rootCertificates, EXTRA_CA];
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
const ILAN_BASE = 'https://www.ilan.gov.tr';
|
||||||
|
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 dk
|
||||||
|
const LIST_SIZE = 20; // ilan.gov.tr sayfa başına 20 kayıtla sınırlıyor
|
||||||
|
const MAX_PAGES = 8; // 8 sayfa × 20 = 160 ilan
|
||||||
|
|
||||||
|
const ILAN_HEADERS = {
|
||||||
|
accept: 'application/json',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
origin: ILAN_BASE,
|
||||||
|
referer: `${ILAN_BASE}/ilan/tum-ilanlar`,
|
||||||
|
'user-agent':
|
||||||
|
'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',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// İl → Bölge eşlemesi
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const REGIONS = {
|
||||||
|
'MARMARA BÖLGESİ': ['İstanbul', 'Bursa', 'Kocaeli', 'Balıkesir', 'Tekirdağ', 'Sakarya', 'Çanakkale', 'Edirne', 'Kırklareli', 'Yalova', 'Bilecik'],
|
||||||
|
'İÇ ANADOLU BÖLGESİ': ['Ankara', 'Konya', 'Kayseri', 'Eskişehir', 'Sivas', 'Aksaray', 'Karaman', 'Kırıkkale', 'Kırşehir', 'Nevşehir', 'Niğde', 'Yozgat', 'Çankırı'],
|
||||||
|
'EGE BÖLGESİ': ['İzmir', 'Manisa', 'Aydın', 'Denizli', 'Muğla', 'Afyonkarahisar', 'Kütahya', 'Uşak'],
|
||||||
|
'AKDENİZ BÖLGESİ': ['Antalya', 'Adana', 'Mersin', 'Hatay', 'Kahramanmaraş', 'Osmaniye', 'Isparta', 'Burdur'],
|
||||||
|
'KARADENİZ BÖLGESİ': ['Samsun', 'Trabzon', 'Ordu', 'Rize', 'Giresun', 'Zonguldak', 'Tokat', 'Çorum', 'Amasya', 'Sinop', 'Kastamonu', 'Bartın', 'Karabük', 'Düzce', 'Bolu', 'Artvin', 'Gümüşhane', 'Bayburt'],
|
||||||
|
'DOĞU ANADOLU BÖLGESİ': ['Erzurum', 'Van', 'Malatya', 'Elazığ', 'Erzincan', 'Ağrı', 'Kars', 'Ardahan', 'Iğdır', 'Muş', 'Bitlis', 'Bingöl', 'Tunceli', 'Hakkari'],
|
||||||
|
'GÜNEYDOĞU ANADOLU BÖLGESİ': ['Gaziantep', 'Şanlıurfa', 'Diyarbakır', 'Mardin', 'Batman', 'Siirt', 'Şırnak', 'Adıyaman', 'Kilis'],
|
||||||
|
};
|
||||||
|
const CITY_TO_REGION = {};
|
||||||
|
for (const [region, cities] of Object.entries(REGIONS)) {
|
||||||
|
for (const c of cities) CITY_TO_REGION[c.toLocaleUpperCase('tr-TR')] = region;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Basit sektör / kurum tipi sınıflandırması (başlık ve kurum adına göre)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const SECTOR_RULES = [
|
||||||
|
[/tıbbi|medikal|sağlık|hastane|ilaç|cerrahi|laboratuvar|diş/i, 'Sağlık / Tıbbi Malzeme'],
|
||||||
|
[/yazılım|bilişim|sunucu|donanım|kamera|network|bilgisayar/i, 'Bilişim & Yazılım'],
|
||||||
|
[/gıda|yemek|catering|ekmek|et |sebze|meyve|kumanya/i, 'Gıda & Catering'],
|
||||||
|
[/temizlik|hijyen|dezenfek|deterjan/i, 'Temizlik & Hijyen'],
|
||||||
|
[/inşaat|yapım|onarım|tadilat|bakım|asfalt|beton|sulama|yol /i, 'İnşaat & Bakım'],
|
||||||
|
[/kırtasiye|matbaa|baskı|kağıt|toner/i, 'Kırtasiye & Büro'],
|
||||||
|
[/giyim|tekstil|elbise|üniforma|kıyafet/i, 'Tekstil & Giyim'],
|
||||||
|
[/araç|otomotiv|lastik|akaryakıt|yakıt|kiralama/i, 'Otomotiv & Yedek Parça'],
|
||||||
|
[/elektrik|trafo|enerji|aydınlatma|jeneratör|güneş/i, 'Elektrik & Enerji'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const INSTITUTION_RULES = [
|
||||||
|
[/büyükşehir belediye/i, 'Büyükşehir Belediyeleri'],
|
||||||
|
[/belediye/i, 'Belediyeler'],
|
||||||
|
[/üniversite/i, 'Üniversiteler'],
|
||||||
|
[/hastane|sağlık müdürlüğü|başhekim/i, 'Eğitim ve Araştırma Hastaneleri'],
|
||||||
|
[/ağız ve diş/i, 'Ağız ve Diş Sağlığı Merkezleri'],
|
||||||
|
[/ceza infaz|cezaevi|tevkifevi/i, 'Ceza İnfaz Kurumları'],
|
||||||
|
[/bakanlığı|bakanlık|valiliği|müdürlüğü/i, 'Bakanlıklar'],
|
||||||
|
[/a\.ş\.|genel müdürlüğü|işletme/i, 'Kamu Şirketleri'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function classify(rules, text, fallback) {
|
||||||
|
for (const [re, label] of rules) if (re.test(text)) return label;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCaseTr(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
return s
|
||||||
|
.toLocaleLowerCase('tr-TR')
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((w) => (w ? w[0].toLocaleUpperCase('tr-TR') + w.slice(1) : w))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilter(ad, key) {
|
||||||
|
const f = (ad.adTypeFilters || []).find((x) => x.key === key);
|
||||||
|
return f ? f.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTimeTr(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const diffMs = Date.now() - new Date(iso).getTime();
|
||||||
|
const mins = Math.floor(diffMs / 60000);
|
||||||
|
if (mins < 60) return `${Math.max(mins, 1)} dk önce eklendi`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours} saat önce eklendi`;
|
||||||
|
return `${Math.floor(hours / 24)} gün önce eklendi`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysUntil(ddmmyyyy) {
|
||||||
|
if (!ddmmyyyy) return null;
|
||||||
|
const [d, m, y] = ddmmyyyy.split('.').map(Number);
|
||||||
|
if (!d || !m || !y) return null;
|
||||||
|
return Math.ceil((new Date(y, m - 1, d) - new Date()) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ilan.gov.tr kaydını ihaleist tender modeline çevir
|
||||||
|
function mapAd(ad) {
|
||||||
|
const cityUpper = (ad.addressCityName || '').toLocaleUpperCase('tr-TR');
|
||||||
|
const sehir = titleCaseTr(ad.addressCityName || '');
|
||||||
|
const tarih = getFilter(ad, 'İhale ve Teklif Açma Tarihi') || '';
|
||||||
|
const ikn = getFilter(ad, 'İhale Kayıt No') || ad.adNo || '';
|
||||||
|
const usul = getFilter(ad, 'İhale Usulü') || 'Belirtilmemiş';
|
||||||
|
const ihaleTuru = getFilter(ad, 'İhale Türü') || '';
|
||||||
|
const searchBase = `${ad.title || ''} ${ad.advertiserName || ''}`;
|
||||||
|
const kalanGun = daysUntil(tarih);
|
||||||
|
|
||||||
|
let aciliyetBadge = null;
|
||||||
|
if (kalanGun !== null && kalanGun >= 0 && kalanGun <= 3) aciliyetBadge = '⚠️ SON 3 GÜN';
|
||||||
|
|
||||||
|
const publishedToday =
|
||||||
|
ad.publishStartDate && new Date(ad.publishStartDate).toDateString() === new Date().toDateString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `ilan-${ad.id}`,
|
||||||
|
kaynak: 'ilan.gov.tr',
|
||||||
|
kaynakUrl: `${ILAN_BASE}${ad.urlStr || ''}`,
|
||||||
|
ikn,
|
||||||
|
baslik: ad.title || '',
|
||||||
|
kurum: ad.advertiserName || '',
|
||||||
|
sehir,
|
||||||
|
bolge: CITY_TO_REGION[cityUpper] || '',
|
||||||
|
tarih,
|
||||||
|
saat: '',
|
||||||
|
alimTuru: ihaleTuru || 'İhale',
|
||||||
|
usul,
|
||||||
|
sektor: classify(SECTOR_RULES, searchBase, 'Genel'),
|
||||||
|
brans: ihaleTuru || 'Genel',
|
||||||
|
kurumTipi: classify(INSTITUTION_RULES, ad.advertiserName || '', 'Diğer Kamu Kurumları'),
|
||||||
|
eklenmeZamani: relativeTimeTr(ad.publishStartDate),
|
||||||
|
isYeni: Boolean(publishedToday),
|
||||||
|
isLiveFeed: true,
|
||||||
|
aciliyetBadge,
|
||||||
|
items: [],
|
||||||
|
aciklama: null, // detay çağrısıyla lazy doldurulur
|
||||||
|
yaklasikMaliyet: null,
|
||||||
|
teslimatSuresi: null,
|
||||||
|
teslimYeri: null,
|
||||||
|
yeterlikKriterleri: [],
|
||||||
|
aiMatchScore: null,
|
||||||
|
aiRiskScore: null,
|
||||||
|
aiBidRange: null,
|
||||||
|
kanbanStatus: 'incelenecek',
|
||||||
|
atananPersonel: null,
|
||||||
|
durum: 'Aktif',
|
||||||
|
incelendi: false,
|
||||||
|
takipte: false,
|
||||||
|
sozlesmeBedeli: null,
|
||||||
|
kazananFirma: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ilan.gov.tr istemcisi + cache
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function httpsJson(urlStr, { method = 'GET', body = null } = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const u = new URL(urlStr);
|
||||||
|
const payload = body ? JSON.stringify(body) : null;
|
||||||
|
const req = https.request(
|
||||||
|
{
|
||||||
|
hostname: u.hostname,
|
||||||
|
path: u.pathname + u.search,
|
||||||
|
method,
|
||||||
|
ca: CA_BUNDLE,
|
||||||
|
headers: {
|
||||||
|
...ILAN_HEADERS,
|
||||||
|
...(payload ? { 'content-length': Buffer.byteLength(payload) } : {}),
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (c) => chunks.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
const text = Buffer.concat(chunks).toString('utf8');
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
return reject(new Error(`ilan.gov.tr HTTP ${res.statusCode}: ${text.slice(0, 200)}`));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(text));
|
||||||
|
} catch {
|
||||||
|
reject(new Error('ilan.gov.tr geçersiz JSON döndürdü'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on('timeout', () => req.destroy(new Error('ilan.gov.tr zaman aşımı')));
|
||||||
|
req.on('error', reject);
|
||||||
|
if (payload) req.write(payload);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ilanSearch(keys, skipCount = 0, maxResultCount = LIST_SIZE) {
|
||||||
|
const data = await httpsJson(`${ILAN_BASE}/api/api/services/app/Ad/AdsByFilter`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { keys, skipCount, maxResultCount },
|
||||||
|
});
|
||||||
|
return data.result || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ilanDetail(id) {
|
||||||
|
const data = await httpsJson(
|
||||||
|
`${ILAN_BASE}/api/api/services/app/AdDetail/GetAdDetail?id=${encodeURIComponent(id)}`,
|
||||||
|
);
|
||||||
|
return data.result || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlToText(htmlStr) {
|
||||||
|
if (!htmlStr) return '';
|
||||||
|
return htmlStr
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||||
|
.replace(/<(?:br|\/tr|\/p|\/h\d|\/li)>/gi, '\n')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/[ \t]+/g, ' ')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = { tenders: null, fetchedAt: 0, totalCount: 0, todayCount: 0, detail: new Map() };
|
||||||
|
|
||||||
|
async function getTenders(force = false) {
|
||||||
|
const fresh = cache.tenders && Date.now() - cache.fetchedAt < CACHE_TTL_MS;
|
||||||
|
if (fresh && !force) return cache;
|
||||||
|
|
||||||
|
const allAds = [];
|
||||||
|
let numFound = 0;
|
||||||
|
for (let page = 0; page < MAX_PAGES; page++) {
|
||||||
|
const result = await ilanSearch({ ats: [3] }, page * LIST_SIZE, LIST_SIZE);
|
||||||
|
const ads = result.ads || [];
|
||||||
|
numFound = result.numFound || numFound;
|
||||||
|
if (ads.length === 0) break;
|
||||||
|
allAds.push(...ads);
|
||||||
|
if (allAds.length >= numFound) break;
|
||||||
|
}
|
||||||
|
const seen = new Set();
|
||||||
|
cache.tenders = allAds
|
||||||
|
.filter((ad) => (seen.has(ad.id) ? false : seen.add(ad.id)))
|
||||||
|
.map(mapAd);
|
||||||
|
cache.totalCount = numFound || allAds.length;
|
||||||
|
cache.todayCount = cache.tenders.filter((t) => t.isYeni).length;
|
||||||
|
cache.fetchedAt = Date.now();
|
||||||
|
console.log(`[ihaleist-api] ${cache.tenders.length} ihale yüklendi (toplam havuz: ${cache.totalCount})`);
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDetail(ilanId) {
|
||||||
|
if (cache.detail.has(ilanId)) return cache.detail.get(ilanId);
|
||||||
|
const raw = await ilanDetail(ilanId);
|
||||||
|
const text = htmlToText(raw.content || '');
|
||||||
|
const detail = { aciklama: text.slice(0, 4000), adNo: raw.adNo || null };
|
||||||
|
cache.detail.set(ilanId, detail);
|
||||||
|
if (cache.detail.size > 500) cache.detail.delete(cache.detail.keys().next().value);
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP sunucu
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function json(res, code, body) {
|
||||||
|
res.writeHead(code, {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||||
|
try {
|
||||||
|
if (url.pathname === '/api/tenders') {
|
||||||
|
const force = url.searchParams.get('refresh') === '1';
|
||||||
|
const c = await getTenders(force);
|
||||||
|
// Doğrudan temin (EKAP worker) verisini varsa birleştir
|
||||||
|
let dt = { tenders: [], totalCount: 0, available: false };
|
||||||
|
try {
|
||||||
|
dt = readDtTenders(300);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ihaleist-api] DT okuma hatası:', e.message);
|
||||||
|
}
|
||||||
|
const sources = ['ilan.gov.tr (Basın İlan Kurumu) — resmi ihale ilanları'];
|
||||||
|
if (dt.available) sources.push('EKAP Doğrudan Temin (22/d) worker');
|
||||||
|
return json(res, 200, {
|
||||||
|
success: true,
|
||||||
|
source: sources.join(' + '),
|
||||||
|
fetchedAt: new Date(c.fetchedAt).toLocaleString('tr-TR'),
|
||||||
|
totalCount: c.totalCount + dt.totalCount,
|
||||||
|
todayCount: c.todayCount,
|
||||||
|
dtCount: dt.totalCount,
|
||||||
|
tenders: [...dt.tenders, ...c.tenders],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/dt') {
|
||||||
|
let dt = { tenders: [], totalCount: 0, available: false };
|
||||||
|
try {
|
||||||
|
dt = readDtTenders(Number(url.searchParams.get('limit')) || 300);
|
||||||
|
} catch (e) {
|
||||||
|
return json(res, 200, { success: true, available: false, error: e.message, tenders: [] });
|
||||||
|
}
|
||||||
|
return json(res, 200, {
|
||||||
|
success: true,
|
||||||
|
available: dt.available,
|
||||||
|
source: 'EKAP Doğrudan Temin (22/d)',
|
||||||
|
totalCount: dt.totalCount,
|
||||||
|
tenders: dt.tenders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailMatch = url.pathname.match(/^\/api\/tenders\/ilan-(\d+)\/detail$/);
|
||||||
|
if (detailMatch) {
|
||||||
|
const detail = await getDetail(detailMatch[1]);
|
||||||
|
return json(res, 200, { success: true, ...detail });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/stats') {
|
||||||
|
const c = await getTenders();
|
||||||
|
return json(res, 200, {
|
||||||
|
success: true,
|
||||||
|
toplamIlan: c.totalCount,
|
||||||
|
bugunEklenen: c.todayCount,
|
||||||
|
yuklenenIlan: c.tenders.length,
|
||||||
|
kaynak: 'ilan.gov.tr',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/health') return json(res, 200, { ok: true });
|
||||||
|
|
||||||
|
return json(res, 404, { success: false, error: 'not found' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ihaleist-api] hata:', err.message);
|
||||||
|
return json(res, 502, { success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`[ihaleist-api] http://localhost:${PORT} — canlı veri: ilan.gov.tr`);
|
||||||
|
getTenders().catch((e) => console.error('[ihaleist-api] ilk yükleme hatası:', e.message));
|
||||||
|
});
|
||||||
@@ -91,10 +91,17 @@ export const TenderCard = ({ tender }) => {
|
|||||||
<span>{tender.aciliyetBadge}</span>
|
<span>{tender.aciliyetBadge}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{tender.aiMatchScore != null && (
|
||||||
<span className="bg-blue-50 text-blue-700 border border-blue-200 text-[11px] font-bold px-2 py-1 rounded-md flex items-center gap-1">
|
<span className="bg-blue-50 text-blue-700 border border-blue-200 text-[11px] font-bold px-2 py-1 rounded-md flex items-center gap-1">
|
||||||
<Sparkles className="w-3 h-3 text-blue-600" />
|
<Sparkles className="w-3 h-3 text-blue-600" />
|
||||||
<span>%{tender.aiMatchScore} Uyumlu</span>
|
<span>%{tender.aiMatchScore} Uyumlu</span>
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
{tender.kaynak && (
|
||||||
|
<span className="bg-emerald-50 text-emerald-700 border border-emerald-200 text-[11px] font-bold px-2 py-1 rounded-md">
|
||||||
|
🟢 {tender.kaynak}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Material Items Preview Box */}
|
{/* Material Items Preview Box */}
|
||||||
|
|||||||
@@ -433,22 +433,26 @@ END:VCALENDAR`;
|
|||||||
<p className="text-xs text-blue-200">Teknik şartname ve geçmiş ihale sonuçları analiz edildi.</p>
|
<p className="text-xs text-blue-200">Teknik şartname ve geçmiş ihale sonuçları analiz edildi.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{tender.aiMatchScore != null && (
|
||||||
<span className="bg-amber-400 text-slate-900 font-black text-xs px-3 py-1.5 rounded-xl shadow-xs">
|
<span className="bg-amber-400 text-slate-900 font-black text-xs px-3 py-1.5 rounded-xl shadow-xs">
|
||||||
%{tender.aiMatchScore} Şirket Uyumu
|
%{tender.aiMatchScore} Şirket Uyumu
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||||
<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">Risk Skoru</span>
|
<span className="text-slate-300 text-xs font-semibold block">Risk Skoru</span>
|
||||||
<span className="text-lg font-black text-emerald-400 flex items-center gap-1">
|
<span className="text-lg font-black text-emerald-400 flex items-center gap-1">
|
||||||
<ShieldCheck className="w-5 h-5" /> {tender.aiRiskScore} Risk
|
<ShieldCheck className="w-5 h-5" /> {tender.aiRiskScore || 'Analiz Bekliyor'}{tender.aiRiskScore ? ' Risk' : ''}
|
||||||
</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">Önerilen Teklif Aralığı</span>
|
<span className="text-slate-300 text-xs font-semibold block">Önerilen Teklif Aralığı</span>
|
||||||
<span className="text-base font-black text-amber-300">
|
<span className="text-base font-black text-amber-300">
|
||||||
₺{tender.aiBidRange?.recommended.toLocaleString('tr-TR')}
|
{tender.aiBidRange?.recommended != null
|
||||||
|
? `₺${tender.aiBidRange.recommended.toLocaleString('tr-TR')}`
|
||||||
|
: '—'}
|
||||||
</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">
|
||||||
|
|||||||
+44
-53
@@ -1,6 +1,7 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
import { MOCK_TENDERS, MOCK_LIVE_STATS, MOCK_COMPANY_STATS } from '../data/mockTenders';
|
import { MOCK_TENDERS, MOCK_LIVE_STATS, MOCK_COMPANY_STATS } from '../data/mockTenders';
|
||||||
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
|
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
|
||||||
|
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
|
||||||
|
|
||||||
const AppContext = createContext();
|
const AppContext = createContext();
|
||||||
|
|
||||||
@@ -30,68 +31,56 @@ export const AppProvider = ({ children }) => {
|
|||||||
|
|
||||||
const [isLiveMode, setIsLiveMode] = useState(true);
|
const [isLiveMode, setIsLiveMode] = useState(true);
|
||||||
const [isRefreshingLive, setIsRefreshingLive] = useState(false);
|
const [isRefreshingLive, setIsRefreshingLive] = useState(false);
|
||||||
|
const [liveDataError, setLiveDataError] = useState(null);
|
||||||
|
const [liveDataSource, setLiveDataSource] = useState(null);
|
||||||
|
|
||||||
const refreshLiveEKAPData = () => {
|
// Canlı ihale verisini (ilan.gov.tr) çeker; kullanıcının kanban/takip durumunu korur.
|
||||||
|
const refreshLiveEKAPData = async ({ refresh = true } = {}) => {
|
||||||
setIsRefreshingLive(true);
|
setIsRefreshingLive(true);
|
||||||
setTimeout(() => {
|
setLiveDataError(null);
|
||||||
const liveItems = [
|
try {
|
||||||
{
|
const data = await fetchLiveTenders({ refresh });
|
||||||
id: `live-ekap-${Date.now()}`,
|
setTenders(prev => mergeUserState(data.tenders, prev));
|
||||||
ikn: `2026/${Math.floor(100000 + Math.random() * 900000)}`,
|
setLiveDataSource({ source: data.source, fetchedAt: data.fetchedAt });
|
||||||
baslik: "T.C. SAĞLIK BAKANLIĞI ANKARA ŞEHİR HASTANESİ 18 KALEM TIBBİ SARF MALZEME ALIMI",
|
|
||||||
kurum: "T.C. SAĞLIK BAKANLIĞI ANKARA ŞEHİR HASTANESİ BAŞHEKİMLİĞİ",
|
|
||||||
sehir: "Ankara",
|
|
||||||
bolge: "İÇ ANADOLU BÖLGESİ",
|
|
||||||
tarih: "14.08.2026",
|
|
||||||
saat: "10:30",
|
|
||||||
alimTuru: "Açık İhale",
|
|
||||||
usul: "Açık İhale Usulü (4734 Sayılı Kanun Madde 19)",
|
|
||||||
sektor: "Tıbbi Cihaz ve Sarf Malzemeleri",
|
|
||||||
brans: "Ortopedi & Beyin Cerrahi Sarf Malzemeleri",
|
|
||||||
kurumTipi: "Eğitim ve Araştırma Hastaneleri",
|
|
||||||
eklenmeZamani: " Canlı Akış (Canlı Yayınlandı)",
|
|
||||||
isYeni: true,
|
|
||||||
isLiveFeed: true,
|
|
||||||
aciliyetBadge: "🔴 CANLI İHALE",
|
|
||||||
items: [
|
|
||||||
{ id: "li1", ad: "Titanyum Pedikül Vidası", miktar: "150", birim: "adet", birimFiyat: 1420 },
|
|
||||||
{ id: "li2", ad: "Cerrahi Rod (5.5mm)", miktar: "80", birim: "adet", birimFiyat: 680 },
|
|
||||||
{ id: "li3", ad: "Posterior Servikal Kafes", miktar: "40", birim: "adet", birimFiyat: 2450 }
|
|
||||||
],
|
|
||||||
aciklama: "T.C. Sağlık Bakanlığı Ankara Şehir Hastanesi Beyin ve Sinir Cerrahisi Kliniği İhtiyacı İçin Tıbbi Sarf Malzeme Canlı Veri Akışıyla Alınacaktır.",
|
|
||||||
yaklasikMaliyet: 542000,
|
|
||||||
teslimatSuresi: "15 Takvim Günü",
|
|
||||||
teslimYeri: "Ankara Bilkent Şehir Hastanesi Medikal Depo",
|
|
||||||
yeterlikKriterleri: ["ÜTS Barkod Kaydı", "ISO 13485 Kalite Belgesi", "TSE HYB Satış Yetkisi"],
|
|
||||||
aiMatchScore: 98,
|
|
||||||
aiRiskScore: "Düşük",
|
|
||||||
aiBidRange: { min: 435000, max: 510000, recommended: 468000 },
|
|
||||||
kanbanStatus: "incelenecek",
|
|
||||||
atananPersonel: "Hikmet Tanrıverdi",
|
|
||||||
durum: "Aktif",
|
|
||||||
incelendi: false,
|
|
||||||
takipte: true,
|
|
||||||
sozlesmeBedeli: null,
|
|
||||||
kazananFirma: null
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
setTenders(prev => {
|
|
||||||
const filtered = prev.filter(t => !t.id.startsWith('live-ekap-'));
|
|
||||||
return [...liveItems, ...filtered];
|
|
||||||
});
|
|
||||||
|
|
||||||
setLiveStats(prev => ({
|
setLiveStats(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
todayAdded: prev.todayAdded + 1,
|
bugunEklenen: data.todayCount ?? prev.bugunEklenen,
|
||||||
totalItems: prev.totalItems + 1
|
aktifTakip: data.totalCount ?? prev.aktifTakip
|
||||||
}));
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
setLiveDataError(err.message);
|
||||||
|
} finally {
|
||||||
setIsRefreshingLive(false);
|
setIsRefreshingLive(false);
|
||||||
}, 1000);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Açılışta canlı veriyi yükle (başarısız olursa mevcut/mock veri ekranda kalır)
|
||||||
|
useEffect(() => {
|
||||||
|
refreshLiveEKAPData({ refresh: false });
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [activeTenderDetail, setActiveTenderDetail] = useState(null);
|
const [activeTenderDetail, setActiveTenderDetail] = useState(null);
|
||||||
|
|
||||||
|
// Canlı ihalede detay açıldığında ilan metnini API'den tamamla
|
||||||
|
useEffect(() => {
|
||||||
|
const t = activeTenderDetail;
|
||||||
|
if (!t || !t.id?.startsWith('ilan-') || t.aciklama) return;
|
||||||
|
let cancelled = false;
|
||||||
|
fetchTenderDetail(t.id)
|
||||||
|
.then(detail => {
|
||||||
|
if (cancelled || !detail.aciklama) return;
|
||||||
|
setActiveTenderDetail(prev =>
|
||||||
|
prev && prev.id === t.id ? { ...prev, aciklama: detail.aciklama } : prev
|
||||||
|
);
|
||||||
|
setTenders(prev =>
|
||||||
|
prev.map(x => (x.id === t.id ? { ...x, aciklama: detail.aciklama } : x))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeTenderDetail?.id]);
|
||||||
const [isDetailedSearchOpen, setIsDetailedSearchOpen] = useState(false);
|
const [isDetailedSearchOpen, setIsDetailedSearchOpen] = useState(false);
|
||||||
const [isCompanyScannerOpen, setIsCompanyScannerOpen] = useState(false);
|
const [isCompanyScannerOpen, setIsCompanyScannerOpen] = useState(false);
|
||||||
const [isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen] = useState(false);
|
const [isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen] = useState(false);
|
||||||
@@ -317,6 +306,8 @@ export const AppProvider = ({ children }) => {
|
|||||||
setIsLiveMode,
|
setIsLiveMode,
|
||||||
isRefreshingLive,
|
isRefreshingLive,
|
||||||
refreshLiveEKAPData,
|
refreshLiveEKAPData,
|
||||||
|
liveDataError,
|
||||||
|
liveDataSource,
|
||||||
liveStats,
|
liveStats,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* ihaleist canlı veri istemcisi.
|
||||||
|
* Backend proxy (server/index.js) üzerinden ilan.gov.tr resmi ihale ilanlarını çeker.
|
||||||
|
* Dev'de Vite proxy'si /api isteklerini localhost:3001'e yönlendirir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function fetchLiveTenders({ refresh = false } = {}) {
|
||||||
|
const res = await fetch(`/api/tenders${refresh ? '?refresh=1' : ''}`);
|
||||||
|
if (!res.ok) throw new Error(`API hatası: HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error(data.error || 'API başarısız yanıt döndü');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenderDetail(tenderId) {
|
||||||
|
const res = await fetch(`/api/tenders/${tenderId}/detail`);
|
||||||
|
if (!res.ok) throw new Error(`Detay API hatası: HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error(data.error || 'Detay alınamadı');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kullanıcının ihale üzerindeki durumunu (kanban, takip vb.) canlı veriyle birleştirir.
|
||||||
|
const USER_FIELDS = ['kanbanStatus', 'takipte', 'incelendi', 'atananPersonel'];
|
||||||
|
|
||||||
|
export function mergeUserState(liveTenders, previousTenders) {
|
||||||
|
const prevById = new Map((previousTenders || []).map((t) => [t.id, t]));
|
||||||
|
return liveTenders.map((tender) => {
|
||||||
|
const prev = prevById.get(tender.id);
|
||||||
|
if (!prev) return tender;
|
||||||
|
const merged = { ...tender };
|
||||||
|
for (const f of USER_FIELDS) {
|
||||||
|
if (prev[f] !== undefined && prev[f] !== null && prev[f] !== false) merged[f] = prev[f];
|
||||||
|
}
|
||||||
|
if (prev.aciklama && !merged.aciklama) merged.aciklama = prev.aciklama;
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
}
|
||||||
+7
-1
@@ -6,6 +6,12 @@ export default defineConfig({
|
|||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
host: true
|
host: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user