77 lines
2.5 KiB
JavaScript
77 lines
2.5 KiB
JavaScript
// @ts-check
|
|
/** @typedef {import('./_types.js').Provider} Provider */
|
|
|
|
// Lever provider — hits the public postings endpoint.
|
|
// Auto-detects from careers_url via jobs.(eu.)?lever.co/<slug>.
|
|
// Handles both explicit `api:` URLs and auto-detection from `careers_url`.
|
|
|
|
const ALLOWED_LEVER_HOSTS = new Set(['api.lever.co', 'api.eu.lever.co']);
|
|
|
|
/** @param {string} url */
|
|
function assertLeverUrl(url) {
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
throw new Error(`lever: invalid URL: ${url}`);
|
|
}
|
|
if (parsed.protocol !== 'https:') throw new Error(`lever: URL must use HTTPS: ${url}`);
|
|
if (!ALLOWED_LEVER_HOSTS.has(parsed.hostname))
|
|
throw new Error(`lever: untrusted hostname "${parsed.hostname}" — must be one of: ${[...ALLOWED_LEVER_HOSTS].join(', ')}`);
|
|
return url;
|
|
}
|
|
|
|
/** @param {import('./_types.js').PortalEntry} entry */
|
|
function resolveApiUrl(entry) {
|
|
// Explicit api: wins — lets an entry keep a human-facing corporate
|
|
// careers_url (e.g. https://www.coalfire.com/careers) while still pinning
|
|
// the Lever postings board (mirrors greenhouse's api: precedence).
|
|
if (entry.api) {
|
|
assertLeverUrl(entry.api);
|
|
return entry.api;
|
|
}
|
|
let url;
|
|
try {
|
|
url = new URL(entry.careers_url || '');
|
|
} catch {
|
|
return null;
|
|
}
|
|
const host = url.hostname.match(/^jobs\.((?:eu\.)?lever\.co)$/);
|
|
if (!host) return null;
|
|
const slug = url.pathname.split('/').filter(Boolean)[0];
|
|
if (!slug) return null;
|
|
return `https://api.${host[1]}/v0/postings/${slug}`;
|
|
}
|
|
|
|
/** @type {Provider} */
|
|
export default {
|
|
id: 'lever',
|
|
|
|
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(`lever: cannot derive API URL for ${entry.name}`);
|
|
assertLeverUrl(apiUrl);
|
|
const json = await ctx.fetchJson(apiUrl, { redirect: 'error' });
|
|
if (!Array.isArray(json)) return [];
|
|
return json.map(j => ({
|
|
title: j.text || '',
|
|
url: j.hostedUrl || '',
|
|
company: entry.name,
|
|
location: j.categories?.location || '',
|
|
// Lever's v0 postings list ships the full description for free (same
|
|
// payload, no per-job request) — enables scan.mjs content_filter.
|
|
description: typeof j.descriptionPlain === 'string' ? j.descriptionPlain : '',
|
|
postedAt: typeof j.createdAt === 'number' ? j.createdAt : undefined,
|
|
}));
|
|
},
|
|
};
|