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/README.md +14 -0
- package/package.json +1 -1
- package/src/adapters/ashby.js +20 -72
- package/src/adapters/greenhouse.js +19 -9
- package/src/adapters/lever.js +16 -8
- package/src/adapters/recruitee.js +20 -9
- package/src/adapters/smartrecruiters.js +113 -36
- package/src/adapters/teamtailor.js +37 -15
- package/src/adapters/workday.js +60 -19
- package/src/boards.js +80 -0
- package/src/cli.js +12 -8
- package/src/errors.js +14 -0
- package/src/filters.js +62 -23
- package/src/http.js +184 -0
- package/src/index.js +168 -57
- package/src/registry.js +79 -25
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 {
|
|
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
|
|
22
|
-
|
|
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
|
|
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<{
|
|
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
|
|
91
|
+
if (!company) throw new ArgumentError('company is required');
|
|
92
|
+
compileFilterPatterns({ titleFilter, filter });
|
|
56
93
|
|
|
57
|
-
|
|
58
|
-
const
|
|
94
|
+
const slug = normSlug(company);
|
|
95
|
+
const filters = { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes };
|
|
59
96
|
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// (
|
|
63
|
-
//
|
|
64
|
-
|
|
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
|
-
|
|
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,
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
|
|
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
|
-
//
|
|
89
|
-
//
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
companyName: hit.entry.name,
|
|
98
|
-
filterContext,
|
|
99
|
-
});
|
|
125
|
+
match = 'registry';
|
|
126
|
+
targets = [registryTarget(hit, config)];
|
|
100
127
|
} else {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
160
|
-
// (ats_unreachable / rate_limited)
|
|
161
|
-
//
|
|
162
|
-
|
|
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
|
|
72
|
-
|
|
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
|
|
128
|
-
|
|
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).
|
|
140
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
181
|
+
export async function detectAtsDetailed(companyName) {
|
|
158
182
|
const { ADAPTERS } = await import('./adapters/index.js');
|
|
159
|
-
const slug = companyName
|
|
183
|
+
const slug = normSlug(companyName);
|
|
184
|
+
const all = await loadRegistry();
|
|
160
185
|
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
}
|