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 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
- "salary": { "min": 180000, "max": 240000, "currency": "USD" },
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` | City, state, country, or remote |
210
- | `locationType` | `remote`, `hybrid`, or `onsite` |
211
- | `salary` | Min-max range with currency (when available) |
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.8.3",
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",
@@ -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 fetch(url);
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 salary = parseAshbyCompensation(job.compensation);
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.departmentName || '',
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.teamName || '',
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
- async function fetchAshbyGraphQL(slug) {
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
- return postings.map(job => normalize({
96
- companySlug: slug,
97
- company: board.title || slug,
98
- title: job.title || '',
99
- department: '',
100
- location: job.locationName || '',
101
- description: job.descriptionHtml || '',
102
- url: `https://jobs.ashbyhq.com/${slug}/${job.id}`,
103
- postedAt: job.publishedDate || null,
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
- if (!comp) return null;
115
- // Ashby compensation can be a string or structured object
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: parseInt(match[1].replace(/,/g, '')),
121
- max: parseInt(match[2].replace(/,/g, '')),
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
- if (comp.min && comp.max) {
126
- return { min: comp.min, max: comp.max, currency: comp.currency || 'USD' };
127
- }
128
- return null;
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
- try {
133
- const resp = await fetch(`${BOARD_URL}/${slug}`, { method: 'HEAD' });
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, stripHtml } from '../normalizer.js';
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 fetch(url);
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
- description: stripHtml(job.content || ''),
46
+ workplace: parseGreenhouseWorkplace(job.metadata),
47
+ // `content` arrives HTML-escaped (`&lt;p&gt;`). 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
- postedAt: job.updated_at || null,
35
- salary: null, // Greenhouse doesn't expose salary in public API
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
- * Check if a company has a Greenhouse board.
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
- try {
50
- const resp = await fetch(`${BASE_URL}/${slug}`, { method: 'HEAD' });
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
  }
@@ -1,18 +1,26 @@
1
- import { normalize, stripHtml } 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
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 fetch(url);
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
- return jobs.map(job => {
26
- const salary = parseLeverSalary(job.categories?.commitment, job.text);
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
- return normalize({
29
- companySlug: slug,
30
- // Lever's API doesn't return the company name at the board or job level,
31
- // so the slug is the honest fallback. `categories.team` is the team within
32
- // the company ("Payments Platform"), not the company itself.
33
- company: titleCaseSlug(slug),
34
- title: job.text || '',
35
- department: job.categories?.department || job.categories?.team || '',
36
- location: job.categories?.location || '',
37
- description: stripHtml(job.descriptionPlain || job.description || ''),
38
- url: job.hostedUrl || '',
39
- postedAt: job.createdAt ? new Date(job.createdAt).toISOString() : null,
40
- salary,
41
- metadata: {
42
- leverId: job.id,
43
- team: job.categories?.team || '',
44
- commitment: job.categories?.commitment || '', // Full-time, Part-time, etc.
45
- workplaceType: job.workplaceType || '',
46
- },
47
- }, 'lever');
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
- function parseLeverSalary(commitment, title) {
59
- // Lever doesn't have a salary field, but sometimes it's in the title
60
- const match = (title || '').match(/\$[\d,]+\s*[-–]\s*\$[\d,]+/);
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
- try {
73
- const resp = await fetch(`${BASE_URL}/${slug}?mode=json`, { method: 'HEAD' });
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
  }