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 +16 -1
- package/package.json +1 -1
- package/src/adapters/ashby.js +18 -76
- package/src/adapters/greenhouse.js +96 -34
- package/src/adapters/index.js +24 -14
- package/src/adapters/lever.js +13 -10
- package/src/adapters/recruitee.js +19 -9
- package/src/adapters/smartrecruiters.js +88 -38
- package/src/adapters/teamtailor.js +36 -15
- package/src/adapters/workday.js +97 -81
- package/src/boards.js +80 -0
- package/src/cli.js +42 -30
- package/src/errors.js +14 -0
- package/src/filters.js +109 -26
- package/src/http.js +184 -0
- package/src/index.js +179 -57
- package/src/normalizer.js +16 -2
- package/src/registry.js +80 -31
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,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<{
|
|
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
|
|
95
|
+
if (!company) throw new ArgumentError('company is required');
|
|
96
|
+
compileFilterPatterns({ titleFilter, filter });
|
|
56
97
|
|
|
57
|
-
|
|
58
|
-
const
|
|
98
|
+
const slug = normSlug(company);
|
|
99
|
+
const filters = { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes };
|
|
59
100
|
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// (
|
|
63
|
-
//
|
|
64
|
-
|
|
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
|
-
|
|
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,
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
|
|
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
|
-
//
|
|
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).
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
companyName: hit.entry.name,
|
|
98
|
-
filterContext,
|
|
99
|
-
});
|
|
129
|
+
match = 'registry';
|
|
130
|
+
targets = [registryTarget(hit, config)];
|
|
100
131
|
} else {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
160
|
-
// (ats_unreachable / rate_limited)
|
|
161
|
-
//
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
72
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
|
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;
|
|
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).
|
|
140
|
-
*
|
|
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
|
-
*
|
|
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
|
|
158
|
-
const
|
|
159
|
-
const
|
|
177
|
+
export async function detectAtsDetailed(companyName) {
|
|
178
|
+
const slug = normSlug(companyName);
|
|
179
|
+
const all = await loadRegistry();
|
|
160
180
|
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
}
|