1
2
3const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
4const decode = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
5 if (e[0] === '#') return String.fromCodePoint(e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : +e.slice(1));
6 return ENTITIES[e.toLowerCase()] ?? m;
7});
8
9export function htmlToText(html) {
10 if (!html) return null;
11 let s = decode(html);
12 s = s.replace(/<(br|\/p|\/div|\/li|\/h\d|\/tr)[^>]*>/gi, '\n').replace(/<li[^>]*>/gi, '- ').replace(/<[^>]+>/g, '');
13 s = decode(s).replace(/ /g, ' ').replace(/[ \t]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{3,}/g, '\n\n');
14 return s.trim() || null;
15}
16
17
18export const ATS = ['greenhouse', 'lever', 'ashby'];
19export const ALL_ATS = [...ATS, 'workable', 'recruitee', 'personio'];
20
21
22export function parseBoard(entry) {
23 const raw = String(entry ?? '').trim();
24 if (!raw) return null;
25 const pre = raw.match(/^(greenhouse|lever|ashby|workable|recruitee|personio):([\w.-]+)$/i);
26 if (pre) return { ats: pre[1].toLowerCase(), slug: pre[2], eu: false };
27 let url;
28 try { url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`); } catch { url = null; }
29 if (url && url.hostname.includes('.')) {
30 const host = url.hostname.toLowerCase();
31 const parts = url.pathname.split('/').filter(Boolean);
32 const q = url.searchParams.get('for');
33 if (host.includes('greenhouse.io')) {
34 const slug = q ?? parts.find((p) => !['embed', 'job_board', 'v1', 'boards', 'jobs'].includes(p));
35 return slug ? { ats: 'greenhouse', slug, eu: host.includes('.eu.') } : null;
36 }
37 if (host.endsWith('lever.co')) {
38 const slug = host.startsWith('api.') ? parts[2] : parts[0];
39 return slug ? { ats: 'lever', slug, eu: host.includes('.eu.') } : null;
40 }
41 if (host.endsWith('ashbyhq.com')) {
42 const slug = host.startsWith('api.') ? parts[2] : parts[0];
43 return slug ? { ats: 'ashby', slug: decodeURIComponent(slug), eu: false } : null;
44 }
45 if (host === 'apply.workable.com') {
46 const slug = parts[0] === 'api' ? parts[4] : parts[0];
47 return slug && slug !== 'j' ? { ats: 'workable', slug, eu: false } : null;
48 }
49 const sub = host.split('.')[0];
50 if (/^[\w-]+\.recruitee\.com$/.test(host) && sub !== 'www') return { ats: 'recruitee', slug: sub, eu: false };
51 if (/^[\w-]+\.jobs\.personio\.(de|com)$/.test(host)) return { ats: 'personio', slug: sub, eu: host.endsWith('.com') };
52 return null;
53 }
54 return /^[\w.-]+$/.test(raw) ? { ats: null, slug: raw, eu: false } : null;
55}
56
57export function apiUrl(ats, slug, eu = false) {
58 const s = encodeURIComponent(slug);
59 if (ats === 'greenhouse') return `https://boards-api${eu ? '.eu' : ''}.greenhouse.io/v1/boards/${s}/jobs?content=true&pay_transparency=true`;
60 if (ats === 'lever') return `https://api${eu ? '.eu' : ''}.lever.co/v0/postings/${s}?mode=json`;
61 if (ats === 'ashby') return `https://api.ashbyhq.com/posting-api/job-board/${s}?includeCompensation=true`;
62
63 if (ats === 'workable') return `https://www.workable.com/api/accounts/${s}?details=true`;
64 if (ats === 'recruitee') return `https://${s}.recruitee.com/api/offers/`;
65 return `https://${s}.jobs.personio.${eu ? 'com' : 'de'}/xml?language=en`;
66}
67
68export const personioUrl = (slug, id, eu = false) => `https://${encodeURIComponent(slug)}.jobs.personio.${eu ? 'com' : 'de'}/job/${id}`;
69
70
71const cdata = (v) => v.replace(/^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/, '$1');
72const tag = (x, t) => { const m = x.match(new RegExp(`<${t}>([\\s\\S]*?)</${t}>`)); return m ? decode(cdata(m[1])).trim() || null : null; };
73export function personioJobs(xml) {
74 return [...String(xml ?? '').matchAll(/<position>([\s\S]*?)<\/position>/g)].map(([, p]) => {
75 const extra = p.match(/<additionalOffices>([\s\S]*?)<\/additionalOffices>/)?.[1] ?? '';
76 const rest = p.replace(/<additionalOffices>[\s\S]*?<\/additionalOffices>/, '').replace(/<jobDescriptions>[\s\S]*?<\/jobDescriptions>/, '');
77 const job = Object.fromEntries(['id', 'subcompany', 'office', 'department', 'recruitingCategory', 'name', 'employmentType',
78 'seniority', 'schedule', 'createdAt'].map((t) => [t, tag(rest, t)]));
79 job.additionalOffices = [...extra.matchAll(/<office>([\s\S]*?)<\/office>/g)].map((m) => decode(cdata(m[1])).trim()).filter(Boolean);
80 job.descriptions = [...p.matchAll(/<jobDescription>([\s\S]*?)<\/jobDescription>/g)].map(([, d]) => ({
81 name: tag(d, 'name'), html: cdata(d.match(/<value>([\s\S]*?)<\/value>/)?.[1] ?? ''),
82 }));
83 return job;
84 });
85}
86
87const EMPLOYMENT = {
88 fulltime: 'full-time', parttime: 'part-time', contract: 'contract', contractor: 'contract',
89 intern: 'internship', internship: 'internship', temporary: 'temporary', fixedterm: 'temporary', temp: 'temporary',
90};
91const employment = (v) => (v ? EMPLOYMENT[v.toLowerCase().replace(/[^a-z]/g, '')] ?? v.toLowerCase() : null);
92
93const employmentFrom = (...vals) => {
94 const v = vals.filter(Boolean).join(' ').toLowerCase();
95 if (!v) return null;
96 for (const [re, out] of [[/intern|trainee|working_student/, 'internship'], [/freelance|contract/, 'contract'],
97 [/fixed|temporary/, 'temporary'], [/part/, 'part-time'], [/full|permanent/, 'full-time']]) if (re.test(v)) return out;
98 return v;
99};
100const WORKPLACE = { remote: 'remote', hybrid: 'hybrid', onsite: 'onsite', inoffice: 'onsite' };
101const workplace = (v, location) => WORKPLACE[(v ?? '').toLowerCase().replace(/[^a-z]/g, '')]
102 ?? (/\bremote\b/i.test(location ?? '') ? 'remote' : null);
103const INTERVAL = { year: 'year', hour: 'hour', month: 'month', week: 'week', day: 'day' };
104const interval = (v) => INTERVAL[(String(v ?? '').toLowerCase().match(/year|hour|month|week|day/) ?? [])[0]] ?? null;
105const iso = (v) => {
106 if (v == null || v === '') return null;
107 const d = new Date(typeof v === 'string' ? v.replace(/^(\d{4}-\d\d-\d\d) (\d\d:\d\d:\d\d) UTC$/, '$1T$2Z') : v);
108 return Number.isNaN(d.getTime()) ? null : d.toISOString();
109};
110const place = (...parts) => parts.filter(Boolean).join(', ') || null;
111
112function base(ats, slug, job) {
113 return {
114 source: ats, company: slug, companyName: null, jobId: null, title: null, department: null, team: null,
115 location: null, allLocations: [], country: null, workplaceType: null, isRemote: false, employmentType: null,
116 salaryMin: null, salaryMax: null, salaryCurrency: null, salaryInterval: null, salaryText: null,
117 postedAt: null, updatedAt: null, url: null, applyUrl: null, description: null, ...job,
118 };
119}
120
121export function normalize(ats, slug, job, { includeDescription = true, eu = false } = {}) {
122 let out;
123 if (ats === 'greenhouse') {
124 const pay = (job.pay_input_ranges ?? [])[0];
125 out = base(ats, slug, {
126 companyName: job.company_name ?? null, jobId: String(job.id), title: job.title?.trim() ?? null,
127 department: (job.departments ?? []).map((d) => d.name).join(', ') || null,
128 location: job.location?.name ?? null,
129 allLocations: (job.offices ?? []).map((o) => o.name).filter(Boolean),
130 workplaceType: workplace(null, job.location?.name),
131 salaryMin: pay?.min_cents != null ? pay.min_cents / 100 : null,
132 salaryMax: pay?.max_cents != null ? pay.max_cents / 100 : null,
133 salaryCurrency: pay?.currency_type ?? null,
134 postedAt: iso(job.first_published), updatedAt: iso(job.updated_at),
135 url: job.absolute_url ?? null, applyUrl: job.absolute_url ?? null,
136 description: includeDescription ? htmlToText(job.content) : null,
137 });
138 } else if (ats === 'lever') {
139 const c = job.categories ?? {};
140 const sal = job.salaryRange;
141 const lists = (job.lists ?? []).map((l) => `${l.text}\n${htmlToText(l.content) ?? ''}`).join('\n\n');
142 out = base(ats, slug, {
143 jobId: job.id, title: job.text?.trim() ?? null, department: c.department ?? null, team: c.team ?? null,
144 location: c.location ?? null, allLocations: c.allLocations ?? (c.location ? [c.location] : []),
145 country: job.country ?? null, workplaceType: workplace(job.workplaceType, c.location),
146 employmentType: employment(c.commitment),
147 salaryMin: sal?.min ?? null, salaryMax: sal?.max ?? null, salaryCurrency: sal?.currency ?? null,
148 salaryInterval: interval(sal?.interval),
149 postedAt: iso(job.createdAt), url: job.hostedUrl ?? null, applyUrl: job.applyUrl ?? job.hostedUrl ?? null,
150 description: includeDescription
151 ? [job.descriptionPlain, lists, job.additionalPlain].map((s) => s?.trim()).filter(Boolean).join('\n\n') || null
152 : null,
153 });
154 } else if (ats === 'workable') {
155 const locs = (job.locations ?? []).filter((l) => !l.hidden);
156 const location = place(job.city, job.state, job.country);
157 out = base(ats, slug, {
158 companyName: job.company_name ?? null, jobId: job.shortcode, title: job.title?.trim() ?? null,
159 department: job.department || null, location,
160 allLocations: locs.length ? locs.map((l) => place(l.city, l.region, l.country)).filter(Boolean) : (location ? [location] : []),
161 country: job.country || null,
162 workplaceType: job.telecommuting === true || job.telecommuting === 'true' ? 'remote' : workplace(null, location),
163 employmentType: employment(job.employment_type || null),
164 postedAt: iso(job.published_on ?? job.created_at), url: job.url ?? job.shortlink ?? null,
165 applyUrl: job.application_url ?? job.url ?? null,
166 description: includeDescription ? htmlToText([job.description, job.requirements, job.benefits].filter(Boolean).join('\n')) : null,
167 });
168 } else if (ats === 'recruitee') {
169 const sal = job.salary ?? {};
170 out = base(ats, slug, {
171 companyName: job.company_name ?? null, jobId: String(job.id), title: job.title?.trim() ?? null,
172 department: job.department || null, location: job.location || place(job.city, job.state_name, job.country),
173 allLocations: (job.locations ?? []).map((l) => place(l.city, l.state, l.country) ?? l.name).filter(Boolean),
174 country: job.country || null,
175 workplaceType: job.remote ? 'remote' : job.hybrid ? 'hybrid' : job.on_site ? 'onsite' : workplace(null, job.location),
176 employmentType: employmentFrom(job.employment_type_code),
177 salaryMin: sal.min != null ? Number(sal.min) : null, salaryMax: sal.max != null ? Number(sal.max) : null,
178 salaryCurrency: sal.currency ?? null, salaryInterval: interval(sal.period),
179 postedAt: iso(job.published_at ?? job.created_at), updatedAt: iso(job.updated_at),
180 url: job.careers_url ?? null, applyUrl: job.careers_apply_url ?? job.careers_url ?? null,
181 description: includeDescription ? htmlToText([job.description, job.requirements].filter(Boolean).join('\n')) : null,
182 });
183 } else if (ats === 'personio') {
184 const url = job.id ? personioUrl(slug, job.id, eu) : null;
185 out = base(ats, slug, {
186 companyName: job.subcompany ?? null, jobId: job.id, title: job.name?.trim() ?? null,
187 department: job.department ?? null, team: job.recruitingCategory ?? null, location: job.office ?? null,
188 allLocations: [job.office, ...(job.additionalOffices ?? [])].filter(Boolean),
189 workplaceType: workplace(null, [job.office, ...(job.additionalOffices ?? [])].join(' ')),
190 employmentType: employmentFrom(job.employmentType === 'permanent' ? null : job.employmentType, job.schedule),
191 postedAt: iso(job.createdAt), url, applyUrl: url,
192 description: includeDescription
193 ? (job.descriptions ?? []).map((d) => [d.name, htmlToText(d.html)].filter(Boolean).join('\n')).filter(Boolean).join('\n\n') || null
194 : null,
195 });
196 } else {
197 const comp = job.compensation ?? {};
198 const salary = [...(comp.summaryComponents ?? []), ...(comp.compensationTiers ?? []).flatMap((t) => t.components ?? [])]
199 .find((x) => x.compensationType === 'Salary' && (x.minValue != null || x.maxValue != null));
200 out = base(ats, slug, {
201 jobId: job.id, title: job.title?.trim() ?? null, department: job.department ?? null, team: job.team ?? null,
202 location: job.location ?? null,
203 allLocations: [job.location, ...(job.secondaryLocations ?? []).map((l) => l.location)].filter(Boolean),
204 country: job.address?.postalAddress?.addressCountry ?? null,
205 workplaceType: workplace(job.workplaceType, job.location) ?? (job.isRemote ? 'remote' : null),
206 employmentType: employment(job.employmentType),
207 salaryMin: salary?.minValue ?? null, salaryMax: salary?.maxValue ?? null,
208 salaryCurrency: salary?.currencyCode ?? null, salaryInterval: interval(salary?.interval),
209 salaryText: comp.scrapeableCompensationSalarySummary ?? comp.compensationTierSummary ?? null,
210 postedAt: iso(job.publishedAt), url: job.jobUrl ?? null, applyUrl: job.applyUrl ?? job.jobUrl ?? null,
211 description: includeDescription ? (job.descriptionPlain?.trim() || htmlToText(job.descriptionHtml)) : null,
212 });
213 }
214 out.isRemote = out.workplaceType === 'remote' || (ats === 'ashby' && job.isRemote === true);
215 return out;
216}
217
218
219export function jobsFrom(ats, body) {
220 if (ats === 'lever') return Array.isArray(body) ? body : [];
221 if (ats === 'personio') return personioJobs(body);
222 if (ats === 'recruitee') return (Array.isArray(body?.offers) ? body.offers : []).filter((o) => (o.status ?? 'published') === 'published');
223 if (ats === 'workable') return (Array.isArray(body?.jobs) ? body.jobs : []).map((j) => ({ company_name: body.name ?? null, ...j }));
224 const jobs = Array.isArray(body?.jobs) ? body.jobs : [];
225 return ats === 'ashby' ? jobs.filter((j) => j.isListed !== false) : jobs;
226}
227
228export const matchesKeywords = (title, keywords) => !keywords?.length
229 || keywords.some((k) => (title ?? '').toLowerCase().includes(String(k).toLowerCase()));