1
2
3
4
5
6
7
8
9
10
11
12
13
14
15const UA = 'Mozilla/5.0 (compatible; HiringSignalBot/1.0; +https://apify.com)';
16const TIMEOUT_MS = 25000;
17
18async function getJson(url, timeout = TIMEOUT_MS) {
19 const res = await fetch(url, {
20 headers: { accept: 'application/json', 'user-agent': UA },
21 signal: AbortSignal.timeout(timeout),
22 });
23 if (!res.ok) return null;
24 const ct = res.headers.get('content-type') || '';
25 if (!ct.includes('json')) return null;
26 try {
27 return await res.json();
28 } catch {
29 return null;
30 }
31}
32
33const str = (v) => (typeof v === 'string' ? v.trim() : '');
34
35const iso = (v) => {
36 if (!v) return null;
37 const d = new Date(typeof v === 'number' && v < 1e12 ? v * 1000 : v);
38 return Number.isNaN(d.getTime()) ? null : d.toISOString();
39};
40
41
42function htmlToText(html) {
43 if (!html) return '';
44 return String(html)
45 .replace(/<script[\s\S]*?<\/script>/gi, ' ')
46 .replace(/<style[\s\S]*?<\/style>/gi, ' ')
47 .replace(/<\/(p|div|li|h[1-6]|tr)>/gi, '\n')
48 .replace(/<br\s*\/?>/gi, '\n')
49 .replace(/<[^>]+>/g, ' ')
50 .replace(/ /g, ' ')
51 .replace(/&/g, '&')
52 .replace(/</g, '<')
53 .replace(/>/g, '>')
54 .replace(/"/g, '"')
55 .replace(/�?39;/g, '’')
56 .replace(/'/gi, '’')
57 .replace(///gi, '/')
58 .replace(/[ \t]+/g, ' ')
59 .replace(/\n{3,}/g, '\n\n')
60 .trim();
61}
62
63
64
65const providers = {
66 greenhouse: {
67 name: 'greenhouse',
68 boardUrl: (s) => `https://job-boards.greenhouse.io/${s}`,
69
70 async fetchJobs(slug, { includeDescription, needDepartments } = {}) {
71 const enc = encodeURIComponent(slug);
72
73
74 const d = await getJson(
75 `https://boards-api.greenhouse.io/v1/boards/${enc}/jobs${includeDescription ? '?content=true' : ''}`,
76 );
77 if (!d || !Array.isArray(d.jobs) || d.jobs.length === 0) return null;
78
79
80 let deptById = null;
81 if (needDepartments && !includeDescription) {
82 const dep = await getJson(`https://boards-api.greenhouse.io/v1/boards/${enc}/departments`, 20000);
83 if (dep && Array.isArray(dep.departments)) {
84 deptById = new Map();
85 for (const dept of dep.departments) {
86 const name = str(dept.name);
87 if (!name || !Array.isArray(dept.jobs)) continue;
88 for (const j of dept.jobs) deptById.set(String(j.id), name);
89 }
90 }
91 }
92
93 return {
94 jobs: d.jobs,
95 companyName: str(d.jobs[0]?.company_name) || null,
96 deptById,
97 };
98 },
99
100 normalize(j, ctx) {
101 const depts = (j.departments || []).map((x) => str(x.name)).filter(Boolean);
102 const mapped = ctx.deptById?.get(String(j.id));
103 const allDepts = depts.length ? depts : (mapped ? [mapped] : []);
104 const offices = (j.offices || []).map((x) => str(x.name)).filter(Boolean);
105 const loc = str(j.location?.name);
106 return {
107 jobId: String(j.id),
108 title: str(j.title),
109 department: allDepts[0] || null,
110 departments: allDepts,
111 location: loc || offices[0] || null,
112 locations: offices.length ? offices : [loc].filter(Boolean),
113 employmentType: null,
114 postedAt: iso(j.first_published || j.updated_at),
115 updatedAt: iso(j.updated_at),
116 applyUrl: str(j.absolute_url),
117 descriptionHtml: j.content ? String(j.content) : null,
118 };
119 },
120 },
121
122 ashby: {
123 name: 'ashby',
124 boardUrl: (s) => `https://jobs.ashbyhq.com/${s}`,
125
126 async fetchJobs(slug, { includeDescription } = {}) {
127 const q = includeDescription ? '?includeCompensation=true' : '';
128 const d = await getJson(`https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(slug)}${q}`);
129 if (!d || !Array.isArray(d.jobs) || d.jobs.length === 0) return null;
130 return { jobs: d.jobs, companyName: null };
131 },
132
133 normalize(j) {
134 const locs = [str(j.location), ...(j.secondaryLocations || []).map((x) => str(x?.location))].filter(Boolean);
135
136 const wt = str(j.workplaceType).toLowerCase();
137 return {
138 jobId: String(j.id || j.jobId),
139 title: str(j.title),
140 department: str(j.department) || null,
141 departments: [str(j.department), str(j.team)].filter(Boolean),
142 location: locs[0] || null,
143 locations: locs,
144 workplaceTypeRaw: ['remote', 'hybrid', 'onsite'].includes(wt) ? wt : null,
145 isRemote: j.isRemote === true ? true : undefined,
146 employmentType: str(j.employmentType) || null,
147 postedAt: iso(j.publishedAt),
148 updatedAt: iso(j.updatedAt || j.publishedAt),
149 applyUrl: str(j.applyUrl) || str(j.jobUrl),
150 descriptionHtml: j.descriptionHtml || null,
151 descriptionTextRaw: str(j.descriptionPlain) || null,
152 salaryRaw: j.compensation?.compensationTierSummary || null,
153 };
154 },
155 },
156
157 lever: {
158 name: 'lever',
159 boardUrl: (s) => `https://jobs.lever.co/${s}`,
160
161 async fetchJobs(slug) {
162 const d = await getJson(`https://api.lever.co/v0/postings/${encodeURIComponent(slug)}?mode=json`);
163 if (!Array.isArray(d) || d.length === 0) return null;
164 return { jobs: d, companyName: null };
165 },
166
167 normalize(j) {
168 const c = j.categories || {};
169 const loc = str(c.location);
170 const wt = str(c.workplaceType).toLowerCase();
171 const sr = j.salaryRange;
172 return {
173 jobId: String(j.id),
174 title: str(j.text),
175 department: str(c.department) || str(c.team) || null,
176 departments: [str(c.department), str(c.team)].filter(Boolean),
177 location: loc || null,
178 locations: loc ? [loc] : [],
179 workplaceTypeRaw: ['remote', 'hybrid', 'onsite'].includes(wt) ? wt : null,
180 employmentType: str(c.commitment) || null,
181 postedAt: iso(j.createdAt),
182 updatedAt: iso(j.createdAt),
183 applyUrl: str(j.hostedUrl) || str(j.applyUrl),
184 descriptionHtml: j.description || null,
185 descriptionTextRaw: str(j.descriptionPlain) || null,
186 salaryRaw: sr && sr.min ? `${sr.min}-${sr.max} ${sr.currency || ''}`.trim() : null,
187 };
188 },
189 },
190
191 smartrecruiters: {
192 name: 'smartrecruiters',
193 boardUrl: (s) => `https://careers.smartrecruiters.com/${s}`,
194
195 async fetchJobs(slug) {
196 const out = [];
197 let total = Infinity;
198 let companyName = null;
199 for (let offset = 0; offset < 2000 && out.length < total; offset += 100) {
200 const d = await getJson(
201 `https://api.smartrecruiters.com/v1/companies/${encodeURIComponent(slug)}/postings?limit=100&offset=${offset}`,
202 );
203 if (!d || !Array.isArray(d.content)) break;
204 total = d.totalFound ?? out.length + d.content.length;
205 out.push(...d.content);
206 companyName = companyName || str(d.content[0]?.company?.name) || null;
207 if (d.content.length < 100) break;
208 }
209 return out.length ? { jobs: out, companyName } : null;
210 },
211
212 normalize(j, ctx) {
213 const loc = j.location || {};
214 const locStr = [str(loc.city), str(loc.region), str(loc.country).toUpperCase()].filter(Boolean).join(', ');
215 return {
216 jobId: String(j.id),
217 title: str(j.name),
218 department: str(j.department?.label) || str(j.function?.label) || null,
219 departments: [str(j.department?.label), str(j.function?.label)].filter(Boolean),
220 location: locStr || null,
221 locations: locStr ? [locStr] : [],
222 workplaceTypeRaw: loc.remote === true ? 'remote' : null,
223 employmentType: str(j.typeOfEmployment?.label) || null,
224 postedAt: iso(j.releasedDate),
225 updatedAt: iso(j.releasedDate),
226 applyUrl: `https://jobs.smartrecruiters.com/${j.company?.identifier || ctx.slug}/${j.id}`,
227 descriptionHtml: null,
228 };
229 },
230 },
231
232 workable: {
233 name: 'workable',
234 boardUrl: (s) => `https://apply.workable.com/${s}/`,
235
236 async fetchJobs(slug) {
237 const d = await getJson(
238 `https://apply.workable.com/api/v1/widget/accounts/${encodeURIComponent(slug)}?details=true`,
239 );
240 if (!d || !Array.isArray(d.jobs) || d.jobs.length === 0) return null;
241 return { jobs: d.jobs, companyName: str(d.name) || null };
242 },
243
244 normalize(j, ctx) {
245 const locStr = [str(j.city), str(j.state), str(j.country)].filter(Boolean).join(', ');
246 return {
247 jobId: String(j.shortcode || j.id),
248 title: str(j.title),
249 department: str(j.department) || null,
250 departments: [str(j.department)].filter(Boolean),
251 location: locStr || str(j.location) || null,
252 locations: locStr ? [locStr] : [],
253 workplaceTypeRaw: j.telecommuting === true ? 'remote' : null,
254 employmentType: str(j.employment_type) || null,
255 postedAt: iso(j.published_on || j.created_at),
256 updatedAt: iso(j.published_on || j.created_at),
257 applyUrl: str(j.application_url) || str(j.url) || `https://apply.workable.com/${ctx.slug}/j/${j.shortcode}/`,
258 descriptionHtml: j.description || null,
259 };
260 },
261 },
262
263 recruitee: {
264 name: 'recruitee',
265 boardUrl: (s) => `https://${s}.recruitee.com`,
266
267 async fetchJobs(slug) {
268 const d = await getJson(`https://${encodeURIComponent(slug)}.recruitee.com/api/offers/`);
269 if (!d || !Array.isArray(d.offers) || d.offers.length === 0) return null;
270 return { jobs: d.offers, companyName: str(d.offers[0]?.company_name) || null };
271 },
272
273 normalize(j) {
274 const locStr = [str(j.city), str(j.country)].filter(Boolean).join(', ');
275 return {
276 jobId: String(j.id),
277 title: str(j.title),
278 department: str(j.department) || null,
279 departments: [str(j.department)].filter(Boolean),
280 location: locStr || str(j.location) || null,
281 locations: [locStr || str(j.location)].filter(Boolean),
282 workplaceTypeRaw: j.remote === true ? 'remote' : null,
283 employmentType: str(j.employment_type_code) || null,
284 postedAt: iso(j.published_at || j.created_at),
285 updatedAt: iso(j.updated_at || j.published_at),
286 applyUrl: str(j.careers_apply_url) || str(j.careers_url),
287 descriptionHtml: j.description || null,
288 };
289 },
290 },
291
292 breezy: {
293 name: 'breezy',
294 boardUrl: (s) => `https://${s}.breezy.hr`,
295
296 async fetchJobs(slug) {
297 const d = await getJson(`https://${encodeURIComponent(slug)}.breezy.hr/json`);
298 if (!Array.isArray(d) || d.length === 0) return null;
299 return { jobs: d, companyName: null };
300 },
301
302 normalize(j) {
303 const locStr = [str(j.location?.city), str(j.location?.country?.name)].filter(Boolean).join(', ');
304 return {
305 jobId: String(j.id),
306 title: str(j.name),
307 department: str(j.department) || null,
308 departments: [str(j.department)].filter(Boolean),
309 location: locStr || null,
310 locations: locStr ? [locStr] : [],
311 workplaceTypeRaw: j.location?.is_remote === true ? 'remote' : null,
312 employmentType: str(j.type?.name) || null,
313 postedAt: iso(j.published_date),
314 updatedAt: iso(j.published_date),
315 applyUrl: str(j.url),
316 descriptionHtml: j.description || null,
317 };
318 },
319 },
320};
321
322
323
324
325
326
327
328
329
330
331const WORKDAY_PAGE = 20;
332const WORKDAY_MAX_JOBS = 1000;
333const WORKDAY_BATCH = 6;
334
335export function parseWorkdaySlug(slug) {
336 const parts = String(slug || '').split('/').filter(Boolean);
337 if (parts.length !== 3) return null;
338 const [tenant, wd, site] = parts;
339 if (!/^wd\d+$/i.test(wd)) return null;
340 return { tenant, wd: wd.toLowerCase(), site };
341}
342
343
344
345
346
347export function parseWorkdayPostedOn(text) {
348 if (!text) return null;
349 const t = String(text).toLowerCase();
350 const now = Date.now();
351 const day = 86400000;
352 if (t.includes('today') || t.includes('just posted')) return new Date(now).toISOString();
353 if (t.includes('yesterday')) return new Date(now - day).toISOString();
354 const d = t.match(/(\d+)\s*\+?\s*days?\s+ago/);
355 if (d) return new Date(now - Number(d[1]) * day).toISOString();
356 const m = t.match(/(\d+)\s*\+?\s*months?\s+ago/);
357 if (m) return new Date(now - Number(m[1]) * 30 * day).toISOString();
358 return null;
359}
360
361
362
363
364
365
366export function workdayLocationFromPath(externalPath) {
367 const m = String(externalPath || '').match(/\/job\/([^/]+)\//);
368 if (!m) return null;
369 const seg = m[1];
370 if (/^[A-Za-z0-9_]+$/.test(seg) && seg.length < 3) return null;
371 const out = seg
372 .split('---')
373 .map((part) => part.replace(/-+/g, ' ').trim())
374 .filter(Boolean)
375 .join(', ');
376 return out || null;
377}
378
379async function workdayPage(base, offset) {
380 try {
381 const res = await fetch(base, {
382 method: 'POST',
383 headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': UA },
384 body: JSON.stringify({ appliedFacets: {}, limit: WORKDAY_PAGE, offset, searchText: '' }),
385 signal: AbortSignal.timeout(TIMEOUT_MS),
386 });
387 if (!res.ok) return null;
388 return await res.json();
389 } catch {
390 return null;
391 }
392}
393
394providers.workday = {
395 name: 'workday',
396 boardUrl: (slug) => {
397 const p = parseWorkdaySlug(slug);
398 return p ? `https://${p.tenant}.${p.wd}.myworkdayjobs.com/${p.site}` : null;
399 },
400
401 async fetchJobs(slug, { maxJobsPerCompany } = {}) {
402 const p = parseWorkdaySlug(slug);
403 if (!p) return null;
404 const base = `https://${p.tenant}.${p.wd}.myworkdayjobs.com/wday/cxs/${p.tenant}/${p.site}/jobs`;
405
406 const first = await workdayPage(base, 0);
407 if (!first || !Array.isArray(first.jobPostings) || first.jobPostings.length === 0) return null;
408
409 const wanted = Math.min(
410 first.total || first.jobPostings.length,
411 maxJobsPerCompany > 0 ? maxJobsPerCompany : WORKDAY_MAX_JOBS,
412 WORKDAY_MAX_JOBS,
413 );
414
415 const jobs = [...first.jobPostings];
416 const offsets = [];
417 for (let o = WORKDAY_PAGE; o < wanted; o += WORKDAY_PAGE) offsets.push(o);
418
419 for (let i = 0; i < offsets.length; i += WORKDAY_BATCH) {
420 const batch = offsets.slice(i, i + WORKDAY_BATCH);
421 const pages = await Promise.all(batch.map((o) => workdayPage(base, o)));
422 for (const pg of pages) {
423 if (pg && Array.isArray(pg.jobPostings)) jobs.push(...pg.jobPostings);
424 }
425 if (jobs.length >= wanted) break;
426 }
427
428
429 const companyName = p.tenant.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
430 return { jobs: jobs.slice(0, wanted), companyName, meta: p };
431 },
432
433 normalize(j, ctx) {
434 const p = ctx.meta || parseWorkdaySlug(ctx.slug) || {};
435 const loc = str(j.locationsText);
436
437 const isAggregate = /^\d+\s+locations?$/i.test(loc);
438
439
440
441 const fromPath = isAggregate || !loc ? workdayLocationFromPath(j.externalPath) : null;
442 const resolvedLoc = isAggregate ? fromPath : (loc || fromPath);
443 const wt = str(j.remoteType).toLowerCase();
444 return {
445 jobId: String((j.bulletFields && j.bulletFields[0]) || j.externalPath || j.title),
446 title: str(j.title),
447 department: null,
448 departments: [],
449 location: resolvedLoc || null,
450 locations: resolvedLoc ? [resolvedLoc] : [],
451 locationSummary: isAggregate ? loc : null,
452 workplaceTypeRaw: wt.includes('remote') ? 'remote' : (wt.includes('hybrid') ? 'hybrid' : null),
453 employmentType: null,
454 postedAt: parseWorkdayPostedOn(j.postedOn),
455 postedAtIsApproximate: true,
456 updatedAt: parseWorkdayPostedOn(j.postedOn),
457 applyUrl: p.tenant
458 ? `https://${p.tenant}.${p.wd}.myworkdayjobs.com/en-US/${p.site}${str(j.externalPath)}`
459 : null,
460 descriptionHtml: null,
461 };
462 },
463};
464
465
466
467
468
469
470
471
472
473
474function xmlTag(block, tag) {
475 const m = block.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'i'));
476 if (!m) return '';
477 return m[1]
478 .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
479 .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
480 .replace(/"/g, '"').replace(/�?39;/g, '’')
481 .trim();
482}
483
484providers.personio = {
485 name: 'personio',
486 boardUrl: (s) => `https://${s}.jobs.personio.de`,
487
488 async fetchJobs(slug) {
489 try {
490 const res = await fetch(`https://${encodeURIComponent(slug)}.jobs.personio.de/xml`, {
491 headers: {
492 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
493 accept: 'application/xml,text/xml,*/*',
494 },
495 signal: AbortSignal.timeout(TIMEOUT_MS),
496 });
497 if (!res.ok) return null;
498 const xml = await res.text();
499 if (!xml.startsWith('<?xml')) return null;
500 const blocks = xml.match(/<position>[\s\S]*?<\/position>/g);
501 if (!blocks || blocks.length === 0) return null;
502 return { jobs: blocks, companyName: xmlTag(blocks[0], 'subcompany') || null };
503 } catch {
504 return null;
505 }
506 },
507
508 normalize(block) {
509 const offices = [xmlTag(block, 'office'), ...(block.match(/<additionalOffices>[\s\S]*?<\/additionalOffices>/i)
510 ? (block.match(/<office>([\s\S]*?)<\/office>/g) || []).map((o) => o.replace(/<\/?office>/g, '').trim())
511 : [])].filter(Boolean);
512 const uniqueOffices = [...new Set(offices)];
513 const dept = xmlTag(block, 'department') || xmlTag(block, 'recruitingCategory');
514 const id = xmlTag(block, 'id');
515 return {
516 jobId: id,
517 title: xmlTag(block, 'name'),
518 department: dept || null,
519 departments: [dept, xmlTag(block, 'recruitingCategory')].filter(Boolean),
520 location: uniqueOffices[0] || null,
521 locations: uniqueOffices,
522 employmentType: xmlTag(block, 'employmentType') || null,
523 postedAt: null,
524 updatedAt: null,
525 applyUrl: null,
526 descriptionHtml: xmlTag(block, 'jobDescriptions') || null,
527 };
528 },
529};
530
531
532
533
534
535
536const PROBE_ORDER = ['greenhouse', 'ashby', 'lever', 'smartrecruiters', 'workable', 'recruitee', 'breezy', 'personio'];
537
538export { providers, PROBE_ORDER, getJson, htmlToText, str, iso, UA };