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,92 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { atomicJson } from "./atomic-file.ts";
|
|
3
|
+
import { deriveLeadState, mergeEnrichmentLeads, readEnrichmentRegistry, retryDisposition, type CompanyMatch, type EnrichmentLead, type IdentityEvidence } from "./enrichment-registry.ts";
|
|
4
|
+
import { stampReport, type ReportMeta } from "./report-meta.ts";
|
|
5
|
+
import { mergeSourceCandidates } from "./source-discovery.ts";
|
|
6
|
+
import { resolveSource } from "./source-verification.ts";
|
|
7
|
+
import type { SourceCandidate } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
interface CompanySeed { companyName: string; companyDomain: string; reference: string }
|
|
10
|
+
export interface SourceEnrichmentReport extends ReportMeta {
|
|
11
|
+
registryPath: string; companiesChecked: number; leadsChecked: number; matched: number; evidenceReady: number; rejected: number; promoted: number; candidatesPath: string;
|
|
12
|
+
companyInputs: string[];
|
|
13
|
+
states: Record<string, number>;
|
|
14
|
+
retry: Record<string, number>;
|
|
15
|
+
registryBytes: number; registryLockHeldMs: number; registryMergeDurationMs: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function enrichSourcesFromCompanies(registryPath: string, companiesPath: string | string[], candidatesPath: string, reportPath: string, options: { evidenceKind?: "authoritative_dataset" | "company_registry"; now?: Date } = {}): Promise<SourceEnrichmentReport> {
|
|
19
|
+
const now = options.now ?? new Date();
|
|
20
|
+
const companyInputs = Array.isArray(companiesPath) ? companiesPath : [companiesPath];
|
|
21
|
+
const registry = await readEnrichmentRegistry(registryPath);
|
|
22
|
+
const companies = await readCompanies(companyInputs);
|
|
23
|
+
const additions: EnrichmentLead[] = [];
|
|
24
|
+
for (const lead of registry.leads) {
|
|
25
|
+
if (!resolveSource(lead.sourceUrl)) continue;
|
|
26
|
+
const matches = companies.filter((company) => sourceMatchesCompany(lead.token, company));
|
|
27
|
+
const identities = new Set(matches.map((company) => company.companyDomain));
|
|
28
|
+
if (identities.size !== 1) continue;
|
|
29
|
+
const companyMatches: CompanyMatch[] = matches.map((company) => ({ companyName: company.companyName, companyDomain: company.companyDomain, method: "normalized_token", reference: company.reference }));
|
|
30
|
+
const identityEvidence: IdentityEvidence[] = options.evidenceKind ? matches.map((company) => ({ companyName: company.companyName, companyDomain: company.companyDomain, kind: options.evidenceKind!, reference: company.reference, observedAt: now.toISOString() })) : [];
|
|
31
|
+
additions.push({ ...lead, companyMatches, identityEvidence });
|
|
32
|
+
}
|
|
33
|
+
const merge = await mergeEnrichmentLeads(registryPath, additions, now);
|
|
34
|
+
const enriched = await readEnrichmentRegistry(registryPath);
|
|
35
|
+
const candidates: SourceCandidate[] = enriched.leads.flatMap((lead) => {
|
|
36
|
+
if (deriveLeadState(lead) !== "evidence_ready") return [];
|
|
37
|
+
if (["cooling_down", "repeatedly_failing"].includes(retryDisposition(lead, now))) return [];
|
|
38
|
+
const identity = winningIdentity(lead);
|
|
39
|
+
if (!identity) return [];
|
|
40
|
+
return [{ companyName: identity.companyName, companyDomain: identity.companyDomain, sourceUrl: lead.sourceUrl,
|
|
41
|
+
discoveredFrom: lead.discoveredFrom[0] ?? { channel: "dataset", reference: identity.reference },
|
|
42
|
+
domainEvidence: { kind: identity.kind === "provider_structured_domain" ? "authoritative_dataset" : identity.kind, reference: identity.reference } }];
|
|
43
|
+
});
|
|
44
|
+
const promoted = await mergeSourceCandidates(candidatesPath, candidates);
|
|
45
|
+
const states = countStates(enriched.leads);
|
|
46
|
+
const retry = countRetry(enriched.leads, now);
|
|
47
|
+
const report = stampReport("source-enrichment:1", 1, {
|
|
48
|
+
registryPath, companyInputs, companiesChecked: companies.length, leadsChecked: enriched.leads.length,
|
|
49
|
+
matched: states.matched ?? 0, evidenceReady: states.evidence_ready ?? 0, rejected: states.rejected ?? 0,
|
|
50
|
+
promoted, candidatesPath, states, retry, registryBytes: merge.bytes, registryLockHeldMs: merge.lockHeldMs, registryMergeDurationMs: merge.durationMs,
|
|
51
|
+
});
|
|
52
|
+
await atomicJson(reportPath, report);
|
|
53
|
+
return report;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function winningIdentity(lead: EnrichmentLead): IdentityEvidence | undefined {
|
|
57
|
+
const qualifying = lead.identityEvidence.filter((evidence) => !["lever", "ashby"].includes(lead.ats) || ["provider_structured_domain", "company_redirect"].includes(evidence.kind));
|
|
58
|
+
const rank = { provider_structured_domain: 4, company_redirect: 3, company_registry: 2, authoritative_dataset: 1 } as const;
|
|
59
|
+
return [...qualifying].sort((left, right) => rank[right.kind] - rank[left.kind] || left.companyDomain.localeCompare(right.companyDomain))[0];
|
|
60
|
+
}
|
|
61
|
+
function countStates(leads: EnrichmentLead[]): Record<string, number> { const counts: Record<string, number> = {}; for (const lead of leads) { const state = deriveLeadState(lead); counts[state] = (counts[state] ?? 0) + 1; } return counts; }
|
|
62
|
+
function countRetry(leads: EnrichmentLead[], now: Date): Record<string, number> { const counts: Record<string, number> = {}; for (const lead of leads) { const state = retryDisposition(lead, now); counts[state] = (counts[state] ?? 0) + 1; } return counts; }
|
|
63
|
+
async function readCompanies(paths: string[]): Promise<CompanySeed[]> {
|
|
64
|
+
const companies: CompanySeed[] = [];
|
|
65
|
+
for (const path of paths) {
|
|
66
|
+
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
67
|
+
const catalog = !Array.isArray(value) && isRecord(value);
|
|
68
|
+
const rows = Array.isArray(value) ? value : catalog ? Object.values(value) : [];
|
|
69
|
+
if (!rows.every((company) => isRecord(company) && typeof (company.companyName ?? company.name) === "string" && String(company.companyName ?? company.name).trim() && typeof company.companyDomain === "string" && /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(company.companyDomain))) {
|
|
70
|
+
throw new Error(`Company input ${path} must be a seed array or verified catalog with valid names and company domains`);
|
|
71
|
+
}
|
|
72
|
+
if (catalog && !rows.every(isVerifiedCatalogRecord)) {
|
|
73
|
+
throw new Error(`Company input ${path} is an object but does not contain verified catalog records`);
|
|
74
|
+
}
|
|
75
|
+
companies.push(...rows.map((company) => ({ companyName: String(company.companyName ?? company.name).trim(), companyDomain: String(company.companyDomain).toLowerCase().replace(/^www\./, ""), reference: path })));
|
|
76
|
+
}
|
|
77
|
+
return companies;
|
|
78
|
+
}
|
|
79
|
+
function isVerifiedCatalogRecord(company: unknown): boolean {
|
|
80
|
+
if (!isRecord(company) || typeof company.ats !== "string" || typeof company.token !== "string" || typeof company.sourceUrl !== "string" || !isRecord(company.verification)) return false;
|
|
81
|
+
const source = resolveSource(company.sourceUrl);
|
|
82
|
+
const verification = company.verification;
|
|
83
|
+
return source?.ats === company.ats && source.token.toLowerCase() === company.token.toLowerCase()
|
|
84
|
+
&& verification.canonicalSourceUrl === source.canonicalSourceUrl
|
|
85
|
+
&& typeof verification.checkedAt === "string" && Number.isFinite(Date.parse(verification.checkedAt))
|
|
86
|
+
&& typeof verification.observedCompanyName === "string" && verification.observedCompanyName.length > 0
|
|
87
|
+
&& ["provider_company_name", "provider_tenant", "structured_domain_link", "company_redirect"].includes(String(verification.identityEvidence))
|
|
88
|
+
&& typeof verification.contentType === "string" && typeof verification.payloadVersion === "string" && verification.payloadVersion.length > 0
|
|
89
|
+
&& Number.isInteger(verification.jobCount) && Number(verification.jobCount) > 0;
|
|
90
|
+
}
|
|
91
|
+
function sourceMatchesCompany(token: string, company: CompanySeed): boolean { const normalize = (value: string) => value.toLowerCase().replace(/\b(inc|llc|ltd|limited|corp|corporation|company)\b/g, "").replace(/[^a-z0-9]/g, ""); const tenant = token.includes("myworkdayjobs.com/") ? token.split("/")[1] : token; const source = normalize(tenant ?? token); const name = normalize(company.companyName); const domain = normalize(company.companyDomain.replace(/^www\./, "").split(".")[0] ?? ""); return source.length >= 3 && (source === name || source === domain); }
|
|
92
|
+
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { atomicJson } from "./atomic-file.ts";
|
|
4
|
+
import { withFileLock } from "./file-lock.ts";
|
|
5
|
+
import { resolveSource, verifyCandidates } from "./source-verification.ts";
|
|
6
|
+
import { deriveLeadState, mergeEnrichmentLeads, readEnrichmentRegistry, retryDisposition, strongestEvidenceRank, transientAttempt, type EnrichmentLead, type IdentityEvidence, type LeadAttempt } from "./enrichment-registry.ts";
|
|
7
|
+
import type { RejectedSource, SourceCandidate, VerifiedCompany } from "./types.ts";
|
|
8
|
+
import type { Ats } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
interface PipelineOptions {
|
|
11
|
+
fetch?: (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
12
|
+
now?: () => Date;
|
|
13
|
+
concurrency?: number;
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
requireCountry?: string;
|
|
16
|
+
countryGateTimeoutMs?: number;
|
|
17
|
+
registryPath?: string;
|
|
18
|
+
retryDeferred?: boolean;
|
|
19
|
+
limit?: number;
|
|
20
|
+
providerConcurrency?: Partial<Record<Ats, number>>;
|
|
21
|
+
batchStatePath?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SourcePipelineReport {
|
|
25
|
+
candidates: number;
|
|
26
|
+
newCandidates: number;
|
|
27
|
+
selected: number;
|
|
28
|
+
selectedNew: number;
|
|
29
|
+
deferred: number;
|
|
30
|
+
verified: number;
|
|
31
|
+
rejected: number;
|
|
32
|
+
retryableFailures: number;
|
|
33
|
+
preserved: number;
|
|
34
|
+
carriedForward: number;
|
|
35
|
+
batchStatePath: string;
|
|
36
|
+
catalogPath: string;
|
|
37
|
+
rejections: RejectedSource[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function runSourceVerification(candidatesPath: string, catalogPath: string, options: PipelineOptions = {}): Promise<SourcePipelineReport> {
|
|
41
|
+
await mkdir(dirname(catalogPath), { recursive: true });
|
|
42
|
+
const batchStatePath = options.batchStatePath ?? `${catalogPath}.verification-state.json`;
|
|
43
|
+
if (resolve(batchStatePath) === resolve(catalogPath)) throw new Error("Source verification batch state must not overwrite the catalog");
|
|
44
|
+
return withFileLock(batchStatePath,
|
|
45
|
+
() => withFileLock(catalogPath, () => runSourceVerificationUnlocked(candidatesPath, catalogPath, { ...options, batchStatePath }), { operation: "verify sources" }),
|
|
46
|
+
{ operation: "schedule source verification batch" });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function runSourceVerificationUnlocked(candidatesPath: string, catalogPath: string, options: PipelineOptions): Promise<SourcePipelineReport> {
|
|
50
|
+
const candidates = await readCandidates(candidatesPath);
|
|
51
|
+
const prior = await readPriorCatalog(catalogPath);
|
|
52
|
+
const batchStatePath = options.batchStatePath ?? `${catalogPath}.verification-state.json`;
|
|
53
|
+
const batchState = await readBatchState(batchStatePath);
|
|
54
|
+
const newCandidates = candidates.filter((candidate) => !candidateInCatalog(candidate, prior)).length;
|
|
55
|
+
let eligible = candidates;
|
|
56
|
+
const deferred: RejectedSource[] = [];
|
|
57
|
+
if (options.registryPath) {
|
|
58
|
+
const registry = await readEnrichmentRegistry(options.registryPath);
|
|
59
|
+
const byKey = new Map(registry.leads.map((lead) => [lead.sourceKey, lead]));
|
|
60
|
+
eligible = candidates.filter((candidate) => {
|
|
61
|
+
const source = resolveSource(candidate.sourceUrl);
|
|
62
|
+
const lead = source ? byKey.get(`${source.ats}:${source.token.toLowerCase()}`) : undefined;
|
|
63
|
+
if (!lead) return true;
|
|
64
|
+
const state = deriveLeadState(lead);
|
|
65
|
+
const retry = retryDisposition(lead, options.now?.() ?? new Date());
|
|
66
|
+
const providerCanAcquireIdentity = source?.ats === "recruitee" && state === "matched";
|
|
67
|
+
if ((["evidence_ready", "verified"].includes(state) || providerCanAcquireIdentity) && (options.retryDeferred || !["cooling_down", "repeatedly_failing"].includes(retry))) return true;
|
|
68
|
+
deferred.push({ ...candidate, reason: "unreachable", detail: `Verification deferred by enrichment policy (${state}, ${retry})` });
|
|
69
|
+
return false;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const limit = options.limit === undefined ? eligible.length : Math.max(1, Math.trunc(options.limit));
|
|
73
|
+
const selected = [...eligible].sort((left, right) => compareVerificationPriority(left, right, prior, batchState)).slice(0, limit);
|
|
74
|
+
const conflicts = selected.filter((candidate) => candidateConflictsCatalog(candidate, prior));
|
|
75
|
+
const verifiable = selected.filter((candidate) => !candidateConflictsCatalog(candidate, prior));
|
|
76
|
+
const result = await verifyCandidates(verifiable, options);
|
|
77
|
+
result.rejected.unshift(...conflicts.map((candidate) => ({ ...candidate, reason: "duplicate_slug" as const, detail: `Catalog slug ${candidateSlug(candidate)} belongs to a different verified company` })));
|
|
78
|
+
const retryableFailures = result.rejected.filter((candidate) => retryableRejection(candidate.reason)).length;
|
|
79
|
+
const freshlyVerified = new Set(result.verified.map((company) => company.slug));
|
|
80
|
+
const verifiedDomains = new Set(result.verified.map((company) => company.companyDomain));
|
|
81
|
+
const verifiedSources = new Set(result.verified.map((company) => `${company.ats}:${company.token.toLocaleLowerCase()}`));
|
|
82
|
+
const preserved = result.rejected.flatMap((candidate) => {
|
|
83
|
+
if (!preservableRejection(candidate.reason)) return [];
|
|
84
|
+
const slug = candidate.slug ?? slugFromDomain(candidate.companyDomain);
|
|
85
|
+
if (freshlyVerified.has(slug)) return [];
|
|
86
|
+
const previous = prior[slug];
|
|
87
|
+
if (!previous || previous.companyDomain !== candidate.companyDomain.toLocaleLowerCase()) return [];
|
|
88
|
+
if (verifiedDomains.has(previous.companyDomain) || verifiedSources.has(`${previous.ats}:${previous.token.toLocaleLowerCase()}`)) return [];
|
|
89
|
+
return [{ slug, ...previous } as VerifiedCompany];
|
|
90
|
+
});
|
|
91
|
+
const selectedSlugs = new Set(verifiable.flatMap((candidate) => { const slug = candidateSlug(candidate); return slug ? [slug] : []; }));
|
|
92
|
+
const untouched = Object.entries(prior).flatMap(([slug, company]) => selectedSlugs.has(slug) ? [] : [{ slug, ...company } as VerifiedCompany]);
|
|
93
|
+
const attemptedAt = (options.now?.() ?? new Date()).toISOString();
|
|
94
|
+
for (const candidate of selected) batchState.attempts[sourceKeyForCandidate(candidate)] = attemptedAt;
|
|
95
|
+
batchState.updatedAt = attemptedAt;
|
|
96
|
+
await atomicJson(batchStatePath, batchState);
|
|
97
|
+
await writeCatalog(catalogPath, [...result.verified, ...preserved, ...untouched]);
|
|
98
|
+
if (options.registryPath) await recordRegistryOutcomes(options.registryPath, result, options.now?.() ?? new Date());
|
|
99
|
+
return {
|
|
100
|
+
candidates: candidates.length,
|
|
101
|
+
newCandidates,
|
|
102
|
+
selected: selected.length,
|
|
103
|
+
selectedNew: selected.filter((candidate) => !candidateInCatalog(candidate, prior)).length,
|
|
104
|
+
deferred: deferred.length + Math.max(0, eligible.length - selected.length),
|
|
105
|
+
verified: result.verified.length,
|
|
106
|
+
rejected: result.rejected.length,
|
|
107
|
+
retryableFailures,
|
|
108
|
+
preserved: preserved.length,
|
|
109
|
+
carriedForward: untouched.length,
|
|
110
|
+
batchStatePath,
|
|
111
|
+
catalogPath,
|
|
112
|
+
rejections: result.rejected,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function candidateInCatalog(candidate: SourceCandidate, catalog: Record<string, Omit<VerifiedCompany, "slug">>): boolean {
|
|
117
|
+
if (typeof candidate.companyDomain !== "string" || typeof candidate.sourceUrl !== "string") return false;
|
|
118
|
+
const slug = candidateSlug(candidate);
|
|
119
|
+
if (!slug) return false;
|
|
120
|
+
const previous = catalog[slug];
|
|
121
|
+
return previous?.companyDomain === candidate.companyDomain.toLowerCase() && previous.sourceUrl === resolveSource(candidate.sourceUrl)?.canonicalSourceUrl;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface VerificationBatchState { version: 1; updatedAt: string; attempts: Record<string, string> }
|
|
125
|
+
|
|
126
|
+
function compareVerificationPriority(left: SourceCandidate, right: SourceCandidate, catalog: Record<string, Omit<VerifiedCompany, "slug">>, state: VerificationBatchState): number {
|
|
127
|
+
const leftAttempt = attemptTime(left, state);
|
|
128
|
+
const rightAttempt = attemptTime(right, state);
|
|
129
|
+
if (leftAttempt !== rightAttempt) return leftAttempt - rightAttempt;
|
|
130
|
+
const leftKnown = candidateInCatalog(left, catalog);
|
|
131
|
+
const rightKnown = candidateInCatalog(right, catalog);
|
|
132
|
+
if (leftKnown !== rightKnown) return Number(leftKnown) - Number(rightKnown);
|
|
133
|
+
if (!leftKnown) return 0;
|
|
134
|
+
return verificationTime(left, catalog) - verificationTime(right, catalog);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function attemptTime(candidate: SourceCandidate, state: VerificationBatchState): number {
|
|
138
|
+
const value = Date.parse(state.attempts[sourceKeyForCandidate(candidate)] ?? "");
|
|
139
|
+
return Number.isFinite(value) ? value : Number.NEGATIVE_INFINITY;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function verificationTime(candidate: SourceCandidate, catalog: Record<string, Omit<VerifiedCompany, "slug">>): number {
|
|
143
|
+
const slug = candidateSlug(candidate);
|
|
144
|
+
const value = slug ? Date.parse(catalog[slug]?.verification.checkedAt ?? "") : Number.NaN;
|
|
145
|
+
return Number.isFinite(value) ? value : Number.NEGATIVE_INFINITY;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function candidateConflictsCatalog(candidate: SourceCandidate, catalog: Record<string, Omit<VerifiedCompany, "slug">>): boolean {
|
|
149
|
+
if (typeof candidate.companyDomain !== "string" || typeof candidate.sourceUrl !== "string") return false;
|
|
150
|
+
const slug = candidateSlug(candidate);
|
|
151
|
+
if (!slug || !catalog[slug]) return false;
|
|
152
|
+
return !candidateInCatalog(candidate, catalog);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function candidateSlug(candidate: SourceCandidate): string | undefined {
|
|
156
|
+
if (typeof candidate.slug === "string" && candidate.slug) return candidate.slug;
|
|
157
|
+
return typeof candidate.companyDomain === "string" && candidate.companyDomain ? slugFromDomain(candidate.companyDomain) : undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sourceKeyForCandidate(candidate: SourceCandidate): string {
|
|
161
|
+
if (typeof candidate.sourceUrl === "string") {
|
|
162
|
+
const source = resolveSource(candidate.sourceUrl);
|
|
163
|
+
if (source) return `${source.ats}:${source.token.toLowerCase()}`;
|
|
164
|
+
}
|
|
165
|
+
return `candidate:${String(candidate.companyDomain ?? "").toLowerCase()}|${String(candidate.sourceUrl ?? "")}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function readBatchState(path: string): Promise<VerificationBatchState> {
|
|
169
|
+
try {
|
|
170
|
+
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
171
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value) && (value as { version?: unknown }).version === 1 && typeof (value as { attempts?: unknown }).attempts === "object" && (value as { attempts?: unknown }).attempts !== null && !Array.isArray((value as { attempts?: unknown }).attempts)) {
|
|
172
|
+
const updatedAt = (value as { updatedAt?: unknown }).updatedAt;
|
|
173
|
+
const entries = Object.entries((value as { attempts: Record<string, unknown> }).attempts);
|
|
174
|
+
if (typeof updatedAt === "string" && Number.isFinite(Date.parse(updatedAt)) && entries.every((entry): entry is [string, string] => entry[0].length > 0 && typeof entry[1] === "string" && Number.isFinite(Date.parse(entry[1])))) {
|
|
175
|
+
return { version: 1, updatedAt, attempts: Object.fromEntries(entries) };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`Invalid source verification batch state: ${path}`);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return { version: 1, updatedAt: new Date(0).toISOString(), attempts: {} };
|
|
181
|
+
if (error instanceof SyntaxError) throw new Error(`Invalid source verification batch state JSON: ${path}`);
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function recordRegistryOutcomes(registryPath: string, result: Awaited<ReturnType<typeof verifyCandidates>>, now: Date): Promise<void> {
|
|
187
|
+
const registry = await readEnrichmentRegistry(registryPath);
|
|
188
|
+
const byKey = new Map(registry.leads.map((lead) => [lead.sourceKey, lead]));
|
|
189
|
+
const additions: EnrichmentLead[] = [];
|
|
190
|
+
for (const company of result.verified) {
|
|
191
|
+
const lead = byKey.get(`${company.ats}:${company.token.toLowerCase()}`);
|
|
192
|
+
if (!lead) continue;
|
|
193
|
+
const attempt: LeadAttempt = { attemptedAt: now.toISOString(), outcome: "success" };
|
|
194
|
+
const evidence: IdentityEvidence[] = company.verification.identityEvidence === "structured_domain_link"
|
|
195
|
+
? [{ companyName: company.name, companyDomain: company.companyDomain, kind: "provider_structured_domain", reference: company.sourceUrl, observedAt: now.toISOString() }]
|
|
196
|
+
: company.verification.identityEvidence === "company_redirect" && company.domainEvidence?.kind === "company_redirect"
|
|
197
|
+
? [{ companyName: company.name, companyDomain: company.companyDomain, kind: "company_redirect", reference: company.domainEvidence.reference, observedAt: now.toISOString() }]
|
|
198
|
+
: [];
|
|
199
|
+
additions.push({ ...lead, identityEvidence: evidence, attempts: [attempt], promotedAt: now.toISOString() });
|
|
200
|
+
}
|
|
201
|
+
for (const rejection of result.rejected) {
|
|
202
|
+
if (rejection.detail.startsWith("Verification deferred by enrichment policy")) continue;
|
|
203
|
+
const source = resolveSource(rejection.sourceUrl);
|
|
204
|
+
if (!source) continue;
|
|
205
|
+
const lead = byKey.get(`${source.ats}:${source.token.toLowerCase()}`);
|
|
206
|
+
if (!lead) continue;
|
|
207
|
+
const transient = retryableRejection(rejection.reason);
|
|
208
|
+
const consecutive = [...lead.attempts].reverse().findIndex((attempt) => attempt.outcome !== "transient_failure");
|
|
209
|
+
const attempt: LeadAttempt = transient
|
|
210
|
+
? transientAttempt(now, consecutive < 0 ? lead.attempts.length + 1 : consecutive + 1, rejection.reason, rejection.detail)
|
|
211
|
+
: { attemptedAt: now.toISOString(), outcome: "permanent_failure", category: rejection.reason, detail: rejection.detail, evidenceRank: strongestEvidenceRank(lead) };
|
|
212
|
+
additions.push({ ...lead, attempts: [attempt] });
|
|
213
|
+
}
|
|
214
|
+
if (additions.length) await mergeEnrichmentLeads(registryPath, additions, now);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function preservableRejection(reason: RejectedSource["reason"]): boolean { return ["unreachable", "invalid_payload", "empty_board", "no_country_jobs"].includes(reason); }
|
|
218
|
+
function retryableRejection(reason: RejectedSource["reason"]): boolean { return reason === "unreachable"; }
|
|
219
|
+
|
|
220
|
+
async function readPriorCatalog(path: string): Promise<Record<string, Omit<VerifiedCompany, "slug">>> {
|
|
221
|
+
try {
|
|
222
|
+
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
223
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, Omit<VerifiedCompany, "slug">> : {};
|
|
224
|
+
} catch { return {}; }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function readCandidates(path: string): Promise<SourceCandidate[]> {
|
|
228
|
+
let value: unknown;
|
|
229
|
+
try { value = JSON.parse(await readFile(path, "utf8")); }
|
|
230
|
+
catch (error) { throw new Error(`Cannot read candidate file ${path}: ${error instanceof Error ? error.message : String(error)}`); }
|
|
231
|
+
if (!Array.isArray(value) || !value.every((candidate) => typeof candidate === "object" && candidate !== null && !Array.isArray(candidate))) {
|
|
232
|
+
throw new Error("Candidate file must contain a JSON array of objects");
|
|
233
|
+
}
|
|
234
|
+
return value as SourceCandidate[];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function writeCatalog(path: string, companies: VerifiedCompany[]): Promise<void> {
|
|
238
|
+
const catalog = Object.fromEntries(companies.sort((a, b) => a.slug.localeCompare(b.slug)).map(({ slug, ...company }) => [slug, company]));
|
|
239
|
+
await mkdir(dirname(path), { recursive: true });
|
|
240
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
241
|
+
await writeFile(temporary, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
242
|
+
await rename(temporary, path);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function slugFromDomain(domain: string): string { return domain.toLocaleLowerCase().replace(/^www\./, "").split(".")[0]!.replace(/[^a-z0-9-]/g, "-"); }
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import type { Ats, RejectedSource, SourceCandidate, SourceRejectionReason, SourceVerificationResult, VerifiedCompany } from "./types.ts";
|
|
2
|
+
import { fetchSafeHead, type HeadTransport, type ResolveHost } from "./safe-head.ts";
|
|
3
|
+
import { abortableDelay, fetchSourceJobs, isTransientStatus, retryDelayMs } from "./catalog.ts";
|
|
4
|
+
import { isEligibleForCountry } from "./locations.ts";
|
|
5
|
+
|
|
6
|
+
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
7
|
+
|
|
8
|
+
interface VerificationOptions {
|
|
9
|
+
fetch?: Fetch;
|
|
10
|
+
now?: () => Date;
|
|
11
|
+
concurrency?: number;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
resolveHost?: ResolveHost;
|
|
14
|
+
headTransport?: HeadTransport;
|
|
15
|
+
requireCountry?: string;
|
|
16
|
+
countryGateTimeoutMs?: number;
|
|
17
|
+
providerConcurrency?: Partial<Record<Ats, number>>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ResolvedSource { ats: Ats; token: string; canonicalSourceUrl: string; structuredEndpoint: string }
|
|
21
|
+
|
|
22
|
+
export async function verifyCandidates(candidates: SourceCandidate[], options: VerificationOptions = {}): Promise<SourceVerificationResult> {
|
|
23
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
24
|
+
const now = options.now ?? (() => new Date());
|
|
25
|
+
const concurrency = Math.max(1, Math.trunc(options.concurrency ?? 10));
|
|
26
|
+
const timeoutMs = Math.max(1, Math.trunc(options.timeoutMs ?? 30_000));
|
|
27
|
+
const countryGateTimeoutMs = Math.max(1, Math.trunc(options.countryGateTimeoutMs ?? 120_000));
|
|
28
|
+
const probed: Array<{ index: number; candidate: SourceCandidate; sourceKey: string; value: VerifiedCompany }> = [];
|
|
29
|
+
const rejected: Array<{ index: number; value: RejectedSource }> = [];
|
|
30
|
+
const providerLimits = new Map<Ats, Semaphore>();
|
|
31
|
+
const providerCooldowns = new Map<Ats, ProviderCooldown>();
|
|
32
|
+
for (const ats of ["greenhouse", "lever", "ashby", "workday", "recruitee"] as const) {
|
|
33
|
+
const fallback = ats === "workday" ? 2 : concurrency;
|
|
34
|
+
providerLimits.set(ats, new Semaphore(Math.max(1, Math.trunc(options.providerConcurrency?.[ats] ?? fallback))));
|
|
35
|
+
providerCooldowns.set(ats, new ProviderCooldown());
|
|
36
|
+
}
|
|
37
|
+
const scheduled = scheduleCandidates(candidates);
|
|
38
|
+
let cursor = 0;
|
|
39
|
+
|
|
40
|
+
async function worker() {
|
|
41
|
+
while (cursor < scheduled.length) {
|
|
42
|
+
const row = scheduled[cursor++];
|
|
43
|
+
const index = row?.index ?? -1;
|
|
44
|
+
const candidate = row?.candidate;
|
|
45
|
+
if (!candidate) continue;
|
|
46
|
+
const invalid = validateCandidate(candidate);
|
|
47
|
+
if (invalid) { rejected.push({ index, value: rejection(candidate, "invalid_candidate", invalid) }); continue; }
|
|
48
|
+
const source = resolveSource(candidate.sourceUrl);
|
|
49
|
+
if (!source) { rejected.push({ index, value: rejection(candidate, "unsupported_source", "URL is not a supported Greenhouse, Lever, Ashby, Workday, or Recruitee job source") }); continue; }
|
|
50
|
+
const sourceKey = `${source.ats}:${source.token.toLocaleLowerCase()}`;
|
|
51
|
+
const companyKey = candidate.companyDomain.toLocaleLowerCase();
|
|
52
|
+
const slug = candidate.slug ?? slugFromDomain(candidate.companyDomain);
|
|
53
|
+
|
|
54
|
+
const release = await providerLimits.get(source.ats)!.acquire();
|
|
55
|
+
const providerFetch = providerCooldowns.get(source.ats)!.wrap(fetcher);
|
|
56
|
+
try {
|
|
57
|
+
const evidence = await probe(candidate, source, providerFetch, timeoutMs, options.resolveHost, options.headTransport);
|
|
58
|
+
if (!identityMatches(candidate.companyName, candidate.companyDomain, evidence.observedCompanyName, source.token)) {
|
|
59
|
+
rejected.push({ index, value: rejection(candidate, "identity_mismatch", `Expected ${candidate.companyName}; observed ${evidence.observedCompanyName}`) });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const value: VerifiedCompany = {
|
|
63
|
+
slug, name: candidate.companyName.trim(), ats: source.ats, token: source.token,
|
|
64
|
+
cohorts: normalizeCohorts(candidate.cohorts), companyDomain: companyKey, sourceUrl: source.canonicalSourceUrl,
|
|
65
|
+
discoveredFrom: candidate.discoveredFrom,
|
|
66
|
+
domainEvidence: candidate.domainEvidence,
|
|
67
|
+
verification: { ...evidence, checkedAt: now().toISOString(), canonicalSourceUrl: source.canonicalSourceUrl },
|
|
68
|
+
};
|
|
69
|
+
if (options.requireCountry) {
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
const timer = setTimeout(() => controller.abort(new Error(`Country gate timed out after ${countryGateTimeoutMs}ms`)), countryGateTimeoutMs);
|
|
72
|
+
try {
|
|
73
|
+
const jobs = await fetchSourceJobs(value, providerFetch, controller.signal);
|
|
74
|
+
if (!jobs.some((job) => isEligibleForCountry(job, options.requireCountry!))) {
|
|
75
|
+
rejected.push({ index, value: rejection(candidate, "no_country_jobs", `Complete source feed has no jobs eligible for ${options.requireCountry}`) });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
} finally { clearTimeout(timer); }
|
|
79
|
+
}
|
|
80
|
+
probed.push({ index, candidate, sourceKey, value });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const reason = error instanceof VerificationError ? error.reason : "unreachable";
|
|
83
|
+
rejected.push({ index, value: rejection(candidate, reason, error instanceof Error ? error.message : String(error)) });
|
|
84
|
+
} finally { release(); }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, candidates.length) }, worker));
|
|
89
|
+
const verified: Array<{ index: number; value: VerifiedCompany }> = [];
|
|
90
|
+
const seenSources = new Set<string>();
|
|
91
|
+
const seenCompanies = new Set<string>();
|
|
92
|
+
const seenSlugs = new Set<string>();
|
|
93
|
+
for (const row of probed.sort((a, b) => a.index - b.index)) {
|
|
94
|
+
const companyKey = row.value.companyDomain;
|
|
95
|
+
if (seenSources.has(row.sourceKey)) rejected.push({ index: row.index, value: rejection(row.candidate, "duplicate_source", `Duplicate of ${row.sourceKey}`) });
|
|
96
|
+
else if (seenCompanies.has(companyKey)) rejected.push({ index: row.index, value: rejection(row.candidate, "duplicate_company", `Duplicate company domain: ${companyKey}`) });
|
|
97
|
+
else if (seenSlugs.has(row.value.slug)) rejected.push({ index: row.index, value: rejection(row.candidate, "duplicate_slug", `Generated catalog slug is already used: ${row.value.slug}`) });
|
|
98
|
+
else {
|
|
99
|
+
seenSources.add(row.sourceKey); seenCompanies.add(companyKey); seenSlugs.add(row.value.slug);
|
|
100
|
+
verified.push({ index: row.index, value: row.value });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
verified: verified.sort((a, b) => a.index - b.index).map((row) => row.value),
|
|
105
|
+
rejected: rejected.sort((a, b) => a.index - b.index).map((row) => row.value),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function scheduleCandidates(candidates: SourceCandidate[]): Array<{ index: number; candidate: SourceCandidate }> {
|
|
110
|
+
const groups = new Map<string, Array<{ index: number; candidate: SourceCandidate }>>();
|
|
111
|
+
candidates.forEach((candidate, index) => {
|
|
112
|
+
const provider = typeof candidate.sourceUrl === "string" ? resolveSource(candidate.sourceUrl)?.ats ?? "other" : "other";
|
|
113
|
+
const group = groups.get(provider) ?? [];
|
|
114
|
+
group.push({ index, candidate });
|
|
115
|
+
groups.set(provider, group);
|
|
116
|
+
});
|
|
117
|
+
const scheduled: Array<{ index: number; candidate: SourceCandidate }> = [];
|
|
118
|
+
while ([...groups.values()].some((group) => group.length)) {
|
|
119
|
+
for (const group of groups.values()) { const row = group.shift(); if (row) scheduled.push(row); }
|
|
120
|
+
}
|
|
121
|
+
return scheduled;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
class Semaphore {
|
|
125
|
+
private active = 0;
|
|
126
|
+
private readonly waiters: Array<() => void> = [];
|
|
127
|
+
constructor(private readonly limit: number) {}
|
|
128
|
+
async acquire(): Promise<() => void> {
|
|
129
|
+
if (this.active >= this.limit) await new Promise<void>((resolve) => this.waiters.push(resolve));
|
|
130
|
+
this.active += 1;
|
|
131
|
+
return () => {
|
|
132
|
+
this.active -= 1;
|
|
133
|
+
this.waiters.shift()?.();
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
class ProviderCooldown {
|
|
139
|
+
private nextAllowedAt = 0;
|
|
140
|
+
wrap(fetcher: Fetch): Fetch {
|
|
141
|
+
return async (input, init) => {
|
|
142
|
+
await abortableDelay(Math.max(0, this.nextAllowedAt - Date.now()), init?.signal ?? undefined);
|
|
143
|
+
const response = await fetcher(input, init);
|
|
144
|
+
if (response.status === 429) this.nextAllowedAt = Math.max(this.nextAllowedAt, Date.now() + retryDelayMs(response, 0));
|
|
145
|
+
return response;
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function probe(candidate: SourceCandidate, source: ResolvedSource, fetcher: Fetch, timeoutMs: number, resolveHost?: ResolveHost, headTransport?: HeadTransport) {
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
const timer = setTimeout(() => controller.abort(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
153
|
+
const workday = source.ats === "workday" ? parseWorkdayToken(source.token) : undefined;
|
|
154
|
+
const endpoint = source.structuredEndpoint;
|
|
155
|
+
try {
|
|
156
|
+
const init = source.ats === "workday" ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ appliedFacets: {}, limit: 20, offset: 0, searchText: "" }), signal: controller.signal } : { signal: controller.signal };
|
|
157
|
+
const response = await fetchProbeWithRetry(fetcher, endpoint, init);
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
await response.body?.cancel().catch(() => undefined);
|
|
160
|
+
throw new VerificationError("unreachable", `HTTP ${response.status}`);
|
|
161
|
+
}
|
|
162
|
+
const contentType = response.headers.get("content-type") ?? "unknown";
|
|
163
|
+
let body: unknown;
|
|
164
|
+
try { body = await response.json(); } catch { throw new VerificationError("invalid_payload", "Endpoint did not return JSON"); }
|
|
165
|
+
const jobs = source.ats === "greenhouse" ? recordArray(body, "jobs") : source.ats === "lever" ? array(body) : source.ats === "workday" ? recordArray(body, "jobPostings") : source.ats === "recruitee" ? recordArray(body, "offers") : recordArray(body, "jobs");
|
|
166
|
+
if (!jobs) throw new VerificationError("invalid_payload", "Payload does not contain the expected jobs array");
|
|
167
|
+
if (jobs.length === 0) throw new VerificationError("empty_board", "Source has no jobs, so identity cannot be verified");
|
|
168
|
+
const providerName = source.ats === "greenhouse" ? majority(jobs.map((job) => stringField(job, "company_name")).filter(Boolean)) : source.ats === "workday" ? workday!.tenant : "";
|
|
169
|
+
const hasDomainLink = !["greenhouse", "workday"].includes(source.ats) && structuredIdentityLinksDomain(jobs, candidate.companyDomain);
|
|
170
|
+
const hasRedirectEvidence = !["greenhouse", "workday"].includes(source.ats) && await verifiedCompanyRedirect(candidate, source, resolveHost, headTransport, timeoutMs);
|
|
171
|
+
if (!["greenhouse", "workday"].includes(source.ats) && !hasDomainLink && !hasRedirectEvidence) throw new VerificationError("identity_mismatch", `Neither structured identity fields nor a verified company redirect link to ${candidate.companyDomain}`);
|
|
172
|
+
const observedCompanyName = providerName || candidate.companyName;
|
|
173
|
+
return {
|
|
174
|
+
observedCompanyName,
|
|
175
|
+
identityEvidence: source.ats === "workday" ? "provider_tenant" as const : (providerName ? "provider_company_name" as const : hasDomainLink ? "structured_domain_link" as const : "company_redirect" as const),
|
|
176
|
+
contentType,
|
|
177
|
+
payloadVersion: source.ats === "greenhouse" ? "greenhouse-job-board:v1" : source.ats === "lever" ? "lever-postings:v0" : source.ats === "workday" ? "workday-cxs:v1" : source.ats === "recruitee" ? "recruitee-careers:v1" : `ashby-job-board:${isRecord(body) && typeof body.apiVersion === "string" ? body.apiVersion : "unknown"}`,
|
|
178
|
+
jobCount: source.ats === "workday" && isRecord(body) && typeof body.total === "number" ? body.total : jobs.length,
|
|
179
|
+
};
|
|
180
|
+
} finally { clearTimeout(timer); }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function fetchProbeWithRetry(fetcher: Fetch, endpoint: string, init: RequestInit): Promise<Response> {
|
|
184
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
185
|
+
const response = await fetcher(endpoint, init);
|
|
186
|
+
if (response.ok || !isTransientStatus(response.status) || attempt === 2) return response;
|
|
187
|
+
const delayMs = retryDelayMs(response, attempt);
|
|
188
|
+
await response.body?.cancel().catch(() => undefined);
|
|
189
|
+
await abortableDelay(delayMs, init.signal ?? undefined);
|
|
190
|
+
}
|
|
191
|
+
throw new Error("Verification probe exhausted retries");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function resolveSource(value: string): ResolvedSource | null {
|
|
195
|
+
let url: URL;
|
|
196
|
+
try { url = new URL(value); } catch { return null; }
|
|
197
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
198
|
+
const host = url.hostname.toLocaleLowerCase();
|
|
199
|
+
let ats: Ats | undefined;
|
|
200
|
+
let token: string | undefined;
|
|
201
|
+
if (host === "job-boards.greenhouse.io" || host === "boards.greenhouse.io") { ats = "greenhouse"; token = parts[0]; }
|
|
202
|
+
else if (host === "boards-api.greenhouse.io" && parts[0] === "v1" && parts[1] === "boards") { ats = "greenhouse"; token = parts[2]; }
|
|
203
|
+
else if (host === "jobs.lever.co") { ats = "lever"; token = parts[0]; }
|
|
204
|
+
else if (host === "jobs.ashbyhq.com") { ats = "ashby"; token = parts[0]; }
|
|
205
|
+
else if (/^[a-z0-9-]+\.recruitee\.com$/u.test(host)) {
|
|
206
|
+
const validPath = parts.length === 0 || parts.length === 2 && parts[0] === "o" || parts.join("/") === "api/offers";
|
|
207
|
+
if (!validPath) return null;
|
|
208
|
+
ats = "recruitee";
|
|
209
|
+
token = host.slice(0, -".recruitee.com".length);
|
|
210
|
+
}
|
|
211
|
+
else if (/\.myworkdayjobs\.com$/.test(host)) {
|
|
212
|
+
const cxs = parts.length === 5 && parts[0] === "wday" && parts[1] === "cxs" && parts[4] === "jobs";
|
|
213
|
+
const localeAndSite = /^[a-z]{2}-[a-z]{2}$/iu.test(parts[0] ?? "") && Boolean(parts[1]) && !parts[1]?.includes(".");
|
|
214
|
+
const board = localeAndSite && (parts.length === 2 || parts.length === 5 && parts[2] === "job");
|
|
215
|
+
if (!cxs && !board) return null;
|
|
216
|
+
const tenant = cxs ? parts[2] : host.split(".")[0];
|
|
217
|
+
const site = cxs ? parts[3] : parts[1];
|
|
218
|
+
if (tenant && site) { ats = "workday"; token = `${host}/${tenant}/${site}`; }
|
|
219
|
+
}
|
|
220
|
+
if (!ats || !token) return null;
|
|
221
|
+
const canonicalSourceUrl = ats === "greenhouse" ? `https://job-boards.greenhouse.io/${token}` : ats === "lever" ? `https://jobs.lever.co/${token}` : ats === "ashby" ? `https://jobs.ashbyhq.com/${token}` : ats === "recruitee" ? `https://${token}.recruitee.com` : (() => { const value = parseWorkdayToken(token); return `https://${value.host}/en-US/${value.site}`; })();
|
|
222
|
+
const structuredEndpoint = ats === "greenhouse"
|
|
223
|
+
? `https://boards-api.greenhouse.io/v1/boards/${encodeURIComponent(token)}/jobs?content=true`
|
|
224
|
+
: ats === "lever"
|
|
225
|
+
? `https://api.lever.co/v0/postings/${encodeURIComponent(token)}?mode=json`
|
|
226
|
+
: ats === "ashby"
|
|
227
|
+
? `https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(token)}`
|
|
228
|
+
: ats === "recruitee"
|
|
229
|
+
? `https://${token}.recruitee.com/api/offers`
|
|
230
|
+
: (() => { const value = parseWorkdayToken(token); return `https://${value.host}/wday/cxs/${encodeURIComponent(value.tenant)}/${encodeURIComponent(value.site)}/jobs`; })();
|
|
231
|
+
return { ats, token, canonicalSourceUrl, structuredEndpoint };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function parseWorkdayToken(token: string): { host: string; tenant: string; site: string } {
|
|
235
|
+
const [host, tenant, site] = token.split("/");
|
|
236
|
+
if (!host || !tenant || !site) throw new Error("Invalid Workday token");
|
|
237
|
+
return { host, tenant, site };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function validateCandidate(candidate: SourceCandidate): string | null {
|
|
241
|
+
if (typeof candidate.companyName !== "string" || !candidate.companyName.trim()) return "companyName is required and must be a string";
|
|
242
|
+
if (candidate.slug !== undefined && (typeof candidate.slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(candidate.slug))) return "slug must contain lowercase letters, numbers, and single hyphens";
|
|
243
|
+
if (typeof candidate.companyDomain !== "string" || !/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(candidate.companyDomain)) return "companyDomain must be a hostname";
|
|
244
|
+
if (typeof candidate.sourceUrl !== "string" || !candidate.sourceUrl) return "sourceUrl is required";
|
|
245
|
+
const channels = new Set(["search", "career_page", "provider_directory", "community", "dataset", "legacy"]);
|
|
246
|
+
if (!isRecord(candidate.discoveredFrom) || typeof candidate.discoveredFrom.channel !== "string" || !channels.has(candidate.discoveredFrom.channel) || typeof candidate.discoveredFrom.reference !== "string" || !candidate.discoveredFrom.reference.trim()) return "discoveredFrom must contain a supported channel and reference";
|
|
247
|
+
if (candidate.cohorts !== undefined && (!Array.isArray(candidate.cohorts) || !candidate.cohorts.every((code) => typeof code === "string"))) return "cohorts must be an array of country codes";
|
|
248
|
+
if (candidate.domainEvidence !== undefined) {
|
|
249
|
+
if (!isRecord(candidate.domainEvidence) || !["authoritative_dataset", "company_registry", "company_redirect"].includes(String(candidate.domainEvidence.kind)) || typeof candidate.domainEvidence.reference !== "string" || !candidate.domainEvidence.reference) return "domainEvidence must contain a supported kind and reference";
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function identityMatches(expected: string, domain: string, observed: string, token: string): boolean {
|
|
255
|
+
const left = normalizeName(expected);
|
|
256
|
+
const right = normalizeName(observed);
|
|
257
|
+
const normalizedToken = normalizeName(token);
|
|
258
|
+
const domainLabel = normalizeName(domain.replace(/^www\./, "").split(".")[0] ?? "");
|
|
259
|
+
const sourceMatches = left === right || left.includes(right) || right.includes(left) || left.includes(normalizedToken) || normalizedToken.includes(left);
|
|
260
|
+
const domainMatches = left.includes(domainLabel) || domainLabel.includes(left);
|
|
261
|
+
return left.length >= 3 && domainLabel.length >= 3 && sourceMatches && domainMatches;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function normalizeName(value: string): string { return value.toLocaleLowerCase().replace(/\b(inc|llc|ltd|limited|corp|corporation|company)\b/g, "").replace(/[^a-z0-9]/g, ""); }
|
|
265
|
+
function normalizeCohorts(value?: string[]): string[] | undefined { const result = [...new Set(value?.map((code) => code.toUpperCase()).filter((code) => /^[A-Z]{2}$/.test(code)) ?? [])]; return result.length ? result.sort() : undefined; }
|
|
266
|
+
function slugFromDomain(domain: string): string { return domain.toLocaleLowerCase().replace(/^www\./, "").split(".")[0]!.replace(/[^a-z0-9-]/g, "-"); }
|
|
267
|
+
function rejection(candidate: SourceCandidate, reason: SourceRejectionReason, detail: string): RejectedSource { return { ...candidate, reason, detail }; }
|
|
268
|
+
function array(value: unknown): Record<string, unknown>[] | null { return Array.isArray(value) && value.every(isRecord) ? value : null; }
|
|
269
|
+
function recordArray(value: unknown, key: string): Record<string, unknown>[] | null { return isRecord(value) ? array(value[key]) : null; }
|
|
270
|
+
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
271
|
+
function stringField(value: Record<string, unknown>, key: string): string { return typeof value[key] === "string" ? value[key] : ""; }
|
|
272
|
+
function majority(values: string[]): string {
|
|
273
|
+
const counts = new Map<string, number>();
|
|
274
|
+
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
275
|
+
return [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
|
|
276
|
+
}
|
|
277
|
+
function structuredIdentityLinksDomain(jobs: Record<string, unknown>[], domain: string): boolean {
|
|
278
|
+
const escaped = domain.toLocaleLowerCase().replace(/^www\./, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
279
|
+
const pattern = new RegExp(`^https?://(?:www\\.)?${escaped}(?:/|$)`, "i");
|
|
280
|
+
const fields = ["companyUrl", "companyWebsite", "organizationUrl", "organizationWebsite", "website", "careers_url", "careers_apply_url"];
|
|
281
|
+
return jobs.some((job) => fields.some((field) => typeof job[field] === "string" && pattern.test(job[field])));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function verifiedCompanyRedirect(candidate: SourceCandidate, expected: ResolvedSource, resolveHost?: ResolveHost, headTransport?: HeadTransport, timeoutMs?: number): Promise<boolean> {
|
|
285
|
+
if (candidate.domainEvidence?.kind !== "company_redirect") return false;
|
|
286
|
+
try {
|
|
287
|
+
const host = new URL(candidate.domainEvidence.reference).hostname.toLowerCase().replace(/^www\./, "");
|
|
288
|
+
const domain = candidate.companyDomain.toLowerCase().replace(/^www\./, "");
|
|
289
|
+
if (host !== domain && !host.endsWith(`.${domain}`)) return false;
|
|
290
|
+
const result = await fetchSafeHead(candidate.domainEvidence.reference, { resolveHost, transport: headTransport, timeoutMs });
|
|
291
|
+
const observed = resolveSource(result.finalUrl);
|
|
292
|
+
return result.response.ok && observed?.ats === expected.ats && observed.token.toLowerCase() === expected.token.toLowerCase();
|
|
293
|
+
} catch { return false; }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class VerificationError extends Error { constructor(readonly reason: SourceRejectionReason, message: string) { super(message); } }
|