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.
- package/.codex-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/catalog.ts +65 -20
- package/src/index.ts +2 -0
- package/src/version.ts +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openings",
|
|
3
|
-
"version": "0.1.
|
|
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
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
|
-
/**
|
|
197
|
-
const
|
|
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
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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) =>
|
|
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.
|
|
1
|
+
export const VERSION = "0.1.28";
|