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
|
## 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
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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$/;
|
||||||
|
|||||||
@@ -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(/ /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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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'>;
|
||||||
|
|||||||
Reference in New Issue
Block a user