openings 0.1.26 → 0.1.28

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "Find evidence-grounded jobs, including relevant roles you may not have searched for, without accounts or API keys.",
5
5
  "author": { "name": "Openings contributors" },
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "A free, candidate-safe job-search substrate for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": { "type": "git", "url": "git+https://github.com/abhay-avagama/hiring-agent.git" },
package/src/catalog.ts CHANGED
@@ -193,10 +193,35 @@ export async function fetchSourceJobs(company: Company, fetcher: Fetch = globalT
193
193
  }
194
194
 
195
195
  const WORKDAY_LISTING_CAP = 2000;
196
- /** Workday's country facet ids are the same on every tenant. Verified live on 2026-09-07. */
197
- const WORKDAY_COUNTRY_FACETS: Record<string, string> = { IN: "c4f78be1a8f14da0ab49ce1162348a5e", US: "bc33aa3152ec42d4995f4791a106ed09" };
196
+ /** Country names as Workday tenants spell them in their facet lists. */
197
+ const WORKDAY_COUNTRY_NAMES: Record<string, string[]> = { IN: ["india"], US: ["united states of america", "united states", "usa"] };
198
+ /** Countries whose multi-location postings ("2 Locations") are labelled from the tenant's own country filter, not only on capped tenants. */
199
+ const WORKDAY_LABEL_COUNTRIES = new Set(["IN"]);
198
200
  function workdayJobKey(job: WorkdayJob): string { return job.bulletFields?.[0] ?? job.externalPath; }
199
201
 
202
+ export interface WorkdayCountryFacet { parameter: string; id: string; count: number }
203
+ /**
204
+ * The tenant's own country filter, read from the facets of an unfiltered listing. Tenants name the parameter
205
+ * differently (locationCountry, Location_Country, custom fields), and a tenant that does not recognise a parameter
206
+ * silently returns everything, so the name must come from the tenant, never from a constant.
207
+ */
208
+ export function workdayCountryFacets(facets: unknown): Map<string, WorkdayCountryFacet> {
209
+ const found = new Map<string, WorkdayCountryFacet>();
210
+ const walk = (node: unknown, parameter?: string) => {
211
+ if (Array.isArray(node)) { for (const child of node) walk(child, parameter); return; }
212
+ if (typeof node !== "object" || node === null) return;
213
+ const record = node as Record<string, unknown>;
214
+ const own = typeof record.facetParameter === "string" ? record.facetParameter : parameter;
215
+ if (own && /country/i.test(own) && typeof record.descriptor === "string" && typeof record.id === "string" && typeof record.count === "number") {
216
+ const name = record.descriptor.trim().toLowerCase();
217
+ for (const [code, names] of Object.entries(WORKDAY_COUNTRY_NAMES)) if (names.includes(name) && !found.has(code)) found.set(code, { parameter: own, id: record.id, count: record.count });
218
+ }
219
+ for (const value of Object.values(record)) if (typeof value === "object" && value !== null) walk(value, own);
220
+ };
221
+ walk(facets);
222
+ return found;
223
+ }
224
+
200
225
  async function fetchWorkdayJobs(company: Company, fetcher: Fetch, signal?: AbortSignal, observer?: FetchJobsObserver): Promise<Job[]> {
201
226
  const source = parseWorkdayToken(company.token);
202
227
  const endpoint = `https://${source.host}/wday/cxs/${encodeURIComponent(source.tenant)}/${encodeURIComponent(source.site)}/jobs`;
@@ -218,14 +243,14 @@ async function fetchWorkdayJobs(company: Company, fetcher: Fetch, signal?: Abort
218
243
  previousPageStart = pacingNow();
219
244
  } finally { release(); }
220
245
  }
221
- async function page(offset: number, appliedFacets: Record<string, string[]> = {}): Promise<{ total: number; jobs: WorkdayJob[] }> {
246
+ async function page(offset: number, appliedFacets: Record<string, string[]> = {}): Promise<{ total: number; jobs: WorkdayJob[]; facets?: unknown }> {
222
247
  for (let attempt = 0; attempt < 3; attempt += 1) {
223
248
  await pacePageStart();
224
249
  const response = await fetcher(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ appliedFacets, limit, offset, searchText: "" }), signal });
225
250
  if (response.ok) {
226
- const body = await response.json() as { total?: unknown; jobPostings?: unknown };
251
+ const body = await response.json() as { total?: unknown; jobPostings?: unknown; facets?: unknown };
227
252
  if (!Number.isInteger(body.total) || !Array.isArray(body.jobPostings)) throw new Error(`${company.name} Workday source returned an invalid payload`);
228
- return { total: body.total as number, jobs: body.jobPostings as WorkdayJob[] };
253
+ return { total: body.total as number, jobs: body.jobPostings as WorkdayJob[], facets: body.facets };
229
254
  }
230
255
  if (!isTransientStatus(response.status) || attempt === 2) { await response.body?.cancel().catch(() => undefined); throw new Error(`${company.name} job board returned HTTP ${response.status}`); }
231
256
  const delayMs = retryDelayMs(response, attempt);
@@ -253,24 +278,44 @@ async function fetchWorkdayJobs(company: Company, fetcher: Fetch, signal?: Abort
253
278
  await Promise.all(Array.from({ length: workerCount }, worker));
254
279
  const jobs = [first.jobs, ...pages].flat().slice(0, first.total);
255
280
  if (jobs.length !== first.total) throw new Error(`${company.name} Workday source returned ${jobs.length} of ${first.total} jobs`);
256
- // Workday stops an unfiltered listing at 2,000 postings. For capped tenants, a per-country pass sees past the cap.
257
- if (first.total >= WORKDAY_LISTING_CAP) {
258
- const seen = new Set(jobs.map((job) => workdayJobKey(job)));
259
- for (const code of observer?.workdayCountries ?? []) {
260
- const facet = WORKDAY_COUNTRY_FACETS[code.toUpperCase()];
261
- if (!facet) continue;
262
- // Best effort: some tenants reject the facet with HTTP 400. The unfiltered 2,000 are still a valid crawl, so a failed pass is skipped, not fatal.
263
- try {
264
- const head = await page(0, { locationCountry: [facet] });
265
- const extra = [head.jobs];
266
- for (let offset = limit; offset < Math.min(head.total, WORKDAY_LISTING_CAP); offset += limit) extra.push((await page(offset, { locationCountry: [facet] })).jobs);
267
- for (const job of extra.flat()) { const key = workdayJobKey(job); if (!seen.has(key)) { seen.add(key); jobs.push(job); } }
268
- } catch (error) {
269
- if (signal?.aborted) throw error;
281
+ // Two reasons to run a country pass with the tenant's own filter: past 2,000 postings the unfiltered listing stops,
282
+ // and multi-location postings read "2 Locations" there, so their countries are invisible without the filter.
283
+ const countryFacets = workdayCountryFacets(first.facets);
284
+ const seen = new Set(jobs.map((job) => workdayJobKey(job)));
285
+ const countryKeys = new Map<string, Set<string>>();
286
+ const normalizedFirst = jobs.map((job) => normalizeWorkday(company, source, job));
287
+ for (const code of (observer?.workdayCountries ?? []).map((value) => value.toUpperCase())) {
288
+ const facet = countryFacets.get(code);
289
+ if (!facet || facet.count === 0) continue;
290
+ const detected = normalizedFirst.filter((job) => job.eligibleCountries.includes(code)).length;
291
+ const capped = first.total >= WORKDAY_LISTING_CAP;
292
+ const unlabelled = WORKDAY_LABEL_COUNTRIES.has(code) && facet.count > detected;
293
+ if (!capped && !unlabelled) continue;
294
+ const applied = { [facet.parameter]: [facet.id] };
295
+ // Best effort: some tenants reject a filter with HTTP 400. The unfiltered listing is still a valid crawl.
296
+ try {
297
+ const head = await page(0, applied);
298
+ // A tenant that ignores the filter returns its whole listing; a total above the facet count means the filter did not apply.
299
+ if (head.total > facet.count + 5) continue;
300
+ const pages = [head.jobs];
301
+ for (let offset = limit; offset < Math.min(head.total, WORKDAY_LISTING_CAP); offset += limit) pages.push((await page(offset, applied)).jobs);
302
+ const keys = countryKeys.get(code) ?? new Set<string>();
303
+ for (const job of pages.flat()) {
304
+ const key = workdayJobKey(job);
305
+ keys.add(key);
306
+ if (!seen.has(key)) { seen.add(key); jobs.push(job); }
270
307
  }
308
+ countryKeys.set(code, keys);
309
+ } catch (error) {
310
+ if (signal?.aborted) throw error;
271
311
  }
272
312
  }
273
- return jobs.map((job) => normalizeWorkday(company, source, job));
313
+ return jobs.map((job) => {
314
+ const normalized = normalizeWorkday(company, source, job);
315
+ const key = workdayJobKey(job);
316
+ const add = [...countryKeys].filter(([code, keys]) => keys.has(key) && !normalized.eligibleCountries.includes(code)).map(([code]) => code);
317
+ return add.length ? { ...normalized, eligibleCountries: [...normalized.eligibleCountries, ...add].sort(), excludedCountries: normalized.excludedCountries.filter((code) => !add.includes(code)), eligibilityConfidence: "explicit" as const } : normalized;
318
+ });
274
319
  }
275
320
 
276
321
  /** JSON fetch with the catalog's retry and backoff, shaped for the table-driven providers. */
package/src/index.ts CHANGED
@@ -41,3 +41,5 @@ export { createToolHandler } from "./tools.ts";
41
41
  export { createMcpHandler, FLOW_INSTRUCTIONS } from "./mcp.ts";
42
42
  export type { UpdateNotice } from "./update-check.ts";
43
43
  export { VERSION } from "./version.ts";
44
+ export { usageEventFor } from "./usage.ts";
45
+ export type { UsageEvent } from "./usage.ts";
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.26";
1
+ export const VERSION = "0.1.28";