181 lines
6.9 KiB
JavaScript
181 lines
6.9 KiB
JavaScript
// @ts-check
|
|
/** @typedef {import('./_types.js').Provider} Provider */
|
|
|
|
// beesite (milch & zucker GJB) provider — the search backend behind branded
|
|
// portals like jobs.mercedes-benz.com. The SPA calls a public, no-auth JSON
|
|
// endpoint on the tenant's *.app.beesite.de host:
|
|
//
|
|
// GET {origin}/search?data={URL-encoded JSON}
|
|
// data = {"LanguageCode":"EN",
|
|
// "SearchParameters":{"FirstItem":1,"CountItem":100,
|
|
// "Sort":[{"Criterion":"PublicationStartDate","Direction":"DESC"}],
|
|
// "MatchedObjectDescriptor":[…field list…]},
|
|
// "SearchCriteria":[…optional facet filters…]}
|
|
// → {"SearchResult":{"SearchResultCount":N,"SearchResultCountAll":TOTAL,
|
|
// "SearchResultItems":[{"MatchedObjectId":"200325",
|
|
// "MatchedObjectDescriptor":{"PositionID":"mer0003yhd",
|
|
// "PositionTitle":…,"PositionURI":"https://jobs.mercedes-benz.com/…",
|
|
// "PositionLocation":[{"CityName":"Bremen"}],
|
|
// "PublicationStartDate":"2026-07-04"}}]}}
|
|
//
|
|
// PositionURI is the BRANDED job page (jobs.mercedes-benz.com/…), so listings
|
|
// link straight to the public posting. FirstItem is 1-based; requesting past
|
|
// the end returns an empty item list. We sort newest-first so a bounded walk
|
|
// (max_pages / MAX_JOBS) keeps the most recent postings.
|
|
//
|
|
// Facet criteria use tenant-internal numeric term codes (Mercedes: country
|
|
// 329 = Germany, discoverable via MatchedObjectDescriptor "Facet:…" queries).
|
|
// A portals.yml entry can pin them via:
|
|
// beesite:
|
|
// searchCriteria:
|
|
// - { CriterionName: PositionLocation.Country, CriterionValue: [329] }
|
|
|
|
const PAGE_SIZE = 100; // verified: the endpoint happily serves 100+/page
|
|
const MAX_PAGES = 40; // safety cap on request count (40*100 = 4000 postings)
|
|
const MAX_JOBS = 1000; // cap total postings pulled (newest-first sort)
|
|
const PAGE_DELAY_MS = 150; // polite pacing between page requests
|
|
|
|
const DESCRIPTOR = [
|
|
'PositionID',
|
|
'PositionTitle',
|
|
'PositionURI',
|
|
'PositionLocation.CityName',
|
|
'PublicationStartDate',
|
|
];
|
|
|
|
/** @param {import('./_types.js').PortalEntry} entry */
|
|
export function resolveConfig(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;
|
|
const host = u.host.toLowerCase();
|
|
if (host !== 'beesite.de' && !host.endsWith('.beesite.de')) return null;
|
|
const cfgBlock = entry.beesite && typeof entry.beesite === 'object' ? entry.beesite : {};
|
|
return {
|
|
searchApi: `${u.origin}/search`,
|
|
languageCode: typeof cfgBlock.languageCode === 'string' ? cfgBlock.languageCode : 'EN',
|
|
searchCriteria: Array.isArray(cfgBlock.searchCriteria) ? cfgBlock.searchCriteria : [],
|
|
};
|
|
}
|
|
|
|
/** Build the ?data= payload for one page. @param {any} cfg @param {number} firstItem */
|
|
export function buildSearchUrl(cfg, firstItem) {
|
|
const data = {
|
|
LanguageCode: cfg.languageCode,
|
|
SearchParameters: {
|
|
FirstItem: firstItem,
|
|
CountItem: PAGE_SIZE,
|
|
Sort: [{ Criterion: 'PublicationStartDate', Direction: 'DESC' }],
|
|
MatchedObjectDescriptor: DESCRIPTOR,
|
|
},
|
|
SearchCriteria: cfg.searchCriteria,
|
|
};
|
|
return `${cfg.searchApi}?data=${encodeURIComponent(JSON.stringify(data))}`;
|
|
}
|
|
|
|
// "2026-07-04" → epoch ms (UTC for determinism).
|
|
/** @param {unknown} raw @returns {number | undefined} */
|
|
export function parseBeesiteDate(raw) {
|
|
const m = typeof raw === 'string' ? raw.trim().match(/^(\d{4})-(\d{2})-(\d{2})$/) : null;
|
|
if (!m) return undefined;
|
|
const month = Number(m[2]);
|
|
const day = Number(m[3]);
|
|
if (month < 1 || month > 12 || day < 1 || day > 31) return undefined;
|
|
const ms = Date.UTC(Number(m[1]), month - 1, day);
|
|
return Number.isFinite(ms) ? ms : undefined;
|
|
}
|
|
|
|
/**
|
|
* Map one search response to raw {id, title, url, location, postedAt} records.
|
|
* @param {any} json
|
|
* @returns {{total: number|null, rows: Array<{id:string,title:string,url:string,location:string,postedAt?:number}>}}
|
|
*/
|
|
export function parseSearchResult(json) {
|
|
const sr = json?.SearchResult;
|
|
const total = typeof sr?.SearchResultCountAll === 'number' ? sr.SearchResultCountAll : null;
|
|
const items = Array.isArray(sr?.SearchResultItems) ? sr.SearchResultItems : [];
|
|
const rows = [];
|
|
for (const item of items) {
|
|
const d = item?.MatchedObjectDescriptor;
|
|
if (!d) continue;
|
|
const id = item.MatchedObjectId != null ? String(item.MatchedObjectId) : String(d.PositionID || '');
|
|
const title = String(d.PositionTitle || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
const url = String(d.PositionURI || '').trim();
|
|
if (!id || !title || !/^https?:\/\//i.test(url)) continue;
|
|
const locs = Array.isArray(d.PositionLocation) ? d.PositionLocation : [];
|
|
const cities = [];
|
|
for (const l of locs) {
|
|
const c = String(l?.CityName || '').trim();
|
|
if (c && !cities.includes(c)) cities.push(c);
|
|
}
|
|
rows.push({
|
|
id,
|
|
title,
|
|
url,
|
|
location: cities.join(' / '),
|
|
postedAt: parseBeesiteDate(d.PublicationStartDate),
|
|
});
|
|
}
|
|
return { total, rows };
|
|
}
|
|
|
|
/** 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: 'beesite',
|
|
|
|
detect(entry) {
|
|
const url = entry.api || entry.careers_url || '';
|
|
if (typeof url !== 'string') return null;
|
|
return resolveConfig({ api: url }) ? { url } : null;
|
|
},
|
|
|
|
async fetch(entry, ctx) {
|
|
const cfg = resolveConfig(entry);
|
|
if (!cfg) throw new Error(`beesite: cannot resolve search host for ${entry.name}`);
|
|
|
|
const wait = (ms) => (ctx.sleep ? ctx.sleep(ms) : new Promise((r) => setTimeout(r, ms)));
|
|
const maxPages = resolveMaxPages(entry);
|
|
const jobs = [];
|
|
const seen = new Set();
|
|
let total = null;
|
|
|
|
for (let page = 0; page < maxPages; page++) {
|
|
if (page > 0) await wait(PAGE_DELAY_MS);
|
|
const json = await ctx.fetchJson(buildSearchUrl(cfg, page * PAGE_SIZE + 1), {
|
|
redirect: 'error',
|
|
headers: { accept: 'application/json' },
|
|
});
|
|
const { total: pageTotal, rows } = parseSearchResult(json);
|
|
if (total === null) total = pageTotal;
|
|
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++;
|
|
const job = { title: row.title, url: row.url, company: entry.name, location: row.location };
|
|
if (typeof row.postedAt === 'number') job.postedAt = row.postedAt;
|
|
jobs.push(job);
|
|
if (jobs.length >= MAX_JOBS) break;
|
|
}
|
|
if (fresh === 0) break; // server ignored FirstItem (or we've looped)
|
|
if (jobs.length >= MAX_JOBS) break;
|
|
if (total !== null && (page + 1) * PAGE_SIZE >= total) break;
|
|
}
|
|
return jobs;
|
|
},
|
|
};
|