Scanner: neue Anzeigen zuerst abfragen, Uhrzeiten korrekt anzeigen

Seit dem Wegfall der Altersgrenze lieferten BA und StepStone nur die
relevantesten Anzeigen aller Jahre - fünf Läufe lang 0 neue Treffer.
- BA: erst veroeffentlichtseit=7 (bis 200), dann ohne Grenze (bis 200)
- StepStone: sort=2 Seiten 1-2 plus Relevanz Seite 1
  (action=sort_publish hatte keine Wirkung)
- Dashboard liest DB-Zeitstempel als UTC (vorher 2 h zu früh)

Erster Lauf danach: 481 statt 298 Anzeigen, 40 neue Treffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-29 17:28:04 +02:00
co-authored by Claude Opus 5.5
parent 0aa8f17b28
commit 651ad2b954
3 changed files with 69 additions and 10 deletions
+13 -2
View File
@@ -20,8 +20,8 @@ einfacher HTTP-Abruf (`server/src/scanner/sources.js`).
| Id | Börse | Land | Weg | Schlüssel | | Id | Börse | Land | Weg | Schlüssel |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `ba` | Agentur für Arbeit | DE | offizielle Jobsuche-API v6, JSON, 100 Anzeigen je Begriff | keiner (öffentlicher Header `X-API-Key: jobboerse-jobsuche`) | | `ba` | Agentur für Arbeit | DE | offizielle Jobsuche-API v6, JSON: je Begriff bis 200 der letzten 7 Tage (`veroeffentlichtseit=7`) plus 200 ohne Grenze | keiner (öffentlicher Header `X-API-Key: jobboerse-jobsuche`) |
| `stepstone` | StepStone.de | DE | HTML der Trefferliste, `data-at`-Attribute | keiner | | `stepstone` | StepStone.de | DE | HTML: `sort=2` (neueste zuerst) Seiten 1–2 plus Seite 1 nach Relevanz, `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, neueste zuerst | kostenlos, App ID + Key | | `adzuna` | Adzuna | DE · AT · CH | REST-API, neueste zuerst | kostenlos, App ID + Key |
| `freelancermap` | freelancermap | DE | HTML, Selektoren wie im perinco-Scanner | keiner | | `freelancermap` | freelancermap | DE | HTML, Selektoren wie im perinco-Scanner | keiner |
@@ -42,6 +42,17 @@ der Trefferliste.
> antwortet auf Begriffe ohne eigene Suchseite mit 404 – das gilt als „keine > antwortet auf Begriffe ohne eigene Suchseite mit 404 – das gilt als „keine
> Treffer", nicht als Fehler. > Treffer", nicht als Fehler.
> **Neueste zuerst abfragen.** Ohne Altersgrenze liefern Arbeitsagentur und
> StepStone auf der ersten Seite die „relevantesten" Anzeigen quer durch alle
> Jahre. Am 29.09.2026 kamen so fünf Läufe lang immer dieselben ~298 Anzeigen
> und 0 neue Treffer. Deshalb fragt jede Quelle zuerst die frischen Anzeigen
> ab und erst danach den Altbestand. StepStones `action=sort_publish` wirkt
> nicht, `sort=2` schon.
>
> **Uhrzeiten sind UTC.** Server und MariaDB laufen in UTC; das Dashboard hängt
> an Zeitstempel aus der Datenbank ein `Z` an (`parseDbDate` in
> `scannerUi.tsx`), sonst erscheinen sie zwei Stunden zu früh.
> **HTML-Quellen brechen, wenn die Börse ihr Layout ändert.** Liefert StepStone, > **HTML-Quellen brechen, wenn die Börse ihr Layout ändert.** Liefert StepStone,
> karriere.at oder freelancermap plötzlich 0 Treffer, zuerst die Selektoren in > karriere.at oder freelancermap plötzlich 0 Treffer, zuerst die Selektoren in
> `sources.js` gegen die aktuelle Seite prüfen. > `sources.js` gegen die aktuelle Seite prüfen.
+46 -6
View File
@@ -129,12 +129,33 @@ const slug = (keyword) =>
* Bundesagentur fuer Arbeit - offizielle Jobsuche-API (JSON, ohne Anmeldung; * Bundesagentur fuer Arbeit - offizielle Jobsuche-API (JSON, ohne Anmeldung;
* der Schluessel "jobboerse-jobsuche" ist der oeffentliche der Jobsuche-App). * der Schluessel "jobboerse-jobsuche" ist der oeffentliche der Jobsuche-App).
* ---------------------------------------------------------------------- */ * ---------------------------------------------------------------------- */
/*
* Zwei Abfragen je Begriff (29.09.2026): ohne Altersgrenze liefert die API
* 100 Anzeigen quer durch alle Jahre - neue waren darin Zufall, der Scanner
* fand stundenlang nichts Neues. Deshalb zuerst alles der letzten 7 Tage
* (bis 200), danach die ersten 200 ohne Grenze fuer den Altbestand.
*/
const BA_PAGE = 100;
async function baPage(keyword, page, since) {
const params = { was: keyword, size: String(BA_PAGE), page: String(page) };
if (since) params.veroeffentlichtseit = String(since);
const data = await request(
`https://rest.arbeitsagentur.de/jobboerse/jobsuche-service/pc/v6/jobs?${new URLSearchParams(params)}`,
{ json: true, headers: { 'X-API-Key': 'jobboerse-jobsuche' } }
);
return data?.ergebnisliste ?? data?.stellenangebote ?? [];
}
async function fetchBa(keyword) { async function fetchBa(keyword) {
const url = const list = [];
'https://rest.arbeitsagentur.de/jobboerse/jobsuche-service/pc/v6/jobs?' + for (const since of [7, null]) {
new URLSearchParams({ was: keyword, size: '100', page: '1' }); for (let page = 1; page <= 2; page += 1) {
const data = await request(url, { json: true, headers: { 'X-API-Key': 'jobboerse-jobsuche' } }); const chunk = await baPage(keyword, page, since);
const list = data?.ergebnisliste ?? data?.stellenangebote ?? []; list.push(...chunk);
if (chunk.length < BA_PAGE) break;
}
}
return list return list
.filter((job) => !/AUSBILDUNG|PRAKTIKUM/i.test(job.stellenangebotsart ?? job.angebotsart ?? '')) .filter((job) => !/AUSBILDUNG|PRAKTIKUM/i.test(job.stellenangebotsart ?? job.angebotsart ?? ''))
@@ -178,9 +199,28 @@ async function fetchBa(keyword) {
/* ------------------------------------------------------------------------ /* ------------------------------------------------------------------------
* StepStone.de - Trefferliste als HTML, jede Karte traegt data-at-Attribute. * StepStone.de - Trefferliste als HTML, jede Karte traegt data-at-Attribute.
* ---------------------------------------------------------------------- */ * ---------------------------------------------------------------------- */
/*
* Drei Seiten je Begriff: sort=2 ("neueste zuerst", Seiten 1 und 2) fuer
* frische Anzeigen, dazu Seite 1 nach Relevanz fuer den Altbestand. Der
* frueher benutzte Parameter action=sort_publish wirkt nicht - StepStone
* lieferte damit immer dieselben 25 "relevantesten" Anzeigen.
*/
const STEPSTONE_PAGES = ['?sort=2', '?sort=2&page=2', ''];
async function fetchStepstone(keyword) { async function fetchStepstone(keyword) {
const base = 'https://www.stepstone.de'; const base = 'https://www.stepstone.de';
const html = await request(`${base}/jobs/${encodeURIComponent(slug(keyword))}?action=sort_publish`); const byId = new Map();
for (const [index, query] of STEPSTONE_PAGES.entries()) {
if (index > 0) await new Promise((resolve) => setTimeout(resolve, 800));
const html = await request(`${base}/jobs/${encodeURIComponent(slug(keyword))}${query}`);
for (const job of parseStepstone(html, base)) {
if (!byId.has(job.externalId)) byId.set(job.externalId, job);
}
}
return [...byId.values()];
}
function parseStepstone(html, base) {
const $ = cheerio.load(html); const $ = cheerio.load(html);
// StepStone rendert CSS-in-JS: <style> steckt mitten in den Karten und // StepStone rendert CSS-in-JS: <style> steckt mitten in den Karten und
// landete sonst in Titel und Firmenname. // landete sonst in Titel und Firmenname.
+10 -2
View File
@@ -52,9 +52,17 @@ export const ScoreBox: React.FC<{ score: number }> = ({ score }) => {
); );
}; };
/*
* Zeitstempel aus der Datenbank ("2026-09-29 11:18:31") sind UTC - Server und
* MariaDB laufen in UTC. Ohne das "Z" las der Browser sie als Ortszeit und
* zeigte Funde zwei Stunden zu früh an (13:18 erschien als 11:18).
*/
const parseDbDate = (value: string) =>
new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(value) ? value : `${value.replace(' ', 'T')}Z`);
export const formatDateTime = (value: string | null) => { export const formatDateTime = (value: string | null) => {
if (!value) return '–'; if (!value) return '–';
const date = new Date(value.includes('T') ? value : value.replace(' ', 'T')); const date = parseDbDate(value);
if (Number.isNaN(date.getTime())) return '–'; if (Number.isNaN(date.getTime())) return '–';
return date.toLocaleString('de-DE', { return date.toLocaleString('de-DE', {
day: '2-digit', day: '2-digit',
@@ -68,7 +76,7 @@ export const formatDateTime = (value: string | null) => {
/** "vor 3 Tagen" - fürs Alter einer Anzeige genauer als ein Datum. */ /** "vor 3 Tagen" - fürs Alter einer Anzeige genauer als ein Datum. */
export const formatAge = (value: string | null) => { export const formatAge = (value: string | null) => {
if (!value) return 'unbekannt'; if (!value) return 'unbekannt';
const date = new Date(value.includes('T') ? value : value.replace(' ', 'T')); const date = parseDbDate(value);
const days = Math.floor((Date.now() - date.getTime()) / 86_400_000); const days = Math.floor((Date.now() - date.getTime()) / 86_400_000);
if (Number.isNaN(days)) return 'unbekannt'; if (Number.isNaN(days)) return 'unbekannt';
if (days <= 0) return 'heute'; if (days <= 0) return 'heute';