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:
co-authored by
Claude Opus 5.5
parent
6fe5b18c11
commit
1b82f580db
@@ -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>
|
||||
);
|
||||
Reference in New Issue
Block a user