// @ts-check /** @typedef {import('./_types.js').Provider} Provider */ // Recruitee provider — hits the public per-tenant offers API. // Auto-detects from careers_url pattern `https://.recruitee.com`. // Per-tenant subdomains are the variable part — SSRF defence uses a // regex match on `.recruitee.com` rather than a static // allowlist. const RECRUITEE_HOST_RE = /^[a-z0-9][a-z0-9-]*\.recruitee\.com$/; function assertRecruiteeUrl(url) { let parsed; try { parsed = new URL(url); } catch { throw new Error(`recruitee: invalid URL: ${url}`); } if (parsed.protocol !== 'https:') throw new Error(`recruitee: URL must use HTTPS: ${url}`); if (!RECRUITEE_HOST_RE.test(parsed.hostname)) { throw new Error(`recruitee: untrusted hostname "${parsed.hostname}" — must match .recruitee.com`); } return url; } function resolveApiUrl(entry) { const raw = typeof entry.careers_url === 'string' ? entry.careers_url : ''; if (!raw) return null; let parsed; try { parsed = new URL(raw); } catch { return null; } if (parsed.protocol !== 'https:') return null; if (!RECRUITEE_HOST_RE.test(parsed.hostname)) return null; return `https://${parsed.hostname}/api/offers/`; } /** @type {Provider} */ export default { id: 'recruitee', detect(entry) { const apiUrl = resolveApiUrl(entry); return apiUrl ? { url: apiUrl } : null; }, async fetch(entry, ctx) { const apiUrl = resolveApiUrl(entry); if (!apiUrl) throw new Error(`recruitee: cannot derive API URL for ${entry.name}`); assertRecruiteeUrl(apiUrl); const json = await ctx.fetchJson(apiUrl, { redirect: 'error' }); return parseRecruiteeResponse(json, entry.name); }, }; /** * Parse a Recruitee /api/offers/ response. Exported for unit tests. * * Recruitee returns: * { offers: [{ title, careers_url?, url?, city?, country?, remote?, location? }] } * * - url: prefer `careers_url`, fall back to `url`. Recruitee tenants commonly * serve postings on their own custom domain (e.g. `careers.hostaway.com`), * so this URL is NOT host-locked to `*.recruitee.com`. Unlike the API * endpoint, the per-offer URL is display-only — it is written to the pipeline * and scan history but never server-fetched here, so the SSRF rationale does * not apply. It is sourced from the already-validated tenant API response. * Requirement: a well-formed `https:` URL; a non-HTTPS or malformed URL is * dropped (empty string returned per the Job contract). * - location: prefer the explicit `location` field; else assemble from * city/country, appending "Remote" when `remote` is true. * * @param {any} json * @param {string} companyName * @returns {Array<{title: string, url: string, company: string, location: string}>} */ export function parseRecruiteeResponse(json, companyName) { const offers = json?.offers; if (!Array.isArray(offers)) return []; return offers.map(j => { const city = j.city || ''; const country = j.country || ''; const remote = j.remote ? 'Remote' : ''; const location = j.location || [city, country, remote].filter(Boolean).join(', '); // Resolve offer URL. Recruitee tenants commonly publish postings on their // own custom domain (e.g. careers.hostaway.com), so the per-offer URL is // NOT host-locked to *.recruitee.com — it is display-only (recorded in the // pipeline/history, never server-fetched here) and comes from the already- // validated tenant API response. Require only a well-formed https: URL; // a non-https or malformed URL is dropped (empty string per the Job contract). let url = ''; const rawUrl = j.careers_url || j.url || ''; if (typeof rawUrl === 'string' && rawUrl) { try { const parsed = new URL(rawUrl); if (parsed.protocol === 'https:') { url = parsed.href; } } catch { // malformed URL → leave url = '' } } return { title: j.title || '', url, location, company: companyName, }; }); }