init
This commit is contained in:
commit
d295c7f139
1527 files changed
+270726
No files matched your search
@@ -0,0 +1,114 @@
|
||||
// @ts-check
|
||||
/** @typedef {import('./_types.js').Provider} Provider */
|
||||
|
||||
// Recruitee provider — hits the public per-tenant offers API.
|
||||
// Auto-detects from careers_url pattern `https://<slug>.recruitee.com`.
|
||||
// Per-tenant subdomains are the variable part — SSRF defence uses a
|
||||
// regex match on `<safe-slug>.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 <slug>.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,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user