import { Agent, Portal, Project, Match, Setting } from '../models/index.js'; import { scrapeFreelancermap, scrapeFreelanceDe, scrapeHays, scrapeEtengo, scrapeSThreePortal, SThreePortals } from './scraper.js'; import { evaluateMatch } from './matchingEngine.js'; import nodemailer from 'nodemailer'; // Helper to encrypt/decrypt credentials if needed, or send email notifications const sendEmailNotification = async (agent, project, matchPercentage, pros, cons) => { const active = await Setting.getVal('email_notifications_active') === 'true'; if (!active) return; const smtpHost = await Setting.getVal('smtp_host'); const smtpPort = parseInt(await Setting.getVal('smtp_port', '587'), 10); const smtpUser = await Setting.getVal('smtp_user'); const smtpPass = await Setting.getVal('smtp_pass'); const smtpFrom = await Setting.getVal('smtp_from', 'scanner@perinco.de'); const recipient = await Setting.getVal('notification_email_recipient') || agent.email; if (!smtpHost || !smtpUser || !smtpPass || !recipient) { console.warn('SMTP settings are incomplete. Skipping email notification.'); return; } const transporter = nodemailer.createTransport({ host: smtpHost, port: smtpPort, secure: smtpPort === 465, auth: { user: smtpUser, pass: smtpPass } }); const prosList = pros.map(p => `
  • ✅ ${p}
  • `).join(''); const consList = cons.map(c => `
  • ❌ ${c}
  • `).join(''); const mailOptions = { from: smtpFrom, to: recipient, subject: `[PROJEKT-MATCHING ${matchPercentage}%] Neue Chance für ${agent.name}`, html: `

    Neues Projekt-Match gefunden

    Hallo,

    es gibt ein neues passendes IT-Projekt auf ${project.portal} für ${agent.name}.

    ${project.title}

    Portal: ${project.portal}
    Ort: ${project.location}
    Rate/Budget: ${project.rate}

    Projekt aufrufen

    Eignung: ${matchPercentage}% Match

    Vorteile (Warum es passt):

    Herausforderungen (Gaps):

    Diese E-Mail wurde automatisch vom Perinco Projekt Scanner generiert.

    ` }; try { await transporter.sendMail(mailOptions); console.log(`Notification email sent for Agent ${agent.name} and Project ${project.title}`); } catch (error) { console.error('Failed to send email notification:', error); } }; /* * Ein Lauf zur Zeit. Scheduler und der Knopf "Jetzt scannen" koennen sich sonst * ueberholen - zwei parallele Laeufe starten doppelt Chromium und bezahlen jede * Bewertung zweimal bei OpenAI. */ let pipelineRunning = false; export const isPipelineRunning = () => pipelineRunning; /** * Scrapen, Speichern und Bewerten in einem Durchlauf. * @returns {Promise} false, wenn schon ein Lauf aktiv war. */ export const runMatchingPipeline = async () => { if (pipelineRunning) { console.log('[Pipeline] Lauf laeuft schon - neuer Aufruf uebersprungen.'); return false; } pipelineRunning = true; try { return await executePipeline(); } finally { pipelineRunning = false; /* * Zeitpunkt festhalten, damit der Zeitplan einen Neustart uebersteht - sonst * beginnt nach jedem Deployment die Wartezeit von vorn. Auch ein manueller * Scan zaehlt: er hat die Boersen ja gerade abgefragt. * * Im finally, also auch nach einem Abbruch: ein gescheiterter Lauf soll sich * nicht sofort wiederholen. */ await Setting.setVal('last_scan_at', new Date().toISOString()).catch(() => {}); } }; const executePipeline = async () => { console.log('--- STARTING MATCHING PIPELINE ---'); // 1. Fetch active agents const activeAgents = await Agent.findAll({ where: { isActive: true } }); if (activeAgents.length === 0) { console.log('No active agents found. Pipeline execution stopped.'); return true; } // 2. Extract unique keywords from active agents const keywordSet = new Set(); activeAgents.forEach(agent => { if (agent.keywords) { agent.keywords.split(',').forEach(k => { const trimmed = k.trim(); if (trimmed) keywordSet.add(trimmed); }); } }); const keywords = Array.from(keywordSet); if (keywords.length === 0) { console.log('No active search keywords found. Pipeline execution stopped.'); return true; } console.log(`Keywords to scrape: ${keywords.join(', ')}`); // 3. Fetch portal configurations const freelancermapPortal = await Portal.findOne({ where: { name: 'freelancermap.de' } }); const freelanceDePortal = await Portal.findOne({ where: { name: 'freelance.de' } }); // 4. Run scrapers let scrapedProjects = []; try { const mapResult = await scrapeFreelancermap(keywords, freelancermapPortal?.sessionState); scrapedProjects = scrapedProjects.concat(mapResult.projects); } catch (err) { console.error('Error scraping Freelancermap:', err); } try { const freelanceResult = await scrapeFreelanceDe(keywords, freelanceDePortal?.sessionState); scrapedProjects = scrapedProjects.concat(freelanceResult.projects); } catch (err) { console.error('Error scraping Freelance.de:', err); } try { const haysResult = await scrapeHays(keywords); scrapedProjects = scrapedProjects.concat(haysResult.projects); await Portal.update( { connectionStatus: 'connected', lastLoginAttempt: new Date() }, { where: { name: 'hays.de' } } ); } catch (err) { console.error('Error scraping Hays.de:', err); await Portal.update({ connectionStatus: 'error' }, { where: { name: 'hays.de' } }); } try { const etengoResult = await scrapeEtengo(keywords); scrapedProjects = scrapedProjects.concat(etengoResult.projects); await Portal.update( { connectionStatus: 'connected', lastLoginAttempt: new Date() }, { where: { name: 'etengo.de' } } ); } catch (err) { console.error('Error scraping Etengo.de:', err); await Portal.update({ connectionStatus: 'error' }, { where: { name: 'etengo.de' } }); } // SThree-family staffing portals (shared template — generic scraper). for (const cfg of SThreePortals) { try { const result = await scrapeSThreePortal(cfg); scrapedProjects = scrapedProjects.concat(result.projects); await Portal.update( { connectionStatus: 'connected', lastLoginAttempt: new Date() }, { where: { name: cfg.name } } ); } catch (err) { console.error(`Error scraping ${cfg.name}:`, err); await Portal.update({ connectionStatus: 'error' }, { where: { name: cfg.name } }); } } console.log(`Total projects scraped: ${scrapedProjects.length}`); // 5. Store projects and run matching analysis let newProjectsStoredCount = 0; let matchesEvaluatedCount = 0; const thresholdStr = await Setting.getVal('match_threshold', '70'); const matchThreshold = parseInt(thresholdStr, 10) || 70; for (const scrapedProj of scrapedProjects) { try { // Find or create project in DB const [project, created] = await Project.findOrCreate({ where: { portal: scrapedProj.portal, portalProjectId: scrapedProj.portalProjectId }, defaults: scrapedProj }); if (created) { newProjectsStoredCount++; } // Perform matching analysis with active agents for (const agent of activeAgents) { // Skip if match already exists const existingMatch = await Match.findOne({ where: { agentId: agent.id, projectId: project.id } }); if (existingMatch) continue; // Exclusion list: skip this project for the agent if its title or // description contains any excluded term (case-insensitive). Runs // before the OpenAI call so excluded projects cost nothing. if (agent.excludeKeywords) { const haystack = `${project.title || ''} ${project.description || ''}`.toLowerCase(); const excluded = agent.excludeKeywords .split(',') .map(t => t.trim().toLowerCase()) .filter(Boolean) .find(term => haystack.includes(term)); if (excluded) { console.log(`Skipping project [${project.title}] for Agent [${agent.name}] — matched exclusion term "${excluded}".`); continue; } } // Skip matching if CV text is empty if (!agent.cvText) { console.log(`Skipping match for Agent ${agent.name} (ID: ${agent.id}) - No CV text uploaded.`); continue; } console.log(`Evaluating match: Agent [${agent.name}] <-> Project [${project.title}]`); try { const matchResult = await evaluateMatch(agent.cvText, project.description); matchesEvaluatedCount++; const match = await Match.create({ agentId: agent.id, projectId: project.id, matchPercentage: matchResult.matchPercentage, pros: matchResult.pros, cons: matchResult.cons, status: 'new', notificationSent: false }); // Trigger email notification if threshold reached if (match.matchPercentage >= matchThreshold) { await sendEmailNotification(agent, project, match.matchPercentage, match.pros, match.cons); match.notificationSent = true; await match.save(); } } catch (matchErr) { console.error(`Failed to evaluate match for Agent ${agent.id} and Project ${project.id}:`, matchErr.message); } } } catch (dbErr) { console.error('Error inserting project or creating match:', dbErr.message); } } console.log(`--- PIPELINE COMPLETED ---`); console.log(`Stored ${newProjectsStoredCount} new projects.`); console.log(`Evaluated ${matchesEvaluatedCount} new matches.`); return true; };