Files
hikmettanandClaude Fable 5.1 48f0c48732 feat: İhaleAB'den taşınan özellikler — MCP sunucusu, RSS, haftalık brief, teklif asistanı, yeterlilik rozeti, 5 yeni kırmızı bayrak
- /mcp (JSON-RPC, ih_live_ anahtarı): search_tenders, get_tender, search_results, search_items, upcoming_deadlines
- /api/feeds/tenders.rss (anahtarlı) ve /api/feeds/sonuc.rss (açık)
- Haftalık brief (Pazartesi 08:00, Telegram + e-posta), /api/me/brief tercihi + önizleme, sağ panelde yaklaşan ihale tarihleri
- Detayda Teklif Asistanı sekmesi: belgeye sor (atıflı), taslak denetimi, taslak üretimi (analyzeSpec zinciri)
- Derin analiz belge listesi × Doküman Deposu eşleştirme rozeti
- Kırmızı bayrak: kısa süre, hafta sonu tarih, geç sözleşme, kazananı gizli, DT parçalama şüphesi
- Kullanım kılavuzu güncellendi

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 02:23:41 +03:00

207 lines
8.3 KiB
JavaScript
Raw Permalink 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';
import { briefZamanlayici } from './brief.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?.();
}
// Haftalık brief (Pazartesi 08:00): takipli ihalelerin tarihleri + sonuçlar
if (tenderGetir) briefZamanlayici(tenderGetir);
// 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?.();
}
}