jd-intel 0.8.3 → 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 +35 -4
- package/package.json +1 -1
- package/src/adapters/ashby.js +69 -86
- package/src/adapters/greenhouse.js +46 -13
- package/src/adapters/lever.js +87 -45
- package/src/adapters/recruitee.js +87 -23
- package/src/adapters/smartrecruiters.js +146 -34
- package/src/adapters/teamtailor.js +57 -39
- package/src/adapters/workday.js +107 -31
- package/src/boards.js +80 -0
- package/src/cli.js +46 -15
- package/src/errors.js +14 -0
- package/src/filters.js +122 -29
- package/src/http.js +184 -0
- package/src/index.js +186 -59
- package/src/normalizer.js +200 -44
- package/src/registry.js +79 -25
package/src/http.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { AtsError, ERROR_CODES, atsErrorFromStatus } from './errors.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one HTTP door for every adapter request (issue #7).
|
|
5
|
+
*
|
|
6
|
+
* atsFetch() wraps the global fetch with the politeness every ATS expects
|
|
7
|
+
* and the failure handling the adapters used to leave out:
|
|
8
|
+
* - a timeout per attempt on the wait for the response to start
|
|
9
|
+
* - retries with exponential backoff and jitter on 429, any 5xx, and
|
|
10
|
+
* network errors (DNS, reset, timeout), honoring Retry-After; a
|
|
11
|
+
* certificate failure is thrown at once, since it cannot pass later
|
|
12
|
+
* - a cap on requests in flight per host
|
|
13
|
+
*
|
|
14
|
+
* Every other status resolves normally, so an adapter keeps its own 404
|
|
15
|
+
* handling. Once the retries are used up the caller gets an AtsError:
|
|
16
|
+
* rate_limited for a 429, ats_unreachable for a 5xx or a network failure.
|
|
17
|
+
* The global fetch is read on every attempt so a test's mock of it applies.
|
|
18
|
+
*
|
|
19
|
+
* The timer stops once the headers are in. Reading the body is the caller's
|
|
20
|
+
* step (resp.json() in the adapter), and it is not timed: a signal left on
|
|
21
|
+
* the request would abort that read too, so a large board on a slow link
|
|
22
|
+
* would fail at the timeout with a raw TimeoutError thrown from resp.json(),
|
|
23
|
+
* outside this retry loop, where master downloaded it fine. undici's own
|
|
24
|
+
* body timeout (300s idle) still ends a stream that stalls.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const DEFAULTS = {
|
|
28
|
+
timeoutMs: 10_000,
|
|
29
|
+
retries: 3, // attempts per request in total; 1 turns retrying off
|
|
30
|
+
perHost: 4, // requests in flight per hostname
|
|
31
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const BASE_BACKOFF_MS = 1000; // 1s, 2s, 4s, ...
|
|
35
|
+
const JITTER_MS = 250;
|
|
36
|
+
const MAX_RETRY_AFTER_MS = 30_000;
|
|
37
|
+
|
|
38
|
+
let settings = { ...DEFAULTS };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Replace the HTTP settings with the defaults plus `overrides`. For tests
|
|
42
|
+
* and scripts. `configureHttp()` restores the defaults. Returns a copy of
|
|
43
|
+
* the settings now in force.
|
|
44
|
+
*/
|
|
45
|
+
export function configureHttp(overrides = {}) {
|
|
46
|
+
settings = { ...DEFAULTS, ...overrides };
|
|
47
|
+
return { ...settings };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* fetch(url, init) with a timeout, retries and a per-host queue.
|
|
52
|
+
*
|
|
53
|
+
* Resolves with the Response for any status that is not retried (2xx, 3xx,
|
|
54
|
+
* and 4xx other than 429). Throws AtsError once the retries are used up on
|
|
55
|
+
* a 429 or 5xx (with `.status`), or on a network error or a timeout waiting
|
|
56
|
+
* for the response to start.
|
|
57
|
+
*/
|
|
58
|
+
export async function atsFetch(url, init = {}) {
|
|
59
|
+
const host = new URL(url).hostname;
|
|
60
|
+
const { retries, sleep, timeoutMs } = settings;
|
|
61
|
+
|
|
62
|
+
for (let attempt = 1; ; attempt++) {
|
|
63
|
+
let resp;
|
|
64
|
+
try {
|
|
65
|
+
resp = await withHostSlot(host, () => fetchWithTimeout(url, init, timeoutMs));
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (attempt >= retries || isCertError(err)) {
|
|
68
|
+
const error = new AtsError(
|
|
69
|
+
ERROR_CODES.ATS_UNREACHABLE,
|
|
70
|
+
`${host}: ${describeCause(err, timeoutMs)} after ${attempts(attempt)}`
|
|
71
|
+
);
|
|
72
|
+
error.cause = err;
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
await sleep(backoffMs(attempt));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!isRetried(resp.status)) return resp;
|
|
80
|
+
if (attempt >= retries) {
|
|
81
|
+
throw atsErrorFromStatus(resp.status, `${host}: HTTP ${resp.status} after ${attempts(attempt)}`);
|
|
82
|
+
}
|
|
83
|
+
await sleep(retryAfterMs(resp) ?? backoffMs(attempt));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Three-state probe outcome for an adapter's has(): true on 2xx, false on
|
|
89
|
+
* a 404, and an AtsError for anything else (401, 403, ...), so a board the
|
|
90
|
+
* probe could not check never reads as "not here" (issue #55). A 429 or
|
|
91
|
+
* 5xx never reaches this point: atsFetch throws on those itself.
|
|
92
|
+
*/
|
|
93
|
+
export function probeResult(resp, label) {
|
|
94
|
+
if (resp.ok) return true;
|
|
95
|
+
if (resp.status === 404) return false;
|
|
96
|
+
throw atsErrorFromStatus(resp.status, `${label}: ${resp.status}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The abort covers connecting and waiting for the headers, and is cleared as
|
|
100
|
+
// soon as fetch resolves so the body read that follows is never aborted
|
|
101
|
+
// (see the header comment). The reason is a TimeoutError like the one
|
|
102
|
+
// AbortSignal.timeout would raise, so describeCause reads both the same way.
|
|
103
|
+
async function fetchWithTimeout(url, init, timeoutMs) {
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
const timer = setTimeout(
|
|
106
|
+
() => controller.abort(new DOMException(`Timed out after ${timeoutMs}ms`, 'TimeoutError')),
|
|
107
|
+
timeoutMs
|
|
108
|
+
);
|
|
109
|
+
try {
|
|
110
|
+
return await globalThis.fetch(url, { ...init, signal: controller.signal });
|
|
111
|
+
} finally {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isRetried(status) {
|
|
117
|
+
return status === 429 || status >= 500;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function backoffMs(attempt) {
|
|
121
|
+
return BASE_BACKOFF_MS * 2 ** (attempt - 1) + Math.floor(Math.random() * JITTER_MS);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Retry-After is either delay-seconds or an HTTP-date. Mocked responses may
|
|
125
|
+
// carry no headers at all.
|
|
126
|
+
function retryAfterMs(resp) {
|
|
127
|
+
const raw = resp.headers?.get?.('retry-after');
|
|
128
|
+
if (!raw) return null;
|
|
129
|
+
const seconds = Number(raw);
|
|
130
|
+
const ms = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(raw) - Date.now();
|
|
131
|
+
if (!Number.isFinite(ms)) return null;
|
|
132
|
+
return Math.min(Math.max(ms, 0), MAX_RETRY_AFTER_MS);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A certificate that fails validation fails the same way on the next
|
|
136
|
+
// attempt, so retrying it only costs time. Seen live: every
|
|
137
|
+
// {slug}.eu.teamtailor.com answers ERR_TLS_CERT_ALTNAME_INVALID.
|
|
138
|
+
const CERT_ERROR = /^(?:ERR_TLS_CERT_ALTNAME_INVALID|CERT_HAS_EXPIRED|CERT_NOT_YET_VALID|DEPTH_ZERO_SELF_SIGNED_CERT|SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT(?:_LOCALLY)?)$/;
|
|
139
|
+
|
|
140
|
+
function isCertError(err) {
|
|
141
|
+
return CERT_ERROR.test(err?.cause?.code || '');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// undici reports socket failures as TypeError('fetch failed') with the OS
|
|
145
|
+
// code on `cause`, and rejects an aborted request with the signal's reason,
|
|
146
|
+
// here the TimeoutError from fetchWithTimeout.
|
|
147
|
+
function describeCause(err, timeoutMs) {
|
|
148
|
+
if (err?.name === 'TimeoutError') return `timed out after ${timeoutMs}ms`;
|
|
149
|
+
const detail = err?.cause?.code || err?.cause?.message;
|
|
150
|
+
const message = err?.message || String(err);
|
|
151
|
+
return detail ? `${message} (${detail})` : message;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function attempts(n) {
|
|
155
|
+
return `${n} attempt${n === 1 ? '' : 's'}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Per-host queue. A finished request hands its slot straight to the next
|
|
159
|
+
// waiter (the count never dips in between), so the cap holds even when a new
|
|
160
|
+
// caller arrives while a waiter is being woken.
|
|
161
|
+
const hosts = new Map(); // hostname -> { active, waiting: [resolve] }
|
|
162
|
+
|
|
163
|
+
async function withHostSlot(host, run) {
|
|
164
|
+
let slot = hosts.get(host);
|
|
165
|
+
if (!slot) {
|
|
166
|
+
slot = { active: 0, waiting: [] };
|
|
167
|
+
hosts.set(host, slot);
|
|
168
|
+
}
|
|
169
|
+
if (slot.active >= settings.perHost) {
|
|
170
|
+
await new Promise((resolve) => slot.waiting.push(resolve));
|
|
171
|
+
} else {
|
|
172
|
+
slot.active += 1;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
return await run();
|
|
176
|
+
} finally {
|
|
177
|
+
const next = slot.waiting.shift();
|
|
178
|
+
if (next) next();
|
|
179
|
+
else {
|
|
180
|
+
slot.active -= 1;
|
|
181
|
+
if (slot.active === 0) hosts.delete(host);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/index.js
CHANGED
|
@@ -7,12 +7,40 @@
|
|
|
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
|
*
|
|
23
|
+
* Same options as fetchJobsDetailed; returns the page as an array.
|
|
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
|
+
*
|
|
30
|
+
* @returns {Promise<Array>} Normalized, filtered job objects
|
|
31
|
+
*/
|
|
32
|
+
export async function fetchJobs(options = {}) {
|
|
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;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fetch jobs from a company's ATS board, with the counts and the boards
|
|
42
|
+
* behind the page.
|
|
43
|
+
*
|
|
16
44
|
* @param {Object} options
|
|
17
45
|
* @param {string} options.company - Company slug or name
|
|
18
46
|
* @param {string} [options.ats] - Specific ATS platform. If omitted, auto-detects.
|
|
@@ -20,12 +48,34 @@ import { applyFilters } from './filters.js';
|
|
|
20
48
|
* @param {string} [options.titleFilter] - Regex matched against title only. Use for role identity ("product manager", "staff engineer").
|
|
21
49
|
* @param {string} [options.filter] - Regex matched across title, department, description. Use for topic/scope.
|
|
22
50
|
* @param {number} [options.postedWithinDays] - Only return jobs posted within N days.
|
|
23
|
-
* @param {string[]} [options.locationIncludes] - Keep jobs
|
|
24
|
-
* @param {string[]} [options.locationExcludes] - Drop jobs
|
|
25
|
-
* @param {
|
|
26
|
-
* @
|
|
51
|
+
* @param {string[]} [options.locationIncludes] - Keep jobs where any listed location contains any of these (case-insensitive).
|
|
52
|
+
* @param {string[]} [options.locationExcludes] - Drop jobs only when every listed location contains one of these (case-insensitive).
|
|
53
|
+
* @param {'newest'|'board'} [options.order='newest'] - 'newest': by postedAt descending, undated last, ties by id. 'board': the adapter's own order.
|
|
54
|
+
* @param {number} [options.offset=0] - Matches to skip after sorting (paging).
|
|
55
|
+
* @param {number} [options.limit=100] - Maximum jobs to return after offset.
|
|
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.
|
|
27
77
|
*/
|
|
28
|
-
export async function
|
|
78
|
+
export async function fetchJobsDetailed({
|
|
29
79
|
company,
|
|
30
80
|
ats,
|
|
31
81
|
config,
|
|
@@ -34,66 +84,135 @@ export async function fetchJobs({
|
|
|
34
84
|
postedWithinDays,
|
|
35
85
|
locationIncludes,
|
|
36
86
|
locationExcludes,
|
|
87
|
+
order = 'newest',
|
|
88
|
+
offset = 0,
|
|
37
89
|
limit = 100,
|
|
38
90
|
} = {}) {
|
|
39
|
-
if (!company) throw new
|
|
91
|
+
if (!company) throw new ArgumentError('company is required');
|
|
92
|
+
compileFilterPatterns({ titleFilter, filter });
|
|
40
93
|
|
|
41
|
-
|
|
42
|
-
const
|
|
94
|
+
const slug = normSlug(company);
|
|
95
|
+
const filters = { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes };
|
|
43
96
|
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
// (
|
|
47
|
-
//
|
|
48
|
-
|
|
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;
|
|
49
104
|
|
|
50
|
-
let jobs;
|
|
51
105
|
if (ats) {
|
|
52
|
-
|
|
53
|
-
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(', ')}`);
|
|
54
107
|
// Explicit ATS: an explicitly passed config wins (the only path that
|
|
55
108
|
// can reach a Workday company not in the registry). With no explicit
|
|
56
|
-
// config,
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
cfg = hit.entry.config;
|
|
67
|
-
companyName = hit.entry.name;
|
|
68
|
-
}
|
|
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 }];
|
|
69
119
|
}
|
|
70
|
-
jobs = await adapter.fetch(fetchSlug, { config: cfg, companyName, filterContext });
|
|
71
120
|
} else {
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
// The registry entry carries the canonical slug (so the adapter is
|
|
75
|
-
// called with the ATS's own casing, e.g. SmartRecruiters "Visa") and
|
|
76
|
-
// 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.
|
|
77
123
|
const hit = await findEntryBySlug(slug);
|
|
78
124
|
if (hit) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
companyName: hit.entry.name,
|
|
82
|
-
filterContext,
|
|
83
|
-
});
|
|
125
|
+
match = 'registry';
|
|
126
|
+
targets = [registryTarget(hit, config)];
|
|
84
127
|
} else {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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;
|
|
93
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);
|
|
94
190
|
}
|
|
191
|
+
for (const board of boards) board.matched = perBoard.get(`${board.ats}|${board.slug}`) || 0;
|
|
95
192
|
|
|
96
|
-
return
|
|
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
|
+
}
|
|
203
|
+
|
|
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
|
+
);
|
|
97
216
|
}
|
|
98
217
|
|
|
99
218
|
/**
|
|
@@ -101,14 +220,16 @@ export async function fetchJobs({
|
|
|
101
220
|
*/
|
|
102
221
|
export async function search({ keyword, location, ats } = {}) {
|
|
103
222
|
// For now, search is registry-based. With SQLite store, this becomes a full-text search.
|
|
104
|
-
if (!keyword) throw new
|
|
223
|
+
if (!keyword) throw new ArgumentError('keyword is required');
|
|
105
224
|
return searchRegistry(keyword);
|
|
106
225
|
}
|
|
107
226
|
|
|
108
227
|
/**
|
|
109
|
-
* 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.
|
|
110
231
|
*/
|
|
111
|
-
export { detectAts } from './registry.js';
|
|
232
|
+
export { detectAts, detectAtsDetailed } from './registry.js';
|
|
112
233
|
|
|
113
234
|
/**
|
|
114
235
|
* Look up which ATS a slug belongs to in the registry (cached, no network).
|
|
@@ -123,6 +244,7 @@ export const registry = {
|
|
|
123
244
|
load: loadRegistry,
|
|
124
245
|
search: searchRegistry,
|
|
125
246
|
detect: detectAts,
|
|
247
|
+
detectDetailed: detectAtsDetailed,
|
|
126
248
|
findAtsBySlug,
|
|
127
249
|
findEntryBySlug,
|
|
128
250
|
getSource: getRegistrySource,
|
|
@@ -134,13 +256,18 @@ export { fetchLever } from './adapters/lever.js';
|
|
|
134
256
|
export { fetchAshby } from './adapters/ashby.js';
|
|
135
257
|
|
|
136
258
|
// Re-export filter logic for reuse (e.g., by the MCP server)
|
|
137
|
-
export { applyFilters } from './filters.js';
|
|
259
|
+
export { applyFilters, applyFiltersDetailed } from './filters.js';
|
|
138
260
|
|
|
139
261
|
// Re-export the list of supported ATS names (e.g. so the MCP layer can report
|
|
140
262
|
// the full set detectAts probes, instead of hardcoding a stale subset).
|
|
141
263
|
export { ATS_NAMES };
|
|
142
264
|
|
|
143
|
-
// Error taxonomy + typed
|
|
144
|
-
// (ats_unreachable / rate_limited)
|
|
145
|
-
//
|
|
146
|
-
|
|
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';
|