1import { Actor, log } from 'apify';
2
3
4
5
6const UA = 'Mozilla/5.0 (compatible; ApifyATSJobs/1.0; +https://apify.com)';
7
8class NotFoundError extends Error {}
9
10async function getJson(url, { method = 'GET', body, retries = 3 } = {}) {
11 let lastErr;
12 for (let attempt = 0; attempt < retries; attempt++) {
13 try {
14 const res = await fetch(url, {
15 method,
16 headers: {
17 'user-agent': UA,
18 accept: 'application/json',
19 ...(body ? { 'content-type': 'application/json' } : {}),
20 },
21 body: body ? JSON.stringify(body) : undefined,
22 signal: AbortSignal.timeout(30000),
23 });
24 if (res.status === 404 || res.status === 410) throw new NotFoundError(`${res.status} ${url}`);
25 if (res.status === 429 || res.status >= 500) {
26 lastErr = new Error(`HTTP ${res.status} ${url}`);
27 await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)));
28 continue;
29 }
30 if (!res.ok) throw new NotFoundError(`HTTP ${res.status} ${url}`);
31 const text = await res.text();
32 try {
33 return JSON.parse(text);
34 } catch {
35 throw new NotFoundError(`Non-JSON response from ${url}`);
36 }
37 } catch (e) {
38 if (e instanceof NotFoundError) throw e;
39 lastErr = e;
40 await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)));
41 }
42 }
43 throw lastErr;
44}
45
46const decodeEntities = (s = '') =>
47 s
48 .replace(/</g, '<')
49 .replace(/>/g, '>')
50 .replace(/"/g, '"')
51 .replace(/'/g, "'")
52 .replace(/ /g, ' ')
53 .replace(/&/g, '&');
54
55const htmlToText = (html = '') =>
56 decodeEntities(
57 decodeEntities(String(html))
58 .replace(/<\s*br\s*\/?>/gi, '\n')
59 .replace(/<\/(p|div|li|h[1-6]|tr)>/gi, '\n')
60 .replace(/<li[^>]*>/gi, '• ')
61 .replace(/<[^>]+>/g, ''),
62 )
63 .replace(/[ \t]+/g, ' ')
64 .replace(/\n\s*\n\s*\n+/g, '\n\n')
65 .trim();
66
67const REMOTE_RE = /\bremote\b|anywhere|work from home|\bwfh\b|distributed/i;
68const iso = (v) => {
69 if (v === undefined || v === null || v === '') return null;
70 const d = typeof v === 'number' ? new Date(v) : new Date(String(v));
71 return Number.isNaN(d.getTime()) ? null : d.toISOString();
72};
73
74function base(source, companySlug) {
75 return {
76 source,
77 companySlug,
78 companyName: null,
79 jobId: null,
80 title: null,
81 department: null,
82 team: null,
83 location: null,
84 locations: [],
85 country: null,
86 isRemote: false,
87 workplaceType: null,
88 employmentType: null,
89 postedAt: null,
90 updatedAt: null,
91 jobUrl: null,
92 applyUrl: null,
93 salaryMin: null,
94 salaryMax: null,
95 salaryCurrency: null,
96 salaryInterval: null,
97 salaryText: null,
98 descriptionText: null,
99 descriptionHtml: null,
100 };
101}
102
103
104async function greenhouse(slug, { includeDescription }) {
105 const data = await getJson(
106 `https://boards-api.greenhouse.io/v1/boards/${encodeURIComponent(slug)}/jobs?content=${includeDescription ? 'true' : 'false'}`,
107 );
108 let companyName = null;
109 try {
110 const b = await getJson(`https://boards-api.greenhouse.io/v1/boards/${encodeURIComponent(slug)}`);
111 companyName = b?.name ?? null;
112 } catch { }
113 return (data.jobs || []).map((j) => {
114 const html = j.content ? decodeEntities(j.content) : null;
115 const loc = j.location?.name ?? null;
116 const offices = (j.offices || []).map((o) => o.location || o.name).filter(Boolean);
117 return {
118 ...base('greenhouse', slug),
119 companyName: j.company_name || companyName,
120 jobId: String(j.id),
121 title: j.title,
122 department: (j.departments || []).map((d) => d.name).join(', ') || null,
123 location: loc,
124 locations: [...new Set([loc, ...offices].filter(Boolean))],
125 isRemote: REMOTE_RE.test(`${loc} ${offices.join(' ')}`),
126 postedAt: iso(j.first_published),
127 updatedAt: iso(j.updated_at),
128 jobUrl: j.absolute_url,
129 applyUrl: j.absolute_url,
130 descriptionHtml: includeDescription ? html : null,
131 descriptionText: includeDescription && html ? htmlToText(html) : null,
132 };
133 });
134}
135
136
137async function lever(slug, { includeDescription }) {
138 let data;
139 try {
140 data = await getJson(`https://api.lever.co/v0/postings/${encodeURIComponent(slug)}?mode=json`);
141 } catch (e) {
142 if (!(e instanceof NotFoundError)) throw e;
143 data = await getJson(`https://api.eu.lever.co/v0/postings/${encodeURIComponent(slug)}?mode=json`);
144 }
145 if (!Array.isArray(data)) throw new NotFoundError('lever: unexpected payload');
146 return data.map((j) => {
147 const c = j.categories || {};
148 const all = c.allLocations?.length ? c.allLocations : c.location ? [c.location] : [];
149 const extra = (j.lists || []).map((l) => `<h3>${l.text}</h3><ul>${l.content}</ul>`).join('');
150 const html = `${j.description || ''}${extra}${j.additional || ''}`;
151 const s = j.salaryRange;
152 return {
153 ...base('lever', slug),
154 jobId: j.id,
155 title: j.text,
156 department: c.department || null,
157 team: c.team || null,
158 location: c.location || null,
159 locations: all,
160 country: j.country || null,
161 workplaceType: j.workplaceType || null,
162 isRemote: j.workplaceType === 'remote' || REMOTE_RE.test(all.join(' ')),
163 employmentType: c.commitment || null,
164 postedAt: iso(j.createdAt),
165 jobUrl: j.hostedUrl,
166 applyUrl: j.applyUrl,
167 salaryMin: s?.min ?? null,
168 salaryMax: s?.max ?? null,
169 salaryCurrency: s?.currency ?? null,
170 salaryInterval: s?.interval ?? null,
171 salaryText: j.salaryDescriptionPlain || null,
172 descriptionHtml: includeDescription ? html : null,
173 descriptionText: includeDescription ? htmlToText(html) : null,
174 };
175 });
176}
177
178
179async function ashby(slug, { includeDescription }) {
180 const data = await getJson(
181 `https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(slug)}?includeCompensation=true`,
182 );
183 if (!data || !Array.isArray(data.jobs)) throw new NotFoundError('ashby: unexpected payload');
184 return data.jobs
185 .filter((j) => j.isListed !== false)
186 .map((j) => {
187 const comp = (j.compensation?.summaryComponents || []).find((x) => x.compensationType === 'Salary')
188 || j.compensation?.summaryComponents?.[0];
189 const secondary = (j.secondaryLocations || []).map((l) => l.location).filter(Boolean);
190 const locs = [j.location, ...secondary].filter(Boolean);
191 return {
192 ...base('ashby', slug),
193 jobId: j.id,
194 title: j.title,
195 department: j.department || null,
196 team: j.team || null,
197 location: j.location || null,
198 locations: locs,
199 country: j.address?.postalAddress?.addressCountry || null,
200 workplaceType: j.workplaceType || null,
201 isRemote: Boolean(j.isRemote) || j.workplaceType === 'Remote' || REMOTE_RE.test(locs.join(' ')),
202 employmentType: j.employmentType || null,
203 postedAt: iso(j.publishedAt),
204 jobUrl: j.jobUrl,
205 applyUrl: j.applyUrl,
206 salaryMin: comp?.minValue ?? null,
207 salaryMax: comp?.maxValue ?? null,
208 salaryCurrency: comp?.currencyCode ?? null,
209 salaryInterval: comp?.interval ?? null,
210 salaryText: j.compensation?.compensationTierSummary || null,
211 descriptionHtml: includeDescription ? j.descriptionHtml || null : null,
212 descriptionText: includeDescription ? j.descriptionPlain || htmlToText(j.descriptionHtml) : null,
213 };
214 });
215}
216
217
218async function workable(slug, { includeDescription }) {
219 const data = await getJson(
220 `https://apply.workable.com/api/v1/widget/accounts/${encodeURIComponent(slug)}?details=${includeDescription ? 'true' : 'false'}`,
221 );
222 if (!data || !Array.isArray(data.jobs)) throw new NotFoundError('workable: unexpected payload');
223 return data.jobs.map((j) => {
224 const locs = (j.locations || [])
225 .map((l) => [l.city, l.region, l.country].filter(Boolean).join(', '))
226 .filter(Boolean);
227 const loc = [j.city, j.state, j.country].filter(Boolean).join(', ') || locs[0] || null;
228 const html = j.description || null;
229 return {
230 ...base('workable', slug),
231 companyName: data.name || null,
232 jobId: j.shortcode || j.code || null,
233 title: j.title,
234 department: j.department || null,
235 location: loc,
236 locations: locs.length ? locs : loc ? [loc] : [],
237 country: j.country || null,
238 isRemote: Boolean(j.telecommuting) || REMOTE_RE.test(`${loc} ${locs.join(' ')}`),
239 employmentType: j.employment_type || null,
240 postedAt: iso(j.published_on || j.created_at),
241 jobUrl: j.shortlink || j.url,
242 applyUrl: j.application_url || j.url,
243 descriptionHtml: includeDescription ? html : null,
244 descriptionText: includeDescription && html ? htmlToText(html) : null,
245 };
246 });
247}
248
249
250async function smartrecruiters(slug, { includeDescription, maxJobs, filter }) {
251 const out = [];
252 let offset = 0;
253 const limit = 100;
254 for (;;) {
255 const data = await getJson(
256 `https://api.smartrecruiters.com/v1/companies/${encodeURIComponent(slug)}/postings?limit=${limit}&offset=${offset}`,
257 );
258 const items = data.content || [];
259 if (offset === 0 && !items.length && !data.totalFound) throw new NotFoundError('smartrecruiters: no postings');
260 out.push(...items);
261 offset += items.length;
262 if (!items.length || offset >= (data.totalFound || 0) || (!filter && maxJobs && out.length >= maxJobs) || out.length >= 20000) break;
263 }
264 const jobs = [];
265 for (const j of out) {
266 const l = j.location || {};
267 const loc = l.fullLocation || [l.city, l.region, l.country].filter(Boolean).join(', ') || null;
268 const job = {
269 ...base('smartrecruiters', slug),
270 companyName: j.company?.name || null,
271 jobId: j.id,
272 title: j.name,
273 department: j.department?.label || null,
274 location: loc,
275 locations: loc ? [loc] : [],
276 country: l.country ? String(l.country).toUpperCase() : null,
277 isRemote: Boolean(l.remote) || REMOTE_RE.test(loc || ''),
278 workplaceType: l.remote ? 'remote' : l.hybrid ? 'hybrid' : null,
279 employmentType: j.typeOfEmployment?.label || null,
280 postedAt: iso(j.releasedDate),
281 jobUrl: `https://jobs.smartrecruiters.com/${encodeURIComponent(slug)}/${j.id}`,
282 applyUrl: `https://jobs.smartrecruiters.com/${encodeURIComponent(slug)}/${j.id}`,
283 };
284 if (filter && !filter(job)) continue;
285 jobs.push(job);
286 if (maxJobs && jobs.length >= maxJobs) break;
287 }
288 if (includeDescription) {
289 let idx = 0;
290 const worker = async () => {
291 while (idx < jobs.length) {
292 const job = jobs[idx++];
293 const j = { id: job.jobId };
294 try {
295 const d = await getJson(
296 `https://api.smartrecruiters.com/v1/companies/${encodeURIComponent(slug)}/postings/${j.id}`,
297 );
298 const s = d.jobAd?.sections || {};
299 const html = ['companyDescription', 'jobDescription', 'qualifications', 'additionalInformation']
300 .map((k) => (s[k]?.text ? `<h3>${s[k].title || ''}</h3>${s[k].text}` : ''))
301 .join('');
302 job.descriptionHtml = html || null;
303 job.descriptionText = html ? htmlToText(html) : null;
304 if (d.postingUrl) job.jobUrl = d.postingUrl;
305 if (d.applyUrl) job.applyUrl = d.applyUrl;
306 } catch { }
307 }
308 };
309 await Promise.all(Array.from({ length: 8 }, worker));
310 }
311 return jobs;
312}
313
314
315async function recruitee(slug, { includeDescription }) {
316 const data = await getJson(`https://${encodeURIComponent(slug)}.recruitee.com/api/offers/`);
317 if (!data || !Array.isArray(data.offers)) throw new NotFoundError('recruitee: unexpected payload');
318 return data.offers.map((j) => {
319 const html = `${j.description || ''}${j.requirements ? `<h3>Requirements</h3>${j.requirements}` : ''}`;
320 const loc = j.location || [j.city, j.country].filter(Boolean).join(', ') || null;
321 const s = j.salary || {};
322 return {
323 ...base('recruitee', slug),
324 companyName: j.company_name || null,
325 jobId: String(j.id),
326 title: j.title,
327 department: j.department || null,
328 location: loc,
329 locations: loc ? [loc] : [],
330 country: j.country_code || j.country || null,
331 isRemote: Boolean(j.remote) || REMOTE_RE.test(loc || ''),
332 workplaceType: j.remote ? 'remote' : j.hybrid ? 'hybrid' : j.on_site ? 'on-site' : null,
333 employmentType: j.employment_type_code || null,
334 postedAt: iso(j.published_at || j.created_at),
335 updatedAt: iso(j.updated_at),
336 jobUrl: j.careers_url,
337 applyUrl: j.careers_apply_url || j.careers_url,
338 salaryMin: s.min ? Number(s.min) : null,
339 salaryMax: s.max ? Number(s.max) : null,
340 salaryCurrency: s.currency || null,
341 salaryInterval: s.period || null,
342 descriptionHtml: includeDescription ? html || null : null,
343 descriptionText: includeDescription && html ? htmlToText(html) : null,
344 };
345 });
346}
347
348const ADAPTERS = { greenhouse, lever, ashby, workable, smartrecruiters, recruitee };
349const AUTO_ORDER = ['greenhouse', 'lever', 'ashby', 'workable', 'smartrecruiters', 'recruitee'];
350
351
352
353
354
355function parseCompanyRef(raw) {
356 const s = String(raw || '').trim();
357 if (!s) return null;
358 const prefixed = s.match(/^(greenhouse|lever|ashby|workable|smartrecruiters|recruitee)\s*:\s*(.+)$/i);
359 if (prefixed) return { ats: prefixed[1].toLowerCase(), slug: prefixed[2].trim() };
360 let u;
361 try {
362 u = new URL(/^https?:\/\//i.test(s) ? s : `https://${s}`);
363 } catch {
364 return { ats: null, slug: s };
365 }
366 const host = u.hostname.toLowerCase();
367 const parts = u.pathname.split('/').filter(Boolean);
368 const q = u.searchParams;
369 if (host.includes('greenhouse.io')) {
370 if (q.get('for')) return { ats: 'greenhouse', slug: q.get('for') };
371 const i = parts[0] === 'v1' ? parts.indexOf('boards') + 1 : 0;
372 if (parts[i]) return { ats: 'greenhouse', slug: parts[i] };
373 }
374 if (host.endsWith('lever.co') && parts[0]) {
375 return { ats: 'lever', slug: parts[0] === 'v0' ? parts[2] : parts[0] };
376 }
377 if (host.endsWith('ashbyhq.com') && parts.length) {
378 return { ats: 'ashby', slug: parts[0] === 'posting-api' ? parts[2] : parts[0] };
379 }
380 if (host.endsWith('workable.com')) {
381 if (host.startsWith('apply.') && parts[0]) return { ats: 'workable', slug: parts[0] === 'api' ? parts[4] : parts[0] };
382 const sub = host.split('.')[0];
383 if (sub && sub !== 'www' && sub !== 'jobs') return { ats: 'workable', slug: sub };
384 }
385 if (host.endsWith('smartrecruiters.com') && parts[0]) {
386 return { ats: 'smartrecruiters', slug: parts[0] === 'v1' ? parts[2] : parts[0] };
387 }
388 if (host.endsWith('recruitee.com')) return { ats: 'recruitee', slug: host.split('.')[0] };
389
390 const label = host.replace(/^www\./, '').split('.')[0];
391 return { ats: null, slug: label };
392}
393
394
395
396await Actor.init();
397
398const input = (await Actor.getInput()) || {};
399const {
400 companies = [],
401 keywords = [],
402 excludeKeywords = [],
403 locations = [],
404 remoteOnly = false,
405 postedWithinDays = 0,
406 includeDescription = true,
407 maxJobsPerCompany = 0,
408 maxJobsTotal = 0,
409 maxConcurrency = 5,
410} = input;
411
412const refs = companies.map(parseCompanyRef).filter(Boolean);
413if (!refs.length) {
414 await Actor.fail('Input "companies" is empty. Add at least one career page URL, "ats:slug", or company slug.');
415}
416
417const lc = (a) => (Array.isArray(a) ? a : String(a || '').split(',')).map((s) => String(s).trim().toLowerCase()).filter(Boolean);
418const kw = lc(keywords);
419const exKw = lc(excludeKeywords);
420const locF = lc(locations);
421const cutoff = postedWithinDays > 0 ? Date.now() - postedWithinDays * 86400000 : 0;
422
423function matches(job) {
424 const titleDept = `${job.title || ''} ${job.department || ''} ${job.team || ''}`.toLowerCase();
425 if (kw.length && !kw.some((k) => titleDept.includes(k))) return false;
426 if (exKw.length && exKw.some((k) => titleDept.includes(k))) return false;
427 if (remoteOnly && !job.isRemote) return false;
428 if (locF.length) {
429 const where = `${job.location || ''} ${(job.locations || []).join(' ')} ${job.country || ''}`.toLowerCase();
430 const remoteWanted = locF.includes('remote');
431 if (!locF.some((l) => where.includes(l)) && !(remoteWanted && job.isRemote)) return false;
432 }
433 if (cutoff && job.postedAt && Date.parse(job.postedAt) < cutoff) return false;
434 return true;
435}
436
437const prettyName = (s = '') => String(s).replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
438
439const summary = [];
440let pushed = 0;
441let stop = false;
442const seen = new Set();
443
444async function processRef(ref) {
445 if (stop) return;
446 const order = ref.ats ? [ref.ats] : AUTO_ORDER;
447 for (const ats of order) {
448 try {
449 const jobs = await ADAPTERS[ats](ref.slug, { includeDescription, maxJobs: maxJobsPerCompany || 0, filter: matches });
450 let kept = jobs.filter(matches);
451 if (maxJobsPerCompany > 0) kept = kept.slice(0, maxJobsPerCompany);
452 let n = 0;
453 for (const job of kept) {
454 if (stop) break;
455 const key = `${job.source}:${job.companySlug}:${job.jobId}`;
456 if (seen.has(key)) continue;
457 seen.add(key);
458 if (maxJobsTotal > 0 && pushed >= maxJobsTotal) { stop = true; break; }
459 if (!job.companyName) job.companyName = prettyName(job.companySlug);
460 job.scrapedAt = new Date().toISOString();
461 const res = await Actor.pushData(job, 'job-result');
462 pushed++;
463 n++;
464 if (res?.eventChargeLimitReached) {
465 log.warning('Maximum cost per run reached, stopping.');
466 stop = true;
467 }
468 }
469 summary.push({ company: ref.slug, ats, found: jobs.length, saved: n });
470 log.info(`${ats}:${ref.slug} -> ${jobs.length} jobs, ${n} saved after filters`);
471 return;
472 } catch (e) {
473 if (e instanceof NotFoundError) continue;
474 log.warning(`${ats}:${ref.slug} failed: ${e.message}`);
475 if (ref.ats) break;
476 }
477 }
478 summary.push({ company: ref.slug, ats: ref.ats || null, found: 0, saved: 0, error: 'Job board not found on supported ATS' });
479 log.warning(`No public job board found for "${ref.slug}"${ref.ats ? ` on ${ref.ats}` : ''}.`);
480}
481
482const queue = [...refs];
483const workers = Array.from({ length: Math.max(1, Math.min(maxConcurrency, 20)) }, async () => {
484 while (queue.length && !stop) await processRef(queue.shift());
485});
486await Promise.all(workers);
487
488await Actor.setValue('SUMMARY', { totalJobsSaved: pushed, companies: summary });
489log.info(`Done. Saved ${pushed} jobs from ${summary.filter((s) => s.found).length}/${refs.length} companies.`);
490await Actor.exit();