333 lines
13 KiB
JavaScript
333 lines
13 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|