Scanner: Stellenanzeigen aus Jobbörsen (DACH, max. 21 Tage)

Neuer Menüpunkt Scanner mit Dashboard und Suchagenten, Zeitplan und
API-Schlüssel unter Einstellungen -> Scanner.

- Quellen: Agentur für Arbeit (API), StepStone, karriere.at,
  freelancermap (HTML) sowie Adzuna und Jooble (kostenlose Schlüssel)
- Quote = gefundene Schlagwörter, Titel zählt doppelt; keine KI
- Abgleich mit icon_companies, bekannte Firmen oben mit Abzeichen
- Zeitplan Vorgabe stündlich Mo-Fr 06-20 Uhr, einstellbar
- Sperre gegen parallele Läufe wie im perinco-Scanner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-29 10:49:44 +02:00
co-authored by Claude Opus 5.5
parent 6fe5b18c11
commit 1b82f580db
23 changed files with 2894 additions and 2 deletions
+197
View File
@@ -0,0 +1,197 @@
import React, { useEffect, useState } from 'react';
import { scannerApi, type ScannerSchedule, type ScannerSecrets } from '../../lib/api';
import { Field, PrimaryButton, inputClass } from '../ui/form';
import { useToast } from '../Toast';
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 die kostenlosen API-Schlüssel für Adzuna und Jooble. Die
* Schlüssel kommen - wie der OpenAI-Schlüssel - nie zurück zum Browser.
*/
const INTERVALS = [15, 30, 60, 120, 180, 240, 360];
export const ScannerSettings: React.FC = () => {
const { toast } = useToast();
const [schedule, setSchedule] = useState<ScannerSchedule | null>(null);
const [secrets, setSecrets] = useState<ScannerSecrets | null>(null);
const [keys, setKeys] = useState({ adzunaId: '', adzunaKey: '', joobleKey: '' });
const [busy, setBusy] = useState(false);
useEffect(() => {
scannerApi
.settings()
.then((data) => {
setSchedule(data.settings);
setSecrets(data.secrets);
})
.catch((err) => toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error'));
}, [toast]);
const save = async (changes: Parameters<typeof scannerApi.saveSettings>[0]) => {
setBusy(true);
try {
const data = await scannerApi.saveSettings(changes);
setSchedule(data.settings);
setSecrets(data.secrets);
setKeys({ adzunaId: '', adzunaKey: '', joobleKey: '' });
toast('Gespeichert.', 'success');
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setBusy(false);
}
};
if (!schedule || !secrets) return <p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>;
const set = (changes: Partial<ScannerSchedule>) => setSchedule({ ...schedule, ...changes });
return (
<div className="space-y-5">
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="text-base font-semibold text-ink-900">Zeitplan</h2>
<p className="mt-1 max-w-3xl text-sm text-ink-500">
In diesem Fenster sucht der Scanner selbstständig. Außerhalb geht es nur über „Jetzt scannen“ im
Dashboard. Gerechnet wird in deutscher Zeit.
</p>
<label className="mt-4 flex items-center gap-2 text-sm font-medium text-ink-800">
<input type="checkbox" checked={schedule.active} onChange={(event) => set({ active: event.target.checked })} />
Automatische Suche aktiv
</label>
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Field label="Wochentage" className="lg:col-span-2">
<div className="flex flex-wrap gap-1.5">
{WEEKDAY_LABELS.map((label, index) => {
const day = index + 1;
const on = schedule.days.includes(day);
return (
<button
key={label}
type="button"
onClick={() =>
set({ days: on ? schedule.days.filter((d) => d !== day) : [...schedule.days, day].sort() })
}
className={`h-9 w-11 rounded-lg border text-sm font-medium ${
on ? 'border-brand-500 bg-brand-50 text-brand-700' : 'border-ink-200 text-ink-500 hover:bg-ink-50'
}`}
>
{label}
</button>
);
})}
</div>
</Field>
<Field label="Von">
<input type="time" value={schedule.from} onChange={(event) => set({ from: event.target.value })} className={inputClass} />
</Field>
<Field label="Bis" hint="Ein Lauf um genau diese Uhrzeit zählt noch.">
<input type="time" value={schedule.to} onChange={(event) => set({ to: event.target.value })} className={inputClass} />
</Field>
<Field label="Intervall">
<select
value={schedule.interval}
onChange={(event) => set({ interval: Number(event.target.value) })}
className={inputClass}
>
{Array.from(new Set([...INTERVALS, schedule.interval]))
.sort((a, b) => a - b)
.map((minutes) => (
<option key={minutes} value={minutes}>
{minutes < 60 ? `alle ${minutes} Minuten` : minutes === 60 ? 'stündlich' : `alle ${minutes / 60} Stunden`}
</option>
))}
</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>
<PrimaryButton
type="button"
className="mt-5"
disabled={busy || schedule.days.length === 0}
onClick={() => void save(schedule)}
>
Zeitplan speichern
</PrimaryButton>
</section>
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="text-base font-semibold text-ink-900">API-Schlüssel</h2>
<p className="mt-1 max-w-3xl text-sm text-ink-500">
Agentur für Arbeit, StepStone, karriere.at und freelancermap brauchen nichts. Adzuna (einzige Quelle für
die Schweiz) und Jooble geben nach kostenloser Registrierung einen Schlüssel aus. Ohne Schlüssel werden
sie übersprungen.
</p>
<div className="mt-4 grid gap-4 lg:grid-cols-3">
<KeyField
label="Adzuna App ID"
current={secrets.adzunaId}
value={keys.adzunaId}
onChange={(adzunaId) => setKeys({ ...keys, adzunaId })}
hint="developer.adzuna.com → Register"
/>
<KeyField
label="Adzuna App Key"
current={secrets.adzunaKey}
value={keys.adzunaKey}
onChange={(adzunaKey) => setKeys({ ...keys, adzunaKey })}
/>
<KeyField
label="Jooble API-Key"
current={secrets.joobleKey}
value={keys.joobleKey}
onChange={(joobleKey) => setKeys({ ...keys, joobleKey })}
hint="jooble.org/api/about → Schlüssel anfordern"
/>
</div>
<PrimaryButton
type="button"
className="mt-5"
disabled={busy || !Object.values(keys).some((value) => value.trim())}
onClick={() =>
void save(Object.fromEntries(Object.entries(keys).filter(([, value]) => value.trim())))
}
>
Schlüssel speichern
</PrimaryButton>
</section>
</div>
);
};
const KeyField: React.FC<{
label: string;
current: string | null;
value: string;
onChange: (value: string) => void;
hint?: string;
}> = ({ label, current, value, onChange, hint }) => (
<Field label={label} hint={current ? `Hinterlegt: ${current}${hint ? ` · ${hint}` : ''}` : hint ?? 'Nicht hinterlegt.'}>
<input
type="password"
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={current ? '•••••••• (ersetzen)' : ''}
autoComplete="off"
spellCheck={false}
className={`${inputClass} font-mono`}
/>
</Field>
);