77 lines
2.5 KiB
JavaScript
77 lines
2.5 KiB
JavaScript
// @ts-check
|
|
/** @typedef {import('./_types.js').Provider} Provider */
|
|
|
|
// Greenhouse provider — hits the public boards-api JSON endpoint.
|
|
// Handles both explicit `api:` URLs and auto-detection from `careers_url`.
|
|
|
|
const ALLOWED_GREENHOUSE_HOSTS = new Set([
|
|
'boards-api.greenhouse.io',
|
|
'boards.greenhouse.io',
|
|
'job-boards.greenhouse.io',
|
|
'job-boards.eu.greenhouse.io',
|
|
]);
|
|
|
|
/** @param {string} url */
|
|
function assertGreenhouseUrl(url) {
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
throw new Error(`greenhouse: invalid URL: ${url}`);
|
|
}
|
|
if (parsed.protocol !== 'https:') throw new Error(`greenhouse: URL must use HTTPS: ${url}`);
|
|
if (!ALLOWED_GREENHOUSE_HOSTS.has(parsed.hostname))
|
|
throw new Error(`greenhouse: untrusted hostname "${parsed.hostname}" — must be one of: ${[...ALLOWED_GREENHOUSE_HOSTS].join(', ')}`);
|
|
return url;
|
|
}
|
|
|
|
/** @param {import('./_types.js').PortalEntry} entry */
|
|
function resolveApiUrl(entry) {
|
|
if (entry.api) {
|
|
assertGreenhouseUrl(entry.api);
|
|
return entry.api;
|
|
}
|
|
const url = entry.careers_url || '';
|
|
const match = url.match(/job-boards(?:\.eu)?\.greenhouse\.io\/([^/?#]+)/);
|
|
if (match) return `https://boards-api.greenhouse.io/v1/boards/${match[1]}/jobs`;
|
|
return null;
|
|
}
|
|
|
|
// NaN-safe Date.parse — `|| undefined` would also coerce a valid epoch 0.
|
|
function toEpochMs(value) {
|
|
if (!value) return undefined;
|
|
const parsed = Date.parse(value);
|
|
return Number.isNaN(parsed) ? undefined : parsed;
|
|
}
|
|
|
|
/** @type {Provider} */
|
|
export default {
|
|
id: 'greenhouse',
|
|
|
|
detect(entry) {
|
|
try {
|
|
const apiUrl = resolveApiUrl(entry);
|
|
return apiUrl ? { url: apiUrl } : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
async fetch(entry, ctx) {
|
|
const apiUrl = resolveApiUrl(entry);
|
|
if (!apiUrl) throw new Error(`greenhouse: cannot derive API URL for ${entry.name}`);
|
|
assertGreenhouseUrl(apiUrl);
|
|
// redirect:'error' prevents SSRF via server-side redirects; combined with
|
|
// assertGreenhouseUrl above it guarantees the final hostname stays in the allowlist.
|
|
const json = /** @type {any} */ (await ctx.fetchJson(apiUrl, { redirect: 'error' }));
|
|
const jobs = Array.isArray(json?.jobs) ? json.jobs : [];
|
|
return jobs.filter(/** @param {any} j */ j => j.absolute_url).map(/** @param {any} j */ j => ({
|
|
title: j.title || '',
|
|
url: j.absolute_url,
|
|
company: entry.name,
|
|
location: j.location?.name || '',
|
|
postedAt: toEpochMs(j.first_published),
|
|
}));
|
|
},
|
|
};
|