jd-intel 0.8.3 → 0.10.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 +35 -4
- package/package.json +1 -1
- package/src/adapters/ashby.js +69 -86
- package/src/adapters/greenhouse.js +46 -13
- package/src/adapters/lever.js +87 -45
- package/src/adapters/recruitee.js +87 -23
- package/src/adapters/smartrecruiters.js +146 -34
- package/src/adapters/teamtailor.js +57 -39
- package/src/adapters/workday.js +107 -31
- package/src/boards.js +80 -0
- package/src/cli.js +46 -15
- package/src/errors.js +14 -0
- package/src/filters.js +122 -29
- package/src/http.js +184 -0
- package/src/index.js +186 -59
- package/src/normalizer.js +200 -44
- package/src/registry.js +79 -25
package/README.md
CHANGED
|
@@ -131,8 +131,35 @@ const jobs = await fetchJobs({
|
|
|
131
131
|
});
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
+
Results come back newest first by `postedAt`, undated last (`order: 'board'` keeps the ATS's own order). `fetchJobs` returns the page as an array. `fetchJobsDetailed` returns the same page plus `total_matched`, the number of matches before `offset` and `limit`, so you can tell a small board from a cut and page through the rest:
|
|
135
|
+
|
|
136
|
+
```js
|
|
137
|
+
import { fetchJobsDetailed } from 'jd-intel';
|
|
138
|
+
|
|
139
|
+
const { jobs, total_matched } = await fetchJobsDetailed({
|
|
140
|
+
company: '<your-target-company>',
|
|
141
|
+
titleFilter: 'engineer',
|
|
142
|
+
limit: 20,
|
|
143
|
+
offset: 20, // second page
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The same result says how the company was resolved and which boards answered:
|
|
148
|
+
|
|
149
|
+
- `match`: `registry` (a known company, one adapter call), `probe` (not in the registry, every adapter asked) or `workday_override` (an explicit Workday config).
|
|
150
|
+
- `company`: `{ key, name }` from the registry row on a registry match, null otherwise.
|
|
151
|
+
- `boards`: one entry per board that answered, `{ ats, slug, name, site, board_url, org_name, org_url, jobs_found, matched, selected, scan }`. `jobs_found` counts the rows the board listed before any filter. Workday and SmartRecruiters filter their list before fetching details, so for them it is the list count, not the rows that came back (a lower bound when the list scan caps). `matched` is the rows left after filters and before `offset` and `limit`, so a board whose rows were all cut from the page still shows up. `board_url` is built from the slug. `org_name` and `org_url` are what the board states about itself, the organization name in the ATS response and the careers or company host its links point at, and they are null where the platform exposes nothing (Lever and Ashby expose neither); nothing is filled in from the slug or the registry name. A `probe` match is a slug match, not a confirmed identity: compare `org_name` and `org_url` with the company you meant, and open `board_url` when they are null, before treating the jobs as that company's.
|
|
152
|
+
- `failed`: adapters that threw during discovery, `{ ats, slug, name, code, message }`, with `code` either `rate_limited` or `ats_unreachable`. When no board answered and a check failed, `fetchJobs` throws that error instead of returning `[]`, so an outage never reads as "not found".
|
|
153
|
+
- `total_before_filters`: the sum of `jobs_found`. Zero matches with a positive `total_before_filters` means the company is hiring and nothing passed the filters, on every ATS.
|
|
154
|
+
|
|
155
|
+
`detectAtsDetailed(company)` returns `{ boards, failed }`: registry rows first (`source: 'registry'`, never probed), then every live probe that answered (`source: 'probe'`), in platform order; `failed` lists the probes that could not be checked, with the same codes. `detectAts` keeps returning `[{ ats, slug }]`.
|
|
156
|
+
|
|
157
|
+
A call the library cannot make throws `ArgumentError` (`code: 'invalid_args'`): no company, an unknown `ats`, or a `titleFilter` or `filter` that does not compile as a regex. It is thrown before any request goes out.
|
|
158
|
+
|
|
134
159
|
CLI usage: `npx jd-intel fetch <company-slug> --title-filter "engineer" --posted-within-days 14`. Full filter reference [below](#filters-quick-reference).
|
|
135
160
|
|
|
161
|
+
Each ATS request gives the server 10 seconds to start responding. A 429, a 5xx or a network error is retried up to three attempts with backoff (1s, 2s), honoring `Retry-After` when the ATS sends one, and at most 4 requests run at a time per host. A failure that outlasts the retries throws an `AtsError` whose `code` is `rate_limited` or `ats_unreachable`.
|
|
162
|
+
|
|
136
163
|
Node.js 18+. No API keys. No configuration.
|
|
137
164
|
|
|
138
165
|
### Manual install (fallback)
|
|
@@ -189,8 +216,10 @@ Every job normalizes to one schema, across every platform:
|
|
|
189
216
|
"title": "Senior Software Engineer, Platform",
|
|
190
217
|
"department": "Engineering",
|
|
191
218
|
"location": "Remote - US",
|
|
219
|
+
"locations": ["Remote - US", "Toronto, Canada"],
|
|
192
220
|
"locationType": "remote",
|
|
193
|
-
"
|
|
221
|
+
"workplace": { "type": "remote", "source": "ats" },
|
|
222
|
+
"salary": { "min": 180000, "max": 240000, "currency": "USD", "period": "year", "source": "text" },
|
|
194
223
|
"description": "Design and build the API surface our customers integrate against...",
|
|
195
224
|
"url": "https://boards.example.com/jobs/12345",
|
|
196
225
|
"postedAt": "2026-04-10T14:30:00Z"
|
|
@@ -206,9 +235,11 @@ No custom parsing per company.
|
|
|
206
235
|
| `title` | Full job title |
|
|
207
236
|
| `company` | Normalized company name |
|
|
208
237
|
| `department` | Team or department (when provided) |
|
|
209
|
-
| `location` |
|
|
210
|
-
| `
|
|
211
|
-
| `
|
|
238
|
+
| `location` | Primary location: city, state, country, or remote |
|
|
239
|
+
| `locations` | Every location the posting is open in, primary first. The location filters check each entry |
|
|
240
|
+
| `locationType` | `remote`, `hybrid`, `onsite`, or `unknown` when neither the platform nor the location text says |
|
|
241
|
+
| `workplace` | `{ type, source }`. `type` repeats `locationType`; `source` is `ats` when the platform stated it, `text` when read from the location string, null when unknown |
|
|
242
|
+
| `salary` | Min-max range with `currency`, plus `period` (`year`, `month`, `hour`, or null) and `source` (`ats` when the platform supplied it, `text` when parsed from the posting). Null when nothing is stated |
|
|
212
243
|
| `description` | Full JD in clean markdown |
|
|
213
244
|
| `url` | Direct link to the posting |
|
|
214
245
|
| `postedAt` | Publication date (when provided) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jd-intel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Fetch and normalize job descriptions across seven major ATS (Greenhouse, Lever, Ashby, Workday, and more), for your AI assistant. No copy-paste.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/adapters/ashby.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { normalize } from '../normalizer.js';
|
|
1
|
+
import { normalize, extractSalaryFromText } from '../normalizer.js';
|
|
2
2
|
import { atsErrorFromStatus } from '../errors.js';
|
|
3
|
+
import { atsFetch, probeResult } from '../http.js';
|
|
3
4
|
|
|
4
|
-
const API_URL = 'https://jobs.ashbyhq.com/api/non-user-graphql';
|
|
5
5
|
const BOARD_URL = 'https://api.ashbyhq.com/posting-api/job-board';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -9,23 +9,18 @@ const BOARD_URL = 'https://api.ashbyhq.com/posting-api/job-board';
|
|
|
9
9
|
* Public API, no auth required.
|
|
10
10
|
* Docs: https://developers.ashbyhq.com/docs/public-job-posting-api
|
|
11
11
|
*
|
|
12
|
+
* REST only. The GraphQL fallback this adapter once carried never named a
|
|
13
|
+
* board, so it never returned a job, and it turned every REST 429 or 5xx
|
|
14
|
+
* into a silent empty result (issue #55).
|
|
15
|
+
*
|
|
12
16
|
* @param {string} slug - Company slug (e.g., 'notion', 'linear')
|
|
17
|
+
* @param {object} [ctx] - { report }; report is called once with
|
|
18
|
+
* { ats, org_name, org_url } when given
|
|
13
19
|
* @returns {Promise<Array>} Normalized job objects
|
|
14
20
|
*/
|
|
15
|
-
export async function fetchAshby(slug) {
|
|
16
|
-
// Try the REST API first (simpler, includes compensation)
|
|
17
|
-
try {
|
|
18
|
-
const restJobs = await fetchAshbyRest(slug);
|
|
19
|
-
if (restJobs.length > 0) return restJobs;
|
|
20
|
-
} catch { /* fall through to GraphQL */ }
|
|
21
|
-
|
|
22
|
-
// Fallback: GraphQL API
|
|
23
|
-
return fetchAshbyGraphQL(slug);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async function fetchAshbyRest(slug) {
|
|
21
|
+
export async function fetchAshby(slug, ctx = {}) {
|
|
27
22
|
const url = `${BOARD_URL}/${slug}?includeCompensation=true`;
|
|
28
|
-
const resp = await
|
|
23
|
+
const resp = await atsFetch(url);
|
|
29
24
|
|
|
30
25
|
if (!resp.ok) {
|
|
31
26
|
if (resp.status === 404) return [];
|
|
@@ -35,104 +30,92 @@ async function fetchAshbyRest(slug) {
|
|
|
35
30
|
const data = await resp.json();
|
|
36
31
|
const jobs = data.jobs || [];
|
|
37
32
|
|
|
33
|
+
// The REST response is { jobs, apiVersion }: no organization name, and
|
|
34
|
+
// every link is on jobs.ashbyhq.com. Both null (issue #58).
|
|
35
|
+
if (typeof ctx.report === 'function') {
|
|
36
|
+
ctx.report({ ats: 'ashby', org_name: null, org_url: null });
|
|
37
|
+
}
|
|
38
|
+
|
|
38
39
|
return jobs.map(job => {
|
|
39
|
-
const
|
|
40
|
+
const comp = job.compensation || {};
|
|
40
41
|
|
|
41
42
|
return normalize({
|
|
42
43
|
companySlug: slug,
|
|
43
44
|
company: data.organizationName || slug,
|
|
44
45
|
title: job.title || '',
|
|
45
|
-
department: job.
|
|
46
|
+
department: job.department || '',
|
|
46
47
|
location: job.location || '',
|
|
48
|
+
locations: (job.secondaryLocations || []).map(l => l?.location || ''),
|
|
49
|
+
workplace: parseAshbyWorkplace(job),
|
|
47
50
|
description: job.descriptionHtml || job.descriptionPlain || '',
|
|
48
51
|
url: `https://jobs.ashbyhq.com/${slug}/${job.id}`,
|
|
49
52
|
postedAt: job.publishedAt || null,
|
|
50
|
-
salary,
|
|
53
|
+
salary: parseAshbyCompensation(comp),
|
|
51
54
|
metadata: {
|
|
52
55
|
ashbyId: job.id,
|
|
53
56
|
employmentType: job.employmentType || '',
|
|
54
57
|
isRemote: job.isRemote || false,
|
|
55
|
-
team: job.
|
|
58
|
+
team: job.team || '',
|
|
59
|
+
// The rendered summaries keep what min/max drop: "Offers Equity",
|
|
60
|
+
// "Multiple Ranges", and per-location tiers labelled OTE.
|
|
61
|
+
compensationSummary: comp.compensationTierSummary || '',
|
|
62
|
+
compensationTiers: (comp.compensationTiers || []).map(tier => ({
|
|
63
|
+
title: tier.title || '',
|
|
64
|
+
summary: tier.tierSummary || '',
|
|
65
|
+
additionalInformation: tier.additionalInformation || '',
|
|
66
|
+
})),
|
|
56
67
|
},
|
|
57
68
|
}, 'ashby');
|
|
58
69
|
});
|
|
59
70
|
}
|
|
60
71
|
|
|
61
|
-
|
|
62
|
-
const query = `{
|
|
63
|
-
jobBoard {
|
|
64
|
-
title
|
|
65
|
-
jobPostings {
|
|
66
|
-
id
|
|
67
|
-
title
|
|
68
|
-
locationName
|
|
69
|
-
employmentType
|
|
70
|
-
descriptionHtml
|
|
71
|
-
publishedDate
|
|
72
|
-
compensationTierSummary
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}`;
|
|
76
|
-
|
|
77
|
-
const resp = await fetch(API_URL, {
|
|
78
|
-
method: 'POST',
|
|
79
|
-
headers: { 'Content-Type': 'application/json' },
|
|
80
|
-
body: JSON.stringify({
|
|
81
|
-
operationName: 'ApiJobBoardWithTeams',
|
|
82
|
-
variables: { organizationHostedJobsPageName: slug },
|
|
83
|
-
query,
|
|
84
|
-
}),
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
if (!resp.ok) return [];
|
|
88
|
-
|
|
89
|
-
const data = await resp.json();
|
|
90
|
-
const board = data.data?.jobBoard;
|
|
91
|
-
if (!board) return [];
|
|
92
|
-
|
|
93
|
-
const postings = board.jobPostings || [];
|
|
72
|
+
const WORKPLACE_TYPES = { remote: 'remote', hybrid: 'hybrid', onsite: 'onsite' };
|
|
94
73
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
salary: null,
|
|
105
|
-
metadata: {
|
|
106
|
-
ashbyId: job.id,
|
|
107
|
-
employmentType: job.employmentType || '',
|
|
108
|
-
compensationSummary: job.compensationTierSummary || '',
|
|
109
|
-
},
|
|
110
|
-
}, 'ashby'));
|
|
74
|
+
/**
|
|
75
|
+
* `workplaceType` is 'Remote', 'Hybrid' or 'OnSite'. `isRemote` is the
|
|
76
|
+
* older flag and can only say remote, so it is the fallback when the type
|
|
77
|
+
* is absent. false means nothing: the role may be hybrid or onsite.
|
|
78
|
+
*/
|
|
79
|
+
function parseAshbyWorkplace(job) {
|
|
80
|
+
const type = WORKPLACE_TYPES[String(job.workplaceType || '').toLowerCase()];
|
|
81
|
+
if (type) return type;
|
|
82
|
+
return job.isRemote === true ? 'remote' : null;
|
|
111
83
|
}
|
|
112
84
|
|
|
85
|
+
const INTERVAL_PERIOD = { '1 YEAR': 'year', '1 MONTH': 'month', '1 HOUR': 'hour' };
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read pay from Ashby's `compensation` object (issue #67).
|
|
89
|
+
*
|
|
90
|
+
* `summaryComponents` carries one structured entry per component type
|
|
91
|
+
* (Salary, Bonus, Commission, Equity); the Salary entry spans every tier.
|
|
92
|
+
* `scrapeableCompensationSalarySummary` and `compensationTierSummary` are
|
|
93
|
+
* the rendered strings. A board that publishes no pay still sends the
|
|
94
|
+
* object, with null summaries and empty arrays, so a miss here has to
|
|
95
|
+
* return null for the normalizer's text fallback to run.
|
|
96
|
+
*/
|
|
113
97
|
function parseAshbyCompensation(comp) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (typeof comp === 'string') {
|
|
117
|
-
const match = comp.match(/\$?([\d,]+)\s*[-–]\s*\$?([\d,]+)/);
|
|
118
|
-
if (!match) return null;
|
|
98
|
+
const salary = (comp.summaryComponents || []).find(c => c.compensationType === 'Salary');
|
|
99
|
+
if (salary && (salary.minValue != null || salary.maxValue != null)) {
|
|
119
100
|
return {
|
|
120
|
-
min:
|
|
121
|
-
max:
|
|
122
|
-
currency: 'USD',
|
|
101
|
+
min: salary.minValue ?? null,
|
|
102
|
+
max: salary.maxValue ?? null,
|
|
103
|
+
currency: salary.currencyCode || 'USD',
|
|
104
|
+
period: INTERVAL_PERIOD[salary.interval] || null,
|
|
105
|
+
source: 'ats',
|
|
123
106
|
};
|
|
124
107
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
108
|
+
// The summaries are still the ATS's own compensation field, so a range
|
|
109
|
+
// read out of one counts as source 'ats'.
|
|
110
|
+
const parsed = extractSalaryFromText(comp.scrapeableCompensationSalarySummary)
|
|
111
|
+
|| extractSalaryFromText(comp.compensationTierSummary);
|
|
112
|
+
return parsed ? { ...parsed, source: 'ats' } : null;
|
|
129
113
|
}
|
|
130
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Check if a company has an Ashby board. See probeResult for the outcomes.
|
|
117
|
+
*/
|
|
131
118
|
export async function hasAshby(slug) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return resp.ok;
|
|
135
|
-
} catch {
|
|
136
|
-
return false;
|
|
137
|
-
}
|
|
119
|
+
const resp = await atsFetch(`${BOARD_URL}/${slug}`, { method: 'HEAD' });
|
|
120
|
+
return probeResult(resp, `Ashby probe for ${slug}`);
|
|
138
121
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { normalize,
|
|
1
|
+
import { normalize, decodeEntities } from '../normalizer.js';
|
|
2
2
|
import { atsErrorFromStatus } from '../errors.js';
|
|
3
|
+
import { atsFetch, probeResult } from '../http.js';
|
|
3
4
|
|
|
4
5
|
const BASE_URL = 'https://boards-api.greenhouse.io/v1/boards';
|
|
5
6
|
|
|
@@ -9,11 +10,13 @@ const BASE_URL = 'https://boards-api.greenhouse.io/v1/boards';
|
|
|
9
10
|
* Docs: https://developers.greenhouse.io/job-board.html
|
|
10
11
|
*
|
|
11
12
|
* @param {string} slug - Company slug (e.g., 'stripe', 'notion')
|
|
13
|
+
* @param {object} [ctx] - { report }; report is called once with
|
|
14
|
+
* { ats, org_name, org_url } when given
|
|
12
15
|
* @returns {Promise<Array>} Normalized job objects
|
|
13
16
|
*/
|
|
14
|
-
export async function fetchGreenhouse(slug) {
|
|
17
|
+
export async function fetchGreenhouse(slug, ctx = {}) {
|
|
15
18
|
const url = `${BASE_URL}/${slug}/jobs?content=true`;
|
|
16
|
-
const resp = await
|
|
19
|
+
const resp = await atsFetch(url);
|
|
17
20
|
|
|
18
21
|
if (!resp.ok) {
|
|
19
22
|
if (resp.status === 404) return []; // Company not found or no jobs
|
|
@@ -23,33 +26,63 @@ export async function fetchGreenhouse(slug) {
|
|
|
23
26
|
const data = await resp.json();
|
|
24
27
|
const jobs = data.jobs || [];
|
|
25
28
|
|
|
29
|
+
// The list response has no top-level name, but each row carries the
|
|
30
|
+
// board's company_name. Its only links are job-boards.greenhouse.io, so
|
|
31
|
+
// there is no company host to report (issue #58).
|
|
32
|
+
if (typeof ctx.report === 'function') {
|
|
33
|
+
ctx.report({
|
|
34
|
+
ats: 'greenhouse',
|
|
35
|
+
org_name: jobs.find(j => j.company_name)?.company_name || null,
|
|
36
|
+
org_url: null,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
return jobs.map(job => normalize({
|
|
27
41
|
companySlug: slug,
|
|
28
42
|
company: data.name || slug,
|
|
29
43
|
title: job.title || '',
|
|
30
44
|
department: job.departments?.[0]?.name || '',
|
|
31
45
|
location: job.location?.name || '',
|
|
32
|
-
|
|
46
|
+
workplace: parseGreenhouseWorkplace(job.metadata),
|
|
47
|
+
// `content` arrives HTML-escaped (`<p>`). Decode that outer layer
|
|
48
|
+
// once so normalize() sees real tags; it strips and decodes the rest.
|
|
49
|
+
description: decodeEntities(job.content || ''),
|
|
33
50
|
url: job.absolute_url || '',
|
|
34
|
-
|
|
35
|
-
|
|
51
|
+
// updated_at is an edit time that many boards bulk-refresh, so it is not
|
|
52
|
+
// a posting date. first_published is. Fallback covers boards without it (#69).
|
|
53
|
+
postedAt: job.first_published || job.updated_at || null,
|
|
54
|
+
salary: null, // list endpoint has no structured pay; normalizer parses the pay transparency text
|
|
36
55
|
metadata: {
|
|
37
56
|
greenhouseId: job.id,
|
|
38
57
|
internal_job_id: job.internal_job_id,
|
|
39
58
|
departments: job.departments?.map(d => d.name) || [],
|
|
40
59
|
offices: job.offices?.map(o => o.name) || [],
|
|
60
|
+
updatedAt: job.updated_at,
|
|
41
61
|
},
|
|
42
62
|
}, 'greenhouse'));
|
|
43
63
|
}
|
|
44
64
|
|
|
45
65
|
/**
|
|
46
|
-
*
|
|
66
|
+
* Greenhouse has no native workplace field. Boards that track it define a
|
|
67
|
+
* custom field ("Location Type", "Workplace Type") that arrives in the
|
|
68
|
+
* job's `metadata[]`, with `value` a string for single-select fields and
|
|
69
|
+
* an array for multi-select. Values seen: On-Site, Hybrid (Travel-Required),
|
|
70
|
+
* Remote. Anything else is no signal and the location string decides.
|
|
71
|
+
*/
|
|
72
|
+
function parseGreenhouseWorkplace(metadata) {
|
|
73
|
+
const field = (metadata || []).find(m => /location type|workplace type/i.test(m?.name || ''));
|
|
74
|
+
if (!field) return null;
|
|
75
|
+
const value = [].concat(field.value ?? []).join(' ').toLowerCase();
|
|
76
|
+
if (/remote/.test(value)) return 'remote';
|
|
77
|
+
if (/hybrid/.test(value)) return 'hybrid';
|
|
78
|
+
if (/on-?site/.test(value)) return 'onsite';
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if a company has a Greenhouse board. See probeResult for the outcomes.
|
|
47
84
|
*/
|
|
48
85
|
export async function hasGreenhouse(slug) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return resp.ok;
|
|
52
|
-
} catch {
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
86
|
+
const resp = await atsFetch(`${BASE_URL}/${slug}`, { method: 'HEAD' });
|
|
87
|
+
return probeResult(resp, `Greenhouse probe for ${slug}`);
|
|
55
88
|
}
|
package/src/adapters/lever.js
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
|
-
import { normalize,
|
|
1
|
+
import { normalize, extractSalaryFromText } from '../normalizer.js';
|
|
2
2
|
import { atsErrorFromStatus } from '../errors.js';
|
|
3
|
+
import { atsFetch, probeResult } from '../http.js';
|
|
3
4
|
|
|
4
5
|
const BASE_URL = 'https://api.lever.co/v0/postings';
|
|
5
6
|
|
|
7
|
+
const PERIODS = { 'per-year-salary': 'year', 'per-month-salary': 'month', 'per-hour-wage': 'hour' };
|
|
8
|
+
// Lever's workplaceType is one of these or 'unspecified'.
|
|
9
|
+
const WORKPLACE_TYPES = new Set(['remote', 'hybrid', 'onsite']);
|
|
10
|
+
|
|
6
11
|
/**
|
|
7
12
|
* Fetch all jobs from a Lever job board.
|
|
8
13
|
* Public API, no auth required.
|
|
14
|
+
* Docs: https://github.com/lever/postings-api
|
|
9
15
|
*
|
|
10
16
|
* @param {string} slug - Company slug (e.g., 'stripe', 'figma')
|
|
17
|
+
* @param {object} [ctx] - { report }; report is called once with
|
|
18
|
+
* { ats, org_name, org_url } when given
|
|
11
19
|
* @returns {Promise<Array>} Normalized job objects
|
|
12
20
|
*/
|
|
13
|
-
export async function fetchLever(slug) {
|
|
21
|
+
export async function fetchLever(slug, ctx = {}) {
|
|
14
22
|
const url = `${BASE_URL}/${slug}?mode=json`;
|
|
15
|
-
const resp = await
|
|
23
|
+
const resp = await atsFetch(url);
|
|
16
24
|
|
|
17
25
|
if (!resp.ok) {
|
|
18
26
|
if (resp.status === 404) return [];
|
|
@@ -22,30 +30,78 @@ export async function fetchLever(slug) {
|
|
|
22
30
|
const jobs = await resp.json();
|
|
23
31
|
if (!Array.isArray(jobs)) return [];
|
|
24
32
|
|
|
25
|
-
|
|
26
|
-
|
|
33
|
+
// The postings response is a bare array of jobs: no organization name
|
|
34
|
+
// anywhere, and every link is on jobs.lever.co. Both null (issue #58).
|
|
35
|
+
if (typeof ctx.report === 'function') {
|
|
36
|
+
ctx.report({ ats: 'lever', org_name: null, org_url: null });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return jobs.map(job => normalize({
|
|
40
|
+
companySlug: slug,
|
|
41
|
+
// Lever's API doesn't return the company name at the board or job level,
|
|
42
|
+
// so the slug is the honest fallback. `categories.team` is the team within
|
|
43
|
+
// the company ("Payments Platform"), not the company itself.
|
|
44
|
+
company: titleCaseSlug(slug),
|
|
45
|
+
title: job.text || '',
|
|
46
|
+
department: job.categories?.department || job.categories?.team || '',
|
|
47
|
+
location: job.categories?.location || '',
|
|
48
|
+
locations: job.categories?.allLocations || [],
|
|
49
|
+
workplace: WORKPLACE_TYPES.has(job.workplaceType) ? job.workplaceType : null,
|
|
50
|
+
description: buildDescription(job),
|
|
51
|
+
url: job.hostedUrl || '',
|
|
52
|
+
postedAt: job.createdAt ? new Date(job.createdAt).toISOString() : null,
|
|
53
|
+
salary: parseLeverSalary(job.salaryRange, job.text),
|
|
54
|
+
metadata: {
|
|
55
|
+
leverId: job.id,
|
|
56
|
+
team: job.categories?.team || '',
|
|
57
|
+
commitment: job.categories?.commitment || '', // Full-time, Part-time, etc.
|
|
58
|
+
workplaceType: job.workplaceType || '',
|
|
59
|
+
salaryDescription: job.salaryDescriptionPlain || '',
|
|
60
|
+
},
|
|
61
|
+
}, 'lever'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Lever splits a posting across `description` (company intro plus overview),
|
|
66
|
+
* `lists` (one `{text, content}` per section: responsibilities, requirements,
|
|
67
|
+
* location details) and `additional` (benefits, EEO). Only the first used to
|
|
68
|
+
* reach the description, so requirements were invisible to filters and to
|
|
69
|
+
* the assistant (issue #64). Reassemble the whole posting as HTML and let
|
|
70
|
+
* normalize() render the headings and bullets.
|
|
71
|
+
*/
|
|
72
|
+
function buildDescription(job) {
|
|
73
|
+
const parts = [job.description || job.descriptionPlain || ''];
|
|
74
|
+
for (const list of job.lists || []) {
|
|
75
|
+
const heading = (list.text || '').trim();
|
|
76
|
+
parts.push((heading ? `<h3>${escapeHtml(heading)}</h3>` : '') + (list.content || ''));
|
|
77
|
+
}
|
|
78
|
+
parts.push(job.additional || job.additionalPlain || '');
|
|
79
|
+
return parts.filter(Boolean).join('\n');
|
|
80
|
+
}
|
|
27
81
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
82
|
+
// `lists[].text` is plain text ("Skills & Experience"). Escaped, normalize()
|
|
83
|
+
// decodes it back; raw, a stray `<` would be stripped as a tag.
|
|
84
|
+
function escapeHtml(s) {
|
|
85
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Lever publishes `salaryRange: {min, max, currency, interval}` on boards
|
|
90
|
+
* that state pay. Boards that don't sometimes put the range in the title.
|
|
91
|
+
*/
|
|
92
|
+
function parseLeverSalary(range, title) {
|
|
93
|
+
const min = range?.min || null;
|
|
94
|
+
const max = range?.max || null;
|
|
95
|
+
if (min || max) {
|
|
96
|
+
return {
|
|
97
|
+
min,
|
|
98
|
+
max,
|
|
99
|
+
currency: range.currency || 'USD',
|
|
100
|
+
period: PERIODS[range.interval] || null,
|
|
101
|
+
source: 'ats',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return extractSalaryFromText(title || '');
|
|
49
105
|
}
|
|
50
106
|
|
|
51
107
|
function titleCaseSlug(slug) {
|
|
@@ -55,24 +111,10 @@ function titleCaseSlug(slug) {
|
|
|
55
111
|
return slug.charAt(0).toUpperCase() + slug.slice(1);
|
|
56
112
|
}
|
|
57
113
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (!match) return null;
|
|
62
|
-
const nums = match[0].match(/[\d,]+/g);
|
|
63
|
-
if (!nums || nums.length < 2) return null;
|
|
64
|
-
return {
|
|
65
|
-
min: parseInt(nums[0].replace(/,/g, '')),
|
|
66
|
-
max: parseInt(nums[1].replace(/,/g, '')),
|
|
67
|
-
currency: 'USD',
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Check if a company has a Lever board. See probeResult for the outcomes.
|
|
116
|
+
*/
|
|
71
117
|
export async function hasLever(slug) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return resp.ok;
|
|
75
|
-
} catch {
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
118
|
+
const resp = await atsFetch(`${BASE_URL}/${slug}?mode=json`, { method: 'HEAD' });
|
|
119
|
+
return probeResult(resp, `Lever probe for ${slug}`);
|
|
78
120
|
}
|