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/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,31 @@ 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
+ * content_missing: number,
61
+ * match: 'registry'|'probe'|'workday_override',
62
+ * company: { key: string, name: string }|null,
63
+ * boards: Array<object>,
64
+ * failed: Array<{ ats: string, slug: string, name: string|null, code: string, message: string }>,
65
+ * }>}
66
+ * jobs: the page. total_matched: matches before offset and limit.
67
+ * total_before_filters: rows every board listed before any filter (on
68
+ * Workday and SmartRecruiters the list count, not the rows they hydrated).
69
+ * content_missing: jobs whose posting was not read because the detail
70
+ * request failed (Workday, SmartRecruiters), counted before the filters:
71
+ * a description filter cannot match text that never arrived.
72
+ * match: how the company was resolved. company: the registry row's name
73
+ * and its key (normalized name); null unless match is 'registry'.
74
+ * boards: one entry per board that answered (see src/boards.js), with
75
+ * jobs_found and matched counted per board, and org_name and org_url as
76
+ * the board states them (null where its ATS exposes nothing). failed:
77
+ * adapters that threw an AtsError during discovery; on a registry hit
78
+ * the error propagates.
79
+ * @throws {ArgumentError} No company, unknown ats, or a filter regex that does not compile.
80
+ * @throws {AtsError} The board's ATS failed on a registry hit, an explicit ats, or a Workday override.
41
81
  */
42
82
  export async function fetchJobsDetailed({
43
83
  company,
@@ -52,64 +92,138 @@ export async function fetchJobsDetailed({
52
92
  offset = 0,
53
93
  limit = 100,
54
94
  } = {}) {
55
- if (!company) throw new Error('Company slug required');
95
+ if (!company) throw new ArgumentError('company is required');
96
+ compileFilterPatterns({ titleFilter, filter });
56
97
 
57
- // Unified slug normalization: strip all non-alphanumeric (matches detectAts)
58
- const slug = company.toLowerCase().replace(/[^a-z0-9]/g, '');
98
+ const slug = normSlug(company);
99
+ const filters = { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes };
59
100
 
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 };
101
+ // Which boards to fetch, and how the company was resolved. A registry hit
102
+ // and a Workday override are one board each and their errors propagate.
103
+ // Discovery (not in the registry, no ats) asks every adapter, keeps the
104
+ // ones that answered, and records the ones that failed.
105
+ let match = 'probe';
106
+ let discovery = false;
107
+ let targets;
65
108
 
66
- let jobs;
67
109
  if (ats) {
68
- const adapter = ADAPTERS[ats];
69
- if (!adapter) throw new Error(`Unknown ATS: ${ats}. Supported: ${ATS_NAMES.join(', ')}`);
110
+ if (!ADAPTERS[ats]) throw new ArgumentError(`Unknown ATS: ${ats}. Supported: ${ATS_NAMES.join(', ')}`);
70
111
  // Explicit ATS: an explicitly passed config wins (the only path that
71
112
  // 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
- }
113
+ // config, the registry supplies the canonical slug, config and name
114
+ // when it lists the company on that ATS (Workday's triple,
115
+ // SmartRecruiters' PascalCase slugs).
116
+ const hit = config ? null : await findEntryBySlug(slug);
117
+ if (hit && hit.ats === ats) {
118
+ match = 'registry';
119
+ targets = [registryTarget(hit, config)];
120
+ } else {
121
+ if (ats === 'workday' && config) match = 'workday_override';
122
+ targets = [{ ats, slug, name: null, config }];
85
123
  }
86
- jobs = await adapter.fetch(fetchSlug, { config: cfg, companyName, filterContext });
87
124
  } 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).
125
+ // Registry first: a known company costs one adapter call, with the
126
+ // ATS's own slug casing and any adapter config the row carries.
93
127
  const hit = await findEntryBySlug(slug);
94
128
  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
- });
129
+ match = 'registry';
130
+ targets = [registryTarget(hit, config)];
100
131
  } 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);
132
+ discovery = true;
133
+ targets = ATS_NAMES.map(atsName => ({ ats: atsName, slug, name: null, config: undefined }));
134
+ }
135
+ }
136
+
137
+ // Adapters get the filters and the page so filter-aware ones (Workday,
138
+ // SmartRecruiters) hydrate only what the page needs, plus `report`, which
139
+ // records what an adapter chooses to say about its own board: the scan
140
+ // counts, and the org name and host the board states. Every call for one
141
+ // adapter merges into one record, so the two can arrive together or apart.
142
+ const reports = {};
143
+ const outcomes = await Promise.allSettled(targets.map(t =>
144
+ ADAPTERS[t.ats].fetch(t.slug, {
145
+ config: t.config,
146
+ companyName: t.name ?? undefined,
147
+ filterContext: { ...filters, offset, limit },
148
+ report: (fields) => {
149
+ reports[t.ats] = { ...reports[t.ats], ...fields };
150
+ },
151
+ })
152
+ ));
153
+
154
+ const boards = [];
155
+ const failed = [];
156
+ const rows = [];
157
+ targets.forEach((t, i) => {
158
+ const outcome = outcomes[i];
159
+ if (outcome.status === 'rejected') {
160
+ const err = outcome.reason;
161
+ if (!discovery || !(err instanceof AtsError)) throw err;
162
+ failed.push({ ats: t.ats, slug: t.slug, name: t.name, code: err.code, message: err.message });
163
+ return;
109
164
  }
165
+ // jobs_found is the board's list before any filter. Workday and
166
+ // SmartRecruiters filter their list before hydrating and report what
167
+ // they listed; counting their rows instead would make a filter miss on
168
+ // a hiring company read as an empty board (issue #60). Every other
169
+ // adapter returns its whole list.
170
+ const report = reports[t.ats] ?? {};
171
+ const scan = SCAN_KEYS.some(k => report[k] !== undefined)
172
+ ? Object.fromEntries(SCAN_KEYS.map(k => [k, report[k]]))
173
+ : null;
174
+ const listed = scan?.listed ?? outcome.value.length;
175
+ // A probed board exists when it listed rows: a 404 and an empty board
176
+ // both come back as []. A registry hit or an override is a board
177
+ // whatever it returned.
178
+ if (match === 'probe' && listed === 0) return;
179
+ rows.push(...outcome.value);
180
+ boards.push(describeBoard({
181
+ ...t,
182
+ org_name: report.org_name ?? null,
183
+ org_url: report.org_url ?? null,
184
+ jobs_found: listed,
185
+ scan,
186
+ }));
187
+ });
188
+
189
+ const matched = filterJobs(rows, filters);
190
+ const perBoard = new Map();
191
+ for (const job of matched) {
192
+ const key = `${job.ats}|${job.companySlug}`;
193
+ perBoard.set(key, (perBoard.get(key) || 0) + 1);
110
194
  }
195
+ for (const board of boards) board.matched = perBoard.get(`${board.ats}|${board.slug}`) || 0;
196
+
197
+ return {
198
+ jobs: pageJobs(matched, { order, offset, limit }),
199
+ total_matched: matched.length,
200
+ total_before_filters: boards.reduce((n, b) => n + b.jobs_found, 0),
201
+ content_missing: rows.filter(j => j.content?.status === 'missing').length,
202
+ match,
203
+ company: match === 'registry' ? { key: normSlug(targets[0].name), name: targets[0].name } : null,
204
+ boards,
205
+ failed,
206
+ };
207
+ }
208
+
209
+ function registryTarget(hit, config) {
210
+ return { ats: hit.ats, slug: hit.entry.slug, name: hit.entry.name, config: config || hit.entry.config };
211
+ }
111
212
 
112
- return applyFiltersDetailed(jobs, { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, order, offset, limit });
213
+ /**
214
+ * The AtsError for a lookup where no board answered and at least one check
215
+ * failed: rate_limited when any failure was a 429, else ats_unreachable,
216
+ * with a message naming each failed check. `failed` is the list
217
+ * fetchJobsDetailed and detectAtsDetailed return.
218
+ */
219
+ export function discoveryFailure(company, failed) {
220
+ const limited = failed.some(f => f.code === ERROR_CODES.RATE_LIMITED);
221
+ const checks = failed.map(f => `${f.ats} (${f.message})`).join('; ');
222
+ return new AtsError(
223
+ limited ? ERROR_CODES.RATE_LIMITED : ERROR_CODES.ATS_UNREACHABLE,
224
+ `No board answered for "${company}" and the check failed on ${checks}`,
225
+ limited ? 429 : undefined
226
+ );
113
227
  }
114
228
 
115
229
  /**
@@ -117,14 +231,16 @@ export async function fetchJobsDetailed({
117
231
  */
118
232
  export async function search({ keyword, location, ats } = {}) {
119
233
  // For now, search is registry-based. With SQLite store, this becomes a full-text search.
120
- if (!keyword) throw new Error('Keyword required');
234
+ if (!keyword) throw new ArgumentError('keyword is required');
121
235
  return searchRegistry(keyword);
122
236
  }
123
237
 
124
238
  /**
125
- * Detect which ATS platform a company uses (probes each adapter).
239
+ * Detect which ATS platform a company uses: the registry first, then a
240
+ * probe of each adapter. detectAts returns [{ ats, slug }]; detectAtsDetailed
241
+ * adds each board's source and the probes that failed.
126
242
  */
127
- export { detectAts } from './registry.js';
243
+ export { detectAts, detectAtsDetailed } from './registry.js';
128
244
 
129
245
  /**
130
246
  * Look up which ATS a slug belongs to in the registry (cached, no network).
@@ -139,6 +255,7 @@ export const registry = {
139
255
  load: loadRegistry,
140
256
  search: searchRegistry,
141
257
  detect: detectAts,
258
+ detectDetailed: detectAtsDetailed,
142
259
  findAtsBySlug,
143
260
  findEntryBySlug,
144
261
  getSource: getRegistrySource,
@@ -156,7 +273,12 @@ export { applyFilters, applyFiltersDetailed } from './filters.js';
156
273
  // the full set detectAts probes, instead of hardcoding a stale subset).
157
274
  export { ATS_NAMES };
158
275
 
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';
276
+ // Error taxonomy + typed errors. Adapters throw AtsError with a stable .code
277
+ // (ats_unreachable / rate_limited); the library throws ArgumentError
278
+ // (invalid_args) for a call it cannot make. The MCP layer maps both by code
279
+ // without parsing messages. ERROR_CODES is the single source of truth.
280
+ export { ERROR_CODES, AtsError, ArgumentError } from './errors.js';
281
+
282
+ // HTTP settings for every adapter request: timeout, retries, per-host cap.
283
+ // For tests and scripts; the defaults are right for normal use.
284
+ export { configureHttp } from './http.js';
package/src/normalizer.js CHANGED
@@ -23,6 +23,9 @@ export function jobId(company, title, ats, location = '') {
23
23
  * adapter to 'remote' | 'hybrid' | 'onsite', or null when the platform
24
24
  * gives no signal. `raw.locations` lists every place the posting is open
25
25
  * in; `location` stays the primary because it feeds the id (issue #68).
26
+ *
27
+ * `raw.content` is set only by a two-step adapter whose detail request
28
+ * failed (see missingContent). Every other job was read in full.
26
29
  */
27
30
  export function normalize(raw, ats) {
28
31
  const now = new Date().toISOString();
@@ -47,10 +50,21 @@ export function normalize(raw, ats) {
47
50
  firstSeen: now,
48
51
  lastSeen: now,
49
52
  status: 'open',
53
+ content: raw.content || { status: 'complete', reason: null },
50
54
  metadata: raw.metadata || {},
51
55
  };
52
56
  }
53
57
 
58
+ /**
59
+ * The `content` of a job whose detail request failed, so its description
60
+ * and pay were never read (issue #85). `failure` is the non-OK Response or
61
+ * the error atsFetch threw: an HTTP status gives "http_503", anything else
62
+ * "network_error".
63
+ */
64
+ export function missingContent(failure) {
65
+ return { status: 'missing', reason: failure?.status ? `http_${failure.status}` : 'network_error' };
66
+ }
67
+
54
68
  const CURRENCY_CODES = 'USD|EUR|GBP|CAD|AUD|NZD|CHF|SEK|NOK|DKK|PLN|CZK|HUF|INR|SGD|HKD|JPY|CNY|BRL|MXN|ZAR|AED|ILS';
55
69
  const SYMBOL_CURRENCY = { $: 'USD', '€': 'EUR', '£': 'GBP' };
56
70
 
@@ -140,14 +154,14 @@ function detectPeriod(before, after, min) {
140
154
  return min >= 10000 ? 'year' : null;
141
155
  }
142
156
 
143
- function periodWord(text) {
157
+ export function periodWord(text) {
144
158
  if (HOUR_RE.test(text)) return 'hour';
145
159
  if (MONTH_RE.test(text)) return 'month';
146
160
  if (YEAR_RE.test(text)) return 'year';
147
161
  return null;
148
162
  }
149
163
 
150
- const WORKPLACE_TYPES = new Set(['remote', 'hybrid', 'onsite']);
164
+ export const WORKPLACE_TYPES = new Set(['remote', 'hybrid', 'onsite']);
151
165
 
152
166
  /**
153
167
  * The platform's own value wins. Without one, a keyword in the location
package/src/registry.js CHANGED
@@ -1,11 +1,16 @@
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';
5
+ import { ADAPTERS, ATS_NAMES } from './adapters/index.js';
4
6
 
5
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
8
  const REGISTRY_DIR = join(__dirname, '..', 'registry');
7
9
 
8
- const PLATFORMS = ['greenhouse', 'lever', 'ashby', 'smartrecruiters', 'teamtailor', 'recruitee', 'workday'];
10
+ // The one order the registry is ever walked in. Lookups, detectAts and the
11
+ // loaded object all follow it, so which file answers for a slug does not
12
+ // depend on which file's load finished first (issue #87).
13
+ const PLATFORMS = ATS_NAMES;
9
14
 
10
15
  // Network-first registry. A hosted copy lets installed bundles AND npx users
11
16
  // pick up newly-added companies without reinstalling; the on-disk copy that
@@ -68,11 +73,8 @@ async function loadPlatform(platform) {
68
73
  */
69
74
  export async function loadRegistry(ats) {
70
75
  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;
76
+ const lists = await Promise.all(PLATFORMS.map(loadPlatform));
77
+ return Object.fromEntries(PLATFORMS.map((platform, i) => [platform, lists[i]]));
76
78
  }
77
79
 
78
80
  /**
@@ -93,7 +95,10 @@ export function getRegistrySource() {
93
95
  }
94
96
 
95
97
  /**
96
- * Search registry for companies matching a query.
98
+ * Search registry for companies matching a query, best match first: an
99
+ * exact name or slug, then a name that starts with the query, then a name
100
+ * that contains it, then a sector-only match. Ties keep platform order, so
101
+ * a caller that cuts the list drops the weakest matches (issue #62).
97
102
  */
98
103
  export async function searchRegistry(query) {
99
104
  const all = await loadRegistry();
@@ -104,40 +109,41 @@ export async function searchRegistry(query) {
104
109
  for (const company of companies) {
105
110
  const name = (company.name || company.slug || '').toLowerCase();
106
111
  const sector = (company.sector || '').toLowerCase();
107
- if (name.includes(lower) || sector.includes(lower)) {
108
- results.push({ ...company, ats });
109
- }
112
+ const rank = name === lower || String(company.slug).toLowerCase() === lower ? 0
113
+ : name.startsWith(lower) ? 1
114
+ : name.includes(lower) ? 2
115
+ : sector.includes(lower) ? 3
116
+ : -1;
117
+ if (rank >= 0) results.push({ rank, row: { ...company, ats } });
110
118
  }
111
119
  }
112
120
 
113
- return results;
121
+ return results.sort((a, b) => a.rank - b.rank).map(r => r.row);
114
122
  }
115
123
 
116
124
  // Slug match is case/punctuation-insensitive: registry slugs are stored
117
125
  // in each ATS's canonical form (SmartRecruiters uses PascalCase, e.g.
118
126
  // "Visa"), but callers pass a lowercased/alnum-stripped slug. Comparing
119
127
  // normalized forms keeps registry-first routing working for those.
120
- const normSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
128
+ export const normSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
129
+
130
+ const byPlatform = (a, b) => PLATFORMS.indexOf(a.ats) - PLATFORMS.indexOf(b.ats);
121
131
 
122
132
  /**
123
133
  * Look up which ATS a slug belongs to in the registry.
124
134
  * Returns the ATS name (e.g., "greenhouse") or null if not in registry.
125
135
  */
126
136
  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;
137
+ const hit = await findEntryBySlug(slug);
138
+ return hit ? hit.ats : null;
133
139
  }
134
140
 
135
141
  /**
136
142
  * Look up the full registry entry for a slug, with its ATS.
137
143
  * Unlike findAtsBySlug (returns just the ats name), this returns the
138
144
  * 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.
145
+ * Workday {tenant, env, site} triple). The files are searched in
146
+ * PLATFORMS order, so the first match is the same on every call.
141
147
  *
142
148
  * @returns {Promise<{ats: string, entry: object}|null>}
143
149
  */
@@ -152,18 +158,61 @@ export async function findEntryBySlug(slug) {
152
158
  }
153
159
 
154
160
  /**
155
- * Auto-detect which ATS a company uses.
161
+ * Where a company answers: the registry first, then a live probe of every
162
+ * adapter the registry did not already answer for.
163
+ *
164
+ * A slug the registry knows is listed with source 'registry' and not probed
165
+ * (Workday included, whose boards cannot be probed at all). Every remaining
166
+ * adapter's has() then runs: true adds a board with source 'probe', false
167
+ * adds nothing, and an AtsError (429, 5xx, 401, network) goes to `failed`
168
+ * with its code, so a board the probe could not check never reads as
169
+ * absent (issue #55). Any other error is a bug and is rethrown. Both lists
170
+ * come back in PLATFORMS order, never in completion order.
171
+ *
172
+ * @returns {Promise<{
173
+ * boards: Array<{ ats: string, slug: string, source: 'registry'|'probe' }>,
174
+ * failed: Array<{ ats: string, slug: string, code: string, message: string }>,
175
+ * }>}
156
176
  */
157
- export async function detectAts(companyName) {
158
- const { ADAPTERS } = await import('./adapters/index.js');
159
- const slug = companyName.toLowerCase().replace(/[^a-z0-9]/g, '');
177
+ export async function detectAtsDetailed(companyName) {
178
+ const slug = normSlug(companyName);
179
+ const all = await loadRegistry();
160
180
 
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
- });
181
+ const boards = [];
182
+ const failed = [];
183
+ const known = new Set();
184
+ for (const [ats, companies] of Object.entries(all)) {
185
+ const entry = companies.find(c => normSlug(c.slug) === slug);
186
+ if (entry) {
187
+ boards.push({ ats, slug: entry.slug, source: 'registry' });
188
+ known.add(ats);
189
+ }
190
+ }
191
+
192
+ const probes = Object.entries(ADAPTERS).filter(([ats]) => !known.has(ats));
193
+ const outcomes = await Promise.all(probes.map(async ([ats, adapter]) => {
194
+ try {
195
+ return { ats, found: await adapter.has(slug) };
196
+ } catch (err) {
197
+ if (!(err instanceof AtsError)) throw err;
198
+ return { ats, error: err };
199
+ }
200
+ }));
201
+ for (const { ats, found, error } of outcomes) {
202
+ if (error) failed.push({ ats, slug, code: error.code, message: error.message });
203
+ else if (found) boards.push({ ats, slug, source: 'probe' });
204
+ }
205
+
206
+ return { boards: boards.sort(byPlatform), failed: failed.sort(byPlatform) };
207
+ }
166
208
 
167
- await Promise.allSettled(checks);
168
- return results;
209
+ /**
210
+ * Auto-detect which ATS a company uses: the boards from detectAtsDetailed
211
+ * as [{ ats, slug }]. A failed probe never rejects this; the detailed
212
+ * variant is where those are reported. An adapter throwing anything but an
213
+ * AtsError is a bug and propagates, as it does through fetchJobs.
214
+ */
215
+ export async function detectAts(companyName) {
216
+ const { boards } = await detectAtsDetailed(companyName);
217
+ return boards.map(({ ats, slug }) => ({ ats, slug }));
169
218
  }