jd-intel 0.11.0 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jd-intel",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Fetch and normalize job descriptions across seven major ATS (Greenhouse, Lever, Ashby, Workday, and more), for your AI assistant. No copy-paste.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -15,7 +15,6 @@
15
15
  "scripts": {
16
16
  "test": "node --test test/*.test.js",
17
17
  "fetch": "node src/cli.js fetch",
18
- "search": "node src/cli.js search",
19
18
  "verify:registry": "node scripts/verify-registry.mjs",
20
19
  "sync:registry-pages": "node scripts/sync-pages-registry.mjs",
21
20
  "pack:mcpb": "node scripts/build-mcpb.mjs",
@@ -1,4 +1,4 @@
1
- import { normalize, extractSalaryFromText } from '../normalizer.js';
1
+ import { normalize, extractSalaryFromText, toIso } from '../normalizer.js';
2
2
  import { atsErrorFromStatus } from '../errors.js';
3
3
  import { atsFetch, probeResult } from '../http.js';
4
4
 
@@ -44,7 +44,7 @@ export async function fetchLever(slug) {
44
44
  workplace: job.workplaceType,
45
45
  description: buildDescription(job),
46
46
  url: job.hostedUrl || '',
47
- postedAt: job.createdAt ? new Date(job.createdAt).toISOString() : null,
47
+ postedAt: toIso(job.createdAt),
48
48
  salary: parseLeverSalary(job.salaryRange, job.text),
49
49
  metadata: {
50
50
  leverId: job.id,
@@ -1,4 +1,4 @@
1
- import { normalize } from '../normalizer.js';
1
+ import { normalize, toIso } from '../normalizer.js';
2
2
  import { atsErrorFromStatus } from '../errors.js';
3
3
  import { atsFetch, probeResult } from '../http.js';
4
4
  import { orgHost } from '../boards.js';
@@ -51,7 +51,7 @@ export async function fetchRecruitee(slug, ctx = {}) {
51
51
  let location = place;
52
52
  if (offer.remote) location = place ? `Remote - ${place}` : 'Remote';
53
53
 
54
- const createdAt = toIso(offer.created_at);
54
+ const createdAt = recruiteeDate(offer.created_at);
55
55
 
56
56
  return normalize({
57
57
  companySlug: slug,
@@ -65,7 +65,7 @@ export async function fetchRecruitee(slug, ctx = {}) {
65
65
  url: offer.careers_url || offer.careers_apply_url || '',
66
66
  // created_at can predate publication by years on long-lived offers,
67
67
  // so it is not a posting date. published_at is.
68
- postedAt: toIso(offer.published_at) || createdAt,
68
+ postedAt: recruiteeDate(offer.published_at) || createdAt,
69
69
  salary: parseRecruiteeSalary(offer.salary),
70
70
  metadata: {
71
71
  recruiteeId: offer.guid || offer.id,
@@ -77,14 +77,8 @@ export async function fetchRecruitee(slug, ctx = {}) {
77
77
  });
78
78
  }
79
79
 
80
- /**
81
- * Recruitee returns "2026-05-13 07:38:11 UTC"; coerce to ISO.
82
- */
83
- function toIso(ts) {
84
- if (!ts) return null;
85
- const d = new Date(ts.replace(' UTC', 'Z').replace(' ', 'T'));
86
- return Number.isNaN(d.getTime()) ? null : d.toISOString();
87
- }
80
+ // Recruitee returns "2026-05-13 07:38:11 UTC".
81
+ const recruiteeDate = (ts) => toIso(ts?.replace(' UTC', 'Z').replace(' ', 'T'));
88
82
 
89
83
  /**
90
84
  * Recruitee sends three booleans, not one enum. Hybrid wins when remote is
@@ -1,4 +1,4 @@
1
- import { normalize, decodeEntities } from '../normalizer.js';
1
+ import { normalize, decodeEntities, toIso } from '../normalizer.js';
2
2
  import { atsErrorFromStatus } from '../errors.js';
3
3
  import { atsFetch } from '../http.js';
4
4
  import { orgHost } from '../boards.js';
@@ -116,12 +116,6 @@ export async function fetchTeamtailor(slug, ctx = {}) {
116
116
  location = location ? `Remote - ${location}` : 'Remote';
117
117
  }
118
118
 
119
- let postedAt = null;
120
- if (pubDateRaw) {
121
- const d = new Date(pubDateRaw);
122
- if (!Number.isNaN(d.getTime())) postedAt = d.toISOString();
123
- }
124
-
125
119
  return normalize({
126
120
  companySlug: slug,
127
121
  company,
@@ -132,7 +126,7 @@ export async function fetchTeamtailor(slug, ctx = {}) {
132
126
  workplace: REMOTE_STATUS[remoteStatus.toLowerCase()] || null,
133
127
  description: decodeEntities(pick('description')),
134
128
  url: link,
135
- postedAt,
129
+ postedAt: toIso(pubDateRaw),
136
130
  salary: null, // No structured salary; normalizer parses from text
137
131
  metadata: {
138
132
  teamtailorId: guid,
@@ -1,4 +1,4 @@
1
- import { normalize, missingContent } from '../normalizer.js';
1
+ import { normalize, missingContent, toIso } from '../normalizer.js';
2
2
  import { atsErrorFromStatus } from '../errors.js';
3
3
  import { prefilterRows } from '../filters.js';
4
4
  import { atsFetch } from '../http.js';
@@ -151,7 +151,8 @@ export async function fetchWorkday(slug, ctx = {}) {
151
151
  workplace: parseWorkdayRemoteType(info.remoteType),
152
152
  description: info.jobDescription || '',
153
153
  url: `https://${tenant}.${env}.myworkdayjobs.com/${site}${externalPath}`,
154
- postedAt: parseWorkdayDate(info.startDate) || normalizePostedOn(p.postedOn),
154
+ // startDate is "2026-05-01" or "May 1, 2026".
155
+ postedAt: toIso(info.startDate) || normalizePostedOn(p.postedOn),
155
156
  salary: null, // normalizer extracts from description text
156
157
  content,
157
158
  metadata: {
@@ -223,23 +224,12 @@ function withinDays(postedOn, days) {
223
224
  * so the library's postedWithinDays re-filter has a value to compare.
224
225
  */
225
226
  function normalizePostedOn(v) {
226
- if (!v) return null;
227
- const direct = new Date(v);
228
- if (Number.isFinite(direct.getTime())) return direct.toISOString();
227
+ const direct = toIso(v);
228
+ if (direct) return direct;
229
229
  const n = daysAgo(v);
230
230
  return n === null ? null : new Date(Date.now() - n * 86400000).toISOString();
231
231
  }
232
232
 
233
- /**
234
- * Workday detail `startDate` ("2026-05-01" or "May 1, 2026"). Return
235
- * ISO, or null if unparseable.
236
- */
237
- function parseWorkdayDate(s) {
238
- if (!s) return null;
239
- const d = new Date(s);
240
- return Number.isFinite(d.getTime()) ? d.toISOString() : null;
241
- }
242
-
243
233
  /**
244
234
  * Registry-only invariant: the {tenant,env,site} triple can't be
245
235
  * probed from a company name. Always false so detect_ats never selects
package/src/normalizer.js CHANGED
@@ -1,5 +1,15 @@
1
1
  import { createHash } from 'node:crypto';
2
2
 
3
+ /**
4
+ * A date value (string or epoch ms) as an ISO string, or null when it is
5
+ * missing or does not parse.
6
+ */
7
+ export function toIso(value) {
8
+ if (!value) return null;
9
+ const d = new Date(value);
10
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
11
+ }
12
+
3
13
  /**
4
14
  * Generate a stable ID for a job posting.
5
15
  */
package/src/registry.js CHANGED
@@ -7,11 +7,6 @@ import { ADAPTERS, ATS_NAMES } from './adapters/index.js';
7
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
8
  const REGISTRY_DIR = join(__dirname, '..', 'registry');
9
9
 
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;
14
-
15
10
  // Network-first registry. A hosted copy lets installed bundles AND npx users
16
11
  // pick up newly-added companies without reinstalling; the on-disk copy that
17
12
  // ships with the package is the guaranteed offline fallback. The base URL is
@@ -73,8 +68,11 @@ async function loadPlatform(platform) {
73
68
  */
74
69
  export async function loadRegistry(ats) {
75
70
  if (ats) return loadPlatform(ats);
76
- const lists = await Promise.all(PLATFORMS.map(loadPlatform));
77
- return Object.fromEntries(PLATFORMS.map((platform, i) => [platform, lists[i]]));
71
+ // ATS_NAMES is the one order the registry is ever walked in. Lookups,
72
+ // detectAts and this object all follow it, so which file answers for a
73
+ // slug does not depend on which file's load finished first (issue #87).
74
+ const lists = await Promise.all(ATS_NAMES.map(loadPlatform));
75
+ return Object.fromEntries(ATS_NAMES.map((platform, i) => [platform, lists[i]]));
78
76
  }
79
77
 
80
78
  /**
@@ -127,7 +125,7 @@ export async function searchRegistry(query) {
127
125
  // normalized forms keeps registry-first routing working for those.
128
126
  export const normSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
129
127
 
130
- const byPlatform = (a, b) => PLATFORMS.indexOf(a.ats) - PLATFORMS.indexOf(b.ats);
128
+ const byPlatform = (a, b) => ATS_NAMES.indexOf(a.ats) - ATS_NAMES.indexOf(b.ats);
131
129
 
132
130
  /**
133
131
  * Look up which ATS a slug belongs to in the registry.
@@ -143,7 +141,7 @@ export async function findAtsBySlug(slug) {
143
141
  * Unlike findAtsBySlug (returns just the ats name), this returns the
144
142
  * whole entry so callers can read adapter-specific config (e.g. the
145
143
  * Workday {tenant, env, site} triple). The files are searched in
146
- * PLATFORMS order, so the first match is the same on every call.
144
+ * ATS_NAMES order, so the first match is the same on every call.
147
145
  *
148
146
  * @returns {Promise<{ats: string, entry: object}|null>}
149
147
  */
@@ -167,7 +165,7 @@ export async function findEntryBySlug(slug) {
167
165
  * adds nothing, and an AtsError (429, 5xx, 401, network) goes to `failed`
168
166
  * with its code, so a board the probe could not check never reads as
169
167
  * absent (issue #55). Any other error is a bug and is rethrown. Both lists
170
- * come back in PLATFORMS order, never in completion order.
168
+ * come back in ATS_NAMES order, never in completion order.
171
169
  *
172
170
  * @returns {Promise<{
173
171
  * boards: Array<{ ats: string, slug: string, source: 'registry'|'probe' }>,