Scanner: Jooble entfernt

Quelle, Schlüsselfeld in Einstellungen -> Scanner und Doku raus.
Agenten, die jooble noch gespeichert haben, bekommen es nicht mehr
angezeigt; die Pipeline überspringt unbekannte Quellen ohnehin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-29 11:24:05 +02:00
co-authored by Claude Opus 5.5
parent a85daf01da
commit e0f6ef06cd
9 changed files with 20 additions and 62 deletions
+3 -3
View File
@@ -189,7 +189,7 @@ Kontakt (*Kontakt → Stundensatz vor Ort / remote*).
## Scanner ## Scanner
Sucht in Jobbörsen (Agentur für Arbeit, StepStone, karriere.at, Adzuna, Jooble, Sucht in Jobbörsen (Agentur für Arbeit, StepStone, karriere.at, Adzuna,
freelancermap) nach offenen Stellen in DACH, höchstens 21 Tage alt. Technik freelancermap) nach offenen Stellen in DACH, höchstens 21 Tage alt. Technik
siehe [12 Scanner](12-scanner.md). siehe [12 Scanner](12-scanner.md).
@@ -206,8 +206,8 @@ siehe [12 Scanner](12-scanner.md).
zur Börse. Status setzen: *In Bearbeitung*, wenn sich jemand kümmert, zur Börse. Status setzen: *In Bearbeitung*, wenn sich jemand kümmert,
*Verworfen*, wenn es nicht passt. *Verworfen*, wenn es nicht passt.
Zeitplan (Wochentage, Uhrzeit, Intervall, Höchstalter) und die kostenlosen Zeitplan (Wochentage, Uhrzeit, Intervall, Höchstalter) und den kostenlosen
Schlüssel für Adzuna und Jooble pflegt ein Administrator unter Schlüssel für Adzuna pflegt ein Administrator unter
*Einstellungen → Scanner*. *Einstellungen → Scanner*.
## Historie ## Historie
+3 -2
View File
@@ -20,10 +20,11 @@ einfacher HTTP-Abruf (`server/src/scanner/sources.js`).
| `stepstone` | StepStone.de | DE | HTML der Trefferliste, `data-at`-Attribute | keiner | | `stepstone` | StepStone.de | DE | HTML der Trefferliste, `data-at`-Attribute | keiner |
| `karriere` | karriere.at | AT | HTML der Trefferliste, `.m-jobsListItem` | keiner | | `karriere` | karriere.at | AT | HTML der Trefferliste, `.m-jobsListItem` | keiner |
| `adzuna` | Adzuna | DE · AT · CH | REST-API, `max_days_old` | kostenlos, App ID + Key | | `adzuna` | Adzuna | DE · AT · CH | REST-API, `max_days_old` | kostenlos, App ID + Key |
| `jooble` | Jooble | DE · AT · CH | REST-API je Land, `datecreatedfrom` | kostenlos, API-Key |
| `freelancermap` | freelancermap | DE | HTML, Selektoren wie im perinco-Scanner | keiner | | `freelancermap` | freelancermap | DE | HTML, Selektoren wie im perinco-Scanner | keiner |
Adzuna ist die einzige Quelle für die Schweiz (jobs.ch blockt mit CAPTCHA). Adzuna ist die einzige Quelle für die Schweiz (jobs.ch blockt mit CAPTCHA).
Jooble war anfangs dabei und ist am 29.09.2026 auf Wunsch wieder entfallen;
Agenten, die es noch in ihrer Liste tragen, überspringen es stillschweigend.
Eine Quelle ohne Schlüssel wird übersprungen, der Lauf geht mit den anderen Eine Quelle ohne Schlüssel wird übersprungen, der Lauf geht mit den anderen
weiter. Scheitert eine Quelle, steht das im Lauf-Protokoll und als Hinweis über weiter. Scheitert eine Quelle, steht das im Lauf-Protokoll und als Hinweis über
der Trefferliste. der Trefferliste.
@@ -142,7 +143,7 @@ Liste neu, sobald er fertig ist.
| `scanner_from` / `scanner_to` | `06:00` / `20:00` | Fenster, deutsche Zeit | | `scanner_from` / `scanner_to` | `06:00` / `20:00` | Fenster, deutsche Zeit |
| `scanner_interval` | `60` | Minuten, 15 bis 1440 | | `scanner_interval` | `60` | Minuten, 15 bis 1440 |
| `scanner_max_age` | `21` | Tage, 1 bis 90 | | `scanner_max_age` | `21` | Tage, 1 bis 90 |
| `scanner_adzuna_id`, `scanner_adzuna_key`, `scanner_jooble_key` | leer | gehen nie im Klartext zum Browser | | `scanner_adzuna_id`, `scanner_adzuna_key` | leer | gehen nie im Klartext zum Browser |
| `scanner_last_scan_at` | – | Beginn des letzten Laufs (ISO) | | `scanner_last_scan_at` | – | Beginn des letzten Laufs (ISO) |
## Tabellen ## Tabellen
+3 -2
View File
@@ -31,7 +31,8 @@ const agentOut = (row) => ({
id: row.id, id: row.id,
name: row.name, name: row.name,
keywords: parseList(row.keywords), keywords: parseList(row.keywords),
sources: parseList(row.sources), // 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), excludes: parseList(row.excludes),
is_active: Boolean(row.is_active), is_active: Boolean(row.is_active),
matches: Number(row.matches ?? 0), matches: Number(row.matches ?? 0),
@@ -323,7 +324,7 @@ router.get('/settings', requireAdmin, async (req, res, next) => {
router.patch('/settings', requireAdmin, async (req, res, next) => { router.patch('/settings', requireAdmin, async (req, res, next) => {
try { try {
const settings = await saveScannerSettings(req.body ?? {}); const settings = await saveScannerSettings(req.body ?? {});
const touchedKeys = ['adzunaId', 'adzunaKey', 'joobleKey'].filter((key) => req.body?.[key] !== undefined); const touchedKeys = ['adzunaId', 'adzunaKey'].filter((key) => req.body?.[key] !== undefined);
await logHistory({ await logHistory({
entityType: 'system', entityType: 'system',
entityLabel: 'Scanner', entityLabel: 'Scanner',
-1
View File
@@ -22,7 +22,6 @@ export const DEFAULTS = {
const SECRET_KEYS = { const SECRET_KEYS = {
adzunaId: 'scanner_adzuna_id', adzunaId: 'scanner_adzuna_id',
adzunaKey: 'scanner_adzuna_key', adzunaKey: 'scanner_adzuna_key',
joobleKey: 'scanner_jooble_key',
}; };
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/; const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
-34
View File
@@ -26,7 +26,6 @@ export const SOURCES = {
stepstone: { label: 'StepStone', countries: 'DE', needsKey: null, fetch: fetchStepstone }, stepstone: { label: 'StepStone', countries: 'DE', needsKey: null, fetch: fetchStepstone },
karriere: { label: 'karriere.at', countries: 'AT', needsKey: null, fetch: fetchKarriere }, karriere: { label: 'karriere.at', countries: 'AT', needsKey: null, fetch: fetchKarriere },
adzuna: { label: 'Adzuna', countries: 'DE · AT · CH', needsKey: 'adzuna', fetch: fetchAdzuna }, adzuna: { label: 'Adzuna', countries: 'DE · AT · CH', needsKey: 'adzuna', fetch: fetchAdzuna },
jooble: { label: 'Jooble', countries: 'DE · AT · CH', needsKey: 'jooble', fetch: fetchJooble },
freelancermap: { label: 'freelancermap', countries: 'DE', needsKey: null, fetch: fetchFreelancermap }, freelancermap: { label: 'freelancermap', countries: 'DE', needsKey: null, fetch: fetchFreelancermap },
}; };
@@ -37,7 +36,6 @@ export function sourceReady(id, secrets) {
const needs = SOURCES[id]?.needsKey; const needs = SOURCES[id]?.needsKey;
if (!needs) return true; if (!needs) return true;
if (needs === 'adzuna') return Boolean(secrets.adzunaId && secrets.adzunaKey); if (needs === 'adzuna') return Boolean(secrets.adzunaId && secrets.adzunaKey);
if (needs === 'jooble') return Boolean(secrets.joobleKey);
return false; return false;
} }
@@ -291,38 +289,6 @@ async function fetchAdzuna(keyword, { maxAge, secrets }) {
return jobs.filter((job) => job.title && job.url); return jobs.filter((job) => job.title && job.url);
} }
/* ------------------------------------------------------------------------
* Jooble - kostenloser API-Schluessel (jooble.org/api/about), Sammler fuer DACH.
* Je Land ein eigener Host.
* ---------------------------------------------------------------------- */
async function fetchJooble(keyword, { maxAge, secrets }) {
const since = new Date(Date.now() - maxAge * 24 * 3_600_000).toISOString().slice(0, 10);
const jobs = [];
for (const country of ['de', 'at', 'ch']) {
const data = await request(`https://${country}.jooble.org/api/${encodeURIComponent(secrets.joobleKey)}`, {
json: true,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keywords: keyword, datecreatedfrom: since, page: '1' }),
});
for (const job of data?.jobs ?? []) {
jobs.push({
source: 'jooble',
externalId: String(job.id ?? job.link),
title: clean(job.title).replace(/<[^>]+>/g, ''),
company: clean(job.company) || null,
location: clean(job.location) || null,
country: country.toUpperCase(),
employmentType: clean(job.type) || null,
url: job.link,
description: clean(job.snippet).replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ') || null,
publishedAt: toDate(job.updated),
});
}
}
return jobs.filter((job) => job.title && job.url);
}
/* ------------------------------------------------------------------------ /* ------------------------------------------------------------------------
* freelancermap - Projekte fuer Freiberufler und Interim, neueste zuerst. * freelancermap - Projekte fuer Freiberufler und Interim, neueste zuerst.
* Selektoren wie im Scanner des perinco CRM. * Selektoren wie im Scanner des perinco CRM.
+2 -2
View File
@@ -281,7 +281,7 @@ export const ScannerAgents: React.FC = () => {
</div> </div>
{sources.some((item) => !item.ready) && ( {sources.some((item) => !item.ready) && (
<p className="mt-2 text-xs text-ink-400"> <p className="mt-2 text-xs text-ink-400">
Adzuna und Jooble brauchen einen kostenlosen API-Schlüssel Adzuna braucht einen kostenlosen API-Schlüssel
{isAdmin ? ( {isAdmin ? (
<> <>
{' '} {' '}
@@ -300,7 +300,7 @@ export const ScannerAgents: React.FC = () => {
) : ( ) : (
' (Administrator: Einstellungen → Scanner)' ' (Administrator: Einstellungen → Scanner)'
)} )}
. Ohne Schlüssel werden sie übersprungen. . Ohne Schlüssel wird es übersprungen.
</p> </p>
)} )}
</div> </div>
+1 -2
View File
@@ -8,11 +8,10 @@ export const SOURCE_LABELS: Record<ScannerSourceId, string> = {
stepstone: 'StepStone', stepstone: 'StepStone',
karriere: 'karriere.at', karriere: 'karriere.at',
adzuna: 'Adzuna', adzuna: 'Adzuna',
jooble: 'Jooble',
freelancermap: 'freelancermap', freelancermap: 'freelancermap',
}; };
export const SOURCE_ORDER: ScannerSourceId[] = ['ba', 'stepstone', 'karriere', 'adzuna', 'jooble', 'freelancermap']; export const SOURCE_ORDER: ScannerSourceId[] = ['ba', 'stepstone', 'karriere', 'adzuna', 'freelancermap'];
export const STATUS_LABELS: Record<ScannerMatchStatus, string> = { export const STATUS_LABELS: Record<ScannerMatchStatus, string> = {
neu: 'Neu', neu: 'Neu',
+6 -13
View File
@@ -8,7 +8,7 @@ import { WEEKDAY_LABELS } from '../scanner/scannerUi';
* Reiter "Scanner" in den Einstellungen (nur Administratoren). * Reiter "Scanner" in den Einstellungen (nur Administratoren).
* *
* Wann und wie oft der Scanner selbst sucht, wie alt Anzeigen höchstens sein * 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 * dürfen, und der kostenlose API-Schlüssel für Adzuna. Die
* Schlüssel kommen - wie der OpenAI-Schlüssel - nie zurück zum Browser. * Schlüssel kommen - wie der OpenAI-Schlüssel - nie zurück zum Browser.
*/ */
@@ -18,7 +18,7 @@ export const ScannerSettings: React.FC = () => {
const { toast } = useToast(); const { toast } = useToast();
const [schedule, setSchedule] = useState<ScannerSchedule | null>(null); const [schedule, setSchedule] = useState<ScannerSchedule | null>(null);
const [secrets, setSecrets] = useState<ScannerSecrets | null>(null); const [secrets, setSecrets] = useState<ScannerSecrets | null>(null);
const [keys, setKeys] = useState({ adzunaId: '', adzunaKey: '', joobleKey: '' }); const [keys, setKeys] = useState({ adzunaId: '', adzunaKey: '' });
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
useEffect(() => { useEffect(() => {
@@ -37,7 +37,7 @@ export const ScannerSettings: React.FC = () => {
const data = await scannerApi.saveSettings(changes); const data = await scannerApi.saveSettings(changes);
setSchedule(data.settings); setSchedule(data.settings);
setSecrets(data.secrets); setSecrets(data.secrets);
setKeys({ adzunaId: '', adzunaKey: '', joobleKey: '' }); setKeys({ adzunaId: '', adzunaKey: '' });
toast('Gespeichert.', 'success'); toast('Gespeichert.', 'success');
} catch (err) { } catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error'); toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
@@ -134,11 +134,11 @@ export const ScannerSettings: React.FC = () => {
<h2 className="text-base font-semibold text-ink-900">API-Schlüssel</h2> <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"> <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 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 die Schweiz) gibt nach kostenloser Registrierung einen Schlüssel aus. Ohne Schlüssel wird es
sie übersprungen. übersprungen.
</p> </p>
<div className="mt-4 grid gap-4 lg:grid-cols-3"> <div className="mt-4 grid gap-4 lg:grid-cols-2">
<KeyField <KeyField
label="Adzuna App ID" label="Adzuna App ID"
current={secrets.adzunaId} current={secrets.adzunaId}
@@ -152,13 +152,6 @@ export const ScannerSettings: React.FC = () => {
value={keys.adzunaKey} value={keys.adzunaKey}
onChange={(adzunaKey) => setKeys({ ...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> </div>
<PrimaryButton <PrimaryButton
+2 -3
View File
@@ -435,7 +435,7 @@ export const dashboardApi = {
/* ---------------------------------------------------------------- Scanner */ /* ---------------------------------------------------------------- Scanner */
export type ScannerSourceId = 'ba' | 'stepstone' | 'karriere' | 'adzuna' | 'jooble' | 'freelancermap'; export type ScannerSourceId = 'ba' | 'stepstone' | 'karriere' | 'adzuna' | 'freelancermap';
export type ScannerMatchStatus = 'neu' | 'gesehen' | 'in_bearbeitung' | 'verworfen'; export type ScannerMatchStatus = 'neu' | 'gesehen' | 'in_bearbeitung' | 'verworfen';
export interface ScannerAgent { export interface ScannerAgent {
@@ -465,7 +465,7 @@ export interface ScannerSource {
label: string; label: string;
countries: string; countries: string;
ready: boolean; ready: boolean;
needsKey: 'adzuna' | 'jooble' | null; needsKey: 'adzuna' | null;
last: { ok: boolean; count: number; errors: string[] } | null; last: { ok: boolean; count: number; errors: string[] } | null;
} }
@@ -512,7 +512,6 @@ export interface ScannerMatch {
export interface ScannerSecrets { export interface ScannerSecrets {
adzunaId: string | null; adzunaId: string | null;
adzunaKey: string | null; adzunaKey: string | null;
joobleKey: string | null;
} }
export type ScannerAgentInput = Pick<ScannerAgent, 'name' | 'keywords' | 'sources' | 'excludes' | 'is_active'>; export type ScannerAgentInput = Pick<ScannerAgent, 'name' | 'keywords' | 'sources' | 'excludes' | 'is_active'>;