Scanner: ohne Altersgrenze suchen, Filter Anzeigenalter, Alarm-Schlagwörter

- Suche ohne 21-Tage-Grenze (BA ohne veroeffentlichtseit, Adzuna ohne
  max_days_old); Einstellung Höchstalter entfällt
- Dashboard: Filter Anzeigenalter (bis/älter als 21 Tage), Sortierung
  älteste Anzeige, Filter nur Alarm, Zeitraum heißt jetzt Gefunden
- Suchagent: Schlagwörter mit Alarm; Treffer mit alarm = 1 melden sich
  über den AlarmWatcher (App-weit, jede Minute) mit Ton und Pop-up,
  Quittieren gilt für alle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-29 13:28:29 +02:00
co-authored by Claude Opus 5.5
parent b34a1968df
commit 1ad774fdd2
16 changed files with 978 additions and 625 deletions
+3
View File
@@ -18,6 +18,7 @@ import { Projects } from './components/projects/Projects';
import { History } from './components/history/History';
import { ScannerDashboard } from './components/scanner/ScannerDashboard';
import { ScannerAgents } from './components/scanner/ScannerAgents';
import { AlarmWatcher } from './components/scanner/AlarmWatcher';
import { Settings } from './components/settings/Settings';
/*
@@ -197,6 +198,8 @@ export const App: React.FC = () => {
{/* Faellige und liegengebliebene Leads melden sich von selbst -
unabhaengig davon, welche Ansicht gerade offen ist. */}
<ReminderWatcher onChanged={loadShared} />
{/* Alarm-Schlagwörter des Scanners: Ton + Pop-up, in jeder Ansicht. */}
<AlarmWatcher />
</div>
</div>
</NavigationProvider>
+110
View File
@@ -0,0 +1,110 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { scannerApi, type ScannerAlarm } from '../../lib/api';
import { useNavigation } from '../../lib/navigation';
import { Modal } from '../ui/Modal';
import { PrimaryButton, SecondaryButton } from '../ui/form';
import { useToast } from '../Toast';
import { AlarmBell, SOURCE_LABELS, formatAge, playAlarmSound } from './scannerUi';
/*
* Alarm des Scanners: Treffer mit Alarm-Schlagwort melden sich mit Ton und
* Pop-up - egal, welche Ansicht offen ist (deshalb hängt der Wächter in App,
* nicht im Scanner-Dashboard; beim perinco-Scanner hörte man den Ton nur dort).
*
* Abgefragt wird jede Minute. Der Scanner läuft stündlich, ein Fund meldet
* sich also spätestens eine Minute nach dem Lauf. Quittieren gilt für alle
* Benutzer (serverseitig `alarm_ack`).
*/
const POLL_MS = 60_000;
export const AlarmWatcher: React.FC = () => {
const { toast } = useToast();
const { setView } = useNavigation();
const [alarms, setAlarms] = useState<ScannerAlarm[]>([]);
/** Welche Alarme schon geklingelt haben - neue klingeln erneut. */
const rung = useRef(new Set<number>());
const check = useCallback(async () => {
try {
const { alarms: list } = await scannerApi.alarms();
setAlarms(list);
if (list.some((alarm) => !rung.current.has(alarm.id))) {
list.forEach((alarm) => rung.current.add(alarm.id));
playAlarmSound();
}
} catch {
/* kein Netz oder Sitzung abgelaufen - beim nächsten Mal */
}
}, []);
useEffect(() => {
void check();
const timer = setInterval(() => void check(), POLL_MS);
return () => clearInterval(timer);
}, [check]);
if (alarms.length === 0) return null;
const acknowledge = async (goToScanner = false) => {
const ids = alarms.map((alarm) => alarm.id);
setAlarms([]);
try {
await scannerApi.ackAlarms(ids);
} catch (err) {
toast(err instanceof Error ? err.message : 'Quittieren fehlgeschlagen.', 'error');
}
if (goToScanner) setView('Scanner');
};
return (
<Modal
open
size="md"
onClose={() => void acknowledge()}
title={
<span className="flex items-center gap-2">
<AlarmBell className="h-5 w-5" />
{alarms.length === 1 ? 'Alarm-Treffer im Scanner' : `${alarms.length} Alarm-Treffer im Scanner`}
</span>
}
footer={
<>
<SecondaryButton type="button" onClick={() => void acknowledge()}>
Gesehen
</SecondaryButton>
<PrimaryButton type="button" onClick={() => void acknowledge(true)}>
Zum Scanner
</PrimaryButton>
</>
}
>
<ul className="max-h-[50vh] space-y-2 overflow-y-auto">
{alarms.map((alarm) => (
<li key={alarm.id} className="rounded-lg border border-red-200 bg-red-50/50 p-3">
<a
href={alarm.url}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-ink-900 hover:text-brand-700"
>
{alarm.title} ↗
</a>
<p className="mt-0.5 text-xs text-ink-500">
{[alarm.company, alarm.location, SOURCE_LABELS[alarm.source]].filter(Boolean).join(' · ')} ·{' '}
{formatAge(alarm.published_at)}
</p>
<p className="mt-1 text-xs text-ink-600">
Agent <strong>{alarm.agent_name}</strong> · gefunden:{' '}
{alarm.hits.map((hit) => (
<span key={hit} className="mr-1 rounded-full border border-red-200 bg-surface px-1.5 py-0.5 text-red-700">
{hit}
</span>
))}
</p>
</li>
))}
</ul>
</Modal>
);
};
+29 -2
View File
@@ -11,7 +11,7 @@ import { useNavigation } from '../../lib/navigation';
import { useToast } from '../Toast';
import { ConfirmDialog, Modal } from '../ui/Modal';
import { Field, PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
import { SOURCE_LABELS, SOURCE_ORDER, TermInput } from './scannerUi';
import { AlarmBell, SOURCE_LABELS, SOURCE_ORDER, TermInput } from './scannerUi';
/*
* Scanner -> Suchagenten.
@@ -26,6 +26,7 @@ const EMPTY: ScannerAgentInput = {
keywords: [],
sources: ['ba', 'stepstone', 'karriere'],
excludes: [],
alarm_keywords: [],
is_active: true,
};
@@ -145,6 +146,7 @@ export const ScannerAgents: React.FC = () => {
keywords: agent.keywords,
sources: agent.sources,
excludes: agent.excludes,
alarm_keywords: agent.alarm_keywords,
is_active: agent.is_active,
},
})
@@ -169,6 +171,19 @@ export const ScannerAgents: React.FC = () => {
</span>
))}
</Row>
{agent.alarm_keywords.length > 0 && (
<Row label="Schlagwörter mit Alarm">
{agent.alarm_keywords.map((term) => (
<span
key={term}
className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800"
>
<AlarmBell className="h-3 w-3" />
{term}
</span>
))}
</Row>
)}
<Row label="Börsen">
{agent.sources.map((id) => (
<span
@@ -209,7 +224,7 @@ export const ScannerAgents: React.FC = () => {
</SecondaryButton>
<PrimaryButton
type="button"
disabled={busy || !value?.name.trim() || !value?.keywords.length || !value?.sources.length}
disabled={busy || !value?.name.trim() || !(value?.keywords.length || value?.alarm_keywords.length) || !value?.sources.length}
onClick={() => void save()}
>
Speichern
@@ -236,6 +251,18 @@ export const ScannerAgents: React.FC = () => {
/>
</Field>
<Field
label="Schlagwörter mit Alarm"
hint="Werden gesucht und gezählt wie Schlagwörter. Wird eines gefunden, gibt es kurz einen Ton und ein Pop-up – in jeder Ansicht des CRM."
>
<TermInput
value={value.alarm_keywords}
onChange={(alarm_keywords) => patch({ alarm_keywords })}
placeholder="z. B. Interim CFO"
tone="alarm"
/>
</Field>
<Field label="Ausschlussbegriffe" hint="Kommt einer davon in Titel oder Text vor, wird die Anzeige ignoriert.">
<TermInput
value={value.excludes}
+26 -5
View File
@@ -12,6 +12,7 @@ import { useToast } from '../Toast';
import { Modal } from '../ui/Modal';
import { PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
import {
AlarmBell,
SOURCE_LABELS,
SOURCE_ORDER,
STATUS_LABELS,
@@ -63,7 +64,10 @@ export const ScannerDashboard: React.FC = () => {
const [knownOnly, setKnownOnly] = useState(false);
const [showDiscarded, setShowDiscarded] = useState(false);
const [search, setSearch] = useState('');
const [sort, setSort] = useState<'known' | 'score' | 'published'>('known');
const [sort, setSort] = useState<'known' | 'score' | 'published' | 'oldest'>('known');
/** Alter der Anzeige: alle | bis 21 Tage | älter als 21 Tage */
const [age, setAge] = useState<'' | 'recent' | 'old'>('');
const [alarmOnly, setAlarmOnly] = useState(false);
const wasRunning = useRef(false);
@@ -77,6 +81,8 @@ export const ScannerDashboard: React.FC = () => {
known: knownOnly ? 1 : undefined,
status: showDiscarded ? 'alle' : undefined,
q: search.trim() || undefined,
age: age || undefined,
alarm: alarmOnly ? 1 : undefined,
sort,
});
setMatches(data.matches);
@@ -84,7 +90,7 @@ export const ScannerDashboard: React.FC = () => {
} catch (err) {
toast(err instanceof Error ? err.message : 'Treffer nicht ladbar.', 'error');
}
}, [hours, agentId, source, minScore, knownOnly, showDiscarded, search, sort, toast]);
}, [hours, agentId, source, minScore, knownOnly, showDiscarded, search, age, alarmOnly, sort, toast]);
const loadOverview = useCallback(async () => {
try {
@@ -168,7 +174,7 @@ export const ScannerDashboard: React.FC = () => {
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm text-ink-500">
Offene Stellen aus Jobbörsen in DACH, höchstens {overview?.schedule.maxAge ?? 21} Tage alt.
Offene Stellen aus Jobbörsen in DACH – jedes Alter, gefiltert wird unten nach „Anzeigenalter“.
</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span
@@ -236,7 +242,7 @@ export const ScannerDashboard: React.FC = () => {
<div className="flex flex-wrap items-end gap-3 border-b border-ink-200 p-4">
<h2 className="mr-auto text-sm font-bold uppercase tracking-wider text-ink-900">Stellen-Treffer</h2>
<Filter label="Zeitraum" className="w-44">
<Filter label="Gefunden" className="w-44">
<select value={hours} onChange={(event) => setHours(Number(event.target.value))} className={inputClass}>
{PERIODS.map((period) => (
<option key={period.value} value={period.value}>
@@ -265,6 +271,13 @@ export const ScannerDashboard: React.FC = () => {
))}
</select>
</Filter>
<Filter label="Anzeigenalter" className="w-44">
<select value={age} onChange={(event) => setAge(event.target.value as typeof age)} className={inputClass}>
<option value="">alle</option>
<option value="recent">bis 21 Tage</option>
<option value="old">älter als 21 Tage</option>
</select>
</Filter>
<Filter label="Ab %" className="w-20">
<input
type="number"
@@ -281,6 +294,7 @@ export const ScannerDashboard: React.FC = () => {
<option value="known">bekannte Firmen zuerst</option>
<option value="score">beste Quote</option>
<option value="published">neueste Anzeige</option>
<option value="oldest">älteste Anzeige</option>
</select>
</Filter>
<Filter label="Suche" className="w-44">
@@ -296,6 +310,10 @@ export const ScannerDashboard: React.FC = () => {
<input type="checkbox" checked={knownOnly} onChange={(event) => setKnownOnly(event.target.checked)} />
nur bekannte Firmen
</label>
<label className="flex items-center gap-2">
<input type="checkbox" checked={alarmOnly} onChange={(event) => setAlarmOnly(event.target.checked)} />
nur Alarm
</label>
<label className="flex items-center gap-2">
<input type="checkbox" checked={showDiscarded} onChange={(event) => setShowDiscarded(event.target.checked)} />
verworfene zeigen
@@ -340,7 +358,10 @@ export const ScannerDashboard: React.FC = () => {
} ${match.status === 'verworfen' ? 'opacity-60' : ''}`}
>
<td className="px-4 py-3">
<ScoreBox score={match.score} />
<div className="flex items-center gap-1.5">
<ScoreBox score={match.score} />
{match.alarm && <AlarmBell />}
</div>
</td>
<td className="px-4 py-3 font-semibold text-ink-900">{match.agent_name}</td>
<td className="max-w-md px-4 py-3">
+53 -2
View File
@@ -78,12 +78,58 @@ export const formatAge = (value: string | null) => {
export const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const BELL_ICON =
'M12 22a2.5 2.5 0 0 0 2.5-2.5h-5A2.5 2.5 0 0 0 12 22Zm7-5V11a7 7 0 0 0-5.5-6.8V3a1.5 1.5 0 0 0-3 0v1.2A7 7 0 0 0 5 11v6l-2 2v1h18v-1l-2-2Z';
/** Glocke für Treffer mit Alarm-Schlagwort. */
export const AlarmBell: React.FC<{ className?: string }> = ({ className = 'h-4 w-4' }) => (
<span title="Alarm-Schlagwort" className="inline-flex text-red-600">
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden="true">
<path d={BELL_ICON} />
</svg>
</span>
);
/**
* Kurzer Zweiklang über WebAudio - ohne Audiodatei.
*
* Browser spielen Ton erst, nachdem auf der Seite einmal geklickt oder getippt
* wurde (Autoplay-Regel). Klappt es nicht, bleibt es still; das Pop-up kommt
* trotzdem.
*/
export function playAlarmSound() {
try {
const AudioContextClass =
window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextClass) return;
const context = new AudioContextClass();
const tone = (frequency: number, start: number) => {
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = frequency;
gain.gain.setValueAtTime(0.0001, context.currentTime + start);
gain.gain.exponentialRampToValueAtTime(0.3, context.currentTime + start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + start + 0.35);
oscillator.connect(gain).connect(context.destination);
oscillator.start(context.currentTime + start);
oscillator.stop(context.currentTime + start + 0.4);
};
tone(880, 0);
tone(1175, 0.18);
// Nicht geschlossene Kontexte zählen gegen das Browserlimit.
setTimeout(() => void context.close().catch(() => {}), 1000);
} catch {
/* kein Ton möglich - das Pop-up reicht */
}
}
/** Eingabe von Begriffen als Chips: Enter oder Komma fügt hinzu. */
export const TermInput: React.FC<{
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
tone?: 'brand' | 'red';
tone?: 'brand' | 'red' | 'alarm';
}> = ({ value, onChange, placeholder, tone = 'brand' }) => {
const [draft, setDraft] = React.useState('');
@@ -99,12 +145,17 @@ export const TermInput: React.FC<{
};
const chip =
tone === 'red' ? 'border-red-200 bg-red-50 text-red-700' : 'border-brand-200 bg-brand-50 text-brand-700';
tone === 'red'
? 'border-red-200 bg-red-50 text-red-700'
: tone === 'alarm'
? 'border-amber-300 bg-amber-50 text-amber-800'
: 'border-brand-200 bg-brand-50 text-brand-700';
return (
<div className="flex min-h-[42px] flex-wrap items-center gap-1.5 rounded-lg border border-ink-200 bg-surface px-2 py-1.5">
{value.map((term) => (
<span key={term} className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium ${chip}`}>
{tone === 'alarm' && <AlarmBell className="h-3 w-3" />}
{term}
<button
type="button"
+5 -12
View File
@@ -7,8 +7,8 @@ import { WEEKDAY_LABELS } from '../scanner/scannerUi';
/*
* Reiter "Scanner" in den Einstellungen (nur Administratoren).
*
* Wann und wie oft der Scanner selbst sucht, wie alt Anzeigen höchstens sein
* dürfen, und der kostenlose API-Schlüssel für Adzuna. Die
* Wann und wie oft der Scanner selbst sucht, und der kostenlose API-Schlüssel
* für Adzuna. Die
* Schlüssel kommen - wie der OpenAI-Schlüssel - nie zurück zum Browser.
*/
@@ -108,17 +108,10 @@ export const ScannerSettings: React.FC = () => {
))}
</select>
</Field>
<Field label="Höchstes Alter einer Anzeige" hint="In Tagen. Älteres wird verworfen.">
<input
type="number"
min={1}
max={90}
value={schedule.maxAge}
onChange={(event) => set({ maxAge: Number(event.target.value) || 21 })}
className={inputClass}
/>
</Field>
</div>
<p className="mt-3 text-xs text-ink-400">
Gesucht wird ohne Altersgrenze – das Alter der Anzeigen filtert das Dashboard („Anzeigenalter“).
</p>
<PrimaryButton
type="button"
+25 -3
View File
@@ -479,6 +479,8 @@ export interface ScannerAgent {
keywords: string[];
sources: ScannerSourceId[];
excludes: string[];
/** Schlagwörter, deren Fund mit Ton und Pop-up gemeldet wird. */
alarm_keywords: string[];
is_active: boolean;
matches: number;
}
@@ -491,8 +493,21 @@ export interface ScannerSchedule {
to: string;
/** Minuten */
interval: number;
/** höchstes Alter einer Anzeige in Tagen */
maxAge: number;
}
export interface ScannerAlarm {
id: number;
score: number;
hits: string[];
created_at: string;
agent_name: string;
source: ScannerSourceId;
title: string;
company: string | null;
location: string | null;
url: string;
published_at: string | null;
company_id: number | null;
}
export interface ScannerSource {
@@ -525,6 +540,8 @@ export interface ScannerMatch {
score: number;
hits: string[];
status: ScannerMatchStatus;
/** Alarm-Schlagwort getroffen */
alarm: boolean;
created_at: string;
agent_id: number;
agent_name: string;
@@ -549,7 +566,10 @@ export interface ScannerSecrets {
adzunaKey: string | null;
}
export type ScannerAgentInput = Pick<ScannerAgent, 'name' | 'keywords' | 'sources' | 'excludes' | 'is_active'>;
export type ScannerAgentInput = Pick<
ScannerAgent,
'name' | 'keywords' | 'alarm_keywords' | 'sources' | 'excludes' | 'is_active'
>;
const put = <T>(path: string, body: unknown) =>
apiRequest<T>(path, { method: 'PUT', body: JSON.stringify(body) });
@@ -563,6 +583,8 @@ export const scannerApi = {
matches: (params: Record<string, unknown> = {}) =>
get<{ total: number; matches: ScannerMatch[] }>(`/scanner/matches${query(params)}`),
setStatus: (id: number, status: ScannerMatchStatus) => patch<{ ok: true }>(`/scanner/matches/${id}`, { status }),
alarms: () => get<{ alarms: ScannerAlarm[] }>('/scanner/alarms'),
ackAlarms: (ids: number[]) => post<{ ok: true }>('/scanner/alarms/ack', { ids }),
scan: () => post<{ started: true }>('/scanner/scan'),
settings: () => get<{ settings: ScannerSchedule; secrets: ScannerSecrets }>('/scanner/settings'),
saveSettings: (changes: Partial<ScannerSchedule> & Partial<Record<keyof ScannerSecrets, string>>) =>