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
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
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,
*Verworfen*, wenn es nicht passt.
Zeitplan (Wochentage, Uhrzeit, Intervall, Höchstalter) und die kostenlosen
Schlüssel für Adzuna und Jooble pflegt ein Administrator unter
Zeitplan (Wochentage, Uhrzeit, Intervall, Höchstalter) und den kostenlosen
Schlüssel für Adzuna pflegt ein Administrator unter
*Einstellungen → Scanner*.
## 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 |
| `karriere` | karriere.at | AT | HTML der Trefferliste, `.m-jobsListItem` | keiner |
| `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 |
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
weiter. Scheitert eine Quelle, steht das im Lauf-Protokoll und als Hinweis über
der Trefferliste.
@@ -142,7 +143,7 @@ Liste neu, sobald er fertig ist.
| `scanner_from` / `scanner_to` | `06:00` / `20:00` | Fenster, deutsche Zeit |
| `scanner_interval` | `60` | Minuten, 15 bis 1440 |
| `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) |
## Tabellen
+3 -2
View File
@@ -31,7 +31,8 @@ const agentOut = (row) => ({
id: row.id,
name: row.name,
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),
is_active: Boolean(row.is_active),
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) => {
try {
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({
entityType: 'system',
entityLabel: 'Scanner',
-1
View File
@@ -22,7 +22,6 @@ export const DEFAULTS = {
const SECRET_KEYS = {
adzunaId: 'scanner_adzuna_id',
adzunaKey: 'scanner_adzuna_key',
joobleKey: 'scanner_jooble_key',
};
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 },
karriere: { label: 'karriere.at', countries: 'AT', needsKey: null, fetch: fetchKarriere },
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 },
};
@@ -37,7 +36,6 @@ export function sourceReady(id, secrets) {
const needs = SOURCES[id]?.needsKey;
if (!needs) return true;
if (needs === 'adzuna') return Boolean(secrets.adzunaId && secrets.adzunaKey);
if (needs === 'jooble') return Boolean(secrets.joobleKey);
return false;
}
@@ -291,38 +289,6 @@ async function fetchAdzuna(keyword, { maxAge, secrets }) {
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.
* Selektoren wie im Scanner des perinco CRM.
+2 -2
View File
@@ -281,7 +281,7 @@ export const ScannerAgents: React.FC = () => {
</div>
{sources.some((item) => !item.ready) && (
<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 ? (
<>
{' '}
@@ -300,7 +300,7 @@ export const ScannerAgents: React.FC = () => {
) : (
' (Administrator: Einstellungen → Scanner)'
)}
. Ohne Schlüssel werden sie übersprungen.
. Ohne Schlüssel wird es übersprungen.
</p>
)}
</div>
+1 -2
View File
@@ -8,11 +8,10 @@ export const SOURCE_LABELS: Record<ScannerSourceId, string> = {
stepstone: 'StepStone',
karriere: 'karriere.at',
adzuna: 'Adzuna',
jooble: 'Jooble',
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> = {
neu: 'Neu',
+6 -13
View File
@@ -8,7 +8,7 @@ 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
* 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.
*/
@@ -18,7 +18,7 @@ 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 [keys, setKeys] = useState({ adzunaId: '', adzunaKey: '' });
const [busy, setBusy] = useState(false);
useEffect(() => {
@@ -37,7 +37,7 @@ export const ScannerSettings: React.FC = () => {
const data = await scannerApi.saveSettings(changes);
setSchedule(data.settings);
setSecrets(data.secrets);
setKeys({ adzunaId: '', adzunaKey: '', joobleKey: '' });
setKeys({ adzunaId: '', adzunaKey: '' });
toast('Gespeichert.', 'success');
} catch (err) {
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>
<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.
die Schweiz) gibt nach kostenloser Registrierung einen Schlüssel aus. Ohne Schlüssel wird es
übersprungen.
</p>
<div className="mt-4 grid gap-4 lg:grid-cols-3">
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<KeyField
label="Adzuna App ID"
current={secrets.adzunaId}
@@ -152,13 +152,6 @@ export const ScannerSettings: React.FC = () => {
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
+2 -3
View File
@@ -435,7 +435,7 @@ export const dashboardApi = {
/* ---------------------------------------------------------------- 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 interface ScannerAgent {
@@ -465,7 +465,7 @@ export interface ScannerSource {
label: string;
countries: string;
ready: boolean;
needsKey: 'adzuna' | 'jooble' | null;
needsKey: 'adzuna' | null;
last: { ok: boolean; count: number; errors: string[] } | null;
}
@@ -512,7 +512,6 @@ export interface ScannerMatch {
export interface ScannerSecrets {
adzunaId: string | null;
adzunaKey: string | null;
joobleKey: string | null;
}
export type ScannerAgentInput = Pick<ScannerAgent, 'name' | 'keywords' | 'sources' | 'excludes' | 'is_active'>;