172 lines
6.3 KiB
JavaScript
172 lines
6.3 KiB
JavaScript
// @ts-check
|
|
/** @typedef {import('./_types.js').Provider} Provider */
|
|
|
|
// TKMS (thyssenkrupp Marine Systems) provider — the careers app at
|
|
// jobs.tkmsgroup.com. Single-employer, but the backend is a shared job-board
|
|
// platform keyed by a `subclient` param, so the provider takes the subclient
|
|
// (and locale) from a config block for reuse if another tenant on the same
|
|
// platform ever needs it.
|
|
//
|
|
// POST {origin}/api/filter/query
|
|
// Content-Type: application/json
|
|
// {"searchQuery":"","filter":{},"subclient":"tkms","locale":"en","page":0}
|
|
// → {"jobs":[{"_geoloc":[…],"data":{id,title,city,country,state,company,
|
|
// postingDate:"2026-07-02T22:00:00",locations:[{city,cityState,…}],…}}],
|
|
// "page":0,"jobsPerPage":20,"nextPage":1,"totalHits":330}
|
|
//
|
|
// `page` is 0-based; `nextPage` is null on the last page. The public job page is
|
|
// {origin}/{locale}/job/{slug}/{id} — the slug is cosmetic (a stub resolves the
|
|
// same posting, verified), so we slugify the title.
|
|
//
|
|
// Detection: jobs.tkmsgroup.com is the only known host, and it carries no
|
|
// generic platform token, so detect() claims that host explicitly.
|
|
|
|
const MAX_PAGES = 60; // safety cap on request count (60*20 = 1200 postings)
|
|
const MAX_JOBS = 1000; // cap total postings pulled
|
|
const PAGE_DELAY_MS = 150; // polite pacing between page requests
|
|
|
|
/** @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:') return null;
|
|
const host = u.host.toLowerCase();
|
|
if (host !== 'jobs.tkmsgroup.com') return null;
|
|
const block = entry.tkms && typeof entry.tkms === 'object' ? entry.tkms : {};
|
|
const locale = typeof block.locale === 'string' ? block.locale : 'en';
|
|
return {
|
|
origin: u.origin,
|
|
queryApi: `${u.origin}/api/filter/query`,
|
|
subclient: typeof block.subclient === 'string' ? block.subclient : 'tkms',
|
|
locale,
|
|
};
|
|
}
|
|
|
|
// Slugify a title for the cosmetic job-page slug (TKMS uses `_` for the
|
|
// gender marker and `-` between words; a stub slug also resolves, so exactness
|
|
// doesn't matter — we just produce a clean, hyphenated string).
|
|
/** @param {string} title */
|
|
export function slugify(title) {
|
|
return String(title)
|
|
.normalize('NFKD')
|
|
.replace(/[̀-ͯ]/g, '')
|
|
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '') || 'job';
|
|
}
|
|
|
|
// postingDate is a naive ISO local timestamp ("2026-07-02T22:00:00"); prefer
|
|
// the epoch-seconds `postingDate_timestamp` when present.
|
|
/** @param {any} data @returns {number | undefined} */
|
|
export function parseTkmsDate(data) {
|
|
const ts = data?.postingDate_timestamp;
|
|
if (typeof ts === 'number' && Number.isFinite(ts)) return ts * 1000;
|
|
const raw = data?.postingDate;
|
|
if (typeof raw === 'string' && raw.trim()) {
|
|
const ms = Date.parse(raw.trim() + 'Z'); // treat naive stamp as UTC for determinism
|
|
if (Number.isFinite(ms)) return ms;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// Location: prefer the `locations` array (cityState per site), else the flat
|
|
// city/country fields. Joined with " / ", deduped.
|
|
/** @param {any} data @returns {string} */
|
|
export function tkmsLocation(data) {
|
|
const locs = Array.isArray(data?.locations) ? data.locations : [];
|
|
const out = [];
|
|
for (const l of locs) {
|
|
const s = String(l?.cityState || l?.city || '').trim();
|
|
if (s && !out.includes(s)) out.push(s);
|
|
}
|
|
if (out.length) return out.join(' / ');
|
|
const flat = [data?.city, data?.country].map((p) => String(p || '').trim()).filter(Boolean);
|
|
return [...new Set(flat)].join(', ');
|
|
}
|
|
|
|
/**
|
|
* Map one query response to {total, nextPage, rows}. A record without an id or
|
|
* title is skipped.
|
|
* @param {any} json @param {{origin:string, locale:string}} cfg
|
|
*/
|
|
export function parseQuery(json, cfg) {
|
|
const total = typeof json?.totalHits === 'number' ? json.totalHits : null;
|
|
const nextPage = typeof json?.nextPage === 'number' ? json.nextPage : null;
|
|
const list = Array.isArray(json?.jobs) ? json.jobs : [];
|
|
const rows = [];
|
|
for (const item of list) {
|
|
const d = item?.data;
|
|
if (!d) continue;
|
|
const id = d.id != null ? String(d.id) : '';
|
|
const title = String(d.title || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
if (!id || !title) continue;
|
|
rows.push({
|
|
id,
|
|
title,
|
|
url: `${cfg.origin}/${encodeURIComponent(cfg.locale)}/job/${slugify(title)}/${encodeURIComponent(id)}`,
|
|
location: tkmsLocation(d),
|
|
postedAt: parseTkmsDate(d),
|
|
});
|
|
}
|
|
return { total, nextPage, 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: 'tkms',
|
|
|
|
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(`tkms: cannot resolve jobs 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();
|
|
|
|
for (let page = 0; page < maxPages; page++) {
|
|
if (page > 0) await wait(PAGE_DELAY_MS);
|
|
const json = await ctx.fetchJson(cfg.queryApi, {
|
|
method: 'POST',
|
|
redirect: 'error',
|
|
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
body: JSON.stringify({ searchQuery: '', filter: {}, subclient: cfg.subclient, locale: cfg.locale, page }),
|
|
});
|
|
const { nextPage, rows } = parseQuery(json, cfg);
|
|
if (rows.length === 0) break;
|
|
|
|
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 looped / ignored page
|
|
if (jobs.length >= MAX_JOBS) break;
|
|
if (nextPage === null) break; // last page
|
|
}
|
|
return jobs;
|
|
},
|
|
};
|