openings 0.1.63 → 0.1.65

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": "openings",
3
- "version": "0.1.63",
3
+ "version": "0.1.65",
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
@@ -258,14 +258,23 @@ async function fetchWorkdayJobs(company: Company, fetcher: Fetch, signal?: Abort
258
258
  previousPageStart = pacingNow();
259
259
  } finally { release(); }
260
260
  }
261
- async function page(offset: number, appliedFacets: Record<string, string[]> = {}): Promise<{ total: number; jobs: WorkdayJob[]; facets?: unknown }> {
261
+ async function page(offset: number, appliedFacets: Record<string, string[]> = {}): Promise<{ total: number; jobs: WorkdayJob[]; facets?: unknown; unusable?: number }> {
262
262
  for (let attempt = 0; attempt < 3; attempt += 1) {
263
263
  await pacePageStart();
264
264
  const response = await fetcher(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ appliedFacets, limit, offset, searchText: "" }), signal });
265
265
  if (response.ok) {
266
266
  const body = await response.json() as { total?: unknown; jobPostings?: unknown; facets?: unknown };
267
267
  if (!Number.isInteger(body.total) || !Array.isArray(body.jobPostings)) throw new Error(`${company.name} Workday source returned an invalid payload`);
268
- return { total: body.total as number, jobs: body.jobPostings as WorkdayJob[], facets: body.facets };
268
+ // Workday began returning postings with no externalPath on 13 October, and every one of them threw
269
+ // inside workdayJobKey, taking the whole tenant down with it: 48 sources failed in an afternoon. A
270
+ // posting with no path cannot produce an apply link, so it is of no use here either way — but it is
271
+ // dropped loudly, because a provider quietly shedding postings is how a source empties itself.
272
+ const postings = (body.jobPostings as WorkdayJob[]).filter((job) => typeof job?.externalPath === "string" && job.externalPath.length > 0);
273
+ if (postings.length < body.jobPostings.length) {
274
+ console.error(`${company.name} Workday source: skipped ${body.jobPostings.length - postings.length} posting(s) with no externalPath`);
275
+ }
276
+ return { total: body.total as number, jobs: postings, facets: body.facets,
277
+ unusable: (body.jobPostings as unknown[]).length - postings.length };
269
278
  }
270
279
  if (!isTransientStatus(response.status) || attempt === 2) { await response.body?.cancel().catch(() => undefined); throw new Error(`${company.name} job board returned HTTP ${response.status}`); }
271
280
  const delayMs = retryDelayMs(response, attempt);
@@ -279,6 +288,7 @@ async function fetchWorkdayJobs(company: Company, fetcher: Fetch, signal?: Abort
279
288
  if (first.total < first.jobs.length || first.total > 100_000) throw new Error(`${company.name} Workday source reported an invalid total`);
280
289
  const offsets = Array.from({ length: Math.ceil(first.total / limit) - 1 }, (_, index) => (index + 1) * limit);
281
290
  const pages = new Array<WorkdayJob[]>(offsets.length);
291
+ const pageUnusable: number[] = [];
282
292
  let cursor = 0;
283
293
  async function worker() {
284
294
  while (cursor < offsets.length) {
@@ -287,12 +297,17 @@ async function fetchWorkdayJobs(company: Company, fetcher: Fetch, signal?: Abort
287
297
  const result = await page(offset);
288
298
  if (result.jobs.length === 0 && offset < first.total) throw new Error(`${company.name} Workday source truncated at offset ${offset} of ${first.total}`);
289
299
  pages[index] = result.jobs;
300
+ pageUnusable[index] = result.unusable ?? 0;
290
301
  }
291
302
  }
292
303
  const workerCount = pageDelayMs > 0 ? 1 : Math.min(4, offsets.length);
293
304
  await Promise.all(Array.from({ length: workerCount }, worker));
294
- const jobs = [first.jobs, ...pages].flat().slice(0, first.total);
295
- if (jobs.length !== first.total) throw new Error(`${company.name} Workday source returned ${jobs.length} of ${first.total} jobs`);
305
+ // Postings Workday sent without a path were never usable, so they are not evidence of a truncated read.
306
+ // The check still fires for a genuinely short page, which is what stops a partial crawl emptying a tenant.
307
+ const unusable = (first.unusable ?? 0) + pages.reduce((sum, _, index) => sum + (pageUnusable[index] ?? 0), 0);
308
+ const expected = first.total - unusable;
309
+ const jobs = [first.jobs, ...pages].flat().slice(0, expected);
310
+ if (jobs.length !== expected) throw new Error(`${company.name} Workday source returned ${jobs.length} of ${expected} jobs`);
296
311
  // Two reasons to run a country pass with the tenant's own filter: past 2,000 postings the unfiltered listing stops,
297
312
  // and multi-location postings read "2 Locations" there, so their countries are invisible without the filter.
298
313
  const countryFacets = workdayCountryFacets(first.facets);
@@ -27,8 +27,12 @@ export function resolveAggregatorUrl(value: string | undefined): string | undefi
27
27
  }
28
28
 
29
29
  /** Posts each successfully crawled partition to the aggregator. Only job data is sent, never resume content. */
30
- export function createCrawlReporter(options: { url: string; fetcher?: Fetch; timeoutMs?: number; attempts?: number; sleep?: (ms: number) => Promise<void> }) {
30
+ export function createCrawlReporter(options: { url: string; fetcher?: Fetch; timeoutMs?: number; attempts?: number; secret?: string; sleep?: (ms: number) => Promise<void> }) {
31
31
  const fetcher = options.fetcher ?? globalThis.fetch;
32
+ // Without this the only thing standing between a stranger and an employer's listings is the slug, ats and
33
+ // token triple — all three of which are published in data/companies.json inside the npm tarball. An empty
34
+ // report for a real slug empties that employer from the site and answers 200.
35
+ const secret = options.secret ?? process.env.OPENINGS_INGEST_SECRET;
32
36
  const target = endpoint(options.url, "v1/crawls");
33
37
  const attempts = Math.max(1, options.attempts ?? 3);
34
38
  const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
@@ -42,7 +46,8 @@ export function createCrawlReporter(options: { url: string; fetcher?: Fetch; tim
42
46
  try {
43
47
  const response = await fetcher(target, {
44
48
  method: "POST",
45
- headers: { "content-type": "application/json", "content-encoding": "gzip" },
49
+ headers: { "content-type": "application/json", "content-encoding": "gzip",
50
+ ...(secret ? { authorization: `Bearer ${secret}` } : {}) },
46
51
  body,
47
52
  // Big employers send tens of megabytes and the aggregator ingests one report at a time; give them room.
48
53
  signal: AbortSignal.timeout(options.timeoutMs ?? 120_000),
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.63";
1
+ export const VERSION = "0.1.65";