openings 0.1.10 → 0.1.12

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.10",
3
+ "version": "0.1.12",
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.10",
3
+ "version": "0.1.12",
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
@@ -116,6 +116,8 @@ export function searchJobs(jobs: Job[], query: SearchQuery): JobSummary[] {
116
116
  export interface FetchJobsObserver {
117
117
  onBackoff?(event: { status: number; delayMs: number }): void;
118
118
  workdayPageDelayMs?: number;
119
+ /** Countries to fetch with Workday's country facet after a capped crawl, so roles beyond the 2,000-posting cap are not lost. */
120
+ workdayCountries?: string[];
119
121
  pacingNow?: () => number;
120
122
  pacingSleep?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
121
123
  }
@@ -147,6 +149,11 @@ export async function fetchSourceJobs(company: Company, fetcher: Fetch = globalT
147
149
  : (body as { offers: RecruiteeJob[] }).offers.map((job) => normalizeRecruitee(company, job));
148
150
  }
149
151
 
152
+ const WORKDAY_LISTING_CAP = 2000;
153
+ /** Workday's country facet ids are the same on every tenant. Verified live on 2026-09-07. */
154
+ const WORKDAY_COUNTRY_FACETS: Record<string, string> = { IN: "c4f78be1a8f14da0ab49ce1162348a5e", US: "bc33aa3152ec42d4995f4791a106ed09" };
155
+ function workdayJobKey(job: WorkdayJob): string { return job.bulletFields?.[0] ?? job.externalPath; }
156
+
150
157
  async function fetchWorkdayJobs(company: Company, fetcher: Fetch, signal?: AbortSignal, observer?: FetchJobsObserver): Promise<Job[]> {
151
158
  const source = parseWorkdayToken(company.token);
152
159
  const endpoint = `https://${source.host}/wday/cxs/${encodeURIComponent(source.tenant)}/${encodeURIComponent(source.site)}/jobs`;
@@ -168,10 +175,10 @@ async function fetchWorkdayJobs(company: Company, fetcher: Fetch, signal?: Abort
168
175
  previousPageStart = pacingNow();
169
176
  } finally { release(); }
170
177
  }
171
- async function page(offset: number): Promise<{ total: number; jobs: WorkdayJob[] }> {
178
+ async function page(offset: number, appliedFacets: Record<string, string[]> = {}): Promise<{ total: number; jobs: WorkdayJob[] }> {
172
179
  for (let attempt = 0; attempt < 3; attempt += 1) {
173
180
  await pacePageStart();
174
- const response = await fetcher(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ appliedFacets: {}, limit, offset, searchText: "" }), signal });
181
+ const response = await fetcher(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ appliedFacets, limit, offset, searchText: "" }), signal });
175
182
  if (response.ok) {
176
183
  const body = await response.json() as { total?: unknown; jobPostings?: unknown };
177
184
  if (!Number.isInteger(body.total) || !Array.isArray(body.jobPostings)) throw new Error(`${company.name} Workday source returned an invalid payload`);
@@ -203,6 +210,18 @@ async function fetchWorkdayJobs(company: Company, fetcher: Fetch, signal?: Abort
203
210
  await Promise.all(Array.from({ length: workerCount }, worker));
204
211
  const jobs = [first.jobs, ...pages].flat().slice(0, first.total);
205
212
  if (jobs.length !== first.total) throw new Error(`${company.name} Workday source returned ${jobs.length} of ${first.total} jobs`);
213
+ // Workday stops an unfiltered listing at 2,000 postings. For capped tenants, a per-country pass sees past the cap.
214
+ if (first.total >= WORKDAY_LISTING_CAP) {
215
+ const seen = new Set(jobs.map((job) => workdayJobKey(job)));
216
+ for (const code of observer?.workdayCountries ?? []) {
217
+ const facet = WORKDAY_COUNTRY_FACETS[code.toUpperCase()];
218
+ if (!facet) continue;
219
+ const head = await page(0, { locationCountry: [facet] });
220
+ const extra = [head.jobs];
221
+ for (let offset = limit; offset < Math.min(head.total, WORKDAY_LISTING_CAP); offset += limit) extra.push((await page(offset, { locationCountry: [facet] })).jobs);
222
+ for (const job of extra.flat()) { const key = workdayJobKey(job); if (!seen.has(key)) { seen.add(key); jobs.push(job); } }
223
+ }
224
+ }
206
225
  return jobs.map((job) => normalizeWorkday(company, source, job));
207
226
  }
208
227
 
package/src/cli.ts CHANGED
@@ -22,7 +22,7 @@ import { mergeAttemptedRoundLeads, prepareRecruiteeRoundArtifacts } from "./recr
22
22
  const HELP = `Openings — search public company job boards
23
23
 
24
24
  Usage:
25
- openings crawl [--country CODE | --companies FILE] [--concurrency N] [--source-cache-hours N] [--source-limit N] [--delay-ms N] [--workday-page-delay-ms N] [--data-dir PATH]
25
+ openings crawl [--workday-countries IN,US] [--country CODE | --companies FILE] [--concurrency N] [--source-cache-hours N] [--source-limit N] [--delay-ms N] [--workday-page-delay-ms N] [--data-dir PATH]
26
26
  openings snapshot export [--input FILE] [--output-dir PATH]
27
27
  openings coverage report --country CODE [--snapshot FILE] [--catalog FILE] [--candidates FILE] [--registry FILE] [--output FILE] [--as-of ISO]
28
28
  openings sources discover-subdomains SEEDS.json [--output FILE] [--limit N] [--delay-ms N] [--report FILE]
@@ -69,7 +69,7 @@ export async function run(args: string[]): Promise<number> {
69
69
  if (typeof parsed === "string") return fail(parsed);
70
70
  const runtime = createRuntime({
71
71
  dataDir: parsed.dataDir, concurrency: parsed.concurrency, sourceCacheHours: parsed.sourceCacheHours,
72
- sourceLimit: parsed.sourceLimit, crawlDelayMs: parsed.delayMs, workdayPageDelayMs: parsed.workdayPageDelayMs,
72
+ sourceLimit: parsed.sourceLimit, crawlDelayMs: parsed.delayMs, workdayPageDelayMs: parsed.workdayPageDelayMs, workdayCountries: parsed.workdayCountries,
73
73
  });
74
74
  const slugs = parsed.companiesFile ? await readCompanyFile(parsed.companiesFile) : undefined;
75
75
  console.log(JSON.stringify(await runtime.crawl({ country: parsed.country, slugs }), null, 2));
@@ -514,6 +514,7 @@ function parseCrawl(args: string[]) {
514
514
  let concurrency = 10;
515
515
  let delayMs = 0;
516
516
  let workdayPageDelayMs = 0;
517
+ let workdayCountries: string[] | undefined;
517
518
  let sourceCacheHours = 24;
518
519
  let sourceLimit = 0;
519
520
  for (let index = 0; index < args.length; index += 1) {
@@ -526,6 +527,10 @@ function parseCrawl(args: string[]) {
526
527
  else if (arg === "--concurrency") {
527
528
  concurrency = Number(args[++index]);
528
529
  if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 100) return "--concurrency must be an integer from 1 to 100";
530
+ } else if (arg === "--workday-countries") {
531
+ const value = args[++index] ?? "";
532
+ workdayCountries = value.split(",").map((code) => code.trim().toUpperCase()).filter(Boolean);
533
+ if (!workdayCountries.every((code) => /^[A-Z]{2}$/.test(code))) return "--workday-countries must be a comma-separated list of two-letter codes";
529
534
  } else if (arg === "--delay-ms") {
530
535
  delayMs = Number(args[++index]);
531
536
  if (!Number.isInteger(delayMs) || delayMs < 0 || delayMs > 60_000) return "--delay-ms must be an integer from 0 to 60000";
@@ -543,7 +548,7 @@ function parseCrawl(args: string[]) {
543
548
  if (country && companiesFile) return "Use either --country or --companies, not both";
544
549
  if (args.includes("--companies") && !companiesFile) return "--companies requires a file";
545
550
  if (args.includes("--data-dir") && !dataDir) return "--data-dir requires a value";
546
- return { country, companiesFile, dataDir, concurrency, delayMs, workdayPageDelayMs, sourceCacheHours, sourceLimit };
551
+ return { country, companiesFile, dataDir, concurrency, delayMs, workdayPageDelayMs, sourceCacheHours, sourceLimit , workdayCountries };
547
552
  }
548
553
 
549
554
  function parseCountry(value: string | undefined): string | undefined {
package/src/crawler.ts CHANGED
@@ -16,6 +16,7 @@ interface CrawlerOptions {
16
16
  sourceFreshnessMs?: number;
17
17
  sourceLimit?: number;
18
18
  workdayPageDelayMs?: number;
19
+ workdayCountries?: string[];
19
20
  pacingNow?: () => number;
20
21
  pacingSleep?: (delayMs: number) => Promise<void>;
21
22
  now?: () => Date;
@@ -94,6 +95,7 @@ export function createCrawler(options: CrawlerOptions): Crawler {
94
95
  const jobs = await options.fetchJobs(source, controller.signal, {
95
96
  onBackoff: ({ status, delayMs }) => { metric.backoffMs += delayMs; if (status === 429) metric.throttles += 1; },
96
97
  workdayPageDelayMs: options.workdayPageDelayMs,
98
+ workdayCountries: options.workdayCountries,
97
99
  });
98
100
  partitions[source.slug] = { fetchedAt: now().toISOString(), jobs };
99
101
  succeeded += 1;
package/src/local-jobs.ts CHANGED
@@ -14,6 +14,7 @@ interface LocalJobsOptions {
14
14
  sourceFreshnessMs?: number;
15
15
  sourceLimit?: number;
16
16
  workdayPageDelayMs?: number;
17
+ workdayCountries?: string[];
17
18
  now?: () => Date;
18
19
  onCrawled?(source: Company, partition: JobPartition): void | Promise<void>;
19
20
  }
package/src/locations.ts CHANGED
@@ -104,8 +104,8 @@ function detectRegions(location: string, description: string): string[] {
104
104
  /** US state and district postal codes. Many collide with ISO country codes (IN, CA, DE, CO, GA, ID, IL, LA, MA, MD, MO, MT, NE, PA, SC, SD, TN, VA). */
105
105
  const US_STATE_CODES = new Set(["AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY", "DC"]);
106
106
  const US_CITY_STATE = /,\s*([A-Z]{2})(?=\s*(?:$|,|\d{5}|\(|\/|-))/g;
107
- /** Workday's "ST-CITY, street" shape, e.g. "IN-INDIANAPOLIS, 220 VIRGINIA AVE". */
108
- const US_STATE_PREFIX = /^([A-Z]{2})-(?=[A-Za-z])/;
107
+ /** Workday's "ST-CITY, street" and "ST - City" shapes, e.g. "IN-INDIANAPOLIS, 220 VIRGINIA AVE" or "IN - Indianapolis". */
108
+ const US_STATE_PREFIX = /^([A-Z]{2})\s*-\s*(?=[A-Za-z])/;
109
109
 
110
110
  function detectCountries(location: string): string[] {
111
111
  const byName: string[] = [];
package/src/runtime.ts CHANGED
@@ -13,7 +13,8 @@ import { createFileSnapshotStore } from "./snapshot-store.ts";
13
13
  import { createJobCoverageReader } from "./job-coverage.ts";
14
14
  import { createJobSearchPreparer } from "./job-search-preparation.ts";
15
15
 
16
- export function createRuntime(options: { dataDir?: string; concurrency?: number; crawlDelayMs?: number; workdayPageDelayMs?: number; sourceCacheHours?: number; sourceLimit?: number } = {}) {
16
+ export function createRuntime(options: { dataDir?: string; concurrency?: number; crawlDelayMs?: number; workdayPageDelayMs?: number; workdayCountries?: string[]; sourceCacheHours?: number; sourceLimit?: number } = {}) {
17
+ const workdayCountries = options.workdayCountries ?? (process.env.OPENINGS_WORKDAY_COUNTRIES ?? "IN,US").split(",").map((code) => code.trim().toUpperCase()).filter((code) => /^[A-Z]{2}$/.test(code));
17
18
  const dataDir = options.dataDir ?? process.env.OPENINGS_DATA_DIR ?? join(process.cwd(), ".openings");
18
19
  const store = createFileSnapshotStore(join(dataDir, "snapshot.json"));
19
20
  const aggregatorUrl = resolveAggregatorUrl(process.env.OPENINGS_AGGREGATOR_URL);
@@ -28,6 +29,7 @@ export function createRuntime(options: { dataDir?: string; concurrency?: number;
28
29
  sourceFreshnessMs: (options.sourceCacheHours ?? 0) * 60 * 60 * 1000,
29
30
  sourceLimit: options.sourceLimit,
30
31
  workdayPageDelayMs: options.workdayPageDelayMs,
32
+ workdayCountries,
31
33
  onCrawled,
32
34
  });
33
35
  const recommender = createJobRecommender({ sources: companies, store, crawl: local.crawl });
@@ -41,6 +43,7 @@ export function createRuntime(options: { dataDir?: string; concurrency?: number;
41
43
  maxAttempts: 1,
42
44
  sourceStartDelayMs: options.crawlDelayMs,
43
45
  workdayPageDelayMs: options.workdayPageDelayMs,
46
+ workdayCountries,
44
47
  onCrawled,
45
48
  });
46
49
  const preparation = createJobSearchPreparer({ sources: companies, store, crawl: preparationLocal.crawl, seed: aggregatorUrl ? (countries) => fetchSeedSnapshot(aggregatorUrl, globalThis.fetch, 20_000, countries) : undefined });
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.10";
1
+ export const VERSION = "0.1.12";