jd-intel 0.9.0 → 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/src/index.js CHANGED
@@ -7,23 +7,39 @@
7
7
  */
8
8
 
9
9
  import { ADAPTERS, ATS_NAMES } from './adapters/index.js';
10
- import { loadRegistry, searchRegistry, detectAts, findAtsBySlug, findEntryBySlug, getRegistrySource } from './registry.js';
11
- import { applyFiltersDetailed } from './filters.js';
10
+ import { loadRegistry, searchRegistry, detectAts, detectAtsDetailed, findAtsBySlug, findEntryBySlug, getRegistrySource, normSlug } from './registry.js';
11
+ import { filterJobs, pageJobs, compileFilterPatterns } from './filters.js';
12
+ import { AtsError, ArgumentError, ERROR_CODES } from './errors.js';
13
+ import { describeBoard } from './boards.js';
14
+
15
+ // The counts an adapter reports about its scan. boards[].scan carries these
16
+ // and nothing else; org_name and org_url from the same report go on the
17
+ // board itself.
18
+ const SCAN_KEYS = ['listed', 'prefiltered', 'hydrated', 'capped'];
12
19
 
13
20
  /**
14
21
  * Fetch jobs from a company's ATS board.
15
22
  *
16
23
  * Same options as fetchJobsDetailed; returns the page as an array.
17
24
  *
25
+ * Discovery that found no board while at least one check failed throws an
26
+ * AtsError (rate_limited when any failure was a 429, else ats_unreachable)
27
+ * instead of returning []: an array has no place for `failed`, and an empty
28
+ * one would read as "not found" (issue #55).
29
+ *
18
30
  * @returns {Promise<Array>} Normalized, filtered job objects
19
31
  */
20
32
  export async function fetchJobs(options = {}) {
21
- const { jobs } = await fetchJobsDetailed(options);
22
- return jobs;
33
+ const result = await fetchJobsDetailed(options);
34
+ if (result.match === 'probe' && result.boards.length === 0 && result.failed.length > 0) {
35
+ throw discoveryFailure(options.company, result.failed);
36
+ }
37
+ return result.jobs;
23
38
  }
24
39
 
25
40
  /**
26
- * Fetch jobs from a company's ATS board, with the match count.
41
+ * Fetch jobs from a company's ATS board, with the counts and the boards
42
+ * behind the page.
27
43
  *
28
44
  * @param {Object} options
29
45
  * @param {string} options.company - Company slug or name
@@ -37,7 +53,27 @@ export async function fetchJobs(options = {}) {
37
53
  * @param {'newest'|'board'} [options.order='newest'] - 'newest': by postedAt descending, undated last, ties by id. 'board': the adapter's own order.
38
54
  * @param {number} [options.offset=0] - Matches to skip after sorting (paging).
39
55
  * @param {number} [options.limit=100] - Maximum jobs to return after offset.
40
- * @returns {Promise<{ jobs: Array, total_matched: number }>} The page, plus the match count before offset and limit
56
+ * @returns {Promise<{
57
+ * jobs: Array,
58
+ * total_matched: number,
59
+ * total_before_filters: number,
60
+ * match: 'registry'|'probe'|'workday_override',
61
+ * company: { key: string, name: string }|null,
62
+ * boards: Array<object>,
63
+ * failed: Array<{ ats: string, slug: string, name: string|null, code: string, message: string }>,
64
+ * }>}
65
+ * jobs: the page. total_matched: matches before offset and limit.
66
+ * total_before_filters: rows every board listed before any filter (on
67
+ * Workday and SmartRecruiters the list count, not the rows they hydrated).
68
+ * match: how the company was resolved. company: the registry row's name
69
+ * and its key (normalized name); null unless match is 'registry'.
70
+ * boards: one entry per board that answered (see src/boards.js), with
71
+ * jobs_found and matched counted per board, and org_name and org_url as
72
+ * the board states them (null where its ATS exposes nothing). failed:
73
+ * adapters that threw an AtsError during discovery; on a registry hit
74
+ * the error propagates.
75
+ * @throws {ArgumentError} No company, unknown ats, or a filter regex that does not compile.
76
+ * @throws {AtsError} The board's ATS failed on a registry hit, an explicit ats, or a Workday override.
41
77
  */
42
78
  export async function fetchJobsDetailed({
43
79
  company,
@@ -52,64 +88,131 @@ export async function fetchJobsDetailed({
52
88
  offset = 0,
53
89
  limit = 100,
54
90
  } = {}) {
55
- if (!company) throw new Error('Company slug required');
91
+ if (!company) throw new ArgumentError('company is required');
92
+ compileFilterPatterns({ titleFilter, filter });
56
93
 
57
- // Unified slug normalization: strip all non-alphanumeric (matches detectAts)
58
- const slug = company.toLowerCase().replace(/[^a-z0-9]/g, '');
94
+ const slug = normSlug(company);
95
+ const filters = { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes };
59
96
 
60
- // Filter context is passed as an additive 2nd arg to adapters. Existing
61
- // adapters declare fetch{Name}(slug) and ignore extra positional args
62
- // (JS no-op), so this is backward-compatible. Filter-aware adapters
63
- // (e.g. Workday) use it to avoid mass detail-fetching on huge tenants.
64
- const filterContext = { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, offset, limit };
97
+ // Which boards to fetch, and how the company was resolved. A registry hit
98
+ // and a Workday override are one board each and their errors propagate.
99
+ // Discovery (not in the registry, no ats) asks every adapter, keeps the
100
+ // ones that answered, and records the ones that failed.
101
+ let match = 'probe';
102
+ let discovery = false;
103
+ let targets;
65
104
 
66
- let jobs;
67
105
  if (ats) {
68
- const adapter = ADAPTERS[ats];
69
- if (!adapter) throw new Error(`Unknown ATS: ${ats}. Supported: ${ATS_NAMES.join(', ')}`);
106
+ if (!ADAPTERS[ats]) throw new ArgumentError(`Unknown ATS: ${ats}. Supported: ${ATS_NAMES.join(', ')}`);
70
107
  // Explicit ATS: an explicitly passed config wins (the only path that
71
108
  // can reach a Workday company not in the registry). With no explicit
72
- // config, fall back to the registry so config-keyed adapters
73
- // (Workday) and canonically-cased registry slugs (SmartRecruiters
74
- // "Visa") also work on the explicit path, not just under auto-detect.
75
- let fetchSlug = slug;
76
- let cfg = config;
77
- let companyName;
78
- if (!cfg) {
79
- const hit = await findEntryBySlug(slug);
80
- if (hit && hit.ats === ats) {
81
- fetchSlug = hit.entry.slug;
82
- cfg = hit.entry.config;
83
- companyName = hit.entry.name;
84
- }
109
+ // config, the registry supplies the canonical slug, config and name
110
+ // when it lists the company on that ATS (Workday's triple,
111
+ // SmartRecruiters' PascalCase slugs).
112
+ const hit = config ? null : await findEntryBySlug(slug);
113
+ if (hit && hit.ats === ats) {
114
+ match = 'registry';
115
+ targets = [registryTarget(hit, config)];
116
+ } else {
117
+ if (ats === 'workday' && config) match = 'workday_override';
118
+ targets = [{ ats, slug, name: null, config }];
85
119
  }
86
- jobs = await adapter.fetch(fetchSlug, { config: cfg, companyName, filterContext });
87
120
  } else {
88
- // Consult registry first — if we know which ATS this company uses,
89
- // skip probing the others (saves API calls, clearer error semantics).
90
- // The registry entry carries the canonical slug (so the adapter is
91
- // called with the ATS's own casing, e.g. SmartRecruiters "Visa") and
92
- // any adapter-specific config (the Workday {tenant,env,site} triple).
121
+ // Registry first: a known company costs one adapter call, with the
122
+ // ATS's own slug casing and any adapter config the row carries.
93
123
  const hit = await findEntryBySlug(slug);
94
124
  if (hit) {
95
- jobs = await ADAPTERS[hit.ats].fetch(hit.entry.slug, {
96
- config: config || hit.entry.config,
97
- companyName: hit.entry.name,
98
- filterContext,
99
- });
125
+ match = 'registry';
126
+ targets = [registryTarget(hit, config)];
100
127
  } else {
101
- // Discovery mode: company not in registry, probe all adapters.
102
- // (Registry-only adapters like Workday bail here via their guard.)
103
- const results = await Promise.allSettled(
104
- Object.entries(ADAPTERS).map(async ([name, adapter]) => adapter.fetch(slug, { filterContext }))
105
- );
106
- jobs = results
107
- .filter(r => r.status === 'fulfilled')
108
- .flatMap(r => r.value);
128
+ discovery = true;
129
+ targets = ATS_NAMES.map(atsName => ({ ats: atsName, slug, name: null, config: undefined }));
130
+ }
131
+ }
132
+
133
+ // Adapters get the filters and the page so filter-aware ones (Workday,
134
+ // SmartRecruiters) hydrate only what the page needs, plus `report`, which
135
+ // records what an adapter chooses to say about its own board: the scan
136
+ // counts, and the org name and host the board states. Every call for one
137
+ // adapter merges into one record, so the two can arrive together or apart.
138
+ const reports = {};
139
+ const outcomes = await Promise.allSettled(targets.map(t =>
140
+ ADAPTERS[t.ats].fetch(t.slug, {
141
+ config: t.config,
142
+ companyName: t.name ?? undefined,
143
+ filterContext: { ...filters, offset, limit },
144
+ report: (fields) => {
145
+ reports[t.ats] = { ...reports[t.ats], ...fields };
146
+ },
147
+ })
148
+ ));
149
+
150
+ const boards = [];
151
+ const failed = [];
152
+ const rows = [];
153
+ targets.forEach((t, i) => {
154
+ const outcome = outcomes[i];
155
+ if (outcome.status === 'rejected') {
156
+ const err = outcome.reason;
157
+ if (!discovery || !(err instanceof AtsError)) throw err;
158
+ failed.push({ ats: t.ats, slug: t.slug, name: t.name, code: err.code, message: err.message });
159
+ return;
109
160
  }
161
+ // jobs_found is the board's list before any filter. Workday and
162
+ // SmartRecruiters filter their list before hydrating and report what
163
+ // they listed; counting their rows instead would make a filter miss on
164
+ // a hiring company read as an empty board (issue #60). Every other
165
+ // adapter returns its whole list.
166
+ const report = reports[t.ats] ?? {};
167
+ const scan = SCAN_KEYS.some(k => report[k] !== undefined)
168
+ ? Object.fromEntries(SCAN_KEYS.map(k => [k, report[k]]))
169
+ : null;
170
+ const listed = scan?.listed ?? outcome.value.length;
171
+ // A probed board exists when it listed rows: a 404 and an empty board
172
+ // both come back as []. A registry hit or an override is a board
173
+ // whatever it returned.
174
+ if (match === 'probe' && listed === 0) return;
175
+ rows.push(...outcome.value);
176
+ boards.push(describeBoard({
177
+ ...t,
178
+ org_name: report.org_name ?? null,
179
+ org_url: report.org_url ?? null,
180
+ jobs_found: listed,
181
+ scan,
182
+ }));
183
+ });
184
+
185
+ const matched = filterJobs(rows, filters);
186
+ const perBoard = new Map();
187
+ for (const job of matched) {
188
+ const key = `${job.ats}|${job.companySlug}`;
189
+ perBoard.set(key, (perBoard.get(key) || 0) + 1);
110
190
  }
191
+ for (const board of boards) board.matched = perBoard.get(`${board.ats}|${board.slug}`) || 0;
192
+
193
+ return {
194
+ jobs: pageJobs(matched, { order, offset, limit }),
195
+ total_matched: matched.length,
196
+ total_before_filters: boards.reduce((n, b) => n + b.jobs_found, 0),
197
+ match,
198
+ company: match === 'registry' ? { key: normSlug(targets[0].name), name: targets[0].name } : null,
199
+ boards,
200
+ failed,
201
+ };
202
+ }
111
203
 
112
- return applyFiltersDetailed(jobs, { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, order, offset, limit });
204
+ function registryTarget(hit, config) {
205
+ return { ats: hit.ats, slug: hit.entry.slug, name: hit.entry.name, config: config || hit.entry.config };
206
+ }
207
+
208
+ function discoveryFailure(company, failed) {
209
+ const limited = failed.some(f => f.code === ERROR_CODES.RATE_LIMITED);
210
+ const checks = failed.map(f => `${f.ats} (${f.message})`).join('; ');
211
+ return new AtsError(
212
+ limited ? ERROR_CODES.RATE_LIMITED : ERROR_CODES.ATS_UNREACHABLE,
213
+ `No board answered for "${company}" and the check failed on ${checks}`,
214
+ limited ? 429 : undefined
215
+ );
113
216
  }
114
217
 
115
218
  /**
@@ -117,14 +220,16 @@ export async function fetchJobsDetailed({
117
220
  */
118
221
  export async function search({ keyword, location, ats } = {}) {
119
222
  // For now, search is registry-based. With SQLite store, this becomes a full-text search.
120
- if (!keyword) throw new Error('Keyword required');
223
+ if (!keyword) throw new ArgumentError('keyword is required');
121
224
  return searchRegistry(keyword);
122
225
  }
123
226
 
124
227
  /**
125
- * Detect which ATS platform a company uses (probes each adapter).
228
+ * Detect which ATS platform a company uses: the registry first, then a
229
+ * probe of each adapter. detectAts returns [{ ats, slug }]; detectAtsDetailed
230
+ * adds each board's source and the probes that failed.
126
231
  */
127
- export { detectAts } from './registry.js';
232
+ export { detectAts, detectAtsDetailed } from './registry.js';
128
233
 
129
234
  /**
130
235
  * Look up which ATS a slug belongs to in the registry (cached, no network).
@@ -139,6 +244,7 @@ export const registry = {
139
244
  load: loadRegistry,
140
245
  search: searchRegistry,
141
246
  detect: detectAts,
247
+ detectDetailed: detectAtsDetailed,
142
248
  findAtsBySlug,
143
249
  findEntryBySlug,
144
250
  getSource: getRegistrySource,
@@ -156,7 +262,12 @@ export { applyFilters, applyFiltersDetailed } from './filters.js';
156
262
  // the full set detectAts probes, instead of hardcoding a stale subset).
157
263
  export { ATS_NAMES };
158
264
 
159
- // Error taxonomy + typed error. Adapters throw AtsError with a stable .code
160
- // (ats_unreachable / rate_limited) so the MCP layer maps failures without
161
- // parsing messages. ERROR_CODES is the single source of truth for both.
162
- export { ERROR_CODES, AtsError } from './errors.js';
265
+ // Error taxonomy + typed errors. Adapters throw AtsError with a stable .code
266
+ // (ats_unreachable / rate_limited); the library throws ArgumentError
267
+ // (invalid_args) for a call it cannot make. The MCP layer maps both by code
268
+ // without parsing messages. ERROR_CODES is the single source of truth.
269
+ export { ERROR_CODES, AtsError, ArgumentError } from './errors.js';
270
+
271
+ // HTTP settings for every adapter request: timeout, retries, per-host cap.
272
+ // For tests and scripts; the defaults are right for normal use.
273
+ export { configureHttp } from './http.js';
package/src/registry.js CHANGED
@@ -1,10 +1,14 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { AtsError } from './errors.js';
4
5
 
5
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
7
  const REGISTRY_DIR = join(__dirname, '..', 'registry');
7
8
 
9
+ // The one order the registry is ever walked in. Lookups, detectAts and the
10
+ // loaded object all follow it, so which file answers for a slug does not
11
+ // depend on which file's load finished first (issue #87).
8
12
  const PLATFORMS = ['greenhouse', 'lever', 'ashby', 'smartrecruiters', 'teamtailor', 'recruitee', 'workday'];
9
13
 
10
14
  // Network-first registry. A hosted copy lets installed bundles AND npx users
@@ -68,11 +72,8 @@ async function loadPlatform(platform) {
68
72
  */
69
73
  export async function loadRegistry(ats) {
70
74
  if (ats) return loadPlatform(ats);
71
- const all = {};
72
- await Promise.all(PLATFORMS.map(async (platform) => {
73
- all[platform] = await loadPlatform(platform);
74
- }));
75
- return all;
75
+ const lists = await Promise.all(PLATFORMS.map(loadPlatform));
76
+ return Object.fromEntries(PLATFORMS.map((platform, i) => [platform, lists[i]]));
76
77
  }
77
78
 
78
79
  /**
@@ -117,34 +118,43 @@ export async function searchRegistry(query) {
117
118
  // in each ATS's canonical form (SmartRecruiters uses PascalCase, e.g.
118
119
  // "Visa"), but callers pass a lowercased/alnum-stripped slug. Comparing
119
120
  // normalized forms keeps registry-first routing working for those.
120
- const normSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
121
+ export const normSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
122
+
123
+ // The loaded registry as [ats, companies] pairs in PLATFORMS order, whatever
124
+ // order the object's keys are in.
125
+ function platformEntries(all) {
126
+ return PLATFORMS.map(ats => [ats, all[ats] || []]);
127
+ }
128
+
129
+ function platformIndex(ats) {
130
+ const i = PLATFORMS.indexOf(ats);
131
+ return i === -1 ? PLATFORMS.length : i;
132
+ }
133
+
134
+ const byPlatform = (a, b) => platformIndex(a.ats) - platformIndex(b.ats);
121
135
 
122
136
  /**
123
137
  * Look up which ATS a slug belongs to in the registry.
124
138
  * Returns the ATS name (e.g., "greenhouse") or null if not in registry.
125
139
  */
126
140
  export async function findAtsBySlug(slug) {
127
- const all = await loadRegistry();
128
- const key = normSlug(slug);
129
- for (const [ats, companies] of Object.entries(all)) {
130
- if (companies.some(c => normSlug(c.slug) === key)) return ats;
131
- }
132
- return null;
141
+ const hit = await findEntryBySlug(slug);
142
+ return hit ? hit.ats : null;
133
143
  }
134
144
 
135
145
  /**
136
146
  * Look up the full registry entry for a slug, with its ATS.
137
147
  * Unlike findAtsBySlug (returns just the ats name), this returns the
138
148
  * whole entry so callers can read adapter-specific config (e.g. the
139
- * Workday {tenant, env, site} triple). Additive — does not change
140
- * findAtsBySlug, which has other callers.
149
+ * Workday {tenant, env, site} triple). The files are searched in
150
+ * PLATFORMS order, so the first match is the same on every call.
141
151
  *
142
152
  * @returns {Promise<{ats: string, entry: object}|null>}
143
153
  */
144
154
  export async function findEntryBySlug(slug) {
145
155
  const all = await loadRegistry();
146
156
  const key = normSlug(slug);
147
- for (const [ats, companies] of Object.entries(all)) {
157
+ for (const [ats, companies] of platformEntries(all)) {
148
158
  const entry = companies.find(c => normSlug(c.slug) === key);
149
159
  if (entry) return { ats, entry };
150
160
  }
@@ -152,18 +162,62 @@ export async function findEntryBySlug(slug) {
152
162
  }
153
163
 
154
164
  /**
155
- * Auto-detect which ATS a company uses.
165
+ * Where a company answers: the registry first, then a live probe of every
166
+ * adapter the registry did not already answer for.
167
+ *
168
+ * A slug the registry knows is listed with source 'registry' and not probed
169
+ * (Workday included, whose boards cannot be probed at all). Every remaining
170
+ * adapter's has() then runs: true adds a board with source 'probe', false
171
+ * adds nothing, and an AtsError (429, 5xx, 401, network) goes to `failed`
172
+ * with its code, so a board the probe could not check never reads as
173
+ * absent (issue #55). Any other error is a bug and is rethrown. Both lists
174
+ * come back in PLATFORMS order, never in completion order.
175
+ *
176
+ * @returns {Promise<{
177
+ * boards: Array<{ ats: string, slug: string, source: 'registry'|'probe' }>,
178
+ * failed: Array<{ ats: string, slug: string, code: string, message: string }>,
179
+ * }>}
156
180
  */
157
- export async function detectAts(companyName) {
181
+ export async function detectAtsDetailed(companyName) {
158
182
  const { ADAPTERS } = await import('./adapters/index.js');
159
- const slug = companyName.toLowerCase().replace(/[^a-z0-9]/g, '');
183
+ const slug = normSlug(companyName);
184
+ const all = await loadRegistry();
160
185
 
161
- const results = [];
162
- const checks = Object.entries(ADAPTERS).map(async ([ats, adapter]) => {
163
- const found = await adapter.has(slug);
164
- if (found) results.push({ ats, slug });
165
- });
186
+ const boards = [];
187
+ const failed = [];
188
+ const known = new Set();
189
+ for (const [ats, companies] of platformEntries(all)) {
190
+ const entry = companies.find(c => normSlug(c.slug) === slug);
191
+ if (entry) {
192
+ boards.push({ ats, slug: entry.slug, source: 'registry' });
193
+ known.add(ats);
194
+ }
195
+ }
166
196
 
167
- await Promise.allSettled(checks);
168
- return results;
197
+ const probes = Object.entries(ADAPTERS).filter(([ats]) => !known.has(ats));
198
+ const outcomes = await Promise.all(probes.map(async ([ats, adapter]) => {
199
+ try {
200
+ return { ats, found: await adapter.has(slug) };
201
+ } catch (err) {
202
+ if (!(err instanceof AtsError)) throw err;
203
+ return { ats, error: err };
204
+ }
205
+ }));
206
+ for (const { ats, found, error } of outcomes) {
207
+ if (error) failed.push({ ats, slug, code: error.code, message: error.message });
208
+ else if (found) boards.push({ ats, slug, source: 'probe' });
209
+ }
210
+
211
+ return { boards: boards.sort(byPlatform), failed: failed.sort(byPlatform) };
212
+ }
213
+
214
+ /**
215
+ * Auto-detect which ATS a company uses: the boards from detectAtsDetailed
216
+ * as [{ ats, slug }]. A failed probe never rejects this; the detailed
217
+ * variant is where those are reported. An adapter throwing anything but an
218
+ * AtsError is a bug and propagates, as it does through fetchJobs.
219
+ */
220
+ export async function detectAts(companyName) {
221
+ const { boards } = await detectAtsDetailed(companyName);
222
+ return boards.map(({ ats, slug }) => ({ ats, slug }));
169
223
  }