Files
santifer--career-ops/providers/radancy.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

148 lines
5.7 KiB
JavaScript

// @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 <li class="search-results-list__item …"> holding:
// <a class="search-results-list__job-link …" href="/{lang}/job/{city}/{slug}/{cat}/{id}"
// data-job-id="{id}">{Title}</a>
// <li class="…__job-info--location"><i></i><span>{City, Country}</span></li>
// 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(/<li class="search-results-list__item/).slice(1);
for (const block of blocks) {
const link = block.match(/search-results-list__job-link[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/);
if (!link) continue;
const href = decodeEntities(link[1]);
const dataIdM = block.match(/data-job-id="([^"]+)"/);
const hrefIds = [...href.matchAll(/\/(\d+)(?=[/?#]|$)/g)];
const id = dataIdM ? dataIdM[1] : (hrefIds.length ? hrefIds[hrefIds.length - 1][1] : href);
if (seen.has(id)) continue;
const title = clean(link[2]);
if (!title) continue;
let url;
try {
url = new URL(href, origin).href;
} catch {
continue;
}
const locM = block.match(/__job-info--location[\s\S]*?<span>([\s\S]*?)<\/span>/);
seen.add(id);
out.push({ id, title, url, location: locM ? clean(locM[1]) : '' });
}
return out;
}
/** Resolve the page cap: positive integer `max_pages`, else default. */
function resolveMaxPages(entry) {
const v = entry?.max_pages;
if (Number.isInteger(v) && v > 0) return Math.min(v, MAX_PAGES);
return MAX_PAGES;
}
/** @type {Provider} */
export default {
id: 'radancy',
detect() {
// Branded hosts carry no stable Radancy token in the URL — wire explicitly
// with `provider: radancy`. No auto-detection.
return null;
},
async fetch(entry, ctx) {
const listUrl = resolveListUrl(entry);
if (!listUrl) throw new Error(`radancy: cannot resolve search-jobs URL for ${entry.name}`);
const origin = new URL(listUrl).origin;
const wait = (ms) => (ctx.sleep ? ctx.sleep(ms) : new Promise((r) => setTimeout(r, ms)));
const maxPages = resolveMaxPages(entry);
const jobs = [];
const seen = new Set();
for (let page = 1; page <= maxPages; page++) {
if (page > 1) await wait(PAGE_DELAY_MS);
let rows;
try {
const html = await ctx.fetchText(`${listUrl}?p=${page}`, { headers: { accept: 'text/html' } });
rows = parseResults(html, origin);
} catch {
break; // keep jobs collected so far — a transient mid-scan failure shouldn't discard earlier pages
}
if (rows.length === 0) break; // past the last page
let fresh = 0;
for (const row of rows) {
if (seen.has(row.id)) continue;
seen.add(row.id);
fresh++;
jobs.push({ title: row.title, url: row.url, company: entry.name, location: row.location });
}
// No new ids → the server clamped ?p= to the last page (or looped). Stop.
if (fresh === 0) break;
if (jobs.length >= MAX_JOBS) break;
}
return jobs.slice(0, MAX_JOBS);
},
};