jd-intel 0.7.0 → 0.8.1
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 +52 -39
- package/package.json +60 -57
- package/registry/ashby.json +49 -49
- package/registry/greenhouse.json +131 -131
- package/registry/lever.json +32 -32
- package/registry/recruitee.json +25 -25
- package/registry/smartrecruiters.json +30 -30
- package/registry/teamtailor.json +33 -33
- package/registry/workday.json +29 -29
- package/src/adapters/ashby.js +2 -1
- package/src/adapters/greenhouse.js +2 -1
- package/src/adapters/lever.js +2 -1
- package/src/adapters/recruitee.js +2 -1
- package/src/adapters/smartrecruiters.js +8 -2
- package/src/adapters/teamtailor.js +2 -1
- package/src/adapters/workday.js +2 -1
- package/src/cli.js +190 -190
- package/src/errors.js +38 -0
- package/src/index.js +146 -136
- package/src/registry.js +164 -114
package/src/index.js
CHANGED
|
@@ -1,136 +1,146 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* jd-intel — JD intelligence toolkit: fetch, normalize, and search job descriptions across every major ATS.
|
|
3
|
-
*
|
|
4
|
-
* Fetches, normalizes, and enriches job data from public ATS APIs
|
|
5
|
-
* (Greenhouse, Lever, Ashby, SmartRecruiters, Teamtailor, Recruitee,
|
|
6
|
-
* Workday) into a unified schema.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { ADAPTERS, ATS_NAMES } from './adapters/index.js';
|
|
10
|
-
import { loadRegistry, searchRegistry, detectAts, findAtsBySlug, findEntryBySlug } from './registry.js';
|
|
11
|
-
import { applyFilters } from './filters.js';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Fetch jobs from a company's ATS board.
|
|
15
|
-
*
|
|
16
|
-
* @param {Object} options
|
|
17
|
-
* @param {string} options.company - Company slug or name
|
|
18
|
-
* @param {string} [options.ats] - Specific ATS platform. If omitted, auto-detects.
|
|
19
|
-
* @param {object} [options.config] - Adapter-specific config (e.g. Workday {tenant, env, site}). Bypasses the registry; the only way to reach a Workday company not in the registry.
|
|
20
|
-
* @param {string} [options.titleFilter] - Regex matched against title only. Use for role identity ("product manager", "staff engineer").
|
|
21
|
-
* @param {string} [options.filter] - Regex matched across title, department, description. Use for topic/scope.
|
|
22
|
-
* @param {number} [options.postedWithinDays] - Only return jobs posted within N days.
|
|
23
|
-
* @param {string[]} [options.locationIncludes] - Keep jobs whose location contains any of these (case-insensitive).
|
|
24
|
-
* @param {string[]} [options.locationExcludes] - Drop jobs whose location contains any of these (case-insensitive).
|
|
25
|
-
* @param {number} [options.limit=100] - Maximum jobs to return after filtering.
|
|
26
|
-
* @returns {Promise<Array>} Normalized, filtered job objects
|
|
27
|
-
*/
|
|
28
|
-
export async function fetchJobs({
|
|
29
|
-
company,
|
|
30
|
-
ats,
|
|
31
|
-
config,
|
|
32
|
-
titleFilter,
|
|
33
|
-
filter,
|
|
34
|
-
postedWithinDays,
|
|
35
|
-
locationIncludes,
|
|
36
|
-
locationExcludes,
|
|
37
|
-
limit = 100,
|
|
38
|
-
} = {}) {
|
|
39
|
-
if (!company) throw new Error('Company slug required');
|
|
40
|
-
|
|
41
|
-
// Unified slug normalization: strip all non-alphanumeric (matches detectAts)
|
|
42
|
-
const slug = company.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
43
|
-
|
|
44
|
-
// Filter context is passed as an additive 2nd arg to adapters. Existing
|
|
45
|
-
// adapters declare fetch{Name}(slug) and ignore extra positional args
|
|
46
|
-
// (JS no-op), so this is backward-compatible. Filter-aware adapters
|
|
47
|
-
// (e.g. Workday) use it to avoid mass detail-fetching on huge tenants.
|
|
48
|
-
const filterContext = { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, limit };
|
|
49
|
-
|
|
50
|
-
let jobs;
|
|
51
|
-
if (ats) {
|
|
52
|
-
const adapter = ADAPTERS[ats];
|
|
53
|
-
if (!adapter) throw new Error(`Unknown ATS: ${ats}. Supported: ${ATS_NAMES.join(', ')}`);
|
|
54
|
-
// Explicit ATS: an explicitly passed config wins (the only path that
|
|
55
|
-
// can reach a Workday company not in the registry). With no explicit
|
|
56
|
-
// config, fall back to the registry so config-keyed adapters
|
|
57
|
-
// (Workday) and canonically-cased registry slugs (SmartRecruiters
|
|
58
|
-
// "Visa") also work on the explicit path, not just under auto-detect.
|
|
59
|
-
let fetchSlug = slug;
|
|
60
|
-
let cfg = config;
|
|
61
|
-
let companyName;
|
|
62
|
-
if (!cfg) {
|
|
63
|
-
const hit = await findEntryBySlug(slug);
|
|
64
|
-
if (hit && hit.ats === ats) {
|
|
65
|
-
fetchSlug = hit.entry.slug;
|
|
66
|
-
cfg = hit.entry.config;
|
|
67
|
-
companyName = hit.entry.name;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
jobs = await adapter.fetch(fetchSlug, { config: cfg, companyName, filterContext });
|
|
71
|
-
} else {
|
|
72
|
-
// Consult registry first — if we know which ATS this company uses,
|
|
73
|
-
// skip probing the others (saves API calls, clearer error semantics).
|
|
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).
|
|
77
|
-
const hit = await findEntryBySlug(slug);
|
|
78
|
-
if (hit) {
|
|
79
|
-
jobs = await ADAPTERS[hit.ats].fetch(hit.entry.slug, {
|
|
80
|
-
config: config || hit.entry.config,
|
|
81
|
-
companyName: hit.entry.name,
|
|
82
|
-
filterContext,
|
|
83
|
-
});
|
|
84
|
-
} else {
|
|
85
|
-
// Discovery mode: company not in registry, probe all adapters.
|
|
86
|
-
// (Registry-only adapters like Workday bail here via their guard.)
|
|
87
|
-
const results = await Promise.allSettled(
|
|
88
|
-
Object.entries(ADAPTERS).map(async ([name, adapter]) => adapter.fetch(slug, { filterContext }))
|
|
89
|
-
);
|
|
90
|
-
jobs = results
|
|
91
|
-
.filter(r => r.status === 'fulfilled')
|
|
92
|
-
.flatMap(r => r.value);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return applyFilters(jobs, { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, limit });
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Search for companies in the registry.
|
|
101
|
-
*/
|
|
102
|
-
export async function search({ keyword, location, ats } = {}) {
|
|
103
|
-
// For now, search is registry-based. With SQLite store, this becomes a full-text search.
|
|
104
|
-
if (!keyword) throw new Error('Keyword required');
|
|
105
|
-
return searchRegistry(keyword);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Detect which ATS platform a company uses (probes each adapter).
|
|
110
|
-
*/
|
|
111
|
-
export { detectAts } from './registry.js';
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Look up which ATS a slug belongs to in the registry (cached, no network).
|
|
115
|
-
* Returns the ATS name (e.g. "greenhouse", "workday") or null if not in registry.
|
|
116
|
-
*/
|
|
117
|
-
export { findAtsBySlug } from './registry.js';
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Registry management.
|
|
121
|
-
*/
|
|
122
|
-
export const registry = {
|
|
123
|
-
load: loadRegistry,
|
|
124
|
-
search: searchRegistry,
|
|
125
|
-
detect: detectAts,
|
|
126
|
-
findAtsBySlug,
|
|
127
|
-
findEntryBySlug,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
export
|
|
132
|
-
export {
|
|
133
|
-
export {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
export
|
|
1
|
+
/**
|
|
2
|
+
* jd-intel — JD intelligence toolkit: fetch, normalize, and search job descriptions across every major ATS.
|
|
3
|
+
*
|
|
4
|
+
* Fetches, normalizes, and enriches job data from public ATS APIs
|
|
5
|
+
* (Greenhouse, Lever, Ashby, SmartRecruiters, Teamtailor, Recruitee,
|
|
6
|
+
* Workday) into a unified schema.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ADAPTERS, ATS_NAMES } from './adapters/index.js';
|
|
10
|
+
import { loadRegistry, searchRegistry, detectAts, findAtsBySlug, findEntryBySlug, getRegistrySource } from './registry.js';
|
|
11
|
+
import { applyFilters } from './filters.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fetch jobs from a company's ATS board.
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} options
|
|
17
|
+
* @param {string} options.company - Company slug or name
|
|
18
|
+
* @param {string} [options.ats] - Specific ATS platform. If omitted, auto-detects.
|
|
19
|
+
* @param {object} [options.config] - Adapter-specific config (e.g. Workday {tenant, env, site}). Bypasses the registry; the only way to reach a Workday company not in the registry.
|
|
20
|
+
* @param {string} [options.titleFilter] - Regex matched against title only. Use for role identity ("product manager", "staff engineer").
|
|
21
|
+
* @param {string} [options.filter] - Regex matched across title, department, description. Use for topic/scope.
|
|
22
|
+
* @param {number} [options.postedWithinDays] - Only return jobs posted within N days.
|
|
23
|
+
* @param {string[]} [options.locationIncludes] - Keep jobs whose location contains any of these (case-insensitive).
|
|
24
|
+
* @param {string[]} [options.locationExcludes] - Drop jobs whose location contains any of these (case-insensitive).
|
|
25
|
+
* @param {number} [options.limit=100] - Maximum jobs to return after filtering.
|
|
26
|
+
* @returns {Promise<Array>} Normalized, filtered job objects
|
|
27
|
+
*/
|
|
28
|
+
export async function fetchJobs({
|
|
29
|
+
company,
|
|
30
|
+
ats,
|
|
31
|
+
config,
|
|
32
|
+
titleFilter,
|
|
33
|
+
filter,
|
|
34
|
+
postedWithinDays,
|
|
35
|
+
locationIncludes,
|
|
36
|
+
locationExcludes,
|
|
37
|
+
limit = 100,
|
|
38
|
+
} = {}) {
|
|
39
|
+
if (!company) throw new Error('Company slug required');
|
|
40
|
+
|
|
41
|
+
// Unified slug normalization: strip all non-alphanumeric (matches detectAts)
|
|
42
|
+
const slug = company.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
43
|
+
|
|
44
|
+
// Filter context is passed as an additive 2nd arg to adapters. Existing
|
|
45
|
+
// adapters declare fetch{Name}(slug) and ignore extra positional args
|
|
46
|
+
// (JS no-op), so this is backward-compatible. Filter-aware adapters
|
|
47
|
+
// (e.g. Workday) use it to avoid mass detail-fetching on huge tenants.
|
|
48
|
+
const filterContext = { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, limit };
|
|
49
|
+
|
|
50
|
+
let jobs;
|
|
51
|
+
if (ats) {
|
|
52
|
+
const adapter = ADAPTERS[ats];
|
|
53
|
+
if (!adapter) throw new Error(`Unknown ATS: ${ats}. Supported: ${ATS_NAMES.join(', ')}`);
|
|
54
|
+
// Explicit ATS: an explicitly passed config wins (the only path that
|
|
55
|
+
// can reach a Workday company not in the registry). With no explicit
|
|
56
|
+
// config, fall back to the registry so config-keyed adapters
|
|
57
|
+
// (Workday) and canonically-cased registry slugs (SmartRecruiters
|
|
58
|
+
// "Visa") also work on the explicit path, not just under auto-detect.
|
|
59
|
+
let fetchSlug = slug;
|
|
60
|
+
let cfg = config;
|
|
61
|
+
let companyName;
|
|
62
|
+
if (!cfg) {
|
|
63
|
+
const hit = await findEntryBySlug(slug);
|
|
64
|
+
if (hit && hit.ats === ats) {
|
|
65
|
+
fetchSlug = hit.entry.slug;
|
|
66
|
+
cfg = hit.entry.config;
|
|
67
|
+
companyName = hit.entry.name;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
jobs = await adapter.fetch(fetchSlug, { config: cfg, companyName, filterContext });
|
|
71
|
+
} else {
|
|
72
|
+
// Consult registry first — if we know which ATS this company uses,
|
|
73
|
+
// skip probing the others (saves API calls, clearer error semantics).
|
|
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).
|
|
77
|
+
const hit = await findEntryBySlug(slug);
|
|
78
|
+
if (hit) {
|
|
79
|
+
jobs = await ADAPTERS[hit.ats].fetch(hit.entry.slug, {
|
|
80
|
+
config: config || hit.entry.config,
|
|
81
|
+
companyName: hit.entry.name,
|
|
82
|
+
filterContext,
|
|
83
|
+
});
|
|
84
|
+
} else {
|
|
85
|
+
// Discovery mode: company not in registry, probe all adapters.
|
|
86
|
+
// (Registry-only adapters like Workday bail here via their guard.)
|
|
87
|
+
const results = await Promise.allSettled(
|
|
88
|
+
Object.entries(ADAPTERS).map(async ([name, adapter]) => adapter.fetch(slug, { filterContext }))
|
|
89
|
+
);
|
|
90
|
+
jobs = results
|
|
91
|
+
.filter(r => r.status === 'fulfilled')
|
|
92
|
+
.flatMap(r => r.value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return applyFilters(jobs, { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, limit });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Search for companies in the registry.
|
|
101
|
+
*/
|
|
102
|
+
export async function search({ keyword, location, ats } = {}) {
|
|
103
|
+
// For now, search is registry-based. With SQLite store, this becomes a full-text search.
|
|
104
|
+
if (!keyword) throw new Error('Keyword required');
|
|
105
|
+
return searchRegistry(keyword);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Detect which ATS platform a company uses (probes each adapter).
|
|
110
|
+
*/
|
|
111
|
+
export { detectAts } from './registry.js';
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Look up which ATS a slug belongs to in the registry (cached, no network).
|
|
115
|
+
* Returns the ATS name (e.g. "greenhouse", "workday") or null if not in registry.
|
|
116
|
+
*/
|
|
117
|
+
export { findAtsBySlug } from './registry.js';
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Registry management.
|
|
121
|
+
*/
|
|
122
|
+
export const registry = {
|
|
123
|
+
load: loadRegistry,
|
|
124
|
+
search: searchRegistry,
|
|
125
|
+
detect: detectAts,
|
|
126
|
+
findAtsBySlug,
|
|
127
|
+
findEntryBySlug,
|
|
128
|
+
getSource: getRegistrySource,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Re-export individual adapters for direct use
|
|
132
|
+
export { fetchGreenhouse } from './adapters/greenhouse.js';
|
|
133
|
+
export { fetchLever } from './adapters/lever.js';
|
|
134
|
+
export { fetchAshby } from './adapters/ashby.js';
|
|
135
|
+
|
|
136
|
+
// Re-export filter logic for reuse (e.g., by the MCP server)
|
|
137
|
+
export { applyFilters } from './filters.js';
|
|
138
|
+
|
|
139
|
+
// Re-export the list of supported ATS names (e.g. so the MCP layer can report
|
|
140
|
+
// the full set detectAts probes, instead of hardcoding a stale subset).
|
|
141
|
+
export { ATS_NAMES };
|
|
142
|
+
|
|
143
|
+
// Error taxonomy + typed error. Adapters throw AtsError with a stable .code
|
|
144
|
+
// (ats_unreachable / rate_limited) so the MCP layer maps failures without
|
|
145
|
+
// parsing messages. ERROR_CODES is the single source of truth for both.
|
|
146
|
+
export { ERROR_CODES, AtsError } from './errors.js';
|
package/src/registry.js
CHANGED
|
@@ -1,114 +1,164 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { join, dirname } from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
|
|
5
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
-
const REGISTRY_DIR = join(__dirname, '..', 'registry');
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const REGISTRY_DIR = join(__dirname, '..', 'registry');
|
|
7
|
+
|
|
8
|
+
const PLATFORMS = ['greenhouse', 'lever', 'ashby', 'smartrecruiters', 'teamtailor', 'recruitee', 'workday'];
|
|
9
|
+
|
|
10
|
+
// Network-first registry. A hosted copy lets installed bundles AND npx users
|
|
11
|
+
// pick up newly-added companies without reinstalling; the on-disk copy that
|
|
12
|
+
// ships with the package is the guaranteed offline fallback. The base URL is
|
|
13
|
+
// resolved at call time so it stays overridable: point JD_INTEL_REGISTRY_URL
|
|
14
|
+
// at a different host, or set it to '' to force disk-only (tests, air-gapped).
|
|
15
|
+
const DEFAULT_REGISTRY_URL = 'https://prpmdev.github.io/jd-intel/registry';
|
|
16
|
+
const FETCH_TIMEOUT_MS = 2500;
|
|
17
|
+
|
|
18
|
+
function registryBaseUrl() {
|
|
19
|
+
return process.env.JD_INTEL_REGISTRY_URL !== undefined
|
|
20
|
+
? process.env.JD_INTEL_REGISTRY_URL
|
|
21
|
+
: DEFAULT_REGISTRY_URL;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let cache = {};
|
|
25
|
+
let sources = {}; // platform -> 'network' | 'disk-fallback'
|
|
26
|
+
|
|
27
|
+
async function fetchPlatform(platform) {
|
|
28
|
+
const base = registryBaseUrl();
|
|
29
|
+
if (!base) throw new Error('registry network disabled');
|
|
30
|
+
const res = await fetch(`${base}/${platform}.json`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
31
|
+
if (!res.ok) throw new Error(`registry fetch ${platform}: HTTP ${res.status}`);
|
|
32
|
+
const data = await res.json();
|
|
33
|
+
if (!Array.isArray(data)) throw new Error(`registry fetch ${platform}: not an array`);
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readPlatform(platform) {
|
|
38
|
+
const data = await readFile(join(REGISTRY_DIR, `${platform}.json`), 'utf-8');
|
|
39
|
+
return JSON.parse(data);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Load one platform: hosted copy first, on-disk fallback on ANY failure
|
|
43
|
+
// (offline, non-200, timeout, malformed). Cached per process after first load.
|
|
44
|
+
async function loadPlatform(platform) {
|
|
45
|
+
if (cache[platform]) return cache[platform];
|
|
46
|
+
try {
|
|
47
|
+
cache[platform] = await fetchPlatform(platform);
|
|
48
|
+
sources[platform] = 'network';
|
|
49
|
+
} catch {
|
|
50
|
+
try {
|
|
51
|
+
cache[platform] = await readPlatform(platform);
|
|
52
|
+
} catch {
|
|
53
|
+
cache[platform] = [];
|
|
54
|
+
}
|
|
55
|
+
sources[platform] = 'disk-fallback';
|
|
56
|
+
}
|
|
57
|
+
return cache[platform];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Load company registry for a specific ATS or all ATS platforms.
|
|
62
|
+
* Network-first with on-disk fallback (see registryBaseUrl).
|
|
63
|
+
*/
|
|
64
|
+
export async function loadRegistry(ats) {
|
|
65
|
+
if (ats) return loadPlatform(ats);
|
|
66
|
+
const all = {};
|
|
67
|
+
await Promise.all(PLATFORMS.map(async (platform) => {
|
|
68
|
+
all[platform] = await loadPlatform(platform);
|
|
69
|
+
}));
|
|
70
|
+
return all;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Where the registry data loaded this process came from:
|
|
75
|
+
* 'network' every loaded platform came from the hosted copy
|
|
76
|
+
* 'disk-fallback' every loaded platform fell back to the bundled copy
|
|
77
|
+
* 'mixed' some of each
|
|
78
|
+
* 'unknown' nothing loaded yet
|
|
79
|
+
* Surfaced in MCP response metadata so the AI can tell the user whether the
|
|
80
|
+
* company list is live or the bundled snapshot.
|
|
81
|
+
*/
|
|
82
|
+
export function getRegistrySource() {
|
|
83
|
+
const vals = Object.values(sources);
|
|
84
|
+
if (vals.length === 0) return 'unknown';
|
|
85
|
+
if (vals.every((v) => v === 'network')) return 'network';
|
|
86
|
+
if (vals.every((v) => v === 'disk-fallback')) return 'disk-fallback';
|
|
87
|
+
return 'mixed';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Search registry for companies matching a query.
|
|
92
|
+
*/
|
|
93
|
+
export async function searchRegistry(query) {
|
|
94
|
+
const all = await loadRegistry();
|
|
95
|
+
const lower = query.toLowerCase();
|
|
96
|
+
const results = [];
|
|
97
|
+
|
|
98
|
+
for (const [ats, companies] of Object.entries(all)) {
|
|
99
|
+
for (const company of companies) {
|
|
100
|
+
const name = (company.name || company.slug || '').toLowerCase();
|
|
101
|
+
const sector = (company.sector || '').toLowerCase();
|
|
102
|
+
if (name.includes(lower) || sector.includes(lower)) {
|
|
103
|
+
results.push({ ...company, ats });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return results;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Slug match is case/punctuation-insensitive: registry slugs are stored
|
|
112
|
+
// in each ATS's canonical form (SmartRecruiters uses PascalCase, e.g.
|
|
113
|
+
// "Visa"), but callers pass a lowercased/alnum-stripped slug. Comparing
|
|
114
|
+
// normalized forms keeps registry-first routing working for those.
|
|
115
|
+
const normSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Look up which ATS a slug belongs to in the registry.
|
|
119
|
+
* Returns the ATS name (e.g., "greenhouse") or null if not in registry.
|
|
120
|
+
*/
|
|
121
|
+
export async function findAtsBySlug(slug) {
|
|
122
|
+
const all = await loadRegistry();
|
|
123
|
+
const key = normSlug(slug);
|
|
124
|
+
for (const [ats, companies] of Object.entries(all)) {
|
|
125
|
+
if (companies.some(c => normSlug(c.slug) === key)) return ats;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Look up the full registry entry for a slug, with its ATS.
|
|
132
|
+
* Unlike findAtsBySlug (returns just the ats name), this returns the
|
|
133
|
+
* whole entry so callers can read adapter-specific config (e.g. the
|
|
134
|
+
* Workday {tenant, env, site} triple). Additive — does not change
|
|
135
|
+
* findAtsBySlug, which has other callers.
|
|
136
|
+
*
|
|
137
|
+
* @returns {Promise<{ats: string, entry: object}|null>}
|
|
138
|
+
*/
|
|
139
|
+
export async function findEntryBySlug(slug) {
|
|
140
|
+
const all = await loadRegistry();
|
|
141
|
+
const key = normSlug(slug);
|
|
142
|
+
for (const [ats, companies] of Object.entries(all)) {
|
|
143
|
+
const entry = companies.find(c => normSlug(c.slug) === key);
|
|
144
|
+
if (entry) return { ats, entry };
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Auto-detect which ATS a company uses.
|
|
151
|
+
*/
|
|
152
|
+
export async function detectAts(companyName) {
|
|
153
|
+
const { ADAPTERS } = await import('./adapters/index.js');
|
|
154
|
+
const slug = companyName.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
155
|
+
|
|
156
|
+
const results = [];
|
|
157
|
+
const checks = Object.entries(ADAPTERS).map(async ([ats, adapter]) => {
|
|
158
|
+
const found = await adapter.has(slug);
|
|
159
|
+
if (found) results.push({ ats, slug });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await Promise.allSettled(checks);
|
|
163
|
+
return results;
|
|
164
|
+
}
|