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:
co-authored by
Claude Opus 5.5
parent
0aa8f17b28
commit
651ad2b954
+13
-2
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user