1import { Actor } from 'apify';
2
3
4
5
6
7
8
9function extractCompanyName(url) {
10 const match = url.match(/lever\.co\/([^\/\?]+)/);
11 return match ? match[1] : null;
12}
13
14
15
16
17
18
19
20
21async function fetchPostings(site) {
22 const apiUrl = `https://api.lever.co/v0/postings/${site}?mode=json`;
23 const response = await fetch(apiUrl, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(30_000) });
24 if (!response.ok) {
25 throw new Error(`API request failed: ${response.status} ${response.statusText}`);
26 }
27 const data = await response.json();
28 if (!Array.isArray(data)) {
29 throw new Error(`Unexpected Lever response (not an array): ${JSON.stringify(data).slice(0, 200)}`);
30 }
31 return data;
32}
33
34
35
36function toIso(s) {
37 if (!s) return null;
38 const t = typeof s === 'number' ? s : Date.parse(s);
39 return Number.isNaN(t) ? null : new Date(t).toISOString();
40}
41
42
43
44
45
46
47
48
49function passesFilter(value, filters) {
50 if (!filters || filters.length === 0) return true;
51 if (!value) return false;
52 const v = String(value).toLowerCase().trim();
53 return filters.some((f) => String(f).toLowerCase().trim() === v);
54}
55
56
57
58
59
60
61
62function parseSalaryFromText(text) {
63 const salaryMatch = (text || '').match(
64 /([£€$])(\d{1,3}(?:,\d{3})*|\d+)[kK]?\s*[-–]\s*\1(\d{1,3}(?:,\d{3})*|\d+)[kK]?/,
65 );
66 if (!salaryMatch) return null;
67
68 const parseAmount = (str) => {
69 const num = parseInt(str.replace(/,/g, ''), 10);
70
71 return /[kK]/.test(str) ? num * 1000 : num;
72 };
73
74 let currency = salaryMatch[1] === '£' ? 'GBP' : salaryMatch[1] === '€' ? 'EUR' : 'USD';
75 if (salaryMatch[1] === '$') {
76 const matchIndex = text.indexOf(salaryMatch[0]);
77 const context = text.slice(Math.max(0, matchIndex - 200), matchIndex + 200);
78 if (/\bCAD\b|Canada/i.test(context)) currency = 'CAD';
79 else if (/\bAUD\b|Australia/i.test(context)) currency = 'AUD';
80 else if (/\bEUR\b|Europe|Ireland/i.test(context)) currency = 'EUR';
81 else if (/\bGBP\b|UK|United Kingdom/i.test(context)) currency = 'GBP';
82 }
83
84 return {
85 min: parseAmount(salaryMatch[2]),
86 max: parseAmount(salaryMatch[3]),
87 currency,
88 interval: null,
89 raw: salaryMatch[0],
90 };
91}
92
93
94
95
96
97
98function extractSalary(posting) {
99 const sr = posting.salaryRange;
100 if (sr && (sr.min != null || sr.max != null)) {
101 return {
102 min: sr.min != null ? Number(sr.min) : null,
103 max: sr.max != null ? Number(sr.max) : null,
104 currency: sr.currency || 'USD',
105 interval: sr.interval || null,
106 raw: `${sr.currency || ''}${sr.min ?? ''}-${sr.max ?? ''}`.trim(),
107 };
108 }
109
110 return parseSalaryFromText(posting.salaryDescriptionPlain || posting.descriptionPlain || '');
111}
112
113
114
115
116
117
118
119
120function formatJobOutput(posting, site) {
121 const categories = posting.categories || {};
122 const locationRaw = categories.location || '';
123 const locations = Array.isArray(categories.allLocations) && categories.allLocations.length
124 ? categories.allLocations
125 : (locationRaw ? [locationRaw] : []);
126
127 const workplaceType = posting.workplaceType || null;
128
129 const haystack = `${workplaceType || ''} ${categories.commitment || ''} ${locations.join(' ')}`.toLowerCase();
130 const isRemote = workplaceType === 'remote' || haystack.includes('remote');
131 const isHybrid = workplaceType === 'hybrid' || haystack.includes('hybrid');
132
133 return {
134 id: posting.id,
135 company: site,
136 type: categories.commitment || null,
137 title: posting.text,
138 description: posting.description || '',
139 descriptionPlain: posting.descriptionPlain || '',
140
141 location: locationRaw,
142 locations,
143 workplaceType,
144 isRemote,
145 isHybrid,
146
147 salary: extractSalary(posting),
148 salaryDescription: posting.salaryDescriptionPlain || null,
149
150 department: categories.department || null,
151 team: categories.team || null,
152 country: posting.country || null,
153
154 metadata: {
155 commitment: categories.commitment || null,
156 department: categories.department || null,
157 team: categories.team || null,
158 },
159
160 postingUrl: posting.hostedUrl || `https://jobs.lever.co/${site}/${posting.id}`,
161 applyUrl: posting.applyUrl || `${posting.hostedUrl || `https://jobs.lever.co/${site}/${posting.id}`}/apply`,
162 publishedAt: toIso(posting.createdAt),
163 };
164}
165
166await Actor.main(async () => {
167 const input = await Actor.getInput() || {};
168 const urls = input.urls || input.requestListSources || [];
169
170 if (!urls || urls.length === 0) {
171 throw new Error('No URLs provided. Add at least one Lever job board URL to the "urls" field.');
172 }
173
174 let totalProcessed = 0;
175 let boardErrors = 0;
176
177 for (const urlConfig of urls) {
178 const url = typeof urlConfig === 'string' ? urlConfig : urlConfig.url;
179 const departments = (urlConfig && urlConfig.departments) || [];
180 const teams = (urlConfig && urlConfig.teams) || [];
181 const maxJobs = (urlConfig && urlConfig.maxJobs) || null;
182 const daysBack = (urlConfig && urlConfig.daysBack) || null;
183
184 const site = extractCompanyName(url);
185 if (!site) {
186 console.log(`⚠️ Invalid Lever URL: ${url} (expected https://jobs.lever.co/company-name)`);
187 boardErrors++;
188 continue;
189 }
190
191
192
193 let cutoffDate = null;
194 if (daysBack !== null && daysBack !== undefined) {
195 const days = parseInt(daysBack, 10);
196 if (Number.isInteger(days) && days > 0) {
197 cutoffDate = new Date();
198 cutoffDate.setDate(cutoffDate.getDate() - days);
199 }
200 }
201
202 console.log(`\n📋 Scraping: ${site}`);
203 if (departments.length) console.log(` 🎯 Department filters: ${departments.join(', ')}`);
204 if (teams.length) console.log(` 🎯 Team filters: ${teams.join(', ')}`);
205 if (cutoffDate) console.log(` 📅 Date filter: created after ${cutoffDate.toISOString()}`);
206
207 try {
208 const postings = await fetchPostings(site);
209 if (!postings.length) {
210 console.log(' ⚠️ No postings found (site may have no active jobs or wrong token)');
211 continue;
212 }
213
214 const boardResults = [];
215 for (const posting of postings) {
216 const categories = posting.categories || {};
217
218
219 if (!passesFilter(categories.department, departments)) continue;
220 if (!passesFilter(categories.team, teams)) continue;
221
222 if (cutoffDate) {
223 const t = typeof posting.createdAt === 'number'
224 ? posting.createdAt
225 : (posting.createdAt ? Date.parse(posting.createdAt) : NaN);
226 if (Number.isNaN(t) || t < cutoffDate.getTime()) continue;
227 }
228
229 boardResults.push(formatJobOutput(posting, site));
230
231 if (maxJobs && boardResults.length >= maxJobs) {
232 console.log(` Reached maxJobs limit of ${maxJobs}, stopping`);
233 break;
234 }
235 }
236
237 if (boardResults.length) {
238 await Actor.pushData(boardResults);
239 totalProcessed += boardResults.length;
240 }
241 console.log(` ✅ Stored ${boardResults.length} job(s) after filtering`);
242 } catch (error) {
243 boardErrors++;
244 console.log(` ❌ Error scraping ${site}: ${error.message}`);
245 }
246
247
248 await new Promise((resolve) => setTimeout(resolve, 100));
249 }
250
251
252 if (boardErrors > 0 && totalProcessed === 0) {
253 throw new Error(`All ${boardErrors} board(s) failed and no jobs were stored.`);
254 }
255
256 console.log(`\n✅ Scraping complete! Stored ${totalProcessed} job(s) from ${urls.length} board(s).`);
257});