init
This commit is contained in:
commit
d295c7f139
1527 files changed
+270726
No files matched your search
@@ -0,0 +1,76 @@
|
||||
// @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),
|
||||
}));
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user