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.
- package/.codex-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/catalog.ts +21 -2
- package/src/cli.ts +8 -3
- package/src/crawler.ts +2 -0
- package/src/local-jobs.ts +1 -0
- package/src/locations.ts +2 -2
- package/src/runtime.ts +4 -1
- package/src/version.ts +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openings",
|
|
3
|
-
"version": "0.1.
|
|
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
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
|
|
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
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"
|
|
108
|
-
const US_STATE_PREFIX = /^([A-Z]{2})
|
|
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.
|
|
1
|
+
export const VERSION = "0.1.12";
|