Files
ihaleist/server/dt-worker.mjs
T

333 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});