diff --git a/docs/08-anleitung-benutzer.md b/docs/08-anleitung-benutzer.md index ac5a8fb..1671ca9 100644 --- a/docs/08-anleitung-benutzer.md +++ b/docs/08-anleitung-benutzer.md @@ -217,8 +217,10 @@ freelancermap) nach offenen Stellen in DACH – jedes Alter; im Dashboard grenze siehe [12 Scanner](12-scanner.md). 1. **Scanner → Suchagenten → + Neuer Suchagent.** Name, Schlagwörter (Enter - oder Komma trennt), Ausschlussbegriffe und Börsen wählen. Jedes Schlagwort - ist eine eigene Suche. + oder Komma trennt), Ausschlussbegriffe, Ausschluss-Unternehmen und Börsen + wählen. Jedes Schlagwort ist eine eigene Suche. Unter + **Ausschluss Unternehmen** z. B. „Hays" eintragen – dann zeigt dieser Agent + keine Anzeigen von Hays mehr; schon vorhandene verschwinden beim Speichern. 2. **Scanner (Dashboard).** Die Treffer erscheinen nach dem nächsten Lauf – automatisch nach Zeitplan oder sofort über **Jetzt scannen**. 3. Die **Quote** zeigt, wie viele Schlagwörter in der Anzeige stehen; ein diff --git a/docs/12-scanner.md b/docs/12-scanner.md index a8cbc4b..99257f7 100644 --- a/docs/12-scanner.md +++ b/docs/12-scanner.md @@ -79,7 +79,7 @@ sequenceDiagram loop je Anzeige P->>DB: speichern (eindeutig je Börse + Kennung), Firma abgleichen loop je Agent, der diese Börse durchsucht - P->>P: Ausschlussbegriff? weiter + P->>P: Ausschlussbegriff oder Ausschluss-Unternehmen? weiter P->>P: Schlagwörter zählen → Quote P->>DB: Treffer (eindeutig je Agent + Anzeige) end @@ -111,6 +111,12 @@ mit „SAP FI" im Text → (2 + 1 + 0) / 6 = **50 %**. („Projektleiter" in „IT-Projektleiterin"). - Ein Ausschlussbegriff in Titel, Firma oder Text verwirft die Anzeige für diesen Agenten. +- **Ausschluss-Unternehmen** (seit 20.10.2026, Spalte `exclude_companies`): + steht ein Eintrag im **Firmennamen** der Anzeige, verwirft der Agent sie – + „Hays" blendet „Hays Professional Solutions GmbH" aus, nicht aber eine + Anzeige, die Hays nur im Text erwähnt. Gleiche Wortregel wie oben (≤ 3 + Zeichen nur als ganzes Wort). Beim Speichern des Agenten werden schon + vorhandene Treffer dieser Firmen gelöscht, außer Status „in Bearbeitung". - Kein Schlagwort gefunden → kein Treffer. Das kostet nichts und ist nachvollziehbar; OpenAI wird vom Scanner nicht @@ -219,6 +225,7 @@ erDiagram text keywords "JSON-Array" text sources "JSON-Array der Quellen-Ids" text excludes "JSON-Array" + text exclude_companies "JSON-Array, Abgleich mit Firmenname" text alarm_keywords "JSON-Array, Ton + Pop-up" tinyint is_active } @@ -274,8 +281,8 @@ Alle unter `/api/scanner`, alle hinter `requireAuth`. | --- | --- | --- | --- | | GET | `/overview` | alle | Kennzahlen, Quellen samt Ergebnis des letzten Laufs, Zeitplan, läuft gerade ein Lauf | | GET | `/agents` | alle | Suchagenten mit Trefferzahl | -| POST | `/agents` | alle | anlegen `{ name, keywords[], alarm_keywords[], sources[], excludes[], is_active }` – Schlagwort oder Alarm-Schlagwort Pflicht | -| PUT | `/agents/:id` | alle | ändern (gleicher Körper) | +| POST | `/agents` | alle | anlegen `{ name, keywords[], alarm_keywords[], sources[], excludes[], exclude_companies[], is_active }` – Schlagwort oder Alarm-Schlagwort Pflicht | +| PUT | `/agents/:id` | alle | ändern (gleicher Körper); löscht vorhandene Treffer von Ausschluss-Unternehmen (außer „in Bearbeitung“) | | DELETE | `/agents/:id` | alle | löschen, Treffer gehen mit | | GET | `/matches` | alle | Filter `hours` (gefunden), `olderThan` / `youngerThan` (Tage ab Veröffentlichung, einzeln oder zusammen; `age=recent|old` = feste 21-Tage-Grenze, aus der ersten Fassung), `agentId`, `source`, `known=1`, `alarm=1`, `status` (`alle` zeigt auch Verworfene), `minScore`, `q`, `sort` (`known`/`score`/`agent`/`title`/`company`/`source`/`published`/`status`/`created`; `oldest` = `published` aufsteigend) mit `dir` (`asc`/`desc`), `limit` | | GET | `/alarms` | alle | unquittierte Alarm-Treffer (höchstens 50) | diff --git a/server/src/db.js b/server/src/db.js index 50bc7ac..6111948 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -191,6 +191,12 @@ async function migrateColumns() { 'alarm_keywords', 'ALTER TABLE icon_scanner_agents ADD COLUMN alarm_keywords TEXT NULL AFTER excludes', ], + // Scanner: Ausschluss-Unternehmen je Agent ("Hays"), 20.10.2026. + [ + 'icon_scanner_agents', + 'exclude_companies', + 'ALTER TABLE icon_scanner_agents ADD COLUMN exclude_companies TEXT NULL AFTER alarm_keywords', + ], [ 'icon_scanner_matches', 'alarm', diff --git a/server/src/routes/scanner.js b/server/src/routes/scanner.js index e96c3cd..2956519 100644 --- a/server/src/routes/scanner.js +++ b/server/src/routes/scanner.js @@ -4,7 +4,7 @@ import { requireAdmin, requireAuth } from '../middleware/auth.js'; import { logHistory } from '../history.js'; import { SOURCES, SOURCE_IDS, sourceReady } from '../scanner/sources.js'; import { parseList, runScan, scanStatus } from '../scanner/pipeline.js'; -import { stripQuotes } from '../scanner/matching.js'; +import { isExcludedCompany, stripQuotes } from '../scanner/matching.js'; import { getLastScanAt, loadScannerSecrets, @@ -37,6 +37,7 @@ const agentOut = (row) => ({ // Nur Quellen, die es noch gibt (Jooble ist am 29.09.2026 entfallen). sources: parseList(row.sources).filter((id) => SOURCE_IDS.includes(id)), excludes: parseList(row.excludes), + exclude_companies: parseList(row.exclude_companies), alarm_keywords: parseList(row.alarm_keywords), is_active: Boolean(row.is_active), matches: Number(row.matches ?? 0), @@ -121,32 +122,54 @@ function readAgent(body) { const keywords = cleanList(body?.keywords); const sources = cleanList(body?.sources).filter((id) => SOURCE_IDS.includes(id)); const excludes = cleanList(body?.excludes); + const excludeCompanies = cleanList(body?.exclude_companies); const alarmKeywords = cleanList(body?.alarm_keywords); if (!name) return { error: 'Bitte einen Namen angeben.' }; if (keywords.length === 0 && alarmKeywords.length === 0) { return { error: 'Mindestens ein Schlagwort oder Alarm-Schlagwort angeben.' }; } if (sources.length === 0) return { error: 'Mindestens eine Börse auswählen.' }; - return { name, keywords, alarmKeywords, sources, excludes, is_active: body?.is_active !== false }; + return { name, keywords, alarmKeywords, sources, excludes, excludeCompanies, is_active: body?.is_active !== false }; } -/** Spaltenwerte in der Reihenfolge name, keywords, alarm_keywords, sources, excludes, is_active. */ +/** + * Spaltenwerte in der Reihenfolge name, keywords, alarm_keywords, sources, + * excludes, exclude_companies, is_active. + */ const agentValues = (agent) => [ agent.name, JSON.stringify(agent.keywords), JSON.stringify(agent.alarmKeywords), JSON.stringify(agent.sources), JSON.stringify(agent.excludes), + JSON.stringify(agent.excludeCompanies), agent.is_active ? 1 : 0, ]; +/** + * Bereits gespeicherte Treffer von Ausschluss-Unternehmen entfernen - sonst + * blieben sie bis zum Aufraeumen sichtbar. Treffer "in Bearbeitung" bleiben, + * an denen arbeitet schon jemand. + */ +async function dropExcludedMatches(agentId, agent) { + if (agent.excludeCompanies.length === 0) return; + const [rows] = await pool.query( + `SELECT m.id, j.company FROM icon_scanner_matches m JOIN icon_scanner_jobs j ON j.id = m.job_id + WHERE m.agent_id = ? AND m.status <> 'in_bearbeitung'`, + [agentId] + ); + const ids = rows.filter((row) => isExcludedCompany(agent, row.company)).map((row) => row.id); + if (ids.length > 0) await pool.query('DELETE FROM icon_scanner_matches WHERE id IN (?)', [ids]); +} + router.post('/agents', async (req, res, next) => { const agent = readAgent(req.body); if (agent.error) return res.status(400).json({ error: agent.error }); try { const [result] = await pool.query( - `INSERT INTO icon_scanner_agents (name, keywords, alarm_keywords, sources, excludes, is_active, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO icon_scanner_agents + (name, keywords, alarm_keywords, sources, excludes, exclude_companies, is_active, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [...agentValues(agent), req.user.id] ); await logHistory({ @@ -169,11 +192,13 @@ router.put('/agents/:id', async (req, res, next) => { try { const [result] = await pool.query( `UPDATE icon_scanner_agents - SET name = ?, keywords = ?, alarm_keywords = ?, sources = ?, excludes = ?, is_active = ? + SET name = ?, keywords = ?, alarm_keywords = ?, sources = ?, excludes = ?, exclude_companies = ?, + is_active = ? WHERE id = ?`, [...agentValues(agent), Number(req.params.id)] ); if (result.affectedRows === 0) return res.status(404).json({ error: 'Suchagent nicht gefunden.' }); + await dropExcludedMatches(Number(req.params.id), agent); const [[row]] = await pool.query('SELECT * FROM icon_scanner_agents WHERE id = ?', [Number(req.params.id)]); res.json({ agent: agentOut(row) }); } catch (err) { diff --git a/server/src/scanner/matching.js b/server/src/scanner/matching.js index c46f176..af7303d 100644 --- a/server/src/scanner/matching.js +++ b/server/src/scanner/matching.js @@ -54,6 +54,16 @@ export function agentTerms(agent) { }); } +/** + * Ausschluss-Unternehmen: geprueft wird nur der Firmenname der Anzeige, nicht + * der Text - "Hays" soll Anzeigen von Hays ausblenden, nicht jede, die Hays + * irgendwo erwaehnt. + */ +export function isExcludedCompany(agent, company) { + if (!company) return false; + return (agent.excludeCompanies ?? []).some((term) => containsTerm(company, term)); +} + /** * @returns {{ score: number, hits: string[], alarm: boolean } | null} * null = ausgeschlossen oder kein einziges Schlagwort gefunden. @@ -64,6 +74,7 @@ export function scoreJob(agent, job) { const all = `${title}\n${job.company ?? ''}\n${job.description ?? ''}`; if ((agent.excludes ?? []).some((term) => containsTerm(all, term))) return null; + if (isExcludedCompany(agent, job.company)) return null; const keywords = agentTerms(agent); if (keywords.length === 0) return null; diff --git a/server/src/scanner/pipeline.js b/server/src/scanner/pipeline.js index 6a9fd10..1bfa700 100644 --- a/server/src/scanner/pipeline.js +++ b/server/src/scanner/pipeline.js @@ -153,6 +153,7 @@ async function loadActiveAgents() { alarmKeywords: parseList(row.alarm_keywords), sources: parseList(row.sources), excludes: parseList(row.excludes), + excludeCompanies: parseList(row.exclude_companies), })); } diff --git a/server/src/schema.sql b/server/src/schema.sql index b47a7b4..41abfcf 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -477,6 +477,8 @@ CREATE TABLE IF NOT EXISTS icon_scanner_agents ( -- Schlagwoerter mit Alarm: werden wie `keywords` gesucht und gezaehlt, -- ein Fund meldet sich zusaetzlich mit Ton und Pop-up. alarm_keywords TEXT NULL, + -- Unternehmen, deren Anzeigen dieser Agent nie zeigt (z. B. ["Hays"]). + exclude_companies TEXT NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, created_by INT UNSIGNED NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/src/components/scanner/ScannerAgents.tsx b/src/components/scanner/ScannerAgents.tsx index 657274c..3986135 100644 --- a/src/components/scanner/ScannerAgents.tsx +++ b/src/components/scanner/ScannerAgents.tsx @@ -18,7 +18,8 @@ import { AlarmBell, SOURCE_LABELS, SOURCE_ORDER, TermInput } from './scannerUi'; * * Ein Agent = Schlagwörter + Börsen + Ausschlussbegriffe. Jedes Schlagwort * wird in jeder gewählten Börse einzeln gesucht; eine Anzeige, die einen - * Ausschlussbegriff enthält, wird gar nicht erst gespeichert. + * Ausschlussbegriff enthält, wird gar nicht erst gespeichert. Dasselbe gilt + * für Anzeigen eines Ausschluss-Unternehmens (Abgleich nur mit dem Firmennamen). */ const EMPTY: ScannerAgentInput = { @@ -26,6 +27,7 @@ const EMPTY: ScannerAgentInput = { keywords: [], sources: ['ba', 'stepstone', 'karriere'], excludes: [], + exclude_companies: [], alarm_keywords: [], is_active: true, }; @@ -104,7 +106,7 @@ export const ScannerAgents: React.FC = () => {
Jeder Agent sucht seine Schlagwörter in den gewählten Börsen. Je mehr Schlagwörter in einer Anzeige vorkommen, desto höher die Quote – ein Treffer im Titel zählt doppelt. Anzeigen mit einem - Ausschlussbegriff fallen raus. + Ausschlussbegriff oder von einem Ausschluss-Unternehmen fallen raus.
Börsen