feat: Projekt Scanner integrieren, Whisper in die Einstellungen
Der eigenstaendige Projekt Scanner zieht komplett ins CRM ein und laeuft parallel zur bestehenden App weiter - jede geteilte Ressource ist verdoppelt statt geteilt, damit die alte Installation unberuehrt bleibt: - eigene Datenbank perinco_scanner, legt sich beim Start selbst an - eigener SCANNER_ENCRYPTION_KEY, eigenes Upload-Verzeichnis - eigener Scheduler mit Zeitversatz, damit nicht beide Instanzen gleichzeitig mit denselben Portal-Cookies scrapen (Rate-Limits, CAPTCHA, doppelter RAM) - Sperre gegen ueberlappende Laeufe: Scheduler und "Jetzt scannen" teilen sie, ein zweiter Aufruf bekommt 409 statt doppelter OpenAI-Kosten Backend unter /api/scanner hinter der CRM-Anmeldung; die HTTP-Basic-Auth des Originals entfaellt. CV-PDFs kommen als Stream mit Token statt aus einem statischen Verzeichnis. Bestandsdaten holt "npm run scanner:import" aus der alten Datenbank, ohne sie zu veraendern. Oberflaeche in Tailwind und CI-Farben neu gebaut: Menuepunkt Scanner mit aufklappbaren Unterpunkten, Einstellungen als eigener Reiter. Whisper hat keinen eigenen Menuepunkt mehr, sondern ist ein Reiter in den Einstellungen. Damit diese Geraeteeinstellung jeder Rolle offensteht, sind die Einstellungen jetzt fuer alle sichtbar - welche Reiter erscheinen, entscheidet die Rolle. Ausserdem: kein festes Admin-Passwort mehr im Quelltext, playwright exakt auf 1.60.0 gepinnt (nutzt den vorhandenen Chromium-Build des Servers), technische Dokumentation mit Funktionszeichnungen unter docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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.');
|
||||
}
|
||||
|
||||
/*
|
||||
* Zeitversatz zum parallel laufenden alten Scanner. Ohne ihn starten beide
|
||||
* Instanzen direkt nach einem Neustart gleichzeitig und greifen mit
|
||||
* denselben Portal-Sessions zeitgleich auf die Boersen zu - das provoziert
|
||||
* Rate-Limits und CAPTCHAs. Mit Versatz laufen die Laeufe verschraenkt.
|
||||
*/
|
||||
const offsetMinutes = Number(process.env.SCANNER_SCHEDULER_OFFSET ?? 30);
|
||||
const useOffset = Number.isFinite(offsetMinutes) && offsetMinutes > 0;
|
||||
|
||||
/*
|
||||
* Eigene Sperrfrist statt eines vorgealterten lastRunTime: ein Zeitstempel
|
||||
* kann einen Lauf nur vorziehen, nie verzoegern. Sobald der Versatz groesser
|
||||
* als das Suchintervall ist - bei 15 Minuten Intervall der Normalfall -
|
||||
* waere der erste Lauf sonst sofort faellig gewesen.
|
||||
*/
|
||||
const notBefore = useOffset ? Date.now() + offsetMinutes * 60 * 1000 : 0;
|
||||
let lastRunTime = null;
|
||||
|
||||
if (useOffset) {
|
||||
console.log(`[Scheduler] Erster Lauf in ${offsetMinutes} Minuten (Versatz zum alten Scanner).`);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user