openings 0.1.0
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 +23 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/data/companies.json +2550 -0
- package/docs/job-seeker-quickstart.md +109 -0
- package/package.json +42 -0
- package/skills/openings/SKILL.md +28 -0
- package/src/artifact-path.ts +48 -0
- package/src/atomic-file.ts +13 -0
- package/src/candidate-profile.ts +273 -0
- package/src/career-tracing.ts +181 -0
- package/src/catalog.ts +382 -0
- package/src/cli.ts +601 -0
- package/src/common-crawl-discovery.ts +137 -0
- package/src/company-seeds.ts +43 -0
- package/src/country-coverage.ts +203 -0
- package/src/crawl-reporting.ts +56 -0
- package/src/crawler.ts +146 -0
- package/src/enrichment-registry.ts +137 -0
- package/src/file-lock.ts +85 -0
- package/src/index.ts +37 -0
- package/src/intent-validation.ts +28 -0
- package/src/job-coverage.ts +73 -0
- package/src/job-fit-analysis.ts +247 -0
- package/src/job-matching.ts +445 -0
- package/src/job-recommendations.ts +193 -0
- package/src/job-search-preparation.ts +116 -0
- package/src/jobposting-probe.ts +167 -0
- package/src/local-jobs.ts +113 -0
- package/src/locations.ts +180 -0
- package/src/mcp.ts +98 -0
- package/src/package-mcp.ts +8 -0
- package/src/recruitee-round.ts +114 -0
- package/src/report-meta.ts +17 -0
- package/src/requirement-vocabulary.ts +111 -0
- package/src/resume-optimization.ts +118 -0
- package/src/runtime.ts +51 -0
- package/src/safe-get.ts +88 -0
- package/src/safe-head.ts +79 -0
- package/src/screening-requirements.ts +99 -0
- package/src/selected-job-lookup.ts +14 -0
- package/src/snapshot-catalog.ts +21 -0
- package/src/snapshot-export.ts +66 -0
- package/src/snapshot-store.ts +31 -0
- package/src/source-discovery-pipeline.ts +26 -0
- package/src/source-discovery.ts +231 -0
- package/src/source-enrichment.ts +92 -0
- package/src/source-pipeline.ts +245 -0
- package/src/source-verification.ts +297 -0
- package/src/tools.ts +194 -0
- package/src/types.ts +136 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { atomicJson } from "./atomic-file.ts";
|
|
3
|
+
import { assertCompatibleReport, stampReport, type ReportMeta } from "./report-meta.ts";
|
|
4
|
+
import { mergeSourceCandidates } from "./source-discovery.ts";
|
|
5
|
+
import { resolveSource } from "./source-verification.ts";
|
|
6
|
+
import { fetchSafeHead, type HeadTransport, type ResolveHost } from "./safe-head.ts";
|
|
7
|
+
import type { SourceCandidate } from "./types.ts";
|
|
8
|
+
import { deriveLeadState, mergeEnrichmentLeads, type EnrichmentLead, type IdentityEvidence } from "./enrichment-registry.ts";
|
|
9
|
+
|
|
10
|
+
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
11
|
+
|
|
12
|
+
interface CompanySeed { companyName: string; companyDomain: string; careerUrl?: string }
|
|
13
|
+
interface TraceIssue { companyName?: string; companyDomain?: string; careerUrls?: string[]; reason: string; detail: string }
|
|
14
|
+
interface TraceOptions { country?: string; fetch?: Fetch; concurrency?: number; timeoutMs?: number; searchKey?: string; commonCrawlReportPath?: string; resolveHost?: ResolveHost; headTransport?: HeadTransport; registryPath?: string }
|
|
15
|
+
|
|
16
|
+
export interface CareerTraceReport extends ReportMeta {
|
|
17
|
+
companiesChecked: number;
|
|
18
|
+
ready: number;
|
|
19
|
+
alreadyKnown: number;
|
|
20
|
+
unresolved: number;
|
|
21
|
+
rejected: number;
|
|
22
|
+
failures: number;
|
|
23
|
+
candidatesPath: string;
|
|
24
|
+
reportPath: string;
|
|
25
|
+
unresolvedCompanies: TraceIssue[];
|
|
26
|
+
rejections: TraceIssue[];
|
|
27
|
+
failureDetails: TraceIssue[];
|
|
28
|
+
matched: number;
|
|
29
|
+
registryPath?: string;
|
|
30
|
+
registryAdded: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function traceCareerSources(inputPath: string, candidatesPath: string, reportPath: string, options: TraceOptions = {}): Promise<CareerTraceReport> {
|
|
34
|
+
const seeds = await readSeeds(inputPath);
|
|
35
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
36
|
+
const concurrency = Math.max(1, Math.trunc(options.concurrency ?? 10));
|
|
37
|
+
const timeoutMs = Math.max(1, Math.trunc(options.timeoutMs ?? 15_000));
|
|
38
|
+
const country = options.country?.toUpperCase();
|
|
39
|
+
if (country && !/^[A-Z]{2}$/.test(country)) throw new Error("country must be a two-letter code");
|
|
40
|
+
const candidates: Array<{ index: number; candidate: SourceCandidate }> = [];
|
|
41
|
+
const commonCrawlLeads = options.commonCrawlReportPath ? await readCommonCrawlLeads(options.commonCrawlReportPath) : [];
|
|
42
|
+
const unresolved: Array<{ index: number; issue: TraceIssue }> = [];
|
|
43
|
+
const rejected: Array<{ index: number; issue: TraceIssue }> = [];
|
|
44
|
+
const failures: Array<{ index: number; issue: TraceIssue }> = [];
|
|
45
|
+
const registryRows: EnrichmentLead[] = [];
|
|
46
|
+
let matched = 0;
|
|
47
|
+
let cursor = 0;
|
|
48
|
+
|
|
49
|
+
async function worker() {
|
|
50
|
+
while (cursor < seeds.length) {
|
|
51
|
+
const index = cursor++;
|
|
52
|
+
const seed = seeds[index]!;
|
|
53
|
+
const invalid = validateSeed(seed);
|
|
54
|
+
if (invalid) { rejected.push({ index, issue: { ...identity(seed), reason: "invalid_company", detail: invalid } }); continue; }
|
|
55
|
+
const careerUrls = seed.careerUrl ? [seed.careerUrl] : ["careers", "career", "jobs"].map((path) => `https://${seed.companyDomain}/${path}`);
|
|
56
|
+
let found: ReturnType<typeof resolveSource> = null;
|
|
57
|
+
let reference = careerUrls[0]!;
|
|
58
|
+
let channel: "career_page" | "search" | "dataset" = "career_page";
|
|
59
|
+
if (options.searchKey) {
|
|
60
|
+
try {
|
|
61
|
+
const result = await searchForSource(seed, options.searchKey, fetcher, country);
|
|
62
|
+
if (result) { found = result.source; reference = result.reference; channel = "search"; }
|
|
63
|
+
} catch (error) {
|
|
64
|
+
failures.push({ index, issue: { ...identity(seed), reason: "search_failed", detail: error instanceof Error ? error.message : String(error) } });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!found) {
|
|
68
|
+
for (const careerUrl of careerUrls) {
|
|
69
|
+
try {
|
|
70
|
+
const result = await fetchSafeHead(careerUrl, { resolveHost: options.resolveHost, transport: options.headTransport, timeoutMs });
|
|
71
|
+
if (!result.response.ok) continue;
|
|
72
|
+
const source = resolveSource(result.finalUrl);
|
|
73
|
+
if (source) { found = source; reference = careerUrl; break; }
|
|
74
|
+
} catch (error) {
|
|
75
|
+
failures.push({ index, issue: { ...identity(seed), careerUrls: [careerUrl], reason: "career_request_failed", detail: error instanceof Error ? error.message : String(error) } });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!found) {
|
|
80
|
+
const lead = commonCrawlLeads.find((item) => sourceMatchesCompany(item.source.token, seed));
|
|
81
|
+
if (lead) { found = lead.source; reference = lead.reference; channel = "dataset"; }
|
|
82
|
+
}
|
|
83
|
+
if (!found) {
|
|
84
|
+
unresolved.push({ index, issue: { ...identity(seed), careerUrls, reason: "ats_not_resolved", detail: "Career URLs did not redirect to a supported structured job source" } });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const companyName = seed.companyName.trim();
|
|
88
|
+
const companyDomain = normalizeDomain(seed.companyDomain);
|
|
89
|
+
const companyMatch = { companyName, companyDomain, method: "normalized_token" as const, reference };
|
|
90
|
+
const identityEvidence: IdentityEvidence[] = channel === "career_page" ? [{ companyName, companyDomain, kind: "company_redirect", reference, observedAt: new Date().toISOString() }] : [];
|
|
91
|
+
const lead: EnrichmentLead = { sourceKey: `${found.ats}:${found.token.toLowerCase()}`, sourceUrl: found.canonicalSourceUrl, ats: found.ats, token: found.token,
|
|
92
|
+
discoveredFrom: [{ channel, reference }], companyMatches: [companyMatch], identityEvidence, attempts: [] };
|
|
93
|
+
registryRows.push(lead);
|
|
94
|
+
if (deriveLeadState(lead) !== "evidence_ready") { matched += 1; continue; }
|
|
95
|
+
candidates.push({ index, candidate: {
|
|
96
|
+
companyName, companyDomain, sourceUrl: found.canonicalSourceUrl,
|
|
97
|
+
cohorts: country ? [country] : undefined,
|
|
98
|
+
discoveredFrom: { channel, reference },
|
|
99
|
+
domainEvidence: channel === "career_page" ? { kind: "company_redirect", reference } : undefined,
|
|
100
|
+
} });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, seeds.length) }, worker));
|
|
105
|
+
const registry = options.registryPath ? await mergeEnrichmentLeads(options.registryPath, registryRows) : { added: 0 };
|
|
106
|
+
const additions = candidates.sort((a, b) => a.index - b.index).map((row) => row.candidate);
|
|
107
|
+
const appended = await mergeSourceCandidates(candidatesPath, additions);
|
|
108
|
+
const report: CareerTraceReport = stampReport("career-tracing:1", 1, {
|
|
109
|
+
companiesChecked: seeds.length, ready: appended, alreadyKnown: additions.length - appended,
|
|
110
|
+
unresolved: unresolved.length, rejected: rejected.length, failures: failures.length, matched, candidatesPath, reportPath, registryPath: options.registryPath, registryAdded: registry.added,
|
|
111
|
+
unresolvedCompanies: unresolved.sort((a, b) => a.index - b.index).map((row) => row.issue),
|
|
112
|
+
rejections: rejected.sort((a, b) => a.index - b.index).map((row) => row.issue),
|
|
113
|
+
failureDetails: failures.sort((a, b) => a.index - b.index).map((row) => row.issue),
|
|
114
|
+
});
|
|
115
|
+
await atomicJson(reportPath, report);
|
|
116
|
+
return report;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function readCommonCrawlLeads(path: string) {
|
|
120
|
+
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
121
|
+
assertCompatibleReport(value, "common-crawl-discovery:1", 1);
|
|
122
|
+
if (!isRecord(value) || !Array.isArray(value.leads)) throw new Error("Common Crawl report must contain a leads array");
|
|
123
|
+
return value.leads.flatMap((lead) => {
|
|
124
|
+
if (!isRecord(lead) || typeof lead.sourceUrl !== "string") return [];
|
|
125
|
+
const source = resolveSource(lead.sourceUrl);
|
|
126
|
+
if (!source) return [];
|
|
127
|
+
const reference = isRecord(lead.discoveredFrom) && typeof lead.discoveredFrom.reference === "string" ? lead.discoveredFrom.reference : path;
|
|
128
|
+
return [{ source, reference }];
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function searchForSource(seed: CompanySeed, key: string, fetcher: Fetch, country?: string) {
|
|
133
|
+
const url = new URL("https://api.search.brave.com/res/v1/web/search");
|
|
134
|
+
url.searchParams.set("q", `\"${seed.companyName}\" (site:job-boards.greenhouse.io OR site:jobs.lever.co OR site:jobs.ashbyhq.com OR site:myworkdayjobs.com OR site:recruitee.com)`);
|
|
135
|
+
url.searchParams.set("count", "20");
|
|
136
|
+
if (country) url.searchParams.set("country", country);
|
|
137
|
+
const response = await fetcher(url, { headers: { Accept: "application/json", "X-Subscription-Token": key } });
|
|
138
|
+
if (!response.ok) throw new Error(`Brave Search returned HTTP ${response.status}`);
|
|
139
|
+
const value: unknown = await response.json();
|
|
140
|
+
const results = isRecord(value) && isRecord(value.web) && Array.isArray(value.web.results) ? value.web.results : [];
|
|
141
|
+
for (const result of results) {
|
|
142
|
+
if (!isRecord(result) || typeof result.url !== "string") continue;
|
|
143
|
+
const source = resolveSource(result.url);
|
|
144
|
+
if (source && sourceMatchesCompany(source.token, seed)) return { source, reference: result.url };
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function readSeeds(path: string): Promise<CompanySeed[]> {
|
|
150
|
+
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
151
|
+
if (!Array.isArray(value) || !value.every(isRecord)) throw new Error("Company input must be a JSON array of objects");
|
|
152
|
+
return value as unknown as CompanySeed[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function validateSeed(seed: CompanySeed): string | null {
|
|
156
|
+
if (typeof seed.companyName !== "string" || !seed.companyName.trim()) return "companyName is required";
|
|
157
|
+
if (typeof seed.companyDomain !== "string" || !/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(seed.companyDomain)) return "companyDomain must be a hostname";
|
|
158
|
+
if (seed.careerUrl !== undefined) {
|
|
159
|
+
try {
|
|
160
|
+
const url = new URL(seed.careerUrl);
|
|
161
|
+
if (url.protocol !== "https:") return "careerUrl must use HTTPS";
|
|
162
|
+
const domain = normalizeDomain(seed.companyDomain);
|
|
163
|
+
const host = normalizeDomain(url.hostname);
|
|
164
|
+
if (host !== domain && !host.endsWith(`.${domain}`)) return "careerUrl must belong to companyDomain";
|
|
165
|
+
}
|
|
166
|
+
catch { return "careerUrl must be a valid URL"; }
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function normalizeDomain(value: string): string { return value.toLowerCase().replace(/^www\./, ""); }
|
|
172
|
+
function sourceMatchesCompany(token: string, seed: CompanySeed): boolean {
|
|
173
|
+
const normalize = (value: string) => value.toLowerCase().replace(/\b(inc|llc|ltd|limited|corp|corporation|company)\b/g, "").replace(/[^a-z0-9]/g, "");
|
|
174
|
+
const workdayTenant = token.includes("myworkdayjobs.com/") ? token.split("/")[1] : undefined;
|
|
175
|
+
const source = normalize(workdayTenant ?? token);
|
|
176
|
+
const name = normalize(seed.companyName);
|
|
177
|
+
const domain = normalize(normalizeDomain(seed.companyDomain).split(".")[0] ?? "");
|
|
178
|
+
return source.length >= 3 && (source === name || source === domain);
|
|
179
|
+
}
|
|
180
|
+
function identity(seed: Partial<CompanySeed>) { return { companyName: seed.companyName, companyDomain: seed.companyDomain }; }
|
|
181
|
+
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import type { Company, Job, JobSummary, SearchQuery } from "./types.ts";
|
|
2
|
+
import { classifyJob, isEligibleForCountry, normalizeLocation } from "./locations.ts";
|
|
3
|
+
|
|
4
|
+
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
5
|
+
|
|
6
|
+
export interface Catalog {
|
|
7
|
+
search(query: SearchQuery): Promise<JobSummary[]>;
|
|
8
|
+
get(id: string): Promise<Job | null>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface CatalogOptions {
|
|
12
|
+
companies: Company[];
|
|
13
|
+
fetch?: Fetch;
|
|
14
|
+
cacheTtlMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface GreenhouseJob {
|
|
18
|
+
id: number;
|
|
19
|
+
title: string;
|
|
20
|
+
location: { name: string };
|
|
21
|
+
absolute_url: string;
|
|
22
|
+
updated_at?: string;
|
|
23
|
+
content?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface LeverJob {
|
|
27
|
+
id: string;
|
|
28
|
+
text: string;
|
|
29
|
+
hostedUrl: string;
|
|
30
|
+
categories?: { location?: string };
|
|
31
|
+
descriptionPlain?: string;
|
|
32
|
+
workplaceType?: string;
|
|
33
|
+
createdAt?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface AshbyJob {
|
|
37
|
+
id: string;
|
|
38
|
+
title: string;
|
|
39
|
+
location?: string;
|
|
40
|
+
isRemote?: boolean;
|
|
41
|
+
jobUrl: string;
|
|
42
|
+
descriptionPlain?: string;
|
|
43
|
+
publishedAt?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface WorkdayJob {
|
|
47
|
+
title: string;
|
|
48
|
+
externalPath: string;
|
|
49
|
+
locationsText?: string;
|
|
50
|
+
postedOn?: string;
|
|
51
|
+
bulletFields?: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface RecruiteeJob {
|
|
55
|
+
guid: string;
|
|
56
|
+
title: string;
|
|
57
|
+
city?: string;
|
|
58
|
+
state_name?: string;
|
|
59
|
+
country_code?: string;
|
|
60
|
+
remote?: boolean;
|
|
61
|
+
hybrid?: boolean;
|
|
62
|
+
on_site?: boolean;
|
|
63
|
+
careers_url: string;
|
|
64
|
+
updated_at?: string;
|
|
65
|
+
published_at?: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
requirements?: string;
|
|
68
|
+
translations?: Record<string, { description?: string; requirements?: string; title?: string }>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createCatalog(options: CatalogOptions): Catalog {
|
|
72
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
73
|
+
const cache = new Map<string, { expiresAt: number; jobs: Promise<Job[]> }>();
|
|
74
|
+
|
|
75
|
+
async function fetchJobs(company: Company): Promise<Job[]> {
|
|
76
|
+
const cached = cache.get(company.slug);
|
|
77
|
+
if (cached && cached.expiresAt > Date.now()) return cached.jobs;
|
|
78
|
+
const jobs = fetchSourceJobs(company, fetcher);
|
|
79
|
+
cache.set(company.slug, { expiresAt: Date.now() + (options.cacheTtlMs ?? 5 * 60_000), jobs });
|
|
80
|
+
try {
|
|
81
|
+
return await jobs;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
cache.delete(company.slug);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
async search(query) {
|
|
90
|
+
const jobs = (await Promise.all(options.companies.map((company) => fetchJobs(company).catch(() => [])))).flat();
|
|
91
|
+
|
|
92
|
+
return searchJobs(jobs, query);
|
|
93
|
+
},
|
|
94
|
+
async get(id) {
|
|
95
|
+
const [ats, slug] = id.split(":", 3);
|
|
96
|
+
const company = options.companies.find((candidate) => candidate.ats === ats && candidate.slug === slug);
|
|
97
|
+
if (!company) return null;
|
|
98
|
+
let job = (await fetchJobs(company)).find((candidate) => candidate.id === id) ?? null;
|
|
99
|
+
if (job && company.ats === "workday" && !job.description.trim()) {
|
|
100
|
+
const description = await fetchWorkdayDescription(company, job.url, fetcher);
|
|
101
|
+
job = { ...job, description };
|
|
102
|
+
}
|
|
103
|
+
if (job && !job.description.trim()) throw new Error(`Full description unavailable for job: ${id}`);
|
|
104
|
+
return job;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function searchJobs(jobs: Job[], query: SearchQuery): JobSummary[] {
|
|
110
|
+
return jobs.filter((job) => matches(job, query)).slice(0, query.limit ?? 50).map(toSummary);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface FetchJobsObserver {
|
|
114
|
+
onBackoff?(event: { status: number; delayMs: number }): void;
|
|
115
|
+
workdayPageDelayMs?: number;
|
|
116
|
+
pacingNow?: () => number;
|
|
117
|
+
pacingSleep?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
export async function fetchSourceJobs(company: Company, fetcher: Fetch = globalThis.fetch, signal?: AbortSignal, observer?: FetchJobsObserver): Promise<Job[]> {
|
|
120
|
+
if (company.ats === "workday") return fetchWorkdayJobs(company, fetcher, signal, observer);
|
|
121
|
+
const url = company.ats === "greenhouse"
|
|
122
|
+
? `https://boards-api.greenhouse.io/v1/boards/${encodeURIComponent(company.token)}/jobs?content=true`
|
|
123
|
+
: company.ats === "lever"
|
|
124
|
+
? `https://api.lever.co/v0/postings/${encodeURIComponent(company.token)}?mode=json`
|
|
125
|
+
: company.ats === "ashby"
|
|
126
|
+
? `https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(company.token)}`
|
|
127
|
+
: `https://${encodeURIComponent(company.token)}.recruitee.com/api/offers`;
|
|
128
|
+
const response = await fetchWithRetry(fetcher, url, signal ? { signal } : undefined, company.name, observer);
|
|
129
|
+
if (!response.ok) { await response.body?.cancel().catch(() => undefined); throw new Error(`${company.name} job board returned HTTP ${response.status}`); }
|
|
130
|
+
const body = await response.json();
|
|
131
|
+
return company.ats === "greenhouse"
|
|
132
|
+
? (body as { jobs: GreenhouseJob[] }).jobs.map((job) => normalizeGreenhouse(company, job))
|
|
133
|
+
: company.ats === "lever"
|
|
134
|
+
? (body as LeverJob[]).map((job) => normalizeLever(company, job))
|
|
135
|
+
: company.ats === "ashby"
|
|
136
|
+
? (body as { jobs: AshbyJob[] }).jobs.map((job) => normalizeAshby(company, job))
|
|
137
|
+
: (body as { offers: RecruiteeJob[] }).offers.map((job) => normalizeRecruitee(company, job));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function fetchWorkdayJobs(company: Company, fetcher: Fetch, signal?: AbortSignal, observer?: FetchJobsObserver): Promise<Job[]> {
|
|
141
|
+
const source = parseWorkdayToken(company.token);
|
|
142
|
+
const endpoint = `https://${source.host}/wday/cxs/${encodeURIComponent(source.tenant)}/${encodeURIComponent(source.site)}/jobs`;
|
|
143
|
+
const limit = 20;
|
|
144
|
+
const pageDelayMs = Math.max(0, Math.trunc(observer?.workdayPageDelayMs ?? 0));
|
|
145
|
+
const pacingNow = observer?.pacingNow ?? Date.now;
|
|
146
|
+
const pacingSleep = observer?.pacingSleep ?? abortableDelay;
|
|
147
|
+
let previousPageStart: number | undefined;
|
|
148
|
+
let pageGate = Promise.resolve();
|
|
149
|
+
async function pacePageStart() {
|
|
150
|
+
if (!pageDelayMs) return;
|
|
151
|
+
let release!: () => void;
|
|
152
|
+
const previous = pageGate;
|
|
153
|
+
pageGate = new Promise<void>((resolve) => { release = resolve; });
|
|
154
|
+
await previous;
|
|
155
|
+
try {
|
|
156
|
+
const remaining = previousPageStart === undefined ? 0 : previousPageStart + pageDelayMs - pacingNow();
|
|
157
|
+
if (remaining > 0) await pacingSleep(remaining, signal);
|
|
158
|
+
previousPageStart = pacingNow();
|
|
159
|
+
} finally { release(); }
|
|
160
|
+
}
|
|
161
|
+
async function page(offset: number): Promise<{ total: number; jobs: WorkdayJob[] }> {
|
|
162
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
163
|
+
await pacePageStart();
|
|
164
|
+
const response = await fetcher(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ appliedFacets: {}, limit, offset, searchText: "" }), signal });
|
|
165
|
+
if (response.ok) {
|
|
166
|
+
const body = await response.json() as { total?: unknown; jobPostings?: unknown };
|
|
167
|
+
if (!Number.isInteger(body.total) || !Array.isArray(body.jobPostings)) throw new Error(`${company.name} Workday source returned an invalid payload`);
|
|
168
|
+
return { total: body.total as number, jobs: body.jobPostings as WorkdayJob[] };
|
|
169
|
+
}
|
|
170
|
+
if (!isTransientStatus(response.status) || attempt === 2) { await response.body?.cancel().catch(() => undefined); throw new Error(`${company.name} job board returned HTTP ${response.status}`); }
|
|
171
|
+
const delayMs = retryDelayMs(response, attempt);
|
|
172
|
+
observer?.onBackoff?.({ status: response.status, delayMs });
|
|
173
|
+
await response.body?.cancel().catch(() => undefined);
|
|
174
|
+
await abortableDelay(delayMs, signal);
|
|
175
|
+
}
|
|
176
|
+
throw new Error(`${company.name} Workday source exhausted retries`);
|
|
177
|
+
}
|
|
178
|
+
const first = await page(0);
|
|
179
|
+
if (first.total < first.jobs.length || first.total > 100_000) throw new Error(`${company.name} Workday source reported an invalid total`);
|
|
180
|
+
const offsets = Array.from({ length: Math.ceil(first.total / limit) - 1 }, (_, index) => (index + 1) * limit);
|
|
181
|
+
const pages = new Array<WorkdayJob[]>(offsets.length);
|
|
182
|
+
let cursor = 0;
|
|
183
|
+
async function worker() {
|
|
184
|
+
while (cursor < offsets.length) {
|
|
185
|
+
const index = cursor++;
|
|
186
|
+
const offset = offsets[index]!;
|
|
187
|
+
const result = await page(offset);
|
|
188
|
+
if (result.jobs.length === 0 && offset < first.total) throw new Error(`${company.name} Workday source truncated at offset ${offset} of ${first.total}`);
|
|
189
|
+
pages[index] = result.jobs;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const workerCount = pageDelayMs > 0 ? 1 : Math.min(4, offsets.length);
|
|
193
|
+
await Promise.all(Array.from({ length: workerCount }, worker));
|
|
194
|
+
const jobs = [first.jobs, ...pages].flat().slice(0, first.total);
|
|
195
|
+
if (jobs.length !== first.total) throw new Error(`${company.name} Workday source returned ${jobs.length} of ${first.total} jobs`);
|
|
196
|
+
return jobs.map((job) => normalizeWorkday(company, source, job));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function fetchWithRetry(fetcher: Fetch, input: string | URL, init: RequestInit | undefined, companyName: string, observer?: FetchJobsObserver): Promise<Response> {
|
|
200
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
201
|
+
const response = await fetcher(input, init);
|
|
202
|
+
if (response.ok || !isTransientStatus(response.status)) return response;
|
|
203
|
+
if (attempt === 2) {
|
|
204
|
+
await response.body?.cancel().catch(() => undefined);
|
|
205
|
+
throw new Error(`${companyName} job board returned HTTP ${response.status}`);
|
|
206
|
+
}
|
|
207
|
+
const delayMs = retryDelayMs(response, attempt);
|
|
208
|
+
observer?.onBackoff?.({ status: response.status, delayMs });
|
|
209
|
+
await response.body?.cancel().catch(() => undefined);
|
|
210
|
+
await abortableDelay(delayMs, init?.signal ?? undefined);
|
|
211
|
+
}
|
|
212
|
+
throw new Error(`${companyName} job source exhausted retries`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function isTransientStatus(status: number): boolean {
|
|
216
|
+
return status === 429 || status === 502 || status === 503 || status === 504 || status === 520;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function retryDelayMs(response: Response, attempt: number): number {
|
|
220
|
+
const value = response.headers.get("retry-after");
|
|
221
|
+
if (value !== null) {
|
|
222
|
+
const seconds = Number(value);
|
|
223
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(30_000, seconds * 1_000);
|
|
224
|
+
const date = Date.parse(value);
|
|
225
|
+
if (Number.isFinite(date)) return Math.min(30_000, Math.max(0, date - Date.now()));
|
|
226
|
+
}
|
|
227
|
+
return 500 * 2 ** attempt;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
231
|
+
if (!ms) return Promise.resolve();
|
|
232
|
+
if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("Aborted"));
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
const onAbort = () => {
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
reject(signal?.reason ?? new Error("Aborted"));
|
|
237
|
+
};
|
|
238
|
+
const timer = setTimeout(() => {
|
|
239
|
+
signal?.removeEventListener("abort", onAbort);
|
|
240
|
+
resolve();
|
|
241
|
+
}, ms);
|
|
242
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function normalizeWorkday(company: Company, source: ReturnType<typeof parseWorkdayToken>, job: WorkdayJob): Job {
|
|
247
|
+
const location = job.locationsText ?? "Unspecified";
|
|
248
|
+
const requisition = job.bulletFields?.[0] ?? job.externalPath.split("_").at(-1) ?? job.externalPath;
|
|
249
|
+
return classifyJob({
|
|
250
|
+
id: `workday:${company.slug}:${requisition}`,
|
|
251
|
+
company: company.name,
|
|
252
|
+
title: job.title,
|
|
253
|
+
location,
|
|
254
|
+
remote: /remote/i.test(location),
|
|
255
|
+
workMode: /remote/i.test(location) ? "remote" : "unknown",
|
|
256
|
+
eligibleCountries: [], excludedCountries: [], eligibleRegions: [], eligibilityConfidence: "unknown",
|
|
257
|
+
url: `https://${source.host}/en-US/${source.site}${job.externalPath}`,
|
|
258
|
+
description: "",
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function fetchWorkdayDescription(company: Company, jobUrl: string, fetcher: Fetch): Promise<string> {
|
|
263
|
+
const source = parseWorkdayToken(company.token);
|
|
264
|
+
const path = new URL(jobUrl).pathname.replace(new RegExp(`^/en-US/${escapeRegExp(source.site)}`), "");
|
|
265
|
+
const response = await fetcher(`https://${source.host}/wday/cxs/${encodeURIComponent(source.tenant)}/${encodeURIComponent(source.site)}${path}`);
|
|
266
|
+
if (!response.ok) throw new Error(`${company.name} job detail returned HTTP ${response.status}`);
|
|
267
|
+
const body = await response.json() as { jobPostingInfo?: { jobDescription?: unknown } };
|
|
268
|
+
if (typeof body.jobPostingInfo?.jobDescription !== "string") throw new Error(`${company.name} job detail returned an invalid payload`);
|
|
269
|
+
return stripHtml(body.jobPostingInfo.jobDescription);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseWorkdayToken(token: string): { host: string; tenant: string; site: string } {
|
|
273
|
+
const [host, tenant, site, ...rest] = token.split("/");
|
|
274
|
+
if (!host || !tenant || !site || rest.length) throw new Error(`Invalid Workday source token: ${token}`);
|
|
275
|
+
return { host, tenant, site };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
|
279
|
+
|
|
280
|
+
function normalizeAshby(company: Company, job: AshbyJob): Job {
|
|
281
|
+
const location = job.location ?? "Unspecified";
|
|
282
|
+
return classifyJob({
|
|
283
|
+
id: `ashby:${company.slug}:${job.id}`,
|
|
284
|
+
company: company.name,
|
|
285
|
+
title: job.title,
|
|
286
|
+
location,
|
|
287
|
+
remote: job.isRemote ?? /remote/i.test(location),
|
|
288
|
+
workMode: job.isRemote ? "remote" : "unknown",
|
|
289
|
+
eligibleCountries: [],
|
|
290
|
+
excludedCountries: [],
|
|
291
|
+
eligibleRegions: [],
|
|
292
|
+
eligibilityConfidence: "unknown",
|
|
293
|
+
url: job.jobUrl,
|
|
294
|
+
updatedAt: job.publishedAt,
|
|
295
|
+
description: job.descriptionPlain ?? "",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function normalizeLever(company: Company, job: LeverJob): Job {
|
|
300
|
+
const location = job.categories?.location ?? "Unspecified";
|
|
301
|
+
return classifyJob({
|
|
302
|
+
id: `lever:${company.slug}:${job.id}`,
|
|
303
|
+
company: company.name,
|
|
304
|
+
title: job.text,
|
|
305
|
+
location,
|
|
306
|
+
remote: job.workplaceType === "remote" || /remote/i.test(location),
|
|
307
|
+
workMode: job.workplaceType === "remote" ? "remote" : job.workplaceType === "hybrid" ? "hybrid" : job.workplaceType === "onsite" ? "onsite" : "unknown",
|
|
308
|
+
eligibleCountries: [],
|
|
309
|
+
excludedCountries: [],
|
|
310
|
+
eligibleRegions: [],
|
|
311
|
+
eligibilityConfidence: "unknown",
|
|
312
|
+
url: job.hostedUrl,
|
|
313
|
+
updatedAt: job.createdAt ? new Date(job.createdAt).toISOString() : undefined,
|
|
314
|
+
description: job.descriptionPlain ?? "",
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function normalizeGreenhouse(company: Company, job: GreenhouseJob): Job {
|
|
319
|
+
const location = job.location?.name ?? "Unspecified";
|
|
320
|
+
return classifyJob({
|
|
321
|
+
id: `greenhouse:${company.slug}:${job.id}`,
|
|
322
|
+
company: company.name,
|
|
323
|
+
title: job.title,
|
|
324
|
+
location,
|
|
325
|
+
remote: /remote/i.test(location),
|
|
326
|
+
workMode: "unknown",
|
|
327
|
+
eligibleCountries: [],
|
|
328
|
+
excludedCountries: [],
|
|
329
|
+
eligibleRegions: [],
|
|
330
|
+
eligibilityConfidence: "unknown",
|
|
331
|
+
url: job.absolute_url,
|
|
332
|
+
updatedAt: job.updated_at,
|
|
333
|
+
description: stripHtml(job.content ?? ""),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function normalizeRecruitee(company: Company, job: RecruiteeJob): Job {
|
|
338
|
+
const location = [job.city, job.state_name, job.country_code?.toUpperCase()].filter(Boolean).join(", ") || "Unspecified";
|
|
339
|
+
const translation = job.translations?.en ?? Object.values(job.translations ?? {})[0];
|
|
340
|
+
const description = [translation?.description ?? job.description, translation?.requirements ?? job.requirements].filter(Boolean).map((value) => stripHtml(value!)).join("\n\n");
|
|
341
|
+
const updatedAt = job.updated_at ?? job.published_at;
|
|
342
|
+
return classifyJob({
|
|
343
|
+
id: `recruitee:${company.slug}:${job.guid}`,
|
|
344
|
+
company: company.name,
|
|
345
|
+
title: translation?.title ?? job.title,
|
|
346
|
+
location,
|
|
347
|
+
remote: job.remote === true,
|
|
348
|
+
workMode: job.remote ? "remote" : job.hybrid ? "hybrid" : job.on_site ? "onsite" : "unknown",
|
|
349
|
+
eligibleCountries: [], excludedCountries: [], eligibleRegions: [], eligibilityConfidence: "unknown",
|
|
350
|
+
url: job.careers_url,
|
|
351
|
+
...(updatedAt ? { updatedAt: new Date(updatedAt).toISOString() } : {}),
|
|
352
|
+
description,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function matches(job: Job, query: SearchQuery): boolean {
|
|
357
|
+
const terms = query.query?.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean) ?? [];
|
|
358
|
+
const location = query.location ? normalizeLocation(query.location) : undefined;
|
|
359
|
+
const searchable = `${job.title} ${job.company}`.toLocaleLowerCase();
|
|
360
|
+
return (terms.length === 0 || terms.every((term) => searchable.includes(term)))
|
|
361
|
+
&& (!location || normalizeLocation(job.location).includes(location))
|
|
362
|
+
&& (!query.country || isEligibleForCountry(job, query.country))
|
|
363
|
+
&& (query.remote === undefined || job.remote === query.remote);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function toSummary(job: Job): JobSummary {
|
|
367
|
+
const { description: _description, ...summary } = job;
|
|
368
|
+
return summary;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function stripHtml(value: string): string {
|
|
372
|
+
return value
|
|
373
|
+
.replace(/<br\s*\/?\s*>/gi, "\n")
|
|
374
|
+
.replace(/<\/p>/gi, "\n\n")
|
|
375
|
+
.replace(/<[^>]+>/g, "")
|
|
376
|
+
.replace(/ /g, " ")
|
|
377
|
+
.replace(/&/g, "&")
|
|
378
|
+
.replace(/</g, "<")
|
|
379
|
+
.replace(/>/g, ">")
|
|
380
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
381
|
+
.trim();
|
|
382
|
+
}
|