Files
santifer--career-ops/providers/tkms.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

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;
},
};