1import { Actor, log } from 'apify';
2import { z } from 'zod';
3
4const CHARGE_EVENT = 'job-scraped';
5
6const CompanySchema = z.object({
7 ats: z.enum(['greenhouse', 'lever', 'ashby', 'workable']),
8 board_token: z.string().min(1),
9});
10
11const InputSchema = z.object({
12 companies: z.array(CompanySchema).optional().default([]),
13 discover_domain: z.string().optional(),
14 timeoutSeconds: z.number().int().min(5).max(60).optional().default(20),
15});
16
17type Job = {
18 company: string;
19 job_title: string;
20 department: string | null;
21 location: string | null;
22 remote: boolean;
23 url: string;
24 posted_at: string | null;
25 ats: string;
26};
27
28function isRemote(text: string | null | undefined): boolean {
29 if (!text) return false;
30 return /\bremote\b|\bwfh\b|work from home/i.test(text);
31}
32
33async function fetchJson(url: string, timeoutSeconds: number): Promise<unknown> {
34 const res = await fetch(url, {
35 headers: { Accept: 'application/json', 'User-Agent': 'ATS-Jobs-Scraper/1.0 (+https://apify.com)' },
36 signal: AbortSignal.timeout(timeoutSeconds * 1000),
37 });
38 if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
39 return res.json();
40}
41
42async function scrapeGreenhouse(token: string, timeoutSeconds: number): Promise<Job[]> {
43 const data = (await fetchJson(
44 `https://boards-api.greenhouse.io/v1/boards/${encodeURIComponent(token)}/jobs?content=true`,
45 timeoutSeconds,
46 )) as { jobs?: Record<string, unknown>[] };
47 const jobs = data.jobs ?? [];
48 return jobs.map((j) => {
49 const loc = (j.location as { name?: string } | undefined)?.name ?? null;
50 const depts = j.departments as { name?: string }[] | undefined;
51 return {
52 company: token,
53 job_title: String(j.title ?? ''),
54 department: depts?.[0]?.name ?? null,
55 location: loc,
56 remote: isRemote(loc) || isRemote(String(j.title ?? '')),
57 url: String(j.absolute_url ?? j.url ?? ''),
58 posted_at: j.updated_at ? String(j.updated_at) : j.first_published ? String(j.first_published) : null,
59 ats: 'greenhouse',
60 };
61 });
62}
63
64async function scrapeLever(token: string, timeoutSeconds: number): Promise<Job[]> {
65 const data = (await fetchJson(
66 `https://api.lever.co/v0/postings/${encodeURIComponent(token)}?mode=json`,
67 timeoutSeconds,
68 )) as Record<string, unknown>[];
69 return (Array.isArray(data) ? data : []).map((j) => {
70 const cats = j.categories as { location?: string; team?: string; commitment?: string } | undefined;
71 const loc = cats?.location ?? null;
72 return {
73 company: token,
74 job_title: String(j.text ?? ''),
75 department: cats?.team ?? null,
76 location: loc,
77 remote: isRemote(loc) || isRemote(cats?.commitment),
78 url: String(j.hostedUrl ?? j.applyUrl ?? ''),
79 posted_at: j.createdAt ? new Date(Number(j.createdAt)).toISOString() : null,
80 ats: 'lever',
81 };
82 });
83}
84
85async function scrapeAshby(token: string, timeoutSeconds: number): Promise<Job[]> {
86 const data = (await fetchJson(
87 `https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(token)}`,
88 timeoutSeconds,
89 )) as { jobs?: Record<string, unknown>[] };
90 return (data.jobs ?? []).map((j) => {
91 const loc = j.location ? String(j.location) : null;
92 return {
93 company: token,
94 job_title: String(j.title ?? ''),
95 department: j.department ? String(j.department) : null,
96 location: loc,
97 remote: Boolean(j.isRemote) || isRemote(loc),
98 url: String(j.jobUrl ?? j.applyUrl ?? ''),
99 posted_at: j.publishedAt ? String(j.publishedAt) : null,
100 ats: 'ashby',
101 };
102 });
103}
104
105async function scrapeWorkable(token: string, timeoutSeconds: number): Promise<Job[]> {
106 const data = (await fetchJson(
107 `https://apply.workable.com/api/v1/widget/accounts/${encodeURIComponent(token)}`,
108 timeoutSeconds,
109 )) as { jobs?: Record<string, unknown>[]; name?: string };
110 return (data.jobs ?? []).map((j) => {
111 const locs = j.locations as { city?: string; country?: string; telecommuting?: boolean }[] | undefined;
112 const locParts = locs?.map((l) => [l.city, l.country].filter(Boolean).join(', ')).filter(Boolean) ?? [];
113 const loc = locParts.join(' | ') || null;
114 const remote = Boolean(locs?.some((l) => l.telecommuting)) || isRemote(loc);
115 return {
116 company: data.name ? String(data.name) : token,
117 job_title: String(j.title ?? ''),
118 department: j.department ? String(j.department) : null,
119 location: loc,
120 remote,
121 url: String(j.url ?? `https://apply.workable.com/${token}/j/${j.shortcode ?? ''}/`),
122 posted_at: j.published_on ? String(j.published_on) : null,
123 ats: 'workable',
124 };
125 });
126}
127
128async function discoverAts(domain: string, timeoutSeconds: number): Promise<z.infer<typeof CompanySchema>[]> {
129 const withProto = /^https?:\/\//i.test(domain) ? domain : `https://${domain}`;
130 const url = new URL(withProto);
131 const candidates = [
132 url.origin,
133 `${url.origin}/careers`,
134 `${url.origin}/jobs`,
135 `${url.origin}/careers/jobs`,
136 ];
137 const found: z.infer<typeof CompanySchema>[] = [];
138 const seen = new Set<string>();
139
140 for (const pageUrl of candidates) {
141 try {
142 const res = await fetch(pageUrl, {
143 headers: { 'User-Agent': 'ATS-Jobs-Scraper/1.0' },
144 signal: AbortSignal.timeout(timeoutSeconds * 1000),
145 redirect: 'follow',
146 });
147 if (!res.ok) continue;
148 const html = await res.text();
149 const patterns: { ats: z.infer<typeof CompanySchema>['ats']; re: RegExp }[] = [
150 { ats: 'greenhouse', re: /boards\.greenhouse\.io\/([a-z0-9_-]+)/gi },
151 { ats: 'greenhouse', re: /boards-api\.greenhouse\.io\/v1\/boards\/([a-z0-9_-]+)/gi },
152 { ats: 'lever', re: /jobs\.lever\.co\/([a-z0-9_-]+)/gi },
153 { ats: 'ashby', re: /jobs\.ashbyhq\.com\/([a-z0-9_-]+)/gi },
154 { ats: 'workable', re: /apply\.workable\.com\/([a-z0-9_-]+)/gi },
155 ];
156 for (const { ats, re } of patterns) {
157 let m: RegExpExecArray | null;
158 while ((m = re.exec(html)) !== null) {
159 const token = m[1]!.toLowerCase();
160 const key = `${ats}:${token}`;
161 if (seen.has(key)) continue;
162 seen.add(key);
163 found.push({ ats, board_token: token });
164 }
165 }
166 if (found.length) break;
167 } catch {
168
169 }
170 }
171 return found;
172}
173
174async function scrapeBoard(
175 company: z.infer<typeof CompanySchema>,
176 timeoutSeconds: number,
177): Promise<Job[]> {
178 switch (company.ats) {
179 case 'greenhouse':
180 return scrapeGreenhouse(company.board_token, timeoutSeconds);
181 case 'lever':
182 return scrapeLever(company.board_token, timeoutSeconds);
183 case 'ashby':
184 return scrapeAshby(company.board_token, timeoutSeconds);
185 case 'workable':
186 return scrapeWorkable(company.board_token, timeoutSeconds);
187 default: {
188 const _exhaustive: never = company.ats;
189 throw new Error(`Unsupported ATS: ${_exhaustive}`);
190 }
191 }
192}
193
194await Actor.init();
195
196try {
197 const input = InputSchema.parse((await Actor.getInput()) ?? {});
198 const boards = [...input.companies];
199
200 if (input.discover_domain) {
201 log.info(`Discovering ATS for ${input.discover_domain}`);
202 const discovered = await discoverAts(input.discover_domain, input.timeoutSeconds);
203 log.info(`Discovered ${discovered.length} board(s)`);
204 boards.push(...discovered);
205 }
206
207
208 const uniq = new Map<string, z.infer<typeof CompanySchema>>();
209 for (const b of boards) uniq.set(`${b.ats}:${b.board_token.toLowerCase()}`, b);
210 const list = [...uniq.values()];
211 if (list.length === 0) throw new Error('Provide companies[] or discover_domain.');
212
213 const dataset = await Actor.openDataset();
214 let total = 0;
215
216 for (const board of list) {
217 log.info(`Scraping ${board.ats}/${board.board_token}`);
218 try {
219 const jobs = await scrapeBoard(board, input.timeoutSeconds);
220 for (const job of jobs) {
221 await dataset.pushData(job);
222 total += 1;
223 try {
224 await Actor.charge({ eventName: CHARGE_EVENT, count: 1 });
225 } catch (e) {
226 log.debug(`Charge skipped: ${e}`);
227 }
228 }
229 log.info(` → ${jobs.length} jobs`);
230 } catch (e) {
231 log.warning(`Failed ${board.ats}/${board.board_token}: ${e instanceof Error ? e.message : e}`);
232 }
233 }
234
235 log.info(`Done. ${total} jobs total.`);
236} finally {
237 await Actor.exit();
238}