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/cli.js CHANGED
@@ -11,38 +11,44 @@
11
11
 
12
12
  import { realpathSync } from 'node:fs';
13
13
  import { fileURLToPath } from 'node:url';
14
+ import { parseArgs } from 'node:util';
14
15
  import { fetchJobs } from './index.js';
15
- import { detectAts, searchRegistry } from './registry.js';
16
+ import { detectAtsDetailed, searchRegistry } from './registry.js';
16
17
 
17
18
  const [,, command, ...args] = process.argv;
18
19
 
19
20
  async function main() {
20
21
  switch (command) {
21
22
  case 'fetch': {
22
- const company = args[0];
23
+ const string = { type: 'string' };
24
+ const { values: flags, positionals } = parseArgs({
25
+ args,
26
+ allowPositionals: true,
27
+ options: {
28
+ ats: string, 'title-filter': string, filter: string, 'posted-within-days': string,
29
+ 'location-include': string, 'location-exclude': string, limit: string,
30
+ 'workday-tenant': string, 'workday-env': string, 'workday-site': string,
31
+ json: { type: 'boolean' },
32
+ },
33
+ });
34
+ const company = positionals[0];
23
35
  if (!company) { console.error('Usage: jd-intel fetch <company> [--ats <platform>] (omit --ats to auto-detect; run "jd-intel" for the platform list)'); process.exit(1); }
24
- const getArg = (flag) => {
25
- const idx = args.indexOf(flag);
26
- return idx >= 0 ? args[idx + 1] : undefined;
27
- };
28
- let ats = getArg('--ats');
29
- const titleFilter = getArg('--title-filter');
30
- const filter = getArg('--filter');
31
- const postedWithinRaw = getArg('--posted-within-days');
32
- const postedWithinDays = postedWithinRaw !== undefined ? Number(postedWithinRaw) : undefined;
33
- const locIncludeRaw = getArg('--location-include');
34
- const locationIncludes = locIncludeRaw ? locIncludeRaw.split(',').map(s => s.trim()).filter(Boolean) : undefined;
35
- const locExcludeRaw = getArg('--location-exclude');
36
- const locationExcludes = locExcludeRaw ? locExcludeRaw.split(',').map(s => s.trim()).filter(Boolean) : undefined;
37
- const limitRaw = getArg('--limit');
38
- const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
36
+ const number = (v) => (v !== undefined ? Number(v) : undefined);
37
+ const list = (v) => (v ? v.split(',').map(s => s.trim()).filter(Boolean) : undefined);
38
+ let ats = flags.ats;
39
+ const titleFilter = flags['title-filter'];
40
+ const filter = flags.filter;
41
+ const postedWithinDays = number(flags['posted-within-days']);
42
+ const locationIncludes = list(flags['location-include']);
43
+ const locationExcludes = list(flags['location-exclude']);
44
+ const limit = number(flags.limit);
39
45
 
40
46
  // Workday is keyed by a {tenant, env, site} triple, not a slug.
41
47
  // Supplying it here makes a Workday board reachable without a
42
48
  // registry entry; presence of the flags infers --ats workday.
43
- const wdTenant = getArg('--workday-tenant');
44
- const wdEnv = getArg('--workday-env');
45
- const wdSite = getArg('--workday-site');
49
+ const wdTenant = flags['workday-tenant'];
50
+ const wdEnv = flags['workday-env'];
51
+ const wdSite = flags['workday-site'];
46
52
  let config;
47
53
  if (wdTenant || wdEnv || wdSite) {
48
54
  if (!wdTenant || !wdEnv || !wdSite) {
@@ -91,8 +97,10 @@ async function main() {
91
97
  const loc = job.location ? ` | ${job.location}` : '';
92
98
  const dept = job.department ? ` [${job.department}]` : '';
93
99
  console.log(` ${job.title}${dept}${loc}${salary}`);
94
- console.log(` ${job.url}`);
95
- if (job.description) {
100
+ console.log(` ${job.url || '(no URL: posting not read)'}`);
101
+ if (job.content?.status === 'missing') {
102
+ console.log(` posting not read: ${job.content.reason}`);
103
+ } else if (job.description) {
96
104
  const preview = job.description.substring(0, 120).replace(/\n/g, ' ');
97
105
  console.log(` ${preview}...`);
98
106
  }
@@ -103,7 +111,7 @@ async function main() {
103
111
  console.log(` ... and ${jobs.length - 20} more. Use --json for full output.`);
104
112
  }
105
113
 
106
- if (args.includes('--json')) {
114
+ if (flags.json) {
107
115
  console.log(JSON.stringify(jobs, null, 2));
108
116
  }
109
117
  break;
@@ -113,13 +121,17 @@ async function main() {
113
121
  const company = args[0];
114
122
  if (!company) { console.error('Usage: jd-intel detect <company>'); process.exit(1); }
115
123
  console.log(`Detecting ATS for ${company}...`);
116
- const results = await detectAts(company);
117
- if (results.length === 0) {
118
- console.log('No ATS board found for this company.');
119
- } else {
120
- for (const r of results) {
121
- console.log(` Found: ${r.ats} (slug: ${r.slug})`);
122
- }
124
+ const { boards, failed } = await detectAtsDetailed(company);
125
+ for (const b of boards) {
126
+ console.log(` Found: ${b.ats} (slug: ${b.slug}, ${b.source === 'registry' ? 'in the registry' : 'live probe'})`);
127
+ }
128
+ for (const f of failed) {
129
+ console.log(` Could not check ${f.ats}: ${f.message}`);
130
+ }
131
+ if (boards.length === 0) {
132
+ console.log(failed.length > 0
133
+ ? 'No ATS board confirmed. At least one check failed, so this is not a definite miss. Retry in a moment.'
134
+ : 'No ATS board found for this company.');
123
135
  }
124
136
  break;
125
137
  }
package/src/errors.js CHANGED
@@ -34,6 +34,20 @@ export class AtsError extends Error {
34
34
  }
35
35
  }
36
36
 
37
+ /**
38
+ * Thrown when a call cannot proceed because of its arguments: a missing
39
+ * company, an unknown ATS name, a regex that does not compile. Carries
40
+ * `code: 'invalid_args'` so callers tell a bad request from a failed fetch
41
+ * (AtsError) without reading the message.
42
+ */
43
+ export class ArgumentError extends Error {
44
+ constructor(message) {
45
+ super(message);
46
+ this.name = 'ArgumentError';
47
+ this.code = ERROR_CODES.INVALID_ARGS;
48
+ }
49
+ }
50
+
37
51
  /**
38
52
  * Helper for adapters: build an AtsError from an HTTP status (429 => rate
39
53
  * limited, anything else => unreachable) with the given message.
package/src/filters.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { ArgumentError } from './errors.js';
2
+
1
3
  /**
2
4
  * Apply filters to a list of normalized jobs.
3
5
  *
@@ -23,30 +25,32 @@ export function applyFilters(jobs, options = {}) {
23
25
  * @returns {{ jobs: Array, total_matched: number }}
24
26
  */
25
27
  export function applyFiltersDetailed(jobs, options = {}) {
26
- const {
27
- titleFilter,
28
- filter,
29
- postedWithinDays,
30
- locationIncludes,
31
- locationExcludes,
32
- order = 'newest',
33
- offset = 0,
34
- limit = 100,
35
- } = options;
28
+ const { order = 'newest', offset = 0, limit = 100 } = options;
29
+ const matched = filterJobs(jobs, options);
30
+ return { jobs: pageJobs(matched, { order, offset, limit }), total_matched: matched.length };
31
+ }
32
+
33
+ /**
34
+ * The filter step on its own: every job that passes titleFilter, filter,
35
+ * postedWithinDays and the location filters, in the order given. No sort
36
+ * and no paging, so fetchJobsDetailed can count the matches per board
37
+ * before the page cut removes them.
38
+ */
39
+ export function filterJobs(jobs, options = {}) {
40
+ const { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes } = options;
41
+ const { title, topic } = compileFilterPatterns({ titleFilter, filter });
36
42
 
37
43
  let result = jobs;
38
44
 
39
- if (titleFilter) {
40
- const pattern = new RegExp(titleFilter, 'i');
41
- result = result.filter(j => pattern.test(j.title || ''));
45
+ if (title) {
46
+ result = result.filter(j => title.test(j.title || ''));
42
47
  }
43
48
 
44
- if (filter) {
45
- const pattern = new RegExp(filter, 'i');
49
+ if (topic) {
46
50
  result = result.filter(j =>
47
- pattern.test(j.title || '') ||
48
- pattern.test(j.department || '') ||
49
- pattern.test(j.description || '')
51
+ topic.test(j.title || '') ||
52
+ topic.test(j.department || '') ||
53
+ topic.test(j.description || '')
50
54
  );
51
55
  }
52
56
 
@@ -69,7 +73,14 @@ export function applyFiltersDetailed(jobs, options = {}) {
69
73
  result = result.filter(j => !jobLocations(j).every(loc => matchers.some(m => m(loc))));
70
74
  }
71
75
 
72
- const total_matched = result.length;
76
+ return result;
77
+ }
78
+
79
+ /**
80
+ * Sort, then cut the page (see applyFiltersDetailed for the order rules).
81
+ */
82
+ export function pageJobs(jobs, { order = 'newest', offset = 0, limit = 100 } = {}) {
83
+ let result = jobs;
73
84
 
74
85
  if (order !== 'board') {
75
86
  result = [...result].sort(byNewest);
@@ -77,11 +88,30 @@ export function applyFiltersDetailed(jobs, options = {}) {
77
88
 
78
89
  const start = typeof offset === 'number' && offset > 0 ? offset : 0;
79
90
  const end = typeof limit === 'number' ? start + limit : undefined;
80
- if (start > 0 || (end !== undefined && result.length > end)) {
81
- result = result.slice(start, end);
82
- }
91
+ return result.slice(start, end);
92
+ }
93
+
94
+ /**
95
+ * Compile the two regex arguments, or throw ArgumentError naming the one
96
+ * that does not compile. Both are case-insensitive. fetchJobsDetailed calls
97
+ * this before its first request, so a bad pattern is reported as a bad
98
+ * argument and costs no upstream traffic.
99
+ *
100
+ * @returns {{ title: RegExp|null, topic: RegExp|null }}
101
+ */
102
+ export function compileFilterPatterns({ titleFilter, filter } = {}) {
103
+ return {
104
+ title: titleFilter ? compilePattern(titleFilter, 'titleFilter') : null,
105
+ topic: filter ? compilePattern(filter, 'filter') : null,
106
+ };
107
+ }
83
108
 
84
- return { jobs: result, total_matched };
109
+ function compilePattern(source, name) {
110
+ try {
111
+ return new RegExp(source, 'i');
112
+ } catch (err) {
113
+ throw new ArgumentError(`${name}: ${err.message}`);
114
+ }
85
115
  }
86
116
 
87
117
  /**
@@ -118,14 +148,19 @@ function byNewest(a, b) {
118
148
  }
119
149
 
120
150
  /**
121
- * Build a matcher for a single location keyword.
151
+ * Build a matcher for a single location keyword. The matcher takes a
152
+ * lowercased location string.
122
153
  *
123
154
  * Short tokens (≤4 chars) use word-boundary matching to prevent substring
124
155
  * collisions like "US" matching "Australia", "Brussels", "Belarus", or "UK"
125
- * matching "Auckland". Longer tokens use substring matching so phrases like
156
+ * matching "Ukraine". Longer tokens use substring matching so phrases like
126
157
  * "United States" can match "United States of America".
158
+ *
159
+ * Exported for the Workday list pre-filter, so one rule (trim, empty
160
+ * keywords never match, word boundaries for short tokens) applies before
161
+ * and after detail hydration (issue #61).
127
162
  */
128
- function makeLocationMatcher(needle) {
163
+ export function makeLocationMatcher(needle) {
129
164
  const lower = (needle || '').toLowerCase().trim();
130
165
  if (!lower) return () => false;
131
166
  if (lower.length <= 4) {
@@ -135,3 +170,51 @@ function makeLocationMatcher(needle) {
135
170
  }
136
171
  return (loc) => loc.includes(lower);
137
172
  }
173
+
174
+ /**
175
+ * The list pre-filter and detail budget the two-step adapters (Workday,
176
+ * SmartRecruiters) share: narrow the cheap list rows with the filters a row
177
+ * can answer, then bound how many get a detail request.
178
+ *
179
+ * The library re-applies every filter after hydration, so a keep here is
180
+ * never final. `location(row)` returns the lowercased location, or null
181
+ * when the row cannot say where it is (it then stays a candidate through
182
+ * both location filters). `postedWithin(row, days)` decides recency.
183
+ *
184
+ * A description `filter` runs only after hydration, so that case keeps the
185
+ * full `max` budget instead of truncating to the page (which could hydrate
186
+ * rows that all fail the regex while better matches go unscanned). Without
187
+ * one, the budget is the page plus the offset before it. List order is kept.
188
+ *
189
+ * @returns {{ candidates: Array, hydrate: Array }}
190
+ */
191
+ export function prefilterRows(rows, fc, { title, location, postedWithin, max }) {
192
+ let candidates = rows;
193
+
194
+ if (fc.titleFilter) {
195
+ const re = new RegExp(fc.titleFilter, 'i');
196
+ candidates = candidates.filter(p => re.test(title(p)));
197
+ }
198
+ if (Array.isArray(fc.locationIncludes) && fc.locationIncludes.length > 0) {
199
+ const inc = fc.locationIncludes.map(makeLocationMatcher);
200
+ candidates = candidates.filter(p => {
201
+ const loc = location(p);
202
+ return loc === null || inc.some(m => m(loc));
203
+ });
204
+ }
205
+ if (Array.isArray(fc.locationExcludes) && fc.locationExcludes.length > 0) {
206
+ const exc = fc.locationExcludes.map(makeLocationMatcher);
207
+ candidates = candidates.filter(p => {
208
+ const loc = location(p);
209
+ return loc === null || !exc.some(m => m(loc));
210
+ });
211
+ }
212
+ if (typeof fc.postedWithinDays === 'number') {
213
+ candidates = candidates.filter(p => postedWithin(p, fc.postedWithinDays));
214
+ }
215
+
216
+ const limit = typeof fc.limit === 'number' && fc.limit > 0 ? fc.limit : 100;
217
+ const skip = typeof fc.offset === 'number' && fc.offset > 0 ? fc.offset : 0;
218
+ const cap = fc.filter ? max : Math.min(skip + limit, max);
219
+ return { candidates, hydrate: candidates.slice(0, cap) };
220
+ }
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
+ }