1
2
3
4export const ATS_LIST = ['greenhouse', 'lever', 'ashby', 'workable', 'recruitee'];
5
6const SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,99}$/i;
7
8
9export function parseCompany(raw) {
10 const s = String(raw ?? '').trim();
11 if (!s) throw new Error('Empty company entry');
12 const pref = s.match(/^(greenhouse|lever|ashby|workable|recruitee)\s*[:/]\s*([^/?#\s]+)$/i);
13 if (pref) return { ats: pref[1].toLowerCase(), slug: pref[2], region: null };
14 if (/^https?:\/\//i.test(s) || /^[a-z0-9.-]+\.(io|co|com)\//i.test(s) || /\.recruitee\.com/i.test(s)) {
15 let u;
16 try { u = new URL(/^https?:\/\//i.test(s) ? s : `https://${s}`); } catch { throw new Error(`"${s}" is not a valid URL`); }
17 const h = u.hostname.toLowerCase();
18 const parts = u.pathname.split('/').filter(Boolean);
19 if (h.endsWith('greenhouse.io')) {
20 const forParam = u.searchParams.get('for');
21 if (forParam) return { ats: 'greenhouse', slug: forParam, region: null };
22 if (h.startsWith('boards-api')) { const i = parts.indexOf('boards'); if (i >= 0 && parts[i + 1]) return { ats: 'greenhouse', slug: parts[i + 1], region: null }; }
23 if (parts[0] && parts[0] !== 'embed') return { ats: 'greenhouse', slug: parts[0], region: null };
24 }
25 if (h.endsWith('lever.co')) {
26 const region = h.includes('.eu.') ? 'eu' : null;
27 if (h.startsWith('api.')) { const i = parts.indexOf('postings'); if (i >= 0 && parts[i + 1]) return { ats: 'lever', slug: parts[i + 1], region }; }
28 if (parts[0]) return { ats: 'lever', slug: parts[0], region };
29 }
30 if (h.endsWith('ashbyhq.com')) {
31 if (h.startsWith('api.')) { const i = parts.indexOf('job-board'); if (i >= 0 && parts[i + 1]) return { ats: 'ashby', slug: parts[i + 1], region: null }; }
32 if (parts[0]) return { ats: 'ashby', slug: parts[0], region: null };
33 }
34 if (h.endsWith('workable.com')) {
35 if (parts[0] === 'api') { const i = parts.indexOf('accounts'); if (i >= 0 && parts[i + 1]) return { ats: 'workable', slug: parts[i + 1], region: null }; }
36 if (parts[0] && parts[0] !== 'j') return { ats: 'workable', slug: parts[0], region: null };
37 }
38 if (h.endsWith('.recruitee.com')) return { ats: 'recruitee', slug: h.split('.')[0], region: null };
39 throw new Error(`"${s}" is not a supported career-site URL. Supported: Greenhouse, Lever, Ashby, Workable and Recruitee job boards (or just type the company's board name, e.g. "stripe").`);
40 }
41 if (!SLUG_RE.test(s)) throw new Error(`"${s}" is not a valid company board name (use e.g. "stripe" or a job-board URL)`);
42 return { ats: null, slug: s, region: null };
43}
44
45export function endpoint(ats, slug, region) {
46 const e = encodeURIComponent(slug);
47 switch (ats) {
48 case 'greenhouse': return `https://boards-api.greenhouse.io/v1/boards/${e}/jobs?content=true`;
49 case 'lever': return `https://api.${region === 'eu' ? 'eu.' : ''}lever.co/v0/postings/${e}?mode=json`;
50 case 'ashby': return `https://api.ashbyhq.com/posting-api/job-board/${e}?includeCompensation=true`;
51 case 'workable': return `https://apply.workable.com/api/v1/widget/accounts/${e}?details=true`;
52 case 'recruitee': return `https://${e}.recruitee.com/api/offers/`;
53 default: throw new Error(`Unknown ATS ${ats}`);
54 }
55}
56export function boardUrl(ats, slug, region) {
57 const e = encodeURIComponent(slug);
58 return {
59 greenhouse: `https://job-boards.greenhouse.io/${e}`,
60 lever: `https://jobs.${region === 'eu' ? 'eu.' : ''}lever.co/${e}`,
61 ashby: `https://jobs.ashbyhq.com/${e}`,
62 workable: `https://apply.workable.com/${e}`,
63 recruitee: `https://${e}.recruitee.com`,
64 }[ats];
65}
66
67const ENT = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'", ndash: '–', mdash: '—', rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“', hellip: '…', bull: '•', middot: '·', euro: '€', pound: '£', copy: '©', reg: '®', trade: '™' };
68export function decodeEntities(s) {
69 return String(s ?? '').replace(/&(#x[0-9a-f]+|#\d+|[a-z0-9]+);/gi, (m, g) => {
70 if (g[0] === '#') { const n = g[1].toLowerCase() === 'x' ? parseInt(g.slice(2), 16) : parseInt(g.slice(1), 10); return Number.isFinite(n) && n > 0 && n < 0x110000 ? String.fromCodePoint(n) : m; }
71 return ENT[g.toLowerCase()] ?? m;
72 });
73}
74export function htmlToText(html) {
75 if (!html) return null;
76 let s = String(html);
77 if (/<\/?[a-z]/i.test(s)) s = decodeEntities(s);
78 s = s.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
79 .replace(/<br\s*\/?>/gi, '\n').replace(/<li[^>]*>/gi, '\n• ').replace(/<\/(p|div|h[1-6]|li|ul|ol|tr|section)>/gi, '\n')
80 .replace(/<[^>]+>/g, ' ');
81 s = decodeEntities(s).replace(/[ \t\u00a0]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{3,}/g, '\n\n').trim();
82 return s || null;
83}
84export function decodedHtml(html) {
85 if (!html) return null;
86 const s = String(html);
87 return /<\/?[a-z]/i.test(s) ? decodeEntities(s) : s;
88}
89
90const CUR = { $: 'USD', '£': 'GBP', '€': 'EUR', USD: 'USD', GBP: 'GBP', EUR: 'EUR', CAD: 'CAD', AUD: 'AUD', CHF: 'CHF' };
91function num(x, k) { let n = parseFloat(String(x).replace(/,/g, '')); if (/k/i.test(k || '')) n *= 1000; return n; }
92
93export function extractSalary(text) {
94 if (!text) return null;
95 const re = /(USD|CAD|AUD|GBP|EUR|CHF|[$£€])\s?(\d{1,3}(?:[,.]\d{3})+|\d+(?:\.\d+)?)\s?([kK])?\s*(?:-|–|—|to)\s*(?:USD|CAD|AUD|GBP|EUR|CHF|[$£€])?\s?(\d{1,3}(?:[,.]\d{3})+|\d+(?:\.\d+)?)\s?([kK])?/;
96 const m = String(text).match(re);
97 if (!m) return null;
98 const min = num(m[2], m[3] || m[5]); const max = num(m[4], m[5]);
99 if (!(min > 0 && max >= min)) return null;
100 if (max < 1000 && !/k/i.test((m[3] || '') + (m[5] || ''))) {
101 return { salaryText: m[0].trim(), salaryMin: min, salaryMax: max, salaryCurrency: CUR[m[1]] ?? null, salaryInterval: max < 500 ? 'hour' : null };
102 }
103 return { salaryText: m[0].trim(), salaryMin: min, salaryMax: max, salaryCurrency: CUR[m[1]] ?? null, salaryInterval: 'year' };
104}
105
106const iso = (v) => {
107 if (v === null || v === undefined || v === '') return null;
108 const d = typeof v === 'number' ? new Date(v) : new Date(String(v).replace(' UTC', 'Z').replace(/^(\d{4}-\d\d-\d\d) (\d)/, '$1T$2'));
109 return Number.isNaN(d.getTime()) ? null : d.toISOString();
110};
111const isRemoteText = (s) => /\bremote\b|anywhere|work from home|telecommut/i.test(s || '');
112
113function base(ats, slug, companyName) {
114 return { ats, companySlug: slug, companyName: companyName || null };
115}
116function finish(o, includeHtml) {
117 const sal = o.salaryMin == null ? extractSalary(o.descriptionText) : null;
118 if (sal) Object.assign(o, sal);
119 if (!includeHtml) delete o.descriptionHtml;
120 for (const k of ['salaryText', 'salaryMin', 'salaryMax', 'salaryCurrency', 'salaryInterval']) if (o[k] === undefined) o[k] = null;
121 return o;
122}
123
124
125export class FormatError extends Error {}
126
127
128
129
130
131
132
133export function pickBoard(candidates) {
134 const found = candidates.filter(Boolean);
135 if (!found.length) return null;
136 let best = found[0];
137 for (const c of found) if (c.jobs.length > best.jobs.length) best = c;
138 return { best, others: found.filter((c) => c !== best && c.jobs.length > 0) };
139}
140
141
142
143
144
145
146
147export async function resolveBoard(t, fetchJson, atsList = ATS_LIST) {
148 if (t.ats) {
149 const r = await fetchJson(endpoint(t.ats, t.slug, t.region));
150 if (r.notFound) return null;
151 return { ats: t.ats, ...extractRaw(t.ats, r.body), alsoFoundOn: [] };
152 }
153 const errors = [];
154 const candidates = await Promise.all(atsList.map(async (ats) => {
155 try {
156 const r = await fetchJson(endpoint(ats, t.slug, t.region));
157 return r.notFound ? null : { ats, ...extractRaw(ats, r.body) };
158 } catch (e) { errors.push(e); return null; }
159 }));
160 const pick = pickBoard(candidates);
161
162 if ((!pick || !pick.best.jobs.length) && errors.length) throw errors.find((e) => e instanceof FormatError) ?? errors[0];
163 if (!pick) return null;
164 return { ...pick.best, alsoFoundOn: pick.others.map((c) => ({ ats: c.ats, jobs: c.jobs.length })) };
165}
166
167export function extractRaw(ats, body) {
168 switch (ats) {
169 case 'greenhouse': if (!Array.isArray(body?.jobs)) throw new FormatError('greenhouse: no jobs array'); return { companyName: body.jobs[0]?.company_name ?? null, jobs: body.jobs };
170 case 'lever': if (!Array.isArray(body)) throw new FormatError('lever: response is not an array'); return { companyName: null, jobs: body };
171 case 'ashby': if (!Array.isArray(body?.jobs)) throw new FormatError('ashby: no jobs array'); return { companyName: null, jobs: body.jobs.filter((j) => j.isListed !== false) };
172 case 'workable': if (!Array.isArray(body?.jobs)) throw new FormatError('workable: no jobs array'); return { companyName: body.name ?? null, jobs: body.jobs };
173 case 'recruitee': if (!Array.isArray(body?.offers)) throw new FormatError('recruitee: no offers array'); return { companyName: body.offers[0]?.company_name ?? null, jobs: body.offers };
174 default: throw new FormatError(`unknown ats ${ats}`);
175 }
176}
177
178export function mapJob(ats, slug, companyName, j, { includeHtml = false, region = null } = {}) {
179 const b = base(ats, slug, companyName);
180 if (ats === 'greenhouse') {
181 const html = decodedHtml(j.content); const text = htmlToText(j.content);
182 const loc = j.location?.name ?? null;
183 return finish({ ...b, jobId: String(j.id), title: j.title?.trim() ?? null,
184 department: j.departments?.map((d) => d.name).filter(Boolean).join(', ') || null, team: null,
185 location: loc, locations: loc ? [loc] : [], country: null,
186 remote: isRemoteText(loc) || null, workplaceType: isRemoteText(loc) ? 'remote' : null, employmentType: null,
187 postedAt: iso(j.first_published), updatedAt: iso(j.updated_at),
188 jobUrl: j.absolute_url ?? null, applyUrl: j.absolute_url ?? null, boardUrl: boardUrl(ats, slug),
189 descriptionText: text, descriptionHtml: html }, includeHtml);
190 }
191 if (ats === 'lever') {
192 const c = j.categories || {};
193 const html = [j.opening, j.descriptionBody || j.description, ...(j.lists || []).map((l) => `<h3>${l.text}</h3><ul>${l.content}</ul>`), j.additional].filter(Boolean).join('\n');
194 const text = [j.openingPlain || j.descriptionPlain, ...(j.lists || []).map((l) => `${l.text}\n${htmlToText(l.content) || ''}`), j.additionalPlain].filter(Boolean).join('\n\n').trim() || htmlToText(html);
195 const sr = j.salaryRange;
196 const locs = c.allLocations?.length ? c.allLocations : (c.location ? [c.location] : []);
197 return finish({ ...b, jobId: j.id, title: j.text?.trim() ?? null, department: c.department ?? null, team: c.team ?? null,
198 location: c.location ?? null, locations: locs, country: j.country ?? null,
199 remote: j.workplaceType ? j.workplaceType === 'remote' : (isRemoteText(c.location) || null), workplaceType: j.workplaceType && j.workplaceType !== 'unspecified' ? j.workplaceType : null,
200 employmentType: c.commitment ?? null, postedAt: iso(j.createdAt), updatedAt: null,
201 jobUrl: j.hostedUrl ?? null, applyUrl: j.applyUrl ?? null, boardUrl: boardUrl(ats, slug, region),
202 ...(sr ? { salaryText: null, salaryMin: sr.min ?? null, salaryMax: sr.max ?? null, salaryCurrency: sr.currency ?? null, salaryInterval: sr.interval ?? null } : {}),
203 descriptionText: text, descriptionHtml: html || null }, includeHtml);
204 }
205 if (ats === 'ashby') {
206 const comp = j.compensation || {};
207 const tier = comp.summaryComponents?.find((x) => x.compensationType === 'Salary') ?? comp.compensationTiers?.[0]?.components?.find((x) => x.compensationType === 'Salary');
208 const locs = [j.location, ...(j.secondaryLocations || []).map((l) => l.location)].filter(Boolean);
209 return finish({ ...b, jobId: j.id, title: j.title?.trim() ?? null, department: j.department ?? null, team: j.team ?? null,
210 location: j.location ?? null, locations: locs, country: j.address?.postalAddress?.addressCountry ?? null,
211 remote: typeof j.isRemote === 'boolean' ? j.isRemote : null, workplaceType: j.workplaceType ? String(j.workplaceType).toLowerCase() : null,
212 employmentType: j.employmentType ?? null, postedAt: iso(j.publishedAt), updatedAt: null,
213 jobUrl: j.jobUrl ?? null, applyUrl: j.applyUrl ?? null, boardUrl: boardUrl(ats, slug),
214 ...(tier ? { salaryText: comp.scrapeableCompensationSalarySummary ?? comp.compensationTierSummary ?? null, salaryMin: tier.minValue ?? null, salaryMax: tier.maxValue ?? null, salaryCurrency: tier.currencyCode ?? null, salaryInterval: tier.interval ? String(tier.interval).replace(/^1 /, '').toLowerCase() : null } : {}),
215 descriptionText: j.descriptionPlain?.trim() || htmlToText(j.descriptionHtml), descriptionHtml: j.descriptionHtml ?? null }, includeHtml);
216 }
217 if (ats === 'workable') {
218 const locs = (j.locations || []).filter((l) => !l.hidden).map((l) => [l.city, l.region, l.country].filter(Boolean).join(', ')).filter(Boolean);
219 const loc = [j.city, j.state, j.country].filter(Boolean).join(', ') || locs[0] || null;
220 return finish({ ...b, jobId: j.shortcode ?? null, title: j.title?.trim() ?? null, department: j.department || null, team: j.function || null,
221 location: loc, locations: locs.length ? locs : (loc ? [loc] : []), country: j.locations?.[0]?.countryCode ?? null,
222 remote: typeof j.telecommuting === 'boolean' ? j.telecommuting : null, workplaceType: j.telecommuting ? 'remote' : null,
223 employmentType: j.employment_type || null, postedAt: iso(j.published_on || j.created_at), updatedAt: null,
224 jobUrl: j.url ?? j.shortlink ?? null, applyUrl: j.application_url ?? null, boardUrl: boardUrl(ats, slug),
225 descriptionText: htmlToText(j.description), descriptionHtml: j.description ?? null }, includeHtml);
226 }
227 if (ats === 'recruitee') {
228 const locs = (j.locations || []).map((l) => [l.city, l.state, l.country].filter(Boolean).join(', ')).filter(Boolean);
229 const html = [j.description, j.requirements].filter(Boolean).join('\n');
230 const s = j.salary || {};
231 return finish({ ...b, jobId: String(j.id), title: j.title?.trim() ?? null, department: j.department ?? null, team: null,
232 location: j.location || locs[0] || null, locations: locs, country: j.country_code ?? null,
233 remote: typeof j.remote === 'boolean' ? j.remote : null, workplaceType: j.remote ? 'remote' : (j.hybrid ? 'hybrid' : (j.on_site ? 'onsite' : null)),
234 employmentType: j.employment_type_code ?? null, postedAt: iso(j.published_at || j.created_at), updatedAt: iso(j.updated_at),
235 jobUrl: j.careers_url ?? null, applyUrl: j.careers_apply_url ?? null, boardUrl: boardUrl(ats, slug),
236 ...(s.min || s.max ? { salaryText: null, salaryMin: s.min ? Number(s.min) : null, salaryMax: s.max ? Number(s.max) : null, salaryCurrency: s.currency ?? null, salaryInterval: s.period ?? null } : {}),
237 descriptionText: htmlToText(html), descriptionHtml: html || null }, includeHtml);
238 }
239 throw new FormatError(`unknown ats ${ats}`);
240}
241
242export function parseDateInput(v, now = Date.now()) {
243 if (v === null || v === undefined || String(v).trim() === '') return null;
244 const s = String(v).trim();
245 const rel = s.match(/^(\d+)\s*(hour|day|week|month)s?(\s+ago)?$/i);
246 if (rel) { const n = Number(rel[1]); const ms = { hour: 3600e3, day: 86400e3, week: 7 * 86400e3, month: 30 * 86400e3 }[rel[2].toLowerCase()]; return new Date(now - n * ms); }
247 const d = new Date(s);
248 if (Number.isNaN(d.getTime())) throw new Error(`"${s}" is not a valid date. Use e.g. 2026-10-01 or "7 days".`);
249 return d;
250}
251
252
253export function matches(job, f) {
254 const lc = (x) => String(x ?? '').toLowerCase();
255 if (f.titleKeywords?.length && !f.titleKeywords.some((k) => lc(job.title).includes(lc(k)))) return false;
256 if (f.excludeTitleKeywords?.length && f.excludeTitleKeywords.some((k) => lc(job.title).includes(lc(k)))) return false;
257 if (f.locationKeywords?.length) {
258 const hay = lc([job.location, ...(job.locations || []), job.country].join(' | '));
259 if (!f.locationKeywords.some((k) => hay.includes(lc(k)))) return false;
260 }
261 if (f.departmentKeywords?.length && !f.departmentKeywords.some((k) => lc(`${job.department} ${job.team}`).includes(lc(k)))) return false;
262 if (f.remoteOnly && job.remote !== true) return false;
263 if (f.since && job.postedAt && new Date(job.postedAt) < f.since) return false;
264 if (f.since && !job.postedAt && f.strictDates) return false;
265 return true;
266}