// @ts-check /** @typedef {import('./_types.js').Provider} Provider */ // Radancy (TalentBrew) provider — the career sites Radancy hosts for large // employers (careers.munichre.com and its ERGO brands, plus many others). The // search-results page is SERVER-rendered and paginates over bare HTTP: // // GET {origin}/{lang}/search-jobs?p={N} # 1-based; past-the-end → empty // // Each posting is one
  • holding: // {Title} //
  • {City, Country}
  • // The generic `search-results-list__` class prefix is the stable TalentBrew // markup (a second, module-numbered `job-list-NN-list__` prefix rides alongside // it and varies per site) — we anchor on the generic one for portability. // // The list carries no posting date, so postedAt is omitted. detect() can't be // host-based (branded domains), so tenants are wired with an explicit // `provider: radancy` + a search-jobs `api:`/`careers_url`. const MAX_PAGES = 200; // safety cap (~15/page ⇒ up to ~3000 postings) const MAX_JOBS = 2000; // cap total postings pulled const PAGE_DELAY_MS = 150; // polite pacing — full walks are >100 sequential requests // Minimal HTML entity decoder — mirrors the other HTML-scraping providers. const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }; /** @param {string} s */ function decodeEntities(s) { return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, body) => { if (body[0] === '#') { const code = body[1] === 'x' || body[1] === 'X' ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10); // String.fromCodePoint throws RangeError outside 0..0x10FFFF or on a lone // surrogate half — a malformed/adversarial entity must degrade to the // original text, never crash the whole parse. const valid = Number.isFinite(code) && code >= 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff); return valid ? String.fromCodePoint(code) : m; } return NAMED_ENTITIES[body.toLowerCase()] ?? m; }); } /** @param {string} s */ function clean(s) { return decodeEntities(s.replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim(); } /** Resolve the search-jobs list URL from api:/careers_url; default /en. */ export function resolveListUrl(entry) { const raw = entry.api || entry.careers_url || ''; let u; try { u = new URL(raw); } catch { return null; } if (u.protocol !== 'https:' && u.protocol !== 'http:') return null; if (/\/search-jobs\/?$/.test(u.pathname)) return `${u.origin}${u.pathname.replace(/\/$/, '')}`; const lang = (u.pathname.match(/^\/([a-z]{2})(\/|$)/) || [])[1] || 'en'; return `${u.origin}/${lang}/search-jobs`; } /** * Parse one search-results page into raw {id, title, url, location} records. * @param {string} html @param {string} origin */ export function parseResults(html, origin) { if (typeof html !== 'string') return []; const out = []; const seen = new Set(); // Split on the stable generic list-item class; slice(0) is the page head. const blocks = html.split(/