/** * 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', 1500); // Detaylar PARALEL çekilir; asıl darboğaz istek başına bekleme idi. const DETAY_PARALEL = Number(process.env.DT_PARALEL || argVal('--paralel', 10)); const DETAY_GECIKME_MS = Number(process.env.DT_GECIKME_MS || 400); 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 — paralel + adaptif fren // EKAP'ı zorlamamak için: her turda DETAY_PARALEL istek eş zamanlı, // aralarda kısa duraklama. Sunucu direnç gösterirse (challenge/hata) // gecikme kendiliğinden büyür, temiz turlarda tekrar daralır. const pending = db .prepare( // EN YENİ kayıtlar önce: arayüz en yeni 5000'i gösteriyor; varsayılan // (rowid) sırada en eskiler çekildiği için gösterilen hiçbir kayda // detay ulaşmıyordu. `SELECT dogrudan_temin_id, idare_id FROM dt_tenders WHERE detail_json IS NULL AND idare_id != '' ORDER BY rowid DESC LIMIT ?`, ) .all(MAX_DETAILS); console.log(`[dt-worker] ${pending.length} detay çekilecek (${DETAY_PARALEL} paralel)`); let ok = 0; let hata = 0; let gecikme = DETAY_GECIKME_MS; const basladi = Date.now(); for (let i = 0; i < pending.length; i += DETAY_PARALEL) { const grup = pending.slice(i, i + DETAY_PARALEL); const sonuc = await Promise.all( grup.map(async (row) => { try { const detail = await fetchDetail(sess, row.dogrudan_temin_id, row.idare_id); setDetail.run(JSON.stringify(detail), row.dogrudan_temin_id); return true; } catch (err) { console.warn(`[dt-worker] detay hatası (${row.dogrudan_temin_id}): ${err.message}`); return false; } }), ); const grupHata = sonuc.filter((x) => !x).length; ok += sonuc.length - grupHata; hata += grupHata; // Adaptif fren: hata varsa yavaşla, temizse hızlan (2.5 sn ile 250 ms arası) gecikme = grupHata ? Math.min(2500, Math.round(gecikme * 2)) : Math.max(250, Math.round(gecikme * 0.9)); if (i && i % (DETAY_PARALEL * 20) === 0) { const hiz = (ok / ((Date.now() - basladi) / 1000)).toFixed(1); console.log(`[dt-worker] ${ok}/${pending.length} detay (${hiz} kayıt/sn, gecikme ${gecikme}ms, hata ${hata})`); } if (i + DETAY_PARALEL < pending.length) await sleep(gecikme + Math.floor(Math.random() * 200)); } console.log(`[dt-worker] detay turu: ${ok} başarılı, ${hata} hata, ${Math.round((Date.now() - basladi) / 1000)} sn`); 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); });