import cron from 'node-cron'; import Setting from '../models/Setting.js'; import { runMatchingPipeline } from './pipeline.js'; import { cleanupOldProjects } from './cleanup.js'; let currentJob = null; let cleanupJob = null; let currentIntervalMinutes = null; /** * Startet den Hintergrund-Crawler und plant sich bei geaenderten Intervallen * selbst neu. */ export const startScheduler = async () => { try { const intervalStr = await Setting.getVal('search_interval', '60'); const intervalMinutes = parseInt(intervalStr, 10) || 60; currentIntervalMinutes = intervalMinutes; console.log('[Scheduler] Cron-Scheduler wird eingerichtet.'); console.log(`[Scheduler] Suchintervall: ${intervalMinutes} Minuten.`); // If a job is already running, stop it if (currentJob) { currentJob.stop(); console.log('[Scheduler] Vorherigen Cron-Job gestoppt.'); } /* * Der Zeitpunkt des letzten Laufs steht in der Datenbank (`last_scan_at`, * geschrieben von der Pipeline). Damit ueberlebt der Zeitplan einen Neustart: * ohne diesen Wert begann nach jedem Deployment wieder eine Sperrfrist von * vorn - zwei Deployments im Abstand von zehn Minuten haben den faelligen * Lauf zweimal um eine halbe Stunde verschoben, er fand also nie statt. */ const storedLast = await Setting.getVal('last_scan_at', null); const parsedLast = storedLast ? new Date(storedLast) : null; let lastRunTime = parsedLast && !Number.isNaN(parsedLast.getTime()) ? parsedLast : null; /* * Zeitversatz zum parallel laufenden alten Scanner - aber nur beim allerersten * Start dieser Instanz. Danach traegt der gespeicherte Zeitstempel den * Zeitplan, und der laeuft von sich aus versetzt zum alten Scanner weiter. * * Als eigene Frist umgesetzt und nicht ueber einen vorgealterten * `lastRunTime`: ein Zeitstempel kann einen Lauf nur vorziehen, nie * verzoegern - bei Intervall 15 und Versatz 30 waere er sofort faellig. */ const offsetMinutes = Number(process.env.SCANNER_SCHEDULER_OFFSET ?? 30); const useOffset = Number.isFinite(offsetMinutes) && offsetMinutes > 0 && lastRunTime === null; const notBefore = useOffset ? Date.now() + offsetMinutes * 60 * 1000 : 0; if (useOffset) { console.log( `[Scheduler] Erster Lauf in ${offsetMinutes} Minuten (Versatz zum alten Scanner).` ); } else if (lastRunTime) { const dueAt = new Date(lastRunTime.getTime() + intervalMinutes * 60 * 1000); const minutes = Math.max(0, Math.round((dueAt.getTime() - Date.now()) / 60000)); console.log( `[Scheduler] Letzter Lauf ${lastRunTime.toLocaleString('de-DE')}, ` + `naechster in ${minutes} Minuten.` ); } // We check every minute if we are due for a run. // This allows exact dynamic changes without having to manually restart the node process. currentJob = cron.schedule('* * * * *', async () => { // Master on/off switch (toggled from Settings UI). When off, the // background scanner stays idle but the backend/UI keep running. const scanningActive = await Setting.getVal('scanning_active', 'true'); if (scanningActive === 'false') { return; } const activeIntervalStr = await Setting.getVal('search_interval', '60'); const activeIntervalMinutes = parseInt(activeIntervalStr, 10) || 60; // Geaendertes Intervall im Betrieb uebernehmen if (activeIntervalMinutes !== currentIntervalMinutes) { console.log(`[Scheduler] Intervall von ${currentIntervalMinutes} auf ${activeIntervalMinutes} Minuten geaendert - Neuplanung.`); startScheduler(); return; } const now = new Date(); // Sperrfrist nach dem Start (Versatz zum parallel laufenden Scanner) if (notBefore && now.getTime() < notBefore) { return; } // Weekday gate: skip Saturday (6) and Sunday (0). Scraper runs Mon-Fri only. const day = now.getDay(); if (day === 0 || day === 6) { return; } // Time-of-day gate: only scan between 07:00 and 21:59 server-local time // (Europe/Berlin). getHours() returns local hour on the host. const hour = now.getHours(); if (hour < 7 || hour >= 22) { return; } /* * Auch den gespeicherten Zeitpunkt beruecksichtigen: ein manueller Scan aus * der Oberflaeche schreibt ihn, weiss aber nichts von der Variablen hier. * Ohne diesen Abgleich koennte der Scheduler direkt hinter einem manuellen * Lauf noch einmal losziehen. */ const stored = await Setting.getVal('last_scan_at', null); const storedTime = stored ? new Date(stored).getTime() : 0; if (Number.isFinite(storedTime) && storedTime > (lastRunTime?.getTime() ?? 0)) { lastRunTime = new Date(storedTime); } // Run if never run before, or if enough time has passed const isDue = !lastRunTime || (now - lastRunTime) >= activeIntervalMinutes * 60 * 1000; if (isDue) { lastRunTime = now; console.log(`[Scheduler] Geplanter Lauf startet: ${now.toLocaleTimeString('de-DE')}`); try { await runMatchingPipeline(); } catch (err) { console.error('[Scheduler] Fehler im Suchlauf:', err); } } }); currentJob.start(); console.log('[Scheduler] Cron-Job laeuft, Pruefung jede Minute.'); // Taegliches Aufraeumen um 03:30 - halbe Stunde nach dem alten Scanner, // damit nicht beide gleichzeitig grosse Loeschlaeufe auf derselben MariaDB // fahren. Laeuft nur einmal, auch wenn der Scheduler neu geplant wird. if (!cleanupJob) { cleanupJob = cron.schedule('30 3 * * *', () => { console.log('[Scheduler] Taegliches Aufraeumen laeuft.'); cleanupOldProjects().catch(err => console.error('[Scheduler] Fehler beim Aufraeumen:', err)); }); cleanupJob.start(); console.log('[Scheduler] Aufraeum-Job registriert (03:30).'); // Also run once on boot so retention applies immediately after restart. cleanupOldProjects().catch(() => {}); } } catch (error) { console.error('[Scheduler] Failed to initialize scheduler:', error); } };