From 651ad2b954c299f9cc4de8d9eecdd545bfea05e7 Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Tue, 29 Sep 2026 17:28:04 +0200 Subject: [PATCH] Scanner: neue Anzeigen zuerst abfragen, Uhrzeiten korrekt anzeigen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/12-scanner.md | 15 ++++++-- server/src/scanner/sources.js | 52 ++++++++++++++++++++++++---- src/components/scanner/scannerUi.tsx | 12 +++++-- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/docs/12-scanner.md b/docs/12-scanner.md index 0a3b98e..a3b22a2 100644 --- a/docs/12-scanner.md +++ b/docs/12-scanner.md @@ -20,8 +20,8 @@ einfacher HTTP-Abruf (`server/src/scanner/sources.js`). | 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`) | -| `stepstone` | StepStone.de | DE | HTML der Trefferliste, `data-at`-Attribute | keiner | +| `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: `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 | | `adzuna` | Adzuna | DE · AT · CH | REST-API, neueste zuerst | kostenlos, App ID + Key | | `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 > 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, > karriere.at oder freelancermap plötzlich 0 Treffer, zuerst die Selektoren in > `sources.js` gegen die aktuelle Seite prüfen. diff --git a/server/src/scanner/sources.js b/server/src/scanner/sources.js index dff79fd..f3d4d9b 100644 --- a/server/src/scanner/sources.js +++ b/server/src/scanner/sources.js @@ -129,12 +129,33 @@ const slug = (keyword) => * Bundesagentur fuer Arbeit - offizielle Jobsuche-API (JSON, ohne Anmeldung; * 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) { - const url = - 'https://rest.arbeitsagentur.de/jobboerse/jobsuche-service/pc/v6/jobs?' + - new URLSearchParams({ was: keyword, size: '100', page: '1' }); - const data = await request(url, { json: true, headers: { 'X-API-Key': 'jobboerse-jobsuche' } }); - const list = data?.ergebnisliste ?? data?.stellenangebote ?? []; + const list = []; + for (const since of [7, null]) { + for (let page = 1; page <= 2; page += 1) { + const chunk = await baPage(keyword, page, since); + list.push(...chunk); + if (chunk.length < BA_PAGE) break; + } + } return list .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. * ---------------------------------------------------------------------- */ +/* + * 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) { 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); // StepStone rendert CSS-in-JS: