Skip to content

Commit f470b26

Browse files
committed
feat: Workday tenant passthrough + case-insensitive registry routing
- CLI --workday-tenant/-env/-site flags; infers ats=workday, fail-fast on incomplete triple, clear bad-triple error, --help URL hint - fetchJobs: optional config param; explicit --ats branch consults registry for config + canonical slug (fixes explicit --ats workday returning [] and SmartRecruiters registry-miss routing) - registry: case-insensitive slug match, returns canonical casing (fixes findAtsBySlug null for PascalCase SmartRecruiters slugs) - tests: +6 (registry case-insensitive, fetchJobs passthrough/ precedence/canonical routing); 130/130 green - bump 0.6.0 (root only; MCP parity deferred to fast-follow)
1 parent 0d52304 commit f470b26

6 files changed

Lines changed: 195 additions & 16 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "jd-intel",
3-
"version": "0.5.0",
3+
"version": "0.6.0",
44
"description": "Fetch and normalize job descriptions across every major ATS (Greenhouse, Lever, Ashby, Workday, and more) — for your AI assistant, no copy-paste.",
55
"type": "module",
66
"main": "src/index.js",

src/cli.js

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ async function main() {
2323
const idx = args.indexOf(flag);
2424
return idx >= 0 ? args[idx + 1] : undefined;
2525
};
26-
const ats = getArg('--ats');
26+
let ats = getArg('--ats');
2727
const titleFilter = getArg('--title-filter');
2828
const filter = getArg('--filter');
2929
const postedWithinRaw = getArg('--posted-within-days');
@@ -35,6 +35,28 @@ async function main() {
3535
const limitRaw = getArg('--limit');
3636
const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
3737

38+
// Workday is keyed by a {tenant, env, site} triple, not a slug.
39+
// Supplying it here makes a Workday board reachable without a
40+
// registry entry; presence of the flags infers --ats workday.
41+
const wdTenant = getArg('--workday-tenant');
42+
const wdEnv = getArg('--workday-env');
43+
const wdSite = getArg('--workday-site');
44+
let config;
45+
if (wdTenant || wdEnv || wdSite) {
46+
if (!wdTenant || !wdEnv || !wdSite) {
47+
console.error('Workday needs all three: --workday-tenant, --workday-env, --workday-site.');
48+
console.error('Find them in the careers URL: https://{tenant}.{env}.myworkdayjobs.com/{site}');
49+
console.error('e.g. https://expedia.wd108.myworkdayjobs.com/search -> --workday-tenant expedia --workday-env wd108 --workday-site search');
50+
process.exit(1);
51+
}
52+
if (ats && ats !== 'workday') {
53+
console.error(`--ats ${ats} conflicts with the --workday-* flags (workday is inferred). Drop one.`);
54+
process.exit(1);
55+
}
56+
config = { tenant: wdTenant, env: wdEnv, site: wdSite };
57+
ats = 'workday';
58+
}
59+
3860
const parts = [];
3961
if (titleFilter) parts.push(`title: ${titleFilter}`);
4062
if (filter) parts.push(`topic: ${filter}`);
@@ -43,10 +65,23 @@ async function main() {
4365
if (locationExcludes) parts.push(`loc-: ${locationExcludes.join('|')}`);
4466
const suffix = parts.length ? ` [${parts.join(', ')}]` : '';
4567

46-
console.log(`Fetching jobs from ${company}${ats ? ` (${ats})` : ' (auto-detect)'}${suffix}...`);
47-
const jobs = await fetchJobs({
48-
company, ats, titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, limit,
49-
});
68+
const atsLabel = config
69+
? ` (workday: ${config.tenant}/${config.env}/${config.site})`
70+
: ats ? ` (${ats})` : ' (auto-detect)';
71+
console.log(`Fetching jobs from ${company}${atsLabel}${suffix}...`);
72+
let jobs;
73+
try {
74+
jobs = await fetchJobs({
75+
company, ats, config, titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, limit,
76+
});
77+
} catch (err) {
78+
if (config) {
79+
console.error(`Could not reach that Workday board (${config.tenant}/${config.env}/${config.site}): ${err.message}`);
80+
console.error('Verify the triple against the careers URL: https://{tenant}.{env}.myworkdayjobs.com/{site}');
81+
process.exit(1);
82+
}
83+
throw err;
84+
}
5085
console.log(`Found ${jobs.length} jobs\n`);
5186

5287
for (const job of jobs.slice(0, 20)) {
@@ -115,9 +150,15 @@ Fetch options:
115150
--ats <platform> Skip auto-detect. One of: greenhouse, lever,
116151
ashby, smartrecruiters, teamtailor, recruitee,
117152
workday. Omit to auto-detect (registry-backed).
118-
Workday is registry-only: fetch it by company
119-
slug and let auto-detect route it, not via
120-
--ats workday.
153+
--workday-tenant T Workday is keyed by a {tenant, env, site}
154+
--workday-env wdN triple, not a slug. Registered Workday
155+
--workday-site S companies work via auto-detect or --ats
156+
workday; for any other Workday board pass
157+
all three, read from the careers URL
158+
https://{tenant}.{env}.myworkdayjobs.com/{site}
159+
e.g. https://expedia.wd108.myworkdayjobs.com/search
160+
-> --workday-tenant expedia --workday-env wd108
161+
--workday-site search
121162
--title-filter pattern Regex matched against TITLE only (role identity)
122163
--filter pattern Regex matched across title, department, description (topic/scope)
123164
--posted-within-days N Only jobs posted in the last N days
@@ -137,6 +178,7 @@ Examples:
137178
jd-intel fetch stripe --title-filter "product manager" --filter "growth|platform"
138179
jd-intel fetch ramp --location-include "United States,US,Remote - US" --location-exclude "London,Dublin"
139180
jd-intel fetch notion --ats ashby --title-filter engineer --posted-within-days 14
181+
jd-intel fetch expedia --workday-tenant expedia --workday-env wd108 --workday-site search
140182
jd-intel detect figma
141183
jd-intel registry search fintech`);
142184
}

src/index.js

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { applyFilters } from './filters.js';
1616
* @param {Object} options
1717
* @param {string} options.company - Company slug or name
1818
* @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.
1920
* @param {string} [options.titleFilter] - Regex matched against title only. Use for role identity ("product manager", "staff engineer").
2021
* @param {string} [options.filter] - Regex matched across title, department, description. Use for topic/scope.
2122
* @param {number} [options.postedWithinDays] - Only return jobs posted within N days.
@@ -27,6 +28,7 @@ import { applyFilters } from './filters.js';
2728
export async function fetchJobs({
2829
company,
2930
ats,
31+
config,
3032
titleFilter,
3133
filter,
3234
postedWithinDays,
@@ -49,16 +51,33 @@ export async function fetchJobs({
4951
if (ats) {
5052
const adapter = ADAPTERS[ats];
5153
if (!adapter) throw new Error(`Unknown ATS: ${ats}. Supported: ${ATS_NAMES.join(', ')}`);
52-
jobs = await adapter.fetch(slug, { filterContext });
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 });
5371
} else {
5472
// Consult registry first — if we know which ATS this company uses,
5573
// skip probing the others (saves API calls, clearer error semantics).
56-
// The full entry is needed so adapter-specific config (e.g. the
57-
// Workday {tenant,env,site} triple) reaches the adapter.
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).
5877
const hit = await findEntryBySlug(slug);
5978
if (hit) {
60-
jobs = await ADAPTERS[hit.ats].fetch(slug, {
61-
config: hit.entry.config,
79+
jobs = await ADAPTERS[hit.ats].fetch(hit.entry.slug, {
80+
config: config || hit.entry.config,
6281
companyName: hit.entry.name,
6382
filterContext,
6483
});

src/registry.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,21 @@ export async function searchRegistry(query) {
5858
return results;
5959
}
6060

61+
// Slug match is case/punctuation-insensitive: registry slugs are stored
62+
// in each ATS's canonical form (SmartRecruiters uses PascalCase, e.g.
63+
// "Visa"), but callers pass a lowercased/alnum-stripped slug. Comparing
64+
// normalized forms keeps registry-first routing working for those.
65+
const normSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
66+
6167
/**
6268
* Look up which ATS a slug belongs to in the registry.
6369
* Returns the ATS name (e.g., "greenhouse") or null if not in registry.
6470
*/
6571
export async function findAtsBySlug(slug) {
6672
const all = await loadRegistry();
73+
const key = normSlug(slug);
6774
for (const [ats, companies] of Object.entries(all)) {
68-
if (companies.some(c => c.slug === slug)) return ats;
75+
if (companies.some(c => normSlug(c.slug) === key)) return ats;
6976
}
7077
return null;
7178
}
@@ -81,8 +88,9 @@ export async function findAtsBySlug(slug) {
8188
*/
8289
export async function findEntryBySlug(slug) {
8390
const all = await loadRegistry();
91+
const key = normSlug(slug);
8492
for (const [ats, companies] of Object.entries(all)) {
85-
const entry = companies.find(c => c.slug === slug);
93+
const entry = companies.find(c => normSlug(c.slug) === key);
8694
if (entry) return { ats, entry };
8795
}
8896
return null;

test/fetch-jobs.test.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { test, describe } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { fetchJobs } from '../src/index.js';
4+
5+
/**
6+
* fetchJobs routing: explicit-ATS config passthrough (Workday reachable
7+
* without a registry entry), registry fallback on the explicit path,
8+
* config-over-registry precedence, and canonical-cased slug routing for
9+
* case-sensitive registries (SmartRecruiters). Mocks global fetch
10+
* (auto-restored per test by t.mock.method).
11+
*/
12+
13+
const WD_LIST = {
14+
total: 1,
15+
jobPostings: [
16+
{ title: 'Product Manager', externalPath: '/job/Remote/PM_R1', locationsText: 'Remote', postedOn: 'Posted Today' },
17+
],
18+
};
19+
const WD_DETAIL = { jobPostingInfo: { jobDescription: '<p>Build.</p>', startDate: '2026-05-01', location: 'Remote' } };
20+
21+
function workdayMock(t) {
22+
const calls = { urls: [] };
23+
t.mock.method(global, 'fetch', async (url) => {
24+
calls.urls.push(String(url));
25+
if (String(url).endsWith('/jobs')) return { ok: true, status: 200, json: async () => WD_LIST };
26+
return { ok: true, status: 200, json: async () => WD_DETAIL };
27+
});
28+
return calls;
29+
}
30+
31+
describe('fetchJobs — Workday config passthrough', () => {
32+
test('explicit ats=workday + config reaches the adapter with that triple', async (t) => {
33+
const calls = workdayMock(t);
34+
const jobs = await fetchJobs({
35+
company: 'expedia',
36+
ats: 'workday',
37+
config: { tenant: 'expedia', env: 'wd108', site: 'search' },
38+
});
39+
assert.equal(jobs.length, 1);
40+
assert.equal(jobs[0].title, 'Product Manager');
41+
assert.ok(
42+
calls.urls.includes('https://expedia.wd108.myworkdayjobs.com/wday/cxs/expedia/search/jobs'),
43+
`expected the passthrough triple in the list URL, got: ${calls.urls[0]}`
44+
);
45+
});
46+
47+
test('explicit ats=workday with NO config falls back to registry config', async (t) => {
48+
const calls = workdayMock(t);
49+
const jobs = await fetchJobs({ company: 'cisco', ats: 'workday' });
50+
assert.equal(jobs.length, 1);
51+
assert.ok(
52+
calls.urls.includes('https://cisco.wd5.myworkdayjobs.com/wday/cxs/cisco/Cisco_Careers/jobs'),
53+
`expected registry-fallback triple, got: ${calls.urls[0]}`
54+
);
55+
});
56+
57+
test('explicit config overrides the registry entry', async (t) => {
58+
const calls = workdayMock(t);
59+
await fetchJobs({
60+
company: 'cisco',
61+
ats: 'workday',
62+
config: { tenant: 'override', env: 'wd99', site: 'OverrideSite' },
63+
});
64+
assert.ok(
65+
calls.urls.some(u => u.startsWith('https://override.wd99.myworkdayjobs.com/')),
66+
`expected explicit config to win, got: ${calls.urls[0]}`
67+
);
68+
assert.ok(
69+
!calls.urls.some(u => u.includes('cisco.wd5')),
70+
'registry config must not be used when explicit config is given'
71+
);
72+
});
73+
});
74+
75+
describe('fetchJobs — canonical-cased registry slug routing', () => {
76+
test('auto-detect routes a PascalCase SmartRecruiters slug from lowercased input', async (t) => {
77+
const calls = { urls: [] };
78+
t.mock.method(global, 'fetch', async (url) => {
79+
calls.urls.push(String(url));
80+
return { ok: true, status: 200, json: async () => ({ content: [], totalFound: 0 }) };
81+
});
82+
const jobs = await fetchJobs({ company: 'visa' }); // no ats -> registry-routed
83+
assert.deepEqual(jobs, []);
84+
// Routed via the registry to a single adapter using the canonical
85+
// 'Visa' (not lowercased 'visa', not 7-adapter discovery probing).
86+
assert.ok(
87+
calls.urls.length > 0 && calls.urls.every(u => u.includes('api.smartrecruiters.com')),
88+
`expected only SmartRecruiters calls (registry-routed), got: ${calls.urls.join(', ')}`
89+
);
90+
assert.ok(
91+
calls.urls.some(u => u.includes('/v1/companies/Visa/postings')),
92+
`expected canonical 'Visa' in the URL, got: ${calls.urls[0]}`
93+
);
94+
});
95+
});

test/registry.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ describe('findAtsBySlug', () => {
7575
assert.equal(await findAtsBySlug('plaid'), 'lever');
7676
});
7777

78+
test('matches case-insensitively (PascalCase SmartRecruiters slug)', async () => {
79+
// Registry stores "Visa"; callers pass a lowercased/stripped slug.
80+
// Pre-fix this returned null and every SR company missed registry
81+
// routing (fell through to slow 7-adapter discovery probing).
82+
assert.equal(await findAtsBySlug('visa'), 'smartrecruiters');
83+
assert.equal(await findAtsBySlug('VISA'), 'smartrecruiters');
84+
});
85+
7886
test('returns null for unknown slug', async () => {
7987
const ats = await findAtsBySlug('zzzz-nonexistent-slug-zzzz');
8088
assert.equal(ats, null);
@@ -98,6 +106,13 @@ describe('findEntryBySlug', () => {
98106
assert.equal(hit.entry.slug, 'stripe');
99107
});
100108

109+
test('resolves a PascalCase slug from lowercase and returns canonical casing', async () => {
110+
const hit = await findEntryBySlug('visa');
111+
assert.ok(hit, 'Visa should resolve from "visa"');
112+
assert.equal(hit.ats, 'smartrecruiters');
113+
assert.equal(hit.entry.slug, 'Visa'); // canonical, not the lowercased input
114+
});
115+
101116
test('returns null for unknown slug', async () => {
102117
const hit = await findEntryBySlug('zzzz-nonexistent-slug-zzzz');
103118
assert.equal(hit, null);

0 commit comments

Comments
 (0)