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 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.9.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",
@@ -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 fetch(url);
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 = WORKPLACE_TYPES[String(job.workplaceType || '').toLowerCase()];
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
- try {
168
- const resp = await fetch(`${BOARD_URL}/${slug}`, { method: 'HEAD' });
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
- const url = `${BASE_URL}/${slug}/jobs?content=true`;
16
- const resp = await fetch(url);
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
- return jobs.map(job => normalize({
27
- companySlug: slug,
28
- company: data.name || slug,
29
- title: job.title || '',
30
- department: job.departments?.[0]?.name || '',
31
- location: job.location?.name || '',
32
- workplace: parseGreenhouseWorkplace(job.metadata),
33
- // `content` arrives HTML-escaped (`&lt;p&gt;`). Decode that outer layer
34
- // once so normalize() sees real tags; it strips and decodes the rest.
35
- description: decodeEntities(job.content || ''),
36
- url: job.absolute_url || '',
37
- // updated_at is an edit time that many boards bulk-refresh, so it is not
38
- // a posting date. first_published is. Fallback covers boards without it (#69).
39
- postedAt: job.first_published || job.updated_at || null,
40
- salary: null, // list endpoint has no structured pay; normalizer parses the pay transparency text
41
- metadata: {
42
- greenhouseId: job.id,
43
- internal_job_id: job.internal_job_id,
44
- departments: job.departments?.map(d => d.name) || [],
45
- offices: job.offices?.map(o => o.name) || [],
46
- updatedAt: job.updated_at,
47
- },
48
- }, 'greenhouse'));
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 (`&lt;p&gt;`). 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
- try {
73
- const resp = await fetch(`${BASE_URL}/${slug}`, { method: 'HEAD' });
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
  }
@@ -1,19 +1,29 @@
1
- export { fetchGreenhouse, hasGreenhouse } from './greenhouse.js';
2
- export { fetchLever, hasLever } from './lever.js';
3
- export { fetchAshby, hasAshby } from './ashby.js';
4
- export { fetchSmartrecruiters, hasSmartrecruiters } from './smartrecruiters.js';
5
- export { fetchTeamtailor, hasTeamtailor } from './teamtailor.js';
6
- export { fetchRecruitee, hasRecruitee } from './recruitee.js';
7
- export { fetchWorkday, hasWorkday } from './workday.js';
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: (...args) => import('./greenhouse.js').then(m => m.fetchGreenhouse(...args)), has: (...args) => import('./greenhouse.js').then(m => m.hasGreenhouse(...args)) },
11
- lever: { fetch: (...args) => import('./lever.js').then(m => m.fetchLever(...args)), has: (...args) => import('./lever.js').then(m => m.hasLever(...args)) },
12
- ashby: { fetch: (...args) => import('./ashby.js').then(m => m.fetchAshby(...args)), has: (...args) => import('./ashby.js').then(m => m.hasAshby(...args)) },
13
- smartrecruiters: { fetch: (...args) => import('./smartrecruiters.js').then(m => m.fetchSmartrecruiters(...args)), has: (...args) => import('./smartrecruiters.js').then(m => m.hasSmartrecruiters(...args)) },
14
- teamtailor: { fetch: (...args) => import('./teamtailor.js').then(m => m.fetchTeamtailor(...args)), has: (...args) => import('./teamtailor.js').then(m => m.hasTeamtailor(...args)) },
15
- recruitee: { fetch: (...args) => import('./recruitee.js').then(m => m.fetchRecruitee(...args)), has: (...args) => import('./recruitee.js').then(m => m.hasRecruitee(...args)) },
16
- workday: { fetch: (...args) => import('./workday.js').then(m => m.fetchWorkday(...args)), has: (...args) => import('./workday.js').then(m => m.hasWorkday(...args)) },
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);
@@ -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 fetch(url);
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
- workplace: WORKPLACE_TYPES.has(job.workplaceType) ? job.workplaceType : null,
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
- try {
107
- const resp = await fetch(`${BASE_URL}/${slug}?mode=json`, { method: 'HEAD' });
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 fetch(url);
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
- try {
118
- const resp = await fetch(`https://${slug}.recruitee.com/api/offers/`);
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
  }