Files
ihaleist/server/scheduler.mjs
T
hikmettanandClaude Fable 5.1 df89eaac33 feat: takip izleme (zeyilname + sonuc bildirimi), hazirlik gorevi, belge sure uyarisi, Telegram, OCR, yasakli sorgu
28 depo taramasindan alinan alti ozellik. Kod kopyalanmadi (depolar
lisanssiz), fikirler uygulandi.

1) Zeyilname / duzeltme ilani takibi (server/zeyilname.mjs): takipteki
   IKN'ler sunucuya yazilir; 3 saatte bir EKAP detayindan ilan sayisi ve
   ihale tarihi karsilastirilir, degisince "ne degisti" bildirimi (uygulama
   ici + e-posta + Telegram). Test: anlik kaydi eskitilince tur "yeni ilan
   yayimlandi; ihale tarihi 01.01.2026 → 08.10.2026" bildirimi uretti.
2) Sonuc bildirimi: takipteki ihale sonuclaninca "X firmasi Y'ye aldi, %Z
   kirim". Yalniz takiptekiler; kismi ihalede oran yazilmaz.
3) Ihaleden goreve (MOBIT-DURT): detayda "Hazirlik Gorevi Ac" → teslim
   tarihi ihale saati, pano "Teklif Hazirlaniyor", derin analizden gelen
   belge kontrol listesi gorevde; Kanban kartinda geri sayim rozeti.
4) Sirket belgesi sure takibi: Belge Kasasi'nda son kullanma tarihi girisi,
   dolmus / 30 gun icinde dolacak uyarisi; girise gunde bir bildirim.
   PATCH /api/me/documents/meta.
5) Telegram (server/telegram.mjs): TELEGRAM_BOT_TOKEN sunucuda; kullanici
   6 haneli kodu bota yazar, "Dogrula" → getUpdates ile chat id baglanir.
   Arayuzde adim adim kurulum rehberi (BotFather → env → Redeploy → kod).
   Ihale ozeti, zeyilname ve sonuc bildirimleri Telegram'a da gider.
6) OCR (tesseract.js, tur): PDF metin katmani bossa (taranmis) ilk 8 sayfa
   OCR. Olcum: sentetik cetvelde guven 87, ~0,3 sn/sayfa; IKN ve kalemler
   dogru okundu. Kalem havuzu ve derin analiz artik taranmis PDF'ten de
   besleniyor.
7) Rakip Radari'nda KIK yasakli sorgusu baglantisi (firma adi panoya
   kopyalanir; EKAP sorguyu API'den vermiyor).

Sinir deger hesaplayici KURULMADI: Kamu Ihale Genel Tebligi'ne ulasilamadi
(mevzuat.gov.tr / ihale.gov.tr bu makineden erisilemiyor); formul kaynaksiz
yazilmadi.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:58:13 +03:00

203 lines
8.2 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.
/**
* Sunucu içi zamanlayıcı — geçmiş sonuç ilanlarını periyodik olarak biriktirir.
* Container'da ayrı cron kurmaya gerek kalmaz; server/index.js başlatır.
*
* Env:
* SCAN_ENABLED=0 → kapat (varsayılan açık)
* SCAN_INTERVAL_SAAT=6 → tarama sıklığı (saat)
* SCAN_GUN=3 → her turda geriye kaç günü tara
* SCAN_LIMIT=300 → tur başına maksimum ihale
*/
import { spawn } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { historyStats } from './history-store.mjs';
import { bildirimleriIsle } from './notifier.mjs';
import { zeyilnameTara, sonucTara } from './zeyilname.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
let calisiyor = false;
// Arşiv boş/küçükse geniş geçmiş dolumu, doluysa artımlı günlük tarama.
function tarataParams() {
const s = historyStats();
const mevcut = s.available ? (s.toplam || 0) : 0;
const esik = Number(process.env.SCAN_BACKFILL_ESIK || 25000);
if (mevcut < esik) {
return {
gun: process.env.SCAN_BACKFILL_GUN || '365',
limit: process.env.SCAN_BACKFILL_LIMIT || '1500',
tip: `geçmiş dolumu (arşivde ${mevcut} kayıt)`,
};
}
return {
gun: process.env.SCAN_GUN || '3',
limit: process.env.SCAN_LIMIT || '300',
tip: `artımlı güncelleme (arşivde ${mevcut} kayıt)`,
};
}
function tara() {
if (calisiyor) {
console.log('[scheduler] önceki tarama sürüyor, bu tur atlandı');
return;
}
calisiyor = true;
const { gun, limit, tip } = tarataParams();
console.log(`[scheduler] EKAP sonuç taraması — ${tip}: son ${gun} gün, limit ${limit}`);
const p = spawn(process.execPath, [path.join(__dirname, 'ekap-scan.mjs'), '--gun', gun, '--limit', limit], {
stdio: ['ignore', 'pipe', 'pipe'],
});
p.stdout.on('data', (d) => process.stdout.write(String(d)));
p.stderr.on('data', (d) => process.stderr.write(String(d)));
p.on('close', (code) => {
calisiyor = false;
console.log(`[scheduler] tarama bitti (çıkış kodu ${code})`);
// Taramanın ardından eksik OKAS kalemlerini doldur (fiyat analizi için)
if (code === 0 && process.env.KALEM_DOLDUR !== '0') kalemDoldur();
});
p.on('error', (e) => {
calisiyor = false;
console.warn('[scheduler] tarama başlatılamadı:', e.message);
});
}
// Eksik OKAS kalemlerini arka planda doldurur (kategori bazlı fiyat analizi)
function kalemDoldur() {
const n = process.env.KALEM_DOLDUR_LIMIT || '150';
const p = spawn(process.execPath, [path.join(__dirname, 'ekap-scan.mjs'), '--kalem-doldur', n], {
stdio: ['ignore', 'pipe', 'pipe'],
});
p.stdout.on('data', (d) => process.stdout.write(String(d)));
p.on('error', (e) => console.warn('[scheduler] kalem doldurma başlatılamadı:', e.message));
}
// --- Doğrudan temin (Playwright/Chromium gerektirir) ---
let dtCalisiyor = false;
let dtSonTur = null; // { baslangic, bitis, kod, hata }
let dtSonrakiMs = null;
// Chromium ~350MB RSS kullanabilir; sunucu darsa DT'yi atla ve uyar.
function bellekYeterliMi() {
const bosMB = Math.round(os.freemem() / 1024 / 1024);
const esik = Number(process.env.DT_MIN_BOS_MB || 600);
if (bosMB < esik) {
console.warn(`[scheduler] ⚠ Sunucu belleği dar (${bosMB}MB boş < ${esik}MB) — doğrudan temin taraması bu tur atlandı. Kalıcı sorun olursa DT_ENABLED=0 ile kapatın.`);
return false;
}
return true;
}
function dtTara() {
if (process.env.DT_ENABLED === '0') return;
if (dtCalisiyor) {
console.log('[scheduler] önceki DT taraması sürüyor, bu tur atlandı');
return;
}
if (!bellekYeterliMi()) return;
dtCalisiyor = true;
dtSonTur = { baslangic: new Date().toISOString() };
const pages = process.env.DT_PAGES || '5';
// 30 detay/4 saat ile 10 bin kayıt 55 günde dolar; sektör sınıflandırması
// detaya bağlı olduğu için bütçe yükseltildi.
const details = process.env.DT_DETAILS || '250';
console.log(`[scheduler] doğrudan temin taraması başlıyor (${pages} sayfa, ${details} detay)`);
const p = spawn(process.execPath, [path.join(__dirname, 'dt-worker.mjs'), '--pages', pages, '--details', details], {
stdio: ['ignore', 'pipe', 'pipe'],
});
p.stdout.on('data', (d) => process.stdout.write(String(d)));
p.stderr.on('data', (d) => process.stderr.write(String(d)));
p.on('close', (code) => {
dtCalisiyor = false;
dtSonTur = { ...(dtSonTur || {}), bitis: new Date().toISOString(), kod: code };
if (code !== 0) console.warn(`[scheduler] ⚠ DT taraması ${code} koduyla bitti — Chromium kurulu mu / bellek yeterli mi kontrol edin.`);
else console.log('[scheduler] DT taraması bitti');
});
p.on('error', (e) => {
dtCalisiyor = false;
dtSonTur = { ...(dtSonTur || {}), bitis: new Date().toISOString(), hata: e.message };
console.warn('[scheduler] ⚠ DT worker başlatılamadı:', e.message, '— Chromium yoksa DT_ENABLED=0 yapın.');
});
}
export function schedulerStatus() {
const bosMB = Math.round(os.freemem() / 1024 / 1024);
return {
sonucTaramasi: { aktif: process.env.SCAN_ENABLED !== '0', calisiyor: calisiyor, aralikSaat: Number(process.env.SCAN_INTERVAL_SAAT || 6) },
dogrudanTemin: {
aktif: process.env.DT_ENABLED !== '0',
calisiyor: dtCalisiyor,
aralikSaat: Number(process.env.DT_INTERVAL_SAAT || 4),
sonTur: dtSonTur,
bellekEsigiMB: Number(process.env.DT_MIN_BOS_MB || 600),
},
bosBellekMB: bosMB,
toplamBellekMB: Math.round(os.totalmem() / 1024 / 1024),
};
}
// --- Otomatik e-posta bildirimi ---
let bildirimCalisiyor = false;
async function bildirimTuru(tenderGetir) {
if (bildirimCalisiyor) return;
bildirimCalisiyor = true;
try {
const s = await bildirimleriIsle(tenderGetir);
if (s.atlandi) console.log(`[scheduler] bildirim atlandı: ${s.atlandi}`);
else console.log(`[scheduler] bildirim turu: ${s.abonelik} abonelik, ${s.gonderilen || 0} e-posta, ${s.hata || 0} hata`);
} catch (e) {
console.warn('[scheduler] bildirim hatası:', e.message);
} finally {
bildirimCalisiyor = false;
}
}
export function startScheduler({ tenderGetir } = {}) {
if (process.env.SCAN_ENABLED === '0') {
console.log('[scheduler] devre dışı (SCAN_ENABLED=0)');
return;
}
const saat = Number(process.env.SCAN_INTERVAL_SAAT || 6);
const ms = Math.max(1, saat) * 3600 * 1000;
console.log(`[scheduler] geçmiş sonuç taraması her ${saat} saatte bir çalışacak`);
// Açılıştan 90 sn sonra ilk tur (sunucu ısınsın), sonra periyodik
setTimeout(tara, 90 * 1000).unref?.();
setInterval(tara, ms).unref?.();
// Doğrudan temin: sonuç taramasıyla çakışmasın diye kaydırmalı
if (process.env.DT_ENABLED === '0') {
console.log('[scheduler] doğrudan temin devre dışı (DT_ENABLED=0)');
} else {
const dtSaat = Number(process.env.DT_INTERVAL_SAAT || 4);
console.log(`[scheduler] doğrudan temin taraması her ${dtSaat} saatte bir çalışacak`);
setTimeout(dtTara, 4 * 60 * 1000).unref?.();
setInterval(dtTara, Math.max(1, dtSaat) * 3600 * 1000).unref?.();
}
// Takipteki ihaleler: zeyilname/düzeltme ilanı + sonuç bildirimi
if (process.env.TAKIP_ENABLED === '0') {
console.log('[scheduler] takip izleme devre dışı (TAKIP_ENABLED=0)');
} else {
const tSaat = Number(process.env.TAKIP_INTERVAL_SAAT || 3);
const takipTuru = () => Promise.all([zeyilnameTara(), sonucTara()]).catch((e) => console.warn('[scheduler] takip:', e.message));
console.log(`[scheduler] takip izleme (zeyilname/sonuç) her ${tSaat} saatte bir`);
setTimeout(takipTuru, 8 * 60 * 1000).unref?.();
setInterval(takipTuru, Math.max(1, tSaat) * 3600 * 1000).unref?.();
}
// Otomatik e-posta bildirimi
if (process.env.NOTIFY_ENABLED === '0' || !tenderGetir) {
console.log('[scheduler] otomatik e-posta bildirimi devre dışı');
} else {
const nSaat = Number(process.env.NOTIFY_INTERVAL_SAAT || 6);
console.log(`[scheduler] otomatik e-posta bildirimi her ${nSaat} saatte bir kontrol edilecek`);
setTimeout(() => bildirimTuru(tenderGetir), 6 * 60 * 1000).unref?.();
setInterval(() => bildirimTuru(tenderGetir), Math.max(1, nSaat) * 3600 * 1000).unref?.();
}
}