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:
co-authored by
Claude Opus 5.5
parent
a85daf01da
commit
e0f6ef06cd
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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$/;
|
||||
|
||||
@@ -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(/ /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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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'>;
|
||||
|
||||
Reference in New Issue
Block a user