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

143 lines
5.9 KiB
JavaScript

// @ts-check
/** @typedef {import('./_types.js').Provider} Provider */
// JibeApply provider — hits the /api/jobs endpoint on the same hostname.
// Auto-detects from careers_url pattern `https://<slug>.jibeapply.com`.
// iCIMS acquired Jibe in 2019; some tenants run it on a branded custom
// domain instead. The JSON API at /api/jobs is stable across all tenants.
// Safety cap on pagination — applied regardless of what the upstream reports
// as totalCount, so a misbehaving/compromised API can't drive this into
// fetching thousands of pages. Every known real tenant (10/page) stays well
// under the default of 50 pages (500 jobs); override with `max_pages` on the
// portal entry for a tenant that genuinely exceeds it — each page is a
// sequential round-trip, so raising the default for everyone isn't free.
const DEFAULT_MAX_PAGES = 50;
const MAX_PAGES_CAP = 500;
// Fallback page size when a response carries no jobs and no usable `count`
// (e.g. an empty first page). Confirmed live against real tenants — JibeApply
// reports this as `filter.displayLimit` and it has been 10 on every tenant
// observed so far.
const DEFAULT_PAGE_SIZE = 10;
/** Resolve the page cap: a positive integer `max_pages` on the entry, capped. */
function resolveMaxPages(entry) {
const v = entry?.max_pages;
if (Number.isInteger(v) && v > 0) return Math.min(v, MAX_PAGES_CAP);
return DEFAULT_MAX_PAGES;
}
function toApiUrl(rawUrl) {
let u;
try { u = new URL(rawUrl); } catch { return null; }
if (u.protocol !== 'https:') return null;
if (!/^[a-z0-9-]+\.jibeapply\.com$/i.test(u.hostname)) return null;
if (!u.pathname.startsWith('/api/')) {
u.pathname = '/api' + (u.pathname.startsWith('/') ? u.pathname : '/' + u.pathname);
}
return u.href;
}
// Validate an explicit entry.api URL (any HTTPS hostname — used for iCIMS-hosted,
// branded JibeApply sites that share the same JSON schema).
function validateExplicitApi(apiUrl) {
let u;
try { u = new URL(apiUrl); } catch { return null; }
return u.protocol === 'https:' ? u.href : null;
}
export function parseJibeapplyResponse(json, entry) {
let origin = '';
try { origin = new URL(entry.careers_url || '').origin; } catch { /* ignore */ }
// careers_url isn't required to be a well-formed absolute URL for fetch()
// to succeed (an explicit entry.api bypasses it entirely — see toApiUrl
// callers below) — fall back to api's origin so job URLs stay absolute
// instead of silently degrading to a relative "/jobs/<slug>" path.
if (!origin) {
try { origin = new URL(entry.api || '').origin; } catch { /* ignore */ }
}
const items = Array.isArray(json?.jobs) ? json.jobs : [];
return items
.map(item => {
if (item == null) return null;
const d = item.data ?? item;
const title = String(d.title || '').trim();
const slug = d.slug || d.req_id;
if (!title || !slug) return null;
return {
title,
url: `${origin}/jobs/${encodeURIComponent(slug)}`,
company: String(d.hiring_organization || entry.name || '').trim(),
location: d.full_location || [d.city, d.country].filter(Boolean).join(', '),
};
})
.filter(Boolean);
}
/** @type {Provider} */
export default {
id: 'jibeapply',
detect(entry) {
const url = entry.careers_url;
if (typeof url !== 'string') return null;
const apiUrl = toApiUrl(url);
if (!apiUrl) return null;
return { url: apiUrl };
},
async fetch(entry, ctx) {
const url = entry.careers_url;
if (typeof url !== 'string' || !url) throw new Error('jibeapply: careers_url required');
// Prefer an explicit entry.api (allows iCIMS-hosted, branded JibeApply sites
// that share the same JSON schema but aren't on jibeapply.com).
const apiUrl = (typeof entry.api === 'string' && validateExplicitApi(entry.api))
|| toApiUrl(url);
if (!apiUrl) throw new Error(`jibeapply: cannot derive API URL for ${entry.name}`);
const first = await ctx.fetchJson(apiUrl, { redirect: 'error' });
const total = first.totalCount ?? 0;
// Use the actual number of items returned as page size — some implementations
// set `count` to the total rather than the per-page count.
const pageSize = first.jobs?.length || first.count || DEFAULT_PAGE_SIZE;
const allJobs = [...(first.jobs ?? [])];
if (total > pageSize && pageSize > 0) {
const maxPages = resolveMaxPages(entry);
const pages = Math.min(Math.ceil(total / pageSize), maxPages);
// Sequential, not concurrent (mirrors providers/4dayweek.mjs, thehub.mjs,
// arbeitnow.mjs, workday.mjs) — a single tenant's API has no reason to
// receive a burst of parallel requests, and a mid-run failure stops
// cleanly with whatever pages were already gathered instead of
// discarding them (Promise.all would fail the whole batch on one error).
for (let page = 2; page <= pages; page++) {
const u2 = new URL(apiUrl);
u2.searchParams.set('page', String(page));
let json;
try {
json = await ctx.fetchJson(u2.toString(), { redirect: 'error' });
} catch (err) {
console.error(`⚠️ jibeapply: ${entry.name} page ${page} fetch failed — ${err.message} (returning ${allJobs.length} jobs fetched so far)`);
break;
}
allJobs.push(...(json.jobs ?? []));
}
// The cap is silent by design (it's a safety net, not a working limit),
// but a tenant that actually exceeds it needs to be surfaced —
// otherwise the user has no way to notice postings are missing from
// their scan.
if (Math.ceil(total / pageSize) > maxPages) {
console.error(
`⚠️ jibeapply: ${entry.name} has more postings than max_pages allows ` +
`(fetched ${allJobs.length} of ${total}) — ` +
`set max_pages on this portal entry to raise the cap (current: ${maxPages})`,
);
}
}
return parseJibeapplyResponse({ jobs: allJobs }, entry);
},
};