jd-intel 0.9.0 → 0.11.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 +16 -1
- package/package.json +1 -1
- package/src/adapters/ashby.js +18 -76
- package/src/adapters/greenhouse.js +96 -34
- package/src/adapters/index.js +24 -14
- package/src/adapters/lever.js +13 -10
- package/src/adapters/recruitee.js +19 -9
- package/src/adapters/smartrecruiters.js +88 -38
- package/src/adapters/teamtailor.js +36 -15
- package/src/adapters/workday.js +97 -81
- package/src/boards.js +80 -0
- package/src/cli.js +42 -30
- package/src/errors.js +14 -0
- package/src/filters.js +109 -26
- package/src/http.js +184 -0
- package/src/index.js +179 -57
- package/src/normalizer.js +16 -2
- package/src/registry.js +80 -31
package/README.md
CHANGED
|
@@ -144,8 +144,22 @@ const { jobs, total_matched } = await fetchJobsDetailed({
|
|
|
144
144
|
});
|
|
145
145
|
```
|
|
146
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
|
+
|
|
147
159
|
CLI usage: `npx jd-intel fetch <company-slug> --title-filter "engineer" --posted-within-days 14`. Full filter reference [below](#filters-quick-reference).
|
|
148
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
|
+
|
|
149
163
|
Node.js 18+. No API keys. No configuration.
|
|
150
164
|
|
|
151
165
|
### Manual install (fallback)
|
|
@@ -226,7 +240,8 @@ No custom parsing per company.
|
|
|
226
240
|
| `locationType` | `remote`, `hybrid`, `onsite`, or `unknown` when neither the platform nor the location text says |
|
|
227
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 |
|
|
228
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 |
|
|
229
|
-
| `description` | Full JD in clean markdown |
|
|
243
|
+
| `description` | Full JD in clean markdown. Empty when `content.status` is `missing` |
|
|
244
|
+
| `content` | `{ status, reason }`. `complete` when the posting was read. `missing` when Workday or SmartRecruiters listed the job but its detail request failed (`reason`: `http_503`, `http_429`, `network_error`), so `description` and `salary` are unknown |
|
|
230
245
|
| `url` | Direct link to the posting |
|
|
231
246
|
| `postedAt` | Publication date (when provided) |
|
|
232
247
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jd-intel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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, extractSalaryFromText } from '../normalizer.js';
|
|
1
|
+
import { normalize, extractSalaryFromText, WORKPLACE_TYPES } 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,16 @@ 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')
|
|
13
17
|
* @returns {Promise<Array>} Normalized job objects
|
|
14
18
|
*/
|
|
15
19
|
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) {
|
|
27
20
|
const url = `${BOARD_URL}/${slug}?includeCompensation=true`;
|
|
28
|
-
const resp = await
|
|
21
|
+
const resp = await atsFetch(url);
|
|
29
22
|
|
|
30
23
|
if (!resp.ok) {
|
|
31
24
|
if (resp.status === 404) return [];
|
|
@@ -35,6 +28,10 @@ async function fetchAshbyRest(slug) {
|
|
|
35
28
|
const data = await resp.json();
|
|
36
29
|
const jobs = data.jobs || [];
|
|
37
30
|
|
|
31
|
+
// The REST response is { jobs, apiVersion }: no organization name, and
|
|
32
|
+
// every link is on jobs.ashbyhq.com. Nothing to report, so the board's
|
|
33
|
+
// org_name and org_url stay null (issue #58).
|
|
34
|
+
|
|
38
35
|
return jobs.map(job => {
|
|
39
36
|
const comp = job.compensation || {};
|
|
40
37
|
|
|
@@ -68,68 +65,14 @@ async function fetchAshbyRest(slug) {
|
|
|
68
65
|
});
|
|
69
66
|
}
|
|
70
67
|
|
|
71
|
-
async function fetchAshbyGraphQL(slug) {
|
|
72
|
-
const query = `{
|
|
73
|
-
jobBoard {
|
|
74
|
-
title
|
|
75
|
-
jobPostings {
|
|
76
|
-
id
|
|
77
|
-
title
|
|
78
|
-
locationName
|
|
79
|
-
employmentType
|
|
80
|
-
descriptionHtml
|
|
81
|
-
publishedDate
|
|
82
|
-
compensationTierSummary
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}`;
|
|
86
|
-
|
|
87
|
-
const resp = await fetch(API_URL, {
|
|
88
|
-
method: 'POST',
|
|
89
|
-
headers: { 'Content-Type': 'application/json' },
|
|
90
|
-
body: JSON.stringify({
|
|
91
|
-
operationName: 'ApiJobBoardWithTeams',
|
|
92
|
-
variables: { organizationHostedJobsPageName: slug },
|
|
93
|
-
query,
|
|
94
|
-
}),
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
if (!resp.ok) return [];
|
|
98
|
-
|
|
99
|
-
const data = await resp.json();
|
|
100
|
-
const board = data.data?.jobBoard;
|
|
101
|
-
if (!board) return [];
|
|
102
|
-
|
|
103
|
-
const postings = board.jobPostings || [];
|
|
104
|
-
|
|
105
|
-
return postings.map(job => normalize({
|
|
106
|
-
companySlug: slug,
|
|
107
|
-
company: board.title || slug,
|
|
108
|
-
title: job.title || '',
|
|
109
|
-
department: '',
|
|
110
|
-
location: job.locationName || '',
|
|
111
|
-
description: job.descriptionHtml || '',
|
|
112
|
-
url: `https://jobs.ashbyhq.com/${slug}/${job.id}`,
|
|
113
|
-
postedAt: job.publishedDate || null,
|
|
114
|
-
salary: null,
|
|
115
|
-
metadata: {
|
|
116
|
-
ashbyId: job.id,
|
|
117
|
-
employmentType: job.employmentType || '',
|
|
118
|
-
compensationSummary: job.compensationTierSummary || '',
|
|
119
|
-
},
|
|
120
|
-
}, 'ashby'));
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const WORKPLACE_TYPES = { remote: 'remote', hybrid: 'hybrid', onsite: 'onsite' };
|
|
124
|
-
|
|
125
68
|
/**
|
|
126
69
|
* `workplaceType` is 'Remote', 'Hybrid' or 'OnSite'. `isRemote` is the
|
|
127
70
|
* older flag and can only say remote, so it is the fallback when the type
|
|
128
71
|
* is absent. false means nothing: the role may be hybrid or onsite.
|
|
129
72
|
*/
|
|
130
73
|
function parseAshbyWorkplace(job) {
|
|
131
|
-
const type =
|
|
132
|
-
if (type) return type;
|
|
74
|
+
const type = String(job.workplaceType || '').toLowerCase();
|
|
75
|
+
if (WORKPLACE_TYPES.has(type)) return type;
|
|
133
76
|
return job.isRemote === true ? 'remote' : null;
|
|
134
77
|
}
|
|
135
78
|
|
|
@@ -163,11 +106,10 @@ function parseAshbyCompensation(comp) {
|
|
|
163
106
|
return parsed ? { ...parsed, source: 'ats' } : null;
|
|
164
107
|
}
|
|
165
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Check if a company has an Ashby board. See probeResult for the outcomes.
|
|
111
|
+
*/
|
|
166
112
|
export async function hasAshby(slug) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return resp.ok;
|
|
170
|
-
} catch {
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
113
|
+
const resp = await atsFetch(`${BOARD_URL}/${slug}`, { method: 'HEAD' });
|
|
114
|
+
return probeResult(resp, `Ashby probe for ${slug}`);
|
|
173
115
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { normalize, decodeEntities } from '../normalizer.js';
|
|
1
|
+
import { normalize, decodeEntities, periodWord } 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,14 @@ 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
|
+
* { org_name, org_url } when given
|
|
12
15
|
* @returns {Promise<Array>} Normalized job objects
|
|
13
16
|
*/
|
|
14
|
-
export async function fetchGreenhouse(slug) {
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
+
export async function fetchGreenhouse(slug, ctx = {}) {
|
|
18
|
+
// pay_transparency adds pay_input_ranges to each row (issue #86).
|
|
19
|
+
const url = `${BASE_URL}/${slug}/jobs?content=true&pay_transparency=true`;
|
|
20
|
+
const resp = await atsFetch(url);
|
|
17
21
|
|
|
18
22
|
if (!resp.ok) {
|
|
19
23
|
if (resp.status === 404) return []; // Company not found or no jobs
|
|
@@ -23,29 +27,91 @@ export async function fetchGreenhouse(slug) {
|
|
|
23
27
|
const data = await resp.json();
|
|
24
28
|
const jobs = data.jobs || [];
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
30
|
+
// The list response has no top-level name, but each row carries the
|
|
31
|
+
// board's company_name. Its only links are job-boards.greenhouse.io, so
|
|
32
|
+
// there is no company host to report (issue #58).
|
|
33
|
+
if (typeof ctx.report === 'function') {
|
|
34
|
+
ctx.report({
|
|
35
|
+
org_name: jobs.find(j => j.company_name)?.company_name || null,
|
|
36
|
+
org_url: null,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return jobs.map(job => {
|
|
41
|
+
const payRanges = parsePayRanges(job.pay_input_ranges);
|
|
42
|
+
return normalize({
|
|
43
|
+
companySlug: slug,
|
|
44
|
+
company: data.name || slug,
|
|
45
|
+
title: job.title || '',
|
|
46
|
+
department: job.departments?.[0]?.name || '',
|
|
47
|
+
location: job.location?.name || '',
|
|
48
|
+
workplace: parseGreenhouseWorkplace(job.metadata),
|
|
49
|
+
// `content` arrives HTML-escaped (`<p>`). Decode that outer layer
|
|
50
|
+
// once so normalize() sees real tags; it strips and decodes the rest.
|
|
51
|
+
description: decodeEntities(job.content || ''),
|
|
52
|
+
url: job.absolute_url || '',
|
|
53
|
+
// updated_at is an edit time that many boards bulk-refresh, so it is not
|
|
54
|
+
// a posting date. first_published is. Fallback covers boards without it (#69).
|
|
55
|
+
postedAt: job.first_published || job.updated_at || null,
|
|
56
|
+
salary: salaryFromRanges(payRanges), // null without structured pay; normalize() then parses the text
|
|
57
|
+
metadata: {
|
|
58
|
+
greenhouseId: job.id,
|
|
59
|
+
internal_job_id: job.internal_job_id,
|
|
60
|
+
departments: job.departments?.map(d => d.name) || [],
|
|
61
|
+
offices: job.offices?.map(o => o.name) || [],
|
|
62
|
+
updatedAt: job.updated_at,
|
|
63
|
+
payRanges,
|
|
64
|
+
},
|
|
65
|
+
}, 'greenhouse');
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `pay_input_ranges` is the board's pay transparency data:
|
|
71
|
+
* [{ min_cents, max_cents, currency_type, title, blurb }]. The blurb is
|
|
72
|
+
* boilerplate already rendered in the description, so it is dropped, and
|
|
73
|
+
* the platform can send the same entry twice, so entries are deduplicated.
|
|
74
|
+
* A board that does not publish pay sends an empty array or no key.
|
|
75
|
+
*/
|
|
76
|
+
function parsePayRanges(ranges) {
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const r of Array.isArray(ranges) ? ranges : []) {
|
|
80
|
+
const range = {
|
|
81
|
+
title: r.title || '',
|
|
82
|
+
min: Number.isFinite(r.min_cents) ? r.min_cents / 100 : null,
|
|
83
|
+
max: Number.isFinite(r.max_cents) ? r.max_cents / 100 : null,
|
|
84
|
+
currency: r.currency_type || 'USD',
|
|
85
|
+
};
|
|
86
|
+
const key = JSON.stringify(range);
|
|
87
|
+
if ((range.min === null && range.max === null) || seen.has(key)) continue;
|
|
88
|
+
seen.add(key);
|
|
89
|
+
out.push(range);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One salary from the ranges. Ranges in one currency span (lowest min,
|
|
96
|
+
* highest max), as the Ashby adapter does for its tiers; with mixed
|
|
97
|
+
* currencies the first range stands alone. Every range stays in
|
|
98
|
+
* metadata.payRanges. The field has no period, so the period is the one
|
|
99
|
+
* the range titles state ("Annual", "Hourly") and null when they state
|
|
100
|
+
* none or disagree: an 'ats' value carries no guessed period.
|
|
101
|
+
*/
|
|
102
|
+
function salaryFromRanges(ranges) {
|
|
103
|
+
if (ranges.length === 0) return null;
|
|
104
|
+
const used = ranges.every(r => r.currency === ranges[0].currency) ? ranges : [ranges[0]];
|
|
105
|
+
const mins = used.map(r => r.min).filter(v => v !== null);
|
|
106
|
+
const maxes = used.map(r => r.max).filter(v => v !== null);
|
|
107
|
+
const periods = new Set(used.map(r => periodWord(r.title)));
|
|
108
|
+
return {
|
|
109
|
+
min: mins.length ? Math.min(...mins) : null,
|
|
110
|
+
max: maxes.length ? Math.max(...maxes) : null,
|
|
111
|
+
currency: used[0].currency,
|
|
112
|
+
period: periods.size === 1 ? [...periods][0] : null,
|
|
113
|
+
source: 'ats',
|
|
114
|
+
};
|
|
49
115
|
}
|
|
50
116
|
|
|
51
117
|
/**
|
|
@@ -66,13 +132,9 @@ function parseGreenhouseWorkplace(metadata) {
|
|
|
66
132
|
}
|
|
67
133
|
|
|
68
134
|
/**
|
|
69
|
-
* Check if a company has a Greenhouse board.
|
|
135
|
+
* Check if a company has a Greenhouse board. See probeResult for the outcomes.
|
|
70
136
|
*/
|
|
71
137
|
export async function hasGreenhouse(slug) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return resp.ok;
|
|
75
|
-
} catch {
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
138
|
+
const resp = await atsFetch(`${BASE_URL}/${slug}`, { method: 'HEAD' });
|
|
139
|
+
return probeResult(resp, `Greenhouse probe for ${slug}`);
|
|
78
140
|
}
|
package/src/adapters/index.js
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import { fetchGreenhouse, hasGreenhouse } from './greenhouse.js';
|
|
2
|
+
import { fetchLever, hasLever } from './lever.js';
|
|
3
|
+
import { fetchAshby, hasAshby } from './ashby.js';
|
|
4
|
+
import { fetchSmartrecruiters, hasSmartrecruiters } from './smartrecruiters.js';
|
|
5
|
+
import { fetchTeamtailor, hasTeamtailor } from './teamtailor.js';
|
|
6
|
+
import { fetchRecruitee, hasRecruitee } from './recruitee.js';
|
|
7
|
+
import { fetchWorkday, hasWorkday } from './workday.js';
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
fetchGreenhouse, hasGreenhouse,
|
|
11
|
+
fetchLever, hasLever,
|
|
12
|
+
fetchAshby, hasAshby,
|
|
13
|
+
fetchSmartrecruiters, hasSmartrecruiters,
|
|
14
|
+
fetchTeamtailor, hasTeamtailor,
|
|
15
|
+
fetchRecruitee, hasRecruitee,
|
|
16
|
+
fetchWorkday, hasWorkday,
|
|
17
|
+
};
|
|
8
18
|
|
|
9
19
|
export const ADAPTERS = {
|
|
10
|
-
greenhouse: { fetch:
|
|
11
|
-
lever: { fetch:
|
|
12
|
-
ashby: { fetch:
|
|
13
|
-
smartrecruiters: { fetch:
|
|
14
|
-
teamtailor: { fetch:
|
|
15
|
-
recruitee: { fetch:
|
|
16
|
-
workday: { fetch:
|
|
20
|
+
greenhouse: { fetch: fetchGreenhouse, has: hasGreenhouse },
|
|
21
|
+
lever: { fetch: fetchLever, has: hasLever },
|
|
22
|
+
ashby: { fetch: fetchAshby, has: hasAshby },
|
|
23
|
+
smartrecruiters: { fetch: fetchSmartrecruiters, has: hasSmartrecruiters },
|
|
24
|
+
teamtailor: { fetch: fetchTeamtailor, has: hasTeamtailor },
|
|
25
|
+
recruitee: { fetch: fetchRecruitee, has: hasRecruitee },
|
|
26
|
+
workday: { fetch: fetchWorkday, has: hasWorkday },
|
|
17
27
|
};
|
|
18
28
|
|
|
19
29
|
export const ATS_NAMES = Object.keys(ADAPTERS);
|
package/src/adapters/lever.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
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
|
|
|
6
7
|
const PERIODS = { 'per-year-salary': 'year', 'per-month-salary': 'month', 'per-hour-wage': 'hour' };
|
|
7
|
-
// Lever's workplaceType is one of these or 'unspecified'.
|
|
8
|
-
const WORKPLACE_TYPES = new Set(['remote', 'hybrid', 'onsite']);
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
10
|
* Fetch all jobs from a Lever job board.
|
|
@@ -17,7 +16,7 @@ const WORKPLACE_TYPES = new Set(['remote', 'hybrid', 'onsite']);
|
|
|
17
16
|
*/
|
|
18
17
|
export async function fetchLever(slug) {
|
|
19
18
|
const url = `${BASE_URL}/${slug}?mode=json`;
|
|
20
|
-
const resp = await
|
|
19
|
+
const resp = await atsFetch(url);
|
|
21
20
|
|
|
22
21
|
if (!resp.ok) {
|
|
23
22
|
if (resp.status === 404) return [];
|
|
@@ -27,6 +26,10 @@ export async function fetchLever(slug) {
|
|
|
27
26
|
const jobs = await resp.json();
|
|
28
27
|
if (!Array.isArray(jobs)) return [];
|
|
29
28
|
|
|
29
|
+
// The postings response is a bare array of jobs: no organization name
|
|
30
|
+
// anywhere, and every link is on jobs.lever.co. Nothing to report, so
|
|
31
|
+
// the board's org_name and org_url stay null (issue #58).
|
|
32
|
+
|
|
30
33
|
return jobs.map(job => normalize({
|
|
31
34
|
companySlug: slug,
|
|
32
35
|
// Lever's API doesn't return the company name at the board or job level,
|
|
@@ -37,7 +40,8 @@ export async function fetchLever(slug) {
|
|
|
37
40
|
department: job.categories?.department || job.categories?.team || '',
|
|
38
41
|
location: job.categories?.location || '',
|
|
39
42
|
locations: job.categories?.allLocations || [],
|
|
40
|
-
|
|
43
|
+
// 'remote', 'hybrid', 'onsite' or 'unspecified'; normalize() ignores the last.
|
|
44
|
+
workplace: job.workplaceType,
|
|
41
45
|
description: buildDescription(job),
|
|
42
46
|
url: job.hostedUrl || '',
|
|
43
47
|
postedAt: job.createdAt ? new Date(job.createdAt).toISOString() : null,
|
|
@@ -102,11 +106,10 @@ function titleCaseSlug(slug) {
|
|
|
102
106
|
return slug.charAt(0).toUpperCase() + slug.slice(1);
|
|
103
107
|
}
|
|
104
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Check if a company has a Lever board. See probeResult for the outcomes.
|
|
111
|
+
*/
|
|
105
112
|
export async function hasLever(slug) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
return resp.ok;
|
|
109
|
-
} catch {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
113
|
+
const resp = await atsFetch(`${BASE_URL}/${slug}?mode=json`, { method: 'HEAD' });
|
|
114
|
+
return probeResult(resp, `Lever probe for ${slug}`);
|
|
112
115
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { normalize } from '../normalizer.js';
|
|
2
2
|
import { atsErrorFromStatus } from '../errors.js';
|
|
3
|
+
import { atsFetch, probeResult } from '../http.js';
|
|
4
|
+
import { orgHost } from '../boards.js';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Fetch jobs from a Recruitee career site.
|
|
@@ -18,11 +20,13 @@ import { atsErrorFromStatus } from '../errors.js';
|
|
|
18
20
|
* both are joined before normalize() strips them (issue #65).
|
|
19
21
|
*
|
|
20
22
|
* @param {string} slug - Recruitee company subdomain (e.g., 'vandebron')
|
|
23
|
+
* @param {object} [ctx] - { report }; report is called once with
|
|
24
|
+
* { org_name, org_url } when given
|
|
21
25
|
* @returns {Promise<Array>} Normalized job objects
|
|
22
26
|
*/
|
|
23
|
-
export async function fetchRecruitee(slug) {
|
|
27
|
+
export async function fetchRecruitee(slug, ctx = {}) {
|
|
24
28
|
const url = `https://${slug}.recruitee.com/api/offers/`;
|
|
25
|
-
const resp = await
|
|
29
|
+
const resp = await atsFetch(url);
|
|
26
30
|
|
|
27
31
|
if (!resp.ok) {
|
|
28
32
|
if (resp.status === 404) return []; // No Recruitee site for this slug
|
|
@@ -32,6 +36,16 @@ export async function fetchRecruitee(slug) {
|
|
|
32
36
|
const data = await resp.json();
|
|
33
37
|
const offers = data.offers || [];
|
|
34
38
|
|
|
39
|
+
// The response is { offers } only, so the identity lives on the rows:
|
|
40
|
+
// company_name, and careers_url, which sits on the company's own careers
|
|
41
|
+
// domain when the site has one and on {slug}.recruitee.com otherwise.
|
|
42
|
+
if (typeof ctx.report === 'function') {
|
|
43
|
+
ctx.report({
|
|
44
|
+
org_name: offers.find(o => o.company_name)?.company_name || null,
|
|
45
|
+
org_url: orgHost(offers.find(o => o.careers_url)?.careers_url),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
return offers.map(offer => {
|
|
36
50
|
const place = [offer.city, offer.country].filter(Boolean).join(', ');
|
|
37
51
|
let location = place;
|
|
@@ -111,13 +125,9 @@ function toAmount(value) {
|
|
|
111
125
|
}
|
|
112
126
|
|
|
113
127
|
/**
|
|
114
|
-
* Check if a company has a Recruitee career site.
|
|
128
|
+
* Check if a company has a Recruitee career site. See probeResult for the outcomes.
|
|
115
129
|
*/
|
|
116
130
|
export async function hasRecruitee(slug) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return resp.ok;
|
|
120
|
-
} catch {
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
131
|
+
const resp = await atsFetch(`https://${slug}.recruitee.com/api/offers/`);
|
|
132
|
+
return probeResult(resp, `Recruitee probe for ${slug}`);
|
|
123
133
|
}
|