1
2
3
4
5const UA = 'Mozilla/5.0 (compatible; ats-jobs-actor/1.0)';
6
7async function get(url, { json = true, retries = 3 } = {}) {
8 for (let attempt = 1; ; attempt++) {
9 const res = await fetch(url, { headers: { 'user-agent': UA, accept: json ? 'application/json' : '*/*' }, signal: AbortSignal.timeout(30000) });
10 if (res.ok) return json ? res.json() : res.text();
11 if (res.status === 404) throw new Error(`Board not found (404): ${url}`);
12 if (attempt >= retries || (res.status < 500 && res.status !== 429)) {
13 throw new Error(`HTTP ${res.status} for ${url}`);
14 }
15 await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
16 }
17}
18
19const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'" };
20const decode = (s) => String(s ?? '').replace(/&(#x?[0-9a-f]+|\w+);/gi, (m, e) => {
21 if (e[0] === '#') {
22 const n = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
23 return Number.isInteger(n) && n >= 0 && n <= 0x10ffff ? String.fromCodePoint(n) : m;
24 }
25 return ENTITIES[e.toLowerCase()] ?? m;
26});
27
28export function htmlToText(html) {
29
30 let s = decode(html);
31 s = s.replace(/<(br|\/p|\/div|\/li|\/h[1-6])[^>]*>/gi, '\n').replace(/<li[^>]*>/gi, '• ');
32 s = decode(s.replace(/<[^>]+>/g, ''));
33 return s.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
34}
35
36const iso = (v) => {
37 if (v === undefined || v === null || v === '') return null;
38 const d = new Date(typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : v);
39 return Number.isNaN(d.getTime()) ? null : d.toISOString();
40};
41
42
43
44export const remoteLabel = (value) => /\bremote\b/i.test(String(value ?? ''))
45 && !/\b(?:not|non|no)[\s-]+remote\b/i.test(String(value ?? ''));
46
47
48export function parseBoard(input) {
49 const raw = String(input).trim();
50 const short = raw.match(/^(greenhouse|lever|ashby|workable|smartrecruiters|recruitee|personio|teamtailor|breezy|rippling|bamboohr):([\w.-]+)$/i);
51 if (short) return { ats: short[1].toLowerCase(), slug: short[2] };
52
53 let u;
54 try { u = new URL(raw.includes('://') ? raw : `https://${raw}`); } catch { return null; }
55 if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password || u.port) return null;
56 const host = u.hostname.toLowerCase();
57 const first = u.pathname.split('/').filter(Boolean)[0];
58 const sub = host.split('.')[0];
59
60 if (/(^|\.)greenhouse\.io$/.test(host)) {
61 const q = u.searchParams.get('for');
62 if (q) return { ats: 'greenhouse', slug: q };
63 if (host.startsWith('boards-api.')) return /^\/v1\/boards\/([^/]+)\/jobs/.test(u.pathname) ? { ats: 'greenhouse', slug: u.pathname.split('/')[3] } : null;
64 return first ? { ats: 'greenhouse', slug: first } : null;
65 }
66 if (host === 'jobs.lever.co' || host === 'jobs.eu.lever.co') return first ? { ats: 'lever', slug: first, eu: host.includes('.eu.') } : null;
67 if (host === 'jobs.ashbyhq.com') return first ? { ats: 'ashby', slug: first } : null;
68 if (host === 'apply.workable.com') return first && first !== 'j' ? { ats: 'workable', slug: first } : null;
69 if (host.endsWith('.workable.com') && sub !== 'www' && sub !== 'apply') return { ats: 'workable', slug: sub };
70 if (host === 'jobs.smartrecruiters.com' || host === 'careers.smartrecruiters.com') return first ? { ats: 'smartrecruiters', slug: first } : null;
71 if (host.endsWith('.recruitee.com')) return { ats: 'recruitee', slug: sub };
72 if (/\.jobs\.personio\.(de|com)$/.test(host)) return { ats: 'personio', slug: sub, tld: host.split('.').pop() };
73 if (host.endsWith('.teamtailor.com') && sub !== 'www') return { ats: 'teamtailor', slug: sub };
74 if (host.endsWith('.breezy.hr') && sub !== 'www') return { ats: 'breezy', slug: sub };
75 if (host === 'ats.rippling.com') return first ? { ats: 'rippling', slug: first } : null;
76 if (host.endsWith('.bamboohr.com') && !['www', 'api'].includes(sub)) return { ats: 'bamboohr', slug: sub };
77 if (/^career\d*\.successfactors\.(eu|com)$/.test(host)) {
78 const company = u.searchParams.get('company');
79 return company ? { ats: 'successfactors', slug: company, host } : null;
80 }
81 return null;
82}
83
84const base = (ats, slug, j) => ({
85 source: ats,
86 company: slug,
87 jobId: null,
88 title: null,
89 location: null,
90 country: null,
91 remote: null,
92 workplaceType: null,
93 department: null,
94 team: null,
95 employmentType: null,
96 salary: null,
97 postedAt: null,
98 updatedAt: null,
99 url: null,
100 applyUrl: null,
101 description: null,
102 ...j,
103});
104
105const fetchers = {
106 async greenhouse({ slug }, { withDescription }) {
107 const data = await get(`https://boards-api.greenhouse.io/v1/boards/${encodeURIComponent(slug)}/jobs?content=${withDescription}`);
108 return data.jobs.map((j) => base('greenhouse', j.company_name || slug, {
109 jobId: String(j.id),
110 title: j.title?.trim(),
111 location: j.location?.name ?? null,
112 remote: remoteLabel(j.location?.name) || null,
113 department: j.departments?.map((d) => d.name).join(', ') || null,
114 postedAt: iso(j.first_published),
115 updatedAt: iso(j.updated_at),
116 url: j.absolute_url,
117 applyUrl: j.absolute_url,
118 description: withDescription ? htmlToText(j.content) : null,
119 }));
120 },
121
122 async lever({ slug, eu }) {
123 const host = eu ? 'api.eu.lever.co' : 'api.lever.co';
124 const data = await get(`https://${host}/v0/postings/${encodeURIComponent(slug)}?mode=json`);
125 return data.map((j) => base('lever', slug, {
126 jobId: j.id,
127 title: j.text?.trim(),
128 location: j.categories?.allLocations?.join('; ') || j.categories?.location || null,
129 country: j.country ?? null,
130 remote: j.workplaceType ? j.workplaceType === 'remote' : null,
131 workplaceType: j.workplaceType ?? null,
132 department: j.categories?.department ?? null,
133 team: j.categories?.team ?? null,
134 employmentType: j.categories?.commitment ?? null,
135 salary: j.salaryRange ? `${j.salaryRange.currency ?? ''} ${j.salaryRange.min}–${j.salaryRange.max} ${j.salaryRange.interval ?? ''}`.trim() : null,
136 postedAt: iso(j.createdAt),
137 url: j.hostedUrl,
138 applyUrl: j.applyUrl,
139 description: [j.descriptionPlain, ...(j.lists ?? []).map((l) => `${l.text}\n${htmlToText(l.content)}`), j.additionalPlain].filter(Boolean).join('\n\n').trim() || null,
140 }));
141 },
142
143 async ashby({ slug }) {
144 const data = await get(`https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(slug)}?includeCompensation=true`);
145 return data.jobs.filter((j) => j.isListed !== false).map((j) => base('ashby', slug, {
146 jobId: j.id,
147 title: j.title?.trim(),
148 location: [j.location, ...(j.secondaryLocations ?? []).map((l) => l.location)].filter(Boolean).join('; ') || null,
149 country: j.address?.postalAddress?.addressCountry ?? null,
150 remote: j.isRemote ?? null,
151 workplaceType: j.workplaceType ?? null,
152 department: j.department ?? null,
153 team: j.team ?? null,
154 employmentType: j.employmentType ?? null,
155 salary: j.compensation?.scrapeableCompensationSalarySummary || j.compensation?.compensationTierSummary || null,
156 postedAt: iso(j.publishedAt),
157 url: j.jobUrl,
158 applyUrl: j.applyUrl,
159 description: j.descriptionPlain?.trim() || null,
160 }));
161 },
162
163 async workable({ slug }, { withDescription }) {
164 const data = await get(`https://apply.workable.com/api/v1/widget/accounts/${encodeURIComponent(slug)}?details=${withDescription}`);
165 return data.jobs.map((j) => base('workable', data.name || slug, {
166 jobId: j.shortcode,
167 title: j.title?.trim(),
168 location: (j.locations?.length ? j.locations : [j]).map((l) => [l.city, l.region ?? l.state, l.country].filter(Boolean).join(', ')).join('; ') || null,
169 country: j.country || null,
170 remote: j.telecommuting ?? null,
171 department: j.department || null,
172 employmentType: j.employment_type || null,
173 postedAt: iso(j.published_on || j.created_at),
174 url: j.url || j.shortlink,
175 applyUrl: j.application_url ?? null,
176 description: withDescription && j.description ? htmlToText(j.description) : null,
177 }));
178 },
179
180 async smartrecruiters({ slug }, { withDescription, maxJobs }) {
181 const out = [];
182 const seen = new Set();
183 for (let offset = 0; ; offset += 100) {
184 const page = await get(`https://api.smartrecruiters.com/v1/companies/${encodeURIComponent(slug)}/postings?limit=100&offset=${offset}`);
185 if (!Array.isArray(page.content) || !Number.isFinite(page.totalFound)) throw new Error('Invalid SmartRecruiters list response');
186 const fresh = page.content.filter((job) => { const key = String(job.id); if (seen.has(key)) return false; seen.add(key); return true; });
187 if (page.content.length && !fresh.length) throw new Error('SmartRecruiters pagination repeated a page');
188 out.push(...fresh);
189 if (!page.content.length || out.length >= page.totalFound || (maxJobs && out.length >= maxJobs)) break;
190 }
191 const jobs = maxJobs ? out.slice(0, maxJobs) : out;
192 const details = withDescription ? await mapLimit(jobs, 5, (j) => get(`https://api.smartrecruiters.com/v1/companies/${encodeURIComponent(slug)}/postings/${j.id}`).catch(() => null)) : [];
193 return jobs.map((j, i) => {
194 const sections = details[i]?.jobAd?.sections;
195 const loc = j.location ?? {};
196 return base('smartrecruiters', j.company?.name || slug, {
197 jobId: String(j.id),
198 title: j.name?.trim(),
199 location: loc.fullLocation || [loc.city, loc.region, loc.country?.toUpperCase()].filter(Boolean).join(', ') || null,
200 country: loc.country?.toUpperCase() ?? null,
201 remote: loc.remote ?? null,
202 workplaceType: loc.remote ? 'remote' : loc.hybrid ? 'hybrid' : 'onsite',
203 department: j.department?.label ?? j.function?.label ?? null,
204 employmentType: j.typeOfEmployment?.label ?? null,
205 postedAt: iso(j.releasedDate),
206 url: `https://jobs.smartrecruiters.com/${slug}/${j.id}`,
207 applyUrl: details[i]?.applyUrl ?? null,
208 detailStatus: withDescription ? details[i] ? 'available' : 'unavailable' : 'not_requested',
209 description: sections ? Object.values(sections).map((s) => [s.title, htmlToText(s.text)].filter(Boolean).join('\n')).join('\n\n').trim() : null,
210 });
211 });
212 },
213
214 async recruitee({ slug }, { withDescription }) {
215 const data = await get(`https://${encodeURIComponent(slug)}.recruitee.com/api/offers/`);
216 return data.offers.map((j) => base('recruitee', j.company_name || slug, {
217 jobId: String(j.id),
218 title: j.title?.trim(),
219 location: j.locations?.map((l) => [l.city, l.country].filter(Boolean).join(', ')).join('; ') || j.location || null,
220 country: j.country_code ?? j.locations?.[0]?.country_code ?? null,
221 remote: j.remote ?? null,
222 workplaceType: j.hybrid ? 'hybrid' : j.remote ? 'remote' : j.on_site ? 'onsite' : null,
223 department: j.department ?? null,
224 employmentType: j.employment_type_code ?? null,
225 salary: j.salary?.min ? `${j.salary.currency ?? ''} ${j.salary.min}–${j.salary.max ?? ''} ${j.salary.period ?? ''}`.trim() : null,
226 postedAt: iso((j.published_at || j.created_at)?.replace(' UTC', 'Z').replace(' ', 'T')),
227 url: j.careers_url,
228 applyUrl: j.careers_apply_url ?? j.careers_url,
229 description: withDescription ? htmlToText(`${j.description ?? ''}\n${j.requirements ?? ''}`) || null : null,
230 }));
231 },
232
233 async personio({ slug, tld = 'de' }, { withDescription }) {
234 const xml = await get(`https://${encodeURIComponent(slug)}.jobs.personio.${tld}/xml`, { json: false });
235 if (!/<workzag-jobs(?:\s|\/?>)/.test(xml)) throw new Error('Invalid Personio jobs XML response');
236 const tag = (s, t) => {
237 const m = s.match(new RegExp(`<${t}>([\\s\\S]*?)</${t}>`));
238 return m ? decode(m[1].replace(/^<!\[CDATA\[|\]\]>$/g, '')).trim() : null;
239 };
240 return [...xml.matchAll(/<position>([\s\S]*?)<\/position>/g)].map(([, p]) => {
241 const id = tag(p, 'id');
242 const offices = [tag(p, 'office'), ...[...(p.match(/<additionalOffices>([\s\S]*?)<\/additionalOffices>/)?.[1] ?? '').matchAll(/<office>([\s\S]*?)<\/office>/g)].map((m) => m[1].trim())].filter(Boolean);
243 const desc = [...p.matchAll(/<jobDescription>([\s\S]*?)<\/jobDescription>/g)].map(([, d]) => [tag(d, 'name'), htmlToText(tag(d, 'value'))].filter(Boolean).join('\n')).join('\n\n');
244 return base('personio', tag(p, 'subcompany') || slug, {
245 jobId: id,
246 title: tag(p, 'name'),
247 location: offices.join('; ') || null,
248 remote: remoteLabel(offices.join(' ')) || null,
249 department: tag(p, 'department'),
250 team: tag(p, 'recruitingCategory'),
251 employmentType: [tag(p, 'employmentType'), tag(p, 'schedule')].filter(Boolean).join(', ') || null,
252 postedAt: iso(tag(p, 'createdAt')),
253 url: `https://${slug}.jobs.personio.${tld}/job/${id}`,
254 applyUrl: `https://${slug}.jobs.personio.${tld}/job/${id}#apply`,
255 description: withDescription ? desc || null : null,
256 });
257 });
258 },
259
260
261 async teamtailor({ slug }, { maxJobs }) {
262 const items = [];
263 const visited = new Set();
264 const origin = `https://${encodeURIComponent(slug)}.teamtailor.com`;
265 for (let url = `https://${encodeURIComponent(slug)}.teamtailor.com/jobs.json`; url; ) {
266 const current = new URL(url, origin);
267 if (current.origin !== origin || current.username || current.password) throw new Error('Unexpected Teamtailor pagination destination');
268 if (visited.has(current.href)) throw new Error('Teamtailor pagination repeated a page');
269 visited.add(current.href);
270 const feed = await get(current.href);
271 if (!Array.isArray(feed.items)) throw new Error('Invalid Teamtailor feed response');
272 items.push(...feed.items);
273 url = maxJobs && items.length >= maxJobs ? null : feed.next_url;
274 }
275 return items.map((j) => {
276 const jp = j._jobposting ?? {};
277 const places = [jp.jobLocation].flat().filter(Boolean).map((l) => l.address ?? {});
278 const remote = jp.jobLocationType === 'TELECOMMUTE' || remoteLabel(j.title);
279 return base('teamtailor', jp.hiringOrganization?.name || slug, {
280 jobId: String(j.id),
281 title: j.title?.trim(),
282 location: places.map((a) => [a.addressLocality, a.addressRegion, a.addressCountry].filter(Boolean).join(', ')).filter(Boolean).join('; ') || null,
283 country: places[0]?.addressCountry ?? null,
284 remote: remote || null,
285 workplaceType: remote ? 'remote' : null,
286 employmentType: [jp.employmentType].flat().filter(Boolean).join(', ') || null,
287 postedAt: iso(j.date_published ?? jp.datePosted),
288 url: j.url,
289 applyUrl: j.url,
290 description: htmlToText(j.content_html ?? jp.description) || null,
291 });
292 });
293 },
294
295 async breezy({ slug }) {
296 const data = await get(`https://${encodeURIComponent(slug)}.breezy.hr/json?verbose=true`);
297 return data.map((j) => {
298 const locs = j.locations?.length ? j.locations : [j.location].filter(Boolean);
299 const remote = locs.some((l) => l.is_remote);
300 return base('breezy', j.company?.name || slug, {
301 jobId: j.id,
302 title: j.name?.trim(),
303 location: locs.map((l) => l.name).filter(Boolean).join('; ') || null,
304 country: locs[0]?.country?.id ?? null,
305 remote: remote || null,
306 workplaceType: remote ? 'remote' : null,
307 department: j.department || null,
308 employmentType: j.type?.name ?? null,
309 salary: j.salary || null,
310 postedAt: iso(j.published_date),
311 url: j.url,
312 applyUrl: j.url,
313 description: htmlToText(j.description) || null,
314 });
315 });
316 },
317
318 async rippling({ slug }, { withDescription, needsPostedDate, maxJobs }) {
319 const list = [];
320 const seen = new Set();
321 for (let page = 0; ; page++) {
322 const data = await get(`https://ats.rippling.com/api/v2/board/${encodeURIComponent(slug)}/jobs?page=${page}&pageSize=100`);
323 if (!Array.isArray(data.items) || !Number.isFinite(data.totalPages)) throw new Error('Invalid Rippling jobs response');
324 const fresh = data.items.filter((job) => { const key = String(job.id); if (seen.has(key)) return false; seen.add(key); return true; });
325 if (data.items.length && !fresh.length) throw new Error('Rippling pagination repeated a page');
326 list.push(...fresh);
327 if (page + 1 >= (data.totalPages ?? 0) || (maxJobs && list.length >= maxJobs)) break;
328 }
329 const jobs = maxJobs ? list.slice(0, maxJobs) : list;
330
331 const details = withDescription || needsPostedDate ? await mapLimit(jobs, 6, (j) => get(`https://ats.rippling.com/api/v2/board/${encodeURIComponent(slug)}/jobs/${j.id}`).catch(() => null)) : [];
332 return jobs.map((j, i) => {
333 const d = details[i];
334 const locs = j.locations ?? [];
335 const types = [...new Set(locs.map((l) => l.workplaceType).filter(Boolean))];
336 const pay = d?.payRangeDetails?.[0];
337 return base('rippling', d?.companyName || slug, {
338 jobId: j.id,
339 title: j.name?.trim(),
340 location: locs.map((l) => l.name).join('; ') || null,
341 country: locs[0]?.countryCode ?? null,
342 remote: types.includes('REMOTE') || null,
343 workplaceType: types.length === 1 ? types[0].replace('_', '') : types.length ? 'mixed' : null,
344 department: j.department?.name ?? null,
345 employmentType: d?.employmentType?.id ?? null,
346 salary: pay ? `${pay.currency} ${pay.rangeStart}–${pay.rangeEnd} ${String(pay.frequency ?? '').toLowerCase()}`.trim() : null,
347 postedAt: iso(d?.createdOn),
348 detailStatus: withDescription || needsPostedDate ? d ? 'available' : 'unavailable' : 'not_requested',
349 url: j.url,
350 applyUrl: j.url,
351 description: d ? htmlToText(`${d.description?.role ?? ''}\n${d.description?.company ?? ''}`) || null : null,
352 });
353 });
354 },
355
356 async bamboohr({ slug }, { withDescription, needsPostedDate, maxJobs }) {
357 const data = await get(`https://${encodeURIComponent(slug)}.bamboohr.com/careers/list`);
358 const jobs = maxJobs ? data.result.slice(0, maxJobs) : data.result;
359 const details = withDescription || needsPostedDate ? await mapLimit(jobs, 5, (j) => get(`https://${encodeURIComponent(slug)}.bamboohr.com/careers/${j.id}/detail`).then((r) => r.result?.jobOpening).catch(() => null)) : [];
360 return jobs.map((j, i) => {
361 const d = details[i];
362 const loc = [j.location?.city, j.location?.state, j.atsLocation?.city, j.atsLocation?.state, j.atsLocation?.country].filter(Boolean);
363 const remote = j.isRemote === true || j.locationType === '1';
364 return base('bamboohr', slug, {
365 jobId: String(j.id),
366 title: j.jobOpeningName?.trim(),
367 location: [...new Set(loc)].join(', ') || null,
368 country: j.atsLocation?.country ?? d?.location?.addressCountry ?? null,
369 remote: remote || null,
370 workplaceType: remote ? 'remote' : j.locationType === '2' ? 'hybrid' : null,
371 department: j.departmentLabel ?? null,
372 employmentType: j.employmentStatusLabel ?? null,
373 salary: d?.compensation || null,
374 postedAt: iso(d?.datePosted),
375 detailStatus: withDescription || needsPostedDate ? d ? 'available' : 'unavailable' : 'not_requested',
376 url: `https://${slug}.bamboohr.com/careers/${j.id}`,
377 applyUrl: `https://${slug}.bamboohr.com/careers/${j.id}`,
378 description: d?.description ? htmlToText(d.description) : null,
379 });
380 });
381 },
382
383
384 async successfactors({ slug, host }) {
385 const xml = await get(`https://${host}/career?company=${encodeURIComponent(slug)}&career_ns=job_listing_summary&resultType=XML`, { json: false });
386 const tag = (s, t) => {
387 const m = s.match(new RegExp(`<${t}>([\\s\\S]*?)</${t}>`));
388 return m ? decode(m[1].replace(/^\s*<!\[CDATA\[|\]\]>\s*$/g, '')).trim() : null;
389 };
390 return [...xml.matchAll(/<Job>([\s\S]*?)<\/Job>/g)].map(([, p]) => {
391 const f = Object.fromEntries([...p.matchAll(/<filter\d+>\s*<label>([\s\S]*?)<\/label>\s*<value>([\s\S]*?)<\/value>\s*<\/filter\d+>/g)].map(([, l, v]) => [l.trim().toLowerCase(), decode(v.replace(/<!\[CDATA\[|\]\]>/g, '')).trim()]));
392 const reqId = tag(p, 'ReqId');
393 const posted = tag(p, 'Posted-Date')?.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
394 const city = f['internal posting location'] || f.location || f.city || null;
395 const extra = f['additional locations'] || '';
396 const url = `https://${host}/career?company=${encodeURIComponent(slug)}&career_job_req_id=${reqId}&career_ns=job_listing`;
397 return base('successfactors', slug, {
398 jobId: reqId,
399 title: tag(p, 'JobTitle'),
400 location: [city, f.country, extra].filter(Boolean).join(', ') || null,
401 country: f.country || null,
402 remote: remoteLabel(`${city} ${extra} ${f['work arrangement'] ?? ''}`) || null,
403 department: f['work area'] || f.department || f.function || null,
404 employmentType: f['employment type'] || null,
405 postedAt: posted ? iso(`${posted[3]}-${posted[1]}-${posted[2]}`) : null,
406 url,
407 applyUrl: url,
408 description: htmlToText(tag(p, 'Job-Description')) || null,
409 });
410 });
411 },
412};
413
414async function mapLimit(items, limit, fn) {
415 const out = new Array(items.length);
416 let next = 0;
417 await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
418 while (next < items.length) {
419 const i = next++;
420 out[i] = await fn(items[i], i);
421 }
422 }));
423 return out;
424}
425
426export { mapLimit };
427
428export async function fetchBoard(board, opts) {
429 const jobs = await fetchers[board.ats](board, opts);
430 if (!Array.isArray(jobs) || jobs.some((job) => !job.title || !job.url || (!job.jobId && !job.url))) throw new Error('Source returned a job without the required title or URL');
431 return jobs.map((j) => ({
432 ...j,
433 workplaceType: j.workplaceType ? String(j.workplaceType).toLowerCase() : null,
434
435 description: opts.withDescription ? j.description : null,
436 boardUrl: opts.boardUrl ?? null,
437 }));
438}
439
440
441export function matches(job, { keywords = [], excludeKeywords = [], locations = [], remoteOnly = false, postedWithinDays }) {
442 const title = (job.title ?? '').toLowerCase();
443 if (keywords.length && !keywords.some((k) => title.includes(k.toLowerCase()))) return false;
444 if (excludeKeywords.some((k) => title.includes(k.toLowerCase()))) return false;
445 const loc = `${job.location ?? ''} ${job.country ?? ''}`.toLowerCase();
446 if (locations.length && !locations.some((l) => loc.includes(l.toLowerCase()))) return false;
447 if (remoteOnly && !(job.remote === true || job.workplaceType === 'remote')) return false;
448 if (postedWithinDays) {
449 const date = Date.parse(job.postedAt);
450 if (!Number.isFinite(date) || date > Date.now() || Date.now() - date > postedWithinDays * 86400000) return false;
451 }
452 return true;
453}