1import { request, mapLimit } from './http.js';
2import { htmlToText, decodeEntities, toIso, parseRelativePosted, makeJob, normalizeWorkplace, detectRemote } from './normalize.js';
3
4
5
6
7
8
9
10
11
12const joinLoc = (...parts) => parts.filter(Boolean).join(', ') || null;
13
14
15const greenhouse = {
16 name: 'greenhouse',
17 async probe(slug) {
18 const d = await request(`https://boards-api.greenhouse.io/v1/boards/${encodeURIComponent(slug)}/jobs`, { retries: 1 });
19 if (!d?.jobs) return null;
20 return { companyName: d.jobs[0]?.company_name ?? null, count: d.jobs.length };
21 },
22 async fetchJobs(board, { includeDescription }) {
23 const d = await request(`https://boards-api.greenhouse.io/v1/boards/${encodeURIComponent(board.slug)}/jobs?content=${includeDescription}`);
24 return (d.jobs || []).map((j) => {
25 const html = includeDescription && j.content ? decodeEntities(j.content) : null;
26 const payMeta = (j.metadata || []).find((m) => /salary|compensation|pay/i.test(m.name || ''));
27 return makeJob({
28 id: String(j.id),
29 ats: 'greenhouse',
30 companySlug: board.slug,
31 companyName: j.company_name || board.companyName,
32 title: j.title,
33 department: (j.departments || []).map((x) => x.name).join(', ') || null,
34 location: j.location?.name || null,
35 locations: (j.offices || []).map((o) => o.location || o.name),
36 salaryText: payMeta?.value ? String(payMeta.value) : null,
37 postedAt: toIso(j.first_published),
38 updatedAt: toIso(j.updated_at),
39 url: j.absolute_url,
40 applyUrl: j.absolute_url,
41 descriptionHtml: html,
42 descriptionText: htmlToText(html),
43 });
44 });
45 },
46};
47
48
49const leverBase = (region) => (region === 'eu' ? 'https://api.eu.lever.co' : 'https://api.lever.co');
50const lever = {
51 name: 'lever',
52 async probe(slug) {
53 for (const region of ['us', 'eu']) {
54 try {
55 const d = await request(`${leverBase(region)}/v0/postings/${encodeURIComponent(slug)}?mode=json`, { retries: 1 });
56 if (Array.isArray(d)) return { count: d.length, region };
57 } catch { }
58 }
59 return null;
60 },
61 async fetchJobs(board, { includeDescription }) {
62 const d = await request(`${leverBase(board.region)}/v0/postings/${encodeURIComponent(board.slug)}?mode=json`);
63 return (d || []).map((j) => {
64 const c = j.categories || {};
65 let html = null;
66 if (includeDescription) {
67 const lists = (j.lists || []).map((l) => `<h3>${l.text}</h3><ul>${l.content}</ul>`).join('');
68 html = [j.description, lists, j.additional].filter(Boolean).join('\n') || null;
69 }
70 const sr = j.salaryRange;
71 return makeJob({
72 id: j.id,
73 ats: 'lever',
74 companySlug: board.slug,
75 companyName: board.companyName,
76 title: j.text,
77 department: c.department || null,
78 team: c.team || null,
79 location: c.location || null,
80 locations: c.allLocations || [],
81 country: j.country || null,
82 workplaceType: j.workplaceType || null,
83 employmentType: c.commitment || null,
84 salaryMin: sr?.min ?? null,
85 salaryMax: sr?.max ?? null,
86 salaryCurrency: sr?.currency ?? null,
87 salaryPeriod: sr?.interval ?? null,
88 postedAt: toIso(j.createdAt),
89 url: j.hostedUrl,
90 applyUrl: j.applyUrl,
91 descriptionHtml: html,
92 descriptionText: includeDescription ? (htmlToText(html) || j.descriptionPlain || null) : null,
93 });
94 });
95 },
96};
97
98
99const ashby = {
100 name: 'ashby',
101 async probe(slug) {
102 const d = await request(`https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(slug)}`, { retries: 1 });
103 if (!d?.jobs) return null;
104 return { count: d.jobs.length };
105 },
106 async fetchJobs(board, { includeDescription }) {
107 const d = await request(`https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(board.slug)}?includeCompensation=true`);
108 return (d.jobs || []).filter((j) => j.isListed !== false).map((j) => {
109 const comp = j.compensation;
110 const salary = comp?.compensationTiers?.flatMap((t) => t.components || []).find((c) => c.compensationType === 'Salary');
111 const addr = j.address?.postalAddress;
112 return makeJob({
113 id: j.id,
114 ats: 'ashby',
115 companySlug: board.slug,
116 companyName: board.companyName,
117 title: j.title,
118 department: j.department || null,
119 team: j.team || null,
120 location: j.location || null,
121 locations: [j.location, ...(j.secondaryLocations || []).map((s) => s.location)],
122 country: addr?.addressCountry || null,
123 isRemote: j.isRemote === true,
124 workplaceType: j.workplaceType || null,
125 employmentType: j.employmentType || null,
126 salaryMin: salary?.minValue ?? null,
127 salaryMax: salary?.maxValue ?? null,
128 salaryCurrency: salary?.currencyCode ?? null,
129 salaryPeriod: salary?.interval ?? null,
130 salaryText: comp?.compensationTierSummary || comp?.scrapeableCompensationSalarySummary || null,
131 postedAt: toIso(j.publishedAt),
132 url: j.jobUrl,
133 applyUrl: j.applyUrl,
134 descriptionHtml: includeDescription ? j.descriptionHtml || null : null,
135 descriptionText: includeDescription ? j.descriptionPlain || htmlToText(j.descriptionHtml) : null,
136 });
137 });
138 },
139};
140
141
142const workable = {
143 name: 'workable',
144 async probe(slug) {
145 const d = await request(`https://apply.workable.com/api/v1/widget/accounts/${encodeURIComponent(slug)}`, { retries: 1 });
146 if (!d?.jobs) return null;
147 return { companyName: d.name || null, count: d.jobs.length };
148 },
149 async fetchJobs(board) {
150 const d = await request(`https://apply.workable.com/api/v1/widget/accounts/${encodeURIComponent(board.slug)}`);
151 return (d.jobs || []).map((j) => makeJob({
152 id: j.shortcode,
153 ats: 'workable',
154 companySlug: board.slug,
155 companyName: d.name || board.companyName,
156 title: j.title,
157 department: j.department || null,
158 location: joinLoc(j.city, j.state, j.country),
159 locations: (j.locations || []).map((l) => joinLoc(l.city, l.region, l.country)),
160 country: j.country || null,
161 isRemote: j.telecommuting === true,
162 employmentType: j.employment_type || null,
163 postedAt: toIso(j.published_on || j.created_at),
164 url: j.url || j.shortlink,
165 applyUrl: j.application_url,
166 }));
167 },
168 async enrich(board, jobs, concurrency) {
169 await mapLimit(jobs, concurrency, async (job) => {
170 const det = await request(`https://apply.workable.com/api/v2/accounts/${encodeURIComponent(board.slug)}/jobs/${job.id}`).catch(() => null);
171 if (!det) return;
172 const html = [det.description, det.requirements, det.benefits].filter(Boolean).join('\n') || null;
173 job.descriptionHtml = html;
174 job.descriptionText = htmlToText(html);
175 if (det.workplace) job.workplaceType = normalizeWorkplace(det.workplace, job.isRemote);
176 });
177 },
178};
179
180
181const smartrecruiters = {
182 name: 'smartrecruiters',
183 async probe(slug) {
184 const d = await request(`https://api.smartrecruiters.com/v1/companies/${encodeURIComponent(slug)}/postings?limit=1`, { retries: 1 });
185 if (!d || !d.totalFound) return null;
186 return { companyName: d.content?.[0]?.company?.name ?? null, count: d.totalFound };
187 },
188 async fetchJobs(board, { keywords }) {
189 const base = `https://api.smartrecruiters.com/v1/companies/${encodeURIComponent(board.slug)}/postings`;
190
191 const q = keywords?.length === 1 ? `&q=${encodeURIComponent(keywords[0])}` : '';
192 const all = [];
193 for (let offset = 0; ; offset += 100) {
194 const d = await request(`${base}?limit=100&offset=${offset}${q}`);
195 all.push(...(d.content || []));
196 if (!d.content?.length || all.length >= d.totalFound) break;
197 }
198 return all.map((j) => {
199 const loc = j.location || {};
200 return makeJob({
201 id: String(j.id),
202 ats: 'smartrecruiters',
203 companySlug: board.slug,
204 companyName: j.company?.name || board.companyName,
205 title: j.name,
206 department: j.department?.label || null,
207 team: j.function?.label || null,
208 location: loc.fullLocation || joinLoc(loc.city, loc.region, loc.country?.toUpperCase()),
209 country: loc.country?.toUpperCase() || null,
210 isRemote: loc.remote === true,
211 workplaceType: loc.remote ? 'remote' : loc.hybrid ? 'hybrid' : null,
212 employmentType: j.typeOfEmployment?.label || null,
213 postedAt: toIso(j.releasedDate),
214 url: `https://jobs.smartrecruiters.com/${board.slug}/${j.id}`,
215 applyUrl: null,
216 });
217 });
218 },
219 async enrich(board, jobs, concurrency) {
220 const base = `https://api.smartrecruiters.com/v1/companies/${encodeURIComponent(board.slug)}/postings`;
221 await mapLimit(jobs, concurrency, async (job) => {
222 const det = await request(`${base}/${job.id}`).catch(() => null);
223 if (!det) return;
224 const s = det.jobAd?.sections || {};
225 const html = [s.jobDescription, s.qualifications, s.additionalInformation]
226 .filter((x) => x?.text).map((x) => `<h3>${x.title || ''}</h3>${x.text}`).join('\n') || null;
227 job.descriptionHtml = html;
228 job.descriptionText = htmlToText(html);
229 if (det.postingUrl) job.url = det.postingUrl;
230 if (det.applyUrl) job.applyUrl = det.applyUrl;
231 });
232 },
233};
234
235
236const recruitee = {
237 name: 'recruitee',
238 async probe(slug) {
239 const d = await request(`https://${slug}.recruitee.com/api/offers/`, { retries: 1 });
240 if (!d?.offers) return null;
241 return { companyName: d.offers[0]?.company_name ?? null, count: d.offers.length };
242 },
243 async fetchJobs(board, { includeDescription }) {
244 const d = await request(`https://${board.slug}.recruitee.com/api/offers/`);
245 return (d.offers || []).filter((o) => !o.status || o.status === 'published').map((o) => {
246 const html = includeDescription ? [o.description, o.requirements].filter(Boolean).join('\n') || null : null;
247 const sal = o.salary || {};
248 return makeJob({
249 id: String(o.id),
250 ats: 'recruitee',
251 companySlug: board.slug,
252 companyName: o.company_name || board.companyName,
253 title: o.title,
254 department: o.department || null,
255 location: o.location || null,
256 locations: (o.locations || []).map((l) => joinLoc(l.city, l.state, l.country)),
257 country: o.country || null,
258 isRemote: o.remote === true,
259 workplaceType: o.remote ? 'remote' : o.hybrid ? 'hybrid' : o.on_site ? 'onsite' : null,
260 employmentType: o.employment_type_code || null,
261 salaryMin: sal.min ? Number(sal.min) : null,
262 salaryMax: sal.max ? Number(sal.max) : null,
263 salaryCurrency: sal.currency || null,
264 salaryPeriod: sal.period || null,
265 postedAt: toIso(o.published_at?.replace(' UTC', 'Z').replace(' ', 'T')),
266 updatedAt: toIso(o.updated_at?.replace(' UTC', 'Z').replace(' ', 'T')),
267 url: o.careers_url,
268 applyUrl: o.careers_apply_url,
269 descriptionHtml: html,
270 descriptionText: htmlToText(html),
271 });
272 });
273 },
274};
275
276
277const xmlTag = (block, tag) => {
278 const m = block.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`));
279 if (!m) return null;
280 return decodeEntities(m[1].replace(/^<!\[CDATA\[([\s\S]*)\]\]>$/, '$1').trim()) || null;
281};
282const personio = {
283 name: 'personio',
284 async fetchXml(slug) {
285 for (const tld of ['de', 'com']) {
286 try {
287 const xml = await request(`https://${slug}.jobs.personio.${tld}/xml`, { as: 'text', retries: 1 });
288 if (xml.includes('<workzag-jobs')) return { xml, tld };
289 } catch { }
290 }
291 return null;
292 },
293 async probe(slug) {
294 const r = await this.fetchXml(slug);
295 if (!r) return null;
296 return { count: (r.xml.match(/<position>/g) || []).length, tld: r.tld };
297 },
298 async fetchJobs(board, { includeDescription }) {
299 const r = await this.fetchXml(board.slug);
300 if (!r) return [];
301 const blocks = r.xml.match(/<position>[\s\S]*?<\/position>/g) || [];
302 return blocks.map((b) => {
303 const id = xmlTag(b, 'id');
304 const offices = [xmlTag(b, 'office'), ...[...(b.match(/<additionalOffices>([\s\S]*?)<\/additionalOffices>/)?.[1] || '').matchAll(/<office>([\s\S]*?)<\/office>/g)].map((m) => decodeEntities(m[1].trim()))];
305 let html = null;
306 if (includeDescription) {
307 html = [...b.matchAll(/<jobDescription>([\s\S]*?)<\/jobDescription>/g)]
308 .map((m) => `<h3>${xmlTag(m[1], 'name') || ''}</h3>${xmlTag(m[1], 'value') || ''}`).join('\n') || null;
309 }
310 const url = `https://${board.slug}.jobs.personio.${r.tld}/job/${id}`;
311 return makeJob({
312 id,
313 ats: 'personio',
314 companySlug: board.slug,
315 companyName: xmlTag(b, 'subcompany') || board.companyName,
316 title: xmlTag(b, 'name'),
317 department: xmlTag(b, 'department'),
318 team: xmlTag(b, 'recruitingCategory'),
319 location: offices[0] || null,
320 locations: offices,
321 employmentType: [xmlTag(b, 'employmentType'), xmlTag(b, 'schedule')].filter(Boolean).join(', ') || null,
322 postedAt: toIso(xmlTag(b, 'createdAt')),
323 url,
324 applyUrl: url,
325 descriptionHtml: html,
326 descriptionText: htmlToText(html),
327 });
328 });
329 },
330};
331
332
333const workday = {
334 name: 'workday',
335
336 async fetchJobs(board, { keywords, maxJobsPerCompany }) {
337 const api = `https://${board.host}/wday/cxs/${board.tenant}/${board.site}`;
338 const searchText = keywords?.length === 1 ? keywords[0] : '';
339 const list = [];
340 let total = Infinity;
341 for (let offset = 0; offset < total; offset += 20) {
342 const d = await request(`${api}/jobs`, { method: 'POST', body: { appliedFacets: {}, limit: 20, offset, searchText } });
343 if (offset === 0) total = d.total ?? 0;
344 if (!d.jobPostings?.length) break;
345 list.push(...d.jobPostings);
346 if (maxJobsPerCompany && list.length >= maxJobsPerCompany * 3) break;
347 }
348 return list.map((j) => {
349 const url = `https://${board.host}/${board.site}${j.externalPath}`;
350 const job = makeJob({
351 id: j.bulletFields?.[0] || j.externalPath,
352 ats: 'workday',
353 companySlug: `${board.tenant}/${board.site}`,
354 companyName: board.companyName || board.tenant,
355 title: j.title,
356 location: j.locationsText || null,
357 postedAt: parseRelativePosted(j.postedOn),
358 url,
359 applyUrl: `${url}/apply`,
360 });
361 Object.defineProperty(job, '_path', { value: j.externalPath, enumerable: false });
362 return job;
363 });
364 },
365 async enrich(board, jobs, concurrency) {
366 const api = `https://${board.host}/wday/cxs/${board.tenant}/${board.site}`;
367 await mapLimit(jobs, concurrency, async (job) => {
368 if (!job._path) return;
369 const d = await request(`${api}${job._path}`).catch(() => null);
370 const info = d?.jobPostingInfo;
371 if (!info) return;
372 job.descriptionHtml = info.jobDescription || null;
373 job.descriptionText = htmlToText(info.jobDescription);
374 job.locations = [...new Set([info.location, ...(info.additionalLocations || [])].filter(Boolean))];
375 job.location = job.locations.join('; ') || job.location;
376 job.country = info.country?.descriptor || job.country;
377 job.employmentType = info.timeType || job.employmentType;
378 job.postedAt = toIso(info.startDate) || job.postedAt;
379 job.isRemote = job.isRemote || detectRemote(job.location, info.remoteType);
380 job.workplaceType = normalizeWorkplace(info.remoteType, job.isRemote);
381 });
382 },
383};
384
385
386const breezy = {
387 name: 'breezy',
388 async probe(slug) {
389 const d = await request(`https://${slug}.breezy.hr/json`, { retries: 1 });
390 if (!Array.isArray(d)) return null;
391 return { companyName: d[0]?.company?.name ?? null, count: d.length };
392 },
393 async fetchJobs(board) {
394 const d = await request(`https://${board.slug}.breezy.hr/json`);
395 return (d || []).map((j) => {
396 const loc = j.location || {};
397 return makeJob({
398 id: j.id || j.friendly_id,
399 ats: 'breezy',
400 companySlug: board.slug,
401 companyName: j.company?.name || board.companyName,
402 title: j.name,
403 department: j.department || null,
404 location: loc.name || null,
405 country: loc.country?.name || null,
406 isRemote: loc.is_remote === true,
407 employmentType: j.type?.name || null,
408 salaryText: typeof j.salary === 'string' ? j.salary : null,
409 postedAt: toIso(j.published_date),
410 url: j.url,
411 applyUrl: j.url,
412 });
413 });
414 },
415};
416
417export const PROVIDERS = { greenhouse, lever, ashby, workable, smartrecruiters, recruitee, personio, workday, breezy };
418
419
420export const PROBE_ORDER = ['greenhouse', 'lever', 'ashby', 'workable', 'recruitee', 'smartrecruiters', 'personio', 'breezy'];