@timurtekb/tekjobs 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +131 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/app/README.md +22 -0
- package/app/dist/assets/index-DzGgV6NF.css +1 -0
- package/app/dist/assets/index-QteuouKv.js +77 -0
- package/app/dist/favicon.svg +1 -0
- package/app/dist/index.html +16 -0
- package/app/server/cover-letter.mjs +243 -0
- package/app/server/index.mjs +113 -0
- package/app/server/mail-check.mjs +234 -0
- package/app/server/mcp.mjs +129 -0
- package/app/server/people.mjs +164 -0
- package/app/server/store.mjs +1131 -0
- package/app/server/tailored-resume.mjs +213 -0
- package/cli.mjs +205 -0
- package/package.json +74 -0
- package/run.cmd +10 -0
- package/run.mjs +201 -0
- package/run.sh +12 -0
- package/samples/vault/Jobs/Basalt Systems - Senior Design Engineer, Growth (1010).md +56 -0
- package/samples/vault/Jobs/Brightline Studio - Design Engineer (1005).md +51 -0
- package/samples/vault/Jobs/Copperleaf - Principal Design Engineer (1006).md +53 -0
- package/samples/vault/Jobs/Example Studio - Senior Design Engineer (1013).md +52 -0
- package/samples/vault/Jobs/Fjord Analytics - Staff Frontend Engineer, Platform (1003).md +58 -0
- package/samples/vault/Jobs/Halcyon Robotics - Senior UX Engineer (1004).md +52 -0
- package/samples/vault/Jobs/Lumen Health - Design Systems Engineer (1002).md +51 -0
- package/samples/vault/Jobs/Meridian Pay - Staff Design Engineer (1009).md +67 -0
- package/samples/vault/Jobs/Northwind Labs - Staff Design Engineer, Design Systems (1000).md +57 -0
- package/samples/vault/Jobs/Orbital Software - Senior Design Engineer (1001).md +52 -0
- package/samples/vault/Jobs/Quill & Co - Design Engineer, Editor (1008).md +55 -0
- package/samples/vault/Jobs/Signalfire Design - Design Engineer (1012).md +53 -0
- package/samples/vault/Jobs/Tessellate - Senior Frontend Engineer, Design Systems (1007).md +52 -0
- package/samples/vault/Jobs/Verdant - UX Engineer (1011).md +51 -0
- package/samples/vault/Logs/2026-09-16.md +13 -0
- package/samples/vault/Logs/2026-09-23.md +13 -0
- package/samples/vault/People/Lee Example (Fjord Analytics).md +22 -0
- package/samples/vault/People/Priya Example (Meridian Pay).md +23 -0
- package/samples/vault/People/Sam Example (Northwind Labs).md +20 -0
- package/samples/vault/Profile/Positioning.md +16 -0
- package/samples/vault/Profile/Profile.md +41 -0
- package/samples/vault/Profile/Resume.md +32 -0
- package/samples/vault/Profile/Voice.md +15 -0
- package/samples/vault/README.md +11 -0
- package/samples/vault/Targets/Companies.md +318 -0
- package/samples/vault/Targets/Search Criteria.md +234 -0
- package/samples/vault/_Home.md +54 -0
- package/scraper/config.mjs +122 -0
- package/scraper/health.mjs +50 -0
- package/scraper/import-link.mjs +281 -0
- package/scraper/profile.mjs +186 -0
- package/scraper/rescore.mjs +236 -0
- package/scraper/resume-sync.mjs +224 -0
- package/scraper/resume.mjs +65 -0
- package/scraper/score.mjs +114 -0
- package/scraper/sources-email.mjs +242 -0
- package/scraper/sources-extra.mjs +558 -0
- package/scraper/sources-sites.mjs +280 -0
- package/scraper/sources.mjs +264 -0
- package/scraper/starter/companies-table.md +306 -0
- package/scraper/starter/criteria.json +56 -0
- package/scraper/starter/profile.md +38 -0
- package/scraper/vault.mjs +212 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Career sites and boards with no public JSON API: the page is the API.
|
|
2
|
+
// Google (server-rendered results page), Apple (search endpoint behind a CSRF token, description from the
|
|
3
|
+
// detail page's hydration data), Wellfound (Next.js page data), Built In (server-rendered cards, JSON-LD on
|
|
4
|
+
// the detail page). Same normalized job shape as sources.mjs. The pure parsers are exported for tests.
|
|
5
|
+
import { htmlToText, decodeEntities, makeTitleFilter } from './sources.mjs';
|
|
6
|
+
|
|
7
|
+
import { UA } from './config.mjs';
|
|
8
|
+
const H = { 'user-agent': UA, accept: 'text/html,application/json;q=0.9,*/*;q=0.8', 'accept-language': 'en-US,en;q=0.9' };
|
|
9
|
+
async function req(url, { method = 'GET', body, headers = {}, timeoutMs = 30000 } = {}) {
|
|
10
|
+
try {
|
|
11
|
+
const r = await fetch(url, { method, headers: { ...H, ...(body ? { 'content-type': 'application/json' } : {}), ...headers }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs) });
|
|
12
|
+
const text = await r.text();
|
|
13
|
+
let data = null; try { data = JSON.parse(text); } catch { /* html */ }
|
|
14
|
+
return { status: r.status, ok: r.ok, data, text, headers: r.headers };
|
|
15
|
+
} catch (e) { return { status: 0, ok: false, data: null, text: '', error: e.name === 'TimeoutError' ? 'timeout' : e.message }; }
|
|
16
|
+
}
|
|
17
|
+
const bad = (r) => ({ ok: false, jobs: [], error: r.error || `HTTP ${r.status}` });
|
|
18
|
+
const isRemoteText = (s = '') => /\bremote\b|\bdistributed\b|\banywhere\b|\bvirtual\b/i.test(s) || /^\s*(united states|usa|u\.s\.|us|north america|americas)\s*$/i.test(s);
|
|
19
|
+
const iso = (v) => { if (!v) return null; const d = typeof v === 'number' ? new Date(v < 1e12 ? v * 1000 : v) : new Date(v); return isNaN(d) ? null : d.toISOString(); };
|
|
20
|
+
const job = (o) => ({ salary: '', department: '', employmentType: '', descriptionHtml: '', location: '', remote: false, posted: null, ...o });
|
|
21
|
+
// Tags become separators, so text that sat in different elements stays apart.
|
|
22
|
+
const segments = (html) => decodeEntities(html.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/g, '').replace(/<[^>]+>/g, '|')).split('|').map((s) => s.replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
23
|
+
const paragraphs = (parts) => parts.filter(Boolean).map((p) => `<p>${p}</p>`).join('');
|
|
24
|
+
|
|
25
|
+
// ---------------- Google ----------------
|
|
26
|
+
// https://www.google.com/about/careers/applications/jobs/results/?q=... Twenty cards a page, rendered on the
|
|
27
|
+
// server. Each card carries the title (as the "Learn more" label), the employer (Google, YouTube, Fitbit...),
|
|
28
|
+
// locations, level, and the minimum qualifications; "Remote eligible" appears on the card when it applies.
|
|
29
|
+
const GOOGLE_BASE = 'https://www.google.com/about/careers/applications/jobs/results/';
|
|
30
|
+
const GOOGLE_QUERIES = ['"design engineer"', '"design technologist"', '"ux engineer"', '"design systems"', '"front end engineer"', '"frontend engineer"', 'prototyper'];
|
|
31
|
+
const GOOGLE_ICONS = new Set(['corporate_fare', 'place', 'bar_chart', 'info', 'expand_more', 'expand_less', 'share', 'link', 'email', 'bookmark', 'bookmark_border', 'open_in_new', 'Learn more']);
|
|
32
|
+
export function parseGoogleCards(html) {
|
|
33
|
+
const cards = html.split(/(?=<li class="lLd3Je")/).filter((s) => /jobs\/results\/\d{12,}-/.test(s));
|
|
34
|
+
return cards.map((c) => {
|
|
35
|
+
const m = c.match(/jobs\/results\/(\d{12,})-([a-z0-9-]+)/);
|
|
36
|
+
const title = decodeEntities((c.match(/aria-label="Learn more about ([^"]+)"/) || [])[1] || '').trim();
|
|
37
|
+
const seg = segments(c);
|
|
38
|
+
const after = (icon, max = 3) => {
|
|
39
|
+
const i = seg.indexOf(icon); if (i < 0) return '';
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const s of seg.slice(i + 1, i + 1 + max)) { if (GOOGLE_ICONS.has(s) || /^Minimum qualifications/i.test(s)) break; out.push(s); }
|
|
42
|
+
return out.join(' ').replace(/\s+;\s*/g, '; ').trim();
|
|
43
|
+
};
|
|
44
|
+
const q = seg.findIndex((s) => /^Minimum qualifications/i.test(s));
|
|
45
|
+
return {
|
|
46
|
+
id: m[1], slug: m[2], title,
|
|
47
|
+
company: after('corporate_fare', 1) || 'Google',
|
|
48
|
+
location: after('place'),
|
|
49
|
+
level: after('bar_chart', 1),
|
|
50
|
+
remote: /Remote eligible/i.test(c),
|
|
51
|
+
qualifications: q < 0 ? [] : seg.slice(q + 1).filter((s) => !GOOGLE_ICONS.has(s)),
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// The detail page repeats the card and adds "About the job" and "Responsibilities"; the pay range, when
|
|
56
|
+
// Google states one, is inside "About the job".
|
|
57
|
+
export function parseGoogleDetail(html) {
|
|
58
|
+
const seg = segments(html);
|
|
59
|
+
const start = seg.findIndex((s) => /^Minimum qualifications/i.test(s));
|
|
60
|
+
if (start < 0) return '';
|
|
61
|
+
const end = seg.findIndex((s, i) => i > start && /^(Google is proud to be an equal opportunity|Information collected and processed)/i.test(s));
|
|
62
|
+
const body = seg.slice(start, end < 0 ? start + 80 : end).filter((s) => !GOOGLE_ICONS.has(s) && !/^(Apply|Share .*|Copy link|Email a friend)$/.test(s));
|
|
63
|
+
return paragraphs(body);
|
|
64
|
+
}
|
|
65
|
+
/** One Google job page (jobs/results/<id>-<slug>) as a job: for a pasted link. */
|
|
66
|
+
export function parseGoogleJobPage(html, url = '') {
|
|
67
|
+
const seg = segments(html);
|
|
68
|
+
const after = (icon, max = 3) => {
|
|
69
|
+
const i = seg.indexOf(icon); if (i < 0) return '';
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const s of seg.slice(i + 1, i + 1 + max)) { if (GOOGLE_ICONS.has(s) || /^Minimum qualifications/i.test(s)) break; out.push(s); }
|
|
72
|
+
return out.join(' ').replace(/\s+;\s*/g, '; ').trim();
|
|
73
|
+
};
|
|
74
|
+
const title = decodeEntities((html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [, ''])[1]).replace(/\s*[—|-]\s*Google Careers.*$/i, '').replace(/\s+/g, ' ').trim();
|
|
75
|
+
if (!title) return null;
|
|
76
|
+
const id = (url.match(/jobs\/results\/(\d{9,})/) || [])[1] || '';
|
|
77
|
+
const location = after('place');
|
|
78
|
+
return job({ id: `goog:${id || title}`, source: 'google', company: after('corporate_fare', 1) || 'Google', title, url, location, remote: /Remote eligible/i.test(html) || isRemoteText(location), descriptionHtml: parseGoogleDetail(html), department: after('bar_chart', 1) ? `Level: ${after('bar_chart', 1)}` : '' });
|
|
79
|
+
}
|
|
80
|
+
export async function fetchGoogle(titleFilter = () => true, { pages = 5, maxDetails = 40, location = 'United States' } = {}) {
|
|
81
|
+
const found = new Map();
|
|
82
|
+
for (const q of GOOGLE_QUERIES) {
|
|
83
|
+
for (let page = 1; page <= pages; page++) {
|
|
84
|
+
const r = await req(`${GOOGLE_BASE}?q=${encodeURIComponent(q)}&employment_type=FULL_TIME&location=${encodeURIComponent(location)}${page > 1 ? `&page=${page}` : ''}`);
|
|
85
|
+
if (!r.ok) { if (found.size === 0 && q === GOOGLE_QUERIES[0]) return bad(r); break; }
|
|
86
|
+
const cards = parseGoogleCards(r.text);
|
|
87
|
+
let fresh = 0;
|
|
88
|
+
for (const c of cards) if (!found.has(c.id)) { found.set(c.id, c); fresh++; }
|
|
89
|
+
if (!cards.length || !fresh || cards.length < 20) break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const jobs = []; let details = 0;
|
|
93
|
+
for (const c of found.values()) {
|
|
94
|
+
const base = job({ id: `goog:${c.id}`, source: 'google', company: c.company, title: c.title, url: `${GOOGLE_BASE}${c.id}-${c.slug}`, location: c.location, remote: c.remote || isRemoteText(c.location), descriptionHtml: paragraphs(['Minimum qualifications', ...c.qualifications]), department: c.level ? `Level: ${c.level}` : '' });
|
|
95
|
+
if (titleFilter(base.title) && details < maxDetails) {
|
|
96
|
+
details++;
|
|
97
|
+
const d = await req(base.url);
|
|
98
|
+
const html = d.ok ? parseGoogleDetail(d.text) : '';
|
|
99
|
+
if (html) base.descriptionHtml = html;
|
|
100
|
+
}
|
|
101
|
+
jobs.push(base);
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, jobs, scanned: found.size };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------- Apple ----------------
|
|
107
|
+
// POST https://jobs.apple.com/api/v1/search with the CSRF token that GET /api/v1/csrfToken hands out.
|
|
108
|
+
// Results carry a summary only; the description lives in the detail page's __staticRouterHydrationData.
|
|
109
|
+
const APPLE_QUERIES = ['"design engineer"', '"design technologist"', '"ux engineer"', '"design systems"', '"front end engineer"', '"frontend engineer"', '"prototyper"'];
|
|
110
|
+
export function parseAppleHydration(html) {
|
|
111
|
+
const m = html.match(/__staticRouterHydrationData\s*=\s*JSON\.parse\(("(?:[^"\\]|\\.)*")\)/);
|
|
112
|
+
if (!m) return null;
|
|
113
|
+
try { return JSON.parse(JSON.parse(m[1])); } catch { return null; }
|
|
114
|
+
}
|
|
115
|
+
export function appleDescription(hydration) {
|
|
116
|
+
const d = hydration?.loaderData?.jobDetails?.jobsData;
|
|
117
|
+
if (!d) return '';
|
|
118
|
+
const footers = (d.postingFooters || []).flatMap((f) => (f.localizations?.en_US || []).map((x) => x.content)).filter((s) => /base pay|salary/i.test(s || ''));
|
|
119
|
+
return [
|
|
120
|
+
d.jobSummary ? `<p>${d.jobSummary}</p>` : '',
|
|
121
|
+
d.description ? `<h3>Description</h3><p>${d.description}</p>` : '',
|
|
122
|
+
d.responsibilities ? `<h3>Responsibilities</h3><p>${d.responsibilities}</p>` : '',
|
|
123
|
+
d.minimumQualifications ? `<h3>Minimum qualifications</h3><p>${d.minimumQualifications}</p>` : '',
|
|
124
|
+
d.preferredQualifications ? `<h3>Preferred qualifications</h3><p>${d.preferredQualifications}</p>` : '',
|
|
125
|
+
footers.length ? `<h3>Pay</h3>${footers.map((f) => `<p>${f}</p>`).join('')}` : '',
|
|
126
|
+
].join('');
|
|
127
|
+
}
|
|
128
|
+
/** One Apple job page (en-us/details/<id>/<slug>) as a job, from its hydration data: for a pasted link. */
|
|
129
|
+
export function parseAppleJobPage(html, url = '') {
|
|
130
|
+
const h = parseAppleHydration(html);
|
|
131
|
+
const d = h?.loaderData?.jobDetails?.jobsData;
|
|
132
|
+
if (!d) return null;
|
|
133
|
+
const loc = (d.locations || []).map((l) => l.name).filter(Boolean).join('; ');
|
|
134
|
+
return job({ id: `ap:${d.positionId || d.id || (url.match(/details\/(\d+)/) || [])[1] || ''}`, source: 'apple', company: 'Apple', title: d.postingTitle || '', url, location: loc, remote: !!d.homeOffice || isRemoteText(loc), posted: iso(d.postDateInGMT || d.postingDate), descriptionHtml: appleDescription(h), department: d.team?.teamName || '', employmentType: d.standardWeeklyHours ? `${d.standardWeeklyHours} h/week` : '' });
|
|
135
|
+
}
|
|
136
|
+
export async function fetchApple(titleFilter = () => true, { pages = 3, maxDetails = 40, country = 'postLocation-USA' } = {}) {
|
|
137
|
+
const t = await req('https://jobs.apple.com/api/v1/csrfToken');
|
|
138
|
+
const token = t.headers?.get('x-apple-csrf-token');
|
|
139
|
+
if (!token) return { ok: false, jobs: [], error: t.error || `no CSRF token (HTTP ${t.status})` };
|
|
140
|
+
const cookie = (t.headers.getSetCookie?.() || []).map((c) => c.split(';')[0]).join('; ');
|
|
141
|
+
const headers = { 'x-apple-csrf-token': token, cookie, referer: 'https://jobs.apple.com/en-us/search', accept: 'application/json' };
|
|
142
|
+
const found = new Map();
|
|
143
|
+
for (const q of APPLE_QUERIES) {
|
|
144
|
+
for (let page = 1; page <= pages; page++) {
|
|
145
|
+
const r = await req('https://jobs.apple.com/api/v1/search', { method: 'POST', headers, body: { query: q, filters: { postingpostLocation: [country] }, locale: 'en-us', page, sort: 'newest', format: { longDate: 'MMMM D, YYYY', mediumDate: 'MMM D, YYYY' } } });
|
|
146
|
+
const rows = r.data?.res?.searchResults;
|
|
147
|
+
if (!Array.isArray(rows)) { if (found.size === 0 && q === APPLE_QUERIES[0]) return bad(r); break; }
|
|
148
|
+
for (const j of rows) found.set(j.positionId || j.id, j);
|
|
149
|
+
if (rows.length < 20) break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const jobs = []; let details = 0;
|
|
153
|
+
for (const j of found.values()) {
|
|
154
|
+
const loc = (j.locations || []).map((l) => l.name).filter(Boolean).join('; ');
|
|
155
|
+
const base = job({ id: `ap:${j.positionId || j.id}`, source: 'apple', company: 'Apple', title: j.postingTitle || '', url: `https://jobs.apple.com/en-us/details/${j.positionId}/${j.transformedPostingTitle || ''}`, location: loc, remote: !!j.homeOffice || isRemoteText(loc), posted: iso(j.postDateInGMT || j.postingDate), descriptionHtml: j.jobSummary ? `<p>${j.jobSummary}</p>` : '', department: j.team?.teamName || '', employmentType: j.standardWeeklyHours ? `${j.standardWeeklyHours} h/week` : '' });
|
|
156
|
+
if (titleFilter(base.title) && details < maxDetails) {
|
|
157
|
+
details++;
|
|
158
|
+
const d = await req(base.url);
|
|
159
|
+
const html = d.ok ? appleDescription(parseAppleHydration(d.text)) : '';
|
|
160
|
+
if (html) base.descriptionHtml = html;
|
|
161
|
+
}
|
|
162
|
+
jobs.push(base);
|
|
163
|
+
}
|
|
164
|
+
return { ok: true, jobs, scanned: found.size };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------- Wellfound ----------------
|
|
168
|
+
// https://wellfound.com/role/r/<role>?remote=true is a Next.js page whose __NEXT_DATA__ holds the Apollo cache:
|
|
169
|
+
// StartupResult records, each pointing at its JobListingSearchResult rows. Paging does not change the page,
|
|
170
|
+
// so one request per role is all there is.
|
|
171
|
+
const WF_ROLES = ['design-engineer', 'design-systems-engineer', 'creative-technologist', 'ux-engineer', 'frontend-engineer'];
|
|
172
|
+
export function parseWellfoundPage(html) {
|
|
173
|
+
const m = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
|
|
174
|
+
if (!m) return [];
|
|
175
|
+
let data; try { data = JSON.parse(m[1])?.props?.pageProps?.apolloState?.data || {}; } catch { return []; }
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const v of Object.values(data)) {
|
|
178
|
+
if (v?.__typename !== 'StartupResult') continue;
|
|
179
|
+
for (const ref of v.highlightedJobListings || []) { const j = data[ref.__ref]; if (j) out.push({ startup: v, listing: j }); }
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
export async function fetchWellfound() {
|
|
184
|
+
const byId = new Map(); let calls = 0, failed = 0, lastError = '';
|
|
185
|
+
for (const role of WF_ROLES) {
|
|
186
|
+
const r = await req(`https://wellfound.com/role/r/${role}?remote=true`);
|
|
187
|
+
calls++;
|
|
188
|
+
if (!r.ok) { failed++; lastError = r.error || `HTTP ${r.status}`; continue; }
|
|
189
|
+
for (const { startup, listing: j } of parseWellfoundPage(r.text)) {
|
|
190
|
+
const id = `wf:${j.id}`;
|
|
191
|
+
if (byId.has(id)) continue;
|
|
192
|
+
// locationNames is where the company sits; acceptedRemoteLocationNames is where it will hire from
|
|
193
|
+
// ("United States", "India"). Both go in, so the location scoring sees the country a remote role is
|
|
194
|
+
// really open to.
|
|
195
|
+
const loc = [...new Set([...(j.locationNames || []), ...(j.acceptedRemoteLocationNames || [])])].join('; ') || (j.remote ? 'Remote' : '');
|
|
196
|
+
byId.set(id, job({ id, source: 'wellfound', company: startup.name || 'Unknown', title: j.title || '', url: `https://wellfound.com/jobs/${j.id}-${j.slug || ''}`, location: loc, remote: !!j.remote || isRemoteText(loc), posted: iso(j.liveStartAt), descriptionHtml: paragraphs(String(j.description || '').split(/\n{2,}/)), salary: j.compensation || '', department: j.primaryRoleTitle || '', employmentType: j.jobType || '' }));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!byId.size) return { ok: false, jobs: [], error: failed ? `${failed}/${calls} requests failed (${lastError})` : 'no results' };
|
|
200
|
+
return { ok: true, jobs: [...byId.values()] };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------- Built In ----------------
|
|
204
|
+
// https://builtin.com/jobs/remote/<category>?search=... renders cards on the server: company, title, posted
|
|
205
|
+
// ("6 Days Ago"), work mode, location, pay, level and a snippet. The detail page has a JSON-LD JobPosting with
|
|
206
|
+
// the full description, fetched for the titles that pass the filter.
|
|
207
|
+
const BI_SEARCHES = [['design-ux', 'design engineer'], ['design-ux', 'design technologist'], ['design-ux', 'ux engineer'], ['dev-engineering', 'design engineer'], ['dev-engineering', 'design systems'], ['dev-engineering', 'frontend engineer']];
|
|
208
|
+
export function relativeDate(s, now = new Date()) {
|
|
209
|
+
const t = String(s || '').trim();
|
|
210
|
+
if (/^(just now|today|\d+\s+(minute|hour)s?\s+ago)$/i.test(t)) return now.toISOString();
|
|
211
|
+
const d = new Date(now);
|
|
212
|
+
if (/^yesterday$/i.test(t)) { d.setDate(d.getDate() - 1); return d.toISOString(); }
|
|
213
|
+
const m = t.match(/^(\d+)\s+(day|week|month)s?\s+ago$/i);
|
|
214
|
+
if (!m) return null;
|
|
215
|
+
const n = Number(m[1]); const unit = m[2].toLowerCase();
|
|
216
|
+
if (unit === 'day') d.setDate(d.getDate() - n); else if (unit === 'week') d.setDate(d.getDate() - 7 * n); else d.setMonth(d.getMonth() - n);
|
|
217
|
+
return d.toISOString();
|
|
218
|
+
}
|
|
219
|
+
export function parseBuiltInCards(html) {
|
|
220
|
+
return html.split(/(?=data-id="job-card")/).slice(1).map((c) => {
|
|
221
|
+
const link = c.match(/href="(\/job\/[a-z0-9-]+\/(\d+))"[^>]*data-id="job-card-title"[^>]*>([^<]+)</);
|
|
222
|
+
if (!link) return null;
|
|
223
|
+
const company = decodeEntities((c.match(/data-id="company-title"[^>]*>\s*<span>([^<]+)</) || [])[1] || '').trim();
|
|
224
|
+
// Short segments are the card's labels (posted, work mode, location, pay, level); icons sit in their own
|
|
225
|
+
// elements, so a label is never glued to one.
|
|
226
|
+
const seg = segments(c);
|
|
227
|
+
const modeAt = seg.findIndex((s) => /^(remote|hybrid|remote or hybrid|in-office)$/i.test(s));
|
|
228
|
+
return {
|
|
229
|
+
id: link[2], path: link[1], title: decodeEntities(link[3]).trim(), company,
|
|
230
|
+
posted: seg.find((s) => /^(\d+ \w+ ago|yesterday|today|just now)$/i.test(s)) || '',
|
|
231
|
+
mode: modeAt < 0 ? '' : seg[modeAt],
|
|
232
|
+
location: modeAt < 0 ? '' : seg[modeAt + 1] || '',
|
|
233
|
+
salary: seg.find((s) => /^[\d.]+K-[\d.]+K annually$/i.test(s)) || '',
|
|
234
|
+
level: seg.find((s) => /^(entry|junior|mid|senior|expert\/leader|principal)( level)?$/i.test(s)) || '',
|
|
235
|
+
snippet: seg.filter((s) => s.length > 120).sort((a, b) => b.length - a.length)[0] || '',
|
|
236
|
+
};
|
|
237
|
+
}).filter(Boolean);
|
|
238
|
+
}
|
|
239
|
+
// The script type is written "application/ld+json" (the plus is entity-encoded) and the posting sits
|
|
240
|
+
// inside an "@graph" array.
|
|
241
|
+
export function parseBuiltInDetail(html) {
|
|
242
|
+
for (const m of html.matchAll(/<script[^>]*ld(?:\+|+|+)json[^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
243
|
+
try {
|
|
244
|
+
const d = JSON.parse(m[1]);
|
|
245
|
+
const arr = [].concat(d).flatMap((x) => (x && x['@graph'] ? x['@graph'] : [x]));
|
|
246
|
+
const p = arr.find((x) => x && x['@type'] === 'JobPosting');
|
|
247
|
+
if (p) return p;
|
|
248
|
+
} catch { /* next block */ }
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
export async function fetchBuiltIn(criteria = {}, { maxDetails = 40 } = {}) {
|
|
253
|
+
const titleFilter = makeTitleFilter(criteria);
|
|
254
|
+
const byId = new Map(); let calls = 0, failed = 0, lastError = '';
|
|
255
|
+
for (const [cat, q] of BI_SEARCHES) {
|
|
256
|
+
const r = await req(`https://builtin.com/jobs/remote/${cat}?search=${encodeURIComponent(q)}`);
|
|
257
|
+
calls++;
|
|
258
|
+
if (!r.ok) { failed++; lastError = r.error || `HTTP ${r.status}`; continue; }
|
|
259
|
+
for (const c of parseBuiltInCards(r.text)) {
|
|
260
|
+
const id = `bi:${c.id}`;
|
|
261
|
+
if (byId.has(id)) continue;
|
|
262
|
+
const salary = c.salary.replace(/\s*annually/i, '').replace(/(\d+)K/gi, '$$$1k');
|
|
263
|
+
byId.set(id, job({ id, source: 'builtin', company: c.company || 'Unknown', title: c.title, url: `https://builtin.com${c.path}`, location: [c.mode, c.location].filter(Boolean).join(' · '), remote: /remote/i.test(c.mode) || isRemoteText(c.location), posted: relativeDate(c.posted), descriptionHtml: paragraphs([c.snippet]), salary, department: cat, employmentType: c.level }));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
let details = 0;
|
|
267
|
+
for (const j of byId.values()) {
|
|
268
|
+
if (!titleFilter(j.title) || details >= maxDetails) continue;
|
|
269
|
+
details++;
|
|
270
|
+
const d = await req(j.url);
|
|
271
|
+
const p = d.ok ? parseBuiltInDetail(d.text) : null;
|
|
272
|
+
if (!p) continue;
|
|
273
|
+
if (p.description) j.descriptionHtml = p.description;
|
|
274
|
+
if (p.datePosted) j.posted = iso(p.datePosted) || j.posted;
|
|
275
|
+
if (p.jobLocationType === 'TELECOMMUTE') j.remote = true;
|
|
276
|
+
if (p.employmentType && !j.employmentType) j.employmentType = String(p.employmentType);
|
|
277
|
+
}
|
|
278
|
+
if (!byId.size) return { ok: false, jobs: [], error: failed ? `${failed}/${calls} requests failed (${lastError})` : 'no results' };
|
|
279
|
+
return { ok: true, jobs: [...byId.values()] };
|
|
280
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Public, no-auth job sources. Every fetcher returns { ok, jobs, error? } with jobs normalized to:
|
|
2
|
+
// { id, source, company, title, url, location, remote, posted, descriptionHtml, salary, department, employmentType }
|
|
3
|
+
|
|
4
|
+
import { UA } from './config.mjs';
|
|
5
|
+
|
|
6
|
+
async function getJSON(url, { timeoutMs = 25000 } = {}) {
|
|
7
|
+
const ctrl = new AbortController();
|
|
8
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(url, {
|
|
11
|
+
signal: ctrl.signal,
|
|
12
|
+
headers: { 'user-agent': UA, accept: 'application/json' },
|
|
13
|
+
});
|
|
14
|
+
const text = await res.text();
|
|
15
|
+
let data = null;
|
|
16
|
+
try { data = JSON.parse(text); } catch { /* not json */ }
|
|
17
|
+
return { status: res.status, data, text };
|
|
18
|
+
} catch (e) {
|
|
19
|
+
return { status: 0, data: null, text: '', error: e.name === 'AbortError' ? 'timeout' : e.message };
|
|
20
|
+
} finally {
|
|
21
|
+
clearTimeout(t);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'", '#x27': "'", '#x2F': '/', '#47': '/', rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“', mdash: '—', ndash: '–', hellip: '…', bull: '•' };
|
|
26
|
+
export function decodeEntities(s = '') {
|
|
27
|
+
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
|
|
28
|
+
if (e in ENTITIES) return ENTITIES[e];
|
|
29
|
+
if (e[0] === '#') {
|
|
30
|
+
const code = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
31
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : m;
|
|
32
|
+
}
|
|
33
|
+
return m;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function htmlToText(html = '') {
|
|
38
|
+
let s = decodeEntities(html);
|
|
39
|
+
if (/&(lt|gt|amp);/.test(s)) s = decodeEntities(s); // greenhouse double-encodes
|
|
40
|
+
s = s
|
|
41
|
+
.replace(/<\s*(script|style)[^>]*>[\s\S]*?<\/\s*\1\s*>/gi, '')
|
|
42
|
+
.replace(/<\s*br\s*\/?>/gi, '\n')
|
|
43
|
+
.replace(/<\s*\/\s*(p|div|h[1-6]|ul|ol|tr|table|section|blockquote)\s*>/gi, '\n\n')
|
|
44
|
+
.replace(/<\s*(h[1-6])[^>]*>/gi, '\n\n## ')
|
|
45
|
+
.replace(/<\s*li[^>]*>/gi, '\n- ')
|
|
46
|
+
.replace(/<\s*(strong|b)\s*>/gi, '**').replace(/<\s*\/\s*(strong|b)\s*>/gi, '**')
|
|
47
|
+
.replace(/<a [^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi, (m, href, txt) => {
|
|
48
|
+
const t = txt.replace(/<[^>]+>/g, '').trim();
|
|
49
|
+
return t && t !== href ? `${t} (${href})` : href;
|
|
50
|
+
})
|
|
51
|
+
.replace(/<[^>]+>/g, '')
|
|
52
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
53
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
54
|
+
.replace(/\*\*\s*\*\*/g, '')
|
|
55
|
+
.trim();
|
|
56
|
+
return s;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// "United States" or "North America" as the entire location is a remote posting in practice.
|
|
60
|
+
const isRemoteText = (s = '') => /\bremote\b|\bdistributed\b|\banywhere\b/i.test(s) || /^\s*(united states|usa|u\.s\.|us|north america|americas)\s*$/i.test(s);
|
|
61
|
+
|
|
62
|
+
// ---------- Greenhouse ----------
|
|
63
|
+
export async function fetchGreenhouse(company, slug) {
|
|
64
|
+
const r = await getJSON(`https://boards-api.greenhouse.io/v1/boards/${encodeURIComponent(slug)}/jobs?content=true`);
|
|
65
|
+
if (!r.data || !Array.isArray(r.data.jobs)) return { ok: false, jobs: [], error: r.error || `HTTP ${r.status}` };
|
|
66
|
+
const jobs = r.data.jobs.map((j) => {
|
|
67
|
+
const location = j.location?.name || (j.offices || []).map((o) => o.name).join('; ') || '';
|
|
68
|
+
return {
|
|
69
|
+
id: `gh:${slug}:${j.id}`,
|
|
70
|
+
source: 'greenhouse',
|
|
71
|
+
company,
|
|
72
|
+
title: j.title || '',
|
|
73
|
+
url: j.absolute_url,
|
|
74
|
+
location,
|
|
75
|
+
remote: isRemoteText(location) || isRemoteText(j.title),
|
|
76
|
+
posted: j.first_published || j.updated_at || null,
|
|
77
|
+
descriptionHtml: j.content || '',
|
|
78
|
+
salary: '',
|
|
79
|
+
department: (j.departments || []).map((d) => d.name).filter(Boolean).join(', '),
|
|
80
|
+
employmentType: '',
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
return { ok: true, jobs };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------- Lever ----------
|
|
87
|
+
export async function fetchLever(company, slug) {
|
|
88
|
+
const r = await getJSON(`https://api.lever.co/v0/postings/${encodeURIComponent(slug)}?mode=json`);
|
|
89
|
+
if (!Array.isArray(r.data)) return { ok: false, jobs: [], error: r.error || (r.data?.error ?? `HTTP ${r.status}`) };
|
|
90
|
+
const jobs = r.data.map((j) => {
|
|
91
|
+
const location = [j.categories?.location, j.categories?.allLocations?.join('; ')].filter(Boolean).join('; ');
|
|
92
|
+
const lists = (j.lists || []).map((l) => `<h3>${l.text}</h3>${l.content}`).join('');
|
|
93
|
+
const sr = j.salaryRange;
|
|
94
|
+
const salary = sr && sr.min ? `${sr.currency || ''} ${sr.min}–${sr.max} / ${sr.interval || ''}`.trim() : '';
|
|
95
|
+
return {
|
|
96
|
+
id: `lv:${slug}:${j.id}`,
|
|
97
|
+
source: 'lever',
|
|
98
|
+
company,
|
|
99
|
+
title: j.text || '',
|
|
100
|
+
url: j.hostedUrl,
|
|
101
|
+
location,
|
|
102
|
+
remote: j.workplaceType === 'remote' || isRemoteText(location),
|
|
103
|
+
posted: j.createdAt ? new Date(j.createdAt).toISOString() : null,
|
|
104
|
+
descriptionHtml: `${j.description || ''}${lists}${j.additional || ''}`,
|
|
105
|
+
salary,
|
|
106
|
+
department: [j.categories?.team, j.categories?.department].filter(Boolean).join(' / '),
|
|
107
|
+
employmentType: j.categories?.commitment || '',
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
return { ok: true, jobs };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------- Ashby ----------
|
|
114
|
+
export async function fetchAshby(company, slug) {
|
|
115
|
+
const r = await getJSON(`https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(slug)}?includeCompensation=true`);
|
|
116
|
+
if (!r.data || !Array.isArray(r.data.jobs)) return { ok: false, jobs: [], error: r.error || `HTTP ${r.status}` };
|
|
117
|
+
const jobs = r.data.jobs.filter((j) => j.isListed !== false).map((j) => {
|
|
118
|
+
const location = [j.location, ...(j.secondaryLocations || []).map((l) => l.location)].filter(Boolean).join('; ');
|
|
119
|
+
return {
|
|
120
|
+
id: `ab:${slug}:${j.id}`,
|
|
121
|
+
source: 'ashby',
|
|
122
|
+
company,
|
|
123
|
+
title: j.title || '',
|
|
124
|
+
url: j.jobUrl || j.applyUrl,
|
|
125
|
+
location,
|
|
126
|
+
// Ashby's isRemote is true on Hybrid postings too; only workplaceType and the location text are trustworthy.
|
|
127
|
+
remote: j.workplaceType === 'Remote' || isRemoteText(location),
|
|
128
|
+
workplaceType: j.workplaceType || '',
|
|
129
|
+
posted: j.publishedAt || null,
|
|
130
|
+
descriptionHtml: j.descriptionHtml || j.descriptionPlain || '',
|
|
131
|
+
salary: j.compensation?.compensationTierSummary || '',
|
|
132
|
+
department: [j.department, j.team].filter(Boolean).join(' / '),
|
|
133
|
+
employmentType: j.employmentType || '',
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
// Ashby returns {jobs: []} for unknown boards too; flag as suspicious when empty.
|
|
137
|
+
return { ok: true, jobs, emptyBoard: jobs.length === 0 };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------- RemoteOK ----------
|
|
141
|
+
export async function fetchRemoteOK() {
|
|
142
|
+
const r = await getJSON('https://remoteok.com/api');
|
|
143
|
+
if (!Array.isArray(r.data)) return { ok: false, jobs: [], error: r.error || `HTTP ${r.status}` };
|
|
144
|
+
const jobs = r.data.filter((j) => j && j.id && j.position).map((j) => ({
|
|
145
|
+
id: `rok:${j.id}`,
|
|
146
|
+
source: 'remoteok',
|
|
147
|
+
company: j.company || 'Unknown',
|
|
148
|
+
title: j.position,
|
|
149
|
+
url: j.url || `https://remoteok.com/remote-jobs/${j.id}`,
|
|
150
|
+
location: j.location || 'Remote',
|
|
151
|
+
remote: true,
|
|
152
|
+
posted: j.date || null,
|
|
153
|
+
descriptionHtml: `${j.description || ''}\n\nTags: ${(j.tags || []).join(', ')}`,
|
|
154
|
+
salary: j.salary_min ? `$${j.salary_min}–$${j.salary_max}` : '',
|
|
155
|
+
department: '',
|
|
156
|
+
employmentType: '',
|
|
157
|
+
}));
|
|
158
|
+
return { ok: true, jobs };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------- Hacker News: Who is hiring ----------
|
|
162
|
+
export async function fetchHNWhoIsHiring() {
|
|
163
|
+
const s = await getJSON('https://hn.algolia.com/api/v1/search_by_date?tags=story,author_whoishiring&hitsPerPage=10');
|
|
164
|
+
const hit = (s.data?.hits || []).find((h) => /who is hiring\?/i.test(h.title || ''));
|
|
165
|
+
if (!hit) return { ok: false, jobs: [], error: 'no Who is hiring thread found' };
|
|
166
|
+
const t = await getJSON(`https://hn.algolia.com/api/v1/items/${hit.objectID}`);
|
|
167
|
+
if (!t.data?.children) return { ok: false, jobs: [], error: t.error || `HTTP ${t.status}` };
|
|
168
|
+
const jobs = [];
|
|
169
|
+
for (const c of t.data.children) {
|
|
170
|
+
if (!c.text) continue;
|
|
171
|
+
const text = htmlToText(c.text);
|
|
172
|
+
let firstLine = text.split('\n').find((l) => l.trim()) || '';
|
|
173
|
+
// HN posts often run the header and body together; cut the header at the first sentence end.
|
|
174
|
+
firstLine = firstLine.split(/(?<=[a-z\)])\.\s+(?=[A-Z])/)[0];
|
|
175
|
+
const segs = firstLine.split('|').map((x) => x.trim()).filter(Boolean).map((x) => x.slice(0, 60));
|
|
176
|
+
const company = (segs[0] || 'HN post').slice(0, 60);
|
|
177
|
+
const header = segs.slice(0, 7).join(' | ').slice(0, 140);
|
|
178
|
+
jobs.push({
|
|
179
|
+
id: `hn:${c.id}`,
|
|
180
|
+
source: 'hn',
|
|
181
|
+
company,
|
|
182
|
+
title: header,
|
|
183
|
+
url: `https://news.ycombinator.com/item?id=${c.id}`,
|
|
184
|
+
location: segs.slice(1).filter((x) => /remote|onsite|on-site|hybrid|,\s*[A-Z]{2}\b|san francisco|new york|nyc|sf\b/i.test(x)).join('; ').slice(0, 120),
|
|
185
|
+
remote: /remote/i.test(firstLine),
|
|
186
|
+
posted: c.created_at || null,
|
|
187
|
+
descriptionHtml: c.text,
|
|
188
|
+
salary: (firstLine.match(/\$\s?\d{2,3}\s?k[^|]*/i) || [''])[0].trim(),
|
|
189
|
+
department: '',
|
|
190
|
+
employmentType: '',
|
|
191
|
+
thread: hit.title,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return { ok: true, jobs, thread: hit.title, threadUrl: `https://news.ycombinator.com/item?id=${hit.objectID}` };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------- Workday (CXS API) ----------
|
|
198
|
+
// slug format: host/tenant/site e.g. adobe.wd5.myworkdayjobs.com/adobe/external_experienced
|
|
199
|
+
// Workday boards are huge, so we search a handful of queries, keep only titles that pass titleFilter,
|
|
200
|
+
// then fetch details (description, remote type, url) for those only.
|
|
201
|
+
const WD_QUERIES = ['design engineer', 'design technologist', 'design systems', 'ux engineer', 'frontend engineer', 'front end engineer', 'prototyping engineer', 'design system engineer'];
|
|
202
|
+
export async function fetchWorkday(company, slug, titleFilter = () => true, { maxPages = 5, maxDetails = 80 } = {}) {
|
|
203
|
+
const [host, tenant, site] = slug.split('/');
|
|
204
|
+
if (!host || !tenant || !site) return { ok: false, jobs: [], error: 'slug must be host/tenant/site' };
|
|
205
|
+
const base = `https://${host}/wday/cxs/${tenant}/${site}`;
|
|
206
|
+
const H = { 'content-type': 'application/json', accept: 'application/json', 'user-agent': UA };
|
|
207
|
+
const found = new Map();
|
|
208
|
+
for (const q of WD_QUERIES) {
|
|
209
|
+
for (let page = 0; page < maxPages; page++) {
|
|
210
|
+
let d;
|
|
211
|
+
try {
|
|
212
|
+
const r = await fetch(`${base}/jobs`, { method: 'POST', headers: H, body: JSON.stringify({ appliedFacets: {}, limit: 20, offset: page * 20, searchText: q }), signal: AbortSignal.timeout(25000) });
|
|
213
|
+
if (r.status >= 400) return { ok: false, jobs: [], error: `HTTP ${r.status}` };
|
|
214
|
+
d = await r.json();
|
|
215
|
+
} catch (e) { return { ok: false, jobs: [], error: e.name === 'TimeoutError' ? 'timeout' : e.message }; }
|
|
216
|
+
const posts = d?.jobPostings || [];
|
|
217
|
+
for (const p of posts) if (p.externalPath && !found.has(p.externalPath)) found.set(p.externalPath, p);
|
|
218
|
+
if (posts.length < 20 || (page + 1) * 20 >= (d.total || 0)) break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const candidates = [...found.values()].filter((p) => titleFilter(p.title || '')).slice(0, maxDetails);
|
|
222
|
+
const jobs = [];
|
|
223
|
+
for (const p of candidates) {
|
|
224
|
+
let info = null;
|
|
225
|
+
try {
|
|
226
|
+
const r = await fetch(`${base}${p.externalPath}`, { headers: H, signal: AbortSignal.timeout(25000) });
|
|
227
|
+
info = (await r.json())?.jobPostingInfo || null;
|
|
228
|
+
} catch { /* skip detail */ }
|
|
229
|
+
const location = [info?.location, ...(info?.additionalLocations || [])].filter(Boolean).join('; ') || p.locationsText || '';
|
|
230
|
+
const remoteType = info?.remoteType || '';
|
|
231
|
+
jobs.push({
|
|
232
|
+
id: `wd:${tenant}:${(info?.jobReqId || p.bulletFields?.[0] || p.externalPath).toString().replace(/[^\w-]/g, '')}`,
|
|
233
|
+
source: 'workday',
|
|
234
|
+
company,
|
|
235
|
+
title: info?.title || p.title || '',
|
|
236
|
+
url: info?.externalUrl || `https://${host}/${site}${p.externalPath}`,
|
|
237
|
+
location,
|
|
238
|
+
remote: /remote/i.test(remoteType) || isRemoteText(location) || isRemoteText(p.title || ''),
|
|
239
|
+
posted: info?.startDate || null,
|
|
240
|
+
descriptionHtml: info?.jobDescription || '',
|
|
241
|
+
salary: '',
|
|
242
|
+
department: '',
|
|
243
|
+
employmentType: info?.timeType || '',
|
|
244
|
+
workplaceType: remoteType,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return { ok: true, jobs, scanned: found.size };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function makeTitleFilter(criteria) {
|
|
251
|
+
const terms = Object.keys(criteria?.titleTerms || {}).map((t) => t.toLowerCase());
|
|
252
|
+
const excl = (criteria?.titleExclude || []).map((t) => t.toLowerCase());
|
|
253
|
+
return (title) => { const t = (title || '').toLowerCase(); return terms.some((k) => t.includes(k)) && !excl.some((k) => t.includes(k)); };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function fetchCompany(c, criteria) {
|
|
257
|
+
if (c.ats === 'greenhouse') return fetchGreenhouse(c.name, c.slug);
|
|
258
|
+
if (c.ats === 'lever') return fetchLever(c.name, c.slug);
|
|
259
|
+
if (c.ats === 'ashby') return fetchAshby(c.name, c.slug);
|
|
260
|
+
if (c.ats === 'workday') return fetchWorkday(c.name, c.slug, makeTitleFilter(criteria));
|
|
261
|
+
const extra = await import('./sources-extra.mjs');
|
|
262
|
+
if (extra.EXTRA_ATS.includes(c.ats)) return extra.fetchExtraCompany(c, makeTitleFilter(criteria));
|
|
263
|
+
return { ok: false, jobs: [], error: `unknown ats ${c.ats}` };
|
|
264
|
+
}
|