feat(scanner): Ausschluss-Unternehmen je Suchagent

Neues Feld exclude_companies: steht ein Eintrag im Firmennamen der
Anzeige (z. B. "Hays"), verwirft der Agent sie. Beim Speichern werden
vorhandene Treffer dieser Firmen gelöscht (außer "in Bearbeitung").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-10-20 14:41:47 +02:00
co-authored by Claude Opus 5.5
parent a24fd938a2
commit 1969d0b77a
9 changed files with 94 additions and 14 deletions
+4 -2
View File
@@ -217,8 +217,10 @@ freelancermap) nach offenen Stellen in DACH – jedes Alter; im Dashboard grenze
siehe [12 Scanner](12-scanner.md). siehe [12 Scanner](12-scanner.md).
1. **Scanner → Suchagenten → + Neuer Suchagent.** Name, Schlagwörter (Enter 1. **Scanner → Suchagenten → + Neuer Suchagent.** Name, Schlagwörter (Enter
oder Komma trennt), Ausschlussbegriffe und Börsen wählen. Jedes Schlagwort oder Komma trennt), Ausschlussbegriffe, Ausschluss-Unternehmen und Börsen
ist eine eigene Suche. 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 – 2. **Scanner (Dashboard).** Die Treffer erscheinen nach dem nächsten Lauf –
automatisch nach Zeitplan oder sofort über **Jetzt scannen**. automatisch nach Zeitplan oder sofort über **Jetzt scannen**.
3. Die **Quote** zeigt, wie viele Schlagwörter in der Anzeige stehen; ein 3. Die **Quote** zeigt, wie viele Schlagwörter in der Anzeige stehen; ein
+10 -3
View File
@@ -79,7 +79,7 @@ sequenceDiagram
loop je Anzeige loop je Anzeige
P->>DB: speichern (eindeutig je Börse + Kennung), Firma abgleichen P->>DB: speichern (eindeutig je Börse + Kennung), Firma abgleichen
loop je Agent, der diese Börse durchsucht 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->>P: Schlagwörter zählen → Quote
P->>DB: Treffer (eindeutig je Agent + Anzeige) P->>DB: Treffer (eindeutig je Agent + Anzeige)
end end
@@ -111,6 +111,12 @@ mit „SAP FI" im Text → (2 + 1 + 0) / 6 = **50 %**.
(„Projektleiter" in „IT-Projektleiterin"). („Projektleiter" in „IT-Projektleiterin").
- Ein Ausschlussbegriff in Titel, Firma oder Text verwirft die Anzeige für - Ein Ausschlussbegriff in Titel, Firma oder Text verwirft die Anzeige für
diesen Agenten. 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. - Kein Schlagwort gefunden → kein Treffer.
Das kostet nichts und ist nachvollziehbar; OpenAI wird vom Scanner nicht Das kostet nichts und ist nachvollziehbar; OpenAI wird vom Scanner nicht
@@ -219,6 +225,7 @@ erDiagram
text keywords "JSON-Array" text keywords "JSON-Array"
text sources "JSON-Array der Quellen-Ids" text sources "JSON-Array der Quellen-Ids"
text excludes "JSON-Array" text excludes "JSON-Array"
text exclude_companies "JSON-Array, Abgleich mit Firmenname"
text alarm_keywords "JSON-Array, Ton + Pop-up" text alarm_keywords "JSON-Array, Ton + Pop-up"
tinyint is_active 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 | `/overview` | alle | Kennzahlen, Quellen samt Ergebnis des letzten Laufs, Zeitplan, läuft gerade ein Lauf |
| GET | `/agents` | alle | Suchagenten mit Trefferzahl | | GET | `/agents` | alle | Suchagenten mit Trefferzahl |
| POST | `/agents` | alle | anlegen `{ name, keywords[], alarm_keywords[], sources[], excludes[], is_active }` – Schlagwort oder Alarm-Schlagwort Pflicht | | 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) | | 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 | | 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 | `/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) | | GET | `/alarms` | alle | unquittierte Alarm-Treffer (höchstens 50) |
+6
View File
@@ -191,6 +191,12 @@ async function migrateColumns() {
'alarm_keywords', 'alarm_keywords',
'ALTER TABLE icon_scanner_agents ADD COLUMN alarm_keywords TEXT NULL AFTER excludes', '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', 'icon_scanner_matches',
'alarm', 'alarm',
+31 -6
View File
@@ -4,7 +4,7 @@ import { requireAdmin, requireAuth } from '../middleware/auth.js';
import { logHistory } from '../history.js'; import { logHistory } from '../history.js';
import { SOURCES, SOURCE_IDS, sourceReady } from '../scanner/sources.js'; import { SOURCES, SOURCE_IDS, sourceReady } from '../scanner/sources.js';
import { parseList, runScan, scanStatus } from '../scanner/pipeline.js'; import { parseList, runScan, scanStatus } from '../scanner/pipeline.js';
import { stripQuotes } from '../scanner/matching.js'; import { isExcludedCompany, stripQuotes } from '../scanner/matching.js';
import { import {
getLastScanAt, getLastScanAt,
loadScannerSecrets, loadScannerSecrets,
@@ -37,6 +37,7 @@ const agentOut = (row) => ({
// Nur Quellen, die es noch gibt (Jooble ist am 29.09.2026 entfallen). // Nur Quellen, die es noch gibt (Jooble ist am 29.09.2026 entfallen).
sources: parseList(row.sources).filter((id) => SOURCE_IDS.includes(id)), sources: parseList(row.sources).filter((id) => SOURCE_IDS.includes(id)),
excludes: parseList(row.excludes), excludes: parseList(row.excludes),
exclude_companies: parseList(row.exclude_companies),
alarm_keywords: parseList(row.alarm_keywords), alarm_keywords: parseList(row.alarm_keywords),
is_active: Boolean(row.is_active), is_active: Boolean(row.is_active),
matches: Number(row.matches ?? 0), matches: Number(row.matches ?? 0),
@@ -121,32 +122,54 @@ function readAgent(body) {
const keywords = cleanList(body?.keywords); const keywords = cleanList(body?.keywords);
const sources = cleanList(body?.sources).filter((id) => SOURCE_IDS.includes(id)); const sources = cleanList(body?.sources).filter((id) => SOURCE_IDS.includes(id));
const excludes = cleanList(body?.excludes); const excludes = cleanList(body?.excludes);
const excludeCompanies = cleanList(body?.exclude_companies);
const alarmKeywords = cleanList(body?.alarm_keywords); const alarmKeywords = cleanList(body?.alarm_keywords);
if (!name) return { error: 'Bitte einen Namen angeben.' }; if (!name) return { error: 'Bitte einen Namen angeben.' };
if (keywords.length === 0 && alarmKeywords.length === 0) { if (keywords.length === 0 && alarmKeywords.length === 0) {
return { error: 'Mindestens ein Schlagwort oder Alarm-Schlagwort angeben.' }; return { error: 'Mindestens ein Schlagwort oder Alarm-Schlagwort angeben.' };
} }
if (sources.length === 0) return { error: 'Mindestens eine Börse auswählen.' }; 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) => [ const agentValues = (agent) => [
agent.name, agent.name,
JSON.stringify(agent.keywords), JSON.stringify(agent.keywords),
JSON.stringify(agent.alarmKeywords), JSON.stringify(agent.alarmKeywords),
JSON.stringify(agent.sources), JSON.stringify(agent.sources),
JSON.stringify(agent.excludes), JSON.stringify(agent.excludes),
JSON.stringify(agent.excludeCompanies),
agent.is_active ? 1 : 0, 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) => { router.post('/agents', async (req, res, next) => {
const agent = readAgent(req.body); const agent = readAgent(req.body);
if (agent.error) return res.status(400).json({ error: agent.error }); if (agent.error) return res.status(400).json({ error: agent.error });
try { try {
const [result] = await pool.query( const [result] = await pool.query(
`INSERT INTO icon_scanner_agents (name, keywords, alarm_keywords, sources, excludes, is_active, created_by) `INSERT INTO icon_scanner_agents
VALUES (?, ?, ?, ?, ?, ?, ?)`, (name, keywords, alarm_keywords, sources, excludes, exclude_companies, is_active, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[...agentValues(agent), req.user.id] [...agentValues(agent), req.user.id]
); );
await logHistory({ await logHistory({
@@ -169,11 +192,13 @@ router.put('/agents/:id', async (req, res, next) => {
try { try {
const [result] = await pool.query( const [result] = await pool.query(
`UPDATE icon_scanner_agents `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 = ?`, WHERE id = ?`,
[...agentValues(agent), Number(req.params.id)] [...agentValues(agent), Number(req.params.id)]
); );
if (result.affectedRows === 0) return res.status(404).json({ error: 'Suchagent nicht gefunden.' }); 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)]); const [[row]] = await pool.query('SELECT * FROM icon_scanner_agents WHERE id = ?', [Number(req.params.id)]);
res.json({ agent: agentOut(row) }); res.json({ agent: agentOut(row) });
} catch (err) { } catch (err) {
+11
View File
@@ -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} * @returns {{ score: number, hits: string[], alarm: boolean } | null}
* null = ausgeschlossen oder kein einziges Schlagwort gefunden. * 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 ?? ''}`; const all = `${title}\n${job.company ?? ''}\n${job.description ?? ''}`;
if ((agent.excludes ?? []).some((term) => containsTerm(all, term))) return null; if ((agent.excludes ?? []).some((term) => containsTerm(all, term))) return null;
if (isExcludedCompany(agent, job.company)) return null;
const keywords = agentTerms(agent); const keywords = agentTerms(agent);
if (keywords.length === 0) return null; if (keywords.length === 0) return null;
+1
View File
@@ -153,6 +153,7 @@ async function loadActiveAgents() {
alarmKeywords: parseList(row.alarm_keywords), alarmKeywords: parseList(row.alarm_keywords),
sources: parseList(row.sources), sources: parseList(row.sources),
excludes: parseList(row.excludes), excludes: parseList(row.excludes),
excludeCompanies: parseList(row.exclude_companies),
})); }));
} }
+2
View File
@@ -477,6 +477,8 @@ CREATE TABLE IF NOT EXISTS icon_scanner_agents (
-- Schlagwoerter mit Alarm: werden wie `keywords` gesucht und gezaehlt, -- Schlagwoerter mit Alarm: werden wie `keywords` gesucht und gezaehlt,
-- ein Fund meldet sich zusaetzlich mit Ton und Pop-up. -- ein Fund meldet sich zusaetzlich mit Ton und Pop-up.
alarm_keywords TEXT NULL, 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, is_active TINYINT(1) NOT NULL DEFAULT 1,
created_by INT UNSIGNED NULL, created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+26 -2
View File
@@ -18,7 +18,8 @@ import { AlarmBell, SOURCE_LABELS, SOURCE_ORDER, TermInput } from './scannerUi';
* *
* Ein Agent = Schlagwörter + Börsen + Ausschlussbegriffe. Jedes Schlagwort * Ein Agent = Schlagwörter + Börsen + Ausschlussbegriffe. Jedes Schlagwort
* wird in jeder gewählten Börse einzeln gesucht; eine Anzeige, die einen * 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 = { const EMPTY: ScannerAgentInput = {
@@ -26,6 +27,7 @@ const EMPTY: ScannerAgentInput = {
keywords: [], keywords: [],
sources: ['ba', 'stepstone', 'karriere'], sources: ['ba', 'stepstone', 'karriere'],
excludes: [], excludes: [],
exclude_companies: [],
alarm_keywords: [], alarm_keywords: [],
is_active: true, is_active: true,
}; };
@@ -104,7 +106,7 @@ export const ScannerAgents: React.FC = () => {
<p className="max-w-3xl text-sm text-ink-500"> <p className="max-w-3xl text-sm text-ink-500">
Jeder Agent sucht seine Schlagwörter in den gewählten Börsen. Je mehr Schlagwörter in einer Anzeige 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 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.
</p> </p>
<PrimaryButton type="button" onClick={() => setEditing({ id: null, value: EMPTY })}> <PrimaryButton type="button" onClick={() => setEditing({ id: null, value: EMPTY })}>
+ Neuer Suchagent + Neuer Suchagent
@@ -146,6 +148,7 @@ export const ScannerAgents: React.FC = () => {
keywords: agent.keywords, keywords: agent.keywords,
sources: agent.sources, sources: agent.sources,
excludes: agent.excludes, excludes: agent.excludes,
exclude_companies: agent.exclude_companies,
alarm_keywords: agent.alarm_keywords, alarm_keywords: agent.alarm_keywords,
is_active: agent.is_active, is_active: agent.is_active,
}, },
@@ -207,6 +210,15 @@ export const ScannerAgents: React.FC = () => {
))} ))}
</Row> </Row>
)} )}
{agent.exclude_companies.length > 0 && (
<Row label="Ausschluss Unternehmen">
{agent.exclude_companies.map((term) => (
<span key={term} className="rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-xs text-red-700">
{term}
</span>
))}
</Row>
)}
</article> </article>
))} ))}
</div> </div>
@@ -272,6 +284,18 @@ export const ScannerAgents: React.FC = () => {
/> />
</Field> </Field>
<Field
label="Ausschluss Unternehmen"
hint="Anzeigen dieser Unternehmen zeigt der Agent nicht – geprüft wird nur der Firmenname. Vorhandene Treffer verschwinden beim Speichern."
>
<TermInput
value={value.exclude_companies}
onChange={(exclude_companies) => patch({ exclude_companies })}
placeholder="z. B. Hays, Randstad"
tone="red"
/>
</Field>
<div> <div>
<p className="field-label mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-500">Börsen</p> <p className="field-label mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-500">Börsen</p>
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
+3 -1
View File
@@ -479,6 +479,8 @@ export interface ScannerAgent {
keywords: string[]; keywords: string[];
sources: ScannerSourceId[]; sources: ScannerSourceId[];
excludes: string[]; excludes: string[];
/** Unternehmen, deren Anzeigen dieser Agent nie zeigt (Abgleich mit dem Firmennamen). */
exclude_companies: string[];
/** Schlagwörter, deren Fund mit Ton und Pop-up gemeldet wird. */ /** Schlagwörter, deren Fund mit Ton und Pop-up gemeldet wird. */
alarm_keywords: string[]; alarm_keywords: string[];
is_active: boolean; is_active: boolean;
@@ -568,7 +570,7 @@ export interface ScannerSecrets {
export type ScannerAgentInput = Pick< export type ScannerAgentInput = Pick<
ScannerAgent, ScannerAgent,
'name' | 'keywords' | 'alarm_keywords' | 'sources' | 'excludes' | 'is_active' 'name' | 'keywords' | 'alarm_keywords' | 'sources' | 'excludes' | 'exclude_companies' | 'is_active'
>; >;
const put = <T>(path: string, body: unknown) => const put = <T>(path: string, body: unknown) =>