crawlforge-extractors 1.2.2 → 1.3.0
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/README.md +92 -3
- package/index.d.ts +73 -4
- package/package.json +19 -4
- package/src/connectors/ats.js +748 -0
- package/src/connectors/gov.js +513 -0
- package/src/templates.js +333 -52
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ATS list connectors — read a company's whole job board from the ATS
|
|
3
|
+
* platform's own public API, in one request.
|
|
4
|
+
*
|
|
5
|
+
* These are list templates: `extractList(body, url)` returns N entities rather
|
|
6
|
+
* than the one entity `extract`/`extractRaw` return. Like every template here
|
|
7
|
+
* they make no network calls — `listUrl(params)` builds the URL, the caller
|
|
8
|
+
* fetches it, and `extractList` parses what comes back.
|
|
9
|
+
*
|
|
10
|
+
* Every connector below is pointed at an endpoint the platform itself
|
|
11
|
+
* documents for public, unauthenticated use, on a host whose robots.txt allows
|
|
12
|
+
* it. The doc URL and the robots.txt finding are recorded above each one, with
|
|
13
|
+
* the date they were verified. Two platforms did not clear that bar and are
|
|
14
|
+
* deliberately absent — see "Not shipped" at the foot of this file.
|
|
15
|
+
*
|
|
16
|
+
* No connector surfaces a named individual. Several of these payloads carry
|
|
17
|
+
* recruiter contacts (Recruitee stamps a per-job application mailbox on every
|
|
18
|
+
* offer); those fields are dropped rather than mapped. A job posting is
|
|
19
|
+
* company data, and that is all these return.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { load } from 'cheerio';
|
|
23
|
+
|
|
24
|
+
// ── The common job shape ─────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One shape across all six platforms, so a caller can concatenate two boards
|
|
28
|
+
* without a per-source mapping step.
|
|
29
|
+
*
|
|
30
|
+
* Every field is null where the platform does not carry it. None is inferred:
|
|
31
|
+
* Greenhouse publishes no employment type at all, so a Greenhouse job reports
|
|
32
|
+
* null there rather than a plausible-looking "Full-time".
|
|
33
|
+
*
|
|
34
|
+
* `employment_type` is passed through in the platform's own words ("FullTime",
|
|
35
|
+
* "Full-time", "fulltime_fixed_term"). Collapsing those onto one vocabulary
|
|
36
|
+
* would mean deciding what Lever's "Regular Full Time (Salary)" really is, and
|
|
37
|
+
* that decision belongs to the caller, who can see their own data.
|
|
38
|
+
*/
|
|
39
|
+
function job(fields) {
|
|
40
|
+
return {
|
|
41
|
+
id: null,
|
|
42
|
+
title: null,
|
|
43
|
+
url: null,
|
|
44
|
+
location: null,
|
|
45
|
+
department: null,
|
|
46
|
+
team: null,
|
|
47
|
+
employment_type: null,
|
|
48
|
+
remote: null,
|
|
49
|
+
published_at: null,
|
|
50
|
+
updated_at: null,
|
|
51
|
+
description: null,
|
|
52
|
+
source: null,
|
|
53
|
+
...fields
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/** Empty strings are how several of these payloads write "no value". */
|
|
60
|
+
function str(value) {
|
|
61
|
+
if (value === null || value === undefined) return null;
|
|
62
|
+
const text = String(value).trim();
|
|
63
|
+
return text || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Ids are numeric on Greenhouse, Recruitee and Workable and UUIDs on Lever,
|
|
68
|
+
* Ashby and Teamtailor. Stringify so a merged list has one id type.
|
|
69
|
+
*/
|
|
70
|
+
function id(value) {
|
|
71
|
+
return value === null || value === undefined || value === '' ? null : String(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A job body is a rendered HTML fragment; callers want the copy, not the markup. */
|
|
75
|
+
function htmlToText(html) {
|
|
76
|
+
if (!html) return null;
|
|
77
|
+
const text = load(`<div>${html}</div>`)('div').text().replace(/\s+/g, ' ').trim();
|
|
78
|
+
return text || null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Greenhouse ships the description entity-escaped a second time: the wire
|
|
83
|
+
* carries "<h2>Who we are</h2>", not "<h2>Who we are</h2>".
|
|
84
|
+
* Stripping tags from that finds none and hands the caller the markup as
|
|
85
|
+
* visible text. Decode first, then strip.
|
|
86
|
+
*/
|
|
87
|
+
function escapedHtmlToText(escaped) {
|
|
88
|
+
if (!escaped) return null;
|
|
89
|
+
return htmlToText(load(`<div>${escaped}</div>`)('div').text());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Every platform stamps a date differently: Greenhouse and Ashby ship ISO
|
|
94
|
+
* 8601, Lever ships epoch milliseconds, Teamtailor ships RFC 822 and Recruitee
|
|
95
|
+
* ships "2026-08-28 12:19:42 UTC", which Date parses as Invalid Date on its
|
|
96
|
+
* own. Normalise to ISO 8601 so two boards sort together.
|
|
97
|
+
*
|
|
98
|
+
* A date with no time — all Workable publishes — is left exactly as it is.
|
|
99
|
+
* Widening it to midnight would invent an hour the source never stated.
|
|
100
|
+
*/
|
|
101
|
+
function isoDate(value) {
|
|
102
|
+
if (value === null || value === undefined || value === '') return null;
|
|
103
|
+
if (typeof value === 'number') {
|
|
104
|
+
const epoch = new Date(value);
|
|
105
|
+
return Number.isNaN(epoch.getTime()) ? null : epoch.toISOString();
|
|
106
|
+
}
|
|
107
|
+
const raw = String(value).trim();
|
|
108
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
|
|
109
|
+
const parsed = new Date(raw.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) UTC$/, '$1T$2Z'));
|
|
110
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `remote` answers "can this job be done from anywhere?", and only two of the
|
|
115
|
+
* words these platforms use answer it.
|
|
116
|
+
*
|
|
117
|
+
* Hybrid does not: it is genuinely part-remote, and reading it as false is as
|
|
118
|
+
* wrong as reading it as true. Lever's "unspecified" does not either. Both
|
|
119
|
+
* return null, and the platform's own word is kept in raw_extra.workplace_type
|
|
120
|
+
* so nothing is lost.
|
|
121
|
+
*/
|
|
122
|
+
const REMOTE_WORDS = new Set(['remote', 'fully', 'telecommuting']);
|
|
123
|
+
const ONSITE_WORDS = new Set(['onsite', 'on_site', 'none', 'office']);
|
|
124
|
+
|
|
125
|
+
function isRemote(workplaceType) {
|
|
126
|
+
const word = String(workplaceType ?? '').trim().toLowerCase().replace(/[\s-]+/g, '');
|
|
127
|
+
if (!word) return null;
|
|
128
|
+
if (REMOTE_WORDS.has(word) || REMOTE_WORDS.has(word.replace(/_/g, ''))) return true;
|
|
129
|
+
if (ONSITE_WORDS.has(word) || ONSITE_WORDS.has(word.replace(/_/g, ''))) return false;
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Workable publishes the parts of a location but never the joined string. */
|
|
134
|
+
function joinLocation(...parts) {
|
|
135
|
+
const joined = parts.map(str).filter(Boolean);
|
|
136
|
+
return joined.length ? [...new Set(joined)].join(', ') : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The parameter is the caller's, so name it and say where to find its value. */
|
|
140
|
+
function requireParam(connector, name, value, where) {
|
|
141
|
+
const given = str(value);
|
|
142
|
+
if (!given) {
|
|
143
|
+
throw new Error(`${connector} listUrl requires a "${name}" parameter — ${where}.`);
|
|
144
|
+
}
|
|
145
|
+
return given;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function notJson(connector, url, api) {
|
|
149
|
+
return new Error(
|
|
150
|
+
`Not a ${connector} response: ${url} did not return JSON. This connector reads ${api}.`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** items.length unless the payload declares a larger total of its own. */
|
|
155
|
+
function listResult(items, extra = {}, totalAvailable = null) {
|
|
156
|
+
return {
|
|
157
|
+
items,
|
|
158
|
+
count: items.length,
|
|
159
|
+
...(typeof totalAvailable === 'number' && totalAvailable > items.length
|
|
160
|
+
? { total_available: totalAvailable }
|
|
161
|
+
: {}),
|
|
162
|
+
...extra
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseJson(body, onFailure) {
|
|
167
|
+
try {
|
|
168
|
+
return JSON.parse(body);
|
|
169
|
+
} catch {
|
|
170
|
+
throw onFailure();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── URL builders ─────────────────────────────────────────────────────────────
|
|
175
|
+
//
|
|
176
|
+
// Each connector's listUrl and resolveUrl are the same construction from two
|
|
177
|
+
// starting points, so both call one of these. They are module-level rather than
|
|
178
|
+
// methods because a consumer that pulls `resolveUrl` off the template and calls
|
|
179
|
+
// it on its own would lose `this`.
|
|
180
|
+
|
|
181
|
+
function greenhouseUrl({ company, content }) {
|
|
182
|
+
const url = `https://boards-api.greenhouse.io/v1/boards/${encodeURIComponent(company)}/jobs`;
|
|
183
|
+
return content === true ? `${url}?content=true` : url;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function leverUrl({ company, skip, limit }) {
|
|
187
|
+
const url = new URL(`https://api.lever.co/v0/postings/${encodeURIComponent(company)}`);
|
|
188
|
+
url.searchParams.set('mode', 'json');
|
|
189
|
+
if (skip !== undefined) url.searchParams.set('skip', String(skip));
|
|
190
|
+
if (limit !== undefined) url.searchParams.set('limit', String(limit));
|
|
191
|
+
return url.toString();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function ashbyUrl({ company }) {
|
|
195
|
+
return `https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(company)}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function workableUrl({ company, details }) {
|
|
199
|
+
// Workable documents the www.workable.com URL; it 302s to the
|
|
200
|
+
// apply.workable.com widget endpoint, so the fetch must follow redirects.
|
|
201
|
+
const url = `https://www.workable.com/api/accounts/${encodeURIComponent(company)}`;
|
|
202
|
+
return details === true ? `${url}?details=true` : url;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function recruiteeUrl({ company }) {
|
|
206
|
+
return `https://${encodeURIComponent(company)}.recruitee.com/api/offers/`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function teamtailorUrl({ company, per_page, offset }) {
|
|
210
|
+
const url = new URL(`https://${encodeURIComponent(company)}.teamtailor.com/jobs.rss`);
|
|
211
|
+
if (per_page !== undefined) url.searchParams.set('per_page', String(per_page));
|
|
212
|
+
if (offset !== undefined) url.searchParams.set('offset', String(offset));
|
|
213
|
+
return url.toString();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Connector definitions ────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
export const ATS_TEMPLATES = [
|
|
219
|
+
{
|
|
220
|
+
// Docs: https://docs.greenhouse.io/job-board.html — "Job Board data is
|
|
221
|
+
// publicly available, so authentication is not required for any GET
|
|
222
|
+
// endpoints." (Only POSTing an application needs Basic Auth.)
|
|
223
|
+
// Robots: boards-api.greenhouse.io/robots.txt is "User-agent: * /
|
|
224
|
+
// Disallow: /embed/". /v1/boards/ is allowed. (2026-08-28)
|
|
225
|
+
id: 'greenhouse-jobs',
|
|
226
|
+
name: 'Greenhouse Job Board',
|
|
227
|
+
description:
|
|
228
|
+
'Read a company\'s whole Greenhouse job board from the Job Board API rather than the rendered ' +
|
|
229
|
+
'careers page: every published job with its title, location, ids and timestamps in one ' +
|
|
230
|
+
'request. Descriptions are opt-in — pass content: true — because they are most of the ' +
|
|
231
|
+
'payload: Stripe\'s 571-job board is 349 KB without them and 4.2 MB with them.',
|
|
232
|
+
targetPattern: /(boards|job-boards|boards-api)\.greenhouse\.io\//i,
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* `company` is Greenhouse's board token — the path segment in
|
|
236
|
+
* https://job-boards.greenhouse.io/<token>, not the company's display name.
|
|
237
|
+
*/
|
|
238
|
+
listUrl(params = {}) {
|
|
239
|
+
const company = requireParam(
|
|
240
|
+
'greenhouse-jobs', 'company', params.company,
|
|
241
|
+
'the board token in https://job-boards.greenhouse.io/<token>'
|
|
242
|
+
);
|
|
243
|
+
return greenhouseUrl({ company, content: params.content });
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
/** Point the fetch at the API for the board the URL names. */
|
|
247
|
+
resolveUrl(url) {
|
|
248
|
+
const parsed = new URL(url);
|
|
249
|
+
if (parsed.hostname === 'boards-api.greenhouse.io') return url;
|
|
250
|
+
const token = parsed.pathname.split('/').filter(Boolean)[0];
|
|
251
|
+
if (!token) return url;
|
|
252
|
+
return greenhouseUrl({ company: token, content: parsed.searchParams.get('content') === 'true' });
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
extractList(body, url) {
|
|
256
|
+
const payload = parseJson(body, () =>
|
|
257
|
+
notJson('Greenhouse job board', url, 'the Greenhouse Job Board API')
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
// An unknown board token answers 404 with {"status":404,"error":"Job not found"}.
|
|
261
|
+
if (!payload || !Array.isArray(payload.jobs)) {
|
|
262
|
+
const reason = str(payload?.error) || 'no jobs array';
|
|
263
|
+
throw new Error(
|
|
264
|
+
`No Greenhouse job board at ${url}: ${reason}. ` +
|
|
265
|
+
'The board token is the path segment in https://job-boards.greenhouse.io/<token>.'
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const items = payload.jobs.map(j => job({
|
|
270
|
+
id: id(j.id),
|
|
271
|
+
title: str(j.title),
|
|
272
|
+
url: str(j.absolute_url),
|
|
273
|
+
location: str(j.location?.name),
|
|
274
|
+
// departments and offices ship only with content=true, so a summary
|
|
275
|
+
// record reports null here rather than a department guessed from the
|
|
276
|
+
// job title.
|
|
277
|
+
department: str(j.departments?.[0]?.name),
|
|
278
|
+
// Greenhouse has no team level and no employment type at all.
|
|
279
|
+
employment_type: null,
|
|
280
|
+
// …and no remote flag: the only hint is prose inside location.name,
|
|
281
|
+
// which is not a fact the payload states.
|
|
282
|
+
remote: null,
|
|
283
|
+
published_at: isoDate(j.first_published),
|
|
284
|
+
updated_at: isoDate(j.updated_at),
|
|
285
|
+
description: escapedHtmlToText(j.content),
|
|
286
|
+
source: 'greenhouse-jobs',
|
|
287
|
+
raw_extra: {
|
|
288
|
+
internal_job_id: id(j.internal_job_id),
|
|
289
|
+
requisition_id: str(j.requisition_id),
|
|
290
|
+
// The flat `location` loses Greenhouse's office hierarchy; keep the
|
|
291
|
+
// names, not the child_ids arrays, which run to hundreds of numbers.
|
|
292
|
+
// Absent on a summary record, where [] would claim the job has no
|
|
293
|
+
// office rather than that this response form does not say.
|
|
294
|
+
offices: j.offices ? j.offices.map(o => str(o.name)).filter(Boolean) : null
|
|
295
|
+
}
|
|
296
|
+
}));
|
|
297
|
+
|
|
298
|
+
return listResult(items, { company: str(payload.jobs[0]?.company_name) }, payload.meta?.total);
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
{
|
|
303
|
+
// Docs: https://github.com/lever/postings-api — Lever's own repository
|
|
304
|
+
// for the Postings API, documenting mode=json plus the skip/limit
|
|
305
|
+
// paging parameters. No key, no header.
|
|
306
|
+
// Robots: api.lever.co/robots.txt is "User-agent: * / Allow: / /
|
|
307
|
+
// Crawl-delay: 1". Allowed, at one request per second. (2026-08-28)
|
|
308
|
+
id: 'lever-postings',
|
|
309
|
+
name: 'Lever Postings',
|
|
310
|
+
description:
|
|
311
|
+
'Read a company\'s whole Lever job board from the Postings API rather than the rendered ' +
|
|
312
|
+
'careers page: every published posting with its team, commitment, workplace type and plain-text ' +
|
|
313
|
+
'description. Lever declares Crawl-delay: 1, which this connector republishes as ' +
|
|
314
|
+
'crawlDelaySeconds for the caller\'s rate limiter to honour.',
|
|
315
|
+
targetPattern: /(jobs|api)\.lever\.co\//i,
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* api.lever.co asks for one request per second in its robots.txt. This
|
|
319
|
+
* package never fetches, so it can only declare the number — the surface
|
|
320
|
+
* that does the fetching has to hold to it.
|
|
321
|
+
*/
|
|
322
|
+
crawlDelaySeconds: 1,
|
|
323
|
+
|
|
324
|
+
/** `company` is the path segment in https://jobs.lever.co/<company>. */
|
|
325
|
+
listUrl(params = {}) {
|
|
326
|
+
const company = requireParam(
|
|
327
|
+
'lever-postings', 'company', params.company,
|
|
328
|
+
'the path segment in https://jobs.lever.co/<company>'
|
|
329
|
+
);
|
|
330
|
+
return leverUrl({ company, skip: params.skip, limit: params.limit });
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
resolveUrl(url) {
|
|
334
|
+
const parsed = new URL(url);
|
|
335
|
+
if (parsed.hostname === 'api.lever.co') return url;
|
|
336
|
+
const company = parsed.pathname.split('/').filter(Boolean)[0];
|
|
337
|
+
return company ? leverUrl({ company }) : url;
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
extractList(body, url) {
|
|
341
|
+
const payload = parseJson(body, () =>
|
|
342
|
+
notJson('Lever postings', url, 'the Lever Postings API')
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
// The postings response is a bare array. An unknown company answers 404
|
|
346
|
+
// with {"ok":false,"error":"Document not found"}.
|
|
347
|
+
if (!Array.isArray(payload)) {
|
|
348
|
+
const reason = str(payload?.error) || 'the response was not a list of postings';
|
|
349
|
+
throw new Error(
|
|
350
|
+
`No Lever postings at ${url}: ${reason}. ` +
|
|
351
|
+
'The company is the path segment in https://jobs.lever.co/<company>, and the request ' +
|
|
352
|
+
'needs mode=json.'
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const items = payload.map(p => {
|
|
357
|
+
const categories = p.categories || {};
|
|
358
|
+
return job({
|
|
359
|
+
id: id(p.id),
|
|
360
|
+
// Lever calls the job title "text".
|
|
361
|
+
title: str(p.text),
|
|
362
|
+
url: str(p.hostedUrl),
|
|
363
|
+
location: str(categories.location),
|
|
364
|
+
department: str(categories.department),
|
|
365
|
+
team: str(categories.team),
|
|
366
|
+
employment_type: str(categories.commitment),
|
|
367
|
+
remote: isRemote(p.workplaceType),
|
|
368
|
+
published_at: isoDate(p.createdAt),
|
|
369
|
+
// Lever publishes no modification time.
|
|
370
|
+
updated_at: null,
|
|
371
|
+
description: str(p.descriptionPlain),
|
|
372
|
+
source: 'lever-postings',
|
|
373
|
+
raw_extra: {
|
|
374
|
+
workplace_type: str(p.workplaceType),
|
|
375
|
+
level: str(categories.level),
|
|
376
|
+
// The flat `location` is the primary one; a posting open in three
|
|
377
|
+
// cities lists all three here.
|
|
378
|
+
all_locations: (categories.allLocations || []).map(str).filter(Boolean),
|
|
379
|
+
salary_range: p.salaryRange || null,
|
|
380
|
+
apply_url: str(p.applyUrl)
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
return listResult(items);
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
{
|
|
390
|
+
// Docs: https://developers.ashbyhq.com/docs/public-job-posting-api —
|
|
391
|
+
// "Public Job Posting API", GET
|
|
392
|
+
// api.ashbyhq.com/posting-api/job-board/{JOB_BOARD_NAME}, no key.
|
|
393
|
+
// Robots: api.ashbyhq.com/robots.txt answers 401 with the body
|
|
394
|
+
// "Unauthorized" — the host serves no robots.txt. Per RFC 9309 an
|
|
395
|
+
// unavailable robots.txt means unrestricted access. (2026-08-28)
|
|
396
|
+
id: 'ashby-jobs',
|
|
397
|
+
name: 'Ashby Job Board',
|
|
398
|
+
description:
|
|
399
|
+
'Read a company\'s whole Ashby job board from the Public Job Posting API rather than the ' +
|
|
400
|
+
'rendered careers page: every listed job with its department, team, employment type, ' +
|
|
401
|
+
'workplace type and plain-text description in one request.',
|
|
402
|
+
targetPattern: /(jobs|api)\.ashbyhq\.com\//i,
|
|
403
|
+
|
|
404
|
+
/** `company` is Ashby's jobs page name — the segment in https://jobs.ashbyhq.com/<name>. */
|
|
405
|
+
listUrl(params = {}) {
|
|
406
|
+
const company = requireParam(
|
|
407
|
+
'ashby-jobs', 'company', params.company,
|
|
408
|
+
'the jobs page name in https://jobs.ashbyhq.com/<name>'
|
|
409
|
+
);
|
|
410
|
+
return ashbyUrl({ company });
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
resolveUrl(url) {
|
|
414
|
+
const parsed = new URL(url);
|
|
415
|
+
if (parsed.hostname === 'api.ashbyhq.com') return url;
|
|
416
|
+
const company = parsed.pathname.split('/').filter(Boolean)[0];
|
|
417
|
+
return company ? ashbyUrl({ company }) : url;
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
extractList(body, url) {
|
|
421
|
+
const payload = parseJson(body, () =>
|
|
422
|
+
notJson('Ashby job board', url, 'the Ashby Public Job Posting API')
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
if (!payload || !Array.isArray(payload.jobs)) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
`No Ashby job board at ${url}: no jobs array. ` +
|
|
428
|
+
'The jobs page name is the path segment in https://jobs.ashbyhq.com/<name>.'
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const items = payload.jobs.map(j => job({
|
|
433
|
+
id: id(j.id),
|
|
434
|
+
title: str(j.title),
|
|
435
|
+
url: str(j.jobUrl),
|
|
436
|
+
location: str(j.location),
|
|
437
|
+
department: str(j.department),
|
|
438
|
+
team: str(j.team),
|
|
439
|
+
employment_type: str(j.employmentType),
|
|
440
|
+
// Read workplaceType, not Ashby's own isRemote boolean. isRemote is
|
|
441
|
+
// true on jobs whose workplaceType is Hybrid — Ramp's "Security
|
|
442
|
+
// Engineer, Cloud" is isRemote: true, workplaceType: "Hybrid", located
|
|
443
|
+
// at the New York HQ (verified 2026-08-28) — so trusting it would
|
|
444
|
+
// report office-based jobs as remote.
|
|
445
|
+
remote: isRemote(j.workplaceType),
|
|
446
|
+
published_at: isoDate(j.publishedAt),
|
|
447
|
+
updated_at: null,
|
|
448
|
+
description: str(j.descriptionPlain),
|
|
449
|
+
source: 'ashby-jobs',
|
|
450
|
+
raw_extra: {
|
|
451
|
+
workplace_type: str(j.workplaceType),
|
|
452
|
+
// Names only. The full entries carry a postal address per country,
|
|
453
|
+
// which is an office directory, not part of a job listing.
|
|
454
|
+
secondary_locations: (j.secondaryLocations || []).map(l => str(l.location)).filter(Boolean),
|
|
455
|
+
apply_url: str(j.applyUrl)
|
|
456
|
+
}
|
|
457
|
+
}));
|
|
458
|
+
|
|
459
|
+
return listResult(items, { api_version: str(payload.apiVersion) });
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
{
|
|
464
|
+
// Docs: https://help.workable.com/hc/en-us/articles/115012771647-Using-the-Workable-API-to-create-a-careers-page
|
|
465
|
+
// — "Alternatively, to get the list of your published jobs only,
|
|
466
|
+
// you can try in your terminal the below public endpoints:
|
|
467
|
+
// curl -L GET 'https://www.workable.com/api/accounts/<account_subdomain>?details=true'".
|
|
468
|
+
// Workable's other jobs endpoint, <subdomain>.workable.com/spi/v3/jobs,
|
|
469
|
+
// needs a Bearer key and is deliberately not used here.
|
|
470
|
+
// Robots: www.workable.com/robots.txt disallows /user_password_resets,
|
|
471
|
+
// /admin, /auth/google and /j/ only — /api/accounts/ is allowed.
|
|
472
|
+
// apply.workable.com, where the documented URL redirects, is
|
|
473
|
+
// "Disallow:" (nothing disallowed). (2026-08-28)
|
|
474
|
+
id: 'workable-jobs',
|
|
475
|
+
name: 'Workable Jobs',
|
|
476
|
+
description:
|
|
477
|
+
'Read a company\'s published Workable jobs from the public accounts endpoint rather than the ' +
|
|
478
|
+
'rendered careers page: title, department, employment type, location parts and telecommuting ' +
|
|
479
|
+
'flag for every open role. Descriptions are opt-in — pass details: true — matching the ' +
|
|
480
|
+
'documented ?details=true parameter.',
|
|
481
|
+
targetPattern: /(apply\.workable\.com|www\.workable\.com\/api\/accounts|[\w-]+\.workable\.com\/(jobs|spi))/i,
|
|
482
|
+
|
|
483
|
+
/** `company` is the account subdomain — the first part of the signed-in Workable URL. */
|
|
484
|
+
listUrl(params = {}) {
|
|
485
|
+
const company = requireParam(
|
|
486
|
+
'workable-jobs', 'company', params.company,
|
|
487
|
+
'the account subdomain, the path segment in https://apply.workable.com/<subdomain>'
|
|
488
|
+
);
|
|
489
|
+
return workableUrl({ company, details: params.details });
|
|
490
|
+
},
|
|
491
|
+
|
|
492
|
+
resolveUrl(url) {
|
|
493
|
+
const parsed = new URL(url);
|
|
494
|
+
if (/\/api\/(v1\/widget\/)?accounts\//.test(parsed.pathname)) return url;
|
|
495
|
+
const details = parsed.searchParams.get('details') === 'true';
|
|
496
|
+
// apply.workable.com/<subdomain>. A single-job link (/j/<shortcode>)
|
|
497
|
+
// does not name the account, so it is left alone.
|
|
498
|
+
if (parsed.hostname === 'apply.workable.com') {
|
|
499
|
+
const [first] = parsed.pathname.split('/').filter(Boolean);
|
|
500
|
+
return first && first !== 'j' ? workableUrl({ company: first, details }) : url;
|
|
501
|
+
}
|
|
502
|
+
const subdomain = parsed.hostname.replace(/\.workable\.com$/i, '');
|
|
503
|
+
return subdomain && subdomain !== parsed.hostname
|
|
504
|
+
? workableUrl({ company: subdomain, details })
|
|
505
|
+
: url;
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
extractList(body, url) {
|
|
509
|
+
const payload = parseJson(body, () =>
|
|
510
|
+
// An unknown account answers 404 with the plain text "Not Found".
|
|
511
|
+
notJson('Workable account', url, 'the public Workable accounts endpoint')
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
if (!payload || !Array.isArray(payload.jobs)) {
|
|
515
|
+
throw new Error(
|
|
516
|
+
`No Workable account at ${url}: no jobs array. ` +
|
|
517
|
+
'The account subdomain is the path segment in https://apply.workable.com/<subdomain>.'
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const items = payload.jobs.map(j => job({
|
|
522
|
+
// Workable's stable public identifier is the shortcode; there is no
|
|
523
|
+
// separate numeric id in this payload.
|
|
524
|
+
id: id(j.shortcode),
|
|
525
|
+
title: str(j.title),
|
|
526
|
+
url: str(j.url),
|
|
527
|
+
location: joinLocation(j.city, j.state, j.country),
|
|
528
|
+
department: str(j.department),
|
|
529
|
+
// Workable has no team level.
|
|
530
|
+
team: null,
|
|
531
|
+
employment_type: str(j.employment_type),
|
|
532
|
+
remote: typeof j.telecommuting === 'boolean' ? j.telecommuting : null,
|
|
533
|
+
published_at: isoDate(j.published_on),
|
|
534
|
+
updated_at: null,
|
|
535
|
+
// description/requirements/benefits ship only with details=true, so a
|
|
536
|
+
// summary record reports null rather than an empty string.
|
|
537
|
+
description: htmlToText(j.description),
|
|
538
|
+
source: 'workable-jobs',
|
|
539
|
+
raw_extra: {
|
|
540
|
+
shortcode: str(j.shortcode),
|
|
541
|
+
code: str(j.code),
|
|
542
|
+
function: str(j.function),
|
|
543
|
+
apply_url: str(j.application_url)
|
|
544
|
+
}
|
|
545
|
+
}));
|
|
546
|
+
|
|
547
|
+
return listResult(items, { company: str(payload.name) });
|
|
548
|
+
}
|
|
549
|
+
},
|
|
550
|
+
|
|
551
|
+
{
|
|
552
|
+
// Docs: https://docs.recruitee.com/reference/intro-to-careers-site-api —
|
|
553
|
+
// "The Recruitee Careers Site API allows to view company data
|
|
554
|
+
// publicly available on a careers site"; the offers endpoint is GET
|
|
555
|
+
// https://<company>.recruitee.com/api/offers/, no key.
|
|
556
|
+
// Robots: <company>.recruitee.com/robots.txt is "User-Agent: * /
|
|
557
|
+
// Disallow: /v/" — on the default domain and, after the redirect a
|
|
558
|
+
// tenant with a custom careers domain issues, there too.
|
|
559
|
+
// /api/offers/ is allowed. (2026-08-28)
|
|
560
|
+
id: 'recruitee-offers',
|
|
561
|
+
name: 'Recruitee Offers',
|
|
562
|
+
description:
|
|
563
|
+
'Read a company\'s published Recruitee offers from the Careers Site API rather than the ' +
|
|
564
|
+
'rendered careers page: title, department, location, employment type code, salary band and ' +
|
|
565
|
+
'plain-text description for every open role in one request.',
|
|
566
|
+
targetPattern: /\.recruitee\.com\//i,
|
|
567
|
+
|
|
568
|
+
/** `company` is the Recruitee subdomain in https://<company>.recruitee.com. */
|
|
569
|
+
listUrl(params = {}) {
|
|
570
|
+
const company = requireParam(
|
|
571
|
+
'recruitee-offers', 'company', params.company,
|
|
572
|
+
'the subdomain in https://<company>.recruitee.com'
|
|
573
|
+
);
|
|
574
|
+
return recruiteeUrl({ company });
|
|
575
|
+
},
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* A tenant on a custom careers domain (jobs.example.com) does not carry its
|
|
579
|
+
* Recruitee subdomain anywhere in the URL, so only *.recruitee.com URLs can
|
|
580
|
+
* be resolved. Callers on a custom domain pass `company` to listUrl.
|
|
581
|
+
*/
|
|
582
|
+
resolveUrl(url) {
|
|
583
|
+
const parsed = new URL(url);
|
|
584
|
+
const subdomain = parsed.hostname.replace(/\.recruitee\.com$/i, '');
|
|
585
|
+
return subdomain && subdomain !== parsed.hostname ? recruiteeUrl({ company: subdomain }) : url;
|
|
586
|
+
},
|
|
587
|
+
|
|
588
|
+
extractList(body, url) {
|
|
589
|
+
const payload = parseJson(body, () =>
|
|
590
|
+
notJson('Recruitee offers', url, 'the Recruitee Careers Site API')
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
// An unknown subdomain answers 404 with {"error":"Not Found"}.
|
|
594
|
+
if (!payload || !Array.isArray(payload.offers)) {
|
|
595
|
+
const reason = str(payload?.error) || 'no offers array';
|
|
596
|
+
throw new Error(
|
|
597
|
+
`No Recruitee careers site at ${url}: ${reason}. ` +
|
|
598
|
+
'The company is the subdomain in https://<company>.recruitee.com.'
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const items = payload.offers.map(o => job({
|
|
603
|
+
id: id(o.id),
|
|
604
|
+
title: str(o.title),
|
|
605
|
+
url: str(o.careers_url),
|
|
606
|
+
location: str(o.location),
|
|
607
|
+
department: str(o.department),
|
|
608
|
+
team: null,
|
|
609
|
+
employment_type: str(o.employment_type_code),
|
|
610
|
+
// Recruitee splits the workplace across three booleans rather than one
|
|
611
|
+
// word, so rebuild the word before reading it.
|
|
612
|
+
remote: isRemote(o.remote ? 'remote' : o.hybrid ? 'hybrid' : o.on_site ? 'onsite' : ''),
|
|
613
|
+
published_at: isoDate(o.published_at),
|
|
614
|
+
updated_at: isoDate(o.updated_at),
|
|
615
|
+
description: htmlToText(o.description),
|
|
616
|
+
source: 'recruitee-offers',
|
|
617
|
+
// Deliberately absent: `mailbox_email` (a per-job application inbox),
|
|
618
|
+
// `open_questions` (the application form) and `translations` (a full
|
|
619
|
+
// copy of the description per locale). The first is contact data that
|
|
620
|
+
// has no place in a job listing; the other two are not the listing.
|
|
621
|
+
raw_extra: {
|
|
622
|
+
workplace_type: o.remote ? 'remote' : o.hybrid ? 'hybrid' : o.on_site ? 'onsite' : null,
|
|
623
|
+
slug: str(o.slug),
|
|
624
|
+
salary: o.salary || null,
|
|
625
|
+
tags: (o.tags || []).map(str).filter(Boolean),
|
|
626
|
+
apply_url: str(o.careers_apply_url)
|
|
627
|
+
}
|
|
628
|
+
}));
|
|
629
|
+
|
|
630
|
+
return listResult(items, { company: str(payload.offers[0]?.company_name) });
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
|
|
634
|
+
{
|
|
635
|
+
// Docs: https://support.teamtailor.com/en/articles/11171756-rss-feed-how-to-guide
|
|
636
|
+
// — "Go to the main jobs page of your careers site and add '.rss'.
|
|
637
|
+
// For example, https://career.teamtailor.com/jobs.rss … Note that
|
|
638
|
+
// all of the data is publicly available", with offset and per_page
|
|
639
|
+
// to page past the default first 100.
|
|
640
|
+
// Teamtailor's other jobs API, api.teamtailor.com/v1/jobs, needs an
|
|
641
|
+
// Authorization token and is deliberately not used here. The
|
|
642
|
+
// sibling /jobs.json JSON Feed is live but Teamtailor documents
|
|
643
|
+
// nothing about it, so this connector reads the documented feed.
|
|
644
|
+
// Robots: career.teamtailor.com/robots.txt disallows /app/, /messages/,
|
|
645
|
+
// /messenger/, /facebook/tab/ and /jobs/internal/ — /jobs.rss is
|
|
646
|
+
// allowed. The same file sets Content-Signal ai-train=no, which
|
|
647
|
+
// governs what may be done with the content after fetching, not
|
|
648
|
+
// whether it may be fetched. (2026-08-28)
|
|
649
|
+
id: 'teamtailor-jobs',
|
|
650
|
+
name: 'Teamtailor Jobs',
|
|
651
|
+
description:
|
|
652
|
+
'Read a company\'s published Teamtailor jobs from the careers site\'s documented RSS feed ' +
|
|
653
|
+
'rather than the rendered page: title, department, locations, remote status and plain-text ' +
|
|
654
|
+
'description for every open role. The feed returns the first 100 jobs unless per_page says otherwise.',
|
|
655
|
+
targetPattern: /teamtailor\.com\/jobs(\.rss)?(\?|$)/i,
|
|
656
|
+
|
|
657
|
+
/** `company` is the subdomain in https://<company>.teamtailor.com. */
|
|
658
|
+
listUrl(params = {}) {
|
|
659
|
+
const company = requireParam(
|
|
660
|
+
'teamtailor-jobs', 'company', params.company,
|
|
661
|
+
'the subdomain in https://<company>.teamtailor.com'
|
|
662
|
+
);
|
|
663
|
+
return teamtailorUrl({ company, per_page: params.per_page, offset: params.offset });
|
|
664
|
+
},
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* The feed is the jobs page with ".rss" appended, which is how Teamtailor
|
|
668
|
+
* documents it — so this also works on a careers site served from the
|
|
669
|
+
* company's own domain, where nothing in the URL names the tenant.
|
|
670
|
+
*/
|
|
671
|
+
resolveUrl(url) {
|
|
672
|
+
const parsed = new URL(url);
|
|
673
|
+
if (parsed.pathname.endsWith('.rss')) return url;
|
|
674
|
+
parsed.pathname = `${parsed.pathname.replace(/\/$/, '')}.rss`;
|
|
675
|
+
return parsed.toString();
|
|
676
|
+
},
|
|
677
|
+
|
|
678
|
+
extractList(body, url) {
|
|
679
|
+
// xmlMode keeps the tt: namespace prefixes intact; they carry the
|
|
680
|
+
// location, department, role and division, which plain RSS has no slot for.
|
|
681
|
+
const $ = load(body, { xmlMode: true });
|
|
682
|
+
const items = $('channel > item');
|
|
683
|
+
|
|
684
|
+
if (!$('rss').length || !items.length) {
|
|
685
|
+
throw new Error(
|
|
686
|
+
`No Teamtailor job feed at ${url}: ${$('rss').length ? 'the feed has no items' : 'the response is not an RSS feed'}. ` +
|
|
687
|
+
'The feed is the careers site jobs page with ".rss" appended, e.g. ' +
|
|
688
|
+
'https://<company>.teamtailor.com/jobs.rss.'
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const parsed = items.map((_, el) => {
|
|
693
|
+
const $item = $(el);
|
|
694
|
+
const field = sel => str($item.find(sel).first().text());
|
|
695
|
+
const locations = $item.find('tt\\:location > tt\\:name')
|
|
696
|
+
.map((__, name) => str($(name).text())).get().filter(Boolean);
|
|
697
|
+
const remoteStatus = field('remoteStatus');
|
|
698
|
+
|
|
699
|
+
return job({
|
|
700
|
+
// Teamtailor's guid is the stable id; the numeric id in the URL slug
|
|
701
|
+
// is the one their support pages call the "Job ID".
|
|
702
|
+
id: id(field('guid')),
|
|
703
|
+
title: field('title'),
|
|
704
|
+
url: field('link'),
|
|
705
|
+
location: locations.length ? locations.join(', ') : null,
|
|
706
|
+
department: field('tt\\:department'),
|
|
707
|
+
team: null,
|
|
708
|
+
// The feed carries no employment type.
|
|
709
|
+
employment_type: null,
|
|
710
|
+
remote: isRemote(remoteStatus),
|
|
711
|
+
published_at: isoDate(field('pubDate')),
|
|
712
|
+
updated_at: null,
|
|
713
|
+
// The description is HTML, entity-escaped inside the XML; cheerio
|
|
714
|
+
// decodes it once on the way out, leaving markup to strip.
|
|
715
|
+
description: htmlToText($item.find('description').first().text()),
|
|
716
|
+
source: 'teamtailor-jobs',
|
|
717
|
+
raw_extra: {
|
|
718
|
+
workplace_type: remoteStatus,
|
|
719
|
+
role: field('tt\\:role'),
|
|
720
|
+
division: field('tt\\:division'),
|
|
721
|
+
locations
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
}).get();
|
|
725
|
+
|
|
726
|
+
return listResult(parsed, { company: str($('channel > title').first().text()) });
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
];
|
|
730
|
+
|
|
731
|
+
// ── Not shipped ──────────────────────────────────────────────────────────────
|
|
732
|
+
//
|
|
733
|
+
// smartrecruiters-postings — SmartRecruiters documents a public Posting API at
|
|
734
|
+
// https://developers.smartrecruiters.com/docs/endpoints (GET
|
|
735
|
+
// api.smartrecruiters.com/v1/companies/<company>/postings, no key), but
|
|
736
|
+
// api.smartrecruiters.com/robots.txt reads, in full:
|
|
737
|
+
//
|
|
738
|
+
// User-agent: LinkedInBot
|
|
739
|
+
// Allow: /v1/companies/
|
|
740
|
+
// User-agent: *
|
|
741
|
+
// Disallow: /
|
|
742
|
+
//
|
|
743
|
+
// The one carve-out from a blanket disallow is LinkedIn's crawler. Reaching
|
|
744
|
+
// that endpoint as anyone else means overriding robots.txt on every call,
|
|
745
|
+
// which is not something a connector gets to decide on the caller's behalf.
|
|
746
|
+
// Left out pending an agreement with SmartRecruiters. (Verified 2026-08-28.)
|
|
747
|
+
|
|
748
|
+
export default ATS_TEMPLATES;
|